pi-vision-handoff 0.2.1 → 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
@@ -41,6 +41,7 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
41
41
  - **🗂️ Explicit overrides** — force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
42
42
  - **⚡ Cache-warmed** — `before_agent_start` describes attached images the moment you submit, so the request is rarely delayed.
43
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.
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.
44
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.
45
46
 
46
47
  ## Usage
@@ -176,6 +177,31 @@ terminal still renders the image inline via kitty.
176
177
 
177
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).
178
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
+
179
205
  ## Comparison with Alternatives
180
206
 
181
207
  | Approach | Pros | Cons |
@@ -190,7 +216,7 @@ The describer call itself goes through pi's normal model machinery (`complete()`
190
216
 
191
217
  ```bash
192
218
  pnpm install
193
- pnpm test # Vitest unit tests (50 passing)
219
+ pnpm test # Vitest unit tests (77 passing)
194
220
  pnpm typecheck # TypeScript validation
195
221
  pnpm lint:dead # Dead code detection (knip)
196
222
  ```
@@ -201,10 +227,12 @@ pnpm lint:dead # Dead code detection (knip)
201
227
  .
202
228
  ├── vision-handoff.ts # Main extension: hooks, command, describer
203
229
  ├── src/
204
- │ ├── 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
205
232
  │ └── vision-model-selector.ts # Interactive picker TUI component
206
233
  ├── __tests__/unit/
207
234
  │ ├── config-dir.test.ts # Ensures getAgentDir() usage
235
+ │ ├── usage.test.ts # Energy parsing, usage records, concurrency-safe fetch routing
208
236
  │ └── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
209
237
  ├── package.json
210
238
  ├── tsconfig.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.2.1",
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
 
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();