pi-vision-handoff 0.8.2 โ 0.9.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/README.md +27 -100
- package/package.json +1 -1
- package/src/image.ts +25 -19
- package/src/index.ts +9 -0
- package/src/vision-model-selector.ts +49 -5
- package/vision-handoff.ts +215 -15
package/README.md
CHANGED
|
@@ -42,8 +42,9 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
|
|
|
42
42
|
- **๐ Provider-agnostic** โ uses pi's own model execution machinery (`@earendil-works/pi-ai`'s `completeSimple()`), so it works with any provider/configured model, including custom provider extensions.
|
|
43
43
|
- **๐ง Automatic targets** โ by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
|
|
44
44
|
- **๐๏ธ Explicit overrides** โ force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
|
|
45
|
-
- **โก Pre-warmed at paste-enter** โ the moment you press enter, `before_agent_start` scans the prompt for pasted
|
|
45
|
+
- **โก Pre-warmed at paste-enter** โ the moment you press enter, `before_agent_start` scans the prompt for pasted image temp-file paths โ pi-clipboard, localterm-paste, and any other paste mechanism that writes a pasted image to a temp file and inserts the path as text โ reads them, and kicks off the ONE batched vision call concurrent with the agent's first response โ so by the time the agent reads the files, they're already cache hits.
|
|
46
46
|
- **๐ Paste-time prewarm (opt-in)** โ `prewarmPastedImages` (off by default) wraps the editor so pasted clipboard images are described the *instant* their path lands in the prompt โ before you hit enter โ not at submit. The vision call starts concurrent with you typing your question. Tradeoff: the description is generated without your typed question as context (the question usually isn't entered yet at paste time), and a paste-then-abandon wastes one vision call. TUI only; inactive if another extension replaces the editor. Toggle with `/vision-handoff prewarm on`.
|
|
47
|
+
- **๐ Async pasted-path fallback (opt-in)** โ `asyncClipboardHandoff` races the normal read path against asynchronous delivery. A direct `read` or nested Fabric `pi.read` of the pasted path cancels the fallback and uses the normal tool-result/context flow; otherwise the completed description is queued as a steering message. Its transcript row is collapsed to one line by default and expands with `Ctrl+O`. Toggle it in the picker with `Ctrl+A` or run `/vision-handoff fallback on`.
|
|
47
48
|
- **๐ก๏ธ Graceful degradation** โ no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
|
|
48
49
|
- **๐ Error logging** โ every describer failure and every `image description failed` warning is appended as a JSONL line to `~/.pi/agent/logs/pi-vision-handoff/errors.log`, so a warning like `image description failed โ unknown error` is troubleshootable: the detailed reason (exception stack, `stopReason`, config snapshot) is captured at the describer's failure source even when the in-memory error string was already reset by the time the warning fired. Size-capped with a single `.1` backup.
|
|
49
50
|
- **๐ Usage reporting** โ every real describer call reports model + tokens (and Neuralwatt energy/cost when the vision model is a Neuralwatt model), via `pi.appendEntry` + a `vision-handoff:usage` event for live consumers.
|
|
@@ -62,6 +63,7 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
|
|
|
62
63
|
| `/vision-handoff enable` / `disable` | Master switch (keeps your configured model) |
|
|
63
64
|
| `/vision-handoff auto on` / `off` | Toggle automatic handoff for all non-vision models |
|
|
64
65
|
| `/vision-handoff prewarm on` / `off` | Toggle paste-time prewarm (opt-in, off by default) |
|
|
66
|
+
| `/vision-handoff fallback on` / `off` | Race matching reads against async pasted-path description injection (opt-in) |
|
|
65
67
|
| `/vision-handoff add ollama/llava:13b` | Force handoff for an extra model |
|
|
66
68
|
| `/vision-handoff remove ollama/llava:13b` | Stop forcing handoff for a model |
|
|
67
69
|
| `/vision-handoff clear` | Clear the configured vision model |
|
|
@@ -78,6 +80,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
78
80
|
"autoHandoff": true,
|
|
79
81
|
"handoffModels": ["ollama/llava:13b"],
|
|
80
82
|
"prewarmPastedImages": false,
|
|
83
|
+
"asyncClipboardHandoff": false,
|
|
81
84
|
"maxTokens": null,
|
|
82
85
|
"cacheMax": 50,
|
|
83
86
|
"maxDescriptionLines": 0,
|
|
@@ -93,6 +96,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
93
96
|
| `autoHandoff` | `true` | Apply handoff to every model whose `input` does not include `image`. |
|
|
94
97
|
| `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
|
|
95
98
|
| `prewarmPastedImages` | `false` | **Opt-in paste-time prewarm.** When `true`, a custom editor wrapper describes pasted clipboard images the instant their temp-file path lands in the prompt (pre-submit), instead of at submit. Trades a bit of description quality (the description is generated without your typed question as context, since the question usually isn't entered yet at paste time) and a speculative vision call on paste-then-abandon, for earlier prewarm. TUI mode only; inactive if another extension has replaced the editor. |
|
|
99
|
+
| `asyncClipboardHandoff` | `false` | **Opt-in async fallback.** Starts from the existing submit-time prewarm. A matching direct `read` or Fabric-nested `pi.read` cancels message delivery; if no matching read wins, the description is queued asynchronously as a collapsed custom message and triggers/continues the agent turn. |
|
|
96
100
|
| `maxTokens` | _(unset = model max, clamped to context window)_ | Cap on a single description's output. `null`/unset = use the vision model's declared max output (`model.maxTokens`), clamped so `maxTokens + 8192 <= contextWindow` (a model whose declared max equals its full context window would otherwise be rejected with a 400). Set a number only to cap cost/latency. A truncation is always surfaced via a `[... description truncated โฆ]` marker when the model hits the limit, so a cut-off description is never mistaken for complete. |
|
|
97
101
|
| `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
|
|
98
102
|
| `maxDescriptionLines` | `0` | Cap on description lines (`0` = unbounded). Default keeps the full description so the `read` tool's native collapse (`ctrl+o`) handles compactness and the model gets complete context; setting `> 0` applies a lossy head-cap to both the TUI render and the model. |
|
|
@@ -155,11 +159,7 @@ pi -e ./vision-handoff.ts
|
|
|
155
159
|
|
|
156
160
|
## How It Works
|
|
157
161
|
|
|
158
|
-
The extension implements the **Facebook DataLoader pattern** for image
|
|
159
|
-
descriptions. The `read` tools are the `load()` callers; a per-image cache
|
|
160
|
-
memoizes promises; all `load()` calls in the same execution frame coalesce
|
|
161
|
-
into ONE batched vision call, dispatched via `setImmediate` after the poll
|
|
162
|
-
phase (so reads completing together batch instead of splitting into N calls).
|
|
162
|
+
The extension implements the **Facebook DataLoader pattern** for image descriptions. The `read` tools are the `load()` callers; a per-image cache memoizes promises; all `load()` calls in the same execution frame coalesce into ONE batched vision call, dispatched via `setImmediate` after the poll phase (so reads completing together batch instead of splitting into N calls).
|
|
163
163
|
|
|
164
164
|
With `prewarmPastedImages` on (opt-in), an even earlier stage runs:
|
|
165
165
|
|
|
@@ -179,6 +179,8 @@ With `prewarmPastedImages` on (opt-in), an even earlier stage runs:
|
|
|
179
179
|
replaced the editor (installing over one would break paste, since
|
|
180
180
|
pi wires paste-image to the outermost editor only)
|
|
181
181
|
|
|
182
|
+
With `asyncClipboardHandoff` on, submit-time prewarm also schedules a non-blocking delivery race. A matching `read` tool call for the pasted path cancels only the custom-message injection (the in-flight description remains available to that read); if description delivery wins, Pi queues it as a steering message. The full description remains model-visible while the transcript shows a one-line row until `Ctrl+O` expands it.
|
|
183
|
+
|
|
182
184
|
The default submit-time pipeline below runs regardless:
|
|
183
185
|
|
|
184
186
|
โ before_agent_start
|
|
@@ -187,9 +189,9 @@ The default submit-time pipeline below runs regardless:
|
|
|
187
189
|
โข PRE-WARMS at paste-enter via the loader, two sources coalescing into
|
|
188
190
|
ONE batch:
|
|
189
191
|
1. attached image blocks (event.images) โ vision-capable targets
|
|
190
|
-
2. pasted
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
2. pasted image FILE PATHS in the prompt text โ the common non-vision
|
|
193
|
+
flow: pi-clipboard, localterm-paste, and other paste mechanisms
|
|
194
|
+
write each pasted image to a temp file and insert the PATH as text
|
|
193
195
|
at the cursor, so on a non-vision model they arrive as path tokens
|
|
194
196
|
in `event.prompt`, NOT as `event.images`. The extension scans the
|
|
195
197
|
prompt for those temp paths (confined to the OS temp dir โ a
|
|
@@ -224,93 +226,25 @@ The default submit-time pipeline below runs regardless:
|
|
|
224
226
|
payload (`emitContext` does a `structuredClone`), leaving storage
|
|
225
227
|
intact for kitty inline rendering and `/resume`
|
|
226
228
|
|
|
227
|
-
Because the describer runs during the **tool-result phase** (before the
|
|
228
|
-
agent's next turn), not during the `context` transform (the critical path
|
|
229
|
-
right before the LLM call), the agent gets described text immediately when
|
|
230
|
-
its turn starts โ it never waits on the describer inline.
|
|
229
|
+
Because the describer runs during the **tool-result phase** (before the agent's next turn), not during the `context` transform (the critical path right before the LLM call), the agent gets described text immediately when its turn starts โ it never waits on the describer inline.
|
|
231
230
|
|
|
232
231
|
### Batching: the DataLoader
|
|
233
232
|
|
|
234
|
-
A single frame's image set is described with **one** vision-model call, not
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
promise; on a cache hit it returns the existing (in-flight or resolved)
|
|
238
|
-
promise. `enqueuePostPromiseJob` schedules dispatch via `setImmediate` (the
|
|
239
|
-
check phase, after the whole poll phase AND after the microtask queue
|
|
240
|
-
drains), so every `load()` caller โ whether from sync code, a `.then`
|
|
241
|
-
cascade, or a separate I/O callback in the same poll iteration โ registers
|
|
242
|
-
its key before the single vision call fires. This is why N parallel reads
|
|
243
|
-
coalesce into one call rather than splitting into N single-image calls:
|
|
244
|
-
`setImmediate` defers past the entire poll phase, whereas DataLoader's
|
|
245
|
-
classic `process.nextTick` would drain between the reads' I/O callbacks and
|
|
246
|
-
fire a dispatch after the first read but before the second. The batched call
|
|
247
|
-
sends every uncached image in a single user message with per-image
|
|
248
|
-
`<<<IMAGE k>>> โฆ <<<END>>>` delimiters; the response is parsed back into
|
|
249
|
-
per-image descriptions (keyed by `sha256(mime + base64)` in the per-image
|
|
250
|
-
cache). If the vision model ignores the delimiter format and the batched
|
|
251
|
-
response can't be split, each unparsed image falls back to its own
|
|
252
|
-
single-image `completeSimple()` call **in parallel** (no delimiters to cooperate
|
|
253
|
-
with) โ descriptions still arrive together. Only when a per-image call
|
|
254
|
-
itself genuinely fails (auth, timeout, empty) does that image degrade to
|
|
255
|
-
`[Image: description unavailable]`; one bad image never voids the rest.
|
|
256
|
-
|
|
257
|
-
Because pi runs parallel `read` tool calls via `Promise.all` and fires
|
|
258
|
-
each read's `tool_result` event as that read's I/O completes (poll phase) โ
|
|
259
|
-
via `agent.afterToolCall` โ `finalizeExecutedToolCall` โ the loader's
|
|
260
|
-
`setImmediate` dispatch defers to the check phase AFTER the whole poll
|
|
261
|
-
iteration, so reads completing together share ONE batch โ ONE vision call,
|
|
262
|
-
all resolving together. Reads completing in separate poll iterations get
|
|
263
|
-
separate calls, but always in parallel, never sequential. (The per-image
|
|
264
|
-
cache also dedupes a duplicate `load()` of the same image in one frame:
|
|
265
|
-
dispatch resolves every callback by hash, so a second load whose first cache
|
|
266
|
-
entry was evicted mid-frame still resolves โ it never hangs.)
|
|
233
|
+
A single frame's image set is described with **one** vision-model call, not one per image. `loadDescription(img)` is synchronous: on a cache miss it pushes the image's key (hash) into the current batch and returns a memoized promise; on a cache hit it returns the existing (in-flight or resolved) promise. `enqueuePostPromiseJob` schedules dispatch via `setImmediate` (the check phase, after the whole poll phase AND after the microtask queue drains), so every `load()` caller โ whether from sync code, a `.then` cascade, or a separate I/O callback in the same poll iteration โ registers its key before the single vision call fires. This is why N parallel reads coalesce into one call rather than splitting into N single-image calls: `setImmediate` defers past the entire poll phase, whereas DataLoader's classic `process.nextTick` would drain between the reads' I/O callbacks and fire a dispatch after the first read but before the second. The batched call sends every uncached image in a single user message with per-image `<<<IMAGE k>>> โฆ <<<END>>>` delimiters; the response is parsed back into per-image descriptions (keyed by `sha256(mime + base64)` in the per-image cache). If the vision model ignores the delimiter format and the batched response can't be split, each unparsed image falls back to its own single-image `completeSimple()` call **in parallel** (no delimiters to cooperate with) โ descriptions still arrive together. Only when a per-image call itself genuinely fails (auth, timeout, empty) does that image degrade to `[Image: description unavailable]`; one bad image never voids the rest.
|
|
234
|
+
|
|
235
|
+
Because pi runs parallel `read` tool calls via `Promise.all` and fires each read's `tool_result` event as that read's I/O completes (poll phase) โ via `agent.afterToolCall` โ `finalizeExecutedToolCall` โ the loader's `setImmediate` dispatch defers to the check phase AFTER the whole poll iteration, so reads completing together share ONE batch โ ONE vision call, all resolving together. Reads completing in separate poll iterations get separate calls, but always in parallel, never sequential. (The per-image cache also dedupes a duplicate `load()` of the same image in one frame: dispatch resolves every callback by hash, so a second load whose first cache entry was evicted mid-frame still resolves โ it never hangs.)
|
|
267
236
|
|
|
268
237
|
**Failures are never cached.**
|
|
269
238
|
|
|
270
|
-
**Failures are never cached.** A genuine describer failure returns
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
**
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
at most once per session (tracked by image hash, reset on `/resume`/new
|
|
280
|
-
session); subsequent turns for the same failing image degrade silently to
|
|
281
|
-
the placeholder instead of spamming.
|
|
282
|
-
|
|
283
|
-
**Timeout scales with batch size.** The describer generates an exhaustive
|
|
284
|
-
multi-paragraph description per image, and the call is batched (N images โ
|
|
285
|
-
1 request). The timeout is `DESCRIBE_TIMEOUT_MS` (120s) plus a per-image
|
|
286
|
-
budget (45s ร (imageCount โ 1)), so a 5-image batch isn't held to the same
|
|
287
|
-
wall-clock budget as one image. A timeout surfaces as `describer timed out
|
|
288
|
-
after <N>s` rather than a misleading `stopReason "aborted"`.
|
|
289
|
-
|
|
290
|
-
**No silent truncation.** The describer prompt says "be exhaustive", so the
|
|
291
|
-
default `maxTokens` is **unset** โ the describer uses the vision model's
|
|
292
|
-
declared max output (`model.maxTokens`) as the cap, rather than relying on a
|
|
293
|
-
provider's small omitted-default. That value is clamped so `maxTokens + 8192
|
|
294
|
-
<= contextWindow`: a model whose declared `maxTokens` equals its full
|
|
295
|
-
`contextWindow` (e.g. a custom provider that sets both to the same number)
|
|
296
|
-
would otherwise be rejected by the provider with a 400 (you can't request
|
|
297
|
-
output tokens equal to the entire context window when you also have input),
|
|
298
|
-
so the clamp subtracts a small input reserve. If the model still hits a token
|
|
299
|
-
limit (a cap you set, or the provider's hard output maximum), `stopReason`
|
|
300
|
-
becomes `"length"`; the describer appends a visible
|
|
301
|
-
`[... description truncated โฆ]` marker to the (still useful) partial text
|
|
302
|
-
rather than letting a cut-off description pass as complete. For a batched call
|
|
303
|
-
the marker lands on the last image being emitted when the cap hit (the one cut
|
|
304
|
-
off mid-stream); earlier sections had `<<<END>>>` delimiters and are complete.
|
|
305
|
-
|
|
306
|
-
**Aborts propagate.** The `context` hook runs in the foreground (pi awaits
|
|
307
|
-
it before the LLM call), so a slow describer would also make aborting a turn
|
|
308
|
-
slow โ pi has to wait for the transform to return. The hook therefore wires
|
|
309
|
-
the turn's abort signal (`ctx.signal`, the active run's `AbortController`)
|
|
310
|
-
into the describer's `completeSimple()` call, so a user cancel kills the in-flight
|
|
311
|
-
vision request immediately. A deliberate abort is not warned about (it's
|
|
312
|
-
not a vision-model failure) and the LLM-bound payload is left untouched since
|
|
313
|
-
the turn is being torn down anyway.
|
|
239
|
+
**Failures are never cached.** A genuine describer failure returns `[Image: description unavailable]` for that turn but is NOT written to the per-image cache, so the next turn re-attempts (and surfaces the real error via a `ctx.ui.notify` warning instead of serving a stuck placeholder). This avoids a transient failure poisoning the cache for the rest of the session.
|
|
240
|
+
|
|
241
|
+
**Per-image-set warning.** The `context` hook fires before every LLM turn, and describer failures aren't cached โ so without dedup the same broken vision model would re-warn every turn. Each distinct image is warned about at most once per session (tracked by image hash, reset on `/resume`/new session); subsequent turns for the same failing image degrade silently to the placeholder instead of spamming.
|
|
242
|
+
|
|
243
|
+
**Timeout scales with batch size.** The describer generates an exhaustive multi-paragraph description per image, and the call is batched (N images โ 1 request). The timeout is `DESCRIBE_TIMEOUT_MS` (120s) plus a per-image budget (45s ร (imageCount โ 1)), so a 5-image batch isn't held to the same wall-clock budget as one image. A timeout surfaces as `describer timed out after <N>s` rather than a misleading `stopReason "aborted"`.
|
|
244
|
+
|
|
245
|
+
**No silent truncation.** The describer prompt says "be exhaustive", so the default `maxTokens` is **unset** โ the describer uses the vision model's declared max output (`model.maxTokens`) as the cap, rather than relying on a provider's small omitted-default. That value is clamped so `maxTokens + 8192 <= contextWindow`: a model whose declared `maxTokens` equals its full `contextWindow` (e.g. a custom provider that sets both to the same number) would otherwise be rejected by the provider with a 400 (you can't request output tokens equal to the entire context window when you also have input), so the clamp subtracts a small input reserve. If the model still hits a token limit (a cap you set, or the provider's hard output maximum), `stopReason` becomes `"length"`; the describer appends a visible `[... description truncated โฆ]` marker to the (still useful) partial text rather than letting a cut-off description pass as complete. For a batched call the marker lands on the last image being emitted when the cap hit (the one cut off mid-stream); earlier sections had `<<<END>>>` delimiters and are complete.
|
|
246
|
+
|
|
247
|
+
**Aborts propagate.** The `context` hook runs in the foreground (pi awaits it before the LLM call), so a slow describer would also make aborting a turn slow โ pi has to wait for the transform to return. The hook therefore wires the turn's abort signal (`ctx.signal`, the active run's `AbortController`) into the describer's `completeSimple()` call, so a user cancel kills the in-flight vision request immediately. A deliberate abort is not warned about (it's not a vision-model failure) and the LLM-bound payload is left untouched since the turn is being torn down anyway.
|
|
314
248
|
|
|
315
249
|
### Image-block formats handled
|
|
316
250
|
|
|
@@ -321,10 +255,7 @@ the turn is being torn down anyway.
|
|
|
321
255
|
| `context` (all messages) | `{ type: "input_image", image_url: "data:โฆ" }` (OpenAI Responses) | detected by shape โ replaced with `{ type: "input_text", text }` |
|
|
322
256
|
| `context` (all messages) | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | detected by shape โ replaced with `{ type: "text", text }` |
|
|
323
257
|
|
|
324
|
-
The describer call itself goes through pi's normal model machinery (`completeSimple()`),
|
|
325
|
-
**not** the agent event loop โ so it never re-triggers `context` (no recursion).
|
|
326
|
-
The `read` tool result keeps its image block untouched (kitty inline + `/resume`);
|
|
327
|
-
only the `context`-cloned LLM-bound payload has images swapped for text.
|
|
258
|
+
The describer call itself goes through pi's normal model machinery (`completeSimple()`), **not** the agent event loop โ so it never re-triggers `context` (no recursion). The `read` tool result keeps its image block untouched (kitty inline + `/resume`); only the `context`-cloned LLM-bound payload has images swapped for text.
|
|
328
259
|
|
|
329
260
|
### Usage reporting
|
|
330
261
|
|
|
@@ -370,7 +301,7 @@ One record is emitted per **real** describer call (a batched call describing sev
|
|
|
370
301
|
|
|
371
302
|
```bash
|
|
372
303
|
pnpm install
|
|
373
|
-
pnpm test # Vitest unit tests
|
|
304
|
+
pnpm test # Vitest unit tests
|
|
374
305
|
pnpm typecheck # TypeScript validation
|
|
375
306
|
pnpm lint:dead # Dead code detection (knip)
|
|
376
307
|
```
|
|
@@ -407,11 +338,7 @@ pnpm lint:dead # Dead code detection (knip)
|
|
|
407
338
|
|
|
408
339
|
## Acknowledgements
|
|
409
340
|
|
|
410
|
-
The vision handoff concept and the exhaustive describer prompt originate from
|
|
411
|
-
the [pi-umans-provider](https://github.com/monotykamary/pi-umans-provider) GLM 5.1
|
|
412
|
-
pipeline. The picker TUI builds on the patterns from
|
|
413
|
-
[pi-hide-providers](https://github.com/monotykamary/pi-hide-providers), which in
|
|
414
|
-
turn mirror pi core's built-in selectors.
|
|
341
|
+
The vision handoff concept and the exhaustive describer prompt originate from the [pi-umans-provider](https://github.com/monotykamary/pi-umans-provider) GLM 5.1 pipeline. The picker TUI builds on the patterns from [pi-hide-providers](https://github.com/monotykamary/pi-hide-providers), which in turn mirror pi core's built-in selectors.
|
|
415
342
|
|
|
416
343
|
## License
|
|
417
344
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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/image.ts
CHANGED
|
@@ -282,24 +282,30 @@ export function readImageBufferBounded(filePath: string): { buf: Buffer; mimeTyp
|
|
|
282
282
|
return readImageBuffer(filePath);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
-
/** Regex matching
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
285
|
+
/** Regex matching any pasted temp-image file path in the prompt text,
|
|
286
|
+
* regardless of which paste mechanism wrote it (pi-clipboard,
|
|
287
|
+
* localterm-paste, and others). We match any non-whitespace token ending in
|
|
288
|
+
* a supported image extension, then confine to the OS temp directory below.
|
|
289
|
+
* The confinement, not the filename pattern, is the security boundary. */
|
|
290
|
+
const PASTED_IMAGE_PATH_RE = /(\S+\.(?:png|jpe?g|gif|webp))/gi;
|
|
291
|
+
const URL_RE = new RegExp('^[a-z][a-z0-9+.-]*://', 'i');
|
|
292
|
+
const LEADING_WRAP_RE = new RegExp('^[^A-Za-z0-9_/@.~-]+');
|
|
293
293
|
|
|
294
|
-
/** Extract pasted
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
|
|
294
|
+
/** Extract pasted image file paths from a prompt. Confined to the OS temp
|
|
295
|
+
* directory so an attacker-crafted prompt can't trick the extension into
|
|
296
|
+
* reading arbitrary files โ only temp-dir image files qualify (this covers
|
|
297
|
+
* every paste mechanism: pi-clipboard, localterm-paste, and others). URLs
|
|
298
|
+
* (http(s)://, file://, ...) are skipped so they aren't joined into the temp
|
|
299
|
+
* dir and mistaken for local files; leading wrapping chars (parentheses,
|
|
300
|
+
* quotes) are stripped so quoted/parenthesized paths still resolve. */
|
|
301
|
+
export function findPastedImagePaths(prompt: string): string[] {
|
|
298
302
|
const tmp = tmpdir();
|
|
299
303
|
const paths = new Set<string>();
|
|
300
|
-
for (const m of prompt.matchAll(
|
|
301
|
-
const
|
|
302
|
-
if (!
|
|
304
|
+
for (const m of prompt.matchAll(PASTED_IMAGE_PATH_RE)) {
|
|
305
|
+
const token = m[1];
|
|
306
|
+
if (!token) continue;
|
|
307
|
+
if (URL_RE.test(token)) continue;
|
|
308
|
+
const p = token.replace(LEADING_WRAP_RE, "");
|
|
303
309
|
const abs = isAbsolute(p) ? p : join(tmp, p);
|
|
304
310
|
// Ensure the resolved candidate stays inside the temp directory.
|
|
305
311
|
if (abs.startsWith(tmp + sep) || abs === tmp) paths.add(abs);
|
|
@@ -307,16 +313,16 @@ export function findClipboardImagePaths(prompt: string): string[] {
|
|
|
307
313
|
return [...paths];
|
|
308
314
|
}
|
|
309
315
|
|
|
310
|
-
/** Diff the
|
|
316
|
+
/** Diff the pasted image paths in `text` against `known`, returning those
|
|
311
317
|
* that are newly appeared (not yet seen). Used by the paste-time prewarm
|
|
312
318
|
* editor to prewarm only newly-pasted paths on each text change, without
|
|
313
|
-
* re-reading already-seen ones. `
|
|
319
|
+
* re-reading already-seen ones. `findPastedImagePaths` already dedups
|
|
314
320
|
* within a single text, so each returned path is unique and seen at most
|
|
315
|
-
* once. Returns [] when `text` holds no
|
|
321
|
+
* once. Returns [] when `text` holds no pasted paths (e.g. ordinary
|
|
316
322
|
* typing) โ so the editor's per-keystroke cost when the opt-in is on is one
|
|
317
323
|
* regex scan that almost always yields nothing. */
|
|
318
324
|
export function diffPrewarmPaths(text: string, known: Set<string>): string[] {
|
|
319
|
-
const paths =
|
|
325
|
+
const paths = findPastedImagePaths(text);
|
|
320
326
|
const newPaths: string[] = [];
|
|
321
327
|
for (const p of paths) {
|
|
322
328
|
if (!known.has(p)) newPaths.push(p);
|
package/src/index.ts
CHANGED
|
@@ -149,6 +149,11 @@ export interface VisionHandoffConfig {
|
|
|
149
149
|
* a custom editor would break clipboard paste, since pi wires paste-image
|
|
150
150
|
* to the outermost editor only). */
|
|
151
151
|
prewarmPastedImages: boolean;
|
|
152
|
+
/** Opt-in: asynchronously inject descriptions for pasted clipboard paths when
|
|
153
|
+
* the target model does not read them. The normal read/tool_result path wins
|
|
154
|
+
* the race: a matching read cancels the queued fallback injection while
|
|
155
|
+
* reusing the same in-flight description. */
|
|
156
|
+
asyncClipboardHandoff: boolean;
|
|
152
157
|
/** Max output tokens for a single description. `undefined` (default) = use the
|
|
153
158
|
* vision model's declared max output (`model.maxTokens`) as the cap โ the
|
|
154
159
|
* highest the model supports โ so the "be exhaustive" prompt isn't defeated
|
|
@@ -186,6 +191,7 @@ export const DEFAULT_CONFIG: VisionHandoffConfig = {
|
|
|
186
191
|
autoHandoff: true,
|
|
187
192
|
handoffModels: [],
|
|
188
193
|
prewarmPastedImages: false,
|
|
194
|
+
asyncClipboardHandoff: false,
|
|
189
195
|
maxTokens: undefined,
|
|
190
196
|
cacheMax: DEFAULT_CACHE_MAX,
|
|
191
197
|
maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
|
|
@@ -236,6 +242,9 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
|
|
|
236
242
|
.filter((m) => m && parseModelRef(m));
|
|
237
243
|
}
|
|
238
244
|
if (typeof obj.prewarmPastedImages === "boolean") base.prewarmPastedImages = obj.prewarmPastedImages;
|
|
245
|
+
if (typeof obj.asyncClipboardHandoff === "boolean") {
|
|
246
|
+
base.asyncClipboardHandoff = obj.asyncClipboardHandoff;
|
|
247
|
+
}
|
|
239
248
|
// maxTokens: optional. undefined (default) = no artificial cap. Only set when
|
|
240
249
|
// a valid positive finite number is given; any other value leaves it unset.
|
|
241
250
|
if (typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) && obj.maxTokens > 0) {
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
matchesKey,
|
|
22
22
|
Spacer,
|
|
23
23
|
Text,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
wrapTextWithAnsi,
|
|
24
26
|
} from "@earendil-works/pi-tui";
|
|
25
27
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
26
28
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
@@ -48,6 +50,8 @@ export interface VisionModelSelectorResult {
|
|
|
48
50
|
thinking: boolean;
|
|
49
51
|
/** Thinking effort chosen in the picker. */
|
|
50
52
|
thinkingLevel: ThinkingLevel;
|
|
53
|
+
/** Whether pasted paths should be injected if no matching read wins. */
|
|
54
|
+
asyncClipboardHandoff: boolean;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
export class VisionModelSelectorComponent implements Component {
|
|
@@ -65,6 +69,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
65
69
|
private currentRef: string | null;
|
|
66
70
|
private thinking: boolean;
|
|
67
71
|
private thinkingLevel: ThinkingLevel;
|
|
72
|
+
private asyncClipboardHandoff: boolean;
|
|
68
73
|
|
|
69
74
|
private _focused = false;
|
|
70
75
|
get focused(): boolean {
|
|
@@ -87,6 +92,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
87
92
|
currentRef: string | null,
|
|
88
93
|
currentThinking: boolean,
|
|
89
94
|
currentThinkingLevel: ThinkingLevel,
|
|
95
|
+
currentAsyncClipboardHandoff: boolean,
|
|
90
96
|
done: (result: VisionModelSelectorResult) => void,
|
|
91
97
|
) {
|
|
92
98
|
this.theme = theme;
|
|
@@ -94,6 +100,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
94
100
|
this.currentRef = currentRef;
|
|
95
101
|
this.thinking = currentThinking;
|
|
96
102
|
this.thinkingLevel = currentThinkingLevel;
|
|
103
|
+
this.asyncClipboardHandoff = currentAsyncClipboardHandoff;
|
|
97
104
|
this.allItems = this.buildItems(allModels);
|
|
98
105
|
this.filteredItems = this.allItems;
|
|
99
106
|
|
|
@@ -116,9 +123,21 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
116
123
|
const lines: string[] = [];
|
|
117
124
|
lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
|
|
118
125
|
lines.push("");
|
|
119
|
-
lines.push(this.theme.fg("accent", this.theme.bold("Vision Handoff")));
|
|
120
126
|
lines.push(
|
|
121
|
-
|
|
127
|
+
truncateToWidth(
|
|
128
|
+
this.theme.fg("accent", this.theme.bold("Vision Handoff")),
|
|
129
|
+
width,
|
|
130
|
+
"",
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
lines.push(
|
|
134
|
+
...wrapTextWithAnsi(
|
|
135
|
+
this.theme.fg(
|
|
136
|
+
"muted",
|
|
137
|
+
"Pick a vision-capable model to describe images for text-only models.",
|
|
138
|
+
),
|
|
139
|
+
width,
|
|
140
|
+
),
|
|
122
141
|
);
|
|
123
142
|
lines.push("");
|
|
124
143
|
lines.push(...this.searchInput.render(width));
|
|
@@ -127,7 +146,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
127
146
|
lines.push("");
|
|
128
147
|
lines.push(...this.footerText.render(width));
|
|
129
148
|
lines.push(...new DynamicBorder((s) => this.theme.fg("accent", s)).render(width));
|
|
130
|
-
return lines;
|
|
149
|
+
return lines.map((line) => truncateToWidth(line, width, ""));
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
handleInput(data: string): void {
|
|
@@ -180,6 +199,12 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
180
199
|
return;
|
|
181
200
|
}
|
|
182
201
|
|
|
202
|
+
if (matchesKey(data, Key.ctrl("a"))) {
|
|
203
|
+
this.asyncClipboardHandoff = !this.asyncClipboardHandoff;
|
|
204
|
+
this.updateList();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
183
208
|
// Thinking controls โ reuse pi's own app.thinking.* keybindings so the
|
|
184
209
|
// hints and behaviour match the rest of pi: ctrl+t toggles thinking
|
|
185
210
|
// on/off, shift+tab cycles the effort level. Intercepted before the
|
|
@@ -263,6 +288,7 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
263
288
|
`ctrl+s done`,
|
|
264
289
|
`${keyText("app.thinking.toggle")} thinking`,
|
|
265
290
|
`${keyText("app.thinking.cycle")} effort`,
|
|
291
|
+
`ctrl+a async fallback`,
|
|
266
292
|
`esc cancel`,
|
|
267
293
|
this.searchInput.getValue() ? `${this.filteredItems.length - 1} match` : `${totalCount} models ยท ${visionCount} vision`,
|
|
268
294
|
];
|
|
@@ -365,6 +391,12 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
365
391
|
);
|
|
366
392
|
}
|
|
367
393
|
this.renderThinkingDetail(selected);
|
|
394
|
+
const fallback = this.asyncClipboardHandoff
|
|
395
|
+
? this.theme.fg("success", "on")
|
|
396
|
+
: this.theme.fg("muted", "off");
|
|
397
|
+
this.listContainer.addChild(
|
|
398
|
+
new Text(this.theme.fg("dim", ` Async pasted-path fallback: ${fallback}`), 0, 0),
|
|
399
|
+
);
|
|
368
400
|
}
|
|
369
401
|
|
|
370
402
|
this.footerText.setText(this.getFooterText());
|
|
@@ -394,11 +426,23 @@ export class VisionModelSelectorComponent implements Component {
|
|
|
394
426
|
}
|
|
395
427
|
|
|
396
428
|
private confirm(item: DisplayItem): void {
|
|
397
|
-
this.done({
|
|
429
|
+
this.done({
|
|
430
|
+
ref: item.ref,
|
|
431
|
+
cancelled: false,
|
|
432
|
+
thinking: this.thinking,
|
|
433
|
+
thinkingLevel: this.thinkingLevel,
|
|
434
|
+
asyncClipboardHandoff: this.asyncClipboardHandoff,
|
|
435
|
+
});
|
|
398
436
|
}
|
|
399
437
|
|
|
400
438
|
private finish(cancelled: boolean): void {
|
|
401
|
-
this.done({
|
|
439
|
+
this.done({
|
|
440
|
+
ref: null,
|
|
441
|
+
cancelled,
|
|
442
|
+
thinking: this.thinking,
|
|
443
|
+
thinkingLevel: this.thinkingLevel,
|
|
444
|
+
asyncClipboardHandoff: this.asyncClipboardHandoff,
|
|
445
|
+
});
|
|
402
446
|
}
|
|
403
447
|
|
|
404
448
|
/** Cycle the thinking effort forward through {@link THINKING_LEVELS},
|
package/vision-handoff.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* before_agent_start โ warm the description cache for attached images AND
|
|
12
12
|
* pasted clipboard image file paths found in the prompt text (pre-warm at
|
|
13
13
|
* paste-enter, concurrent with the agent's first response).
|
|
14
|
+
* optional async clipboard fallback โ race matching read tool calls against a
|
|
15
|
+
* non-blocking steering-message injection; matching reads cancel delivery.
|
|
14
16
|
* tool_result (read) โ loadDescription() each read-tool image and AWAIT the
|
|
15
17
|
* shared batch, so N parallel reads coalesce into ONE vision call. The
|
|
16
18
|
* descriptions land in the tool results before the agent's next turn.
|
|
@@ -51,11 +53,12 @@ import {
|
|
|
51
53
|
type VisionHandoffUsageRecord,
|
|
52
54
|
} from "./src/usage.js";
|
|
53
55
|
import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
|
|
54
|
-
import { imageHash,
|
|
56
|
+
import { imageHash, findPastedImagePaths, readImageBuffer, readImageBufferBounded, resolvePrewarmImage, isOmittedImageNote } from "./src/image.js";
|
|
55
57
|
import { appendVisionError } from "./src/error-log.js";
|
|
56
58
|
import { resizeImage } from "@earendil-works/pi-coding-agent";
|
|
57
59
|
import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
|
|
58
60
|
import { PrewarmEditor } from "./src/prewarm-editor.js";
|
|
61
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
59
62
|
|
|
60
63
|
let config: VisionHandoffConfig = readConfig();
|
|
61
64
|
|
|
@@ -85,6 +88,110 @@ let editorInstalled = false;
|
|
|
85
88
|
let visionModelCache: { ref: string; model: Model<Api> } | null = null;
|
|
86
89
|
let visionModelUnresolvedRef: string | null = null;
|
|
87
90
|
|
|
91
|
+
interface PendingAsyncClipboardHandoff {
|
|
92
|
+
token: symbol;
|
|
93
|
+
paths: Set<string>;
|
|
94
|
+
cancelled: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface PreparedClipboardImage {
|
|
98
|
+
path: string;
|
|
99
|
+
image: ExtractedImage;
|
|
100
|
+
description: Promise<string>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let pendingAsyncClipboardHandoff: PendingAsyncClipboardHandoff | null = null;
|
|
104
|
+
|
|
105
|
+
function cancelAsyncClipboardHandoff(): void {
|
|
106
|
+
if (!pendingAsyncClipboardHandoff) return;
|
|
107
|
+
pendingAsyncClipboardHandoff.cancelled = true;
|
|
108
|
+
pendingAsyncClipboardHandoff = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPendingClipboardRead(input: unknown, cwd: string): boolean {
|
|
112
|
+
const pending = pendingAsyncClipboardHandoff;
|
|
113
|
+
if (!pending || !input || typeof input !== "object") return false;
|
|
114
|
+
const raw = (input as { path?: unknown }).path;
|
|
115
|
+
if (typeof raw !== "string") return false;
|
|
116
|
+
const path = raw.startsWith("@") ? raw.slice(1) : raw;
|
|
117
|
+
const absolute = isAbsolute(path) ? path : resolve(cwd, path);
|
|
118
|
+
return pending.paths.has(absolute);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scheduleAsyncClipboardInjection(
|
|
122
|
+
pi: ExtensionAPI,
|
|
123
|
+
ctx: ExtensionContext,
|
|
124
|
+
paths: string[],
|
|
125
|
+
prepared: Promise<PreparedClipboardImage | null>[],
|
|
126
|
+
): void {
|
|
127
|
+
cancelAsyncClipboardHandoff();
|
|
128
|
+
const pending: PendingAsyncClipboardHandoff = {
|
|
129
|
+
token: Symbol("async-clipboard-handoff"),
|
|
130
|
+
paths: new Set(paths),
|
|
131
|
+
cancelled: false,
|
|
132
|
+
};
|
|
133
|
+
pendingAsyncClipboardHandoff = pending;
|
|
134
|
+
|
|
135
|
+
void (async () => {
|
|
136
|
+
const entries = (await Promise.all(prepared)).filter(
|
|
137
|
+
(entry): entry is PreparedClipboardImage => entry !== null,
|
|
138
|
+
);
|
|
139
|
+
if (entries.length === 0) {
|
|
140
|
+
if (pendingAsyncClipboardHandoff?.token === pending.token) {
|
|
141
|
+
pendingAsyncClipboardHandoff = null;
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const descriptions = await Promise.all(entries.map((entry) => entry.description));
|
|
146
|
+
|
|
147
|
+
// Always yield out of before_agent_start, even when paste-time prewarm made
|
|
148
|
+
// every description an immediate cache hit. This lets Pi enter the active
|
|
149
|
+
// agent run before sendMessage queues the fallback as a steering message.
|
|
150
|
+
await new Promise<void>((resolveImmediate) => setImmediate(resolveImmediate));
|
|
151
|
+
if (
|
|
152
|
+
pending.cancelled ||
|
|
153
|
+
pendingAsyncClipboardHandoff?.token !== pending.token ||
|
|
154
|
+
!config.asyncClipboardHandoff ||
|
|
155
|
+
!isConfigured(config)
|
|
156
|
+
) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
warnFailedImages(
|
|
161
|
+
ctx,
|
|
162
|
+
entries.map((entry) => entry.image),
|
|
163
|
+
descriptions,
|
|
164
|
+
lastDescriberError ?? "unknown error",
|
|
165
|
+
);
|
|
166
|
+
const content = [
|
|
167
|
+
"Asynchronous vision handoff for pasted image path(s):",
|
|
168
|
+
...entries.map(
|
|
169
|
+
(entry, index) => `${entry.path}\n${descriptions[index] ?? UNAVAILABLE}`,
|
|
170
|
+
),
|
|
171
|
+
].join("\n\n");
|
|
172
|
+
|
|
173
|
+
pendingAsyncClipboardHandoff = null;
|
|
174
|
+
try {
|
|
175
|
+
pi.sendMessage(
|
|
176
|
+
{
|
|
177
|
+
customType: "vision-handoff-async",
|
|
178
|
+
content,
|
|
179
|
+
display: true,
|
|
180
|
+
details: { imageCount: entries.length },
|
|
181
|
+
},
|
|
182
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
183
|
+
);
|
|
184
|
+
} catch {
|
|
185
|
+
// The session may have been replaced/reloaded while the description was
|
|
186
|
+
// in flight. The stale extension instance must not affect the new one.
|
|
187
|
+
}
|
|
188
|
+
})().catch(() => {
|
|
189
|
+
if (pendingAsyncClipboardHandoff?.token === pending.token) {
|
|
190
|
+
pendingAsyncClipboardHandoff = null;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
88
195
|
// Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
|
|
89
196
|
// export. No-op until then so the describer is safe to call before wiring.
|
|
90
197
|
let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
|
|
@@ -236,6 +343,24 @@ function warnFailedImages(
|
|
|
236
343
|
export default function (pi: ExtensionAPI) {
|
|
237
344
|
config = readConfig();
|
|
238
345
|
|
|
346
|
+
pi.registerMessageRenderer("vision-handoff-async", (message, { expanded }, theme) => {
|
|
347
|
+
const details = message.details as { imageCount?: number } | undefined;
|
|
348
|
+
const count = details?.imageCount ?? 1;
|
|
349
|
+
const label = `Vision handoff ยท ${count} pasted image${count === 1 ? "" : "s"}`;
|
|
350
|
+
const hint = expanded ? "Ctrl+O to collapse" : "Ctrl+O to expand";
|
|
351
|
+
const summary = theme.fg("dim", `๐ ${label} ยท ${hint}`);
|
|
352
|
+
if (!expanded) return new Text(summary, 0, 0);
|
|
353
|
+
// Apply the dim (grey) style per line: the TUI appends a full SGR reset at
|
|
354
|
+
// the end of each rendered line, so a single style wrapper would only tint
|
|
355
|
+
// the first line. Text wraps with wrapTextWithAnsi (ANSI-preserving), and
|
|
356
|
+
// each line here carries its own dim code so wrapped continuations stay grey.
|
|
357
|
+
const contentStr = typeof message.content === "string" ? message.content : "";
|
|
358
|
+
const body = contentStr.length > 0
|
|
359
|
+
? contentStr.split("\n").map((line) => theme.fg("dim", line)).join("\n")
|
|
360
|
+
: "";
|
|
361
|
+
return new Text(body ? `${summary}\n${body}` : summary, 0, 0);
|
|
362
|
+
});
|
|
363
|
+
|
|
239
364
|
// Wire the usage reporter to pi's persistence + event bus. appendEntry
|
|
240
365
|
// persists the record so it replays on session resume/branch, and the event
|
|
241
366
|
// lets live consumers filter on one channel for tokens AND energy. Each call
|
|
@@ -257,6 +382,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
257
382
|
};
|
|
258
383
|
|
|
259
384
|
pi.on("session_start", async (_event, ctx) => {
|
|
385
|
+
cancelAsyncClipboardHandoff();
|
|
260
386
|
// Reload in case the user edited the config on disk from another session.
|
|
261
387
|
config = readConfig();
|
|
262
388
|
visionModelCache = null;
|
|
@@ -268,6 +394,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
268
394
|
});
|
|
269
395
|
|
|
270
396
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
397
|
+
cancelAsyncClipboardHandoff();
|
|
271
398
|
if (!isConfigured(config)) return;
|
|
272
399
|
if (!isHandoffTarget(ctx.model, config)) return;
|
|
273
400
|
|
|
@@ -298,9 +425,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
298
425
|
// 1. Attached image blocks (event.images) โ vision-capable targets where
|
|
299
426
|
// the user message itself carries image blocks (e.g. `pi --image`).
|
|
300
427
|
//
|
|
301
|
-
// 2. Pasted
|
|
302
|
-
//
|
|
303
|
-
// image to
|
|
428
|
+
// 2. Pasted image FILE PATHS in the prompt text โ the common non-vision
|
|
429
|
+
// flow. pi's `handleClipboardImagePaste` (and other paste mechanisms like
|
|
430
|
+
// localterm-paste) write each pasted image to a temp file and insert the
|
|
431
|
+
// PATH as
|
|
304
432
|
// text at the cursor; on a non-vision model these arrive as path tokens
|
|
305
433
|
// in `event.prompt`, NOT as `event.images`. We scan the prompt for those
|
|
306
434
|
// temp paths, read the files, and `loadDescription()` them so the ONE
|
|
@@ -331,17 +459,49 @@ export default function (pi: ExtensionAPI) {
|
|
|
331
459
|
// describes an image the agent will see). A file that can't be read, isn't
|
|
332
460
|
// a supported image, or fails to resize is skipped (the agent's `read`
|
|
333
461
|
// will still describe it via `tool_result` if it emits an image block).
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
462
|
+
const clipboardPaths = findPastedImagePaths(event.prompt || "");
|
|
463
|
+
const preparedClipboardImages = clipboardPaths.map(
|
|
464
|
+
async (path): Promise<PreparedClipboardImage | null> => {
|
|
465
|
+
try {
|
|
466
|
+
const read = readImageBuffer(path);
|
|
467
|
+
if (!read) return null;
|
|
468
|
+
const image = await resolvePrewarmImage(read.buf, read.mimeType, resizeImage);
|
|
469
|
+
if (!image) return null;
|
|
470
|
+
const description = loader.loadDescription(image);
|
|
471
|
+
description.catch(() => {});
|
|
472
|
+
return { path, image, description };
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
if (config.asyncClipboardHandoff && clipboardPaths.length > 0) {
|
|
480
|
+
scheduleAsyncClipboardInjection(pi, ctx, clipboardPaths, preparedClipboardImages);
|
|
481
|
+
} else {
|
|
482
|
+
// Start every preparation task even when no consumer awaits it. Each task
|
|
483
|
+
// calls loadDescription as soon as resize completes, preserving the
|
|
484
|
+
// original fire-and-forget submit-time prewarm behavior.
|
|
485
|
+
for (const prepared of preparedClipboardImages) prepared.catch(() => {});
|
|
342
486
|
}
|
|
343
487
|
});
|
|
344
488
|
|
|
489
|
+
// A direct read and a nested pi.read both emit a read tool_call. If it targets
|
|
490
|
+
// one of this turn's pasted clipboard paths before the async fallback is
|
|
491
|
+
// injected, the normal tool_result/context path wins and the queued custom
|
|
492
|
+
// message is cancelled. The in-flight description is deliberately retained:
|
|
493
|
+
// the read result reuses it as a cache/in-flight hit.
|
|
494
|
+
pi.on("tool_call", (event, ctx) => {
|
|
495
|
+
if (event.toolName !== "read" && event.toolName !== "pi.read") return;
|
|
496
|
+
if (isPendingClipboardRead(event.input, ctx.cwd)) {
|
|
497
|
+
cancelAsyncClipboardHandoff();
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
pi.on("session_shutdown", () => {
|
|
502
|
+
cancelAsyncClipboardHandoff();
|
|
503
|
+
});
|
|
504
|
+
|
|
345
505
|
// The PRIMARY injection point: the `read` tool's `tool_result` handler.
|
|
346
506
|
// When the agent reads image files, this fires for each read result. It
|
|
347
507
|
// calls the loader's `loadDescription(img)` for every image block and
|
|
@@ -565,7 +725,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
565
725
|
pi.registerCommand("vision-handoff", {
|
|
566
726
|
description: HANDOFF_COMMAND_DESCRIPTION,
|
|
567
727
|
getArgumentCompletions(prefix: string) {
|
|
568
|
-
const subcommands = ["select", "model", "status", "enable", "disable", "auto", "thinking", "prewarm", "add", "remove", "clear", "help"];
|
|
728
|
+
const subcommands = ["select", "model", "status", "enable", "disable", "auto", "thinking", "prewarm", "fallback", "add", "remove", "clear", "help"];
|
|
569
729
|
const matches = subcommands.filter((s) => s.startsWith(prefix));
|
|
570
730
|
return matches.length > 0 ? matches.map((s) => ({ value: s, label: s })) : null;
|
|
571
731
|
},
|
|
@@ -601,6 +761,8 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
|
|
|
601
761
|
" Set the vision describer's thinking effort (off = disabled)",
|
|
602
762
|
" /vision-handoff prewarm <on|off>",
|
|
603
763
|
" Toggle describing pasted images at paste-time (opt-in, off by default)",
|
|
764
|
+
" /vision-handoff fallback <on|off>",
|
|
765
|
+
" Inject pasted-image descriptions asynchronously when no matching read wins",
|
|
604
766
|
" /vision-handoff add <p/id> Force handoff for an extra model",
|
|
605
767
|
" /vision-handoff remove <p/id> Stop forcing handoff for a model",
|
|
606
768
|
" /vision-handoff clear Clear the configured vision model",
|
|
@@ -611,6 +773,7 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
|
|
|
611
773
|
" read images through a dataloader (one batched vision call); context swaps",
|
|
612
774
|
" image blocks in the payload for the cached text description.",
|
|
613
775
|
" prewarm on wraps the editor to describe pasted images at paste-time.",
|
|
776
|
+
" fallback on asynchronously injects a collapsed description unless a matching read wins.",
|
|
614
777
|
].join("\n"),
|
|
615
778
|
"info",
|
|
616
779
|
);
|
|
@@ -657,6 +820,11 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
|
|
|
657
820
|
return;
|
|
658
821
|
}
|
|
659
822
|
|
|
823
|
+
if (subcommand === "fallback") {
|
|
824
|
+
handleFallbackSubcommand(ctx, rest);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
660
828
|
if (subcommand === "clear") {
|
|
661
829
|
updateConfig(
|
|
662
830
|
ctx,
|
|
@@ -743,6 +911,7 @@ function updateConfig(
|
|
|
743
911
|
message: string,
|
|
744
912
|
): void {
|
|
745
913
|
const next = transform(config);
|
|
914
|
+
if (!next.asyncClipboardHandoff) cancelAsyncClipboardHandoff();
|
|
746
915
|
const path = writeConfig(next);
|
|
747
916
|
config = next;
|
|
748
917
|
visionModelCache = null;
|
|
@@ -806,6 +975,31 @@ function handlePrewarmSubcommand(ctx: ExtensionCommandContext, rest: string): vo
|
|
|
806
975
|
updateConfig(ctx, (c) => ({ ...c, prewarmPastedImages: on }), note);
|
|
807
976
|
}
|
|
808
977
|
|
|
978
|
+
/** Handle /vision-handoff fallback <on|off>. */
|
|
979
|
+
function handleFallbackSubcommand(ctx: ExtensionCommandContext, rest: string): void {
|
|
980
|
+
const value = rest.trim().toLowerCase();
|
|
981
|
+
if (!value) {
|
|
982
|
+
ctx.ui.notify(
|
|
983
|
+
`Async pasted-path fallback: ${config.asyncClipboardHandoff ? "on" : "off"}.\n` +
|
|
984
|
+
"Usage: /vision-handoff fallback <on|off>",
|
|
985
|
+
"info",
|
|
986
|
+
);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (value !== "on" && value !== "off") {
|
|
990
|
+
ctx.ui.notify("Usage: /vision-handoff fallback <on|off>", "warning");
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const on = value === "on";
|
|
994
|
+
updateConfig(
|
|
995
|
+
ctx,
|
|
996
|
+
(c) => ({ ...c, asyncClipboardHandoff: on }),
|
|
997
|
+
on
|
|
998
|
+
? "Async pasted-path fallback on โ a matching read wins the race; otherwise the description is injected as a collapsed message."
|
|
999
|
+
: "Async pasted-path fallback off.",
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
809
1003
|
async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
810
1004
|
if (!ctx.hasUI) {
|
|
811
1005
|
ctx.ui.notify("/vision-handoff requires interactive mode.", "error");
|
|
@@ -825,12 +1019,15 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
825
1019
|
if (thinkingPick === undefined) return;
|
|
826
1020
|
const thinking = thinkingPick === "on";
|
|
827
1021
|
const thinkingLevel = config.thinkingLevel;
|
|
1022
|
+
const fallbackPick = await ctx.ui.select("Async pasted-path fallback", ["on", "off"]);
|
|
1023
|
+
if (fallbackPick === undefined) return;
|
|
1024
|
+
const asyncClipboardHandoff = fallbackPick === "on";
|
|
828
1025
|
const thinkingNote = thinking
|
|
829
1026
|
? `thinking on (${thinkingLevel})${ref ? " \u2014 applies only if the vision model supports reasoning" : ""}`
|
|
830
1027
|
: "thinking off";
|
|
831
1028
|
updateConfig(
|
|
832
1029
|
ctx,
|
|
833
|
-
(c) => ({ ...c, visionModel: ref, thinking, thinkingLevel }),
|
|
1030
|
+
(c) => ({ ...c, visionModel: ref, thinking, thinkingLevel, asyncClipboardHandoff }),
|
|
834
1031
|
ref ? `Vision model set to ${ref} \u00b7 ${thinkingNote}` : `Vision model cleared \u00b7 ${thinkingNote}`,
|
|
835
1032
|
);
|
|
836
1033
|
if (!ref) {
|
|
@@ -846,6 +1043,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
846
1043
|
config.visionModel,
|
|
847
1044
|
config.thinking,
|
|
848
1045
|
config.thinkingLevel,
|
|
1046
|
+
config.asyncClipboardHandoff,
|
|
849
1047
|
(r) => done(r),
|
|
850
1048
|
);
|
|
851
1049
|
return {
|
|
@@ -870,6 +1068,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
870
1068
|
const ref = result.ref;
|
|
871
1069
|
const thinking = result.thinking;
|
|
872
1070
|
const thinkingLevel = result.thinkingLevel;
|
|
1071
|
+
const asyncClipboardHandoff = result.asyncClipboardHandoff;
|
|
873
1072
|
// Fold the thinking state into the single updateConfig notify so the
|
|
874
1073
|
// "thinking off" message can't overwrite the model-change message.
|
|
875
1074
|
const thinkingNote = thinking
|
|
@@ -877,7 +1076,7 @@ async function showSelector(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
877
1076
|
: "thinking off";
|
|
878
1077
|
updateConfig(
|
|
879
1078
|
ctx,
|
|
880
|
-
(c) => ({ ...c, visionModel: ref, thinking, thinkingLevel }),
|
|
1079
|
+
(c) => ({ ...c, visionModel: ref, thinking, thinkingLevel, asyncClipboardHandoff }),
|
|
881
1080
|
ref ? `Vision model set to ${ref} ยท ${thinkingNote}` : `Vision model cleared ยท ${thinkingNote}`,
|
|
882
1081
|
);
|
|
883
1082
|
if (!ref) {
|
|
@@ -893,6 +1092,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
|
|
|
893
1092
|
lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
|
|
894
1093
|
lines.push(`Thinking: ${config.thinking ? `on (${config.thinkingLevel})` : "off"}`);
|
|
895
1094
|
lines.push(`Paste-time prewarm: ${config.prewarmPastedImages ? `on${editorInstalled ? "" : " (inactive โ another custom editor is active)"}` : "off"}`);
|
|
1095
|
+
lines.push(`Async pasted-path fallback: ${config.asyncClipboardHandoff ? "on" : "off"}`);
|
|
896
1096
|
lines.push(`maxTokens: ${config.maxTokens ?? "unbounded"} ยท cacheMax: ${config.cacheMax} ยท maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
|
|
897
1097
|
|
|
898
1098
|
const model = ctx.model;
|