@liustack/modlens 3.9.1 → 3.10.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.10.0 - 2026-08-14
4
+
5
+ - **Pasting into the dsh Web UI now works end to end, thumbnails included.** The plugin registers a wrapper provider whose model list carries two entries, `DeepSeek-V4-Flash (modlens vision)` and `DeepSeek-V4-Pro (modlens vision)`; select one and pastes are admitted, because dsh's image admission asks the selected provider for input modalities and the wrapper declares image input. Conversion happens at request time on the wire messages only: the durable session log and the UI keep the native image blocks (your paste keeps its thumbnail), each pasted attachment is read once through the modlens CLI and cached for every later step, and the wrapped request is answered by the same DeepSeek route it always used. The wrap covers DeepSeek and GLM text models only; their own vision models (deepseek-vl/ocr, janus, glm v-series) are excluded by name pattern and by declared modality, so a model that can already see is never wrapped. The `agent/pre-step` auto-read from 3.9.0 becomes opt-in (`autoRead: true`) since request-time conversion owns the paste path and preserves the native UX. Registration also stopped failing silently: the adapter now supplies the base-class methods dsh's duck-typing expects (`providerInfo`, `providerRetryPolicy`), whose absence was exactly the invisible failure, and a registration error is logged to the harness console instead of swallowed.
6
+ - **Paste auto-read read a field the attachment store never had ([#17](https://github.com/liustack/modlens/issues/17)).** dsh's `attachments.readImage` returns `{ ref, data }`, but the 3.9.1 plugin read `stored.bytes` and `stored.mediaType`, so every pasted image degraded to a `Buffer.from(undefined)` TypeError block. The plugin now reads the real shape (`data`, with the media type on `ref`), and a missing byte field fails with a named error pointing at the dsh attachment shape instead of a bare TypeError, so the next developer-preview drift stays legible. Thanks to @nico2656-ui for the rc.6 type archaeology and a verified patch.
7
+ - The README leads its demo gallery with the real dsh paste session (recovered from the machine that ran it), states the wrap scope, and lists both variant names verbatim under a rather more visible first-plugin banner.
8
+
3
9
  ## 3.9.1 - 2026-08-13
4
10
 
5
11
  - dsh follow-ups that missed the 3.9.0 tag during a GitHub outage: the plugin row references the bare package name via a root export, so the dsh plugin list shows `modlens` instead of `modlens/dsh`; install lines use `npx -y @deepseek-ai/dsh` (the developer preview has no global binary); and both READMEs state the paste status honestly, since the dsh DeepSeek adapter declares text-only input and Web-UI image admission runs before any plugin hook, `read_image` is the working path today and paste auto-read stays wired for when images can enter.
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  <p align="center"><b>Give a text-only model sight, and just paste the image.</b></p>
8
8
 
9
+ <p align="center">🥇 <b>The FIRST vision plugin for DeepSeek Harness (dsh)</b> 🥇</p>
10
+
9
11
  <p align="center">
10
12
  <a href="./README.zh-CN.md">简体中文</a> ·
11
13
  <a href="docs/troubleshooting.md">Troubleshooting</a> ·
@@ -32,7 +34,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
32
34
 
33
35
  ## Highlights
34
36
 
35
- **🥇 The first plug-in vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`, and the text-only DeepSeek model behind dsh reads images through a native `read_image` tool. (Pasted-image auto-read is wired but currently blocked upstream: the dsh DeepSeek adapter declares text-only, so the Web UI refuses pastes before any plugin runs; reference images by file path for now.)
37
+ **🥇 The first vision plugin for DeepSeek Harness (dsh):** one command, `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`, and the text-only DeepSeek model behind dsh reads images through a native `read_image` tool. For pasted images, switch the model selector to one of the two entries the plugin adds, **`DeepSeek-V4-Flash (modlens vision)`** or **`DeepSeek-V4-Pro (modlens vision)`**: pastes are admitted there, converted to evidence at request time (your message keeps its native thumbnail), and answered by the same DeepSeek route. The wrap covers DeepSeek and GLM text models only; their own vision models are excluded automatically.
36
38
 
37
39
  **Paste an image and it reads it.** No saving to a file and passing a path first.
38
40
 
@@ -65,6 +67,10 @@ Once installed, just chat. Paste an image or drop a path, ask anything, and the
65
67
 
66
68
  Unedited runs, all driving a text-only DeepSeek-V4-Flash.
67
69
 
70
+ The newest one first: pasting a screenshot straight into DeepSeek Harness on the `DeepSeek-V4-Flash (modlens vision)` variant. The paste keeps its native thumbnail, the trajectory shows the image arriving "already transcribed by the modlens vision bridge", and the answer walks the UI element by element.
71
+
72
+ ![Pasting an image straight into DeepSeek Harness, read through the modlens vision plugin](https://raw.githubusercontent.com/liustack/modlens/main/assets/demo-dsh-paste.jpg)
73
+
68
74
  A tweet screenshot in the Codex desktop app. It reads the author, the caption, the photo itself (down to what both people are wearing), the timestamp, and every engagement number: 5.4M views, 1.6K replies, 5.7K reposts, 116K likes.
69
75
 
70
76
  ![Text-only DeepSeek reading a tweet screenshot in full detail via ModLens](https://raw.githubusercontent.com/liustack/modlens/main/assets/demo-codex-app.jpg)
package/README.zh-CN.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  <p align="center"><b>为纯文本模型补上视觉能力,直接粘贴图片就能识别。</b></p>
8
8
 
9
+ <p align="center">🥇 <b>全网第一个支持 DeepSeek Harness(dsh)的视觉插件</b> 🥇</p>
10
+
9
11
  <p align="center">
10
12
  <a href="./README.md">English</a> ·
11
13
  <a href="docs/troubleshooting.md">故障排查</a> ·
@@ -32,7 +34,7 @@ DeepSeek 和 GLM 没有视觉能力,无法进行图片识别。ModLens 借助
32
34
 
33
35
  ## 亮点
34
36
 
35
- **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `read_image` 工具读图。(粘贴自动识别已内置,但目前被上游拦截:dsh 的 DeepSeek 适配器声明纯文本,Web 界面在任何插件运行前就拒绝贴图,暂时请用文件路径指图。)
37
+ **🥇 全网第一个支持 DeepSeek Harness(dsh)的外挂视觉识别插件:**一条命令 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens`,dsh 背后的纯文本 DeepSeek 模型即可通过原生 `read_image` 工具读图。要粘贴识图,把模型选择器切到插件新增的两个条目之一:**`DeepSeek-V4-Flash (modlens vision)`** 或 **`DeepSeek-V4-Pro (modlens vision)`**,贴图放行、发请求时转成证据(你的消息保留原生缩略图)、仍由原 DeepSeek 路由回答。包装只覆盖 DeepSeek 与 GLM 的文本模型,两家自己的视觉型号自动排除。
36
38
 
37
39
  **直接粘贴图片识别** 无需先保存成文件再提供路径。
38
40
 
@@ -63,7 +65,7 @@ agy # 浏览器完成
63
65
  npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens
64
66
  ```
65
67
 
66
- 装完即有 `read_image` 工具(粘贴自动识别待上游放行图片进气后自动生效)。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.md)。
68
+ 装完即有 `read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.md)。
67
69
 
68
70
  ## 用法
69
71
 
@@ -73,6 +75,10 @@ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens
73
75
 
74
76
  以下均为原样记录,驱动的都是纯文本的 DeepSeek-V4-Flash。
75
77
 
78
+ 最新的一条放最前:在 DeepSeek Harness 里选 `DeepSeek-V4-Flash (modlens vision)` 变体直接粘贴截图。粘贴保留原生缩略图,轨迹里可见图片抵达时「已由 modlens 视觉桥转写」,回答逐个元素还原了界面。
79
+
80
+ ![在 DeepSeek Harness 中直接粘贴图片,经 modlens 视觉插件读取](https://raw.githubusercontent.com/liustack/modlens/main/assets/demo-dsh-paste.jpg)
81
+
76
82
  Codex 桌面 App 中识别一张推文截图。作者、配文、照片内容(连两人的穿着都在内)、发帖时间和全部互动数据(540 万浏览、1.6K 回复、5.7K 转发、11.6 万点赞)逐项读出。
77
83
 
78
84
  ![纯文本 DeepSeek 通过 ModLens 读出推文截图的全部细节](https://raw.githubusercontent.com/liustack/modlens/main/assets/demo-codex-app.jpg)
package/dist/main.js CHANGED
@@ -30560,7 +30560,7 @@ function recoverPastedImages(options = {}) {
30560
30560
  return result;
30561
30561
  }
30562
30562
  const program = new Command();
30563
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.9.1");
30563
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.10.0");
30564
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(
30565
30565
  "--extra-body <json>",
30566
30566
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
package/dsh/index.js CHANGED
@@ -22,7 +22,7 @@ const OUTPUT_SCHEMA = JSON.parse(
22
22
  const CLI_TIMEOUT_MS = 180_000
23
23
 
24
24
  export const name = 'modlens'
25
- export const inject = ['tools', 'agents', 'attachments']
25
+ export const inject = ['tools', 'agents', 'attachments', 'llm']
26
26
 
27
27
  const MEDIA_EXT = {
28
28
  'image/png': '.png',
@@ -32,9 +32,15 @@ const MEDIA_EXT = {
32
32
  }
33
33
 
34
34
  export function apply(ctx, config = {}) {
35
- if (config.autoRead !== false) {
35
+ // Off by default since the vision provider converts at request time and
36
+ // keeps the durable log (and the UI thumbnail) intact; turn it on only for
37
+ // setups where images enter through a provider this plugin does not wrap.
38
+ if (config.autoRead === true) {
36
39
  registerAutoRead(ctx)
37
40
  }
41
+ if (config.visionProvider !== false) {
42
+ registerVisionProvider(ctx, config)
43
+ }
38
44
  // Registered as a raw JSON-Schema tool definition (no dsh package imports:
39
45
  // the developer-preview registry accepts these and out-of-tree resolution
40
46
  // of @deepseek-ai/dsh-tools is not yet reliable), so this plugin owns its
@@ -100,6 +106,117 @@ export function apply(ctx, config = {}) {
100
106
  })
101
107
  }
102
108
 
109
+ /**
110
+ * Phase 3: the paste unlock. dsh's image admission asks the selected
111
+ * provider's adapter for inputModalities, and the DeepSeek adapter hardcodes
112
+ * text-only, so pastes are refused before any plugin hook runs. This wrapper
113
+ * registers a NEW provider whose model metadata declares image input and
114
+ * whose stream() is a one-line delegation back to the real route. Pick the
115
+ * wrapped model in the model selector, paste, and the pre-step rewrite below
116
+ * turns the image into evidence text before the delegated request goes out;
117
+ * the upstream serializer's own image rejection stays as the fail-closed
118
+ * backstop. Guarded feature-detection: if the llm registration surface moved
119
+ * (developer preview), the plugin quietly stays a read_image-only tool.
120
+ */
121
+ function registerVisionProvider(ctx, config) {
122
+ const upstream = config.upstream || 'deepseek-official'
123
+ const providerId = config.providerId || 'deepseek-modlens'
124
+ // Wrap only the text-only members of these families. Their own vision
125
+ // models (present or future: deepseek-vl/ocr/janus, glm-4.5v, glm-5v-...)
126
+ // need no bridge and are excluded by name and by declared modality.
127
+ const families = config.families || ['deepseek', 'glm']
128
+ const VISION_ID = /(deepseek-(vl|ocr)|janus|glm-[\d.]*v(\b|-))/i
129
+ const shouldWrap = (info) => {
130
+ const id = String(info?.id ?? '').toLowerCase()
131
+ if (!families.some((family) => id.startsWith(family))) return false
132
+ if (VISION_ID.test(id)) return false
133
+ if (Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')) return false
134
+ return true
135
+ }
136
+ if (typeof ctx.llm?.registerAdapter !== 'function' || typeof ctx.llm?.stream !== 'function') {
137
+ return
138
+ }
139
+ const withVision = (info) => ({
140
+ ...info,
141
+ provider: providerId,
142
+ inputModalities: ['text', 'image'],
143
+ })
144
+ try {
145
+ ctx.llm.registerAdapter([providerId], {
146
+ // Duck-typing LlmAdapter: providerInfo/providerRetryPolicy are base-class
147
+ // defaults a plain object must supply itself (their absence is exactly
148
+ // the silent registration failure this catch used to swallow).
149
+ providerInfo(provider) {
150
+ return { id: provider, name: 'DeepSeek (modlens vision)' }
151
+ },
152
+ providerRetryPolicy() {
153
+ return undefined
154
+ },
155
+ async listModels(_provider, signal) {
156
+ try {
157
+ const models = await ctx.llm.listModels(upstream, signal)
158
+ return models.filter(shouldWrap).map((model) => ({
159
+ ...withVision(model),
160
+ name: `${model.name ?? model.id} (modlens vision)`,
161
+ }))
162
+ } catch {
163
+ return []
164
+ }
165
+ },
166
+ async resolveModel(_provider, model, signal) {
167
+ const info = await ctx.llm.resolveModelInfo(upstream, model, signal)
168
+ if (!shouldWrap(info)) {
169
+ throw new Error(`model "${model}" is outside the modlens vision wrap scope`)
170
+ }
171
+ return { ...withVision(info), id: model }
172
+ },
173
+ stream(options) {
174
+ // Convert at request time, not at log time: the durable session log
175
+ // keeps the real image blocks (so the UI shows the paste natively),
176
+ // and only the wire messages carry evidence text. Cached per
177
+ // attachment, since the same history rides every later step.
178
+ const self = this
179
+ return (async function* () {
180
+ const messages = await convertImagesToEvidence(ctx, options.messages, options.signal, self)
181
+ yield* ctx.llm.stream({ ...options, provider: upstream, messages })
182
+ })()
183
+ },
184
+ evidenceCache: new Map(),
185
+ })
186
+ } catch (error) {
187
+ // DUPLICATE_ADAPTER or a preview-era surface change: degrade to the
188
+ // read_image-only plugin, but say so in the harness log instead of
189
+ // vanishing (a swallowed TypeError here once hid a missing base method).
190
+ console.error(`[modlens] vision provider registration skipped: ${error}`)
191
+ }
192
+ }
193
+
194
+ async function convertImagesToEvidence(ctx, messages, signal, adapter) {
195
+ const out = []
196
+ for (const message of messages) {
197
+ if (!Array.isArray(message.content) || !message.content.some((b) => b?.type === 'image')) {
198
+ out.push(message)
199
+ continue
200
+ }
201
+ const content = []
202
+ for (const block of message.content) {
203
+ if (block?.type !== 'image') {
204
+ content.push(block)
205
+ continue
206
+ }
207
+ const key = JSON.stringify(block.attachment ?? block)
208
+ let text = adapter.evidenceCache.get(key)
209
+ if (text === undefined) {
210
+ text = (await readImageBlock(ctx, block, signal)).text
211
+ adapter.evidenceCache.set(key, text)
212
+ }
213
+ content.push({ type: 'text', text })
214
+ }
215
+ out.push({ ...message, content })
216
+ }
217
+ return out
218
+ }
219
+
103
220
  /**
104
221
  * Phase 2: paste auto-route. When entered messages carry image blocks (the
105
222
  * Web UI's paste/drop intake) and the model behind dsh is text-only, rewrite
@@ -148,13 +265,20 @@ async function readImageBlock(ctx, block, signal) {
148
265
  const { join } = await import('node:path')
149
266
  let dir
150
267
  try {
268
+ // StoredImageAttachment carries { ref, data: Uint8Array }; the media type
269
+ // rides the reference (verified against dsh attachment/src/types.ts).
151
270
  const stored = await ctx.attachments.readImage(block.attachment, signal)
271
+ if (!stored?.data) {
272
+ // Named failure instead of Buffer.from(undefined)'s bare TypeError the
273
+ // next time a developer-preview release moves the field (issue #17).
274
+ throw new Error(
275
+ "attachments.readImage returned no 'data' bytes; the dsh attachment shape may have changed",
276
+ )
277
+ }
152
278
  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 })
279
+ const mediaType = stored.ref?.mediaType ?? block.attachment?.mediaType
280
+ const file = join(dir, `paste${MEDIA_EXT[mediaType] ?? '.png'}`)
281
+ await writeFile(file, Buffer.from(stored.data), { mode: 0o600 })
158
282
  const cli = process.env.MODLENS_DSH_CLI || CLI_PATH
159
283
  const { stdout, stderr, code } = await run(
160
284
  process.execPath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.9.1",
3
+ "version": "3.10.0",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.9.1):
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.10.0):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.9.1: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.9.1 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.9.1 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.10.0: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.10.0 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.10.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.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.9.1
11
+ - Pinned CLI version: 3.10.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.9.1'
27
+ $Pinned = '3.10.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.9.1"
25
+ PINNED="3.10.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"