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 +75 -28
- package/package.json +1 -1
- package/src/index.ts +44 -0
- package/src/usage.ts +245 -0
- package/vision-handoff.ts +66 -9
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
|
|
30
|
-
- Works across all
|
|
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
|
-
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
|
153
|
-
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
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 (
|
|
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
|
-
│
|
|
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.
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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();
|