@liustack/modlens 2.7.1 → 2.7.3
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/README.md +40 -7
- package/README.zh-CN.md +41 -8
- package/dist/main.js +115 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,9 +23,30 @@ text-only model in your agent harness ──▶ modlens skill (auto-triggers on
|
|
|
23
23
|
structured JSON evidence ──▶ model answers with sight
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
## You can just paste the image
|
|
27
|
+
|
|
28
|
+
The mainstream way to give a text-only model sight is a vision MCP server. Read their docs and you'll find the same admission: they can't catch a paste. The reason is structural. Pasting is handled entirely by the client: the moment an image lands in the chat box, the client encodes it and sends it straight to the model, and the MCP server never gets a chance to step in. The usual advice is to save the image to a local folder first, then mention the filename or path in the chat.
|
|
29
|
+
|
|
30
|
+
ModLens catches that paste. You paste, the text-only model can't see it (the gateway strips the image down to a pathless placeholder), and the skill quietly pulls the image bytes back out of local session storage, writes them to a file, and feeds that to the vision engine. The model answers with the full image content, not a request for a path. You do nothing extra.
|
|
31
|
+
|
|
32
|
+
Verified on real machines across all four harnesses: Claude Code pinpoints the exact session via the injected session id, Pi stores sessions the same way, OpenCode swaps in SQLite, and Codex's pasted images already carry a temp file path, so the skill takes the path-tag route and never misuses recovery. `recover-paste` figures out which harness it is running inside first, by walking the process ancestry and checking environment fingerprints, then reads only that harness's storage, so another tool's stale sessions can't impersonate it.
|
|
33
|
+
|
|
34
|
+
As far as we know, no other tool catches this paste yet. The usual answer is save the file, then report the path. ModLens's answer is just paste it.
|
|
35
|
+
|
|
26
36
|
## Quick start
|
|
27
37
|
|
|
28
|
-
**1.
|
|
38
|
+
**1. Pick a path and wire up a vision engine** (one-time, pick either one):
|
|
39
|
+
|
|
40
|
+
**Recommended: grab a free Gemini key.** Three minutes, no credit card, 5-10 seconds per image versus 15-40 for agy, and you won't hit a quota wall nearly as fast. Get a key at [aistudio.google.com](https://aistudio.google.com), then:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
modlens config set gemini-api.apiKey <key>
|
|
44
|
+
modlens config set provider gemini-api
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Don't want to type that? Install the skill (step 2 below), then just tell your agent: "set my Gemini key in modlens." It'll run those two commands for you.
|
|
48
|
+
|
|
49
|
+
**Or: no sign-up, start right now with Antigravity CLI.** No key, pure free quota, but slower (15-40s) and the quota is tight. Details below in Providers and config.
|
|
29
50
|
|
|
30
51
|
```bash
|
|
31
52
|
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
|
@@ -46,7 +67,7 @@ npx -y skills add liustack/modlens
|
|
|
46
67
|
|
|
47
68
|
Harnesses look for skills in different places: Claude Code reads `~/.claude/skills/`, Codex reads `~/.codex/skills/`, Pi and OpenCode read `~/.agents/skills/`. Symlinks work in all of them, so linking the skill folder once keeps every agent on the latest version.
|
|
48
69
|
|
|
49
|
-
**3. Use it.** Paste an image
|
|
70
|
+
**3. Use it.** Paste an image (or its path) and ask anything. The skill fires on its own.
|
|
50
71
|
|
|
51
72
|
## See it work
|
|
52
73
|
|
|
@@ -92,7 +113,7 @@ Batch mode works too: drop three illustrations at once, and the model announces
|
|
|
92
113
|
|
|
93
114
|

|
|
94
115
|
|
|
95
|
-
Stress test: a scatter plot of 128 models. ModLens pulls out the axes, the log scale, and the highlighted DeepSeek V4 Flash point at $0.028 and score 50, then walks through the cost-performance cutoff line. Dense charts are where vision models usually fold
|
|
116
|
+
Stress test: a scatter plot of 128 models. ModLens pulls out the axes, the log scale, and the highlighted DeepSeek V4 Flash point at $0.028 and score 50, then walks through the cost-performance cutoff line. Dense charts are where vision models usually fold. This one holds.
|
|
96
117
|
|
|
97
118
|

|
|
98
119
|
|
|
@@ -130,12 +151,14 @@ ModLens ships five vision providers. `antigravity-cli` stays the default: zero k
|
|
|
130
151
|
|
|
131
152
|
| Provider | Needs | Typical speed | Notes |
|
|
132
153
|
| :-- | :-- | :-- | :-- |
|
|
133
|
-
| `antigravity-cli` (default) | `agy` signed in | 15-40s | free quota, full agent loop |
|
|
134
|
-
| `gemini-api` | free AI Studio key | 5-10s | fastest free route, schema enforced server-side |
|
|
154
|
+
| `antigravity-cli` (default) | `agy` signed in | 15-40s | free quota, full agent loop, quota is tight (see below) |
|
|
155
|
+
| `gemini-api` (recommended) | free AI Studio key | 5-10s | fastest free route, schema enforced server-side |
|
|
135
156
|
| `openai` | baseUrl + apiKey + model | endpoint-dependent | any OpenAI-compatible multimodal endpoint (qwen-vl, GLM, ...) |
|
|
136
157
|
| `anthropic` | `ANTHROPIC_API_KEY` | a few seconds | Claude Haiku by default, schema via forced tool call |
|
|
137
158
|
| `claude-cli` | Claude Code signed in | 20-45s | no key, rides your Claude subscription, Read-only permissions |
|
|
138
159
|
|
|
160
|
+
`antigravity-cli` is free, but it costs you on both ends. It's slower (a full agent loop takes 15-40 seconds, versus 5-10 for `gemini-api` direct), and the quota is tight. At launch in November 2025 the free tier was 250 requests a day. By December it was cut to 20 a day. In 2026 it moved to a one-time weekly grant, and once you hit it, you wait out the cycle: we hit that wall ourselves, and the message read "94 hours until reset." That quota is also a shared pool across the desktop app, the CLI, and the SDK, and running subagents in parallel drains it faster. For steady work, `gemini-api` is the better bet.
|
|
161
|
+
|
|
139
162
|
Config lives in `~/.modlens/config.json`. Environment variables override the file (`GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`), and CLI flags override everything.
|
|
140
163
|
|
|
141
164
|
```bash
|
|
@@ -145,7 +168,9 @@ modlens config show # keys come out masked
|
|
|
145
168
|
modlens config set provider gemini-api # switch the default provider
|
|
146
169
|
```
|
|
147
170
|
|
|
148
|
-
The free Gemini key takes three minutes at [aistudio.google.com](https://aistudio.google.com), no credit card.
|
|
171
|
+
The free Gemini key takes three minutes at [aistudio.google.com](https://aistudio.google.com), no credit card.
|
|
172
|
+
|
|
173
|
+
Don't want to type the commands yourself? Once the skill is installed, these are all one sentence away: ask your agent "how do I configure modlens," "set my Gemini key in modlens," or "switch modlens to claude-cli." It follows the skill's own provider setup guide and runs the `modlens config set` commands for you. No docs to read, no flags to memorize.
|
|
149
174
|
|
|
150
175
|
## Using it in Codex (DeepSeek and friends)
|
|
151
176
|
|
|
@@ -160,7 +185,15 @@ One catch: once text-only is declared, the Codex TUI **blocks Ctrl+V image paste
|
|
|
160
185
|
|
|
161
186
|
No setup needed: drag the image file into the terminal, or type its path, and the skill takes over.
|
|
162
187
|
|
|
163
|
-
Paste is trickier. If you run a text-only model behind `ANTHROPIC_BASE_URL`, Claude Code never writes pasted images to a regular temp file and has no modality switch
|
|
188
|
+
Paste is trickier. If you run a text-only model behind `ANTHROPIC_BASE_URL`, Claude Code never writes pasted images to a regular temp file and has no modality switch. A pasted image reaches the model as a pathless `[Unsupported Image]` placeholder (lenient gateways like DeepSeek's Anthropic endpoint) or breaks the request outright ([#62009](https://github.com/anthropics/claude-code/issues/62009)). But the bytes are not gone: Claude Code appends every user message, images included, to the local session transcript before the gateway ever sees it, and that is what `modlens recover-paste` exploits: it pulls the images back out and prints real file paths, ready for `modlens -i`. The skill runs this automatically the moment it spots the placeholder.
|
|
189
|
+
|
|
190
|
+
Transcripts are per-session files, so skills can pass the exact one via `--session` (Claude Code substitutes `${CLAUDE_SESSION_ID}` into skill text since v2.1.9). Without it, recovery picks the transcript holding the newest pasted image by message timestamp, so concurrent sessions in the same project do not confuse it either way.
|
|
191
|
+
|
|
192
|
+
[Pi](https://github.com/earendil-works/pi) stores sessions the same way (`~/.pi/agent/sessions/`, images as base64 in JSONL). [OpenCode](https://github.com/sst/opencode) keeps them in SQLite instead (`~/.local/share/opencode/opencode.db`, images as data URLs, reading it needs Node 22.5+ for node:sqlite).
|
|
193
|
+
|
|
194
|
+
`recover-paste` first identifies the harness it is running inside, by walking the process ancestry and checking env fingerprints (`CLAUDECODE`, `PI_CODING_AGENT`, `CODEX_THREAD_ID`), and reads only that harness's storage, so one tool's stale sessions can never hijack another tool's paste. In Claude Code it targets the exact session from the injected session id. In Codex it refuses outright and points back to the path tag. Only when detection comes up empty does it fall back to racing all three stores by newest image timestamp.
|
|
195
|
+
|
|
196
|
+
Verified live in all four harnesses: Claude Code recovers the paste via its injected session id, OpenCode runs the whole loop on DeepSeek with the skill firing on its own, Pi stays scoped to its own store, and Codex is refused with the path-tag guidance. One honest caveat: transcript layouts are internal implementation details of those tools with no compatibility promise. If recovery ever breaks, dragging the file still works everywhere.
|
|
164
197
|
|
|
165
198
|
Pointing OpenCode at DeepSeek takes two lines of setup: `opencode auth login`, pick DeepSeek and paste your key (it lands in `~/.local/share/opencode/auth.json`), then set the default model in `~/.config/opencode/opencode.jsonc` to `deepseek/deepseek-v4-flash`. Pi reads its key from `~/.pi/agent/auth.json`.
|
|
166
199
|
|
package/README.zh-CN.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
DeepSeek-V4-Flash 碗大又好吃,速度快,性能强,要说唯一的缺点就是没有多模态。不仅 DeepSeek-V4-Flash,只要是纯文本语言模型,跑在 Codex、Claude Code、Pi Agent、OpenClaw 中,都有这个问题。
|
|
13
13
|
|
|
14
|
-
ModLens 用最轻量级方案解决这个问题。ModLens 不会入侵你的配置,也不会给你添加本地代理,ModLens 只是一个视觉外挂,有 cli 或 skill 两种模式。ModLens 能产出结构化的视觉证据:文字、版面、区块、实体、关系、视觉线索。ModLens 由
|
|
14
|
+
ModLens 用最轻量级方案解决这个问题。ModLens 不会入侵你的配置,也不会给你添加本地代理,ModLens 只是一个视觉外挂,有 cli 或 skill 两种模式。ModLens 能产出结构化的视觉证据:文字、版面、区块、实体、关系、视觉线索。ModLens 由 [Antigravity CLI](https://antigravity.google)(`agy`)驱动,而 Antigravity 的视觉由免费额度的 Gemini 3.6 Flash 驱动。Gemini 的识图能力,连 Fable 5 都吊打。原理如下:
|
|
15
15
|
|
|
16
16
|
```text
|
|
17
17
|
Agent Harness 中的纯文本模型 ──▶ modlens skill(遇到图片自动触发)
|
|
@@ -23,9 +23,30 @@ Agent Harness 中的纯文本模型 ──▶ modlens skill(遇到图片自动
|
|
|
23
23
|
结构化 JSON 证据 ──▶ 模型带着视力回答
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
## 你可以直接粘贴图片
|
|
27
|
+
|
|
28
|
+
纯文本模型想看图,主流方案是装一个识图 MCP server。翻开它们的文档,你会看到一句坦白:接不住粘贴。原因很简单,粘贴这个动作从头到尾都是客户端自己办的,图一贴进对话框,客户端就把它转码直接发给模型了,MCP server 连插手的机会都没有。文档给的建议通常是:先把图存到本地某个目录,再在对话里报一句文件名或路径。
|
|
29
|
+
|
|
30
|
+
ModLens 接住了这一下。你粘贴,纯文本模型看不见(网关把图剥成一个不带路径的占位符),skill 自动从本地的会话存储里把图片字节捞回来落成文件,再喂给视觉引擎。模型拿到的是完整图片内容,不是一句「麻烦告诉我路径」。整个过程你不用做任何事。
|
|
31
|
+
|
|
32
|
+
四家主流 harness 都在真机上验证过:Claude Code 用注入的会话 ID 精确定位到当前会话,Pi 的存储路数和它一样,OpenCode 换成了 SQLite,Codex 的粘贴图本来就带临时文件路径,skill 走路径标签这条路,不会误用恢复逻辑。`recover-paste` 会先判断自己正跑在哪一家宿主里(沿进程祖先链往上查,再核对环境变量指纹),只读那一家的存储,别家的旧会话没机会冒充。
|
|
33
|
+
|
|
34
|
+
据我们所知,还没有第二个工具接住粘贴这一下。别的方案是先存文件、再报路径。ModLens 是你直接粘贴。
|
|
35
|
+
|
|
26
36
|
## 快速开始
|
|
27
37
|
|
|
28
|
-
**1.
|
|
38
|
+
**1. 选一条路,把视觉引擎接好**(一次性,选一条就好):
|
|
39
|
+
|
|
40
|
+
**推荐:领一个免费 Gemini key。** 三分钟,不要信用卡,直连模型 5-10 秒出结果(agy 要 15-40 秒),额度也没那么容易撞墙。去 [aistudio.google.com](https://aistudio.google.com) 拿到 key,然后:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
modlens config set gemini-api.apiKey <key>
|
|
44
|
+
modlens config set provider gemini-api
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
嫌敲命令麻烦?装完下面第 2 步的 skill 后,直接跟你的 agent 说「帮我把 Gemini key 配进 modlens」,它会替你跑完这两行。
|
|
48
|
+
|
|
49
|
+
**次选:不想注册、想立刻开跑,就直接用 Antigravity CLI。** 零 key,纯免费额度,但慢一些(15-40 秒),额度也紧,细节见下文「Provider 与配置」。
|
|
29
50
|
|
|
30
51
|
```bash
|
|
31
52
|
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
|
@@ -46,7 +67,7 @@ npx -y skills add liustack/modlens
|
|
|
46
67
|
|
|
47
68
|
各家 harness 找 skill 的位置不一样:Claude Code 读 `~/.claude/skills/`,Codex 读 `~/.codex/skills/`,Pi 和 OpenCode 读 `~/.agents/skills/`。软链接在哪家都好使,把 skill 目录链一次,各家永远用最新版。
|
|
48
69
|
|
|
49
|
-
**3. 用起来。**
|
|
70
|
+
**3. 用起来。** 粘贴一张图(或者图片路径),随便问,skill 会自动触发。
|
|
50
71
|
|
|
51
72
|
## 看看效果
|
|
52
73
|
|
|
@@ -130,12 +151,14 @@ ModLens 内置五个视觉 provider,默认还是 `antigravity-cli`:零 key
|
|
|
130
151
|
|
|
131
152
|
| Provider | 需要什么 | 速度 | 说明 |
|
|
132
153
|
| :-- | :-- | :-- | :-- |
|
|
133
|
-
| `antigravity-cli`(默认) | `agy` 登录过 | 15-40 秒 | 免费额度,完整 agent
|
|
134
|
-
| `gemini-api
|
|
154
|
+
| `antigravity-cli`(默认) | `agy` 登录过 | 15-40 秒 | 免费额度,完整 agent 循环,额度紧(见下文) |
|
|
155
|
+
| `gemini-api`(推荐) | 免费 AI Studio key | 5-10 秒 | 最快的免费路线,服务端强制 schema |
|
|
135
156
|
| `openai` | baseUrl + apiKey + model | 看端点 | 任何 OpenAI 兼容的多模态端点(qwen-vl、GLM 等) |
|
|
136
157
|
| `anthropic` | `ANTHROPIC_API_KEY` | 几秒 | 默认 Claude Haiku,强制工具调用保 schema |
|
|
137
158
|
| `claude-cli` | Claude Code 已登录 | 20-45 秒 | 零 key,吃你的 Claude 订阅额度,只放行 Read 工具 |
|
|
138
159
|
|
|
160
|
+
`antigravity-cli` 免费,但两头都紧:慢(完整 agent 循环要 15-40 秒,`gemini-api` 直连只要 5-10 秒),额度也紧。2025 年 11 月刚上线时每天 250 次请求,12 月直接砍到每天 20 次,2026 年又改成一次性发放的周配额,用超了就得等下一个周期重置,我们实测撞过一次墙,提示是「94 小时后重置」。这份配额还是桌面应用、CLI、SDK 三头共用一个池子,用 subagent 并行跑消耗得更快。想稳定干活,还是建议换成 `gemini-api`。
|
|
161
|
+
|
|
139
162
|
配置放在 `~/.modlens/config.json`,环境变量能盖过它(`GEMINI_API_KEY`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`ANTHROPIC_API_KEY`),CLI 参数最大。
|
|
140
163
|
|
|
141
164
|
```bash
|
|
@@ -145,11 +168,13 @@ modlens config show # key 打码显示
|
|
|
145
168
|
modlens config set provider gemini-api # 换默认 provider
|
|
146
169
|
```
|
|
147
170
|
|
|
148
|
-
免费 Gemini key 去 [aistudio.google.com](https://aistudio.google.com)
|
|
171
|
+
免费 Gemini key 去 [aistudio.google.com](https://aistudio.google.com) 领,三分钟,不要信用卡。
|
|
172
|
+
|
|
173
|
+
嫌自己敲命令麻烦?装完 skill 之后这些配置全部能甩给 agent:问一句「modlens 怎么配置」「帮我把 Gemini key 配进 modlens」「把默认 provider 切成 claude-cli」,agent 会照着 skill 自带的配置手册,自己跑 `modlens config set` 这些命令,不用你查文档,也不用你记参数。
|
|
149
174
|
|
|
150
175
|
## 在 Codex 里用(DeepSeek 等纯文本模型)
|
|
151
176
|
|
|
152
|
-
Codex 只认 Responses API,DeepSeek 官方端点原生支持。先照着[官方集成文档](https://api-docs.deepseek.com/zh-cn/quick_start/agent_integrations/codex)配好:它的 `models.json` 把 deepseek-v4-flash 声明成纯文本(`input_modalities: ["text"]
|
|
177
|
+
Codex 只认 Responses API,DeepSeek 官方端点原生支持。先照着[官方集成文档](https://api-docs.deepseek.com/zh-cn/quick_start/agent_integrations/codex)配好:它的 `models.json` 把 deepseek-v4-flash 声明成纯文本(`input_modalities: ["text"]`),这一行就是解锁下面整条链路的钥匙。
|
|
153
178
|
|
|
154
179
|
有个坑要注意:声明纯文本之后,Codex TUI 会**直接拦下 Ctrl+V 粘贴图片**(报错 `Model deepseek-v4-flash does not support image inputs`),闸门卡在输入框那一层,图片压根到不了消息里。能用的招数有两个,都拿 deepseek-v4-flash 端到端验证过:
|
|
155
180
|
|
|
@@ -160,7 +185,15 @@ Codex 只认 Responses API,DeepSeek 官方端点原生支持。先照着[官
|
|
|
160
185
|
|
|
161
186
|
不用任何配置:把图片文件拖进终端,或手打路径,skill 直接接手。
|
|
162
187
|
|
|
163
|
-
粘贴要多说两句。走 `ANTHROPIC_BASE_URL` 网关跑纯文本模型时,Claude Code 粘贴的图片从不写普通临时文件,也没有声明模型无视觉的开关,粘贴的图要么变成一个不带路径的 `[Unsupported Image]` 占位符到达模型(DeepSeek 的 Anthropic 兼容端点这类宽容网关),要么直接把请求搞挂([#62009](https://github.com/anthropics/claude-code/issues/62009))。但图片字节没有蒸发:Claude Code
|
|
188
|
+
粘贴要多说两句。走 `ANTHROPIC_BASE_URL` 网关跑纯文本模型时,Claude Code 粘贴的图片从不写普通临时文件,也没有声明模型无视觉的开关,粘贴的图要么变成一个不带路径的 `[Unsupported Image]` 占位符到达模型(DeepSeek 的 Anthropic 兼容端点这类宽容网关),要么直接把请求搞挂([#62009](https://github.com/anthropics/claude-code/issues/62009))。但图片字节没有蒸发:Claude Code 在网关看到消息之前,就把每条用户消息(含图片)原样写进了本地会话记录,`modlens recover-paste` 干的就是把它们捞回来、落成真实文件路径,直接喂给 `modlens -i`。skill 看到占位符会自动跑这一步。
|
|
189
|
+
|
|
190
|
+
会话记录本来就是一个会话一个文件,skill 可以通过 `--session` 传入精确会话(Claude Code 从 v2.1.9 起会把 `${CLAUDE_SESSION_ID}` 替换进 skill 文本)。不传时按消息时间戳挑「持有最新粘贴图」的那份,两条路都不怕同项目并发多开。
|
|
191
|
+
|
|
192
|
+
[Pi](https://github.com/earendil-works/pi) 的会话存储和它同构(`~/.pi/agent/sessions/`,图片以 base64 存 JSONL)。[OpenCode](https://github.com/sst/opencode) 换了个存法,图片以 data URL 塞进 SQLite(`~/.local/share/opencode/opencode.db`,读它需要 Node 22.5+ 的 node:sqlite)。
|
|
193
|
+
|
|
194
|
+
`recover-paste` 会先搞清楚自己正跑在哪家宿主里(沿进程祖先链往上找,再核对 `CLAUDECODE`、`PI_CODING_AGENT`、`CODEX_THREAD_ID` 这些环境变量指纹),然后只读那一家的存储,别家的陈年会话再也没机会冒充。在 Claude Code 里还会直接用注入的会话 ID 精确定位,在 Codex 里则干脆拒绝执行并把你指回 path tag。实在识别不出来才退回按最新图片时间戳在三家赛跑。
|
|
195
|
+
|
|
196
|
+
四家宿主全部活体验证过:Claude Code 靠注入的会话 ID 精确捞回粘贴,OpenCode 上 DeepSeek 全程自动触发 skill 跑完整条链路,Pi 只认自家存储不受别家污染,Codex 被拒之门外并指回 path tag。一句老实话:会话记录格式是这些工具的内部实现,没有兼容承诺,哪天捞不动了,拖文件永远是保底。
|
|
164
197
|
|
|
165
198
|
OpenCode 接 DeepSeek 只要两步:`opencode auth login` 选 DeepSeek 贴上 key(落在 `~/.local/share/opencode/auth.json`),再把 `~/.config/opencode/opencode.jsonc` 的默认模型设成 `deepseek/deepseek-v4-flash`。Pi 的 key 放 `~/.pi/agent/auth.json`。
|
|
166
199
|
|
package/dist/main.js
CHANGED
|
@@ -314,11 +314,72 @@ function tryParseJson$1(text) {
|
|
|
314
314
|
return null;
|
|
315
315
|
}
|
|
316
316
|
}
|
|
317
|
+
const SWITCH_HINT = `Or switch to a provider with its own quota and no interactive login:
|
|
318
|
+
modlens config set gemini-api.apiKey <key> # free key, no card: https://aistudio.google.com
|
|
319
|
+
modlens config set provider gemini-api`;
|
|
320
|
+
function describeAntigravityFailure(context) {
|
|
321
|
+
let envelope = null;
|
|
322
|
+
try {
|
|
323
|
+
envelope = parseEnvelope$1(context.stdout);
|
|
324
|
+
} catch {
|
|
325
|
+
envelope = null;
|
|
326
|
+
}
|
|
327
|
+
if (!envelope) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
const agyError = typeof envelope?.error === "string" ? envelope.error.trim() : "";
|
|
331
|
+
const evidence = `${agyError}
|
|
332
|
+
${context.stderr}
|
|
333
|
+
${readRecentAgyLog()}`.toLowerCase();
|
|
334
|
+
if (evidence.includes("quota")) {
|
|
335
|
+
return [
|
|
336
|
+
agyError || "Antigravity CLI reported a quota error.",
|
|
337
|
+
"agy's free tier is one weekly bucket shared by the desktop app, the CLI, and the SDK, and subagents drain it in parallel. Wait for the reset shown above, or use a different provider.",
|
|
338
|
+
SWITCH_HINT
|
|
339
|
+
].join("\n\n");
|
|
340
|
+
}
|
|
341
|
+
if (evidence.includes("not logged into antigravity") || evidence.includes("getting token source") || evidence.includes("keyring") || evidence.includes("failed to read token store")) {
|
|
342
|
+
return [
|
|
343
|
+
"Antigravity CLI cannot read its stored login token.",
|
|
344
|
+
"On Linux this usually means the OS keyring is locked, which is normal for headless sessions (agents, cron, systemd, SSH without a desktop login). agy then reports it as being signed out and tries a browser sign-in that cannot complete without a display. Unlock the keyring, or run modlens from a desktop session, or sign in again with `agy`.",
|
|
345
|
+
SWITCH_HINT
|
|
346
|
+
].join("\n\n");
|
|
347
|
+
}
|
|
348
|
+
const totalTokens = envelope?.usage?.total_tokens;
|
|
349
|
+
if (agyError || totalTokens === 0) {
|
|
350
|
+
return [
|
|
351
|
+
agyError || "Antigravity CLI exited before doing any work (no tokens consumed).",
|
|
352
|
+
`Usually auth or quota. Check \`agy\` interactively, and look at the newest log in ${agyLogDir()} for the real reason.`,
|
|
353
|
+
SWITCH_HINT
|
|
354
|
+
].join("\n\n");
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
function agyLogDir() {
|
|
359
|
+
return path.join(os.homedir(), ".gemini", "antigravity-cli", "log");
|
|
360
|
+
}
|
|
361
|
+
const LOG_FRESHNESS_MS = 2 * 60 * 1e3;
|
|
362
|
+
function readRecentAgyLog() {
|
|
363
|
+
try {
|
|
364
|
+
const dir = agyLogDir();
|
|
365
|
+
const newest = fs.readdirSync(dir).filter((name) => name.endsWith(".log")).map((name) => {
|
|
366
|
+
const full = path.join(dir, name);
|
|
367
|
+
return { full, mtime: fs.statSync(full).mtimeMs };
|
|
368
|
+
}).sort((a, b) => b.mtime - a.mtime)[0];
|
|
369
|
+
if (!newest || Date.now() - newest.mtime > LOG_FRESHNESS_MS) {
|
|
370
|
+
return "";
|
|
371
|
+
}
|
|
372
|
+
return fs.readFileSync(newest.full, "utf-8").slice(-8e3);
|
|
373
|
+
} catch {
|
|
374
|
+
return "";
|
|
375
|
+
}
|
|
376
|
+
}
|
|
317
377
|
const antigravityCliProvider = {
|
|
318
378
|
name: "antigravity-cli",
|
|
319
379
|
defaultModel: DEFAULT_MODEL,
|
|
320
380
|
buildInvocation: buildAntigravityInvocation,
|
|
321
|
-
parseOutput: parseAntigravityOutput
|
|
381
|
+
parseOutput: parseAntigravityOutput,
|
|
382
|
+
describeFailure: describeAntigravityFailure
|
|
322
383
|
};
|
|
323
384
|
const MIME_BY_EXT = {
|
|
324
385
|
".jpg": "image/jpeg",
|
|
@@ -724,6 +785,7 @@ function listProviders() {
|
|
|
724
785
|
}
|
|
725
786
|
const DEFAULT_TIMEOUT_MS = 18e4;
|
|
726
787
|
const KILL_GRACE_MS = 3e4;
|
|
788
|
+
const DRAIN_GRACE_MS = 500;
|
|
727
789
|
async function analyzeImage(options) {
|
|
728
790
|
const resolvedInput = resolveInput(options.input);
|
|
729
791
|
if (resolvedInput.kind === "local") {
|
|
@@ -752,7 +814,8 @@ async function analyzeImage(options) {
|
|
|
752
814
|
const commandResult = await runCommand(
|
|
753
815
|
provider.name,
|
|
754
816
|
invocation,
|
|
755
|
-
timeoutMs + KILL_GRACE_MS
|
|
817
|
+
timeoutMs + KILL_GRACE_MS,
|
|
818
|
+
provider.describeFailure
|
|
756
819
|
);
|
|
757
820
|
parsed = provider.parseOutput(commandResult.stdout);
|
|
758
821
|
} else {
|
|
@@ -797,7 +860,7 @@ function validateInputFile(filePath) {
|
|
|
797
860
|
throw new Error(`Input is not a file: ${filePath}`);
|
|
798
861
|
}
|
|
799
862
|
}
|
|
800
|
-
function runCommand(providerName, invocation, timeoutMs) {
|
|
863
|
+
function runCommand(providerName, invocation, timeoutMs, describeFailure) {
|
|
801
864
|
return new Promise((resolve, reject) => {
|
|
802
865
|
const child = spawn(invocation.command, invocation.args, {
|
|
803
866
|
cwd: invocation.cwd,
|
|
@@ -806,18 +869,61 @@ function runCommand(providerName, invocation, timeoutMs) {
|
|
|
806
869
|
let stdout = "";
|
|
807
870
|
let stderr = "";
|
|
808
871
|
let timedOut = false;
|
|
872
|
+
let settled = false;
|
|
873
|
+
let drainTimer;
|
|
809
874
|
const timer = setTimeout(() => {
|
|
810
875
|
timedOut = true;
|
|
811
876
|
child.kill("SIGTERM");
|
|
812
877
|
}, timeoutMs);
|
|
878
|
+
const settle = (code) => {
|
|
879
|
+
if (settled) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
settled = true;
|
|
883
|
+
clearTimeout(timer);
|
|
884
|
+
clearTimeout(drainTimer);
|
|
885
|
+
child.stdout?.destroy();
|
|
886
|
+
child.stderr?.destroy();
|
|
887
|
+
child.unref();
|
|
888
|
+
if (timedOut) {
|
|
889
|
+
reject(new Error(`${providerName} provider timed out after ${timeoutMs} ms.`));
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (code !== 0) {
|
|
893
|
+
const explained = describeFailure?.({ stdout, stderr, code }) ?? null;
|
|
894
|
+
reject(
|
|
895
|
+
new Error(
|
|
896
|
+
explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
|
|
897
|
+
)
|
|
898
|
+
);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
resolve({ stdout, stderr });
|
|
902
|
+
};
|
|
903
|
+
let exitCode = null;
|
|
904
|
+
let exited = false;
|
|
905
|
+
const restartDrain = () => {
|
|
906
|
+
if (!exited || settled) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
clearTimeout(drainTimer);
|
|
910
|
+
drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
|
|
911
|
+
};
|
|
813
912
|
child.stdout.on("data", (chunk) => {
|
|
814
913
|
stdout += chunk.toString();
|
|
914
|
+
restartDrain();
|
|
815
915
|
});
|
|
816
916
|
child.stderr.on("data", (chunk) => {
|
|
817
917
|
stderr += chunk.toString();
|
|
918
|
+
restartDrain();
|
|
818
919
|
});
|
|
819
920
|
child.on("error", (error) => {
|
|
921
|
+
if (settled) {
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
settled = true;
|
|
820
925
|
clearTimeout(timer);
|
|
926
|
+
clearTimeout(drainTimer);
|
|
821
927
|
if (error.code === "ENOENT") {
|
|
822
928
|
reject(
|
|
823
929
|
new Error(
|
|
@@ -828,22 +934,12 @@ function runCommand(providerName, invocation, timeoutMs) {
|
|
|
828
934
|
}
|
|
829
935
|
reject(error);
|
|
830
936
|
});
|
|
831
|
-
child.on("
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
if (code !== 0) {
|
|
838
|
-
reject(
|
|
839
|
-
new Error(
|
|
840
|
-
`${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
|
|
841
|
-
)
|
|
842
|
-
);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
resolve({ stdout, stderr });
|
|
937
|
+
child.on("exit", (code) => {
|
|
938
|
+
exitCode = code;
|
|
939
|
+
exited = true;
|
|
940
|
+
restartDrain();
|
|
846
941
|
});
|
|
942
|
+
child.on("close", (code) => settle(code));
|
|
847
943
|
});
|
|
848
944
|
}
|
|
849
945
|
const EXT_BY_MIME = {
|
|
@@ -1236,7 +1332,7 @@ function recoverPastedImages(options = {}) {
|
|
|
1236
1332
|
return result;
|
|
1237
1333
|
}
|
|
1238
1334
|
const program = new Command();
|
|
1239
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.
|
|
1335
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.3");
|
|
1240
1336
|
program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").action(async (options) => {
|
|
1241
1337
|
try {
|
|
1242
1338
|
const timeoutMs = Number.parseInt(options.timeout, 10);
|