@liustack/modlens 3.13.0 → 3.15.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/dsh/index.js CHANGED
@@ -43,6 +43,24 @@ export function apply(ctx, config = {}) {
43
43
  if (config.visionProvider !== false) {
44
44
  registerVisionProvider(ctx, config)
45
45
  }
46
+ // Paste-to-path: the browser half (dsh/client.js) intercepts image pastes
47
+ // and POSTs the bytes here; the file lands in a private temp dir and the
48
+ // path text goes into the composer instead of an image attachment. A
49
+ // text-only model then never trips image admission, and the path is the
50
+ // same trigger shape Pi, OpenCode, and Claude Code hand their models.
51
+ // webServer exists only under the web profile, and this cordis has no
52
+ // optional-inject form, so the route rides a scoped ctx.inject: the closure
53
+ // runs when the service appears and never runs where it does not (headless
54
+ // stays untouched, and the plugin itself never waits on it).
55
+ if (config.pasteToPath !== false && typeof ctx.inject === 'function') {
56
+ ctx.inject(['webServer'], (scope) => {
57
+ try {
58
+ registerPasteRoute(scope)
59
+ } catch (error) {
60
+ console.error(`[modlens] paste-to-path route skipped: ${error}`)
61
+ }
62
+ })
63
+ }
46
64
  // Registered as a raw JSON-Schema tool definition (no dsh package imports:
47
65
  // the developer-preview registry accepts these and out-of-tree resolution
48
66
  // of @deepseek-ai/dsh-tools is not yet reliable), so this plugin owns its
@@ -134,21 +152,93 @@ export function apply(ctx, config = {}) {
134
152
  }
135
153
  }
136
154
 
155
+ // Image magic bytes for the paste route: refuse anything that is not a real
156
+ // image before a byte touches disk. Mirrors the CLI's sniffing table.
157
+ const PASTE_SNIFFS = [
158
+ { ext: '.png', test: (b) => b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
159
+ { ext: '.jpg', test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
160
+ { ext: '.gif', test: (b) => b.length >= 6 && b.toString('ascii', 0, 3) === 'GIF' },
161
+ { ext: '.webp', test: (b) => b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' },
162
+ { ext: '.heic', test: (b) => b.length >= 12 && b.toString('ascii', 4, 8) === 'ftyp' },
163
+ ]
164
+ const PASTE_MAX_BYTES = 25 * 1024 * 1024
165
+
166
+ /**
167
+ * POST /modlens/paste: image bytes in, `{ path }` out. Bound to the dsh web
168
+ * server, which listens on loopback by default; the file is private (0600)
169
+ * in a fresh unpredictable temp dir, magic-byte checked and size-capped.
170
+ */
171
+ function registerPasteRoute(ctx) {
172
+ ctx.webServer.register({
173
+ name: 'modlens-paste',
174
+ kind: 'exact',
175
+ path: '/modlens/paste',
176
+ handler: async (req, res) => {
177
+ if (req.method !== 'POST') {
178
+ res.writeHead(405).end()
179
+ return
180
+ }
181
+ try {
182
+ const chunks = []
183
+ let total = 0
184
+ for await (const chunk of req) {
185
+ total += chunk.length
186
+ if (total > PASTE_MAX_BYTES) {
187
+ res.writeHead(413, { 'content-type': 'application/json' })
188
+ res.end(JSON.stringify({ error: `image over the ${PASTE_MAX_BYTES}-byte limit` }))
189
+ req.destroy()
190
+ return
191
+ }
192
+ chunks.push(chunk)
193
+ }
194
+ const buffer = Buffer.concat(chunks)
195
+ const sniff = PASTE_SNIFFS.find((s) => s.test(buffer))
196
+ if (!sniff) {
197
+ res.writeHead(400, { 'content-type': 'application/json' })
198
+ res.end(JSON.stringify({ error: 'not a recognized image (png/jpeg/gif/webp/heic)' }))
199
+ return
200
+ }
201
+ const { mkdtemp, writeFile } = await import('node:fs/promises')
202
+ const { tmpdir } = await import('node:os')
203
+ const { join } = await import('node:path')
204
+ const dir = await mkdtemp(join(tmpdir(), 'modlens-dsh-paste-'))
205
+ const file = join(dir, `paste${sniff.ext}`)
206
+ await writeFile(file, buffer, { mode: 0o600 })
207
+ res.writeHead(200, { 'content-type': 'application/json' })
208
+ res.end(JSON.stringify({ path: file }))
209
+ } catch (error) {
210
+ res.writeHead(500, { 'content-type': 'application/json' })
211
+ res.end(JSON.stringify({ error: String(error && error.message ? error.message : error) }))
212
+ }
213
+ },
214
+ })
215
+ }
216
+
137
217
  /**
138
218
  * Phase 3: the paste unlock. dsh's image admission asks the selected
139
219
  * provider's adapter for inputModalities, and the DeepSeek adapter hardcodes
140
220
  * text-only, so pastes are refused before any plugin hook runs. This wrapper
141
221
  * registers a NEW provider whose model metadata declares image input and
142
222
  * whose stream() is a one-line delegation back to the real route. Pick the
143
- * wrapped model in the model selector, paste, and the pre-step rewrite below
223
+ * wrapped model in the model selector, paste, and the request-time rewrite
144
224
  * turns the image into evidence text before the delegated request goes out;
145
225
  * the upstream serializer's own image rejection stays as the fail-closed
146
226
  * backstop. Guarded feature-detection: if the llm registration surface moved
147
227
  * (developer preview), the plugin quietly stays a read_image-only tool.
228
+ *
229
+ * Two modes (issue #29, design contributed by @zlycode01):
230
+ * - `config.upstream` set: wrap exactly that one route, legacy behavior.
231
+ * - unset: auto-discovery — every registered provider route carrying
232
+ * wrappable text-only family models gets its own `modlens-<provider>`
233
+ * wrapper, so a machine with several subscription packages (opencode-go,
234
+ * zai, ...) wraps them all instead of hand-picking one. A `discover` array
235
+ * of provider ids narrows the set. Routes that register late (llm-pi-ai
236
+ * mounts its routes after settings load) are picked up by re-sweeping on
237
+ * the registry's own `llm/adapters-updated` notification, no polling. The
238
+ * deepseek-official wrap keeps its historical `deepseek-modlens` id, so a
239
+ * selector remembering that provider survives the upgrade.
148
240
  */
149
241
  function registerVisionProvider(ctx, config) {
150
- const upstream = config.upstream || 'deepseek-official'
151
- const providerId = config.providerId || 'deepseek-modlens'
152
242
  // Wrap only the text-only members of these families. Their own vision
153
243
  // models (present or future: deepseek-vl/ocr/janus, glm-4.5v, glm-5v-...)
154
244
  // need no bridge and are excluded by name and by declared modality.
@@ -164,58 +254,148 @@ function registerVisionProvider(ctx, config) {
164
254
  if (typeof ctx.llm?.registerAdapter !== 'function' || typeof ctx.llm?.stream !== 'function') {
165
255
  return
166
256
  }
167
- const withVision = (info) => ({
168
- ...info,
169
- provider: providerId,
170
- inputModalities: ['text', 'image'],
171
- })
172
- try {
173
- ctx.llm.registerAdapter([providerId], {
174
- // Duck-typing LlmAdapter: providerInfo/providerRetryPolicy are base-class
175
- // defaults a plain object must supply itself (their absence is exactly
176
- // the silent registration failure this catch used to swallow).
177
- providerInfo(provider) {
178
- return { id: provider, name: 'DeepSeek (modlens vision)' }
179
- },
180
- providerRetryPolicy() {
181
- return undefined
182
- },
183
- async listModels(_provider, signal) {
184
- try {
185
- const models = await ctx.llm.listModels(upstream, signal)
186
- return models.filter(shouldWrap).map((model) => ({
187
- ...withVision(model),
188
- name: `${model.name ?? model.id} (modlens vision)`,
189
- }))
190
- } catch {
191
- return []
192
- }
193
- },
194
- async resolveModel(_provider, model, signal) {
195
- const info = await ctx.llm.resolveModelInfo(upstream, model, signal)
196
- if (!shouldWrap(info)) {
197
- throw new Error(`model "${model}" is outside the modlens vision wrap scope`)
198
- }
199
- return { ...withVision(info), id: model }
200
- },
201
- stream(options) {
202
- // Convert at request time, not at log time: the durable session log
203
- // keeps the real image blocks (so the UI shows the paste natively),
204
- // and only the wire messages carry evidence text. Cached per
205
- // attachment, since the same history rides every later step.
206
- const self = this
207
- return (async function* () {
208
- const messages = await convertImagesToEvidence(ctx, options.messages, options.signal, self)
209
- yield* ctx.llm.stream({ ...options, provider: upstream, messages })
210
- })()
211
- },
212
- evidenceCache: new Map(),
257
+
258
+ const registerWrapper = (upstream, providerId, displayName) => {
259
+ const withVision = (info) => ({
260
+ ...info,
261
+ provider: providerId,
262
+ inputModalities: ['text', 'image'],
263
+ })
264
+ try {
265
+ ctx.llm.registerAdapter([providerId], {
266
+ // Duck-typing LlmAdapter: providerInfo/providerRetryPolicy are
267
+ // base-class defaults a plain object must supply itself (their
268
+ // absence is exactly the silent registration failure this catch
269
+ // used to swallow).
270
+ providerInfo(provider) {
271
+ return { id: provider, name: displayName }
272
+ },
273
+ providerRetryPolicy() {
274
+ return undefined
275
+ },
276
+ async listModels(_provider, signal) {
277
+ try {
278
+ const models = await ctx.llm.listModels(upstream, signal)
279
+ return models.filter(shouldWrap).map((model) => ({
280
+ ...withVision(model),
281
+ name: `${model.name ?? model.id} (modlens vision)`,
282
+ }))
283
+ } catch {
284
+ return []
285
+ }
286
+ },
287
+ async resolveModel(_provider, model, signal) {
288
+ const info = await ctx.llm.resolveModelInfo(upstream, model, signal)
289
+ if (!shouldWrap(info)) {
290
+ throw new Error(`model "${model}" is outside the modlens vision wrap scope`)
291
+ }
292
+ return { ...withVision(info), id: model }
293
+ },
294
+ stream(options) {
295
+ // Convert at request time, not at log time: the durable session
296
+ // log keeps the real image blocks (so the UI shows the paste
297
+ // natively), and only the wire messages carry evidence text.
298
+ // Cached per attachment, since the same history rides every step.
299
+ const self = this
300
+ return (async function* () {
301
+ const messages = await convertImagesToEvidence(ctx, options.messages, options.signal, self)
302
+ yield* ctx.llm.stream({ ...options, provider: upstream, messages })
303
+ })()
304
+ },
305
+ evidenceCache: new Map(),
306
+ })
307
+ return true
308
+ } catch (error) {
309
+ // A duplicate means a concurrent or earlier registration already won:
310
+ // that is success for the claim, not a reason to retry forever.
311
+ if (/already|duplicate/i.test(String(error))) {
312
+ console.error(`[modlens] vision provider ${providerId} already registered, keeping the existing one`)
313
+ return true
314
+ }
315
+ // A preview-era surface change: degrade to the read_image-only plugin,
316
+ // but say so in the harness log instead of vanishing (a swallowed
317
+ // TypeError here once hid a missing base method).
318
+ console.error(`[modlens] vision provider registration skipped (${providerId}): ${error}`)
319
+ return false
320
+ }
321
+ }
322
+
323
+ if (config.upstream) {
324
+ registerWrapper(
325
+ config.upstream,
326
+ config.providerId || 'deepseek-modlens',
327
+ 'DeepSeek (modlens vision)',
328
+ )
329
+ return
330
+ }
331
+
332
+ // Auto-discovery. `wrapped` guards duplicates across sweeps and the
333
+ // self-nesting case (our own wrappers appear in listProviders too). Two
334
+ // re-entrancy rules matter because registerAdapter itself broadcasts
335
+ // llm/adapters-updated, so every successful wrap re-triggers a sweep:
336
+ // an id is claimed in `wrapped` BEFORE any await (a concurrent sweep must
337
+ // skip it while this one is still probing), and sweeps are serialized on
338
+ // one promise chain so two can never interleave their probes at all.
339
+ const discover = Array.isArray(config.discover) ? new Set(config.discover) : null
340
+ const wrapped = new Set(['deepseek-modlens'])
341
+ const sweepOnce = async () => {
342
+ try {
343
+ await sweepBody()
344
+ } catch (error) {
345
+ // A sweep failure must never become an unhandled rejection inside the
346
+ // host process; the next topology notification simply tries again.
347
+ console.error(`[modlens] vision provider discovery sweep failed: ${error}`)
348
+ }
349
+ }
350
+ const sweepBody = async () => {
351
+ if (typeof ctx.llm.listProviders !== 'function') {
352
+ // Older registry surface: fall back to the single legacy wrap once.
353
+ if (!wrapped.has('__legacy_fallback__')) {
354
+ wrapped.add('__legacy_fallback__')
355
+ registerWrapper('deepseek-official', 'deepseek-modlens', 'DeepSeek (modlens vision)')
356
+ }
357
+ return
358
+ }
359
+ for (const info of ctx.llm.listProviders()) {
360
+ const id = info?.id
361
+ if (!id || wrapped.has(id) || String(id).startsWith('modlens-')) continue
362
+ if (discover && !discover.has(id)) continue
363
+ // Claim before the await: the probe may suspend, and the sweep a
364
+ // registration triggers must not probe the same id concurrently.
365
+ wrapped.add(id)
366
+ let models = []
367
+ try {
368
+ models = await ctx.llm.listModels(id)
369
+ } catch {
370
+ // Unreachable route today; release the claim so a later topology
371
+ // change retries it.
372
+ wrapped.delete(id)
373
+ continue
374
+ }
375
+ if (!models.some(shouldWrap)) {
376
+ // No eligible models yet: release, the route may gain some later.
377
+ wrapped.delete(id)
378
+ continue
379
+ }
380
+ const providerId = id === 'deepseek-official' ? 'deepseek-modlens' : `modlens-${id}`
381
+ const base = info.name ?? id
382
+ if (!registerWrapper(id, providerId, `${base} (modlens vision)`)) {
383
+ wrapped.delete(id)
384
+ }
385
+ }
386
+ }
387
+ // Serialize: a sweep triggered mid-sweep runs after, never interleaved.
388
+ // The first sweep is invoked directly so its synchronous prefix (the
389
+ // legacy fallback, the pre-await claims) completes during apply().
390
+ let sweeping = sweepOnce()
391
+ const sweep = () => {
392
+ sweeping = sweeping.then(sweepOnce, sweepOnce)
393
+ return sweeping
394
+ }
395
+ if (typeof ctx.on === 'function') {
396
+ ctx.on('llm/adapters-updated', () => {
397
+ void sweep()
213
398
  })
214
- } catch (error) {
215
- // DUPLICATE_ADAPTER or a preview-era surface change: degrade to the
216
- // read_image-only plugin, but say so in the harness log instead of
217
- // vanishing (a swallowed TypeError here once hid a missing base method).
218
- console.error(`[modlens] vision provider registration skipped: ${error}`)
219
399
  }
220
400
  }
221
401
 
@@ -444,7 +624,13 @@ async function readImageBlock(ctx, block, signal) {
444
624
 
445
625
  function run(command, args, signal) {
446
626
  return new Promise((resolve, reject) => {
447
- const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], signal })
627
+ const child = spawn(command, args, {
628
+ stdio: ['ignore', 'pipe', 'pipe'],
629
+ signal,
630
+ // In the packaged desktop app process.execPath is the Electron binary;
631
+ // this makes it behave as plain node for the spawned CLI (issue #25).
632
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
633
+ })
448
634
  let stdout = ''
449
635
  let stderr = ''
450
636
  child.stdout.on('data', (chunk) => {
package/package.json CHANGED
@@ -1,80 +1,86 @@
1
1
  {
2
- "name": "@liustack/modlens",
3
- "version": "3.13.0",
4
- "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
- "type": "module",
6
- "bin": {
7
- "modlens": "./dist/main.js"
8
- },
9
- "scripts": {
10
- "dev": "vite build --watch",
11
- "build": "vite build",
12
- "typecheck": "tsc --noEmit",
13
- "test": "vitest run",
14
- "coverage": "vitest run --coverage",
15
- "lint": "biome check src scripts dsh",
16
- "format": "biome check --write src scripts",
17
- "eval": "node evals/run.mjs",
18
- "release": "node scripts/release.mjs",
19
- "prepublishOnly": "pnpm build",
20
- "docs:list": "node scripts/docs-list.js"
21
- },
22
- "files": [
23
- "dist",
24
- "docs",
25
- "skills/modlens/SKILL.md",
26
- "skills/modlens/scripts",
27
- "skills/modlens/references",
28
- "CHANGELOG.md",
29
- "SECURITY.md",
30
- "dsh",
31
- "cordis.patch.yml"
32
- ],
33
- "keywords": [
34
- "cli",
35
- "vision",
36
- "ocr",
37
- "antigravity",
38
- "agent-skill",
39
- "claude-code",
40
- "skill",
41
- "image-to-text",
42
- "multimodal",
43
- "modlens"
44
- ],
45
- "author": "Leon Liu",
46
- "license": "MIT",
47
- "repository": {
48
- "type": "git",
49
- "url": "git+https://github.com/liustack/modlens.git"
50
- },
51
- "bugs": {
52
- "url": "https://github.com/liustack/modlens/issues"
53
- },
54
- "homepage": "https://github.com/liustack/modlens#readme",
55
- "engines": {
56
- "node": ">=22.19"
57
- },
58
- "dependencies": {
59
- "commander": "^13.1.0",
60
- "undici": "^8.10.0"
61
- },
62
- "devDependencies": {
63
- "@biomejs/biome": "^2.5.7",
64
- "@types/node": "^22.19.7",
65
- "@vitest/coverage-v8": "^3.2.7",
66
- "typescript": "^5.9.3",
67
- "vite": "^6.4.1",
68
- "vitest": "^3.2.7"
69
- },
70
- "exports": {
71
- ".": "./dsh/index.js",
72
- "./dsh": "./dsh/index.js",
73
- "./package.json": "./package.json"
74
- },
75
- "dsh": {
76
- "bundle": {
77
- "patch": "./cordis.patch.yml"
2
+ "name": "@liustack/modlens",
3
+ "version": "3.15.0",
4
+ "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "modlens": "./dist/main.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "vite build --watch",
11
+ "build": "vite build",
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "vitest run",
14
+ "coverage": "vitest run --coverage",
15
+ "lint": "biome check src scripts dsh",
16
+ "format": "biome check --write src scripts",
17
+ "eval": "node evals/run.mjs",
18
+ "release": "node scripts/release.mjs",
19
+ "prepublishOnly": "pnpm build",
20
+ "docs:list": "node scripts/docs-list.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "docs",
25
+ "skills/modlens/SKILL.md",
26
+ "skills/modlens/scripts",
27
+ "skills/modlens/references",
28
+ "CHANGELOG.md",
29
+ "SECURITY.md",
30
+ "dsh",
31
+ "cordis.patch.yml"
32
+ ],
33
+ "keywords": [
34
+ "cli",
35
+ "vision",
36
+ "ocr",
37
+ "antigravity",
38
+ "agent-skill",
39
+ "claude-code",
40
+ "skill",
41
+ "image-to-text",
42
+ "multimodal",
43
+ "modlens"
44
+ ],
45
+ "author": "Leon Liu",
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/liustack/modlens.git"
50
+ },
51
+ "bugs": {
52
+ "url": "https://github.com/liustack/modlens/issues"
53
+ },
54
+ "homepage": "https://github.com/liustack/modlens#readme",
55
+ "engines": {
56
+ "node": ">=22.19"
57
+ },
58
+ "dependencies": {
59
+ "commander": "^13.1.0",
60
+ "undici": "^8.10.0"
61
+ },
62
+ "devDependencies": {
63
+ "@biomejs/biome": "^2.5.7",
64
+ "@types/node": "^22.19.7",
65
+ "@vitest/coverage-v8": "^3.2.7",
66
+ "typescript": "^5.9.3",
67
+ "vite": "^6.4.1",
68
+ "vitest": "^3.2.7"
69
+ },
70
+ "exports": {
71
+ ".": "./dsh/index.js",
72
+ "./dsh": "./dsh/index.js",
73
+ "./package.json": "./package.json",
74
+ "./client": "./dsh/client.js"
75
+ },
76
+ "dsh": {
77
+ "bundle": {
78
+ "patch": "./cordis.patch.yml"
79
+ },
80
+ "client": {
81
+ "inject": [],
82
+ "platform": "web",
83
+ "immediately": true
84
+ }
78
85
  }
79
- }
80
86
  }
@@ -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.13.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.15.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.13.0: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.13.0 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.13.0 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.15.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.15.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.15.0 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (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.
@@ -1,5 +1,7 @@
1
1
  # Configuring ModLens
2
2
 
3
+ English | [中文](configure.zh-CN.md)
4
+
3
5
  Read this when the user asks how to set up, configure, or switch ModLens providers. Prefer running the commands for the user over explaining them.
4
6
 
5
7
  ## Where config lives