@liustack/modlens 2.4.2 → 2.5.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 CHANGED
@@ -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 (gateway models)
157
+ ## Using it in Claude Code and Pi (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. 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. One honest caveat: the transcript layout is Claude Code internals with no compatibility promise; if recovery ever breaks, dragging the file still works everywhere.
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), and `recover-paste` auto-detects both harnesses, verified live against a real pi 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
@@ -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 里用(网关接第三方模型)
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 看到占位符会自动跑这一步。会话记录本来就是一个会话一个文件。skill 可以通过 `--session` 传入精确会话(Claude Code 从 v2.1.9 起会把 `${CLAUDE_SESSION_ID}` 替换进 skill 文本),不传时按消息时间戳挑「持有最新粘贴图」的那份,两条路都不怕同项目并发多开。一句老实话:会话记录格式是 Claude Code 的内部实现,没有兼容承诺,哪天捞不动了,拖文件永远是保底。
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),`recover-paste` 会自动探测两家宿主,已拿真实 pi 会话验证过。一句老实话:会话记录格式是这些工具的内部实现,没有兼容承诺,哪天捞不动了,拖文件永远是保底。
162
162
 
163
163
  ## 为什么外挂,而不是换多模态模型?
164
164
 
package/dist/main.js CHANGED
@@ -850,123 +850,149 @@ const EXT_BY_MIME = {
850
850
  "image/webp": "webp",
851
851
  "image/gif": "gif"
852
852
  };
853
- function projectSlug(cwd) {
853
+ function claudeProjectSlug(cwd) {
854
854
  return path.resolve(cwd).replace(/[/.]/g, "-");
855
855
  }
856
- function locateTranscript(cwd) {
857
- const dir = path.join(os.homedir(), ".claude", "projects", projectSlug(cwd));
858
- let entries;
859
- try {
860
- entries = fs.readdirSync(dir).filter((name) => name.endsWith(".jsonl"));
861
- } catch {
862
- throw new Error(
863
- `No Claude Code transcripts found for this directory (${dir}). Run from the project the image was pasted in, or pass --transcript <path>.`
864
- );
865
- }
866
- if (entries.length === 0) {
867
- throw new Error(`No transcripts in ${dir}. Pass --transcript <path> to pick one manually.`);
868
- }
869
- let best = null;
870
- for (const name of entries) {
871
- const full = path.join(dir, name);
872
- const timestamp = lastImageTimestamp(full);
873
- if (timestamp && (!best || timestamp > best.timestamp)) {
874
- best = { full, timestamp };
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 [];
875
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;
876
877
  }
877
- if (!best) {
878
- throw new Error(
879
- `No pasted images found in any transcript under ${dir}. The user may not have pasted any, or the transcript format changed; ask for a file path instead.`
880
- );
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;
881
897
  }
882
- return best.full;
898
+ };
899
+ const ADAPTERS = [claudeAdapter, piAdapter];
900
+ function lineTimestamp(line) {
901
+ const ts = line.timestamp;
902
+ return typeof ts === "string" ? ts : null;
883
903
  }
884
- function lastImageTimestamp(transcriptPath) {
904
+ function forEachJsonLine(filePath, visit) {
885
905
  let raw;
886
906
  try {
887
- raw = fs.readFileSync(transcriptPath, "utf-8");
907
+ raw = fs.readFileSync(filePath, "utf-8");
888
908
  } catch {
889
- return null;
909
+ return;
890
910
  }
891
- let latest = null;
892
911
  for (const line of raw.split("\n")) {
893
912
  if (!line.includes('"image"')) {
894
913
  continue;
895
914
  }
896
- let parsed;
897
915
  try {
898
- parsed = JSON.parse(line);
916
+ visit(JSON.parse(line));
899
917
  } catch {
900
- continue;
901
918
  }
902
- const entry = parsed;
903
- if (entry.message?.role !== "user" || !Array.isArray(entry.message.content)) {
904
- continue;
919
+ }
920
+ }
921
+ function newestImageTimestamp(adapter, filePath) {
922
+ let latest = null;
923
+ forEachJsonLine(filePath, (line) => {
924
+ if (adapter.extractUserImages(line).length === 0) {
925
+ return;
905
926
  }
906
- const hasImage = entry.message.content.some(
907
- (block) => block?.type === "image" && block.source?.type === "base64"
908
- );
909
- if (hasImage && entry.timestamp && (!latest || entry.timestamp > latest)) {
910
- latest = entry.timestamp;
927
+ const ts = lineTimestamp(line);
928
+ if (ts && (!latest || ts > latest)) {
929
+ latest = ts;
911
930
  }
912
- }
931
+ });
913
932
  return latest;
914
933
  }
915
- function extractUserImages(transcriptPath) {
916
- let raw;
934
+ function listTranscripts(adapter, cwd) {
917
935
  try {
918
- raw = fs.readFileSync(transcriptPath, "utf-8");
919
- } catch (error) {
920
- throw new Error(`Cannot read transcript ${transcriptPath}: ${error.message}`);
936
+ return fs.readdirSync(adapter.sessionDir(cwd)).filter((name) => name.endsWith(".jsonl")).map((name) => path.join(adapter.sessionDir(cwd), name));
937
+ } catch {
938
+ return [];
921
939
  }
922
- const images = [];
923
- for (const line of raw.split("\n")) {
924
- if (!line.includes('"image"')) {
925
- continue;
926
- }
927
- let parsed;
928
- try {
929
- parsed = JSON.parse(line);
930
- } catch {
931
- continue;
932
- }
933
- const message = parsed.message;
934
- if (message?.role !== "user" || !Array.isArray(message.content)) {
935
- continue;
936
- }
937
- for (const block of message.content) {
938
- const source = block?.source;
939
- if (block?.type === "image" && source?.type === "base64" && source.data) {
940
- images.push({ mediaType: source.media_type ?? "image/png", data: source.data });
940
+ }
941
+ function locateTranscript(cwd) {
942
+ const candidates = [];
943
+ for (const adapter of ADAPTERS) {
944
+ for (const transcript of listTranscripts(adapter, cwd)) {
945
+ const timestamp = newestImageTimestamp(adapter, transcript);
946
+ if (timestamp) {
947
+ candidates.push({ harness: adapter.name, transcript, timestamp });
941
948
  }
942
949
  }
943
950
  }
944
- return images;
951
+ if (candidates.length === 0) {
952
+ const dirs = ADAPTERS.map((a) => a.sessionDir(cwd)).join(" , ");
953
+ throw new Error(
954
+ `No pasted images found in any session transcript for this directory (looked in: ${dirs}). The user may not have pasted any, or the transcript format changed; ask for a file path instead.`
955
+ );
956
+ }
957
+ candidates.sort((a, b) => a.timestamp < b.timestamp ? 1 : -1);
958
+ return { harness: candidates[0].harness, transcript: candidates[0].transcript };
945
959
  }
946
960
  function transcriptForSession(cwd, sessionId) {
947
- const file = path.join(
948
- os.homedir(),
949
- ".claude",
950
- "projects",
951
- projectSlug(cwd),
952
- `${sessionId}.jsonl`
961
+ for (const adapter of ADAPTERS) {
962
+ for (const transcript of listTranscripts(adapter, cwd)) {
963
+ if (adapter.matchesSession(path.basename(transcript), sessionId)) {
964
+ return { harness: adapter.name, transcript };
965
+ }
966
+ }
967
+ }
968
+ const dirs = ADAPTERS.map((a) => a.sessionDir(cwd)).join(" , ");
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.`
953
971
  );
954
- if (!fs.existsSync(file)) {
955
- throw new Error(
956
- `No transcript for session ${sessionId} under this project (${file}). Check --cwd, or drop --session to auto-locate by newest pasted image.`
957
- );
972
+ }
973
+ function adapterFor(transcript) {
974
+ if (transcript.includes(`${path.sep}.pi${path.sep}`)) {
975
+ return piAdapter;
958
976
  }
959
- return file;
977
+ return claudeAdapter;
978
+ }
979
+ function extractUserImages(transcriptPath) {
980
+ const adapter = adapterFor(transcriptPath);
981
+ const images = [];
982
+ forEachJsonLine(transcriptPath, (line) => {
983
+ images.push(...adapter.extractUserImages(line));
984
+ });
985
+ return images;
960
986
  }
961
987
  function recoverPastedImages(options = {}) {
962
988
  const cwd = options.cwd ?? process.cwd();
963
- const transcript = options.transcript ?? (options.session ? transcriptForSession(cwd, options.session) : locateTranscript(cwd));
989
+ const located = options.transcript ? { harness: adapterFor(options.transcript).name, transcript: options.transcript } : options.session ? transcriptForSession(cwd, options.session) : locateTranscript(cwd);
964
990
  const count = Math.max(1, options.count ?? 1);
965
991
  const outDir = options.outDir ?? path.join(os.tmpdir(), "modlens-paste");
966
- const all = extractUserImages(transcript);
992
+ const all = extractUserImages(located.transcript);
967
993
  if (all.length === 0) {
968
994
  throw new Error(
969
- `No pasted images found in ${transcript}. The user may not have pasted any, or the transcript format changed; ask for a file path instead.`
995
+ `No pasted images found in ${located.transcript}. The user may not have pasted any, or the transcript format changed; ask for a file path instead.`
970
996
  );
971
997
  }
972
998
  fs.mkdirSync(outDir, { recursive: true });
@@ -979,10 +1005,10 @@ function recoverPastedImages(options = {}) {
979
1005
  fs.writeFileSync(filePath, buffer);
980
1006
  return { path: filePath, mediaType: image.mediaType, bytes: buffer.length };
981
1007
  });
982
- return { transcript, images };
1008
+ return { harness: located.harness, transcript: located.transcript, images };
983
1009
  }
984
1010
  const program = new Command();
985
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.4.2");
1011
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("2.5.0");
986
1012
  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) => {
987
1013
  try {
988
1014
  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.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,13 +67,13 @@ 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** (no path tag anywhere; the placeholder looks like `[Unsupported Image]` or a bare `[Image #1]`, and `${CLAUDE_SESSION_ID}` below reads as a UUID):
70
+ **Claude Code or Pi** (no path tag anywhere; the placeholder looks like `[Unsupported Image]` or a bare `[Image #1]`):
71
71
 
72
- - Claude Code never writes pasted images to a regular temp file, but it logs them into its local session transcript. 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`.
72
+ - Neither harness writes pasted images to a regular temp file, but both log them into local session transcripts (`~/.claude/projects/` and `~/.pi/agent/sessions/`). `recover-paste` auto-detects which harness owns the newest pasted image. 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
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 transcripts 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
74
  - If recovery fails (transcript format is Claude Code internals and may change), ask the user to drag the image file into the terminal or type its path.
75
75
 
76
- **Any other harness, or nothing matches** (no path tag, `${CLAUDE_SESSION_ID}` still a literal placeholder, no Claude Code transcripts): do not guess and do not run `recover-paste`. Ask the user for the image file path, or suggest dragging the file into the terminal.
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
 
78
78
  ## Workflow
79
79