pi-vision-handoff 0.3.0 → 0.3.2
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 +155 -48
- package/package.json +4 -4
- package/src/dataloader.ts +208 -0
- package/src/describer.ts +320 -0
- package/src/dispose.ts +61 -0
- package/src/image.ts +274 -0
- package/src/index.ts +133 -7
- package/src/usage.ts +17 -1
- package/vision-handoff.ts +284 -229
package/src/describer.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vision describer: calls a vision-capable model via pi-ai's `complete()`
|
|
3
|
+
* to produce text descriptions of images.
|
|
4
|
+
*
|
|
5
|
+
* Two entry points:
|
|
6
|
+
* - {@link runBatch}: ONE batched call describing N images at once (the
|
|
7
|
+
* dataloader's dispatch path). The model is asked to emit one delimited
|
|
8
|
+
* `<<<IMAGE k>>> … <<<END>>>` section per image so the response can be
|
|
9
|
+
* split back into per-image descriptions.
|
|
10
|
+
* - {@link describeSingle}: one call for one image (no delimiters). The
|
|
11
|
+
* robust per-image fallback used when a batched response couldn't be split.
|
|
12
|
+
*
|
|
13
|
+
* Resource lifetimes (fetch interceptor, timeout timer, turn-abort wire) are
|
|
14
|
+
* managed with the `using` keyword via the {@link Disposable} guards in
|
|
15
|
+
* `dispose.ts`, replacing the manual `try`/`finally` cleanup the old code
|
|
16
|
+
* carried. Disposing is lexical and exception-safe: a thrown `complete()`
|
|
17
|
+
* still tears down the timer, uninstalls the interceptor, and detaches the
|
|
18
|
+
* abort listener.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Api, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai";
|
|
22
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
23
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import {
|
|
25
|
+
batchUserPrompt,
|
|
26
|
+
DEFAULT_USER_PROMPT_PREFIX,
|
|
27
|
+
DEFAULT_VISION_PROMPT,
|
|
28
|
+
describeTimeoutMs,
|
|
29
|
+
markDescriptionTruncated,
|
|
30
|
+
parseBatchedDescriptions,
|
|
31
|
+
type ExtractedImage,
|
|
32
|
+
type VisionHandoffConfig,
|
|
33
|
+
} from "./index.js";
|
|
34
|
+
import {
|
|
35
|
+
buildUsageRecord,
|
|
36
|
+
describeAls,
|
|
37
|
+
EMPTY_ENERGY_CAPTURE,
|
|
38
|
+
type DescribeContext,
|
|
39
|
+
type VisionHandoffEnergyCapture,
|
|
40
|
+
type VisionHandoffUsageRecord,
|
|
41
|
+
} from "./usage.js";
|
|
42
|
+
import { imageHash } from "./image.js";
|
|
43
|
+
import { abortWireGuard, fetchInterceptorGuard, timeoutGuard, type AbortWire } from "./dispose.js";
|
|
44
|
+
|
|
45
|
+
/** Tokens reserved for the describer's input so the requested output budget
|
|
46
|
+
* can't exceed the model's context window. The describer's input is bounded —
|
|
47
|
+
* one (or a few) image(s) plus a short prompt — so a fixed reserve keeps the
|
|
48
|
+
* math simple while leaving generous room for the image + prompt tokens.
|
|
49
|
+
* Providers reject a request when `inputTokens + maxTokens > contextWindow`
|
|
50
|
+
* (e.g. a model whose declared maxTokens equals its full context window), so
|
|
51
|
+
* this clamp prevents that 400 without meaningfully limiting a high-output
|
|
52
|
+
* model (e.g. 262144 context − 8192 reserve = 253952 output budget). */
|
|
53
|
+
const INPUT_RESERVE_TOKENS = 8192;
|
|
54
|
+
|
|
55
|
+
/** Resolve the `maxTokens` to pass to `complete()` for a describer call.
|
|
56
|
+
*
|
|
57
|
+
* - A configured `cfg.maxTokens` wins (explicit cost/latency cap), clamped to
|
|
58
|
+
* fit the context window.
|
|
59
|
+
* - Otherwise use the vision model's declared max output (`model.maxTokens`),
|
|
60
|
+
* clamped so `maxTokens + INPUT_RESERVE_TOKENS <= contextWindow` — i.e. the
|
|
61
|
+
* requested output can't exceed the context window minus a reserve for the
|
|
62
|
+
* input. This is the real fix for models whose declared `maxTokens` equals
|
|
63
|
+
* their full `contextWindow` (which providers reject with any non-empty
|
|
64
|
+
* input): we cap at `contextWindow − reserve` instead.
|
|
65
|
+
* - If the model declares no usable max (`maxTokens <= 0`) and no cap is
|
|
66
|
+
* configured, return `undefined` so the provider applies its own default. */
|
|
67
|
+
export function resolveMaxTokens(
|
|
68
|
+
cfg: VisionHandoffConfig,
|
|
69
|
+
visionModel: Model<Api>,
|
|
70
|
+
): number | undefined {
|
|
71
|
+
const ctx = visionModel.contextWindow > 0 ? visionModel.contextWindow : Number.POSITIVE_INFINITY;
|
|
72
|
+
const cap = ctx - INPUT_RESERVE_TOKENS;
|
|
73
|
+
const requested = cfg.maxTokens ?? (visionModel.maxTokens > 0 ? visionModel.maxTokens : undefined);
|
|
74
|
+
if (requested === undefined) return undefined;
|
|
75
|
+
// Clamp to the context-window-derived ceiling; never below 1.
|
|
76
|
+
return Math.max(1, Math.min(requested, cap));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Dependencies the describer can't own itself (held by the engine). */
|
|
80
|
+
export interface DescriberDeps {
|
|
81
|
+
/** Report a usage+energy record for one real describer call (cache hits emit none). */
|
|
82
|
+
reportUsage(record: VisionHandoffUsageRecord): void;
|
|
83
|
+
/** Set the most-recent describer failure message (surfaced to the user by the engine).
|
|
84
|
+
* Pass `null` to clear before a fresh attempt. */
|
|
85
|
+
setLastError(msg: string | null): void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The result of a batched describer call: per-image raw descriptions keyed by hash. */
|
|
89
|
+
export type BatchResult = Map<string, string>;
|
|
90
|
+
|
|
91
|
+
/** Describe N images with ONE batched `complete()` call and split the response
|
|
92
|
+
* back into per-image descriptions. Returns a map keyed by image hash; an
|
|
93
|
+
* image whose section failed to parse is omitted (the caller treats omission
|
|
94
|
+
* as "description unavailable"). On a genuine call failure (auth, abort,
|
|
95
|
+
* empty) the map is empty and `deps.setLastError` records the reason.
|
|
96
|
+
*
|
|
97
|
+
* A failed delimiter-parse is a failed BATCH, not a failed description: the
|
|
98
|
+
* unparsed images fall back to parallel single-image calls (no delimiters to
|
|
99
|
+
* cooperate with), so the common cooperative case stays one call while an
|
|
100
|
+
* uncooperative model still gets every image described. */
|
|
101
|
+
export async function runBatch(
|
|
102
|
+
misses: { img: ExtractedImage; hash: string }[],
|
|
103
|
+
userPrompt: string,
|
|
104
|
+
visionModel: Model<Api>,
|
|
105
|
+
modelRegistry: ModelRegistry,
|
|
106
|
+
cfg: VisionHandoffConfig,
|
|
107
|
+
deps: DescriberDeps,
|
|
108
|
+
turnSignal?: AbortSignal,
|
|
109
|
+
): Promise<BatchResult> {
|
|
110
|
+
const out: BatchResult = new Map();
|
|
111
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
112
|
+
if (!auth.ok || !auth.apiKey) {
|
|
113
|
+
deps.setLastError(
|
|
114
|
+
!auth.ok ? auth.error : `No API key for vision model "${visionModel.provider}/${visionModel.id}"`,
|
|
115
|
+
);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const prefix = cfg.userPromptPrefix ?? DEFAULT_USER_PROMPT_PREFIX;
|
|
120
|
+
const systemPrompt = cfg.prompt ?? DEFAULT_VISION_PROMPT;
|
|
121
|
+
const content: (TextContent | ImageContent)[] = [
|
|
122
|
+
{ type: "text", text: batchUserPrompt(misses.length, userPrompt, prefix) },
|
|
123
|
+
...misses.map((m) => ({ type: "image", data: m.img.data, mimeType: m.img.mimeType } satisfies ImageContent)),
|
|
124
|
+
];
|
|
125
|
+
const userMessage: Message = { role: "user", content, timestamp: Date.now() };
|
|
126
|
+
|
|
127
|
+
const timeoutMs = describeTimeoutMs(misses.length);
|
|
128
|
+
const maxTokens = resolveMaxTokens(cfg, visionModel);
|
|
129
|
+
const controller = new AbortController();
|
|
130
|
+
let timedOut = false;
|
|
131
|
+
using fetchGuard = fetchInterceptorGuard();
|
|
132
|
+
using timer = timeoutGuard(timeoutMs, () => {
|
|
133
|
+
timedOut = true;
|
|
134
|
+
controller.abort();
|
|
135
|
+
});
|
|
136
|
+
using abortWire = abortWireGuard(turnSignal, controller);
|
|
137
|
+
|
|
138
|
+
const describeCtx: DescribeContext = { energyReader: undefined };
|
|
139
|
+
try {
|
|
140
|
+
const response = await describeAls.run(describeCtx, async () =>
|
|
141
|
+
complete(
|
|
142
|
+
visionModel,
|
|
143
|
+
{ systemPrompt, messages: [userMessage] },
|
|
144
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: controller.signal, maxTokens },
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
const capture = await readCapture(describeCtx);
|
|
148
|
+
const hashes = misses.map((m) => m.hash);
|
|
149
|
+
const record = buildUsageRecord(response, capture, visionModel, hashes[0], hashes.length > 1 ? hashes : undefined);
|
|
150
|
+
if (record) deps.reportUsage(record);
|
|
151
|
+
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
152
|
+
setStopReasonError(deps, response.stopReason, response.errorMessage, abortWire, timedOut, timeoutMs);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
const text = response.content
|
|
156
|
+
.filter((c): c is TextContent => c.type === "text")
|
|
157
|
+
.map((c) => c.text)
|
|
158
|
+
.join("\n")
|
|
159
|
+
.trim();
|
|
160
|
+
if (!text) {
|
|
161
|
+
deps.setLastError("vision model returned an empty description");
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
const parsed = parseBatchedDescriptions(text, misses.length);
|
|
165
|
+
for (let i = 0; i < misses.length; i++) {
|
|
166
|
+
const d = parsed[i];
|
|
167
|
+
if (d) out.set(misses[i].hash, d);
|
|
168
|
+
}
|
|
169
|
+
// stopReason "length" = the batch response was cut off mid-stream. The last
|
|
170
|
+
// image being emitted when the cap hit is the one whose section is truncated
|
|
171
|
+
// (parseBatchedDescriptions still captures it via the end-of-text lookahead,
|
|
172
|
+
// just with partial content). Mark that one — the highest-index parsed slot —
|
|
173
|
+
// so the agent/user know it's incomplete. Earlier sections had `<<<END>>>`
|
|
174
|
+
// markers and are complete. We deliberately do NOT re-describe the truncated
|
|
175
|
+
// image: a single call for the same complex image would likely hit the same
|
|
176
|
+
// limit, so the partial text + marker is the honest, no-wasted-call result.
|
|
177
|
+
if (response.stopReason === "length") {
|
|
178
|
+
for (let i = misses.length - 1; i >= 0; i--) {
|
|
179
|
+
const h = misses[i].hash;
|
|
180
|
+
if (out.has(h)) {
|
|
181
|
+
out.set(h, markDescriptionTruncated(out.get(h)!));
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Fallback: a failed delimiter-parse is NOT a failed description — it's a
|
|
187
|
+
// failed batching. Describe each unparsed image with its own single-image
|
|
188
|
+
// call (no delimiters to cooperate with). The calls run in parallel so
|
|
189
|
+
// their results still arrive together, not sequentially.
|
|
190
|
+
const unparsed = misses.filter((m) => !out.has(m.hash));
|
|
191
|
+
if (unparsed.length > 0) {
|
|
192
|
+
const fallbacks = await Promise.all(
|
|
193
|
+
unparsed.map((m) => describeSingle(m.img, userPrompt, visionModel, modelRegistry, cfg, deps, turnSignal)),
|
|
194
|
+
);
|
|
195
|
+
for (let i = 0; i < unparsed.length; i++) {
|
|
196
|
+
if (fallbacks[i]) out.set(unparsed[i].hash, fallbacks[i]!);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
deps.setLastError(
|
|
202
|
+
timedOut ? `describer timed out after ${timeoutMs / 1000}s` : err instanceof Error ? err.message : String(err),
|
|
203
|
+
);
|
|
204
|
+
return out;
|
|
205
|
+
} finally {
|
|
206
|
+
// The `using` guards above already released the fetch interceptor, timer,
|
|
207
|
+
// and abort wire. Only the energy tee needs an explicit unhandled-rejection
|
|
208
|
+
// swallow: if the main stream aborted, the tee rejects too.
|
|
209
|
+
describeCtx.energyReader?.catch(() => {});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Describe a single image with one `complete()` call and return the RAW
|
|
214
|
+
* description (no envelope, no truncation). Returns null on any genuine
|
|
215
|
+
* failure (auth, abort/error, empty) so the caller only caches `UNAVAILABLE`
|
|
216
|
+
* when a real describer attempt failed. */
|
|
217
|
+
export async function describeSingle(
|
|
218
|
+
img: ExtractedImage,
|
|
219
|
+
userPrompt: string,
|
|
220
|
+
visionModel: Model<Api>,
|
|
221
|
+
modelRegistry: ModelRegistry,
|
|
222
|
+
cfg: VisionHandoffConfig,
|
|
223
|
+
deps: DescriberDeps,
|
|
224
|
+
turnSignal?: AbortSignal,
|
|
225
|
+
): Promise<string | null> {
|
|
226
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
227
|
+
if (!auth.ok || !auth.apiKey) {
|
|
228
|
+
deps.setLastError(
|
|
229
|
+
!auth.ok ? auth.error : `No API key for vision model "${visionModel.provider}/${visionModel.id}"`,
|
|
230
|
+
);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const prefix = cfg.userPromptPrefix ?? DEFAULT_USER_PROMPT_PREFIX;
|
|
234
|
+
const systemPrompt = cfg.prompt ?? DEFAULT_VISION_PROMPT;
|
|
235
|
+
const content: (TextContent | ImageContent)[] = [
|
|
236
|
+
{ type: "text", text: batchUserPrompt(1, userPrompt, prefix) },
|
|
237
|
+
{ type: "image", data: img.data, mimeType: img.mimeType } satisfies ImageContent,
|
|
238
|
+
];
|
|
239
|
+
const userMessage: Message = { role: "user", content, timestamp: Date.now() };
|
|
240
|
+
const timeoutMs = describeTimeoutMs(1);
|
|
241
|
+
const maxTokens = resolveMaxTokens(cfg, visionModel);
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
let timedOut = false;
|
|
244
|
+
using fetchGuard = fetchInterceptorGuard();
|
|
245
|
+
using timer = timeoutGuard(timeoutMs, () => {
|
|
246
|
+
timedOut = true;
|
|
247
|
+
controller.abort();
|
|
248
|
+
});
|
|
249
|
+
using abortWire = abortWireGuard(turnSignal, controller);
|
|
250
|
+
|
|
251
|
+
const describeCtx: DescribeContext = { energyReader: undefined };
|
|
252
|
+
try {
|
|
253
|
+
const response = await describeAls.run(describeCtx, async () =>
|
|
254
|
+
complete(
|
|
255
|
+
visionModel,
|
|
256
|
+
{ systemPrompt, messages: [userMessage] },
|
|
257
|
+
{ apiKey: auth.apiKey, headers: auth.headers, signal: controller.signal, maxTokens },
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
const capture = await readCapture(describeCtx);
|
|
261
|
+
const hash = imageHash(img.mimeType, img.data);
|
|
262
|
+
const record = buildUsageRecord(response, capture, visionModel, hash);
|
|
263
|
+
if (record) deps.reportUsage(record);
|
|
264
|
+
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
265
|
+
setStopReasonError(deps, response.stopReason, response.errorMessage, abortWire, timedOut, timeoutMs);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const text = response.content
|
|
269
|
+
.filter((c): c is TextContent => c.type === "text")
|
|
270
|
+
.map((c) => c.text)
|
|
271
|
+
.join("\n")
|
|
272
|
+
.trim();
|
|
273
|
+
if (!text) {
|
|
274
|
+
deps.setLastError("vision model returned an empty description");
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
// stopReason "length" = the model hit a token limit (configured maxTokens or
|
|
278
|
+
// the provider's hard output cap) before finishing. The partial text is still
|
|
279
|
+
// useful, but it must not pass as complete — mark it so the agent/user know.
|
|
280
|
+
return response.stopReason === "length" ? markDescriptionTruncated(text) : text;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
deps.setLastError(
|
|
283
|
+
timedOut ? `describer timed out after ${timeoutMs / 1000}s` : err instanceof Error ? err.message : String(err),
|
|
284
|
+
);
|
|
285
|
+
return null;
|
|
286
|
+
} finally {
|
|
287
|
+
describeCtx.energyReader?.catch(() => {});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Read the energy tee for a describer call, if one was captured. Returns the
|
|
292
|
+
* empty capture when there is no reader (non-Neuralwatt models) or the tee
|
|
293
|
+
* aborted with the main stream. */
|
|
294
|
+
async function readCapture(describeCtx: DescribeContext): Promise<VisionHandoffEnergyCapture> {
|
|
295
|
+
if (!describeCtx.energyReader) return EMPTY_ENERGY_CAPTURE;
|
|
296
|
+
try {
|
|
297
|
+
return await describeCtx.energyReader;
|
|
298
|
+
} catch {
|
|
299
|
+
return EMPTY_ENERGY_CAPTURE;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Translate a non-OK stopReason into a user-facing failure message, unless the
|
|
304
|
+
* abort came from the user cancelling the turn (then stay silent — no warning
|
|
305
|
+
* for a deliberate cancel). */
|
|
306
|
+
function setStopReasonError(
|
|
307
|
+
deps: DescriberDeps,
|
|
308
|
+
stopReason: string,
|
|
309
|
+
errorMessage: string | undefined,
|
|
310
|
+
abortWire: AbortWire,
|
|
311
|
+
timedOut: boolean,
|
|
312
|
+
timeoutMs: number,
|
|
313
|
+
): void {
|
|
314
|
+
if (abortWire.userAborted()) return; // user cancelled the turn — no warning
|
|
315
|
+
if (timedOut) {
|
|
316
|
+
deps.setLastError(`describer timed out after ${timeoutMs / 1000}s`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
deps.setLastError(`vision model returned stopReason "${stopReason}"${errorMessage ? ": " + errorMessage : ""}`);
|
|
320
|
+
}
|
package/src/dispose.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Disposable` guard factories for the `using` keyword (Explicit Resource
|
|
3
|
+
* Management). Each factory acquires a resource and returns a `Disposable`
|
|
4
|
+
* whose `[Symbol.dispose]` releases it, so a `using` binding replaces a
|
|
5
|
+
* manual acquire/`try`/`finally`/release pair.
|
|
6
|
+
*
|
|
7
|
+
* Used by the describer to bundle the fetch interceptor, the timeout timer,
|
|
8
|
+
* and the turn-abort wire into lexical scopes whose cleanup is automatic.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { installFetchInterceptor, uninstallFetchInterceptor } from "./usage.js";
|
|
12
|
+
|
|
13
|
+
/** A `Disposable` that uninstalls the refcounted fetch interceptor on release. */
|
|
14
|
+
export function fetchInterceptorGuard(): Disposable {
|
|
15
|
+
installFetchInterceptor();
|
|
16
|
+
return {
|
|
17
|
+
[Symbol.dispose]() {
|
|
18
|
+
uninstallFetchInterceptor();
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A `Disposable` that clears a `setTimeout` handle on release. */
|
|
24
|
+
export function timeoutGuard(ms: number, onTimeout: () => void): Disposable {
|
|
25
|
+
const handle = setTimeout(onTimeout, ms);
|
|
26
|
+
return {
|
|
27
|
+
[Symbol.dispose]() {
|
|
28
|
+
clearTimeout(handle);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A `Disposable` abort-wire: propagates a turn abort signal into a
|
|
34
|
+
* describer's `AbortController`, and detaches the listener on release.
|
|
35
|
+
* Also exposes `userAborted()` so the describer can tell a deliberate user
|
|
36
|
+
* cancel apart from a provider/timeout abort (to suppress spurious warnings).
|
|
37
|
+
*
|
|
38
|
+
* Always returns a `Disposable` (a no-op when there is no turn signal) so a
|
|
39
|
+
* `using` binding never has to null-check. */
|
|
40
|
+
export interface AbortWire extends Disposable {
|
|
41
|
+
/** True iff the abort originated from the turn signal (a user cancel). */
|
|
42
|
+
userAborted(): boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function abortWireGuard(turnSignal: AbortSignal | undefined, controller: AbortController): AbortWire {
|
|
46
|
+
if (!turnSignal) {
|
|
47
|
+
return {
|
|
48
|
+
[Symbol.dispose]() {},
|
|
49
|
+
userAborted: () => false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const onAbort = () => controller.abort();
|
|
53
|
+
if (turnSignal.aborted) controller.abort();
|
|
54
|
+
else turnSignal.addEventListener("abort", onAbort, { once: true });
|
|
55
|
+
return {
|
|
56
|
+
[Symbol.dispose]() {
|
|
57
|
+
turnSignal.removeEventListener("abort", onAbort);
|
|
58
|
+
},
|
|
59
|
+
userAborted: () => turnSignal.aborted,
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
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 } 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
|
+
/** Regex matching pi's pasted-clipboard temp image file paths anywhere in the
|
|
252
|
+
* prompt text. pi writes pasted clipboard images to
|
|
253
|
+
* `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the path as text at the
|
|
254
|
+
* cursor, so on a non-vision model these arrive as path tokens in the user
|
|
255
|
+
* prompt — NOT as `event.images`. Matching them lets the extension pre-warm
|
|
256
|
+
* the describer at paste-enter (concurrent with the agent's first response)
|
|
257
|
+
* instead of waiting for the agent to `read` them. */
|
|
258
|
+
const CLIPBOARD_IMAGE_PATH_RE = /(\S*pi-clipboard-[^\s]+\.(?:png|jpe?g|gif|webp))/gi;
|
|
259
|
+
|
|
260
|
+
/** Extract pasted clipboard image file paths from a prompt. Confined to the
|
|
261
|
+
* OS temp directory so an attacker-crafted prompt can't trick the extension
|
|
262
|
+
* into reading arbitrary files — only pi's own clipboard temp files qualify. */
|
|
263
|
+
export function findClipboardImagePaths(prompt: string): string[] {
|
|
264
|
+
const tmp = tmpdir();
|
|
265
|
+
const paths = new Set<string>();
|
|
266
|
+
for (const m of prompt.matchAll(CLIPBOARD_IMAGE_PATH_RE)) {
|
|
267
|
+
const p = m[1];
|
|
268
|
+
if (!p) continue;
|
|
269
|
+
const abs = isAbsolute(p) ? p : join(tmp, p);
|
|
270
|
+
// Ensure the resolved candidate stays inside the temp directory.
|
|
271
|
+
if (abs.startsWith(tmp + sep) || abs === tmp) paths.add(abs);
|
|
272
|
+
}
|
|
273
|
+
return [...paths];
|
|
274
|
+
}
|