@liustack/modlens 3.15.0 → 3.16.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 +12 -0
- package/README.md +5 -2
- package/README.zh-CN.md +26 -19
- package/dist/main.js +85 -28
- package/docs/harness-setup.md +16 -4
- package/docs/harness-setup.zh-CN.md +1 -1
- package/dsh/client.js +87 -19
- package/dsh/index.js +180 -31
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +5 -5
- package/skills/modlens/references/configure.md +2 -2
- package/skills/modlens/references/configure.zh-CN.md +2 -2
- 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.16.1 - 2026-08-14
|
|
4
|
+
|
|
5
|
+
- **OpenChamber (OpenCode's desktop UI) is detected, and Windows detection got quieter and sharper ([#30](https://github.com/liustack/modlens/issues/30)).** Three stacked Windows gaps from one runtime-confirmed report. The env-fingerprint fallback never checked the markers opencode servers inject (`OPENCODE`, `OPENCODE_PID`, `OPENCODE_BINARY`), so OpenChamber read as "none detected" and `recover-paste` never auto-ran, while the pasted bytes sat recoverable in the opencode database the whole time; the fingerprint now resolves to `opencode`, placed before Claude Code's so nested setups pick the innermost input box. The `ps` ancestry probe now runs only off Windows: MSYS machines carry a `ps` that exists but rejects `-Ao`, and a failed child's stderr printed into every `doctor` run (the docs always said Windows skips ancestry, now the code agrees). And `findOnPath` tries the PATHEXT extensions before the bare name, so the POSIX `sh` shim npm installs next to `opencode.cmd` no longer shadows the executable into a `spawnSync ENOENT`. Thanks to @IA20201 for a report with the evidence already attached: observed env markers, doctor output, and the exact database row.
|
|
6
|
+
|
|
7
|
+
## 3.16.0 - 2026-08-14
|
|
8
|
+
|
|
9
|
+
- **dsh: whether a paste is taken over is now the host's call, made from real model metadata.** The browser half used to guess with a name regex, which read every vision model it did not recognize (Qwen2.5-VL, GPT-4o, ...) as text-only and hijacked its native paste. And turning `pasteToPath` off only removed the host route while the client kept capturing pastes into a 404. The client now asks `GET /modlens/paste?model=<selector label>` and the host answers from the provider registry's declared `inputModalities`, with every unknown answered conservatively: the label carries no provider id, so EVERY model whose name or id appears in it must be confirmed text-only. One image-capable match anywhere vetoes, an unreadable provider catalog vetoes (the vision twin could live there), and missing modality metadata counts as unknown, never as text-only. Verdicts are re-asked on every composer focus and every paste, the host empties its own cache on every provider-topology change (a same-named vision route mounting mid-session is seen within one round-trip), a 60-second hard age bound backstops both, and a route that vanishes mid-session costs only the pastes inside the one round-trip it takes the failure to come back, after which the client forgets its verdicts and stands down. Until a model is positively confirmed text-only, pastes stay native. The paste route's magic-byte table now matches the CLI's signature for signature (a plain `ftypmp42` video is refused instead of saved as `paste.heic`, real heic/heif brands pass), and the browser half gained its first test suite, loaded straight from `dsh/client.js`.
|
|
10
|
+
- **A provider that ignores SIGTERM no longer outlives the CLI.** The SIGKILL escalation timer was unref'd, so in a standalone run it died with the event loop and a stubborn provider process survived its parent indefinitely, found by independent review with a live repro. The timer is now ref'd and cleared by the child's own exit (normal runs pay nothing, the stubborn case at most the 2s grace), pinned by a test that drives the real spawn path from a genuinely independent parent process, because inside the test runner its own timers had been masking exactly this.
|
|
11
|
+
- **`config show` and error text stop leaking proxy credentials.** A proxy URL's userinfo (`http://alice:s3cr3t@proxy`) rendered verbatim in the one command whose contract is being safe to paste into an issue. It is now masked everywhere config show renders a proxy, and the shared redactor handles URL userinfo through the same WHATWG parser the runtime connects with, instead of a regex approximation: backslash authorities, slash runs, tabs inside the authority, and passwords containing `@` all get their credentials removed in normalized form, while scheme-less `//text@` prose and ordinary query `@`s stay verbatim.
|
|
12
|
+
- **The release pipeline can no longer publish first and fail later.** The tag workflow validated the CHANGELOG section only after `npm publish`. The notes are now extracted and verified before anything irreversible runs. And the release script's `git push --follow-tags` could deliver the tag while a stale main was rejected, triggering a release from a tree the remote branch never contained. It now syncs with origin first, refuses a behind or diverged main and an already-existing remote tag, and pushes branch and tag atomically, together or not at all.
|
|
13
|
+
- **The gates got honest.** `pnpm lint` always claimed to check `dsh` while the Biome config silently excluded it (now covered, with the plugin's own style rules); CI ran "Node 22" as latest-22.x while calling it the 22.19 floor (the matrix now runs the exact floor); and an Electron-runtime argv test pins the [#25](https://github.com/liustack/modlens/issues/25) fix against regression. Docs corrections ride along: both READMEs present the two paste flows as they actually ship, an empty `provider` is documented as the failover chain rather than a hardcoded antigravity pin, and the skill front matter carries the real 22.19 floor.
|
|
14
|
+
|
|
3
15
|
## 3.15.0 - 2026-08-14
|
|
4
16
|
|
|
5
17
|
- **dsh: the vision wrap discovers every eligible provider route ([#29](https://github.com/liustack/modlens/issues/29)).** The wrapper used to bind one hardcoded upstream, so a machine with several subscription packages (opencode-go, zai, ...) had to hand-pick a single route and lose the rest. With `upstream` unset the plugin now sweeps the llm registry and registers a `modlens-<provider>` wrapper for every route carrying wrappable text-only DeepSeek/GLM models, re-sweeping on the registry's own `llm/adapters-updated` notification so late-registering routes (llm-pi-ai mounts after settings load) are picked up without polling. A `discover` array narrows the set, `families` filters as before, setting `upstream` keeps exact legacy single-route behavior, wrappers never wrap themselves, and the deepseek-official wrap keeps its historical `deepseek-modlens` id so an upgraded machine's model selection survives. Design and behavior spec contributed by @zlycode01, who also published a reference implementation in a public fork; this is an independent upstream implementation of that spec, with the polling schedule replaced by the registry notification.
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
<img src="https://img.shields.io/badge/users-unknown-lightgrey?style=flat-square" alt="Users unknown">
|
|
27
27
|
</p>
|
|
28
28
|
|
|
29
|
-
DeepSeek and GLM
|
|
29
|
+
The flagship DeepSeek and GLM chat models are text-only and cannot read images. ModLens is a plug-in vision engine that gives a text-only model sight. **ModLens reads images pasted straight into the chat**, no saving to a file and passing a path first.
|
|
30
30
|
|
|
31
31
|
## Talk to us
|
|
32
32
|
|
|
@@ -34,10 +34,13 @@ 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@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.
|
|
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.
|
|
38
|
+
|
|
39
|
+
Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer — the same interaction OpenCode and Pi ship — and the `read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying text-only DeepSeek or GLM models and adds a wrapped entry per route (a stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**; extra routes like opencode-go or zai get their own); the two families' own vision models are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
|
|
38
40
|
|
|
39
41
|
**Paste an image and it reads it.** No saving to a file and passing a path first.
|
|
40
42
|
|
|
43
|
+
- **The lightest touch on the market.** No hooks, no wrappers, no local proxy daemon, not a single line changed in any harness config: on the skill harnesses it is exactly one skill folder, on dsh exactly one plugin. Uninstalling is deleting a folder, and your agents are back to stock.
|
|
41
44
|
- **Zero-config start.** Reuses what Claude Code, Codex, OpenCode, or Pi already have set up: the multimodal models on your machine go straight to work. Nothing at all? Antigravity CLI is a free no-key channel, and a free Gemini key brings a read down to 5-10 seconds.
|
|
42
45
|
- **Evidence, not imagination.** Full transcription, reading-order layout regions, entity and relation lists. The model quotes specifics.
|
|
43
46
|
- **Install once, use everywhere.** Verified on real machines in Claude Code, Codex, Pi, and OpenCode.
|
package/README.zh-CN.md
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
<img src="https://img.shields.io/badge/users-unknown-lightgrey?style=flat-square" alt="Users unknown">
|
|
27
27
|
</p>
|
|
28
28
|
|
|
29
|
-
DeepSeek 和 GLM
|
|
29
|
+
DeepSeek 和 GLM 的主力对话模型是纯文本的,无法进行图片识别。ModLens 借助外挂视觉引擎,为纯文本模型补上视觉能力。**ModLens 支持直接粘贴图片识别**,无需先保存成文件再提供路径。
|
|
30
30
|
|
|
31
31
|
## 交流
|
|
32
32
|
|
|
@@ -34,10 +34,17 @@ DeepSeek 和 GLM 没有视觉能力,无法进行图片识别。ModLens 借助
|
|
|
34
34
|
|
|
35
35
|
## 亮点
|
|
36
36
|
|
|
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
|
|
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.zh-CN.md#dsh-提示-declares-no-dshbundle--installed-as-a-plain-dependency)。
|
|
38
|
+
|
|
39
|
+
DeepSeek Harness 粘贴识图有两种玩法。
|
|
40
|
+
|
|
41
|
+
**① 直接粘贴** 贴进来的图片自主转换成文件路径进输入框(与 OpenCode、Pi 同款交互),`read_image` 工具接手读图。
|
|
42
|
+
|
|
43
|
+
**② 切到带 `(modlens vision)` 后缀的模型变体**(选择器有记忆,选一次就行)再粘贴:缩略图直接可见、所见即所得,体验更接近 Codex App。变体由插件自动发现生成:每条承载纯文本 DeepSeek 或 GLM 模型的 provider 路由各得一组包装条目(默认安装下就是 **`DeepSeek-V4-Flash (modlens vision)`** 和 **`DeepSeek-V4-Pro (modlens vision)`**,装了 opencode-go、zai 等额外路由的机器会各自多出一组),两家自己的视觉型号自动排除。走哪条通路由 host 依据真实模型元数据逐个裁决:只有被元数据确认纯文本的模型才会被接管,确认不了的一律不动,视觉模型因此保留原生贴图([细节](docs/harness-setup.zh-CN.md))。
|
|
38
44
|
|
|
39
45
|
**直接粘贴图片识别** 无需先保存成文件再提供路径。
|
|
40
46
|
|
|
47
|
+
- **全网最轻量。** 不用 hook,不套壳,不跑本地代理进程,不改任何 harness 配置的一行字:在 skill 类 harness 里它就是一个 skill 文件夹,在 dsh 里就是一个插件。卸载等于删个文件夹,你的 agent 立刻回到原样。
|
|
41
48
|
- **零配置起手。** 复用 Claude Code、Codex、OpenCode、Pi 已配置,直接复用你本机的其他多模态模型。什么都没有?Antigravity CLI 是免 key 的免费通道,配一个免费 Gemini key 可将识别耗时降至 5 到 10 秒。
|
|
42
49
|
- **基于证据而非想象。** 全文转录、按阅读顺序划分的版面区块、实体与关系列表,模型引用的是具体内容。
|
|
43
50
|
- **一次安装,多端可用。** Claude Code、Codex、Pi、OpenCode 均经真机验证。
|
|
@@ -75,13 +82,13 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
|
75
82
|
|
|
76
83
|
ModLens 不绑定任何单一视觉服务。视觉来源一共九个:五个内置 provider(配好任意一个就能用),加四家本机 agent CLI 的登录可以复用。先看内置的:
|
|
77
84
|
|
|
78
|
-
| Provider
|
|
79
|
-
|
|
|
80
|
-
| `gemini-api`
|
|
81
|
-
| `openai`
|
|
82
|
-
| `anthropic`
|
|
83
|
-
| `antigravity-cli` | 免费的 `agy` CLI,浏览器登录一次,无需 key
|
|
84
|
-
| `claude-cli`
|
|
85
|
+
| Provider | 需要什么 | 单次识别耗时 | 适合谁 |
|
|
86
|
+
| :---------------- | :----------------------------------------------------------------------- | :----------- | :--------------------- |
|
|
87
|
+
| `gemini-api` | 免费 Gemini key([三分钟领取,无需信用卡](https://aistudio.google.com)) | 5-10 秒 | 推荐默认 |
|
|
88
|
+
| `openai` | 任意 OpenAI 兼容端点(key + baseUrl + model) | 5-10 秒 | qwen-vl、GLM、自建网关 |
|
|
89
|
+
| `anthropic` | Anthropic API key | 5-10 秒 | 手上已有 key 的机器 |
|
|
90
|
+
| `antigravity-cli` | 免费的 `agy` CLI,浏览器登录一次,无需 key | 15-45 秒 | 完全免注册起步 |
|
|
91
|
+
| `claude-cli` | 已登录的 Claude Code | 20-45 秒 | 复用现有 Claude 订阅 |
|
|
85
92
|
|
|
86
93
|
不钉死 provider 时,所有配好的引擎组成一条故障转移链:API 快车道先试,agent CLI 兜底,第一个可用结果胜出,`meta.attempts` 记录每次尝试,回退永远不是无声的。
|
|
87
94
|
|
|
@@ -104,12 +111,12 @@ modlens config set openai.model qwen3-vl-plus
|
|
|
104
111
|
- **你正在对话的这个 harness 本身。**在登录了订阅的 Claude Code 里用?`claude-cli` 开箱即可借它读图。装进哪个 harness,安装流程就会问哪个 harness 的授权。
|
|
105
112
|
- **机器上其他的 agent CLI。**`modlens doctor` 会逐个发现,你按家授权,它们与你自己的 key 平级入链,不插队。每次复用都在 `meta.warnings` 里标明花的是谁的额度,绝不无声扣费:
|
|
106
113
|
|
|
107
|
-
| 复用来源 | 需要什么
|
|
108
|
-
|
|
|
109
|
-
| Codex
|
|
110
|
-
| OpenCode | OpenCode 里配好的视觉模型
|
|
111
|
-
| Pi
|
|
112
|
-
| Grok
|
|
114
|
+
| 复用来源 | 需要什么 | 授权命令 | 走哪条道 |
|
|
115
|
+
| :------- | :----------------------------- | :------------------------------- | :----------------------------------------------------- |
|
|
116
|
+
| Codex | 已登录且有视觉模型的 Codex CLI | `config set reuse.codex true` | agent 通道,15-45 秒 |
|
|
117
|
+
| OpenCode | OpenCode 里配好的视觉模型 | `config set reuse.opencode true` | agent 通道,15-45 秒 |
|
|
118
|
+
| Pi | Pi 持有的模型凭据 | `config set reuse.pi true` | API key 直接升级到 5-10 秒的快车道,OAuth 驱动 Pi 本体 |
|
|
119
|
+
| Grok | 已登录的 Grok CLI(SuperGrok) | `config set reuse.grok true` | agent 通道,15-45 秒 |
|
|
113
120
|
|
|
114
121
|
### 选择与路由
|
|
115
122
|
|
|
@@ -141,16 +148,16 @@ Codex 桌面 App 中识别一张推文截图。作者、配文、照片内容(
|
|
|
141
148
|
|
|
142
149
|
## 文档
|
|
143
150
|
|
|
144
|
-
| 文档
|
|
145
|
-
|
|
|
146
|
-
| [安装手册](INSTALL.md)
|
|
151
|
+
| 文档 | 适用场景 |
|
|
152
|
+
| :------------------------------------------------------- | :----------------------------------------- |
|
|
153
|
+
| [安装手册](INSTALL.md) | 一步步安装 skill(为 agent 编写) |
|
|
147
154
|
| [CLI 手册](docs/cli.zh-CN.md) | skill 所驱动的 CLI:参数、配置与体检 |
|
|
148
155
|
| [故障排查](docs/troubleshooting.zh-CN.md) | 命令报错,查成因和解法 |
|
|
149
156
|
| [配置手册](skills/modlens/references/configure.zh-CN.md) | 配置 key、切换 provider、排查配置 |
|
|
150
157
|
| [输出契约](docs/output-schema.zh-CN.md) | 解析 JSON 或构建下游工具 |
|
|
151
158
|
| [宿主接入](docs/harness-setup.zh-CN.md) | 在 Codex、Claude Code、Pi、OpenCode 中配置 |
|
|
152
159
|
| [安全说明](docs/security.zh-CN.md) | 恢复文件的权限、图片内容作为不可信输入 |
|
|
153
|
-
| [更新日志](CHANGELOG.md)
|
|
160
|
+
| [更新日志](CHANGELOG.md) | 查询版本变更 |
|
|
154
161
|
|
|
155
162
|
## 参与方式
|
|
156
163
|
|
package/dist/main.js
CHANGED
|
@@ -355,6 +355,8 @@ async function fetchRemoteImageBase64(url, timeoutMs) {
|
|
|
355
355
|
if (response.status >= 300 && response.status < 400) {
|
|
356
356
|
const location = response.headers.get("location");
|
|
357
357
|
if (!location) {
|
|
358
|
+
await response.body?.cancel().catch(() => {
|
|
359
|
+
});
|
|
358
360
|
throw new Error(
|
|
359
361
|
`Redirect response (${response.status}) missing location header: ${safeUrl(current.toString())}`
|
|
360
362
|
);
|
|
@@ -367,12 +369,16 @@ async function fetchRemoteImageBase64(url, timeoutMs) {
|
|
|
367
369
|
continue;
|
|
368
370
|
}
|
|
369
371
|
if (!response.ok) {
|
|
372
|
+
await response.body?.cancel().catch(() => {
|
|
373
|
+
});
|
|
370
374
|
throw new Error(
|
|
371
375
|
`Failed to download image (${response.status}): ${safeUrl(current.toString())}`
|
|
372
376
|
);
|
|
373
377
|
}
|
|
374
378
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
375
379
|
if (Number.isFinite(declaredLength) && declaredLength > MAX_REMOTE_IMAGE_BYTES) {
|
|
380
|
+
await response.body?.cancel().catch(() => {
|
|
381
|
+
});
|
|
376
382
|
throw new Error(
|
|
377
383
|
`Remote image is ${declaredLength} bytes, over the ${MAX_REMOTE_IMAGE_BYTES}-byte limit: ${safeUrl(current.toString())}`
|
|
378
384
|
);
|
|
@@ -385,9 +391,7 @@ async function fetchRemoteImageBase64(url, timeoutMs) {
|
|
|
385
391
|
}
|
|
386
392
|
throw new Error(`Too many redirects (max ${MAX_REDIRECTS}): ${safeUrl(url)}`);
|
|
387
393
|
} finally {
|
|
388
|
-
|
|
389
|
-
void dispatcher.close();
|
|
390
|
-
}
|
|
394
|
+
await Promise.allSettled(dispatchers.map((dispatcher) => dispatcher.close()));
|
|
391
395
|
}
|
|
392
396
|
}
|
|
393
397
|
function pinnedDispatcher(pinned) {
|
|
@@ -773,10 +777,29 @@ const TOKEN_SHAPES = [
|
|
|
773
777
|
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g,
|
|
774
778
|
// Auth headers: "Bearer xyz" / "Authorization: xyz" (space form is real).
|
|
775
779
|
/\b(?:bearer|authorization)\b[=:\s]+"?[A-Za-z0-9._~+/-]{12,}"?/gi,
|
|
776
|
-
// Labeled keys need an explicit = or : separator
|
|
780
|
+
// Labeled keys need an explicit = or : separator. Prose like
|
|
777
781
|
// "token limit_exceeded" is diagnostics, not a credential.
|
|
778
782
|
/\b(?:token|api[-_]?key)\b\s*[=:]\s*"?[A-Za-z0-9._~+/-]{12,}"?/gi
|
|
779
783
|
];
|
|
784
|
+
const URL_CANDIDATE = /\b[a-z][a-z0-9+.-]*:[^ ]*@[^ ]*/gi;
|
|
785
|
+
const RAW_USERINFO = /^([a-z][a-z0-9+.-]*:[\\/]{2,4})[^\s/?#]*@/i;
|
|
786
|
+
function parseUrl(candidate) {
|
|
787
|
+
try {
|
|
788
|
+
return new URL(candidate);
|
|
789
|
+
} catch {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function rebuildMasked(url, replacement) {
|
|
794
|
+
return `${url.protocol}//${replacement}@${url.host}${url.pathname}${url.search}${url.hash}`;
|
|
795
|
+
}
|
|
796
|
+
function maskUrlCredentials(url) {
|
|
797
|
+
const parsed = parseUrl(url);
|
|
798
|
+
if (parsed) {
|
|
799
|
+
return parsed.username !== "" || parsed.password !== "" ? rebuildMasked(parsed, "***") : url;
|
|
800
|
+
}
|
|
801
|
+
return url.replace(RAW_USERINFO, "$1***@");
|
|
802
|
+
}
|
|
780
803
|
function redactSecrets(text, knownSecrets = []) {
|
|
781
804
|
let out = text;
|
|
782
805
|
for (const secret of knownSecrets) {
|
|
@@ -787,6 +810,23 @@ function redactSecrets(text, knownSecrets = []) {
|
|
|
787
810
|
for (const shape of TOKEN_SHAPES) {
|
|
788
811
|
out = out.replace(shape, "[redacted]");
|
|
789
812
|
}
|
|
813
|
+
out = out.replace(URL_CANDIDATE, (token) => {
|
|
814
|
+
const pieces = token.split(/(?<=[^a-z0-9+.-])(?=[a-z][a-z0-9+.-]*:[\\/]{1,4})/i);
|
|
815
|
+
if (pieces.length === 1) {
|
|
816
|
+
const parsed = parseUrl(token);
|
|
817
|
+
if (parsed) {
|
|
818
|
+
return parsed.username !== "" || parsed.password !== "" ? rebuildMasked(parsed, "[redacted]") : token;
|
|
819
|
+
}
|
|
820
|
+
return token.replace(RAW_USERINFO, "$1[redacted]@");
|
|
821
|
+
}
|
|
822
|
+
return pieces.map((piece) => {
|
|
823
|
+
const parsed = parseUrl(piece);
|
|
824
|
+
if (parsed) {
|
|
825
|
+
return parsed.password !== "" ? rebuildMasked(parsed, "[redacted]") : piece;
|
|
826
|
+
}
|
|
827
|
+
return piece.replace(RAW_USERINFO, "$1[redacted]@");
|
|
828
|
+
}).join("");
|
|
829
|
+
});
|
|
790
830
|
return out;
|
|
791
831
|
}
|
|
792
832
|
const ANTHROPIC_DEFAULT_MODEL = "claude-haiku-4-5-20251001";
|
|
@@ -1498,7 +1538,7 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
1498
1538
|
const value = envValue ?? fileSettings[field];
|
|
1499
1539
|
const source = envValue ? "env" : fileSettings[field] !== void 0 ? "file" : null;
|
|
1500
1540
|
if (value !== void 0 && source) {
|
|
1501
|
-
const shown = field === "apiKey" ? maskKey(value) : value;
|
|
1541
|
+
const shown = field === "apiKey" ? maskKey(value) : field === "proxy" ? maskUrlCredentials(value) : value;
|
|
1502
1542
|
fields[field] = `${shown} (${source})`;
|
|
1503
1543
|
}
|
|
1504
1544
|
}
|
|
@@ -1516,9 +1556,10 @@ function renderEffectiveConfig(config2, env = process.env) {
|
|
|
1516
1556
|
effective.provider = config2.provider.trim();
|
|
1517
1557
|
}
|
|
1518
1558
|
if (config2.proxy?.trim()) {
|
|
1519
|
-
effective.proxy = `${config2.proxy.trim()} (file)`;
|
|
1559
|
+
effective.proxy = `${maskUrlCredentials(config2.proxy.trim())} (file)`;
|
|
1520
1560
|
} else if (env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy) {
|
|
1521
|
-
|
|
1561
|
+
const raw = env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy;
|
|
1562
|
+
effective.proxy = `${maskUrlCredentials(raw)} (env)`;
|
|
1522
1563
|
}
|
|
1523
1564
|
if (config2.guards) {
|
|
1524
1565
|
const guards = {};
|
|
@@ -1589,7 +1630,7 @@ const PROVIDER_DESCRIPTORS = [
|
|
|
1589
1630
|
];
|
|
1590
1631
|
function findOnPath(bin, env) {
|
|
1591
1632
|
const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
1592
|
-
const suffixes = process.platform === "win32" ? [
|
|
1633
|
+
const suffixes = process.platform === "win32" ? [...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean), ""] : [""];
|
|
1593
1634
|
for (const dir of dirs) {
|
|
1594
1635
|
for (const suffix of suffixes) {
|
|
1595
1636
|
const full = path.join(dir, bin + suffix);
|
|
@@ -2575,15 +2616,16 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
2575
2616
|
let timedOut = false;
|
|
2576
2617
|
let settled = false;
|
|
2577
2618
|
let drainTimer;
|
|
2619
|
+
let killTimer;
|
|
2578
2620
|
const timer = setTimeout(() => {
|
|
2579
2621
|
timedOut = true;
|
|
2580
2622
|
child.kill("SIGTERM");
|
|
2581
2623
|
settle(null);
|
|
2582
|
-
setTimeout(() => {
|
|
2624
|
+
killTimer = setTimeout(() => {
|
|
2583
2625
|
if (!exited) {
|
|
2584
2626
|
child.kill("SIGKILL");
|
|
2585
2627
|
}
|
|
2586
|
-
}, SIGKILL_GRACE_MS)
|
|
2628
|
+
}, SIGKILL_GRACE_MS);
|
|
2587
2629
|
}, timeoutMs);
|
|
2588
2630
|
const settle = (code) => {
|
|
2589
2631
|
if (settled) {
|
|
@@ -2638,6 +2680,7 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
2638
2680
|
settled = true;
|
|
2639
2681
|
clearTimeout(timer);
|
|
2640
2682
|
clearTimeout(drainTimer);
|
|
2683
|
+
clearTimeout(killTimer);
|
|
2641
2684
|
if (error.code === "ENOENT") {
|
|
2642
2685
|
const missingCwd = !fs.existsSync(invocation.cwd);
|
|
2643
2686
|
reject(
|
|
@@ -2652,6 +2695,7 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
|
2652
2695
|
child.on("exit", (code) => {
|
|
2653
2696
|
exitCode = code;
|
|
2654
2697
|
exited = true;
|
|
2698
|
+
clearTimeout(killTimer);
|
|
2655
2699
|
restartDrain();
|
|
2656
2700
|
});
|
|
2657
2701
|
child.on("close", (code) => settle(code));
|
|
@@ -2701,27 +2745,40 @@ function detectHarnessDetailed() {
|
|
|
2701
2745
|
if (override) {
|
|
2702
2746
|
return { harness: override === "none" ? null : override, source: "override" };
|
|
2703
2747
|
}
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2748
|
+
if (process.platform !== "win32") {
|
|
2749
|
+
try {
|
|
2750
|
+
const ps = childProcess.execFileSync("ps", ["-Ao", "pid=,ppid=,command="], {
|
|
2751
|
+
encoding: "utf-8",
|
|
2752
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
2753
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2754
|
+
});
|
|
2755
|
+
const found = harnessFromPsTable(ps, process.pid);
|
|
2756
|
+
if (found) {
|
|
2757
|
+
return { harness: found, source: "ancestry" };
|
|
2758
|
+
}
|
|
2759
|
+
} catch {
|
|
2712
2760
|
}
|
|
2713
|
-
} catch {
|
|
2714
2761
|
}
|
|
2715
|
-
|
|
2716
|
-
|
|
2762
|
+
const fromEnv = harnessFromEnv(process.env);
|
|
2763
|
+
if (fromEnv) {
|
|
2764
|
+
return { harness: fromEnv, source: "env" };
|
|
2717
2765
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2766
|
+
return { harness: null, source: "none" };
|
|
2767
|
+
}
|
|
2768
|
+
function harnessFromEnv(env) {
|
|
2769
|
+
if (env.PI_CODING_AGENT) {
|
|
2770
|
+
return "pi";
|
|
2720
2771
|
}
|
|
2721
|
-
if (
|
|
2722
|
-
return
|
|
2772
|
+
if (env.CODEX_THREAD_ID || env.CODEX_SANDBOX) {
|
|
2773
|
+
return "codex";
|
|
2723
2774
|
}
|
|
2724
|
-
|
|
2775
|
+
if (env.OPENCODE || env.OPENCODE_PID || env.OPENCODE_BINARY) {
|
|
2776
|
+
return "opencode";
|
|
2777
|
+
}
|
|
2778
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE_SESSION_ID) {
|
|
2779
|
+
return "claude-code";
|
|
2780
|
+
}
|
|
2781
|
+
return null;
|
|
2725
2782
|
}
|
|
2726
2783
|
function detectHarness() {
|
|
2727
2784
|
return detectHarnessDetailed().harness;
|
|
@@ -3737,7 +3794,7 @@ function parsePositiveInt(raw, flag) {
|
|
|
3737
3794
|
}
|
|
3738
3795
|
return Number.parseInt(raw, 10);
|
|
3739
3796
|
}
|
|
3740
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.
|
|
3797
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.16.1");
|
|
3741
3798
|
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(
|
|
3742
3799
|
"--extra-body <json>",
|
|
3743
3800
|
`JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
|
|
@@ -3788,7 +3845,7 @@ program.command("recover-paste").description(
|
|
|
3788
3845
|
"Recover images pasted into Claude Code, Pi, or OpenCode from local session storage (they never hit disk otherwise)"
|
|
3789
3846
|
).option("--count <n>", "How many recent pasted images to recover", "1").option("--out-dir <path>", "Directory to write recovered images to").option(
|
|
3790
3847
|
"--session <id>",
|
|
3791
|
-
"Claude Code session id for exact targeting (skills get it via ${
|
|
3848
|
+
"Claude Code session id for exact targeting (skills get it via ${CLAUDE_CODE_SESSION_ID})"
|
|
3792
3849
|
).option("--transcript <path>", "Explicit transcript .jsonl or .db (overrides --session)").option(
|
|
3793
3850
|
"--harness <name>",
|
|
3794
3851
|
"Force the storage scope: claude-code, pi, opencode, or none (default: auto-detect via process ancestry and env)"
|
package/docs/harness-setup.md
CHANGED
|
@@ -72,8 +72,20 @@ OpenCode, and Claude Code hand their models, and the modlens skill's and
|
|
|
72
72
|
`read_image` tool's primary trigger. Admission never fires because the message
|
|
73
73
|
carries no image attachment.
|
|
74
74
|
|
|
75
|
-
The takeover is conditional
|
|
76
|
-
|
|
75
|
+
The takeover is conditional, and the decision is the host's: the browser half
|
|
76
|
+
asks the plugin's route whether the currently selected model is text-only,
|
|
77
|
+
and the host answers from the provider registry's declared model metadata
|
|
78
|
+
(`inputModalities`), not from a name heuristic. A `(modlens vision)` variant
|
|
79
|
+
or any model that declares image input keeps its native paste flow (variants
|
|
77
80
|
convert at request time with the thumbnail preserved; vision models read
|
|
78
|
-
images themselves)
|
|
79
|
-
|
|
81
|
+
images themselves), and so does any model the host cannot resolve. Pastes
|
|
82
|
+
stay native until the host has confirmed a takeover is right. A model whose
|
|
83
|
+
metadata declares no input modalities counts as unresolved: absent metadata is
|
|
84
|
+
never read as "confirmed text-only". Verdicts also age out (60s), so a route
|
|
85
|
+
whose models changed mid-session is re-asked, not trusted forever.
|
|
86
|
+
`pasteToPath: false` in the plugin row turns the whole feature off: the
|
|
87
|
+
browser half stands down when the policy endpoint 404s. If the route vanishes
|
|
88
|
+
mid-session after a verdict already confirmed it, the pastes made in the
|
|
89
|
+
brief window before the failed upload comes back (one local round-trip) are
|
|
90
|
+
lost. The client then forgets its verdicts and every later paste goes
|
|
91
|
+
native.
|
|
@@ -64,4 +64,4 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@latest
|
|
|
64
64
|
|
|
65
65
|
过去在 dsh Web UI 里,**纯文本模型**下粘贴图片会死在图片准入检查这一步。插件现在带了一个浏览器端半边(由 dsh 的客户端插件系统自动加载),恰好在这种情况下接管粘贴:图片字节发到插件在 dsh web 服务器上的 `/modlens/paste` 路由(仅回环地址,校验 magic byte,上限 25 MB),落成一个私有临时文件,输入框收到的则是纯文本的文件路径。这与 Pi、OpenCode、Claude Code 递给模型的形态一致,也正是 modlens skill 和 `read_image` 工具的首要触发条件。消息里不带图片附件,准入检查根本不会触发。
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
接管是有条件的,且裁决权在 host 一侧:浏览器半边先向插件路由询问当前选中的模型是否纯文本,host 用 provider 注册表里声明的模型元数据(`inputModalities`)回答,而不是靠名称猜。`(modlens vision)` 变体和任何声明支持图片输入的模型都保留原生粘贴流程(变体在发请求时转换且保留缩略图,视觉模型自己读图),host 认不出的模型同样不接管:在 host 确认该接管之前,粘贴一律走原生路径。模型元数据里没有声明输入模态的,一律算认不出:元数据缺失绝不当成「已确认纯文本」。裁决还有 60 秒时效,模型中途变了会重新问询,不会永远信旧答案。在插件配置行里设 `pasteToPath: false` 可整体关掉这个功能:策略端点 404 时浏览器半边彻底停手。若路由在裁决确认后中途消失,失败结果返回前那个短暂窗口(一次本地往返)内发生的粘贴会丢失,之后客户端清空全部裁决,后续粘贴一律走原生路径。
|
package/dsh/client.js
CHANGED
|
@@ -20,7 +20,7 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
var exports = module.exports
|
|
21
21
|
|
|
22
22
|
function imageFilesOf(event) {
|
|
23
|
-
var items = event.clipboardData
|
|
23
|
+
var items = event.clipboardData?.items
|
|
24
24
|
if (!items) return []
|
|
25
25
|
var files = []
|
|
26
26
|
for (var i = 0; i < items.length; i++) {
|
|
@@ -33,10 +33,7 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function insertText(target, text) {
|
|
36
|
-
var el =
|
|
37
|
-
target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT')
|
|
38
|
-
? target
|
|
39
|
-
: document.activeElement
|
|
36
|
+
var el = target && (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') ? target : document.activeElement
|
|
40
37
|
if (!el || (el.tagName !== 'TEXTAREA' && el.tagName !== 'INPUT')) return
|
|
41
38
|
el.focus()
|
|
42
39
|
// execCommand fires the input event React's controlled textarea needs;
|
|
@@ -48,8 +45,7 @@ window.__ModuleLoader__.load({
|
|
|
48
45
|
inserted = false
|
|
49
46
|
}
|
|
50
47
|
if (!inserted) {
|
|
51
|
-
var proto =
|
|
52
|
-
el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
|
|
48
|
+
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype
|
|
53
49
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set
|
|
54
50
|
setter.call(el, el.value + text)
|
|
55
51
|
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
@@ -64,7 +60,9 @@ window.__ModuleLoader__.load({
|
|
|
64
60
|
.json()
|
|
65
61
|
.catch(() => ({}))
|
|
66
62
|
.then((body) => {
|
|
67
|
-
|
|
63
|
+
var error = new Error(body.error || `paste upload failed (${res.status})`)
|
|
64
|
+
error.status = res.status
|
|
65
|
+
throw error
|
|
68
66
|
})
|
|
69
67
|
}
|
|
70
68
|
return res.json()
|
|
@@ -72,14 +70,6 @@ window.__ModuleLoader__.load({
|
|
|
72
70
|
)
|
|
73
71
|
}
|
|
74
72
|
|
|
75
|
-
// The takeover is for text-only models: the (modlens vision) variants
|
|
76
|
-
// convert pastes at request time with the thumbnail preserved, and real
|
|
77
|
-
// vision models read images natively — both keep the original paste UX.
|
|
78
|
-
// The model selector button's accessible label is the only client-side
|
|
79
|
-
// source of the current model; when it cannot be found, taking over is
|
|
80
|
-
// the safe default (text-only is the common case this exists for).
|
|
81
|
-
var VISION_HINT = /\(modlens vision\)|deepseek-(vl|ocr)|janus|glm-[\d.]*v\b|vision|image/i
|
|
82
|
-
|
|
83
73
|
function currentModelLabel() {
|
|
84
74
|
var buttons = document.querySelectorAll('button[aria-label]')
|
|
85
75
|
for (var i = 0; i < buttons.length; i++) {
|
|
@@ -89,10 +79,73 @@ window.__ModuleLoader__.load({
|
|
|
89
79
|
return ''
|
|
90
80
|
}
|
|
91
81
|
|
|
82
|
+
// Whether to take a paste over is the HOST's call (GET /modlens/paste
|
|
83
|
+
// with the selector label; the host resolves it against real model
|
|
84
|
+
// metadata). A name regex here once declared every vision model it did
|
|
85
|
+
// not recognize text-only and hijacked its native paste. The verdict is
|
|
86
|
+
// cached per label and refreshed in the background; until a label has a
|
|
87
|
+
// cached `true`, pastes stay native — the safe direction for both a
|
|
88
|
+
// vision model (keeps its thumbnail) and a text-only one (keeps only its
|
|
89
|
+
// old error message, once). A 404 means the route is off (pasteToPath:
|
|
90
|
+
// false, or no host half), so the client stands down entirely instead of
|
|
91
|
+
// swallowing pastes into a dead endpoint.
|
|
92
|
+
var routeAvailable = true
|
|
93
|
+
var verdicts = {}
|
|
94
|
+
// A verdict older than this is UNKNOWN again, even while a refresh is in
|
|
95
|
+
// flight: the route's model metadata can change mid-session (discovery
|
|
96
|
+
// sweeps, provider mounts), and acting on a long-stale `true` is exactly
|
|
97
|
+
// the vision-model hijack this design exists to prevent. The bound is a
|
|
98
|
+
// backstop, since every focus and paste re-asks anyway.
|
|
99
|
+
var VERDICT_MAX_AGE_MS = 60000
|
|
100
|
+
|
|
101
|
+
function refreshVerdict(label) {
|
|
102
|
+
if (!routeAvailable) return
|
|
103
|
+
var cached = verdicts[label]
|
|
104
|
+
// Dedupe only on an in-flight request, never on freshness: the host's
|
|
105
|
+
// model inventory can change under an unchanged label (a same-named
|
|
106
|
+
// route mounting mid-session), so every focus and paste re-asks and a
|
|
107
|
+
// stale answer survives at most one local round-trip.
|
|
108
|
+
if (cached?.pending) return
|
|
109
|
+
var entry = { pending: true, takeover: cached ? cached.takeover : false, at: cached ? cached.at : 0 }
|
|
110
|
+
verdicts[label] = entry
|
|
111
|
+
fetch(`/modlens/paste?model=${encodeURIComponent(label)}`)
|
|
112
|
+
.then((res) => {
|
|
113
|
+
if (res.status === 404) {
|
|
114
|
+
routeAvailable = false
|
|
115
|
+
entry.pending = false
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
if (!res.ok) throw new Error(`policy ${res.status}`)
|
|
119
|
+
return res.json()
|
|
120
|
+
})
|
|
121
|
+
.then((body) => {
|
|
122
|
+
entry.pending = false
|
|
123
|
+
if (body) {
|
|
124
|
+
entry.takeover = body.takeover === true
|
|
125
|
+
entry.at = Date.now()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
entry.pending = false
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A paste needs the composer focused first, so a focus-time prefetch has
|
|
134
|
+
// the verdict ready before the first paste can land.
|
|
135
|
+
function onFocusIn() {
|
|
136
|
+
refreshVerdict(currentModelLabel())
|
|
137
|
+
}
|
|
138
|
+
|
|
92
139
|
function onPaste(event) {
|
|
140
|
+
if (!routeAvailable) return
|
|
93
141
|
var files = imageFilesOf(event)
|
|
94
142
|
if (files.length === 0) return
|
|
95
|
-
|
|
143
|
+
var label = currentModelLabel()
|
|
144
|
+
var cached = verdicts[label]
|
|
145
|
+
refreshVerdict(label)
|
|
146
|
+
// No fresh confirmed host verdict: leave the paste native. Wrong only
|
|
147
|
+
// for a text-only model's very first paste, and self-correcting.
|
|
148
|
+
if (!cached || cached.at === 0 || cached.takeover !== true || Date.now() - cached.at > VERDICT_MAX_AGE_MS) return
|
|
96
149
|
// Take the paste before the composer's intake starts an attachment (and
|
|
97
150
|
// with it the host-side image admission a text-only model fails).
|
|
98
151
|
event.preventDefault()
|
|
@@ -107,15 +160,30 @@ window.__ModuleLoader__.load({
|
|
|
107
160
|
if (text) insertText(target, `${text} `)
|
|
108
161
|
})
|
|
109
162
|
.catch((error) => {
|
|
110
|
-
|
|
163
|
+
// A 404 here means the route vanished AFTER a verdict confirmed it
|
|
164
|
+
// (plugin disposed mid-session): that race can cost this one paste
|
|
165
|
+
// — preventDefault already ran — but never another. Stand down and
|
|
166
|
+
// forget every verdict, so the next paste goes native immediately.
|
|
167
|
+
if (error && error.status === 404) {
|
|
168
|
+
routeAvailable = false
|
|
169
|
+
verdicts = {}
|
|
170
|
+
}
|
|
171
|
+
console.error(`[modlens] paste-to-path failed: ${error?.message ? error.message : error}`)
|
|
111
172
|
})
|
|
112
173
|
}
|
|
113
174
|
|
|
114
175
|
function apply(ctx) {
|
|
115
176
|
document.addEventListener('paste', onPaste, true)
|
|
177
|
+
document.addEventListener('focusin', onFocusIn, true)
|
|
116
178
|
// cordis effect: unregister on plugin disposal (HMR, profile reload).
|
|
117
179
|
if (typeof ctx.effect === 'function') {
|
|
118
|
-
ctx.effect(
|
|
180
|
+
ctx.effect(
|
|
181
|
+
() => () => {
|
|
182
|
+
document.removeEventListener('paste', onPaste, true)
|
|
183
|
+
document.removeEventListener('focusin', onFocusIn, true)
|
|
184
|
+
},
|
|
185
|
+
'modlens: paste-to-path listener',
|
|
186
|
+
)
|
|
119
187
|
}
|
|
120
188
|
}
|
|
121
189
|
|
package/dsh/index.js
CHANGED
|
@@ -15,9 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
const CLI_PATH = fileURLToPath(new URL('../dist/main.js', import.meta.url))
|
|
16
16
|
// Kept in lockstep with src/schema.ts by a repo test; the plugin file cannot
|
|
17
17
|
// import the TS source and stays fully dependency-free (node builtins only).
|
|
18
|
-
const OUTPUT_SCHEMA = JSON.parse(
|
|
19
|
-
readFileSync(new URL('./vision-schema.json', import.meta.url), 'utf8'),
|
|
20
|
-
)
|
|
18
|
+
const OUTPUT_SCHEMA = JSON.parse(readFileSync(new URL('./vision-schema.json', import.meta.url), 'utf8'))
|
|
21
19
|
|
|
22
20
|
const CLI_TIMEOUT_MS = 180_000
|
|
23
21
|
|
|
@@ -55,7 +53,9 @@ export function apply(ctx, config = {}) {
|
|
|
55
53
|
if (config.pasteToPath !== false && typeof ctx.inject === 'function') {
|
|
56
54
|
ctx.inject(['webServer'], (scope) => {
|
|
57
55
|
try {
|
|
58
|
-
|
|
56
|
+
// scope carries webServer; the plugin's own ctx carries llm for the
|
|
57
|
+
// takeover verdicts.
|
|
58
|
+
registerPasteRoute(scope, ctx)
|
|
59
59
|
} catch (error) {
|
|
60
60
|
console.error(`[modlens] paste-to-path route skipped: ${error}`)
|
|
61
61
|
}
|
|
@@ -117,9 +117,7 @@ export function apply(ctx, config = {}) {
|
|
|
117
117
|
}
|
|
118
118
|
const { stdout, stderr, code } = await run(process.execPath, cliArgs, exec.signal)
|
|
119
119
|
if (code !== 0) {
|
|
120
|
-
throw new Error(
|
|
121
|
-
`modlens failed (exit ${code}): ${(stderr || stdout).trim().slice(0, 500)}`,
|
|
122
|
-
)
|
|
120
|
+
throw new Error(`modlens failed (exit ${code}): ${(stderr || stdout).trim().slice(0, 500)}`)
|
|
123
121
|
}
|
|
124
122
|
let parsed
|
|
125
123
|
try {
|
|
@@ -140,9 +138,7 @@ export function apply(ctx, config = {}) {
|
|
|
140
138
|
if (preferred !== fallback && /already|duplicate/i.test(String(error))) {
|
|
141
139
|
try {
|
|
142
140
|
ctx.tools.register(readImageTool(fallback))
|
|
143
|
-
console.error(
|
|
144
|
-
`[modlens] tool name "${preferred}" is taken by the host; registered as "${fallback}" instead`,
|
|
145
|
-
)
|
|
141
|
+
console.error(`[modlens] tool name "${preferred}" is taken by the host; registered as "${fallback}" instead`)
|
|
146
142
|
} catch (retryError) {
|
|
147
143
|
console.error(`[modlens] read_image registration skipped: ${retryError}`)
|
|
148
144
|
}
|
|
@@ -153,27 +149,188 @@ export function apply(ctx, config = {}) {
|
|
|
153
149
|
}
|
|
154
150
|
|
|
155
151
|
// Image magic bytes for the paste route: refuse anything that is not a real
|
|
156
|
-
// image before a byte touches disk. Mirrors the CLI's sniffing table
|
|
152
|
+
// image before a byte touches disk. Mirrors the CLI's sniffing table
|
|
153
|
+
// (src/imageInput.ts SNIFFERS) signature for signature: full PNG magic, both
|
|
154
|
+
// GIF variants, and ftyp only with a known heic/heif brand — a generic BMFF
|
|
155
|
+
// (`ftypmp42`, plain video) must not be saved as an image.
|
|
157
156
|
const PASTE_SNIFFS = [
|
|
158
|
-
{
|
|
157
|
+
{
|
|
158
|
+
ext: '.png',
|
|
159
|
+
test: (b) =>
|
|
160
|
+
b.length >= 8 &&
|
|
161
|
+
b[0] === 0x89 &&
|
|
162
|
+
b[1] === 0x50 &&
|
|
163
|
+
b[2] === 0x4e &&
|
|
164
|
+
b[3] === 0x47 &&
|
|
165
|
+
b[4] === 0x0d &&
|
|
166
|
+
b[5] === 0x0a &&
|
|
167
|
+
b[6] === 0x1a &&
|
|
168
|
+
b[7] === 0x0a,
|
|
169
|
+
},
|
|
159
170
|
{ ext: '.jpg', test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
160
|
-
{
|
|
161
|
-
|
|
162
|
-
|
|
171
|
+
{
|
|
172
|
+
ext: '.gif',
|
|
173
|
+
test: (b) => b.length >= 6 && ['GIF87a', 'GIF89a'].includes(b.toString('ascii', 0, 6)),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
ext: '.webp',
|
|
177
|
+
test: (b) => b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP',
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
ext: '.heic',
|
|
181
|
+
test: (b) =>
|
|
182
|
+
b.length >= 12 &&
|
|
183
|
+
b.toString('ascii', 4, 8) === 'ftyp' &&
|
|
184
|
+
['heic', 'heix', 'hevc', 'hevx'].includes(b.toString('ascii', 8, 12)),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
ext: '.heif',
|
|
188
|
+
test: (b) =>
|
|
189
|
+
b.length >= 12 &&
|
|
190
|
+
b.toString('ascii', 4, 8) === 'ftyp' &&
|
|
191
|
+
['mif1', 'msf1', 'heif'].includes(b.toString('ascii', 8, 12)),
|
|
192
|
+
},
|
|
163
193
|
]
|
|
164
194
|
const PASTE_MAX_BYTES = 25 * 1024 * 1024
|
|
165
195
|
|
|
166
196
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
197
|
+
* Should the browser take a paste over for the model behind this selector
|
|
198
|
+
* label? Decided here, not in the browser, because only the host holds the
|
|
199
|
+
* structured model metadata: a name regex in the client called every vision
|
|
200
|
+
* model it did not recognize text-only and hijacked its native paste.
|
|
201
|
+
*
|
|
202
|
+
* The label carries no provider id, only prose plus a display name, so the
|
|
203
|
+
* host cannot know WHICH matching model is selected: a longest-match pick
|
|
204
|
+
* was still hijackable (a text route named "Current Pro" outscored a selected
|
|
205
|
+
* vision model named "Pro", because the label's own "current" prose completed
|
|
206
|
+
* the longer name). So no picking at all: the answer is true only when EVERY
|
|
207
|
+
* model whose name or id appears in the label is positively confirmed
|
|
208
|
+
* text-only. One image-capable match anywhere vetoes; a model with no
|
|
209
|
+
* declared inputModalities is UNKNOWN, not text-only; and a provider whose
|
|
210
|
+
* catalog cannot be read is unknown too, a veto rather than a shrug, since the
|
|
211
|
+
* unreadable route is exactly where the vision twin could live. Anything
|
|
212
|
+
* unresolvable answers false: the native path is the safe default, and a
|
|
213
|
+
* text-only model merely keeps its old error message.
|
|
214
|
+
*/
|
|
215
|
+
async function pasteTakeoverVerdict(host, label) {
|
|
216
|
+
if (typeof label !== 'string' || label.trim() === '') return false
|
|
217
|
+
// Our own wrappers convert pastes at request time with the thumbnail
|
|
218
|
+
// preserved; taking their paste over would defeat the better path.
|
|
219
|
+
if (/\(modlens vision\)/i.test(label)) return false
|
|
220
|
+
const llm = host.llm
|
|
221
|
+
if (!llm || typeof llm.listProviders !== 'function' || typeof llm.listModels !== 'function') {
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
const lowered = label.toLowerCase()
|
|
225
|
+
let matchedAny = false
|
|
226
|
+
for (const info of llm.listProviders()) {
|
|
227
|
+
const providerId = info?.id
|
|
228
|
+
if (!providerId) continue
|
|
229
|
+
let models = []
|
|
230
|
+
try {
|
|
231
|
+
models = await llm.listModels(providerId)
|
|
232
|
+
} catch {
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
for (const model of models) {
|
|
236
|
+
for (const candidate of [model?.name, model?.id]) {
|
|
237
|
+
if (typeof candidate !== 'string' || candidate.length === 0) continue
|
|
238
|
+
if (!lowered.includes(candidate.toLowerCase())) continue
|
|
239
|
+
// The veto has no length floor: a vision model named "AI" appears in
|
|
240
|
+
// the label just as legitimately as a long name does, and skipping
|
|
241
|
+
// short names let a longer text-only name confirm the takeover alone.
|
|
242
|
+
const modalities = model?.inputModalities
|
|
243
|
+
if (!Array.isArray(modalities) || modalities.includes('image')) {
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
// Positive confirmation does have a floor: one- and two-character
|
|
247
|
+
// text-only names match label prose far too easily to identify the
|
|
248
|
+
// selected model.
|
|
249
|
+
if (candidate.length >= 3) {
|
|
250
|
+
matchedAny = true
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return matchedAny
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Verdicts are stable for the lifetime of a model route but the inventory can
|
|
259
|
+
// grow (llm-pi-ai mounts after settings load), so cache briefly, not forever.
|
|
260
|
+
const PASTE_VERDICT_TTL_MS = 15_000
|
|
261
|
+
const PASTE_VERDICT_CAP = 32
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The paste route. POST /modlens/paste: image bytes in, `{ path }` out; the
|
|
265
|
+
* file is private (0600) in a fresh unpredictable temp dir, magic-byte
|
|
266
|
+
* checked and size-capped. GET /modlens/paste?model=<selector label>:
|
|
267
|
+
* `{ takeover }`: the browser half asks before ever touching a paste, so a
|
|
268
|
+
* disabled route (pasteToPath: false, or no web profile) means the client
|
|
269
|
+
* stands down instead of swallowing pastes into a 404. Bound to the dsh web
|
|
270
|
+
* server, which listens on loopback by default.
|
|
170
271
|
*/
|
|
171
|
-
function registerPasteRoute(ctx) {
|
|
272
|
+
function registerPasteRoute(ctx, host) {
|
|
273
|
+
const verdicts = new Map()
|
|
274
|
+
// The cache key is only the selector label, which cannot tell two
|
|
275
|
+
// same-named models on different routes apart. A route mounting mid-TTL
|
|
276
|
+
// (llm-pi-ai lands after settings load) could therefore serve a stale
|
|
277
|
+
// verdict computed before its vision twin existed, so every topology
|
|
278
|
+
// change empties the cache at exactly the boundary that invalidates it.
|
|
279
|
+
// The epoch guards the async gap the clear cannot reach: a verdict whose
|
|
280
|
+
// computation STARTED before the event describes a registry that no longer
|
|
281
|
+
// exists, and without the counter it was written back into the just-
|
|
282
|
+
// emptied cache and served for a full TTL.
|
|
283
|
+
let topologyEpoch = 0
|
|
284
|
+
if (typeof host.on === 'function') {
|
|
285
|
+
host.on('llm/adapters-updated', () => {
|
|
286
|
+
topologyEpoch += 1
|
|
287
|
+
verdicts.clear()
|
|
288
|
+
})
|
|
289
|
+
}
|
|
172
290
|
ctx.webServer.register({
|
|
173
291
|
name: 'modlens-paste',
|
|
174
292
|
kind: 'exact',
|
|
175
293
|
path: '/modlens/paste',
|
|
176
294
|
handler: async (req, res) => {
|
|
295
|
+
if (req.method === 'GET') {
|
|
296
|
+
try {
|
|
297
|
+
const label = new URL(req.url, 'http://localhost').searchParams.get('model') ?? ''
|
|
298
|
+
const cached = verdicts.get(label)
|
|
299
|
+
let takeover
|
|
300
|
+
if (cached && Date.now() - cached.at < PASTE_VERDICT_TTL_MS) {
|
|
301
|
+
takeover = cached.takeover
|
|
302
|
+
} else {
|
|
303
|
+
// Recompute while the topology moves under the computation: an
|
|
304
|
+
// answer read from a pre-event registry snapshot must be neither
|
|
305
|
+
// cached nor served. Bounded, and the give-up answer is the
|
|
306
|
+
// conservative one.
|
|
307
|
+
let attempts = 0
|
|
308
|
+
for (;;) {
|
|
309
|
+
const startedEpoch = topologyEpoch
|
|
310
|
+
takeover = await pasteTakeoverVerdict(host, label)
|
|
311
|
+
if (topologyEpoch === startedEpoch) {
|
|
312
|
+
verdicts.delete(label)
|
|
313
|
+
verdicts.set(label, { takeover, at: Date.now() })
|
|
314
|
+
if (verdicts.size > PASTE_VERDICT_CAP) {
|
|
315
|
+
verdicts.delete(verdicts.keys().next().value)
|
|
316
|
+
}
|
|
317
|
+
break
|
|
318
|
+
}
|
|
319
|
+
attempts += 1
|
|
320
|
+
if (attempts >= 3) {
|
|
321
|
+
takeover = false
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
327
|
+
res.end(JSON.stringify({ takeover }))
|
|
328
|
+
} catch (error) {
|
|
329
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
330
|
+
res.end(JSON.stringify({ error: String(error?.message ?? error) }))
|
|
331
|
+
}
|
|
332
|
+
return
|
|
333
|
+
}
|
|
177
334
|
if (req.method !== 'POST') {
|
|
178
335
|
res.writeHead(405).end()
|
|
179
336
|
return
|
|
@@ -208,7 +365,7 @@ function registerPasteRoute(ctx) {
|
|
|
208
365
|
res.end(JSON.stringify({ path: file }))
|
|
209
366
|
} catch (error) {
|
|
210
367
|
res.writeHead(500, { 'content-type': 'application/json' })
|
|
211
|
-
res.end(JSON.stringify({ error: String(error
|
|
368
|
+
res.end(JSON.stringify({ error: String(error?.message ? error.message : error) }))
|
|
212
369
|
}
|
|
213
370
|
},
|
|
214
371
|
})
|
|
@@ -321,11 +478,7 @@ function registerVisionProvider(ctx, config) {
|
|
|
321
478
|
}
|
|
322
479
|
|
|
323
480
|
if (config.upstream) {
|
|
324
|
-
registerWrapper(
|
|
325
|
-
config.upstream,
|
|
326
|
-
config.providerId || 'deepseek-modlens',
|
|
327
|
-
'DeepSeek (modlens vision)',
|
|
328
|
-
)
|
|
481
|
+
registerWrapper(config.upstream, config.providerId || 'deepseek-modlens', 'DeepSeek (modlens vision)')
|
|
329
482
|
return
|
|
330
483
|
}
|
|
331
484
|
|
|
@@ -488,9 +641,7 @@ function abortableWait(promise, signal) {
|
|
|
488
641
|
function contentHasImage(blocks) {
|
|
489
642
|
return (
|
|
490
643
|
Array.isArray(blocks) &&
|
|
491
|
-
blocks.some(
|
|
492
|
-
(b) => b?.type === 'image' || (b?.type === 'tool-result' && contentHasImage(b.content)),
|
|
493
|
-
)
|
|
644
|
+
blocks.some((b) => b?.type === 'image' || (b?.type === 'tool-result' && contentHasImage(b.content)))
|
|
494
645
|
)
|
|
495
646
|
}
|
|
496
647
|
|
|
@@ -574,9 +725,7 @@ async function readImageBlock(ctx, block, signal) {
|
|
|
574
725
|
if (!stored?.data) {
|
|
575
726
|
// Named failure instead of Buffer.from(undefined)'s bare TypeError the
|
|
576
727
|
// next time a developer-preview release moves the field (issue #17).
|
|
577
|
-
throw new Error(
|
|
578
|
-
"attachments.readImage returned no 'data' bytes; the dsh attachment shape may have changed",
|
|
579
|
-
)
|
|
728
|
+
throw new Error("attachments.readImage returned no 'data' bytes; the dsh attachment shape may have changed")
|
|
580
729
|
}
|
|
581
730
|
const mediaType = stored.ref?.mediaType ?? block.attachment?.mediaType
|
|
582
731
|
const ext = MEDIA_EXT[mediaType]
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: modlens
|
|
3
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
|
-
compatibility: Requires network access and one of node 22+/npx, bun/bunx, or a preinstalled modlens binary on PATH.
|
|
4
|
+
compatibility: Requires network access and one of node 22.19+/npx, bun/bunx, or a preinstalled modlens binary on PATH.
|
|
5
5
|
allowed-tools: Bash
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -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.16.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.16.1: `modlens <args>`.
|
|
26
|
+
2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.16.1 modlens <args>`.
|
|
27
|
+
3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.16.1 <args>`.
|
|
28
28
|
4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (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.
|
|
@@ -6,7 +6,7 @@ Read this when the user asks how to set up, configure, or switch ModLens provide
|
|
|
6
6
|
|
|
7
7
|
## Where config lives
|
|
8
8
|
|
|
9
|
-
`~/.modlens/config.json`, managed by the CLI. Precedence: CLI flags > environment variables > config file > built-in defaults.
|
|
9
|
+
`~/.modlens/config.json`, managed by the CLI. Precedence: CLI flags > environment variables > config file > built-in defaults. With no `provider` set, runs walk the failover chain in order (an available `gemini-api` key is tried before the agent CLIs); a machine with nothing configured at all ends up on `antigravity-cli`.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
modlens config init # write a starter config (refuses to overwrite; --force to redo)
|
|
@@ -56,7 +56,7 @@ Everything lives under four top-level keys, all optional. This example shows eve
|
|
|
56
56
|
|
|
57
57
|
Field semantics:
|
|
58
58
|
|
|
59
|
-
- `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `claude-code` for `claude-cli`). Empty or absent
|
|
59
|
+
- `provider`: which provider runs when `-p` is not given. Canonical names or aliases both work (`agy`/`antigravity` for `antigravity-cli`, `gemini` for `gemini-api`, `openai-compat` for `openai`, `claude` for `anthropic`, `claude-code` for `claude-cli`). Empty or absent pins nothing: the failover chain decides, trying configured API providers before the agent CLIs.
|
|
60
60
|
- `providers.<name>.<field>`: four fields exist, `apiKey`, `baseUrl`, `model`, and `extraBody`. Every provider entry is optional, and every field inside it is optional. Alias keys are read too (settings saved under `gemini` are found when `gemini-api` resolves), with the canonical key winning on conflict.
|
|
61
61
|
- `providers.<name>.extraBody`: a JSON object merged into the request body of the API providers (`gemini-api`, `openai`, `anthropic`), for whatever knobs that vendor has and modlens has no flag for. Turning thinking off is the usual reason, see the section below. Nested objects merge key by key, so adding one knob leaves the rest of that block alone. The fields carrying the image, the prompt, and the schema enforcement are refused with an error naming the field. The two CLI providers take no request body, so a run on `antigravity-cli` or `claude-cli` ignores it and says so in `meta.warnings`.
|
|
62
62
|
- `guards`: the invocation guard, for people who run both text-only and vision-capable models through the same client. Both lists hold glob patterns (`*` and `?`, case-insensitive, matched against the model name and `provider/model`), set with `modlens config set guards.denyModels '["gemini-3*"]'` or `guards.allowModels` (a JSON array or a comma-separated list, empty clears). Two ways to express the same intent, pick the shorter list:
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 配置放在哪
|
|
8
8
|
|
|
9
|
-
`~/.modlens/config.json`,由 CLI 管理。优先级:CLI 参数 > 环境变量 > 配置文件 >
|
|
9
|
+
`~/.modlens/config.json`,由 CLI 管理。优先级:CLI 参数 > 环境变量 > 配置文件 > 内置默认值。不设 `provider` 时按失败切换链依次尝试(有 `gemini-api` key 会先于 agent CLI 被试到),机器上什么都没配才会落在 `antigravity-cli`。
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
modlens config init # 写入一份起步配置(已存在则拒绝,--force 重写)
|
|
@@ -56,7 +56,7 @@ modlens config set <provider>.<field> <value> # 字段:apiKey、baseUrl、mo
|
|
|
56
56
|
|
|
57
57
|
字段含义:
|
|
58
58
|
|
|
59
|
-
- `provider`:不传 `-p` 时由哪个 provider 执行。标准名和别名都行(`agy`/`antigravity` 对应 `antigravity-cli`,`gemini` 对应 `gemini-api`,`openai-compat` 对应 `openai`,`claude` 对应 `anthropic`,`claude-code` 对应 `claude-cli
|
|
59
|
+
- `provider`:不传 `-p` 时由哪个 provider 执行。标准名和别名都行(`agy`/`antigravity` 对应 `antigravity-cli`,`gemini` 对应 `gemini-api`,`openai-compat` 对应 `openai`,`claude` 对应 `anthropic`,`claude-code` 对应 `claude-cli`)。留空或缺失表示不钉任何一个:由失败切换链决定,已配置的 API provider 先于 agent CLI 被尝试。
|
|
60
60
|
- `providers.<name>.<field>`:共四个字段,`apiKey`、`baseUrl`、`model`、`extraBody`。每个 provider 条目都可选,条目里的每个字段也都可选。别名键同样会被读取(存在 `gemini` 下的设置在解析到 `gemini-api` 时也能找到),冲突时标准键胜出。
|
|
61
61
|
- `providers.<name>.extraBody`:一个 JSON 对象,合并进 API provider(`gemini-api`、`openai`、`anthropic`)的请求体,用来传厂商有而 modlens 没有对应参数的开关。最常见的用途是关掉思考,见下文小节。嵌套对象逐键合并,所以加一个开关不会动到该块里的其他内容。承载图片、提示词和 schema 约束的字段会被拒绝,报错会点名该字段。两个 CLI provider 不发请求体,所以在 `antigravity-cli` 或 `claude-cli` 上运行时它会被忽略,并在 `meta.warnings` 里说明。
|
|
62
62
|
- `guards`:调用 guard,给在同一个客户端里既跑纯文本模型又跑视觉模型的人用。两个列表都放 glob 模式(支持 `*` 和 `?`,不区分大小写,同时匹配模型名和 `provider/model`),用 `modlens config set guards.denyModels '["gemini-3*"]'` 或 `guards.allowModels` 设置(JSON 数组或逗号分隔的列表都行,传空则清除)。两种写法表达同一个意图,选列表更短的那种:
|
|
@@ -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.16.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.16.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"
|