@bismawy/pi-vision-watcher 1.0.7 → 1.0.8

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 CHANGED
@@ -66,6 +66,7 @@ Then run `/reload` in Pi (or restart Pi).
66
66
  | `/vision-watcher add <provider/id>` | Force handoff for a specific model (e.g. weak vision models) |
67
67
  | `/vision-watcher remove <provider/id>` | Remove model from forced handoff list |
68
68
  | `/vision-watcher thinking <level>` | Set describer thinking level (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) |
69
+ | `/vision-watcher timeout <ms>` | Set the base per-image description timeout in ms (default 45000) |
69
70
  | `/vision-watcher prewarm on` / `off` | Enable paste-time prewarming in TUI editor |
70
71
  | `/vision-watcher fallback on` / `off` | Enable async pasted-path description injection |
71
72
  | `/vision-watcher clear` | Clear configured vision model |
@@ -86,6 +87,7 @@ Configuration is stored at `~/.pi/agent/extensions/pi-vision-watcher.json`:
86
87
  "handoffModels": [],
87
88
  "thinking": false,
88
89
  "thinkingLevel": "medium",
90
+ "describeTimeoutMs": 45000,
89
91
  "prewarmPastedImages": false,
90
92
  "asyncClipboardHandoff": false,
91
93
  "maxTokens": null,
@@ -102,6 +104,7 @@ Configuration is stored at `~/.pi/agent/extensions/pi-vision-watcher.json`:
102
104
  | `autoHandoff` | `true` | Automatically apply handoff to all models lacking native vision. |
103
105
  | `handoffModels` | `[]` | Additional models forced to receive handoff even if vision-capable. |
104
106
  | `thinking` / `thinkingLevel` | `false` / `"medium"` | Reasoning effort for vision models that support thinking. |
107
+ | `describeTimeoutMs` | `45000` | Base per-image timeout in ms before failing over to fallback models. |
105
108
  | `prewarmPastedImages` | `false` | Describe images immediately upon pasting into the prompt. |
106
109
  | `asyncClipboardHandoff` | `false` | Asynchronous injection fallback for pasted image paths. |
107
110
  | `maxTokens` | `null` | Output token cap for descriptions (`null` = model default). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bismawy/pi-vision-watcher",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
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": "bismawy",
package/src/image.ts CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { readFileSync, statSync } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
- import { isAbsolute, join, sep } from "node:path";
23
+ import { isAbsolute, join, normalize, sep } from "node:path";
24
24
  import crypto from "node:crypto";
25
25
  import type { ExtractedImage } from "./index.js";
26
26
 
@@ -299,14 +299,14 @@ const LEADING_WRAP_RE = new RegExp('^[^A-Za-z0-9_/@.~-]+');
299
299
  * dir and mistaken for local files; leading wrapping chars (parentheses,
300
300
  * quotes) are stripped so quoted/parenthesized paths still resolve. */
301
301
  export function findPastedImagePaths(prompt: string): string[] {
302
- const tmp = tmpdir();
302
+ const tmp = normalize(tmpdir());
303
303
  const paths = new Set<string>();
304
304
  for (const m of prompt.matchAll(PASTED_IMAGE_PATH_RE)) {
305
305
  const token = m[1];
306
306
  if (!token) continue;
307
307
  if (URL_RE.test(token)) continue;
308
308
  const p = token.replace(LEADING_WRAP_RE, "");
309
- const abs = isAbsolute(p) ? p : join(tmp, p);
309
+ const abs = normalize(isAbsolute(p) ? p : join(tmp, p));
310
310
  // Ensure the resolved candidate stays inside the temp directory.
311
311
  if (abs.startsWith(tmp + sep) || abs === tmp) paths.add(abs);
312
312
  }
package/src/index.ts CHANGED
@@ -235,6 +235,84 @@ export function formatModelRef(provider: string, id: string): string {
235
235
  return `${provider}/${id}`;
236
236
  }
237
237
 
238
+ /** Match provider errors meaning "this model can't take image input" — e.g.
239
+ * AgentRouter's 400 `"This model does not support image"`, OpenRouter's
240
+ * "does not support image inputs". Used by the message_end auto-recovery:
241
+ * a model whose registry entry FALSELY declares `input: ["text","image"]`
242
+ * passes the autoHandoff vision check, so the raw image reaches the provider
243
+ * and 400s — we learn from that error and force handoff for the model. */
244
+ export function isImageNotSupportedError(text: string): boolean {
245
+ return /not support (?:the )?image|image[s]? (?:input[s]? )?(?:is |are )?not supported|does not accept image|unsupported (?:image|multimodal)/i.test(
246
+ text,
247
+ );
248
+ }
249
+
250
+ /** Models whose registry entries commonly declare image input while the
251
+ * backend rejects images (DeepSeek V4 via aggregator proxies). Conservative:
252
+ * VL / Vision / Janus variants keep image input. Used so autoHandoff covers
253
+ * them on the FIRST send — waiting for a 400 is too late. */
254
+ export function isKnownTextOnlyFalselyVision(id: string | undefined | null): boolean {
255
+ if (!id) return false;
256
+ const n = id.toLowerCase();
257
+ if (n.includes("vl") || n.includes("vision") || n.includes("janus")) return false;
258
+ return /deepseek[-_./]*v4/.test(n);
259
+ }
260
+
261
+ /** Extract the provider error string from a finalized assistant message.
262
+ * Pi stores it on `errorMessage`; some providers also put the 400 body in a
263
+ * text content block. */
264
+ export function assistantErrorText(msg: {
265
+ errorMessage?: unknown;
266
+ content?: unknown;
267
+ } | null | undefined): string {
268
+ if (!msg) return "";
269
+ if (typeof msg.errorMessage === "string" && msg.errorMessage) return msg.errorMessage;
270
+ if (typeof msg.content === "string") return msg.content;
271
+ if (!Array.isArray(msg.content)) return "";
272
+ return msg.content
273
+ .filter((b): b is { type: string; text: string } => !!b && typeof b === "object" && typeof (b as { text?: unknown }).text === "string")
274
+ .map((b) => b.text)
275
+ .join("\n");
276
+ }
277
+
278
+ /** Set (or add) a `modelOverrides` entry forcing a model's input to
279
+ * `["text"]` in a PARSED models.json config object. Pure: returns a new
280
+ * object, never mutates the input. Preserves every other field (credentials,
281
+ * compat, sibling models) — only the one model's `input` override is touched.
282
+ * Used by the message_end auto-recovery to fix models whose registry entry
283
+ * falsely declares image input the backend rejects with a 400. */
284
+ export function setTextOnlyInputOverride(
285
+ cfg: unknown,
286
+ providerId: string,
287
+ modelId: string,
288
+ ): Record<string, unknown> {
289
+ const root =
290
+ cfg && typeof cfg === "object" && !Array.isArray(cfg)
291
+ ? { ...(cfg as Record<string, unknown>) }
292
+ : {};
293
+ const providers =
294
+ root.providers && typeof root.providers === "object" && !Array.isArray(root.providers)
295
+ ? { ...(root.providers as Record<string, unknown>) }
296
+ : {};
297
+ const provider =
298
+ providers[providerId] && typeof providers[providerId] === "object" && !Array.isArray(providers[providerId])
299
+ ? { ...(providers[providerId] as Record<string, unknown>) }
300
+ : {};
301
+ const overrides =
302
+ provider.modelOverrides && typeof provider.modelOverrides === "object" && !Array.isArray(provider.modelOverrides)
303
+ ? { ...(provider.modelOverrides as Record<string, unknown>) }
304
+ : {};
305
+ const existing =
306
+ overrides[modelId] && typeof overrides[modelId] === "object" && !Array.isArray(overrides[modelId])
307
+ ? { ...(overrides[modelId] as Record<string, unknown>) }
308
+ : {};
309
+ overrides[modelId] = { ...existing, input: ["text"] };
310
+ provider.modelOverrides = overrides;
311
+ providers[providerId] = provider;
312
+ root.providers = providers;
313
+ return root;
314
+ }
315
+
238
316
  /** Whether a model declares image input. */
239
317
  export function isVisionModel(model: { input?: ("text" | "image")[] } | undefined | null): boolean {
240
318
  return !!model && Array.isArray(model.input) && model.input.includes("image");
package/vision-watcher.ts CHANGED
@@ -31,17 +31,24 @@
31
31
  */
32
32
 
33
33
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-agent";
34
+ import { getAgentDir, resizeImage } from "@earendil-works/pi-coding-agent";
34
35
  import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
35
36
  import { isAbsolute, resolve } from "node:path";
37
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
38
+ import { join } from "node:path";
36
39
  import {
37
40
  DESCRIBE_TIMEOUT_MS,
38
41
  extractImageFromBlock,
39
42
  formatModelRef,
40
43
  isThinkingLevel,
44
+ assistantErrorText,
45
+ isImageNotSupportedError,
46
+ isKnownTextOnlyFalselyVision,
41
47
  isVisionModel,
42
48
  NON_VISION_IMAGE_NOTE,
43
49
  parseModelRef,
44
50
  readConfig,
51
+ setTextOnlyInputOverride,
45
52
  stripNonVisionImageNote,
46
53
  writeConfig,
47
54
  HANDOFF_COMMAND_DESCRIPTION,
@@ -56,7 +63,6 @@ import {
56
63
  import { DescriptionLoader, UNAVAILABLE, type LoaderDeps } from "./src/dataloader.js";
57
64
  import { imageHash, findPastedImagePaths, readImageBuffer, readImageBufferBounded, resolvePrewarmImage, isOmittedImageNote } from "./src/image.js";
58
65
  import { appendVisionError } from "./src/error-log.js";
59
- import { resizeImage } from "@earendil-works/pi-coding-agent";
60
66
  import { VisionModelSelectorComponent, type VisionModelSelectorResult } from "./src/vision-model-selector.js";
61
67
  import { PrewarmEditor } from "./src/prewarm-editor.js";
62
68
  import { Text } from "@earendil-works/pi-tui";
@@ -238,6 +244,10 @@ function isHandoffTarget(
238
244
  if (!model || !model.provider || !model.id) return false;
239
245
  const ref = formatModelRef(model.provider, model.id);
240
246
  if (cfg.handoffModels.includes(ref)) return true;
247
+ // Aggregators often mark DeepSeek V4 as vision; the backend 400s on images.
248
+ // Treat as text-only so the FIRST send is handed off — waiting for the 400
249
+ // is too late. Persist the models.json override separately so /model agrees.
250
+ if (cfg.autoHandoff && isKnownTextOnlyFalselyVision(model.id)) return true;
241
251
  if (cfg.autoHandoff && !isVisionModel(model)) return true;
242
252
  return false;
243
253
  }
@@ -303,6 +313,46 @@ function installPrewarmEditor(ctx: ExtensionContext): void {
303
313
  );
304
314
  }
305
315
 
316
+ /** Write a `modelOverrides.<modelId>.input = ["text"]` fix to models.json and
317
+ * refresh the registry in-process so it applies without /reload (the same
318
+ * write+refresh pattern pi-auto-compat uses). Never touches credentials or
319
+ * other fields — the merge is done by the pure {@link setTextOnlyInputOverride}.
320
+ * Returns true when the override was written; false when models.json is
321
+ * unreadable/corrupt or the write fails (callers fall back to handoffModels). */
322
+ function fixModelInputTextOnly(ctx: ExtensionContext, providerId: string, modelId: string): boolean {
323
+ const path = join(getAgentDir(), "models.json");
324
+ let cfg: unknown;
325
+ try {
326
+ cfg = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
327
+ } catch {
328
+ return false;
329
+ }
330
+ try {
331
+ writeFileSync(path, JSON.stringify(setTextOnlyInputOverride(cfg, providerId, modelId), null, 2) + "\n", "utf8");
332
+ } catch {
333
+ return false;
334
+ }
335
+ // Refresh the merged registry in-process so the corrected input takes
336
+ // effect this session. Fire-and-forget: a refresh failure only delays the
337
+ // fix to the next session start (models.json is already corrected on disk).
338
+ void ctx.modelRegistry
339
+ ?.refresh?.({ allowNetwork: false })
340
+ ?.catch?.(() => {});
341
+ return true;
342
+ }
343
+
344
+ /** Persist `input: ["text"]` for a model that falsely declares image input.
345
+ * No-op if the model is already text-only or isn't a known false-vision id. */
346
+ function persistFalseVisionOverride(
347
+ ctx: ExtensionContext,
348
+ model: { provider?: string; id?: string; input?: ("text" | "image")[] } | undefined | null,
349
+ ): boolean {
350
+ if (!model?.provider || !model.id) return false;
351
+ if (!isKnownTextOnlyFalselyVision(model.id)) return false;
352
+ if (!isVisionModel(model)) return false;
353
+ return fixModelInputTextOnly(ctx, model.provider, model.id);
354
+ }
355
+
306
356
  function notifyUnresolvedVisionModel(ctx: ExtensionContext, ref: string): void {
307
357
  if (visionModelUnresolvedRef === ref) return;
308
358
  visionModelUnresolvedRef = ref;
@@ -401,6 +451,7 @@ export default function (pi: ExtensionAPI) {
401
451
  warnedHashes.clear();
402
452
  loader.reset();
403
453
  currentModel = ctx.model;
454
+ persistFalseVisionOverride(ctx, ctx.model);
404
455
  installPrewarmEditor(ctx);
405
456
  });
406
457
 
@@ -719,13 +770,69 @@ export default function (pi: ExtensionAPI) {
719
770
  if (changed) return { messages: event.messages };
720
771
  });
721
772
 
773
+ // Auto-recovery from "model does not support image" 400s: a model whose
774
+ // registry entry falsely declares `input: ["text","image"]` (common on
775
+ // aggregator providers) passes the autoHandoff vision check, so handoff is
776
+ // skipped and the raw image block reaches the provider — which rejects it.
777
+ // PRIMARY fix: correct the metadata at its layer — write a `modelOverrides`
778
+ // entry forcing `input: ["text"]` to models.json and refresh the registry
779
+ // in-process (modelOverrides is Pi's topmost config layer, so it wins even
780
+ // over a regenerated models[] entry). The model then registers as text-only,
781
+ // autoHandoff covers it naturally, and /model shows it correctly. FALLBACK
782
+ // (write failed): force handoff via handoffModels. Either way, the failed
783
+ // turn's images are still in history; the context hook describes them on the
784
+ // retry instead of failing again.
785
+ pi.on("message_end", (event, ctx) => {
786
+ const msg = event.message as {
787
+ role?: string;
788
+ type?: string;
789
+ stopReason?: string;
790
+ errorMessage?: unknown;
791
+ content?: unknown;
792
+ };
793
+ // Pi assistant messages use `role`, not `type`. Checking `type` made this
794
+ // handler a no-op, so the 400 never taught us anything.
795
+ if (msg?.role !== "assistant" && msg?.type !== "assistant") return;
796
+ if (msg.stopReason !== "error") return;
797
+ if (!isImageNotSupportedError(assistantErrorText(msg))) return;
798
+ if (!isConfigured(config)) return;
799
+ const model = ctx.model;
800
+ if (!model?.provider || !model.id) return;
801
+ const ref = formatModelRef(model.provider, model.id);
802
+
803
+ const fixed = persistFalseVisionOverride(ctx, model) || fixModelInputTextOnly(ctx, model.provider, model.id);
804
+ if (fixed) {
805
+ if (!config.handoffModels.includes(ref)) {
806
+ config = { ...config, handoffModels: [...config.handoffModels, ref] };
807
+ writeConfig(config);
808
+ }
809
+ if (ctx.hasUI) {
810
+ ctx.ui.notify(
811
+ `pi-vision-watcher: ${ref} rejected image input — marked as text-only. Resend; images will be described by ${config.visionModel}.`,
812
+ "warning",
813
+ );
814
+ }
815
+ return;
816
+ }
817
+
818
+ if (config.handoffModels.includes(ref)) return;
819
+ config = { ...config, handoffModels: [...config.handoffModels, ref] };
820
+ writeConfig(config);
821
+ if (!ctx.hasUI) return;
822
+ ctx.ui.notify(
823
+ `pi-vision-watcher: ${ref} rejected image input — handoff forced for this model. Resend; images will be described by ${config.visionModel}.`,
824
+ "warning",
825
+ );
826
+ });
827
+
722
828
  pi.on("model_select", (event, ctx) => {
723
829
  currentModel = event.model;
830
+ persistFalseVisionOverride(ctx, event.model);
724
831
  if (!ctx.hasUI) return;
725
832
  if (!isConfigured(config)) return;
726
833
  const model = event.model;
727
834
  if (!model) return;
728
- if (isHandoffTarget(model, config) && !isVisionModel(model)) {
835
+ if (isHandoffTarget(model, config)) {
729
836
  ctx.ui.notify(
730
837
  `pi-vision-watcher: active — images will be described by ${config.visionModel}`,
731
838
  "info",