@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.
@@ -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
+ }