@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 +23 -27
- package/README.zh-CN.md +23 -27
- package/dist/main.js +292 -150
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
42
|
+
or do it yourself:
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
|
-
|
|
44
|
-
modlens config set provider gemini-api
|
|
45
|
+
npx -y skills add liustack/modlens
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
53
|
-
|
|
53
|
+
modlens config set gemini-api.apiKey <key>
|
|
54
|
+
modlens config set provider gemini-api
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
|
63
|
+
agy # opens browser sign-in, then exit
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
别的方案让你先把图存成文件,再在对话里报一句路径。ModLens 让你直接粘贴。
|
|
29
29
|
|
|
30
|
-
ModLens
|
|
30
|
+
这不怪它们偷懒。粘贴这个动作从头到尾是客户端办的,图一进对话框就被转码发走,识图 MCP server 连插手的机会都没有,所以它们的文档只能教你存文件、报路径。ModLens 走的是另一条路:图片字节在发走之前,早被 harness 原样写进了本地会话存储,skill 直接去那里把它捞回来落成文件,再喂给视觉引擎。你什么都不用做,模型拿到的是完整图片,不是一句「麻烦告诉我路径」。
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
42
|
+
或者自己动手:
|
|
41
43
|
|
|
42
44
|
```bash
|
|
43
|
-
|
|
44
|
-
modlens config set provider gemini-api
|
|
45
|
+
npx -y skills add liustack/modlens
|
|
45
46
|
```
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
各家 harness 找 skill 的位置不一样:Claude Code 读 `~/.claude/skills/`,Codex 读 `~/.codex/skills/`,Pi 和 OpenCode 读 `~/.agents/skills/`。软链接在哪家都好使,把 skill 目录链一次,各家永远用最新版。
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
**2. 接一个视觉引擎。** 推荐去 [aistudio.google.com](https://aistudio.google.com) 领个免费 Gemini key,三分钟,不要信用卡,出图 5-10 秒:
|
|
50
51
|
|
|
51
52
|
```bash
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
modlens config set gemini-api.apiKey <key>
|
|
54
|
+
modlens config set provider gemini-api
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
|
|
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
|
-
|
|
62
|
+
curl -fsSL https://antigravity.google/cli/install.sh | bash
|
|
63
|
+
agy # 浏览器完成登录后退出
|
|
66
64
|
```
|
|
67
65
|
|
|
68
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
275
|
+
if (!newest || newest.mtime < since) {
|
|
370
276
|
return "";
|
|
371
277
|
}
|
|
372
|
-
|
|
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
|
|
735
|
-
if (
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
976
|
+
stdout += outDecoder.decode(chunk, { stream: true });
|
|
914
977
|
restartDrain();
|
|
915
978
|
});
|
|
916
979
|
child.stderr.on("data", (chunk) => {
|
|
917
|
-
stderr +=
|
|
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
|
|
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
|
|
952
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1079
|
+
function newestJsonlTimestamp(lines, extractLine) {
|
|
982
1080
|
let latest = null;
|
|
983
|
-
|
|
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
|
|
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
|
|
1092
|
-
const
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
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,
|
|
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] ??
|
|
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.
|
|
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);
|