pi-vision-handoff 0.3.0 → 0.3.2

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/vision-handoff.ts CHANGED
@@ -8,87 +8,73 @@
8
8
  * choice is persisted to ~/.pi/agent/extensions/pi-vision-handoff.json.
9
9
  *
10
10
  * Pipeline (provider-agnostic via @earendil-works/pi-ai's complete()):
11
- * before_agent_start → warm the description cache for attached images
12
- * tool_result (read) describe read-tool images and INSERT the description
13
- * as a text block before each image, keeping the image so the TUI still
14
- * renders it (kitty). pi-ai later strips the image for non-vision models,
15
- * leaving the description text for the model.
16
- * before_provider_request swap remaining image blocks in the payload for
17
- * text (catches user-attached images for vision-capable handoff targets)
11
+ * before_agent_start → warm the description cache for attached images AND
12
+ * pasted clipboard image file paths found in the prompt text (pre-warm at
13
+ * paste-enter, concurrent with the agent's first response).
14
+ * tool_result (read) loadDescription() each read-tool image and AWAIT the
15
+ * shared batch, so N parallel reads coalesce into ONE vision call. The
16
+ * descriptions land in the tool results before the agent's next turn.
17
+ * context swap any remaining image blocks for their (now cached) text
18
+ * description on the cloned LLM-bound payload.
18
19
  *
19
- * Image blocks are detected by shape across the four formats pi uses:
20
- * openai-completions: { type: "image_url", image_url: { url: "data:..." } }
21
- * openai-responses: { type: "input_image", image_url: "data:..." }
22
- * anthropic-messages: { type: "image", source: { type: "base64", media_type, data } }
23
- * pi-ai internal: { type: "image", data, mimeType } ← read tool / ToolResultEvent
20
+ * This file is the wiring layer: pi event handlers + the /vision-handoff
21
+ * command. The dataloader (`src/dataloader.ts`), describer (`src/describer.ts`),
22
+ * image IO (`src/image.ts`), resource guards (`src/dispose.ts`), config/types
23
+ * (`src/index.ts`), and usage/energy (`src/usage.ts`) live in `src/`.
24
24
  *
25
- * Descriptions are cached per image hash (LRU, size = config.cacheMax) so the
26
- * swap is instant by the time before_provider_request fires.
25
+ * Image blocks are detected by shape across the four formats pi uses — see
26
+ * `extractImageFromBlock` in `src/index.ts`. Descriptions are cached per image
27
+ * hash (LRU, size = config.cacheMax) so the swap is instant by the time
28
+ * `context` fires.
27
29
  */
28
30
 
29
- import crypto from "node:crypto";
30
31
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
31
- import { complete } from "@earendil-works/pi-ai";
32
- import type { Api, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai";
32
+ import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
33
33
  import {
34
- DEFAULT_USER_PROMPT_PREFIX,
35
- DEFAULT_VISION_PROMPT,
36
- DESCRIBE_TIMEOUT_MS,
37
- HANDOFF_COMMAND_DESCRIPTION,
38
- IMAGE_PLACEHOLDER_PREFIX,
39
- IMAGE_PLACEHOLDER_SUFFIX,
40
- USAGE_ENTRY_TYPE,
41
- USAGE_EVENT_CHANNEL,
42
- EMPTY_ENERGY_CAPTURE,
43
- buildUsageRecord,
44
- describeAls,
45
34
  extractImageFromBlock,
46
35
  formatModelRef,
47
- insertImageDescriptions,
48
- installFetchInterceptor,
49
36
  isVisionModel,
50
- makeReplacementText,
37
+ NON_VISION_IMAGE_NOTE,
51
38
  parseModelRef,
52
39
  readConfig,
53
- truncateDescription,
54
- uninstallFetchInterceptor,
40
+ stripNonVisionImageNote,
55
41
  writeConfig,
56
- type DescribeContext,
42
+ HANDOFF_COMMAND_DESCRIPTION,
43
+ type ExtractedImage,
57
44
  type VisionHandoffConfig,
58
- type VisionHandoffEnergyCapture,
59
- type VisionHandoffUsageRecord,
60
45
  } from "./src/index.js";
46
+ import {
47
+ USAGE_ENTRY_TYPE,
48
+ USAGE_EVENT_CHANNEL,
49
+ type VisionHandoffUsageRecord,
50
+ } from "./src/usage.js";
51
+ import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
52
+ import { imageHash, findClipboardImagePaths, readImageBuffer, resolvePrewarmImage } from "./src/image.js";
53
+ import { resizeImage } from "@earendil-works/pi-coding-agent";
61
54
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
62
55
 
63
- const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
64
-
65
- // Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
66
- // export. No-op until then so describeImage is safe to call before wiring.
67
- let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
68
-
69
56
  let config: VisionHandoffConfig = readConfig();
70
57
 
71
- /** User prompt for the current agent turn, captured from before_agent_start. */
72
- let pendingTurnPrompt: string | null = null;
58
+ /** Most recent describer failure message (auth error, network error, abort,
59
+ * empty response, etc.). Set by the describer via the loader deps; surfaced
60
+ * to the user by the `context`/`tool_result` handler via ctx.ui.notify so a
61
+ * broken vision model stops looking like a silent "extension doesn't work".
62
+ * Cleared at the start of each describer attempt. */
63
+ let lastDescriberError: string | null = null;
64
+
65
+ /** Image hashes we've already warned the user about this session. Prevents the
66
+ * `context` hook (which fires before every LLM turn) from re-warning on the
67
+ * same failing images every turn — describer failures aren't cached, so
68
+ * without this guard a broken vision model would spam a warning per turn.
69
+ * Cleared on `session_start`. */
70
+ const warnedHashes = new Set<string>();
73
71
 
74
- const visionCache = new Map<string, Promise<string>>();
75
72
  let visionModelCache: { ref: string; model: Model<Api> } | null = null;
76
73
  let visionModelUnresolvedRef: string | null = null;
77
74
 
78
- function isConfigured(cfg: VisionHandoffConfig): boolean {
79
- return cfg.enabled && !!cfg.visionModel;
80
- }
81
-
82
- function isHandoffTarget(
83
- model: { provider?: string; id?: string; input?: ("text" | "image")[] } | undefined | null,
84
- cfg: VisionHandoffConfig,
85
- ): boolean {
86
- if (!model || !model.provider || !model.id) return false;
87
- const ref = formatModelRef(model.provider, model.id);
88
- if (cfg.handoffModels.includes(ref)) return true;
89
- if (cfg.autoHandoff && !isVisionModel(model)) return true;
90
- return false;
91
- }
75
+ // Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
76
+ // export. No-op until then so the describer is safe to call before wiring.
77
+ let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
92
78
 
93
79
  function resolveVisionModel(modelRegistry: ModelRegistry, ref: string): Model<Api> | null {
94
80
  if (visionModelCache && visionModelCache.ref === ref) return visionModelCache.model;
@@ -100,140 +86,29 @@ function resolveVisionModel(modelRegistry: ModelRegistry, ref: string): Model<Ap
100
86
  return model;
101
87
  }
102
88
 
103
- function imageHash(mimeType: string, data: string): string {
104
- return crypto.createHash("sha256").update(`${mimeType}\x00${data}`).digest("hex").slice(0, 32);
105
- }
106
-
107
- async function describeImage(
108
- data: string,
109
- mimeType: string,
110
- userPrompt: string,
111
- visionModel: Model<Api>,
112
- modelRegistry: ModelRegistry,
113
- cfg: VisionHandoffConfig,
114
- ): Promise<string> {
115
- const key = imageHash(mimeType, data);
116
- const cached = visionCache.get(key);
117
- if (cached) return cached;
118
-
119
- const promise = (async (): Promise<string> => {
120
- const auth = await modelRegistry.getApiKeyAndHeaders(visionModel);
121
- if (!auth.ok || !auth.apiKey) return UNAVAILABLE;
122
-
123
- const prefix = cfg.userPromptPrefix ?? DEFAULT_USER_PROMPT_PREFIX;
124
- const systemPrompt = cfg.prompt ?? DEFAULT_VISION_PROMPT;
125
-
126
- const content: (TextContent | ImageContent)[] = [];
127
- if (userPrompt && userPrompt.trim()) {
128
- content.push({ type: "text", text: prefix + userPrompt });
129
- } else {
130
- content.push({ type: "text", text: "Describe this image." } satisfies TextContent);
131
- }
132
- content.push({ type: "image", data, mimeType } satisfies ImageContent);
133
-
134
- const userMessage: Message = {
135
- role: "user",
136
- content,
137
- timestamp: Date.now(),
138
- };
89
+ const loaderDeps: LoaderDeps = {
90
+ getConfig: () => config,
91
+ resolveVisionModel,
92
+ reportUsage: (record) => reportUsage(record),
93
+ setLastError: (msg) => {
94
+ lastDescriberError = msg;
95
+ },
96
+ };
97
+ const loader = new DescriptionLoader(loaderDeps);
139
98
 
140
- const controller = new AbortController();
141
- const timer = setTimeout(() => controller.abort(), DESCRIBE_TIMEOUT_MS);
142
- // Energy/token capture for this describer call. The fetch interceptor is
143
- // refcount-installed around the complete() window and routes the teed
144
- // response body to this describe's AsyncLocalStorage slot — so concurrent
145
- // describes (before_agent_start warm-up fires several in parallel) each
146
- // get their own capture without clobbering globalThis.fetch. For non-
147
- // Neuralwatt vision models no SSE energy comments are present and the
148
- // capture stays empty (energy fields omitted from the record).
149
- const describeCtx: DescribeContext = { energyReader: undefined };
150
- installFetchInterceptor();
151
- try {
152
- const response = await describeAls.run(describeCtx, async () =>
153
- complete(
154
- visionModel,
155
- { systemPrompt, messages: [userMessage] },
156
- {
157
- apiKey: auth.apiKey,
158
- headers: auth.headers,
159
- signal: controller.signal,
160
- maxTokens: cfg.maxTokens,
161
- },
162
- ),
163
- );
164
- let capture: VisionHandoffEnergyCapture = EMPTY_ENERGY_CAPTURE;
165
- if (describeCtx.energyReader) {
166
- try {
167
- capture = await describeCtx.energyReader;
168
- } catch {
169
- // tee aborted with the main stream — keep the empty capture
170
- }
171
- }
172
- const record = buildUsageRecord(response, capture, visionModel, key);
173
- if (record) reportUsage(record);
174
- if (response.stopReason === "aborted" || response.stopReason === "error") {
175
- return UNAVAILABLE;
176
- }
177
- const description = response.content
178
- .filter((c): c is TextContent => c.type === "text")
179
- .map((c) => c.text)
180
- .join("\n")
181
- .trim();
182
- if (!description) return UNAVAILABLE;
183
- // The full description lives in the stored tool-result content; the read
184
- // tool's native collapse (ctrl+o) handles compactness — collapsed shows
185
- // the image only, expanded shows the full description + image. Only apply
186
- // a hard line cap when the user opts in (maxDescriptionLines > 0); by
187
- // default (0) the description is unbounded so ctrl+o can expand to all
188
- // of it and the model receives the complete context.
189
- const { text: final } =
190
- cfg.maxDescriptionLines && cfg.maxDescriptionLines > 0
191
- ? truncateDescription(description, cfg.maxDescriptionLines)
192
- : { text: description };
193
- return `${IMAGE_PLACEHOLDER_PREFIX}${final}${IMAGE_PLACEHOLDER_SUFFIX}`;
194
- } catch {
195
- return UNAVAILABLE;
196
- } finally {
197
- if (describeCtx.energyReader) describeCtx.energyReader.catch(() => {});
198
- uninstallFetchInterceptor();
199
- clearTimeout(timer);
200
- }
201
- })();
202
-
203
- if (visionCache.size >= cfg.cacheMax) {
204
- const firstKey = visionCache.keys().next().value;
205
- if (firstKey !== undefined) visionCache.delete(firstKey);
206
- }
207
- visionCache.set(key, promise);
208
-
209
- return promise;
99
+ function isConfigured(cfg: VisionHandoffConfig): boolean {
100
+ return cfg.enabled && !!cfg.visionModel;
210
101
  }
211
102
 
212
- async function replaceImagesWithDescriptions(
213
- payload: Record<string, unknown>,
214
- userPrompt: string,
215
- visionModel: Model<Api>,
216
- modelRegistry: ModelRegistry,
103
+ function isHandoffTarget(
104
+ model: { provider?: string; id?: string; input?: ("text" | "image")[] } | undefined | null,
217
105
  cfg: VisionHandoffConfig,
218
- ): Promise<boolean> {
219
- const messages = payload.messages;
220
- if (!Array.isArray(messages)) return false;
221
-
222
- let replaced = false;
223
- for (const msg of messages) {
224
- if (!msg || typeof msg !== "object") continue;
225
- const content = (msg as Record<string, unknown>).content;
226
- if (!Array.isArray(content)) continue;
227
-
228
- for (let i = 0; i < content.length; i++) {
229
- const img = extractImageFromBlock(content[i]);
230
- if (!img) continue;
231
- const description = await describeImage(img.data, img.mimeType, userPrompt, visionModel, modelRegistry, cfg);
232
- content[i] = makeReplacementText(content[i], description);
233
- replaced = true;
234
- }
235
- }
236
- return replaced;
106
+ ): boolean {
107
+ if (!model || !model.provider || !model.id) return false;
108
+ const ref = formatModelRef(model.provider, model.id);
109
+ if (cfg.handoffModels.includes(ref)) return true;
110
+ if (cfg.autoHandoff && !isVisionModel(model)) return true;
111
+ return false;
237
112
  }
238
113
 
239
114
  function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
@@ -247,6 +122,29 @@ function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
247
122
  }
248
123
  }
249
124
 
125
+ /** Notify once per failing image per session (dedup via warnedHashes). */
126
+ function warnFailedImages(
127
+ ctx: ExtensionContext,
128
+ imgs: ExtractedImage[],
129
+ descs: string[],
130
+ reason: string,
131
+ ): void {
132
+ if (!ctx.hasUI) return;
133
+ const newlyFailed: string[] = [];
134
+ for (let i = 0; i < imgs.length; i++) {
135
+ if (descs[i] === UNAVAILABLE) {
136
+ const h = imageHash(imgs[i].mimeType, imgs[i].data);
137
+ if (!warnedHashes.has(h)) newlyFailed.push(h);
138
+ }
139
+ }
140
+ if (newlyFailed.length === 0) return;
141
+ for (const h of newlyFailed) warnedHashes.add(h);
142
+ ctx.ui.notify(
143
+ `pi-vision-handoff: image description failed — ${reason}. Vision model: ${config.visionModel}`,
144
+ "warning",
145
+ );
146
+ }
147
+
250
148
  export default function (pi: ExtensionAPI) {
251
149
  config = readConfig();
252
150
 
@@ -274,72 +172,228 @@ export default function (pi: ExtensionAPI) {
274
172
  // Reload in case the user edited the config on disk from another session.
275
173
  config = readConfig();
276
174
  visionModelCache = null;
277
- pendingTurnPrompt = null;
175
+ visionModelUnresolvedRef = null;
176
+ warnedHashes.clear();
177
+ loader.reset();
278
178
  });
279
179
 
280
180
  pi.on("before_agent_start", async (event, ctx) => {
281
181
  if (!isConfigured(config)) return;
282
182
  if (!isHandoffTarget(ctx.model, config)) return;
283
183
 
284
- // Capture this turn's user prompt so read-tool images are described in
285
- // the same context. Fires before any tool_result for the turn.
286
- pendingTurnPrompt = event.prompt || "";
287
-
288
- const images = event.images;
289
- if (!images || images.length === 0) return;
184
+ // Capture this turn's user prompt so every image in the turn — attached or
185
+ // read via the read tool is described in the same request context.
186
+ loader.setPendingTurnPrompt(event.prompt || "");
187
+ loader.bindTurnContext(ctx);
290
188
 
291
- const visionModel = resolveVisionModel(ctx.modelRegistry, config.visionModel!);
292
- if (!visionModel) {
189
+ if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
293
190
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
294
191
  return;
295
192
  }
296
193
 
297
- const userPrompt = event.prompt || "";
298
- for (const image of images) {
194
+ // PRE-WARM at paste-enter via the DataLoader. Two image sources land here:
195
+ //
196
+ // 1. Attached image blocks (event.images) — vision-capable targets where
197
+ // the user message itself carries image blocks (e.g. `pi --image`).
198
+ //
199
+ // 2. Pasted clipboard image FILE PATHS in the prompt text — the common
200
+ // non-vision flow. pi's `handleClipboardImagePaste` writes each pasted
201
+ // image to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the PATH as
202
+ // text at the cursor; on a non-vision model these arrive as path tokens
203
+ // in `event.prompt`, NOT as `event.images`. We scan the prompt for those
204
+ // temp paths, read the files, and `loadDescription()` them so the ONE
205
+ // batched vision call starts the instant you press enter — CONCURRENT
206
+ // with the agent's first response generation — instead of waiting for
207
+ // the agent to `read` the files. By the time the agent's `read` tool
208
+ // results fire, `tool_result`'s `loadDescription()` is a cache hit.
209
+ //
210
+ // Both sources flow through the same loader: `load()` is synchronous and
211
+ // memoized, so all images in this frame (attached + clipboard-path)
212
+ // coalesce into ONE batch dispatched after the microtask cascade.
213
+ for (const image of event.images ?? []) {
299
214
  if (!image || image.type !== "image" || !image.data) continue;
300
- const mimeType = image.mimeType || "image/png";
301
- describeImage(image.data, mimeType, userPrompt, visionModel, ctx.modelRegistry, config).catch(
302
- () => {},
303
- );
215
+ loader.loadDescription({ data: image.data, mimeType: image.mimeType || "image/png" }).catch(() => {});
216
+ }
217
+
218
+ // Pasted clipboard image paths in the prompt text — resolve each to the
219
+ // SAME ExtractedImage pi's `read` tool will emit, then warm the loader so
220
+ // the `tool_result`'s `loadDescription()` is a cache hit (no wasted vision
221
+ // call). pi's read tool resizes images by default: for a small image it
222
+ // returns the raw bytes unchanged (our raw read matches), but for an
223
+ // oversized image it RE-ENCODES via Photon — so we run the same
224
+ // `resizeImage` pipeline to produce a matching key. The resize branch is
225
+ // async (worker thread) and fire-and-forget; its `loadDescription()` lands
226
+ // in a later batch than the no-resize images, so a mixed small+oversized
227
+ // paste may split into two vision calls (still no WASTED call — each
228
+ // describes an image the agent will see). A file that can't be read, isn't
229
+ // a supported image, or fails to resize is skipped (the agent's `read`
230
+ // will still describe it via `tool_result` if it emits an image block).
231
+ for (const p of findClipboardImagePaths(event.prompt || "")) {
232
+ const read = readImageBuffer(p);
233
+ if (!read) continue;
234
+ resolvePrewarmImage(read.buf, read.mimeType, resizeImage)
235
+ .then((img) => {
236
+ if (img) loader.loadDescription(img).catch(() => {});
237
+ })
238
+ .catch(() => {});
304
239
  }
305
240
  });
306
241
 
307
- pi.on("before_provider_request", async (event, ctx) => {
242
+ // The PRIMARY injection point: the `read` tool's `tool_result` handler.
243
+ // When the agent reads image files, this fires for each read result. It
244
+ // calls the loader's `loadDescription(img)` for every image block and
245
+ // AWAITS the shared batch — so N parallel reads (pi runs `read` via
246
+ // Promise.all) coalesce into ONE batched vision call (DataLoader: all
247
+ // load() calls in the same microtask frame share one batch, dispatched
248
+ // after the cascade settles) and all resolve together. The descriptions
249
+ // replace the image blocks in the returned `content`, so by the time the
250
+ // agent's next turn starts the tool results already carry text — the
251
+ // agent never sees raw image blocks it can't process.
252
+ //
253
+ // Why block here and not in `context`: the tool-result phase is free time
254
+ // (the agent is just waiting for tool results), so running the describer
255
+ // here adds zero latency to the critical path. `context` then becomes a
256
+ // cache-hit no-op for read images.
257
+ pi.on("tool_result", async (event, ctx) => {
308
258
  if (!isConfigured(config)) return;
309
- if (!isHandoffTarget(ctx.model, config)) return;
259
+ if (event.toolName !== "read") return;
260
+ const content = event.content;
261
+ if (!Array.isArray(content)) return;
310
262
 
311
- const visionModel = resolveVisionModel(ctx.modelRegistry, config.visionModel!);
312
- if (!visionModel) {
263
+ // Collect image blocks in this read result. (The read tool emits image
264
+ // blocks even for non-vision models — they reach here untouched.)
265
+ const imgs: ExtractedImage[] = [];
266
+ for (const block of content) {
267
+ const img = extractImageFromBlock(block);
268
+ if (img) imgs.push(img);
269
+ }
270
+ if (imgs.length === 0) return;
271
+ if (!isHandoffTarget(ctx.model, config)) return;
272
+ loader.bindTurnContext(ctx);
273
+ if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
313
274
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
314
275
  return;
315
276
  }
316
277
 
317
- const payload = event.payload as Record<string, unknown>;
318
- await replaceImagesWithDescriptions(payload, "", visionModel, ctx.modelRegistry, config);
319
- return payload;
278
+ // load() each image synchronous calls that push into the current batch
279
+ // and return memoized promises — then await them all. Parallel reads'
280
+ // load() calls land in the SAME batch (same microtask frame), so this is
281
+ // ONE vision call for the whole read set, not N. Awaiting here runs the
282
+ // describer during the tool-result phase (free time — the agent is just
283
+ // waiting for tool results), so the batch is COMPLETE before `context`
284
+ // fires, making `context` a non-blocking cache hit instead of a cold miss
285
+ // on the critical path.
286
+ //
287
+ // We do NOT mutate the result content here for the image blocks: returning
288
+ // undefined keeps the image block in storage so kitty renders it inline
289
+ // and `/resume` retains it. The actual image→text swap happens in the
290
+ // `context` hook (on the cloned LLM-bound payload only), by which point
291
+ // these are cache hits. We DO strip pi's misleading non-vision note (below)
292
+ // since the handoff will replace the image with a description.
293
+ const descs = await Promise.all(imgs.map((img) => loader.loadDescription(img)));
294
+
295
+ // On user abort, leave the result untouched — pi is tearing the turn
296
+ // down and the LLM-bound content won't be sent.
297
+ if (ctx.signal?.aborted) return;
298
+
299
+ warnFailedImages(ctx, imgs, descs, lastDescriberError ?? "unknown error");
300
+
301
+ // Strip pi's `[Current model does not support images…]` note from text
302
+ // blocks — since the handoff replaces the image with a description in the
303
+ // `context` hook, that note is misleading (the agent WILL receive the
304
+ // image's content, as text). Keep the image block itself so kitty still
305
+ // renders it inline and `/resume` retains it; the `context` hook swaps the
306
+ // image for its description in the LLM-bound clone before the next turn.
307
+ let stripped = false;
308
+ const next = content.slice();
309
+ for (let i = 0; i < next.length; i++) {
310
+ const block = next[i];
311
+ if (block && typeof block === "object" && (block as { type: string }).type === "text") {
312
+ const text = (block as { text: string }).text;
313
+ if (typeof text === "string" && text.includes(NON_VISION_IMAGE_NOTE)) {
314
+ const cleaned = stripNonVisionImageNote(text);
315
+ if (cleaned !== text) {
316
+ next[i] = { type: "text", text: cleaned };
317
+ stripped = true;
318
+ }
319
+ }
320
+ }
321
+ }
322
+ if (stripped) return { content: next as (TextContent | ImageContent)[] };
320
323
  });
321
324
 
322
- pi.on("tool_result", async (event, ctx) => {
325
+ // The FALLBACK injection point: the `context` event fires as the agent's
326
+ // `transformContext`, BEFORE pi-ai's `downgradeUnsupportedImages` strips
327
+ // image blocks and BEFORE `convertToLlm`. It catches any image blocks that
328
+ // didn't go through the `read` tool's `tool_result` handler — user-attached
329
+ // images (for vision-capable handoff targets), custom extension-injected
330
+ // messages, or reads that somehow bypassed the handler. `emitContext` does a
331
+ // `structuredClone`, so swapping here touches only the LLM-bound payload.
332
+ //
333
+ // Read images are already text by this point (the `tool_result` handler
334
+ // replaced them), so this is usually a no-op for the common paste-and-read
335
+ // flow. For the images it does find, `loadDescription()` is a cache hit
336
+ // (warmed by `before_agent_start`) or queues into the loader's current batch.
337
+ pi.on("context", async (event, ctx) => {
323
338
  if (!isConfigured(config)) return;
324
- if (!isHandoffTarget(ctx.model, config)) return;
325
- if (event.toolName !== "read") return;
326
339
 
327
- const content = event.content;
328
- if (!Array.isArray(content)) return;
329
- // Skip the async work entirely when there is nothing to describe.
330
- if (!content.some((c) => extractImageFromBlock(c))) return;
331
-
332
- const visionModel = resolveVisionModel(ctx.modelRegistry, config.visionModel!);
333
- if (!visionModel) {
340
+ const messages = event.messages as unknown as Array<Record<string, unknown>>;
341
+ if (!Array.isArray(messages)) return;
342
+
343
+ const byHash = new Map<string, ExtractedImage>();
344
+ let anyImage = false;
345
+ for (const msg of messages) {
346
+ const content = msg.content;
347
+ if (!Array.isArray(content)) continue;
348
+ for (const block of content) {
349
+ const img = extractImageFromBlock(block);
350
+ if (!img) continue;
351
+ anyImage = true;
352
+ byHash.set(imageHash(img.mimeType, img.data), img);
353
+ }
354
+ }
355
+ if (!anyImage) return;
356
+ if (!isHandoffTarget(ctx.model, config)) return;
357
+ loader.bindTurnContext(ctx);
358
+ if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
334
359
  notifyUnresolvedVisionModel(ctx, config.visionModel!);
335
360
  return;
336
361
  }
337
362
 
338
- const { content: next, changed } = await insertImageDescriptions(content, (img) =>
339
- describeImage(img.data, img.mimeType, pendingTurnPrompt ?? "", visionModel, ctx.modelRegistry, config),
340
- );
341
- if (!changed) return;
342
- return { content: next };
363
+ // Cache hits (warmed by before_agent_start / tool_result) resolve
364
+ // instantly; any remaining misses queue into the loader's current batch.
365
+ const imgs = [...byHash.values()];
366
+ const descArr = await Promise.all(imgs.map((img) => loader.loadDescription(img)));
367
+ const descs = new Map<string, string>();
368
+ for (let i = 0; i < imgs.length; i++) {
369
+ descs.set(imageHash(imgs[i].mimeType, imgs[i].data), descArr[i]);
370
+ }
371
+
372
+ if (ctx.signal?.aborted) return;
373
+
374
+ warnFailedImages(ctx, imgs, descArr, lastDescriberError ?? "unknown error");
375
+
376
+ let changed = false;
377
+ for (const msg of messages) {
378
+ const content = msg.content;
379
+ if (!Array.isArray(content)) continue;
380
+ let touched = false;
381
+ const next: unknown[] = [];
382
+ for (const block of content) {
383
+ const img = extractImageFromBlock(block);
384
+ if (img) {
385
+ next.push({ type: "text", text: descs.get(imageHash(img.mimeType, img.data)) ?? UNAVAILABLE });
386
+ touched = true;
387
+ } else {
388
+ next.push(block);
389
+ }
390
+ }
391
+ if (touched) {
392
+ msg.content = next;
393
+ changed = true;
394
+ }
395
+ }
396
+ if (changed) return { messages: event.messages };
343
397
  });
344
398
 
345
399
  pi.on("model_select", (event, ctx) => {
@@ -396,8 +450,9 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
396
450
  " /vision-handoff help This message",
397
451
  "",
398
452
  "Config: ~/.pi/agent/extensions/pi-vision-handoff.json",
399
- "Mechanism: before_agent_start warms a description cache; before_provider_request",
400
- " swaps image blocks in the payload for the cached text description.",
453
+ "Mechanism: before_agent_start warms a description cache; tool_result loads",
454
+ " read images through a dataloader (one batched vision call); context swaps",
455
+ " image blocks in the payload for the cached text description.",
401
456
  ].join("\n"),
402
457
  "info",
403
458
  );
@@ -571,7 +626,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
571
626
  lines.push(`Vision model: ${config.visionModel ?? "(none — pick one with /vision-handoff)"}`);
572
627
  lines.push(`Auto handoff (non-vision models): ${config.autoHandoff ? "on" : "off"}`);
573
628
  lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
574
- lines.push(`maxTokens: ${config.maxTokens} · cacheMax: ${config.cacheMax} · maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
629
+ lines.push(`maxTokens: ${config.maxTokens ?? "unbounded"} · cacheMax: ${config.cacheMax} · maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
575
630
 
576
631
  const model = ctx.model;
577
632
  let active = false;