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 +30 -2
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/usage.ts +245 -0
- package/vision-handoff.ts +66 -9
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 (
|
|
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.
|
|
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
|
|
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();
|