@liustack/modlens 3.11.0 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/main.js +176 -102
- package/docs/cli.md +1 -1
- package/docs/troubleshooting.md +23 -0
- package/dsh/index.js +28 -2
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
- package/skills/modlens/references/configure.md +1 -0
- package/skills/modlens/references/runtime.md +1 -1
- package/skills/modlens/scripts/run.ps1 +1 -1
- package/skills/modlens/scripts/run.sh +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.12.1 - 2026-08-14
|
|
4
|
+
|
|
5
|
+
- **claude-cli reads the envelope's `structured_output` first ([#22](https://github.com/liustack/modlens/issues/22)).** Newer claude CLI builds ship the schema-parsed object beside the `result` string, and the parser only hard-parsed the string, so an unescaped newline in the OCR text failed the whole read while the good object sat unread — intermittently, since it depended on what the model emitted. The parse order is now `structured_output`, then fence-tolerant extraction of the result string, then the error, matching the antigravity provider. Thanks to @lin-nanxing for the precise diagnosis, down to the code lines.
|
|
6
|
+
- **A `read_image` name collision no longer kills the whole dsh plugin ([#21](https://github.com/liustack/modlens/issues/21)).** Hosts with a durable attachment store mount dsh's own native `read_image` (from `dsh-tool-fs`), the duplicate registration threw, and the whole plugin fiber failed — vision wrapper included. The registration now falls back to `modlens_read_image` on a name collision (valuable exactly there: the native tool is gated on the model declaring image input and vanishes for text-only models, so the renamed bridge is the only image path left), the name is configurable via the plugin row's `toolName`, and any other registration error degrades loudly instead of taking the plugin down. Thanks to @abyss-stars for the root-cause analysis and the interim patch.
|
|
7
|
+
|
|
8
|
+
## 3.12.0 - 2026-08-14
|
|
9
|
+
|
|
10
|
+
- **The API providers work behind a proxy ([#20](https://github.com/liustack/modlens/issues/20)).** Node's fetch ignores `HTTP_PROXY`/`HTTPS_PROXY` entirely, so machines that reach the internet through a proxy could not use `gemini-api` at all, and the failure surfaced as a bare `fetch failed`. The three inline API providers now honor the standard environment variables (`NO_PROXY` included, via undici's `EnvHttpProxyAgent`), with an explicit setting as the escape hatch: `modlens config set proxy <url>` for all API providers, `<provider>.proxy` to scope it to one. A connect-level failure now names the unreachable host and points at both knobs instead of saying `fetch failed`. Scope is deliberate and documented: the proxy applies to API requests only, while the remote-image download path keeps its direct, IP-pinned connection, because its SSRF guards validate the exact address being contacted and a proxy would blind them. Thanks to @soloyu for a report that arrived with the diagnosis, the fix direction, and the security boundary already thought through.
|
|
11
|
+
|
|
3
12
|
## 3.11.0 - 2026-08-14
|
|
4
13
|
|
|
5
14
|
- **A full-project audit, all ten findings fixed, then re-reviewed until clean.** An independent deep review of the whole repository (P0: none) surfaced ten conditional-but-real defects. Every fix went back through further independent review rounds, which caught real bugs in the first fixes themselves (case and Unicode boundaries, a cancellation regression); the final round accepted with no blocking findings. Each item below carries a regression test — the suite grew by 32 cases.
|
package/dist/main.js
CHANGED
|
@@ -27414,6 +27414,51 @@ async function readCapped(response2, url) {
|
|
|
27414
27414
|
}
|
|
27415
27415
|
return Buffer.concat(chunks);
|
|
27416
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
|
+
}
|
|
27417
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):
|
|
27418
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"]}`;
|
|
27419
27464
|
function buildVisionPrompt(options) {
|
|
@@ -27735,43 +27780,47 @@ async function executeAnthropicApi(options) {
|
|
|
27735
27780
|
|
|
27736
27781
|
Report your findings by calling the ${TOOL_NAME} tool.`;
|
|
27737
27782
|
const startedAt = Date.now();
|
|
27738
|
-
const response2 = await
|
|
27739
|
-
|
|
27740
|
-
|
|
27741
|
-
"
|
|
27742
|
-
|
|
27743
|
-
|
|
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)
|
|
27744
27821
|
},
|
|
27745
|
-
|
|
27746
|
-
|
|
27747
|
-
{
|
|
27748
|
-
model,
|
|
27749
|
-
max_tokens: 4096,
|
|
27750
|
-
tools: [
|
|
27751
|
-
{
|
|
27752
|
-
name: TOOL_NAME,
|
|
27753
|
-
description: "Report the structured visual evidence extracted from the image.",
|
|
27754
|
-
input_schema: VISION_RESULT_SCHEMA
|
|
27755
|
-
}
|
|
27756
|
-
],
|
|
27757
|
-
tool_choice: { type: "tool", name: TOOL_NAME },
|
|
27758
|
-
messages: [
|
|
27759
|
-
{
|
|
27760
|
-
role: "user",
|
|
27761
|
-
content: [
|
|
27762
|
-
{ type: "image", source: imageSource },
|
|
27763
|
-
{ type: "text", text: prompt }
|
|
27764
|
-
]
|
|
27765
|
-
}
|
|
27766
|
-
]
|
|
27767
|
-
},
|
|
27768
|
-
options.settings?.extraBody,
|
|
27769
|
-
["model", "messages", "tools", "tool_choice", "stream"],
|
|
27770
|
-
"anthropic"
|
|
27771
|
-
)
|
|
27772
|
-
),
|
|
27773
|
-
signal: AbortSignal.timeout(options.timeoutMs)
|
|
27774
|
-
});
|
|
27822
|
+
options.settings?.proxy
|
|
27823
|
+
);
|
|
27775
27824
|
if (!response2.ok) {
|
|
27776
27825
|
const body2 = await response2.text();
|
|
27777
27826
|
throw new Error(
|
|
@@ -27982,14 +28031,12 @@ function parseClaudeCliOutput(stdout) {
|
|
|
27982
28031
|
`Claude CLI reported ${envelope.subtype ?? "an error"}: ${truncate(envelope.result ?? "")}`
|
|
27983
28032
|
);
|
|
27984
28033
|
}
|
|
27985
|
-
if (typeof envelope.result !== "string" || !envelope.result.trim()) {
|
|
28034
|
+
if (envelope.structured_output === void 0 && (typeof envelope.result !== "string" || !envelope.result.trim())) {
|
|
27986
28035
|
throw new Error("Claude CLI output contains no result. Check login state (run: claude).");
|
|
27987
28036
|
}
|
|
27988
|
-
|
|
27989
|
-
|
|
27990
|
-
|
|
27991
|
-
} catch {
|
|
27992
|
-
throw new Error(`Claude CLI returned non-JSON result: ${truncate(envelope.result)}`);
|
|
28037
|
+
const result = envelope.structured_output ?? (typeof envelope.result === "string" ? extractJson(envelope.result) : null);
|
|
28038
|
+
if (result === null || result === void 0) {
|
|
28039
|
+
throw new Error(`Claude CLI returned non-JSON result: ${truncate(envelope.result ?? "")}`);
|
|
27993
28040
|
}
|
|
27994
28041
|
return {
|
|
27995
28042
|
result,
|
|
@@ -28032,39 +28079,48 @@ async function executeGeminiApi(options) {
|
|
|
28032
28079
|
extraPrompt: options.extraPrompt
|
|
28033
28080
|
});
|
|
28034
28081
|
const startedAt = Date.now();
|
|
28035
|
-
const response2 = await
|
|
28036
|
-
|
|
28037
|
-
|
|
28038
|
-
"
|
|
28039
|
-
|
|
28040
|
-
|
|
28041
|
-
|
|
28042
|
-
|
|
28043
|
-
|
|
28044
|
-
|
|
28045
|
-
|
|
28046
|
-
|
|
28047
|
-
|
|
28048
|
-
|
|
28049
|
-
|
|
28082
|
+
const response2 = await apiFetch(
|
|
28083
|
+
`${baseUrl}/v1beta/models/${model}:generateContent`,
|
|
28084
|
+
{
|
|
28085
|
+
method: "POST",
|
|
28086
|
+
headers: {
|
|
28087
|
+
"x-goog-api-key": apiKey,
|
|
28088
|
+
"Content-Type": "application/json"
|
|
28089
|
+
},
|
|
28090
|
+
body: JSON.stringify(
|
|
28091
|
+
mergeExtraBody(
|
|
28092
|
+
{
|
|
28093
|
+
contents: [
|
|
28094
|
+
{
|
|
28095
|
+
parts: [
|
|
28096
|
+
{
|
|
28097
|
+
inline_data: {
|
|
28098
|
+
mime_type: image.mimeType,
|
|
28099
|
+
data: image.data
|
|
28100
|
+
}
|
|
28101
|
+
},
|
|
28102
|
+
{ text: prompt }
|
|
28103
|
+
]
|
|
28104
|
+
}
|
|
28105
|
+
],
|
|
28106
|
+
generationConfig: {
|
|
28107
|
+
responseMimeType: "application/json",
|
|
28108
|
+
responseJsonSchema: VISION_RESULT_SCHEMA
|
|
28050
28109
|
}
|
|
28110
|
+
},
|
|
28111
|
+
options.settings?.extraBody,
|
|
28112
|
+
[
|
|
28113
|
+
"contents",
|
|
28114
|
+
"generationConfig.responseMimeType",
|
|
28115
|
+
"generationConfig.responseJsonSchema"
|
|
28051
28116
|
],
|
|
28052
|
-
|
|
28053
|
-
|
|
28054
|
-
|
|
28055
|
-
|
|
28056
|
-
|
|
28057
|
-
|
|
28058
|
-
|
|
28059
|
-
"contents",
|
|
28060
|
-
"generationConfig.responseMimeType",
|
|
28061
|
-
"generationConfig.responseJsonSchema"
|
|
28062
|
-
],
|
|
28063
|
-
"gemini-api"
|
|
28064
|
-
)
|
|
28065
|
-
),
|
|
28066
|
-
signal: AbortSignal.timeout(options.timeoutMs)
|
|
28067
|
-
});
|
|
28117
|
+
"gemini-api"
|
|
28118
|
+
)
|
|
28119
|
+
),
|
|
28120
|
+
signal: AbortSignal.timeout(options.timeoutMs)
|
|
28121
|
+
},
|
|
28122
|
+
options.settings?.proxy
|
|
28123
|
+
);
|
|
28068
28124
|
if (!response2.ok) {
|
|
28069
28125
|
const body2 = await response2.text();
|
|
28070
28126
|
throw new Error(
|
|
@@ -28114,33 +28170,37 @@ async function executeOpenaiCompat(options) {
|
|
|
28114
28170
|
|
|
28115
28171
|
${JSON_TEMPLATE_INSTRUCTION}`;
|
|
28116
28172
|
const startedAt = Date.now();
|
|
28117
|
-
const response2 = await
|
|
28118
|
-
|
|
28119
|
-
|
|
28120
|
-
|
|
28121
|
-
|
|
28173
|
+
const response2 = await apiFetch(
|
|
28174
|
+
`${baseUrl}/chat/completions`,
|
|
28175
|
+
{
|
|
28176
|
+
method: "POST",
|
|
28177
|
+
headers: {
|
|
28178
|
+
Authorization: `Bearer ${apiKey}`,
|
|
28179
|
+
"Content-Type": "application/json"
|
|
28180
|
+
},
|
|
28181
|
+
body: JSON.stringify(
|
|
28182
|
+
mergeExtraBody(
|
|
28183
|
+
{
|
|
28184
|
+
model,
|
|
28185
|
+
messages: [
|
|
28186
|
+
{
|
|
28187
|
+
role: "user",
|
|
28188
|
+
content: [
|
|
28189
|
+
{ type: "image_url", image_url: { url: imageUrl } },
|
|
28190
|
+
{ type: "text", text: prompt }
|
|
28191
|
+
]
|
|
28192
|
+
}
|
|
28193
|
+
]
|
|
28194
|
+
},
|
|
28195
|
+
options.settings?.extraBody,
|
|
28196
|
+
["model", "messages", "stream"],
|
|
28197
|
+
"openai"
|
|
28198
|
+
)
|
|
28199
|
+
),
|
|
28200
|
+
signal: AbortSignal.timeout(options.timeoutMs)
|
|
28122
28201
|
},
|
|
28123
|
-
|
|
28124
|
-
|
|
28125
|
-
{
|
|
28126
|
-
model,
|
|
28127
|
-
messages: [
|
|
28128
|
-
{
|
|
28129
|
-
role: "user",
|
|
28130
|
-
content: [
|
|
28131
|
-
{ type: "image_url", image_url: { url: imageUrl } },
|
|
28132
|
-
{ type: "text", text: prompt }
|
|
28133
|
-
]
|
|
28134
|
-
}
|
|
28135
|
-
]
|
|
28136
|
-
},
|
|
28137
|
-
options.settings?.extraBody,
|
|
28138
|
-
["model", "messages", "stream"],
|
|
28139
|
-
"openai"
|
|
28140
|
-
)
|
|
28141
|
-
),
|
|
28142
|
-
signal: AbortSignal.timeout(options.timeoutMs)
|
|
28143
|
-
});
|
|
28202
|
+
options.settings?.proxy
|
|
28203
|
+
);
|
|
28144
28204
|
if (!response2.ok) {
|
|
28145
28205
|
const body2 = await response2.text();
|
|
28146
28206
|
throw new Error(
|
|
@@ -28210,7 +28270,7 @@ function providerAliases() {
|
|
|
28210
28270
|
function listProviders() {
|
|
28211
28271
|
return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
|
|
28212
28272
|
}
|
|
28213
|
-
const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
|
|
28273
|
+
const STRING_FIELDS = ["apiKey", "baseUrl", "model", "proxy"];
|
|
28214
28274
|
const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi", "grok"];
|
|
28215
28275
|
const CONFIG_DIR = path.join(os.homedir(), ".modlens");
|
|
28216
28276
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
@@ -28251,6 +28311,9 @@ function resolveProviderSettings(providerName, config2, env = process.env) {
|
|
|
28251
28311
|
};
|
|
28252
28312
|
const bindings = ENV_BINDINGS[providerName] ?? {};
|
|
28253
28313
|
const settings = { ...fromFile };
|
|
28314
|
+
if (!settings.proxy && config2.proxy?.trim()) {
|
|
28315
|
+
settings.proxy = config2.proxy.trim();
|
|
28316
|
+
}
|
|
28254
28317
|
for (const [field, envName] of Object.entries(bindings)) {
|
|
28255
28318
|
const value = env[envName]?.trim();
|
|
28256
28319
|
if (value) {
|
|
@@ -28263,6 +28326,12 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
28263
28326
|
const config2 = loadConfigFile(configPath);
|
|
28264
28327
|
if (dottedKey === "provider") {
|
|
28265
28328
|
config2.provider = value;
|
|
28329
|
+
} else if (dottedKey === "proxy") {
|
|
28330
|
+
if (value.trim() === "") {
|
|
28331
|
+
delete config2.proxy;
|
|
28332
|
+
} else {
|
|
28333
|
+
config2.proxy = value.trim();
|
|
28334
|
+
}
|
|
28266
28335
|
} else if (dottedKey.startsWith("reuse.")) {
|
|
28267
28336
|
const harness = dottedKey.slice("reuse.".length);
|
|
28268
28337
|
if (!REUSE_HARNESSES.includes(harness)) {
|
|
@@ -28307,7 +28376,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
|
|
|
28307
28376
|
}
|
|
28308
28377
|
} else if (!STRING_FIELDS.includes(field)) {
|
|
28309
28378
|
throw new Error(
|
|
28310
|
-
`Unknown config field: ${field}. Use apiKey, baseUrl, model, or extraBody.`
|
|
28379
|
+
`Unknown config field: ${field}. Use apiKey, baseUrl, model, proxy, or extraBody.`
|
|
28311
28380
|
);
|
|
28312
28381
|
} else {
|
|
28313
28382
|
config2.providers ??= {};
|
|
@@ -28409,6 +28478,11 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
28409
28478
|
if (config2.provider?.trim()) {
|
|
28410
28479
|
effective.provider = config2.provider.trim();
|
|
28411
28480
|
}
|
|
28481
|
+
if (config2.proxy?.trim()) {
|
|
28482
|
+
effective.proxy = `${config2.proxy.trim()} (file)`;
|
|
28483
|
+
} else if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
|
|
28484
|
+
effective.proxy = `${env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy} (env)`;
|
|
28485
|
+
}
|
|
28412
28486
|
if (config2.guards) {
|
|
28413
28487
|
const guards = {};
|
|
28414
28488
|
if (config2.guards.denyModels !== void 0) {
|
|
@@ -30626,7 +30700,7 @@ function parsePositiveInt(raw, flag) {
|
|
|
30626
30700
|
}
|
|
30627
30701
|
return Number.parseInt(raw, 10);
|
|
30628
30702
|
}
|
|
30629
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.
|
|
30703
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.12.1");
|
|
30630
30704
|
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(
|
|
30631
30705
|
"--extra-body <json>",
|
|
30632
30706
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
package/docs/cli.md
CHANGED
|
@@ -95,6 +95,6 @@ Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `
|
|
|
95
95
|
Other subcommands:
|
|
96
96
|
|
|
97
97
|
- `modlens guard [--model <id>]`: should the engine run for the active model at all? Exit 0 allow, 1 deny, verdict as JSON.
|
|
98
|
-
- `modlens config <init|set|show>`: keys are `provider`, `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|extraBody>`.
|
|
98
|
+
- `modlens config <init|set|show>`: keys are `provider`, `proxy` (HTTP/HTTPS proxy for the API providers, `HTTPS_PROXY`/`HTTP_PROXY` also honored), `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|proxy|extraBody>`.
|
|
99
99
|
- `modlens doctor`: Node and node:sqlite, provider readiness, the failover chains for this machine, the detected harness, the guard's rules with a live verdict, and the Reuse section with per-harness grant decisions and discovered vision. Spends no quota; `--json` for a machine-readable report.
|
|
100
100
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -154,6 +154,29 @@ The trade-off is honest either way: an explicit `@latest` (or the exclusion)
|
|
|
154
154
|
opts modlens out of pnpm's supply-chain cooling-off window, so new releases
|
|
155
155
|
install immediately.
|
|
156
156
|
|
|
157
|
+
## fetch failed, or could not connect
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
Could not connect to generativelanguage.googleapis.com (UND_ERR_CONNECT_TIMEOUT). The request never reached the network. ...
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The API request never left the machine. On networks that reach the internet
|
|
164
|
+
through a proxy this is expected: Node's fetch ignores the proxy environment
|
|
165
|
+
variables by default. modlens honors them once you ask it to route that way,
|
|
166
|
+
in either form:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
HTTPS_PROXY=http://127.0.0.1:7890 modlens -i shot.png -p gemini-api # env (NO_PROXY honored too)
|
|
170
|
+
modlens config set proxy http://127.0.0.1:7890 # persistent, all API providers
|
|
171
|
+
modlens config set openai.proxy http://127.0.0.1:7890 # one provider only
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The proxy applies to API provider requests only. The remote-image download
|
|
175
|
+
path keeps its direct, IP-pinned connection on purpose: its SSRF guards
|
|
176
|
+
validate the exact address being contacted, and a proxy would blind them. On
|
|
177
|
+
a proxied machine, prefer local files or let the failover chain hand remote
|
|
178
|
+
URLs to a provider that fetches them upstream.
|
|
179
|
+
|
|
157
180
|
## Config file problems
|
|
158
181
|
|
|
159
182
|
```
|
package/dsh/index.js
CHANGED
|
@@ -47,8 +47,16 @@ export function apply(ctx, config = {}) {
|
|
|
47
47
|
// the developer-preview registry accepts these and out-of-tree resolution
|
|
48
48
|
// of @deepseek-ai/dsh-tools is not yet reliable), so this plugin owns its
|
|
49
49
|
// own argument validation inside execute.
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
//
|
|
51
|
+
// The name can collide: hosts with a durable attachment store mount their
|
|
52
|
+
// own native read_image (dsh-tool-fs), and a duplicate registration throws,
|
|
53
|
+
// which used to fail the whole plugin fiber (issue #21). The collision
|
|
54
|
+
// falls back to a prefixed name — valuable exactly there, since the native
|
|
55
|
+
// tool is gated on the model declaring image input and vanishes for
|
|
56
|
+
// text-only models — and any other registration error degrades loudly
|
|
57
|
+
// instead of taking the vision wrapper down with it.
|
|
58
|
+
const readImageTool = (toolName) => ({
|
|
59
|
+
name: toolName,
|
|
52
60
|
description:
|
|
53
61
|
'Read an image through the modlens vision bridge. Use whenever a message references an image the current model cannot see: a local file path or an http(s) URL to a screenshot, photo, chart, diagram, or document scan. Returns structured evidence with every word transcribed (ocr.full_text), layout regions in reading order, semantics, and an uncertainty list; quote the evidence instead of guessing. Requires a configured modlens engine (run `npx @liustack/modlens doctor` in a terminal to check).',
|
|
54
62
|
parameters: {
|
|
@@ -106,6 +114,24 @@ export function apply(ctx, config = {}) {
|
|
|
106
114
|
return parsed.result
|
|
107
115
|
},
|
|
108
116
|
})
|
|
117
|
+
const preferred = config.toolName || 'read_image'
|
|
118
|
+
try {
|
|
119
|
+
ctx.tools.register(readImageTool(preferred))
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const fallback = 'modlens_read_image'
|
|
122
|
+
if (preferred !== fallback && /already|duplicate/i.test(String(error))) {
|
|
123
|
+
try {
|
|
124
|
+
ctx.tools.register(readImageTool(fallback))
|
|
125
|
+
console.error(
|
|
126
|
+
`[modlens] tool name "${preferred}" is taken by the host; registered as "${fallback}" instead`,
|
|
127
|
+
)
|
|
128
|
+
} catch (retryError) {
|
|
129
|
+
console.error(`[modlens] read_image registration skipped: ${retryError}`)
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
console.error(`[modlens] read_image registration skipped: ${error}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
109
135
|
}
|
|
110
136
|
|
|
111
137
|
/**
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
|
|
|
20
20
|
|
|
21
21
|
It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
|
|
22
22
|
|
|
23
|
-
If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.
|
|
23
|
+
If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.12.1):
|
|
24
24
|
|
|
25
|
-
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.
|
|
26
|
-
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.
|
|
27
|
-
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.
|
|
25
|
+
1. A `modlens` on `PATH` whose major version is 3 and is at least 3.12.1: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.12.1 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.12.1 <args>`.
|
|
28
28
|
4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.13+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
|
|
29
29
|
|
|
30
30
|
`references/runtime.md` documents the pin and the diagnostic fields.
|
|
@@ -22,6 +22,7 @@ Everything lives under four top-level keys, all optional. This example shows eve
|
|
|
22
22
|
```json
|
|
23
23
|
{
|
|
24
24
|
"provider": "gemini-api",
|
|
25
|
+
"proxy": "http://127.0.0.1:7890",
|
|
25
26
|
"reuse": { "claude": true, "codex": true, "opencode": false, "pi": true, "grok": true },
|
|
26
27
|
"guards": {
|
|
27
28
|
"allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
|
|
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
|
|
|
24
24
|
# package.json version, and the release script rewrites it on every bump.
|
|
25
25
|
$Package = '@liustack/modlens'
|
|
26
26
|
$Bin = 'modlens'
|
|
27
|
-
$Pinned = '3.
|
|
27
|
+
$Pinned = '3.12.1'
|
|
28
28
|
# -------------------------------------------------------------------------------
|
|
29
29
|
|
|
30
30
|
$NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
|
|
@@ -22,7 +22,7 @@ set -eu
|
|
|
22
22
|
# package.json version, and the release script rewrites it on every bump.
|
|
23
23
|
PKG="@liustack/modlens"
|
|
24
24
|
BIN="modlens"
|
|
25
|
-
PINNED="3.
|
|
25
|
+
PINNED="3.12.1"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|