@liustack/modlens 3.5.1 → 3.7.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.7.0 - 2026-08-13
4
+
5
+ - An image-extension path is now the skill's primary trigger. Transcript forensics on a Claude Code cli session explained why the skill loaded but never fired for a text-only model there: the harness silently swaps a pasted image for a usable `[Image: source: <path>]` line, so no failure ever pushes the model to consult its skill list, and a path in hand makes hand-rolled OCR the path of least resistance (the same model behind OpenCode, where the gateway error "model does not support image input" is loud, found and followed this skill exactly). The description now keys on what the model perceives first: any file path or URL ending in an image extension (.png, .jpg, .jpeg, .webp, .gif, .bmp, .heic) it cannot see behind is a hard trigger, with self-built OCR, PIL, and tesseract explicitly forbidden. Placeholder cues (`[Image #1]`, `[Unsupported Image]`) remain as the second tier.
6
+ - First slice of auto mode (borrowing local harness vision, design in progress): a read-only discovery module probes the four supported harnesses without spending anything, and `doctor` grew an Auto section showing what it found. claude on PATH counts as borrowable (Anthropic's lineup is all multimodal), the codex model catalog is judged by its own `input_modalities`, pi's models-store is crossed with its stored credentials so only borrowable vision counts, and one `opencode models` listing is judged by a builtin vision-pattern table that harness metadata outranks. Results cache to `~/.modlens/auto-cache.json` (6h TTL, doctor always probes fresh). The `auto` config switch parses as a strict boolean and defaults to off; nothing routes through these results yet, so behavior with the switch off (or on) is unchanged from 3.6.0.
7
+
8
+ ## 3.6.0 - 2026-08-13
9
+
10
+ - The skill now triggers on what a text-only model can actually see. Field testing with DeepSeek behind an Anthropic-compatible gateway showed the old description asking the model to judge "can I see images", the exact self-assessment that fails when a gateway strips images silently, so the skill loaded but never fired. The description now keys on visible evidence: placeholders like `[Image #1]` and `[Unsupported Image]` trigger with a guard check as backup, and a `[Image: source: <path>]` line with no visible image content is a hard trigger, because that line means the harness stored the pasted image on disk and did not deliver it. Newer Claude Code builds write pastes to `~/.claude/image-cache/<session>/` and inject that line from the cli entrypoint (all models get it, vision models also get the real image and are immune to the trigger by the no-visible-content condition; the VSCode entrypoint injects nothing). The skill reads that path directly when it is alive, falls back to `recover-paste` when it is not, and never deletes Claude Code's own cache files.
11
+ - `guards.allowModels`: the guard gains an allowlist mode for the world where most models are multimodal and the text-only ones are the short list. Non-empty means only listed models run the engine and every other identified model is denied. Deny patterns win over allow matches, so a broad allow can have vision variants carved out (`allowModels: ["glm-5.*"]`, `denyModels: ["glm-*v*"]`), and the unknown-model policy is unchanged (fail open unless `denyWhenUnknown`). `config set guards.allowModels` takes a JSON array or comma list, `doctor` reports both lists and flags allowlist mode, the analyze fast gate also refuses an explicit `MODLENS_MODEL` that is off the list, and `configure.md` documents tightly anchored patterns (`deepseek-v4-*`, not `deepseek*`) so a vendor's next multimodal generation falls off the list instead of into it. Configure by what actually reaches the model, not by what it could see: a multimodal model behind an image-stripping gateway still needs modlens.
12
+
3
13
  ## 3.5.1 - 2026-08-13
4
14
 
5
15
  - `file://` inputs now resolve through Node's `fileURLToPath` instead of hand-stripping the prefix (issue #16). The old unwrap left a leading slash in front of Windows drive letters, so `file:///C:/Temp/shot.png` could resolve against the current working drive as `E:\C:\Temp\shot.png`, and `decodeURI` left reserved escapes such as the `%23` in a `#` filename undecoded. A URL produced by `pathToFileURL()` now round-trips back to the original local path, and a malformed file URL fails with Node's clear error instead of silently resolving to a wrong path. Thanks to @BruceWae for the report and a validated fix branch.
package/dist/main.js CHANGED
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import * as childProcess from "child_process";
6
- import { spawn } from "child_process";
6
+ import { spawn, execFileSync } from "child_process";
7
7
  import * as os from "os";
8
8
  import { fileURLToPath } from "url";
9
9
  import require$$0$1 from "node:assert";
@@ -28149,13 +28149,19 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28149
28149
  const config2 = loadConfigFile(configPath);
28150
28150
  if (dottedKey === "provider") {
28151
28151
  config2.provider = value;
28152
+ } else if (dottedKey === "auto") {
28153
+ const normalized = value.trim().toLowerCase();
28154
+ if (normalized !== "true" && normalized !== "false") {
28155
+ throw new Error("auto must be true or false.");
28156
+ }
28157
+ config2.auto = normalized === "true";
28152
28158
  } else if (dottedKey.startsWith("guards.")) {
28153
28159
  setGuardsValue(config2, dottedKey.slice("guards.".length), value);
28154
28160
  } else {
28155
28161
  const dot = dottedKey.indexOf(".");
28156
28162
  if (dot <= 0 || dot === dottedKey.length - 1) {
28157
28163
  throw new Error(
28158
- `Invalid config key: ${dottedKey}. Use "provider", "guards.<denyModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28164
+ `Invalid config key: ${dottedKey}. Use "provider", "auto", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28159
28165
  );
28160
28166
  }
28161
28167
  const providerName = dottedKey.slice(0, dot);
@@ -28190,12 +28196,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28190
28196
  }
28191
28197
  }
28192
28198
  function setGuardsValue(config2, field, value) {
28193
- if (field === "denyModels") {
28199
+ if (field === "denyModels" || field === "allowModels") {
28194
28200
  if (value.trim() === "") {
28195
- delete config2.guards?.denyModels;
28201
+ delete config2.guards?.[field];
28196
28202
  } else {
28197
28203
  config2.guards ??= {};
28198
- config2.guards.denyModels = parseModelList(value);
28204
+ config2.guards[field] = parseModelList(value, `guards.${field}`);
28199
28205
  }
28200
28206
  } else if (field === "denyWhenUnknown") {
28201
28207
  const normalized = value.trim().toLowerCase();
@@ -28205,17 +28211,19 @@ function setGuardsValue(config2, field, value) {
28205
28211
  config2.guards ??= {};
28206
28212
  config2.guards.denyWhenUnknown = normalized === "true";
28207
28213
  } else {
28208
- throw new Error(`Unknown guards field: ${field}. Use denyModels or denyWhenUnknown.`);
28214
+ throw new Error(
28215
+ `Unknown guards field: ${field}. Use denyModels, allowModels, or denyWhenUnknown.`
28216
+ );
28209
28217
  }
28210
28218
  if (config2.guards && Object.keys(config2.guards).length === 0) {
28211
28219
  delete config2.guards;
28212
28220
  }
28213
28221
  }
28214
- function parseModelList(value) {
28222
+ function parseModelList(value, key) {
28215
28223
  if (value.trim().startsWith("[")) {
28216
- const parsed = parseJsonOrExplain(value, "guards.denyModels");
28224
+ const parsed = parseJsonOrExplain(value, key);
28217
28225
  if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
28218
- throw new Error("guards.denyModels must be a JSON array of glob strings.");
28226
+ throw new Error(`${key} must be a JSON array of glob strings.`);
28219
28227
  }
28220
28228
  return parsed;
28221
28229
  }
@@ -28655,6 +28663,286 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
28655
28663
  child.on("close", (code) => settle(code));
28656
28664
  });
28657
28665
  }
28666
+ function denyPatterns(guards) {
28667
+ return stringPatterns(guards?.denyModels);
28668
+ }
28669
+ function allowPatterns(guards) {
28670
+ return stringPatterns(guards?.allowModels);
28671
+ }
28672
+ function stringPatterns(raw) {
28673
+ if (!Array.isArray(raw)) {
28674
+ return [];
28675
+ }
28676
+ return raw.filter((pattern) => typeof pattern === "string");
28677
+ }
28678
+ function globMatch(pattern, value) {
28679
+ const regex = pattern.split(/([*?])/).map((part) => {
28680
+ if (part === "*") {
28681
+ return ".*";
28682
+ }
28683
+ if (part === "?") {
28684
+ return ".";
28685
+ }
28686
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28687
+ }).join("");
28688
+ return new RegExp(`^${regex}$`, "i").test(value);
28689
+ }
28690
+ function evaluateGuard(guards, detection) {
28691
+ const deny = denyPatterns(guards);
28692
+ const allow = allowPatterns(guards);
28693
+ if (!detection.model) {
28694
+ if (guards?.denyWhenUnknown === true) {
28695
+ return {
28696
+ ...detection,
28697
+ guard: "deny",
28698
+ reason: "model unknown and denyWhenUnknown is set"
28699
+ };
28700
+ }
28701
+ return {
28702
+ ...detection,
28703
+ guard: "allow",
28704
+ reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
28705
+ };
28706
+ }
28707
+ if (deny.length === 0 && allow.length === 0) {
28708
+ return { ...detection, guard: "allow", reason: "no deny rules configured" };
28709
+ }
28710
+ const candidates = [detection.model];
28711
+ if (detection.provider) {
28712
+ candidates.push(`${detection.provider}/${detection.model}`);
28713
+ }
28714
+ const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
28715
+ const denied = firstMatch(deny);
28716
+ if (denied) {
28717
+ return {
28718
+ ...detection,
28719
+ guard: "deny",
28720
+ matched: denied,
28721
+ reason: "model has native vision per guards.denyModels"
28722
+ };
28723
+ }
28724
+ if (allow.length > 0) {
28725
+ const allowed = firstMatch(allow);
28726
+ if (allowed) {
28727
+ return {
28728
+ ...detection,
28729
+ guard: "allow",
28730
+ matched: allowed,
28731
+ reason: "model is on guards.allowModels"
28732
+ };
28733
+ }
28734
+ return {
28735
+ ...detection,
28736
+ guard: "deny",
28737
+ reason: "not on guards.allowModels: only listed models run the engine"
28738
+ };
28739
+ }
28740
+ return { ...detection, guard: "allow", reason: "not on the deny list" };
28741
+ }
28742
+ const VISION_MODEL_PATTERNS = [
28743
+ "claude-*",
28744
+ "gpt-4o*",
28745
+ "gpt-4.1*",
28746
+ "gpt-5*",
28747
+ "o3*",
28748
+ "o4*",
28749
+ "gemini-*",
28750
+ "glm-*v*",
28751
+ "qwen*-vl*",
28752
+ "qwen3.5-plus*",
28753
+ "qwen3.6-plus*",
28754
+ "kimi-k2.5*",
28755
+ "kimi-k2.6*",
28756
+ "kimi-k2.7*",
28757
+ "kimi-k3*",
28758
+ "moonshot-v1-*vision*",
28759
+ "minimax-vl*",
28760
+ "minimax-m3*",
28761
+ "deepseek-vl*",
28762
+ "deepseek-ocr*",
28763
+ "janus*",
28764
+ "pixtral*",
28765
+ "llama-4*",
28766
+ "llama-3.2-*vision*",
28767
+ "grok-4*",
28768
+ "grok-2-vision*",
28769
+ "internvl*"
28770
+ ];
28771
+ function isVisionModel(modelId) {
28772
+ const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId;
28773
+ return VISION_MODEL_PATTERNS.some((pattern) => globMatch(pattern, bare));
28774
+ }
28775
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
28776
+ const CLI_TIMEOUT_MS = 1e4;
28777
+ function defaultRunCli(bin, args, timeoutMs) {
28778
+ return execFileSync(bin, args, { encoding: "utf-8", timeout: timeoutMs, stdio: "pipe" });
28779
+ }
28780
+ function timed(run) {
28781
+ const start = Date.now();
28782
+ const probe = run();
28783
+ return { ...probe, elapsedMs: Date.now() - start };
28784
+ }
28785
+ function readJson(filePath) {
28786
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
28787
+ }
28788
+ function probeClaude(env) {
28789
+ return timed(() => {
28790
+ const cliPath = findOnPath("claude", env);
28791
+ if (!cliPath) {
28792
+ return {
28793
+ harness: "claude-code",
28794
+ cliFound: false,
28795
+ visionModels: [],
28796
+ source: "none"
28797
+ };
28798
+ }
28799
+ return {
28800
+ harness: "claude-code",
28801
+ cliFound: true,
28802
+ cliPath,
28803
+ visionModels: ["anthropic/* (all current models)"],
28804
+ source: "builtin-table"
28805
+ };
28806
+ });
28807
+ }
28808
+ function probeCodex(env, home) {
28809
+ return timed(() => {
28810
+ const cliPath = findOnPath("codex", env);
28811
+ const base = { harness: "codex", cliFound: cliPath !== null };
28812
+ if (!cliPath) {
28813
+ return { ...base, visionModels: [], source: "none" };
28814
+ }
28815
+ const codexHome = path.join(home, ".codex");
28816
+ const loggedIn = fs.existsSync(path.join(codexHome, "auth.json"));
28817
+ try {
28818
+ const toml = fs.readFileSync(path.join(codexHome, "config.toml"), "utf-8");
28819
+ const catalogPath = toml.match(/^model_catalog_json\s*=\s*"([^"]+)"/m)?.[1];
28820
+ if (!catalogPath) {
28821
+ return { ...base, cliPath, loggedIn, visionModels: [], source: "none" };
28822
+ }
28823
+ const catalog = readJson(catalogPath);
28824
+ const vision = (catalog.models ?? []).filter((m) => m.slug && (m.input_modalities ?? []).includes("image")).map((m) => m.slug);
28825
+ return {
28826
+ ...base,
28827
+ cliPath,
28828
+ loggedIn,
28829
+ visionModels: vision,
28830
+ source: "metadata"
28831
+ };
28832
+ } catch (error) {
28833
+ return {
28834
+ ...base,
28835
+ cliPath,
28836
+ loggedIn,
28837
+ visionModels: [],
28838
+ source: "none",
28839
+ error: error instanceof Error ? error.message : String(error)
28840
+ };
28841
+ }
28842
+ });
28843
+ }
28844
+ function probePi(env, home) {
28845
+ return timed(() => {
28846
+ const cliPath = findOnPath("pi", env);
28847
+ const base = { harness: "pi", cliFound: cliPath !== null };
28848
+ if (!cliPath) {
28849
+ return { ...base, visionModels: [], source: "none" };
28850
+ }
28851
+ const agentDir = path.join(home, ".pi", "agent");
28852
+ try {
28853
+ const auth = readJson(path.join(agentDir, "auth.json"));
28854
+ const providersWithCreds = new Set(Object.keys(auth));
28855
+ const store = readJson(path.join(agentDir, "models-store.json"));
28856
+ const vision = [];
28857
+ for (const entry of Object.values(store)) {
28858
+ for (const model of entry?.models ?? []) {
28859
+ if (model.id && (model.input ?? []).includes("image") && model.provider && providersWithCreds.has(model.provider)) {
28860
+ vision.push(model.id);
28861
+ }
28862
+ }
28863
+ }
28864
+ return {
28865
+ ...base,
28866
+ cliPath,
28867
+ loggedIn: providersWithCreds.size > 0,
28868
+ visionModels: vision,
28869
+ source: "metadata"
28870
+ };
28871
+ } catch (error) {
28872
+ return {
28873
+ ...base,
28874
+ cliPath,
28875
+ visionModels: [],
28876
+ source: "none",
28877
+ error: error instanceof Error ? error.message : String(error)
28878
+ };
28879
+ }
28880
+ });
28881
+ }
28882
+ function probeOpencode(env, runCli) {
28883
+ return timed(() => {
28884
+ const cliPath = findOnPath("opencode", env);
28885
+ const base = { harness: "opencode", cliFound: cliPath !== null };
28886
+ if (!cliPath) {
28887
+ return { ...base, visionModels: [], source: "none" };
28888
+ }
28889
+ try {
28890
+ const listing = runCli(cliPath, ["models"], CLI_TIMEOUT_MS);
28891
+ const vision = listing.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && isVisionModel(line));
28892
+ return { ...base, cliPath, visionModels: vision, source: "builtin-table" };
28893
+ } catch (error) {
28894
+ return {
28895
+ ...base,
28896
+ cliPath,
28897
+ visionModels: [],
28898
+ source: "none",
28899
+ error: error instanceof Error ? error.message : String(error)
28900
+ };
28901
+ }
28902
+ });
28903
+ }
28904
+ function readCache(cachePath, ttlMs) {
28905
+ try {
28906
+ const cached = readJson(cachePath);
28907
+ if (!cached.cachedAt || !Array.isArray(cached.probes)) {
28908
+ return null;
28909
+ }
28910
+ if (Date.now() - Date.parse(cached.cachedAt) > ttlMs) {
28911
+ return null;
28912
+ }
28913
+ return cached;
28914
+ } catch {
28915
+ return null;
28916
+ }
28917
+ }
28918
+ function discoverAuto(options = {}) {
28919
+ const env = options.env ?? process.env;
28920
+ const home = options.home ?? os.homedir();
28921
+ const cachePath = options.cachePath ?? path.join(home, ".modlens", "auto-cache.json");
28922
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
28923
+ if (!options.fresh) {
28924
+ const cached = readCache(cachePath, ttlMs);
28925
+ if (cached) {
28926
+ return { probes: cached.probes, cachedAt: cached.cachedAt, fromCache: true };
28927
+ }
28928
+ }
28929
+ const runCli = options.runCli ?? defaultRunCli;
28930
+ const probes = [
28931
+ probeClaude(env),
28932
+ probeCodex(env, home),
28933
+ probeOpencode(env, runCli),
28934
+ probePi(env, home)
28935
+ ];
28936
+ const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
28937
+ try {
28938
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
28939
+ fs.writeFileSync(cachePath, JSON.stringify({ cachedAt, probes }, null, 2), {
28940
+ mode: 384
28941
+ });
28942
+ } catch {
28943
+ }
28944
+ return { probes, cachedAt, fromCache: false };
28945
+ }
28658
28946
  const HARNESS_BY_BASENAME = {
28659
28947
  claude: "claude-code",
28660
28948
  "claude-code": "claude-code",
@@ -29257,60 +29545,6 @@ function sniffModel(harness, cwd, env, roots = {}) {
29257
29545
  return null;
29258
29546
  }
29259
29547
  }
29260
- function denyPatterns(guards) {
29261
- const raw = guards?.denyModels;
29262
- if (!Array.isArray(raw)) {
29263
- return [];
29264
- }
29265
- return raw.filter((pattern) => typeof pattern === "string");
29266
- }
29267
- function globMatch(pattern, value) {
29268
- const regex = pattern.split(/([*?])/).map((part) => {
29269
- if (part === "*") {
29270
- return ".*";
29271
- }
29272
- if (part === "?") {
29273
- return ".";
29274
- }
29275
- return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29276
- }).join("");
29277
- return new RegExp(`^${regex}$`, "i").test(value);
29278
- }
29279
- function evaluateGuard(guards, detection) {
29280
- const patterns = denyPatterns(guards);
29281
- if (!detection.model) {
29282
- if (guards?.denyWhenUnknown === true) {
29283
- return {
29284
- ...detection,
29285
- guard: "deny",
29286
- reason: "model unknown and denyWhenUnknown is set"
29287
- };
29288
- }
29289
- return {
29290
- ...detection,
29291
- guard: "allow",
29292
- reason: patterns.length === 0 ? "no deny rules configured" : "model unknown, failing open"
29293
- };
29294
- }
29295
- if (patterns.length === 0) {
29296
- return { ...detection, guard: "allow", reason: "no deny rules configured" };
29297
- }
29298
- const candidates = [detection.model];
29299
- if (detection.provider) {
29300
- candidates.push(`${detection.provider}/${detection.model}`);
29301
- }
29302
- for (const pattern of patterns) {
29303
- if (candidates.some((candidate) => globMatch(pattern, candidate))) {
29304
- return {
29305
- ...detection,
29306
- guard: "deny",
29307
- matched: pattern,
29308
- reason: "model has native vision per guards.denyModels"
29309
- };
29310
- }
29311
- }
29312
- return { ...detection, guard: "allow", reason: "not on the deny list" };
29313
- }
29314
29548
  function detectActiveModel(options) {
29315
29549
  const env = options.env ?? process.env;
29316
29550
  const envModel = env.MODLENS_MODEL?.trim();
@@ -29340,7 +29574,7 @@ function detectActiveModel(options) {
29340
29574
  return { model: null, source: "none", harness };
29341
29575
  }
29342
29576
  function runGuard(guards, options) {
29343
- if (denyPatterns(guards).length === 0 && guards?.denyWhenUnknown !== true) {
29577
+ if (denyPatterns(guards).length === 0 && allowPatterns(guards).length === 0 && guards?.denyWhenUnknown !== true) {
29344
29578
  return { model: null, source: "none", guard: "allow", reason: "no deny rules configured" };
29345
29579
  }
29346
29580
  return evaluateGuard(guards, detectActiveModel(options));
@@ -29481,6 +29715,7 @@ function buildDoctorReport(input) {
29481
29715
  harness: { detected: harnessDetection.harness, source: harnessDetection.source },
29482
29716
  guard: {
29483
29717
  rules: denyPatterns(input.config.guards).length,
29718
+ allowRules: allowPatterns(input.config.guards).length,
29484
29719
  denyWhenUnknown: input.config.guards?.denyWhenUnknown ?? false,
29485
29720
  model: guardVerdict.model,
29486
29721
  source: guardVerdict.source,
@@ -29488,7 +29723,13 @@ function buildDoctorReport(input) {
29488
29723
  matched: guardVerdict.matched,
29489
29724
  reason: guardVerdict.reason
29490
29725
  },
29491
- config: inspectConfigFile(configPath)
29726
+ config: inspectConfigFile(configPath),
29727
+ // doctor is the "what would auto find" view, so it always probes fresh
29728
+ // (and rewrites the cache); regular runs will read the cache instead.
29729
+ auto: {
29730
+ enabled: input.config.auto === true,
29731
+ probes: discoverAuto({ env, fresh: true, ...input.auto }).probes
29732
+ }
29492
29733
  };
29493
29734
  }
29494
29735
  function mark(ok) {
@@ -29530,13 +29771,37 @@ function renderDoctorReport(report) {
29530
29771
  lines.push("");
29531
29772
  lines.push("Guard (should the vision engine run for the active model?)");
29532
29773
  lines.push(
29533
- ` rules: ${report.guard.rules} deny pattern(s), denyWhenUnknown: ${report.guard.denyWhenUnknown}`
29774
+ ` rules: ${report.guard.rules} deny pattern(s), ${report.guard.allowRules} allow pattern(s)${report.guard.allowRules > 0 ? " (allowlist mode)" : ""}, denyWhenUnknown: ${report.guard.denyWhenUnknown}`
29534
29775
  );
29535
29776
  lines.push(` active model: ${report.guard.model ?? "unknown"} (via ${report.guard.source})`);
29536
29777
  lines.push(
29537
29778
  ` verdict: ${report.guard.verdict}${report.guard.matched ? ` (matched "${report.guard.matched}")` : ""}, ${report.guard.reason}`
29538
29779
  );
29539
29780
  lines.push("");
29781
+ lines.push("Auto (borrowable local harness vision; off by default)");
29782
+ lines.push(
29783
+ ` enabled: ${report.auto.enabled}${report.auto.enabled ? "" : " (turn on: modlens config set auto true)"}`
29784
+ );
29785
+ for (const probe of report.auto.probes) {
29786
+ if (!probe.cliFound) {
29787
+ lines.push(` ${probe.harness}: cli not found`);
29788
+ continue;
29789
+ }
29790
+ const parts = [];
29791
+ const shown = probe.visionModels.slice(0, 3).join(", ");
29792
+ parts.push(
29793
+ probe.visionModels.length === 0 ? "no vision models" : `${probe.visionModels.length} vision model(s): ${shown}${probe.visionModels.length > 3 ? ", ..." : ""}`
29794
+ );
29795
+ if (probe.loggedIn !== void 0) {
29796
+ parts.push(probe.loggedIn ? "logged in" : "no credentials found");
29797
+ }
29798
+ parts.push(`via ${probe.source}, ${probe.elapsedMs}ms`);
29799
+ if (probe.error) {
29800
+ parts.push(`error: ${probe.error}`);
29801
+ }
29802
+ lines.push(` ${probe.harness}: ${parts.join(", ")}`);
29803
+ }
29804
+ lines.push("");
29540
29805
  lines.push("Config file");
29541
29806
  lines.push(` path: ${report.config.path}`);
29542
29807
  if (report.config.exists) {
@@ -29729,7 +29994,7 @@ function recoverPastedImages(options = {}) {
29729
29994
  return result;
29730
29995
  }
29731
29996
  const program = new Command();
29732
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.5.1");
29997
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.7.0");
29733
29998
  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(
29734
29999
  "--extra-body <json>",
29735
30000
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -29745,9 +30010,10 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
29745
30010
  cwd: process.cwd(),
29746
30011
  env: process.env
29747
30012
  });
29748
- if (verdict.guard === "deny" && verdict.matched) {
30013
+ if (verdict.guard === "deny" && verdict.model) {
30014
+ const cause = verdict.matched ? `matches guards.denyModels pattern "${verdict.matched}". A model with native vision should read the image itself.` : "is not on guards.allowModels, which only lets listed models run the engine.";
29749
30015
  throw new Error(
29750
- `Invocation guard denied this read: active model "${verdict.model}" matches guards.denyModels pattern "${verdict.matched}". A model with native vision should read the image itself. To override, unset MODLENS_MODEL or edit guards in ${CONFIG_PATH}.`
30016
+ `Invocation guard denied this read: active model "${verdict.model}" ${cause} To override, unset MODLENS_MODEL or edit guards in ${CONFIG_PATH}.`
29751
30017
  );
29752
30018
  }
29753
30019
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.5.1",
3
+ "version": "3.7.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: modlens
3
- description: "Plug-in vision for text-only models. Use whenever the user shares an image (local path, screenshot, photo, chart, document scan, or image URL) and the active model cannot see images or has no vision tool. Before the first read of a session, run `modlens guard`: a deny verdict means the active model has native vision and must read the image itself, not through this skill. 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, .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)."
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
  ---
@@ -9,7 +9,8 @@ allowed-tools: Bash
9
9
 
10
10
  Use this skill when:
11
11
 
12
- - The user provides an image path or image URL and asks anything about it
12
+ - A file path or URL ending in an image extension (`.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.bmp`, `.heic`) appears and you cannot see the image behind it. The path alone is the trigger: hand it to modlens first, do not Read the bytes yourself, do not build your own OCR out of PIL or tesseract
13
+ - A pasted image reaches you only as a placeholder: `[Image #1]`, `[Unsupported Image]`, a `[Image: source: <path>]` line, or an attachment whose content you cannot see
13
14
  - The active model has no native vision (text-only model in a coding agent)
14
15
  - You need the text inside an image, its layout, or a chart's structure as evidence before reasoning
15
16
  - The user asks how to configure modlens, get an API key for it, or switch its provider: follow `references/configure.md` and run the commands for them
@@ -33,11 +34,11 @@ The launcher finds a working way to run modlens and forwards your arguments to i
33
34
 
34
35
  ### If you cannot run the launcher script
35
36
 
36
- Some harnesses forbid running scripts. Reason through the same order by hand and run the first line that works (the pinned version is 3.5.1):
37
+ Some harnesses forbid running scripts. Reason through the same order by hand and run the first line that works (the pinned version is 3.7.0):
37
38
 
38
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.5.1: `modlens <args>`.
39
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.5.1 modlens <args>`.
40
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.5.1 <args>`.
39
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.7.0: `modlens <args>`.
40
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.7.0 modlens <args>`.
41
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.7.0 <args>`.
41
42
  4. Otherwise none of these runtimes is here. 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.
42
43
 
43
44
  `references/runtime.md` documents the version pin, the compatibility rule, and the diagnostic fields.
@@ -71,14 +72,15 @@ modlens guard --model <your-model-id>
71
72
  Pass `--model` with your own model id when you know it (most harnesses state it in your system prompt). Never pass a guess. The verdict weighs three signals, strongest first: the `MODLENS_MODEL` env var, the harness's own session storage (it records the model on every assistant turn, so it outranks your self-report), then your `--model` value.
72
73
 
73
74
  - `{"guard": "allow"}` (exit 0): proceed with the read.
74
- - `{"guard": "deny"}` (exit 1) **with a `matched` field**: do not run the engine. The active model is on the user's own deny list of vision-capable models: read the image with your native vision instead.
75
- - `{"guard": "deny"}` (exit 1) **without `matched`**: the model could not be identified and the user set `denyWhenUnknown`. Do not run the engine, and do not pretend to see the image either. Tell the user the guard could not identify the active model and that `MODLENS_MODEL=<model>` (or `MODLENS_MODEL=none` after fixing the guards config) unblocks it.
75
+ - `{"guard": "deny"}` (exit 1) **with a `model` identified**: do not run the engine. Either the model matched the user's deny list of vision-capable models (a `matched` field names the pattern), or the user runs an allow list of text-only models and this model is not on it. Read the image with your native vision instead.
76
+ - `{"guard": "deny"}` (exit 1) **with `model: null`**: the model could not be identified and the user set `denyWhenUnknown`. Do not run the engine, and do not pretend to see the image either. Tell the user the guard could not identify the active model and that `MODLENS_MODEL=<model>` (or `MODLENS_MODEL=none` after fixing the guards config) unblocks it.
76
77
  - Exit 2 is an error: the guard fails open, report the error and proceed.
77
78
 
78
- One check per session is enough, unless the user switches models mid-session: the verdict follows the model, so re-run the guard after a switch. Users enable this with glob patterns of vision-capable model names, and `modlens doctor` shows the rules plus a live evaluation in its Guard section:
79
+ One check per session is enough, unless the user switches models mid-session: the verdict follows the model, so re-run the guard after a switch. Users configure it with glob patterns either way round, a deny list of vision models or an allow list of text-only models (deny wins on overlap, so a vision variant can be carved out of a broad allow). `modlens doctor` shows the rules plus a live evaluation in its Guard section:
79
80
 
80
81
  ```bash
81
- modlens config set guards.denyModels '["gemini-3*", "qwen-vl-*"]'
82
+ modlens config set guards.allowModels '["deepseek-v4-*", "glm-5.*"]' # only these run the engine
83
+ modlens config set guards.denyModels '["glm-*v*", "qwen-vl-*"]' # never these
82
84
  modlens config set guards.denyWhenUnknown true # optional, default false (fail open)
83
85
  ```
84
86
 
@@ -110,9 +112,15 @@ Harnesses rarely hand you a clean path. First identify which harness you are in,
110
112
 
111
113
  - Extract the `path` value from the tag and run modlens on it. Pasted images live in a temp file Codex already created; a stripped image keeps its path tag next to the placeholder. Do NOT use `recover-paste` here: it detects Codex and refuses with this same guidance.
112
114
 
113
- **Claude Code, Pi, or OpenCode** (no path tag anywhere; the image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see):
115
+ **Claude Code with a `[Image: source: <path>]` line in the conversation**:
114
116
 
115
- - None of these harnesses writes pasted images to a regular temp file, but all of them persist user messages locally before any gateway strips them: 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 if the launcher resolved to bunx, OpenCode recovery needs a real Node install). Run `modlens recover-paste` from the project directory the conversation is happening in (add `--count <n>` for several images). It detects which harness it is running inside (process ancestry, then env fingerprints) and reads ONLY that harness's storage, so another tool's old sessions cannot leak in. In Claude Code it also targets your exact session automatically via the injected CLAUDE_CODE_SESSION_ID; `--session <id>` (e.g. from the ${CLAUDE_SESSION_ID} substitution) is only needed to override.
117
+ - Newer Claude Code builds write every pasted image to `~/.claude/image-cache/<session-id>/` and, in the terminal (`cli`) entrypoint, inject that line as a user message. This is undocumented internal behavior (observed on 2.1.201 through 2.1.229; the VSCode and desktop entrypoints do not inject it), so treat it as a shortcut, not a guarantee.
118
+ - If the file at that path exists, run modlens on it directly and skip `recover-paste` entirely. The file is Claude Code's own cache: read it, never delete or move it.
119
+ - If the path is gone (the cache is cleaned after a while) or there is no such line, fall through to the next branch.
120
+
121
+ **Claude Code, Pi, or OpenCode** (no usable path anywhere; the image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see):
122
+
123
+ - 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 if the launcher resolved to bunx, OpenCode recovery needs a real Node install). Run `modlens recover-paste` from the project directory the conversation is happening in (add `--count <n>` for several images). It detects which harness it is running inside (process ancestry, then env fingerprints) and reads ONLY that harness's storage, so another tool's old sessions cannot leak in. In Claude Code it also targets your exact session automatically via the injected CLAUDE_CODE_SESSION_ID; `--session <id>` (e.g. from the ${CLAUDE_SESSION_ID} substitution) is only needed to override.
116
124
  - The output is JSON with real file paths, ordered oldest to newest, so the LAST path is the user's most recent paste. Analyze that one first. Entries carry `filename` (the original attachment name) when the harness stored one; if the user's message or an error mentions a filename, match on it.
117
125
  - Run every command yourself: `recover-paste`, then `modlens -i <path>` on the recovered file, then answer from the JSON. Never ask the user to run modlens or to relay paths.
118
126
  - When the analysis is done, delete the recovered files: they are private copies of the user's pasted images sitting in the temp dir, and nothing cleans them up until the OS does. Remove the recovery output directory (each entry's `path` sits inside it), unless the user asked to keep the files.
@@ -23,7 +23,8 @@ Everything lives under three top-level keys, all optional. A missing file means
23
23
  {
24
24
  "provider": "gemini-api",
25
25
  "guards": {
26
- "denyModels": ["gemini-3*", "qwen-vl-*"],
26
+ "allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
27
+ "denyModels": ["glm-*v*", "deepseek-vl*"],
27
28
  "denyWhenUnknown": false
28
29
  },
29
30
  "providers": {
@@ -50,7 +51,11 @@ Field semantics:
50
51
  - `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `claude-code` for `claude-cli`). Empty or absent means `antigravity-cli`.
51
52
  - `providers.<name>.<field>`: four fields exist, `apiKey`, `baseUrl`, `model`, and `extraBody`. Every provider entry is optional, and every field inside it is optional. Alias keys are read too (settings saved under `gemini` are found when `gemini-api` resolves), with the canonical key winning on conflict.
52
53
  - `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and the schema enforcement are refused with an error naming the field. The two CLI providers take no request body, so a run on `antigravity-cli` or `claude-cli` ignores it and says so in `meta.warnings`.
53
- - `guards`: the invocation guard, for people who run both text-only and vision-capable models through the same client. `denyModels` is a list of glob patterns (`*` and `?`, case-insensitive, matched against the model name and `provider/model`): when the active model matches one, `modlens guard` answers deny and the skill must not run the engine. `denyWhenUnknown` (default `false`) decides what happens when no signal identifies the active model: `false` proceeds, `true` denies. Set with `modlens config set guards.denyModels '["gemini-3*"]'` (a JSON array or a comma-separated list) and `modlens config set guards.denyWhenUnknown true`. The active model is detected from, strongest first: the `MODLENS_MODEL` env var (`none` means "treat as unknown"), the harness's session storage, the `--model` self-report.
54
+ - `guards`: the invocation guard, for people who run both text-only and vision-capable models through the same client. Both lists hold glob patterns (`*` and `?`, case-insensitive, matched against the model name and `provider/model`), set with `modlens config set guards.denyModels '["gemini-3*"]'` or `guards.allowModels` (a JSON array or a comma-separated list, empty clears). Two ways to express the same intent, pick the shorter list:
55
+ - `denyModels` alone: everything runs the engine except the listed vision models. Right when text-only models are the majority of what you plug in.
56
+ - `allowModels` non-empty (allowlist mode): only the listed models run the engine, every other identified model is denied. Right for the actual 2026 landscape, where text-only models are the short list. A deny pattern still wins over an allow match, so a broad allow can have its vision variants carved out, as in the example above: `glm-5.*` allows the text line while `glm-*v*` catches `glm-5v-turbo`. Anchor allow patterns tightly (`deepseek-v4-*`, not `deepseek*`) so a vendor's next multimodal generation falls off the list and steps aside until you have checked it.
57
+ - List a model by what actually reaches it, not by what it could see: a multimodal model behind a gateway that strips images still needs modlens, and your session transcript records the model name the gateway reports. `modlens doctor`'s Guard section shows the rules and a live verdict for checking the result.
58
+ - `denyWhenUnknown` (default `false`) decides what happens when no signal identifies the active model, in either mode: `false` proceeds, `true` denies. The active model is detected from, strongest first: the `MODLENS_MODEL` env var (`none` means "treat as unknown"), the harness's session storage, the `--model` self-report.
54
59
  - Environment variables override the file for these bindings: `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`. Beyond those, modlens reads `MODLENS_HARNESS` (paste-recovery and guard scope), `MODLENS_MODEL` (guard override, see `guards`), and the fingerprints harnesses inject themselves, which pin the guard's storage lookup to the current session: `CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, plus the presence markers harness detection relies on (`CLAUDECODE`, `PI_CODING_AGENT`, `CODEX_SANDBOX`).
55
60
  - Unknown top-level keys and unknown provider names are ignored rather than rejected, so a typo fails quiet: run `modlens doctor` after hand-editing, it shows which file and env values are actually in effect.
56
61
 
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.5.1
11
+ - Pinned CLI version: 3.7.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.5.1'
27
+ $Pinned = '3.7.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.5.1"
25
+ PINNED="3.7.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"