@liustack/modlens 3.10.0 → 3.11.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.11.0 - 2026-08-14
4
+
5
+ - **A full-project audit, all ten findings fixed, then re-reviewed until clean.** An independent deep review of the whole repository (P0: none) surfaced ten conditional-but-real defects. Every fix went back through further independent review rounds, which caught real bugs in the first fixes themselves (case and Unicode boundaries, a cancellation regression); the final round accepted with no blocking findings. Each item below carries a regression test — the suite grew by 32 cases.
6
+ - *Cross-project recovery, two ways in.* The OpenCode directory filter's ancestor branch built a LIKE pattern from the database's own `session.directory`, so `_`/`%` inside another project's path acted as wildcards; the comparison now uses exact SUBSTR prefixes in both directions, measured in Unicode code points (SQLite counts characters, JS counts UTF-16 units, so a path with an emoji shifted the boundary), case-sensitive on POSIX and LOWER-folded on Windows (SQLite's LIKE was ASCII case-insensitive, crossing projects on case-sensitive filesystems), with filesystem roots normalized so `/` and `E:/` match as ancestors. And a Claude/Pi transcript recording no cwd at all was accepted on slug evidence alone, though slugs collide (`/tmp/a.b` and `/tmp/a-b`); ownerless transcripts are now skipped by scanning and session lookup, while an explicit `--transcript` still reads anything you point it at.
7
+ - *Image-type spoofing.* When magic-byte sniffing failed, the extension or content-type used to win, so an `.png` URL serving HTML was encoded and uploaded as an image. Every allowed type must now prove itself from its file header — heic/heif included, via ftyp-box sniffing — so there is no extension or content-type fallback left at all.
8
+ - *Credential redaction, everywhere errors quote foreign text.* Subprocess stderr, the three API providers' error bodies, and discovery-probe failures now pass through a shared redactor (known keys replaced exactly, common token shapes as the second net) before reaching terminals, `meta.attempts`, model contexts, or the discovery cache. Remote-URL errors quote origin and path only, so signed query tokens (S3 presigns) never travel, and the shapes are tuned to leave prose like "token limit_exceeded" alone.
9
+ - *Windows PATH probing.* `findOnPath` now walks `PATHEXT`, so a real `agy.exe`/`claude.cmd` install no longer reads as "not on PATH" (doctor and the failover chain both trusted that verdict).
10
+ - *dsh evidence cache.* Failed reads are no longer memoized forever (a fixed config gets a fresh chance without restarting dsh), concurrent steps join one in-flight read instead of double-spending the engine, the cache is capped LRU-style, and cancellation is per-waiter: aborting one step stops its own wait immediately while the shared read completes into the cache, instead of killing every concurrent joiner. Pasted HEIC/HEIF now maps to its real extension, and an unknown media type refuses instead of dressing up as PNG.
11
+ - *Docs told the truth about less than the code did.* The security page now states per provider who fetches a remote URL (only gemini-api downloads locally behind the SSRF guards, magic-byte check, and size cap; openai/anthropic hand the URL to the vendor). The output contract now lists `visual` as required, matching the schema that always enforced it, with a docs-contract test pinning the two together. Doctor renders CLI providers as `[ok?] installed / sign-in not verified offline` instead of a flat ok, adds a machine-readable `status` field (`ready`/`installed`/`missing`) so JSON consumers stop reading bare-binary as ready, and the install flow treats the first real read as the auth check. The skill's trigger list drops `.bmp` (never supported by the CLI) and gains `.heif`.
12
+ - *Hardening around the edges.* The release script and workflow now run lint, refuse version downgrades, and verify the tag matches package.json; CLI numeric flags reject trailing garbage (`--count 3x`); error exits use `process.exitCode` so piped output survives.
13
+ - **dsh install can silently land on 3.5.0 ([#18](https://github.com/liustack/modlens/issues/18)).** pnpm v11's release-age quarantine falls back to an old version when every recent one is inside the window (10 days on pnpm 11.21, measured), and versions before 3.9.0 carry no `dsh.bundle` declaration, so the plugin installs as a plain dependency and none of the tools appear. There is no CLI or env override for the gate ([pnpm#11224](https://github.com/pnpm/pnpm/issues/11224) is still open), but an explicit version or dist-tag skips it ([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989), verified against pnpm 11.21: a bare add resolved 2.8.0 while `@latest` resolved 3.10.0 under the same default gate). The install command everywhere now carries `@latest`, so new installs land current with zero user-side setup; troubleshooting documents the mechanism plus the durable bare-name `minimumReleaseAgeExclude` fallback for whenever pnpm closes that skip. Thanks to @stonogic086-1 for the precise diagnosis.
14
+
3
15
  ## 3.10.0 - 2026-08-14
4
16
 
5
17
  - **Pasting into the dsh Web UI now works end to end, thumbnails included.** The plugin registers a wrapper provider whose model list carries two entries, `DeepSeek-V4-Flash (modlens vision)` and `DeepSeek-V4-Pro (modlens vision)`; select one and pastes are admitted, because dsh's image admission asks the selected provider for input modalities and the wrapper declares image input. Conversion happens at request time on the wire messages only: the durable session log and the UI keep the native image blocks (your paste keeps its thumbnail), each pasted attachment is read once through the modlens CLI and cached for every later step, and the wrapped request is answered by the same DeepSeek route it always used. The wrap covers DeepSeek and GLM text models only; their own vision models (deepseek-vl/ocr, janus, glm v-series) are excluded by name pattern and by declared modality, so a model that can already see is never wrapped. The `agent/pre-step` auto-read from 3.9.0 becomes opt-in (`autoRead: true`) since request-time conversion owns the paste path and preserves the native UX. Registration also stopped failing silently: the adapter now supplies the base-class methods dsh's duck-typing expects (`providerInfo`, `providerRetryPolicy`), whose absence was exactly the invisible failure, and a registration error is logged to the harness console instead of swallowed.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
34
34
 
35
35
  ## Highlights
36
36
 
37
- **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`, and the text-only DeepSeek model behind dsh reads images through a native `read_image` tool. For pasted images, switch the model selector to one of the two entries the plugin adds, **`DeepSeek-V4-Flash (modlens vision)`** or **`DeepSeek-V4-Pro (modlens vision)`**: pastes are admitted there, converted to evidence at request time (your message keeps its native thumbnail), and answered by the same DeepSeek route. The wrap covers DeepSeek and GLM text models only; their own vision models are excluded automatically.
37
+ **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`, and the text-only DeepSeek model behind dsh reads images through a native `read_image` tool. If dsh warns `declares no dsh.bundle`, pnpm's release-age gate installed an old version: see [troubleshooting](docs/troubleshooting.md#dsh-says-declares-no-dshbundle--installed-as-a-plain-dependency) for the one-line fix. For pasted images, switch the model selector to one of the two entries the plugin adds, **`DeepSeek-V4-Flash (modlens vision)`** or **`DeepSeek-V4-Pro (modlens vision)`**: pastes are admitted there, converted to evidence at request time (your message keeps its native thumbnail), and answered by the same DeepSeek route. The wrap covers DeepSeek and GLM text models only; their own vision models are excluded automatically.
38
38
 
39
39
  **Paste an image and it reads it.** No saving to a file and passing a path first.
40
40
 
package/README.zh-CN.md CHANGED
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 没有视觉能力,无法进行图片识别。ModLens 借助
34
34
 
35
35
  ## 亮点
36
36
 
37
- **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `read_image` 工具读图。要粘贴识图,把模型选择器切到插件新增的两个条目之一:**`DeepSeek-V4-Flash (modlens vision)`** 或 **`DeepSeek-V4-Pro (modlens vision)`**,贴图放行、发请求时转成证据(你的消息保留原生缩略图)、仍由原 DeepSeek 路由回答。包装只覆盖 DeepSeek 与 GLM 的文本模型,两家自己的视觉型号自动排除。
37
+ **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `read_image` 工具读图。如果 dsh 提示 `declares no dsh.bundle`,是 pnpm 的发布冷静期把版本压旧了,一行配置可解,见[故障排查](docs/troubleshooting.md#dsh-says-declares-no-dshbundle--installed-as-a-plain-dependency)。要粘贴识图,把模型选择器切到插件新增的两个条目之一:**`DeepSeek-V4-Flash (modlens vision)`** 或 **`DeepSeek-V4-Pro (modlens vision)`**,贴图放行、发请求时转成证据(你的消息保留原生缩略图)、仍由原 DeepSeek 路由回答。包装只覆盖 DeepSeek 与 GLM 的文本模型,两家自己的视觉型号自动排除。
38
38
 
39
39
  **直接粘贴图片识别** 无需先保存成文件再提供路径。
40
40
 
@@ -62,7 +62,7 @@ agy # 浏览器完成
62
62
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
63
63
 
64
64
  ```sh
65
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens
65
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
66
66
  ```
67
67
 
68
68
  装完即有 `read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.md)。
package/dist/main.js CHANGED
@@ -13672,7 +13672,7 @@ function requireMockUtils() {
13672
13672
  }
13673
13673
  return normalizedQp;
13674
13674
  }
13675
- function safeUrl(path2) {
13675
+ function safeUrl2(path2) {
13676
13676
  if (typeof path2 !== "string") {
13677
13677
  return path2;
13678
13678
  }
@@ -13710,10 +13710,10 @@ function requireMockUtils() {
13710
13710
  }
13711
13711
  function getMockDispatch(mockDispatches, key) {
13712
13712
  const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path;
13713
- const resolvedPath = typeof basePath === "string" ? safeUrl(basePath) : basePath;
13713
+ const resolvedPath = typeof basePath === "string" ? safeUrl2(basePath) : basePath;
13714
13714
  const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath);
13715
13715
  let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path: path2, ignoreTrailingSlash }) => {
13716
- return ignoreTrailingSlash ? matchValue(removeTrailingSlash(safeUrl(path2)), resolvedPathWithoutTrailingSlash) : matchValue(safeUrl(path2), resolvedPath);
13716
+ return ignoreTrailingSlash ? matchValue(removeTrailingSlash(safeUrl2(path2)), resolvedPathWithoutTrailingSlash) : matchValue(safeUrl2(path2), resolvedPath);
13717
13717
  });
13718
13718
  if (matchedMockDispatches.length === 0) {
13719
13719
  throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`);
@@ -27236,15 +27236,6 @@ function parseIpv6Groups(groups) {
27236
27236
  }
27237
27237
  return parsed;
27238
27238
  }
27239
- const MIME_BY_EXT = {
27240
- ".jpg": "image/jpeg",
27241
- ".jpeg": "image/jpeg",
27242
- ".png": "image/png",
27243
- ".webp": "image/webp",
27244
- ".gif": "image/gif",
27245
- ".heic": "image/heic",
27246
- ".heif": "image/heif"
27247
- };
27248
27239
  const MAX_REMOTE_IMAGE_BYTES = 25 * 1024 * 1024;
27249
27240
  const ALLOWED_MIME = /* @__PURE__ */ new Set([
27250
27241
  "image/png",
@@ -27270,8 +27261,27 @@ const SNIFFERS = [
27270
27261
  {
27271
27262
  mime: "image/webp",
27272
27263
  test: (b) => b.length >= 12 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP"
27264
+ },
27265
+ // ISO BMFF: bytes 4-8 spell "ftyp" and the brand names the format. This
27266
+ // closes the last extension-trust hole: heic/heif must now prove
27267
+ // themselves from the header like every other type.
27268
+ {
27269
+ mime: "image/heic",
27270
+ test: (b) => b.length >= 12 && b.toString("ascii", 4, 8) === "ftyp" && ["heic", "heix", "hevc", "hevx"].includes(b.toString("ascii", 8, 12))
27271
+ },
27272
+ {
27273
+ mime: "image/heif",
27274
+ test: (b) => b.length >= 12 && b.toString("ascii", 4, 8) === "ftyp" && ["mif1", "msf1", "heif"].includes(b.toString("ascii", 8, 12))
27273
27275
  }
27274
27276
  ];
27277
+ function safeUrl(url) {
27278
+ try {
27279
+ const u = new URL(url);
27280
+ return `${u.origin}${u.pathname}`;
27281
+ } catch {
27282
+ return "<unparseable url>";
27283
+ }
27284
+ }
27275
27285
  function sniffImageMime(buffer) {
27276
27286
  for (const { mime, test } of SNIFFERS) {
27277
27287
  if (test(buffer)) {
@@ -27280,22 +27290,13 @@ function sniffImageMime(buffer) {
27280
27290
  }
27281
27291
  return null;
27282
27292
  }
27283
- function extMime(source) {
27284
- const ext = /^https?:\/\//i.test(source) ? path.extname(new URL(source).pathname).toLowerCase() : path.extname(source).toLowerCase();
27285
- return MIME_BY_EXT[ext] ?? null;
27286
- }
27287
- function resolveImageMime(buffer, source, contentType) {
27293
+ function resolveImageMime(buffer, source, _contentType) {
27288
27294
  const sniffed = sniffImageMime(buffer);
27289
27295
  if (sniffed) {
27290
27296
  return sniffed;
27291
27297
  }
27292
- const declared = contentType?.split(";")[0]?.trim().toLowerCase();
27293
- const candidate = extMime(source) ?? (declared?.startsWith("image/") ? declared : null);
27294
- if (candidate && ALLOWED_MIME.has(candidate)) {
27295
- return candidate;
27296
- }
27297
27298
  throw new Error(
27298
- `Unsupported or unrecognized image type for ${source}. Allowed: ${[...ALLOWED_MIME].join(", ")}.`
27299
+ `Content of ${source} does not look like a supported image (its bytes match no known image header). Allowed types: ${[...ALLOWED_MIME].join(", ")}.`
27299
27300
  );
27300
27301
  }
27301
27302
  function readLocalImageBase64(filePath) {
@@ -27331,32 +27332,34 @@ async function fetchRemoteImageBase64(url, timeoutMs) {
27331
27332
  const location = response2.headers.get("location");
27332
27333
  if (!location) {
27333
27334
  throw new Error(
27334
- `Redirect response (${response2.status}) missing location header: ${current}`
27335
+ `Redirect response (${response2.status}) missing location header: ${safeUrl(current.toString())}`
27335
27336
  );
27336
27337
  }
27337
27338
  await response2.body?.cancel();
27338
27339
  if (hop === MAX_REDIRECTS) {
27339
- throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${url}`);
27340
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${safeUrl(url)}`);
27340
27341
  }
27341
27342
  current = normalizeRemoteImageUrl(new URL(location, current).toString());
27342
27343
  continue;
27343
27344
  }
27344
27345
  if (!response2.ok) {
27345
- throw new Error(`Failed to download image (${response2.status}): ${current}`);
27346
+ throw new Error(
27347
+ `Failed to download image (${response2.status}): ${safeUrl(current.toString())}`
27348
+ );
27346
27349
  }
27347
27350
  const declaredLength = Number(response2.headers.get("content-length"));
27348
27351
  if (Number.isFinite(declaredLength) && declaredLength > MAX_REMOTE_IMAGE_BYTES) {
27349
27352
  throw new Error(
27350
- `Remote image is ${declaredLength} bytes, over the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${current}`
27353
+ `Remote image is ${declaredLength} bytes, over the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(current.toString())}`
27351
27354
  );
27352
27355
  }
27353
27356
  const finalUrl = current.toString();
27354
27357
  const buffer = await readCapped(response2, finalUrl);
27355
27358
  const contentType = response2.headers.get("content-type") ?? void 0;
27356
- const mimeType = resolveImageMime(buffer, finalUrl, contentType);
27359
+ const mimeType = resolveImageMime(buffer, safeUrl(finalUrl), contentType);
27357
27360
  return { data: buffer.toString("base64"), mimeType };
27358
27361
  }
27359
- throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${url}`);
27362
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${safeUrl(url)}`);
27360
27363
  } finally {
27361
27364
  for (const dispatcher2 of dispatchers) {
27362
27365
  void dispatcher2.close();
@@ -27387,7 +27390,7 @@ async function readCapped(response2, url) {
27387
27390
  const buffer = Buffer.from(await response2.arrayBuffer());
27388
27391
  if (buffer.length > MAX_REMOTE_IMAGE_BYTES) {
27389
27392
  throw new Error(
27390
- `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${url}`
27393
+ `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(url)}`
27391
27394
  );
27392
27395
  }
27393
27396
  return buffer;
@@ -27404,7 +27407,7 @@ async function readCapped(response2, url) {
27404
27407
  if (total > MAX_REMOTE_IMAGE_BYTES) {
27405
27408
  await reader.cancel();
27406
27409
  throw new Error(
27407
- `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${url}`
27410
+ `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(url)}`
27408
27411
  );
27409
27412
  }
27410
27413
  chunks.push(Buffer.from(value));
@@ -27677,6 +27680,33 @@ function hasPath(value, dottedPath) {
27677
27680
  function isPlainObject(value) {
27678
27681
  return typeof value === "object" && value !== null && !Array.isArray(value);
27679
27682
  }
27683
+ const TOKEN_SHAPES = [
27684
+ // Vendor-prefixed keys (OpenAI/Anthropic sk-, Stripe rk/pk, Slack xox*).
27685
+ /\b(?:sk|rk|pk|xox[a-z])-[A-Za-z0-9_-]{12,}\b/g,
27686
+ // Google API keys.
27687
+ /\bAIza[A-Za-z0-9_-]{20,}\b/g,
27688
+ // GitHub tokens.
27689
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
27690
+ // JWTs (three base64url segments, the first spelling {"alg" or {"typ").
27691
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g,
27692
+ // Auth headers: "Bearer xyz" / "Authorization: xyz" (space form is real).
27693
+ /\b(?:bearer|authorization)\b[=:\s]+"?[A-Za-z0-9._~+/-]{12,}"?/gi,
27694
+ // Labeled keys need an explicit = or : separator; prose like
27695
+ // "token limit_exceeded" is diagnostics, not a credential.
27696
+ /\b(?:token|api[-_]?key)\b\s*[=:]\s*"?[A-Za-z0-9._~+/-]{12,}"?/gi
27697
+ ];
27698
+ function redactSecrets(text, knownSecrets = []) {
27699
+ let out = text;
27700
+ for (const secret of knownSecrets) {
27701
+ if (secret && secret.length >= 6) {
27702
+ out = out.split(secret).join("[redacted]");
27703
+ }
27704
+ }
27705
+ for (const shape of TOKEN_SHAPES) {
27706
+ out = out.replace(shape, "[redacted]");
27707
+ }
27708
+ return out;
27709
+ }
27680
27710
  const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
27681
27711
  const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
27682
27712
  const TOOL_NAME = "report_vision_evidence";
@@ -27744,7 +27774,9 @@ Report your findings by calling the ${TOOL_NAME} tool.`;
27744
27774
  });
27745
27775
  if (!response2.ok) {
27746
27776
  const body2 = await response2.text();
27747
- throw new Error(`Anthropic API error ${response2.status}: ${truncate(body2)}`);
27777
+ throw new Error(
27778
+ `Anthropic API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
27779
+ );
27748
27780
  }
27749
27781
  const payload = await response2.json();
27750
27782
  const toolUse = payload.content?.find((block) => block.type === "tool_use");
@@ -28035,7 +28067,9 @@ async function executeGeminiApi(options) {
28035
28067
  });
28036
28068
  if (!response2.ok) {
28037
28069
  const body2 = await response2.text();
28038
- throw new Error(`Gemini API error ${response2.status}: ${truncate(body2)}`);
28070
+ throw new Error(
28071
+ `Gemini API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
28072
+ );
28039
28073
  }
28040
28074
  const payload = await response2.json();
28041
28075
  const text = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("");
@@ -28109,7 +28143,9 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
28109
28143
  });
28110
28144
  if (!response2.ok) {
28111
28145
  const body2 = await response2.text();
28112
- throw new Error(`OpenAI-compatible API error ${response2.status}: ${truncate(body2)}`);
28146
+ throw new Error(
28147
+ `OpenAI-compatible API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
28148
+ );
28113
28149
  }
28114
28150
  const payload = await response2.json();
28115
28151
  const text = payload.choices?.[0]?.message?.content;
@@ -28442,13 +28478,16 @@ const PROVIDER_DESCRIPTORS = [
28442
28478
  ];
28443
28479
  function findOnPath(bin, env) {
28444
28480
  const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
28481
+ const suffixes = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
28445
28482
  for (const dir of dirs) {
28446
- const full = path.join(dir, bin);
28447
- try {
28448
- if (fs.statSync(full).isFile()) {
28449
- return full;
28483
+ for (const suffix of suffixes) {
28484
+ const full = path.join(dir, bin + suffix);
28485
+ try {
28486
+ if (fs.statSync(full).isFile()) {
28487
+ return full;
28488
+ }
28489
+ } catch {
28450
28490
  }
28451
- } catch {
28452
28491
  }
28453
28492
  }
28454
28493
  return null;
@@ -28609,7 +28648,10 @@ function probeCodex(env, home) {
28609
28648
  loggedIn,
28610
28649
  visionModels: [],
28611
28650
  source: "none",
28612
- error: error instanceof Error ? error.message : String(error)
28651
+ error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
28652
+ 0,
28653
+ 200
28654
+ )
28613
28655
  };
28614
28656
  }
28615
28657
  });
@@ -28682,7 +28724,10 @@ function probePi(env, home) {
28682
28724
  cliPath,
28683
28725
  visionModels: [],
28684
28726
  source: "none",
28685
- error: error instanceof Error ? error.message : String(error)
28727
+ error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
28728
+ 0,
28729
+ 200
28730
+ )
28686
28731
  };
28687
28732
  }
28688
28733
  });
@@ -28704,7 +28749,10 @@ function probeOpencode(env, runCli) {
28704
28749
  cliPath,
28705
28750
  visionModels: [],
28706
28751
  source: "none",
28707
- error: error instanceof Error ? error.message : String(error)
28752
+ error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
28753
+ 0,
28754
+ 200
28755
+ )
28708
28756
  };
28709
28757
  }
28710
28758
  });
@@ -29221,7 +29269,9 @@ async function analyzeImage(options) {
29221
29269
  provider: provider.name,
29222
29270
  ok: false,
29223
29271
  durationSeconds: (Date.now() - startedAt) / 1e3,
29224
- error: message.slice(0, 300)
29272
+ // Providers redact their own errors, but attempts travel into
29273
+ // output and model contexts, so the record gets the belt too.
29274
+ error: redactSecrets(message).slice(0, 300)
29225
29275
  });
29226
29276
  }
29227
29277
  }
@@ -29444,7 +29494,9 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
29444
29494
  const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
29445
29495
  reject(
29446
29496
  new Error(
29447
- explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
29497
+ redactSecrets(
29498
+ explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
29499
+ )
29448
29500
  )
29449
29501
  );
29450
29502
  return;
@@ -29572,7 +29624,6 @@ function cwdMatches(recorded, wanted, bothDirections = false) {
29572
29624
  return bothDirections && resolvedWanted.startsWith(`${resolvedRecorded}${path.sep}`);
29573
29625
  }
29574
29626
  function transcriptBelongsTo(lines, cwd, bothDirections = false) {
29575
- let sawCwd = false;
29576
29627
  for (const line of lines) {
29577
29628
  if (!line.includes('"cwd"')) {
29578
29629
  continue;
@@ -29582,14 +29633,13 @@ function transcriptBelongsTo(lines, cwd, bothDirections = false) {
29582
29633
  if (typeof recorded !== "string") {
29583
29634
  continue;
29584
29635
  }
29585
- sawCwd = true;
29586
29636
  if (cwdMatches(recorded, cwd, bothDirections)) {
29587
29637
  return true;
29588
29638
  }
29589
29639
  } catch {
29590
29640
  }
29591
29641
  }
29592
- return !sawCwd;
29642
+ return false;
29593
29643
  }
29594
29644
  function readLines(filePath) {
29595
29645
  try {
@@ -29718,16 +29768,19 @@ const claudeAdapter = jsonlAdapter({
29718
29768
  function opencodeDbPath() {
29719
29769
  return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
29720
29770
  }
29721
- function escapeLikePattern(value) {
29722
- return value.replace(/[\\%_]/g, (char) => `\\${char}`);
29723
- }
29724
- function opencodeDirectoryFilter(resolvedCwd) {
29771
+ function opencodeDirectoryFilter(resolvedCwd, caseInsensitive = process.platform === "win32") {
29725
29772
  const normalized = resolvedCwd.replace(/\\/g, "/");
29726
- const escaped = escapeLikePattern(normalized);
29727
- const dir = `REPLACE(session.directory, '\\', '/')`;
29773
+ const cwd = caseInsensitive ? normalized.toLowerCase() : normalized;
29774
+ const prefix = `${cwd.replace(/\/+$/, "")}/`;
29775
+ const rawDir = `REPLACE(session.directory, '\\', '/')`;
29776
+ const dir = caseInsensitive ? `LOWER(${rawDir})` : rawDir;
29777
+ const dirPrefix = `RTRIM(${dir}, '/') || '/'`;
29728
29778
  return {
29729
- clause: `(${dir} = ? OR ${dir} LIKE ? || '/%' ESCAPE '\\' OR ? LIKE ${dir} || '/%' ESCAPE '\\')`,
29730
- params: [normalized, escaped, normalized]
29779
+ // SQLite SUBSTR counts Unicode characters while JS .length counts
29780
+ // UTF-16 units, so the length parameter is measured in code points
29781
+ // ([...str].length) or an emoji in a path would shift the boundary.
29782
+ clause: `(${dir} = ? OR SUBSTR(${dir}, 1, ?) = ? OR SUBSTR(?, 1, LENGTH(${dirPrefix})) = ${dirPrefix})`,
29783
+ params: [cwd, [...prefix].length, prefix, cwd]
29731
29784
  };
29732
29785
  }
29733
29786
  function buildOpencodeQuery(resolvedCwd, sessionId) {
@@ -30175,8 +30228,13 @@ function inspectProvider(descriptor, config2, env) {
30175
30228
  name: descriptor.name,
30176
30229
  kind: "subprocess",
30177
30230
  ready: binaryPath !== null,
30231
+ status: binaryPath !== null ? "installed" : "missing",
30232
+ // "On PATH" proves installation, not a working login: doctor runs
30233
+ // offline and spends nothing, so sign-in state stays unverified
30234
+ // here and the first real read is the auth check.
30235
+ authUnverified: binaryPath !== null,
30178
30236
  binaryPath,
30179
- detail: binaryPath ? `${descriptor.bin} found at ${binaryPath}` : `${descriptor.bin} not on PATH`,
30237
+ detail: binaryPath ? `${descriptor.bin} found at ${binaryPath} (installed; sign-in not verified offline)` : `${descriptor.bin} not on PATH`,
30180
30238
  fix: binaryPath ? void 0 : descriptor.install
30181
30239
  };
30182
30240
  }
@@ -30194,6 +30252,7 @@ function inspectProvider(descriptor, config2, env) {
30194
30252
  name: descriptor.name,
30195
30253
  kind: "api",
30196
30254
  ready,
30255
+ status: ready ? "ready" : "missing",
30197
30256
  settings: statuses,
30198
30257
  detail,
30199
30258
  fix: ready ? void 0 : descriptor.fix
@@ -30314,7 +30373,8 @@ function renderDoctorReport(report) {
30314
30373
  lines.push("");
30315
30374
  lines.push("Providers");
30316
30375
  for (const provider of report.providers) {
30317
- lines.push(` ${mark(provider.ready)} ${provider.name}: ${provider.detail}`);
30376
+ const providerMark = provider.ready && provider.authUnverified ? "[ok?]" : mark(provider.ready);
30377
+ lines.push(` ${providerMark} ${provider.name}: ${provider.detail}`);
30318
30378
  if (provider.fix) {
30319
30379
  lines.push(` fix: ${provider.fix}`);
30320
30380
  }
@@ -30461,7 +30521,7 @@ function locateSource(cwd, adapters = ADAPTERS) {
30461
30521
  const blocked = blockers.length > 0 ? `
30462
30522
  Blocked: ${blockers.join(" | ")}` : "";
30463
30523
  throw new Error(
30464
- `No pasted images found in any session storage for this directory (looked in: ${dirs}). The user may not have pasted any, or the storage format changed; ask for a file path instead.${blocked}`
30524
+ `No pasted images found in any session storage for this directory (looked in: ${dirs}). The user may not have pasted any, the storage format changed, or a legacy transcript records no cwd (ownership cannot be proven; an explicit --transcript path bypasses that check). Ask for a file path instead.${blocked}`
30465
30525
  );
30466
30526
  }
30467
30527
  return best.ref;
@@ -30528,7 +30588,7 @@ function recoverPastedImages(options = {}) {
30528
30588
  const all = source.extract();
30529
30589
  if (all.length === 0) {
30530
30590
  throw new Error(
30531
- `No pasted images found in ${source.location}. The user may not have pasted any, or the storage format changed; ask for a file path instead.`
30591
+ `No pasted images found in ${source.location}. The user may not have pasted any, the storage format changed, or a legacy transcript records no cwd (ownership cannot be proven; an explicit --transcript path bypasses that check). Ask for a file path instead.`
30532
30592
  );
30533
30593
  }
30534
30594
  const outDir = prepareOutDir(options.outDir);
@@ -30560,16 +30620,19 @@ function recoverPastedImages(options = {}) {
30560
30620
  return result;
30561
30621
  }
30562
30622
  const program = new Command();
30563
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.10.0");
30623
+ function parsePositiveInt(raw, flag) {
30624
+ if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) <= 0) {
30625
+ throw new Error(`Invalid ${flag}. Use a positive integer.`);
30626
+ }
30627
+ return Number.parseInt(raw, 10);
30628
+ }
30629
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.11.0");
30564
30630
  program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
30565
30631
  "--extra-body <json>",
30566
30632
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
30567
30633
  ).action(async (options) => {
30568
30634
  try {
30569
- const timeoutMs = Number.parseInt(options.timeout, 10);
30570
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
30571
- throw new Error("Invalid --timeout. Use a positive integer in milliseconds.");
30572
- }
30635
+ const timeoutMs = parsePositiveInt(options.timeout, "--timeout (milliseconds)");
30573
30636
  const config2 = loadConfigFile();
30574
30637
  if (process.env.MODLENS_MODEL?.trim()) {
30575
30638
  const verdict = runGuard(config2.guards, {
@@ -30607,7 +30670,7 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
30607
30670
  `Error: ${error instanceof Error ? error.message : String(error)}
30608
30671
  `
30609
30672
  );
30610
- process.exit(1);
30673
+ process.exitCode = 1;
30611
30674
  }
30612
30675
  });
30613
30676
  program.command("recover-paste").description(
@@ -30620,10 +30683,7 @@ program.command("recover-paste").description(
30620
30683
  "Force the storage scope: claude-code, pi, opencode, or none (default: auto-detect via process ancestry and env)"
30621
30684
  ).option("--cwd <path>", "Project directory the image was pasted in", process.cwd()).action(async (options) => {
30622
30685
  try {
30623
- const count = Number.parseInt(options.count, 10);
30624
- if (!Number.isFinite(count) || count <= 0) {
30625
- throw new Error("Invalid --count. Use a positive integer.");
30626
- }
30686
+ const count = parsePositiveInt(options.count, "--count");
30627
30687
  const result = recoverPastedImages({
30628
30688
  count,
30629
30689
  outDir: options.outDir,
@@ -30639,7 +30699,7 @@ program.command("recover-paste").description(
30639
30699
  `Error: ${error instanceof Error ? error.message : String(error)}
30640
30700
  `
30641
30701
  );
30642
- process.exit(1);
30702
+ process.exitCode = 1;
30643
30703
  }
30644
30704
  });
30645
30705
  program.command("guard").description(
@@ -30683,7 +30743,7 @@ program.command("doctor").description(
30683
30743
  `Error: ${error instanceof Error ? error.message : String(error)}
30684
30744
  `
30685
30745
  );
30686
- process.exit(1);
30746
+ process.exitCode = 1;
30687
30747
  }
30688
30748
  });
30689
30749
  const config = program.command("config").description(`Manage ${CONFIG_PATH} (providers, keys, models)`);
@@ -30705,7 +30765,7 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
30705
30765
  `Error: ${error instanceof Error ? error.message : String(error)}
30706
30766
  `
30707
30767
  );
30708
- process.exit(1);
30768
+ process.exitCode = 1;
30709
30769
  }
30710
30770
  });
30711
30771
  config.command("set <key> <value>").description("Set a value, e.g. modlens config set gemini-api.apiKey <key>").action((key, value) => {
@@ -30718,7 +30778,7 @@ config.command("set <key> <value>").description("Set a value, e.g. modlens confi
30718
30778
  `Error: ${error instanceof Error ? error.message : String(error)}
30719
30779
  `
30720
30780
  );
30721
- process.exit(1);
30781
+ process.exitCode = 1;
30722
30782
  }
30723
30783
  });
30724
30784
  config.command("show").description("Print the effective config (file merged with env vars), API keys masked").action(() => {
@@ -30730,7 +30790,7 @@ config.command("show").description("Print the effective config (file merged with
30730
30790
  `Error: ${error instanceof Error ? error.message : String(error)}
30731
30791
  `
30732
30792
  );
30733
- process.exit(1);
30793
+ process.exitCode = 1;
30734
30794
  }
30735
30795
  });
30736
- program.parse();
30796
+ await program.parseAsync();
package/docs/cli.md CHANGED
@@ -30,6 +30,8 @@ Output is a fixed JSON shape:
30
30
  "summary": "A workflow diagram with four nodes connected by labeled arrows.",
31
31
  "ocr": { "full_text": "/shaping\nBEFORE YOU BUILD\n...", "lines": [] },
32
32
  "layout": { "regions": [{ "reading_order": 1, "type": "title", "text": "/shaping" }] },
33
+ "semantics": { "scene": "workflow diagram", "entities": [], "relations": [] },
34
+ "visual": { "dominant_colors": ["white", "black"], "style": "flat", "notes": [] },
33
35
  "uncertainty": []
34
36
  },
35
37
  "meta": {
@@ -65,7 +67,7 @@ Output is a fixed JSON shape:
65
67
  `--extra-body` is how vendor-specific knobs get through, turning thinking off
66
68
  being the common one. It applies to the three API providers and replaces the
67
69
  configured `extraBody` for that run. Per-vendor spellings and the fields it
68
- refuses to touch are in [Configuration](configure.md).
70
+ refuses to touch are in [Configuration](../skills/modlens/references/configure.md).
69
71
 
70
72
  The default `-m` model depends on the provider:
71
73
 
@@ -88,7 +90,7 @@ The default `-m` model depends on the provider:
88
90
  | `--harness <name>` | Force storage scope: `claude-code`, `pi`, `opencode`, `none` | auto-detect |
89
91
  | `--cwd <path>` | Project directory the image was pasted in | current directory |
90
92
 
91
- Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic`, and `claude-cli` (uses your existing Claude subscription). Without `-p`, a run tries every provider that is set up, inline API providers first (5-10s), then the agents; the first good result wins and `meta.attempts` records the rest. Harnesses granted via `reuse.<harness>` contribute reused engines to the same regions (pi credentials inline, agent CLIs behind), with no priority over the user's own; details and the `guards` deny/allow lists are in [Configuration](configure.md).
93
+ Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic`, and `claude-cli` (uses your existing Claude subscription). Without `-p`, a run tries every provider that is set up, inline API providers first (5-10s), then the agents; the first good result wins and `meta.attempts` records the rest. Harnesses granted via `reuse.<harness>` contribute reused engines to the same regions (pi credentials inline, agent CLIs behind), with no priority over the user's own; details and the `guards` deny/allow lists are in [Configuration](../skills/modlens/references/configure.md).
92
94
 
93
95
  Other subcommands:
94
96
 
@@ -18,7 +18,7 @@ One catch with text-only models: once `models.json` declares `input_modalities:
18
18
 
19
19
  ## Claude Code, Pi, OpenCode
20
20
 
21
- None of them writes a pasted image to a regular temp file, but all three persist the user message locally before any gateway strips it:
21
+ None of them hands the model a usable temp-file path the way Codex does (newer Claude Code builds do write pastes to their own `~/.claude/image-cache/`, injected as a path line only in the terminal entrypoint), but all three persist the user message locally before any gateway strips it:
22
22
 
23
23
  | Harness | Storage | Notes |
24
24
  | :-- | :-- | :-- |
@@ -53,7 +53,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
53
53
  dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
54
54
 
55
55
  ```sh
56
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens
56
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
57
57
  ```
58
58
 
59
- This registers a `read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin touches only `ctx.tools.register` to keep that surface minimal.
59
+ This registers a `read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
@@ -67,6 +67,6 @@ The CLI prints one JSON object to stdout:
67
67
  }
68
68
  ```
69
69
 
70
- Required fields: `summary`, `ocr`, `layout`, `semantics`, `uncertainty`. `visual` is optional.
70
+ Required fields: `summary`, `ocr`, `layout`, `semantics`, `visual`, `uncertainty` — every top-level field, `visual` included. (Earlier docs called `visual` optional; the enforced schema has always required it, so build to the schema.)
71
71
 
72
72
  Changes from v1: pixel `bbox` coordinates and numeric `confidence` scores were removed. Vision models fabricate both, so v2 stops pretending to provide them. `layout.regions[].type` gained `code`.
package/docs/security.md CHANGED
@@ -22,7 +22,15 @@ The `claude-cli` provider runs with `--allowedTools Read` only, so it can read l
22
22
 
23
23
  Both subprocess providers also run in a throwaway directory created fresh per call and removed afterward. For a local image it holds a private copy of that one image and nothing else, and it is a real copy, never a hardlink, so a provider writing to its temp path cannot touch the original. For a remote image the directory is empty and the agent downloads into it. Without this, text inside an image could steer a broadly-permissioned agent into reading files next to the original, or whatever project the caller happened to be in. Passing `--workdir` opts out and runs where you point it.
24
24
 
25
- This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer an inline API provider (`-p gemini-api`), which hands the bytes to an HTTP endpoint and runs no local agent. Remote URLs already prefer that path: the failover chain for a remote URL tries the inline API providers first and the agent last, because the inline path downloads the image itself, behind the private-address guards, the magic-byte image check, and the size cap, none of which apply when an agent fetches the URL on its own. An explicit `-p` pins one provider and overrides the chain.
25
+ This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer `-p gemini-api`, which downloads the bytes itself and runs no local agent. Remote URLs already prefer the inline region: the failover chain for a remote URL tries the inline API providers first and the agents last. Who actually fetches a remote URL differs per provider, and only a local download can be guarded locally:
26
+
27
+ | Provider | Who fetches a remote URL | Local guards |
28
+ | :-- | :-- | :-- |
29
+ | `gemini-api` | modlens downloads, sends bytes inline | private-address guards, magic-byte check, 25 MB cap |
30
+ | `openai`, `anthropic` | the URL is passed to the vendor, which fetches it | none locally; the vendor's own fetching policy applies |
31
+ | `antigravity-cli`, agent CLIs | the agent fetches on its own | none locally |
32
+
33
+ So the private-address guards, the magic-byte check, and the size cap protect exactly the paths where modlens itself downloads: every local file read, and gemini-api's remote fetch. An explicit `-p` pins one provider and overrides the chain.
26
34
 
27
35
  ## Image content is untrusted input
28
36
 
@@ -119,6 +119,41 @@ One known blind spot: storage detection reads the newest assistant turn recorded
119
119
 
120
120
  Note that the hard refusal above only fires on an actual `denyModels` match against the explicit `MODLENS_MODEL` value. Storage detection and the `denyWhenUnknown` policy never block `analyze`, they only speak through `modlens guard`, whose deny is advice to the agent rather than a locked door.
121
121
 
122
+ ## dsh says "declares no dsh.bundle — installed as a plain dependency"
123
+
124
+ The dsh profile installed an old modlens version. The `dsh.bundle` declaration
125
+ exists since 3.9.0, and pnpm v11's release-age gate (`minimumReleaseAge`,
126
+ quarantining recently published versions, with a 10-day window measured on pnpm 11.21) silently falls back to an older version when every
127
+ recent one is inside the window. That old version has no bundle declaration,
128
+ so dsh correctly treats it as a plain dependency and none of the tools appear.
129
+
130
+ The fix: name the version explicitly. pnpm applies the age gate when resolving
131
+ a range, but an explicit version or dist-tag skips it ([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989), verified on pnpm 11.21), which is why the install
132
+ command carries `@latest`:
133
+
134
+ ```sh
135
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@latest
136
+ ```
137
+
138
+ dsh's reconcile notices the bundle declaration on the new version and
139
+ activates it; restart dsh afterwards. Verify with
140
+ `npx -y @deepseek-ai/dsh plugin --profile <name> list` — the version shown
141
+ should be 3.9.0 or newer.
142
+
143
+ If a future pnpm closes that skip, the durable alternative is a one-time
144
+ exclusion in `~/.dsh/profiles/<name>/pnpm-workspace.yaml` — the bare package
145
+ name, not `name@version`, so it survives future releases:
146
+
147
+ ```yaml
148
+ minimumReleaseAgeExclude:
149
+ - '@liustack/modlens'
150
+ ```
151
+
152
+ then `npx -y @deepseek-ai/dsh plugin --profile <name> update @liustack/modlens`.
153
+ The trade-off is honest either way: an explicit `@latest` (or the exclusion)
154
+ opts modlens out of pnpm's supply-chain cooling-off window, so new releases
155
+ install immediately.
156
+
122
157
  ## Config file problems
123
158
 
124
159
  ```
package/dsh/index.js CHANGED
@@ -24,11 +24,13 @@ const CLI_TIMEOUT_MS = 180_000
24
24
  export const name = 'modlens'
25
25
  export const inject = ['tools', 'agents', 'attachments', 'llm']
26
26
 
27
- const MEDIA_EXT = {
27
+ export const MEDIA_EXT = {
28
28
  'image/png': '.png',
29
29
  'image/jpeg': '.jpg',
30
30
  'image/webp': '.webp',
31
31
  'image/gif': '.gif',
32
+ 'image/heic': '.heic',
33
+ 'image/heif': '.heif',
32
34
  }
33
35
 
34
36
  export function apply(ctx, config = {}) {
@@ -191,6 +193,84 @@ function registerVisionProvider(ctx, config) {
191
193
  }
192
194
  }
193
195
 
196
+ // The same pasted attachment rides every later step of its session, but the
197
+ // cache must never make a failure permanent or run the engine twice for
198
+ // concurrent steps. So it stores promises (concurrent readers join the first
199
+ // run), evicts failed reads on settle (a fixed config gets a fresh chance),
200
+ // and caps itself LRU-style so a long-lived Web profile cannot hoard
201
+ // evidence text forever.
202
+ const EVIDENCE_CACHE_LIMIT = 64
203
+
204
+ function cachedEvidence(ctx, adapter, block) {
205
+ const key = JSON.stringify(block.attachment ?? block)
206
+ const hit = adapter.evidenceCache.get(key)
207
+ if (hit !== undefined) {
208
+ // Refresh recency: Map iteration order is insertion order.
209
+ adapter.evidenceCache.delete(key)
210
+ adapter.evidenceCache.set(key, hit)
211
+ return hit
212
+ }
213
+ // Deliberately no caller signal: a shared entry must not die with its first
214
+ // caller (their abort used to cancel every concurrent joiner). A cancelled
215
+ // caller simply stops awaiting; the read finishes and the cache keeps it.
216
+ const pending = readImageBlock(ctx, block, undefined).then(
217
+ (evidence) => {
218
+ // Only evict our own entry: this promise may have been LRU-evicted and
219
+ // the key re-populated by a newer read meanwhile.
220
+ if (!evidence.ok && adapter.evidenceCache.get(key) === pending) {
221
+ adapter.evidenceCache.delete(key)
222
+ }
223
+ return evidence.block
224
+ },
225
+ (error) => {
226
+ // readImageBlock never rejects by contract; this is the belt for a
227
+ // future refactor breaking that, so a rejected promise cannot lodge in
228
+ // the cache forever.
229
+ if (adapter.evidenceCache.get(key) === pending) {
230
+ adapter.evidenceCache.delete(key)
231
+ }
232
+ return {
233
+ type: 'text',
234
+ text: `[A pasted image could not be read by modlens: ${
235
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
236
+ }]`,
237
+ }
238
+ },
239
+ )
240
+ adapter.evidenceCache.set(key, pending)
241
+ while (adapter.evidenceCache.size > EVIDENCE_CACHE_LIMIT) {
242
+ adapter.evidenceCache.delete(adapter.evidenceCache.keys().next().value)
243
+ }
244
+ return pending
245
+ }
246
+
247
+ /**
248
+ * Wait on a shared promise without inheriting its lifetime: the caller's
249
+ * abort rejects THIS wait immediately, while the underlying read keeps
250
+ * running and lands in the cache for the retry.
251
+ */
252
+ function abortableWait(promise, signal) {
253
+ if (!signal) return promise
254
+ return new Promise((resolve, reject) => {
255
+ if (signal.aborted) {
256
+ reject(signal.reason ?? new Error('aborted'))
257
+ return
258
+ }
259
+ const onAbort = () => reject(signal.reason ?? new Error('aborted'))
260
+ signal.addEventListener('abort', onAbort, { once: true })
261
+ promise.then(
262
+ (value) => {
263
+ signal.removeEventListener('abort', onAbort)
264
+ resolve(value)
265
+ },
266
+ (error) => {
267
+ signal.removeEventListener('abort', onAbort)
268
+ reject(error)
269
+ },
270
+ )
271
+ })
272
+ }
273
+
194
274
  async function convertImagesToEvidence(ctx, messages, signal, adapter) {
195
275
  const out = []
196
276
  for (const message of messages) {
@@ -204,13 +284,7 @@ async function convertImagesToEvidence(ctx, messages, signal, adapter) {
204
284
  content.push(block)
205
285
  continue
206
286
  }
207
- const key = JSON.stringify(block.attachment ?? block)
208
- let text = adapter.evidenceCache.get(key)
209
- if (text === undefined) {
210
- text = (await readImageBlock(ctx, block, signal)).text
211
- adapter.evidenceCache.set(key, text)
212
- }
213
- content.push({ type: 'text', text })
287
+ content.push(await abortableWait(cachedEvidence(ctx, adapter, block), signal))
214
288
  }
215
289
  out.push({ ...message, content })
216
290
  }
@@ -251,7 +325,7 @@ function registerAutoRead(ctx) {
251
325
  content.push(block)
252
326
  continue
253
327
  }
254
- content.push(await readImageBlock(ctx, block, payload.signal))
328
+ content.push((await readImageBlock(ctx, block, payload.signal)).block)
255
329
  }
256
330
  messages.push({ ...message, content })
257
331
  }
@@ -259,6 +333,12 @@ function registerAutoRead(ctx) {
259
333
  })
260
334
  }
261
335
 
336
+ /**
337
+ * Read one image block into an evidence text block. Never throws: failures
338
+ * degrade to an explanatory block with `ok: false`, so callers can decide
339
+ * what a failure means (the pre-step keeps the step going, the cache refuses
340
+ * to memoize it).
341
+ */
262
342
  async function readImageBlock(ctx, block, signal) {
263
343
  const { mkdtemp, rm, writeFile } = await import('node:fs/promises')
264
344
  const { tmpdir } = await import('node:os')
@@ -275,9 +355,15 @@ async function readImageBlock(ctx, block, signal) {
275
355
  "attachments.readImage returned no 'data' bytes; the dsh attachment shape may have changed",
276
356
  )
277
357
  }
278
- dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-'))
279
358
  const mediaType = stored.ref?.mediaType ?? block.attachment?.mediaType
280
- const file = join(dir, `paste${MEDIA_EXT[mediaType] ?? '.png'}`)
359
+ const ext = MEDIA_EXT[mediaType]
360
+ if (!ext) {
361
+ // Refusing beats disguising: a fake .png suffix would make the CLI (and
362
+ // the provider behind it) judge mislabelled bytes.
363
+ throw new Error(`unsupported pasted media type ${mediaType ?? '(none declared)'}`)
364
+ }
365
+ dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-'))
366
+ const file = join(dir, `paste${ext}`)
281
367
  await writeFile(file, Buffer.from(stored.data), { mode: 0o600 })
282
368
  const cli = process.env.MODLENS_DSH_CLI || CLI_PATH
283
369
  const { stdout, stderr, code } = await run(
@@ -290,15 +376,21 @@ async function readImageBlock(ctx, block, signal) {
290
376
  }
291
377
  const parsed = JSON.parse(stdout)
292
378
  return {
293
- type: 'text',
294
- text: `[Pasted image, read by the modlens vision bridge]\n${renderEvidence(parsed.result)}`,
379
+ ok: true,
380
+ block: {
381
+ type: 'text',
382
+ text: `[Pasted image, read by the modlens vision bridge]\n${renderEvidence(parsed.result)}`,
383
+ },
295
384
  }
296
385
  } catch (error) {
297
386
  return {
298
- type: 'text',
299
- text: `[A pasted image could not be read by modlens: ${
300
- error instanceof Error ? error.message.slice(0, 300) : String(error)
301
- }. Tell the user, and suggest running \`npx @liustack/modlens doctor\`.]`,
387
+ ok: false,
388
+ block: {
389
+ type: 'text',
390
+ text: `[A pasted image could not be read by modlens: ${
391
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
392
+ }. Tell the user, and suggest running \`npx @liustack/modlens doctor\`.]`,
393
+ },
302
394
  }
303
395
  } finally {
304
396
  if (dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "typecheck": "tsc --noEmit",
13
13
  "test": "vitest run",
14
14
  "coverage": "vitest run --coverage",
15
- "lint": "biome check src scripts",
15
+ "lint": "biome check src scripts dsh",
16
16
  "format": "biome check --write src scripts",
17
17
  "eval": "node evals/run.mjs",
18
18
  "release": "node scripts/release.mjs",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: modlens
3
- description: "Plug-in vision for text-only models. Hard rule: when a file path or URL with an image extension (.png, .jpg, .jpeg, .webp, .gif, .bmp, .heic) appears anywhere in the conversation (typed by the user, injected as a `[Image: source: <path>]` line, or inside a tag) and you cannot see that image's content, run this skill on it before any other approach: no self-built OCR, no PIL, no tesseract. Also triggers on pasted-image placeholders such as `[Image #1]` and `[Unsupported Image]`. If you can actually see the image, do not use this skill. When unsure, run `modlens guard` before the first read of a session: a deny verdict means the active model has native vision and must read the image itself. Runs the modlens CLI to convert the image into structured JSON evidence: every word transcribed, layout regions, semantics, visual clues. Also use when the user asks how to install, configure, or switch modlens providers (Gemini API key, OpenAI-compatible endpoints, Claude API or Claude Code CLI)."
3
+ description: "Plug-in vision for text-only models. Hard rule: when a file path or URL with an image extension (.png, .jpg, .jpeg, .webp, .gif, .heic, .heif) appears anywhere in the conversation (typed by the user, injected as a `[Image: source: <path>]` line, or inside a tag) and you cannot see that image's content, run this skill on it before any other approach: no self-built OCR, no PIL, no tesseract. Also triggers on pasted-image placeholders such as `[Image #1]` and `[Unsupported Image]`. If you can actually see the image, do not use this skill. When unsure, run `modlens guard` before the first read of a session: a deny verdict means the active model has native vision and must read the image itself. Runs the modlens CLI to convert the image into structured JSON evidence: every word transcribed, layout regions, semantics, visual clues. Also use when the user asks how to install, configure, or switch modlens providers (Gemini API key, OpenAI-compatible endpoints, Claude API or Claude Code CLI)."
4
4
  compatibility: Requires network access and one of node 22+/npx, bun/bunx, or a preinstalled modlens binary on PATH.
5
5
  allowed-tools: Bash
6
6
  ---
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.10.0):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.11.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.10.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.10.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.10.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.11.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.11.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.11.0 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.13+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -22,7 +22,7 @@ Everything lives under four top-level keys, all optional. This example shows eve
22
22
  ```json
23
23
  {
24
24
  "provider": "gemini-api",
25
- "reuse": { "claude": true, "codex": true, "opencode": false, "pi": true },
25
+ "reuse": { "claude": true, "codex": true, "opencode": false, "pi": true, "grok": true },
26
26
  "guards": {
27
27
  "allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
28
28
  "denyModels": ["glm-*v*", "deepseek-vl*"],
@@ -18,7 +18,7 @@ Newer Claude Code builds write every pasted image to `~/.claude/image-cache/<ses
18
18
 
19
19
  ## Claude Code, Pi, or OpenCode (no usable path anywhere)
20
20
 
21
- The image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see. Whatever a gateway strips from the request, these harnesses persist user messages, image bytes included, in local session storage first: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.5+; Bun cannot load node:sqlite, so a bunx-resolved launcher needs a real Node install for OpenCode recovery).
21
+ The image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see. Whatever a gateway strips from the request, these harnesses persist user messages, image bytes included, in local session storage first: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.13+; Bun cannot load node:sqlite, so a bunx-resolved launcher needs a real Node install for OpenCode recovery).
22
22
 
23
23
  - Run `modlens recover-paste` from the project directory the conversation is happening in (`--count <n>` for several images). It detects which harness it is running inside and reads ONLY that harness's storage. In Claude Code it targets your exact session automatically via the injected `CLAUDE_CODE_SESSION_ID`; `--session <id>` is only needed to override.
24
24
  - The output is JSON with real file paths, oldest to newest, so the LAST path is the most recent paste: analyze that one first. Entries carry `filename` when the harness stored one; match on it when the user's message mentions a name.
@@ -8,7 +8,7 @@ Run this flow when `~/.modlens/config.json` does not exist, or `modlens config s
8
8
  modlens doctor --json
9
9
  ```
10
10
 
11
- Read three things from the report: which providers are `ready`, the Reuse section (per-harness decisions plus discovered logins and vision models; the harness this conversation runs inside is itself the first reusable engine), and the guard state. Doctor spends no quota and makes no network calls.
11
+ Read three things from the report: each provider's `status` (`ready` means verified, `installed` means a CLI is on PATH with its sign-in unverified until the first real read, rendered as `[ok?]` in the text report), the Reuse section (per-harness decisions plus discovered logins and vision models, where the harness this conversation runs inside is itself the first reusable engine), and the guard state. Doctor spends no quota and makes no network calls.
12
12
 
13
13
  ## 2. Tell the user what their machine already has
14
14
 
@@ -16,9 +16,9 @@ One line per finding, plain words, in the user's language. Name concrete things,
16
16
 
17
17
  - An engine is ready: "modlens is ready to go: <provider> is configured (via <env var / config file / existing login>)."
18
18
  - Reusable vision found: "Your <harness> CLI is signed in and its model can read images. modlens can reuse it when needed, about <n> seconds per read, and it spends that account's quota."
19
- - Nothing at all: "No vision engine is set up yet. The fastest free option is a Gemini API key (three minutes, no card); Antigravity CLI works with no sign-up at all."
19
+ - Nothing at all: "No vision engine is set up yet. The fastest free option is a Gemini API key (three minutes, no card). Antigravity CLI works with no sign-up at all."
20
20
 
21
- Do not dump the raw doctor output on the user; summarize it. Do not describe options the machine does not have.
21
+ Do not dump the raw doctor output on the user, summarize it. Do not describe options the machine does not have.
22
22
 
23
23
  ## 3. Ask before touching anything
24
24
 
@@ -27,7 +27,7 @@ Consent rules:
27
27
  - One question per decision, never a bundled yes. Reusing Codex and reusing pi credentials are two questions (or one question with independent options), not one.
28
28
  - Each question names the harness, whose quota it spends, and the accounting promise. Example wording: "Allow modlens to reuse your signed-in Codex CLI for image reads? Every reused read is labeled in the result so you always see whose quota was spent."
29
29
  - The do-nothing outcome must be safe and stated: "If you skip this, modlens just uses the engines you configure yourself."
30
- - If the user offers an API key, take exactly that key; never go looking for keys they did not hand over.
30
+ - If the user offers an API key, take exactly that key, and never go looking for keys they did not hand over.
31
31
 
32
32
  ## 4. Apply only what was consented to
33
33
 
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.10.0
11
+ - Pinned CLI version: 3.11.0
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.10.0'
27
+ $Pinned = '3.11.0'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.10.0"
25
+ PINNED="3.11.0"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"