pi-vision-guard 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — Initial release
4
+
5
+ - Cap image payloads per request (default 5, `/vision-guard 1..6`), dropping to 1 past 35% context usage.
6
+ - Resize attached and tool-result images to 1600px (macOS `sips`, graceful no-op elsewhere).
7
+ - Relevance-ranked pruning via any registered Pi classifier (newest always pinned, one batched keep/drop call, recency fallback).
8
+ - Exempt non-HTTP transports (`muse-msp` stdio bridge).
9
+ - Placeholder text plus a system-prompt note so the model banks image conclusions as text.
10
+ - `node --test` suites for provider scoping and ranking/fallback behavior.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jesse Wise
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # pi-vision-guard
2
+
3
+ A [Pi coding-agent](https://github.com/earendil-works/pi) extension that keeps image payloads from blowing up long sessions. Every screenshot, render, and pasted image gets re-sent on every request; past a handful of them, providers start rejecting requests (HTTP 413) or the context window fills with pixels instead of work. This guard caps how many images reach the model, shrinks the ones that do, and — when a decision-model classifier is available — keeps the *relevant* ones instead of just the newest.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:pi-vision-guard
9
+ ```
10
+
11
+ Requires Pi 1.0 or later (uses the native classifier registry). No configuration needed; the guard is on from the first session.
12
+
13
+ ## Usage
14
+
15
+ ```
16
+ /vision-guard [status|on|off|1..6]
17
+ ```
18
+
19
+ - `status` (default) — show mode and retained-image budget.
20
+ - `on` / `off` — enable or disable the guard.
21
+ - `1..6` — set how many image payloads are retained per request (default 5).
22
+
23
+ The status bar shows what happened per request, e.g. `vision 5/9 images · ranked` or `vision ≤5 images`.
24
+
25
+ ## How it works
26
+
27
+ Three layers, all automatic:
28
+
29
+ 1. **Resize.** Attached and tool-result images are capped at 1600px on the long edge (macOS `sips`; other platforms skip this step and keep everything else).
30
+ 2. **Budget.** At most 5 image payloads are sent per request, dropping to 1 total past 35% context usage. The newest image is always kept; older ones beyond budget become a short placeholder note.
31
+ 3. **Relevance ranking.** When any Pi classifier is registered (a local Jev-style model, TypeSafe `jev-latest`, etc.), one batched keep/drop call ranks older images from their surrounding text and keeps the highest scorers. No classifier, no signal, or any failure → newest-first fallback. Image bytes are never sent to the classifier, only short text descriptors.
32
+
33
+ ## Classifier ranking
34
+
35
+ Ranking lights up automatically with any registered Pi classifier — no settings. A local `clef-local/clef-flash` provider is preferred when present, otherwise the first available classifier is used. The questions are plain keep/drop judgments, so any Jev-family decision model works.
36
+
37
+ ## ⚠️ Cache-churn caveat
38
+
39
+ This guard reprunes to exactly N images on *every* request: each new image flips one more old image block into placeholder text, which rewrites the transcript prefix. Providers that cache by exact prefix may therefore serve fewer requests from cache in image-heavy sessions than they would with no pruning at all. In practice the alternative is worse — unpruned image histories 413 or exhaust the window — but if you are tuning for maximum cache hits above all else, raise the budget (`/vision-guard 6`) or disable the guard and accept the size. Text-output pruners with batched schedules (e.g. pi-condense) have the same fundamental tradeoff; this guard simply chooses recency caps over batching because a single oversized vision request fails hard instead of degrading gracefully.
40
+
41
+ ## Development
42
+
43
+ ```bash
44
+ npm test # node --test, no dependencies to install
45
+ ```
46
+
47
+ Layout: `extensions/vision-context-guard.ts` is the whole extension; `tests/vision-guard/` holds the `node --test` suites (provider scoping + ranking/fallback, with a stubbed classifier registry).
48
+
49
+ ## License
50
+
51
+ MIT
@@ -0,0 +1,335 @@
1
+ import type { ClassifierQuestion, ImageContent } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ const MAX_EDGE = 1600;
8
+ const DEFAULT_RETAINED_IMAGES = 5;
9
+ const LONG_CONTEXT_FRACTION = 0.35;
10
+ const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
11
+ // Preferred registry classifier for relevance ranking. Any registered
12
+ // classifier works — local clef first when present, otherwise the first
13
+ // available one. Recency fallback covers no-classifier setups.
14
+ const CLEF_PROVIDER = "clef-local";
15
+ const CLEF_MODEL = "clef-flash";
16
+ const OMITTED_TEXT =
17
+ "[Older image omitted from this request by the local vision context guard. Use existing textual observations or reread one specific image if it is essential.]";
18
+
19
+ let enabled = true;
20
+ let retainedImages = DEFAULT_RETAINED_IMAGES;
21
+
22
+ function protectsModel(ctx: ExtensionContext): boolean {
23
+ const model = ctx.model;
24
+ if (!model || !model.input.includes("image")) return false;
25
+ // The failure mode is HTTP request-body size (provider 413s), which
26
+ // applies to every HTTP(S) provider — remote ones like openrouter just
27
+ // as much as localhost ones. Only exempt transports known not to be
28
+ // HTTP-size-limited: muse-msp talks over a stdio JSON-RPC bridge (its
29
+ // "http://localhost" baseUrl is a placeholder), so body limits do not
30
+ // apply to it.
31
+ if (model.provider === "muse-msp") return false;
32
+ return true;
33
+ }
34
+
35
+ function extensionFor(mimeType: string): string {
36
+ switch (mimeType.toLowerCase()) {
37
+ case "image/jpeg":
38
+ case "image/jpg":
39
+ return ".jpg";
40
+ case "image/gif":
41
+ return ".gif";
42
+ case "image/tiff":
43
+ return ".tiff";
44
+ case "image/bmp":
45
+ return ".bmp";
46
+ default:
47
+ return ".png";
48
+ }
49
+ }
50
+
51
+ async function resizeImage(image: ImageContent, pi: ExtensionAPI, signal?: AbortSignal): Promise<ImageContent> {
52
+ let source: Buffer;
53
+ try {
54
+ source = Buffer.from(image.data, "base64");
55
+ } catch {
56
+ return image;
57
+ }
58
+ if (source.length === 0 || source.length > MAX_SOURCE_BYTES) return image;
59
+
60
+ const dir = await mkdtemp(join(tmpdir(), "pi-vision-guard-"));
61
+ const ext = extensionFor(image.mimeType);
62
+ const input = join(dir, `input${ext}`);
63
+ const output = join(dir, `output${ext}`);
64
+ try {
65
+ await writeFile(input, source);
66
+ const result = await pi.exec("sips", ["-Z", String(MAX_EDGE), input, "--out", output], {
67
+ signal,
68
+ timeout: 30_000,
69
+ });
70
+ if (result.code !== 0) return image;
71
+ const resized = await readFile(output);
72
+ if (resized.length === 0) return image;
73
+ return { type: "image", data: resized.toString("base64"), mimeType: image.mimeType };
74
+ } catch {
75
+ return image;
76
+ } finally {
77
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
78
+ }
79
+ }
80
+
81
+ async function resizeImages(
82
+ content: ImageContent[],
83
+ pi: ExtensionAPI,
84
+ signal?: AbortSignal,
85
+ ): Promise<ImageContent[]> {
86
+ return Promise.all(content.map((image) => resizeImage(image, pi, signal)));
87
+ }
88
+
89
+ function imageCount(messages: any[]): number {
90
+ let count = 0;
91
+ for (const message of messages) {
92
+ if (!Array.isArray(message?.content)) continue;
93
+ for (const block of message.content) if (block?.type === "image") count++;
94
+ }
95
+ return count;
96
+ }
97
+
98
+ function pruneOldImages(messages: any[], keep: number): { messages: any[]; omitted: number; total: number } {
99
+ const total = imageCount(messages);
100
+ let remaining = keep;
101
+ let omitted = 0;
102
+
103
+ for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) {
104
+ const message = messages[messageIndex];
105
+ if (!Array.isArray(message?.content)) continue;
106
+ for (let contentIndex = message.content.length - 1; contentIndex >= 0; contentIndex--) {
107
+ const block = message.content[contentIndex];
108
+ if (block?.type !== "image") continue;
109
+ if (remaining > 0) {
110
+ remaining--;
111
+ continue;
112
+ }
113
+ message.content[contentIndex] = { type: "text", text: OMITTED_TEXT };
114
+ omitted++;
115
+ }
116
+ }
117
+ return { messages, omitted, total };
118
+ }
119
+
120
+ interface ImageSlot {
121
+ messageIndex: number;
122
+ contentIndex: number;
123
+ role: string;
124
+ /** Adjacent text in the same message — the ranking signal. Image bytes are never sent. */
125
+ text: string;
126
+ recency: number; // 0 = oldest
127
+ }
128
+
129
+ function collectImageSlots(messages: any[]): ImageSlot[] {
130
+ const slots: ImageSlot[] = [];
131
+ for (let mi = 0; mi < messages.length; mi++) {
132
+ const message = messages[mi];
133
+ if (!Array.isArray(message?.content)) continue;
134
+ const text = message.content
135
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
136
+ .map((b: any) => b.text as string)
137
+ .join("\n")
138
+ .slice(0, 300);
139
+ for (let ci = 0; ci < message.content.length; ci++) {
140
+ if (message.content[ci]?.type !== "image") continue;
141
+ slots.push({ messageIndex: mi, contentIndex: ci, role: String(message.role ?? "?"), text, recency: slots.length });
142
+ }
143
+ }
144
+ return slots;
145
+ }
146
+
147
+ function lastUserText(messages: any[]): string {
148
+ for (let i = messages.length - 1; i >= 0; i--) {
149
+ const message = messages[i];
150
+ if (message?.role !== "user" || !Array.isArray(message.content)) continue;
151
+ const text = message.content
152
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
153
+ .map((b: any) => b.text as string)
154
+ .join("\n")
155
+ .trim();
156
+ if (text) return text.slice(0, 500);
157
+ }
158
+ return "(unknown)";
159
+ }
160
+
161
+ /** One batched classifier call scoring every candidate. Returns scores aligned with slots, or undefined on any failure. */
162
+ async function scoreKeepers(
163
+ slots: ImageSlot[],
164
+ taskHint: string,
165
+ ctx: ExtensionContext,
166
+ ): Promise<number[] | undefined> {
167
+ const clf =
168
+ ctx.modelRegistry.findOfType("classifier", CLEF_PROVIDER, CLEF_MODEL) ??
169
+ ctx.modelRegistry.getModelsOfType("classifier")[0];
170
+ if (!clf) return undefined;
171
+ const questions: Record<string, ClassifierQuestion> = {};
172
+ for (let i = 0; i < slots.length; i++) {
173
+ questions[`keep_${i}`] = {
174
+ type: "bool",
175
+ instructions: `Is image [${i}] still needed to complete the current task?`,
176
+ criteria: {
177
+ true: "still needed; dropping it would lose information",
178
+ false: "no longer relevant; safe to drop",
179
+ },
180
+ };
181
+ }
182
+ try {
183
+ const result = await ctx.modelRegistry.classify(
184
+ clf,
185
+ {
186
+ state: {
187
+ task: taskHint,
188
+ images: slots.map((s, i) => ({
189
+ index: i,
190
+ role: s.role,
191
+ context: s.text.slice(0, 300) || "(no surrounding text)",
192
+ })),
193
+ },
194
+ questions,
195
+ },
196
+ { signal: ctx.signal },
197
+ );
198
+ if (result.stopReason !== "stop") {
199
+ if (result.stopReason === "aborted" || ctx.signal?.aborted) throw new Error("aborted");
200
+ return undefined;
201
+ }
202
+ const scores: number[] = [];
203
+ for (let i = 0; i < slots.length; i++) {
204
+ const a = result.answers[`keep_${i}`];
205
+ if (a?.type !== "bool") return undefined;
206
+ scores.push(a.probability);
207
+ }
208
+ return scores;
209
+ } catch (e) {
210
+ if (ctx.signal?.aborted) throw e;
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Relevance-ranked prune within the same budget the recency guard used.
217
+ * Newest image is always kept; remaining slots go to the highest clef
218
+ * keep-scores, falling back to recency when there is no ranking signal
219
+ * (bare images) or the sidecar is unreachable.
220
+ */
221
+ async function pruneRanked(
222
+ messages: any[],
223
+ keep: number,
224
+ ctx: ExtensionContext,
225
+ ): Promise<{ messages: any[]; omitted: number; total: number; mode: "ranked" | "recent" }> {
226
+ const slots = collectImageSlots(messages);
227
+ const total = slots.length;
228
+ if (total <= keep) return { messages, omitted: 0, total, mode: "recent" };
229
+ const newest = slots[slots.length - 1];
230
+ const candidates = slots.slice(0, -1);
231
+ const candidateSlots = keep - 1;
232
+ const keepSet = new Set<ImageSlot>([newest]);
233
+ let mode: "ranked" | "recent" = "recent";
234
+ if (candidates.length <= candidateSlots) {
235
+ for (const c of candidates) keepSet.add(c);
236
+ } else if (candidateSlots > 0) {
237
+ const hasSignal = candidates.some((c) => c.text.length > 0);
238
+ const scores = hasSignal ? await scoreKeepers(candidates, lastUserText(messages), ctx) : undefined;
239
+ if (scores) {
240
+ mode = "ranked";
241
+ const ranked = candidates
242
+ .map((c, i) => ({ c, s: scores[i] }))
243
+ .sort((a, b) => b.s - a.s || b.c.recency - a.c.recency)
244
+ .slice(0, candidateSlots);
245
+ for (const r of ranked) keepSet.add(r.c);
246
+ } else {
247
+ const newestFirst = [...candidates]
248
+ .sort((a, b) => b.recency - a.recency)
249
+ .slice(0, candidateSlots);
250
+ for (const c of newestFirst) keepSet.add(c);
251
+ }
252
+ }
253
+ let omitted = 0;
254
+ for (const s of slots) {
255
+ if (keepSet.has(s)) continue;
256
+ messages[s.messageIndex].content[s.contentIndex] = { type: "text", text: OMITTED_TEXT };
257
+ omitted++;
258
+ }
259
+ return { messages, omitted, total, mode };
260
+ }
261
+
262
+ export default function (pi: ExtensionAPI) {
263
+ pi.on("input", async (event, ctx) => {
264
+ if (!enabled || !protectsModel(ctx) || !event.images?.length) return { action: "continue" };
265
+ const images = await resizeImages(event.images, pi, ctx.signal);
266
+ return { action: "transform", text: event.text, images };
267
+ });
268
+
269
+ pi.on("tool_result", async (event, ctx) => {
270
+ if (!enabled || !protectsModel(ctx)) return;
271
+ const imageIndexes = event.content
272
+ .map((block, index) => (block.type === "image" ? index : -1))
273
+ .filter((index) => index >= 0);
274
+ if (imageIndexes.length === 0) return;
275
+
276
+ const images = imageIndexes.map((index) => event.content[index] as ImageContent);
277
+ const resized = await resizeImages(images, pi, ctx.signal);
278
+ const content = [...event.content];
279
+ for (let i = 0; i < imageIndexes.length; i++) content[imageIndexes[i]] = resized[i];
280
+ return { content };
281
+ });
282
+
283
+ pi.on("context", async (event, ctx) => {
284
+ if (!enabled || !protectsModel(ctx)) {
285
+ ctx.ui.setStatus("vision-context-guard", undefined);
286
+ return;
287
+ }
288
+ const usage = ctx.getContextUsage();
289
+ const longContext = Boolean(
290
+ usage && ctx.model && usage.tokens >= ctx.model.contextWindow * LONG_CONTEXT_FRACTION,
291
+ );
292
+ const keep = longContext ? 1 : retainedImages;
293
+ const result = await pruneRanked(event.messages, keep, ctx);
294
+ ctx.ui.setStatus(
295
+ "vision-context-guard",
296
+ result.omitted > 0
297
+ ? `vision ${Math.min(result.total, keep)}/${result.total} images${longContext ? " · long" : ""} · ${result.mode}`
298
+ : `vision ≤${keep} image${keep === 1 ? "" : "s"}${longContext ? " · long" : ""}`,
299
+ );
300
+ return { messages: result.messages };
301
+ });
302
+
303
+ pi.on("before_agent_start", (event, ctx) => {
304
+ if (!enabled || !protectsModel(ctx)) return;
305
+ return {
306
+ systemPrompt:
307
+ event.systemPrompt +
308
+ `\n\nVision payload guard: images are limited to ${MAX_EDGE}px. At most ${retainedImages} image payloads are retained per request (newest always kept, older ones relevance-ranked), dropping to one total beyond ${Math.round(LONG_CONTEXT_FRACTION * 100)}% context usage. Inspect the minimum necessary images, record conclusions as text, and do not repeatedly reread render history.`,
309
+ };
310
+ });
311
+
312
+ pi.on("session_shutdown", (_event, ctx) => {
313
+ ctx.ui.setStatus("vision-context-guard", undefined);
314
+ });
315
+
316
+ pi.registerCommand("vision-guard", {
317
+ description: "Show, enable, disable, or set the retained local-vision image count",
318
+ handler: async (args, ctx) => {
319
+ const value = args.trim().toLowerCase();
320
+ if (value === "off") enabled = false;
321
+ else if (value === "on") enabled = true;
322
+ else if (/^[1-6]$/.test(value)) {
323
+ enabled = true;
324
+ retainedImages = Number(value);
325
+ } else if (value && value !== "status") {
326
+ ctx.ui.notify("Usage: /vision-guard [status|on|off|1..6]", "warning");
327
+ return;
328
+ }
329
+ ctx.ui.notify(
330
+ `Vision guard ${enabled ? "on" : "off"}; max edge ${MAX_EDGE}px; retaining ${retainedImages} image(s) per request for image-input models (muse-msp exempt).`,
331
+ "info",
332
+ );
333
+ },
334
+ });
335
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pi-vision-guard",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that caps, shrinks, and relevance-ranks image payloads so long vision sessions stop 413ing",
5
+ "author": "Jesse Wise",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/jwise7/pi-vision-guard.git"
10
+ },
11
+ "homepage": "https://github.com/jwise7/pi-vision-guard",
12
+ "bugs": {
13
+ "url": "https://github.com/jwise7/pi-vision-guard/issues"
14
+ },
15
+ "keywords": ["pi-package", "pi", "vision", "images", "context", "coding-agent"],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "pi": {
20
+ "extensions": ["./extensions"]
21
+ },
22
+ "files": ["extensions/", "README.md", "CHANGELOG.md", "LICENSE"],
23
+ "scripts": {
24
+ "test": "node --test tests/vision-guard/scoping.test.mjs tests/vision-guard/ranking.test.mjs"
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-ai": "*",
29
+ "@earendil-works/pi-tui": "*"
30
+ }
31
+ }