@liustack/modlens 3.5.1 → 3.6.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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.6.0 - 2026-08-13
4
+
5
+ - 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.
6
+ - `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.
7
+
3
8
  ## 3.5.1 - 2026-08-13
4
9
 
5
10
  - `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
@@ -28155,7 +28155,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28155
28155
  const dot = dottedKey.indexOf(".");
28156
28156
  if (dot <= 0 || dot === dottedKey.length - 1) {
28157
28157
  throw new Error(
28158
- `Invalid config key: ${dottedKey}. Use "provider", "guards.<denyModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28158
+ `Invalid config key: ${dottedKey}. Use "provider", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28159
28159
  );
28160
28160
  }
28161
28161
  const providerName = dottedKey.slice(0, dot);
@@ -28190,12 +28190,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28190
28190
  }
28191
28191
  }
28192
28192
  function setGuardsValue(config2, field, value) {
28193
- if (field === "denyModels") {
28193
+ if (field === "denyModels" || field === "allowModels") {
28194
28194
  if (value.trim() === "") {
28195
- delete config2.guards?.denyModels;
28195
+ delete config2.guards?.[field];
28196
28196
  } else {
28197
28197
  config2.guards ??= {};
28198
- config2.guards.denyModels = parseModelList(value);
28198
+ config2.guards[field] = parseModelList(value, `guards.${field}`);
28199
28199
  }
28200
28200
  } else if (field === "denyWhenUnknown") {
28201
28201
  const normalized = value.trim().toLowerCase();
@@ -28205,17 +28205,19 @@ function setGuardsValue(config2, field, value) {
28205
28205
  config2.guards ??= {};
28206
28206
  config2.guards.denyWhenUnknown = normalized === "true";
28207
28207
  } else {
28208
- throw new Error(`Unknown guards field: ${field}. Use denyModels or denyWhenUnknown.`);
28208
+ throw new Error(
28209
+ `Unknown guards field: ${field}. Use denyModels, allowModels, or denyWhenUnknown.`
28210
+ );
28209
28211
  }
28210
28212
  if (config2.guards && Object.keys(config2.guards).length === 0) {
28211
28213
  delete config2.guards;
28212
28214
  }
28213
28215
  }
28214
- function parseModelList(value) {
28216
+ function parseModelList(value, key) {
28215
28217
  if (value.trim().startsWith("[")) {
28216
- const parsed = parseJsonOrExplain(value, "guards.denyModels");
28218
+ const parsed = parseJsonOrExplain(value, key);
28217
28219
  if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
28218
- throw new Error("guards.denyModels must be a JSON array of glob strings.");
28220
+ throw new Error(`${key} must be a JSON array of glob strings.`);
28219
28221
  }
28220
28222
  return parsed;
28221
28223
  }
@@ -29258,7 +29260,12 @@ function sniffModel(harness, cwd, env, roots = {}) {
29258
29260
  }
29259
29261
  }
29260
29262
  function denyPatterns(guards) {
29261
- const raw = guards?.denyModels;
29263
+ return stringPatterns(guards?.denyModels);
29264
+ }
29265
+ function allowPatterns(guards) {
29266
+ return stringPatterns(guards?.allowModels);
29267
+ }
29268
+ function stringPatterns(raw) {
29262
29269
  if (!Array.isArray(raw)) {
29263
29270
  return [];
29264
29271
  }
@@ -29277,7 +29284,8 @@ function globMatch(pattern, value) {
29277
29284
  return new RegExp(`^${regex}$`, "i").test(value);
29278
29285
  }
29279
29286
  function evaluateGuard(guards, detection) {
29280
- const patterns = denyPatterns(guards);
29287
+ const deny = denyPatterns(guards);
29288
+ const allow = allowPatterns(guards);
29281
29289
  if (!detection.model) {
29282
29290
  if (guards?.denyWhenUnknown === true) {
29283
29291
  return {
@@ -29289,25 +29297,41 @@ function evaluateGuard(guards, detection) {
29289
29297
  return {
29290
29298
  ...detection,
29291
29299
  guard: "allow",
29292
- reason: patterns.length === 0 ? "no deny rules configured" : "model unknown, failing open"
29300
+ reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
29293
29301
  };
29294
29302
  }
29295
- if (patterns.length === 0) {
29303
+ if (deny.length === 0 && allow.length === 0) {
29296
29304
  return { ...detection, guard: "allow", reason: "no deny rules configured" };
29297
29305
  }
29298
29306
  const candidates = [detection.model];
29299
29307
  if (detection.provider) {
29300
29308
  candidates.push(`${detection.provider}/${detection.model}`);
29301
29309
  }
29302
- for (const pattern of patterns) {
29303
- if (candidates.some((candidate) => globMatch(pattern, candidate))) {
29310
+ const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
29311
+ const denied = firstMatch(deny);
29312
+ if (denied) {
29313
+ return {
29314
+ ...detection,
29315
+ guard: "deny",
29316
+ matched: denied,
29317
+ reason: "model has native vision per guards.denyModels"
29318
+ };
29319
+ }
29320
+ if (allow.length > 0) {
29321
+ const allowed = firstMatch(allow);
29322
+ if (allowed) {
29304
29323
  return {
29305
29324
  ...detection,
29306
- guard: "deny",
29307
- matched: pattern,
29308
- reason: "model has native vision per guards.denyModels"
29325
+ guard: "allow",
29326
+ matched: allowed,
29327
+ reason: "model is on guards.allowModels"
29309
29328
  };
29310
29329
  }
29330
+ return {
29331
+ ...detection,
29332
+ guard: "deny",
29333
+ reason: "not on guards.allowModels: only listed models run the engine"
29334
+ };
29311
29335
  }
29312
29336
  return { ...detection, guard: "allow", reason: "not on the deny list" };
29313
29337
  }
@@ -29340,7 +29364,7 @@ function detectActiveModel(options) {
29340
29364
  return { model: null, source: "none", harness };
29341
29365
  }
29342
29366
  function runGuard(guards, options) {
29343
- if (denyPatterns(guards).length === 0 && guards?.denyWhenUnknown !== true) {
29367
+ if (denyPatterns(guards).length === 0 && allowPatterns(guards).length === 0 && guards?.denyWhenUnknown !== true) {
29344
29368
  return { model: null, source: "none", guard: "allow", reason: "no deny rules configured" };
29345
29369
  }
29346
29370
  return evaluateGuard(guards, detectActiveModel(options));
@@ -29481,6 +29505,7 @@ function buildDoctorReport(input) {
29481
29505
  harness: { detected: harnessDetection.harness, source: harnessDetection.source },
29482
29506
  guard: {
29483
29507
  rules: denyPatterns(input.config.guards).length,
29508
+ allowRules: allowPatterns(input.config.guards).length,
29484
29509
  denyWhenUnknown: input.config.guards?.denyWhenUnknown ?? false,
29485
29510
  model: guardVerdict.model,
29486
29511
  source: guardVerdict.source,
@@ -29530,7 +29555,7 @@ function renderDoctorReport(report) {
29530
29555
  lines.push("");
29531
29556
  lines.push("Guard (should the vision engine run for the active model?)");
29532
29557
  lines.push(
29533
- ` rules: ${report.guard.rules} deny pattern(s), denyWhenUnknown: ${report.guard.denyWhenUnknown}`
29558
+ ` rules: ${report.guard.rules} deny pattern(s), ${report.guard.allowRules} allow pattern(s)${report.guard.allowRules > 0 ? " (allowlist mode)" : ""}, denyWhenUnknown: ${report.guard.denyWhenUnknown}`
29534
29559
  );
29535
29560
  lines.push(` active model: ${report.guard.model ?? "unknown"} (via ${report.guard.source})`);
29536
29561
  lines.push(
@@ -29729,7 +29754,7 @@ function recoverPastedImages(options = {}) {
29729
29754
  return result;
29730
29755
  }
29731
29756
  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");
29757
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.6.0");
29733
29758
  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
29759
  "--extra-body <json>",
29735
29760
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -29745,9 +29770,10 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
29745
29770
  cwd: process.cwd(),
29746
29771
  env: process.env
29747
29772
  });
29748
- if (verdict.guard === "deny" && verdict.matched) {
29773
+ if (verdict.guard === "deny" && verdict.model) {
29774
+ 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
29775
  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}.`
29776
+ `Invocation guard denied this read: active model "${verdict.model}" ${cause} To override, unset MODLENS_MODEL or edit guards in ${CONFIG_PATH}.`
29751
29777
  );
29752
29778
  }
29753
29779
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.5.1",
3
+ "version": "3.6.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. Use whenever an image is in play and you cannot see its content: the user gives an image path, screenshot, photo, chart, document scan, or image URL, or a pasted image appears only as a placeholder such as `[Image #1]`, `[Unsupported Image]`, or an attachment you cannot view. Hard rule: a `[Image: source: <path>]` line with no visible image content means the harness stored the pasted image at that path and did not deliver it to you; run modlens on that path directly. 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
  ---
@@ -10,6 +10,7 @@ allowed-tools: Bash
10
10
  Use this skill when:
11
11
 
12
12
  - The user provides an image path or image URL and asks anything about it
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.6.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.6.0: `modlens <args>`.
40
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.6.0 modlens <args>`.
41
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.6.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.6.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.6.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.6.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"