@liustack/modlens 2.7.0 → 2.7.2
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 +5 -1
- package/README.zh-CN.md +5 -1
- package/dist/main.js +49 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ or do it yourself:
|
|
|
44
44
|
npx -y skills add liustack/modlens
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
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
|
+
|
|
47
49
|
**3. Use it.** Paste an image path into the CLI and ask anything. The skill fires on its own.
|
|
48
50
|
|
|
49
51
|
## See it work
|
|
@@ -158,7 +160,9 @@ One catch: once text-only is declared, the Codex TUI **blocks Ctrl+V image paste
|
|
|
158
160
|
|
|
159
161
|
No setup needed: drag the image file into the terminal, or type its path, and the skill takes over.
|
|
160
162
|
|
|
161
|
-
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, so 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. That is what `modlens recover-paste` exploits: it pulls the most recent pasted images back out of the transcript and prints real file paths, ready for `modlens -i`. The skill runs this automatically when it spots the placeholder. Verified end to end in a real DeepSeek-gateway Claude Code session: paste an image, the model sees only the placeholder, recovers the file by session id, and answers with full image content. Transcripts are per-session files. Skills can pass the exact session 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. [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). `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 even targets the exact session from the injected session id, and 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.
|
|
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, so 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. That is what `modlens recover-paste` exploits: it pulls the most recent pasted images back out of the transcript and prints real file paths, ready for `modlens -i`. The skill runs this automatically when it spots the placeholder. Verified end to end in a real DeepSeek-gateway Claude Code session: paste an image, the model sees only the placeholder, recovers the file by session id, and answers with full image content. Transcripts are per-session files. Skills can pass the exact session 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. [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). `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 even targets the exact session from the injected session id, and 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. 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
|
+
|
|
165
|
+
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`.
|
|
162
166
|
|
|
163
167
|
## Why a bridge instead of a multimodal model?
|
|
164
168
|
|
package/README.zh-CN.md
CHANGED
|
@@ -44,6 +44,8 @@ agy # 浏览器完成登录后退出
|
|
|
44
44
|
npx -y skills add liustack/modlens
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
各家 harness 找 skill 的位置不一样:Claude Code 读 `~/.claude/skills/`,Codex 读 `~/.codex/skills/`,Pi 和 OpenCode 读 `~/.agents/skills/`。软链接在哪家都好使,把 skill 目录链一次,各家永远用最新版。
|
|
48
|
+
|
|
47
49
|
**3. 用起来。** 在 cli 里粘贴个图片路径,随便问,skill 会自动触发。
|
|
48
50
|
|
|
49
51
|
## 看看效果
|
|
@@ -158,7 +160,9 @@ Codex 只认 Responses API,DeepSeek 官方端点原生支持。先照着[官
|
|
|
158
160
|
|
|
159
161
|
不用任何配置:把图片文件拖进终端,或手打路径,skill 直接接手。
|
|
160
162
|
|
|
161
|
-
粘贴要多说两句。走 `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 看到占位符会自动跑这一步。已在真实的 DeepSeek 网关 Claude Code 会话里端到端验证:粘贴一张图,模型只看到占位符,按会话 ID 捞回文件,带着完整图片内容回答。会话记录本来就是一个会话一个文件。skill 可以通过 `--session` 传入精确会话(Claude Code 从 v2.1.9 起会把 `${CLAUDE_SESSION_ID}` 替换进 skill 文本),不传时按消息时间戳挑「持有最新粘贴图」的那份,两条路都不怕同项目并发多开。[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)。`recover-paste` 会先搞清楚自己正跑在哪家宿主里(沿进程祖先链往上找,再核对 `CLAUDECODE`、`PI_CODING_AGENT`、`CODEX_THREAD_ID` 这些环境变量指纹),然后只读那一家的存储,别家的陈年会话再也没机会冒充。在 Claude Code 里还会直接用注入的会话 ID 精确定位,在 Codex 里则干脆拒绝执行并把你指回 path tag
|
|
163
|
+
粘贴要多说两句。走 `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 看到占位符会自动跑这一步。已在真实的 DeepSeek 网关 Claude Code 会话里端到端验证:粘贴一张图,模型只看到占位符,按会话 ID 捞回文件,带着完整图片内容回答。会话记录本来就是一个会话一个文件。skill 可以通过 `--session` 传入精确会话(Claude Code 从 v2.1.9 起会把 `${CLAUDE_SESSION_ID}` 替换进 skill 文本),不传时按消息时间戳挑「持有最新粘贴图」的那份,两条路都不怕同项目并发多开。[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)。`recover-paste` 会先搞清楚自己正跑在哪家宿主里(沿进程祖先链往上找,再核对 `CLAUDECODE`、`PI_CODING_AGENT`、`CODEX_THREAD_ID` 这些环境变量指纹),然后只读那一家的存储,别家的陈年会话再也没机会冒充。在 Claude Code 里还会直接用注入的会话 ID 精确定位,在 Codex 里则干脆拒绝执行并把你指回 path tag。实在识别不出来才退回按最新图片时间戳在三家赛跑。四家宿主全部活体验证过:Claude Code 靠注入的会话 ID 精确捞回粘贴,OpenCode 上 DeepSeek 全程自动触发 skill 跑完整条链路,Pi 只认自家存储不受别家污染,Codex 被拒之门外并指回 path tag。一句老实话:会话记录格式是这些工具的内部实现,没有兼容承诺,哪天捞不动了,拖文件永远是保底。
|
|
164
|
+
|
|
165
|
+
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`。
|
|
162
166
|
|
|
163
167
|
## 为什么外挂,而不是换多模态模型?
|
|
164
168
|
|
package/dist/main.js
CHANGED
|
@@ -724,6 +724,7 @@ function listProviders() {
|
|
|
724
724
|
}
|
|
725
725
|
const DEFAULT_TIMEOUT_MS = 18e4;
|
|
726
726
|
const KILL_GRACE_MS = 3e4;
|
|
727
|
+
const DRAIN_GRACE_MS = 500;
|
|
727
728
|
async function analyzeImage(options) {
|
|
728
729
|
const resolvedInput = resolveInput(options.input);
|
|
729
730
|
if (resolvedInput.kind === "local") {
|
|
@@ -806,18 +807,60 @@ function runCommand(providerName, invocation, timeoutMs) {
|
|
|
806
807
|
let stdout = "";
|
|
807
808
|
let stderr = "";
|
|
808
809
|
let timedOut = false;
|
|
810
|
+
let settled = false;
|
|
811
|
+
let drainTimer;
|
|
809
812
|
const timer = setTimeout(() => {
|
|
810
813
|
timedOut = true;
|
|
811
814
|
child.kill("SIGTERM");
|
|
812
815
|
}, timeoutMs);
|
|
816
|
+
const settle = (code) => {
|
|
817
|
+
if (settled) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
settled = true;
|
|
821
|
+
clearTimeout(timer);
|
|
822
|
+
clearTimeout(drainTimer);
|
|
823
|
+
child.stdout?.destroy();
|
|
824
|
+
child.stderr?.destroy();
|
|
825
|
+
child.unref();
|
|
826
|
+
if (timedOut) {
|
|
827
|
+
reject(new Error(`${providerName} provider timed out after ${timeoutMs} ms.`));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (code !== 0) {
|
|
831
|
+
reject(
|
|
832
|
+
new Error(
|
|
833
|
+
`${providerName} provider failed with code ${code}.${stderr ? ` stderr: ${stderr.trim()}` : ""}`
|
|
834
|
+
)
|
|
835
|
+
);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
resolve({ stdout, stderr });
|
|
839
|
+
};
|
|
840
|
+
let exitCode = null;
|
|
841
|
+
let exited = false;
|
|
842
|
+
const restartDrain = () => {
|
|
843
|
+
if (!exited || settled) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
clearTimeout(drainTimer);
|
|
847
|
+
drainTimer = setTimeout(() => settle(exitCode), DRAIN_GRACE_MS);
|
|
848
|
+
};
|
|
813
849
|
child.stdout.on("data", (chunk) => {
|
|
814
850
|
stdout += chunk.toString();
|
|
851
|
+
restartDrain();
|
|
815
852
|
});
|
|
816
853
|
child.stderr.on("data", (chunk) => {
|
|
817
854
|
stderr += chunk.toString();
|
|
855
|
+
restartDrain();
|
|
818
856
|
});
|
|
819
857
|
child.on("error", (error) => {
|
|
858
|
+
if (settled) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
settled = true;
|
|
820
862
|
clearTimeout(timer);
|
|
863
|
+
clearTimeout(drainTimer);
|
|
821
864
|
if (error.code === "ENOENT") {
|
|
822
865
|
reject(
|
|
823
866
|
new Error(
|
|
@@ -828,22 +871,12 @@ function runCommand(providerName, invocation, timeoutMs) {
|
|
|
828
871
|
}
|
|
829
872
|
reject(error);
|
|
830
873
|
});
|
|
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 });
|
|
874
|
+
child.on("exit", (code) => {
|
|
875
|
+
exitCode = code;
|
|
876
|
+
exited = true;
|
|
877
|
+
restartDrain();
|
|
846
878
|
});
|
|
879
|
+
child.on("close", (code) => settle(code));
|
|
847
880
|
});
|
|
848
881
|
}
|
|
849
882
|
const EXT_BY_MIME = {
|
|
@@ -1236,7 +1269,7 @@ function recoverPastedImages(options = {}) {
|
|
|
1236
1269
|
return result;
|
|
1237
1270
|
}
|
|
1238
1271
|
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.
|
|
1272
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.7.2");
|
|
1240
1273
|
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
1274
|
try {
|
|
1242
1275
|
const timeoutMs = Number.parseInt(options.timeout, 10);
|