@liustack/modlens 3.10.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/README.zh-CN.md +2 -2
- package/dist/main.js +135 -75
- package/docs/cli.md +4 -2
- package/docs/harness-setup.md +3 -3
- package/docs/output-schema.md +1 -1
- package/docs/security.md +9 -1
- package/docs/troubleshooting.md +35 -0
- package/dsh/index.js +109 -17
- package/package.json +2 -2
- package/skills/modlens/SKILL.md +5 -5
- package/skills/modlens/references/configure.md +1 -1
- package/skills/modlens/references/find-image.md +1 -1
- package/skills/modlens/references/onboard.md +4 -4
- 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,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.11.0 - 2026-08-14
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- *Cross-project recovery, two ways in.* The OpenCode directory filter's ancestor branch built a LIKE pattern from the database's own `session.directory`, so `_`/`%` inside another project's path acted as wildcards; the comparison now uses exact SUBSTR prefixes in both directions, measured in Unicode code points (SQLite counts characters, JS counts UTF-16 units, so a path with an emoji shifted the boundary), case-sensitive on POSIX and LOWER-folded on Windows (SQLite's LIKE was ASCII case-insensitive, crossing projects on case-sensitive filesystems), with filesystem roots normalized so `/` and `E:/` match as ancestors. And a Claude/Pi transcript recording no cwd at all was accepted on slug evidence alone, though slugs collide (`/tmp/a.b` and `/tmp/a-b`); ownerless transcripts are now skipped by scanning and session lookup, while an explicit `--transcript` still reads anything you point it at.
|
|
7
|
+
- *Image-type spoofing.* When magic-byte sniffing failed, the extension or content-type used to win, so an `.png` URL serving HTML was encoded and uploaded as an image. Every allowed type must now prove itself from its file header — heic/heif included, via ftyp-box sniffing — so there is no extension or content-type fallback left at all.
|
|
8
|
+
- *Credential redaction, everywhere errors quote foreign text.* Subprocess stderr, the three API providers' error bodies, and discovery-probe failures now pass through a shared redactor (known keys replaced exactly, common token shapes as the second net) before reaching terminals, `meta.attempts`, model contexts, or the discovery cache. Remote-URL errors quote origin and path only, so signed query tokens (S3 presigns) never travel, and the shapes are tuned to leave prose like "token limit_exceeded" alone.
|
|
9
|
+
- *Windows PATH probing.* `findOnPath` now walks `PATHEXT`, so a real `agy.exe`/`claude.cmd` install no longer reads as "not on PATH" (doctor and the failover chain both trusted that verdict).
|
|
10
|
+
- *dsh evidence cache.* Failed reads are no longer memoized forever (a fixed config gets a fresh chance without restarting dsh), concurrent steps join one in-flight read instead of double-spending the engine, the cache is capped LRU-style, and cancellation is per-waiter: aborting one step stops its own wait immediately while the shared read completes into the cache, instead of killing every concurrent joiner. Pasted HEIC/HEIF now maps to its real extension, and an unknown media type refuses instead of dressing up as PNG.
|
|
11
|
+
- *Docs told the truth about less than the code did.* The security page now states per provider who fetches a remote URL (only gemini-api downloads locally behind the SSRF guards, magic-byte check, and size cap; openai/anthropic hand the URL to the vendor). The output contract now lists `visual` as required, matching the schema that always enforced it, with a docs-contract test pinning the two together. Doctor renders CLI providers as `[ok?] installed / sign-in not verified offline` instead of a flat ok, adds a machine-readable `status` field (`ready`/`installed`/`missing`) so JSON consumers stop reading bare-binary as ready, and the install flow treats the first real read as the auth check. The skill's trigger list drops `.bmp` (never supported by the CLI) and gains `.heif`.
|
|
12
|
+
- *Hardening around the edges.* The release script and workflow now run lint, refuse version downgrades, and verify the tag matches package.json; CLI numeric flags reject trailing garbage (`--count 3x`); error exits use `process.exitCode` so piped output survives.
|
|
13
|
+
- **dsh install can silently land on 3.5.0 ([#18](https://github.com/liustack/modlens/issues/18)).** pnpm v11's release-age quarantine falls back to an old version when every recent one is inside the window (10 days on pnpm 11.21, measured), and versions before 3.9.0 carry no `dsh.bundle` declaration, so the plugin installs as a plain dependency and none of the tools appear. There is no CLI or env override for the gate ([pnpm#11224](https://github.com/pnpm/pnpm/issues/11224) is still open), but an explicit version or dist-tag skips it ([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989), verified against pnpm 11.21: a bare add resolved 2.8.0 while `@latest` resolved 3.10.0 under the same default gate). The install command everywhere now carries `@latest`, so new installs land current with zero user-side setup; troubleshooting documents the mechanism plus the durable bare-name `minimumReleaseAgeExclude` fallback for whenever pnpm closes that skip. Thanks to @stonogic086-1 for the precise diagnosis.
|
|
14
|
+
|
|
3
15
|
## 3.10.0 - 2026-08-14
|
|
4
16
|
|
|
5
17
|
- **Pasting into the dsh Web UI now works end to end, thumbnails included.** The plugin registers a wrapper provider whose model list carries two entries, `DeepSeek-V4-Flash (modlens vision)` and `DeepSeek-V4-Pro (modlens vision)`; select one and pastes are admitted, because dsh's image admission asks the selected provider for input modalities and the wrapper declares image input. Conversion happens at request time on the wire messages only: the durable session log and the UI keep the native image blocks (your paste keeps its thumbnail), each pasted attachment is read once through the modlens CLI and cached for every later step, and the wrapped request is answered by the same DeepSeek route it always used. The wrap covers DeepSeek and GLM text models only; their own vision models (deepseek-vl/ocr, janus, glm v-series) are excluded by name pattern and by declared modality, so a model that can already see is never wrapped. The `agent/pre-step` auto-read from 3.9.0 becomes opt-in (`autoRead: true`) since request-time conversion owns the paste path and preserves the native UX. Registration also stopped failing silently: the adapter now supplies the base-class methods dsh's duck-typing expects (`providerInfo`, `providerRetryPolicy`), whose absence was exactly the invisible failure, and a registration error is logged to the harness console instead of swallowed.
|
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
|
|
|
34
34
|
|
|
35
35
|
## Highlights
|
|
36
36
|
|
|
37
|
-
**🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`, and the text-only DeepSeek model behind dsh reads images through a native `read_image` tool. For pasted images, switch the model selector to one of the two entries the plugin adds, **`DeepSeek-V4-Flash (modlens vision)`** or **`DeepSeek-V4-Pro (modlens vision)`**: pastes are admitted there, converted to evidence at request time (your message keeps its native thumbnail), and answered by the same DeepSeek route. The wrap covers DeepSeek and GLM text models only; their own vision models are excluded automatically.
|
|
37
|
+
**🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`, and the text-only DeepSeek model behind dsh reads images through a native `read_image` tool. If dsh warns `declares no dsh.bundle`, pnpm's release-age gate installed an old version: see [troubleshooting](docs/troubleshooting.md#dsh-says-declares-no-dshbundle--installed-as-a-plain-dependency) for the one-line fix. For pasted images, switch the model selector to one of the two entries the plugin adds, **`DeepSeek-V4-Flash (modlens vision)`** or **`DeepSeek-V4-Pro (modlens vision)`**: pastes are admitted there, converted to evidence at request time (your message keeps its native thumbnail), and answered by the same DeepSeek route. The wrap covers DeepSeek and GLM text models only; their own vision models are excluded automatically.
|
|
38
38
|
|
|
39
39
|
**Paste an image and it reads it.** No saving to a file and passing a path first.
|
|
40
40
|
|
package/README.zh-CN.md
CHANGED
|
@@ -34,7 +34,7 @@ DeepSeek 和 GLM 没有视觉能力,无法进行图片识别。ModLens 借助
|
|
|
34
34
|
|
|
35
35
|
## 亮点
|
|
36
36
|
|
|
37
|
-
**🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `read_image`
|
|
37
|
+
**🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `read_image` 工具读图。如果 dsh 提示 `declares no dsh.bundle`,是 pnpm 的发布冷静期把版本压旧了,一行配置可解,见[故障排查](docs/troubleshooting.md#dsh-says-declares-no-dshbundle--installed-as-a-plain-dependency)。要粘贴识图,把模型选择器切到插件新增的两个条目之一:**`DeepSeek-V4-Flash (modlens vision)`** 或 **`DeepSeek-V4-Pro (modlens vision)`**,贴图放行、发请求时转成证据(你的消息保留原生缩略图)、仍由原 DeepSeek 路由回答。包装只覆盖 DeepSeek 与 GLM 的文本模型,两家自己的视觉型号自动排除。
|
|
38
38
|
|
|
39
39
|
**直接粘贴图片识别** 无需先保存成文件再提供路径。
|
|
40
40
|
|
|
@@ -62,7 +62,7 @@ agy # 浏览器完成
|
|
|
62
62
|
**DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
|
|
63
63
|
|
|
64
64
|
```sh
|
|
65
|
-
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens
|
|
65
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
装完即有 `read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.md)。
|
package/dist/main.js
CHANGED
|
@@ -13672,7 +13672,7 @@ function requireMockUtils() {
|
|
|
13672
13672
|
}
|
|
13673
13673
|
return normalizedQp;
|
|
13674
13674
|
}
|
|
13675
|
-
function
|
|
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" ?
|
|
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(
|
|
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
|
|
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
|
-
`
|
|
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(
|
|
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,7 +27407,7 @@ 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));
|
|
@@ -27677,6 +27680,33 @@ function hasPath(value, dottedPath) {
|
|
|
27677
27680
|
function isPlainObject(value) {
|
|
27678
27681
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27679
27682
|
}
|
|
27683
|
+
const TOKEN_SHAPES = [
|
|
27684
|
+
// Vendor-prefixed keys (OpenAI/Anthropic sk-, Stripe rk/pk, Slack xox*).
|
|
27685
|
+
/\b(?:sk|rk|pk|xox[a-z])-[A-Za-z0-9_-]{12,}\b/g,
|
|
27686
|
+
// Google API keys.
|
|
27687
|
+
/\bAIza[A-Za-z0-9_-]{20,}\b/g,
|
|
27688
|
+
// GitHub tokens.
|
|
27689
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
27690
|
+
// JWTs (three base64url segments, the first spelling {"alg" or {"typ").
|
|
27691
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g,
|
|
27692
|
+
// Auth headers: "Bearer xyz" / "Authorization: xyz" (space form is real).
|
|
27693
|
+
/\b(?:bearer|authorization)\b[=:\s]+"?[A-Za-z0-9._~+/-]{12,}"?/gi,
|
|
27694
|
+
// Labeled keys need an explicit = or : separator; prose like
|
|
27695
|
+
// "token limit_exceeded" is diagnostics, not a credential.
|
|
27696
|
+
/\b(?:token|api[-_]?key)\b\s*[=:]\s*"?[A-Za-z0-9._~+/-]{12,}"?/gi
|
|
27697
|
+
];
|
|
27698
|
+
function redactSecrets(text, knownSecrets = []) {
|
|
27699
|
+
let out = text;
|
|
27700
|
+
for (const secret of knownSecrets) {
|
|
27701
|
+
if (secret && secret.length >= 6) {
|
|
27702
|
+
out = out.split(secret).join("[redacted]");
|
|
27703
|
+
}
|
|
27704
|
+
}
|
|
27705
|
+
for (const shape of TOKEN_SHAPES) {
|
|
27706
|
+
out = out.replace(shape, "[redacted]");
|
|
27707
|
+
}
|
|
27708
|
+
return out;
|
|
27709
|
+
}
|
|
27680
27710
|
const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
|
|
27681
27711
|
const DEFAULT_BASE_URL$1 = "https://api.anthropic.com";
|
|
27682
27712
|
const TOOL_NAME = "report_vision_evidence";
|
|
@@ -27744,7 +27774,9 @@ Report your findings by calling the ${TOOL_NAME} tool.`;
|
|
|
27744
27774
|
});
|
|
27745
27775
|
if (!response2.ok) {
|
|
27746
27776
|
const body2 = await response2.text();
|
|
27747
|
-
throw new Error(
|
|
27777
|
+
throw new Error(
|
|
27778
|
+
`Anthropic API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
|
|
27779
|
+
);
|
|
27748
27780
|
}
|
|
27749
27781
|
const payload = await response2.json();
|
|
27750
27782
|
const toolUse = payload.content?.find((block) => block.type === "tool_use");
|
|
@@ -28035,7 +28067,9 @@ async function executeGeminiApi(options) {
|
|
|
28035
28067
|
});
|
|
28036
28068
|
if (!response2.ok) {
|
|
28037
28069
|
const body2 = await response2.text();
|
|
28038
|
-
throw new Error(
|
|
28070
|
+
throw new Error(
|
|
28071
|
+
`Gemini API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
|
|
28072
|
+
);
|
|
28039
28073
|
}
|
|
28040
28074
|
const payload = await response2.json();
|
|
28041
28075
|
const text = payload.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("");
|
|
@@ -28109,7 +28143,9 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
|
|
|
28109
28143
|
});
|
|
28110
28144
|
if (!response2.ok) {
|
|
28111
28145
|
const body2 = await response2.text();
|
|
28112
|
-
throw new Error(
|
|
28146
|
+
throw new Error(
|
|
28147
|
+
`OpenAI-compatible API error ${response2.status}: ${truncate(redactSecrets(body2, [apiKey]))}`
|
|
28148
|
+
);
|
|
28113
28149
|
}
|
|
28114
28150
|
const payload = await response2.json();
|
|
28115
28151
|
const text = payload.choices?.[0]?.message?.content;
|
|
@@ -28442,13 +28478,16 @@ const PROVIDER_DESCRIPTORS = [
|
|
|
28442
28478
|
];
|
|
28443
28479
|
function findOnPath(bin, env) {
|
|
28444
28480
|
const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
28481
|
+
const suffixes = process.platform === "win32" ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
|
|
28445
28482
|
for (const dir of dirs) {
|
|
28446
|
-
const
|
|
28447
|
-
|
|
28448
|
-
|
|
28449
|
-
|
|
28483
|
+
for (const suffix of suffixes) {
|
|
28484
|
+
const full = path.join(dir, bin + suffix);
|
|
28485
|
+
try {
|
|
28486
|
+
if (fs.statSync(full).isFile()) {
|
|
28487
|
+
return full;
|
|
28488
|
+
}
|
|
28489
|
+
} catch {
|
|
28450
28490
|
}
|
|
28451
|
-
} catch {
|
|
28452
28491
|
}
|
|
28453
28492
|
}
|
|
28454
28493
|
return null;
|
|
@@ -28609,7 +28648,10 @@ function probeCodex(env, home) {
|
|
|
28609
28648
|
loggedIn,
|
|
28610
28649
|
visionModels: [],
|
|
28611
28650
|
source: "none",
|
|
28612
|
-
error: error instanceof Error ? error.message : String(error)
|
|
28651
|
+
error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
|
|
28652
|
+
0,
|
|
28653
|
+
200
|
|
28654
|
+
)
|
|
28613
28655
|
};
|
|
28614
28656
|
}
|
|
28615
28657
|
});
|
|
@@ -28682,7 +28724,10 @@ function probePi(env, home) {
|
|
|
28682
28724
|
cliPath,
|
|
28683
28725
|
visionModels: [],
|
|
28684
28726
|
source: "none",
|
|
28685
|
-
error: error instanceof Error ? error.message : String(error)
|
|
28727
|
+
error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
|
|
28728
|
+
0,
|
|
28729
|
+
200
|
|
28730
|
+
)
|
|
28686
28731
|
};
|
|
28687
28732
|
}
|
|
28688
28733
|
});
|
|
@@ -28704,7 +28749,10 @@ function probeOpencode(env, runCli) {
|
|
|
28704
28749
|
cliPath,
|
|
28705
28750
|
visionModels: [],
|
|
28706
28751
|
source: "none",
|
|
28707
|
-
error: error instanceof Error ? error.message : String(error)
|
|
28752
|
+
error: redactSecrets(error instanceof Error ? error.message : String(error)).slice(
|
|
28753
|
+
0,
|
|
28754
|
+
200
|
|
28755
|
+
)
|
|
28708
28756
|
};
|
|
28709
28757
|
}
|
|
28710
28758
|
});
|
|
@@ -29221,7 +29269,9 @@ async function analyzeImage(options) {
|
|
|
29221
29269
|
provider: provider.name,
|
|
29222
29270
|
ok: false,
|
|
29223
29271
|
durationSeconds: (Date.now() - startedAt) / 1e3,
|
|
29224
|
-
|
|
29272
|
+
// Providers redact their own errors, but attempts travel into
|
|
29273
|
+
// output and model contexts, so the record gets the belt too.
|
|
29274
|
+
error: redactSecrets(message).slice(0, 300)
|
|
29225
29275
|
});
|
|
29226
29276
|
}
|
|
29227
29277
|
}
|
|
@@ -29444,7 +29494,9 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
29444
29494
|
const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
|
|
29445
29495
|
reject(
|
|
29446
29496
|
new Error(
|
|
29447
|
-
|
|
29497
|
+
redactSecrets(
|
|
29498
|
+
explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
|
|
29499
|
+
)
|
|
29448
29500
|
)
|
|
29449
29501
|
);
|
|
29450
29502
|
return;
|
|
@@ -29572,7 +29624,6 @@ function cwdMatches(recorded, wanted, bothDirections = false) {
|
|
|
29572
29624
|
return bothDirections && resolvedWanted.startsWith(`${resolvedRecorded}${path.sep}`);
|
|
29573
29625
|
}
|
|
29574
29626
|
function transcriptBelongsTo(lines, cwd, bothDirections = false) {
|
|
29575
|
-
let sawCwd = false;
|
|
29576
29627
|
for (const line of lines) {
|
|
29577
29628
|
if (!line.includes('"cwd"')) {
|
|
29578
29629
|
continue;
|
|
@@ -29582,14 +29633,13 @@ function transcriptBelongsTo(lines, cwd, bothDirections = false) {
|
|
|
29582
29633
|
if (typeof recorded !== "string") {
|
|
29583
29634
|
continue;
|
|
29584
29635
|
}
|
|
29585
|
-
sawCwd = true;
|
|
29586
29636
|
if (cwdMatches(recorded, cwd, bothDirections)) {
|
|
29587
29637
|
return true;
|
|
29588
29638
|
}
|
|
29589
29639
|
} catch {
|
|
29590
29640
|
}
|
|
29591
29641
|
}
|
|
29592
|
-
return
|
|
29642
|
+
return false;
|
|
29593
29643
|
}
|
|
29594
29644
|
function readLines(filePath) {
|
|
29595
29645
|
try {
|
|
@@ -29718,16 +29768,19 @@ const claudeAdapter = jsonlAdapter({
|
|
|
29718
29768
|
function opencodeDbPath() {
|
|
29719
29769
|
return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
|
|
29720
29770
|
}
|
|
29721
|
-
function
|
|
29722
|
-
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
29723
|
-
}
|
|
29724
|
-
function opencodeDirectoryFilter(resolvedCwd) {
|
|
29771
|
+
function opencodeDirectoryFilter(resolvedCwd, caseInsensitive = process.platform === "win32") {
|
|
29725
29772
|
const normalized = resolvedCwd.replace(/\\/g, "/");
|
|
29726
|
-
const
|
|
29727
|
-
const
|
|
29773
|
+
const cwd = caseInsensitive ? normalized.toLowerCase() : normalized;
|
|
29774
|
+
const prefix = `${cwd.replace(/\/+$/, "")}/`;
|
|
29775
|
+
const rawDir = `REPLACE(session.directory, '\\', '/')`;
|
|
29776
|
+
const dir = caseInsensitive ? `LOWER(${rawDir})` : rawDir;
|
|
29777
|
+
const dirPrefix = `RTRIM(${dir}, '/') || '/'`;
|
|
29728
29778
|
return {
|
|
29729
|
-
|
|
29730
|
-
|
|
29779
|
+
// SQLite SUBSTR counts Unicode characters while JS .length counts
|
|
29780
|
+
// UTF-16 units, so the length parameter is measured in code points
|
|
29781
|
+
// ([...str].length) or an emoji in a path would shift the boundary.
|
|
29782
|
+
clause: `(${dir} = ? OR SUBSTR(${dir}, 1, ?) = ? OR SUBSTR(?, 1, LENGTH(${dirPrefix})) = ${dirPrefix})`,
|
|
29783
|
+
params: [cwd, [...prefix].length, prefix, cwd]
|
|
29731
29784
|
};
|
|
29732
29785
|
}
|
|
29733
29786
|
function buildOpencodeQuery(resolvedCwd, sessionId) {
|
|
@@ -30175,8 +30228,13 @@ function inspectProvider(descriptor, config2, env) {
|
|
|
30175
30228
|
name: descriptor.name,
|
|
30176
30229
|
kind: "subprocess",
|
|
30177
30230
|
ready: binaryPath !== null,
|
|
30231
|
+
status: binaryPath !== null ? "installed" : "missing",
|
|
30232
|
+
// "On PATH" proves installation, not a working login: doctor runs
|
|
30233
|
+
// offline and spends nothing, so sign-in state stays unverified
|
|
30234
|
+
// here and the first real read is the auth check.
|
|
30235
|
+
authUnverified: binaryPath !== null,
|
|
30178
30236
|
binaryPath,
|
|
30179
|
-
detail: binaryPath ? `${descriptor.bin} found at ${binaryPath}` : `${descriptor.bin} not on PATH`,
|
|
30237
|
+
detail: binaryPath ? `${descriptor.bin} found at ${binaryPath} (installed; sign-in not verified offline)` : `${descriptor.bin} not on PATH`,
|
|
30180
30238
|
fix: binaryPath ? void 0 : descriptor.install
|
|
30181
30239
|
};
|
|
30182
30240
|
}
|
|
@@ -30194,6 +30252,7 @@ function inspectProvider(descriptor, config2, env) {
|
|
|
30194
30252
|
name: descriptor.name,
|
|
30195
30253
|
kind: "api",
|
|
30196
30254
|
ready,
|
|
30255
|
+
status: ready ? "ready" : "missing",
|
|
30197
30256
|
settings: statuses,
|
|
30198
30257
|
detail,
|
|
30199
30258
|
fix: ready ? void 0 : descriptor.fix
|
|
@@ -30314,7 +30373,8 @@ function renderDoctorReport(report) {
|
|
|
30314
30373
|
lines.push("");
|
|
30315
30374
|
lines.push("Providers");
|
|
30316
30375
|
for (const provider of report.providers) {
|
|
30317
|
-
|
|
30376
|
+
const providerMark = provider.ready && provider.authUnverified ? "[ok?]" : mark(provider.ready);
|
|
30377
|
+
lines.push(` ${providerMark} ${provider.name}: ${provider.detail}`);
|
|
30318
30378
|
if (provider.fix) {
|
|
30319
30379
|
lines.push(` fix: ${provider.fix}`);
|
|
30320
30380
|
}
|
|
@@ -30461,7 +30521,7 @@ function locateSource(cwd, adapters = ADAPTERS) {
|
|
|
30461
30521
|
const blocked = blockers.length > 0 ? `
|
|
30462
30522
|
Blocked: ${blockers.join(" | ")}` : "";
|
|
30463
30523
|
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,
|
|
30524
|
+
`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
30525
|
);
|
|
30466
30526
|
}
|
|
30467
30527
|
return best.ref;
|
|
@@ -30528,7 +30588,7 @@ function recoverPastedImages(options = {}) {
|
|
|
30528
30588
|
const all = source.extract();
|
|
30529
30589
|
if (all.length === 0) {
|
|
30530
30590
|
throw new Error(
|
|
30531
|
-
`No pasted images found in ${source.location}. The user may not have pasted any,
|
|
30591
|
+
`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
30592
|
);
|
|
30533
30593
|
}
|
|
30534
30594
|
const outDir = prepareOutDir(options.outDir);
|
|
@@ -30560,16 +30620,19 @@ function recoverPastedImages(options = {}) {
|
|
|
30560
30620
|
return result;
|
|
30561
30621
|
}
|
|
30562
30622
|
const program = new Command();
|
|
30563
|
-
|
|
30623
|
+
function parsePositiveInt(raw, flag) {
|
|
30624
|
+
if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) <= 0) {
|
|
30625
|
+
throw new Error(`Invalid ${flag}. Use a positive integer.`);
|
|
30626
|
+
}
|
|
30627
|
+
return Number.parseInt(raw, 10);
|
|
30628
|
+
}
|
|
30629
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.11.0");
|
|
30564
30630
|
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
30631
|
"--extra-body <json>",
|
|
30566
30632
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
|
30567
30633
|
).action(async (options) => {
|
|
30568
30634
|
try {
|
|
30569
|
-
const timeoutMs =
|
|
30570
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
30571
|
-
throw new Error("Invalid --timeout. Use a positive integer in milliseconds.");
|
|
30572
|
-
}
|
|
30635
|
+
const timeoutMs = parsePositiveInt(options.timeout, "--timeout (milliseconds)");
|
|
30573
30636
|
const config2 = loadConfigFile();
|
|
30574
30637
|
if (process.env.MODLENS_MODEL?.trim()) {
|
|
30575
30638
|
const verdict = runGuard(config2.guards, {
|
|
@@ -30607,7 +30670,7 @@ program.command("analyze", { isDefault: true }).description("Analyze an image in
|
|
|
30607
30670
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
|
30608
30671
|
`
|
|
30609
30672
|
);
|
|
30610
|
-
process.
|
|
30673
|
+
process.exitCode = 1;
|
|
30611
30674
|
}
|
|
30612
30675
|
});
|
|
30613
30676
|
program.command("recover-paste").description(
|
|
@@ -30620,10 +30683,7 @@ program.command("recover-paste").description(
|
|
|
30620
30683
|
"Force the storage scope: claude-code, pi, opencode, or none (default: auto-detect via process ancestry and env)"
|
|
30621
30684
|
).option("--cwd <path>", "Project directory the image was pasted in", process.cwd()).action(async (options) => {
|
|
30622
30685
|
try {
|
|
30623
|
-
const count =
|
|
30624
|
-
if (!Number.isFinite(count) || count <= 0) {
|
|
30625
|
-
throw new Error("Invalid --count. Use a positive integer.");
|
|
30626
|
-
}
|
|
30686
|
+
const count = parsePositiveInt(options.count, "--count");
|
|
30627
30687
|
const result = recoverPastedImages({
|
|
30628
30688
|
count,
|
|
30629
30689
|
outDir: options.outDir,
|
|
@@ -30639,7 +30699,7 @@ program.command("recover-paste").description(
|
|
|
30639
30699
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
|
30640
30700
|
`
|
|
30641
30701
|
);
|
|
30642
|
-
process.
|
|
30702
|
+
process.exitCode = 1;
|
|
30643
30703
|
}
|
|
30644
30704
|
});
|
|
30645
30705
|
program.command("guard").description(
|
|
@@ -30683,7 +30743,7 @@ program.command("doctor").description(
|
|
|
30683
30743
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
|
30684
30744
|
`
|
|
30685
30745
|
);
|
|
30686
|
-
process.
|
|
30746
|
+
process.exitCode = 1;
|
|
30687
30747
|
}
|
|
30688
30748
|
});
|
|
30689
30749
|
const config = program.command("config").description(`Manage ${CONFIG_PATH} (providers, keys, models)`);
|
|
@@ -30705,7 +30765,7 @@ config.command("init").description(`Create a starter config at ${CONFIG_PATH}`).
|
|
|
30705
30765
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
|
30706
30766
|
`
|
|
30707
30767
|
);
|
|
30708
|
-
process.
|
|
30768
|
+
process.exitCode = 1;
|
|
30709
30769
|
}
|
|
30710
30770
|
});
|
|
30711
30771
|
config.command("set <key> <value>").description("Set a value, e.g. modlens config set gemini-api.apiKey <key>").action((key, value) => {
|
|
@@ -30718,7 +30778,7 @@ config.command("set <key> <value>").description("Set a value, e.g. modlens confi
|
|
|
30718
30778
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
|
30719
30779
|
`
|
|
30720
30780
|
);
|
|
30721
|
-
process.
|
|
30781
|
+
process.exitCode = 1;
|
|
30722
30782
|
}
|
|
30723
30783
|
});
|
|
30724
30784
|
config.command("show").description("Print the effective config (file merged with env vars), API keys masked").action(() => {
|
|
@@ -30730,7 +30790,7 @@ config.command("show").description("Print the effective config (file merged with
|
|
|
30730
30790
|
`Error: ${error instanceof Error ? error.message : String(error)}
|
|
30731
30791
|
`
|
|
30732
30792
|
);
|
|
30733
|
-
process.
|
|
30793
|
+
process.exitCode = 1;
|
|
30734
30794
|
}
|
|
30735
30795
|
});
|
|
30736
|
-
program.
|
|
30796
|
+
await program.parseAsync();
|
package/docs/cli.md
CHANGED
|
@@ -30,6 +30,8 @@ Output is a fixed JSON shape:
|
|
|
30
30
|
"summary": "A workflow diagram with four nodes connected by labeled arrows.",
|
|
31
31
|
"ocr": { "full_text": "/shaping\nBEFORE YOU BUILD\n...", "lines": [] },
|
|
32
32
|
"layout": { "regions": [{ "reading_order": 1, "type": "title", "text": "/shaping" }] },
|
|
33
|
+
"semantics": { "scene": "workflow diagram", "entities": [], "relations": [] },
|
|
34
|
+
"visual": { "dominant_colors": ["white", "black"], "style": "flat", "notes": [] },
|
|
33
35
|
"uncertainty": []
|
|
34
36
|
},
|
|
35
37
|
"meta": {
|
|
@@ -65,7 +67,7 @@ Output is a fixed JSON shape:
|
|
|
65
67
|
`--extra-body` is how vendor-specific knobs get through, turning thinking off
|
|
66
68
|
being the common one. It applies to the three API providers and replaces the
|
|
67
69
|
configured `extraBody` for that run. Per-vendor spellings and the fields it
|
|
68
|
-
refuses to touch are in [Configuration](configure.md).
|
|
70
|
+
refuses to touch are in [Configuration](../skills/modlens/references/configure.md).
|
|
69
71
|
|
|
70
72
|
The default `-m` model depends on the provider:
|
|
71
73
|
|
|
@@ -88,7 +90,7 @@ The default `-m` model depends on the provider:
|
|
|
88
90
|
| `--harness <name>` | Force storage scope: `claude-code`, `pi`, `opencode`, `none` | auto-detect |
|
|
89
91
|
| `--cwd <path>` | Project directory the image was pasted in | current directory |
|
|
90
92
|
|
|
91
|
-
Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic`, and `claude-cli` (uses your existing Claude subscription). Without `-p`, a run tries every provider that is set up, inline API providers first (5-10s), then the agents; the first good result wins and `meta.attempts` records the rest. Harnesses granted via `reuse.<harness>` contribute reused engines to the same regions (pi credentials inline, agent CLIs behind), with no priority over the user's own; details and the `guards` deny/allow lists are in [Configuration](configure.md).
|
|
93
|
+
Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `openai` (any OpenAI-compatible multimodal endpoint), `anthropic`, and `claude-cli` (uses your existing Claude subscription). Without `-p`, a run tries every provider that is set up, inline API providers first (5-10s), then the agents; the first good result wins and `meta.attempts` records the rest. Harnesses granted via `reuse.<harness>` contribute reused engines to the same regions (pi credentials inline, agent CLIs behind), with no priority over the user's own; details and the `guards` deny/allow lists are in [Configuration](../skills/modlens/references/configure.md).
|
|
92
94
|
|
|
93
95
|
Other subcommands:
|
|
94
96
|
|
package/docs/harness-setup.md
CHANGED
|
@@ -18,7 +18,7 @@ One catch with text-only models: once `models.json` declares `input_modalities:
|
|
|
18
18
|
|
|
19
19
|
## Claude Code, Pi, OpenCode
|
|
20
20
|
|
|
21
|
-
None of them
|
|
21
|
+
None of them hands the model a usable temp-file path the way Codex does (newer Claude Code builds do write pastes to their own `~/.claude/image-cache/`, injected as a path line only in the terminal entrypoint), but all three persist the user message locally before any gateway strips it:
|
|
22
22
|
|
|
23
23
|
| Harness | Storage | Notes |
|
|
24
24
|
| :-- | :-- | :-- |
|
|
@@ -53,7 +53,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
|
|
|
53
53
|
dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
|
|
54
54
|
|
|
55
55
|
```sh
|
|
56
|
-
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens
|
|
56
|
+
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
This registers a `read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin
|
|
59
|
+
This registers a `read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
|
package/docs/output-schema.md
CHANGED
|
@@ -67,6 +67,6 @@ The CLI prints one JSON object to stdout:
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Required fields: `summary`, `ocr`, `layout`, `semantics`, `uncertainty
|
|
70
|
+
Required fields: `summary`, `ocr`, `layout`, `semantics`, `visual`, `uncertainty` — every top-level field, `visual` included. (Earlier docs called `visual` optional; the enforced schema has always required it, so build to the schema.)
|
|
71
71
|
|
|
72
72
|
Changes from v1: pixel `bbox` coordinates and numeric `confidence` scores were removed. Vision models fabricate both, so v2 stops pretending to provide them. `layout.regions[].type` gained `code`.
|
package/docs/security.md
CHANGED
|
@@ -22,7 +22,15 @@ The `claude-cli` provider runs with `--allowedTools Read` only, so it can read l
|
|
|
22
22
|
|
|
23
23
|
Both subprocess providers also run in a throwaway directory created fresh per call and removed afterward. For a local image it holds a private copy of that one image and nothing else, and it is a real copy, never a hardlink, so a provider writing to its temp path cannot touch the original. For a remote image the directory is empty and the agent downloads into it. Without this, text inside an image could steer a broadly-permissioned agent into reading files next to the original, or whatever project the caller happened to be in. Passing `--workdir` opts out and runs where you point it.
|
|
24
24
|
|
|
25
|
-
This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer
|
|
25
|
+
This is exposure reduction, not an OS sandbox: the agent can still read absolute paths, reach the network, and spawn processes. Treat it as a narrower default, not a security boundary. For images you do not trust, prefer `-p gemini-api`, which downloads the bytes itself and runs no local agent. Remote URLs already prefer the inline region: the failover chain for a remote URL tries the inline API providers first and the agents last. Who actually fetches a remote URL differs per provider, and only a local download can be guarded locally:
|
|
26
|
+
|
|
27
|
+
| Provider | Who fetches a remote URL | Local guards |
|
|
28
|
+
| :-- | :-- | :-- |
|
|
29
|
+
| `gemini-api` | modlens downloads, sends bytes inline | private-address guards, magic-byte check, 25 MB cap |
|
|
30
|
+
| `openai`, `anthropic` | the URL is passed to the vendor, which fetches it | none locally; the vendor's own fetching policy applies |
|
|
31
|
+
| `antigravity-cli`, agent CLIs | the agent fetches on its own | none locally |
|
|
32
|
+
|
|
33
|
+
So the private-address guards, the magic-byte check, and the size cap protect exactly the paths where modlens itself downloads: every local file read, and gemini-api's remote fetch. An explicit `-p` pins one provider and overrides the chain.
|
|
26
34
|
|
|
27
35
|
## Image content is untrusted input
|
|
28
36
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -119,6 +119,41 @@ One known blind spot: storage detection reads the newest assistant turn recorded
|
|
|
119
119
|
|
|
120
120
|
Note that the hard refusal above only fires on an actual `denyModels` match against the explicit `MODLENS_MODEL` value. Storage detection and the `denyWhenUnknown` policy never block `analyze`, they only speak through `modlens guard`, whose deny is advice to the agent rather than a locked door.
|
|
121
121
|
|
|
122
|
+
## dsh says "declares no dsh.bundle — installed as a plain dependency"
|
|
123
|
+
|
|
124
|
+
The dsh profile installed an old modlens version. The `dsh.bundle` declaration
|
|
125
|
+
exists since 3.9.0, and pnpm v11's release-age gate (`minimumReleaseAge`,
|
|
126
|
+
quarantining recently published versions, with a 10-day window measured on pnpm 11.21) silently falls back to an older version when every
|
|
127
|
+
recent one is inside the window. That old version has no bundle declaration,
|
|
128
|
+
so dsh correctly treats it as a plain dependency and none of the tools appear.
|
|
129
|
+
|
|
130
|
+
The fix: name the version explicitly. pnpm applies the age gate when resolving
|
|
131
|
+
a range, but an explicit version or dist-tag skips it ([pnpm#9989](https://github.com/pnpm/pnpm/issues/9989), verified on pnpm 11.21), which is why the install
|
|
132
|
+
command carries `@latest`:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@latest
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
dsh's reconcile notices the bundle declaration on the new version and
|
|
139
|
+
activates it; restart dsh afterwards. Verify with
|
|
140
|
+
`npx -y @deepseek-ai/dsh plugin --profile <name> list` — the version shown
|
|
141
|
+
should be 3.9.0 or newer.
|
|
142
|
+
|
|
143
|
+
If a future pnpm closes that skip, the durable alternative is a one-time
|
|
144
|
+
exclusion in `~/.dsh/profiles/<name>/pnpm-workspace.yaml` — the bare package
|
|
145
|
+
name, not `name@version`, so it survives future releases:
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
minimumReleaseAgeExclude:
|
|
149
|
+
- '@liustack/modlens'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
then `npx -y @deepseek-ai/dsh plugin --profile <name> update @liustack/modlens`.
|
|
153
|
+
The trade-off is honest either way: an explicit `@latest` (or the exclusion)
|
|
154
|
+
opts modlens out of pnpm's supply-chain cooling-off window, so new releases
|
|
155
|
+
install immediately.
|
|
156
|
+
|
|
122
157
|
## Config file problems
|
|
123
158
|
|
|
124
159
|
```
|
package/dsh/index.js
CHANGED
|
@@ -24,11 +24,13 @@ const CLI_TIMEOUT_MS = 180_000
|
|
|
24
24
|
export const name = 'modlens'
|
|
25
25
|
export const inject = ['tools', 'agents', 'attachments', 'llm']
|
|
26
26
|
|
|
27
|
-
const MEDIA_EXT = {
|
|
27
|
+
export const MEDIA_EXT = {
|
|
28
28
|
'image/png': '.png',
|
|
29
29
|
'image/jpeg': '.jpg',
|
|
30
30
|
'image/webp': '.webp',
|
|
31
31
|
'image/gif': '.gif',
|
|
32
|
+
'image/heic': '.heic',
|
|
33
|
+
'image/heif': '.heif',
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function apply(ctx, config = {}) {
|
|
@@ -191,6 +193,84 @@ function registerVisionProvider(ctx, config) {
|
|
|
191
193
|
}
|
|
192
194
|
}
|
|
193
195
|
|
|
196
|
+
// The same pasted attachment rides every later step of its session, but the
|
|
197
|
+
// cache must never make a failure permanent or run the engine twice for
|
|
198
|
+
// concurrent steps. So it stores promises (concurrent readers join the first
|
|
199
|
+
// run), evicts failed reads on settle (a fixed config gets a fresh chance),
|
|
200
|
+
// and caps itself LRU-style so a long-lived Web profile cannot hoard
|
|
201
|
+
// evidence text forever.
|
|
202
|
+
const EVIDENCE_CACHE_LIMIT = 64
|
|
203
|
+
|
|
204
|
+
function cachedEvidence(ctx, adapter, block) {
|
|
205
|
+
const key = JSON.stringify(block.attachment ?? block)
|
|
206
|
+
const hit = adapter.evidenceCache.get(key)
|
|
207
|
+
if (hit !== undefined) {
|
|
208
|
+
// Refresh recency: Map iteration order is insertion order.
|
|
209
|
+
adapter.evidenceCache.delete(key)
|
|
210
|
+
adapter.evidenceCache.set(key, hit)
|
|
211
|
+
return hit
|
|
212
|
+
}
|
|
213
|
+
// Deliberately no caller signal: a shared entry must not die with its first
|
|
214
|
+
// caller (their abort used to cancel every concurrent joiner). A cancelled
|
|
215
|
+
// caller simply stops awaiting; the read finishes and the cache keeps it.
|
|
216
|
+
const pending = readImageBlock(ctx, block, undefined).then(
|
|
217
|
+
(evidence) => {
|
|
218
|
+
// Only evict our own entry: this promise may have been LRU-evicted and
|
|
219
|
+
// the key re-populated by a newer read meanwhile.
|
|
220
|
+
if (!evidence.ok && adapter.evidenceCache.get(key) === pending) {
|
|
221
|
+
adapter.evidenceCache.delete(key)
|
|
222
|
+
}
|
|
223
|
+
return evidence.block
|
|
224
|
+
},
|
|
225
|
+
(error) => {
|
|
226
|
+
// readImageBlock never rejects by contract; this is the belt for a
|
|
227
|
+
// future refactor breaking that, so a rejected promise cannot lodge in
|
|
228
|
+
// the cache forever.
|
|
229
|
+
if (adapter.evidenceCache.get(key) === pending) {
|
|
230
|
+
adapter.evidenceCache.delete(key)
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
type: 'text',
|
|
234
|
+
text: `[A pasted image could not be read by modlens: ${
|
|
235
|
+
error instanceof Error ? error.message.slice(0, 300) : String(error)
|
|
236
|
+
}]`,
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
adapter.evidenceCache.set(key, pending)
|
|
241
|
+
while (adapter.evidenceCache.size > EVIDENCE_CACHE_LIMIT) {
|
|
242
|
+
adapter.evidenceCache.delete(adapter.evidenceCache.keys().next().value)
|
|
243
|
+
}
|
|
244
|
+
return pending
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Wait on a shared promise without inheriting its lifetime: the caller's
|
|
249
|
+
* abort rejects THIS wait immediately, while the underlying read keeps
|
|
250
|
+
* running and lands in the cache for the retry.
|
|
251
|
+
*/
|
|
252
|
+
function abortableWait(promise, signal) {
|
|
253
|
+
if (!signal) return promise
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
if (signal.aborted) {
|
|
256
|
+
reject(signal.reason ?? new Error('aborted'))
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const onAbort = () => reject(signal.reason ?? new Error('aborted'))
|
|
260
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
261
|
+
promise.then(
|
|
262
|
+
(value) => {
|
|
263
|
+
signal.removeEventListener('abort', onAbort)
|
|
264
|
+
resolve(value)
|
|
265
|
+
},
|
|
266
|
+
(error) => {
|
|
267
|
+
signal.removeEventListener('abort', onAbort)
|
|
268
|
+
reject(error)
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
194
274
|
async function convertImagesToEvidence(ctx, messages, signal, adapter) {
|
|
195
275
|
const out = []
|
|
196
276
|
for (const message of messages) {
|
|
@@ -204,13 +284,7 @@ async function convertImagesToEvidence(ctx, messages, signal, adapter) {
|
|
|
204
284
|
content.push(block)
|
|
205
285
|
continue
|
|
206
286
|
}
|
|
207
|
-
|
|
208
|
-
let text = adapter.evidenceCache.get(key)
|
|
209
|
-
if (text === undefined) {
|
|
210
|
-
text = (await readImageBlock(ctx, block, signal)).text
|
|
211
|
-
adapter.evidenceCache.set(key, text)
|
|
212
|
-
}
|
|
213
|
-
content.push({ type: 'text', text })
|
|
287
|
+
content.push(await abortableWait(cachedEvidence(ctx, adapter, block), signal))
|
|
214
288
|
}
|
|
215
289
|
out.push({ ...message, content })
|
|
216
290
|
}
|
|
@@ -251,7 +325,7 @@ function registerAutoRead(ctx) {
|
|
|
251
325
|
content.push(block)
|
|
252
326
|
continue
|
|
253
327
|
}
|
|
254
|
-
content.push(await readImageBlock(ctx, block, payload.signal))
|
|
328
|
+
content.push((await readImageBlock(ctx, block, payload.signal)).block)
|
|
255
329
|
}
|
|
256
330
|
messages.push({ ...message, content })
|
|
257
331
|
}
|
|
@@ -259,6 +333,12 @@ function registerAutoRead(ctx) {
|
|
|
259
333
|
})
|
|
260
334
|
}
|
|
261
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Read one image block into an evidence text block. Never throws: failures
|
|
338
|
+
* degrade to an explanatory block with `ok: false`, so callers can decide
|
|
339
|
+
* what a failure means (the pre-step keeps the step going, the cache refuses
|
|
340
|
+
* to memoize it).
|
|
341
|
+
*/
|
|
262
342
|
async function readImageBlock(ctx, block, signal) {
|
|
263
343
|
const { mkdtemp, rm, writeFile } = await import('node:fs/promises')
|
|
264
344
|
const { tmpdir } = await import('node:os')
|
|
@@ -275,9 +355,15 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
275
355
|
"attachments.readImage returned no 'data' bytes; the dsh attachment shape may have changed",
|
|
276
356
|
)
|
|
277
357
|
}
|
|
278
|
-
dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-'))
|
|
279
358
|
const mediaType = stored.ref?.mediaType ?? block.attachment?.mediaType
|
|
280
|
-
const
|
|
359
|
+
const ext = MEDIA_EXT[mediaType]
|
|
360
|
+
if (!ext) {
|
|
361
|
+
// Refusing beats disguising: a fake .png suffix would make the CLI (and
|
|
362
|
+
// the provider behind it) judge mislabelled bytes.
|
|
363
|
+
throw new Error(`unsupported pasted media type ${mediaType ?? '(none declared)'}`)
|
|
364
|
+
}
|
|
365
|
+
dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-'))
|
|
366
|
+
const file = join(dir, `paste${ext}`)
|
|
281
367
|
await writeFile(file, Buffer.from(stored.data), { mode: 0o600 })
|
|
282
368
|
const cli = process.env.MODLENS_DSH_CLI || CLI_PATH
|
|
283
369
|
const { stdout, stderr, code } = await run(
|
|
@@ -290,15 +376,21 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
290
376
|
}
|
|
291
377
|
const parsed = JSON.parse(stdout)
|
|
292
378
|
return {
|
|
293
|
-
|
|
294
|
-
|
|
379
|
+
ok: true,
|
|
380
|
+
block: {
|
|
381
|
+
type: 'text',
|
|
382
|
+
text: `[Pasted image, read by the modlens vision bridge]\n${renderEvidence(parsed.result)}`,
|
|
383
|
+
},
|
|
295
384
|
}
|
|
296
385
|
} catch (error) {
|
|
297
386
|
return {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
387
|
+
ok: false,
|
|
388
|
+
block: {
|
|
389
|
+
type: 'text',
|
|
390
|
+
text: `[A pasted image could not be read by modlens: ${
|
|
391
|
+
error instanceof Error ? error.message.slice(0, 300) : String(error)
|
|
392
|
+
}. Tell the user, and suggest running \`npx @liustack/modlens doctor\`.]`,
|
|
393
|
+
},
|
|
302
394
|
}
|
|
303
395
|
} finally {
|
|
304
396
|
if (dir) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liustack/modlens",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
13
13
|
"test": "vitest run",
|
|
14
14
|
"coverage": "vitest run --coverage",
|
|
15
|
-
"lint": "biome check src scripts",
|
|
15
|
+
"lint": "biome check src scripts dsh",
|
|
16
16
|
"format": "biome check --write src scripts",
|
|
17
17
|
"eval": "node evals/run.mjs",
|
|
18
18
|
"release": "node scripts/release.mjs",
|
package/skills/modlens/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: modlens
|
|
3
|
-
description: "Plug-in vision for text-only models. Hard rule: when a file path or URL with an image extension (.png, .jpg, .jpeg, .webp, .gif, .
|
|
3
|
+
description: "Plug-in vision for text-only models. Hard rule: when a file path or URL with an image extension (.png, .jpg, .jpeg, .webp, .gif, .heic, .heif) appears anywhere in the conversation (typed by the user, injected as a `[Image: source: <path>]` line, or inside a tag) and you cannot see that image's content, run this skill on it before any other approach: no self-built OCR, no PIL, no tesseract. Also triggers on pasted-image placeholders such as `[Image #1]` and `[Unsupported Image]`. If you can actually see the image, do not use this skill. When unsure, run `modlens guard` before the first read of a session: a deny verdict means the active model has native vision and must read the image itself. Runs the modlens CLI to convert the image into structured JSON evidence: every word transcribed, layout regions, semantics, visual clues. Also use when the user asks how to install, configure, or switch modlens providers (Gemini API key, OpenAI-compatible endpoints, Claude API or Claude Code CLI)."
|
|
4
4
|
compatibility: Requires network access and one of node 22+/npx, bun/bunx, or a preinstalled modlens binary on PATH.
|
|
5
5
|
allowed-tools: Bash
|
|
6
6
|
---
|
|
@@ -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.11.0):
|
|
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.11.0: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.11.0 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.11.0 <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,7 +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
|
-
"reuse": { "claude": true, "codex": true, "opencode": false, "pi": true },
|
|
25
|
+
"reuse": { "claude": true, "codex": true, "opencode": false, "pi": true, "grok": true },
|
|
26
26
|
"guards": {
|
|
27
27
|
"allowModels": ["deepseek-v4-*", "glm-5.*", "minimax-m2.5*", "qwen3-coder*"],
|
|
28
28
|
"denyModels": ["glm-*v*", "deepseek-vl*"],
|
|
@@ -18,7 +18,7 @@ Newer Claude Code builds write every pasted image to `~/.claude/image-cache/<ses
|
|
|
18
18
|
|
|
19
19
|
## Claude Code, Pi, or OpenCode (no usable path anywhere)
|
|
20
20
|
|
|
21
|
-
The image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see. Whatever a gateway strips from the request, these harnesses persist user messages, image bytes included, in local session storage first: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.
|
|
21
|
+
The image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see. Whatever a gateway strips from the request, these harnesses persist user messages, image bytes included, in local session storage first: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.13+; Bun cannot load node:sqlite, so a bunx-resolved launcher needs a real Node install for OpenCode recovery).
|
|
22
22
|
|
|
23
23
|
- Run `modlens recover-paste` from the project directory the conversation is happening in (`--count <n>` for several images). It detects which harness it is running inside and reads ONLY that harness's storage. In Claude Code it targets your exact session automatically via the injected `CLAUDE_CODE_SESSION_ID`; `--session <id>` is only needed to override.
|
|
24
24
|
- The output is JSON with real file paths, oldest to newest, so the LAST path is the most recent paste: analyze that one first. Entries carry `filename` when the harness stored one; match on it when the user's message mentions a name.
|
|
@@ -8,7 +8,7 @@ Run this flow when `~/.modlens/config.json` does not exist, or `modlens config s
|
|
|
8
8
|
modlens doctor --json
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Read three things from the report:
|
|
11
|
+
Read three things from the report: each provider's `status` (`ready` means verified, `installed` means a CLI is on PATH with its sign-in unverified until the first real read, rendered as `[ok?]` in the text report), the Reuse section (per-harness decisions plus discovered logins and vision models, where the harness this conversation runs inside is itself the first reusable engine), and the guard state. Doctor spends no quota and makes no network calls.
|
|
12
12
|
|
|
13
13
|
## 2. Tell the user what their machine already has
|
|
14
14
|
|
|
@@ -16,9 +16,9 @@ One line per finding, plain words, in the user's language. Name concrete things,
|
|
|
16
16
|
|
|
17
17
|
- An engine is ready: "modlens is ready to go: <provider> is configured (via <env var / config file / existing login>)."
|
|
18
18
|
- Reusable vision found: "Your <harness> CLI is signed in and its model can read images. modlens can reuse it when needed, about <n> seconds per read, and it spends that account's quota."
|
|
19
|
-
- Nothing at all: "No vision engine is set up yet. The fastest free option is a Gemini API key (three minutes, no card)
|
|
19
|
+
- Nothing at all: "No vision engine is set up yet. The fastest free option is a Gemini API key (three minutes, no card). Antigravity CLI works with no sign-up at all."
|
|
20
20
|
|
|
21
|
-
Do not dump the raw doctor output on the user
|
|
21
|
+
Do not dump the raw doctor output on the user, summarize it. Do not describe options the machine does not have.
|
|
22
22
|
|
|
23
23
|
## 3. Ask before touching anything
|
|
24
24
|
|
|
@@ -27,7 +27,7 @@ Consent rules:
|
|
|
27
27
|
- One question per decision, never a bundled yes. Reusing Codex and reusing pi credentials are two questions (or one question with independent options), not one.
|
|
28
28
|
- Each question names the harness, whose quota it spends, and the accounting promise. Example wording: "Allow modlens to reuse your signed-in Codex CLI for image reads? Every reused read is labeled in the result so you always see whose quota was spent."
|
|
29
29
|
- The do-nothing outcome must be safe and stated: "If you skip this, modlens just uses the engines you configure yourself."
|
|
30
|
-
- If the user offers an API key, take exactly that key
|
|
30
|
+
- If the user offers an API key, take exactly that key, and never go looking for keys they did not hand over.
|
|
31
31
|
|
|
32
32
|
## 4. Apply only what was consented to
|
|
33
33
|
|
|
@@ -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.11.0'
|
|
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.11.0"
|
|
26
26
|
# -------------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"
|