pi-vision-handoff 0.1.0 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vision-handoff",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Give text-only pi models vision — describe images with a vision model you pick via an interactive picker, then hand off the text description to non-vision models",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * convention pi-model-sort uses for picker-backed extensions.
6
6
  */
7
7
 
8
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
8
9
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
11
  import { join } from "node:path";
@@ -44,6 +45,18 @@ export const DEFAULT_CACHE_MAX = 50;
44
45
  /** Per-description request timeout. */
45
46
  export const DESCRIBE_TIMEOUT_MS = 30_000;
46
47
 
48
+ /**
49
+ * Default cap on the number of lines kept from a description before truncation.
50
+ *
51
+ * Mirrors pi core's read-result truncation: tool output is bounded for both the
52
+ * TUI render and the model context. The stored `result.content` is the single
53
+ * source for both surfaces (no decouple point exists at `tool_result` — it's the
54
+ * only hook that still has the raw image before pi-ai strips it), so the cap
55
+ * applies uniformly. 0 = unbounded (default): the read tool's native collapse
56
+ * handles compactness and `ctrl+o` expands to the full description.
57
+ */
58
+ export const DEFAULT_MAX_DESCRIPTION_LINES = 0;
59
+
47
60
  export interface VisionHandoffConfig {
48
61
  /** Master switch. When false, no handoff occurs even if a vision model is configured. */
49
62
  enabled: boolean;
@@ -57,6 +70,12 @@ export interface VisionHandoffConfig {
57
70
  maxTokens: number;
58
71
  /** Max images kept in the in-memory description cache. */
59
72
  cacheMax: number;
73
+ /**
74
+ * Max lines kept from a description before truncation (0 = unbounded).
75
+ * Bounds both the TUI render and the model context, like pi core's tool-output
76
+ * truncation.
77
+ */
78
+ maxDescriptionLines: number;
60
79
  /** Override the describer system prompt (defaults to DEFAULT_VISION_PROMPT). */
61
80
  prompt?: string;
62
81
  /** Override the user-prompt prefix (defaults to DEFAULT_USER_PROMPT_PREFIX). */
@@ -70,6 +89,7 @@ export const DEFAULT_CONFIG: VisionHandoffConfig = {
70
89
  handoffModels: [],
71
90
  maxTokens: DEFAULT_MAX_TOKENS,
72
91
  cacheMax: DEFAULT_CACHE_MAX,
92
+ maxDescriptionLines: DEFAULT_MAX_DESCRIPTION_LINES,
73
93
  };
74
94
 
75
95
  /** Parse a "provider/id" reference. Returns null if malformed. */
@@ -120,6 +140,14 @@ export function normalizeConfig(raw: unknown): VisionHandoffConfig {
120
140
  if (typeof obj.cacheMax === "number" && Number.isFinite(obj.cacheMax) && obj.cacheMax > 0) {
121
141
  base.cacheMax = Math.floor(obj.cacheMax);
122
142
  }
143
+ // maxDescriptionLines: any non-negative finite integer. 0 = unbounded.
144
+ if (
145
+ typeof obj.maxDescriptionLines === "number" &&
146
+ Number.isFinite(obj.maxDescriptionLines) &&
147
+ obj.maxDescriptionLines >= 0
148
+ ) {
149
+ base.maxDescriptionLines = Math.floor(obj.maxDescriptionLines);
150
+ }
123
151
  if (typeof obj.prompt === "string" && obj.prompt.trim()) base.prompt = obj.prompt;
124
152
  if (typeof obj.userPromptPrefix === "string") base.userPromptPrefix = obj.userPromptPrefix;
125
153
 
@@ -181,10 +209,20 @@ export function extractImageFromBlock(block: unknown): ExtractedImage | null {
181
209
  return null;
182
210
  }
183
211
 
212
+ // anthropic-messages image block (wrapped in `source`).
184
213
  if (b.type === "image" && b.source?.type === "base64" && typeof b.source.data === "string") {
185
214
  return { data: b.source.data, mimeType: b.source.media_type || "image/png" };
186
215
  }
187
216
 
217
+ // pi-ai internal image block — emitted by the `read` tool and carried by
218
+ // ToolResultEvent.content / user-message content blocks (regardless of whether
219
+ // the active model declares image input):
220
+ // { type: "image", data: "<base64>", mimeType }
221
+ // Distinct from the anthropic shape above: no `source` wrapper, direct fields.
222
+ if (b.type === "image" && typeof b.data === "string") {
223
+ return { data: b.data, mimeType: b.mimeType || "image/png" };
224
+ }
225
+
188
226
  return null;
189
227
  }
190
228
 
@@ -196,3 +234,86 @@ export function makeReplacementText(block: unknown, description: string): Record
196
234
  }
197
235
  return { type: "text", text: description };
198
236
  }
237
+
238
+ /** Outcome of {@link truncateDescription}. */
239
+ export interface TruncatedDescription {
240
+ text: string;
241
+ /** True iff lines were dropped. */
242
+ truncated: boolean;
243
+ /** Number of trailing lines removed. */
244
+ hidden: number;
245
+ }
246
+
247
+ /**
248
+ * Head-truncate a description to its first `maxLines` lines with a
249
+ * "... (N more lines)" footer, mirroring pi core's read-result truncation
250
+ * notice (which itself surfaces "... (N more lines, ctrl+o to expand)").
251
+ *
252
+ * Returns the text unchanged when it is at or below the limit, or when
253
+ * `maxLines` is non-positive (0 = unbounded). Head-truncation keeps the leading
254
+ * layout / text-content / structure sections, which are typically the most
255
+ * actionable for a coding agent.
256
+ */
257
+ export function truncateDescription(text: string, maxLines: number): TruncatedDescription {
258
+ if (!maxLines || maxLines <= 0) {
259
+ return { text, truncated: false, hidden: 0 };
260
+ }
261
+ const lines = text.split("\n");
262
+ if (lines.length <= maxLines) {
263
+ return { text, truncated: false, hidden: 0 };
264
+ }
265
+ const hidden = lines.length - maxLines;
266
+ const kept = lines.slice(0, maxLines).join("\n");
267
+ return { text: `${kept}\n... (${hidden} more lines)`, truncated: true, hidden };
268
+ }
269
+
270
+ /** Outcome of {@link insertImageDescriptions}. */
271
+ export interface ReplacedContent {
272
+ content: (TextContent | ImageContent)[];
273
+ /** True iff at least one image block had a description inserted before it. */
274
+ changed: boolean;
275
+ }
276
+
277
+ /**
278
+ * Insert a description text block before each image block in a tool-result /
279
+ * message content array, KEEPING the image block in place.
280
+ *
281
+ * Why insert (not replace): the stored tool-result content is the single
282
+ * source for both the TUI render (kitty inline images read it via
283
+ * `result.content.filter(c => c.type === "image")`) and the provider payload.
284
+ * Replacing the image would strip it from the terminal render. Keeping the
285
+ * image preserves kitty rendering; the inserted description still reaches
286
+ * non-vision models because pi-ai's `downgradeUnsupportedImages` only rewrites
287
+ * `type: "image"` blocks for non-vision models — text blocks (including this
288
+ * description) pass through untouched to the provider.
289
+ *
290
+ * `describe` is injected (rather than calling the vision model directly) so the
291
+ * extract → describe → insert pipeline is unit-testable without standing up a
292
+ * provider, registry, and API call. The extension wires its real `describeImage`
293
+ * into this helper in its `tool_result` handler.
294
+ *
295
+ * Returns a new array and `changed: false` when there were no images, so
296
+ * callers can short-circuit and avoid mutating pi's stored result unnecessarily.
297
+ */
298
+ export async function insertImageDescriptions(
299
+ content: readonly (TextContent | ImageContent)[] | undefined,
300
+ describe: (img: ExtractedImage) => Promise<string>,
301
+ ): Promise<ReplacedContent> {
302
+ if (!Array.isArray(content)) {
303
+ return { content: [], changed: false };
304
+ }
305
+ const next: (TextContent | ImageContent)[] = [];
306
+ let changed = false;
307
+ for (const block of content) {
308
+ const img = extractImageFromBlock(block);
309
+ if (!img) {
310
+ next.push(block);
311
+ continue;
312
+ }
313
+ const description = await describe(img);
314
+ next.push({ type: "text", text: description } satisfies TextContent);
315
+ next.push(block);
316
+ changed = true;
317
+ }
318
+ return { content: next, changed };
319
+ }
package/vision-handoff.ts CHANGED
@@ -9,12 +9,18 @@
9
9
  *
10
10
  * Pipeline (provider-agnostic via @earendil-works/pi-ai's complete()):
11
11
  * before_agent_start → warm the description cache for attached images
12
- * before_provider_requestswap image blocks in the payload for text
12
+ * tool_result (read) describe read-tool images and INSERT the description
13
+ * as a text block before each image, keeping the image so the TUI still
14
+ * renders it (kitty). pi-ai later strips the image for non-vision models,
15
+ * leaving the description text for the model.
16
+ * before_provider_request → swap remaining image blocks in the payload for
17
+ * text (catches user-attached images for vision-capable handoff targets)
13
18
  *
14
- * Image blocks are detected by shape across the three request formats pi uses:
19
+ * Image blocks are detected by shape across the four formats pi uses:
15
20
  * openai-completions: { type: "image_url", image_url: { url: "data:..." } }
16
21
  * openai-responses: { type: "input_image", image_url: "data:..." }
17
22
  * anthropic-messages: { type: "image", source: { type: "base64", media_type, data } }
23
+ * pi-ai internal: { type: "image", data, mimeType } ← read tool / ToolResultEvent
18
24
  *
19
25
  * Descriptions are cached per image hash (LRU, size = config.cacheMax) so the
20
26
  * swap is instant by the time before_provider_request fires.
@@ -33,10 +39,12 @@ import {
33
39
  IMAGE_PLACEHOLDER_SUFFIX,
34
40
  extractImageFromBlock,
35
41
  formatModelRef,
42
+ insertImageDescriptions,
36
43
  isVisionModel,
37
44
  makeReplacementText,
38
45
  parseModelRef,
39
46
  readConfig,
47
+ truncateDescription,
40
48
  writeConfig,
41
49
  type VisionHandoffConfig,
42
50
  } from "./src/index.js";
@@ -46,6 +54,9 @@ const UNAVAILABLE = `${IMAGE_PLACEHOLDER_PREFIX}description unavailable${IMAGE_P
46
54
 
47
55
  let config: VisionHandoffConfig = readConfig();
48
56
 
57
+ /** User prompt for the current agent turn, captured from before_agent_start. */
58
+ let pendingTurnPrompt: string | null = null;
59
+
49
60
  const visionCache = new Map<string, Promise<string>>();
50
61
  let visionModelCache: { ref: string; model: Model<Api> } | null = null;
51
62
  let visionModelUnresolvedRef: string | null = null;
@@ -134,7 +145,17 @@ async function describeImage(
134
145
  .join("\n")
135
146
  .trim();
136
147
  if (!description) return UNAVAILABLE;
137
- return `${IMAGE_PLACEHOLDER_PREFIX}${description}${IMAGE_PLACEHOLDER_SUFFIX}`;
148
+ // The full description lives in the stored tool-result content; the read
149
+ // tool's native collapse (ctrl+o) handles compactness — collapsed shows
150
+ // the image only, expanded shows the full description + image. Only apply
151
+ // a hard line cap when the user opts in (maxDescriptionLines > 0); by
152
+ // default (0) the description is unbounded so ctrl+o can expand to all
153
+ // of it and the model receives the complete context.
154
+ const { text: final } =
155
+ cfg.maxDescriptionLines && cfg.maxDescriptionLines > 0
156
+ ? truncateDescription(description, cfg.maxDescriptionLines)
157
+ : { text: description };
158
+ return `${IMAGE_PLACEHOLDER_PREFIX}${final}${IMAGE_PLACEHOLDER_SUFFIX}`;
138
159
  } catch {
139
160
  return UNAVAILABLE;
140
161
  } finally {
@@ -196,11 +217,17 @@ export default function (pi: ExtensionAPI) {
196
217
  // Reload in case the user edited the config on disk from another session.
197
218
  config = readConfig();
198
219
  visionModelCache = null;
220
+ pendingTurnPrompt = null;
199
221
  });
200
222
 
201
223
  pi.on("before_agent_start", async (event, ctx) => {
202
224
  if (!isConfigured(config)) return;
203
225
  if (!isHandoffTarget(ctx.model, config)) return;
226
+
227
+ // Capture this turn's user prompt so read-tool images are described in
228
+ // the same context. Fires before any tool_result for the turn.
229
+ pendingTurnPrompt = event.prompt || "";
230
+
204
231
  const images = event.images;
205
232
  if (!images || images.length === 0) return;
206
233
 
@@ -235,6 +262,29 @@ export default function (pi: ExtensionAPI) {
235
262
  return payload;
236
263
  });
237
264
 
265
+ pi.on("tool_result", async (event, ctx) => {
266
+ if (!isConfigured(config)) return;
267
+ if (!isHandoffTarget(ctx.model, config)) return;
268
+ if (event.toolName !== "read") return;
269
+
270
+ const content = event.content;
271
+ if (!Array.isArray(content)) return;
272
+ // Skip the async work entirely when there is nothing to describe.
273
+ if (!content.some((c) => extractImageFromBlock(c))) return;
274
+
275
+ const visionModel = resolveVisionModel(ctx.modelRegistry, config.visionModel!);
276
+ if (!visionModel) {
277
+ notifyUnresolvedVisionModel(ctx, config.visionModel!);
278
+ return;
279
+ }
280
+
281
+ const { content: next, changed } = await insertImageDescriptions(content, (img) =>
282
+ describeImage(img.data, img.mimeType, pendingTurnPrompt ?? "", visionModel, ctx.modelRegistry, config),
283
+ );
284
+ if (!changed) return;
285
+ return { content: next };
286
+ });
287
+
238
288
  pi.on("model_select", (event, ctx) => {
239
289
  if (!ctx.hasUI) return;
240
290
  if (!isConfigured(config)) return;
@@ -464,7 +514,7 @@ function showStatus(ctx: ExtensionCommandContext): void {
464
514
  lines.push(`Vision model: ${config.visionModel ?? "(none — pick one with /vision-handoff)"}`);
465
515
  lines.push(`Auto handoff (non-vision models): ${config.autoHandoff ? "on" : "off"}`);
466
516
  lines.push(`Handoff targets (explicit): ${config.handoffModels.length ? config.handoffModels.join(", ") : "(none)"}`);
467
- lines.push(`maxTokens: ${config.maxTokens} · cacheMax: ${config.cacheMax}`);
517
+ lines.push(`maxTokens: ${config.maxTokens} · cacheMax: ${config.cacheMax} · maxDescriptionLines: ${config.maxDescriptionLines === 0 ? "unbounded" : config.maxDescriptionLines}`);
468
518
 
469
519
  const model = ctx.model;
470
520
  let active = false;