pi-vision-handoff 0.2.0 → 0.3.0

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 CHANGED
@@ -26,8 +26,8 @@ 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 **before** the request is sent, then **swaps the image block for the description** in the actual provider payload.
30
- - Works across all three request formats pi uses — OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages — detected by block shape.
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 **inserts the description before the kept image** in the stored `read`-tool result **before** pi-ai can strip the image for non-vision models. The image stays for kitty rendering; pi-ai later strips only the image block, leaving the description text for the model.
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
31
  - Descriptions are **cached per image hash** (LRU), 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.
@@ -35,12 +35,14 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
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
+ - **🖼️ Read-tool hijack** — `read`-tool images are intercepted at `tool_result` time, before pi-ai can strip them for non-vision models. The kept image still renders inline (kitty); the description is inserted as a text block the text-only model consumes.
38
39
  - **🔌 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.
39
40
  - **🧠 Automatic targets** — by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
40
41
  - **🗂️ Explicit overrides** — force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
41
42
  - **⚡ Cache-warmed** — `before_agent_start` describes attached images the moment you submit, so the request is rarely delayed.
42
43
  - **🛡️ Graceful degradation** — no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
43
- - **🔧 Tunable** — cap description length and cache size in the config file.
44
+ - **📊 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
+ - **🔧 Tunable** — cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
44
46
 
45
47
  ## Usage
46
48
 
@@ -71,6 +73,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
71
73
  "handoffModels": ["ollama/llava:13b"],
72
74
  "maxTokens": 1024,
73
75
  "cacheMax": 50,
76
+ "maxDescriptionLines": 0,
74
77
  "prompt": "Describe this image exhaustively…",
75
78
  "userPromptPrefix": "The user's request about this image: "
76
79
  }
@@ -84,6 +87,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
84
87
  | `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
85
88
  | `maxTokens` | `1024` | Cap on a single description's output. |
86
89
  | `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
90
+ | `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. |
87
91
  | `prompt` | _(built-in)_ | Override the describer system prompt. |
88
92
  | `userPromptPrefix` | _(built-in)_ | Override the prefix prepended to your original prompt. |
89
93
 
@@ -129,34 +133,75 @@ pi -e ./vision-handoff.ts
129
133
 
130
134
  ## How It Works
131
135
 
132
- ```
133
- You submit a prompt + image, on a text-only model
134
- before_agent_start
135
- is this model a handoff target? (non-vision, or in handoffModels)
136
- for each attached image describeImage() with your chosen vision model
137
- - complete() via pi-ai (resolves API key/headers/baseUrl for you)
138
- - cached by sha256(mime + base64)
139
- fire-and-forget warms the cache
140
- before_provider_request
141
- walk the provider payload's messages[]
142
- for every image block (detected by shape) → swap for a text block:
143
- "[Image: <cached description>]"
144
- returns the modified payload to pi
145
-
146
- Result: the text-only model receives a vivid text description in place of
147
- the image, and your turn proceeds normally.
148
- ```
136
+ Read-tool images (the common case — paste a screenshot, `read` an image file):
137
+
138
+ read tool returns an image block
139
+ tool_result (toolName === "read")
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):
155
+
156
+ → before_agent_start
157
+ • warms the description cache for attached images (fire-and-forget)
158
+ → before_provider_request
159
+ • walks the provider payload, swaps image blocks for "[Image: <desc>]"
160
+ • NOTE: for non-vision models, pi-ai strips image blocks BEFORE this
161
+ hook fires — so user-attached images on text-only models are not yet
162
+ described. Use the `read` tool on the image file instead, which routes
163
+ through the tool_result path above.
164
+
165
+ Result: the text-only model receives a vivid text description alongside the
166
+ (now-stripped) image reference, and your turn proceeds normally — while the
167
+ terminal still renders the image inline via kitty.
149
168
 
150
169
  ### Image-block formats handled
151
170
 
152
- | API | Image block shape | Replacement |
153
- |-----|-------------------|-------------|
154
- | OpenAI Chat Completions | `{ type: "image_url", image_url: { url: "data:…" } }` | `{ type: "text", text }` |
155
- | OpenAI Responses | `{ type: "input_image", image_url: "data:…" }` | `{ type: "input_text", text }` |
156
- | Anthropic Messages | `{ type: "image", source: { type: "base64", media_type, data } }` | `{ type: "text", text }` |
171
+ | Hook | Image block shape | Replacement |
172
+ |------|-------------------|-------------|
173
+ | `tool_result` (read) | `{ type: "image", data, mimeType }` (pi-ai internal) | description text block inserted **before** the kept image |
174
+ | `before_provider_request` | `{ type: "image_url", image_url: { url: "data:…" } }` (OpenAI Chat Completions) | `{ type: "text", text }` |
175
+ | `before_provider_request` | `{ type: "input_image", image_url: "data:…" }` (OpenAI Responses) | `{ type: "input_text", text }` |
176
+ | `before_provider_request` | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | `{ type: "text", text }` |
157
177
 
158
178
  The describer call itself goes through pi's normal model machinery (`complete()`), **not** the agent event loop — so it never re-triggers `before_provider_request` (no recursion).
159
179
 
180
+ ### Usage reporting
181
+
182
+ Every **real** describer call (cache misses only — cache hits emit nothing) reports its model + tokens so pi and other extensions can account for the handoff cost. When the vision model is a **Neuralwatt** model, the response's `: energy` / `: cost` / `: mcr-session` SSE comments are also captured (the OpenAI SDK discards comment lines, so the response body is teed and parsed — the same technique `pi-neuralwatt-provider` uses). For non-Neuralwatt vision models the energy fields are **omitted** (not zeroed), so consumers can distinguish "no energy" from "zero energy".
183
+
184
+ Each record is published two ways, mirroring `pi-neuralwatt-provider`'s `neuralwatt:turn-energy` pattern:
185
+
186
+ - **`pi.appendEntry("vision-handoff-usage", record)`** — persisted to the session log, so it replays on `/resume`, fork, and branch navigation.
187
+ - **`pi.events.emit("vision-handoff:usage", record)`** — live event-bus channel a consumer (e.g. a `pi-tps`-style extension) can filter on to see tokens **and** energy in one payload.
188
+
189
+ Record shape:
190
+
191
+ ```ts
192
+ {
193
+ imageHash: string, // sha256(mime + base64), first 32 hex chars
194
+ model: string, provider: string,
195
+ responseModel?: string, responseId?: string,
196
+ usage: Usage, // { input, output, cacheRead, cacheWrite, totalTokens, cost }
197
+ // Present ONLY when Neuralwatt SSE energy comments were captured:
198
+ energyJoules?: number, costUsd?: number,
199
+ energyRaw?: object, mcrSessionRaw?: object, costRaw?: object,
200
+ }
201
+ ```
202
+
203
+ Because `before_agent_start` fires several `describeImage()` calls 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
+
160
205
  ## Comparison with Alternatives
161
206
 
162
207
  | Approach | Pros | Cons |
@@ -171,7 +216,7 @@ The describer call itself goes through pi's normal model machinery (`complete()`
171
216
 
172
217
  ```bash
173
218
  pnpm install
174
- pnpm test # Vitest unit tests (31 passing)
219
+ pnpm test # Vitest unit tests (77 passing)
175
220
  pnpm typecheck # TypeScript validation
176
221
  pnpm lint:dead # Dead code detection (knip)
177
222
  ```
@@ -182,11 +227,13 @@ pnpm lint:dead # Dead code detection (knip)
182
227
  .
183
228
  ├── vision-handoff.ts # Main extension: hooks, command, describer
184
229
  ├── src/
185
- │ ├── index.ts # Config schema, read/write, image-block helpers
230
+ │ ├── index.ts # Config schema, read/write, image-block helpers (barrel)
231
+ │ ├── usage.ts # Describer usage + Neuralwatt energy capture, fetch interceptor
186
232
  │ └── vision-model-selector.ts # Interactive picker TUI component
187
233
  ├── __tests__/unit/
188
234
  │ ├── config-dir.test.ts # Ensures getAgentDir() usage
189
- └── vision-handoff.test.ts # Config, refs, image-block extraction, round-trip
235
+ ├── usage.test.ts # Energy parsing, usage records, concurrency-safe fetch routing
236
+ │ └── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
190
237
  ├── package.json
191
238
  ├── tsconfig.json
192
239
  ├── knip.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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/index.ts CHANGED
@@ -10,6 +10,8 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
+ export * from "./usage.js";
14
+
13
15
  /** Subdirectory under the pi agent dir where picker extensions store config. */
14
16
  const CONFIG_SUBDIR = "extensions";
15
17
 
@@ -57,6 +59,29 @@ export const DESCRIBE_TIMEOUT_MS = 30_000;
57
59
  */
58
60
  export const DEFAULT_MAX_DESCRIPTION_LINES = 0;
59
61
 
62
+ /**
63
+ * The note pi core's `read` tool appends to image-result text blocks when the
64
+ * active model lacks image input (see `getNonVisionImageNote` in pi core).
65
+ *
66
+ * Once this extension inserts a description, the note becomes self-
67
+ * contradicting ("image will be omitted" vs. the vivid description that
68
+ * follows) and confuses the model. {@link stripNonVisionImageNote} removes it.
69
+ */
70
+ export const NON_VISION_IMAGE_NOTE =
71
+ "[Current model does not support images. The image will be omitted from this request.]";
72
+
73
+ /**
74
+ * Remove {@link NON_VISION_IMAGE_NOTE} from a text block.
75
+ *
76
+ * The read tool appends the note as `\n${NOTE}` (always the trailing segment of
77
+ * the metadata text block), so this also collapses the orphaned newline it
78
+ * leaves behind. Safe to call on text that does not contain the note (no-op).
79
+ */
80
+ export function stripNonVisionImageNote(text: string): string {
81
+ if (!text.includes(NON_VISION_IMAGE_NOTE)) return text;
82
+ return text.split(NON_VISION_IMAGE_NOTE).join("").replace(/\n+$/, "");
83
+ }
84
+
60
85
  export interface VisionHandoffConfig {
61
86
  /** Master switch. When false, no handoff occurs even if a vision model is configured. */
62
87
  enabled: boolean;
@@ -292,6 +317,12 @@ export interface ReplacedContent {
292
317
  * provider, registry, and API call. The extension wires its real `describeImage`
293
318
  * into this helper in its `tool_result` handler.
294
319
  *
320
+ * When at least one description is inserted, also strips pi core's
321
+ * {@link NON_VISION_IMAGE_NOTE} from text blocks — the note (appended by the
322
+ * read tool for non-vision models) would otherwise contradict the inserted
323
+ * description. Text blocks are reassigned (not mutated in place); the input
324
+ * array is left untouched.
325
+ *
295
326
  * Returns a new array and `changed: false` when there were no images, so
296
327
  * callers can short-circuit and avoid mutating pi's stored result unnecessarily.
297
328
  */
@@ -315,5 +346,18 @@ export async function insertImageDescriptions(
315
346
  next.push(block);
316
347
  changed = true;
317
348
  }
349
+ if (!changed) {
350
+ return { content: next, changed };
351
+ }
352
+ // We inserted at least one description. Strip the read tool's
353
+ // "[Current model does not support images...]" note from text blocks — it
354
+ // contradicts the description we just inserted ("image will be omitted" vs.
355
+ // the description that follows) and confuses the model.
356
+ for (let i = 0; i < next.length; i++) {
357
+ const block = next[i];
358
+ if (block.type === "text" && typeof block.text === "string" && block.text.includes(NON_VISION_IMAGE_NOTE)) {
359
+ next[i] = { type: "text", text: stripNonVisionImageNote(block.text) } satisfies TextContent;
360
+ }
361
+ }
318
362
  return { content: next, changed };
319
363
  }
package/src/usage.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Describer usage + energy capture for pi-vision-handoff.
3
+ *
4
+ * One record is produced per REAL describer provider call (cache hits emit
5
+ * nothing):
6
+ * - model + tokens: from complete()'s AssistantMessage.usage
7
+ * - energy + cost + raw MCR/energy/cost payloads: from Neuralwatt SSE comment
8
+ * lines parsed out of the teed response body (readEnergyFromTee). Present
9
+ * ONLY when the vision model is a Neuralwatt model — non-Neuralwatt models
10
+ * produce no comment lines, so the energy fields are OMITTED (not zeroed)
11
+ * for easy downstream filtering ("no energy" vs "zero energy").
12
+ *
13
+ * The caller (vision-handoff.ts) persists the record via pi.appendEntry (replays
14
+ * on session resume/branch) AND emits it on pi.events so a live consumer can
15
+ * filter on the one channel for tokens AND energy.
16
+ *
17
+ * Split out of vision-handoff.ts so the pure pieces (readEnergyFromTee,
18
+ * buildUsageRecord) and the concurrency-safe fetch interceptor are unit-testable
19
+ * through the normal src/ import path — vision-handoff.ts runs readConfig() at
20
+ * module load and pulls in the TUI selector, so it is not unit-test-friendly.
21
+ */
22
+
23
+ import { AsyncLocalStorage } from "node:async_hooks";
24
+ import type { Api, AssistantMessage, Model, Usage } from "@earendil-works/pi-ai";
25
+
26
+ /** Custom session-entry type persisted via pi.appendEntry. */
27
+ export const USAGE_ENTRY_TYPE = "vision-handoff-usage";
28
+
29
+ /** Event-bus channel emitted via pi.events. */
30
+ export const USAGE_EVENT_CHANNEL = "vision-handoff:usage";
31
+
32
+ /** Parsed Neuralwatt SSE-comment energy/cost/MCR data for one describer call. */
33
+ export interface VisionHandoffEnergyCapture {
34
+ energyJoules: number;
35
+ costUsd: number;
36
+ energyRaw: Record<string, unknown> | null;
37
+ mcrSessionRaw: Record<string, unknown> | null;
38
+ costRaw: Record<string, unknown> | null;
39
+ }
40
+
41
+ /** Shared empty capture. Safe to share: readEnergyFromTee returns a fresh object
42
+ * and never mutates this; buildUsageRecord only reads from its argument. */
43
+ export const EMPTY_ENERGY_CAPTURE: VisionHandoffEnergyCapture = {
44
+ energyJoules: 0,
45
+ costUsd: 0,
46
+ energyRaw: null,
47
+ mcrSessionRaw: null,
48
+ costRaw: null,
49
+ };
50
+
51
+ /** A single describer-call usage record. Energy fields are present only when
52
+ * Neuralwatt SSE energy comments were captured (omitted, not zeroed, otherwise). */
53
+ export interface VisionHandoffUsageRecord {
54
+ imageHash: string;
55
+ model: string;
56
+ provider: string;
57
+ responseModel?: string;
58
+ responseId?: string;
59
+ usage: Usage;
60
+ /** Neuralwatt energy — present only when SSE energy comments were captured. */
61
+ energyJoules?: number;
62
+ costUsd?: number;
63
+ energyRaw?: Record<string, unknown> | null;
64
+ mcrSessionRaw?: Record<string, unknown> | null;
65
+ costRaw?: Record<string, unknown> | null;
66
+ }
67
+
68
+ /**
69
+ * Parse Neuralwatt SSE comment lines (`: energy`, `: cost`, `: mcr-session`)
70
+ * from a teed response body into a fresh capture object. Mirrors
71
+ * pi-neuralwatt-provider's readEnergyFromTee but returns a result instead of
72
+ * mutating module state — vision-handoff describes images concurrently (the
73
+ * before_agent_start warm-up fires several in parallel), so each call needs its
74
+ * own capture routed via {@link describeAls}. For non-Neuralwatt vision models
75
+ * no comment lines are present and the returned capture stays empty.
76
+ */
77
+ export async function readEnergyFromTee(
78
+ body: ReadableStream<Uint8Array>,
79
+ ): Promise<VisionHandoffEnergyCapture> {
80
+ const result: VisionHandoffEnergyCapture = { ...EMPTY_ENERGY_CAPTURE };
81
+ const reader = body.getReader();
82
+ const decoder = new TextDecoder();
83
+ let buffer = "";
84
+
85
+ function processLine(line: string): void {
86
+ const trimmed = line.trim();
87
+ if (trimmed.startsWith(": energy ")) {
88
+ try {
89
+ const energy = JSON.parse(trimmed.slice(9));
90
+ result.energyJoules += energy.energy_joules || 0;
91
+ result.energyRaw = energy;
92
+ } catch {
93
+ // malformed energy comment — ignore
94
+ }
95
+ } else if (trimmed.startsWith(": mcr-session ")) {
96
+ try {
97
+ result.mcrSessionRaw = JSON.parse(trimmed.slice(14));
98
+ } catch {
99
+ // malformed mcr-session comment — ignore
100
+ }
101
+ } else if (trimmed.startsWith(": cost ")) {
102
+ try {
103
+ const cost = JSON.parse(trimmed.slice(7));
104
+ result.costUsd += cost.request_cost_usd || 0;
105
+ result.costRaw = cost;
106
+ } catch {
107
+ // malformed cost comment — ignore
108
+ }
109
+ }
110
+ }
111
+
112
+ try {
113
+ while (true) {
114
+ const { done, value } = await reader.read();
115
+ if (done) break;
116
+ buffer += decoder.decode(value, { stream: true });
117
+ const lines = buffer.split("\n");
118
+ buffer = lines.pop() || "";
119
+ for (const line of lines) processLine(line);
120
+ }
121
+ } catch {
122
+ // tee stream may error if the main stream is aborted — that's fine
123
+ }
124
+
125
+ const final = decoder.decode(new Uint8Array(0), { stream: false });
126
+ const remaining = (buffer + final).trim();
127
+ if (remaining) processLine(remaining);
128
+
129
+ try {
130
+ reader.releaseLock();
131
+ } catch {
132
+ // ignore
133
+ }
134
+ return result;
135
+ }
136
+
137
+ /**
138
+ * Build a usage record from a describer response + energy capture. Returns
139
+ * null when there is nothing meaningful to report (e.g. a provider-level
140
+ * failure with zero tokens and no energy) so the caller can skip emitting.
141
+ *
142
+ * Energy fields are OMITTED entirely (not zeroed) when no Neuralwatt SSE energy
143
+ * comments were captured, so consumers can distinguish "no energy" from
144
+ * "zero energy".
145
+ */
146
+ export function buildUsageRecord(
147
+ response: AssistantMessage,
148
+ capture: VisionHandoffEnergyCapture,
149
+ visionModel: Model<Api>,
150
+ imageHash: string,
151
+ ): VisionHandoffUsageRecord | null {
152
+ const hasEnergy = !!(
153
+ capture.energyRaw ||
154
+ capture.costRaw ||
155
+ capture.mcrSessionRaw ||
156
+ capture.energyJoules > 0 ||
157
+ capture.costUsd > 0
158
+ );
159
+ const hasUsage =
160
+ !!response.usage &&
161
+ (response.usage.totalTokens > 0 || response.usage.input > 0 || response.usage.output > 0);
162
+ if (!hasUsage && !hasEnergy) return null;
163
+
164
+ const record: VisionHandoffUsageRecord = {
165
+ imageHash,
166
+ model: response.model || visionModel.id,
167
+ provider: response.provider || visionModel.provider,
168
+ responseModel: response.responseModel,
169
+ responseId: response.responseId,
170
+ usage: response.usage,
171
+ };
172
+ if (hasEnergy) {
173
+ record.energyJoules = capture.energyJoules;
174
+ record.costUsd = capture.costUsd;
175
+ record.energyRaw = capture.energyRaw;
176
+ record.mcrSessionRaw = capture.mcrSessionRaw;
177
+ record.costRaw = capture.costRaw;
178
+ }
179
+ return record;
180
+ }
181
+
182
+ // ── Concurrency-safe fetch interceptor ─────────────────────────────────────
183
+ //
184
+ // The only body-interception point for complete() is globalThis.fetch (pi-ai's
185
+ // StreamOptions.onResponse exposes headers only, not the body where the
186
+ // `: energy` SSE comments live). before_agent_start fires several describeImage()
187
+ // calls fire-and-forget, so a naïve save/patch/restore of globalThis.fetch would
188
+ // clobber under concurrency. The fix is a refcounted shared interceptor
189
+ // (installed only while ≥1 describe is in flight, so non-describe fetches pass
190
+ // through unmodified) + AsyncLocalStorage to route each teed response body to
191
+ // the describe call that issued it. Nested patches from other extensions (e.g.
192
+ // pi-neuralwatt-provider's streamNeuralwatt, which also tees for its own energy
193
+ // display) chain on top and restore back to this interceptor, so both tees read
194
+ // the same comment lines independently.
195
+
196
+ /** Per-describer-call ALS slot carrying the energy tee reader. */
197
+ export interface DescribeContext {
198
+ energyReader: Promise<VisionHandoffEnergyCapture> | undefined;
199
+ }
200
+
201
+ /** Routes each teed response body to the describe call that issued it. */
202
+ export const describeAls = new AsyncLocalStorage<DescribeContext>();
203
+
204
+ let fetchInterceptRefCount = 0;
205
+ let savedRealFetch: typeof globalThis.fetch | null = null;
206
+
207
+ /** Install the globalThis.fetch interceptor. Refcounted: the first caller
208
+ * patches fetch; later callers just bump the count. Idempotent per install. */
209
+ export function installFetchInterceptor(): void {
210
+ if (fetchInterceptRefCount === 0) {
211
+ savedRealFetch = globalThis.fetch;
212
+ globalThis.fetch = interceptedFetch;
213
+ }
214
+ fetchInterceptRefCount++;
215
+ }
216
+
217
+ /** Remove the interceptor. Refcounted: only the last caller restores fetch. */
218
+ export function uninstallFetchInterceptor(): void {
219
+ if (fetchInterceptRefCount > 0) fetchInterceptRefCount--;
220
+ if (fetchInterceptRefCount === 0 && savedRealFetch) {
221
+ globalThis.fetch = savedRealFetch;
222
+ savedRealFetch = null;
223
+ }
224
+ }
225
+
226
+ /** Current refcount — 0 means the interceptor is not installed. Test hook. */
227
+ export function fetchInterceptorRefcount(): number {
228
+ return fetchInterceptRefCount;
229
+ }
230
+
231
+ async function interceptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
232
+ const real = savedRealFetch ?? globalThis.fetch;
233
+ const response = await real(input, init);
234
+ const store = describeAls.getStore();
235
+ // Outside a describer call (no ALS store) or for bodiless responses: pass
236
+ // through untouched.
237
+ if (!store || !response.body) return response;
238
+ const [bodyForSdk, bodyForEnergy] = response.body.tee();
239
+ store.energyReader = readEnergyFromTee(bodyForEnergy);
240
+ return new Response(bodyForSdk, {
241
+ headers: response.headers,
242
+ status: response.status,
243
+ statusText: response.statusText,
244
+ });
245
+ }
package/vision-handoff.ts CHANGED
@@ -37,21 +37,35 @@ import {
37
37
  HANDOFF_COMMAND_DESCRIPTION,
38
38
  IMAGE_PLACEHOLDER_PREFIX,
39
39
  IMAGE_PLACEHOLDER_SUFFIX,
40
+ USAGE_ENTRY_TYPE,
41
+ USAGE_EVENT_CHANNEL,
42
+ EMPTY_ENERGY_CAPTURE,
43
+ buildUsageRecord,
44
+ describeAls,
40
45
  extractImageFromBlock,
41
46
  formatModelRef,
42
47
  insertImageDescriptions,
48
+ installFetchInterceptor,
43
49
  isVisionModel,
44
50
  makeReplacementText,
45
51
  parseModelRef,
46
52
  readConfig,
47
53
  truncateDescription,
54
+ uninstallFetchInterceptor,
48
55
  writeConfig,
56
+ type DescribeContext,
49
57
  type VisionHandoffConfig,
58
+ type VisionHandoffEnergyCapture,
59
+ type VisionHandoffUsageRecord,
50
60
  } from "./src/index.js";
51
61
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
52
62
 
53
63
  const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
54
64
 
65
+ // Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
66
+ // export. No-op until then so describeImage is safe to call before wiring.
67
+ let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
68
+
55
69
  let config: VisionHandoffConfig = readConfig();
56
70
 
57
71
  /** User prompt for the current agent turn, captured from before_agent_start. */
@@ -125,17 +139,38 @@ async function describeImage(
125
139
 
126
140
  const controller = new AbortController();
127
141
  const timer = setTimeout(() => controller.abort(), DESCRIBE_TIMEOUT_MS);
142
+ // Energy/token capture for this describer call. The fetch interceptor is
143
+ // refcount-installed around the complete() window and routes the teed
144
+ // response body to this describe's AsyncLocalStorage slot — so concurrent
145
+ // describes (before_agent_start warm-up fires several in parallel) each
146
+ // get their own capture without clobbering globalThis.fetch. For non-
147
+ // Neuralwatt vision models no SSE energy comments are present and the
148
+ // capture stays empty (energy fields omitted from the record).
149
+ const describeCtx: DescribeContext = { energyReader: undefined };
150
+ installFetchInterceptor();
128
151
  try {
129
- const response = await complete(
130
- visionModel,
131
- { systemPrompt, messages: [userMessage] },
132
- {
133
- apiKey: auth.apiKey,
134
- headers: auth.headers,
135
- signal: controller.signal,
136
- maxTokens: cfg.maxTokens,
137
- },
152
+ const response = await describeAls.run(describeCtx, async () =>
153
+ complete(
154
+ visionModel,
155
+ { systemPrompt, messages: [userMessage] },
156
+ {
157
+ apiKey: auth.apiKey,
158
+ headers: auth.headers,
159
+ signal: controller.signal,
160
+ maxTokens: cfg.maxTokens,
161
+ },
162
+ ),
138
163
  );
164
+ let capture: VisionHandoffEnergyCapture = EMPTY_ENERGY_CAPTURE;
165
+ if (describeCtx.energyReader) {
166
+ try {
167
+ capture = await describeCtx.energyReader;
168
+ } catch {
169
+ // tee aborted with the main stream — keep the empty capture
170
+ }
171
+ }
172
+ const record = buildUsageRecord(response, capture, visionModel, key);
173
+ if (record) reportUsage(record);
139
174
  if (response.stopReason === "aborted" || response.stopReason === "error") {
140
175
  return UNAVAILABLE;
141
176
  }
@@ -159,6 +194,8 @@ async function describeImage(
159
194
  } catch {
160
195
  return UNAVAILABLE;
161
196
  } finally {
197
+ if (describeCtx.energyReader) describeCtx.energyReader.catch(() => {});
198
+ uninstallFetchInterceptor();
162
199
  clearTimeout(timer);
163
200
  }
164
201
  })();
@@ -213,6 +250,26 @@ function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
213
250
  export default function (pi: ExtensionAPI) {
214
251
  config = readConfig();
215
252
 
253
+ // Wire the usage reporter to pi's persistence + event bus. appendEntry
254
+ // persists the record so it replays on session resume/branch, and the event
255
+ // lets live consumers filter on one channel for tokens AND energy. Each call
256
+ // is independently guarded so a persistence/emit failure never breaks a
257
+ // describer turn. Re-assigned every factory invocation (pi re-runs the
258
+ // factory on /new, /resume, fork, /reload) so the closure always references
259
+ // the live pi.
260
+ reportUsage = (record: VisionHandoffUsageRecord) => {
261
+ try {
262
+ pi.appendEntry(USAGE_ENTRY_TYPE, record);
263
+ } catch {
264
+ // never break the describer on persistence failure
265
+ }
266
+ try {
267
+ pi.events?.emit(USAGE_EVENT_CHANNEL, record);
268
+ } catch {
269
+ // never break the describer on emit failure
270
+ }
271
+ };
272
+
216
273
  pi.on("session_start", async () => {
217
274
  // Reload in case the user edited the config on disk from another session.
218
275
  config = readConfig();