dsh-vision-router 1.5.0 → 1.5.2

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><strong>Paste an image and it just works — eyes for text-only agents on DeepSeek Harness. Free out of the box, no key, no Python, one command.</strong></p>
8
8
 
9
- <p align="center">DeepSeek keeps thinking; the built-in free vision chain and thirteen deep tools do the seeing. Image turns behave like ordinary tool-calling turns — grounded, measurable, repeatable.</p>
9
+ <p align="center">DeepSeek keeps thinking; the built-in free vision chain and fourteen deep tools do the seeing. Image turns behave like ordinary tool-calling turns — grounded, measurable, repeatable.</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
@@ -15,8 +15,8 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.5.0"><img src="https://img.shields.io/badge/release-v1.5.0-5B4CF0?style=flat-square" alt="Release v1.5.0" /></a>
19
- <a href="tests"><img src="https://img.shields.io/badge/verified-348%20tests-2EA44F?style=flat-square" alt="Verified: 348 tests" /></a>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.5.2"><img src="https://img.shields.io/badge/release-v1.5.2-5B4CF0?style=flat-square" alt="Release v1.5.2" /></a>
19
+ <a href="tests"><img src="https://img.shields.io/badge/verified-355%20tests-2EA44F?style=flat-square" alt="Verified: 355 tests" /></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
21
21
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
22
22
  <img src="https://img.shields.io/badge/runtime-no%20Python-8A2BE2?style=flat-square" alt="No Python" />
@@ -28,9 +28,9 @@
28
28
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **Announcement (v1.5.0)**
31
+ > 📌 **Announcement (v1.5.2)**
32
32
  >
33
- > **v1.5.0:** Adds local Ollama / LM Studio vision and desktop screenshots, with stronger Settings and lifecycle reliability.
33
+ > **v1.5.2:** Fixes DSH Settings keyed-slot loading.
34
34
 
35
35
  <p align="center">
36
36
  <img src="assets/vision-demo.gif" width="640" alt="Demo: paste an image, the agent locates the send button with vision_ground / vision_crop / vision_pixel_diff and answers with coordinates" />
@@ -190,7 +190,7 @@ Any of these channels can join the vision chain as an `httpProviders` entry (key
190
190
  - **Automatic failover with classified errors.** Region blocks, ToS filtering, 402 quota, 429 rate limits, context overflow, network failures — the chain walks providers one by one and only reports after all of them failed, with actionable advice. A 429 immediately advances to the next backend and opens a Retry-After-aware cooldown instead of sleeping inside the request.
191
191
  - **Image memory.** Vision answers are cached by attachment content hash; later text turns substitute the recorded description (marked as untrusted evidence), so DeepSeek genuinely remembers earlier images without re-spending vision calls.
192
192
  - **A verifiable pixel loop.** Reference → `vision_html_screenshot` → `vision_pixel_diff` (ratio + red heatmap + worst-region ranking) → fix → repeat until the mismatch converges. UI restoration becomes measurable instead of eyeballed.
193
- - **Stable tool schema.** All thirteen deep tools are registered from session start by default, avoiding a mid-conversation tool-list expansion that can invalidate long-context KV/prefix caches. `progressiveTools: true` remains an advanced boot-time opt-in; only then does `vision_activate` mount the tools on demand. See [`docs/progressive-tools-cache.md`](docs/progressive-tools-cache.md).
193
+ - **Stable tool schema.** All fourteen deep tools are registered from session start by default, avoiding a mid-conversation tool-list expansion that can invalidate long-context KV/prefix caches. `progressiveTools: true` remains an advanced boot-time opt-in; only then does `vision_activate` mount the tools on demand. See [`docs/progressive-tools-cache.md`](docs/progressive-tools-cache.md).
194
194
  - **Selective proxy.** Only the configured vision provider hosts go through your local proxy; DeepSeek stays direct.
195
195
 
196
196
  ### Pixel loop in practice
@@ -211,18 +211,19 @@ The vision model is **only the eyes**; DeepSeek is **always the brain**. An imag
211
211
 
212
212
  ## Tools
213
213
 
214
- Default `progressiveTools: false`: all thirteen deep tools stay registered from plugin startup, so text and image turns can call them immediately. If you explicitly set `progressiveTools: true` in the profile/composition `cordis.patch.yml`, progressive mode is restored: only `vision_activate` is exposed initially, the full tool set mounts on first use, and the `vision-tools` skill is registered. This is a boot-time switch; restart DSH after changing it. Built on sharp / potrace / tesseract / system Chrome — no Python:
214
+ Default `progressiveTools: false`: all fourteen deep tools stay registered from plugin startup, so text and image turns can call them immediately. If you explicitly set `progressiveTools: true` in the profile/composition `cordis.patch.yml`, progressive mode is restored: only `vision_activate` is exposed initially, the full tool set mounts on first use, and the `vision-tools` skill is registered. This is a boot-time switch; restart DSH after changing it. Built on sharp / potrace / tesseract / system Chrome — no Python:
215
215
 
216
216
  <p align="center">
217
217
  <img src="assets/vision-tools.svg" width="100%" alt="Eleven image-processing tools available in DSH Vision Router." />
218
218
  </p>
219
219
 
220
- The diagram covers the eleven image-processing tools. `vision_present` (durable image delivery) and `vision_bootstrap` (the optional 1+x structured first pass) bring the default deep-tool set to thirteen. Enabling the privacy-gated `vision_screenshot` at boot adds an optional fourteenth tool.
220
+ The diagram covers the eleven image-processing tools. `vision_present` (durable image delivery) and `vision_bootstrap` (the optional 1+x structured first pass) bring the default deep-tool set to fourteen. Enabling the privacy-gated `vision_screenshot` at boot adds an optional fifteenth tool.
221
221
 
222
222
  | Tool | What it does | Artifact |
223
223
  |---|---|---|
224
224
  | `vision_bootstrap` | Optional 1+x structured first visual pass; establishes task-independent evidence before at least one follow-up vision call | — |
225
225
  | `vision_describe` | Image Q&A / multi-image compare / structured-evidence JSON mode (summary + layout regions + entity inventory + verbatim transcription) | — |
226
+ | `vision_materialize` | Copy an authorized attachment into the session workspace and return a filesystem path for local OCR/parser fallbacks; no vision/network call | image copy |
226
227
  | `vision_ground` | Locate a target → **original-pixel box x1/y1/x2/y2** | annotated PNG (optional) |
227
228
  | `vision_detect` | Numbered inventory of every element of a kind (buttons/inputs/links…) with original-pixel boxes | annotated PNG with numbered boxes |
228
229
  | `vision_crop` | Crop and zoom into a pixel box | PNG |
package/README.zh.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center"><strong>图片粘贴即用:给 DeepSeek Harness 的纯文本 Agent 装上“眼睛”——开箱免费、免 Key、无 Python、一条命令安装。</strong></p>
8
8
 
9
- <p align="center">DeepSeek 只负责思考,内置免费视觉链 + 13 个深看工具负责“看”;图片轮次就像普通工具调用一样自然、可定位、可验证。</p>
9
+ <p align="center">DeepSeek 只负责思考,内置免费视觉链 + 14 个深看工具负责“看”;图片轮次就像普通工具调用一样自然、可定位、可验证。</p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
@@ -15,8 +15,8 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.5.0"><img src="https://img.shields.io/badge/release-v1.5.0-5B4CF0?style=flat-square" alt="Release v1.5.0" /></a>
19
- <a href="tests"><img src="https://img.shields.io/badge/verified-348%20tests-2EA44F?style=flat-square" alt="Verified: 348 tests" /></a>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.5.2"><img src="https://img.shields.io/badge/release-v1.5.2-5B4CF0?style=flat-square" alt="Release v1.5.2" /></a>
19
+ <a href="tests"><img src="https://img.shields.io/badge/verified-355%20tests-2EA44F?style=flat-square" alt="Verified: 355 tests" /></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
21
21
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
22
22
  <img src="https://img.shields.io/badge/runtime-no%20Python-8A2BE2?style=flat-square" alt="No Python" />
@@ -28,9 +28,9 @@
28
28
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **公告(v1.5.0)**
31
+ > 📌 **公告(v1.5.2)**
32
32
  >
33
- > **v1.5.0:新增 Ollama / LM Studio 本地视觉与桌面截图,强化设置和生命周期稳定性。**
33
+ > **v1.5.2:修复 DSH 设置页 keyed-slot 加载失败。**
34
34
 
35
35
  <p align="center">
36
36
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -189,7 +189,7 @@ opencode-go + 自动识图 ← 发图片时选这个
189
189
  - **自动降级 + 分类报错。** 地区限制、ToS 风控、402 额度、429 限流、上下文超长、网络故障——链路逐供应商尝试,全部失败才报错并给出可操作的建议。遇到 429 会立即尝试下一后端,并按 Retry-After 开启冷却,不会在单次请求内睡眠等待。
190
190
  - **图片记忆。** 视觉答案按附件内容哈希缓存;后续文字轮用记录的描述替换历史图片(标注为不可信证据),DeepSeek 真正“记得”之前发过的图,且不重复消耗视觉调用。
191
191
  - **可验证的像素闭环。** 参照图 → `vision_html_screenshot` → `vision_pixel_diff`(差异率 + 红色热力图 + 最差区域排行)→ 修复 → 再对比,直到差异收敛。UI 还原从“目测”变成“实测”。
192
- - **稳定工具 schema。** 默认从会话开始就注册完整 13 个深看工具,避免图片轮中途扩展工具列表导致长上下文的 KV / prefix cache 失效。仍保留 `progressiveTools: true` 作为高级启动期 opt-in;开启后才使用 `vision_activate` 按需挂载。详见 [`docs/progressive-tools-cache.md`](docs/progressive-tools-cache.md)。
192
+ - **稳定工具 schema。** 默认从会话开始就注册完整 14 个深看工具,避免图片轮中途扩展工具列表导致长上下文的 KV / prefix cache 失效。仍保留 `progressiveTools: true` 作为高级启动期 opt-in;开启后才使用 `vision_activate` 按需挂载。详见 [`docs/progressive-tools-cache.md`](docs/progressive-tools-cache.md)。
193
193
  - **选择性代理。** 只有配置的视觉供应商域名走本地代理;DeepSeek 保持直连。
194
194
 
195
195
  ### 像素闭环实测
@@ -210,7 +210,7 @@ Agent 仅根据参考图复刻 UI,再用 `vision_pixel_diff` 验证最终结
210
210
 
211
211
  ## 工具
212
212
 
213
- 默认 `progressiveTools: false`:13 个深看工具从插件启动时就保持常驻,文本轮和图片轮都可直接调用。若你在 profile / composition 的 `cordis.patch.yml` 中显式开启 `progressiveTools: true`,才会恢复渐进模式:初始只暴露 `vision_activate`,首次需要时再挂载完整工具,并注册 `vision-tools` 技能。该开关是启动期配置,修改后需重启 DSH。全部工具基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
213
+ 默认 `progressiveTools: false`:14 个深看工具从插件启动时就保持常驻,文本轮和图片轮都可直接调用。若你在 profile / composition 的 `cordis.patch.yml` 中显式开启 `progressiveTools: true`,才会恢复渐进模式:初始只暴露 `vision_activate`,首次需要时再挂载完整工具,并注册 `vision-tools` 技能。该开关是启动期配置,修改后需重启 DSH。全部工具基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
214
214
 
215
215
  <p align="center">
216
216
  <img src="assets/vision-tools-zh.svg" width="100%" alt="DSH Vision Router 的 11 个图像处理工具。" />
@@ -222,6 +222,7 @@ Agent 仅根据参考图复刻 UI,再用 `vision_pixel_diff` 验证最终结
222
222
  |---|---|---|
223
223
  | `vision_bootstrap` | 可选 1+x 结构化首遍视觉识别;先建立任务无关证据底图,再至少进行 1 次后续视觉调用 | — |
224
224
  | `vision_describe` | 看图问答 / 多图对比 / 结构化证据 JSON 模式(摘要 + 布局区域 + 实体清单 + 原文转写) | — |
225
+ | `vision_materialize` | 把已授权附件复制到会话工作区并返回真实文件路径,供本地 OCR/解析器降级使用;不调用视觉模型或网络 | image copy |
225
226
  | `vision_ground` | 定位目标 → **原图像素框 x1/y1/x2/y2** | 标注 PNG(可选) |
226
227
  | `vision_detect` | 盘点某类元素(按钮/输入框/链接…)→ 编号清单 + 原图像素框 | 编号标注 PNG |
227
228
  | `vision_crop` | 按像素框裁剪放大 | PNG |
package/index.js CHANGED
@@ -4947,7 +4947,8 @@ export function apply(ctx, config = {}) {
4947
4947
  'question — rephrasing cannot fix an auth, rate-limit or outage problem. Answer from the information ' +
4948
4948
  'you already have and continue the text task, telling the user vision is temporarily unavailable. ' +
4949
4949
  'Only content-level uncertainty in a SUCCESSFUL answer justifies a second look (vision_crop, ' +
4950
- 'vision_ground or another vision_describe).',
4950
+ 'vision_ground or another vision_describe). If infrastructure failure leaves a file_path-only OCR/parser as the fallback, ' +
4951
+ 'use vision_materialize on the uploaded attachment id; never guess a same-named local file or private attachment-store path.',
4951
4952
  parameters: {
4952
4953
  type: 'object',
4953
4954
  properties: {
@@ -4997,6 +4998,12 @@ export function apply(ctx, config = {}) {
4997
4998
  if (paths.length + attachmentIds.length === 0 || paths.length + attachmentIds.length > 4) {
4998
4999
  throw new Error('vision_describe: provide 1-4 images via paths and/or attachmentIds')
4999
5000
  }
5001
+ // Preserve only durable upload ids for a deterministic offline fallback.
5002
+ // Never expose or guess the attachment store's private filesystem path.
5003
+ const materializableAttachmentIds = [...new Set([
5004
+ ...attachmentIds.map((id) => String(id)).filter((id) => isAttachmentIdInput(id)),
5005
+ ...paths.map((item) => String(item)).filter((item) => isAttachmentIdInput(item)),
5006
+ ])]
5000
5007
 
5001
5008
  for (const path of paths) {
5002
5009
  let bytes
@@ -5363,11 +5370,18 @@ export function apply(ctx, config = {}) {
5363
5370
  attempted.length > 0 ? attempted : errors.map((text) => ({ backend: 'configured', kind: 'NO_ADAPTER', error: text })),
5364
5371
  reason,
5365
5372
  )
5366
- return JSON.stringify(
5367
- attempted.length > 0
5368
- ? failure
5369
- : { ...failure, code: VISION_RESULT_CODES.UNSUPPORTED_BACKEND },
5370
- )
5373
+ const baseFailure = attempted.length > 0
5374
+ ? failure
5375
+ : { ...failure, code: VISION_RESULT_CODES.UNSUPPORTED_BACKEND }
5376
+ if (materializableAttachmentIds.length > 0) {
5377
+ baseFailure.degradedAccess = {
5378
+ tool: 'vision_materialize',
5379
+ attachmentIds: materializableAttachmentIds,
5380
+ advice:
5381
+ 'If another local/OCR tool requires a filesystem path, call vision_materialize for the uploaded attachment id. Do not guess a filename or the attachment store path.',
5382
+ }
5383
+ }
5384
+ return JSON.stringify(baseFailure)
5371
5385
  },
5372
5386
  }
5373
5387
  deepToolDefs.push(visionDescribeTool)
@@ -5539,6 +5553,44 @@ export function apply(ctx, config = {}) {
5539
5553
  render: (_args, value) => [{ type: 'text', text: value }],
5540
5554
  }
5541
5555
 
5556
+ // issue #153: materialize an authorized attachment into the workspace for
5557
+ // file_path-only local parsers without coupling them to DSH storage internals.
5558
+ deepToolDefs.push({
5559
+ name: 'vision_materialize',
5560
+ description:
5561
+ 'Copy an uploaded image attachment (sha256:...) or readable local image into the session workspace and return a real filesystem path. ' +
5562
+ 'This tool performs NO vision model/network call. Use it after vision_describe/vision_bootstrap returns ok:false when a local OCR/parser accepts only file_path. ' +
5563
+ 'Never guess the attachment store path or search for a same-named file.',
5564
+ parameters: {
5565
+ type: 'object',
5566
+ properties: {
5567
+ image: { type: 'string', description: 'Uploaded image attachment id (recommended, e.g. sha256:...) or a readable local image path' },
5568
+ },
5569
+ required: ['image'],
5570
+ additionalProperties: false,
5571
+ },
5572
+ output: stringOutput,
5573
+ async execute(args, exec) {
5574
+ const source = String(args.image ?? '')
5575
+ const { bytes, mediaType } = await readImageBytes(exec, source)
5576
+ const extension = mediaType === 'image/jpeg'
5577
+ ? 'jpg'
5578
+ : mediaType === 'image/webp'
5579
+ ? 'webp'
5580
+ : mediaType === 'image/gif'
5581
+ ? 'gif'
5582
+ : 'png'
5583
+ const target = await saveArtifact(exec, `${artifactStem(source, 'materialized')}.${extension}`, bytes)
5584
+ return JSON.stringify({
5585
+ path: target,
5586
+ mediaType,
5587
+ bytes: bytes.length,
5588
+ ...(isAttachmentIdInput(source) ? { source } : {}),
5589
+ safeWorkspaceCopy: true,
5590
+ })
5591
+ },
5592
+ })
5593
+
5542
5594
  const visionPresentOutput = {
5543
5595
  schema: {
5544
5596
  type: 'object',
@@ -6763,7 +6815,7 @@ export function apply(ctx, config = {}) {
6763
6815
  }
6764
6816
  return (
6765
6817
  '视觉深看工具已挂载:vision_bootstrap(结构化预识别)、vision_describe(看图问答)、vision_ground(像素定位)、vision_detect(元素清单)、' +
6766
- 'vision_crop(裁剪放大)、vision_pixel_diff(像素对比验证)、vision_colors(取色)、' +
6818
+ 'vision_materialize(附件落盘)、vision_crop(裁剪放大)、vision_pixel_diff(像素对比验证)、vision_colors(取色)、' +
6767
6819
  'vision_ocr(文字识别)、vision_trace(SVG 矢量化)、vision_extract_foreground(抠图)、' +
6768
6820
  'vision_html_screenshot(页面截图)。现在可以直接调用已启用的工具。' +
6769
6821
  '注意:vision_ocr 只用于读取图片文字;视觉工具返回 ok:false 后端不可用结果时,不要改问法重复调用,继续文本任务。'
@@ -6773,7 +6825,7 @@ export function apply(ctx, config = {}) {
6773
6825
  ctx.tools.register({
6774
6826
  name: 'vision_activate',
6775
6827
  description:
6776
- 'Mount the deep vision tools (vision_bootstrap / vision_describe / vision_ground / vision_detect / vision_crop / ' +
6828
+ 'Mount the deep vision tools (vision_bootstrap / vision_describe / vision_ground / vision_detect / vision_materialize / vision_crop / ' +
6777
6829
  'vision_pixel_diff / vision_colors / vision_ocr / vision_trace / ' +
6778
6830
  'vision_extract_foreground / vision_present / vision_html_screenshot) for this session. Desktop screenshot remains disabled until the user explicitly opts in through Vision Router settings. They mount ' +
6779
6831
  'automatically on image turns; call this only when you need them on a text-only turn.',
package/lib/client.js CHANGED
@@ -163,6 +163,8 @@ window.__ModuleLoader__.load({
163
163
  updateCurrent: '已是最新版本 v{current}',
164
164
  updateAhead: '当前 v{current} 高于 registry 最新 v{latest};可能是源码或预发布构建,不会建议降级。',
165
165
  updateFailed: '更新检查失败:{error}',
166
+ updateNoDiagnostic: '更新检查接口未返回可诊断的错误详情',
167
+ updateInvalidResponse: '更新检查接口返回了无效响应',
166
168
  updateInstallHint: '已安全识别当前 DSH CLI,可直接用这套 DSH 更新插件;完成后需要重启 DSH 才会加载新版本。',
167
169
  updateAutoUnavailable: '当前 DSH CLI 无法被安全识别,因此不执行自动更新。请沿用你原来安装 DSH / 插件的方式手动更新。',
168
170
  updateNow: '一键更新到 v{latest}',
@@ -173,10 +175,12 @@ window.__ModuleLoader__.load({
173
175
  updateActionFailed: '一键更新失败:{error}',
174
176
  updateReleaseNotes: '查看更新说明',
175
177
  updateRegistryFallback: '当前配置的 registry 不可用,已自动改用 npm 官方源完成检查。',
178
+ updateReleaseFallback: 'npm registry 检查失败,已通过 GitHub Releases 获取最新版本号;安装仍需 npm registry 可访问。',
179
+ updateManualUnknownTarget: '无法确认最新版本,因此不会生成 @latest 或普通 update 命令。请先打开 Releases 确认最新版本号,再把下方 <version> 替换为该版本。',
176
180
  updateManualTitle: '手动更新',
177
181
  updateManualSource: '源码仓库 / pnpm DSH:',
178
182
  updateManualNpx: '普通 npm / npx DSH:',
179
- updateManualAgeHint: '若执行后版本仍未变化:pnpm 11 默认在新版本发布 24 小时内静默拦截更新(minimumReleaseAge 策略,但命令仍显示成功)。请使用上方带版本号的安装命令,或运行 npx dsh-vision-router repair 清理过期的版本钉住豁免后重试。',
183
+ updateManualAgeHint: '上方只会使用已确认的具体版本号;若无法确认版本,则只提供 <version> 模板。pnpm 11 默认会在新版本发布 24 小时内静默拦截 @latest / 普通 update(minimumReleaseAge 策略,但命令仍可能显示成功),所以这里不再推荐这两种模糊更新方式。',
180
184
  updateProject: '项目主页',
181
185
  updateReleases: 'Releases',
182
186
  save: '保存',
@@ -398,6 +402,8 @@ window.__ModuleLoader__.load({
398
402
  updateCurrent: 'Up to date: v{current}',
399
403
  updateAhead: 'Current v{current} is ahead of registry v{latest}; this may be a source or prerelease build, so no downgrade is suggested.',
400
404
  updateFailed: 'Update check failed: {error}',
405
+ updateNoDiagnostic: 'The update-check endpoint returned no diagnostic error details',
406
+ updateInvalidResponse: 'The update-check endpoint returned an invalid response',
401
407
  updateInstallHint: 'The current DSH CLI was verified, so Vision Router can update through this same DSH installation. Restart DSH after the update to load the new plugin bundle.',
402
408
  updateAutoUnavailable: 'The current DSH CLI could not be verified safely, so automatic update is disabled. Update through the same DSH/plugin installation path you originally used.',
403
409
  updateNow: 'Update to v{latest}',
@@ -408,10 +414,12 @@ window.__ModuleLoader__.load({
408
414
  updateActionFailed: 'One-click update failed: {error}',
409
415
  updateReleaseNotes: 'View release notes',
410
416
  updateRegistryFallback: 'The configured registry failed; the check succeeded through the official npm registry.',
417
+ updateReleaseFallback: 'npm registry checks failed, so the latest version was resolved from GitHub Releases; installation still requires an accessible npm registry.',
418
+ updateManualUnknownTarget: 'The latest version could not be confirmed, so no @latest or plain update command is generated. Open Releases first, confirm the newest version, then replace <version> in the template below.',
411
419
  updateManualTitle: 'Manual update',
412
420
  updateManualSource: 'DSH source checkout / pnpm:',
413
421
  updateManualNpx: 'Normal npm / npx DSH:',
414
- updateManualAgeHint: 'If the version still has not changed after running this: pnpm 11 silently withholds releases younger than 24h (minimumReleaseAge policy, default 1440 minutes) while the command still reports success. Use the versioned install command above, or run npx dsh-vision-router repair to clean up a stale version-pinned exemption, then retry.',
422
+ updateManualAgeHint: 'The command above only uses a confirmed exact version; if no version can be confirmed, the UI shows a <version> template instead. pnpm 11 can silently withhold @latest / plain update for releases younger than 24h (minimumReleaseAge) even while reporting success, so those ambiguous update forms are no longer recommended here.',
415
423
  updateProject: 'Project',
416
424
  updateReleases: 'Releases',
417
425
  save: 'Save',
@@ -1026,6 +1034,12 @@ window.__ModuleLoader__.load({
1026
1034
  let settingsPersistence
1027
1035
  function installSettingsPersistence(scope) {
1028
1036
  const pending = new Map()
1037
+ // Browser-side hidden settings (onboardingSeen / visionGuideStep) are
1038
+ // best-effort durability hints, not a retry queue. Remember the last
1039
+ // mutation issued for each field for this page lifetime so a rejected
1040
+ // Host write or a stale/oscillating snapshot can never create an
1041
+ // endless /api/settings.mutate loop (issue #155).
1042
+ const issued = new Map()
1029
1043
  let flushing = false
1030
1044
  const readSnapshot = () => {
1031
1045
  try {
@@ -1038,14 +1052,32 @@ window.__ModuleLoader__.load({
1038
1052
  const snapshot = readSnapshot()
1039
1053
  return snapshot && snapshot.value ? snapshot.value : undefined
1040
1054
  }
1055
+ const sameMutation = (left, operation, value) =>
1056
+ !!left && left.operation === operation &&
1057
+ (operation === 'unset' || jsonValueEqual(left.value, value))
1058
+ const alreadyStored = (field, operation, value) => {
1059
+ const snapshot = readSnapshot()
1060
+ const user = snapshot && snapshot.user
1061
+ const stored = !!user && typeof user === 'object' && Object.prototype.hasOwnProperty.call(user, field)
1062
+ return operation === 'unset'
1063
+ ? !stored
1064
+ : stored && jsonValueEqual(user[field], value)
1065
+ }
1041
1066
  const queue = (field, operation, value) => {
1042
- const previous = pending.get(field)
1043
- if (
1044
- previous && previous.operation === operation &&
1045
- (operation === 'unset' || jsonValueEqual(previous.value, value))
1046
- ) {
1067
+ // No network mutation when the raw user layer already has the exact
1068
+ // requested state. This also makes repeated finish/dismiss handlers
1069
+ // idempotent after a successful write.
1070
+ if (alreadyStored(field, operation, value)) {
1071
+ pending.delete(field)
1072
+ issued.set(field, { operation, value })
1047
1073
  return
1048
1074
  }
1075
+ const previous = pending.get(field)
1076
+ if (sameMutation(previous, operation, value)) return
1077
+ // Once an identical hidden-state mutation was attempted in this page,
1078
+ // never auto-retry it merely because subscribe/readback fired again.
1079
+ // A genuinely different value/operation is still allowed through.
1080
+ if (sameMutation(issued.get(field), operation, value)) return
1049
1081
  pending.set(field, { operation, value, attempted: false })
1050
1082
  void flush()
1051
1083
  }
@@ -1068,6 +1100,7 @@ window.__ModuleLoader__.load({
1068
1100
  for (const [field, entry] of work) {
1069
1101
  if (pending.get(field) !== entry) continue
1070
1102
  entry.attempted = true
1103
+ issued.set(field, { operation: entry.operation, value: entry.value })
1071
1104
  try {
1072
1105
  if (entry.operation === 'unset') {
1073
1106
  if (typeof scope.unset !== 'function') continue
@@ -1083,6 +1116,10 @@ window.__ModuleLoader__.load({
1083
1116
  }
1084
1117
  } finally {
1085
1118
  flushing = false
1119
+ // A different value may have been queued while the previous write
1120
+ // was in flight. Drain it once; identical mutations were filtered by
1121
+ // `issued`, so this cannot become a subscribe-driven busy loop.
1122
+ if ([...pending.values()].some((entry) => !entry.attempted)) void flush()
1086
1123
  }
1087
1124
  }
1088
1125
  settingsPersistence = {
@@ -1109,6 +1146,7 @@ window.__ModuleLoader__.load({
1109
1146
  },
1110
1147
  }
1111
1148
  void flush()
1149
+ return settingsPersistence
1112
1150
  }
1113
1151
 
1114
1152
  function readOnboardingSeen() {
@@ -2344,6 +2382,14 @@ window.__ModuleLoader__.load({
2344
2382
  }).catch(() => {})
2345
2383
  }
2346
2384
 
2385
+ const diagnosticError = (value, fallback) => {
2386
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
2387
+ if (value && typeof value.message === 'string' && value.message.trim() !== '') {
2388
+ return value.message.trim()
2389
+ }
2390
+ return fallback
2391
+ }
2392
+
2347
2393
  const runUpdateCheck = async (force = false) => {
2348
2394
  if (updateState.status === 'running') return
2349
2395
  setUpdateState({ status: 'running', result: updateState.result })
@@ -2354,7 +2400,10 @@ window.__ModuleLoader__.load({
2354
2400
  )
2355
2401
  const result = await response.json().catch(() => undefined)
2356
2402
  if (!response.ok) {
2357
- throw new Error(result && result.error ? result.error : `HTTP ${response.status}`)
2403
+ throw new Error(diagnosticError(result && result.error, `HTTP ${response.status}`))
2404
+ }
2405
+ if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
2406
+ throw new Error(t('updateInvalidResponse'))
2358
2407
  }
2359
2408
  setUpdateState({ status: 'done', result })
2360
2409
  } catch (error) {
@@ -2383,7 +2432,10 @@ window.__ModuleLoader__.load({
2383
2432
  })
2384
2433
  const result = await response.json().catch(() => undefined)
2385
2434
  if (!response.ok) {
2386
- throw new Error(result && result.error ? result.error : `HTTP ${response.status}`)
2435
+ throw new Error(diagnosticError(result && result.error, `HTTP ${response.status}`))
2436
+ }
2437
+ if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
2438
+ throw new Error(t('updateInvalidResponse'))
2387
2439
  }
2388
2440
  setSelfUpdateState({ status: 'done', result })
2389
2441
  } catch (error) {
@@ -2955,13 +3007,18 @@ window.__ModuleLoader__.load({
2955
3007
  result && typeof result.releasesUrl === 'string' && result.releasesUrl
2956
3008
  ? result.releasesUrl
2957
3009
  : projectUrl + '/releases/latest'
2958
- // When a newer version is known, the manual commands install it
2959
- // explicitly: plain `update` can be silently withheld by pnpm 11's
2960
- // minimumReleaseAge policy while still reporting success.
2961
- const manualAction =
2962
- result && typeof result.latestVersion === 'string' && result.latestVersion
2963
- ? 'add dsh-vision-router@' + result.latestVersion
2964
- : 'update dsh-vision-router'
3010
+ // Manual recovery is executable only when an exact target was
3011
+ // confirmed by npm metadata or GitHub Releases. If every version source
3012
+ // is unavailable, show a non-executable <version> template instead of
3013
+ // falling back to @latest / plain update, both of which pnpm 11 may
3014
+ // silently withhold while still reporting success.
3015
+ const manualVersion =
3016
+ result && typeof result.latestVersion === 'string' && result.latestVersion.trim()
3017
+ ? result.latestVersion.trim()
3018
+ : ''
3019
+ const manualTargetKnown = manualVersion !== ''
3020
+ const manualPackageSpec = 'dsh-vision-router@' + (manualTargetKnown ? manualVersion : '<version>')
3021
+ const manualAction = 'add ' + manualPackageSpec
2965
3022
  const pnpmCommand = 'pnpm dsh plugin --profile ' + profile + ' ' + manualAction
2966
3023
  const npxCommand = 'npx @deepseek-ai/dsh plugin --profile ' + profile + ' ' + manualAction
2967
3024
  let status
@@ -2978,7 +3035,7 @@ window.__ModuleLoader__.load({
2978
3035
  }
2979
3036
  } else if (updateState.status === 'done') {
2980
3037
  failedUpdate = true
2981
- status = t('updateFailed', { error: result && result.error ? result.error : 'unknown' })
3038
+ status = t('updateFailed', { error: diagnosticError(result && result.error, t('updateNoDiagnostic')) })
2982
3039
  }
2983
3040
  let selfUpdateStatus
2984
3041
  let selfUpdateFailed = false
@@ -2998,7 +3055,7 @@ window.__ModuleLoader__.load({
2998
3055
  } else if (selfUpdateState.status === 'error') {
2999
3056
  selfUpdateFailed = true
3000
3057
  selfUpdateStatus = t('updateActionFailed', {
3001
- error: selfUpdateState.result && selfUpdateState.result.error ? selfUpdateState.result.error : 'unknown',
3058
+ error: diagnosticError(selfUpdateState.result && selfUpdateState.result.error, t('updateNoDiagnostic')),
3002
3059
  })
3003
3060
  }
3004
3061
  const showManualHelp =
@@ -3013,6 +3070,9 @@ window.__ModuleLoader__.load({
3013
3070
  const manualHelp = showManualHelp
3014
3071
  ? h('div', { className: 'vr-update-manual' },
3015
3072
  h('div', { className: 'vr-update-manual-title' }, t('updateManualTitle')),
3073
+ !manualTargetKnown
3074
+ ? h('p', { className: 'vr-update-note' }, t('updateManualUnknownTarget'))
3075
+ : null,
3016
3076
  auto && auto.reason === 'source-cli-needs-loader'
3017
3077
  ? commandBlock(t('updateManualSource'), pnpmCommand)
3018
3078
  : null,
@@ -3043,6 +3103,9 @@ window.__ModuleLoader__.load({
3043
3103
  result && result.ok === true && result.registryFallbackFrom
3044
3104
  ? h('p', { className: 'vr-hint' }, t('updateRegistryFallback'))
3045
3105
  : null,
3106
+ result && result.ok === true && result.releaseFallback === true
3107
+ ? h('p', { className: 'vr-hint' }, t('updateReleaseFallback'))
3108
+ : null,
3046
3109
  result && result.ok === true && result.updateAvailable === true
3047
3110
  ? h('div', { className: 'vr-catalog-error' },
3048
3111
  h('p', { className: 'vr-hint' }, auto && auto.supported === true ? t('updateInstallHint') : t('updateAutoUnavailable')),
@@ -3305,6 +3368,7 @@ window.__ModuleLoader__.load({
3305
3368
  typeof React.memo === 'function' ? React.memo(VisionRouterCard) : VisionRouterCard
3306
3369
 
3307
3370
  const ARTIFACT_TOOL_KEYS = [
3371
+ 'vision_materialize',
3308
3372
  'vision_crop',
3309
3373
  'vision_pixel_diff',
3310
3374
  'vision_trace',
@@ -3507,6 +3571,7 @@ window.__ModuleLoader__.load({
3507
3571
  yield ctx.slots.register(
3508
3572
  {
3509
3573
  name: 'settings.plugin.item',
3574
+ key: 'vision-router',
3510
3575
  id: 'vision-router',
3511
3576
  order: 30,
3512
3577
  label: () => t('nav'),
@@ -3546,6 +3611,7 @@ window.__ModuleLoader__.load({
3546
3611
  exports.parseLocalProviderDraft = parseLocalProviderDraft
3547
3612
  exports.jsonValueEqual = jsonValueEqual
3548
3613
  exports.commitSettingsPlan = commitSettingsPlan
3614
+ exports.installSettingsPersistence = installSettingsPersistence
3549
3615
  return module.exports
3550
3616
  },
3551
3617
  })
@@ -7,6 +7,7 @@ export const PACKAGE_NAME = 'dsh-vision-router'
7
7
  export const CURRENT_VERSION = String(packageJson.version ?? '')
8
8
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
9
9
  export const RELEASES_URL = 'https://github.com/ysr666/dsh-vision-router/releases/latest'
10
+ export const GITHUB_LATEST_RELEASE_API = 'https://api.github.com/repos/ysr666/dsh-vision-router/releases/latest'
10
11
 
11
12
  function parseIdentifier(value) {
12
13
  return /^\d+$/.test(value) ? { numeric: true, value: Number(value) } : { numeric: false, value }
@@ -96,6 +97,25 @@ async function fetchLatestVersion({ fetchImpl, registryBase, signal, timeoutMs }
96
97
  return latestVersion
97
98
  }
98
99
 
100
+
101
+ async function fetchLatestReleaseVersion({ fetchImpl, releaseApi, signal, timeoutMs }) {
102
+ const requestSignal = signal ?? AbortSignal.timeout(timeoutMs)
103
+ const response = await fetchImpl(releaseApi, {
104
+ method: 'GET',
105
+ headers: {
106
+ accept: 'application/vnd.github+json',
107
+ 'user-agent': 'dsh-vision-router-update-check',
108
+ },
109
+ signal: requestSignal,
110
+ })
111
+ if (!response.ok) throw new Error(`GitHub releases API returned HTTP ${response.status}`)
112
+ const body = await response.json().catch(() => undefined)
113
+ const tag = body && typeof body.tag_name === 'string' ? body.tag_name.trim() : ''
114
+ const latestVersion = tag.replace(/^v/i, '')
115
+ if (!parseSemver(latestVersion)) throw new Error('GitHub latest release returned an invalid tag')
116
+ return latestVersion
117
+ }
118
+
99
119
  /**
100
120
  * Check only; never installs or mutates anything. This deliberately stays
101
121
  * independent of whether DSH/plugin was installed via npx, a global CLI,
@@ -111,6 +131,7 @@ export async function checkPackageUpdate({
111
131
  currentVersion = CURRENT_VERSION,
112
132
  registry = registryBaseFromEnv(),
113
133
  fallbackRegistry = DEFAULT_NPM_REGISTRY,
134
+ releaseApi = GITHUB_LATEST_RELEASE_API,
114
135
  signal,
115
136
  timeoutMs = 10_000,
116
137
  } = {}) {
@@ -155,10 +176,48 @@ export async function checkPackageUpdate({
155
176
  }
156
177
  }
157
178
 
158
- throw new Error(
159
- 'update check failed: ' +
160
- failures.map((item) => `${item.registry} (${item.error})`).join(' -> '),
161
- )
179
+ // Registry metadata is the source of truth for installability, but a user's
180
+ // registry path can be blocked while GitHub remains reachable. Use the
181
+ // project's latest release only as a read-only version fallback so the UI
182
+ // can still show an exact `@<version>` recovery command instead of falling
183
+ // back to a bare `update` (which pnpm 11 may silently withhold for 24h).
184
+ if (signal?.aborted) throw signal.reason ?? new Error('update check aborted')
185
+ try {
186
+ const latestVersion = await fetchLatestReleaseVersion({
187
+ fetchImpl,
188
+ releaseApi,
189
+ signal,
190
+ timeoutMs,
191
+ })
192
+ const precedence = compareSemver(currentVersion, latestVersion)
193
+ return {
194
+ ok: true,
195
+ packageName: PACKAGE_NAME,
196
+ currentVersion,
197
+ latestVersion,
198
+ updateAvailable: precedence === -1,
199
+ aheadOfRegistry: precedence === 1,
200
+ checkedAt: Date.now(),
201
+ latestSource: 'github-release',
202
+ releaseFallback: true,
203
+ registryFailures: failures,
204
+ releasesUrl: RELEASES_URL,
205
+ // Exact on purpose: `@latest` can still be filtered by pnpm 11's
206
+ // minimumReleaseAge policy, while an explicit version is exempted.
207
+ packageSpec: `${PACKAGE_NAME}@${latestVersion}`,
208
+ installMethodAgnostic: true,
209
+ }
210
+ } catch (error) {
211
+ if (signal?.aborted) throw error
212
+ const releaseFailure = errorMessage(error)
213
+ throw new Error(
214
+ 'update check failed: ' +
215
+ [
216
+ ...failures.map((item) => `${item.registry} (${item.error})`),
217
+ `${releaseApi} (${releaseFailure})`,
218
+ ].join(' -> '),
219
+ )
220
+ }
162
221
  }
163
222
 
164
223
  /**
@@ -170,6 +229,7 @@ export function createCachedUpdateChecker({
170
229
  currentVersion = CURRENT_VERSION,
171
230
  registry = registryBaseFromEnv(),
172
231
  fallbackRegistry = DEFAULT_NPM_REGISTRY,
232
+ releaseApi = GITHUB_LATEST_RELEASE_API,
173
233
  successTtlMs = 6 * 60 * 60 * 1000,
174
234
  failureTtlMs = 5 * 60 * 1000,
175
235
  timeoutMs = 10_000,
@@ -191,6 +251,7 @@ export function createCachedUpdateChecker({
191
251
  currentVersion,
192
252
  registry,
193
253
  fallbackRegistry,
254
+ releaseApi,
194
255
  timeoutMs,
195
256
  }).catch((error) => ({
196
257
  ok: false,
@@ -198,7 +259,9 @@ export function createCachedUpdateChecker({
198
259
  checkedAt: Date.now(),
199
260
  registry: normalizeRegistryBase(registry),
200
261
  releasesUrl: RELEASES_URL,
201
- packageSpec: `${PACKAGE_NAME}@latest`,
262
+ // No ambiguous package target when every version source failed. The UI
263
+ // must require the user to confirm an exact release before constructing
264
+ // a recovery command.
202
265
  installMethodAgnostic: true,
203
266
  error: errorMessage(error),
204
267
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain (no key) + pixel-level vision tools (Q&A, grounding, crop, pixel diff, colors, OCR, SVG trace, cutout, screenshots). One-command install, no Python, image turns work like ordinary tool-calling turns.",
5
5
  "license": "MIT",
6
6
  "repository": {