@liustack/modlens 3.6.0 → 3.8.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 +14 -0
- package/README.md +24 -23
- package/README.zh-CN.md +33 -32
- package/dist/main.js +793 -89
- package/{skills/modlens/references → docs}/cli.md +19 -4
- package/{skills/modlens/references → docs}/output-schema.md +13 -2
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +34 -137
- package/skills/modlens/references/configure.md +13 -6
- package/skills/modlens/references/find-image.md +33 -0
- package/skills/modlens/references/onboard.md +51 -0
- 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/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 { execFileSync, spawn } 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";
|
|
@@ -33,6 +33,82 @@ import * as dns$1 from "dns/promises";
|
|
|
33
33
|
import { isIP } from "net";
|
|
34
34
|
import { createRequire } from "module";
|
|
35
35
|
import * as crypto from "crypto";
|
|
36
|
+
function denyPatterns(guards) {
|
|
37
|
+
return stringPatterns(guards?.denyModels);
|
|
38
|
+
}
|
|
39
|
+
function allowPatterns(guards) {
|
|
40
|
+
return stringPatterns(guards?.allowModels);
|
|
41
|
+
}
|
|
42
|
+
function stringPatterns(raw) {
|
|
43
|
+
if (!Array.isArray(raw)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
return raw.filter((pattern) => typeof pattern === "string");
|
|
47
|
+
}
|
|
48
|
+
function globMatch(pattern, value) {
|
|
49
|
+
const regex = pattern.split(/([*?])/).map((part) => {
|
|
50
|
+
if (part === "*") {
|
|
51
|
+
return ".*";
|
|
52
|
+
}
|
|
53
|
+
if (part === "?") {
|
|
54
|
+
return ".";
|
|
55
|
+
}
|
|
56
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
57
|
+
}).join("");
|
|
58
|
+
return new RegExp(`^${regex}$`, "i").test(value);
|
|
59
|
+
}
|
|
60
|
+
function evaluateGuard(guards, detection) {
|
|
61
|
+
const deny = denyPatterns(guards);
|
|
62
|
+
const allow = allowPatterns(guards);
|
|
63
|
+
if (!detection.model) {
|
|
64
|
+
if (guards?.denyWhenUnknown === true) {
|
|
65
|
+
return {
|
|
66
|
+
...detection,
|
|
67
|
+
guard: "deny",
|
|
68
|
+
reason: "model unknown and denyWhenUnknown is set"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
...detection,
|
|
73
|
+
guard: "allow",
|
|
74
|
+
reason: deny.length === 0 && allow.length === 0 ? "no deny rules configured" : "model unknown, failing open"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (deny.length === 0 && allow.length === 0) {
|
|
78
|
+
return { ...detection, guard: "allow", reason: "no deny rules configured" };
|
|
79
|
+
}
|
|
80
|
+
const candidates = [detection.model];
|
|
81
|
+
if (detection.provider) {
|
|
82
|
+
candidates.push(`${detection.provider}/${detection.model}`);
|
|
83
|
+
}
|
|
84
|
+
const firstMatch = (patterns) => patterns.find((pattern) => candidates.some((candidate) => globMatch(pattern, candidate)));
|
|
85
|
+
const denied = firstMatch(deny);
|
|
86
|
+
if (denied) {
|
|
87
|
+
return {
|
|
88
|
+
...detection,
|
|
89
|
+
guard: "deny",
|
|
90
|
+
matched: denied,
|
|
91
|
+
reason: "model has native vision per guards.denyModels"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (allow.length > 0) {
|
|
95
|
+
const allowed = firstMatch(allow);
|
|
96
|
+
if (allowed) {
|
|
97
|
+
return {
|
|
98
|
+
...detection,
|
|
99
|
+
guard: "allow",
|
|
100
|
+
matched: allowed,
|
|
101
|
+
reason: "model is on guards.allowModels"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...detection,
|
|
106
|
+
guard: "deny",
|
|
107
|
+
reason: "not on guards.allowModels: only listed models run the engine"
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ...detection, guard: "allow", reason: "not on the deny list" };
|
|
111
|
+
}
|
|
36
112
|
var undici = { exports: {} };
|
|
37
113
|
var symbols;
|
|
38
114
|
var hasRequiredSymbols;
|
|
@@ -27335,6 +27411,8 @@ async function readCapped(response2, url) {
|
|
|
27335
27411
|
}
|
|
27336
27412
|
return Buffer.concat(chunks);
|
|
27337
27413
|
}
|
|
27414
|
+
const JSON_TEMPLATE_INSTRUCTION = `Respond with ONE JSON object only, no markdown fences, no commentary. Fill this exact structure with your findings from the image (do not repeat this template literally, replace every value):
|
|
27415
|
+
{"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title|subtitle|paragraph|list|table|chart|form|code|image|icon|other","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
|
|
27338
27416
|
function buildVisionPrompt(options) {
|
|
27339
27417
|
const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
|
|
27340
27418
|
const basePrompt = `${readInstruction}
|
|
@@ -28000,8 +28078,7 @@ async function executeOpenaiCompat(options) {
|
|
|
28000
28078
|
extraPrompt: options.extraPrompt
|
|
28001
28079
|
})}
|
|
28002
28080
|
|
|
28003
|
-
|
|
28004
|
-
{"summary":"one paragraph describing the image","ocr":{"full_text":"all visible text","lines":[{"text":"one line","language":"en"}]},"layout":{"regions":[{"type":"title|subtitle|paragraph|list|table|chart|form|code|image|icon|other","reading_order":1,"text":"region text"}]},"semantics":{"scene":"what kind of scene","intent":"what the image is for","entities":[{"name":"entity","type":"kind","evidence":"where seen"}],"relations":[{"subject":"a","predicate":"relates to","object":"b"}]},"visual":{"dominant_colors":["color"],"style":"visual style","notes":["notable visual detail"]},"uncertainty":["anything unreadable or ambiguous"]}`;
|
|
28081
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28005
28082
|
const startedAt = Date.now();
|
|
28006
28083
|
const response2 = await fetch(`${baseUrl}/chat/completions`, {
|
|
28007
28084
|
method: "POST",
|
|
@@ -28098,6 +28175,7 @@ function listProviders() {
|
|
|
28098
28175
|
return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
|
|
28099
28176
|
}
|
|
28100
28177
|
const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
|
|
28178
|
+
const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi"];
|
|
28101
28179
|
const CONFIG_DIR = path.join(os.homedir(), ".modlens");
|
|
28102
28180
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
28103
28181
|
const ENV_BINDINGS = {
|
|
@@ -28149,13 +28227,33 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
28149
28227
|
const config2 = loadConfigFile(configPath);
|
|
28150
28228
|
if (dottedKey === "provider") {
|
|
28151
28229
|
config2.provider = value;
|
|
28230
|
+
} else if (dottedKey.startsWith("reuse.")) {
|
|
28231
|
+
const harness = dottedKey.slice("reuse.".length);
|
|
28232
|
+
if (!REUSE_HARNESSES.includes(harness)) {
|
|
28233
|
+
throw new Error(
|
|
28234
|
+
`Unknown reuse harness: ${harness}. Use ${REUSE_HARNESSES.join(", ")}.`
|
|
28235
|
+
);
|
|
28236
|
+
}
|
|
28237
|
+
const key = harness;
|
|
28238
|
+
const normalized = value.trim().toLowerCase();
|
|
28239
|
+
if (normalized === "") {
|
|
28240
|
+
delete config2.reuse?.[key];
|
|
28241
|
+
if (config2.reuse && Object.keys(config2.reuse).length === 0) {
|
|
28242
|
+
delete config2.reuse;
|
|
28243
|
+
}
|
|
28244
|
+
} else if (normalized !== "true" && normalized !== "false") {
|
|
28245
|
+
throw new Error(`reuse.${harness} must be true or false (empty clears).`);
|
|
28246
|
+
} else {
|
|
28247
|
+
config2.reuse ??= {};
|
|
28248
|
+
config2.reuse[key] = normalized === "true";
|
|
28249
|
+
}
|
|
28152
28250
|
} else if (dottedKey.startsWith("guards.")) {
|
|
28153
28251
|
setGuardsValue(config2, dottedKey.slice("guards.".length), value);
|
|
28154
28252
|
} else {
|
|
28155
28253
|
const dot = dottedKey.indexOf(".");
|
|
28156
28254
|
if (dot <= 0 || dot === dottedKey.length - 1) {
|
|
28157
28255
|
throw new Error(
|
|
28158
|
-
`Invalid config key: ${dottedKey}. Use "provider", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
|
|
28256
|
+
`Invalid config key: ${dottedKey}. Use "provider", "reuse.<claude|codex|opencode|pi>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
|
|
28159
28257
|
);
|
|
28160
28258
|
}
|
|
28161
28259
|
const providerName = dottedKey.slice(0, dot);
|
|
@@ -28280,6 +28378,9 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
28280
28378
|
if (config2.guards.denyModels !== void 0) {
|
|
28281
28379
|
guards.denyModels = `${JSON.stringify(config2.guards.denyModels)} (file)`;
|
|
28282
28380
|
}
|
|
28381
|
+
if (config2.guards.allowModels !== void 0) {
|
|
28382
|
+
guards.allowModels = `${JSON.stringify(config2.guards.allowModels)} (file)`;
|
|
28383
|
+
}
|
|
28283
28384
|
if (config2.guards.denyWhenUnknown !== void 0) {
|
|
28284
28385
|
guards.denyWhenUnknown = `${config2.guards.denyWhenUnknown} (file)`;
|
|
28285
28386
|
}
|
|
@@ -28287,6 +28388,14 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
28287
28388
|
effective.guards = guards;
|
|
28288
28389
|
}
|
|
28289
28390
|
}
|
|
28391
|
+
if (config2.reuse && Object.keys(config2.reuse).length > 0) {
|
|
28392
|
+
effective.reuse = Object.fromEntries(
|
|
28393
|
+
Object.entries(config2.reuse).map(([harness, granted]) => [
|
|
28394
|
+
harness,
|
|
28395
|
+
`${granted} (file)`
|
|
28396
|
+
])
|
|
28397
|
+
);
|
|
28398
|
+
}
|
|
28290
28399
|
return JSON.stringify(effective, null, 2);
|
|
28291
28400
|
}
|
|
28292
28401
|
function maskKey(key) {
|
|
@@ -28356,15 +28465,18 @@ function providerAvailable(name, config2, env = process.env) {
|
|
|
28356
28465
|
return (descriptor.required ?? []).every((req) => Boolean(settings[req.field]?.trim()));
|
|
28357
28466
|
}
|
|
28358
28467
|
const LOCAL_FAILOVER_ORDER = [
|
|
28359
|
-
"antigravity-cli",
|
|
28360
28468
|
"gemini-api",
|
|
28361
28469
|
"openai",
|
|
28362
28470
|
"anthropic",
|
|
28471
|
+
"antigravity-cli",
|
|
28363
28472
|
"claude-cli"
|
|
28364
28473
|
];
|
|
28365
28474
|
const REMOTE_FAILOVER_ORDER = ["gemini-api", "openai", "anthropic", "antigravity-cli"];
|
|
28366
28475
|
function providerChain(kind, config2, env = process.env) {
|
|
28367
|
-
|
|
28476
|
+
let names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
|
|
28477
|
+
if (config2.reuse?.claude === false) {
|
|
28478
|
+
names = names.filter((name) => name !== "claude-cli");
|
|
28479
|
+
}
|
|
28368
28480
|
const preferred = config2.provider?.trim();
|
|
28369
28481
|
if (preferred) {
|
|
28370
28482
|
let canonical = null;
|
|
@@ -28384,6 +28496,557 @@ function providerChain(kind, config2, env = process.env) {
|
|
|
28384
28496
|
}
|
|
28385
28497
|
return names.filter((name) => providerAvailable(name, config2, env)).map((name) => resolveProvider(name));
|
|
28386
28498
|
}
|
|
28499
|
+
const VISION_MODEL_PATTERNS = [
|
|
28500
|
+
"claude-*",
|
|
28501
|
+
"gpt-4o*",
|
|
28502
|
+
"gpt-4.1*",
|
|
28503
|
+
"gpt-5*",
|
|
28504
|
+
"o3*",
|
|
28505
|
+
"o4*",
|
|
28506
|
+
"gemini-*",
|
|
28507
|
+
"glm-*v*",
|
|
28508
|
+
"qwen*-vl*",
|
|
28509
|
+
"qwen3.5-plus*",
|
|
28510
|
+
"qwen3.6-plus*",
|
|
28511
|
+
"kimi-k2.5*",
|
|
28512
|
+
"kimi-k2.6*",
|
|
28513
|
+
"kimi-k2.7*",
|
|
28514
|
+
"kimi-k3*",
|
|
28515
|
+
"moonshot-v1-*vision*",
|
|
28516
|
+
"minimax-vl*",
|
|
28517
|
+
"minimax-m3*",
|
|
28518
|
+
"deepseek-vl*",
|
|
28519
|
+
"deepseek-ocr*",
|
|
28520
|
+
"janus*",
|
|
28521
|
+
"pixtral*",
|
|
28522
|
+
"llama-4*",
|
|
28523
|
+
"llama-3.2-*vision*",
|
|
28524
|
+
"grok-4*",
|
|
28525
|
+
"grok-2-vision*",
|
|
28526
|
+
"internvl*"
|
|
28527
|
+
];
|
|
28528
|
+
function isVisionModel(modelId) {
|
|
28529
|
+
const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId;
|
|
28530
|
+
return VISION_MODEL_PATTERNS.some((pattern) => globMatch(pattern, bare));
|
|
28531
|
+
}
|
|
28532
|
+
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1e3;
|
|
28533
|
+
const CLI_TIMEOUT_MS = 1e4;
|
|
28534
|
+
function defaultRunCli(bin, args, timeoutMs) {
|
|
28535
|
+
return execFileSync(bin, args, { encoding: "utf-8", timeout: timeoutMs, stdio: "pipe" });
|
|
28536
|
+
}
|
|
28537
|
+
function timed(run) {
|
|
28538
|
+
const start = Date.now();
|
|
28539
|
+
const probe = run();
|
|
28540
|
+
return { ...probe, elapsedMs: Date.now() - start };
|
|
28541
|
+
}
|
|
28542
|
+
function readJson(filePath) {
|
|
28543
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
28544
|
+
}
|
|
28545
|
+
function probeClaude(env) {
|
|
28546
|
+
return timed(() => {
|
|
28547
|
+
const cliPath = findOnPath("claude", env);
|
|
28548
|
+
if (!cliPath) {
|
|
28549
|
+
return {
|
|
28550
|
+
harness: "claude-code",
|
|
28551
|
+
cliFound: false,
|
|
28552
|
+
visionModels: [],
|
|
28553
|
+
source: "none"
|
|
28554
|
+
};
|
|
28555
|
+
}
|
|
28556
|
+
return {
|
|
28557
|
+
harness: "claude-code",
|
|
28558
|
+
cliFound: true,
|
|
28559
|
+
cliPath,
|
|
28560
|
+
visionModels: ["anthropic/* (all current models)"],
|
|
28561
|
+
source: "builtin-table"
|
|
28562
|
+
};
|
|
28563
|
+
});
|
|
28564
|
+
}
|
|
28565
|
+
function probeCodex(env, home) {
|
|
28566
|
+
return timed(() => {
|
|
28567
|
+
const cliPath = findOnPath("codex", env);
|
|
28568
|
+
const base = { harness: "codex", cliFound: cliPath !== null };
|
|
28569
|
+
if (!cliPath) {
|
|
28570
|
+
return { ...base, visionModels: [], source: "none" };
|
|
28571
|
+
}
|
|
28572
|
+
const codexHome = path.join(home, ".codex");
|
|
28573
|
+
const loggedIn = fs.existsSync(path.join(codexHome, "auth.json"));
|
|
28574
|
+
if (!fs.existsSync(path.join(codexHome, "config.toml"))) {
|
|
28575
|
+
return {
|
|
28576
|
+
...base,
|
|
28577
|
+
cliPath,
|
|
28578
|
+
loggedIn,
|
|
28579
|
+
visionModels: ["default"],
|
|
28580
|
+
source: "builtin-table"
|
|
28581
|
+
};
|
|
28582
|
+
}
|
|
28583
|
+
try {
|
|
28584
|
+
const toml = fs.readFileSync(path.join(codexHome, "config.toml"), "utf-8");
|
|
28585
|
+
const catalogPath = toml.match(/^model_catalog_json\s*=\s*"([^"]+)"/m)?.[1];
|
|
28586
|
+
const thirdParty = /^model_provider\s*=/m.test(toml);
|
|
28587
|
+
if (!catalogPath) {
|
|
28588
|
+
return thirdParty ? { ...base, cliPath, loggedIn, visionModels: [], source: "none" } : {
|
|
28589
|
+
...base,
|
|
28590
|
+
cliPath,
|
|
28591
|
+
loggedIn,
|
|
28592
|
+
visionModels: ["default"],
|
|
28593
|
+
source: "builtin-table"
|
|
28594
|
+
};
|
|
28595
|
+
}
|
|
28596
|
+
const catalog = readJson(catalogPath);
|
|
28597
|
+
const vision = (catalog.models ?? []).filter((m) => m.slug && (m.input_modalities ?? []).includes("image")).map((m) => m.slug);
|
|
28598
|
+
return {
|
|
28599
|
+
...base,
|
|
28600
|
+
cliPath,
|
|
28601
|
+
loggedIn,
|
|
28602
|
+
visionModels: vision,
|
|
28603
|
+
source: "metadata"
|
|
28604
|
+
};
|
|
28605
|
+
} catch (error) {
|
|
28606
|
+
return {
|
|
28607
|
+
...base,
|
|
28608
|
+
cliPath,
|
|
28609
|
+
loggedIn,
|
|
28610
|
+
visionModels: [],
|
|
28611
|
+
source: "none",
|
|
28612
|
+
error: error instanceof Error ? error.message : String(error)
|
|
28613
|
+
};
|
|
28614
|
+
}
|
|
28615
|
+
});
|
|
28616
|
+
}
|
|
28617
|
+
function probePi(env, home) {
|
|
28618
|
+
return timed(() => {
|
|
28619
|
+
const cliPath = findOnPath("pi", env);
|
|
28620
|
+
const base = { harness: "pi", cliFound: cliPath !== null };
|
|
28621
|
+
if (!cliPath) {
|
|
28622
|
+
return { ...base, visionModels: [], source: "none" };
|
|
28623
|
+
}
|
|
28624
|
+
const agentDir = path.join(home, ".pi", "agent");
|
|
28625
|
+
try {
|
|
28626
|
+
const auth = readJson(path.join(agentDir, "auth.json"));
|
|
28627
|
+
const providersWithCreds = new Set(Object.keys(auth));
|
|
28628
|
+
const store = readJson(path.join(agentDir, "models-store.json"));
|
|
28629
|
+
const vision = [];
|
|
28630
|
+
for (const entry of Object.values(store)) {
|
|
28631
|
+
for (const model of entry?.models ?? []) {
|
|
28632
|
+
if (model.id && (model.input ?? []).includes("image") && model.provider && providersWithCreds.has(model.provider)) {
|
|
28633
|
+
vision.push(model.id);
|
|
28634
|
+
}
|
|
28635
|
+
}
|
|
28636
|
+
}
|
|
28637
|
+
return {
|
|
28638
|
+
...base,
|
|
28639
|
+
cliPath,
|
|
28640
|
+
loggedIn: providersWithCreds.size > 0,
|
|
28641
|
+
visionModels: vision,
|
|
28642
|
+
source: "metadata"
|
|
28643
|
+
};
|
|
28644
|
+
} catch (error) {
|
|
28645
|
+
return {
|
|
28646
|
+
...base,
|
|
28647
|
+
cliPath,
|
|
28648
|
+
visionModels: [],
|
|
28649
|
+
source: "none",
|
|
28650
|
+
error: error instanceof Error ? error.message : String(error)
|
|
28651
|
+
};
|
|
28652
|
+
}
|
|
28653
|
+
});
|
|
28654
|
+
}
|
|
28655
|
+
function probeOpencode(env, runCli) {
|
|
28656
|
+
return timed(() => {
|
|
28657
|
+
const cliPath = findOnPath("opencode", env);
|
|
28658
|
+
const base = { harness: "opencode", cliFound: cliPath !== null };
|
|
28659
|
+
if (!cliPath) {
|
|
28660
|
+
return { ...base, visionModels: [], source: "none" };
|
|
28661
|
+
}
|
|
28662
|
+
try {
|
|
28663
|
+
const listing = runCli(cliPath, ["models"], CLI_TIMEOUT_MS);
|
|
28664
|
+
const vision = listing.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && isVisionModel(line));
|
|
28665
|
+
return { ...base, cliPath, visionModels: vision, source: "builtin-table" };
|
|
28666
|
+
} catch (error) {
|
|
28667
|
+
return {
|
|
28668
|
+
...base,
|
|
28669
|
+
cliPath,
|
|
28670
|
+
visionModels: [],
|
|
28671
|
+
source: "none",
|
|
28672
|
+
error: error instanceof Error ? error.message : String(error)
|
|
28673
|
+
};
|
|
28674
|
+
}
|
|
28675
|
+
});
|
|
28676
|
+
}
|
|
28677
|
+
function readCache(cachePath, ttlMs) {
|
|
28678
|
+
try {
|
|
28679
|
+
const cached = readJson(cachePath);
|
|
28680
|
+
if (!cached.cachedAt || !Array.isArray(cached.probes)) {
|
|
28681
|
+
return null;
|
|
28682
|
+
}
|
|
28683
|
+
const cachedAtMs = Date.parse(cached.cachedAt);
|
|
28684
|
+
if (!Number.isFinite(cachedAtMs) || Date.now() - cachedAtMs > ttlMs) {
|
|
28685
|
+
return null;
|
|
28686
|
+
}
|
|
28687
|
+
return cached;
|
|
28688
|
+
} catch {
|
|
28689
|
+
return null;
|
|
28690
|
+
}
|
|
28691
|
+
}
|
|
28692
|
+
function discoverAuto(options = {}) {
|
|
28693
|
+
const env = options.env ?? process.env;
|
|
28694
|
+
const home = options.home ?? os.homedir();
|
|
28695
|
+
const cachePath = options.cachePath ?? path.join(home, ".modlens", "auto-cache.json");
|
|
28696
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
28697
|
+
if (!options.fresh) {
|
|
28698
|
+
const cached = readCache(cachePath, ttlMs);
|
|
28699
|
+
if (cached) {
|
|
28700
|
+
return { probes: cached.probes, cachedAt: cached.cachedAt, fromCache: true };
|
|
28701
|
+
}
|
|
28702
|
+
}
|
|
28703
|
+
const runCli = options.runCli ?? defaultRunCli;
|
|
28704
|
+
const probes = [
|
|
28705
|
+
probeClaude(env),
|
|
28706
|
+
probeCodex(env, home),
|
|
28707
|
+
probeOpencode(env, runCli),
|
|
28708
|
+
probePi(env, home)
|
|
28709
|
+
];
|
|
28710
|
+
const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
28711
|
+
try {
|
|
28712
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
28713
|
+
fs.writeFileSync(cachePath, JSON.stringify({ cachedAt, probes }, null, 2), {
|
|
28714
|
+
mode: 384
|
|
28715
|
+
});
|
|
28716
|
+
} catch {
|
|
28717
|
+
}
|
|
28718
|
+
return { probes, cachedAt, fromCache: false };
|
|
28719
|
+
}
|
|
28720
|
+
const KEY_FETCH_TIMEOUT_MS = 1e4;
|
|
28721
|
+
function codexCliRoute(visionModel) {
|
|
28722
|
+
return {
|
|
28723
|
+
name: "codex-cli",
|
|
28724
|
+
defaultModel: visionModel,
|
|
28725
|
+
isolateWorkdir: true,
|
|
28726
|
+
reuseNote: "this read reused the local Codex CLI login and spent that account's quota.",
|
|
28727
|
+
buildInvocation: (options) => {
|
|
28728
|
+
if (options.imageKind === "remote") {
|
|
28729
|
+
throw new Error(
|
|
28730
|
+
"codex-cli route reads local files only. Remote URLs stay on the inline providers."
|
|
28731
|
+
);
|
|
28732
|
+
}
|
|
28733
|
+
const prompt = `${buildVisionPrompt({
|
|
28734
|
+
imageSource: options.imageSource,
|
|
28735
|
+
imageKind: "inline",
|
|
28736
|
+
extraPrompt: options.extraPrompt
|
|
28737
|
+
})}
|
|
28738
|
+
|
|
28739
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28740
|
+
const model = options.model || visionModel;
|
|
28741
|
+
const args = [
|
|
28742
|
+
"exec",
|
|
28743
|
+
"--skip-git-repo-check",
|
|
28744
|
+
"--ephemeral",
|
|
28745
|
+
"-s",
|
|
28746
|
+
"read-only",
|
|
28747
|
+
"--json",
|
|
28748
|
+
"-i",
|
|
28749
|
+
options.imageSource
|
|
28750
|
+
];
|
|
28751
|
+
if (model && model !== "default") {
|
|
28752
|
+
args.push("-m", model);
|
|
28753
|
+
}
|
|
28754
|
+
args.push("--", prompt);
|
|
28755
|
+
return {
|
|
28756
|
+
command: options.providerBin || "codex",
|
|
28757
|
+
args,
|
|
28758
|
+
cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
|
|
28759
|
+
};
|
|
28760
|
+
},
|
|
28761
|
+
parseOutput: (stdout) => {
|
|
28762
|
+
let threadId = null;
|
|
28763
|
+
let usage = null;
|
|
28764
|
+
let lastMessage = null;
|
|
28765
|
+
for (const line of stdout.split("\n")) {
|
|
28766
|
+
const event = tryParseJson(line.trim());
|
|
28767
|
+
if (!event) {
|
|
28768
|
+
continue;
|
|
28769
|
+
}
|
|
28770
|
+
if (event.type === "thread.started" && event.thread_id) {
|
|
28771
|
+
threadId = event.thread_id;
|
|
28772
|
+
}
|
|
28773
|
+
if (event.type === "turn.completed" && event.usage !== void 0) {
|
|
28774
|
+
usage = event.usage;
|
|
28775
|
+
}
|
|
28776
|
+
if (event.type === "item.completed" && event.item?.type === "agent_message" && typeof event.item.text === "string") {
|
|
28777
|
+
lastMessage = event.item.text;
|
|
28778
|
+
}
|
|
28779
|
+
}
|
|
28780
|
+
if (!lastMessage) {
|
|
28781
|
+
throw new Error(
|
|
28782
|
+
"codex exec produced no agent message. Check the Codex login (run: codex)."
|
|
28783
|
+
);
|
|
28784
|
+
}
|
|
28785
|
+
const result = extractJson(lastMessage);
|
|
28786
|
+
if (result === null) {
|
|
28787
|
+
throw new Error(`codex returned a non-JSON answer: ${truncate(lastMessage)}`);
|
|
28788
|
+
}
|
|
28789
|
+
return {
|
|
28790
|
+
result,
|
|
28791
|
+
meta: { conversationId: threadId, durationSeconds: null, usage }
|
|
28792
|
+
};
|
|
28793
|
+
}
|
|
28794
|
+
};
|
|
28795
|
+
}
|
|
28796
|
+
function opencodeCliRoute(modelId) {
|
|
28797
|
+
return {
|
|
28798
|
+
name: "opencode-cli",
|
|
28799
|
+
defaultModel: modelId,
|
|
28800
|
+
isolateWorkdir: true,
|
|
28801
|
+
reuseNote: `this read reused OpenCode's ${modelId} and spent that account's quota.`,
|
|
28802
|
+
buildInvocation: (options) => {
|
|
28803
|
+
if (options.imageKind === "remote") {
|
|
28804
|
+
throw new Error(
|
|
28805
|
+
"opencode-cli route reads local files only. Remote URLs stay on the inline providers."
|
|
28806
|
+
);
|
|
28807
|
+
}
|
|
28808
|
+
const prompt = `${buildVisionPrompt({
|
|
28809
|
+
imageSource: options.imageSource,
|
|
28810
|
+
imageKind: "inline",
|
|
28811
|
+
extraPrompt: options.extraPrompt
|
|
28812
|
+
})}
|
|
28813
|
+
|
|
28814
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28815
|
+
return {
|
|
28816
|
+
command: options.providerBin || "opencode",
|
|
28817
|
+
args: [
|
|
28818
|
+
"run",
|
|
28819
|
+
prompt,
|
|
28820
|
+
"-m",
|
|
28821
|
+
options.model || modelId,
|
|
28822
|
+
"--format",
|
|
28823
|
+
"json",
|
|
28824
|
+
"-f",
|
|
28825
|
+
options.imageSource
|
|
28826
|
+
],
|
|
28827
|
+
cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
|
|
28828
|
+
};
|
|
28829
|
+
},
|
|
28830
|
+
parseOutput: (stdout) => {
|
|
28831
|
+
let sessionId = null;
|
|
28832
|
+
let usage = null;
|
|
28833
|
+
const texts = [];
|
|
28834
|
+
for (const line of stdout.split("\n")) {
|
|
28835
|
+
const event = tryParseJson(line.trim());
|
|
28836
|
+
if (!event) {
|
|
28837
|
+
continue;
|
|
28838
|
+
}
|
|
28839
|
+
sessionId ??= event.sessionID ?? null;
|
|
28840
|
+
if (event.type === "text" && typeof event.part?.text === "string") {
|
|
28841
|
+
texts.push(event.part.text);
|
|
28842
|
+
}
|
|
28843
|
+
if (event.type === "step_finish" && event.part?.tokens !== void 0) {
|
|
28844
|
+
usage = event.part.tokens;
|
|
28845
|
+
}
|
|
28846
|
+
}
|
|
28847
|
+
const answer = texts.join("").trim();
|
|
28848
|
+
if (!answer) {
|
|
28849
|
+
throw new Error("opencode run produced no text answer.");
|
|
28850
|
+
}
|
|
28851
|
+
const result = extractJson(answer);
|
|
28852
|
+
if (result === null) {
|
|
28853
|
+
throw new Error(`opencode returned a non-JSON answer: ${truncate(answer)}`);
|
|
28854
|
+
}
|
|
28855
|
+
return {
|
|
28856
|
+
result,
|
|
28857
|
+
meta: { conversationId: sessionId, durationSeconds: null, usage }
|
|
28858
|
+
};
|
|
28859
|
+
}
|
|
28860
|
+
};
|
|
28861
|
+
}
|
|
28862
|
+
const PI_API_TARGETS = {
|
|
28863
|
+
"openai-completions": "openai",
|
|
28864
|
+
"anthropic-messages": "anthropic"
|
|
28865
|
+
};
|
|
28866
|
+
const DEFAULT_TARGETS = {
|
|
28867
|
+
openai: openaiCompatProvider,
|
|
28868
|
+
anthropic: anthropicApiProvider,
|
|
28869
|
+
"gemini-api": geminiApiProvider
|
|
28870
|
+
};
|
|
28871
|
+
function piCliRoute(providerName, modelId) {
|
|
28872
|
+
return {
|
|
28873
|
+
name: "pi-cli",
|
|
28874
|
+
defaultModel: modelId,
|
|
28875
|
+
isolateWorkdir: true,
|
|
28876
|
+
reuseNote: `this read reused pi's ${providerName}/${modelId} and spent that account's quota.`,
|
|
28877
|
+
buildInvocation: (options) => {
|
|
28878
|
+
if (options.imageKind === "remote") {
|
|
28879
|
+
throw new Error(
|
|
28880
|
+
"pi-cli route reads local files only. Remote URLs stay on the inline providers."
|
|
28881
|
+
);
|
|
28882
|
+
}
|
|
28883
|
+
const prompt = `${buildVisionPrompt({
|
|
28884
|
+
imageSource: options.imageSource,
|
|
28885
|
+
imageKind: "inline",
|
|
28886
|
+
extraPrompt: options.extraPrompt
|
|
28887
|
+
})}
|
|
28888
|
+
|
|
28889
|
+
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28890
|
+
return {
|
|
28891
|
+
command: options.providerBin || "pi",
|
|
28892
|
+
args: [
|
|
28893
|
+
"-p",
|
|
28894
|
+
"--no-session",
|
|
28895
|
+
"--no-tools",
|
|
28896
|
+
"--mode",
|
|
28897
|
+
"json",
|
|
28898
|
+
"--provider",
|
|
28899
|
+
providerName,
|
|
28900
|
+
"--model",
|
|
28901
|
+
options.model || modelId,
|
|
28902
|
+
`@${options.imageSource}`,
|
|
28903
|
+
prompt
|
|
28904
|
+
],
|
|
28905
|
+
cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
|
|
28906
|
+
};
|
|
28907
|
+
},
|
|
28908
|
+
parseOutput: (stdout) => {
|
|
28909
|
+
let final = null;
|
|
28910
|
+
for (const line of stdout.split("\n")) {
|
|
28911
|
+
const event = tryParseJson(line.trim());
|
|
28912
|
+
if (event?.type === "message_end" && event.message) {
|
|
28913
|
+
final = event.message;
|
|
28914
|
+
}
|
|
28915
|
+
}
|
|
28916
|
+
const answer = (final?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("").trim();
|
|
28917
|
+
if (!answer) {
|
|
28918
|
+
throw new Error("pi produced no text answer. Check pi and its credentials.");
|
|
28919
|
+
}
|
|
28920
|
+
const result = extractJson(answer);
|
|
28921
|
+
if (result === null) {
|
|
28922
|
+
throw new Error(`pi returned a non-JSON answer: ${truncate(answer)}`);
|
|
28923
|
+
}
|
|
28924
|
+
return {
|
|
28925
|
+
result,
|
|
28926
|
+
meta: {
|
|
28927
|
+
conversationId: final?.responseId ?? null,
|
|
28928
|
+
durationSeconds: null,
|
|
28929
|
+
usage: final?.usage ?? null
|
|
28930
|
+
}
|
|
28931
|
+
};
|
|
28932
|
+
}
|
|
28933
|
+
};
|
|
28934
|
+
}
|
|
28935
|
+
function piRoutes(home, env, targets = DEFAULT_TARGETS) {
|
|
28936
|
+
const empty = { inline: [], agents: [] };
|
|
28937
|
+
const piPath = findOnPath("pi", env);
|
|
28938
|
+
if (!piPath) {
|
|
28939
|
+
return empty;
|
|
28940
|
+
}
|
|
28941
|
+
const agentDir = path.join(home, ".pi", "agent");
|
|
28942
|
+
let auth;
|
|
28943
|
+
let store;
|
|
28944
|
+
try {
|
|
28945
|
+
auth = JSON.parse(fs.readFileSync(path.join(agentDir, "auth.json"), "utf-8"));
|
|
28946
|
+
store = JSON.parse(fs.readFileSync(path.join(agentDir, "models-store.json"), "utf-8"));
|
|
28947
|
+
} catch {
|
|
28948
|
+
return empty;
|
|
28949
|
+
}
|
|
28950
|
+
const routes = [];
|
|
28951
|
+
const agents = [];
|
|
28952
|
+
const usedTargets = /* @__PURE__ */ new Set();
|
|
28953
|
+
for (const entry of Object.values(store)) {
|
|
28954
|
+
for (const model of entry?.models ?? []) {
|
|
28955
|
+
if (!model.id || !model.provider || !model.baseUrl || !(model.input ?? []).includes("image") || !(model.provider in auth)) {
|
|
28956
|
+
continue;
|
|
28957
|
+
}
|
|
28958
|
+
const credential = auth[model.provider];
|
|
28959
|
+
const targetName = PI_API_TARGETS[model.api ?? ""];
|
|
28960
|
+
const target = targetName ? targets[targetName] : void 0;
|
|
28961
|
+
const targetExecute = target?.execute;
|
|
28962
|
+
if (!target || !targetExecute || credential?.type !== "api_key") {
|
|
28963
|
+
if (agents.length < 2) {
|
|
28964
|
+
agents.push(piCliRoute(model.provider, model.id));
|
|
28965
|
+
}
|
|
28966
|
+
continue;
|
|
28967
|
+
}
|
|
28968
|
+
if (usedTargets.has(target.name)) {
|
|
28969
|
+
continue;
|
|
28970
|
+
}
|
|
28971
|
+
usedTargets.add(target.name);
|
|
28972
|
+
const { id, provider, baseUrl } = model;
|
|
28973
|
+
routes.push({
|
|
28974
|
+
name: `pi:${target.name}`,
|
|
28975
|
+
defaultModel: id,
|
|
28976
|
+
reuseNote: `this read reused pi's ${provider} credentials for ${id} and spent that account's quota.`,
|
|
28977
|
+
execute: async (options) => {
|
|
28978
|
+
const apiKey = fetchPiKey(
|
|
28979
|
+
piPath,
|
|
28980
|
+
id,
|
|
28981
|
+
provider,
|
|
28982
|
+
Math.min(KEY_FETCH_TIMEOUT_MS, options.timeoutMs || KEY_FETCH_TIMEOUT_MS)
|
|
28983
|
+
);
|
|
28984
|
+
return targetExecute({
|
|
28985
|
+
...options,
|
|
28986
|
+
settings: {
|
|
28987
|
+
...options.settings ?? {},
|
|
28988
|
+
apiKey,
|
|
28989
|
+
baseUrl,
|
|
28990
|
+
model: options.model || id
|
|
28991
|
+
}
|
|
28992
|
+
});
|
|
28993
|
+
}
|
|
28994
|
+
});
|
|
28995
|
+
}
|
|
28996
|
+
}
|
|
28997
|
+
return { inline: routes.slice(0, 2), agents };
|
|
28998
|
+
}
|
|
28999
|
+
function fetchPiKey(piPath, modelId, provider, timeoutMs) {
|
|
29000
|
+
try {
|
|
29001
|
+
const key = execFileSync(
|
|
29002
|
+
piPath,
|
|
29003
|
+
["auth", "print-api-key", "--model", modelId, "--provider", provider],
|
|
29004
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs }
|
|
29005
|
+
).trim();
|
|
29006
|
+
if (!key) {
|
|
29007
|
+
throw new Error("empty");
|
|
29008
|
+
}
|
|
29009
|
+
return key;
|
|
29010
|
+
} catch {
|
|
29011
|
+
throw new Error(
|
|
29012
|
+
`pi could not print an API key for ${provider}/${modelId}. Run \`pi auth\` to check that credential.`
|
|
29013
|
+
);
|
|
29014
|
+
}
|
|
29015
|
+
}
|
|
29016
|
+
function reuseProviders(kind, config2, options = {}) {
|
|
29017
|
+
const env = options.env ?? process.env;
|
|
29018
|
+
const home = options.home ?? os.homedir();
|
|
29019
|
+
const grants = config2.reuse ?? {};
|
|
29020
|
+
const inline = [];
|
|
29021
|
+
const agents = [];
|
|
29022
|
+
let piAgents = [];
|
|
29023
|
+
if (grants.pi === true) {
|
|
29024
|
+
try {
|
|
29025
|
+
const pi = piRoutes(home, env, options.targets);
|
|
29026
|
+
inline.push(...pi.inline);
|
|
29027
|
+
piAgents = pi.agents;
|
|
29028
|
+
} catch {
|
|
29029
|
+
}
|
|
29030
|
+
}
|
|
29031
|
+
if (kind === "local" && (grants.codex === true || grants.opencode === true)) {
|
|
29032
|
+
try {
|
|
29033
|
+
const discovery = options.discovery ?? discoverAuto({ env, home });
|
|
29034
|
+
const codex = discovery.probes.find((probe) => probe.harness === "codex");
|
|
29035
|
+
if (grants.codex === true && codex?.cliFound && codex.loggedIn !== false && codex.visionModels[0]) {
|
|
29036
|
+
agents.push(codexCliRoute(codex.visionModels[0]));
|
|
29037
|
+
}
|
|
29038
|
+
const opencode = discovery.probes.find((probe) => probe.harness === "opencode");
|
|
29039
|
+
if (grants.opencode === true && opencode?.cliFound && opencode.visionModels[0]) {
|
|
29040
|
+
agents.push(opencodeCliRoute(opencode.visionModels[0]));
|
|
29041
|
+
}
|
|
29042
|
+
} catch {
|
|
29043
|
+
}
|
|
29044
|
+
}
|
|
29045
|
+
if (kind === "local") {
|
|
29046
|
+
agents.push(...piAgents);
|
|
29047
|
+
}
|
|
29048
|
+
return { inline, agents };
|
|
29049
|
+
}
|
|
28387
29050
|
const DEFAULT_TIMEOUT_MS = 18e4;
|
|
28388
29051
|
const KILL_GRACE_MS = 3e4;
|
|
28389
29052
|
const DRAIN_GRACE_MS = 500;
|
|
@@ -28394,10 +29057,10 @@ async function analyzeImage(options) {
|
|
|
28394
29057
|
validateInputFile(resolvedInput.source);
|
|
28395
29058
|
}
|
|
28396
29059
|
const config2 = options.config ?? loadConfigFile();
|
|
28397
|
-
const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] :
|
|
29060
|
+
const chain = options.provider ? [resolveProvider(options.provider)] : options.providerBin ? [resolveProvider("antigravity-cli")] : composeChain(resolvedInput.kind, config2, options.autoOptions);
|
|
28398
29061
|
if (chain.length === 0) {
|
|
28399
29062
|
throw new Error(
|
|
28400
|
-
"No vision provider is set up on this machine. Install Antigravity CLI (curl -fsSL https://antigravity.google/cli/install.sh | bash, then run agy once to sign in), or configure a key: modlens config set gemini-api.apiKey <key>. Run modlens doctor for the full picture."
|
|
29063
|
+
"No vision provider is set up on this machine. Install Antigravity CLI (curl -fsSL https://antigravity.google/cli/install.sh | bash, then run agy once to sign in), or configure a key: modlens config set gemini-api.apiKey <key>. Run modlens doctor for the full picture." + reuseHint(config2, options.autoOptions)
|
|
28401
29064
|
);
|
|
28402
29065
|
}
|
|
28403
29066
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -28422,6 +29085,9 @@ async function analyzeImage(options) {
|
|
|
28422
29085
|
ok: true,
|
|
28423
29086
|
durationSeconds: (Date.now() - startedAt) / 1e3
|
|
28424
29087
|
});
|
|
29088
|
+
if (provider.reuseNote) {
|
|
29089
|
+
warnings.push(provider.reuseNote);
|
|
29090
|
+
}
|
|
28425
29091
|
if (attempts.length > 1) {
|
|
28426
29092
|
const failed = attempts.slice(0, -1);
|
|
28427
29093
|
warnings.push(
|
|
@@ -28459,12 +29125,81 @@ async function analyzeImage(options) {
|
|
|
28459
29125
|
}
|
|
28460
29126
|
}
|
|
28461
29127
|
if (chain.length === 1) {
|
|
29128
|
+
if (!options.provider && !options.providerBin && lastError instanceof Error) {
|
|
29129
|
+
const hint = reuseHint(config2, options.autoOptions);
|
|
29130
|
+
if (hint) {
|
|
29131
|
+
lastError.message += hint;
|
|
29132
|
+
}
|
|
29133
|
+
}
|
|
28462
29134
|
throw lastError;
|
|
28463
29135
|
}
|
|
28464
29136
|
throw new Error(
|
|
28465
|
-
`Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}`
|
|
29137
|
+
`Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}${reuseHint(config2, options.autoOptions)}`
|
|
28466
29138
|
);
|
|
28467
29139
|
}
|
|
29140
|
+
const INLINE_REGION = /* @__PURE__ */ new Set(["gemini-api", "openai", "anthropic"]);
|
|
29141
|
+
function composeChain(kind, config2, autoOptions) {
|
|
29142
|
+
const chain = [...providerChain(kind, config2, autoOptions?.env ?? process.env)];
|
|
29143
|
+
const borrowed = reuseProviders(kind, config2, autoOptions);
|
|
29144
|
+
let preferredName = null;
|
|
29145
|
+
if (config2.provider?.trim()) {
|
|
29146
|
+
try {
|
|
29147
|
+
preferredName = resolveProvider(config2.provider.trim()).name;
|
|
29148
|
+
} catch {
|
|
29149
|
+
preferredName = null;
|
|
29150
|
+
}
|
|
29151
|
+
}
|
|
29152
|
+
if (borrowed.inline.length > 0) {
|
|
29153
|
+
const lastInline = chain.map((p) => INLINE_REGION.has(p.name)).lastIndexOf(true);
|
|
29154
|
+
const insertAt = lastInline >= 0 ? lastInline + 1 : kind === "local" && preferredName === chain[0]?.name ? 1 : 0;
|
|
29155
|
+
chain.splice(insertAt, 0, ...borrowed.inline);
|
|
29156
|
+
}
|
|
29157
|
+
if (borrowed.agents.length > 0) {
|
|
29158
|
+
const last = chain[chain.length - 1];
|
|
29159
|
+
const beforeClaude = last?.name === "claude-cli" && preferredName !== "claude-cli";
|
|
29160
|
+
chain.splice(beforeClaude ? chain.length - 1 : chain.length, 0, ...borrowed.agents);
|
|
29161
|
+
}
|
|
29162
|
+
return chain;
|
|
29163
|
+
}
|
|
29164
|
+
const REUSE_KEY_BY_HARNESS = {
|
|
29165
|
+
codex: "codex",
|
|
29166
|
+
opencode: "opencode",
|
|
29167
|
+
pi: "pi"
|
|
29168
|
+
};
|
|
29169
|
+
function reuseHint(config2, autoOptions) {
|
|
29170
|
+
try {
|
|
29171
|
+
const grants = config2.reuse ?? {};
|
|
29172
|
+
const discovery = autoOptions?.discovery ?? discoverAuto({ env: autoOptions?.env, home: autoOptions?.home });
|
|
29173
|
+
const unasked = [];
|
|
29174
|
+
const dead = [];
|
|
29175
|
+
for (const probe of discovery.probes) {
|
|
29176
|
+
const key = REUSE_KEY_BY_HARNESS[probe.harness];
|
|
29177
|
+
if (key === void 0) {
|
|
29178
|
+
continue;
|
|
29179
|
+
}
|
|
29180
|
+
const usable = probe.cliFound && probe.visionModels.length > 0 && probe.loggedIn !== false;
|
|
29181
|
+
if (grants[key] === void 0 && usable) {
|
|
29182
|
+
unasked.push(probe.harness);
|
|
29183
|
+
} else if (grants[key] === true && !usable) {
|
|
29184
|
+
dead.push(probe.harness);
|
|
29185
|
+
}
|
|
29186
|
+
}
|
|
29187
|
+
const parts = [];
|
|
29188
|
+
if (unasked.length > 0) {
|
|
29189
|
+
parts.push(
|
|
29190
|
+
` Hint: this machine has vision reachable through ${unasked.join(", ")}, which modlens is not yet allowed to reuse. Ask the user, then: modlens config set reuse.<harness> true.`
|
|
29191
|
+
);
|
|
29192
|
+
}
|
|
29193
|
+
if (dead.length > 0) {
|
|
29194
|
+
parts.push(
|
|
29195
|
+
` Note: reuse is granted for ${dead.join(", ")} but it is currently unusable (signed out, uninstalled, or no vision model); check that CLI's login.`
|
|
29196
|
+
);
|
|
29197
|
+
}
|
|
29198
|
+
return parts.join("");
|
|
29199
|
+
} catch {
|
|
29200
|
+
return "";
|
|
29201
|
+
}
|
|
29202
|
+
}
|
|
28468
29203
|
async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
|
|
28469
29204
|
const configured = resolveProviderSettings(provider.name, config2);
|
|
28470
29205
|
const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
|
|
@@ -29259,82 +29994,6 @@ function sniffModel(harness, cwd, env, roots = {}) {
|
|
|
29259
29994
|
return null;
|
|
29260
29995
|
}
|
|
29261
29996
|
}
|
|
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
29997
|
function detectActiveModel(options) {
|
|
29339
29998
|
const env = options.env ?? process.env;
|
|
29340
29999
|
const envModel = env.MODLENS_MODEL?.trim();
|
|
@@ -29377,6 +30036,9 @@ function versionParts(version) {
|
|
|
29377
30036
|
}
|
|
29378
30037
|
return [Number(match[1]), Number(match[2])];
|
|
29379
30038
|
}
|
|
30039
|
+
function chainEntryName(provider) {
|
|
30040
|
+
return provider.reuseNote ? `${provider.name} (reused)` : provider.name;
|
|
30041
|
+
}
|
|
29380
30042
|
function meetsMinimum(version, minimum) {
|
|
29381
30043
|
const [major, minor] = versionParts(version);
|
|
29382
30044
|
const [minMajor, minMinor] = versionParts(minimum);
|
|
@@ -29489,6 +30151,8 @@ function buildDoctorReport(input) {
|
|
|
29489
30151
|
harness: harnessDetection.harness
|
|
29490
30152
|
});
|
|
29491
30153
|
const guardVerdict = evaluateGuard(input.config.guards, guardDetection);
|
|
30154
|
+
const reuseDiscovery = discoverAuto({ env, fresh: true, ...input.auto });
|
|
30155
|
+
const reuseOptions = { env, ...input.auto, discovery: reuseDiscovery };
|
|
29492
30156
|
return {
|
|
29493
30157
|
node: {
|
|
29494
30158
|
version: process.version,
|
|
@@ -29498,9 +30162,12 @@ function buildDoctorReport(input) {
|
|
|
29498
30162
|
nodeSqlite: checkNodeSqlite(),
|
|
29499
30163
|
providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
|
|
29500
30164
|
selection: resolveSelection(input.config, input.providerFlag),
|
|
30165
|
+
// The chains a run would actually use, reused routes included and
|
|
30166
|
+
// labeled, so a machine living entirely on granted logins does not
|
|
30167
|
+
// read as "no engine" right next to a granted Reuse section.
|
|
29501
30168
|
chains: {
|
|
29502
|
-
local:
|
|
29503
|
-
remote:
|
|
30169
|
+
local: composeChain("local", input.config, reuseOptions).map(chainEntryName),
|
|
30170
|
+
remote: composeChain("remote", input.config, reuseOptions).map(chainEntryName)
|
|
29504
30171
|
},
|
|
29505
30172
|
harness: { detected: harnessDetection.harness, source: harnessDetection.source },
|
|
29506
30173
|
guard: {
|
|
@@ -29513,7 +30180,20 @@ function buildDoctorReport(input) {
|
|
|
29513
30180
|
matched: guardVerdict.matched,
|
|
29514
30181
|
reason: guardVerdict.reason
|
|
29515
30182
|
},
|
|
29516
|
-
config: inspectConfigFile(configPath)
|
|
30183
|
+
config: inspectConfigFile(configPath),
|
|
30184
|
+
reuse: {
|
|
30185
|
+
decisions: Object.fromEntries(
|
|
30186
|
+
REUSE_HARNESSES.map((harness) => {
|
|
30187
|
+
const decision = input.config.reuse?.[harness];
|
|
30188
|
+
const fallback = harness === "claude" ? "granted" : "not asked";
|
|
30189
|
+
return [
|
|
30190
|
+
harness,
|
|
30191
|
+
decision === true ? "granted" : decision === false ? "refused" : fallback
|
|
30192
|
+
];
|
|
30193
|
+
})
|
|
30194
|
+
),
|
|
30195
|
+
probes: reuseDiscovery.probes
|
|
30196
|
+
}
|
|
29517
30197
|
};
|
|
29518
30198
|
}
|
|
29519
30199
|
function mark(ok) {
|
|
@@ -29562,6 +30242,30 @@ function renderDoctorReport(report) {
|
|
|
29562
30242
|
` verdict: ${report.guard.verdict}${report.guard.matched ? ` (matched "${report.guard.matched}")` : ""}, ${report.guard.reason}`
|
|
29563
30243
|
);
|
|
29564
30244
|
lines.push("");
|
|
30245
|
+
lines.push('Reuse (may modlens reuse other local logins? config "reuse.<harness>")');
|
|
30246
|
+
lines.push(
|
|
30247
|
+
` decisions: ${Object.entries(report.reuse.decisions).map(([harness, decision]) => `${harness} ${decision}`).join(", ")}`
|
|
30248
|
+
);
|
|
30249
|
+
for (const probe of report.reuse.probes) {
|
|
30250
|
+
if (!probe.cliFound) {
|
|
30251
|
+
lines.push(` ${probe.harness}: cli not found`);
|
|
30252
|
+
continue;
|
|
30253
|
+
}
|
|
30254
|
+
const parts = [];
|
|
30255
|
+
const shown = probe.visionModels.slice(0, 3).join(", ");
|
|
30256
|
+
parts.push(
|
|
30257
|
+
probe.visionModels.length === 0 ? "no vision models" : `${probe.visionModels.length} vision model(s): ${shown}${probe.visionModels.length > 3 ? ", ..." : ""}`
|
|
30258
|
+
);
|
|
30259
|
+
if (probe.loggedIn !== void 0) {
|
|
30260
|
+
parts.push(probe.loggedIn ? "logged in" : "no credentials found");
|
|
30261
|
+
}
|
|
30262
|
+
parts.push(`via ${probe.source}, ${probe.elapsedMs}ms`);
|
|
30263
|
+
if (probe.error) {
|
|
30264
|
+
parts.push(`error: ${probe.error}`);
|
|
30265
|
+
}
|
|
30266
|
+
lines.push(` ${probe.harness}: ${parts.join(", ")}`);
|
|
30267
|
+
}
|
|
30268
|
+
lines.push("");
|
|
29565
30269
|
lines.push("Config file");
|
|
29566
30270
|
lines.push(` path: ${report.config.path}`);
|
|
29567
30271
|
if (report.config.exists) {
|
|
@@ -29754,7 +30458,7 @@ function recoverPastedImages(options = {}) {
|
|
|
29754
30458
|
return result;
|
|
29755
30459
|
}
|
|
29756
30460
|
const program = new Command();
|
|
29757
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.
|
|
30461
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.8.0");
|
|
29758
30462
|
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
30463
|
"--extra-body <json>",
|
|
29760
30464
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|