pi-vision-handoff 0.3.1 → 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/vision-handoff.ts
CHANGED
|
@@ -8,87 +8,73 @@
|
|
|
8
8
|
* choice is persisted to ~/.pi/agent/extensions/pi-vision-handoff.json.
|
|
9
9
|
*
|
|
10
10
|
* Pipeline (provider-agnostic via @earendil-works/pi-ai's complete()):
|
|
11
|
-
* before_agent_start → warm the description cache for attached images
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* before_agent_start → warm the description cache for attached images AND
|
|
12
|
+
* pasted clipboard image file paths found in the prompt text (pre-warm at
|
|
13
|
+
* paste-enter, concurrent with the agent's first response).
|
|
14
|
+
* tool_result (read) → loadDescription() each read-tool image and AWAIT the
|
|
15
|
+
* shared batch, so N parallel reads coalesce into ONE vision call. The
|
|
16
|
+
* descriptions land in the tool results before the agent's next turn.
|
|
17
|
+
* context → swap any remaining image blocks for their (now cached) text
|
|
18
|
+
* description on the cloned LLM-bound payload.
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* pi-ai internal: { type: "image", data, mimeType } ← read tool / ToolResultEvent
|
|
20
|
+
* This file is the wiring layer: pi event handlers + the /vision-handoff
|
|
21
|
+
* command. The dataloader (`src/dataloader.ts`), describer (`src/describer.ts`),
|
|
22
|
+
* image IO (`src/image.ts`), resource guards (`src/dispose.ts`), config/types
|
|
23
|
+
* (`src/index.ts`), and usage/energy (`src/usage.ts`) live in `src/`.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* Image blocks are detected by shape across the four formats pi uses — see
|
|
26
|
+
* `extractImageFromBlock` in `src/index.ts`. Descriptions are cached per image
|
|
27
|
+
* hash (LRU, size = config.cacheMax) so the swap is instant by the time
|
|
28
|
+
* `context` fires.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
|
-
import crypto from "node:crypto";
|
|
30
31
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import {
|
|
32
|
-
import type { Api, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai";
|
|
32
|
+
import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
|
|
33
33
|
import {
|
|
34
|
-
DEFAULT_USER_PROMPT_PREFIX,
|
|
35
|
-
DEFAULT_VISION_PROMPT,
|
|
36
|
-
DESCRIBE_TIMEOUT_MS,
|
|
37
|
-
HANDOFF_COMMAND_DESCRIPTION,
|
|
38
|
-
IMAGE_PLACEHOLDER_PREFIX,
|
|
39
|
-
IMAGE_PLACEHOLDER_SUFFIX,
|
|
40
|
-
USAGE_ENTRY_TYPE,
|
|
41
|
-
USAGE_EVENT_CHANNEL,
|
|
42
|
-
EMPTY_ENERGY_CAPTURE,
|
|
43
|
-
buildUsageRecord,
|
|
44
|
-
describeAls,
|
|
45
34
|
extractImageFromBlock,
|
|
46
35
|
formatModelRef,
|
|
47
|
-
insertImageDescriptions,
|
|
48
|
-
installFetchInterceptor,
|
|
49
36
|
isVisionModel,
|
|
50
|
-
|
|
37
|
+
NON_VISION_IMAGE_NOTE,
|
|
51
38
|
parseModelRef,
|
|
52
39
|
readConfig,
|
|
53
|
-
|
|
54
|
-
uninstallFetchInterceptor,
|
|
40
|
+
stripNonVisionImageNote,
|
|
55
41
|
writeConfig,
|
|
56
|
-
|
|
42
|
+
HANDOFF_COMMAND_DESCRIPTION,
|
|
43
|
+
type ExtractedImage,
|
|
57
44
|
type VisionHandoffConfig,
|
|
58
|
-
type VisionHandoffEnergyCapture,
|
|
59
|
-
type VisionHandoffUsageRecord,
|
|
60
45
|
} from "./src/index.js";
|
|
46
|
+
import {
|
|
47
|
+
USAGE_ENTRY_TYPE,
|
|
48
|
+
USAGE_EVENT_CHANNEL,
|
|
49
|
+
type VisionHandoffUsageRecord,
|
|
50
|
+
} from "./src/usage.js";
|
|
51
|
+
import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
|
|
52
|
+
import { imageHash, findClipboardImagePaths, readImageBuffer, resolvePrewarmImage } from "./src/image.js";
|
|
53
|
+
import { resizeImage } from "@earendil-works/pi-coding-agent";
|
|
61
54
|
import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
|
|
62
55
|
|
|
63
|
-
const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_PLACEHOLDER_SUFFIX}`;
|
|
64
|
-
|
|
65
|
-
// Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
|
|
66
|
-
// export. No-op until then so describeImage is safe to call before wiring.
|
|
67
|
-
let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
|
|
68
|
-
|
|
69
56
|
let config: VisionHandoffConfig = readConfig();
|
|
70
57
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
58
|
+
/** Most recent describer failure message (auth error, network error, abort,
|
|
59
|
+
* empty response, etc.). Set by the describer via the loader deps; surfaced
|
|
60
|
+
* to the user by the `context`/`tool_result` handler via ctx.ui.notify so a
|
|
61
|
+
* broken vision model stops looking like a silent "extension doesn't work".
|
|
62
|
+
* Cleared at the start of each describer attempt. */
|
|
63
|
+
let lastDescriberError: string | null = null;
|
|
64
|
+
|
|
65
|
+
/** Image hashes we've already warned the user about this session. Prevents the
|
|
66
|
+
* `context` hook (which fires before every LLM turn) from re-warning on the
|
|
67
|
+
* same failing images every turn — describer failures aren't cached, so
|
|
68
|
+
* without this guard a broken vision model would spam a warning per turn.
|
|
69
|
+
* Cleared on `session_start`. */
|
|
70
|
+
const warnedHashes = new Set<string>();
|
|
73
71
|
|
|
74
|
-
const visionCache = new Map<string, Promise<string>>();
|
|
75
72
|
let visionModelCache: { ref: string; model: Model<Api> } | null = null;
|
|
76
73
|
let visionModelUnresolvedRef: string | null = null;
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function isHandoffTarget(
|
|
83
|
-
model: { provider?: string; id?: string; input?: ("text" | "image")[] } | undefined | null,
|
|
84
|
-
cfg: VisionHandoffConfig,
|
|
85
|
-
): boolean {
|
|
86
|
-
if (!model || !model.provider || !model.id) return false;
|
|
87
|
-
const ref = formatModelRef(model.provider, model.id);
|
|
88
|
-
if (cfg.handoffModels.includes(ref)) return true;
|
|
89
|
-
if (cfg.autoHandoff && !isVisionModel(model)) return true;
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
75
|
+
// Usage reporter; wired to pi.appendEntry + pi.events.emit in the default
|
|
76
|
+
// export. No-op until then so the describer is safe to call before wiring.
|
|
77
|
+
let reportUsage: (record: VisionHandoffUsageRecord) => void = () => {};
|
|
92
78
|
|
|
93
79
|
function resolveVisionModel(modelRegistry: ModelRegistry, ref: string): Model<Api> | null {
|
|
94
80
|
if (visionModelCache && visionModelCache.ref === ref) return visionModelCache.model;
|
|
@@ -100,140 +86,29 @@ function resolveVisionModel(modelRegistry: ModelRegistry, ref: string): Model<Ap
|
|
|
100
86
|
return model;
|
|
101
87
|
}
|
|
102
88
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
modelRegistry: ModelRegistry,
|
|
113
|
-
cfg: VisionHandoffConfig,
|
|
114
|
-
): Promise<string> {
|
|
115
|
-
const key = imageHash(mimeType, data);
|
|
116
|
-
const cached = visionCache.get(key);
|
|
117
|
-
if (cached) return cached;
|
|
118
|
-
|
|
119
|
-
const promise = (async (): Promise<string> => {
|
|
120
|
-
const auth = await modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
121
|
-
if (!auth.ok || !auth.apiKey) return UNAVAILABLE;
|
|
122
|
-
|
|
123
|
-
const prefix = cfg.userPromptPrefix ?? DEFAULT_USER_PROMPT_PREFIX;
|
|
124
|
-
const systemPrompt = cfg.prompt ?? DEFAULT_VISION_PROMPT;
|
|
125
|
-
|
|
126
|
-
const content: (TextContent | ImageContent)[] = [];
|
|
127
|
-
if (userPrompt && userPrompt.trim()) {
|
|
128
|
-
content.push({ type: "text", text: prefix + userPrompt });
|
|
129
|
-
} else {
|
|
130
|
-
content.push({ type: "text", text: "Describe this image." } satisfies TextContent);
|
|
131
|
-
}
|
|
132
|
-
content.push({ type: "image", data, mimeType } satisfies ImageContent);
|
|
133
|
-
|
|
134
|
-
const userMessage: Message = {
|
|
135
|
-
role: "user",
|
|
136
|
-
content,
|
|
137
|
-
timestamp: Date.now(),
|
|
138
|
-
};
|
|
89
|
+
const loaderDeps: LoaderDeps = {
|
|
90
|
+
getConfig: () => config,
|
|
91
|
+
resolveVisionModel,
|
|
92
|
+
reportUsage: (record) => reportUsage(record),
|
|
93
|
+
setLastError: (msg) => {
|
|
94
|
+
lastDescriberError = msg;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const loader = new DescriptionLoader(loaderDeps);
|
|
139
98
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
// Energy/token capture for this describer call. The fetch interceptor is
|
|
143
|
-
// refcount-installed around the complete() window and routes the teed
|
|
144
|
-
// response body to this describe's AsyncLocalStorage slot — so concurrent
|
|
145
|
-
// describes (before_agent_start warm-up fires several in parallel) each
|
|
146
|
-
// get their own capture without clobbering globalThis.fetch. For non-
|
|
147
|
-
// Neuralwatt vision models no SSE energy comments are present and the
|
|
148
|
-
// capture stays empty (energy fields omitted from the record).
|
|
149
|
-
const describeCtx: DescribeContext = { energyReader: undefined };
|
|
150
|
-
installFetchInterceptor();
|
|
151
|
-
try {
|
|
152
|
-
const response = await describeAls.run(describeCtx, async () =>
|
|
153
|
-
complete(
|
|
154
|
-
visionModel,
|
|
155
|
-
{ systemPrompt, messages: [userMessage] },
|
|
156
|
-
{
|
|
157
|
-
apiKey: auth.apiKey,
|
|
158
|
-
headers: auth.headers,
|
|
159
|
-
signal: controller.signal,
|
|
160
|
-
maxTokens: cfg.maxTokens,
|
|
161
|
-
},
|
|
162
|
-
),
|
|
163
|
-
);
|
|
164
|
-
let capture: VisionHandoffEnergyCapture = EMPTY_ENERGY_CAPTURE;
|
|
165
|
-
if (describeCtx.energyReader) {
|
|
166
|
-
try {
|
|
167
|
-
capture = await describeCtx.energyReader;
|
|
168
|
-
} catch {
|
|
169
|
-
// tee aborted with the main stream — keep the empty capture
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const record = buildUsageRecord(response, capture, visionModel, key);
|
|
173
|
-
if (record) reportUsage(record);
|
|
174
|
-
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
175
|
-
return UNAVAILABLE;
|
|
176
|
-
}
|
|
177
|
-
const description = response.content
|
|
178
|
-
.filter((c): c is TextContent => c.type === "text")
|
|
179
|
-
.map((c) => c.text)
|
|
180
|
-
.join("\n")
|
|
181
|
-
.trim();
|
|
182
|
-
if (!description) return UNAVAILABLE;
|
|
183
|
-
// The full description lives in the stored tool-result content; the read
|
|
184
|
-
// tool's native collapse (ctrl+o) handles compactness — collapsed shows
|
|
185
|
-
// the image only, expanded shows the full description + image. Only apply
|
|
186
|
-
// a hard line cap when the user opts in (maxDescriptionLines > 0); by
|
|
187
|
-
// default (0) the description is unbounded so ctrl+o can expand to all
|
|
188
|
-
// of it and the model receives the complete context.
|
|
189
|
-
const { text: final } =
|
|
190
|
-
cfg.maxDescriptionLines && cfg.maxDescriptionLines > 0
|
|
191
|
-
? truncateDescription(description, cfg.maxDescriptionLines)
|
|
192
|
-
: { text: description };
|
|
193
|
-
return `${IMAGE_PLACEHOLDER_PREFIX}${final}${IMAGE_PLACEHOLDER_SUFFIX}`;
|
|
194
|
-
} catch {
|
|
195
|
-
return UNAVAILABLE;
|
|
196
|
-
} finally {
|
|
197
|
-
if (describeCtx.energyReader) describeCtx.energyReader.catch(() => {});
|
|
198
|
-
uninstallFetchInterceptor();
|
|
199
|
-
clearTimeout(timer);
|
|
200
|
-
}
|
|
201
|
-
})();
|
|
202
|
-
|
|
203
|
-
if (visionCache.size >= cfg.cacheMax) {
|
|
204
|
-
const firstKey = visionCache.keys().next().value;
|
|
205
|
-
if (firstKey !== undefined) visionCache.delete(firstKey);
|
|
206
|
-
}
|
|
207
|
-
visionCache.set(key, promise);
|
|
208
|
-
|
|
209
|
-
return promise;
|
|
99
|
+
function isConfigured(cfg: VisionHandoffConfig): boolean {
|
|
100
|
+
return cfg.enabled && !!cfg.visionModel;
|
|
210
101
|
}
|
|
211
102
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
userPrompt: string,
|
|
215
|
-
visionModel: Model<Api>,
|
|
216
|
-
modelRegistry: ModelRegistry,
|
|
103
|
+
function isHandoffTarget(
|
|
104
|
+
model: { provider?: string; id?: string; input?: ("text" | "image")[] } | undefined | null,
|
|
217
105
|
cfg: VisionHandoffConfig,
|
|
218
|
-
):
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (!msg || typeof msg !== "object") continue;
|
|
225
|
-
const content = (msg as Record<string, unknown>).content;
|
|
226
|
-
if (!Array.isArray(content)) continue;
|
|
227
|
-
|
|
228
|
-
for (let i = 0; i < content.length; i++) {
|
|
229
|
-
const img = extractImageFromBlock(content[i]);
|
|
230
|
-
if (!img) continue;
|
|
231
|
-
const description = await describeImage(img.data, img.mimeType, userPrompt, visionModel, modelRegistry, cfg);
|
|
232
|
-
content[i] = makeReplacementText(content[i], description);
|
|
233
|
-
replaced = true;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return replaced;
|
|
106
|
+
): boolean {
|
|
107
|
+
if (!model || !model.provider || !model.id) return false;
|
|
108
|
+
const ref = formatModelRef(model.provider, model.id);
|
|
109
|
+
if (cfg.handoffModels.includes(ref)) return true;
|
|
110
|
+
if (cfg.autoHandoff && !isVisionModel(model)) return true;
|
|
111
|
+
return false;
|
|
237
112
|
}
|
|
238
113
|
|
|
239
114
|
function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
|
|
@@ -247,6 +122,29 @@ function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
|
|
|
247
122
|
}
|
|
248
123
|
}
|
|
249
124
|
|
|
125
|
+
/** Notify once per failing image per session (dedup via warnedHashes). */
|
|
126
|
+
function warnFailedImages(
|
|
127
|
+
ctx: ExtensionContext,
|
|
128
|
+
imgs: ExtractedImage[],
|
|
129
|
+
descs: string[],
|
|
130
|
+
reason: string,
|
|
131
|
+
): void {
|
|
132
|
+
if (!ctx.hasUI) return;
|
|
133
|
+
const newlyFailed: string[] = [];
|
|
134
|
+
for (let i = 0; i < imgs.length; i++) {
|
|
135
|
+
if (descs[i] === UNAVAILABLE) {
|
|
136
|
+
const h = imageHash(imgs[i].mimeType, imgs[i].data);
|
|
137
|
+
if (!warnedHashes.has(h)) newlyFailed.push(h);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (newlyFailed.length === 0) return;
|
|
141
|
+
for (const h of newlyFailed) warnedHashes.add(h);
|
|
142
|
+
ctx.ui.notify(
|
|
143
|
+
`pi-vision-handoff: image description failed — ${reason}. Vision model: ${config.visionModel}`,
|
|
144
|
+
"warning",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
250
148
|
export default function (pi: ExtensionAPI) {
|
|
251
149
|
config = readConfig();
|
|
252
150
|
|
|
@@ -274,72 +172,228 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
172
|
// Reload in case the user edited the config on disk from another session.
|
|
275
173
|
config = readConfig();
|
|
276
174
|
visionModelCache = null;
|
|
277
|
-
|
|
175
|
+
visionModelUnresolvedRef = null;
|
|
176
|
+
warnedHashes.clear();
|
|
177
|
+
loader.reset();
|
|
278
178
|
});
|
|
279
179
|
|
|
280
180
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
281
181
|
if (!isConfigured(config)) return;
|
|
282
182
|
if (!isHandoffTarget(ctx.model, config)) return;
|
|
283
183
|
|
|
284
|
-
// Capture this turn's user prompt so
|
|
285
|
-
// the
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const images = event.images;
|
|
289
|
-
if (!images || images.length === 0) return;
|
|
184
|
+
// Capture this turn's user prompt so every image in the turn — attached or
|
|
185
|
+
// read via the read tool — is described in the same request context.
|
|
186
|
+
loader.setPendingTurnPrompt(event.prompt || "");
|
|
187
|
+
loader.bindTurnContext(ctx);
|
|
290
188
|
|
|
291
|
-
|
|
292
|
-
if (!visionModel) {
|
|
189
|
+
if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
|
|
293
190
|
notifyUnresolvedVisionModel(ctx, config.visionModel!);
|
|
294
191
|
return;
|
|
295
192
|
}
|
|
296
193
|
|
|
297
|
-
|
|
298
|
-
|
|
194
|
+
// PRE-WARM at paste-enter via the DataLoader. Two image sources land here:
|
|
195
|
+
//
|
|
196
|
+
// 1. Attached image blocks (event.images) — vision-capable targets where
|
|
197
|
+
// the user message itself carries image blocks (e.g. `pi --image`).
|
|
198
|
+
//
|
|
199
|
+
// 2. Pasted clipboard image FILE PATHS in the prompt text — the common
|
|
200
|
+
// non-vision flow. pi's `handleClipboardImagePaste` writes each pasted
|
|
201
|
+
// image to `<tmpdir>/pi-clipboard-<uuid>.<ext>` and inserts the PATH as
|
|
202
|
+
// text at the cursor; on a non-vision model these arrive as path tokens
|
|
203
|
+
// in `event.prompt`, NOT as `event.images`. We scan the prompt for those
|
|
204
|
+
// temp paths, read the files, and `loadDescription()` them so the ONE
|
|
205
|
+
// batched vision call starts the instant you press enter — CONCURRENT
|
|
206
|
+
// with the agent's first response generation — instead of waiting for
|
|
207
|
+
// the agent to `read` the files. By the time the agent's `read` tool
|
|
208
|
+
// results fire, `tool_result`'s `loadDescription()` is a cache hit.
|
|
209
|
+
//
|
|
210
|
+
// Both sources flow through the same loader: `load()` is synchronous and
|
|
211
|
+
// memoized, so all images in this frame (attached + clipboard-path)
|
|
212
|
+
// coalesce into ONE batch dispatched after the microtask cascade.
|
|
213
|
+
for (const image of event.images ?? []) {
|
|
299
214
|
if (!image || image.type !== "image" || !image.data) continue;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
215
|
+
loader.loadDescription({ data: image.data, mimeType: image.mimeType || "image/png" }).catch(() => {});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Pasted clipboard image paths in the prompt text — resolve each to the
|
|
219
|
+
// SAME ExtractedImage pi's `read` tool will emit, then warm the loader so
|
|
220
|
+
// the `tool_result`'s `loadDescription()` is a cache hit (no wasted vision
|
|
221
|
+
// call). pi's read tool resizes images by default: for a small image it
|
|
222
|
+
// returns the raw bytes unchanged (our raw read matches), but for an
|
|
223
|
+
// oversized image it RE-ENCODES via Photon — so we run the same
|
|
224
|
+
// `resizeImage` pipeline to produce a matching key. The resize branch is
|
|
225
|
+
// async (worker thread) and fire-and-forget; its `loadDescription()` lands
|
|
226
|
+
// in a later batch than the no-resize images, so a mixed small+oversized
|
|
227
|
+
// paste may split into two vision calls (still no WASTED call — each
|
|
228
|
+
// describes an image the agent will see). A file that can't be read, isn't
|
|
229
|
+
// a supported image, or fails to resize is skipped (the agent's `read`
|
|
230
|
+
// will still describe it via `tool_result` if it emits an image block).
|
|
231
|
+
for (const p of findClipboardImagePaths(event.prompt || "")) {
|
|
232
|
+
const read = readImageBuffer(p);
|
|
233
|
+
if (!read) continue;
|
|
234
|
+
resolvePrewarmImage(read.buf, read.mimeType, resizeImage)
|
|
235
|
+
.then((img) => {
|
|
236
|
+
if (img) loader.loadDescription(img).catch(() => {});
|
|
237
|
+
})
|
|
238
|
+
.catch(() => {});
|
|
304
239
|
}
|
|
305
240
|
});
|
|
306
241
|
|
|
307
|
-
|
|
242
|
+
// The PRIMARY injection point: the `read` tool's `tool_result` handler.
|
|
243
|
+
// When the agent reads image files, this fires for each read result. It
|
|
244
|
+
// calls the loader's `loadDescription(img)` for every image block and
|
|
245
|
+
// AWAITS the shared batch — so N parallel reads (pi runs `read` via
|
|
246
|
+
// Promise.all) coalesce into ONE batched vision call (DataLoader: all
|
|
247
|
+
// load() calls in the same microtask frame share one batch, dispatched
|
|
248
|
+
// after the cascade settles) and all resolve together. The descriptions
|
|
249
|
+
// replace the image blocks in the returned `content`, so by the time the
|
|
250
|
+
// agent's next turn starts the tool results already carry text — the
|
|
251
|
+
// agent never sees raw image blocks it can't process.
|
|
252
|
+
//
|
|
253
|
+
// Why block here and not in `context`: the tool-result phase is free time
|
|
254
|
+
// (the agent is just waiting for tool results), so running the describer
|
|
255
|
+
// here adds zero latency to the critical path. `context` then becomes a
|
|
256
|
+
// cache-hit no-op for read images.
|
|
257
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
308
258
|
if (!isConfigured(config)) return;
|
|
309
|
-
if (
|
|
259
|
+
if (event.toolName !== "read") return;
|
|
260
|
+
const content = event.content;
|
|
261
|
+
if (!Array.isArray(content)) return;
|
|
310
262
|
|
|
311
|
-
|
|
312
|
-
|
|
263
|
+
// Collect image blocks in this read result. (The read tool emits image
|
|
264
|
+
// blocks even for non-vision models — they reach here untouched.)
|
|
265
|
+
const imgs: ExtractedImage[] = [];
|
|
266
|
+
for (const block of content) {
|
|
267
|
+
const img = extractImageFromBlock(block);
|
|
268
|
+
if (img) imgs.push(img);
|
|
269
|
+
}
|
|
270
|
+
if (imgs.length === 0) return;
|
|
271
|
+
if (!isHandoffTarget(ctx.model, config)) return;
|
|
272
|
+
loader.bindTurnContext(ctx);
|
|
273
|
+
if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
|
|
313
274
|
notifyUnresolvedVisionModel(ctx, config.visionModel!);
|
|
314
275
|
return;
|
|
315
276
|
}
|
|
316
277
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
278
|
+
// load() each image — synchronous calls that push into the current batch
|
|
279
|
+
// and return memoized promises — then await them all. Parallel reads'
|
|
280
|
+
// load() calls land in the SAME batch (same microtask frame), so this is
|
|
281
|
+
// ONE vision call for the whole read set, not N. Awaiting here runs the
|
|
282
|
+
// describer during the tool-result phase (free time — the agent is just
|
|
283
|
+
// waiting for tool results), so the batch is COMPLETE before `context`
|
|
284
|
+
// fires, making `context` a non-blocking cache hit instead of a cold miss
|
|
285
|
+
// on the critical path.
|
|
286
|
+
//
|
|
287
|
+
// We do NOT mutate the result content here for the image blocks: returning
|
|
288
|
+
// undefined keeps the image block in storage so kitty renders it inline
|
|
289
|
+
// and `/resume` retains it. The actual image→text swap happens in the
|
|
290
|
+
// `context` hook (on the cloned LLM-bound payload only), by which point
|
|
291
|
+
// these are cache hits. We DO strip pi's misleading non-vision note (below)
|
|
292
|
+
// since the handoff will replace the image with a description.
|
|
293
|
+
const descs = await Promise.all(imgs.map((img) => loader.loadDescription(img)));
|
|
294
|
+
|
|
295
|
+
// On user abort, leave the result untouched — pi is tearing the turn
|
|
296
|
+
// down and the LLM-bound content won't be sent.
|
|
297
|
+
if (ctx.signal?.aborted) return;
|
|
298
|
+
|
|
299
|
+
warnFailedImages(ctx, imgs, descs, lastDescriberError ?? "unknown error");
|
|
300
|
+
|
|
301
|
+
// Strip pi's `[Current model does not support images…]` note from text
|
|
302
|
+
// blocks — since the handoff replaces the image with a description in the
|
|
303
|
+
// `context` hook, that note is misleading (the agent WILL receive the
|
|
304
|
+
// image's content, as text). Keep the image block itself so kitty still
|
|
305
|
+
// renders it inline and `/resume` retains it; the `context` hook swaps the
|
|
306
|
+
// image for its description in the LLM-bound clone before the next turn.
|
|
307
|
+
let stripped = false;
|
|
308
|
+
const next = content.slice();
|
|
309
|
+
for (let i = 0; i < next.length; i++) {
|
|
310
|
+
const block = next[i];
|
|
311
|
+
if (block && typeof block === "object" && (block as { type: string }).type === "text") {
|
|
312
|
+
const text = (block as { text: string }).text;
|
|
313
|
+
if (typeof text === "string" && text.includes(NON_VISION_IMAGE_NOTE)) {
|
|
314
|
+
const cleaned = stripNonVisionImageNote(text);
|
|
315
|
+
if (cleaned !== text) {
|
|
316
|
+
next[i] = { type: "text", text: cleaned };
|
|
317
|
+
stripped = true;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (stripped) return { content: next as (TextContent | ImageContent)[] };
|
|
320
323
|
});
|
|
321
324
|
|
|
322
|
-
|
|
325
|
+
// The FALLBACK injection point: the `context` event fires as the agent's
|
|
326
|
+
// `transformContext`, BEFORE pi-ai's `downgradeUnsupportedImages` strips
|
|
327
|
+
// image blocks and BEFORE `convertToLlm`. It catches any image blocks that
|
|
328
|
+
// didn't go through the `read` tool's `tool_result` handler — user-attached
|
|
329
|
+
// images (for vision-capable handoff targets), custom extension-injected
|
|
330
|
+
// messages, or reads that somehow bypassed the handler. `emitContext` does a
|
|
331
|
+
// `structuredClone`, so swapping here touches only the LLM-bound payload.
|
|
332
|
+
//
|
|
333
|
+
// Read images are already text by this point (the `tool_result` handler
|
|
334
|
+
// replaced them), so this is usually a no-op for the common paste-and-read
|
|
335
|
+
// flow. For the images it does find, `loadDescription()` is a cache hit
|
|
336
|
+
// (warmed by `before_agent_start`) or queues into the loader's current batch.
|
|
337
|
+
pi.on("context", async (event, ctx) => {
|
|
323
338
|
if (!isConfigured(config)) return;
|
|
324
|
-
if (!isHandoffTarget(ctx.model, config)) return;
|
|
325
|
-
if (event.toolName !== "read") return;
|
|
326
339
|
|
|
327
|
-
const
|
|
328
|
-
if (!Array.isArray(
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
340
|
+
const messages = event.messages as unknown as Array<Record<string, unknown>>;
|
|
341
|
+
if (!Array.isArray(messages)) return;
|
|
342
|
+
|
|
343
|
+
const byHash = new Map<string, ExtractedImage>();
|
|
344
|
+
let anyImage = false;
|
|
345
|
+
for (const msg of messages) {
|
|
346
|
+
const content = msg.content;
|
|
347
|
+
if (!Array.isArray(content)) continue;
|
|
348
|
+
for (const block of content) {
|
|
349
|
+
const img = extractImageFromBlock(block);
|
|
350
|
+
if (!img) continue;
|
|
351
|
+
anyImage = true;
|
|
352
|
+
byHash.set(imageHash(img.mimeType, img.data), img);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!anyImage) return;
|
|
356
|
+
if (!isHandoffTarget(ctx.model, config)) return;
|
|
357
|
+
loader.bindTurnContext(ctx);
|
|
358
|
+
if (!resolveVisionModel(ctx.modelRegistry, config.visionModel!)) {
|
|
334
359
|
notifyUnresolvedVisionModel(ctx, config.visionModel!);
|
|
335
360
|
return;
|
|
336
361
|
}
|
|
337
362
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
|
|
363
|
+
// Cache hits (warmed by before_agent_start / tool_result) resolve
|
|
364
|
+
// instantly; any remaining misses queue into the loader's current batch.
|
|
365
|
+
const imgs = [...byHash.values()];
|
|
366
|
+
const descArr = await Promise.all(imgs.map((img) => loader.loadDescription(img)));
|
|
367
|
+
const descs = new Map<string, string>();
|
|
368
|
+
for (let i = 0; i < imgs.length; i++) {
|
|
369
|
+
descs.set(imageHash(imgs[i].mimeType, imgs[i].data), descArr[i]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (ctx.signal?.aborted) return;
|
|
373
|
+
|
|
374
|
+
warnFailedImages(ctx, imgs, descArr, lastDescriberError ?? "unknown error");
|
|
375
|
+
|
|
376
|
+
let changed = false;
|
|
377
|
+
for (const msg of messages) {
|
|
378
|
+
const content = msg.content;
|
|
379
|
+
if (!Array.isArray(content)) continue;
|
|
380
|
+
let touched = false;
|
|
381
|
+
const next: unknown[] = [];
|
|
382
|
+
for (const block of content) {
|
|
383
|
+
const img = extractImageFromBlock(block);
|
|
384
|
+
if (img) {
|
|
385
|
+
next.push({ type: "text", text: descs.get(imageHash(img.mimeType, img.data)) ?? UNAVAILABLE });
|
|
386
|
+
touched = true;
|
|
387
|
+
} else {
|
|
388
|
+
next.push(block);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (touched) {
|
|
392
|
+
msg.content = next;
|
|
393
|
+
changed = true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (changed) return { messages: event.messages };
|
|
343
397
|
});
|
|
344
398
|
|
|
345
399
|
pi.on("model_select", (event, ctx) => {
|
|
@@ -396,8 +450,9 @@ async function handleHandoffCommand(ctx: ExtensionCommandContext, args: string):
|
|
|
396
450
|
" /vision-handoff help This message",
|
|
397
451
|
"",
|
|
398
452
|
"Config: ~/.pi/agent/extensions/pi-vision-handoff.json",
|
|
399
|
-
"Mechanism: before_agent_start warms a description cache;
|
|
400
|
-
"
|
|
453
|
+
"Mechanism: before_agent_start warms a description cache; tool_result loads",
|
|
454
|
+
" read images through a dataloader (one batched vision call); context swaps",
|
|
455
|
+
" image blocks in the payload for the cached text description.",
|
|
401
456
|
].join("\n"),
|
|
402
457
|
"info",
|
|
403
458
|
);
|
|
@@ -571,7 +626,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
|
|
|
571
626
|
lines.push(`Vision model: ${config.visionModel ?? "(none — pick one with /vision-handoff)"}`);
|
|
572
627
|
lines.push(`Auto handoff (non-vision models): ${config.autoHandoff ? "on" : "off"}`);
|
|
573
628
|
lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
|
|
574
|
-
lines.push(`maxTokens: ${config.maxTokens} · cacheMax: ${config.cacheMax} · maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
|
|
629
|
+
lines.push(`maxTokens: ${config.maxTokens ?? "unbounded"} · cacheMax: ${config.cacheMax} · maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
|
|
575
630
|
|
|
576
631
|
const model = ctx.model;
|
|
577
632
|
let active = false;
|