@liustack/modlens 3.8.0 → 3.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.9.0 - 2026-08-13
4
+
5
+ - **The first plug-in vision plugin for DeepSeek Harness (dsh).** The npm package is now also a dsh bundle: `dsh plugin --profile <name> add @liustack/modlens` is the whole install. It registers a native `read_image` tool (schema in every model request, so there is no trigger heuristic at all) that spawns the modlens CLI shipped in the same package, declares the vision schema as its canonical output contract, and renders evidence text for the model. Phase 2 rides `agent/pre-step`: images pasted or dropped into the dsh Web UI are read automatically and enter the step as modlens evidence blocks, with failed reads degrading to an explanatory note instead of rejecting the step (`autoRead: false` in the plugin row turns this off). The plugin imports no dsh packages (raw JSON-Schema tool registration, node builtins only), which is also the smallest possible surface against developer-preview churn. Verified end to end on a real dsh headless profile: the DeepSeek model called `read_image` and quoted the exact transcription back.
6
+ - **Grok Build joins as the fifth reusable harness.** `reuse.grok` grants the local Grok CLI login as an engine: discovery reads `~/.grok` (OAuth evidence in auth.json, model ids from models_cache.json judged by the builtin vision table), and the route drives headless `grok -p` with `--json-schema` (which accepts this project's schema unmodified; the structuredOutput field carries the conforming answer) and `--allow Read`, following the claude-cli template since headless grok has no image-attach flag. Verified live: an exact OCR read through a real SuperGrok login. The agent region order becomes antigravity, codex, opencode, grok, pi-cli, claude-cli.
7
+
3
8
  ## 3.8.0 - 2026-08-13
4
9
 
5
10
  - **Reuse: per-harness grants for the logins already on your machine.** A signed-in Codex, an OpenCode vision model, or credentials held by pi can now serve as vision engines, each behind one explicit yes recorded as `reuse.<claude|codex|opencode|pi>` (true grants, false means never ask again, absent means never asked; `claude` absent counts as granted since `claude-cli` predates the model, and `reuse.claude false` now removes it from the chain). pi credentials with an exactly-supported api shape (`openai-completions`, `anthropic-messages`) and a printable API key run through the existing inline providers with every guard intact; OAuth logins and other shapes (Responses, Vertex) drive pi itself (`pi -p` with the `@file` attachment), and codex (`codex exec -i`) and opencode (`opencode run -f`) join as agent routes. Reused engines get no priority: one chain, two regions, speed-class placement only, and every reused answer adds a `meta.warnings` line naming whose quota it spent. Keys are fetched at call time, live only in memory, and no subprocess output ever travels into an error message.
package/README.md CHANGED
@@ -32,6 +32,8 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
32
32
 
33
33
  ## Highlights
34
34
 
35
+ **🥇 The first plug-in vision plugin for DeepSeek Harness (dsh):** one command, `dsh plugin --profile web add @liustack/modlens`, and the text-only DeepSeek model behind dsh reads images, as a native `read_image` tool plus automatic reading of pasted images.
36
+
35
37
  **Paste an image and it reads it.** No saving to a file and passing a path first.
36
38
 
37
39
  - **Zero-config start.** Reuses what Claude Code, Codex, OpenCode, or Pi already have set up: the multimodal models on your machine go straight to work. Nothing at all? Antigravity CLI is a free no-key channel, and a free Gemini key brings a read down to 5-10 seconds.
package/README.zh-CN.md CHANGED
@@ -32,6 +32,8 @@ DeepSeek 和 GLM 没有视觉能力,无法进行图片识别。ModLens 借助
32
32
 
33
33
  ## 亮点
34
34
 
35
+ **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `dsh plugin --profile web add @liustack/modlens`,dsh 背后的纯文本 DeepSeek 模型即可读图,原生 `read_image` 工具加粘贴图片自动识别。
36
+
35
37
  **直接粘贴图片识别** 无需先保存成文件再提供路径。
36
38
 
37
39
  - **零配置起手。** 复用 Claude Code、Codex、OpenCode、Pi 已配置,直接复用你本机的其他多模态模型。什么都没有?Antigravity CLI 是免 key 的免费通道,配一个免费 Gemini key 可将识别耗时降至 5 到 10 秒。
@@ -55,6 +57,14 @@ agy # 浏览器完成
55
57
 
56
58
  安装还会盘点本机其他 harness CLI(Codex、OpenCode、Pi)里可触达的视觉能力,并逐个询问是否允许 modlens 复用。获准的登录态与你自己配的引擎平级入池,每次复用都会在结果里标明花的是谁的额度。
57
59
 
60
+ **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
61
+
62
+ ```sh
63
+ dsh plugin --profile web add @liustack/modlens
64
+ ```
65
+
66
+ 装完即有 `read_image` 工具和粘贴图片自动识别。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.md)。
67
+
58
68
  ## 用法
59
69
 
60
70
  装好之后不需要记任何命令。正常聊天,粘贴图片或给出图片路径,提问即可,skill 自动触发:图片交给视觉引擎,答案基于读到的内容返回。
@@ -0,0 +1,4 @@
1
+ # dsh bundle layer: mount the modlens vision plugin (see dsh/index.js).
2
+ - insert:
3
+ - id: modlens
4
+ name: '@liustack/modlens/dsh'
package/dist/main.js CHANGED
@@ -28175,7 +28175,7 @@ function listProviders() {
28175
28175
  return [...new Set(Object.values(PROVIDERS).map((provider) => provider.name))];
28176
28176
  }
28177
28177
  const STRING_FIELDS = ["apiKey", "baseUrl", "model"];
28178
- const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi"];
28178
+ const REUSE_HARNESSES = ["claude", "codex", "opencode", "pi", "grok"];
28179
28179
  const CONFIG_DIR = path.join(os.homedir(), ".modlens");
28180
28180
  const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
28181
28181
  const ENV_BINDINGS = {
@@ -28253,7 +28253,7 @@ function setConfigValue(dottedKey, value, configPath = CONFIG_PATH) {
28253
28253
  const dot = dottedKey.indexOf(".");
28254
28254
  if (dot <= 0 || dot === dottedKey.length - 1) {
28255
28255
  throw new Error(
28256
- `Invalid config key: ${dottedKey}. Use "provider", "reuse.<claude|codex|opencode|pi>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28256
+ `Invalid config key: ${dottedKey}. Use "provider", "reuse.<claude|codex|opencode|pi|grok>", "guards.<denyModels|allowModels|denyWhenUnknown>", or "<provider>.<apiKey|baseUrl|model|extraBody>".`
28257
28257
  );
28258
28258
  }
28259
28259
  const providerName = dottedKey.slice(0, dot);
@@ -28614,6 +28614,41 @@ function probeCodex(env, home) {
28614
28614
  }
28615
28615
  });
28616
28616
  }
28617
+ function probeGrok(env, home) {
28618
+ return timed(() => {
28619
+ const cliPath = findOnPath("grok", env);
28620
+ const base = { harness: "grok", cliFound: cliPath !== null };
28621
+ if (!cliPath) {
28622
+ return { ...base, visionModels: [], source: "none" };
28623
+ }
28624
+ const grokHome = path.join(home, ".grok");
28625
+ let loggedIn = false;
28626
+ try {
28627
+ const auth = readJson(path.join(grokHome, "auth.json"));
28628
+ loggedIn = Object.keys(auth).length > 0;
28629
+ } catch {
28630
+ }
28631
+ try {
28632
+ const cache2 = readJson(path.join(grokHome, "models_cache.json"));
28633
+ const vision = Object.keys(cache2.models ?? {}).filter((id) => isVisionModel(id));
28634
+ return {
28635
+ ...base,
28636
+ cliPath,
28637
+ loggedIn,
28638
+ visionModels: vision.length > 0 ? vision : ["default"],
28639
+ source: "builtin-table"
28640
+ };
28641
+ } catch {
28642
+ return {
28643
+ ...base,
28644
+ cliPath,
28645
+ loggedIn,
28646
+ visionModels: ["default"],
28647
+ source: "builtin-table"
28648
+ };
28649
+ }
28650
+ });
28651
+ }
28617
28652
  function probePi(env, home) {
28618
28653
  return timed(() => {
28619
28654
  const cliPath = findOnPath("pi", env);
@@ -28705,7 +28740,8 @@ function discoverAuto(options = {}) {
28705
28740
  probeClaude(env),
28706
28741
  probeCodex(env, home),
28707
28742
  probeOpencode(env, runCli),
28708
- probePi(env, home)
28743
+ probePi(env, home),
28744
+ probeGrok(env, home)
28709
28745
  ];
28710
28746
  const cachedAt = (/* @__PURE__ */ new Date()).toISOString();
28711
28747
  try {
@@ -28859,6 +28895,67 @@ ${JSON_TEMPLATE_INSTRUCTION}`;
28859
28895
  }
28860
28896
  };
28861
28897
  }
28898
+ function grokCliRoute(modelId) {
28899
+ return {
28900
+ name: "grok-cli",
28901
+ defaultModel: modelId,
28902
+ isolateWorkdir: true,
28903
+ reuseNote: "this read reused the local Grok CLI login and spent that account's quota.",
28904
+ buildInvocation: (options) => {
28905
+ if (options.imageKind === "remote") {
28906
+ throw new Error(
28907
+ "grok-cli route reads local files only. Remote URLs stay on the inline providers."
28908
+ );
28909
+ }
28910
+ const prompt = buildVisionPrompt({
28911
+ imageSource: options.imageSource,
28912
+ imageKind: "local",
28913
+ extraPrompt: options.extraPrompt
28914
+ });
28915
+ const args = [
28916
+ "-p",
28917
+ prompt,
28918
+ "--output-format",
28919
+ "json",
28920
+ "--json-schema",
28921
+ visionResultSchemaJson(),
28922
+ "--allow",
28923
+ "Read"
28924
+ ];
28925
+ const model = options.model || modelId;
28926
+ if (model && model !== "default") {
28927
+ args.push("-m", model);
28928
+ }
28929
+ return {
28930
+ command: options.providerBin || "grok",
28931
+ args,
28932
+ cwd: path.resolve(options.workdir || path.dirname(options.imageSource))
28933
+ };
28934
+ },
28935
+ parseOutput: (stdout) => {
28936
+ const envelope = parseJsonLoose(stdout);
28937
+ if (!envelope || typeof envelope !== "object") {
28938
+ throw new Error(
28939
+ "grok produced no JSON envelope. Check the Grok login (run: grok)."
28940
+ );
28941
+ }
28942
+ const result = envelope.structuredOutput ?? (typeof envelope.text === "string" ? extractJson(envelope.text) : null);
28943
+ if (result === null || result === void 0) {
28944
+ throw new Error(
28945
+ `grok returned no structured output: ${truncate(envelope.text ?? "")}`
28946
+ );
28947
+ }
28948
+ return {
28949
+ result,
28950
+ meta: {
28951
+ conversationId: envelope.sessionId ?? null,
28952
+ durationSeconds: null,
28953
+ usage: envelope.usage ?? null
28954
+ }
28955
+ };
28956
+ }
28957
+ };
28958
+ }
28862
28959
  const PI_API_TARGETS = {
28863
28960
  "openai-completions": "openai",
28864
28961
  "anthropic-messages": "anthropic"
@@ -29028,7 +29125,7 @@ function reuseProviders(kind, config2, options = {}) {
29028
29125
  } catch {
29029
29126
  }
29030
29127
  }
29031
- if (kind === "local" && (grants.codex === true || grants.opencode === true)) {
29128
+ if (kind === "local" && (grants.codex === true || grants.opencode === true || grants.grok === true)) {
29032
29129
  try {
29033
29130
  const discovery = options.discovery ?? discoverAuto({ env, home });
29034
29131
  const codex = discovery.probes.find((probe) => probe.harness === "codex");
@@ -29039,6 +29136,10 @@ function reuseProviders(kind, config2, options = {}) {
29039
29136
  if (grants.opencode === true && opencode?.cliFound && opencode.visionModels[0]) {
29040
29137
  agents.push(opencodeCliRoute(opencode.visionModels[0]));
29041
29138
  }
29139
+ const grok = discovery.probes.find((probe) => probe.harness === "grok");
29140
+ if (grants.grok === true && grok?.cliFound && grok.loggedIn !== false && grok.visionModels[0]) {
29141
+ agents.push(grokCliRoute(grok.visionModels[0]));
29142
+ }
29042
29143
  } catch {
29043
29144
  }
29044
29145
  }
@@ -29164,7 +29265,8 @@ function composeChain(kind, config2, autoOptions) {
29164
29265
  const REUSE_KEY_BY_HARNESS = {
29165
29266
  codex: "codex",
29166
29267
  opencode: "opencode",
29167
- pi: "pi"
29268
+ pi: "pi",
29269
+ grok: "grok"
29168
29270
  };
29169
29271
  function reuseHint(config2, autoOptions) {
29170
29272
  try {
@@ -30458,7 +30560,7 @@ function recoverPastedImages(options = {}) {
30458
30560
  return result;
30459
30561
  }
30460
30562
  const program = new Command();
30461
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.8.0");
30563
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.9.0");
30462
30564
  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").option(
30463
30565
  "--extra-body <json>",
30464
30566
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
package/docs/cli.md CHANGED
@@ -93,6 +93,6 @@ Five providers: `antigravity-cli` (no key), `gemini-api` (fastest free route), `
93
93
  Other subcommands:
94
94
 
95
95
  - `modlens guard [--model <id>]`: should the engine run for the active model at all? Exit 0 allow, 1 deny, verdict as JSON.
96
- - `modlens config <init|set|show>`: keys are `provider`, `reuse.<claude|codex|opencode|pi>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|extraBody>`.
96
+ - `modlens config <init|set|show>`: keys are `provider`, `reuse.<claude|codex|opencode|pi|grok>`, `guards.<denyModels|allowModels|denyWhenUnknown>`, and `<provider>.<apiKey|baseUrl|model|extraBody>`.
97
97
  - `modlens doctor`: Node and node:sqlite, provider readiness, the failover chains for this machine, the detected harness, the guard's rules with a live verdict, and the Reuse section with per-harness grant decisions and discovered vision. Spends no quota; `--json` for a machine-readable report.
98
98
 
@@ -47,3 +47,13 @@ Windows runs the same CI matrix. Detection there skips the process-ancestry pass
47
47
  ## Gateway setups
48
48
 
49
49
  OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the 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`.
50
+
51
+ ## DeepSeek Harness (dsh)
52
+
53
+ dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
54
+
55
+ ```sh
56
+ dsh plugin --profile web add @liustack/modlens
57
+ ```
58
+
59
+ This registers a `read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin touches only `ctx.tools.register` to keep that surface minimal.
package/dsh/index.js ADDED
@@ -0,0 +1,213 @@
1
+ // DeepSeek Harness (dsh) plugin: registers a read_image tool backed by the
2
+ // modlens CLI that ships in this very package. dsh models are text-only, so
3
+ // the tool is the vision bridge; unlike prompt-triggered skills, a registered
4
+ // tool schema reaches the model on every request, so there is no trigger
5
+ // gamble. The engine is spawned from ../dist/main.js inside this package:
6
+ // no PATH lookup, no npx, the plugin and its engine version-lock together.
7
+ //
8
+ // Loaded via the cordis.patch.yml row `@liustack/modlens/dsh` (see the
9
+ // package.json `dsh.bundle` manifest). Providers, reuse grants, and guard
10
+ // rules keep living in ~/.modlens/config.json, shared with every harness.
11
+ import { spawn } from 'node:child_process'
12
+ import { readFileSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const CLI_PATH = fileURLToPath(new URL('../dist/main.js', import.meta.url))
16
+ // Kept in lockstep with src/schema.ts by a repo test; the plugin file cannot
17
+ // import the TS source and stays fully dependency-free (node builtins only).
18
+ const OUTPUT_SCHEMA = JSON.parse(
19
+ readFileSync(new URL('./vision-schema.json', import.meta.url), 'utf8'),
20
+ )
21
+
22
+ const CLI_TIMEOUT_MS = 180_000
23
+
24
+ export const name = 'modlens'
25
+ export const inject = ['tools', 'agents', 'attachments']
26
+
27
+ const MEDIA_EXT = {
28
+ 'image/png': '.png',
29
+ 'image/jpeg': '.jpg',
30
+ 'image/webp': '.webp',
31
+ 'image/gif': '.gif',
32
+ }
33
+
34
+ export function apply(ctx, config = {}) {
35
+ if (config.autoRead !== false) {
36
+ registerAutoRead(ctx)
37
+ }
38
+ // Registered as a raw JSON-Schema tool definition (no dsh package imports:
39
+ // the developer-preview registry accepts these and out-of-tree resolution
40
+ // of @deepseek-ai/dsh-tools is not yet reliable), so this plugin owns its
41
+ // own argument validation inside execute.
42
+ ctx.tools.register({
43
+ name: 'read_image',
44
+ description:
45
+ 'Read an image through the modlens vision bridge. Use whenever a message references an image the current model cannot see: a local file path or an http(s) URL to a screenshot, photo, chart, diagram, or document scan. Returns structured evidence with every word transcribed (ocr.full_text), layout regions in reading order, semantics, and an uncertainty list; quote the evidence instead of guessing. Requires a configured modlens engine (run `npx @liustack/modlens doctor` in a terminal to check).',
46
+ parameters: {
47
+ type: 'object',
48
+ properties: {
49
+ path: {
50
+ type: 'string',
51
+ description: 'Absolute local file path or http(s) URL of the image',
52
+ },
53
+ prompt: {
54
+ type: 'string',
55
+ description: 'Optional extra focus for the reading (e.g. "focus on the axis labels")',
56
+ },
57
+ },
58
+ required: ['path'],
59
+ },
60
+ output: {
61
+ schema: OUTPUT_SCHEMA,
62
+ render: (_args, value) => [{ type: 'text', text: renderEvidence(value) }],
63
+ },
64
+ // The CLI enforces its own deadline; this is the cooperative backstop.
65
+ timeoutMs: CLI_TIMEOUT_MS + 20_000,
66
+ isConcurrencySafe: () => true,
67
+ presentCall: (args) => ({
68
+ card: 'generic',
69
+ title: 'read_image',
70
+ kind: 'read',
71
+ rawInput: args,
72
+ ...(typeof args?.path === 'string' && !/^https?:\/\//i.test(args.path)
73
+ ? { locations: [{ path: args.path }] }
74
+ : {}),
75
+ }),
76
+ async execute(args, exec) {
77
+ if (typeof args?.path !== 'string' || args.path.trim() === '') {
78
+ throw new Error('read_image needs a non-empty string "path".')
79
+ }
80
+ const cliArgs = [CLI_PATH, '-i', args.path, '--timeout', String(CLI_TIMEOUT_MS)]
81
+ if (args.prompt) {
82
+ cliArgs.push('--prompt', args.prompt)
83
+ }
84
+ const { stdout, stderr, code } = await run(process.execPath, cliArgs, exec.signal)
85
+ if (code !== 0) {
86
+ throw new Error(
87
+ `modlens failed (exit ${code}): ${(stderr || stdout).trim().slice(0, 500)}`,
88
+ )
89
+ }
90
+ let parsed
91
+ try {
92
+ parsed = JSON.parse(stdout)
93
+ } catch {
94
+ throw new Error(`modlens produced no JSON: ${stdout.trim().slice(0, 300)}`)
95
+ }
96
+ // The canonical value is the vision result itself; routing details
97
+ // (meta.attempts, whose quota a reused engine spent) stay operational.
98
+ return parsed.result
99
+ },
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Phase 2: paste auto-route. When entered messages carry image blocks (the
105
+ * Web UI's paste/drop intake) and the model behind dsh is text-only, rewrite
106
+ * each image block into a modlens evidence text block before the step starts.
107
+ * Runs after `next()` so downstream pre-step listeners (compaction, context
108
+ * injectors) see and shape the same final message set; a failed read degrades
109
+ * to an explanatory text block instead of rejecting the step.
110
+ */
111
+ function registerAutoRead(ctx) {
112
+ ctx.on('agent/pre-step', async (payload, next) => {
113
+ const decision = await next()
114
+ if (decision.kind !== 'enter') {
115
+ return decision
116
+ }
117
+ const hasImage = decision.messages.some(
118
+ (message) =>
119
+ Array.isArray(message.content) &&
120
+ message.content.some((block) => block?.type === 'image'),
121
+ )
122
+ if (!hasImage) {
123
+ return decision
124
+ }
125
+ const messages = []
126
+ for (const message of decision.messages) {
127
+ if (!Array.isArray(message.content)) {
128
+ messages.push(message)
129
+ continue
130
+ }
131
+ const content = []
132
+ for (const block of message.content) {
133
+ if (block?.type !== 'image') {
134
+ content.push(block)
135
+ continue
136
+ }
137
+ content.push(await readImageBlock(ctx, block, payload.signal))
138
+ }
139
+ messages.push({ ...message, content })
140
+ }
141
+ return { kind: 'enter', messages }
142
+ })
143
+ }
144
+
145
+ async function readImageBlock(ctx, block, signal) {
146
+ const { mkdtemp, rm, writeFile } = await import('node:fs/promises')
147
+ const { tmpdir } = await import('node:os')
148
+ const { join } = await import('node:path')
149
+ let dir
150
+ try {
151
+ const stored = await ctx.attachments.readImage(block.attachment, signal)
152
+ dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-'))
153
+ const file = join(
154
+ dir,
155
+ `paste${MEDIA_EXT[stored.mediaType ?? block.attachment?.mediaType] ?? '.png'}`,
156
+ )
157
+ await writeFile(file, Buffer.from(stored.bytes), { mode: 0o600 })
158
+ const cli = process.env.MODLENS_DSH_CLI || CLI_PATH
159
+ const { stdout, stderr, code } = await run(
160
+ process.execPath,
161
+ [cli, '-i', file, '--timeout', String(CLI_TIMEOUT_MS)],
162
+ signal,
163
+ )
164
+ if (code !== 0) {
165
+ throw new Error((stderr || stdout).trim().slice(0, 300))
166
+ }
167
+ const parsed = JSON.parse(stdout)
168
+ return {
169
+ type: 'text',
170
+ text: `[Pasted image, read by the modlens vision bridge]\n${renderEvidence(parsed.result)}`,
171
+ }
172
+ } catch (error) {
173
+ return {
174
+ type: 'text',
175
+ text: `[A pasted image could not be read by modlens: ${
176
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
177
+ }. Tell the user, and suggest running \`npx @liustack/modlens doctor\`.]`,
178
+ }
179
+ } finally {
180
+ if (dir) {
181
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
182
+ }
183
+ }
184
+ }
185
+
186
+ function run(command, args, signal) {
187
+ return new Promise((resolve, reject) => {
188
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], signal })
189
+ let stdout = ''
190
+ let stderr = ''
191
+ child.stdout.on('data', (chunk) => {
192
+ stdout += chunk
193
+ })
194
+ child.stderr.on('data', (chunk) => {
195
+ stderr += chunk
196
+ })
197
+ child.on('error', reject)
198
+ child.on('close', (code) => resolve({ stdout, stderr, code }))
199
+ })
200
+ }
201
+
202
+ function renderEvidence(value) {
203
+ const lines = [value.summary]
204
+ const text = value.ocr?.full_text?.trim()
205
+ if (text) {
206
+ lines.push('', 'Transcription:', text.length > 4000 ? `${text.slice(0, 4000)}…` : text)
207
+ }
208
+ const uncertainty = value.uncertainty ?? []
209
+ if (uncertainty.length > 0) {
210
+ lines.push('', `Uncertain: ${uncertainty.join('; ')}`)
211
+ }
212
+ return lines.join('\n')
213
+ }
@@ -0,0 +1 @@
1
+ {"type":"object","properties":{"summary":{"type":"string"},"ocr":{"type":"object","properties":{"full_text":{"type":"string"},"lines":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string"},"language":{"type":"string"}},"required":["text"]}}},"required":["full_text","lines"]},"layout":{"type":"object","properties":{"regions":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["title","subtitle","paragraph","list","table","chart","form","code","image","icon","other"]},"reading_order":{"type":"number"},"text":{"type":"string"}},"required":["type","reading_order","text"]}}},"required":["regions"]},"semantics":{"type":"object","properties":{"scene":{"type":"string"},"intent":{"type":"string"},"entities":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string"},"evidence":{"type":"string"}},"required":["name","type"]}},"relations":{"type":"array","items":{"type":"object","properties":{"subject":{"type":"string"},"predicate":{"type":"string"},"object":{"type":"string"}},"required":["subject","predicate","object"]}}},"required":["scene","entities"]},"visual":{"type":"object","properties":{"dominant_colors":{"type":"array","items":{"type":"string"}},"style":{"type":"string"},"notes":{"type":"array","items":{"type":"string"}}}},"uncertainty":{"type":"array","items":{"type":"string"}}},"required":["summary","ocr","layout","semantics","visual","uncertainty"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.8.0",
3
+ "version": "3.9.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,9 @@
26
26
  "skills/modlens/scripts",
27
27
  "skills/modlens/references",
28
28
  "CHANGELOG.md",
29
- "SECURITY.md"
29
+ "SECURITY.md",
30
+ "dsh",
31
+ "cordis.patch.yml"
30
32
  ],
31
33
  "keywords": [
32
34
  "cli",
@@ -64,5 +66,14 @@
64
66
  "typescript": "^5.9.3",
65
67
  "vite": "^6.4.1",
66
68
  "vitest": "^3.2.7"
69
+ },
70
+ "exports": {
71
+ "./dsh": "./dsh/index.js",
72
+ "./package.json": "./package.json"
73
+ },
74
+ "dsh": {
75
+ "bundle": {
76
+ "patch": "./cordis.patch.yml"
77
+ }
67
78
  }
68
- }
79
+ }
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.8.0):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.9.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.8.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.8.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.8.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.9.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.9.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.9.0 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.13+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -62,7 +62,7 @@ Field semantics:
62
62
  - List a model by what actually reaches it, not by what it could see: a multimodal model behind a gateway that strips images still needs modlens, and your session transcript records the model name the gateway reports. `modlens doctor`'s Guard section shows the rules and a live verdict for checking the result.
63
63
  - `denyWhenUnknown` (default `false`) decides what happens when no signal identifies the active model, in either mode: `false` proceeds, `true` denies. The active model is detected from, strongest first: the `MODLENS_MODEL` env var (`none` means "treat as unknown"), the harness's session storage, the `--model` self-report.
64
64
  - Environment variables override the file for these bindings: `GEMINI_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`. Beyond those, modlens reads `MODLENS_HARNESS` (paste-recovery and guard scope), `MODLENS_MODEL` (guard override, see `guards`), and the fingerprints harnesses inject themselves, which pin the guard's storage lookup to the current session: `CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, plus the presence markers harness detection relies on (`CLAUDECODE`, `PI_CODING_AGENT`, `CODEX_SANDBOX`).
65
- - `reuse.<claude|codex|opencode|pi>`: per-harness grants for spending other local logins, written by the onboarding conversation (`references/onboard.md`). `true` lets reads reuse that harness (pi credentials join the inline region with every guard intact; a signed-in Codex, an OpenCode vision model, or pi driven directly join the agent region before `claude-cli`), `false` records a refusal so the user is never re-asked, absent means never asked and nothing runs. `claude` absent counts as granted: `claude-cli` predates this model as a built-in provider, and `reuse.claude false` removes it from the chain (`-p claude-cli` still pins). Reused engines get no priority over the user's own: regions order by speed class only. Every reused answer adds a `meta.warnings` line naming whose quota it spent, and `modlens doctor`'s Reuse section shows each harness's decision plus what discovery found (probe results cache for 6 hours in `~/.modlens/auto-cache.json`; doctor always re-probes). Set with `modlens config set reuse.codex true` (empty clears back to never-asked).
65
+ - `reuse.<claude|codex|opencode|pi|grok>`: per-harness grants for spending other local logins, written by the onboarding conversation (`references/onboard.md`). `true` lets reads reuse that harness (pi credentials join the inline region with every guard intact; a signed-in Codex, an OpenCode vision model, or pi driven directly join the agent region before `claude-cli`), `false` records a refusal so the user is never re-asked, absent means never asked and nothing runs. `claude` absent counts as granted: `claude-cli` predates this model as a built-in provider, and `reuse.claude false` removes it from the chain (`-p claude-cli` still pins). Reused engines get no priority over the user's own: regions order by speed class only. Every reused answer adds a `meta.warnings` line naming whose quota it spent, and `modlens doctor`'s Reuse section shows each harness's decision plus what discovery found (probe results cache for 6 hours in `~/.modlens/auto-cache.json`; doctor always re-probes). Set with `modlens config set reuse.codex true` (empty clears back to never-asked).
66
66
  - Unknown top-level keys and unknown provider names are ignored rather than rejected, so a typo fails quiet: run `modlens doctor` after hand-editing, it shows which file and env values are actually in effect.
67
67
 
68
68
  Hand-editing is fine (keep the file valid JSON and its permissions 0600). `modlens config set` does the same thing with guardrails.
@@ -33,7 +33,7 @@ Consent rules:
33
33
 
34
34
  | The user agreed to | Run |
35
35
  | :-- | :-- |
36
- | Reusing a harness CLI | `modlens config set reuse.<claude\|codex\|opencode\|pi> true` (one per consent) |
36
+ | Reusing a harness CLI | `modlens config set reuse.<claude\|codex\|opencode\|pi\|grok> true` (one per consent) |
37
37
  | A Gemini key they handed over | `modlens config set gemini-api.apiKey <key>` |
38
38
  | An OpenAI-compatible endpoint | `config set openai.baseUrl / openai.apiKey / openai.model` |
39
39
  | Guard rules for their text-only model | `modlens config set guards.allowModels '["<pattern>"]'` (patterns: `references/configure.md`) |
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.8.0
11
+ - Pinned CLI version: 3.9.0
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.8.0'
27
+ $Pinned = '3.9.0'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.8.0"
25
+ PINNED="3.9.0"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"