@bismawy/pi-vision-watcher 1.0.7
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/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +68 -0
- package/src/dataloader.ts +383 -0
- package/src/describer.ts +546 -0
- package/src/dispose.ts +61 -0
- package/src/error-log.ts +131 -0
- package/src/image.ts +331 -0
- package/src/index.ts +580 -0
- package/src/prewarm-editor.ts +95 -0
- package/src/usage.ts +261 -0
- package/src/vision-model-selector.ts +452 -0
- package/vision-watcher.ts +1161 -0
- package/vitest.config.ts +15 -0
package/src/error-log.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort structured error logging for pi-vision-watcher.
|
|
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-watcher/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-watcher";
|
|
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-watcher (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/src/image.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure image IO utilities: MIME sniffing, dimension parsing, and resolving a
|
|
3
|
+
* pasted clipboard image file to the SAME {@link ExtractedImage} pi's `read`
|
|
4
|
+
* tool will emit — so a pre-warm's cache key matches the later `tool_result`'s
|
|
5
|
+
* key (no wasted vision call).
|
|
6
|
+
*
|
|
7
|
+
* Why this matters: pi's `read` tool runs `resizeImage` (on by default). For a
|
|
8
|
+
* small image (≤2000×2000 AND <4.5MB base64) the resize is a no-op that returns
|
|
9
|
+
* the raw input bytes unchanged — so our raw read matches. For an oversized
|
|
10
|
+
* image pi RE-ENCODES (Photon resize + possible JPEG@80), producing different
|
|
11
|
+
* bytes; pre-warming the raw file would then cache-miss at `tool_result` time
|
|
12
|
+
* and waste a vision call. {@link willBeResized} mirrors pi's threshold so we
|
|
13
|
+
* run the same {@link resolvePrewarmImage} pipeline and match the key in both
|
|
14
|
+
* cases.
|
|
15
|
+
*
|
|
16
|
+
* MIME sniffing is aligned with pi's `detectSupportedImageMimeType` (incl.
|
|
17
|
+
* rejecting animated PNG, which pi treats as a non-image → text) so our sniff
|
|
18
|
+
* agrees with pi on whether a file is an image at all.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { isAbsolute, join, sep } from "node:path";
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import type { ExtractedImage } from "./index.js";
|
|
26
|
+
|
|
27
|
+
// pi's resize defaults (see @earendil-works/pi-coding-agent utils/image-resize).
|
|
28
|
+
// The `read` tool returns raw input bytes unchanged when the image fits BOTH
|
|
29
|
+
// the dimension limit AND the base64-payload size limit; otherwise it resizes.
|
|
30
|
+
const RESIZE_MAX_WIDTH = 2000;
|
|
31
|
+
const RESIZE_MAX_HEIGHT = 2000;
|
|
32
|
+
const RESIZE_MAX_BYTES = 4.5 * 1024 * 1024; // base64 payload size
|
|
33
|
+
|
|
34
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
35
|
+
|
|
36
|
+
/** Stable hash of an image's MIME + base64 data, used as the dataloader key. */
|
|
37
|
+
export function imageHash(mimeType: string, data: string): string {
|
|
38
|
+
return crypto.createHash("sha256").update(`${mimeType}\x00${data}`).digest("hex").slice(0, 32);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readUint32BE(buf: Buffer, offset: number): number {
|
|
42
|
+
return (
|
|
43
|
+
(buf[offset] ?? 0) * 0x1000000 +
|
|
44
|
+
((buf[offset + 1] ?? 0) << 16) +
|
|
45
|
+
((buf[offset + 2] ?? 0) << 8) +
|
|
46
|
+
(buf[offset + 3] ?? 0)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readUint16BE(buf: Buffer, offset: number): number {
|
|
51
|
+
return ((buf[offset] ?? 0) << 8) + (buf[offset + 1] ?? 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readUint16LE(buf: Buffer, offset: number): number {
|
|
55
|
+
return (buf[offset] ?? 0) + ((buf[offset + 1] ?? 0) << 8);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function startsWithAscii(buf: Buffer, offset: number, text: string): boolean {
|
|
59
|
+
for (let i = 0; i < text.length; i++) {
|
|
60
|
+
if (buf[offset + i] !== text.charCodeAt(i)) return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isPng(buf: Buffer): boolean {
|
|
66
|
+
// IHDR chunk: length == 13 at offset 8, "IHDR" at offset 12.
|
|
67
|
+
return buf.length >= 16 && readUint32BE(buf, PNG_SIGNATURE.length) === 13 && startsWithAscii(buf, 12, "IHDR");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isAnimatedPng(buf: Buffer): boolean {
|
|
71
|
+
// Scan chunks for "acTL" (animation control) before "IDAT". Matches pi's
|
|
72
|
+
// detectSupportedImageMimeType so an animated PNG is treated as a non-image
|
|
73
|
+
// (pi reads it as text) and we skip pre-warming it.
|
|
74
|
+
let offset = PNG_SIGNATURE.length;
|
|
75
|
+
while (offset + 8 <= buf.length) {
|
|
76
|
+
const chunkLength = readUint32BE(buf, offset);
|
|
77
|
+
const typeOffset = offset + 4;
|
|
78
|
+
if (startsWithAscii(buf, typeOffset, "acTL")) return true;
|
|
79
|
+
if (startsWithAscii(buf, typeOffset, "IDAT")) return false;
|
|
80
|
+
const next = offset + 8 + chunkLength + 4;
|
|
81
|
+
if (next <= offset || next > buf.length) return false;
|
|
82
|
+
offset = next;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Sniff an image MIME type from magic bytes, aligned with pi's
|
|
88
|
+
* detectSupportedImageMimeType. Returns null for unsupported/animated PNG. */
|
|
89
|
+
export function sniffImageMime(buf: Buffer): string | null {
|
|
90
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
91
|
+
return buf[3] === 0xf7 ? null : "image/jpeg";
|
|
92
|
+
}
|
|
93
|
+
if (buf.length >= 8 && PNG_SIGNATURE.every((b, i) => buf[i] === b)) {
|
|
94
|
+
return isPng(buf) && !isAnimatedPng(buf) ? "image/png" : null;
|
|
95
|
+
}
|
|
96
|
+
if (buf.length >= 3 && startsWithAscii(buf, 0, "GIF")) return "image/gif";
|
|
97
|
+
if (buf.length >= 12 && startsWithAscii(buf, 0, "RIFF") && startsWithAscii(buf, 8, "WEBP")) {
|
|
98
|
+
return "image/webp";
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Parse an image's pixel dimensions from its headers (no decode). Returns null
|
|
104
|
+
* for an unsupported/unparseable format. The dimension check is orientation-
|
|
105
|
+
* invariant (`max(w,h) > 2000` is unchanged by EXIF rotation swaps), so it
|
|
106
|
+
* agrees with pi's resize decision even for EXIF-oriented images. */
|
|
107
|
+
export function imageDimensions(buf: Buffer, mimeType: string): { width: number; height: number } | null {
|
|
108
|
+
switch (mimeType) {
|
|
109
|
+
case "image/png": {
|
|
110
|
+
// IHDR: width (4 BE) at offset 16, height (4 BE) at offset 20.
|
|
111
|
+
if (buf.length < 24) return null;
|
|
112
|
+
return { width: readUint32BE(buf, 16), height: readUint32BE(buf, 20) };
|
|
113
|
+
}
|
|
114
|
+
case "image/gif": {
|
|
115
|
+
// Logical screen descriptor: width (2 LE) at 6, height (2 LE) at 8.
|
|
116
|
+
if (buf.length < 10) return null;
|
|
117
|
+
return { width: readUint16LE(buf, 6), height: readUint16LE(buf, 8) };
|
|
118
|
+
}
|
|
119
|
+
case "image/jpeg": {
|
|
120
|
+
return jpegDimensions(buf);
|
|
121
|
+
}
|
|
122
|
+
case "image/webp": {
|
|
123
|
+
return webpDimensions(buf);
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Scan JPEG segments for a SOF marker and read width/height. */
|
|
131
|
+
function jpegDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
132
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
133
|
+
let offset = 2;
|
|
134
|
+
while (offset + 9 <= buf.length) {
|
|
135
|
+
if (buf[offset] !== 0xff) return null;
|
|
136
|
+
// Skip fill bytes.
|
|
137
|
+
let marker = buf[offset + 1];
|
|
138
|
+
while (marker === 0xff && offset + 2 < buf.length) {
|
|
139
|
+
offset++;
|
|
140
|
+
marker = buf[offset + 1];
|
|
141
|
+
}
|
|
142
|
+
// Standalone markers (RSTn, SOI, EOI) have no length payload.
|
|
143
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
144
|
+
offset += 2;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (offset + 4 > buf.length) return null;
|
|
148
|
+
const segLen = readUint16BE(buf, offset + 2);
|
|
149
|
+
// SOF0–SOF15 (excluding RST/sof-defined non-SOF): C0–CF except C4 (DHT),
|
|
150
|
+
// C8 (JPG), CC (DAC).
|
|
151
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
152
|
+
if (offset + 9 > buf.length) return null;
|
|
153
|
+
const height = readUint16BE(buf, offset + 5);
|
|
154
|
+
const width = readUint16BE(buf, offset + 7);
|
|
155
|
+
return { width, height };
|
|
156
|
+
}
|
|
157
|
+
// SOS: image data follows, no more SOF.
|
|
158
|
+
if (marker === 0xda) return null;
|
|
159
|
+
offset += 2 + segLen;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse WEBP dimensions across VP8 (lossy), VP8L (lossless), VP8X (extended). */
|
|
165
|
+
function webpDimensions(buf: Buffer): { width: number; height: number } | null {
|
|
166
|
+
if (buf.length < 30 || !startsWithAscii(buf, 0, "RIFF") || !startsWithAscii(buf, 8, "WEBP")) return null;
|
|
167
|
+
const fourcc = buf.subarray(12, 16).toString("ascii");
|
|
168
|
+
if (fourcc === "VP8 ") {
|
|
169
|
+
// Lossy: 3-byte frame tag at 20, 3-byte start code at 23, width (2 LE &
|
|
170
|
+
// 0x3FFF) at 26, height (2 LE & 0x3FFF) at 28.
|
|
171
|
+
return { width: readUint16LE(buf, 26) & 0x3fff, height: readUint16LE(buf, 28) & 0x3fff };
|
|
172
|
+
}
|
|
173
|
+
if (fourcc === "VP8L") {
|
|
174
|
+
// Lossless: 0x2F signature at 20, then 14-bit (width-1) + 14-bit (height-1)
|
|
175
|
+
// packed LSB-first across bytes 21–24.
|
|
176
|
+
if (buf[20] !== 0x2f) return null;
|
|
177
|
+
const val = (buf[21] ?? 0) | ((buf[22] ?? 0) << 8) | ((buf[23] ?? 0) << 16) | ((buf[24] ?? 0) << 24);
|
|
178
|
+
return { width: 1 + (val & 0x3fff), height: 1 + ((val >> 14) & 0x3fff) };
|
|
179
|
+
}
|
|
180
|
+
if (fourcc === "VP8X") {
|
|
181
|
+
// Extended: canvas width-1 (24-bit LE) at 24, height-1 (24-bit LE) at 27.
|
|
182
|
+
const w = 1 + ((buf[24] ?? 0) | ((buf[25] ?? 0) << 8) | ((buf[26] ?? 0) << 16));
|
|
183
|
+
const h = 1 + ((buf[27] ?? 0) | ((buf[28] ?? 0) << 8) | ((buf[29] ?? 0) << 16));
|
|
184
|
+
return { width: w, height: h };
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Base64-encoded size of `byteLength` raw bytes (4/3 ratio, ceil). */
|
|
190
|
+
function base64Size(byteLength: number): number {
|
|
191
|
+
return Math.ceil(byteLength / 3) * 4;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether pi's `read` tool would RESIZE (re-encode) this image. When false,
|
|
195
|
+
* pi returns the raw input bytes unchanged — so a raw pre-warm matches the
|
|
196
|
+
* `tool_result` key. When true, pi re-encodes — the caller must run the same
|
|
197
|
+
* resize pipeline to match. Mirrors pi's resize threshold exactly. Unknown
|
|
198
|
+
* dimensions default to `true` (force the resize path, which always matches). */
|
|
199
|
+
export function willBeResized(buf: Buffer, mimeType: string): boolean {
|
|
200
|
+
const dims = imageDimensions(buf, mimeType);
|
|
201
|
+
if (!dims) return true;
|
|
202
|
+
return (
|
|
203
|
+
dims.width > RESIZE_MAX_WIDTH ||
|
|
204
|
+
dims.height > RESIZE_MAX_HEIGHT ||
|
|
205
|
+
base64Size(buf.length) >= RESIZE_MAX_BYTES
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A resize function shaped like pi's `resizeImage` (injected so this module
|
|
210
|
+
* stays free of the pi-coding-agent dependency and fully unit-testable). */
|
|
211
|
+
export type ResizeFn = (
|
|
212
|
+
inputBytes: Uint8Array,
|
|
213
|
+
mimeType: string,
|
|
214
|
+
) => Promise<{ data: string; mimeType: string } | null>;
|
|
215
|
+
|
|
216
|
+
/** Resolve a clipboard image buffer to the SAME {@link ExtractedImage} pi's
|
|
217
|
+
* `read` tool will emit, so the pre-warm's cache key matches the later
|
|
218
|
+
* `tool_result`'s key (no wasted vision call):
|
|
219
|
+
* - no-resize case (≤2000² & <4.5MB base64): return the raw bytes — pi's
|
|
220
|
+
* no-resize path returns `inputBytes` unchanged.
|
|
221
|
+
* - resize case: run the same `resize` pipeline (pi's `resizeImage`) and
|
|
222
|
+
* return the re-encoded data — matches pi's resize path.
|
|
223
|
+
* Returns null if resize failed (pi then emits no image block, so there is
|
|
224
|
+
* nothing to pre-warm) or if the bytes aren't a supported image. */
|
|
225
|
+
export async function resolvePrewarmImage(
|
|
226
|
+
buf: Buffer,
|
|
227
|
+
mimeType: string,
|
|
228
|
+
resize: ResizeFn,
|
|
229
|
+
): Promise<ExtractedImage | null> {
|
|
230
|
+
if (!willBeResized(buf, mimeType)) {
|
|
231
|
+
return { data: buf.toString("base64"), mimeType };
|
|
232
|
+
}
|
|
233
|
+
const resized = await resize(buf, mimeType);
|
|
234
|
+
if (!resized) return null;
|
|
235
|
+
return { data: resized.data, mimeType: resized.mimeType };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Read an image file into its raw buffer + sniffed MIME. Returns null if the
|
|
239
|
+
* file can't be read or isn't a supported image (aligned with pi's sniff). */
|
|
240
|
+
export function readImageBuffer(filePath: string): { buf: Buffer; mimeType: string } | null {
|
|
241
|
+
try {
|
|
242
|
+
const buf = readFileSync(filePath);
|
|
243
|
+
const mimeType = sniffImageMime(buf);
|
|
244
|
+
if (!mimeType) return null;
|
|
245
|
+
return { buf, mimeType };
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Max raw file size the omitted-image recovery will re-read. pi's `read`
|
|
252
|
+
* already read the file; this bounds the handoff's re-read so a pathologically
|
|
253
|
+
* huge file doesn't double the memory. 20MB covers the most generous vision
|
|
254
|
+
* model inline-image limit; a larger file would be rejected by the vision
|
|
255
|
+
* model anyway. */
|
|
256
|
+
export const MAX_RECOVER_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
257
|
+
|
|
258
|
+
/** pi core's `read` tool emits this text note (with NO image block) when it
|
|
259
|
+
* detected an image but `processImage` failed — Photon/WASM unavailable,
|
|
260
|
+
* decode failure, convert-to-PNG failure, or couldn't resize below the inline
|
|
261
|
+
* size limit. The handoff's image-block path never sees these (no image
|
|
262
|
+
* block), so the image goes undescribed and the model is told the image was
|
|
263
|
+
* "omitted". This detects that note so the tool_result handler can re-read the
|
|
264
|
+
* raw file and describe its bytes directly (the vision model decodes them —
|
|
265
|
+
* no Photon needed). */
|
|
266
|
+
export function isOmittedImageNote(text: string): boolean {
|
|
267
|
+
return text.includes("Read image file [") && text.includes("[Image omitted:");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Read an image file for the omitted-image recovery, bounded by
|
|
271
|
+
* {@link MAX_RECOVER_IMAGE_BYTES}. Returns null if the file can't be read, is
|
|
272
|
+
* too large, or isn't a supported image (aligned with pi's sniff — APNG and
|
|
273
|
+
* unsupported formats are rejected, since the vision model can't decode them
|
|
274
|
+
* either). */
|
|
275
|
+
export function readImageBufferBounded(filePath: string): { buf: Buffer; mimeType: string } | null {
|
|
276
|
+
try {
|
|
277
|
+
const stat = statSync(filePath);
|
|
278
|
+
if (stat.size > MAX_RECOVER_IMAGE_BYTES) return null;
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return readImageBuffer(filePath);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Regex matching any pasted temp-image file path in the prompt text,
|
|
286
|
+
* regardless of which paste mechanism wrote it (pi-clipboard,
|
|
287
|
+
* localterm-paste, and others). We match any non-whitespace token ending in
|
|
288
|
+
* a supported image extension, then confine to the OS temp directory below.
|
|
289
|
+
* The confinement, not the filename pattern, is the security boundary. */
|
|
290
|
+
const PASTED_IMAGE_PATH_RE = /(\S+\.(?:png|jpe?g|gif|webp))/gi;
|
|
291
|
+
const URL_RE = new RegExp('^[a-z][a-z0-9+.-]*://', 'i');
|
|
292
|
+
const LEADING_WRAP_RE = new RegExp('^[^A-Za-z0-9_/@.~-]+');
|
|
293
|
+
|
|
294
|
+
/** Extract pasted image file paths from a prompt. Confined to the OS temp
|
|
295
|
+
* directory so an attacker-crafted prompt can't trick the extension into
|
|
296
|
+
* reading arbitrary files — only temp-dir image files qualify (this covers
|
|
297
|
+
* every paste mechanism: pi-clipboard, localterm-paste, and others). URLs
|
|
298
|
+
* (http(s)://, file://, ...) are skipped so they aren't joined into the temp
|
|
299
|
+
* dir and mistaken for local files; leading wrapping chars (parentheses,
|
|
300
|
+
* quotes) are stripped so quoted/parenthesized paths still resolve. */
|
|
301
|
+
export function findPastedImagePaths(prompt: string): string[] {
|
|
302
|
+
const tmp = tmpdir();
|
|
303
|
+
const paths = new Set<string>();
|
|
304
|
+
for (const m of prompt.matchAll(PASTED_IMAGE_PATH_RE)) {
|
|
305
|
+
const token = m[1];
|
|
306
|
+
if (!token) continue;
|
|
307
|
+
if (URL_RE.test(token)) continue;
|
|
308
|
+
const p = token.replace(LEADING_WRAP_RE, "");
|
|
309
|
+
const abs = isAbsolute(p) ? p : join(tmp, p);
|
|
310
|
+
// Ensure the resolved candidate stays inside the temp directory.
|
|
311
|
+
if (abs.startsWith(tmp + sep) || abs === tmp) paths.add(abs);
|
|
312
|
+
}
|
|
313
|
+
return [...paths];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Diff the pasted image paths in `text` against `known`, returning those
|
|
317
|
+
* that are newly appeared (not yet seen). Used by the paste-time prewarm
|
|
318
|
+
* editor to prewarm only newly-pasted paths on each text change, without
|
|
319
|
+
* re-reading already-seen ones. `findPastedImagePaths` already dedups
|
|
320
|
+
* within a single text, so each returned path is unique and seen at most
|
|
321
|
+
* once. Returns [] when `text` holds no pasted paths (e.g. ordinary
|
|
322
|
+
* typing) — so the editor's per-keystroke cost when the opt-in is on is one
|
|
323
|
+
* regex scan that almost always yields nothing. */
|
|
324
|
+
export function diffPrewarmPaths(text: string, known: Set<string>): string[] {
|
|
325
|
+
const paths = findPastedImagePaths(text);
|
|
326
|
+
const newPaths: string[] = [];
|
|
327
|
+
for (const p of paths) {
|
|
328
|
+
if (!known.has(p)) newPaths.push(p);
|
|
329
|
+
}
|
|
330
|
+
return newPaths;
|
|
331
|
+
}
|