pi-vision-handoff 0.2.0 → 0.2.1
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 +46 -27
- package/package.json +1 -1
- package/src/index.ts +42 -0
package/README.md
CHANGED
|
@@ -26,8 +26,8 @@ The `pi-umans-provider` extension quietly solved this for GLM 5.1: a hardcoded "
|
|
|
26
26
|
|
|
27
27
|
- **Pick any vision-capable model** from your registry via an interactive picker — OpenAI, Anthropic, Google, Ollama, or any custom provider pi knows about.
|
|
28
28
|
- Your choice **persists** to `~/.pi/agent/extensions/pi-vision-handoff.json`.
|
|
29
|
-
- For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model **before** the
|
|
30
|
-
- Works across all
|
|
29
|
+
- For any model that doesn't declare image input (or any model you explicitly target), `pi-vision-handoff` describes the image with your chosen vision model and **inserts the description before the kept image** in the stored `read`-tool result — **before** pi-ai can strip the image for non-vision models. The image stays for kitty rendering; pi-ai later strips only the image block, leaving the description text for the model.
|
|
30
|
+
- Works across all four image-block shapes pi uses — the three provider-transformed formats (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages) plus pi-ai's internal `{ type: "image", data, mimeType }` emitted by the `read` tool — detected by shape.
|
|
31
31
|
- Descriptions are **cached per image hash** (LRU), so the swap is instant by the time the request fires.
|
|
32
32
|
|
|
33
33
|
No `settings.json` touched. No per-provider glue. Pick a describer once and every text-only model you own can suddenly see.
|
|
@@ -35,12 +35,13 @@ No `settings.json` touched. No per-provider glue. Pick a describer once and ever
|
|
|
35
35
|
## Features
|
|
36
36
|
|
|
37
37
|
- **🎮 Interactive picker** — `/vision-handoff` opens a TUI listing every model, vision-capable ones first (👁), to choose your describer.
|
|
38
|
+
- **🖼️ Read-tool hijack** — `read`-tool images are intercepted at `tool_result` time, before pi-ai can strip them for non-vision models. The kept image still renders inline (kitty); the description is inserted as a text block the text-only model consumes.
|
|
38
39
|
- **🔌 Provider-agnostic** — uses pi's own model execution machinery (`@earendil-works/pi-ai`'s `complete()`), so it works with any provider/configured model, including custom provider extensions.
|
|
39
40
|
- **🧠 Automatic targets** — by default, handoff applies to *every* model that lacks native vision. Opt out with `/vision-handoff auto off`.
|
|
40
41
|
- **🗂️ Explicit overrides** — force handoff for specific models (e.g. a weak vision model) with `/vision-handoff add`.
|
|
41
42
|
- **⚡ Cache-warmed** — `before_agent_start` describes attached images the moment you submit, so the request is rarely delayed.
|
|
42
43
|
- **🛡️ Graceful degradation** — no API key? Describer unreachable? Aborted? The image is replaced with a clean `[Image: description unavailable]` placeholder instead of crashing your turn.
|
|
43
|
-
- **🔧 Tunable** — cap description length and cache size in the config file.
|
|
44
|
+
- **🔧 Tunable** — cap description length (`maxDescriptionLines`; unbounded by default, so `read`'s native `ctrl+o` collapse handles compactness) and cache size, in the config file.
|
|
44
45
|
|
|
45
46
|
## Usage
|
|
46
47
|
|
|
@@ -71,6 +72,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
71
72
|
"handoffModels": ["ollama/llava:13b"],
|
|
72
73
|
"maxTokens": 1024,
|
|
73
74
|
"cacheMax": 50,
|
|
75
|
+
"maxDescriptionLines": 0,
|
|
74
76
|
"prompt": "Describe this image exhaustively…",
|
|
75
77
|
"userPromptPrefix": "The user's request about this image: "
|
|
76
78
|
}
|
|
@@ -84,6 +86,7 @@ Created automatically at `~/.pi/agent/extensions/pi-vision-handoff.json` on firs
|
|
|
84
86
|
| `handoffModels` | `[]` | Extra `provider/id` refs that should also receive handoff. |
|
|
85
87
|
| `maxTokens` | `1024` | Cap on a single description's output. |
|
|
86
88
|
| `cacheMax` | `50` | Max described images kept in the in-memory cache per session. |
|
|
89
|
+
| `maxDescriptionLines` | `0` | Cap on description lines (`0` = unbounded). Default keeps the full description so the `read` tool's native collapse (`ctrl+o`) handles compactness and the model gets complete context; setting `> 0` applies a lossy head-cap to both the TUI render and the model. |
|
|
87
90
|
| `prompt` | _(built-in)_ | Override the describer system prompt. |
|
|
88
91
|
| `userPromptPrefix` | _(built-in)_ | Override the prefix prepended to your original prompt. |
|
|
89
92
|
|
|
@@ -129,31 +132,47 @@ pi -e ./vision-handoff.ts
|
|
|
129
132
|
|
|
130
133
|
## How It Works
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
135
|
+
Read-tool images (the common case — paste a screenshot, `read` an image file):
|
|
136
|
+
|
|
137
|
+
read tool returns an image block
|
|
138
|
+
→ tool_result (toolName === "read")
|
|
139
|
+
• fires BEFORE pi-ai's transformMessages can strip the image for
|
|
140
|
+
non-vision models (the strip would leave only a "(tool image
|
|
141
|
+
omitted)" placeholder — too late to describe)
|
|
142
|
+
• is this model a handoff target? (non-vision, or in handoffModels)
|
|
143
|
+
• for each {type:"image",...} block → describeImage() with your
|
|
144
|
+
chosen vision model
|
|
145
|
+
- complete() via pi-ai (resolves API key/headers/baseUrl)
|
|
146
|
+
- cached by sha256(mime + base64)
|
|
147
|
+
• AUGMENTS the stored result: inserts "[Image: <description>]" as a
|
|
148
|
+
text block BEFORE the kept image, returns {content} to pi
|
|
149
|
+
• the image stays in content → kitty renders it inline
|
|
150
|
+
• at provider time, pi-ai strips ONLY the image block for non-vision
|
|
151
|
+
models — the inserted description text passes through to the model
|
|
152
|
+
|
|
153
|
+
User-attached images (pasted into the prompt, not read via a tool):
|
|
154
|
+
|
|
155
|
+
→ before_agent_start
|
|
156
|
+
• warms the description cache for attached images (fire-and-forget)
|
|
157
|
+
→ before_provider_request
|
|
158
|
+
• walks the provider payload, swaps image blocks for "[Image: <desc>]"
|
|
159
|
+
• NOTE: for non-vision models, pi-ai strips image blocks BEFORE this
|
|
160
|
+
hook fires — so user-attached images on text-only models are not yet
|
|
161
|
+
described. Use the `read` tool on the image file instead, which routes
|
|
162
|
+
through the tool_result path above.
|
|
163
|
+
|
|
164
|
+
Result: the text-only model receives a vivid text description alongside the
|
|
165
|
+
(now-stripped) image reference, and your turn proceeds normally — while the
|
|
166
|
+
terminal still renders the image inline via kitty.
|
|
149
167
|
|
|
150
168
|
### Image-block formats handled
|
|
151
169
|
|
|
152
|
-
|
|
|
153
|
-
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
170
|
+
| Hook | Image block shape | Replacement |
|
|
171
|
+
|------|-------------------|-------------|
|
|
172
|
+
| `tool_result` (read) | `{ type: "image", data, mimeType }` (pi-ai internal) | description text block inserted **before** the kept image |
|
|
173
|
+
| `before_provider_request` | `{ type: "image_url", image_url: { url: "data:…" } }` (OpenAI Chat Completions) | `{ type: "text", text }` |
|
|
174
|
+
| `before_provider_request` | `{ type: "input_image", image_url: "data:…" }` (OpenAI Responses) | `{ type: "input_text", text }` |
|
|
175
|
+
| `before_provider_request` | `{ type: "image", source: { type: "base64", media_type, data } }` (Anthropic Messages) | `{ type: "text", text }` |
|
|
157
176
|
|
|
158
177
|
The describer call itself goes through pi's normal model machinery (`complete()`), **not** the agent event loop — so it never re-triggers `before_provider_request` (no recursion).
|
|
159
178
|
|
|
@@ -171,7 +190,7 @@ The describer call itself goes through pi's normal model machinery (`complete()`
|
|
|
171
190
|
|
|
172
191
|
```bash
|
|
173
192
|
pnpm install
|
|
174
|
-
pnpm test # Vitest unit tests (
|
|
193
|
+
pnpm test # Vitest unit tests (50 passing)
|
|
175
194
|
pnpm typecheck # TypeScript validation
|
|
176
195
|
pnpm lint:dead # Dead code detection (knip)
|
|
177
196
|
```
|
|
@@ -186,7 +205,7 @@ pnpm lint:dead # Dead code detection (knip)
|
|
|
186
205
|
│ └── vision-model-selector.ts # Interactive picker TUI component
|
|
187
206
|
├── __tests__/unit/
|
|
188
207
|
│ ├── config-dir.test.ts # Ensures getAgentDir() usage
|
|
189
|
-
│ └── vision-handoff.test.ts # Config, refs, image-block extraction, round-trip
|
|
208
|
+
│ └── vision-handoff.test.ts # Config, refs, image-block extraction, insertion, truncation, round-trip
|
|
190
209
|
├── package.json
|
|
191
210
|
├── tsconfig.json
|
|
192
211
|
├── knip.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-vision-handoff",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
@@ -57,6 +57,29 @@ export const DESCRIBE_TIMEOUT_MS = 30_000;
|
|
|
57
57
|
*/
|
|
58
58
|
export const DEFAULT_MAX_DESCRIPTION_LINES = 0;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The note pi core's `read` tool appends to image-result text blocks when the
|
|
62
|
+
* active model lacks image input (see `getNonVisionImageNote` in pi core).
|
|
63
|
+
*
|
|
64
|
+
* Once this extension inserts a description, the note becomes self-
|
|
65
|
+
* contradicting ("image will be omitted" vs. the vivid description that
|
|
66
|
+
* follows) and confuses the model. {@link stripNonVisionImageNote} removes it.
|
|
67
|
+
*/
|
|
68
|
+
export const NON_VISION_IMAGE_NOTE =
|
|
69
|
+
"[Current model does not support images. The image will be omitted from this request.]";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Remove {@link NON_VISION_IMAGE_NOTE} from a text block.
|
|
73
|
+
*
|
|
74
|
+
* The read tool appends the note as `\n${NOTE}` (always the trailing segment of
|
|
75
|
+
* the metadata text block), so this also collapses the orphaned newline it
|
|
76
|
+
* leaves behind. Safe to call on text that does not contain the note (no-op).
|
|
77
|
+
*/
|
|
78
|
+
export function stripNonVisionImageNote(text: string): string {
|
|
79
|
+
if (!text.includes(NON_VISION_IMAGE_NOTE)) return text;
|
|
80
|
+
return text.split(NON_VISION_IMAGE_NOTE).join("").replace(/\n+$/, "");
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
export interface VisionHandoffConfig {
|
|
61
84
|
/** Master switch. When false, no handoff occurs even if a vision model is configured. */
|
|
62
85
|
enabled: boolean;
|
|
@@ -292,6 +315,12 @@ export interface ReplacedContent {
|
|
|
292
315
|
* provider, registry, and API call. The extension wires its real `describeImage`
|
|
293
316
|
* into this helper in its `tool_result` handler.
|
|
294
317
|
*
|
|
318
|
+
* When at least one description is inserted, also strips pi core's
|
|
319
|
+
* {@link NON_VISION_IMAGE_NOTE} from text blocks — the note (appended by the
|
|
320
|
+
* read tool for non-vision models) would otherwise contradict the inserted
|
|
321
|
+
* description. Text blocks are reassigned (not mutated in place); the input
|
|
322
|
+
* array is left untouched.
|
|
323
|
+
*
|
|
295
324
|
* Returns a new array and `changed: false` when there were no images, so
|
|
296
325
|
* callers can short-circuit and avoid mutating pi's stored result unnecessarily.
|
|
297
326
|
*/
|
|
@@ -315,5 +344,18 @@ export async function insertImageDescriptions(
|
|
|
315
344
|
next.push(block);
|
|
316
345
|
changed = true;
|
|
317
346
|
}
|
|
347
|
+
if (!changed) {
|
|
348
|
+
return { content: next, changed };
|
|
349
|
+
}
|
|
350
|
+
// We inserted at least one description. Strip the read tool's
|
|
351
|
+
// "[Current model does not support images...]" note from text blocks — it
|
|
352
|
+
// contradicts the description we just inserted ("image will be omitted" vs.
|
|
353
|
+
// the description that follows) and confuses the model.
|
|
354
|
+
for (let i = 0; i < next.length; i++) {
|
|
355
|
+
const block = next[i];
|
|
356
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.includes(NON_VISION_IMAGE_NOTE)) {
|
|
357
|
+
next[i] = { type: "text", text: stripNonVisionImageNote(block.text) } satisfies TextContent;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
318
360
|
return { content: next, changed };
|
|
319
361
|
}
|