@liustack/modlens 2.5.0 → 2.6.0
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 +3 -3
- package/README.zh-CN.md +3 -3
- package/dist/main.js +223 -95
- package/package.json +1 -1
- package/skills/modlens/SKILL.md +4 -4
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ modlens -i <image-path-or-url> [options]
|
|
|
113
113
|
|
|
114
114
|
Reach for `-m gemini-3.1-pro-high` on dense screenshots or tricky documents. Output contract: [skills/modlens/references/output-schema.md](skills/modlens/references/output-schema.md).
|
|
115
115
|
|
|
116
|
-
Two more subcommands: `modlens config <init|set|show>` manages providers and keys (details below), and `modlens recover-paste` rescues images pasted into Claude Code:
|
|
116
|
+
Two more subcommands: `modlens config <init|set|show>` manages providers and keys (details below), and `modlens recover-paste` rescues images pasted into Claude Code, Pi, or OpenCode:
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
119
|
modlens recover-paste # newest pasted image, path printed as JSON
|
|
@@ -154,11 +154,11 @@ One catch: once text-only is declared, the Codex TUI **blocks Ctrl+V image paste
|
|
|
154
154
|
- **Drag the image file into the terminal**, or type its path. The path lands as plain text, and the modlens skill picks it up from there.
|
|
155
155
|
- Attach it with `codex exec -i image.png "..."`. The skill reads the path out of the message tag.
|
|
156
156
|
|
|
157
|
-
## Using it in Claude Code and
|
|
157
|
+
## Using it in Claude Code, Pi, and OpenCode (gateway models)
|
|
158
158
|
|
|
159
159
|
No setup needed: drag the image file into the terminal, or type its path, and the skill takes over.
|
|
160
160
|
|
|
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)
|
|
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` auto-detects all three harnesses and picks whichever holds this project's newest pasted image, each verified against a real session. 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.
|
|
162
162
|
|
|
163
163
|
## Why a bridge instead of a multimodal model?
|
|
164
164
|
|
package/README.zh-CN.md
CHANGED
|
@@ -113,7 +113,7 @@ modlens -i <图片路径或 URL> [选项]
|
|
|
113
113
|
|
|
114
114
|
截图信息密集或文档难啃,换成 `-m gemini-3.1-pro-high`。输出契约见 [skills/modlens/references/output-schema.md](skills/modlens/references/output-schema.md)。
|
|
115
115
|
|
|
116
|
-
另外两个子命令:`modlens config <init|set|show>` 管 provider 和 key(下文详述),`modlens recover-paste` 抢救粘贴进 Claude Code 的图片:
|
|
116
|
+
另外两个子命令:`modlens config <init|set|show>` 管 provider 和 key(下文详述),`modlens recover-paste` 抢救粘贴进 Claude Code、Pi、OpenCode 的图片:
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
119
|
modlens recover-paste # 捞最新一张,路径以 JSON 打印
|
|
@@ -154,11 +154,11 @@ Codex 只认 Responses API,DeepSeek 官方端点原生支持。先照着[官
|
|
|
154
154
|
- **把图片文件拖进终端**,或者手打路径。路径以纯文本形式落进消息,modlens skill 接着从这里接手。
|
|
155
155
|
- 用 `codex exec -i 图片.png "..."` skill 从这里把路径抠出来。
|
|
156
156
|
|
|
157
|
-
## 在 Claude Code
|
|
157
|
+
## 在 Claude Code、Pi、OpenCode 里用(网关接第三方模型)
|
|
158
158
|
|
|
159
159
|
不用任何配置:把图片文件拖进终端,或手打路径,skill 直接接手。
|
|
160
160
|
|
|
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
|
|
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` 会自动探测三家宿主,挑出持有本项目最新粘贴图的那家,都拿真实会话验证过。一句老实话:会话记录格式是这些工具的内部实现,没有兼容承诺,哪天捞不动了,拖文件永远是保底。
|
|
162
162
|
|
|
163
163
|
## 为什么外挂,而不是换多模态模型?
|
|
164
164
|
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as path from "path";
|
|
|
5
5
|
import { spawn } from "child_process";
|
|
6
6
|
import * as os from "os";
|
|
7
7
|
import * as crypto from "crypto";
|
|
8
|
+
import { createRequire } from "module";
|
|
8
9
|
const CONFIG_DIR = path.join(os.homedir(), ".modlens");
|
|
9
10
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
10
11
|
const ENV_BINDINGS = {
|
|
@@ -850,57 +851,6 @@ const EXT_BY_MIME = {
|
|
|
850
851
|
"image/webp": "webp",
|
|
851
852
|
"image/gif": "gif"
|
|
852
853
|
};
|
|
853
|
-
function claudeProjectSlug(cwd) {
|
|
854
|
-
return path.resolve(cwd).replace(/[/.]/g, "-");
|
|
855
|
-
}
|
|
856
|
-
function piSessionSlug(cwd) {
|
|
857
|
-
const resolved = path.resolve(cwd);
|
|
858
|
-
return `--${resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
859
|
-
}
|
|
860
|
-
const claudeAdapter = {
|
|
861
|
-
name: "claude-code",
|
|
862
|
-
sessionDir: (cwd) => path.join(os.homedir(), ".claude", "projects", claudeProjectSlug(cwd)),
|
|
863
|
-
matchesSession: (fileName, sessionId) => fileName === `${sessionId}.jsonl`,
|
|
864
|
-
extractUserImages: (line) => {
|
|
865
|
-
const message = line.message;
|
|
866
|
-
if (message?.role !== "user" || !Array.isArray(message.content)) {
|
|
867
|
-
return [];
|
|
868
|
-
}
|
|
869
|
-
const images = [];
|
|
870
|
-
for (const block of message.content) {
|
|
871
|
-
const source = block?.source;
|
|
872
|
-
if (block?.type === "image" && source?.type === "base64" && source.data) {
|
|
873
|
-
images.push({ mediaType: source.media_type ?? "image/png", data: source.data });
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
return images;
|
|
877
|
-
}
|
|
878
|
-
};
|
|
879
|
-
const piAdapter = {
|
|
880
|
-
name: "pi",
|
|
881
|
-
sessionDir: (cwd) => path.join(os.homedir(), ".pi", "agent", "sessions", piSessionSlug(cwd)),
|
|
882
|
-
// pi files look like 2026-08-03T14-18-04-595Z_<uuid>.jsonl
|
|
883
|
-
matchesSession: (fileName, sessionId) => fileName.endsWith(`_${sessionId}.jsonl`),
|
|
884
|
-
extractUserImages: (line) => {
|
|
885
|
-
const message = line.message;
|
|
886
|
-
if (message?.role !== "user" || !Array.isArray(message.content)) {
|
|
887
|
-
return [];
|
|
888
|
-
}
|
|
889
|
-
const images = [];
|
|
890
|
-
for (const block of message.content) {
|
|
891
|
-
const typed = block;
|
|
892
|
-
if (typed?.type === "image" && typed.data) {
|
|
893
|
-
images.push({ mediaType: typed.mimeType ?? "image/png", data: typed.data });
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
return images;
|
|
897
|
-
}
|
|
898
|
-
};
|
|
899
|
-
const ADAPTERS = [claudeAdapter, piAdapter];
|
|
900
|
-
function lineTimestamp(line) {
|
|
901
|
-
const ts = line.timestamp;
|
|
902
|
-
return typeof ts === "string" ? ts : null;
|
|
903
|
-
}
|
|
904
854
|
function forEachJsonLine(filePath, visit) {
|
|
905
855
|
let raw;
|
|
906
856
|
try {
|
|
@@ -918,81 +868,259 @@ function forEachJsonLine(filePath, visit) {
|
|
|
918
868
|
}
|
|
919
869
|
}
|
|
920
870
|
}
|
|
921
|
-
function
|
|
871
|
+
function jsonlSource(harness, filePath, extractLine) {
|
|
872
|
+
return {
|
|
873
|
+
harness,
|
|
874
|
+
location: filePath,
|
|
875
|
+
extract: () => {
|
|
876
|
+
const images = [];
|
|
877
|
+
forEachJsonLine(filePath, (line) => {
|
|
878
|
+
images.push(...extractLine(line));
|
|
879
|
+
});
|
|
880
|
+
return images;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function newestJsonlTimestamp(filePath, extractLine) {
|
|
922
885
|
let latest = null;
|
|
923
886
|
forEachJsonLine(filePath, (line) => {
|
|
924
|
-
if (
|
|
887
|
+
if (extractLine(line).length === 0) {
|
|
925
888
|
return;
|
|
926
889
|
}
|
|
927
|
-
const ts =
|
|
928
|
-
|
|
929
|
-
|
|
890
|
+
const ts = line.timestamp;
|
|
891
|
+
const ms = typeof ts === "string" ? Date.parse(ts) : NaN;
|
|
892
|
+
if (Number.isFinite(ms) && (latest === null || ms > latest)) {
|
|
893
|
+
latest = ms;
|
|
930
894
|
}
|
|
931
895
|
});
|
|
932
896
|
return latest;
|
|
933
897
|
}
|
|
934
|
-
function
|
|
898
|
+
function listJsonl(dir) {
|
|
935
899
|
try {
|
|
936
|
-
return fs.readdirSync(
|
|
900
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => path.join(dir, name));
|
|
937
901
|
} catch {
|
|
938
902
|
return [];
|
|
939
903
|
}
|
|
940
904
|
}
|
|
941
|
-
function
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
905
|
+
function jsonlAdapter(options) {
|
|
906
|
+
const { name, dirFor, matchesSession, extractLine } = options;
|
|
907
|
+
return {
|
|
908
|
+
name,
|
|
909
|
+
describe: (cwd) => dirFor(cwd),
|
|
910
|
+
findNewest: (cwd) => {
|
|
911
|
+
let best = null;
|
|
912
|
+
for (const file of listJsonl(dirFor(cwd))) {
|
|
913
|
+
const timestamp = newestJsonlTimestamp(file, extractLine);
|
|
914
|
+
if (timestamp !== null && (!best || timestamp > best.timestamp)) {
|
|
915
|
+
best = { ref: jsonlSource(name, file, extractLine), timestamp };
|
|
916
|
+
}
|
|
948
917
|
}
|
|
918
|
+
return best;
|
|
919
|
+
},
|
|
920
|
+
findSession: (cwd, sessionId) => {
|
|
921
|
+
for (const file of listJsonl(dirFor(cwd))) {
|
|
922
|
+
if (matchesSession(path.basename(file), sessionId)) {
|
|
923
|
+
return jsonlSource(name, file, extractLine);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
function claudeProjectSlug(cwd) {
|
|
931
|
+
return path.resolve(cwd).replace(/[/.]/g, "-");
|
|
932
|
+
}
|
|
933
|
+
function claudeExtractLine(line) {
|
|
934
|
+
const message = line.message;
|
|
935
|
+
if (message?.role !== "user" || !Array.isArray(message.content)) {
|
|
936
|
+
return [];
|
|
937
|
+
}
|
|
938
|
+
const images = [];
|
|
939
|
+
for (const block of message.content) {
|
|
940
|
+
const source = block?.source;
|
|
941
|
+
if (block?.type === "image" && source?.type === "base64" && source.data) {
|
|
942
|
+
images.push({ mediaType: source.media_type ?? "image/png", data: source.data });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return images;
|
|
946
|
+
}
|
|
947
|
+
const claudeAdapter = jsonlAdapter({
|
|
948
|
+
name: "claude-code",
|
|
949
|
+
dirFor: (cwd) => path.join(os.homedir(), ".claude", "projects", claudeProjectSlug(cwd)),
|
|
950
|
+
matchesSession: (fileName, sessionId) => fileName === `${sessionId}.jsonl`,
|
|
951
|
+
extractLine: claudeExtractLine
|
|
952
|
+
});
|
|
953
|
+
function piSessionSlug(cwd) {
|
|
954
|
+
const resolved = path.resolve(cwd);
|
|
955
|
+
return `--${resolved.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
956
|
+
}
|
|
957
|
+
function piExtractLine(line) {
|
|
958
|
+
const message = line.message;
|
|
959
|
+
if (message?.role !== "user" || !Array.isArray(message.content)) {
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
const images = [];
|
|
963
|
+
for (const block of message.content) {
|
|
964
|
+
const typed = block;
|
|
965
|
+
if (typed?.type === "image" && typed.data) {
|
|
966
|
+
images.push({ mediaType: typed.mimeType ?? "image/png", data: typed.data });
|
|
949
967
|
}
|
|
950
968
|
}
|
|
951
|
-
|
|
952
|
-
|
|
969
|
+
return images;
|
|
970
|
+
}
|
|
971
|
+
const piAdapter = jsonlAdapter({
|
|
972
|
+
name: "pi",
|
|
973
|
+
dirFor: (cwd) => path.join(os.homedir(), ".pi", "agent", "sessions", piSessionSlug(cwd)),
|
|
974
|
+
// pi files look like 2026-08-03T14-18-04-595Z_<uuid>.jsonl
|
|
975
|
+
matchesSession: (fileName, sessionId) => fileName.endsWith(`_${sessionId}.jsonl`),
|
|
976
|
+
extractLine: piExtractLine
|
|
977
|
+
});
|
|
978
|
+
function opencodeDbPath() {
|
|
979
|
+
return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
|
|
980
|
+
}
|
|
981
|
+
function opencodeQuery(dbPath, cwd, sessionId) {
|
|
982
|
+
let DatabaseSync;
|
|
983
|
+
try {
|
|
984
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
985
|
+
({ DatabaseSync } = nodeRequire("node:sqlite"));
|
|
986
|
+
} catch {
|
|
953
987
|
throw new Error(
|
|
954
|
-
|
|
988
|
+
"Reading opencode storage needs the node:sqlite module (Node 22.5+). Upgrade Node, or pass --transcript/--session for a JSONL-based harness."
|
|
955
989
|
);
|
|
956
990
|
}
|
|
957
|
-
|
|
958
|
-
|
|
991
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
992
|
+
try {
|
|
993
|
+
const resolved = path.resolve(cwd);
|
|
994
|
+
const sessionFilter = sessionId ? `AND (session.id = ? OR session.slug = ?)` : `AND session.directory = ?`;
|
|
995
|
+
const params = sessionId ? [sessionId, sessionId] : [resolved];
|
|
996
|
+
const rows = db.prepare(
|
|
997
|
+
`SELECT part.data AS data, part.time_created AS time_created
|
|
998
|
+
FROM part
|
|
999
|
+
JOIN message ON message.id = part.message_id
|
|
1000
|
+
JOIN session ON session.id = part.session_id
|
|
1001
|
+
WHERE part.data LIKE '{"type":"file"%'
|
|
1002
|
+
AND json_extract(message.data, '$.role') = 'user'
|
|
1003
|
+
${sessionFilter}
|
|
1004
|
+
ORDER BY part.time_created ASC`
|
|
1005
|
+
).all(...params);
|
|
1006
|
+
return rows;
|
|
1007
|
+
} finally {
|
|
1008
|
+
db.close();
|
|
1009
|
+
}
|
|
959
1010
|
}
|
|
960
|
-
function
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1011
|
+
function opencodeImagesFromRows(rows) {
|
|
1012
|
+
const images = [];
|
|
1013
|
+
for (const row of rows) {
|
|
1014
|
+
try {
|
|
1015
|
+
const part = JSON.parse(row.data);
|
|
1016
|
+
if (part.type !== "file" || !part.mime?.startsWith("image/")) {
|
|
1017
|
+
continue;
|
|
965
1018
|
}
|
|
1019
|
+
const match = /^data:[^;]+;base64,(.+)$/.exec(part.url ?? "");
|
|
1020
|
+
if (match) {
|
|
1021
|
+
images.push({ mediaType: part.mime, data: match[1] });
|
|
1022
|
+
}
|
|
1023
|
+
} catch {
|
|
966
1024
|
}
|
|
967
1025
|
}
|
|
968
|
-
|
|
969
|
-
throw new Error(
|
|
970
|
-
`No transcript for session ${sessionId} under this project (looked in: ${dirs}). Check --cwd, or drop --session to auto-locate by newest pasted image.`
|
|
971
|
-
);
|
|
1026
|
+
return images;
|
|
972
1027
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1028
|
+
const opencodeAdapter = {
|
|
1029
|
+
name: "opencode",
|
|
1030
|
+
describe: () => opencodeDbPath(),
|
|
1031
|
+
findNewest: (cwd) => {
|
|
1032
|
+
const dbPath = opencodeDbPath();
|
|
1033
|
+
if (!fs.existsSync(dbPath)) {
|
|
1034
|
+
return null;
|
|
1035
|
+
}
|
|
1036
|
+
const rows = opencodeQuery(dbPath, cwd);
|
|
1037
|
+
const images = opencodeImagesFromRows(rows);
|
|
1038
|
+
if (images.length === 0) {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
return {
|
|
1042
|
+
ref: {
|
|
1043
|
+
harness: "opencode",
|
|
1044
|
+
location: dbPath,
|
|
1045
|
+
extract: () => opencodeImagesFromRows(opencodeQuery(dbPath, cwd))
|
|
1046
|
+
},
|
|
1047
|
+
timestamp: rows[rows.length - 1].time_created
|
|
1048
|
+
};
|
|
1049
|
+
},
|
|
1050
|
+
findSession: (cwd, sessionId) => {
|
|
1051
|
+
const dbPath = opencodeDbPath();
|
|
1052
|
+
if (!fs.existsSync(dbPath)) {
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
const rows = opencodeQuery(dbPath, cwd, sessionId);
|
|
1056
|
+
if (opencodeImagesFromRows(rows).length === 0) {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
return {
|
|
1060
|
+
harness: "opencode",
|
|
1061
|
+
location: dbPath,
|
|
1062
|
+
extract: () => opencodeImagesFromRows(opencodeQuery(dbPath, cwd, sessionId))
|
|
1063
|
+
};
|
|
976
1064
|
}
|
|
977
|
-
|
|
1065
|
+
};
|
|
1066
|
+
const ADAPTERS = [claudeAdapter, piAdapter, opencodeAdapter];
|
|
1067
|
+
function sourceForExplicitPath(filePath) {
|
|
1068
|
+
if (filePath.endsWith(".db")) {
|
|
1069
|
+
return {
|
|
1070
|
+
harness: "opencode",
|
|
1071
|
+
location: filePath,
|
|
1072
|
+
extract: () => opencodeImagesFromRows(opencodeQuery(filePath, process.cwd()))
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
if (filePath.includes(`${path.sep}.pi${path.sep}`)) {
|
|
1076
|
+
return jsonlSource("pi", filePath, piExtractLine);
|
|
1077
|
+
}
|
|
1078
|
+
return jsonlSource("claude-code", filePath, claudeExtractLine);
|
|
978
1079
|
}
|
|
979
|
-
function
|
|
980
|
-
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1080
|
+
function locateSource(cwd) {
|
|
1081
|
+
let best = null;
|
|
1082
|
+
for (const adapter of ADAPTERS) {
|
|
1083
|
+
let candidate = null;
|
|
1084
|
+
try {
|
|
1085
|
+
candidate = adapter.findNewest(cwd);
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
if (candidate && (!best || candidate.timestamp > best.timestamp)) {
|
|
1089
|
+
best = candidate;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (!best) {
|
|
1093
|
+
const dirs = ADAPTERS.map((a) => a.describe(cwd)).join(" , ");
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
`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.`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
return best.ref;
|
|
1099
|
+
}
|
|
1100
|
+
function sourceForSession(cwd, sessionId) {
|
|
1101
|
+
for (const adapter of ADAPTERS) {
|
|
1102
|
+
try {
|
|
1103
|
+
const ref = adapter.findSession(cwd, sessionId);
|
|
1104
|
+
if (ref) {
|
|
1105
|
+
return ref;
|
|
1106
|
+
}
|
|
1107
|
+
} catch {
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
const dirs = ADAPTERS.map((a) => a.describe(cwd)).join(" , ");
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`No session ${sessionId} with pasted images under this project (looked in: ${dirs}). Check --cwd, or drop --session to auto-locate by newest pasted image.`
|
|
1113
|
+
);
|
|
986
1114
|
}
|
|
987
1115
|
function recoverPastedImages(options = {}) {
|
|
988
1116
|
const cwd = options.cwd ?? process.cwd();
|
|
989
|
-
const
|
|
1117
|
+
const source = options.transcript ? sourceForExplicitPath(options.transcript) : options.session ? sourceForSession(cwd, options.session) : locateSource(cwd);
|
|
990
1118
|
const count = Math.max(1, options.count ?? 1);
|
|
991
1119
|
const outDir = options.outDir ?? path.join(os.tmpdir(), "modlens-paste");
|
|
992
|
-
const all =
|
|
1120
|
+
const all = source.extract();
|
|
993
1121
|
if (all.length === 0) {
|
|
994
1122
|
throw new Error(
|
|
995
|
-
`No pasted images found in ${
|
|
1123
|
+
`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.`
|
|
996
1124
|
);
|
|
997
1125
|
}
|
|
998
1126
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -1005,10 +1133,10 @@ function recoverPastedImages(options = {}) {
|
|
|
1005
1133
|
fs.writeFileSync(filePath, buffer);
|
|
1006
1134
|
return { path: filePath, mediaType: image.mediaType, bytes: buffer.length };
|
|
1007
1135
|
});
|
|
1008
|
-
return { harness:
|
|
1136
|
+
return { harness: source.harness, transcript: source.location, images };
|
|
1009
1137
|
}
|
|
1010
1138
|
const program = new Command();
|
|
1011
|
-
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.
|
|
1139
|
+
program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.6.0");
|
|
1012
1140
|
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) => {
|
|
1013
1141
|
try {
|
|
1014
1142
|
const timeoutMs = Number.parseInt(options.timeout, 10);
|
package/package.json
CHANGED
package/skills/modlens/SKILL.md
CHANGED
|
@@ -67,11 +67,11 @@ Harnesses rarely hand you a clean path. First identify which harness you are in,
|
|
|
67
67
|
|
|
68
68
|
- Extract the `path` value from the tag and run modlens on it. Pasted images live in a temp file Codex already created; a stripped image keeps its path tag next to the placeholder. Do NOT use `recover-paste` here: it reads Claude Code session files, which do not exist for Codex.
|
|
69
69
|
|
|
70
|
-
**Claude Code or
|
|
70
|
+
**Claude Code, Pi, or OpenCode** (no path tag anywhere; the image reads as `[Unsupported Image]`, a bare `[Image #1]`, or an attachment you simply cannot see):
|
|
71
71
|
|
|
72
|
-
-
|
|
73
|
-
- Session targeting: your session id is ${CLAUDE_SESSION_ID}. If that value reads as a UUID, pass it as `--session <uuid>` for exact targeting. If it reads as a literal placeholder, omit `--session`: the command auto-locates by scanning this project's
|
|
74
|
-
- If recovery fails (
|
|
72
|
+
- None of these harnesses writes pasted images to a regular temp file, but all of them persist user messages locally before any gateway strips them: Claude Code and Pi in session JSONL files (`~/.claude/projects/`, `~/.pi/agent/sessions/`), OpenCode in a SQLite database (`~/.local/share/opencode/opencode.db`, read via node:sqlite, needs Node 22.5+). `recover-paste` scans all three and auto-detects which harness owns the newest pasted image for this project. Run `modlens recover-paste` (add `--count <n>` for several images): it recovers the pasted image bytes and prints real file paths as JSON. Feed that path to `modlens -i`.
|
|
73
|
+
- Session targeting: your session id is ${CLAUDE_SESSION_ID}. If that value reads as a UUID, pass it as `--session <uuid>` for exact targeting (on OpenCode, the session id or slug works too). If it reads as a literal placeholder, omit `--session`: the command auto-locates by scanning this project's sessions for the newest pasted-image message, which is the session the user just pasted into, even with concurrent sessions. Run it from the project directory the conversation is happening in.
|
|
74
|
+
- If recovery fails (session storage is each harness's internals and may change), ask the user to drag the image file into the terminal or type its path.
|
|
75
75
|
|
|
76
76
|
**Any other harness, or nothing matches** (no path tag and `recover-paste` reports no transcripts): do not guess. Ask the user for the image file path, or suggest dragging the file into the terminal.
|
|
77
77
|
|