@liustack/modlens 3.5.0 → 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 +9 -0
- package/dist/main.js +50 -24
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +19 -11
- package/skills/modlens/references/configure.md +7 -2
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
8
|
+
## 3.5.1 - 2026-08-13
|
|
9
|
+
|
|
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.
|
|
11
|
+
|
|
3
12
|
## 3.5.0 - 2026-08-12
|
|
4
13
|
|
|
5
14
|
- The CLI no longer prints a `node:sqlite` ExperimentalWarning on every start. Bundling undici had hoisted its lazy `require('node:sqlite')` (for a cache store nothing here uses) into a top-level import. The build now keeps that require a runtime call.
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as path from "path";
|
|
|
5
5
|
import * as childProcess from "child_process";
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
import * as os from "os";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
8
9
|
import require$$0$1 from "node:assert";
|
|
9
10
|
import require$$1 from "node:net";
|
|
10
11
|
import require$$2 from "node:http";
|
|
@@ -28154,7 +28155,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
28154
28155
|
const dot = dottedKey.indexOf(".");
|
|
28155
28156
|
if (dot <= 0 || dot === dottedKey.length - 1) {
|
|
28156
28157
|
throw new Error(
|
|
28157
|
-
`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>".`
|
|
28158
28159
|
);
|
|
28159
28160
|
}
|
|
28160
28161
|
const providerName = dottedKey.slice(0, dot);
|
|
@@ -28189,12 +28190,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
28189
28190
|
}
|
|
28190
28191
|
}
|
|
28191
28192
|
function setGuardsValue(config2, field, value) {
|
|
28192
|
-
if (field === "denyModels") {
|
|
28193
|
+
if (field === "denyModels" || field === "allowModels") {
|
|
28193
28194
|
if (value.trim() === "") {
|
|
28194
|
-
delete config2.guards?.
|
|
28195
|
+
delete config2.guards?.[field];
|
|
28195
28196
|
} else {
|
|
28196
28197
|
config2.guards ??= {};
|
|
28197
|
-
config2.guards
|
|
28198
|
+
config2.guards[field] = parseModelList(value, `guards.${field}`);
|
|
28198
28199
|
}
|
|
28199
28200
|
} else if (field === "denyWhenUnknown") {
|
|
28200
28201
|
const normalized = value.trim().toLowerCase();
|
|
@@ -28204,17 +28205,19 @@ function setGuardsValue(config2, field, value) {
|
|
|
28204
28205
|
config2.guards ??= {};
|
|
28205
28206
|
config2.guards.denyWhenUnknown = normalized === "true";
|
|
28206
28207
|
} else {
|
|
28207
|
-
throw new Error(
|
|
28208
|
+
throw new Error(
|
|
28209
|
+
`Unknown guards field: ${field}. Use denyModels, allowModels, or denyWhenUnknown.`
|
|
28210
|
+
);
|
|
28208
28211
|
}
|
|
28209
28212
|
if (config2.guards && Object.keys(config2.guards).length === 0) {
|
|
28210
28213
|
delete config2.guards;
|
|
28211
28214
|
}
|
|
28212
28215
|
}
|
|
28213
|
-
function parseModelList(value) {
|
|
28216
|
+
function parseModelList(value, key) {
|
|
28214
28217
|
if (value.trim().startsWith("[")) {
|
|
28215
|
-
const parsed = parseJsonOrExplain(value,
|
|
28218
|
+
const parsed = parseJsonOrExplain(value, key);
|
|
28216
28219
|
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
|
|
28217
|
-
throw new Error(
|
|
28220
|
+
throw new Error(`${key} must be a JSON array of glob strings.`);
|
|
28218
28221
|
}
|
|
28219
28222
|
return parsed;
|
|
28220
28223
|
}
|
|
@@ -28526,8 +28529,7 @@ function resolveInput(input) {
|
|
|
28526
28529
|
return { source: trimmed, kind: "remote" };
|
|
28527
28530
|
}
|
|
28528
28531
|
if (/^file:\/\//i.test(trimmed)) {
|
|
28529
|
-
|
|
28530
|
-
return { source: path.resolve(localPath), kind: "local" };
|
|
28532
|
+
return { source: path.resolve(fileURLToPath(trimmed)), kind: "local" };
|
|
28531
28533
|
}
|
|
28532
28534
|
return { source: path.resolve(trimmed), kind: "local" };
|
|
28533
28535
|
}
|
|
@@ -29258,7 +29260,12 @@ function sniffModel(harness, cwd, env, roots = {}) {
|
|
|
29258
29260
|
}
|
|
29259
29261
|
}
|
|
29260
29262
|
function denyPatterns(guards) {
|
|
29261
|
-
|
|
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
|
|
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:
|
|
29300
|
+
reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
|
|
29293
29301
|
};
|
|
29294
29302
|
}
|
|
29295
|
-
if (
|
|
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
|
-
|
|
29303
|
-
|
|
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: "
|
|
29307
|
-
matched:
|
|
29308
|
-
reason: "model
|
|
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.
|
|
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.
|
|
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}"
|
|
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
package/skills/modlens/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: modlens
|
|
3
|
-
description: "Plug-in vision for text-only models. Use whenever the user
|
|
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.
|
|
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.
|
|
39
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.
|
|
40
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.
|
|
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 `
|
|
75
|
-
- `{"guard": "deny"}` (exit 1) **
|
|
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
|
|
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.
|
|
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
|
|
115
|
+
**Claude Code with a `[Image: source: <path>]` line in the conversation**:
|
|
114
116
|
|
|
115
|
-
-
|
|
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
|
-
"
|
|
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.
|
|
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
|
|
|
@@ -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.
|
|
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.
|
|
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"
|