pi-vision-handoff 0.3.1 → 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/README.md +155 -48
- package/package.json +4 -4
- package/src/dataloader.ts +208 -0
- package/src/describer.ts +320 -0
- package/src/dispose.ts +61 -0
- package/src/image.ts +274 -0
- package/src/index.ts +133 -7
- package/src/usage.ts +17 -1
- package/vision-handoff.ts +284 -229
package/README.md
CHANGED
|
@@ -26,20 +26,21 @@ The `pi-umans-provider` extension quietly solved this for GLM 5.1: a hardcoded "
|
|
|
26
26
|
|
|
27
27
|
- **Pick any vision-capable model** from your registry via an interactive picker — OpenAI, Anthropic, Google, Ollama, or any custom provider pi knows about.
|
|
28
28
|
- Your choice **persists** to `~/.pi/agent/extensions/pi-vision-handoff.json`.
|
|
29
|
-
- For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model and
|
|
29
|
+
- For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model and swaps the image block for its description text at the **`context`** event — which fires *before* pi-ai's `downgradeUnsupportedImages` can strip image blocks for non-vision models. This covers every image source: pasted/attached images, `read`-tool results, and custom extension-injected messages. (Read-tool images additionally keep the description + image in the stored `tool_result` for kitty inline rendering and `/resume`.)
|
|
30
30
|
- Works across all four image-block shapes pi uses — the three provider-transformed formats (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages) plus pi-ai's internal `{ type: "image", data, mimeType }` emitted by the `read` tool — detected by shape.
|
|
31
|
-
- Descriptions are **cached per image hash** (LRU), so the swap is instant by the time the request fires.
|
|
31
|
+
- Descriptions are **cached per image hash** (LRU) and produced **one batched vision call per image set** (dataloader-style: a prompt describing N images does not spin up N describer calls), so the swap is instant by the time the request fires.
|
|
32
32
|
|
|
33
33
|
No `settings.json` touched. No per-provider glue. Pick a describer once and every text-only model you own can suddenly see.
|
|
34
34
|
|
|
35
35
|
## Features
|
|
36
36
|
|
|
37
37
|
- **🎮 Interactive picker** — `/vision-handoff` opens a TUI listing every model, vision-capable ones first (👁), to choose your describer.
|
|
38
|
-
- **🖼️
|
|
38
|
+
- **🖼️ DataLoader-batched descriptions** — the `read` tools are `load()` callers: N parallel reads coalesce into ONE batched vision call (dispatched after the microtask cascade settles), awaited during the tool-result phase (free time) so the agent's next turn never blocks on the describer. Descriptions are ready before `context` fires, so the swap is a non-blocking cache hit.
|
|
39
|
+
- **🧹 Hides pi's "model does not support images" note** — on read results the extension strips pi's `[Current model does not support images…]` note from the text block (it's misleading once the handoff delivers the image's content as text), while keeping the image block for kitty inline rendering and `/resume`.
|
|
39
40
|
- **🔌 Provider-agnostic** — uses pi's own model execution machinery (`@earendil-works/pi-ai`'s `complete()`), so it works with any provider/configured model, including custom provider extensions.
|
|
40
41
|
- **🧠 Automatic targets** — by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
|
|
41
42
|
- **🗂️ Explicit overrides** — force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
|
|
42
|
-
- **⚡
|
|
43
|
+
- **⚡ Pre-warmed at paste-enter** — the moment you press enter, `before_agent_start` scans the prompt for pasted clipboard image temp-file paths (pi writes pasted images to `<tmpdir>/pi-clipboard-<uuid>.<ext>` 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.
|
|
43
44
|
- **🛡️ Graceful degradation** — no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
|
|
44
45
|
- **📊 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.
|
|
45
46
|
- **🔧 Tunable** — cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
|
|
@@ -71,7 +72,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
71
72
|
"visionModel": "openai/gpt-4o",
|
|
72
73
|
"autoHandoff": true,
|
|
73
74
|
"handoffModels": ["ollama/llava:13b"],
|
|
74
|
-
"maxTokens":
|
|
75
|
+
"maxTokens": null,
|
|
75
76
|
"cacheMax": 50,
|
|
76
77
|
"maxDescriptionLines": 0,
|
|
77
78
|
"prompt": "Describe this image exhaustively…",
|
|
@@ -85,7 +86,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
85
86
|
| `visionModel` | `null` | The describer, as `provider/id`. `null` = not configured (handoff inactive). |
|
|
86
87
|
| `autoHandoff` | `true` | Apply handoff to every model whose `input` does not include `image`. |
|
|
87
88
|
| `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
|
|
88
|
-
| `maxTokens` |
|
|
89
|
+
| `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. |
|
|
89
90
|
| `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
|
|
90
91
|
| `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. |
|
|
91
92
|
| `prompt` | _(built-in)_ | Override the describer system prompt. |
|
|
@@ -133,49 +134,142 @@ pi -e ./vision-handoff.ts
|
|
|
133
134
|
|
|
134
135
|
## How It Works
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
• fires BEFORE pi-ai's transformMessages can strip the image for
|
|
141
|
-
non-vision models (the strip would leave only a "(tool image
|
|
142
|
-
omitted)" placeholder — too late to describe)
|
|
143
|
-
• is this model a handoff target? (non-vision, or in handoffModels)
|
|
144
|
-
• for each {type:"image",...} block → describeImage() with your
|
|
145
|
-
chosen vision model
|
|
146
|
-
- complete() via pi-ai (resolves API key/headers/baseUrl)
|
|
147
|
-
- cached by sha256(mime + base64)
|
|
148
|
-
• AUGMENTS the stored result: inserts "[Image: <description>]" as a
|
|
149
|
-
text block BEFORE the kept image, returns {content} to pi
|
|
150
|
-
• the image stays in content → kitty renders it inline
|
|
151
|
-
• at provider time, pi-ai strips ONLY the image block for non-vision
|
|
152
|
-
models — the inserted description text passes through to the model
|
|
153
|
-
|
|
154
|
-
User-attached images (pasted into the prompt, not read via a tool):
|
|
137
|
+
The extension implements the **Facebook DataLoader pattern** for image
|
|
138
|
+
descriptions. The `read` tools are the `load()` callers; a per-image cache
|
|
139
|
+
memoizes promises; all `load()` calls in the same execution frame coalesce
|
|
140
|
+
into ONE batched vision call, dispatched after the microtask cascade settles.
|
|
155
141
|
|
|
156
142
|
→ before_agent_start
|
|
157
|
-
•
|
|
158
|
-
|
|
159
|
-
•
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
143
|
+
• captures this turn's user prompt (shared by every image description)
|
|
144
|
+
• binds turn context (vision model + abort signal) to the loader
|
|
145
|
+
• PRE-WARMS at paste-enter via the loader, two sources coalescing into
|
|
146
|
+
ONE batch:
|
|
147
|
+
1. attached image blocks (event.images) — vision-capable targets
|
|
148
|
+
2. pasted clipboard image FILE PATHS in the prompt text — the common
|
|
149
|
+
non-vision flow: pi writes each pasted image to
|
|
150
|
+
`<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the PATH as text
|
|
151
|
+
at the cursor, so on a non-vision model they arrive as path tokens
|
|
152
|
+
in `event.prompt`, NOT as `event.images`. The extension scans the
|
|
153
|
+
prompt for those temp paths (confined to the OS temp dir — a
|
|
154
|
+
prompt can't trick it into reading arbitrary files), reads the
|
|
155
|
+
files synchronously (keeps all load() calls in one batch frame →
|
|
156
|
+
one vision call), and `loadDescription()`s them — so the vision
|
|
157
|
+
call starts the INSTANT you press enter, concurrent with the
|
|
158
|
+
agent's first response, not waiting for it to `read` the files
|
|
159
|
+
→ read tool / tool_result (PRIMARY injection point)
|
|
160
|
+
• pi runs `read` calls in parallel (Promise.all); each read's
|
|
161
|
+
tool_result handler calls `loadDescription(img)` for its image
|
|
162
|
+
blocks and AWAITS the shared batch
|
|
163
|
+
• DataLoader: all `load()` calls in the same microtask frame land in
|
|
164
|
+
ONE batch → ONE `complete()` vision call for the whole read set
|
|
165
|
+
(dispatched after the microtask cascade via enqueuePostPromiseJob:
|
|
166
|
+
`Promise.resolve().then(() => process.nextTick(dispatch))`)
|
|
167
|
+
• awaits the shared batch — runs the describer during the tool-result
|
|
168
|
+
phase (free time: the agent is just waiting for tool results), so
|
|
169
|
+
the batch is COMPLETE before `context` fires → `context` is a
|
|
170
|
+
non-blocking cache hit, not a cold miss on the critical path
|
|
171
|
+
• does NOT mutate the result: the image stays in storage for kitty
|
|
172
|
+
inline rendering and `/resume`; the image→text swap happens in the
|
|
173
|
+
`context` hook (on the cloned LLM-bound payload only)
|
|
174
|
+
→ context (FALLBACK + swap — fires before each LLM call)
|
|
175
|
+
• catches image blocks that didn't go through `read`'s tool_result —
|
|
176
|
+
user-attached images, custom extension-injected messages — and
|
|
177
|
+
swaps read images too (cache hits from the tool_result priming)
|
|
178
|
+
• `loadDescription()` is a cache hit (warmed above) or queues into the
|
|
179
|
+
loader's current batch; swaps images for text in the cloned LLM-bound
|
|
180
|
+
payload (`emitContext` does a `structuredClone`), leaving storage
|
|
181
|
+
intact for kitty inline rendering and `/resume`
|
|
182
|
+
|
|
183
|
+
Because the describer runs during the **tool-result phase** (before the
|
|
184
|
+
agent's next turn), not during the `context` transform (the critical path
|
|
185
|
+
right before the LLM call), the agent gets described text immediately when
|
|
186
|
+
its turn starts — it never waits on the describer inline.
|
|
187
|
+
|
|
188
|
+
### Batching: the DataLoader
|
|
189
|
+
|
|
190
|
+
A single frame's image set is described with **one** vision-model call, not
|
|
191
|
+
one per image. `loadDescription(img)` is synchronous: on a cache miss it
|
|
192
|
+
pushes the image's key (hash) into the current batch and returns a memoized
|
|
193
|
+
promise; on a cache hit it returns the existing (in-flight or resolved)
|
|
194
|
+
promise. `enqueuePostPromiseJob` schedules dispatch after the microtask
|
|
195
|
+
cascade settles, so every `load()` caller in the frame registers its key
|
|
196
|
+
before the single vision call fires — exactly DataLoader's `getCurrentBatch`
|
|
197
|
+
+ `enqueuePostPromiseJob`. The batched call sends every uncached image in a
|
|
198
|
+
single user message with per-image `<<<IMAGE k>>> … <<<END>>>` delimiters; the
|
|
199
|
+
response is parsed back into per-image descriptions (keyed by
|
|
200
|
+
`sha256(mime + base64)` in the per-image cache). If the vision model ignores
|
|
201
|
+
the delimiter format and the batched response can't be split, each unparsed
|
|
202
|
+
image falls back to its own single-image `complete()` call **in parallel**
|
|
203
|
+
(no delimiters to cooperate with) — descriptions still arrive together. Only
|
|
204
|
+
when a per-image call itself genuinely fails (auth, timeout, empty) does that
|
|
205
|
+
image degrade to `[Image: description unavailable]`; one bad image never
|
|
206
|
+
voids the rest.
|
|
207
|
+
|
|
208
|
+
Because pi runs parallel `read` tool calls via `Promise.all` and fires the
|
|
209
|
+
`tool_result` event inside that `Promise.all` (via `agent.afterToolCall` →
|
|
210
|
+
`finalizeExecutedToolCall`), N parallel reads' `load()` calls share ONE
|
|
211
|
+
microtask frame → ONE batch → ONE vision call, all resolving together.
|
|
212
|
+
|
|
213
|
+
**Failures are never cached.**
|
|
214
|
+
|
|
215
|
+
**Failures are never cached.** A genuine describer failure returns
|
|
216
|
+
`[Image: description unavailable]` for that turn but is NOT written to the
|
|
217
|
+
per-image cache, so the next turn re-attempts (and surfaces the real error
|
|
218
|
+
via a `ctx.ui.notify` warning instead of serving a stuck placeholder). This
|
|
219
|
+
avoids a transient failure poisoning the cache for the rest of the session.
|
|
220
|
+
|
|
221
|
+
**Per-image-set warning.** The `context` hook fires before every LLM turn,
|
|
222
|
+
and describer failures aren't cached — so without dedup the same broken
|
|
223
|
+
vision model would re-warn every turn. Each distinct image is warned about
|
|
224
|
+
at most once per session (tracked by image hash, reset on `/resume`/new
|
|
225
|
+
session); subsequent turns for the same failing image degrade silently to
|
|
226
|
+
the placeholder instead of spamming.
|
|
227
|
+
|
|
228
|
+
**Timeout scales with batch size.** The describer generates an exhaustive
|
|
229
|
+
multi-paragraph description per image, and the call is batched (N images →
|
|
230
|
+
1 request). The timeout is `DESCRIBE_TIMEOUT_MS` (120s) plus a per-image
|
|
231
|
+
budget (45s × (imageCount − 1)), so a 5-image batch isn't held to the same
|
|
232
|
+
wall-clock budget as one image. A timeout surfaces as `describer timed out
|
|
233
|
+
after <N>s` rather than a misleading `stopReason "aborted"`.
|
|
234
|
+
|
|
235
|
+
**No silent truncation.** The describer prompt says "be exhaustive", so the
|
|
236
|
+
default `maxTokens` is **unset** — the describer uses the vision model's
|
|
237
|
+
declared max output (`model.maxTokens`) as the cap, rather than relying on a
|
|
238
|
+
provider's small omitted-default. That value is clamped so `maxTokens + 8192
|
|
239
|
+
<= contextWindow`: a model whose declared `maxTokens` equals its full
|
|
240
|
+
`contextWindow` (e.g. a custom provider that sets both to the same number)
|
|
241
|
+
would otherwise be rejected by the provider with a 400 (you can't request
|
|
242
|
+
output tokens equal to the entire context window when you also have input),
|
|
243
|
+
so the clamp subtracts a small input reserve. If the model still hits a token
|
|
244
|
+
limit (a cap you set, or the provider's hard output maximum), `stopReason`
|
|
245
|
+
becomes `"length"`; the describer appends a visible
|
|
246
|
+
`[... description truncated …]` marker to the (still useful) partial text
|
|
247
|
+
rather than letting a cut-off description pass as complete. For a batched call
|
|
248
|
+
the marker lands on the last image being emitted when the cap hit (the one cut
|
|
249
|
+
off mid-stream); earlier sections had `<<<END>>>` delimiters and are complete.
|
|
250
|
+
|
|
251
|
+
**Aborts propagate.** The `context` hook runs in the foreground (pi awaits
|
|
252
|
+
it before the LLM call), so a slow describer would also make aborting a turn
|
|
253
|
+
slow — pi has to wait for the transform to return. The hook therefore wires
|
|
254
|
+
the turn's abort signal (`ctx.signal`, the active run's `AbortController`)
|
|
255
|
+
into the describer's `complete()` call, so a user cancel kills the in-flight
|
|
256
|
+
vision request immediately. A deliberate abort is not warned about (it's
|
|
257
|
+
not a vision-model failure) and the LLM-bound payload is left untouched since
|
|
258
|
+
the turn is being torn down anyway.
|
|
168
259
|
|
|
169
260
|
### Image-block formats handled
|
|
170
261
|
|
|
171
|
-
| Hook | Image block shape |
|
|
172
|
-
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `
|
|
176
|
-
| `
|
|
262
|
+
| Hook | Image block shape | Handling |
|
|
263
|
+
|------|-------------------|----------|
|
|
264
|
+
| `context` (all messages) | `{ type: "image", data, mimeType }` (pi-ai internal) | undescribed → replaced with description text; already-described → dropped |
|
|
265
|
+
| `context` (all messages) | `{ type: "image_url", image_url: { url: "data:…" } }` (OpenAI Chat Completions) | detected by shape → replaced with `{ type: "text", text }` |
|
|
266
|
+
| `context` (all messages) | `{ type: "input_image", image_url: "data:…" }` (OpenAI Responses) | detected by shape → replaced with `{ type: "input_text", text }` |
|
|
267
|
+
| `context` (all messages) | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | detected by shape → replaced with `{ type: "text", text }` |
|
|
177
268
|
|
|
178
|
-
The describer call itself goes through pi's normal model machinery (`complete()`),
|
|
269
|
+
The describer call itself goes through pi's normal model machinery (`complete()`),
|
|
270
|
+
**not** the agent event loop — so it never re-triggers `context` (no recursion).
|
|
271
|
+
The `read` tool result keeps its image block untouched (kitty inline + `/resume`);
|
|
272
|
+
only the `context`-cloned LLM-bound payload has images swapped for text.
|
|
179
273
|
|
|
180
274
|
### Usage reporting
|
|
181
275
|
|
|
@@ -194,19 +288,24 @@ Record shape:
|
|
|
194
288
|
model: string, provider: string,
|
|
195
289
|
responseModel?: string, responseId?: string,
|
|
196
290
|
usage: Usage, // { input, output, cacheRead, cacheWrite, totalTokens, cost }
|
|
291
|
+
imageHash: string, // representative (first) member of the batch; sha256(mime + base64), first 32 hex chars
|
|
292
|
+
imageHashes?: string[], // present only for batched calls (length > 1): every image the call covered
|
|
293
|
+
model: string, provider: string,
|
|
294
|
+
responseModel?: string, responseId?: string,
|
|
295
|
+
usage: Usage, // { input, output, cacheRead, cacheWrite, totalTokens, cost }
|
|
197
296
|
// Present ONLY when Neuralwatt SSE energy comments were captured:
|
|
198
297
|
energyJoules?: number, costUsd?: number,
|
|
199
298
|
energyRaw?: object, mcrSessionRaw?: object, costRaw?: object,
|
|
200
299
|
}
|
|
201
300
|
```
|
|
202
301
|
|
|
203
|
-
Because `before_agent_start` fires
|
|
302
|
+
One record is emitted per **real** describer call (a batched call describing several images still emits a single record, with `imageHashes` listing every member so consumers can attribute the call's tokens/energy per image without double-counting). Because `before_agent_start` fires a batched describe fire-and-forget, the fetch interception is **refcounted** (installed only while ≥1 describe is in flight) and uses `AsyncLocalStorage` to route each teed response body to the describe call that issued it — so concurrent describes each attribute their own energy correctly without clobbering `globalThis.fetch`. When the vision model is a Neuralwatt model, `pi-neuralwatt-provider`'s own `streamNeuralwatt` tee nests on top and restores back to this interceptor; both tees read the same comment lines independently (the accepted duplication for easy filtering).
|
|
204
303
|
|
|
205
304
|
## Comparison with Alternatives
|
|
206
305
|
|
|
207
306
|
| Approach | Pros | Cons |
|
|
208
307
|
|----------|------|------|
|
|
209
|
-
| **pi-vision-handoff** (this) | Provider-agnostic; pick any describer; automatic for text-only models; cached; survives across providers | Adds one extra model call per
|
|
308
|
+
| **pi-vision-handoff** (this) | Provider-agnostic; pick any describer; automatic for text-only models; cached; batched (one call per image set); survives across providers | Adds one extra model call per image set per turn |
|
|
210
309
|
| Native vision on every model | Zero overhead | Not all models support it; you may be forced off your preferred coding model |
|
|
211
310
|
| Manually describing images | No extension | Tedious; lossy; kills the "paste a screenshot" workflow |
|
|
212
311
|
| The original `pi-umans-provider` handoff | Battle-tested | Hardcoded to `umans-flash` + UMANS models only |
|
|
@@ -225,15 +324,23 @@ pnpm lint:dead # Dead code detection (knip)
|
|
|
225
324
|
|
|
226
325
|
```
|
|
227
326
|
.
|
|
228
|
-
├── vision-handoff.ts #
|
|
327
|
+
├── vision-handoff.ts # Wiring layer: pi hooks, /vision-handoff command
|
|
229
328
|
├── src/
|
|
230
|
-
│ ├── index.ts # Config schema, read/write, image-block helpers (barrel)
|
|
329
|
+
│ ├── index.ts # Config schema, read/write, image-block helpers, batching (barrel)
|
|
330
|
+
│ ├── dataloader.ts # DescriptionLoader — DataLoader-batched descriptions (Disposable)
|
|
331
|
+
│ ├── describer.ts # Vision describer calls (runBatch / describeSingle) with `using` resource guards
|
|
332
|
+
│ ├── image.ts # Image hashing, MIME sniffing, clipboard-path reading
|
|
333
|
+
│ ├── dispose.ts # `Disposable` guard factories for `using` (fetch interceptor, timer, abort wire)
|
|
231
334
|
│ ├── usage.ts # Describer usage + Neuralwatt energy capture, fetch interceptor
|
|
232
335
|
│ └── vision-model-selector.ts # Interactive picker TUI component
|
|
233
336
|
├── __tests__/unit/
|
|
234
337
|
│ ├── config-dir.test.ts # Ensures getAgentDir() usage
|
|
235
338
|
│ ├── usage.test.ts # Energy parsing, usage records, concurrency-safe fetch routing
|
|
236
|
-
│
|
|
339
|
+
│ ├── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
|
|
340
|
+
│ ├── dataloader.test.ts # Batch coalescing, memoization, failure eviction, Disposable reset
|
|
341
|
+
│ ├── describer.test.ts # stopReason handling (length → truncation marker, aborted/error)
|
|
342
|
+
│ ├── image.test.ts # MIME sniffing, clipboard-path confinement, file reading
|
|
343
|
+
│ └── dispose.test.ts # `using` guards: fetch refcount, timeout, abort-wire propagation
|
|
237
344
|
├── package.json
|
|
238
345
|
├── tsconfig.json
|
|
239
346
|
├── knip.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Tom X Nguyen",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@earendil-works/pi-ai": "0.
|
|
35
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
36
|
-
"@earendil-works/pi-tui": "0.
|
|
34
|
+
"@earendil-works/pi-ai": "0.80.2",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "0.80.2",
|
|
36
|
+
"@earendil-works/pi-tui": "0.80.2",
|
|
37
37
|
"@types/node": "25.9.1",
|
|
38
38
|
"@vitest/coverage-v8": "4.1.7",
|
|
39
39
|
"knip": "6.14.1",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facebook DataLoader pattern for image descriptions.
|
|
3
|
+
*
|
|
4
|
+
* `loadDescription(img)` returns a memoized Promise for the description and
|
|
5
|
+
* pushes the image's key (hash) into the CURRENT batch. All `load()` calls in
|
|
6
|
+
* the same execution frame (+ its microtask cascade) coalesce into ONE batch
|
|
7
|
+
* object. Dispatch is scheduled after the microtask queue settles (via
|
|
8
|
+
* `enqueuePostPromiseJob`), so every load in the frame lands in the single
|
|
9
|
+
* batch before the ONE vision call fires. Each load()'s promise then resolves
|
|
10
|
+
* with its description.
|
|
11
|
+
*
|
|
12
|
+
* Mapping: the `read` tools are the `load()` callers. Their `tool_result`
|
|
13
|
+
* handler awaits the shared batch — so N parallel reads (pi runs `read` via
|
|
14
|
+
* `Promise.all`) block on the SAME single vision call and all resolve together,
|
|
15
|
+
* the descriptions landing in the tool results BEFORE the agent's next turn.
|
|
16
|
+
* The agent's tool-result wait is free time, so this adds zero latency to the
|
|
17
|
+
* critical path. `context` then sees text-described tool results (no image
|
|
18
|
+
* blocks to swap); any remaining images (user-attached, custom-injected) are
|
|
19
|
+
* cache hits.
|
|
20
|
+
*
|
|
21
|
+
* All mutable state (batch, cache, turn context) lives on the instance — no
|
|
22
|
+
* module-level globals — and the class implements `Disposable` so a `using`
|
|
23
|
+
* binding (or an explicit `reset()`) cleanly abandons an in-flight batch and
|
|
24
|
+
* clears turn context, e.g. on session reset.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
28
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import {
|
|
30
|
+
IMAGE_PLACEHOLDER_PREFIX,
|
|
31
|
+
IMAGE_PLACEHOLDER_SUFFIX,
|
|
32
|
+
wrapDescription,
|
|
33
|
+
type ExtractedImage,
|
|
34
|
+
type VisionHandoffConfig,
|
|
35
|
+
} from "./index.js";
|
|
36
|
+
import { imageHash } from "./image.js";
|
|
37
|
+
import { runBatch, describeSingle, type DescriberDeps } from "./describer.js";
|
|
38
|
+
|
|
39
|
+
/** Resolved when a description couldn't be obtained (graceful degradation).
|
|
40
|
+
* Failures are NOT cached, so the next turn re-attempts. */
|
|
41
|
+
export const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
|
|
42
|
+
|
|
43
|
+
interface DescriptionBatch {
|
|
44
|
+
keys: string[];
|
|
45
|
+
imgs: ExtractedImage[];
|
|
46
|
+
callbacks: { resolve: (v: string) => void; reject: (e: Error) => void }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Engine-provided resolver for the configured vision model. */
|
|
50
|
+
export interface VisionModelResolver {
|
|
51
|
+
(registry: ModelRegistry, ref: string): Model<Api> | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Dependencies the loader can't own itself (held by the engine). */
|
|
55
|
+
export interface LoaderDeps extends DescriberDeps {
|
|
56
|
+
/** Read the current config (reloaded on session_start / config writes). */
|
|
57
|
+
getConfig(): VisionHandoffConfig;
|
|
58
|
+
/** Resolve the configured vision model against a registry. */
|
|
59
|
+
resolveVisionModel: VisionModelResolver;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const resolvedMicrotask = Promise.resolve();
|
|
63
|
+
|
|
64
|
+
/** Defer `fn` until after the current microtask cascade settles, so every
|
|
65
|
+
* `loadDescription()` in the same frame coalesces into one batch before the
|
|
66
|
+
* single vision call fires. Mirrors DataLoader's enqueuePostPromiseJob: a
|
|
67
|
+
* Promise Job enqueues a global Job (process.nextTick), guaranteeing dispatch
|
|
68
|
+
* runs after "PromiseJobs" ends — after all `load()` callers in the frame
|
|
69
|
+
* have registered their keys. */
|
|
70
|
+
function enqueuePostPromiseJob(fn: () => void): void {
|
|
71
|
+
resolvedMicrotask.then(() => process.nextTick(fn));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class DescriptionLoader implements Disposable {
|
|
75
|
+
private readonly cache = new Map<string, Promise<string>>();
|
|
76
|
+
private batch: DescriptionBatch | null = null;
|
|
77
|
+
private dispatchScheduled = false;
|
|
78
|
+
private turnModelRegistry: ModelRegistry | null = null;
|
|
79
|
+
private turnVisionModel: Model<Api> | null = null;
|
|
80
|
+
private turnSignal: AbortSignal | undefined;
|
|
81
|
+
private pendingTurnPrompt = "";
|
|
82
|
+
|
|
83
|
+
constructor(private readonly deps: LoaderDeps) {}
|
|
84
|
+
|
|
85
|
+
/** Bind the turn context (model registry, resolved vision model, abort signal)
|
|
86
|
+
* for the loader's next dispatch. Called from every handler that may trigger
|
|
87
|
+
* `loadDescription()`. */
|
|
88
|
+
bindTurnContext(ctx: { modelRegistry: ModelRegistry; signal?: AbortSignal }): void {
|
|
89
|
+
this.turnModelRegistry = ctx.modelRegistry;
|
|
90
|
+
this.turnSignal = ctx.signal;
|
|
91
|
+
const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, this.deps.getConfig().visionModel!);
|
|
92
|
+
if (resolved) this.turnVisionModel = resolved;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Capture this turn's user prompt so every image in the turn is described in
|
|
96
|
+
* the same request context. */
|
|
97
|
+
setPendingTurnPrompt(prompt: string): void {
|
|
98
|
+
this.pendingTurnPrompt = prompt;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Load an image's description. Returns a memoized Promise: cache hits return
|
|
102
|
+
* the existing (in-flight or resolved) promise; misses push the image's key
|
|
103
|
+
* into the current batch and schedule dispatch. Callers in the same frame
|
|
104
|
+
* share ONE batch → ONE vision call. Failures resolve to {@link UNAVAILABLE}
|
|
105
|
+
* and are NOT cached, so the next turn re-attempts. */
|
|
106
|
+
loadDescription(img: ExtractedImage): Promise<string> {
|
|
107
|
+
const hash = imageHash(img.mimeType, img.data);
|
|
108
|
+
const cached = this.cache.get(hash);
|
|
109
|
+
if (cached) return cached;
|
|
110
|
+
|
|
111
|
+
if (!this.batch) {
|
|
112
|
+
this.batch = { keys: [], imgs: [], callbacks: [] };
|
|
113
|
+
this.scheduleDispatch();
|
|
114
|
+
}
|
|
115
|
+
const batch = this.batch;
|
|
116
|
+
let idx = batch.keys.indexOf(hash);
|
|
117
|
+
if (idx === -1) {
|
|
118
|
+
batch.keys.push(hash);
|
|
119
|
+
batch.imgs.push(img);
|
|
120
|
+
idx = batch.keys.length - 1;
|
|
121
|
+
} else {
|
|
122
|
+
// Same image loaded twice in the frame — share one cache slot, but give
|
|
123
|
+
// this caller its own promise.
|
|
124
|
+
batch.imgs[idx] = img;
|
|
125
|
+
}
|
|
126
|
+
const promise = new Promise<string>((resolve, reject) => {
|
|
127
|
+
batch.callbacks.push({ resolve, reject });
|
|
128
|
+
});
|
|
129
|
+
const cfg = this.deps.getConfig();
|
|
130
|
+
if (this.cache.size >= cfg.cacheMax) {
|
|
131
|
+
const firstKey = this.cache.keys().next().value;
|
|
132
|
+
if (firstKey !== undefined) this.cache.delete(firstKey);
|
|
133
|
+
}
|
|
134
|
+
this.cache.set(hash, promise);
|
|
135
|
+
return promise;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Abandon any in-flight batch and clear turn context. Used on session reset
|
|
139
|
+
* (the batch's turn is gone) — also exposed via `[Symbol.dispose]` so a
|
|
140
|
+
* `using` binding can scope a loader lifetime. The description cache is
|
|
141
|
+
* preserved across turns (descriptions are stable per hash). */
|
|
142
|
+
reset(): void {
|
|
143
|
+
this.batch = null;
|
|
144
|
+
this.dispatchScheduled = false;
|
|
145
|
+
this.turnModelRegistry = null;
|
|
146
|
+
this.turnVisionModel = null;
|
|
147
|
+
this.turnSignal = undefined;
|
|
148
|
+
this.pendingTurnPrompt = "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
[Symbol.dispose](): void {
|
|
152
|
+
this.reset();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private scheduleDispatch(): void {
|
|
156
|
+
if (this.dispatchScheduled) return;
|
|
157
|
+
this.dispatchScheduled = true;
|
|
158
|
+
enqueuePostPromiseJob(() => this.dispatchBatch());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Dispatch the current batch: ONE batched `runBatch` vision call for every
|
|
162
|
+
* key collected this frame, then resolve each load()'s promise with its
|
|
163
|
+
* description (or {@link UNAVAILABLE} on failure). Failures are evicted from
|
|
164
|
+
* the cache so the next turn re-attempts. */
|
|
165
|
+
private async dispatchBatch(): Promise<void> {
|
|
166
|
+
this.dispatchScheduled = false;
|
|
167
|
+
const batch = this.batch;
|
|
168
|
+
this.batch = null;
|
|
169
|
+
if (!batch || batch.keys.length === 0) return;
|
|
170
|
+
if (!this.turnVisionModel || !this.turnModelRegistry) {
|
|
171
|
+
for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (this.turnSignal?.aborted) {
|
|
175
|
+
for (let i = 0; i < batch.keys.length; i++) {
|
|
176
|
+
this.cache.delete(batch.keys[i]);
|
|
177
|
+
batch.callbacks[i].resolve(UNAVAILABLE);
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.deps.setLastError(null); // clear before a fresh attempt
|
|
182
|
+
const misses = batch.keys.map((k, i) => ({ hash: k, img: batch.imgs[i] }));
|
|
183
|
+
const cfg = this.deps.getConfig();
|
|
184
|
+
const parsed = await runBatch(
|
|
185
|
+
misses,
|
|
186
|
+
this.pendingTurnPrompt,
|
|
187
|
+
this.turnVisionModel,
|
|
188
|
+
this.turnModelRegistry,
|
|
189
|
+
cfg,
|
|
190
|
+
this.deps,
|
|
191
|
+
this.turnSignal,
|
|
192
|
+
);
|
|
193
|
+
for (let i = 0; i < batch.keys.length; i++) {
|
|
194
|
+
const raw = parsed.get(batch.keys[i]);
|
|
195
|
+
if (raw) {
|
|
196
|
+
const final = wrapDescription(raw, cfg);
|
|
197
|
+
this.cache.set(batch.keys[i], Promise.resolve(final));
|
|
198
|
+
batch.callbacks[i].resolve(final);
|
|
199
|
+
} else {
|
|
200
|
+
// Genuine failure — do NOT cache; next turn re-attempts (and surfaces
|
|
201
|
+
// the real error). Resolve (not reject) with UNAVAILABLE to match the
|
|
202
|
+
// graceful-degradation contract and avoid unhandled rejections.
|
|
203
|
+
this.cache.delete(batch.keys[i]);
|
|
204
|
+
batch.callbacks[i].resolve(UNAVAILABLE);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|