@liustack/modlens 2.7.3 → 2.7.5

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 CHANGED
@@ -11,13 +11,13 @@
11
11
 
12
12
  DeepSeek-V4-Flash gives you a lot of model for very little money: fast, strong, and its one real flaw is no multimodal. And it's not just DeepSeek. Every text-only model running inside Codex, Claude Code, Pi Agent, or OpenClaw hits the same wall.
13
13
 
14
- ModLens fixes this the lightest way possible. It never touches your config and never adds a local proxy. It's just a vision plug-in, usable as a CLI or as an Agent Skill, that turns any image into structured visual evidence: text, layout, regions, entities, relations, visual clues. Under the hood it runs on [Antigravity CLI](https://antigravity.google) (`agy`), whose vision comes from free-quota Gemini 3.6 Flash. And Gemini's image understanding is famously good, good enough to embarrass most flagships, Fable 5 included. How it works:
14
+ ModLens fixes this the lightest way possible: it never touches your config, never adds a local proxy, and is just a vision plug-in you can run as a CLI or install as an Agent Skill. What it hands back is not a one-line caption but structured visual evidence: text, layout, regions, entities, relations, visual clues. Five vision engines to pick from. The default one needs no key at all, and the fastest one runs on a free Gemini key whose image understanding embarrasses most flagships, Fable 5 included. How it works:
15
15
 
16
16
  ```text
17
17
  text-only model in your agent harness ──▶ modlens skill (auto-triggers on images)
18
18
  │
19
19
  ▼
20
- agy · Gemini 3.6 Flash (free quota)
20
+ vision engine · Gemini 3.6 Flash (free)
21
21
  │
22
22
  ▼
23
23
  structured JSON evidence ──▶ model answers with sight
@@ -25,49 +25,45 @@ text-only model in your agent harness ──▶ modlens skill (auto-triggers on
25
25
 
26
26
  ## You can just paste the image
27
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.
28
+ Every other bridge makes you save the image to a file first, then mention the path in the chat. ModLens lets you paste it.
29
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.
30
+ That is not laziness on their part. Pasting is handled end to end by the client: the moment an image lands in the chat box it is encoded and sent, and a vision MCP server never gets a chance to step in, which is why their docs can only tell you to save the file and report the path. ModLens takes the other route. Before those bytes are ever sent, the harness has already written them to local session storage, so the skill goes there, pulls them back out into a real file, and feeds that to the vision engine. You do nothing, and the model answers with the full image instead of asking you for a path.
31
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.
32
+ Verified on real machines across four harnesses: Claude Code pinpoints the exact session from its injected session id, Pi stores sessions the same way, OpenCode swaps in SQLite, and Codex's pasted images already carry a temp path so the path-tag route handles them. Before touching anything, `recover-paste` works out which harness it is running inside, by walking the process ancestry and checking environment fingerprints, and reads only that harness's storage, so another tool's stale sessions can't impersonate it.
35
33
 
36
34
  ## Quick start
37
35
 
38
- **1. Pick a path and wire up a vision engine** (one-time, pick either one):
36
+ **1. Install the skill.** Just tell your agent (Claude Code, Codex, OpenClaw, Cursor, ...):
37
+
38
+ ```text
39
+ Install the skill from https://github.com/liustack/modlens
40
+ ```
39
41
 
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:
42
+ or do it yourself:
41
43
 
42
44
  ```bash
43
- modlens config set gemini-api.apiKey <key>
44
- modlens config set provider gemini-api
45
+ npx -y skills add liustack/modlens
45
46
  ```
46
47
 
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
+ 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
49
 
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.
50
+ **2. Wire up a vision engine.** Recommended: a free Gemini key from [aistudio.google.com](https://aistudio.google.com). Three minutes, no credit card, 5-10 seconds per image:
50
51
 
51
52
  ```bash
52
- curl -fsSL https://antigravity.google/cli/install.sh | bash
53
- agy # opens browser sign-in, then exit
53
+ modlens config set gemini-api.apiKey <key>
54
+ modlens config set provider gemini-api
54
55
  ```
55
56
 
56
- **2. Install the skill.** Just tell your agent (Claude Code, Codex, OpenClaw, Cursor, ...):
57
-
58
- ```text
59
- Install the skill from https://github.com/liustack/modlens
60
- ```
57
+ Don't feel like typing those two lines? Tell your agent "set my Gemini key in modlens" and it runs them for you.
61
58
 
62
- or do it yourself:
59
+ Skipping the sign-up is fine too: Antigravity CLI works with no key at all, it is just slower (15-40s) and its free quota is tight.
63
60
 
64
61
  ```bash
65
- npx -y skills add liustack/modlens
62
+ curl -fsSL https://antigravity.google/cli/install.sh | bash
63
+ agy # opens browser sign-in, then exit
66
64
  ```
67
65
 
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.
69
-
70
- **3. Use it.** Paste an image (or its path) and ask anything. The skill fires on its own.
66
+ **3. Use it.** Paste an image, or throw a path at it, and ask anything. The skill fires on its own.
71
67
 
72
68
  ## See it work
73
69
 
@@ -157,7 +153,7 @@ ModLens ships five vision providers. `antigravity-cli` stays the default: zero k
157
153
  | `anthropic` | `ANTHROPIC_API_KEY` | a few seconds | Claude Haiku by default, schema via forced tool call |
158
154
  | `claude-cli` | Claude Code signed in | 20-45s | no key, rides your Claude subscription, Read-only permissions |
159
155
 
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.
156
+ `antigravity-cli` wins on needing no key and loses on both other fronts: it's slower (a full agent loop takes 15-40 seconds against 5-10 for `gemini-api` direct) and its quota is tight. The free tier is now a one-time weekly grant, pooled across the desktop app, the CLI, and the SDK, and parallel subagents drain it faster. Once it's gone you wait out the cycle: we hit that wall ourselves and the message read "94 hours until reset." Great for a first look, but `gemini-api` is what holds up day to day.
161
157
 
162
158
  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.
163
159
 
@@ -170,7 +166,7 @@ modlens config set provider gemini-api # switch the default provider
170
166
 
171
167
  The free Gemini key takes three minutes at [aistudio.google.com](https://aistudio.google.com), no credit card.
172
168
 
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.
169
+ You don't actually have to remember any of these commands. The skill ships a per-provider setup guide, so once it's installed you can just ask your agent: "how do I configure modlens," "set my Gemini key in modlens," "switch modlens to claude-cli." It reads the guide and runs them.
174
170
 
175
171
  ## Using it in Codex (DeepSeek and friends)
176
172
 
package/README.zh-CN.md CHANGED
@@ -11,13 +11,13 @@
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 由 [Antigravity CLI](https://antigravity.google)(`agy`)驱动,而 Antigravity 的视觉由免费额度的 Gemini 3.6 Flash 驱动。Gemini 的识图能力,连 Fable 5 都吊打。原理如下:
14
+ ModLens 用最轻的方式解决它:不动你的配置,不装本地代理,就是一个视觉外挂,CLI 和 skill 两种用法。它产出的不是一句话描述,是结构化的视觉证据:文字、版面、区块、实体、关系、视觉线索。视觉引擎有五个可选,默认那个零 key 就能跑,最快的那个用免费 Gemini key,识图能力连 Fable 5 都吊打。原理如下:
15
15
 
16
16
  ```text
17
17
  Agent Harness 中的纯文本模型 ──▶ modlens skill(遇到图片自动触发)
18
18
  │
19
19
  ▼
20
- agy · Gemini 3.6 Flash(免费额度)
20
+ 视觉引擎 · Gemini 3.6 Flash(免费)
21
21
  │
22
22
  ▼
23
23
  结构化 JSON 证据 ──▶ 模型带着视力回答
@@ -25,49 +25,45 @@ Agent Harness 中的纯文本模型 ──▶ modlens skill(遇到图片自动
25
25
 
26
26
  ## 你可以直接粘贴图片
27
27
 
28
- 纯文本模型想看图,主流方案是装一个识图 MCP server。翻开它们的文档,你会看到一句坦白:接不住粘贴。原因很简单,粘贴这个动作从头到尾都是客户端自己办的,图一贴进对话框,客户端就把它转码直接发给模型了,MCP server 连插手的机会都没有。文档给的建议通常是:先把图存到本地某个目录,再在对话里报一句文件名或路径。
28
+ 别的方案让你先把图存成文件,再在对话里报一句路径。ModLens 让你直接粘贴。
29
29
 
30
- ModLens 接住了这一下。你粘贴,纯文本模型看不见(网关把图剥成一个不带路径的占位符),skill 自动从本地的会话存储里把图片字节捞回来落成文件,再喂给视觉引擎。模型拿到的是完整图片内容,不是一句「麻烦告诉我路径」。整个过程你不用做任何事。
30
+ 这不怪它们偷懒。粘贴这个动作从头到尾是客户端办的,图一进对话框就被转码发走,识图 MCP server 连插手的机会都没有,所以它们的文档只能教你存文件、报路径。ModLens 走的是另一条路:图片字节在发走之前,早被 harness 原样写进了本地会话存储,skill 直接去那里把它捞回来落成文件,再喂给视觉引擎。你什么都不用做,模型拿到的是完整图片,不是一句「麻烦告诉我路径」。
31
31
 
32
- 四家主流 harness 都在真机上验证过:Claude Code 用注入的会话 ID 精确定位到当前会话,Pi 的存储路数和它一样,OpenCode 换成了 SQLite,Codex 的粘贴图本来就带临时文件路径,skill 走路径标签这条路,不会误用恢复逻辑。`recover-paste` 会先判断自己正跑在哪一家宿主里(沿进程祖先链往上查,再核对环境变量指纹),只读那一家的存储,别家的旧会话没机会冒充。
33
-
34
- 据我们所知,还没有第二个工具接住粘贴这一下。别的方案是先存文件、再报路径。ModLens 是你直接粘贴。
32
+ 四家 harness 真机验证过:Claude Code 按注入的会话 ID 精确定位,Pi 的存储路数和它同构,OpenCode 换成了 SQLite,Codex 的粘贴图本来就带临时路径,走路径标签就行。动手之前 `recover-paste` 会先认清自己跑在哪一家(查进程祖先链,核对环境变量指纹),只读那一家的存储,别家的旧会话冒充不了。
35
33
 
36
34
  ## 快速开始
37
35
 
38
- **1. 选一条路,把视觉引擎接好**(一次性,选一条就好):
36
+ **1. 装 skill。** 直接告诉你的 agent(Claude Code、Codex、OpenClaw、Cursor 等):
37
+
38
+ ```text
39
+ 安装这个 skill https://github.com/liustack/modlens
40
+ ```
39
41
 
40
- **推荐:领一个免费 Gemini key。** 三分钟,不要信用卡,直连模型 5-10 秒出结果(agy 要 15-40 秒),额度也没那么容易撞墙。去 [aistudio.google.com](https://aistudio.google.com) 拿到 key,然后:
42
+ 或者自己动手:
41
43
 
42
44
  ```bash
43
- modlens config set gemini-api.apiKey <key>
44
- modlens config set provider gemini-api
45
+ npx -y skills add liustack/modlens
45
46
  ```
46
47
 
47
- 嫌敲命令麻烦?装完下面第 2 步的 skill 后,直接跟你的 agent 说「帮我把 Gemini key 配进 modlens」,它会替你跑完这两行。
48
+ 各家 harness 找 skill 的位置不一样:Claude Code 读 `~/.claude/skills/`,Codex 读 `~/.codex/skills/`,Pi 和 OpenCode 读 `~/.agents/skills/`。软链接在哪家都好使,把 skill 目录链一次,各家永远用最新版。
48
49
 
49
- **次选:不想注册、想立刻开跑,就直接用 Antigravity CLI。** 零 key,纯免费额度,但慢一些(15-40 秒),额度也紧,细节见下文「Provider 与配置」。
50
+ **2. 接一个视觉引擎。** 推荐去 [aistudio.google.com](https://aistudio.google.com) 领个免费 Gemini key,三分钟,不要信用卡,出图 5-10 秒:
50
51
 
51
52
  ```bash
52
- curl -fsSL https://antigravity.google/cli/install.sh | bash
53
- agy # 浏览器完成登录后退出
53
+ modlens config set gemini-api.apiKey <key>
54
+ modlens config set provider gemini-api
54
55
  ```
55
56
 
56
- **2. 安装 skill。** 直接告诉你的 agent(Claude Code、Codex、OpenClaw、Cursor 等):
57
-
58
- ```text
59
- 安装这个 skill https://github.com/liustack/modlens
60
- ```
57
+ 懒得敲这两行?跟 agent 说一句「帮我把 Gemini key 配进 modlens」,它自己会跑。
61
58
 
62
- 或者自己动手:
59
+ 不想注册也行,装上 Antigravity CLI 就能零 key 开跑,代价是慢(15-40 秒),免费额度也紧:
63
60
 
64
61
  ```bash
65
- npx -y skills add liustack/modlens
62
+ curl -fsSL https://antigravity.google/cli/install.sh | bash
63
+ agy # 浏览器完成登录后退出
66
64
  ```
67
65
 
68
- 各家 harness 找 skill 的位置不一样:Claude Code 读 `~/.claude/skills/`,Codex 读 `~/.codex/skills/`,Pi 和 OpenCode 读 `~/.agents/skills/`。软链接在哪家都好使,把 skill 目录链一次,各家永远用最新版。
69
-
70
- **3. 用起来。** 粘贴一张图(或者图片路径),随便问,skill 会自动触发。
66
+ **3. 用起来。** 粘贴一张图,或者甩个图片路径,随便问。skill 自己会触发。
71
67
 
72
68
  ## 看看效果
73
69
 
@@ -157,7 +153,7 @@ ModLens 内置五个视觉 provider,默认还是 `antigravity-cli`:零 key
157
153
  | `anthropic` | `ANTHROPIC_API_KEY` | 几秒 | 默认 Claude Haiku,强制工具调用保 schema |
158
154
  | `claude-cli` | Claude Code 已登录 | 20-45 秒 | 零 key,吃你的 Claude 订阅额度,只放行 Read 工具 |
159
155
 
160
- `antigravity-cli` 免费,但两头都紧:慢(完整 agent 循环要 15-40 秒,`gemini-api` 直连只要 5-10 秒),额度也紧。2025 年 11 月刚上线时每天 250 次请求,12 月直接砍到每天 20 次,2026 年又改成一次性发放的周配额,用超了就得等下一个周期重置,我们实测撞过一次墙,提示是「94 小时后重置」。这份配额还是桌面应用、CLI、SDK 三头共用一个池子,用 subagent 并行跑消耗得更快。想稳定干活,还是建议换成 `gemini-api`。
156
+ `antigravity-cli` 胜在零 key,输在两头:慢(完整 agent 循环 15-40 秒,`gemini-api` 直连 5-10 秒),额度紧。它的免费档如今是一次性发放的周配额,桌面应用、CLI、SDK 共用一个池子,subagent 并行还加倍消耗,用超了得等下个周期(我们实测撞过一次,提示「94 小时后重置」)。所以它适合尝鲜,日常主力还是 `gemini-api` 稳。
161
157
 
162
158
  配置放在 `~/.modlens/config.json`,环境变量能盖过它(`GEMINI_API_KEY`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`ANTHROPIC_API_KEY`),CLI 参数最大。
163
159
 
@@ -170,7 +166,7 @@ modlens config set provider gemini-api # 换默认 provider
170
166
 
171
167
  免费 Gemini key 去 [aistudio.google.com](https://aistudio.google.com) 领,三分钟,不要信用卡。
172
168
 
173
- 嫌自己敲命令麻烦?装完 skill 之后这些配置全部能甩给 agent:问一句「modlens 怎么配置」「帮我把 Gemini key 配进 modlens」「把默认 provider 切成 claude-cli」,agent 会照着 skill 自带的配置手册,自己跑 `modlens config set` 这些命令,不用你查文档,也不用你记参数。
169
+ 这些命令你其实一条都不用记。skill 自带一份分 provider 的配置手册,装完之后直接问你的 agent:「modlens 怎么配置」「帮我把 Gemini key 配进 modlens」「把默认 provider 切成 claude-cli」,它照着手册自己跑完。
174
170
 
175
171
  ## 在 Codex 里用(DeepSeek 等纯文本模型)
176
172
 
package/dist/main.js CHANGED
@@ -7,118 +7,6 @@ import { spawn } from "child_process";
7
7
  import * as os from "os";
8
8
  import * as crypto from "crypto";
9
9
  import { createRequire } from "module";
10
- const CONFIG_DIR = path.join(os.homedir(), ".modlens");
11
- const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
12
- const ENV_BINDINGS = {
13
- "gemini-api": { apiKey: "GEMINI_API_KEY" },
14
- openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
15
- anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
16
- };
17
- function loadConfigFile(configPath = CONFIG_PATH) {
18
- let raw;
19
- try {
20
- raw = fs.readFileSync(configPath, "utf-8");
21
- } catch {
22
- return {};
23
- }
24
- try {
25
- const parsed = JSON.parse(raw);
26
- if (!parsed || typeof parsed !== "object") {
27
- return {};
28
- }
29
- return parsed;
30
- } catch (error) {
31
- throw new Error(
32
- `Failed to parse ${configPath}: ${error.message}. Fix or delete the file.`
33
- );
34
- }
35
- }
36
- function defaultProviderName(config2) {
37
- return config2.provider?.trim() || "antigravity-cli";
38
- }
39
- function resolveProviderSettings(providerName, config2, env = process.env) {
40
- const fromFile = config2.providers?.[providerName] ?? {};
41
- const bindings = ENV_BINDINGS[providerName] ?? {};
42
- const settings = { ...fromFile };
43
- for (const [field, envName] of Object.entries(bindings)) {
44
- const value = env[envName]?.trim();
45
- if (value) {
46
- settings[field] = value;
47
- }
48
- }
49
- return settings;
50
- }
51
- function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
52
- const config2 = loadConfigFile(configPath);
53
- if (dottedKey === "provider") {
54
- config2.provider = value;
55
- } else {
56
- const dot = dottedKey.indexOf(".");
57
- if (dot <= 0 || dot === dottedKey.length - 1) {
58
- throw new Error(
59
- `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
60
- );
61
- }
62
- const providerName = dottedKey.slice(0, dot);
63
- const field = dottedKey.slice(dot + 1);
64
- if (!["apiKey", "baseUrl", "model"].includes(field)) {
65
- throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
66
- }
67
- config2.providers ??= {};
68
- config2.providers[providerName] ??= {};
69
- config2.providers[providerName][field] = value;
70
- }
71
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
72
- fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
73
- `, { mode: 384 });
74
- try {
75
- fs.chmodSync(configPath, 384);
76
- } catch {
77
- }
78
- }
79
- const CONFIG_TEMPLATE = {
80
- provider: "antigravity-cli",
81
- providers: {
82
- "antigravity-cli": { model: "gemini-3.6-flash-low" },
83
- "gemini-api": { apiKey: "", model: "gemini-3.6-flash" },
84
- openai: { baseUrl: "", apiKey: "", model: "" },
85
- anthropic: { apiKey: "", model: "claude-haiku-4-5-20251001" },
86
- "claude-cli": { model: "haiku" }
87
- }
88
- };
89
- function initConfigFile(configPath = CONFIG_PATH, force = false) {
90
- if (!force && fs.existsSync(configPath)) {
91
- throw new Error(`${configPath} already exists. Use --force to overwrite.`);
92
- }
93
- fs.mkdirSync(path.dirname(configPath), { recursive: true });
94
- fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
95
- `, { mode: 384 });
96
- try {
97
- fs.chmodSync(configPath, 384);
98
- } catch {
99
- }
100
- }
101
- function renderConfig(config2) {
102
- const masked = {
103
- ...config2,
104
- providers: Object.fromEntries(
105
- Object.entries(config2.providers ?? {}).map(([name, settings]) => [
106
- name,
107
- {
108
- ...settings,
109
- ...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
110
- }
111
- ])
112
- )
113
- };
114
- return JSON.stringify(masked, null, 2);
115
- }
116
- function maskKey(key) {
117
- if (key.length <= 8) {
118
- return "****";
119
- }
120
- return `${key.slice(0, 6)}...${key.slice(-2)}`;
121
- }
122
10
  function buildVisionPrompt(options) {
123
11
  const readInstruction = options.imageKind === "inline" ? "Analyze the image attached to this message." : options.imageKind === "remote" ? `Fetch the image at this URL and analyze it: ${options.imageSource}` : `Read the image file at this path and analyze it: ${options.imageSource}`;
124
12
  const basePrompt = `${readInstruction}
@@ -318,6 +206,7 @@ const SWITCH_HINT = `Or switch to a provider with its own quota and no interacti
318
206
  modlens config set gemini-api.apiKey <key> # free key, no card: https://aistudio.google.com
319
207
  modlens config set provider gemini-api`;
320
208
  function describeAntigravityFailure(context) {
209
+ const since = context.startedAt ?? Date.now() - LOG_FRESHNESS_MS;
321
210
  let envelope = null;
322
211
  try {
323
212
  envelope = parseEnvelope$1(context.stdout);
@@ -330,7 +219,7 @@ function describeAntigravityFailure(context) {
330
219
  const agyError = typeof envelope?.error === "string" ? envelope.error.trim() : "";
331
220
  const evidence = `${agyError}
332
221
  ${context.stderr}
333
- ${readRecentAgyLog()}`.toLowerCase();
222
+ ${readRecentAgyLog(since)}`.toLowerCase();
334
223
  if (evidence.includes("quota")) {
335
224
  return [
336
225
  agyError || "Antigravity CLI reported a quota error.",
@@ -359,17 +248,38 @@ function agyLogDir() {
359
248
  return path.join(os.homedir(), ".gemini", "antigravity-cli", "log");
360
249
  }
361
250
  const LOG_FRESHNESS_MS = 2 * 60 * 1e3;
362
- function readRecentAgyLog() {
251
+ function parseAgyLogTime(line, now = /* @__PURE__ */ new Date()) {
252
+ const match = /\b[IWEF](\d{2})(\d{2})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/.exec(line);
253
+ if (!match) {
254
+ return null;
255
+ }
256
+ const [, month, day, hour, minute, second, fraction] = match;
257
+ const stamp = new Date(
258
+ now.getFullYear(),
259
+ Number(month) - 1,
260
+ Number(day),
261
+ Number(hour),
262
+ Number(minute),
263
+ Number(second),
264
+ fraction ? Number(fraction.slice(0, 3)) : 0
265
+ ).getTime();
266
+ return stamp - now.getTime() > 24 * 60 * 60 * 1e3 ? new Date(new Date(stamp).setFullYear(now.getFullYear() - 1)).getTime() : stamp;
267
+ }
268
+ function readRecentAgyLog(since) {
363
269
  try {
364
270
  const dir = agyLogDir();
365
271
  const newest = fs.readdirSync(dir).filter((name) => name.endsWith(".log")).map((name) => {
366
272
  const full = path.join(dir, name);
367
273
  return { full, mtime: fs.statSync(full).mtimeMs };
368
274
  }).sort((a, b) => b.mtime - a.mtime)[0];
369
- if (!newest || Date.now() - newest.mtime > LOG_FRESHNESS_MS) {
275
+ if (!newest || newest.mtime < since) {
370
276
  return "";
371
277
  }
372
- return fs.readFileSync(newest.full, "utf-8").slice(-8e3);
278
+ const recent = fs.readFileSync(newest.full, "utf-8").slice(-64e3).split("\n").filter((line) => {
279
+ const stamp = parseAgyLogTime(line);
280
+ return stamp !== null && stamp >= since;
281
+ });
282
+ return recent.join("\n");
373
283
  } catch {
374
284
  return "";
375
285
  }
@@ -379,7 +289,8 @@ const antigravityCliProvider = {
379
289
  defaultModel: DEFAULT_MODEL,
380
290
  buildInvocation: buildAntigravityInvocation,
381
291
  parseOutput: parseAntigravityOutput,
382
- describeFailure: describeAntigravityFailure
292
+ describeFailure: describeAntigravityFailure,
293
+ hasInternalTimeout: true
383
294
  };
384
295
  const MIME_BY_EXT = {
385
296
  ".jpg": "image/jpeg",
@@ -731,10 +642,10 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
731
642
  if (result === null) {
732
643
  throw new Error(`OpenAI-compatible API returned non-JSON output: ${truncate(text)}`);
733
644
  }
734
- const shaped = result;
735
- if (typeof shaped.summary !== "string" || typeof shaped.ocr !== "object") {
645
+ const missing = missingSchemaFields(result);
646
+ if (missing.length > 0) {
736
647
  throw new Error(
737
- `OpenAI-compatible API returned JSON that does not match the vision schema (missing summary/ocr). Retry, or switch to gemini-api / anthropic for enforced schemas. Got: ${truncate(text)}`
648
+ `OpenAI-compatible API returned JSON that does not match the vision schema${missing.length > 0 ? ` (missing: ${missing.join(", ")})` : ""}. Retry, or switch to gemini-api / anthropic for enforced schemas. Got: ${truncate(text)}`
738
649
  );
739
650
  }
740
651
  return {
@@ -746,6 +657,28 @@ Respond with ONE JSON object only, no markdown fences, no commentary. Fill this
746
657
  }
747
658
  };
748
659
  }
660
+ function missingSchemaFields(result) {
661
+ const missing = [];
662
+ const root = result ?? {};
663
+ const child = (key) => root[key] && typeof root[key] === "object" ? root[key] : {};
664
+ const expect = (path2, ok) => {
665
+ if (!ok) {
666
+ missing.push(path2);
667
+ }
668
+ };
669
+ expect("summary", typeof root.summary === "string");
670
+ expect("ocr", typeof root.ocr === "object" && root.ocr !== null);
671
+ expect("ocr.full_text", typeof child("ocr").full_text === "string");
672
+ expect("ocr.lines", Array.isArray(child("ocr").lines));
673
+ expect("layout", typeof root.layout === "object" && root.layout !== null);
674
+ expect("layout.regions", Array.isArray(child("layout").regions));
675
+ expect("semantics", typeof root.semantics === "object" && root.semantics !== null);
676
+ expect("semantics.scene", typeof child("semantics").scene === "string");
677
+ expect("semantics.entities", Array.isArray(child("semantics").entities));
678
+ expect("visual", typeof root.visual === "object" && root.visual !== null);
679
+ expect("uncertainty", Array.isArray(root.uncertainty));
680
+ return missing;
681
+ }
749
682
  function toDataUrl(image) {
750
683
  return `data:${image.mimeType};base64,${image.data}`;
751
684
  }
@@ -780,9 +713,135 @@ function resolveProvider(providerName = "antigravity-cli") {
780
713
  }
781
714
  return provider;
782
715
  }
716
+ function providerAliases() {
717
+ return Object.fromEntries(
718
+ Object.entries(PROVIDERS).map(([alias, provider]) => [alias, provider.name])
719
+ );
720
+ }
783
721
  function listProviders() {
784
722
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
785
723
  }
724
+ const CONFIG_DIR = path.join(os.homedir(), ".modlens");
725
+ const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
726
+ const ENV_BINDINGS = {
727
+ "gemini-api": { apiKey: "GEMINI_API_KEY" },
728
+ openai: { apiKey: "OPENAI_API_KEY", baseUrl: "OPENAI_BASE_URL" },
729
+ anthropic: { apiKey: "ANTHROPIC_API_KEY", baseUrl: "ANTHROPIC_BASE_URL" }
730
+ };
731
+ function loadConfigFile(configPath = CONFIG_PATH) {
732
+ let raw;
733
+ try {
734
+ raw = fs.readFileSync(configPath, "utf-8");
735
+ } catch (error) {
736
+ if (error.code === "ENOENT") {
737
+ return {};
738
+ }
739
+ throw new Error(
740
+ `Cannot read ${configPath}: ${error.message}. Fix the file or its permissions.`
741
+ );
742
+ }
743
+ try {
744
+ const parsed = JSON.parse(raw);
745
+ if (!parsed || typeof parsed !== "object") {
746
+ return {};
747
+ }
748
+ return parsed;
749
+ } catch (error) {
750
+ throw new Error(
751
+ `Failed to parse ${configPath}: ${error.message}. Fix or delete the file.`
752
+ );
753
+ }
754
+ }
755
+ function defaultProviderName(config2) {
756
+ return config2.provider?.trim() || "antigravity-cli";
757
+ }
758
+ function resolveProviderSettings(providerName, config2, env = process.env) {
759
+ const aliasNames = Object.entries(providerAliases()).filter(([alias, canonical]) => canonical === providerName && alias !== providerName).map(([alias]) => alias);
760
+ const fromFile = {
761
+ ...Object.assign({}, ...aliasNames.map((alias) => config2.providers?.[alias] ?? {})),
762
+ ...config2.providers?.[providerName] ?? {}
763
+ };
764
+ const bindings = ENV_BINDINGS[providerName] ?? {};
765
+ const settings = { ...fromFile };
766
+ for (const [field, envName] of Object.entries(bindings)) {
767
+ const value = env[envName]?.trim();
768
+ if (value) {
769
+ settings[field] = value;
770
+ }
771
+ }
772
+ return settings;
773
+ }
774
+ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
775
+ const config2 = loadConfigFile(configPath);
776
+ if (dottedKey === "provider") {
777
+ config2.provider = value;
778
+ } else {
779
+ const dot = dottedKey.indexOf(".");
780
+ if (dot <= 0 || dot === dottedKey.length - 1) {
781
+ throw new Error(
782
+ `Invalid config key: ${dottedKey}. Use "provider" or "<provider>.<apiKey|baseUrl|model>".`
783
+ );
784
+ }
785
+ const providerName = dottedKey.slice(0, dot);
786
+ const field = dottedKey.slice(dot + 1);
787
+ if (!["apiKey", "baseUrl", "model"].includes(field)) {
788
+ throw new Error(`Unknown config field: ${field}. Use apiKey, baseUrl, or model.`);
789
+ }
790
+ config2.providers ??= {};
791
+ config2.providers[providerName] ??= {};
792
+ config2.providers[providerName][field] = value;
793
+ }
794
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
795
+ fs.writeFileSync(configPath, `${JSON.stringify(config2, null, 2)}
796
+ `, { mode: 384 });
797
+ try {
798
+ fs.chmodSync(configPath, 384);
799
+ } catch {
800
+ }
801
+ }
802
+ const CONFIG_TEMPLATE = {
803
+ provider: "antigravity-cli",
804
+ providers: {
805
+ "antigravity-cli": { model: "gemini-3.6-flash-low" },
806
+ "gemini-api": { apiKey: "", model: "gemini-3.6-flash" },
807
+ openai: { baseUrl: "", apiKey: "", model: "" },
808
+ anthropic: { apiKey: "", model: "claude-haiku-4-5-20251001" },
809
+ "claude-cli": { model: "haiku" }
810
+ }
811
+ };
812
+ function initConfigFile(configPath = CONFIG_PATH, force = false) {
813
+ if (!force && fs.existsSync(configPath)) {
814
+ throw new Error(`${configPath} already exists. Use --force to overwrite.`);
815
+ }
816
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
817
+ fs.writeFileSync(configPath, `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
818
+ `, { mode: 384 });
819
+ try {
820
+ fs.chmodSync(configPath, 384);
821
+ } catch {
822
+ }
823
+ }
824
+ function renderConfig(config2) {
825
+ const masked = {
826
+ ...config2,
827
+ providers: Object.fromEntries(
828
+ Object.entries(config2.providers ?? {}).map(([name, settings]) => [
829
+ name,
830
+ {
831
+ ...settings,
832
+ ...settings.apiKey ? { apiKey: maskKey(settings.apiKey) } : {}
833
+ }
834
+ ])
835
+ )
836
+ };
837
+ return JSON.stringify(masked, null, 2);
838
+ }
839
+ function maskKey(key) {
840
+ if (key.length <= 8) {
841
+ return "****";
842
+ }
843
+ return `${key.slice(0, 6)}...${key.slice(-2)}`;
844
+ }
786
845
  const DEFAULT_TIMEOUT_MS = 18e4;
787
846
  const KILL_GRACE_MS = 3e4;
788
847
  const DRAIN_GRACE_MS = 500;
@@ -811,10 +870,11 @@ async function analyzeImage(options) {
811
870
  parsed = await provider.execute(providerOptions);
812
871
  } else if (provider.buildInvocation && provider.parseOutput) {
813
872
  const invocation = provider.buildInvocation(providerOptions);
873
+ const backstop = provider.hasInternalTimeout ? timeoutMs + KILL_GRACE_MS : timeoutMs;
814
874
  const commandResult = await runCommand(
815
875
  provider.name,
816
876
  invocation,
817
- timeoutMs + KILL_GRACE_MS,
877
+ backstop,
818
878
  provider.describeFailure
819
879
  );
820
880
  parsed = provider.parseOutput(commandResult.stdout);
@@ -861,11 +921,14 @@ function validateInputFile(filePath) {
861
921
  }
862
922
  }
863
923
  function runCommand(providerName, invocation, timeoutMs, describeFailure) {
924
+ const runStartedAt = Date.now();
864
925
  return new Promise((resolve, reject) => {
865
926
  const child = spawn(invocation.command, invocation.args, {
866
927
  cwd: invocation.cwd,
867
928
  stdio: ["ignore", "pipe", "pipe"]
868
929
  });
930
+ const outDecoder = new TextDecoder("utf-8");
931
+ const errDecoder = new TextDecoder("utf-8");
869
932
  let stdout = "";
870
933
  let stderr = "";
871
934
  let timedOut = false;
@@ -890,7 +953,7 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
890
953
  return;
891
954
  }
892
955
  if (code !== 0) {
893
- const explained = describeFailure?.({ stdout, stderr, code }) ?? null;
956
+ const explained = describeFailure?.({ stdout, stderr, code, startedAt: runStartedAt }) ?? null;
894
957
  reject(
895
958
  new Error(
896
959
  explained ?? `${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
@@ -910,11 +973,11 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
910
973
  drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
911
974
  };
912
975
  child.stdout.on("data", (chunk) => {
913
- stdout += chunk.toString();
976
+ stdout += outDecoder.decode(chunk, { stream: true });
914
977
  restartDrain();
915
978
  });
916
979
  child.stderr.on("data", (chunk) => {
917
- stderr += chunk.toString();
980
+ stderr += errDecoder.decode(chunk, { stream: true });
918
981
  restartDrain();
919
982
  });
920
983
  child.on("error", (error) => {
@@ -925,9 +988,10 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
925
988
  clearTimeout(timer);
926
989
  clearTimeout(drainTimer);
927
990
  if (error.code === "ENOENT") {
991
+ const missingCwd = !fs.existsSync(invocation.cwd);
928
992
  reject(
929
993
  new Error(
930
- `Provider CLI not found: ${invocation.command}. Install Antigravity CLI and sign in first.`
994
+ missingCwd ? `Working directory does not exist: ${invocation.cwd}` : `Provider CLI not found: ${invocation.command}. Install it and sign in first.`
931
995
  )
932
996
  );
933
997
  return;
@@ -942,20 +1006,54 @@ function runCommand(providerName, invocation, timeoutMs, describeFailure) {
942
1006
  child.on("close", (code) => settle(code));
943
1007
  });
944
1008
  }
1009
+ function extensionFromMediaType(mediaType) {
1010
+ const subtype = mediaType.split("/")[1]?.split("+")[0]?.replace(/[^a-z0-9]/gi, "");
1011
+ return subtype ? subtype.toLowerCase() : "bin";
1012
+ }
945
1013
  const EXT_BY_MIME = {
946
1014
  "image/png": "png",
947
1015
  "image/jpeg": "jpg",
948
1016
  "image/webp": "webp",
949
1017
  "image/gif": "gif"
950
1018
  };
951
- function forEachJsonLine(filePath, visit) {
952
- let raw;
1019
+ function transcriptBelongsTo(lines, cwd) {
1020
+ const wanted = path.resolve(cwd);
1021
+ let sawCwd = false;
1022
+ for (const line of lines) {
1023
+ if (!line.includes('"cwd"')) {
1024
+ continue;
1025
+ }
1026
+ try {
1027
+ const recorded = JSON.parse(line).cwd;
1028
+ if (typeof recorded !== "string") {
1029
+ continue;
1030
+ }
1031
+ sawCwd = true;
1032
+ const resolved = path.resolve(recorded);
1033
+ if (resolved === wanted || resolved.startsWith(`${wanted}${path.sep}`)) {
1034
+ return true;
1035
+ }
1036
+ } catch {
1037
+ }
1038
+ }
1039
+ return !sawCwd;
1040
+ }
1041
+ function readLines(filePath) {
953
1042
  try {
954
- raw = fs.readFileSync(filePath, "utf-8");
1043
+ return fs.readFileSync(filePath, "utf-8").split("\n");
955
1044
  } catch {
1045
+ return null;
1046
+ }
1047
+ }
1048
+ function forEachJsonLine(filePath, visit) {
1049
+ const lines = readLines(filePath);
1050
+ if (!lines) {
956
1051
  return;
957
1052
  }
958
- for (const line of raw.split("\n")) {
1053
+ forEachParsedLine(lines, visit);
1054
+ }
1055
+ function forEachParsedLine(lines, visit) {
1056
+ for (const line of lines) {
959
1057
  if (!line.includes('"image"')) {
960
1058
  continue;
961
1059
  }
@@ -978,9 +1076,9 @@ function jsonlSource(harness, filePath, extractLine) {
978
1076
  }
979
1077
  };
980
1078
  }
981
- function newestJsonlTimestamp(filePath, extractLine) {
1079
+ function newestJsonlTimestamp(lines, extractLine) {
982
1080
  let latest = null;
983
- forEachJsonLine(filePath, (line) => {
1081
+ forEachParsedLine(lines, (line) => {
984
1082
  if (extractLine(line).length === 0) {
985
1083
  return;
986
1084
  }
@@ -1007,7 +1105,11 @@ function jsonlAdapter(options) {
1007
1105
  findNewest: (cwd) => {
1008
1106
  let best = null;
1009
1107
  for (const file of listJsonl(dirFor(cwd))) {
1010
- const timestamp = newestJsonlTimestamp(file, extractLine);
1108
+ const lines = readLines(file);
1109
+ if (!lines || !transcriptBelongsTo(lines, cwd)) {
1110
+ continue;
1111
+ }
1112
+ const timestamp = newestJsonlTimestamp(lines, extractLine);
1011
1113
  if (timestamp !== null && (!best || timestamp > best.timestamp)) {
1012
1114
  best = { ref: jsonlSource(name, file, extractLine), timestamp };
1013
1115
  }
@@ -1016,7 +1118,11 @@ function jsonlAdapter(options) {
1016
1118
  },
1017
1119
  findSession: (cwd, sessionId) => {
1018
1120
  for (const file of listJsonl(dirFor(cwd))) {
1019
- if (matchesSession(path.basename(file), sessionId)) {
1121
+ if (!matchesSession(path.basename(file), sessionId)) {
1122
+ continue;
1123
+ }
1124
+ const lines = readLines(file);
1125
+ if (lines && transcriptBelongsTo(lines, cwd)) {
1020
1126
  return jsonlSource(name, file, extractLine);
1021
1127
  }
1022
1128
  }
@@ -1075,6 +1181,9 @@ const piAdapter = jsonlAdapter({
1075
1181
  function opencodeDbPath() {
1076
1182
  return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
1077
1183
  }
1184
+ function escapeLikePattern(value) {
1185
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
1186
+ }
1078
1187
  function opencodeQuery(dbPath, cwd, sessionId) {
1079
1188
  let DatabaseSync;
1080
1189
  try {
@@ -1088,8 +1197,10 @@ function opencodeQuery(dbPath, cwd, sessionId) {
1088
1197
  const db = new DatabaseSync(dbPath, { readOnly: true });
1089
1198
  try {
1090
1199
  const resolved = path.resolve(cwd);
1091
- const sessionFilter = sessionId ? `AND (session.id = ? OR session.slug = ?)` : `AND (session.directory = ? OR session.directory LIKE ? || '/%' OR ? LIKE session.directory || '/%')`;
1092
- const params = sessionId ? [sessionId, sessionId] : [resolved, resolved, resolved];
1200
+ const directoryFilter = `(session.directory = ? OR session.directory LIKE ? || '/%' ESCAPE '\\' OR ? LIKE session.directory || '/%' ESCAPE '\\')`;
1201
+ const escaped = escapeLikePattern(resolved);
1202
+ const sessionFilter = sessionId ? `AND ${directoryFilter} AND (session.id = ? OR session.slug = ?)` : `AND ${directoryFilter}`;
1203
+ const params = sessionId ? [resolved, escaped, resolved, sessionId, sessionId] : [resolved, escaped, resolved];
1093
1204
  const rows = db.prepare(
1094
1205
  `SELECT part.data AS data, part.time_created AS time_created, part.session_id AS session_id
1095
1206
  FROM part
@@ -1182,8 +1293,16 @@ function harnessFromPsTable(psOutput, startPid) {
1182
1293
  if (!proc) {
1183
1294
  return null;
1184
1295
  }
1185
- for (const token of proc.command.trim().split(/\s+/).slice(0, 8)) {
1186
- const mapped = HARNESS_BY_BASENAME[path.basename(token)];
1296
+ const tokens = proc.command.trim().split(/\s+/);
1297
+ const candidates = [tokens[0]];
1298
+ if (/^(node|bun|deno)$/.test(path.basename(tokens[0] ?? ""))) {
1299
+ const script = tokens.slice(1).find((token) => !token.startsWith("-") && /[/\\]|\.(m|c)?[jt]s$/.test(token));
1300
+ if (script) {
1301
+ candidates.push(script);
1302
+ }
1303
+ }
1304
+ for (const token of candidates) {
1305
+ const mapped = token ? HARNESS_BY_BASENAME[path.basename(token)] : void 0;
1187
1306
  if (mapped) {
1188
1307
  return mapped;
1189
1308
  }
@@ -1220,26 +1339,29 @@ function detectHarness() {
1220
1339
  return null;
1221
1340
  }
1222
1341
  const ADAPTERS = [claudeAdapter, piAdapter, opencodeAdapter];
1223
- function sourceForExplicitPath(filePath) {
1224
- if (filePath.endsWith(".db")) {
1342
+ function sourceForExplicitPath(filePath, cwd, harness) {
1343
+ const declared = harness && harness !== "none" ? harness : void 0;
1344
+ if (declared === "opencode" || !declared && filePath.endsWith(".db")) {
1225
1345
  return {
1226
1346
  harness: "opencode",
1227
1347
  location: filePath,
1228
- extract: () => opencodeImagesFromRows(opencodeQuery(filePath, process.cwd()))
1348
+ extract: () => opencodeImagesFromRows(opencodeQuery(filePath, cwd))
1229
1349
  };
1230
1350
  }
1231
- if (filePath.includes(`${path.sep}.pi${path.sep}`)) {
1351
+ if (declared === "pi" || !declared && filePath.includes(`${path.sep}.pi${path.sep}`)) {
1232
1352
  return jsonlSource("pi", filePath, piExtractLine);
1233
1353
  }
1234
1354
  return jsonlSource("claude-code", filePath, claudeExtractLine);
1235
1355
  }
1236
1356
  function locateSource(cwd, adapters = ADAPTERS) {
1237
1357
  let best = null;
1358
+ const blockers = [];
1238
1359
  for (const adapter of adapters) {
1239
1360
  let candidate = null;
1240
1361
  try {
1241
1362
  candidate = adapter.findNewest(cwd);
1242
- } catch {
1363
+ } catch (error) {
1364
+ blockers.push(`${adapter.name}: ${error instanceof Error ? error.message : String(error)}`);
1243
1365
  }
1244
1366
  if (candidate && (!best || candidate.timestamp > best.timestamp)) {
1245
1367
  best = candidate;
@@ -1247,25 +1369,31 @@ function locateSource(cwd, adapters = ADAPTERS) {
1247
1369
  }
1248
1370
  if (!best) {
1249
1371
  const dirs = adapters.map((a) => a.describe(cwd)).join(" , ");
1372
+ const blocked = blockers.length > 0 ? `
1373
+ Blocked: ${blockers.join(" | ")}` : "";
1250
1374
  throw new Error(
1251
- `No pasted images found in any session storage for this directory (looked in: ${dirs}). The user may not have pasted any, or the storage format changed; ask for a file path instead.`
1375
+ `No pasted images found in any session storage for this directory (looked in: ${dirs}). The user may not have pasted any, or the storage format changed; ask for a file path instead.${blocked}`
1252
1376
  );
1253
1377
  }
1254
1378
  return best.ref;
1255
1379
  }
1256
1380
  function sourceForSession(cwd, sessionId, adapters = ADAPTERS) {
1381
+ const blockers = [];
1257
1382
  for (const adapter of adapters) {
1258
1383
  try {
1259
1384
  const ref = adapter.findSession(cwd, sessionId);
1260
1385
  if (ref) {
1261
1386
  return ref;
1262
1387
  }
1263
- } catch {
1388
+ } catch (error) {
1389
+ blockers.push(`${adapter.name}: ${error instanceof Error ? error.message : String(error)}`);
1264
1390
  }
1265
1391
  }
1266
1392
  const dirs = adapters.map((a) => a.describe(cwd)).join(" , ");
1393
+ const blocked = blockers.length > 0 ? `
1394
+ Blocked: ${blockers.join(" | ")}` : "";
1267
1395
  throw new Error(
1268
- `No session ${sessionId} with pasted images under this project (looked in: ${dirs}). Check --cwd, or drop --session to auto-locate by newest pasted image.`
1396
+ `No session ${sessionId} with pasted images under this project (looked in: ${dirs}). Check --cwd, or drop --session to auto-locate by newest pasted image.${blocked}`
1269
1397
  );
1270
1398
  }
1271
1399
  function recoverPastedImages(options = {}) {
@@ -1276,6 +1404,12 @@ function recoverPastedImages(options = {}) {
1276
1404
  "This is a Codex session: pasted images already exist as temp files, and each image tag in the message carries its path. Read the path from the tag instead of running recover-paste."
1277
1405
  );
1278
1406
  }
1407
+ const requested = options.harness?.trim();
1408
+ if (requested && requested !== "none" && !ADAPTERS.some((a) => a.name === requested)) {
1409
+ throw new Error(
1410
+ `Unknown harness "${requested}". Supported: ${ADAPTERS.map((a) => a.name).join(", ")} (or none to scan all).`
1411
+ );
1412
+ }
1279
1413
  const scoped = detected && detected !== "none" ? detected : null;
1280
1414
  if (scoped && !ADAPTERS.some((adapter) => adapter.name === scoped)) {
1281
1415
  throw new Error(
@@ -1285,7 +1419,7 @@ function recoverPastedImages(options = {}) {
1285
1419
  const adapters = scoped ? ADAPTERS.filter((adapter) => adapter.name === scoped) : ADAPTERS;
1286
1420
  let source = null;
1287
1421
  if (options.transcript) {
1288
- source = sourceForExplicitPath(options.transcript);
1422
+ source = sourceForExplicitPath(options.transcript, cwd, options.harness);
1289
1423
  } else if (options.session) {
1290
1424
  source = sourceForSession(cwd, options.session, adapters);
1291
1425
  } else {
@@ -1307,14 +1441,22 @@ function recoverPastedImages(options = {}) {
1307
1441
  `No pasted images found in ${source.location}. The user may not have pasted any, or the storage format changed; ask for a file path instead.`
1308
1442
  );
1309
1443
  }
1310
- fs.mkdirSync(outDir, { recursive: true });
1444
+ fs.mkdirSync(outDir, { recursive: true, mode: 448 });
1445
+ try {
1446
+ fs.chmodSync(outDir, 448);
1447
+ } catch {
1448
+ }
1311
1449
  const picked = all.slice(-count);
1312
1450
  const images = picked.map((image) => {
1313
1451
  const buffer = Buffer.from(image.data, "base64");
1314
1452
  const hash = crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 8);
1315
- const ext = EXT_BY_MIME[image.mediaType] ?? "png";
1453
+ const ext = EXT_BY_MIME[image.mediaType] ?? extensionFromMediaType(image.mediaType);
1316
1454
  const filePath = path.join(outDir, `paste-${hash}.${ext}`);
1317
- fs.writeFileSync(filePath, buffer);
1455
+ fs.writeFileSync(filePath, buffer, { mode: 384 });
1456
+ try {
1457
+ fs.chmodSync(filePath, 384);
1458
+ } catch {
1459
+ }
1318
1460
  const recovered = {
1319
1461
  path: filePath,
1320
1462
  mediaType: image.mediaType,
@@ -1332,7 +1474,7 @@ function recoverPastedImages(options = {}) {
1332
1474
  return result;
1333
1475
  }
1334
1476
  const program = new Command();
1335
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.3");
1477
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.5");
1336
1478
  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) => {
1337
1479
  try {
1338
1480
  const timeoutMs = Number.parseInt(options.timeout, 10);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "2.7.3",
3
+ "version": "2.7.5",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {