@liustack/modlens 3.6.0 → 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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 3.6.0 - 2026-08-13
|
|
4
9
|
|
|
5
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.
|
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|allowModels|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);
|
|
@@ -28657,6 +28663,286 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
28657
28663
|
child.on("close", (code) => settle(code));
|
|
28658
28664
|
});
|
|
28659
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
|
+
}
|
|
28660
28946
|
const HARNESS_BY_BASENAME = {
|
|
28661
28947
|
claude: "claude-code",
|
|
28662
28948
|
"claude-code": "claude-code",
|
|
@@ -29259,82 +29545,6 @@ function sniffModel(harness, cwd, env, roots = {}) {
|
|
|
29259
29545
|
return null;
|
|
29260
29546
|
}
|
|
29261
29547
|
}
|
|
29262
|
-
function denyPatterns(guards) {
|
|
29263
|
-
return stringPatterns(guards?.denyModels);
|
|
29264
|
-
}
|
|
29265
|
-
function allowPatterns(guards) {
|
|
29266
|
-
return stringPatterns(guards?.allowModels);
|
|
29267
|
-
}
|
|
29268
|
-
function stringPatterns(raw) {
|
|
29269
|
-
if (!Array.isArray(raw)) {
|
|
29270
|
-
return [];
|
|
29271
|
-
}
|
|
29272
|
-
return raw.filter((pattern) => typeof pattern === "string");
|
|
29273
|
-
}
|
|
29274
|
-
function globMatch(pattern, value) {
|
|
29275
|
-
const regex = pattern.split(/([*?])/).map((part) => {
|
|
29276
|
-
if (part === "*") {
|
|
29277
|
-
return ".*";
|
|
29278
|
-
}
|
|
29279
|
-
if (part === "?") {
|
|
29280
|
-
return ".";
|
|
29281
|
-
}
|
|
29282
|
-
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29283
|
-
}).join("");
|
|
29284
|
-
return new RegExp(`^${regex}$`, "i").test(value);
|
|
29285
|
-
}
|
|
29286
|
-
function evaluateGuard(guards, detection) {
|
|
29287
|
-
const deny = denyPatterns(guards);
|
|
29288
|
-
const allow = allowPatterns(guards);
|
|
29289
|
-
if (!detection.model) {
|
|
29290
|
-
if (guards?.denyWhenUnknown === true) {
|
|
29291
|
-
return {
|
|
29292
|
-
...detection,
|
|
29293
|
-
guard: "deny",
|
|
29294
|
-
reason: "model unknown and denyWhenUnknown is set"
|
|
29295
|
-
};
|
|
29296
|
-
}
|
|
29297
|
-
return {
|
|
29298
|
-
...detection,
|
|
29299
|
-
guard: "allow",
|
|
29300
|
-
reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
|
|
29301
|
-
};
|
|
29302
|
-
}
|
|
29303
|
-
if (deny.length === 0 && allow.length === 0) {
|
|
29304
|
-
return { ...detection, guard: "allow", reason: "no deny rules configured" };
|
|
29305
|
-
}
|
|
29306
|
-
const candidates = [detection.model];
|
|
29307
|
-
if (detection.provider) {
|
|
29308
|
-
candidates.push(`${detection.provider}/${detection.model}`);
|
|
29309
|
-
}
|
|
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) {
|
|
29323
|
-
return {
|
|
29324
|
-
...detection,
|
|
29325
|
-
guard: "allow",
|
|
29326
|
-
matched: allowed,
|
|
29327
|
-
reason: "model is on guards.allowModels"
|
|
29328
|
-
};
|
|
29329
|
-
}
|
|
29330
|
-
return {
|
|
29331
|
-
...detection,
|
|
29332
|
-
guard: "deny",
|
|
29333
|
-
reason: "not on guards.allowModels: only listed models run the engine"
|
|
29334
|
-
};
|
|
29335
|
-
}
|
|
29336
|
-
return { ...detection, guard: "allow", reason: "not on the deny list" };
|
|
29337
|
-
}
|
|
29338
29548
|
function detectActiveModel(options) {
|
|
29339
29549
|
const env = options.env ?? process.env;
|
|
29340
29550
|
const envModel = env.MODLENS_MODEL?.trim();
|
|
@@ -29513,7 +29723,13 @@ function buildDoctorReport(input) {
|
|
|
29513
29723
|
matched: guardVerdict.matched,
|
|
29514
29724
|
reason: guardVerdict.reason
|
|
29515
29725
|
},
|
|
29516
|
-
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
|
+
}
|
|
29517
29733
|
};
|
|
29518
29734
|
}
|
|
29519
29735
|
function mark(ok) {
|
|
@@ -29562,6 +29778,30 @@ function renderDoctorReport(report) {
|
|
|
29562
29778
|
` verdict: ${report.guard.verdict}${report.guard.matched ? ` (matched "${report.guard.matched}")` : ""}, ${report.guard.reason}`
|
|
29563
29779
|
);
|
|
29564
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("");
|
|
29565
29805
|
lines.push("Config file");
|
|
29566
29806
|
lines.push(` path: ${report.config.path}`);
|
|
29567
29807
|
if (report.config.exists) {
|
|
@@ -29754,7 +29994,7 @@ function recoverPastedImages(options = {}) {
|
|
|
29754
29994
|
return result;
|
|
29755
29995
|
}
|
|
29756
29996
|
const program = new Command();
|
|
29757
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.
|
|
29997
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.7.0");
|
|
29758
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(
|
|
29759
29999
|
"--extra-body <json>",
|
|
29760
30000
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
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.
|
|
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,7 @@ allowed-tools: Bash
|
|
|
9
9
|
|
|
10
10
|
Use this skill when:
|
|
11
11
|
|
|
12
|
-
-
|
|
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
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
|
|
14
14
|
- The active model has no native vision (text-only model in a coding agent)
|
|
15
15
|
- You need the text inside an image, its layout, or a chart's structure as evidence before reasoning
|
|
@@ -34,11 +34,11 @@ The launcher finds a working way to run modlens and forwards your arguments to i
|
|
|
34
34
|
|
|
35
35
|
### If you cannot run the launcher script
|
|
36
36
|
|
|
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.
|
|
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):
|
|
38
38
|
|
|
39
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.
|
|
40
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.
|
|
41
|
-
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.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>`.
|
|
42
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.
|
|
43
43
|
|
|
44
44
|
`references/runtime.md` documents the version pin, the compatibility rule, and the diagnostic fields.
|
|
@@ -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.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.
|
|
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"
|