@liustack/modlens 3.10.0 → 3.12.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
@@ -13672,7 +13672,7 @@ function requireMockUtils() {
13672
13672
  }
13673
13673
  return normalizedQp;
13674
13674
  }
13675
- function safeUrl(path2) {
13675
+ function safeUrl2(path2) {
13676
13676
  if (typeof path2 !== "string") {
13677
13677
  return path2;
13678
13678
  }
@@ -13710,10 +13710,10 @@ function requireMockUtils() {
13710
13710
  }
13711
13711
  function getMockDispatch(mockDispatches, key) {
13712
13712
  const basePath = key.query ? serializePathWithQuery(key.path, key.query) : key.path;
13713
- const resolvedPath = typeof basePath === "string" ? safeUrl(basePath) : basePath;
13713
+ const resolvedPath = typeof basePath === "string" ? safeUrl2(basePath) : basePath;
13714
13714
  const resolvedPathWithoutTrailingSlash = removeTrailingSlash(resolvedPath);
13715
13715
  let matchedMockDispatches = mockDispatches.filter(({ consumed }) => !consumed).filter(({ path: path2, ignoreTrailingSlash }) => {
13716
- return ignoreTrailingSlash ? matchValue(removeTrailingSlash(safeUrl(path2)), resolvedPathWithoutTrailingSlash) : matchValue(safeUrl(path2), resolvedPath);
13716
+ return ignoreTrailingSlash ? matchValue(removeTrailingSlash(safeUrl2(path2)), resolvedPathWithoutTrailingSlash) : matchValue(safeUrl2(path2), resolvedPath);
13717
13717
  });
13718
13718
  if (matchedMockDispatches.length === 0) {
13719
13719
  throw new MockNotMatchedError(`Mock dispatch not matched for path '${resolvedPath}'`);
@@ -27236,15 +27236,6 @@ function parseIpv6Groups(groups) {
27236
27236
  }
27237
27237
  return parsed;
27238
27238
  }
27239
- const MIME_BY_EXT = {
27240
- ".jpg": "image/jpeg",
27241
- ".jpeg": "image/jpeg",
27242
- ".png": "image/png",
27243
- ".webp": "image/webp",
27244
- ".gif": "image/gif",
27245
- ".heic": "image/heic",
27246
- ".heif": "image/heif"
27247
- };
27248
27239
  const MAX_REMOTE_IMAGE_BYTES = 25 * 1024 * 1024;
27249
27240
  const ALLOWED_MIME = /* @__PURE__ */ new Set([
27250
27241
  "image/png",
@@ -27270,8 +27261,27 @@ const SNIFFERS = [
27270
27261
  {
27271
27262
  mime: "image/webp",
27272
27263
  test: (b) => b.length >= 12 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP"
27264
+ },
27265
+ // ISO BMFF: bytes 4-8 spell "ftyp" and the brand names the format. This
27266
+ // closes the last extension-trust hole: heic/heif must now prove
27267
+ // themselves from the header like every other type.
27268
+ {
27269
+ mime: "image/heic",
27270
+ test: (b) => b.length >= 12 && b.toString("ascii", 4, 8) === "ftyp" && ["heic", "heix", "hevc", "hevx"].includes(b.toString("ascii", 8, 12))
27271
+ },
27272
+ {
27273
+ mime: "image/heif",
27274
+ test: (b) => b.length >= 12 && b.toString("ascii", 4, 8) === "ftyp" && ["mif1", "msf1", "heif"].includes(b.toString("ascii", 8, 12))
27273
27275
  }
27274
27276
  ];
27277
+ function safeUrl(url) {
27278
+ try {
27279
+ const u = new URL(url);
27280
+ return `${u.origin}${u.pathname}`;
27281
+ } catch {
27282
+ return "<unparseable url>";
27283
+ }
27284
+ }
27275
27285
  function sniffImageMime(buffer) {
27276
27286
  for (const { mime, test } of SNIFFERS) {
27277
27287
  if (test(buffer)) {
@@ -27280,22 +27290,13 @@ function sniffImageMime(buffer) {
27280
27290
  }
27281
27291
  return null;
27282
27292
  }
27283
- function extMime(source) {
27284
- const ext = /^https?:\/\//i.test(source) ? path.extname(new URL(source).pathname).toLowerCase() : path.extname(source).toLowerCase();
27285
- return MIME_BY_EXT[ext] ?? null;
27286
- }
27287
- function resolveImageMime(buffer, source, contentType) {
27293
+ function resolveImageMime(buffer, source, _contentType) {
27288
27294
  const sniffed = sniffImageMime(buffer);
27289
27295
  if (sniffed) {
27290
27296
  return sniffed;
27291
27297
  }
27292
- const declared = contentType?.split(";")[0]?.trim().toLowerCase();
27293
- const candidate = extMime(source) ?? (declared?.startsWith("image/") ? declared : null);
27294
- if (candidate && ALLOWED_MIME.has(candidate)) {
27295
- return candidate;
27296
- }
27297
27298
  throw new Error(
27298
- `Unsupported or unrecognized image type for ${source}. Allowed: ${[...ALLOWED_MIME].join(", ")}.`
27299
+ `Content of ${source} does not look like a supported image (its bytes match no known image header). Allowed types: ${[...ALLOWED_MIME].join(", ")}.`
27299
27300
  );
27300
27301
  }
27301
27302
  function readLocalImageBase64(filePath) {
@@ -27331,32 +27332,34 @@ async function fetchRemoteImageBase64(url, timeoutMs) {
27331
27332
  const location = response2.headers.get("location");
27332
27333
  if (!location) {
27333
27334
  throw new Error(
27334
- `Redirect response (${response2.status}) missing location header: ${current}`
27335
+ `Redirect response (${response2.status}) missing location header: ${safeUrl(current.toString())}`
27335
27336
  );
27336
27337
  }
27337
27338
  await response2.body?.cancel();
27338
27339
  if (hop === MAX_REDIRECTS) {
27339
- throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${url}`);
27340
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${safeUrl(url)}`);
27340
27341
  }
27341
27342
  current = normalizeRemoteImageUrl(new URL(location, current).toString());
27342
27343
  continue;
27343
27344
  }
27344
27345
  if (!response2.ok) {
27345
- throw new Error(`Failed to download image (${response2.status}): ${current}`);
27346
+ throw new Error(
27347
+ `Failed to download image (${response2.status}): ${safeUrl(current.toString())}`
27348
+ );
27346
27349
  }
27347
27350
  const declaredLength = Number(response2.headers.get("content-length"));
27348
27351
  if (Number.isFinite(declaredLength) && declaredLength > MAX_REMOTE_IMAGE_BYTES) {
27349
27352
  throw new Error(
27350
- `Remote image is ${declaredLength} bytes, over the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${current}`
27353
+ `Remote image is ${declaredLength} bytes, over the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(current.toString())}`
27351
27354
  );
27352
27355
  }
27353
27356
  const finalUrl = current.toString();
27354
27357
  const buffer = await readCapped(response2, finalUrl);
27355
27358
  const contentType = response2.headers.get("content-type") ?? void 0;
27356
- const mimeType = resolveImageMime(buffer, finalUrl, contentType);
27359
+ const mimeType = resolveImageMime(buffer, safeUrl(finalUrl), contentType);
27357
27360
  return { data: buffer.toString("base64"), mimeType };
27358
27361
  }
27359
- throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${url}`);
27362
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${safeUrl(url)}`);
27360
27363
  } finally {
27361
27364
  for (const dispatcher2 of dispatchers) {
27362
27365
  void dispatcher2.close();
@@ -27387,7 +27390,7 @@ async function readCapped(response2, url) {
27387
27390
  const buffer = Buffer.from(await response2.arrayBuffer());
27388
27391
  if (buffer.length > MAX_REMOTE_IMAGE_BYTES) {
27389
27392
  throw new Error(
27390
- `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${url}`
27393
+ `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(url)}`
27391
27394
  );
27392
27395
  }
27393
27396
  return buffer;
@@ -27404,13 +27407,58 @@ async function readCapped(response2, url) {
27404
27407
  if (total > MAX_REMOTE_IMAGE_BYTES) {
27405
27408
  await reader.cancel();
27406
27409
  throw new Error(
27407
- `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${url}`
27410
+ `Remote image exceeds the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(url)}`
27408
27411
  );
27409
27412
  }
27410
27413
  chunks.push(Buffer.from(value));
27411
27414
  }
27412
27415
  return Buffer.concat(chunks);
27413
27416
  }
27417
+ function apiProxyDispatcher(explicitProxy, env) {
27418
+ const proxy = explicitProxy?.trim();
27419
+ if (proxy) {
27420
+ return new undiciExports.ProxyAgent(proxy);
27421
+ }
27422
+ if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
27423
+ return new undiciExports.EnvHttpProxyAgent();
27424
+ }
27425
+ return void 0;
27426
+ }
27427
+ const CONNECT_CODES = /* @__PURE__ */ new Set([
27428
+ "UND_ERR_CONNECT_TIMEOUT",
27429
+ "ECONNREFUSED",
27430
+ "ECONNRESET",
27431
+ "ENOTFOUND",
27432
+ "EHOSTUNREACH",
27433
+ "ENETUNREACH",
27434
+ "ETIMEDOUT"
27435
+ ]);
27436
+ function connectFailureHint(error, url) {
27437
+ const cause = error instanceof Error ? error.cause : void 0;
27438
+ if (!cause?.code || !CONNECT_CODES.has(cause.code)) {
27439
+ return null;
27440
+ }
27441
+ let host;
27442
+ try {
27443
+ host = new URL(url).host;
27444
+ } catch {
27445
+ return null;
27446
+ }
27447
+ return `Could not connect to ${host} (${cause.code}). The request never reached the network. If this machine reaches the internet through a proxy, set HTTPS_PROXY/HTTP_PROXY, or run: modlens config set proxy <url>`;
27448
+ }
27449
+ async function apiFetch(url, init, proxy, env = process.env) {
27450
+ const dispatcher2 = apiProxyDispatcher(proxy, env);
27451
+ try {
27452
+ return await fetch(
27453
+ url,
27454
+ // `dispatcher` is a Node/undici extension to fetch's options.
27455
+ dispatcher2 ? { ...init, dispatcher: dispatcher2 } : init
27456
+ );
27457
+ } catch (error) {
27458
+ const hint = connectFailureHint(error, url);
27459
+ throw hint ? new Error(hint, { cause: error }) : error;
27460
+ }
27461
+ }
27414
27462
  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
27463
  {"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"]}`;
27416
27464
  function buildVisionPrompt(options) {
@@ -27677,6 +27725,33 @@ function hasPath(value, dottedPath) {
27677
27725
  function isPlainObject(value) {
27678
27726
  return typeof value === "object" && value !== null && !Array.isArray(value);
27679
27727
  }
27728
+ const TOKEN_SHAPES = [
27729
+ // Vendor-prefixed keys (OpenAI/Anthropic sk-, Stripe rk/pk, Slack xox*).
27730
+ /\b(?:sk|rk|pk|xox[a-z])-[A-Za-z0-9_-]{12,}\b/g,
27731
+ // Google API keys.
27732
+ /\bAIza[A-Za-z0-9_-]{20,}\b/g,
27733
+ // GitHub tokens.
27734
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
27735
+ // JWTs (three base64url segments, the first spelling {"alg" or {"typ").
27736
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g,
27737
+ // Auth headers: "Bearer xyz" / "Authorization: xyz" (space form is real).
27738
+ /\b(?:bearer|authorization)\b[=:\s]+"?[A-Za-z0-9._~+/-]{12,}"?/gi,
27739
+ // Labeled keys need an explicit = or : separator; prose like
27740
+ // "token limit_exceeded" is diagnostics, not a credential.
27741
+ /\b(?:token|api[-_]?key)\b\s*[=:]\s*"?[A-Za-z0-9._~+/-]{12,}"?/gi
27742
+ ];
27743
+ function redactSecrets(text, knownSecrets = []) {
27744
+ let out = text;
27745
+ for (const secret of knownSecrets) {
27746
+ if (secret && secret.length >= 6) {
27747
+ out = out.split(secret).join("[redacted]");
27748
+ }
27749
+ }
27750
+ for (const shape of TOKEN_SHAPES) {
27751
+ out = out.replace(shape, "[redacted]");
27752
+ }
27753
+ return out;
27754
+ }
27680
27755
  const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
27681
27756
  const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
27682
27757
  const TOOL_NAME = "report_vision_evidence";
@@ -27705,46 +27780,52 @@ async function executeAnthropicApi(options) {
27705
27780
 
27706
27781
  Report your findings by calling the ${TOOL_NAME} tool.`;
27707
27782
  const startedAt = Date.now();
27708
- const response2 = await fetch(`${baseUrl}/v1/messages`, {
27709
- method: "POST",
27710
- headers: {
27711
- "x-api-key": apiKey,
27712
- "anthropic-version": "2023-06-01",
27713
- "Content-Type": "application/json"
27783
+ const response2 = await apiFetch(
27784
+ `${baseUrl}/v1/messages`,
27785
+ {
27786
+ method: "POST",
27787
+ headers: {
27788
+ "x-api-key": apiKey,
27789
+ "anthropic-version": "2023-06-01",
27790
+ "Content-Type": "application/json"
27791
+ },
27792
+ body: JSON.stringify(
27793
+ mergeExtraBody(
27794
+ {
27795
+ model,
27796
+ max_tokens: 4096,
27797
+ tools: [
27798
+ {
27799
+ name: TOOL_NAME,
27800
+ description: "Report the structured visual evidence extracted from the image.",
27801
+ input_schema: VISION_RESULT_SCHEMA
27802
+ }
27803
+ ],
27804
+ tool_choice: { type: "tool", name: TOOL_NAME },
27805
+ messages: [
27806
+ {
27807
+ role: "user",
27808
+ content: [
27809
+ { type: "image", source: imageSource },
27810
+ { type: "text", text: prompt }
27811
+ ]
27812
+ }
27813
+ ]
27814
+ },
27815
+ options.settings?.extraBody,
27816
+ ["model", "messages", "tools", "tool_choice", "stream"],
27817
+ "anthropic"
27818
+ )
27819
+ ),
27820
+ signal: AbortSignal.timeout(options.timeoutMs)
27714
27821
  },
27715
- body: JSON.stringify(
27716
- mergeExtraBody(
27717
- {
27718
- model,
27719
- max_tokens: 4096,
27720
- tools: [
27721
- {
27722
- name: TOOL_NAME,
27723
- description: "Report the structured visual evidence extracted from the image.",
27724
- input_schema: VISION_RESULT_SCHEMA
27725
- }
27726
- ],
27727
- tool_choice: { type: "tool", name: TOOL_NAME },
27728
- messages: [
27729
- {
27730
- role: "user",
27731
- content: [
27732
- { type: "image", source: imageSource },
27733
- { type: "text", text: prompt }
27734
- ]
27735
- }
27736
- ]
27737
- },
27738
- options.settings?.extraBody,
27739
- ["model", "messages", "tools", "tool_choice", "stream"],
27740
- "anthropic"
27741
- )
27742
- ),
27743
- signal: AbortSignal.timeout(options.timeoutMs)
27744
- });
27822
+ options.settings?.proxy
27823
+ );
27745
27824
  if (!response2.ok) {
27746
27825
  const body2 = await response2.text();
27747
- throw new Error(`Anthropic API error ${response2.status}: ${truncate(body2)}`);
27826
+ throw new Error(
27827
+ `Anthropic API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
27828
+ );
27748
27829
  }
27749
27830
  const payload = await response2.json();
27750
27831
  const toolUse = payload.content?.find((block) => block.type === "tool_use");
@@ -28000,42 +28081,53 @@ async function executeGeminiApi(options) {
28000
28081
  extraPrompt: options.extraPrompt
28001
28082
  });
28002
28083
  const startedAt = Date.now();
28003
- const response2 = await fetch(`${baseUrl}/v1beta/models/${model}:generateContent`, {
28004
- method: "POST",
28005
- headers: {
28006
- "x-goog-api-key": apiKey,
28007
- "Content-Type": "application/json"
28008
- },
28009
- body: JSON.stringify(
28010
- mergeExtraBody(
28011
- {
28012
- contents: [
28013
- {
28014
- parts: [
28015
- { inline_data: { mime_type: image.mimeType, data: image.data } },
28016
- { text: prompt }
28017
- ]
28084
+ const response2 = await apiFetch(
28085
+ `${baseUrl}/v1beta/models/${model}:generateContent`,
28086
+ {
28087
+ method: "POST",
28088
+ headers: {
28089
+ "x-goog-api-key": apiKey,
28090
+ "Content-Type": "application/json"
28091
+ },
28092
+ body: JSON.stringify(
28093
+ mergeExtraBody(
28094
+ {
28095
+ contents: [
28096
+ {
28097
+ parts: [
28098
+ {
28099
+ inline_data: {
28100
+ mime_type: image.mimeType,
28101
+ data: image.data
28102
+ }
28103
+ },
28104
+ { text: prompt }
28105
+ ]
28106
+ }
28107
+ ],
28108
+ generationConfig: {
28109
+ responseMimeType: "application/json",
28110
+ responseJsonSchema: VISION_RESULT_SCHEMA
28018
28111
  }
28112
+ },
28113
+ options.settings?.extraBody,
28114
+ [
28115
+ "contents",
28116
+ "generationConfig.responseMimeType",
28117
+ "generationConfig.responseJsonSchema"
28019
28118
  ],
28020
- generationConfig: {
28021
- responseMimeType: "application/json",
28022
- responseJsonSchema: VISION_RESULT_SCHEMA
28023
- }
28024
- },
28025
- options.settings?.extraBody,
28026
- [
28027
- "contents",
28028
- "generationConfig.responseMimeType",
28029
- "generationConfig.responseJsonSchema"
28030
- ],
28031
- "gemini-api"
28032
- )
28033
- ),
28034
- signal: AbortSignal.timeout(options.timeoutMs)
28035
- });
28119
+ "gemini-api"
28120
+ )
28121
+ ),
28122
+ signal: AbortSignal.timeout(options.timeoutMs)
28123
+ },
28124
+ options.settings?.proxy
28125
+ );
28036
28126
  if (!response2.ok) {
28037
28127
  const body2 = await response2.text();
28038
- throw new Error(`Gemini API error ${response2.status}: ${truncate(body2)}`);
28128
+ throw new Error(
28129
+ `Gemini API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
28130
+ );
28039
28131
  }
28040
28132
  const payload = await response2.json();
28041
28133
  const text = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("");
@@ -28080,36 +28172,42 @@ async function executeOpenaiCompat(options) {
28080
28172
 
28081
28173
  ${JSON_TEMPLATE_INSTRUCTION}`;
28082
28174
  const startedAt = Date.now();
28083
- const response2 = await fetch(`${baseUrl}/chat/completions`, {
28084
- method: "POST",
28085
- headers: {
28086
- Authorization: `Bearer ${apiKey}`,
28087
- "Content-Type": "application/json"
28175
+ const response2 = await apiFetch(
28176
+ `${baseUrl}/chat/completions`,
28177
+ {
28178
+ method: "POST",
28179
+ headers: {
28180
+ Authorization: `Bearer ${apiKey}`,
28181
+ "Content-Type": "application/json"
28182
+ },
28183
+ body: JSON.stringify(
28184
+ mergeExtraBody(
28185
+ {
28186
+ model,
28187
+ messages: [
28188
+ {
28189
+ role: "user",
28190
+ content: [
28191
+ { type: "image_url", image_url: { url: imageUrl } },
28192
+ { type: "text", text: prompt }
28193
+ ]
28194
+ }
28195
+ ]
28196
+ },
28197
+ options.settings?.extraBody,
28198
+ ["model", "messages", "stream"],
28199
+ "openai"
28200
+ )
28201
+ ),
28202
+ signal: AbortSignal.timeout(options.timeoutMs)
28088
28203
  },
28089
- body: JSON.stringify(
28090
- mergeExtraBody(
28091
- {
28092
- model,
28093
- messages: [
28094
- {
28095
- role: "user",
28096
- content: [
28097
- { type: "image_url", image_url: { url: imageUrl } },
28098
- { type: "text", text: prompt }
28099
- ]
28100
- }
28101
- ]
28102
- },
28103
- options.settings?.extraBody,
28104
- ["model", "messages", "stream"],
28105
- "openai"
28106
- )
28107
- ),
28108
- signal: AbortSignal.timeout(options.timeoutMs)
28109
- });
28204
+ options.settings?.proxy
28205
+ );
28110
28206
  if (!response2.ok) {
28111
28207
  const body2 = await response2.text();
28112
- throw new Error(`OpenAI-compatible API error ${response2.status}: ${truncate(body2)}`);
28208
+ throw new Error(
28209
+ `OpenAI-compatible API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
28210
+ );
28113
28211
  }
28114
28212
  const payload = await response2.json();
28115
28213
  const text = payload.choices?.[0]?.message?.content;
@@ -28174,7 +28272,7 @@ function providerAliases() {
28174
28272
  function listProviders() {
28175
28273
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
28176
28274
  }
28177
- const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
28275
+ const STRING_FIELDS = ["apiKey", "baseUrl", "model", "proxy"];
28178
28276
  const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi", "grok"];
28179
28277
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
28180
28278
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
@@ -28215,6 +28313,9 @@ function resolveProviderSettings(providerName, config2, env = process.env) {
28215
28313
  };
28216
28314
  const bindings = ENV_BINDINGS[providerName] ?? {};
28217
28315
  const settings = { ...fromFile };
28316
+ if (!settings.proxy && config2.proxy?.trim()) {
28317
+ settings.proxy = config2.proxy.trim();
28318
+ }
28218
28319
  for (const [field, envName] of Object.entries(bindings)) {
28219
28320
  const value = env[envName]?.trim();
28220
28321
  if (value) {
@@ -28227,6 +28328,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28227
28328
  const config2 = loadConfigFile(configPath);
28228
28329
  if (dottedKey === "provider") {
28229
28330
  config2.provider = value;
28331
+ } else if (dottedKey === "proxy") {
28332
+ if (value.trim() === "") {
28333
+ delete config2.proxy;
28334
+ } else {
28335
+ config2.proxy = value.trim();
28336
+ }
28230
28337
  } else if (dottedKey.startsWith("reuse.")) {
28231
28338
  const harness = dottedKey.slice("reuse.".length);
28232
28339
  if (!REUSE_HARNESSES.includes(harness)) {
@@ -28271,7 +28378,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28271
28378
  }
28272
28379
  } else if (!STRING_FIELDS.includes(field)) {
28273
28380
  throw new Error(
28274
- `Unknown config field: ${field}. Use apiKey, baseUrl, model, or extraBody.`
28381
+ `Unknown config field: ${field}. Use apiKey, baseUrl, model, proxy, or extraBody.`
28275
28382
  );
28276
28383
  } else {
28277
28384
  config2.providers ??= {};
@@ -28373,6 +28480,11 @@ function renderEffectiveConfig(config2, env = process.env) {
28373
28480
  if (config2.provider?.trim()) {
28374
28481
  effective.provider = config2.provider.trim();
28375
28482
  }
28483
+ if (config2.proxy?.trim()) {
28484
+ effective.proxy = `${config2.proxy.trim()} (file)`;
28485
+ } else if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
28486
+ effective.proxy = `${env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy} (env)`;
28487
+ }
28376
28488
  if (config2.guards) {
28377
28489
  const guards = {};
28378
28490
  if (config2.guards.denyModels !== void 0) {
@@ -28442,13 +28554,16 @@ const PROVIDER_DESCRIPTORS = [
28442
28554
  ];
28443
28555
  function findOnPath(bin, env) {
28444
28556
  const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
28557
+ const suffixes = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
28445
28558
  for (const dir of dirs) {
28446
- const full = path.join(dir, bin);
28447
- try {
28448
- if (fs.statSync(full).isFile()) {
28449
- return full;
28559
+ for (const suffix of suffixes) {
28560
+ const full = path.join(dir, bin + suffix);
28561
+ try {
28562
+ if (fs.statSync(full).isFile()) {
28563
+ return full;
28564
+ }
28565
+ } catch {
28450
28566
  }
28451
- } catch {
28452
28567
  }
28453
28568
  }
28454
28569
  return null;
@@ -28609,7 +28724,10 @@ function probeCodex(env, home) {
28609
28724
  loggedIn,
28610
28725
  visionModels: [],
28611
28726
  source: "none",
28612
- error: error instanceof Error ? error.message : String(error)
28727
+ error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
28728
+ 0,
28729
+ 200
28730
+ )
28613
28731
  };
28614
28732
  }
28615
28733
  });
@@ -28682,7 +28800,10 @@ function probePi(env, home) {
28682
28800
  cliPath,
28683
28801
  visionModels: [],
28684
28802
  source: "none",
28685
- error: error instanceof Error ? error.message : String(error)
28803
+ error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
28804
+ 0,
28805
+ 200
28806
+ )
28686
28807
  };
28687
28808
  }
28688
28809
  });
@@ -28704,7 +28825,10 @@ function probeOpencode(env, runCli) {
28704
28825
  cliPath,
28705
28826
  visionModels: [],
28706
28827
  source: "none",
28707
- error: error instanceof Error ? error.message : String(error)
28828
+ error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
28829
+ 0,
28830
+ 200
28831
+ )
28708
28832
  };
28709
28833
  }
28710
28834
  });
@@ -29221,7 +29345,9 @@ async function analyzeImage(options) {
29221
29345
  provider: provider.name,
29222
29346
  ok: false,
29223
29347
  durationSeconds: (Date.now() - startedAt) / 1e3,
29224
- error: message.slice(0, 300)
29348
+ // Providers redact their own errors, but attempts travel into
29349
+ // output and model contexts, so the record gets the belt too.
29350
+ error: redactSecrets(message).slice(0, 300)
29225
29351
  });
29226
29352
  }
29227
29353
  }
@@ -29444,7 +29570,9 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
29444
29570
  const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
29445
29571
  reject(
29446
29572
  new Error(
29447
- explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
29573
+ redactSecrets(
29574
+ explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
29575
+ )
29448
29576
  )
29449
29577
  );
29450
29578
  return;
@@ -29572,7 +29700,6 @@ function cwdMatches(recorded, wanted, bothDirections = false) {
29572
29700
  return bothDirections && resolvedWanted.startsWith(`${resolvedRecorded}${path.sep}`);
29573
29701
  }
29574
29702
  function transcriptBelongsTo(lines, cwd, bothDirections = false) {
29575
- let sawCwd = false;
29576
29703
  for (const line of lines) {
29577
29704
  if (!line.includes('"cwd"')) {
29578
29705
  continue;
@@ -29582,14 +29709,13 @@ function transcriptBelongsTo(lines, cwd, bothDirections = false) {
29582
29709
  if (typeof recorded !== "string") {
29583
29710
  continue;
29584
29711
  }
29585
- sawCwd = true;
29586
29712
  if (cwdMatches(recorded, cwd, bothDirections)) {
29587
29713
  return true;
29588
29714
  }
29589
29715
  } catch {
29590
29716
  }
29591
29717
  }
29592
- return !sawCwd;
29718
+ return false;
29593
29719
  }
29594
29720
  function readLines(filePath) {
29595
29721
  try {
@@ -29718,16 +29844,19 @@ const claudeAdapter = jsonlAdapter({
29718
29844
  function opencodeDbPath() {
29719
29845
  return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
29720
29846
  }
29721
- function escapeLikePattern(value) {
29722
- return value.replace(/[\\%_]/g, (char) => `\\${char}`);
29723
- }
29724
- function opencodeDirectoryFilter(resolvedCwd) {
29847
+ function opencodeDirectoryFilter(resolvedCwd, caseInsensitive = process.platform === "win32") {
29725
29848
  const normalized = resolvedCwd.replace(/\\/g, "/");
29726
- const escaped = escapeLikePattern(normalized);
29727
- const dir = `REPLACE(session.directory, '\\', '/')`;
29849
+ const cwd = caseInsensitive ? normalized.toLowerCase() : normalized;
29850
+ const prefix = `${cwd.replace(/\/+$/, "")}/`;
29851
+ const rawDir = `REPLACE(session.directory, '\\', '/')`;
29852
+ const dir = caseInsensitive ? `LOWER(${rawDir})` : rawDir;
29853
+ const dirPrefix = `RTRIM(${dir}, '/') || '/'`;
29728
29854
  return {
29729
- clause: `(${dir} = ? OR ${dir} LIKE ? || '/%' ESCAPE '\\' OR ? LIKE ${dir} || '/%' ESCAPE '\\')`,
29730
- params: [normalized, escaped, normalized]
29855
+ // SQLite SUBSTR counts Unicode characters while JS .length counts
29856
+ // UTF-16 units, so the length parameter is measured in code points
29857
+ // ([...str].length) or an emoji in a path would shift the boundary.
29858
+ clause: `(${dir} = ? OR SUBSTR(${dir}, 1, ?) = ? OR SUBSTR(?, 1, LENGTH(${dirPrefix})) = ${dirPrefix})`,
29859
+ params: [cwd, [...prefix].length, prefix, cwd]
29731
29860
  };
29732
29861
  }
29733
29862
  function buildOpencodeQuery(resolvedCwd, sessionId) {
@@ -30175,8 +30304,13 @@ function inspectProvider(descriptor, config2, env) {
30175
30304
  name: descriptor.name,
30176
30305
  kind: "subprocess",
30177
30306
  ready: binaryPath !== null,
30307
+ status: binaryPath !== null ? "installed" : "missing",
30308
+ // "On PATH" proves installation, not a working login: doctor runs
30309
+ // offline and spends nothing, so sign-in state stays unverified
30310
+ // here and the first real read is the auth check.
30311
+ authUnverified: binaryPath !== null,
30178
30312
  binaryPath,
30179
- detail: binaryPath ? `${descriptor.bin} found at ${binaryPath}` : `${descriptor.bin} not on PATH`,
30313
+ detail: binaryPath ? `${descriptor.bin} found at ${binaryPath} (installed; sign-in not verified offline)` : `${descriptor.bin} not on PATH`,
30180
30314
  fix: binaryPath ? void 0 : descriptor.install
30181
30315
  };
30182
30316
  }
@@ -30194,6 +30328,7 @@ function inspectProvider(descriptor, config2, env) {
30194
30328
  name: descriptor.name,
30195
30329
  kind: "api",
30196
30330
  ready,
30331
+ status: ready ? "ready" : "missing",
30197
30332
  settings: statuses,
30198
30333
  detail,
30199
30334
  fix: ready ? void 0 : descriptor.fix
@@ -30314,7 +30449,8 @@ function renderDoctorReport(report) {
30314
30449
  lines.push("");
30315
30450
  lines.push("Providers");
30316
30451
  for (const provider of report.providers) {
30317
- lines.push(` ${mark(provider.ready)} ${provider.name}: ${provider.detail}`);
30452
+ const providerMark = provider.ready && provider.authUnverified ? "[ok?]" : mark(provider.ready);
30453
+ lines.push(` ${providerMark} ${provider.name}: ${provider.detail}`);
30318
30454
  if (provider.fix) {
30319
30455
  lines.push(` fix: ${provider.fix}`);
30320
30456
  }
@@ -30461,7 +30597,7 @@ function locateSource(cwd, adapters = ADAPTERS) {
30461
30597
  const blocked = blockers.length > 0 ? `
30462
30598
  Blocked: ${blockers.join(" | ")}` : "";
30463
30599
  throw new Error(
30464
- `No pasted images found in any session storage for this directory (looked in: ${dirs}). The user may not have pasted any, or the storage format changed; ask for a file path instead.${blocked}`
30600
+ `No pasted images found in any session storage for this directory (looked in: ${dirs}). The user may not have pasted any, the storage format changed, or a legacy transcript records no cwd (ownership cannot be proven; an explicit --transcript path bypasses that check). Ask for a file path instead.${blocked}`
30465
30601
  );
30466
30602
  }
30467
30603
  return best.ref;
@@ -30528,7 +30664,7 @@ function recoverPastedImages(options = {}) {
30528
30664
  const all = source.extract();
30529
30665
  if (all.length === 0) {
30530
30666
  throw new Error(
30531
- `No pasted images found in ${source.location}. The user may not have pasted any, or the storage format changed; ask for a file path instead.`
30667
+ `No pasted images found in ${source.location}. The user may not have pasted any, the storage format changed, or a legacy transcript records no cwd (ownership cannot be proven; an explicit --transcript path bypasses that check). Ask for a file path instead.`
30532
30668
  );
30533
30669
  }
30534
30670
  const outDir = prepareOutDir(options.outDir);
@@ -30560,16 +30696,19 @@ function recoverPastedImages(options = {}) {
30560
30696
  return result;
30561
30697
  }
30562
30698
  const program = new Command();
30563
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.10.0");
30699
+ function parsePositiveInt(raw, flag) {
30700
+ if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) <= 0) {
30701
+ throw new Error(`Invalid ${flag}. Use a positive integer.`);
30702
+ }
30703
+ return Number.parseInt(raw, 10);
30704
+ }
30705
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.12.0");
30564
30706
  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(
30565
30707
  "--extra-body <json>",
30566
30708
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
30567
30709
  ).action(async (options) => {
30568
30710
  try {
30569
- const timeoutMs = Number.parseInt(options.timeout, 10);
30570
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
30571
- throw new Error("Invalid --timeout. Use a positive integer in milliseconds.");
30572
- }
30711
+ const timeoutMs = parsePositiveInt(options.timeout, "--timeout (milliseconds)");
30573
30712
  const config2 = loadConfigFile();
30574
30713
  if (process.env.MODLENS_MODEL?.trim()) {
30575
30714
  const verdict = runGuard(config2.guards, {
@@ -30607,7 +30746,7 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
30607
30746
  `Error: ${error instanceof Error ? error.message : String(error)}
30608
30747
  `
30609
30748
  );
30610
- process.exit(1);
30749
+ process.exitCode = 1;
30611
30750
  }
30612
30751
  });
30613
30752
  program.command("recover-paste").description(
@@ -30620,10 +30759,7 @@ program.command("recover-paste").description(
30620
30759
  "Force the storage scope: claude-code, pi, opencode, or none (default: auto-detect via process ancestry and env)"
30621
30760
  ).option("--cwd <path>", "Project directory the image was pasted in", process.cwd()).action(async (options) => {
30622
30761
  try {
30623
- const count = Number.parseInt(options.count, 10);
30624
- if (!Number.isFinite(count) || count <= 0) {
30625
- throw new Error("Invalid --count. Use a positive integer.");
30626
- }
30762
+ const count = parsePositiveInt(options.count, "--count");
30627
30763
  const result = recoverPastedImages({
30628
30764
  count,
30629
30765
  outDir: options.outDir,
@@ -30639,7 +30775,7 @@ program.command("recover-paste").description(
30639
30775
  `Error: ${error instanceof Error ? error.message : String(error)}
30640
30776
  `
30641
30777
  );
30642
- process.exit(1);
30778
+ process.exitCode = 1;
30643
30779
  }
30644
30780
  });
30645
30781
  program.command("guard").description(
@@ -30683,7 +30819,7 @@ program.command("doctor").description(
30683
30819
  `Error: ${error instanceof Error ? error.message : String(error)}
30684
30820
  `
30685
30821
  );
30686
- process.exit(1);
30822
+ process.exitCode = 1;
30687
30823
  }
30688
30824
  });
30689
30825
  const config = program.command("config").description(`Manage ${CONFIG_PATH} (providers, keys, models)`);
@@ -30705,7 +30841,7 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
30705
30841
  `Error: ${error instanceof Error ? error.message : String(error)}
30706
30842
  `
30707
30843
  );
30708
- process.exit(1);
30844
+ process.exitCode = 1;
30709
30845
  }
30710
30846
  });
30711
30847
  config.command("set <key> <value>").description("Set a value, e.g. modlens config set gemini-api.apiKey <key>").action((key, value) => {
@@ -30718,7 +30854,7 @@ config.command("set <key> <value>").description("Set a value, e.g. modlens confi
30718
30854
  `Error: ${error instanceof Error ? error.message : String(error)}
30719
30855
  `
30720
30856
  );
30721
- process.exit(1);
30857
+ process.exitCode = 1;
30722
30858
  }
30723
30859
  });
30724
30860
  config.command("show").description("Print the effective config (file merged with env vars), API keys masked").action(() => {
@@ -30730,7 +30866,7 @@ config.command("show").description("Print the effective config (file merged with
30730
30866
  `Error: ${error instanceof Error ? error.message : String(error)}
30731
30867
  `
30732
30868
  );
30733
- process.exit(1);
30869
+ process.exitCode = 1;
30734
30870
  }
30735
30871
  });
30736
- program.parse();
30872
+ await program.parseAsync();