@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/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
- 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):
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
- const names = [...kind === "remote" ? REMOTE_FAILOVER_ORDER : LOCAL_FAILOVER_ORDER];
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")] : providerChain(resolvedInput.kind, config2);
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: providerChain("local", input.config, env).map((p) => p.name),
29503
- remote: providerChain("remote", input.config, env).map((p) => p.name)
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.6.0");
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"}}'`