@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/index.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants, types, and utilities for pi-vision-watcher.
|
|
3
|
+
*
|
|
4
|
+
* Config lives at ~/.pi/agent/extensions/pi-vision-watcher.json — the same
|
|
5
|
+
* convention pi-model-sort uses for picker-backed extensions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ImageContent, TextContent, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export * from "./usage.js";
|
|
14
|
+
|
|
15
|
+
/** Subdirectory under the pi agent dir where picker extensions store config. */
|
|
16
|
+
const CONFIG_SUBDIR = "extensions";
|
|
17
|
+
|
|
18
|
+
/** Config file name. */
|
|
19
|
+
export const CONFIG_FILENAME = "pi-vision-watcher.json";
|
|
20
|
+
|
|
21
|
+
/** Full config path: ~/.pi/agent/extensions/pi-vision-watcher.json */
|
|
22
|
+
export function getConfigPath(): string {
|
|
23
|
+
return join(getAgentDir(), CONFIG_SUBDIR, CONFIG_FILENAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Description shown in the / commands list. */
|
|
27
|
+
export const HANDOFF_COMMAND_DESCRIPTION =
|
|
28
|
+
"Configure vision watcher — pick a vision model to describe images for text-only models";
|
|
29
|
+
|
|
30
|
+
/** Default system prompt for the vision describer. Mirrors the pi-umans-provider pipeline. */
|
|
31
|
+
export const DEFAULT_VISION_PROMPT =
|
|
32
|
+
"You are a vision assistant for a coding agent. Describe this image exhaustively. Cover: all visible text (verbatim if possible), code snippets, UI layout and widgets, diagrams and flow arrows, error messages and stack traces, file trees, terminal output, color and style details, spatial relationships between elements, and anything else a developer would need to act on this image. Do not summarize — be exhaustive.";
|
|
33
|
+
|
|
34
|
+
/** Prefix prepended to the user's original prompt when describing an image. */
|
|
35
|
+
export const DEFAULT_USER_PROMPT_PREFIX = "The user's request about this image: ";
|
|
36
|
+
|
|
37
|
+
/** Placeholder text block injected in place of an image block. */
|
|
38
|
+
export const IMAGE_PLACEHOLDER_PREFIX = "[Image: ";
|
|
39
|
+
export const IMAGE_PLACEHOLDER_SUFFIX = "]";
|
|
40
|
+
|
|
41
|
+
/** Marker appended to a description whose `completeSimple()` call ended with
|
|
42
|
+
* `stopReason: "length"` — i.e. the vision model hit a token limit (either the
|
|
43
|
+
* configured `maxTokens` or the provider's hard output cap) before finishing.
|
|
44
|
+
* A truncated description is still useful, but the agent must not mistake it
|
|
45
|
+
* for the complete picture (it would reason as if the image contained only
|
|
46
|
+
* what made it into the text). The marker makes the cut-off visible to both
|
|
47
|
+
* the agent (it reads the marker in the tool result / context) and the user
|
|
48
|
+
* (it renders inline). */
|
|
49
|
+
export const DESCRIPTION_TRUNCATED_MARKER =
|
|
50
|
+
"\n\n[... description truncated — the vision model reached its output token limit. The above may be incomplete; raise maxTokens or use a higher-output vision model for the full description.]";
|
|
51
|
+
|
|
52
|
+
/** Append the {@link DESCRIPTION_TRUNCATED_MARKER} to a raw description. */
|
|
53
|
+
export function markDescriptionTruncated(text: string): string {
|
|
54
|
+
return text + DESCRIPTION_TRUNCATED_MARKER;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Default vision cache size (number of described images kept in memory per session). */
|
|
58
|
+
export const DEFAULT_CACHE_MAX = 50;
|
|
59
|
+
|
|
60
|
+
/** The pi thinking levels, ordered from lightest to deepest reasoning.
|
|
61
|
+
* Mirrors `@earendil-works/pi-ai`'s `ThinkingLevel`; "off" is handled
|
|
62
|
+
* separately via {@link VisionHandoffConfig.thinking} (a boolean on/off
|
|
63
|
+
* switch) so the level persists across toggles. */
|
|
64
|
+
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
65
|
+
"minimal",
|
|
66
|
+
"low",
|
|
67
|
+
"medium",
|
|
68
|
+
"high",
|
|
69
|
+
"xhigh",
|
|
70
|
+
"max",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Default thinking effort for the vision describer when thinking is enabled. */
|
|
74
|
+
export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium";
|
|
75
|
+
|
|
76
|
+
/** Whether `level` is one of the supported {@link ThinkingLevel} values. */
|
|
77
|
+
export function isThinkingLevel(level: unknown): level is ThinkingLevel {
|
|
78
|
+
return typeof level === "string" && (THINKING_LEVELS as readonly string[]).includes(level);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Default per-image description timeout (ms). Kept modest so a slow vision
|
|
82
|
+
* provider aborts early and fails over to {@link VisionHandoffConfig.fallbackModels}
|
|
83
|
+
* instead of blocking the agent turn for minutes on a free-tier queue. A single
|
|
84
|
+
* image commonly takes ~20s; raise via `describeTimeoutMs` in config only if
|
|
85
|
+
* a specific model genuinely needs it. {@link describeTimeoutMs} scales this
|
|
86
|
+
* per batch size. */
|
|
87
|
+
export const DESCRIBE_TIMEOUT_MS = 45_000;
|
|
88
|
+
|
|
89
|
+
/** Per-batch timeout: the base timeout plus a per-image budget so a 5-image
|
|
90
|
+
* batch isn't held to the same wall-clock budget as a single image. The
|
|
91
|
+
* describer generates exhaustive prose per image, and websocket transport
|
|
92
|
+
* adds latency, so the budget scales with the number of images in the call.
|
|
93
|
+
* `baseTimeout` defaults to {@link DESCRIBE_TIMEOUT_MS} and is overridden by
|
|
94
|
+
* the configured `describeTimeoutMs` field. */
|
|
95
|
+
export function describeTimeoutMs(imageCount: number, baseTimeout: number = DESCRIBE_TIMEOUT_MS): number {
|
|
96
|
+
const perImage = 45_000;
|
|
97
|
+
return baseTimeout + Math.max(0, imageCount - 1) * perImage;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Default cap on the number of lines kept from a description before truncation.
|
|
102
|
+
*
|
|
103
|
+
* Mirrors pi core's read-result truncation: tool output is bounded for both the
|
|
104
|
+
* TUI render and the model context. The stored `result.content` is the single
|
|
105
|
+
* source for both surfaces (no decouple point exists at `tool_result` — it's the
|
|
106
|
+
* only hook that still has the raw image before pi-ai strips it), so the cap
|
|
107
|
+
* applies uniformly. 0 = unbounded (default): the read tool's native collapse
|
|
108
|
+
* handles compactness and `ctrl+o` expands to the full description.
|
|
109
|
+
*/
|
|
110
|
+
export const DEFAULT_MAX_DESCRIPTION_LINES = 0;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The note pi core's `read` tool appends to image-result text blocks when the
|
|
114
|
+
* active model lacks image input (see `getNonVisionImageNote` in pi core).
|
|
115
|
+
*
|
|
116
|
+
* Once this extension inserts a description, the note becomes self-
|
|
117
|
+
* contradicting ("image will be omitted" vs. the vivid description that
|
|
118
|
+
* follows) and confuses the model. {@link stripNonVisionImageNote} removes it.
|
|
119
|
+
*/
|
|
120
|
+
export const NON_VISION_IMAGE_NOTE =
|
|
121
|
+
"[Current model does not support images. The image will be omitted from this request.]";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remove {@link NON_VISION_IMAGE_NOTE} from a text block.
|
|
125
|
+
*
|
|
126
|
+
* The read tool appends the note as `\n${NOTE}` (always the trailing segment of
|
|
127
|
+
* the metadata text block), so this also collapses the orphaned newline it
|
|
128
|
+
* leaves behind. Safe to call on text that does not contain the note (no-op).
|
|
129
|
+
*/
|
|
130
|
+
export function stripNonVisionImageNote(text: string): string {
|
|
131
|
+
if (!text.includes(NON_VISION_IMAGE_NOTE)) return text;
|
|
132
|
+
return text.split(NON_VISION_IMAGE_NOTE).join("").replace(/\n+$/, "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface VisionHandoffConfig {
|
|
136
|
+
/** Master switch. When false, no handoff occurs even if a vision model is configured. */
|
|
137
|
+
enabled: boolean;
|
|
138
|
+
/** The vision-capable model that describes images, as "provider/id". null = not configured. */
|
|
139
|
+
visionModel: string | null;
|
|
140
|
+
/** Optional fallback describers, tried in order when the primary vision
|
|
141
|
+
* model's batched call fails entirely (auth, rate limit, timeout, network,
|
|
142
|
+
* empty/error stopReason) and the same-model retry didn't recover.
|
|
143
|
+
* Each entry is a "provider/id" ref; providers usually differ from the
|
|
144
|
+
* primary so their rate limits / auth / availability are independent.
|
|
145
|
+
* Empty (default) = no failover. */
|
|
146
|
+
fallbackModels: string[];
|
|
147
|
+
/** When true (default), handoff is applied to every model whose input does not include "image". */
|
|
148
|
+
autoHandoff: boolean;
|
|
149
|
+
/** Extra "provider/id" refs that should ALSO receive handoff (e.g. weak vision models). */
|
|
150
|
+
handoffModels: string[];
|
|
151
|
+
/** Opt-in: when true, the editor is wrapped at session start to describe
|
|
152
|
+
* pasted clipboard images the instant their temp-file path lands in the
|
|
153
|
+
* prompt (pre-submit), instead of waiting for submit. Trades a bit of
|
|
154
|
+
* description quality (the description is generated without the user's
|
|
155
|
+
* typed question as context, since the question usually isn't entered yet
|
|
156
|
+
* at paste time) and a speculative vision call on paste-then-abandon, for
|
|
157
|
+
* earlier prewarm. Off by default — submit-time prewarm
|
|
158
|
+
* (before_agent_start) still covers this case when off. TUI mode only, and
|
|
159
|
+
* inactive when another extension has replaced the editor (installing over
|
|
160
|
+
* a custom editor would break clipboard paste, since pi wires paste-image
|
|
161
|
+
* to the outermost editor only). */
|
|
162
|
+
prewarmPastedImages: boolean;
|
|
163
|
+
/** Opt-in: asynchronously inject descriptions for pasted clipboard paths when
|
|
164
|
+
* the target model does not read them. The normal read/tool_result path wins
|
|
165
|
+
* the race: a matching read cancels the queued fallback injection while
|
|
166
|
+
* reusing the same in-flight description. */
|
|
167
|
+
asyncClipboardHandoff: boolean;
|
|
168
|
+
/** Max output tokens for a single description. `undefined` (default) = use the
|
|
169
|
+
* vision model's declared max output (`model.maxTokens`) as the cap — the
|
|
170
|
+
* highest the model supports — so the "be exhaustive" prompt isn't defeated
|
|
171
|
+
* by a provider's small omitted-default. Set a number only to cap lower.
|
|
172
|
+
* A truncation is always surfaced via {@link DESCRIPTION_TRUNCATED_MARKER}
|
|
173
|
+
* when the model hits the limit. */
|
|
174
|
+
maxTokens?: number;
|
|
175
|
+
/** Base per-image description timeout in ms (defaults to
|
|
176
|
+
* {@link DESCRIBE_TIMEOUT_MS}). The per-batch timeout scales from this base
|
|
177
|
+
* by image count. Lower it to fail over to {@link fallbackModels} sooner on a
|
|
178
|
+
* slow primary; raise it for a high-latency vision model that times out
|
|
179
|
+
* spuriously. */
|
|
180
|
+
describeTimeoutMs: number;
|
|
181
|
+
/** Max images kept in the in-memory description cache. */
|
|
182
|
+
cacheMax: number;
|
|
183
|
+
/**
|
|
184
|
+
* Max lines kept from a description before truncation (0 = unbounded).
|
|
185
|
+
* Bounds both the TUI render and the model context, like pi core's tool-output
|
|
186
|
+
* truncation.
|
|
187
|
+
*/
|
|
188
|
+
maxDescriptionLines: number;
|
|
189
|
+
/** Override the describer system prompt (defaults to DEFAULT_VISION_PROMPT). */
|
|
190
|
+
prompt?: string;
|
|
191
|
+
/** Override the user-prompt prefix (defaults to DEFAULT_USER_PROMPT_PREFIX). */
|
|
192
|
+
userPromptPrefix?: string;
|
|
193
|
+
/** Whether the vision describer should reason (think) before describing.
|
|
194
|
+
* Off by default — describing images is a perception task, not a reasoning
|
|
195
|
+
* one, and thinking adds latency + cost. When on, the level below is sent
|
|
196
|
+
* to the vision model via pi-ai's `reasoning` option (only honoured when
|
|
197
|
+
* the configured vision model declares `reasoning: true`). */
|
|
198
|
+
thinking: boolean;
|
|
199
|
+
/** Thinking effort for the describer when {@link thinking} is on. One of
|
|
200
|
+
* {@link THINKING_LEVELS}. Ignored when {@link thinking} is off or the
|
|
201
|
+
* vision model lacks reasoning support. */
|
|
202
|
+
thinkingLevel: ThinkingLevel;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const DEFAULT_CONFIG: VisionHandoffConfig = {
|
|
206
|
+
enabled: true,
|
|
207
|
+
visionModel: null,
|
|
208
|
+
fallbackModels: [],
|
|
209
|
+
autoHandoff: true,
|
|
210
|
+
handoffModels: [],
|
|
211
|
+
prewarmPastedImages: false,
|
|
212
|
+
asyncClipboardHandoff: false,
|
|
213
|
+
maxTokens: undefined,
|
|
214
|
+
describeTimeoutMs: DESCRIBE_TIMEOUT_MS,
|
|
215
|
+
cacheMax: DEFAULT_CACHE_MAX,
|
|
216
|
+
maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
|
|
217
|
+
thinking: false,
|
|
218
|
+
thinkingLevel: DEFAULT_THINKING_LEVEL,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Parse a "provider/id" reference. Returns null if malformed. */
|
|
222
|
+
export function parseModelRef(ref: string): { provider: string; id: string } | null {
|
|
223
|
+
const trimmed = ref.trim();
|
|
224
|
+
if (!trimmed) return null;
|
|
225
|
+
const slashIndex = trimmed.indexOf("/");
|
|
226
|
+
if (slashIndex <= 0) return null; // no slash, or empty provider
|
|
227
|
+
const provider = trimmed.slice(0, slashIndex);
|
|
228
|
+
const id = trimmed.slice(slashIndex + 1);
|
|
229
|
+
if (!provider || !id) return null;
|
|
230
|
+
return { provider, id };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Format a provider/id reference string. */
|
|
234
|
+
export function formatModelRef(provider: string, id: string): string {
|
|
235
|
+
return `${provider}/${id}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a model declares image input. */
|
|
239
|
+
export function isVisionModel(model: { input?: ("text" | "image")[] } | undefined | null): boolean {
|
|
240
|
+
return !!model && Array.isArray(model.input) && model.input.includes("image");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Merge a parsed config object onto defaults, tolerating missing/invalid fields. */
|
|
244
|
+
export function normalizeConfig(raw: unknown): VisionHandoffConfig {
|
|
245
|
+
const base: VisionHandoffConfig = { ...DEFAULT_CONFIG };
|
|
246
|
+
if (!raw || typeof raw !== "object") return base;
|
|
247
|
+
const obj = raw as Record<string, unknown>;
|
|
248
|
+
|
|
249
|
+
if (typeof obj.enabled === "boolean") base.enabled = obj.enabled;
|
|
250
|
+
if (typeof obj.visionModel === "string" && obj.visionModel.trim()) {
|
|
251
|
+
base.visionModel = parseModelRef(obj.visionModel) ? obj.visionModel.trim() : null;
|
|
252
|
+
} else if (obj.visionModel === null) {
|
|
253
|
+
base.visionModel = null;
|
|
254
|
+
}
|
|
255
|
+
if (Array.isArray(obj.fallbackModels)) {
|
|
256
|
+
base.fallbackModels = obj.fallbackModels
|
|
257
|
+
.filter((m): m is string => typeof m === "string")
|
|
258
|
+
.map((m) => m.trim())
|
|
259
|
+
.filter((m) => m && parseModelRef(m));
|
|
260
|
+
}
|
|
261
|
+
if (typeof obj.autoHandoff === "boolean") base.autoHandoff = obj.autoHandoff;
|
|
262
|
+
|
|
263
|
+
if (Array.isArray(obj.handoffModels)) {
|
|
264
|
+
base.handoffModels = obj.handoffModels
|
|
265
|
+
.filter((m): m is string => typeof m === "string")
|
|
266
|
+
.map((m) => m.trim())
|
|
267
|
+
.filter((m) => m && parseModelRef(m));
|
|
268
|
+
}
|
|
269
|
+
if (typeof obj.prewarmPastedImages === "boolean") base.prewarmPastedImages = obj.prewarmPastedImages;
|
|
270
|
+
if (typeof obj.asyncClipboardHandoff === "boolean") {
|
|
271
|
+
base.asyncClipboardHandoff = obj.asyncClipboardHandoff;
|
|
272
|
+
}
|
|
273
|
+
// maxTokens: optional. undefined (default) = no artificial cap. Only set when
|
|
274
|
+
// a valid positive finite number is given; any other value leaves it unset.
|
|
275
|
+
if (typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) && obj.maxTokens > 0) {
|
|
276
|
+
base.maxTokens = Math.floor(obj.maxTokens);
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
typeof obj.describeTimeoutMs === "number" &&
|
|
280
|
+
Number.isFinite(obj.describeTimeoutMs) &&
|
|
281
|
+
obj.describeTimeoutMs > 0
|
|
282
|
+
) {
|
|
283
|
+
base.describeTimeoutMs = Math.floor(obj.describeTimeoutMs);
|
|
284
|
+
}
|
|
285
|
+
if (typeof obj.cacheMax === "number" && Number.isFinite(obj.cacheMax) && obj.cacheMax > 0) {
|
|
286
|
+
base.cacheMax = Math.floor(obj.cacheMax);
|
|
287
|
+
}
|
|
288
|
+
// maxDescriptionLines: any non-negative finite integer. 0 = unbounded.
|
|
289
|
+
if (
|
|
290
|
+
typeof obj.maxDescriptionLines === "number" &&
|
|
291
|
+
Number.isFinite(obj.maxDescriptionLines) &&
|
|
292
|
+
obj.maxDescriptionLines >= 0
|
|
293
|
+
) {
|
|
294
|
+
base.maxDescriptionLines = Math.floor(obj.maxDescriptionLines);
|
|
295
|
+
}
|
|
296
|
+
if (typeof obj.prompt === "string" && obj.prompt.trim()) base.prompt = obj.prompt;
|
|
297
|
+
if (typeof obj.userPromptPrefix === "string") base.userPromptPrefix = obj.userPromptPrefix;
|
|
298
|
+
|
|
299
|
+
if (typeof obj.thinking === "boolean") base.thinking = obj.thinking;
|
|
300
|
+
if (isThinkingLevel(obj.thinkingLevel)) base.thinkingLevel = obj.thinkingLevel;
|
|
301
|
+
|
|
302
|
+
return base;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Read config from disk (falls back to defaults on missing/corrupt file). */
|
|
306
|
+
export function readConfig(): VisionHandoffConfig {
|
|
307
|
+
const path = getConfigPath();
|
|
308
|
+
if (!existsSync(path)) return { ...DEFAULT_CONFIG };
|
|
309
|
+
try {
|
|
310
|
+
const raw = readFileSync(path, "utf8");
|
|
311
|
+
return normalizeConfig(JSON.parse(raw));
|
|
312
|
+
} catch {
|
|
313
|
+
return { ...DEFAULT_CONFIG };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Write config to disk. Creates the directory if needed. Returns the path written. */
|
|
318
|
+
export function writeConfig(config: VisionHandoffConfig): string {
|
|
319
|
+
const path = getConfigPath();
|
|
320
|
+
const dir = join(getAgentDir(), CONFIG_SUBDIR);
|
|
321
|
+
if (!existsSync(dir)) {
|
|
322
|
+
mkdirSync(dir, { recursive: true });
|
|
323
|
+
}
|
|
324
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
325
|
+
return path;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface ExtractedImage {
|
|
329
|
+
data: string;
|
|
330
|
+
mimeType: string;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Parse a `data:<mime>;base64,<data>` URL into raw base64 + mime. Returns null if not a data URL. */
|
|
334
|
+
export function parseDataUrl(url: string): ExtractedImage | null {
|
|
335
|
+
const match = /^data:([^;,]+)?(?:;base64)?,(.*)$/s.exec(url);
|
|
336
|
+
if (!match) return null;
|
|
337
|
+
return { mimeType: match[1] || "image/png", data: match[2] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Detect an image block by shape across the three request formats pi uses:
|
|
342
|
+
* openai-completions: { type: "image_url", image_url: { url: "data:..." } }
|
|
343
|
+
* openai-responses: { type: "input_image", image_url: "data:..." | { url } }
|
|
344
|
+
* anthropic-messages: { type: "image", source: { type: "base64", media_type, data } }
|
|
345
|
+
*/
|
|
346
|
+
export function extractImageFromBlock(block: unknown): ExtractedImage | null {
|
|
347
|
+
if (!block || typeof block !== "object") return null;
|
|
348
|
+
const b = block as Record<string, any>;
|
|
349
|
+
|
|
350
|
+
if (b.type === "image_url" && typeof b.image_url?.url === "string") {
|
|
351
|
+
return parseDataUrl(b.image_url.url);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (b.type === "input_image") {
|
|
355
|
+
const url = typeof b.image_url === "string" ? b.image_url : b.image_url?.url;
|
|
356
|
+
if (typeof url === "string") return parseDataUrl(url);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// anthropic-messages image block (wrapped in `source`).
|
|
361
|
+
if (b.type === "image" && b.source?.type === "base64" && typeof b.source.data === "string") {
|
|
362
|
+
return { data: b.source.data, mimeType: b.source.media_type || "image/png" };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// pi-ai internal image block — emitted by the `read` tool and carried by
|
|
366
|
+
// ToolResultEvent.content / user-message content blocks (regardless of whether
|
|
367
|
+
// the active model declares image input):
|
|
368
|
+
// { type: "image", data: "<base64>", mimeType }
|
|
369
|
+
// Distinct from the anthropic shape above: no `source` wrapper, direct fields.
|
|
370
|
+
if (b.type === "image" && typeof b.data === "string") {
|
|
371
|
+
return { data: b.data, mimeType: b.mimeType || "image/png" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Build a text block that replaces an image block, matching the request format. */
|
|
378
|
+
export function makeReplacementText(block: unknown, description: string): Record<string, unknown> {
|
|
379
|
+
const b = (block ?? null) as Record<string, unknown> | null;
|
|
380
|
+
if (b?.type === "input_image") {
|
|
381
|
+
return { type: "input_text", text: description };
|
|
382
|
+
}
|
|
383
|
+
return { type: "text", text: description };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Outcome of {@link truncateDescription}. */
|
|
387
|
+
export interface TruncatedDescription {
|
|
388
|
+
text: string;
|
|
389
|
+
/** True iff lines were dropped. */
|
|
390
|
+
truncated: boolean;
|
|
391
|
+
/** Number of trailing lines removed. */
|
|
392
|
+
hidden: number;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Head-truncate a description to its first `maxLines` lines with a
|
|
397
|
+
* "... (N more lines)" footer, mirroring pi core's read-result truncation
|
|
398
|
+
* notice (which itself surfaces "... (N more lines, ctrl+o to expand)").
|
|
399
|
+
*
|
|
400
|
+
* Returns the text unchanged when it is at or below the limit, or when
|
|
401
|
+
* `maxLines` is non-positive (0 = unbounded). Head-truncation keeps the leading
|
|
402
|
+
* layout / text-content / structure sections, which are typically the most
|
|
403
|
+
* actionable for a coding agent.
|
|
404
|
+
*/
|
|
405
|
+
export function truncateDescription(text: string, maxLines: number): TruncatedDescription {
|
|
406
|
+
if (!maxLines || maxLines <= 0) {
|
|
407
|
+
return { text, truncated: false, hidden: 0 };
|
|
408
|
+
}
|
|
409
|
+
const lines = text.split("\n");
|
|
410
|
+
if (lines.length <= maxLines) {
|
|
411
|
+
return { text, truncated: false, hidden: 0 };
|
|
412
|
+
}
|
|
413
|
+
const hidden = lines.length - maxLines;
|
|
414
|
+
const kept = lines.slice(0, maxLines).join("\n");
|
|
415
|
+
return { text: `${kept}\n... (${hidden} more lines)`, truncated: true, hidden };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Start marker for the k-th image section in a batched describer response. */
|
|
419
|
+
export const BATCH_IMAGE_MARKER_START = "<<<IMAGE";
|
|
420
|
+
/** End marker closing a single image section in a batched describer response. */
|
|
421
|
+
export const BATCH_IMAGE_MARKER_END = "<<<END>>>";
|
|
422
|
+
|
|
423
|
+
/** Escape a literal string for use in a RegExp. */
|
|
424
|
+
function escapeRe(s: string): string {
|
|
425
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Section regexes built from the marker constants so the producer
|
|
429
|
+
// (batchUserPrompt) and consumer (parseBatchedDescriptions) share one source of
|
|
430
|
+
// truth for the delimiter format. A lone `<<<IMAGE k>>> … <<<END>>>` wrapper
|
|
431
|
+
// for a single image; a global scan for the multi-image case that stops at the
|
|
432
|
+
// next start marker, the end marker, or end-of-text.
|
|
433
|
+
const SECTION_SINGLE_RE = new RegExp(
|
|
434
|
+
`${escapeRe(BATCH_IMAGE_MARKER_START)}\\s+1>>>\\s*([\\s\\S]*?)${escapeRe(BATCH_IMAGE_MARKER_END)}`,
|
|
435
|
+
);
|
|
436
|
+
const SECTION_MULTI_RE = new RegExp(
|
|
437
|
+
`${escapeRe(BATCH_IMAGE_MARKER_START)}\\s+(\\d+)>>>\\s*([\\s\\S]*?)(?=${escapeRe(
|
|
438
|
+
BATCH_IMAGE_MARKER_START,
|
|
439
|
+
)}\\s+\\d+>>>|${escapeRe(BATCH_IMAGE_MARKER_END)}|$)`,
|
|
440
|
+
"g",
|
|
441
|
+
);
|
|
442
|
+
const SECTION_END_STRIP_RE = new RegExp(`${escapeRe(BATCH_IMAGE_MARKER_END)}\\s*$`);
|
|
443
|
+
|
|
444
|
+
/** Build the user-message text block for a (possibly batched) describer call.
|
|
445
|
+
*
|
|
446
|
+
* For a single image (count <= 1) this returns the same simple prompt the
|
|
447
|
+
* original per-image pipeline used (no markers), so single-image behaviour is
|
|
448
|
+
* unchanged. For count > 1 it instructs the vision model to emit one delimited
|
|
449
|
+
* `<<<IMAGE k>>> … <<<END>>>` section per image, in order, so the response can
|
|
450
|
+
* be split back into per-image descriptions by {@link parseBatchedDescriptions}.
|
|
451
|
+
*
|
|
452
|
+
* The user's turn prompt (if any) is folded in via {@link DEFAULT_USER_PROMPT_PREFIX}
|
|
453
|
+
* so every image in the batch is described in the context of the same request —
|
|
454
|
+
* one describer call covers the whole prompt's image set, dataloader-style,
|
|
455
|
+
* rather than spinning up one agent call per image. */
|
|
456
|
+
export function batchUserPrompt(count: number, userPrompt: string, prefix: string): string {
|
|
457
|
+
const userLine = userPrompt && userPrompt.trim() ? `${prefix}${userPrompt}` : "";
|
|
458
|
+
if (count <= 1) {
|
|
459
|
+
return userLine || "Describe this image.";
|
|
460
|
+
}
|
|
461
|
+
return [
|
|
462
|
+
`Describe each of the ${count} images below exhaustively, in order. For image k (1 through ${count}), emit exactly:`,
|
|
463
|
+
"",
|
|
464
|
+
`${BATCH_IMAGE_MARKER_START} k>>>`,
|
|
465
|
+
"<your exhaustive description>",
|
|
466
|
+
BATCH_IMAGE_MARKER_END,
|
|
467
|
+
...(userLine ? ["", userLine] : []),
|
|
468
|
+
].join("\n");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Parse a batched vision-model response into one description per image, in order.
|
|
472
|
+
*
|
|
473
|
+
* Returns an array of length `count`; each slot is the trimmed description for
|
|
474
|
+
* image k (1-indexed → array index k-1), or `null` when that image's section is
|
|
475
|
+
* missing, empty, or unparseable. Callers treat `null` as "description
|
|
476
|
+
* unavailable" (graceful degradation) — one image failing to parse must not
|
|
477
|
+
* void the rest of the batch.
|
|
478
|
+
*
|
|
479
|
+
* For `count <= 1` the whole response is treated as the single image's
|
|
480
|
+
* description (a lone `<<<IMAGE 1>>> … <<<END>>>` wrapper is stripped if the
|
|
481
|
+
* model emitted one anyway), preserving the original single-image behaviour. */
|
|
482
|
+
export function parseBatchedDescriptions(text: string, count: number): (string | null)[] {
|
|
483
|
+
const trimmed = (text ?? "").trim();
|
|
484
|
+
if (count <= 1) {
|
|
485
|
+
const m = SECTION_SINGLE_RE.exec(trimmed);
|
|
486
|
+
const body = (m ? m[1] : trimmed).trim();
|
|
487
|
+
return [body || null];
|
|
488
|
+
}
|
|
489
|
+
const out: (string | null)[] = new Array(count).fill(null);
|
|
490
|
+
let m: RegExpExecArray | null;
|
|
491
|
+
SECTION_MULTI_RE.lastIndex = 0;
|
|
492
|
+
while ((m = SECTION_MULTI_RE.exec(trimmed)) !== null) {
|
|
493
|
+
const idx = parseInt(m[1], 10) - 1;
|
|
494
|
+
const body = m[2].replace(SECTION_END_STRIP_RE, "").trim();
|
|
495
|
+
if (idx >= 0 && idx < count && !out[idx]) out[idx] = body || null;
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Truncate (if configured) and wrap a raw description in the `[Image: …]`
|
|
501
|
+
* placeholder envelope used by every insertion path. Centralises the wrap+
|
|
502
|
+
* truncate logic shared by the read-tool and context injection paths so both
|
|
503
|
+
* surfaces stay identical for the same image hash. */
|
|
504
|
+
export function wrapDescription(description: string, cfg: VisionHandoffConfig): string {
|
|
505
|
+
const { text: final } =
|
|
506
|
+
cfg.maxDescriptionLines && cfg.maxDescriptionLines > 0
|
|
507
|
+
? truncateDescription(description, cfg.maxDescriptionLines)
|
|
508
|
+
: { text: description };
|
|
509
|
+
return `${IMAGE_PLACEHOLDER_PREFIX}${final}${IMAGE_PLACEHOLDER_SUFFIX}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Outcome of {@link insertImageDescriptions}. */
|
|
513
|
+
export interface ReplacedContent {
|
|
514
|
+
content: (TextContent | ImageContent)[];
|
|
515
|
+
/** True iff at least one image block had a description inserted before it. */
|
|
516
|
+
changed: boolean;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Insert a description text block before each image block in a tool-result /
|
|
521
|
+
* message content array, KEEPING the image block in place.
|
|
522
|
+
*
|
|
523
|
+
* Why insert (not replace): the stored tool-result content is the single
|
|
524
|
+
* source for both the TUI render (kitty inline images read it via
|
|
525
|
+
* `result.content.filter(c => c.type === "image")`) and the provider payload.
|
|
526
|
+
* Replacing the image would strip it from the terminal render. Keeping the
|
|
527
|
+
* image preserves kitty rendering; the inserted description still reaches
|
|
528
|
+
* non-vision models because pi-ai's `downgradeUnsupportedImages` only rewrites
|
|
529
|
+
* `type: "image"` blocks for non-vision models — text blocks (including this
|
|
530
|
+
* description) pass through untouched to the provider.
|
|
531
|
+
*
|
|
532
|
+
* `describe` is injected (rather than calling the vision model directly) so the
|
|
533
|
+
* extract → describe → insert pipeline is unit-testable without standing up a
|
|
534
|
+
* provider, registry, and API call. The extension wires its real `describeImage`
|
|
535
|
+
* into this helper in its `tool_result` handler.
|
|
536
|
+
*
|
|
537
|
+
* When at least one description is inserted, also strips pi core's
|
|
538
|
+
* {@link NON_VISION_IMAGE_NOTE} from text blocks — the note (appended by the
|
|
539
|
+
* read tool for non-vision models) would otherwise contradict the inserted
|
|
540
|
+
* description. Text blocks are reassigned (not mutated in place); the input
|
|
541
|
+
* array is left untouched.
|
|
542
|
+
*
|
|
543
|
+
* Returns a new array and `changed: false` when there were no images, so
|
|
544
|
+
* callers can short-circuit and avoid mutating pi's stored result unnecessarily.
|
|
545
|
+
*/
|
|
546
|
+
export async function insertImageDescriptions(
|
|
547
|
+
content: readonly (TextContent | ImageContent)[] | undefined,
|
|
548
|
+
describe: (img: ExtractedImage) => Promise<string>,
|
|
549
|
+
): Promise<ReplacedContent> {
|
|
550
|
+
if (!Array.isArray(content)) {
|
|
551
|
+
return { content: [], changed: false };
|
|
552
|
+
}
|
|
553
|
+
const next: (TextContent | ImageContent)[] = [];
|
|
554
|
+
let changed = false;
|
|
555
|
+
for (const block of content) {
|
|
556
|
+
const img = extractImageFromBlock(block);
|
|
557
|
+
if (!img) {
|
|
558
|
+
next.push(block);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const description = await describe(img);
|
|
562
|
+
next.push({ type: "text", text: description } satisfies TextContent);
|
|
563
|
+
next.push(block);
|
|
564
|
+
changed = true;
|
|
565
|
+
}
|
|
566
|
+
if (!changed) {
|
|
567
|
+
return { content: next, changed };
|
|
568
|
+
}
|
|
569
|
+
// We inserted at least one description. Strip the read tool's
|
|
570
|
+
// "[Current model does not support images...]" note from text blocks — it
|
|
571
|
+
// contradicts the description we just inserted ("image will be omitted" vs.
|
|
572
|
+
// the description that follows) and confuses the model.
|
|
573
|
+
for (let i = 0; i < next.length; i++) {
|
|
574
|
+
const block = next[i];
|
|
575
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.includes(NON_VISION_IMAGE_NOTE)) {
|
|
576
|
+
next[i] = { type: "text", text: stripNonVisionImageNote(block.text) } satisfies TextContent;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { content: next, changed };
|
|
580
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paste-time prewarm editor wrapper.
|
|
3
|
+
*
|
|
4
|
+
* pi has no "image pasted into the prompt" event — the extension event bus
|
|
5
|
+
* only surfaces input at submit time (`input` / `before_agent_start`). The
|
|
6
|
+
* earliest observable signal that a clipboard image has landed in the prompt
|
|
7
|
+
* is the editor's own `onChange(text)`, which fires when pi's
|
|
8
|
+
* `handleClipboardImagePaste` inserts the temp-file path
|
|
9
|
+
* (`<tmpdir>/pi-clipboard-<uuid>.<ext>`) via `insertTextAtCursor` — still
|
|
10
|
+
* pre-submit. This wrapper installs a `CustomEditor` that observes `onChange`
|
|
11
|
+
* to prewarm the describer for newly-pasted clipboard image paths the instant
|
|
12
|
+
* they appear, so the vision call starts before the user hits enter (true
|
|
13
|
+
* paste-time) instead of at `before_agent_start` (submit-time).
|
|
14
|
+
*
|
|
15
|
+
* It does NOT override `handleInput`, so every keybinding and pi's clipboard
|
|
16
|
+
* paste flow (`ctrl+v` → `onPasteImage` → `handleClipboardImagePaste`) is
|
|
17
|
+
* untouched. We only observe the result via `onChange`.
|
|
18
|
+
*
|
|
19
|
+
* Why chain `onChange` via an accessor: pi's `setCustomEditorComponent` does
|
|
20
|
+
* `newEditor.onChange = this.defaultEditor.onChange` after construction,
|
|
21
|
+
* clobbering any `onChange` set in the constructor — and that default
|
|
22
|
+
* `onChange` tracks bash-mode for the editor border, so it must keep running.
|
|
23
|
+
* The accessor captures pi's assignment and runs it alongside our observer.
|
|
24
|
+
* (The `Editor` base never reassigns `onChange` itself, so the captured
|
|
25
|
+
* reference is stable for the editor's lifetime.)
|
|
26
|
+
*
|
|
27
|
+
* Opt-in via `VisionHandoffConfig.prewarmPastedImages`. When off (or the
|
|
28
|
+
* active model isn't a handoff target), the observer short-circuits before
|
|
29
|
+
* the path regex runs, so overhead is one boolean check per text change.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { CustomEditor, type KeybindingsManager, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
34
|
+
import { diffPrewarmPaths } from "./image.js";
|
|
35
|
+
|
|
36
|
+
/** Dependencies the editor can't own itself (held by the engine, captured at
|
|
37
|
+
* session start). `modelRegistry` is fixed for the editor's (session)
|
|
38
|
+
* lifetime; `shouldPrewarm` and `prewarmPath` read live config/model state so
|
|
39
|
+
* toggling the opt-in or switching models takes effect without reinstalling. */
|
|
40
|
+
export interface PrewarmEditorDeps {
|
|
41
|
+
modelRegistry: ModelRegistry;
|
|
42
|
+
/** Live gate: paste-time prewarm runs only when this returns true. */
|
|
43
|
+
shouldPrewarm: () => boolean;
|
|
44
|
+
/** Prewarm one clipboard image path (read + resize-match + loadDescription). */
|
|
45
|
+
prewarmPath: (path: string, modelRegistry: ModelRegistry) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A `CustomEditor` that observes `onChange` to prewarm pasted clipboard image
|
|
49
|
+
* paths at paste-time. See the module doc for the `onChange` chaining. */
|
|
50
|
+
export class PrewarmEditor extends CustomEditor {
|
|
51
|
+
private readonly knownPaths = new Set<string>();
|
|
52
|
+
private piOnChange: ((text: string) => void) | undefined;
|
|
53
|
+
private readonly shouldPrewarm: () => boolean;
|
|
54
|
+
private readonly prewarmPath: (path: string, modelRegistry: ModelRegistry) => void;
|
|
55
|
+
private readonly modelRegistry: ModelRegistry;
|
|
56
|
+
|
|
57
|
+
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PrewarmEditorDeps) {
|
|
58
|
+
super(tui, theme, keybindings);
|
|
59
|
+
this.shouldPrewarm = deps.shouldPrewarm;
|
|
60
|
+
this.prewarmPath = deps.prewarmPath;
|
|
61
|
+
this.modelRegistry = deps.modelRegistry;
|
|
62
|
+
|
|
63
|
+
// Stable wrapper: calls pi's onChange (bash-mode border tracking) then our
|
|
64
|
+
// observer. Returned on every read so the Editor's `this.onChange(...)` calls
|
|
65
|
+
// all hit the same function.
|
|
66
|
+
const wrapper = (text: string): void => {
|
|
67
|
+
this.piOnChange?.(text);
|
|
68
|
+
this.handleTextChange(text);
|
|
69
|
+
};
|
|
70
|
+
// pi assigns defaultEditor.onChange to this property after construction;
|
|
71
|
+
// capture it via the setter, expose the wrapper via the getter.
|
|
72
|
+
Object.defineProperty(this, "onChange", {
|
|
73
|
+
configurable: true,
|
|
74
|
+
enumerable: true,
|
|
75
|
+
get: () => wrapper,
|
|
76
|
+
set: (fn: ((text: string) => void) | undefined) => {
|
|
77
|
+
this.piOnChange = fn;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Scan the editor text for clipboard image paths not yet seen this prompt
|
|
83
|
+
* and prewarm each. Clears the known set when the editor empties (after
|
|
84
|
+
* submit) so a later paste (fresh uuid) re-prewarms and the set can't grow
|
|
85
|
+
* unbounded. */
|
|
86
|
+
private handleTextChange(text: string): void {
|
|
87
|
+
if (!this.shouldPrewarm()) return;
|
|
88
|
+
const newPaths = diffPrewarmPaths(text, this.knownPaths);
|
|
89
|
+
for (const p of newPaths) {
|
|
90
|
+
this.knownPaths.add(p);
|
|
91
|
+
this.prewarmPath(p, this.modelRegistry);
|
|
92
|
+
}
|
|
93
|
+
if (text.length === 0) this.knownPaths.clear();
|
|
94
|
+
}
|
|
95
|
+
}
|