@bismawy/pi-vision-watcher 1.0.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tom X Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # 👁️ pi-vision-watcher
2
+
3
+ **Give text-only [pi](https://github.com/earendil-works/pi-coding-agent) models vision**
4
+
5
+ Describe images using an authenticated vision model of your choice, then seamlessly hand off the text descriptions to non-vision models.
6
+
7
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
8
+ [![npm](https://img.shields.io/npm/v/@bismawy/pi-vision-watcher)](https://www.npmjs.com/package/@bismawy/pi-vision-watcher)
9
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
10
+
11
+ ---
12
+
13
+ ## The Problem
14
+
15
+ Some of the best coding models are text-only. When you attach a screenshot, diagram, or UI mock, they either ignore it or fail the request entirely. Switching models just to read an image interrupts your workflow.
16
+
17
+ ## The Solution
18
+
19
+ `pi-vision-watcher` bridges this gap automatically:
20
+ - **Interactive Picker:** Pick any vision model from your authenticated providers with `/vision-watcher`.
21
+ - **Automatic Handoff:** Whenever a non-vision model receives an image (via paste, attachment, or the `read` tool), the image is described behind the scenes and swapped for rich descriptive text before reaching the model.
22
+ - **Batched & Cached:** Uses a DataLoader pattern so multiple images in a turn coalesce into a **single batched vision request**, cached by SHA-256 hash.
23
+
24
+ ---
25
+
26
+ ## ✨ Features
27
+
28
+ - 🎯 **Connected-Only Model Picker** — `/vision-watcher` filters out unconfigured providers, showing only models you actually have credentials for (`/login`, `models.json`, or environment variables). Vision-capable models are highlighted with 👁️.
29
+ - ⚡ **DataLoader Batching** — Multiple images from parallel `read` calls or multi-image attachments merge into ONE batched vision call during the tool-result phase, eliminating latency bottlenecks.
30
+ - 🧠 **Thinking & Reasoning Support** — Configure reasoning effort (`/vision-watcher thinking <level>`) for reasoning-capable vision models (e.g. OpenAI o-series, Claude, DeepSeek).
31
+ - 🔄 **Fallback Chains** — Specify backup vision models that automatically take over if your primary describer is unavailable or encounters rate limits.
32
+ - 🚀 **Paste-Time Prewarm (Opt-in)** — Describe pasted images the moment the path lands in the prompt editor before you even press Enter.
33
+ - 📬 **Async Clipboard Fallback (Opt-in)** — Races direct reads against asynchronous description delivery to prevent stalling.
34
+ - 💾 **LRU Hash Caching** — Prevents duplicate calls for identical images across conversation turns.
35
+ - 🛡️ **Graceful Degradation** — Never crashes your agent turn. If a description fails, a clean `[Image: description unavailable]` placeholder is provided and logged to `~/.pi/agent/logs/pi-vision-watcher/errors.log`.
36
+
37
+ ---
38
+
39
+ ## 📦 Install
40
+
41
+ ```bash
42
+ pi install npm:@bismawy/pi-vision-watcher
43
+ ```
44
+
45
+ Alternatively, install directly from GitHub:
46
+
47
+ ```bash
48
+ pi install git:github.com/bismawy/pi-vision-watcher
49
+ ```
50
+
51
+ Then run `/reload` in Pi (or restart Pi).
52
+
53
+ ---
54
+
55
+ ## 🎮 Usage
56
+
57
+ ### Interactive Commands
58
+
59
+ | Command | Description |
60
+ |---|---|
61
+ | `/vision-watcher` | Open the interactive TUI picker to select your vision model |
62
+ | `/vision-watcher model <provider/id>` | Set the vision describer model directly |
63
+ | `/vision-watcher status` | View active configuration and handoff status |
64
+ | `/vision-watcher enable` / `disable` | Toggle extension on or off |
65
+ | `/vision-watcher auto on` / `off` | Toggle automatic handoff for all non-vision models |
66
+ | `/vision-watcher add <provider/id>` | Force handoff for a specific model (e.g. weak vision models) |
67
+ | `/vision-watcher remove <provider/id>` | Remove model from forced handoff list |
68
+ | `/vision-watcher thinking <level>` | Set describer thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) |
69
+ | `/vision-watcher prewarm on` / `off` | Enable paste-time prewarming in TUI editor |
70
+ | `/vision-watcher fallback on` / `off` | Enable async pasted-path description injection |
71
+ | `/vision-watcher clear` | Clear configured vision model |
72
+ | `/vision-watcher help` | Display full command reference |
73
+
74
+ ---
75
+
76
+ ## ⚙️ Configuration
77
+
78
+ Configuration is stored at `~/.pi/agent/extensions/pi-vision-watcher.json`:
79
+
80
+ ```json
81
+ {
82
+ "enabled": true,
83
+ "visionModel": "openai/gpt-4o",
84
+ "fallbackModels": [],
85
+ "autoHandoff": true,
86
+ "handoffModels": [],
87
+ "thinking": false,
88
+ "thinkingLevel": "medium",
89
+ "prewarmPastedImages": false,
90
+ "asyncClipboardHandoff": false,
91
+ "maxTokens": null,
92
+ "cacheMax": 50,
93
+ "maxDescriptionLines": 0
94
+ }
95
+ ```
96
+
97
+ | Field | Default | Description |
98
+ |---|---|---|
99
+ | `enabled` | `true` | Master switch for vision handoff. |
100
+ | `visionModel` | `null` | Primary describer as `provider/id` (`null` = handoff inactive). |
101
+ | `fallbackModels` | `[]` | List of fallback `provider/id` models tried in order if primary fails. |
102
+ | `autoHandoff` | `true` | Automatically apply handoff to all models lacking native vision. |
103
+ | `handoffModels` | `[]` | Additional models forced to receive handoff even if vision-capable. |
104
+ | `thinking` / `thinkingLevel` | `false` / `"medium"` | Reasoning effort for vision models that support thinking. |
105
+ | `prewarmPastedImages` | `false` | Describe images immediately upon pasting into the prompt. |
106
+ | `asyncClipboardHandoff` | `false` | Asynchronous injection fallback for pasted image paths. |
107
+ | `maxTokens` | `null` | Output token cap for descriptions (`null` = model default). |
108
+ | `cacheMax` | `50` | Maximum number of described images cached per session. |
109
+ | `maxDescriptionLines` | `0` | Truncate description lines (`0` = unbounded). |
110
+
111
+ ---
112
+
113
+ ## 🔍 Troubleshooting & Logs
114
+
115
+ - **Error Logs:** Detailed failure traces, timestamps, and config snapshots are recorded in `~/.pi/agent/logs/pi-vision-watcher/errors.log`.
116
+ - **Transient Retries:** Failed descriptions are never cached permanently — the next turn automatically re-attempts description generation.
117
+
118
+ ---
119
+
120
+ ## 🛠️ Development
121
+
122
+ ```bash
123
+ pnpm install
124
+ pnpm test # Run Vitest unit tests
125
+ pnpm typecheck # Run TypeScript type check
126
+ ```
127
+
128
+ ---
129
+
130
+ ## 📜 Credits & License
131
+
132
+ `pi-vision-watcher` is inspired by and forked from [`pi-vision-handoff`](https://github.com/monotykamary/pi-vision-handoff) by [Tom X Nguyen](https://github.com/monotykamary) (originating from the concept in `pi-umans-provider`).
133
+
134
+ **Key Enhancements:**
135
+ - Filters picker to only authenticated/connected models.
136
+ - Added thinking & reasoning controls for modern reasoning vision models.
137
+ - Multi-model fallback chain support.
138
+ - Streamlined settings and UI badging.
139
+
140
+ Released under the [MIT License](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@bismawy/pi-vision-watcher",
3
+ "version": "1.0.7",
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
+ "type": "module",
6
+ "author": "bismawy",
7
+ "license": "MIT",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/bismawy/pi-vision-watcher.git"
14
+ },
15
+ "homepage": "https://github.com/bismawy/pi-vision-watcher#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/bismawy/pi-vision-watcher/issues"
18
+ },
19
+ "keywords": [
20
+ "pi-package",
21
+ "pi-extension",
22
+ "pi-vision-watcher",
23
+ "pi-vision-handoff",
24
+ "pi",
25
+ "pi-coding-agent",
26
+ "extension",
27
+ "vision",
28
+ "image",
29
+ "handoff",
30
+ "multimodal",
31
+ "model-selector",
32
+ "accessibility"
33
+ ],
34
+ "files": [
35
+ "*.ts",
36
+ "src/",
37
+ "README.md"
38
+ ],
39
+ "scripts": {
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "test:coverage": "vitest run --coverage",
43
+ "typecheck": "tsc --noEmit",
44
+ "lint:dead": "knip --no-gitignore"
45
+ },
46
+ "devDependencies": {
47
+ "@earendil-works/pi-ai": "0.84.2",
48
+ "@earendil-works/pi-coding-agent": "0.84.2",
49
+ "@earendil-works/pi-tui": "0.84.2",
50
+ "@types/node": "25.9.1",
51
+ "@vitest/coverage-v8": "4.1.7",
52
+ "knip": "6.14.1",
53
+ "typescript": "6.0.3",
54
+ "vitest": "4.1.7"
55
+ },
56
+ "pi": {
57
+ "extensions": [
58
+ "./vision-watcher.ts"
59
+ ],
60
+ "image": "https://raw.githubusercontent.com/bismawy/pi-vision-watcher/main/assets/screenshot.png"
61
+ },
62
+ "peerDependencies": {
63
+ "@earendil-works/pi-ai": "*",
64
+ "@earendil-works/pi-coding-agent": "*",
65
+ "@earendil-works/pi-tui": "*"
66
+ },
67
+ "packageManager": "pnpm@11.20.0"
68
+ }
@@ -0,0 +1,383 @@
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 via `setImmediate` (see `enqueuePostPromiseJob`),
8
+ * so every load in the frame — and every load from a separate I/O callback in
9
+ * the same poll iteration — lands in the single batch before the ONE vision
10
+ * call fires. Each load()'s promise then resolves 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 coalesce into the SAME
14
+ * single vision call and all resolve together, the descriptions landing in the
15
+ * tool results BEFORE the agent's next turn. pi fires each read's `tool_result`
16
+ * as that read's I/O completes (poll phase); the loader's `setImmediate`
17
+ * dispatch defers to the check phase, AFTER the whole poll iteration, so reads
18
+ * completing together (the common case for cached local files) land in ONE
19
+ * batch — and reads completing in separate iterations get separate calls, but
20
+ * always in parallel, never sequential. The agent's tool-result wait is free
21
+ * time, so this adds zero latency to the critical path. `context` then sees
22
+ * text-described tool results (no image blocks to swap); any remaining images
23
+ * (user-attached, custom-injected) are cache hits.
24
+ *
25
+ * All mutable state (batch, cache, turn context) lives on the instance — no
26
+ * module-level globals — and the class implements `Disposable` so a `using`
27
+ * binding (or an explicit `reset()`) cleanly abandons an in-flight batch and
28
+ * clears turn context, e.g. on session reset.
29
+ */
30
+
31
+ import type { Api, Model } from "@earendil-works/pi-ai";
32
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
33
+ import {
34
+ IMAGE_PLACEHOLDER_PREFIX,
35
+ IMAGE_PLACEHOLDER_SUFFIX,
36
+ wrapDescription,
37
+ type ExtractedImage,
38
+ type VisionHandoffConfig,
39
+ } from "./index.js";
40
+ import { imageHash } from "./image.js";
41
+ import { runBatch, describeSingle, type DescriberDeps } from "./describer.js";
42
+
43
+ /** Resolved when a description couldn't be obtained (graceful degradation).
44
+ * Failures are NOT cached, so the next turn re-attempts. */
45
+ export const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
46
+
47
+ interface DescriptionBatch {
48
+ keys: string[];
49
+ imgs: ExtractedImage[];
50
+ /** One per `loadDescription()` call. A duplicate load (same hash, but its
51
+ * first cache entry was evicted mid-frame so it couldn't short-circuit on
52
+ * the cache) pushes a second callback for an existing key — so
53
+ * `callbacks.length` can exceed `keys.length`. `dispatchBatch` resolves by
54
+ * hash so every callback is reached. */
55
+ callbacks: { hash: string; resolve: (v: string) => void; reject: (e: Error) => void }[];
56
+ }
57
+
58
+ /** Engine-provided resolver for the configured vision model. */
59
+ export interface VisionModelResolver {
60
+ (registry: ModelRegistry, ref: string): Model<Api> | null;
61
+ }
62
+
63
+ /** Dependencies the loader can't own itself (held by the engine). */
64
+ export interface LoaderDeps extends DescriberDeps {
65
+ /** Read the current config (reloaded on session_start / config writes). */
66
+ getConfig(): VisionHandoffConfig;
67
+ /** Resolve the configured vision model against a registry. */
68
+ resolveVisionModel: VisionModelResolver;
69
+ /** Backoff (ms) before retrying a totally-failed describer batch. Defaults
70
+ * to {@link DESCRIBE_RETRY_BACKOFF_MS} when unset; 0 skips the wait (tests). */
71
+ retryBackoffMs?: number;
72
+ }
73
+
74
+ /** Defer `fn` to the next check phase (`setImmediate`), so every
75
+ * `loadDescription()` — whether called from sync code, a microtask cascade,
76
+ * or a separate I/O callback in the same poll iteration — coalesces into one
77
+ * batch before the single vision call fires.
78
+ *
79
+ * Why `setImmediate` and not DataLoader's classic `process.nextTick`:
80
+ * nextTick drains between I/O callbacks in the poll phase, so dispatch would
81
+ * fire after the first parallel `read`'s `tool_result` but before the
82
+ * second's — splitting N reads into N single-image calls. `setImmediate` runs
83
+ * in the check phase, AFTER the whole poll phase, so all `tool_result`
84
+ * handlers that fire in one poll iteration land in ONE batch. The check phase
85
+ * also runs after the microtask queue drains, so loads issued from a `.then`
86
+ * cascade (e.g. the clipboard pre-warm) still coalesce. */
87
+ function enqueuePostPromiseJob(fn: () => void): void {
88
+ setImmediate(fn);
89
+ }
90
+
91
+ /** Default backoff (ms) before retrying a totally-failed describer batch. A
92
+ * short backoff covers a transient provider blip (network hiccup, momentary
93
+ * 429) without adding meaningful latency to the tool-result phase (free
94
+ * time). Generous enough to let a rate-limited provider recover, short enough
95
+ * that a genuinely broken vision model doesn't stall a turn. */
96
+ const DESCRIBE_RETRY_BACKOFF_MS = 500;
97
+
98
+ /** Resolve after `ms`, or immediately if `signal` is already aborted or aborts
99
+ * during the wait. Used by the retry backoff so a user cancel (ESC) doesn't
100
+ * wait the full backoff before the retry is skipped. */
101
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
102
+ return new Promise((resolve) => {
103
+ if (ms <= 0 || signal.aborted) return resolve();
104
+ const onAbort = () => {
105
+ clearTimeout(timer);
106
+ resolve();
107
+ };
108
+ const timer = setTimeout(() => {
109
+ signal.removeEventListener("abort", onAbort);
110
+ resolve();
111
+ }, ms);
112
+ signal.addEventListener("abort", onAbort, { once: true });
113
+ });
114
+ }
115
+
116
+ export class DescriptionLoader implements Disposable {
117
+ private readonly cache = new Map<string, Promise<string>>();
118
+ private batch: DescriptionBatch | null = null;
119
+ private dispatchScheduled = false;
120
+ private turnModelRegistry: ModelRegistry | null = null;
121
+ private turnVisionModel: Model<Api> | null = null;
122
+ /** Turn-level abort controller. In-flight describer batches (`runBatch`)
123
+ * are wired to `turnAbortController.signal` so a user cancel (ESC) aborts
124
+ * them EVEN WHEN the batch was dispatched before the run's live abort
125
+ * signal existed — the `before_agent_start` / paste-time prewarm case,
126
+ * where `ctx.signal` is `undefined` (the agent run hasn't started yet).
127
+ * The run's live signal, once it arrives via `bindTurnContext`, is forwarded
128
+ * into `turnAbortController.abort()`. Because `runBatch` holds a reference
129
+ * to this controller's signal (not a snapshot of `ctx.signal`), a batch
130
+ * that started during prewarm becomes abortable the instant a later
131
+ * `bindTurnContext` (from `tool_result`/`context`) brings the live signal.
132
+ * Reset per turn via {@link resetTurnAbort} so a previous turn's cancel
133
+ * can't poison the next turn's prewarm. */
134
+ private turnAbortController = new AbortController();
135
+ /** The run signal currently forwarded into {@link turnAbortController}, so
136
+ * the abort listener is attached at most once per signal (the same signal
137
+ * is bound by every `tool_result`/`context` event in a turn). */
138
+ private wiredSignal: AbortSignal | undefined;
139
+ private pendingTurnPrompt = "";
140
+
141
+ constructor(private readonly deps: LoaderDeps) {}
142
+
143
+ /** Bind the turn context (model registry, resolved vision model, abort
144
+ * signal) for the loader's next dispatch. Called from every handler that
145
+ * may trigger `loadDescription()`.
146
+ *
147
+ * The abort signal is forwarded into the loader's {@link turnAbortController}
148
+ * rather than stored directly. Storing `ctx.signal` directly would leave a
149
+ * prewarm-dispatched batch (started in `before_agent_start`, where
150
+ * `ctx.signal` is `undefined` because the run hasn't started) with no abort
151
+ * wire — ESC couldn't cancel it, so the `tool_result` handler would only
152
+ * discard the result AFTER the vision call ran to completion. Forwarding
153
+ * the live signal into a stable, loader-owned controller lets an in-flight
154
+ * prewarm batch be aborted the moment the live signal arrives. */
155
+ bindTurnContext(ctx: { modelRegistry: ModelRegistry; signal?: AbortSignal }): void {
156
+ this.turnModelRegistry = ctx.modelRegistry;
157
+ const cfg = this.deps.getConfig();
158
+ const resolved = this.deps.resolveVisionModel(ctx.modelRegistry, cfg.visionModel!);
159
+ if (resolved) {
160
+ this.turnVisionModel = resolved;
161
+ } else {
162
+ // Primary unresolvable (typo, removed model): pick the first resolvable
163
+ // fallback so failover still works instead of leaving the loader dead.
164
+ // Call-time failover in dispatchBatch covers the case where the primary
165
+ // RESOLVES but fails at call time.
166
+ for (const ref of cfg.fallbackModels ?? []) {
167
+ const fb = this.deps.resolveVisionModel(ctx.modelRegistry, ref);
168
+ if (fb) {
169
+ this.turnVisionModel = fb;
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ const signal = ctx.signal;
175
+ if (!signal) return;
176
+ if (signal.aborted) {
177
+ this.turnAbortController.abort();
178
+ this.wiredSignal = signal;
179
+ return;
180
+ }
181
+ if (signal === this.wiredSignal) return; // already forwarded this signal
182
+ this.wiredSignal = signal;
183
+ signal.addEventListener("abort", () => this.turnAbortController.abort(), { once: true });
184
+ }
185
+
186
+ /** Reset the turn-level abort controller for a fresh turn. Call at turn
187
+ * boundaries (`before_agent_start`, paste-time prewarm) so a previous turn's
188
+ * cancel doesn't leave {@link turnAbortController} aborted — which would
189
+ * make every subsequent dispatch short-circuit to UNAVAILABLE. A
190
+ * non-aborted controller is reused (avoids orphaning an in-flight paste
191
+ * prewarm holding its signal); only an aborted one is replaced. `wiredSignal`
192
+ * is always cleared so the next live signal re-wires. Safe at a real turn
193
+ * boundary, where the prior turn's batch has settled. */
194
+ resetTurnAbort(): void {
195
+ if (this.turnAbortController.signal.aborted) {
196
+ this.turnAbortController = new AbortController();
197
+ }
198
+ this.wiredSignal = undefined;
199
+ }
200
+
201
+ /** Capture this turn's user prompt so every image in the turn is described in
202
+ * the same request context. */
203
+ setPendingTurnPrompt(prompt: string): void {
204
+ this.pendingTurnPrompt = prompt;
205
+ }
206
+
207
+ /** Load an image's description. Returns a memoized Promise: cache hits return
208
+ * the existing (in-flight or resolved) promise; misses push the image's key
209
+ * into the current batch and schedule dispatch. Callers in the same frame
210
+ * share ONE batch → ONE vision call. Failures resolve to {@link UNAVAILABLE}
211
+ * and are NOT cached, so the next turn re-attempts. */
212
+ loadDescription(img: ExtractedImage): Promise<string> {
213
+ const hash = imageHash(img.mimeType, img.data);
214
+ const cached = this.cache.get(hash);
215
+ if (cached) return cached;
216
+
217
+ if (!this.batch) {
218
+ this.batch = { keys: [], imgs: [], callbacks: [] };
219
+ this.scheduleDispatch();
220
+ }
221
+ const batch = this.batch;
222
+ let idx = batch.keys.indexOf(hash);
223
+ if (idx === -1) {
224
+ batch.keys.push(hash);
225
+ batch.imgs.push(img);
226
+ idx = batch.keys.length - 1;
227
+ } else {
228
+ // Same image loaded twice in the frame (the first load's cache entry was
229
+ // evicted mid-frame, else the second load would have short-circuited on
230
+ // the cache). Share the one key/image slot but give this caller its own
231
+ // promise — dispatch resolves it by hash alongside the first caller's.
232
+ batch.imgs[idx] = img;
233
+ }
234
+ const promise = new Promise<string>((resolve, reject) => {
235
+ batch.callbacks.push({ hash, resolve, reject });
236
+ });
237
+ const cfg = this.deps.getConfig();
238
+ if (this.cache.size >= cfg.cacheMax) {
239
+ const firstKey = this.cache.keys().next().value;
240
+ if (firstKey !== undefined) this.cache.delete(firstKey);
241
+ }
242
+ this.cache.set(hash, promise);
243
+ return promise;
244
+ }
245
+
246
+ /** Abandon any in-flight batch and clear turn context. Used on session reset
247
+ * (the batch's turn is gone) — also exposed via `[Symbol.dispose]` so a
248
+ * `using` binding can scope a loader lifetime. The description cache is
249
+ * preserved across turns (descriptions are stable per hash). */
250
+ reset(): void {
251
+ this.batch = null;
252
+ this.dispatchScheduled = false;
253
+ this.turnModelRegistry = null;
254
+ this.turnVisionModel = null;
255
+ // Fresh controller on session reset (no in-flight batch to orphan).
256
+ this.turnAbortController = new AbortController();
257
+ this.wiredSignal = undefined;
258
+ this.pendingTurnPrompt = "";
259
+ }
260
+
261
+ [Symbol.dispose](): void {
262
+ this.reset();
263
+ }
264
+
265
+ private scheduleDispatch(): void {
266
+ if (this.dispatchScheduled) return;
267
+ this.dispatchScheduled = true;
268
+ enqueuePostPromiseJob(() => this.dispatchBatch());
269
+ }
270
+
271
+ /** Dispatch the current batch: ONE batched `runBatch` vision call for every
272
+ * key collected this frame, then resolve each load()'s promise with its
273
+ * description (or {@link UNAVAILABLE} on failure). Failures are evicted from
274
+ * the cache so the next turn re-attempts.
275
+ *
276
+ * Results are resolved BY HASH and fanned out to EVERY callback: a batch can
277
+ * hold more callbacks than keys when the same image was loaded twice in one
278
+ * frame (its first cache entry was evicted mid-frame, so the second load
279
+ * pushed a second callback for the same hash). Indexing callbacks by key
280
+ * would skip those duplicates and hang their promises; iterating callbacks
281
+ * and looking up each one's hash fans the one result to all of them. */
282
+ private async dispatchBatch(): Promise<void> {
283
+ this.dispatchScheduled = false;
284
+ const batch = this.batch;
285
+ this.batch = null;
286
+ if (!batch || batch.keys.length === 0) return;
287
+ if (!this.turnVisionModel || !this.turnModelRegistry) {
288
+ for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
289
+ return;
290
+ }
291
+ if (this.turnAbortController.signal.aborted) {
292
+ for (const key of batch.keys) this.cache.delete(key);
293
+ for (const cb of batch.callbacks) cb.resolve(UNAVAILABLE);
294
+ return;
295
+ }
296
+ this.deps.setLastError(null); // clear before a fresh attempt
297
+ const misses = batch.keys.map((k, i) => ({ hash: k, img: batch.imgs[i] }));
298
+ const cfg = this.deps.getConfig();
299
+ let parsed = await runBatch(
300
+ misses,
301
+ this.pendingTurnPrompt,
302
+ this.turnVisionModel,
303
+ this.turnModelRegistry,
304
+ cfg,
305
+ this.deps,
306
+ this.turnAbortController.signal,
307
+ );
308
+ // Retry once on a TOTAL batch failure (the batched call itself failed:
309
+ // auth, network, timeout, empty, or stopReason "error"). A transient blip
310
+ // would otherwise cost the agent a full turn — UNAVAILABLE this turn, not
311
+ // cached, re-attempted next turn. Retrying the whole batch recovers it
312
+ // within the same tool-result phase (free time). Skip when the turn was
313
+ // cancelled (ESC); an auth failure re-fails cheaply (runBatch re-checks the
314
+ // API key before the vision call), so no vision call is wasted on a
315
+ // permanent auth error. Partial failures (some images described) are left
316
+ // as-is — only a totally-empty result retries.
317
+ if (parsed.size === 0 && misses.length > 0 && !this.turnAbortController.signal.aborted) {
318
+ await sleep(this.deps.retryBackoffMs ?? DESCRIBE_RETRY_BACKOFF_MS, this.turnAbortController.signal);
319
+ if (!this.turnAbortController.signal.aborted) {
320
+ this.deps.setLastError(null);
321
+ parsed = await runBatch(
322
+ misses,
323
+ this.pendingTurnPrompt,
324
+ this.turnVisionModel,
325
+ this.turnModelRegistry,
326
+ cfg,
327
+ this.deps,
328
+ this.turnAbortController.signal,
329
+ );
330
+ }
331
+ }
332
+ // Failover: the same-model retry failed too. Try each configured fallback
333
+ // describer in order (a different provider has independent rate limits /
334
+ // auth / availability). No per-fallback backoff — the fallback doesn't
335
+ // share the primary's rate-limit state. A partial result from any
336
+ // fallback wins; only when every fallback also returns empty do we give
337
+ // up (UNAVAILABLE, evicted from cache, re-attempted next turn). User
338
+ // cancel (ESC) skips failover.
339
+ if (parsed.size === 0 && misses.length > 0 && !this.turnAbortController.signal.aborted) {
340
+ for (const ref of cfg.fallbackModels ?? []) {
341
+ if (this.turnAbortController.signal.aborted) break;
342
+ const fallbackModel = this.deps.resolveVisionModel(this.turnModelRegistry, ref);
343
+ if (!fallbackModel) {
344
+ this.deps.setLastError(`fallback vision model "${ref}" could not be resolved`);
345
+ continue;
346
+ }
347
+ this.deps.setLastError(null);
348
+ parsed = await runBatch(
349
+ misses,
350
+ this.pendingTurnPrompt,
351
+ fallbackModel,
352
+ this.turnModelRegistry,
353
+ cfg,
354
+ this.deps,
355
+ this.turnAbortController.signal,
356
+ );
357
+ if (parsed.size > 0 || this.turnAbortController.signal.aborted) break;
358
+ }
359
+ }
360
+ // Build per-hash results, then fan them out to every callback. This reaches
361
+ // duplicate-hash callbacks that a key-indexed loop would have skipped (and
362
+ // left hanging).
363
+ const results = new Map<string, string>();
364
+ for (let i = 0; i < batch.keys.length; i++) {
365
+ const key = batch.keys[i];
366
+ const raw = parsed.get(key);
367
+ if (raw) {
368
+ const final = wrapDescription(raw, cfg);
369
+ results.set(key, final);
370
+ // Cache the resolved value so later loads (this frame or next) hit.
371
+ this.cache.set(key, Promise.resolve(final));
372
+ } else {
373
+ // Genuine failure — do NOT cache; next turn re-attempts (and surfaces
374
+ // the real error). Resolve (not reject) with UNAVAILABLE to match the
375
+ // graceful-degradation contract and avoid unhandled rejections.
376
+ this.cache.delete(key);
377
+ }
378
+ }
379
+ for (const cb of batch.callbacks) {
380
+ cb.resolve(results.get(cb.hash) ?? UNAVAILABLE);
381
+ }
382
+ }
383
+ }