@liustack/modlens 3.3.0 → 3.5.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
@@ -20,15 +20,14 @@ import require$$5$1 from "node:perf_hooks";
20
20
  import require$$8 from "node:util/types";
21
21
  import require$$2$1 from "node:worker_threads";
22
22
  import require$$2$2 from "node:crypto";
23
- import require$$1$1 from "node:sqlite";
24
23
  import require$$11 from "node:stream/web";
25
24
  import require$$0$6 from "node:url";
26
- import require$$1$2 from "node:async_hooks";
27
- import require$$1$3 from "node:console";
25
+ import require$$1$1 from "node:async_hooks";
26
+ import require$$1$2 from "node:console";
28
27
  import require$$0$7 from "node:fs/promises";
29
- import require$$1$4 from "node:path";
28
+ import require$$1$3 from "node:path";
30
29
  import require$$2$3 from "node:timers";
31
- import require$$1$5 from "node:dns";
30
+ import require$$1$4 from "node:dns";
32
31
  import * as dns$1 from "dns/promises";
33
32
  import { isIP } from "net";
34
33
  import { createRequire } from "module";
@@ -5654,7 +5653,7 @@ function requireRuntimeFeatures() {
5654
5653
  const lazyLoaders = {
5655
5654
  __proto__: null,
5656
5655
  "node:crypto": () => require$$2$2,
5657
- "node:sqlite": () => require$$1$1
5656
+ "node:sqlite": () => require("node:sqlite")
5658
5657
  };
5659
5658
  function detectRuntimeFeatureByNodeModule(moduleName) {
5660
5659
  try {
@@ -12557,7 +12556,7 @@ function requireApiRequest() {
12557
12556
  if (hasRequiredApiRequest) return apiRequest.exports;
12558
12557
  hasRequiredApiRequest = 1;
12559
12558
  const assert = require$$0$1;
12560
- const { AsyncResource } = require$$1$2;
12559
+ const { AsyncResource } = require$$1$1;
12561
12560
  const { Readable } = requireReadable();
12562
12561
  const { InvalidArgumentError, RequestAbortedError } = requireErrors();
12563
12562
  const util2 = requireUtil$5();
@@ -12810,7 +12809,7 @@ function requireApiStream() {
12810
12809
  if (hasRequiredApiStream) return apiStream;
12811
12810
  hasRequiredApiStream = 1;
12812
12811
  const assert = require$$0$1;
12813
- const { AsyncResource } = require$$1$2;
12812
+ const { AsyncResource } = require$$1$1;
12814
12813
  const { InvalidArgumentError, InvalidReturnValueError } = requireErrors();
12815
12814
  const util2 = requireUtil$5();
12816
12815
  const { addSignal, removeSignal } = requireAbortSignal();
@@ -13025,7 +13024,7 @@ function requireApiPipeline() {
13025
13024
  PassThrough
13026
13025
  } = require$$0$2;
13027
13026
  const assert = require$$0$1;
13028
- const { AsyncResource } = require$$1$2;
13027
+ const { AsyncResource } = require$$1$1;
13029
13028
  const {
13030
13029
  InvalidArgumentError,
13031
13030
  InvalidReturnValueError,
@@ -13227,7 +13226,7 @@ function requireApiUpgrade() {
13227
13226
  if (hasRequiredApiUpgrade) return apiUpgrade;
13228
13227
  hasRequiredApiUpgrade = 1;
13229
13228
  const { InvalidArgumentError, SocketError } = requireErrors();
13230
- const { AsyncResource } = require$$1$2;
13229
+ const { AsyncResource } = require$$1$1;
13231
13230
  const assert = require$$0$1;
13232
13231
  const util2 = requireUtil$5();
13233
13232
  const { kHTTP2Stream } = requireSymbols();
@@ -13327,7 +13326,7 @@ function requireApiConnect() {
13327
13326
  if (hasRequiredApiConnect) return apiConnect;
13328
13327
  hasRequiredApiConnect = 1;
13329
13328
  const assert = require$$0$1;
13330
- const { AsyncResource } = require$$1$2;
13329
+ const { AsyncResource } = require$$1$1;
13331
13330
  const { InvalidArgumentError, SocketError } = requireErrors();
13332
13331
  const util2 = requireUtil$5();
13333
13332
  const { addSignal, removeSignal } = requireAbortSignal();
@@ -14543,7 +14542,7 @@ function requirePendingInterceptorsFormatter() {
14543
14542
  if (hasRequiredPendingInterceptorsFormatter) return pendingInterceptorsFormatter;
14544
14543
  hasRequiredPendingInterceptorsFormatter = 1;
14545
14544
  const { Transform } = require$$0$2;
14546
- const { Console } = require$$1$3;
14545
+ const { Console } = require$$1$2;
14547
14546
  const PERSISTENT = process.versions.icu ? "✅" : "Y ";
14548
14547
  const NOT_PERSISTENT = process.versions.icu ? "❌" : "N ";
14549
14548
  pendingInterceptorsFormatter = class PendingInterceptorsFormatter {
@@ -14854,7 +14853,7 @@ function requireSnapshotRecorder() {
14854
14853
  if (hasRequiredSnapshotRecorder) return snapshotRecorder;
14855
14854
  hasRequiredSnapshotRecorder = 1;
14856
14855
  const { writeFile, readFile, mkdir } = require$$0$7;
14857
- const { dirname, resolve } = require$$1$4;
14856
+ const { dirname, resolve } = require$$1$3;
14858
14857
  const { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = require$$2$3;
14859
14858
  const { InvalidArgumentError, UndiciError } = requireErrors();
14860
14859
  const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } = requireSnapshotUtils();
@@ -16040,7 +16039,7 @@ function requireDns() {
16040
16039
  if (hasRequiredDns) return dns;
16041
16040
  hasRequiredDns = 1;
16042
16041
  const { isIP: isIP2 } = require$$1;
16043
- const { lookup } = require$$1$5;
16042
+ const { lookup } = require$$1$4;
16044
16043
  const DecoratorHandler = requireDecoratorHandler();
16045
16044
  const { InvalidArgumentError, InformationalError } = requireErrors();
16046
16045
  const maxInt = Math.pow(2, 31) - 1;
@@ -19506,7 +19505,7 @@ function requireSqliteCacheStore() {
19506
19505
  }
19507
19506
  }
19508
19507
  if (!DatabaseSync) {
19509
- DatabaseSync = require$$1$1.DatabaseSync;
19508
+ DatabaseSync = require("node:sqlite").DatabaseSync;
19510
19509
  }
19511
19510
  this.#db = new DatabaseSync(opts?.location ?? ":memory:");
19512
19511
  this.#db.exec(`
@@ -27515,6 +27514,13 @@ function tryParseJson(text) {
27515
27514
  return null;
27516
27515
  }
27517
27516
  }
27517
+ function parseJsonOrExplain(raw, origin) {
27518
+ try {
27519
+ return JSON.parse(raw);
27520
+ } catch (error) {
27521
+ throw new Error(`${origin} is not valid JSON: ${error.message}`);
27522
+ }
27523
+ }
27518
27524
  function parseJsonLoose(text) {
27519
27525
  const trimmed = text.trim();
27520
27526
  const direct = tryParseJson(trimmed);
@@ -27549,6 +27555,49 @@ function parseBraceSlice(trimmed) {
27549
27555
  function truncate(text, max = 300) {
27550
27556
  return text.length > max ? `${text.slice(0, max)}...` : text;
27551
27557
  }
27558
+ function parseExtraBody(raw, origin) {
27559
+ const parsed = parseJsonOrExplain(raw, origin);
27560
+ if (!isPlainObject(parsed)) {
27561
+ throw new Error(
27562
+ `${origin} must be a JSON object, for example {"thinking":{"type":"disabled"}}`
27563
+ );
27564
+ }
27565
+ return parsed;
27566
+ }
27567
+ function mergeExtraBody(body2, extra, reserved, providerName) {
27568
+ if (!extra || Object.keys(extra).length === 0) {
27569
+ return body2;
27570
+ }
27571
+ for (const path2 of reserved) {
27572
+ if (hasPath(extra, path2)) {
27573
+ throw new Error(
27574
+ `extraBody cannot override "${path2}" for the ${providerName} provider: it carries the image, the prompt, or the schema this tool depends on. Remove that field from ${providerName}.extraBody (or --extra-body).`
27575
+ );
27576
+ }
27577
+ }
27578
+ return deepMerge(body2, extra);
27579
+ }
27580
+ function deepMerge(base, overlay) {
27581
+ const merged = { ...base };
27582
+ for (const [key, value] of Object.entries(overlay)) {
27583
+ const current = merged[key];
27584
+ merged[key] = isPlainObject(current) && isPlainObject(value) ? deepMerge(current, value) : value;
27585
+ }
27586
+ return merged;
27587
+ }
27588
+ function hasPath(value, dottedPath) {
27589
+ let cursor = value;
27590
+ for (const segment of dottedPath.split(".")) {
27591
+ if (!isPlainObject(cursor) || !Object.hasOwn(cursor, segment)) {
27592
+ return false;
27593
+ }
27594
+ cursor = cursor[segment];
27595
+ }
27596
+ return true;
27597
+ }
27598
+ function isPlainObject(value) {
27599
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27600
+ }
27552
27601
  const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
27553
27602
  const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
27554
27603
  const TOOL_NAME = "report_vision_evidence";
@@ -27584,27 +27633,34 @@ Report your findings by calling the ${TOOL_NAME} tool.`;
27584
27633
  "anthropic-version": "2023-06-01",
27585
27634
  "Content-Type": "application/json"
27586
27635
  },
27587
- body: JSON.stringify({
27588
- model,
27589
- max_tokens: 4096,
27590
- tools: [
27591
- {
27592
- name: TOOL_NAME,
27593
- description: "Report the structured visual evidence extracted from the image.",
27594
- input_schema: VISION_RESULT_SCHEMA
27595
- }
27596
- ],
27597
- tool_choice: { type: "tool", name: TOOL_NAME },
27598
- messages: [
27636
+ body: JSON.stringify(
27637
+ mergeExtraBody(
27599
27638
  {
27600
- role: "user",
27601
- content: [
27602
- { type: "image", source: imageSource },
27603
- { type: "text", text: prompt }
27639
+ model,
27640
+ max_tokens: 4096,
27641
+ tools: [
27642
+ {
27643
+ name: TOOL_NAME,
27644
+ description: "Report the structured visual evidence extracted from the image.",
27645
+ input_schema: VISION_RESULT_SCHEMA
27646
+ }
27647
+ ],
27648
+ tool_choice: { type: "tool", name: TOOL_NAME },
27649
+ messages: [
27650
+ {
27651
+ role: "user",
27652
+ content: [
27653
+ { type: "image", source: imageSource },
27654
+ { type: "text", text: prompt }
27655
+ ]
27656
+ }
27604
27657
  ]
27605
- }
27606
- ]
27607
- }),
27658
+ },
27659
+ options.settings?.extraBody,
27660
+ ["model", "messages", "tools", "tool_choice", "stream"],
27661
+ "anthropic"
27662
+ )
27663
+ ),
27608
27664
  signal: AbortSignal.timeout(options.timeoutMs)
27609
27665
  });
27610
27666
  if (!response2.ok) {
@@ -27871,20 +27927,31 @@ async function executeGeminiApi(options) {
27871
27927
  "x-goog-api-key": apiKey,
27872
27928
  "Content-Type": "application/json"
27873
27929
  },
27874
- body: JSON.stringify({
27875
- contents: [
27930
+ body: JSON.stringify(
27931
+ mergeExtraBody(
27876
27932
  {
27877
- parts: [
27878
- { inline_data: { mime_type: image.mimeType, data: image.data } },
27879
- { text: prompt }
27880
- ]
27881
- }
27882
- ],
27883
- generationConfig: {
27884
- responseMimeType: "application/json",
27885
- responseJsonSchema: VISION_RESULT_SCHEMA
27886
- }
27887
- }),
27933
+ contents: [
27934
+ {
27935
+ parts: [
27936
+ { inline_data: { mime_type: image.mimeType, data: image.data } },
27937
+ { text: prompt }
27938
+ ]
27939
+ }
27940
+ ],
27941
+ generationConfig: {
27942
+ responseMimeType: "application/json",
27943
+ responseJsonSchema: VISION_RESULT_SCHEMA
27944
+ }
27945
+ },
27946
+ options.settings?.extraBody,
27947
+ [
27948
+ "contents",
27949
+ "generationConfig.responseMimeType",
27950
+ "generationConfig.responseJsonSchema"
27951
+ ],
27952
+ "gemini-api"
27953
+ )
27954
+ ),
27888
27955
  signal: AbortSignal.timeout(options.timeoutMs)
27889
27956
  });
27890
27957
  if (!response2.ok) {
@@ -27941,18 +28008,25 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
27941
28008
  Authorization: `Bearer ${apiKey}`,
27942
28009
  "Content-Type": "application/json"
27943
28010
  },
27944
- body: JSON.stringify({
27945
- model,
27946
- messages: [
28011
+ body: JSON.stringify(
28012
+ mergeExtraBody(
27947
28013
  {
27948
- role: "user",
27949
- content: [
27950
- { type: "image_url", image_url: { url: imageUrl } },
27951
- { type: "text", text: prompt }
28014
+ model,
28015
+ messages: [
28016
+ {
28017
+ role: "user",
28018
+ content: [
28019
+ { type: "image_url", image_url: { url: imageUrl } },
28020
+ { type: "text", text: prompt }
28021
+ ]
28022
+ }
27952
28023
  ]
27953
- }
27954
- ]
27955
- }),
28024
+ },
28025
+ options.settings?.extraBody,
28026
+ ["model", "messages", "stream"],
28027
+ "openai"
28028
+ )
28029
+ ),
27956
28030
  signal: AbortSignal.timeout(options.timeoutMs)
27957
28031
  });
27958
28032
  if (!response2.ok) {
@@ -28022,6 +28096,7 @@ function providerAliases() {
28022
28096
  function listProviders() {
28023
28097
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
28024
28098
  }
28099
+ const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
28025
28100
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
28026
28101
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
28027
28102
  const ENV_BINDINGS = {
@@ -28073,21 +28148,37 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28073
28148
  const config2 = loadConfigFile(configPath);
28074
28149
  if (dottedKey === "provider") {
28075
28150
  config2.provider = value;
28151
+ } else if (dottedKey.startsWith("guards.")) {
28152
+ setGuardsValue(config2, dottedKey.slice("guards.".length), value);
28076
28153
  } else {
28077
28154
  const dot = dottedKey.indexOf(".");
28078
28155
  if (dot <= 0 || dot === dottedKey.length - 1) {
28079
28156
  throw new Error(
28080
- `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
28157
+ `Invalid config key: ${dottedKey}. Use "provider", "guards.<denyModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28081
28158
  );
28082
28159
  }
28083
28160
  const providerName = dottedKey.slice(0, dot);
28084
28161
  const field = dottedKey.slice(dot + 1);
28085
- if (!["apiKey", "baseUrl", "model"].includes(field)) {
28086
- throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
28162
+ if (field === "extraBody") {
28163
+ config2.providers ??= {};
28164
+ config2.providers[providerName] ??= {};
28165
+ if (value.trim() === "") {
28166
+ delete config2.providers[providerName].extraBody;
28167
+ } else {
28168
+ config2.providers[providerName].extraBody = parseExtraBody(
28169
+ value,
28170
+ `${providerName}.extraBody`
28171
+ );
28172
+ }
28173
+ } else if (!STRING_FIELDS.includes(field)) {
28174
+ throw new Error(
28175
+ `Unknown config field: ${field}. Use apiKey, baseUrl, model, or extraBody.`
28176
+ );
28177
+ } else {
28178
+ config2.providers ??= {};
28179
+ config2.providers[providerName] ??= {};
28180
+ config2.providers[providerName][field] = value;
28087
28181
  }
28088
- config2.providers ??= {};
28089
- config2.providers[providerName] ??= {};
28090
- config2.providers[providerName][field] = value;
28091
28182
  }
28092
28183
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
28093
28184
  fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
@@ -28097,6 +28188,38 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28097
28188
  } catch {
28098
28189
  }
28099
28190
  }
28191
+ function setGuardsValue(config2, field, value) {
28192
+ if (field === "denyModels") {
28193
+ if (value.trim() === "") {
28194
+ delete config2.guards?.denyModels;
28195
+ } else {
28196
+ config2.guards ??= {};
28197
+ config2.guards.denyModels = parseModelList(value);
28198
+ }
28199
+ } else if (field === "denyWhenUnknown") {
28200
+ const normalized = value.trim().toLowerCase();
28201
+ if (normalized !== "true" && normalized !== "false") {
28202
+ throw new Error("guards.denyWhenUnknown must be true or false.");
28203
+ }
28204
+ config2.guards ??= {};
28205
+ config2.guards.denyWhenUnknown = normalized === "true";
28206
+ } else {
28207
+ throw new Error(`Unknown guards field: ${field}. Use denyModels or denyWhenUnknown.`);
28208
+ }
28209
+ if (config2.guards && Object.keys(config2.guards).length === 0) {
28210
+ delete config2.guards;
28211
+ }
28212
+ }
28213
+ function parseModelList(value) {
28214
+ if (value.trim().startsWith("[")) {
28215
+ const parsed = parseJsonOrExplain(value, "guards.denyModels");
28216
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
28217
+ throw new Error("guards.denyModels must be a JSON array of glob strings.");
28218
+ }
28219
+ return parsed;
28220
+ }
28221
+ return value.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
28222
+ }
28100
28223
  const CONFIG_TEMPLATE = {
28101
28224
  // Empty means the built-in default provider.
28102
28225
  provider: "",
@@ -28126,7 +28249,7 @@ function renderEffectiveConfig(config2, env = process.env) {
28126
28249
  const fileSettings = config2.providers?.[name] ?? {};
28127
28250
  const bindings = ENV_BINDINGS[name] ?? {};
28128
28251
  const fields = {};
28129
- for (const field of ["apiKey", "baseUrl", "model"]) {
28252
+ for (const field of STRING_FIELDS) {
28130
28253
  const envName = bindings[field];
28131
28254
  const envValue = envName ? env[envName]?.trim() : void 0;
28132
28255
  const value = envValue ?? fileSettings[field];
@@ -28136,6 +28259,9 @@ function renderEffectiveConfig(config2, env = process.env) {
28136
28259
  fields[field] = `${shown} (${source})`;
28137
28260
  }
28138
28261
  }
28262
+ if (fileSettings.extraBody !== void 0) {
28263
+ fields.extraBody = `${JSON.stringify(fileSettings.extraBody)} (file)`;
28264
+ }
28139
28265
  if (Object.keys(fields).length > 0) {
28140
28266
  providers[name] = fields;
28141
28267
  }
@@ -28146,6 +28272,18 @@ function renderEffectiveConfig(config2, env = process.env) {
28146
28272
  if (config2.provider?.trim()) {
28147
28273
  effective.provider = config2.provider.trim();
28148
28274
  }
28275
+ if (config2.guards) {
28276
+ const guards = {};
28277
+ if (config2.guards.denyModels !== void 0) {
28278
+ guards.denyModels = `${JSON.stringify(config2.guards.denyModels)} (file)`;
28279
+ }
28280
+ if (config2.guards.denyWhenUnknown !== void 0) {
28281
+ guards.denyWhenUnknown = `${config2.guards.denyWhenUnknown} (file)`;
28282
+ }
28283
+ if (Object.keys(guards).length > 0) {
28284
+ effective.guards = guards;
28285
+ }
28286
+ }
28149
28287
  return JSON.stringify(effective, null, 2);
28150
28288
  }
28151
28289
  function maskKey(key) {
@@ -28273,7 +28411,8 @@ async function analyzeImage(options) {
28273
28411
  options,
28274
28412
  resolvedInput,
28275
28413
  timeoutMs,
28276
- config2
28414
+ config2,
28415
+ warnings
28277
28416
  );
28278
28417
  attempts.push({
28279
28418
  provider: provider.name,
@@ -28323,8 +28462,14 @@ async function analyzeImage(options) {
28323
28462
  `Every configured vision provider failed for this image. ${attempts.map((attempt) => `${attempt.provider}: ${attempt.error}`).join(" | ")}`
28324
28463
  );
28325
28464
  }
28326
- async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2) {
28327
- const settings = resolveProviderSettings(provider.name, config2);
28465
+ async function runProvider(provider, model, options, resolvedInput, timeoutMs, config2, warnings) {
28466
+ const configured = resolveProviderSettings(provider.name, config2);
28467
+ const settings = options.extraBody ? { ...configured, extraBody: options.extraBody } : configured;
28468
+ if (settings.extraBody && !provider.execute) {
28469
+ warnings.push(
28470
+ `${provider.name} is a CLI provider and takes no request body, so extraBody was ignored for this run.`
28471
+ );
28472
+ }
28328
28473
  const providerOptions = {
28329
28474
  imageSource: resolvedInput.source,
28330
28475
  imageKind: resolvedInput.kind,
@@ -28579,192 +28724,15 @@ function detectHarnessDetailed() {
28579
28724
  function detectHarness() {
28580
28725
  return detectHarnessDetailed().harness;
28581
28726
  }
28582
- const MIN_NODE = "22.13";
28583
- function versionParts(version) {
28584
- const match = /(\d+)\.(\d+)/.exec(version.replace(/^v/, ""));
28585
- if (!match) {
28586
- return [0, 0];
28587
- }
28588
- return [Number(match[1]), Number(match[2])];
28589
- }
28590
- function meetsMinimum(version, minimum) {
28591
- const [major, minor] = versionParts(version);
28592
- const [minMajor, minMinor] = versionParts(minimum);
28593
- return major > minMajor || major === minMajor && minor >= minMinor;
28594
- }
28595
- function checkNodeSqlite() {
28596
- const realEmit = process.emitWarning;
28597
- process.emitWarning = () => {
28598
- };
28599
- try {
28600
- const mod = createRequire(import.meta.url)("node:sqlite");
28601
- if (mod?.DatabaseSync) {
28602
- return {
28603
- available: true,
28604
- detail: "node:sqlite is available (OpenCode paste recovery)"
28605
- };
28606
- }
28607
- return { available: false, detail: "node:sqlite loaded but DatabaseSync is missing" };
28608
- } catch {
28609
- return {
28610
- available: false,
28611
- detail: "node:sqlite unavailable. Upgrade Node to 22.13+ for OpenCode paste recovery"
28612
- };
28613
- } finally {
28614
- process.emitWarning = realEmit;
28615
- }
28616
- }
28617
- function inspectProvider(descriptor, config2, env) {
28618
- if (descriptor.kind === "subprocess") {
28619
- const binaryPath = findOnPath(descriptor.bin, env);
28620
- return {
28621
- name: descriptor.name,
28622
- kind: "subprocess",
28623
- ready: binaryPath !== null,
28624
- binaryPath,
28625
- detail: binaryPath ? `${descriptor.bin} found at ${binaryPath}` : `${descriptor.bin} not on PATH`,
28626
- fix: binaryPath ? void 0 : descriptor.install
28627
- };
28628
- }
28629
- const settings = resolveProviderSettings(descriptor.name, config2, env);
28630
- const statuses = (descriptor.required ?? []).map((req) => {
28631
- const envValue = req.env ? env[req.env]?.trim() : void 0;
28632
- const value = settings[req.field]?.trim();
28633
- const source = envValue ? "env" : value ? "file" : "missing";
28634
- return { field: req.field, present: Boolean(value), source, env: req.env };
28635
- });
28636
- const missing = statuses.filter((s) => !s.present).map((s) => s.field);
28637
- const ready = missing.length === 0;
28638
- const detail = ready ? statuses.map((s) => `${s.field}: ${s.source}`).join(", ") : `missing: ${missing.join(", ")}`;
28639
- return {
28640
- name: descriptor.name,
28641
- kind: "api",
28642
- ready,
28643
- settings: statuses,
28644
- detail,
28645
- fix: ready ? void 0 : descriptor.fix
28646
- };
28647
- }
28648
- function resolveSelection(config2, providerFlag) {
28649
- const raw = providerFlag?.trim() || config2.provider?.trim() || "antigravity-cli";
28650
- const source = providerFlag?.trim() ? "flag" : config2.provider?.trim() ? "config" : "default";
28651
- let canonical;
28652
- try {
28653
- canonical = resolveProvider(raw).name;
28654
- } catch {
28655
- canonical = null;
28656
- }
28657
- const reason = source === "flag" ? `-p ${raw} on the command line` : source === "config" ? "provider set in the config file" : "built-in default (no -p flag and no provider in the config file)";
28658
- return { provider: raw, canonical, source, reason };
28659
- }
28660
- function inspectConfigFile(configPath) {
28661
- try {
28662
- const stat = fs.statSync(configPath);
28663
- const mode = stat.mode & 511;
28664
- const enforcesPosixPerms = typeof process.getuid === "function";
28665
- const permissionsOk = !enforcesPosixPerms || (mode & 63) === 0;
28666
- return {
28667
- path: configPath,
28668
- exists: true,
28669
- mode: mode.toString(8).padStart(3, "0"),
28670
- permissionsOk,
28671
- note: permissionsOk ? void 0 : "group/world can read this file. Run: chmod 600 to lock it down"
28672
- };
28673
- } catch (error) {
28674
- if (error.code === "ENOENT") {
28675
- return {
28676
- path: configPath,
28677
- exists: false,
28678
- mode: null,
28679
- permissionsOk: true,
28680
- note: "no config file (using env vars and built-in defaults)"
28681
- };
28682
- }
28683
- return {
28684
- path: configPath,
28685
- exists: true,
28686
- mode: null,
28687
- permissionsOk: false,
28688
- note: `cannot stat: ${error.message}`
28689
- };
28690
- }
28691
- }
28692
- function buildDoctorReport(input) {
28693
- const env = input.env ?? process.env;
28694
- const configPath = input.configPath ?? CONFIG_PATH;
28695
- return {
28696
- node: {
28697
- version: process.version,
28698
- minimum: MIN_NODE,
28699
- meetsMinimum: meetsMinimum(process.version, MIN_NODE)
28700
- },
28701
- nodeSqlite: checkNodeSqlite(),
28702
- providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
28703
- selection: resolveSelection(input.config, input.providerFlag),
28704
- chains: {
28705
- local: providerChain("local", input.config, env).map((p) => p.name),
28706
- remote: providerChain("remote", input.config, env).map((p) => p.name)
28707
- },
28708
- harness: (() => {
28709
- const detection = detectHarnessDetailed();
28710
- return { detected: detection.harness, source: detection.source };
28711
- })(),
28712
- config: inspectConfigFile(configPath)
28713
- };
28714
- }
28715
- function mark(ok) {
28716
- return ok ? "[ok]" : "[!!]";
28717
- }
28718
- function renderDoctorReport(report) {
28719
- const lines = [];
28720
- lines.push("modlens doctor");
28721
- lines.push("(local diagnostics only: no network calls, no provider quota spent)");
28722
- lines.push("");
28723
- lines.push("Node");
28724
- lines.push(
28725
- ` ${mark(report.node.meetsMinimum)} ${report.node.version} (minimum ${report.node.minimum})`
28726
- );
28727
- lines.push(` ${mark(report.nodeSqlite.available)} ${report.nodeSqlite.detail}`);
28728
- lines.push("");
28729
- lines.push("Providers");
28730
- for (const provider of report.providers) {
28731
- lines.push(` ${mark(provider.ready)} ${provider.name}: ${provider.detail}`);
28732
- if (provider.fix) {
28733
- lines.push(` fix: ${provider.fix}`);
28734
- }
28735
- }
28736
- lines.push("");
28737
- lines.push("Selected provider");
28738
- const canonicalNote = report.selection.canonical && report.selection.canonical !== report.selection.provider ? ` (canonical: ${report.selection.canonical})` : report.selection.canonical === null ? " (unknown provider name)" : "";
28739
- lines.push(` ${report.selection.provider}${canonicalNote}`);
28740
- lines.push(` reason: ${report.selection.reason}`);
28741
- lines.push("");
28742
- lines.push("Failover chains (what a run tries, in order)");
28743
- const chainLine = (chain) => chain.length > 0 ? chain.join(" -> ") : "(none available)";
28744
- lines.push(` local: ${chainLine(report.chains.local)}`);
28745
- lines.push(` remote: ${chainLine(report.chains.remote)}`);
28746
- lines.push("");
28747
- lines.push("Harness");
28748
- lines.push(
28749
- report.harness.detected ? ` ${report.harness.detected} (via ${report.harness.source})` : ` none detected (${report.harness.source})`
28750
- );
28751
- lines.push("");
28752
- lines.push("Config file");
28753
- lines.push(` path: ${report.config.path}`);
28754
- if (report.config.exists) {
28755
- lines.push(
28756
- ` ${mark(report.config.permissionsOk)} exists, mode ${report.config.mode ?? "?"}`
28757
- );
28758
- } else {
28759
- lines.push(" not present");
28760
- }
28761
- if (report.config.note) {
28762
- lines.push(` note: ${report.config.note}`);
28727
+ function cwdMatches(recorded, wanted, bothDirections = false) {
28728
+ const resolvedRecorded = path.resolve(recorded);
28729
+ const resolvedWanted = path.resolve(wanted);
28730
+ if (resolvedRecorded === resolvedWanted || resolvedRecorded.startsWith(`${resolvedWanted}${path.sep}`)) {
28731
+ return true;
28763
28732
  }
28764
- return lines.join("\n");
28733
+ return bothDirections && resolvedWanted.startsWith(`${resolvedRecorded}${path.sep}`);
28765
28734
  }
28766
- function transcriptBelongsTo(lines, cwd) {
28767
- const wanted = path.resolve(cwd);
28735
+ function transcriptBelongsTo(lines, cwd, bothDirections = false) {
28768
28736
  let sawCwd = false;
28769
28737
  for (const line of lines) {
28770
28738
  if (!line.includes('"cwd"')) {
@@ -28776,8 +28744,7 @@ function transcriptBelongsTo(lines, cwd) {
28776
28744
  continue;
28777
28745
  }
28778
28746
  sawCwd = true;
28779
- const resolved = path.resolve(recorded);
28780
- if (resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`)) {
28747
+ if (cwdMatches(recorded, cwd, bothDirections)) {
28781
28748
  return true;
28782
28749
  }
28783
28750
  } catch {
@@ -28844,8 +28811,17 @@ function listJsonl(dir) {
28844
28811
  return [];
28845
28812
  }
28846
28813
  }
28847
- function jsonlAdapter(options) {
28848
- const { name, dirFor, matchesSession, extractLine } = options;
28814
+ function listJsonlByMtimeDesc(dir) {
28815
+ return listJsonl(dir).map((file) => {
28816
+ try {
28817
+ return { file, mtime: fs.statSync(file).mtimeMs };
28818
+ } catch {
28819
+ return null;
28820
+ }
28821
+ }).filter((entry) => entry !== null).sort((a, b) => b.mtime - a.mtime).map((entry) => entry.file);
28822
+ }
28823
+ function jsonlAdapter(options) {
28824
+ const { name, dirFor, matchesSession, extractLine } = options;
28849
28825
  return {
28850
28826
  name,
28851
28827
  describe: (cwd) => dirFor(cwd),
@@ -28906,13 +28882,19 @@ function opencodeDbPath() {
28906
28882
  function escapeLikePattern(value) {
28907
28883
  return value.replace(/[\\%_]/g, (char) => `\\${char}`);
28908
28884
  }
28909
- function buildOpencodeQuery(resolvedCwd, sessionId) {
28885
+ function opencodeDirectoryFilter(resolvedCwd) {
28910
28886
  const normalized = resolvedCwd.replace(/\\/g, "/");
28911
28887
  const escaped = escapeLikePattern(normalized);
28912
28888
  const dir = `REPLACE(session.directory, '\\', '/')`;
28913
- const directoryFilter = `(${dir} = ? OR ${dir} LIKE ? || '/%' ESCAPE '\\' OR ? LIKE ${dir} || '/%' ESCAPE '\\')`;
28914
- const sessionFilter = sessionId ? `AND ${directoryFilter} AND (session.id = ? OR session.slug = ?)` : `AND ${directoryFilter}`;
28915
- const params = sessionId ? [normalized, escaped, normalized, sessionId, sessionId] : [normalized, escaped, normalized];
28889
+ return {
28890
+ clause: `(${dir} = ? OR ${dir} LIKE ? || '/%' ESCAPE '\\' OR ? LIKE ${dir} || '/%' ESCAPE '\\')`,
28891
+ params: [normalized, escaped, normalized]
28892
+ };
28893
+ }
28894
+ function buildOpencodeQuery(resolvedCwd, sessionId) {
28895
+ const directory = opencodeDirectoryFilter(resolvedCwd);
28896
+ const sessionFilter = sessionId ? `AND ${directory.clause} AND (session.id = ? OR session.slug = ?)` : `AND ${directory.clause}`;
28897
+ const params = sessionId ? [...directory.params, sessionId, sessionId] : directory.params;
28916
28898
  const sql = `SELECT part.data AS data, part.time_created AS time_created, part.session_id AS session_id
28917
28899
  FROM part
28918
28900
  JOIN message ON message.id = part.message_id
@@ -28923,12 +28905,17 @@ function buildOpencodeQuery(resolvedCwd, sessionId) {
28923
28905
  ORDER BY part.time_created ASC`;
28924
28906
  return { sql, params };
28925
28907
  }
28926
- function opencodeQuery(dbPath, cwd, sessionId) {
28927
- let DatabaseSync;
28908
+ function loadNodeSqlite() {
28928
28909
  try {
28929
28910
  const nodeRequire = createRequire(import.meta.url);
28930
- ({ DatabaseSync } = nodeRequire("node:sqlite"));
28911
+ return nodeRequire("node:sqlite").DatabaseSync;
28931
28912
  } catch {
28913
+ return null;
28914
+ }
28915
+ }
28916
+ function opencodeQuery(dbPath, cwd, sessionId) {
28917
+ const DatabaseSync = loadNodeSqlite();
28918
+ if (!DatabaseSync) {
28932
28919
  throw new Error(
28933
28920
  "Reading opencode storage needs the node:sqlite module (unflagged on Node 22.13+). Upgrade Node, or pass --transcript/--session for a JSONL-based harness."
28934
28921
  );
@@ -29029,6 +29016,541 @@ const piAdapter = jsonlAdapter({
29029
29016
  matchesSession: (fileName, sessionId) => fileName.endsWith(`_${sessionId}.jsonl`),
29030
29017
  extractLine: piExtractLine
29031
29018
  });
29019
+ const WINDOW_BYTES = 512 * 1024;
29020
+ function readWindowedLines(file, maxBytes = WINDOW_BYTES) {
29021
+ let fd;
29022
+ try {
29023
+ fd = fs.openSync(file, "r");
29024
+ } catch {
29025
+ return null;
29026
+ }
29027
+ try {
29028
+ const size = fs.fstatSync(fd).size;
29029
+ if (size <= 2 * maxBytes) {
29030
+ const whole = Buffer.alloc(size);
29031
+ fs.readSync(fd, whole, 0, size, 0);
29032
+ return whole.toString("utf-8").split("\n");
29033
+ }
29034
+ const head = Buffer.alloc(maxBytes);
29035
+ fs.readSync(fd, head, 0, maxBytes, 0);
29036
+ const tail = Buffer.alloc(maxBytes);
29037
+ fs.readSync(fd, tail, 0, maxBytes, size - maxBytes);
29038
+ const headLines = head.toString("utf-8").split("\n");
29039
+ headLines.pop();
29040
+ const tailLines = tail.toString("utf-8").split("\n");
29041
+ tailLines.shift();
29042
+ return [...headLines, ...tailLines];
29043
+ } catch {
29044
+ return null;
29045
+ } finally {
29046
+ fs.closeSync(fd);
29047
+ }
29048
+ }
29049
+ function lastAssistantModelFromLines(lines) {
29050
+ for (let i = lines.length - 1; i >= 0; i--) {
29051
+ const line = lines[i];
29052
+ if (!line.includes('"model"')) {
29053
+ continue;
29054
+ }
29055
+ try {
29056
+ const message = JSON.parse(line).message;
29057
+ if (message?.role === "assistant" && typeof message.model === "string") {
29058
+ const found = { model: message.model };
29059
+ if (typeof message.provider === "string") {
29060
+ found.provider = message.provider;
29061
+ }
29062
+ return found;
29063
+ }
29064
+ } catch {
29065
+ }
29066
+ }
29067
+ return null;
29068
+ }
29069
+ function lastCodexModelFromLines(lines) {
29070
+ for (let i = lines.length - 1; i >= 0; i--) {
29071
+ const line = lines[i];
29072
+ if (!line.includes('"turn_context"')) {
29073
+ continue;
29074
+ }
29075
+ try {
29076
+ const parsed = JSON.parse(line);
29077
+ if (parsed.type === "turn_context" && typeof parsed.payload?.model === "string") {
29078
+ return parsed.payload.model;
29079
+ }
29080
+ } catch {
29081
+ }
29082
+ }
29083
+ return null;
29084
+ }
29085
+ function codexTranscriptBelongsTo(lines, cwd) {
29086
+ for (const line of lines) {
29087
+ if (!line.includes('"cwd"')) {
29088
+ continue;
29089
+ }
29090
+ try {
29091
+ const recorded = JSON.parse(line).payload?.cwd;
29092
+ if (typeof recorded === "string" && cwdMatches(recorded, cwd, true)) {
29093
+ return true;
29094
+ }
29095
+ } catch {
29096
+ }
29097
+ }
29098
+ return false;
29099
+ }
29100
+ function* cwdAncestors(cwd) {
29101
+ let current = path.resolve(cwd);
29102
+ for (; ; ) {
29103
+ yield current;
29104
+ const parent = path.dirname(current);
29105
+ if (parent === current) {
29106
+ return;
29107
+ }
29108
+ current = parent;
29109
+ }
29110
+ }
29111
+ function newestAssistantModelInDir(dir, cwd) {
29112
+ for (const file of listJsonlByMtimeDesc(dir)) {
29113
+ const lines = readWindowedLines(file);
29114
+ if (!lines || !transcriptBelongsTo(lines, cwd, true)) {
29115
+ continue;
29116
+ }
29117
+ const found = lastAssistantModelFromLines(lines);
29118
+ if (found) {
29119
+ return found;
29120
+ }
29121
+ }
29122
+ return null;
29123
+ }
29124
+ function existingSlugDirs(root, cwd, slugFor) {
29125
+ const dirs = [];
29126
+ for (const ancestor of cwdAncestors(cwd)) {
29127
+ const dir = path.join(root, slugFor(ancestor));
29128
+ if (fs.existsSync(dir)) {
29129
+ dirs.push(dir);
29130
+ }
29131
+ }
29132
+ return dirs;
29133
+ }
29134
+ function sniffClaudeModel(cwd, env, projectsDir = path.join(os.homedir(), ".claude", "projects")) {
29135
+ const dirs = existingSlugDirs(projectsDir, cwd, claudeProjectSlug);
29136
+ const sessionId = env.CLAUDE_CODE_SESSION_ID?.trim();
29137
+ if (sessionId) {
29138
+ for (const dir of dirs) {
29139
+ const lines = readWindowedLines(path.join(dir, `${sessionId}.jsonl`));
29140
+ const pinned = lines ? lastAssistantModelFromLines(lines) : null;
29141
+ if (pinned) {
29142
+ return pinned;
29143
+ }
29144
+ }
29145
+ }
29146
+ for (const dir of dirs) {
29147
+ const found = newestAssistantModelInDir(dir, cwd);
29148
+ if (found) {
29149
+ return found;
29150
+ }
29151
+ }
29152
+ return null;
29153
+ }
29154
+ function sniffPiModel(cwd, sessionsRoot = path.join(os.homedir(), ".pi", "agent", "sessions")) {
29155
+ for (const dir of existingSlugDirs(sessionsRoot, cwd, piSessionSlug)) {
29156
+ const found = newestAssistantModelInDir(dir, cwd);
29157
+ if (found) {
29158
+ return found;
29159
+ }
29160
+ }
29161
+ return null;
29162
+ }
29163
+ const CODEX_SCAN_LIMIT = 20;
29164
+ const CODEX_STAT_LIMIT = 200;
29165
+ function sniffCodexModel(cwd, env, sessionsRoot = path.join(os.homedir(), ".codex", "sessions")) {
29166
+ let names;
29167
+ try {
29168
+ names = fs.readdirSync(sessionsRoot, { recursive: true }).filter((name) => name.endsWith(".jsonl")).sort().reverse();
29169
+ } catch {
29170
+ return null;
29171
+ }
29172
+ const threadId = env.CODEX_THREAD_ID?.trim();
29173
+ if (threadId) {
29174
+ const pinned = names.find((name) => path.basename(name).endsWith(`-${threadId}.jsonl`));
29175
+ if (pinned) {
29176
+ const lines = readWindowedLines(path.join(sessionsRoot, pinned));
29177
+ const model = lines ? lastCodexModelFromLines(lines) : null;
29178
+ if (model) {
29179
+ return model;
29180
+ }
29181
+ }
29182
+ }
29183
+ const byMtimeDesc = names.slice(0, CODEX_STAT_LIMIT).map((name) => {
29184
+ const file = path.join(sessionsRoot, name);
29185
+ try {
29186
+ return { file, mtime: fs.statSync(file).mtimeMs };
29187
+ } catch {
29188
+ return null;
29189
+ }
29190
+ }).filter((entry) => entry !== null).sort((a, b) => b.mtime - a.mtime);
29191
+ for (const entry of byMtimeDesc.slice(0, CODEX_SCAN_LIMIT)) {
29192
+ const lines = readWindowedLines(entry.file);
29193
+ if (!lines || !codexTranscriptBelongsTo(lines, cwd)) {
29194
+ continue;
29195
+ }
29196
+ const model = lastCodexModelFromLines(lines);
29197
+ if (model) {
29198
+ return model;
29199
+ }
29200
+ }
29201
+ return null;
29202
+ }
29203
+ function opencodeModelForCwd(cwd, dbPath = opencodeDbPath()) {
29204
+ if (!fs.existsSync(dbPath)) {
29205
+ return null;
29206
+ }
29207
+ const DatabaseSync = loadNodeSqlite();
29208
+ if (!DatabaseSync) {
29209
+ return null;
29210
+ }
29211
+ const directory = opencodeDirectoryFilter(path.resolve(cwd));
29212
+ const sql = `SELECT message.data AS data
29213
+ FROM message
29214
+ JOIN session ON session.id = message.session_id
29215
+ WHERE json_extract(message.data, '$.role') = 'assistant'
29216
+ AND ${directory.clause}
29217
+ ORDER BY message.time_created DESC
29218
+ LIMIT 1`;
29219
+ const db = new DatabaseSync(dbPath, { readOnly: true });
29220
+ try {
29221
+ const rows = db.prepare(sql).all(...directory.params);
29222
+ if (rows.length === 0) {
29223
+ return null;
29224
+ }
29225
+ const data = JSON.parse(rows[0].data);
29226
+ if (typeof data.modelID !== "string") {
29227
+ return null;
29228
+ }
29229
+ const found = { model: data.modelID };
29230
+ if (typeof data.providerID === "string") {
29231
+ found.provider = data.providerID;
29232
+ }
29233
+ return found;
29234
+ } catch {
29235
+ return null;
29236
+ } finally {
29237
+ db.close();
29238
+ }
29239
+ }
29240
+ function sniffModel(harness, cwd, env, roots = {}) {
29241
+ try {
29242
+ switch (harness) {
29243
+ case "claude-code":
29244
+ return sniffClaudeModel(cwd, env, roots.claudeProjectsDir);
29245
+ case "pi":
29246
+ return sniffPiModel(cwd, roots.piSessionsRoot);
29247
+ case "codex": {
29248
+ const model = sniffCodexModel(cwd, env, roots.codexSessionsRoot);
29249
+ return model ? { model } : null;
29250
+ }
29251
+ case "opencode":
29252
+ return opencodeModelForCwd(cwd, roots.opencodeDb);
29253
+ default:
29254
+ return null;
29255
+ }
29256
+ } catch {
29257
+ return null;
29258
+ }
29259
+ }
29260
+ function denyPatterns(guards) {
29261
+ const raw = guards?.denyModels;
29262
+ if (!Array.isArray(raw)) {
29263
+ return [];
29264
+ }
29265
+ return raw.filter((pattern) => typeof pattern === "string");
29266
+ }
29267
+ function globMatch(pattern, value) {
29268
+ const regex = pattern.split(/([*?])/).map((part) => {
29269
+ if (part === "*") {
29270
+ return ".*";
29271
+ }
29272
+ if (part === "?") {
29273
+ return ".";
29274
+ }
29275
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29276
+ }).join("");
29277
+ return new RegExp(`^${regex}$`, "i").test(value);
29278
+ }
29279
+ function evaluateGuard(guards, detection) {
29280
+ const patterns = denyPatterns(guards);
29281
+ if (!detection.model) {
29282
+ if (guards?.denyWhenUnknown === true) {
29283
+ return {
29284
+ ...detection,
29285
+ guard: "deny",
29286
+ reason: "model unknown and denyWhenUnknown is set"
29287
+ };
29288
+ }
29289
+ return {
29290
+ ...detection,
29291
+ guard: "allow",
29292
+ reason: patterns.length === 0 ? "no deny rules configured" : "model unknown, failing open"
29293
+ };
29294
+ }
29295
+ if (patterns.length === 0) {
29296
+ return { ...detection, guard: "allow", reason: "no deny rules configured" };
29297
+ }
29298
+ const candidates = [detection.model];
29299
+ if (detection.provider) {
29300
+ candidates.push(`${detection.provider}/${detection.model}`);
29301
+ }
29302
+ for (const pattern of patterns) {
29303
+ if (candidates.some((candidate) => globMatch(pattern, candidate))) {
29304
+ return {
29305
+ ...detection,
29306
+ guard: "deny",
29307
+ matched: pattern,
29308
+ reason: "model has native vision per guards.denyModels"
29309
+ };
29310
+ }
29311
+ }
29312
+ return { ...detection, guard: "allow", reason: "not on the deny list" };
29313
+ }
29314
+ function detectActiveModel(options) {
29315
+ const env = options.env ?? process.env;
29316
+ const envModel = env.MODLENS_MODEL?.trim();
29317
+ if (envModel) {
29318
+ return envModel.toLowerCase() === "none" ? { model: null, source: "env" } : { model: envModel, source: "env" };
29319
+ }
29320
+ const forced = env.MODLENS_HARNESS;
29321
+ const harness = forced === "none" ? null : forced || (options.harness !== void 0 ? options.harness : detectHarnessDetailed().harness);
29322
+ const sniffed = harness ? sniffModel(harness, options.cwd, env, options.roots) : null;
29323
+ if (sniffed) {
29324
+ const detection = {
29325
+ model: sniffed.model,
29326
+ source: "storage",
29327
+ harness
29328
+ };
29329
+ if (sniffed.provider) {
29330
+ detection.provider = sniffed.provider;
29331
+ }
29332
+ if (options.selfReported && options.selfReported.toLowerCase() !== sniffed.model.toLowerCase()) {
29333
+ detection.selfReported = options.selfReported;
29334
+ }
29335
+ return detection;
29336
+ }
29337
+ if (options.selfReported) {
29338
+ return { model: options.selfReported, source: "self-report", harness };
29339
+ }
29340
+ return { model: null, source: "none", harness };
29341
+ }
29342
+ function runGuard(guards, options) {
29343
+ if (denyPatterns(guards).length === 0 && guards?.denyWhenUnknown !== true) {
29344
+ return { model: null, source: "none", guard: "allow", reason: "no deny rules configured" };
29345
+ }
29346
+ return evaluateGuard(guards, detectActiveModel(options));
29347
+ }
29348
+ const MIN_NODE = "22.13";
29349
+ function versionParts(version) {
29350
+ const match = /(\d+)\.(\d+)/.exec(version.replace(/^v/, ""));
29351
+ if (!match) {
29352
+ return [0, 0];
29353
+ }
29354
+ return [Number(match[1]), Number(match[2])];
29355
+ }
29356
+ function meetsMinimum(version, minimum) {
29357
+ const [major, minor] = versionParts(version);
29358
+ const [minMajor, minMinor] = versionParts(minimum);
29359
+ return major > minMajor || major === minMajor && minor >= minMinor;
29360
+ }
29361
+ function checkNodeSqlite() {
29362
+ const realEmit = process.emitWarning;
29363
+ process.emitWarning = () => {
29364
+ };
29365
+ try {
29366
+ const mod = createRequire(import.meta.url)("node:sqlite");
29367
+ if (mod?.DatabaseSync) {
29368
+ return {
29369
+ available: true,
29370
+ detail: "node:sqlite is available (OpenCode paste recovery)"
29371
+ };
29372
+ }
29373
+ return { available: false, detail: "node:sqlite loaded but DatabaseSync is missing" };
29374
+ } catch {
29375
+ return {
29376
+ available: false,
29377
+ detail: "node:sqlite unavailable. Upgrade Node to 22.13+ for OpenCode paste recovery"
29378
+ };
29379
+ } finally {
29380
+ process.emitWarning = realEmit;
29381
+ }
29382
+ }
29383
+ function inspectProvider(descriptor, config2, env) {
29384
+ if (descriptor.kind === "subprocess") {
29385
+ const binaryPath = findOnPath(descriptor.bin, env);
29386
+ return {
29387
+ name: descriptor.name,
29388
+ kind: "subprocess",
29389
+ ready: binaryPath !== null,
29390
+ binaryPath,
29391
+ detail: binaryPath ? `${descriptor.bin} found at ${binaryPath}` : `${descriptor.bin} not on PATH`,
29392
+ fix: binaryPath ? void 0 : descriptor.install
29393
+ };
29394
+ }
29395
+ const settings = resolveProviderSettings(descriptor.name, config2, env);
29396
+ const statuses = (descriptor.required ?? []).map((req) => {
29397
+ const envValue = req.env ? env[req.env]?.trim() : void 0;
29398
+ const value = settings[req.field]?.trim();
29399
+ const source = envValue ? "env" : value ? "file" : "missing";
29400
+ return { field: req.field, present: Boolean(value), source, env: req.env };
29401
+ });
29402
+ const missing = statuses.filter((s) => !s.present).map((s) => s.field);
29403
+ const ready = missing.length === 0;
29404
+ const detail = ready ? statuses.map((s) => `${s.field}: ${s.source}`).join(", ") : `missing: ${missing.join(", ")}`;
29405
+ return {
29406
+ name: descriptor.name,
29407
+ kind: "api",
29408
+ ready,
29409
+ settings: statuses,
29410
+ detail,
29411
+ fix: ready ? void 0 : descriptor.fix
29412
+ };
29413
+ }
29414
+ function resolveSelection(config2, providerFlag) {
29415
+ const raw = providerFlag?.trim() || config2.provider?.trim() || "antigravity-cli";
29416
+ const source = providerFlag?.trim() ? "flag" : config2.provider?.trim() ? "config" : "default";
29417
+ let canonical;
29418
+ try {
29419
+ canonical = resolveProvider(raw).name;
29420
+ } catch {
29421
+ canonical = null;
29422
+ }
29423
+ const reason = source === "flag" ? `-p ${raw} on the command line` : source === "config" ? "provider set in the config file" : "built-in default (no -p flag and no provider in the config file)";
29424
+ return { provider: raw, canonical, source, reason };
29425
+ }
29426
+ function inspectConfigFile(configPath) {
29427
+ try {
29428
+ const stat = fs.statSync(configPath);
29429
+ const mode = stat.mode & 511;
29430
+ const enforcesPosixPerms = typeof process.getuid === "function";
29431
+ const permissionsOk = !enforcesPosixPerms || (mode & 63) === 0;
29432
+ return {
29433
+ path: configPath,
29434
+ exists: true,
29435
+ mode: mode.toString(8).padStart(3, "0"),
29436
+ permissionsOk,
29437
+ note: permissionsOk ? void 0 : "group/world can read this file. Run: chmod 600 to lock it down"
29438
+ };
29439
+ } catch (error) {
29440
+ if (error.code === "ENOENT") {
29441
+ return {
29442
+ path: configPath,
29443
+ exists: false,
29444
+ mode: null,
29445
+ permissionsOk: true,
29446
+ note: "no config file (using env vars and built-in defaults)"
29447
+ };
29448
+ }
29449
+ return {
29450
+ path: configPath,
29451
+ exists: true,
29452
+ mode: null,
29453
+ permissionsOk: false,
29454
+ note: `cannot stat: ${error.message}`
29455
+ };
29456
+ }
29457
+ }
29458
+ function buildDoctorReport(input) {
29459
+ const env = input.env ?? process.env;
29460
+ const configPath = input.configPath ?? CONFIG_PATH;
29461
+ const harnessDetection = detectHarnessDetailed();
29462
+ const guardDetection = detectActiveModel({
29463
+ cwd: process.cwd(),
29464
+ env,
29465
+ harness: harnessDetection.harness
29466
+ });
29467
+ const guardVerdict = evaluateGuard(input.config.guards, guardDetection);
29468
+ return {
29469
+ node: {
29470
+ version: process.version,
29471
+ minimum: MIN_NODE,
29472
+ meetsMinimum: meetsMinimum(process.version, MIN_NODE)
29473
+ },
29474
+ nodeSqlite: checkNodeSqlite(),
29475
+ providers: PROVIDER_DESCRIPTORS.map((d) => inspectProvider(d, input.config, env)),
29476
+ selection: resolveSelection(input.config, input.providerFlag),
29477
+ chains: {
29478
+ local: providerChain("local", input.config, env).map((p) => p.name),
29479
+ remote: providerChain("remote", input.config, env).map((p) => p.name)
29480
+ },
29481
+ harness: { detected: harnessDetection.harness, source: harnessDetection.source },
29482
+ guard: {
29483
+ rules: denyPatterns(input.config.guards).length,
29484
+ denyWhenUnknown: input.config.guards?.denyWhenUnknown ?? false,
29485
+ model: guardVerdict.model,
29486
+ source: guardVerdict.source,
29487
+ verdict: guardVerdict.guard,
29488
+ matched: guardVerdict.matched,
29489
+ reason: guardVerdict.reason
29490
+ },
29491
+ config: inspectConfigFile(configPath)
29492
+ };
29493
+ }
29494
+ function mark(ok) {
29495
+ return ok ? "[ok]" : "[!!]";
29496
+ }
29497
+ function renderDoctorReport(report) {
29498
+ const lines = [];
29499
+ lines.push("modlens doctor");
29500
+ lines.push("(local diagnostics only: no network calls, no provider quota spent)");
29501
+ lines.push("");
29502
+ lines.push("Node");
29503
+ lines.push(
29504
+ ` ${mark(report.node.meetsMinimum)} ${report.node.version} (minimum ${report.node.minimum})`
29505
+ );
29506
+ lines.push(` ${mark(report.nodeSqlite.available)} ${report.nodeSqlite.detail}`);
29507
+ lines.push("");
29508
+ lines.push("Providers");
29509
+ for (const provider of report.providers) {
29510
+ lines.push(` ${mark(provider.ready)} ${provider.name}: ${provider.detail}`);
29511
+ if (provider.fix) {
29512
+ lines.push(` fix: ${provider.fix}`);
29513
+ }
29514
+ }
29515
+ lines.push("");
29516
+ lines.push("Selected provider");
29517
+ const canonicalNote = report.selection.canonical && report.selection.canonical !== report.selection.provider ? ` (canonical: ${report.selection.canonical})` : report.selection.canonical === null ? " (unknown provider name)" : "";
29518
+ lines.push(` ${report.selection.provider}${canonicalNote}`);
29519
+ lines.push(` reason: ${report.selection.reason}`);
29520
+ lines.push("");
29521
+ lines.push("Failover chains (what a run tries, in order)");
29522
+ const chainLine = (chain) => chain.length > 0 ? chain.join(" -> ") : "(none available)";
29523
+ lines.push(` local: ${chainLine(report.chains.local)}`);
29524
+ lines.push(` remote: ${chainLine(report.chains.remote)}`);
29525
+ lines.push("");
29526
+ lines.push("Harness");
29527
+ lines.push(
29528
+ report.harness.detected ? ` ${report.harness.detected} (via ${report.harness.source})` : ` none detected (${report.harness.source})`
29529
+ );
29530
+ lines.push("");
29531
+ lines.push("Guard (should the vision engine run for the active model?)");
29532
+ lines.push(
29533
+ ` rules: ${report.guard.rules} deny pattern(s), denyWhenUnknown: ${report.guard.denyWhenUnknown}`
29534
+ );
29535
+ lines.push(` active model: ${report.guard.model ?? "unknown"} (via ${report.guard.source})`);
29536
+ lines.push(
29537
+ ` verdict: ${report.guard.verdict}${report.guard.matched ? ` (matched "${report.guard.matched}")` : ""}, ${report.guard.reason}`
29538
+ );
29539
+ lines.push("");
29540
+ lines.push("Config file");
29541
+ lines.push(` path: ${report.config.path}`);
29542
+ if (report.config.exists) {
29543
+ lines.push(
29544
+ ` ${mark(report.config.permissionsOk)} exists, mode ${report.config.mode ?? "?"}`
29545
+ );
29546
+ } else {
29547
+ lines.push(" not present");
29548
+ }
29549
+ if (report.config.note) {
29550
+ lines.push(` note: ${report.config.note}`);
29551
+ }
29552
+ return lines.join("\n");
29553
+ }
29032
29554
  function extensionFromMediaType(mediaType) {
29033
29555
  const subtype = mediaType.split("/")[1]?.split("+")[0]?.replace(/[^a-z0-9]/gi, "");
29034
29556
  return subtype ? subtype.toLowerCase() : "bin";
@@ -29207,13 +29729,28 @@ function recoverPastedImages(options = {}) {
29207
29729
  return result;
29208
29730
  }
29209
29731
  const program = new Command();
29210
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.3.0");
29211
- 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").action(async (options) => {
29732
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.5.0");
29733
+ program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
29734
+ "--extra-body <json>",
29735
+ `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
29736
+ ).action(async (options) => {
29212
29737
  try {
29213
29738
  const timeoutMs = Number.parseInt(options.timeout, 10);
29214
29739
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
29215
29740
  throw new Error("Invalid --timeout. Use a positive integer in milliseconds.");
29216
29741
  }
29742
+ const config2 = loadConfigFile();
29743
+ if (process.env.MODLENS_MODEL?.trim()) {
29744
+ const verdict = runGuard(config2.guards, {
29745
+ cwd: process.cwd(),
29746
+ env: process.env
29747
+ });
29748
+ if (verdict.guard === "deny" && verdict.matched) {
29749
+ throw new Error(
29750
+ `Invocation guard denied this read: active model "${verdict.model}" matches guards.denyModels pattern "${verdict.matched}". A model with native vision should read the image itself. To override, unset MODLENS_MODEL or edit guards in ${CONFIG_PATH}.`
29751
+ );
29752
+ }
29753
+ }
29217
29754
  const result = await analyzeImage({
29218
29755
  input: options.input,
29219
29756
  provider: options.provider,
@@ -29221,7 +29758,9 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
29221
29758
  prompt: options.prompt,
29222
29759
  timeoutMs,
29223
29760
  providerBin: options.providerBin,
29224
- workdir: options.workdir
29761
+ workdir: options.workdir,
29762
+ extraBody: options.extraBody ? parseExtraBody(options.extraBody, "--extra-body") : void 0,
29763
+ config: config2
29225
29764
  });
29226
29765
  const output = JSON.stringify(result, null, 2);
29227
29766
  if (options.output) {
@@ -29271,6 +29810,29 @@ program.command("recover-paste").description(
29271
29810
  process.exit(1);
29272
29811
  }
29273
29812
  });
29813
+ program.command("guard").description(
29814
+ "Check whether the vision engine should run for the active model (exit 0 allow, 1 deny, 2 error)"
29815
+ ).option(
29816
+ "--model <name>",
29817
+ "The calling agent's own model name; weakest signal, used when env and session storage say nothing"
29818
+ ).option("--cwd <path>", "Project directory of the session", process.cwd()).action((options) => {
29819
+ try {
29820
+ const verdict = runGuard(loadConfigFile().guards, {
29821
+ cwd: options.cwd,
29822
+ env: process.env,
29823
+ selfReported: options.model
29824
+ });
29825
+ process.stdout.write(`${JSON.stringify(verdict, null, 2)}
29826
+ `);
29827
+ process.exitCode = verdict.guard === "deny" ? 1 : 0;
29828
+ } catch (error) {
29829
+ process.stderr.write(
29830
+ `Error: ${error instanceof Error ? error.message : String(error)}
29831
+ `
29832
+ );
29833
+ process.exitCode = 2;
29834
+ }
29835
+ });
29274
29836
  program.command("doctor").description(
29275
29837
  "Diagnose local config and routing (Node, providers, selection, harness) without spending quota or hitting the network"
29276
29838
  ).option("--json", "Emit the report as JSON").option("-p, --provider <name>", "Show which provider this -p value would select").action((options) => {
@@ -29302,6 +29864,7 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
29302
29864
  "Everything is optional. Two things you can set:",
29303
29865
  " modlens config set provider <name> which provider analyzes images",
29304
29866
  " modlens config set <provider>.<apiKey|baseUrl|model> <value> provider credentials",
29867
+ ` modlens config set <provider>.extraBody '{"thinking":{"type":"disabled"}}' vendor request fields`,
29305
29868
  ""
29306
29869
  ].join("\n")
29307
29870
  );