pi-vision-handoff 0.5.1 → 0.6.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 +18 -1
- package/package.json +1 -1
- package/src/describer.ts +151 -20
- package/src/error-log.ts +131 -0
- package/vision-handoff.ts +17 -2
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
|
|
|
45
45
|
- **⚡ Pre-warmed at paste-enter** — the moment you press enter, `before_agent_start` scans the prompt for pasted clipboard image temp-file paths (pi writes pasted images to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text), reads them, and kicks off the ONE batched vision call concurrent with the agent's first response — so by the time the agent reads the files, they're already cache hits.
|
|
46
46
|
- **🚀 Paste-time prewarm (opt-in)** — `prewarmPastedImages` (off by default) wraps the editor so pasted clipboard images are described the *instant* their path lands in the prompt — before you hit enter — not at submit. The vision call starts concurrent with you typing your question. Tradeoff: the description is generated without your typed question as context (the question usually isn't entered yet at paste time), and a paste-then-abandon wastes one vision call. TUI only; inactive if another extension replaces the editor. Toggle with `/vision-handoff prewarm on`.
|
|
47
47
|
- **🛡️ Graceful degradation** — no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
|
|
48
|
+
- **📋 Error logging** — every describer failure and every `image description failed` warning is appended as a JSONL line to `~/.pi/agent/logs/pi-vision-handoff/errors.log`, so a warning like `image description failed — unknown error` is troubleshootable: the detailed reason (exception stack, `stopReason`, config snapshot) is captured at the describer's failure source even when the in-memory error string was already reset by the time the warning fired. Size-capped with a single `.1` backup.
|
|
48
49
|
- **📊 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.
|
|
49
50
|
- **🔧 Tunable** — cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
|
|
50
51
|
|
|
@@ -100,6 +101,20 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
100
101
|
|
|
101
102
|
> The config path uses pi's `getAgentDir()` — set `PI_CODING_AGENT_DIR` to relocate it.
|
|
102
103
|
|
|
104
|
+
### Troubleshooting
|
|
105
|
+
|
|
106
|
+
When handoff fails, pi shows a short warning like `pi-vision-handoff: image description failed — <reason>. Vision model: <model>`. The full detail is in the error log:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
~/.pi/agent/logs/pi-vision-handoff/errors.log
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
(one JSON object per line; relocate via `PI_CODING_AGENT_DIR`). Each entry records the `phase` (`batch` / `single` = describer failure source, `warn` = the user-facing warning), the `reason`, the `visionModel`, the failing `imageHashes`, and — when applicable — the `stopReason`, `errorMessage`, `errorStack`, and a `config` snapshot (`maxTokens` / `thinking` / `thinkingLevel`).
|
|
113
|
+
|
|
114
|
+
**`reason: "unknown error"`** in a `warn` entry means the engine's shared last-error string was already reset (typically a concurrent batch cleared it) by the time the warning fired — so the real cause isn't in the warning. Correlate it with the matching `batch`/`single` entry: match on the `imageHashes` (and the timestamp) to recover the actual exception/`stopReason`. Deliberate user cancels (ESC) are not logged — they aren't errors.
|
|
115
|
+
|
|
116
|
+
The log is append-only and size-capped (10 MB, rotating to `errors.log.1`), so a runaway broken vision model can't fill the disk; truncate it any time.
|
|
117
|
+
|
|
103
118
|
## Installation
|
|
104
119
|
|
|
105
120
|
**With `pi install`** (recommended):
|
|
@@ -372,6 +387,7 @@ pnpm lint:dead # Dead code detection (knip)
|
|
|
372
387
|
│ ├── image.ts # Image hashing, MIME sniffing, clipboard-path reading
|
|
373
388
|
│ ├── prewarm-editor.ts # Opt-in paste-time prewarm CustomEditor wrapper (chains onChange)
|
|
374
389
|
│ ├── dispose.ts # `Disposable` guard factories for `using` (fetch interceptor, timer, abort wire)
|
|
390
|
+
│ ├── error-log.ts # Best-effort JSONL error log → ~/.pi/agent/logs/pi-vision-handoff/errors.log
|
|
375
391
|
│ ├── usage.ts # Describer usage + Neuralwatt energy capture, fetch interceptor
|
|
376
392
|
│ └── vision-model-selector.ts # Interactive picker TUI component
|
|
377
393
|
├── __tests__/unit/
|
|
@@ -379,8 +395,9 @@ pnpm lint:dead # Dead code detection (knip)
|
|
|
379
395
|
│ ├── usage.test.ts # Energy parsing, usage records, concurrency-safe fetch routing
|
|
380
396
|
│ ├── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
|
|
381
397
|
│ ├── dataloader.test.ts # Batch coalescing, memoization, failure eviction, Disposable reset
|
|
382
|
-
│ ├── describer.test.ts # stopReason handling (length → truncation marker, aborted/error)
|
|
398
|
+
│ ├── describer.test.ts # stopReason handling (length → truncation marker, aborted/error), error-log wiring
|
|
383
399
|
│ ├── image.test.ts # MIME sniffing, clipboard-path confinement, file reading, diffPrewarmPaths
|
|
400
|
+
│ ├── error-log.test.ts # JSONL error log: path resolution, append, size-capped rotation, never-throws
|
|
384
401
|
│ └── dispose.test.ts # `using` guards: fetch refcount, timeout, abort-wire propagation
|
|
385
402
|
├── package.json
|
|
386
403
|
├── tsconfig.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/describer.ts
CHANGED
|
@@ -34,11 +34,13 @@ import {
|
|
|
34
34
|
DEFAULT_USER_PROMPT_PREFIX,
|
|
35
35
|
DEFAULT_VISION_PROMPT,
|
|
36
36
|
describeTimeoutMs,
|
|
37
|
+
formatModelRef,
|
|
37
38
|
markDescriptionTruncated,
|
|
38
39
|
parseBatchedDescriptions,
|
|
39
40
|
type ExtractedImage,
|
|
40
41
|
type VisionHandoffConfig,
|
|
41
42
|
} from "./index.js";
|
|
43
|
+
import { appendVisionError } from "./error-log.js";
|
|
42
44
|
import {
|
|
43
45
|
buildUsageRecord,
|
|
44
46
|
describeAls,
|
|
@@ -104,6 +106,17 @@ export function resolveReasoning(
|
|
|
104
106
|
return cfg.thinkingLevel;
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
/** Small config snapshot embedded in every error-log entry — the fields most
|
|
110
|
+
* relevant to troubleshooting a describer failure (a bad maxTokens clamp, a
|
|
111
|
+
* reasoning level the model rejects, etc.). */
|
|
112
|
+
function configSnapshot(cfg: VisionHandoffConfig): {
|
|
113
|
+
maxTokens?: number;
|
|
114
|
+
thinking: boolean;
|
|
115
|
+
thinkingLevel: string;
|
|
116
|
+
} {
|
|
117
|
+
return { maxTokens: cfg.maxTokens, thinking: cfg.thinking, thinkingLevel: cfg.thinkingLevel };
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
/** Dependencies the describer can't own itself (held by the engine). */
|
|
108
121
|
export interface DescriberDeps {
|
|
109
122
|
/** Report a usage+energy record for one real describer call (cache hits emit none). */
|
|
@@ -138,9 +151,18 @@ export async function runBatch(
|
|
|
138
151
|
const out: BatchResult = new Map();
|
|
139
152
|
const auth = await modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
140
153
|
if (!auth.ok || !auth.apiKey) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
154
|
+
const reason = !auth.ok
|
|
155
|
+
? auth.error
|
|
156
|
+
: `No API key for vision model "${formatModelRef(visionModel.provider, visionModel.id)}"`;
|
|
157
|
+
deps.setLastError(reason);
|
|
158
|
+
appendVisionError({
|
|
159
|
+
phase: "batch",
|
|
160
|
+
reason,
|
|
161
|
+
visionModel: cfg.visionModel,
|
|
162
|
+
imageHashes: misses.map((m) => m.hash),
|
|
163
|
+
imageCount: misses.length,
|
|
164
|
+
config: configSnapshot(cfg),
|
|
165
|
+
});
|
|
144
166
|
return out;
|
|
145
167
|
}
|
|
146
168
|
|
|
@@ -178,7 +200,28 @@ export async function runBatch(
|
|
|
178
200
|
const record = buildUsageRecord(response, capture, visionModel, hashes[0], hashes.length > 1 ? hashes : undefined);
|
|
179
201
|
if (record) deps.reportUsage(record);
|
|
180
202
|
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
181
|
-
|
|
203
|
+
const reason = setStopReasonError(
|
|
204
|
+
deps,
|
|
205
|
+
response.stopReason,
|
|
206
|
+
response.errorMessage,
|
|
207
|
+
abortWire,
|
|
208
|
+
timedOut,
|
|
209
|
+
timeoutMs,
|
|
210
|
+
);
|
|
211
|
+
if (reason) {
|
|
212
|
+
appendVisionError({
|
|
213
|
+
phase: "batch",
|
|
214
|
+
reason,
|
|
215
|
+
visionModel: cfg.visionModel,
|
|
216
|
+
imageHashes: hashes,
|
|
217
|
+
imageCount: misses.length,
|
|
218
|
+
stopReason: response.stopReason,
|
|
219
|
+
timedOut,
|
|
220
|
+
timeoutMs,
|
|
221
|
+
errorMessage: response.errorMessage,
|
|
222
|
+
config: configSnapshot(cfg),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
182
225
|
return out;
|
|
183
226
|
}
|
|
184
227
|
const text = response.content
|
|
@@ -188,6 +231,14 @@ export async function runBatch(
|
|
|
188
231
|
.trim();
|
|
189
232
|
if (!text) {
|
|
190
233
|
deps.setLastError("vision model returned an empty description");
|
|
234
|
+
appendVisionError({
|
|
235
|
+
phase: "batch",
|
|
236
|
+
reason: "vision model returned an empty description",
|
|
237
|
+
visionModel: cfg.visionModel,
|
|
238
|
+
imageHashes: hashes,
|
|
239
|
+
imageCount: misses.length,
|
|
240
|
+
config: configSnapshot(cfg),
|
|
241
|
+
});
|
|
191
242
|
return out;
|
|
192
243
|
}
|
|
193
244
|
const parsed = parseBatchedDescriptions(text, misses.length);
|
|
@@ -227,9 +278,28 @@ export async function runBatch(
|
|
|
227
278
|
}
|
|
228
279
|
return out;
|
|
229
280
|
} catch (err) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
281
|
+
const userAborted = abortWire.userAborted();
|
|
282
|
+
const reason = timedOut
|
|
283
|
+
? `describer timed out after ${timeoutMs / 1000}s`
|
|
284
|
+
: err instanceof Error
|
|
285
|
+
? err.message
|
|
286
|
+
: String(err);
|
|
287
|
+
deps.setLastError(reason);
|
|
288
|
+
// A deliberate user cancel isn't a troubleshooting-worthy error — skip
|
|
289
|
+
// logging it (mirroring the no-warn-on-user-abort contract).
|
|
290
|
+
if (!userAborted) {
|
|
291
|
+
appendVisionError({
|
|
292
|
+
phase: "batch",
|
|
293
|
+
reason,
|
|
294
|
+
visionModel: cfg.visionModel,
|
|
295
|
+
imageHashes: misses.map((m) => m.hash),
|
|
296
|
+
imageCount: misses.length,
|
|
297
|
+
timedOut,
|
|
298
|
+
timeoutMs,
|
|
299
|
+
errorStack: err instanceof Error ? err.stack : undefined,
|
|
300
|
+
config: configSnapshot(cfg),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
233
303
|
return out;
|
|
234
304
|
} finally {
|
|
235
305
|
// The `using` guards above already released the fetch interceptor, timer,
|
|
@@ -254,9 +324,18 @@ export async function describeSingle(
|
|
|
254
324
|
): Promise<string | null> {
|
|
255
325
|
const auth = await modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
256
326
|
if (!auth.ok || !auth.apiKey) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
327
|
+
const reason = !auth.ok
|
|
328
|
+
? auth.error
|
|
329
|
+
: `No API key for vision model "${formatModelRef(visionModel.provider, visionModel.id)}"`;
|
|
330
|
+
deps.setLastError(reason);
|
|
331
|
+
appendVisionError({
|
|
332
|
+
phase: "single",
|
|
333
|
+
reason,
|
|
334
|
+
visionModel: cfg.visionModel,
|
|
335
|
+
imageHashes: [imageHash(img.mimeType, img.data)],
|
|
336
|
+
imageCount: 1,
|
|
337
|
+
config: configSnapshot(cfg),
|
|
338
|
+
});
|
|
260
339
|
return null;
|
|
261
340
|
}
|
|
262
341
|
const prefix = cfg.userPromptPrefix ?? DEFAULT_USER_PROMPT_PREFIX;
|
|
@@ -292,7 +371,28 @@ export async function describeSingle(
|
|
|
292
371
|
const record = buildUsageRecord(response, capture, visionModel, hash);
|
|
293
372
|
if (record) deps.reportUsage(record);
|
|
294
373
|
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
295
|
-
|
|
374
|
+
const reason = setStopReasonError(
|
|
375
|
+
deps,
|
|
376
|
+
response.stopReason,
|
|
377
|
+
response.errorMessage,
|
|
378
|
+
abortWire,
|
|
379
|
+
timedOut,
|
|
380
|
+
timeoutMs,
|
|
381
|
+
);
|
|
382
|
+
if (reason) {
|
|
383
|
+
appendVisionError({
|
|
384
|
+
phase: "single",
|
|
385
|
+
reason,
|
|
386
|
+
visionModel: cfg.visionModel,
|
|
387
|
+
imageHashes: [hash],
|
|
388
|
+
imageCount: 1,
|
|
389
|
+
stopReason: response.stopReason,
|
|
390
|
+
timedOut,
|
|
391
|
+
timeoutMs,
|
|
392
|
+
errorMessage: response.errorMessage,
|
|
393
|
+
config: configSnapshot(cfg),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
296
396
|
return null;
|
|
297
397
|
}
|
|
298
398
|
const text = response.content
|
|
@@ -302,6 +402,14 @@ export async function describeSingle(
|
|
|
302
402
|
.trim();
|
|
303
403
|
if (!text) {
|
|
304
404
|
deps.setLastError("vision model returned an empty description");
|
|
405
|
+
appendVisionError({
|
|
406
|
+
phase: "single",
|
|
407
|
+
reason: "vision model returned an empty description",
|
|
408
|
+
visionModel: cfg.visionModel,
|
|
409
|
+
imageHashes: [hash],
|
|
410
|
+
imageCount: 1,
|
|
411
|
+
config: configSnapshot(cfg),
|
|
412
|
+
});
|
|
305
413
|
return null;
|
|
306
414
|
}
|
|
307
415
|
// stopReason "length" = the model hit a token limit (configured maxTokens or
|
|
@@ -309,9 +417,28 @@ export async function describeSingle(
|
|
|
309
417
|
// useful, but it must not pass as complete — mark it so the agent/user know.
|
|
310
418
|
return response.stopReason === "length" ? markDescriptionTruncated(text) : text;
|
|
311
419
|
} catch (err) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
420
|
+
const userAborted = abortWire.userAborted();
|
|
421
|
+
const reason = timedOut
|
|
422
|
+
? `describer timed out after ${timeoutMs / 1000}s`
|
|
423
|
+
: err instanceof Error
|
|
424
|
+
? err.message
|
|
425
|
+
: String(err);
|
|
426
|
+
deps.setLastError(reason);
|
|
427
|
+
// A deliberate user cancel isn't a troubleshooting-worthy error — skip
|
|
428
|
+
// logging it (mirroring the no-warn-on-user-abort contract).
|
|
429
|
+
if (!userAborted) {
|
|
430
|
+
appendVisionError({
|
|
431
|
+
phase: "single",
|
|
432
|
+
reason,
|
|
433
|
+
visionModel: cfg.visionModel,
|
|
434
|
+
imageHashes: [imageHash(img.mimeType, img.data)],
|
|
435
|
+
imageCount: 1,
|
|
436
|
+
timedOut,
|
|
437
|
+
timeoutMs,
|
|
438
|
+
errorStack: err instanceof Error ? err.stack : undefined,
|
|
439
|
+
config: configSnapshot(cfg),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
315
442
|
return null;
|
|
316
443
|
} finally {
|
|
317
444
|
describeCtx.energyReader?.catch(() => {});
|
|
@@ -332,7 +459,8 @@ async function readCapture(describeCtx: DescribeContext): Promise<VisionHandoffE
|
|
|
332
459
|
|
|
333
460
|
/** Translate a non-OK stopReason into a user-facing failure message, unless the
|
|
334
461
|
* abort came from the user cancelling the turn (then stay silent — no warning
|
|
335
|
-
* for a deliberate cancel).
|
|
462
|
+
* for a deliberate cancel). Returns the message that was set (so the caller
|
|
463
|
+
* can log it), or null when the abort was a user cancel / nothing was set. */
|
|
336
464
|
function setStopReasonError(
|
|
337
465
|
deps: DescriberDeps,
|
|
338
466
|
stopReason: string,
|
|
@@ -340,11 +468,14 @@ function setStopReasonError(
|
|
|
340
468
|
abortWire: AbortWire,
|
|
341
469
|
timedOut: boolean,
|
|
342
470
|
timeoutMs: number,
|
|
343
|
-
):
|
|
344
|
-
if (abortWire.userAborted()) return; // user cancelled the turn — no warning
|
|
471
|
+
): string | null {
|
|
472
|
+
if (abortWire.userAborted()) return null; // user cancelled the turn — no warning
|
|
345
473
|
if (timedOut) {
|
|
346
|
-
|
|
347
|
-
|
|
474
|
+
const reason = `describer timed out after ${timeoutMs / 1000}s`;
|
|
475
|
+
deps.setLastError(reason);
|
|
476
|
+
return reason;
|
|
348
477
|
}
|
|
349
|
-
|
|
478
|
+
const reason = `vision model returned stopReason "${stopReason}"${errorMessage ? ": " + errorMessage : ""}`;
|
|
479
|
+
deps.setLastError(reason);
|
|
480
|
+
return reason;
|
|
350
481
|
}
|
package/src/error-log.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort structured error logging for pi-vision-handoff.
|
|
3
|
+
*
|
|
4
|
+
* Every describer failure (auth error, network error, abort, empty response,
|
|
5
|
+
* stopReason error/aborted, timeout) and every user-facing "image description
|
|
6
|
+
* failed" warning is appended as one JSONL line to
|
|
7
|
+
* ~/.pi/agent/logs/pi-vision-handoff/errors.log
|
|
8
|
+
* (resolvable via $PI_CODING_AGENT_DIR, like every other pi path).
|
|
9
|
+
*
|
|
10
|
+
* This is the troubleshooting surface for the "image description failed —
|
|
11
|
+
* unknown error" warning. That warning fires with reason "unknown error" when
|
|
12
|
+
* the engine's shared last-error string was already null — typically because a
|
|
13
|
+
* concurrent batch reset it between the failure and the warn — so the detailed
|
|
14
|
+
* reason lives only here, captured at the describer's failure source where the
|
|
15
|
+
* real exception/stopReason is still in hand. A `warn` entry with reason
|
|
16
|
+
* "unknown error" carries the failing image hashes; correlate them (and the
|
|
17
|
+
* timestamp) with the matching `batch`/`single` entry to recover the real
|
|
18
|
+
* cause.
|
|
19
|
+
*
|
|
20
|
+
* Best-effort: logging never throws and never breaks a describer turn. The
|
|
21
|
+
* directory is created lazily; a single size-based rotation (errors.log →
|
|
22
|
+
* errors.log.1) bounds growth to ~2× the cap.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
/** Subdirectory under the pi agent dir holding this extension's logs. */
|
|
30
|
+
const LOG_SUBDIR = "logs/pi-vision-handoff";
|
|
31
|
+
/** Active log file name. */
|
|
32
|
+
const LOG_FILENAME = "errors.log";
|
|
33
|
+
|
|
34
|
+
/** A single error log is capped at this many bytes before it rotates to the
|
|
35
|
+
* single `.1` backup, bounding total on-disk log to ~2× this. Generous enough
|
|
36
|
+
* to retain a long troubleshooting history; small enough that a runaway
|
|
37
|
+
* broken vision model (failures aren't cached, so they re-fire per turn)
|
|
38
|
+
* can't fill the disk. */
|
|
39
|
+
export const MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/** One structured error-log record (one JSONL line). */
|
|
42
|
+
export interface VisionErrorLogEntry {
|
|
43
|
+
/** ISO 8601 timestamp (filled by {@link appendVisionError}). */
|
|
44
|
+
timestamp: string;
|
|
45
|
+
/** Where the entry originated: "batch"/"single" = describer failure source
|
|
46
|
+
* (rich detail), "warn" = the user-facing warning (may carry reason
|
|
47
|
+
* "unknown error" when the engine's shared error was already reset). */
|
|
48
|
+
phase: "batch" | "single" | "warn";
|
|
49
|
+
/** Human-readable failure reason (what `setLastError` received, or the warn
|
|
50
|
+
* reason). "unknown error" in a `warn` entry means no describer error was
|
|
51
|
+
* captured — correlate via {@link imageHashes} + {@link timestamp} with the
|
|
52
|
+
* matching batch/single entry. */
|
|
53
|
+
reason: string;
|
|
54
|
+
/** Configured vision model ref ("provider/id"), or null if unset. */
|
|
55
|
+
visionModel: string | null;
|
|
56
|
+
/** Image hashes the failure covered (batch/single) or the warning named. */
|
|
57
|
+
imageHashes: string[];
|
|
58
|
+
/** Number of images involved. */
|
|
59
|
+
imageCount: number;
|
|
60
|
+
/** Describer stopReason, when the failure came from a completed response. */
|
|
61
|
+
stopReason?: string;
|
|
62
|
+
/** True when the describer's timeout fired. */
|
|
63
|
+
timedOut?: boolean;
|
|
64
|
+
/** The timeout budget in ms, when timedOut applies. */
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
/** Provider error message from the response (response.errorMessage). */
|
|
67
|
+
errorMessage?: string;
|
|
68
|
+
/** Stack trace of a thrown error, when available. */
|
|
69
|
+
errorStack?: string;
|
|
70
|
+
/** Small config snapshot relevant to troubleshooting. */
|
|
71
|
+
config?: { maxTokens?: number; thinking: boolean; thinkingLevel: string };
|
|
72
|
+
/** The model being handed off to ("provider/id"), at the warn point. */
|
|
73
|
+
activeModel?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Directory holding this extension's logs:
|
|
77
|
+
* ~/.pi/agent/logs/pi-vision-handoff (overridable via $PI_CODING_AGENT_DIR). */
|
|
78
|
+
export function getErrorLogDir(): string {
|
|
79
|
+
return join(getAgentDir(), LOG_SUBDIR);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Path to the active error log file. */
|
|
83
|
+
export function getErrorLogPath(): string {
|
|
84
|
+
return join(getErrorLogDir(), LOG_FILENAME);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Serialize an entry to one JSONL line (JSON + terminating newline). Pure and
|
|
88
|
+
* unit-testable independent of the disk. */
|
|
89
|
+
export function formatErrorLogLine(entry: VisionErrorLogEntry): string {
|
|
90
|
+
return JSON.stringify(entry) + "\n";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Rotate the log when it has grown to at least `maxSize`: the current file
|
|
94
|
+
* becomes the `.1` backup (overwriting any prior backup) and the active path
|
|
95
|
+
* is cleared for a fresh log. Bounds total on-disk log to ~2× `maxSize`.
|
|
96
|
+
* No-op when the file doesn't exist or is under the cap. Never throws. */
|
|
97
|
+
export function rotateLogIfNeeded(path: string = getErrorLogPath(), maxSize: number = MAX_LOG_BYTES): void {
|
|
98
|
+
let size: number;
|
|
99
|
+
try {
|
|
100
|
+
size = statSync(path).size;
|
|
101
|
+
} catch {
|
|
102
|
+
return; // missing file — nothing to rotate
|
|
103
|
+
}
|
|
104
|
+
if (size < maxSize) return;
|
|
105
|
+
try {
|
|
106
|
+
renameSync(path, path + ".1");
|
|
107
|
+
} catch {
|
|
108
|
+
// rename failed (permissions, etc.) — leave the file; it'll keep growing
|
|
109
|
+
// until the condition clears. Logging must not throw.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Append one structured error entry to the log. Fills `timestamp`, ensures the
|
|
115
|
+
* log directory exists, rotates on size, and writes a JSONL line. Best-effort:
|
|
116
|
+
* swallows every error so a logging failure can NEVER break a describer turn
|
|
117
|
+
* (the describer calls this from its failure paths, where throwing would mask
|
|
118
|
+
* the real error and abort the batch).
|
|
119
|
+
*/
|
|
120
|
+
export function appendVisionError(input: Omit<VisionErrorLogEntry, "timestamp">): void {
|
|
121
|
+
try {
|
|
122
|
+
const dir = getErrorLogDir();
|
|
123
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
124
|
+
const path = getErrorLogPath();
|
|
125
|
+
rotateLogIfNeeded(path);
|
|
126
|
+
const entry: VisionErrorLogEntry = { timestamp: new Date().toISOString(), ...input };
|
|
127
|
+
appendFileSync(path, formatErrorLogLine(entry), "utf8");
|
|
128
|
+
} catch {
|
|
129
|
+
// never break the describer on a logging failure
|
|
130
|
+
}
|
|
131
|
+
}
|
package/vision-handoff.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "./src/usage.js";
|
|
52
52
|
import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
|
|
53
53
|
import { imageHash, findClipboardImagePaths, readImageBuffer, resolvePrewarmImage } from "./src/image.js";
|
|
54
|
+
import { appendVisionError } from "./src/error-log.js";
|
|
54
55
|
import { resizeImage } from "@earendil-works/pi-coding-agent";
|
|
55
56
|
import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
|
|
56
57
|
import { PrewarmEditor } from "./src/prewarm-editor.js";
|
|
@@ -194,14 +195,17 @@ function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
|
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
/** Notify once per failing image per session (dedup via warnedHashes)
|
|
198
|
+
/** Notify once per failing image per session (dedup via warnedHashes), and log
|
|
199
|
+
* EVERY failure (even in headless/SDK mode with no UI) to the error log so the
|
|
200
|
+
* user can troubleshoot. The log entry's `phase: "warn"` carries the failing
|
|
201
|
+
* image hashes and the surfaced reason — when the reason is "unknown error",
|
|
202
|
+
* the real cause lives in the matching `batch`/`single` entry for those hashes. */
|
|
198
203
|
function warnFailedImages(
|
|
199
204
|
ctx: ExtensionContext,
|
|
200
205
|
imgs: ExtractedImage[],
|
|
201
206
|
descs: string[],
|
|
202
207
|
reason: string,
|
|
203
208
|
): void {
|
|
204
|
-
if (!ctx.hasUI) return;
|
|
205
209
|
const newlyFailed: string[] = [];
|
|
206
210
|
for (let i = 0; i < imgs.length; i++) {
|
|
207
211
|
if (descs[i] === UNAVAILABLE) {
|
|
@@ -211,6 +215,17 @@ function warnFailedImages(
|
|
|
211
215
|
}
|
|
212
216
|
if (newlyFailed.length === 0) return;
|
|
213
217
|
for (const h of newlyFailed) warnedHashes.add(h);
|
|
218
|
+
// Always log: troubleshooting must work in headless/SDK mode too, where the
|
|
219
|
+
// notify below never fires.
|
|
220
|
+
appendVisionError({
|
|
221
|
+
phase: "warn",
|
|
222
|
+
reason,
|
|
223
|
+
visionModel: config.visionModel,
|
|
224
|
+
imageHashes: newlyFailed,
|
|
225
|
+
imageCount: newlyFailed.length,
|
|
226
|
+
activeModel: ctx.model ? formatModelRef(ctx.model.provider, ctx.model.id) : undefined,
|
|
227
|
+
});
|
|
228
|
+
if (!ctx.hasUI) return;
|
|
214
229
|
ctx.ui.notify(
|
|
215
230
|
`pi-vision-handoff: image description failed — ${reason}. Vision model: ${config.visionModel}`,
|
|
216
231
|
"warning",
|