dsh-vision-router 1.2.1 → 1.2.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 ten pixel-level 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 eleven pixel-level 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>
@@ -28,9 +28,11 @@
28
28
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **Announcement (v1.2.1)**
31
+ > 📌 **Announcement (v1.2.2)**
32
32
  >
33
- > v1.2.1 hardens the pixel loop: all ten pixel tools now accept uploaded-image attachment ids directly (no more `cannot read …/sha256:…` round trips), artifact filenames carry collision-free fingerprints, `vision_ground` retries degenerate boxes, the model guide replays fully from step 1 (leaving the settings first), and the settings card scrolls smoothly even with hundreds of models per provider.
33
+ > v1.2.2 closes the last attachment-id gap — ids announced for images the host persisted itself (e.g. `read_image` re-uploads, `sha256:…`) now resolve in `vision_describe` and every pixel tool (issue #72) — stops `vision_present` and other tool-result image blocks from ever locking a text-model session with `UNSUPPORTED_CONTENT` (issue #74; already-locked sessions heal after upgrading), and warns loudly when a stale sharp left over from a pre-v1.2 upgrade would break the pixel tools with `colourspace: parameter space not set` (issue #75).
34
+ >
35
+ > v1.2.1 hardened the pixel loop: all eleven pixel tools now accept uploaded-image attachment ids directly (no more `cannot read …/sha256:…` round trips), artifact filenames carry collision-free fingerprints, `vision_ground` retries degenerate boxes, the model guide replays fully from step 1 (leaving the settings first), and the settings card scrolls smoothly even with hundreds of models per provider.
34
36
 
35
37
  <p align="center">
36
38
  <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" />
@@ -125,7 +127,7 @@ The built-in anonymous OVH vision fallback is already configured, so normal imag
125
127
  - **Automatic failover with classified errors.** Region blocks, ToS filtering, 402 quota, 429 rate limits (with Retry-After backoff), context overflow, network failures — the chain walks providers one by one and only reports after all of them failed, with actionable advice.
126
128
  - **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.
127
129
  - **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.
128
- - **Progressive schema exposure.** Only a zero-arg `vision_activate` bootstrap is always visible; image turns auto-mount all ten deep tools with a one-time usage note, and a `vision-tools` skill is registered for text-only turns.
130
+ - **Progressive schema exposure.** Only a zero-arg `vision_activate` bootstrap is always visible; image turns auto-mount all eleven deep tools with a one-time usage note, and a `vision-tools` skill is registered for text-only turns.
129
131
  - **Selective proxy.** Only the configured vision provider hosts go through your local proxy; DeepSeek stays direct.
130
132
 
131
133
  ### Pixel loop in practice
@@ -146,10 +148,10 @@ The vision model is **only the eyes**; DeepSeek is **always the brain**. An imag
146
148
 
147
149
  ## Tools
148
150
 
149
- All ten deep tools mount automatically on image turns (`autoActivateOnImage`); text turns can mount them via `vision_activate` or the `/vision-tools` skill. Built on sharp / potrace / tesseract / system Chrome — no Python:
151
+ All eleven deep tools mount automatically on image turns (`autoActivateOnImage`); text turns can mount them via `vision_activate` or the `/vision-tools` skill. Built on sharp / potrace / tesseract / system Chrome — no Python:
150
152
 
151
153
  <p align="center">
152
- <img src="assets/vision-tools.svg" width="100%" alt="Nine vision tools available in DSH Vision Router." />
154
+ <img src="assets/vision-tools.svg" width="100%" alt="Eleven vision tools available in DSH Vision Router." />
153
155
  </p>
154
156
 
155
157
  | Tool | What it does | Artifact |
@@ -340,6 +342,15 @@ Settings live in the profile's settings provider and survive upgrades.
340
342
  > # your overrides …
341
343
  > ```
342
344
 
345
+ > **After upgrading from v1.1.x, pixel tools fail with
346
+ > `colourspace: parameter space not set`:** a stale sharp 0.34.0 from the
347
+ > v1.1.0 era still sits in the profile and its libvips DLL conflicts with the
348
+ > host's sharp 0.35.3 in the same process (issues #42 / #75). Delete
349
+ > `~/.dsh/profiles/<profile>/node_modules/sharp` and
350
+ > `~/.dsh/profiles/<profile>/node_modules/@img` and restart, or run
351
+ > `pnpm install` inside the profile. Since v1.2.2 the plugin detects the
352
+ > stale version at runtime and prints the same guidance itself.
353
+
343
354
  ### Uninstall
344
355
 
345
356
  ```sh
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 只负责思考,内置免费视觉链 + 10 个像素级工具负责“看”;图片轮次就像普通工具调用一样自然、可定位、可验证。</p>
9
+ <p align="center">DeepSeek 只负责思考,内置免费视觉链 + 11 个像素级工具负责“看”;图片轮次就像普通工具调用一样自然、可定位、可验证。</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>
@@ -28,9 +28,11 @@
28
28
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **公告(v1.2.1)**
31
+ > 📌 **公告(v1.2.2)**
32
32
  >
33
- > v1.2.1 加固像素闭环:十个像素工具可直接接受上传图片的附件 ID(告别 `cannot read …/sha256:…` 的绕路),产物文件名带指纹不再互相覆盖,`vision_ground` 对退化框自动重试,模型引导支持从第 1 步完整重放(先退出设置页),设置卡片在数百模型目录下滚动依然流畅。
33
+ > v1.2.2 现已支持:补上最后一处附件 ID 缺口——宿主 `read_image` 回挂图片公布的 `sha256:…` ID 现在可被 `vision_describe` 与全部像素工具解析(issue #72);`vision_present` 等工具结果里的图像块不再把文本模型会话锁死在 `UNSUPPORTED_CONTENT`(issue #74,已锁死的历史会话升级后自动修复);检测到 v1.1.x 升级残留的旧版 sharp 时明确告警,把玄学的 `colourspace` 报错变成一眼可见的修复指引(issue #75)。
34
+ >
35
+ > v1.2.1 加固像素闭环:十一个像素工具可直接接受上传图片的附件 ID(告别 `cannot read …/sha256:…` 的绕路),产物文件名带指纹不再互相覆盖,`vision_ground` 对退化框自动重试,模型引导支持从第 1 步完整重放(先退出设置页),设置卡片在数百模型目录下滚动依然流畅。
34
36
 
35
37
  <p align="center">
36
38
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -125,7 +127,7 @@ opencode-go + 自动识图 ← 发图片时选这个
125
127
  - **自动降级 + 分类报错。** 地区限制、ToS 风控、402 额度、429 限流(尊重 Retry-After 退避重试)、上下文超长、网络故障——链路逐供应商尝试,全部失败才报错并给出可操作的建议。
126
128
  - **图片记忆。** 视觉答案按附件内容哈希缓存;后续文字轮用记录的描述替换历史图片(标注为不可信证据),DeepSeek 真正“记得”之前发过的图,且不重复消耗视觉调用。
127
129
  - **可验证的像素闭环。** 参照图 → `vision_html_screenshot` → `vision_pixel_diff`(差异率 + 红色热力图 + 最差区域排行)→ 修复 → 再对比,直到差异收敛。UI 还原从“目测”变成“实测”。
128
- - **渐进式 schema 暴露。** 平时只有一个零参引导工具 `vision_activate`;图片轮自动挂载全部 10 个深看工具(附一次性使用提示),并为纯文字轮注册 `vision-tools` 技能。
130
+ - **渐进式 schema 暴露。** 平时只有一个零参引导工具 `vision_activate`;图片轮自动挂载全部 11 个深看工具(附一次性使用提示),并为纯文字轮注册 `vision-tools` 技能。
129
131
  - **选择性代理。** 只有配置的视觉供应商域名走本地代理;DeepSeek 保持直连。
130
132
 
131
133
  ### 像素闭环实测
@@ -146,10 +148,10 @@ Agent 仅根据参考图复刻 UI,再用 `vision_pixel_diff` 验证最终结
146
148
 
147
149
  ## 工具
148
150
 
149
- 10 个深看工具在图片轮自动挂载(`autoActivateOnImage`);文字轮可通过 `vision_activate` 或 `/vision-tools` 技能挂载。全部基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
151
+ 11 个深看工具在图片轮自动挂载(`autoActivateOnImage`);文字轮可通过 `vision_activate` 或 `/vision-tools` 技能挂载。全部基于 sharp / potrace / tesseract / 系统 Chrome——无 Python:
150
152
 
151
153
  <p align="center">
152
- <img src="assets/vision-tools-zh.svg" width="100%" alt="DSH Vision Router 的 10 个视觉工具。" />
154
+ <img src="assets/vision-tools-zh.svg" width="100%" alt="DSH Vision Router 的 11 个视觉工具。" />
153
155
  </p>
154
156
 
155
157
  | 工具 | 作用 | 产物 |
@@ -338,6 +340,14 @@ pnpm dsh plugin --profile web update dsh-vision-router
338
340
  > # 你的配置…
339
341
  > ```
340
342
 
343
+ > **从 v1.1.x 升级后像素工具报 `colourspace: parameter space not set`:**
344
+ > 这是 v1.1.0 时代自带的 sharp 0.34.0 残留在 profile 里、与宿主 sharp 0.35.3
345
+ > 同进程 DLL 冲突所致(issue #42 / #75)。删除
346
+ > `~/.dsh/profiles/<profile>/node_modules/sharp` 与
347
+ > `~/.dsh/profiles/<profile>/node_modules/@img` 后重启,或在 profile 目录执行
348
+ > `pnpm install` 重装依赖即可。v1.2.2 起插件会在检测到残留版本时直接告警并
349
+ > 给出同样的指引。
350
+
341
351
  ### 卸载
342
352
 
343
353
  ```sh
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1080" viewBox="0 0 1440 1080">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1140" viewBox="0 0 1440 1140">
2
2
  <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#070914"/><stop offset="1" stop-color="#09111a"/></linearGradient><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#8a68ff"/><stop offset="1" stop-color="#39d6ff"/></linearGradient><pattern id="d" width="28" height="28" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="#73809a" opacity=".1"/></pattern></defs>
3
- <rect width="1440" height="1080" rx="28" fill="url(#bg)"/><rect width="1440" height="1080" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1078" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
- <g font-family="system-ui,Segoe UI,Microsoft YaHei,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">10 个视觉工具</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">基于 sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">问 这张图片里</text><text x="314" y="359" fill="#c8cede" font-size="12"> 有什么?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">答 山间湖泊</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> 松树与云</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">仪表盘</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">项目</text><text x="565" y="414" fill="#7f899c" font-size="11">分析</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">项目</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="746" y="399" fill="#c7a6ff" font-size="11">新建项目</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">基线图像</text><text x="92" y="635" fill="#fff" font-size="23">$29/月</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="115" y="680" fill="#fff" font-size="10">开始使用</text><text x="232" y="603" fill="#d8deea" font-size="12">当前图像</text><text x="232" y="635" fill="#fff" font-size="23">$29/月</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="255" y="680" fill="#fff" font-size="10">开始使用</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">概览</text><text x="1018" y="918" fill="#8d96a8" font-size="10">报表</text><text x="1018" y="942" fill="#8d96a8" font-size="10">设置</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">用户数</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">元素清单:按类盘点页面元素,编号 + 原图像素框</text></g></svg>
3
+ <rect width="1440" height="1140" rx="28" fill="url(#bg)"/><rect width="1440" height="1140" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1138" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
+ <g font-family="system-ui,Segoe UI,Microsoft YaHei,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">11 个视觉工具</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">基于 sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">问 这张图片里</text><text x="314" y="359" fill="#c8cede" font-size="12"> 有什么?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">答 山间湖泊</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> 松树与云</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">仪表盘</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">项目</text><text x="565" y="414" fill="#7f899c" font-size="11">分析</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">项目</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="746" y="399" fill="#c7a6ff" font-size="11">新建项目</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">基线图像</text><text x="92" y="635" fill="#fff" font-size="23">$29/月</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="115" y="680" fill="#fff" font-size="10">开始使用</text><text x="232" y="603" fill="#d8deea" font-size="12">当前图像</text><text x="232" y="635" fill="#fff" font-size="23">$29/月</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="255" y="680" fill="#fff" font-size="10">开始使用</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">概览</text><text x="1018" y="918" fill="#8d96a8" font-size="10">报表</text><text x="1018" y="942" fill="#8d96a8" font-size="10">设置</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">用户数</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">元素清单:按类盘点页面元素,编号 + 原图像素框</text></g><rect x="50" y="1082" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1108" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1108" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">11</text><text x="108" y="1114" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_long_screenshot_ocr</text><text x="420" y="1114" fill="#818ba0" font-size="14">长截图转写:分块重叠识别,拼接输出 Markdown</text></svg>
@@ -1,4 +1,4 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1080" viewBox="0 0 1440 1080">
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1440" height="1140" viewBox="0 0 1440 1140">
2
2
  <defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#070914"/><stop offset="1" stop-color="#09111a"/></linearGradient><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#8a68ff"/><stop offset="1" stop-color="#39d6ff"/></linearGradient><pattern id="d" width="28" height="28" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="#73809a" opacity=".1"/></pattern></defs>
3
- <rect width="1440" height="1080" rx="28" fill="url(#bg)"/><rect width="1440" height="1080" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1078" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
- <g font-family="system-ui,Segoe UI,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">10 Vision Tools</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">Built on sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">Q What&apos;s in</text><text x="314" y="359" fill="#c8cede" font-size="12"> this image?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">A Mountain lake</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> with trees</text><text x="314" y="441" fill="#bfc7d6" font-size="12"> and clouds.</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">Dashboard</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">Projects</text><text x="565" y="414" fill="#7f899c" font-size="11">Analytics</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">Projects</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="742" y="399" fill="#c7a6ff" font-size="11">New Project</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="92" y="635" fill="#fff" font-size="23">$29</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="109" y="680" fill="#fff" font-size="10">Get Started</text><text x="232" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="232" y="635" fill="#fff" font-size="23">$29</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="249" y="680" fill="#fff" font-size="10">Get Started</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">Overview</text><text x="1018" y="918" fill="#8d96a8" font-size="10">Reports</text><text x="1018" y="942" fill="#8d96a8" font-size="10">Settings</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">Users</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">numbered inventory of every element of a kind — original-pixel boxes</text></g></svg>
3
+ <rect width="1440" height="1140" rx="28" fill="url(#bg)"/><rect width="1440" height="1140" rx="28" fill="url(#d)"/><rect x="1" y="1" width="1438" height="1138" rx="27" fill="none" stroke="#6554ff" opacity=".32"/>
4
+ <g font-family="system-ui,Segoe UI,Arial,sans-serif"><text x="72" y="75" fill="#f5f7fc" font-size="42" font-weight="800">DSH <tspan fill="#8768ff">Vision</tspan> <tspan fill="#4acfff">Router</tspan></text><text x="72" y="108" fill="#818ba0" font-size="15">github.com/ysr666/dsh-vision-router</text><text x="720" y="170" text-anchor="middle" fill="url(#a)" font-size="52" font-weight="800">11 Vision Tools</text><text x="720" y="205" text-anchor="middle" fill="#8d97aa" font-size="17">Built on sharp · potrace · tesseract · system Chrome</text><rect x="50" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">1</text><text x="118" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_describe</text><rect x="505" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">2</text><text x="573" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ground</text><rect x="960" y="245" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="279" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="279" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">3</text><text x="1028" y="286" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_crop</text><rect x="50" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">4</text><text x="118" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_pixel_diff</text><rect x="505" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">5</text><text x="573" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_colors</text><rect x="960" y="505" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="539" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="539" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">6</text><text x="1028" y="546" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_ocr</text><rect x="50" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">7</text><text x="118" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_trace</text><rect x="505" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="527" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="527" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">8</text><text x="573" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_extract_foreground</text><rect x="960" y="765" width="430" height="240" rx="20" fill="#0c111c" stroke="#265b73"/><circle cx="982" cy="799" r="18" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="982" y="799" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-family="system-ui,Segoe UI,Arial,sans-serif" font-size="17" font-weight="800">9</text><text x="1028" y="806" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="18" font-weight="700">vision_html_screenshot</text><rect x="75" y="315" width="205" height="140" rx="10" fill="#102437"/><circle cx="225" cy="347" r="22" fill="#f0d38b"/><path d="M75 431l70-66 45 39 31-30 59 57" fill="#1d5264"/><path d="M75 445l77-62 35 30 42-38 51 70" fill="#18344d"/><rect x="300" y="320" width="150" height="50" rx="10" fill="#151525" stroke="#42366e"/><text x="314" y="342" fill="#a98cff" font-size="12">Q What&apos;s in</text><text x="314" y="359" fill="#c8cede" font-size="12"> this image?</text><rect x="300" y="382" width="150" height="68" rx="10" fill="#111925" stroke="#334054"/><text x="314" y="405" fill="#7fe0bd" font-size="12">A Mountain lake</text><text x="314" y="423" fill="#bfc7d6" font-size="12"> with trees</text><text x="314" y="441" fill="#bfc7d6" font-size="12"> and clouds.</text><rect x="535" y="315" width="370" height="140" rx="10" fill="#101620" stroke="#293244"/><rect x="550" y="330" width="100" height="110" rx="7" fill="#151b27"/><text x="565" y="354" fill="#7f899c" font-size="11">Dashboard</text><rect x="558" y="368" width="80" height="27" rx="6" fill="#251d45"/><text x="570" y="386" fill="#b99cff" font-size="11">Projects</text><text x="565" y="414" fill="#7f899c" font-size="11">Analytics</text><rect x="670" y="337" width="205" height="94" rx="8" fill="#111824"/><text x="686" y="360" fill="#e5e9f1" font-size="15" font-weight="700">Projects</text><rect x="720" y="374" width="115" height="40" rx="6" fill="#161d29" stroke="#48dc8e" stroke-width="2"/><circle cx="720" cy="374" r="4" fill="#48dc8e"/><circle cx="835" cy="374" r="4" fill="#48dc8e"/><circle cx="720" cy="414" r="4" fill="#48dc8e"/><circle cx="835" cy="414" r="4" fill="#48dc8e"/><text x="742" y="399" fill="#c7a6ff" font-size="11">New Project</text><rect x="990" y="315" width="175" height="140" rx="10" fill="#161c2b"/><path d="M1000 435l55-87 45 68 55-45" fill="none" stroke="#d37e46" stroke-width="5"/><rect x="1030" y="335" width="90" height="95" fill="none" stroke="#a987ff" stroke-width="3" stroke-dasharray="8 6"/><circle cx="1030" cy="335" r="5" fill="#b293ff"/><circle cx="1120" cy="335" r="5" fill="#b293ff"/><circle cx="1030" cy="430" r="5" fill="#b293ff"/><circle cx="1120" cy="430" r="5" fill="#b293ff"/><text x="1182" y="392" fill="#886cff" font-size="28">→</text><rect x="1220" y="315" width="145" height="140" rx="10" fill="#201d25"/><path d="M1230 440l45-100 50 83 30-50" fill="none" stroke="#d37e46" stroke-width="6"/><rect x="75" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><rect x="215" y="575" width="125" height="135" rx="9" fill="#121824" stroke="#344052"/><text x="92" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="92" y="635" fill="#fff" font-size="23">$29</text><rect x="91" y="662" width="92" height="26" rx="6" fill="#226bd1"/><text x="109" y="680" fill="#fff" font-size="10">Get Started</text><text x="232" y="603" fill="#d8deea" font-size="12">Pro Plan</text><text x="232" y="635" fill="#fff" font-size="23">$29</text><rect x="231" y="662" width="92" height="26" rx="6" fill="#3eaa67"/><text x="249" y="680" fill="#fff" font-size="10">Get Started</text><text x="355" y="649" fill="#886cff" font-size="28">→</text><rect x="390" y="575" width="65" height="135" rx="8" fill="#0f1118" stroke="#553445"/><g fill="#f0444f"><rect x="400" y="590" width="14" height="18"/><rect x="430" y="612" width="17" height="20"/><rect x="408" y="645" width="22" height="22"/><rect x="435" y="670" width="12" height="27"/></g><rect x="535" y="575" width="370" height="75" rx="10" fill="#35203c"/><circle cx="840" cy="610" r="30" fill="#f2a34e"/><path d="M535 640l85-48 72 40 74-55 139 63" fill="#1e2a44"/><g><rect x="545" y="668" width="62" height="42" rx="6" fill="#1e2a44"/><rect x="616" y="668" width="62" height="42" rx="6" fill="#3b4d7a"/><rect x="687" y="668" width="62" height="42" rx="6" fill="#6b7fcb"/><rect x="758" y="668" width="62" height="42" rx="6" fill="#f29d49"/><rect x="829" y="668" width="62" height="42" rx="6" fill="#ffd6a6"/></g><g fill="#9da6b8" font-family="monospace" font-size="9"><text x="548" y="724">#1E2A44</text><text x="619" y="724">#3B4D7A</text><text x="690" y="724">#6B7FCB</text><text x="761" y="724">#F29D49</text><text x="832" y="724">#FFD6A6</text></g><rect x="990" y="575" width="175" height="135" rx="10" fill="#e8e8e5" stroke="#5f6670"/><text x="1077" y="607" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">PARKING</text><text x="1077" y="633" text-anchor="middle" fill="#111827" font-size="18" font-weight="800">RESERVED</text><text x="1077" y="659" text-anchor="middle" fill="#111827" font-size="20" font-weight="800">24/7</text><text x="1182" y="646" fill="#886cff" font-size="28">→</text><rect x="1220" y="575" width="145" height="135" rx="10" fill="#0d1518" stroke="#28483a"/><text x="1235" y="610" fill="#58d99a" font-family="monospace" font-size="14">PARKING</text><text x="1235" y="635" fill="#58d99a" font-family="monospace" font-size="14">RESERVED</text><text x="1235" y="660" fill="#58d99a" font-family="monospace" font-size="14">24/7</text><rect x="75" y="835" width="165" height="140" rx="10" fill="#111827" stroke="#2d3b55"/><path d="M101 940l45-82 59 40-58 61z" fill="#d8e1f2"/><circle cx="165" cy="890" r="12" fill="#3d87c7"/><path d="M118 949l-13 19 21-10" fill="#ff633f"/><text x="255" y="920" fill="#886cff" font-size="28">→</text><rect x="300" y="835" width="150" height="140" rx="10" fill="#0f1420" stroke="#2d3b55"/><path d="M322 948l45-91 57 42-62 63z" fill="none" stroke="url(#a)" stroke-width="3"/><circle cx="386" cy="889" r="13" fill="none" stroke="#43d6ff" stroke-width="3"/><rect x="535" y="835" width="170" height="140" rx="10" fill="#262a36"/><path d="M590 890h62l13 52h-90z" fill="#606672"/><path d="M580 942v25M658 942v25" stroke="#7b533a" stroke-width="8"/><text x="720" y="920" fill="#886cff" font-size="28">→</text><rect x="760" y="835" width="145" height="140" rx="10" fill="#ddd"/><g fill="#bfc3c8"><rect x="760" y="835" width="29" height="28"/><rect x="818" y="835" width="29" height="28"/><rect x="876" y="835" width="29" height="28"/><rect x="789" y="863" width="29" height="28"/><rect x="847" y="863" width="29" height="28"/></g><path d="M803 890h60l12 52h-86z" fill="#555b67"/><path d="M793 942v25M868 942v25" stroke="#7b533a" stroke-width="8"/><rect x="990" y="835" width="375" height="140" rx="10" fill="#101621" stroke="#344054"/><rect x="990" y="835" width="375" height="25" rx="10" fill="#171d2a"/><circle cx="1005" cy="847" r="4" fill="#ff665a"/><circle cx="1018" cy="847" r="4" fill="#f0b85b"/><circle cx="1031" cy="847" r="4" fill="#50d890"/><text x="1060" y="851" fill="#8c96a9" font-family="monospace" font-size="10">http://localhost:8080</text><rect x="1004" y="873" width="95" height="86" rx="6" fill="#17132b"/><text x="1018" y="894" fill="#9e8cff" font-size="10">Overview</text><text x="1018" y="918" fill="#8d96a8" font-size="10">Reports</text><text x="1018" y="942" fill="#8d96a8" font-size="10">Settings</text><rect x="1113" y="873" width="105" height="62" rx="6" fill="#151b28"/><rect x="1231" y="873" width="118" height="62" rx="6" fill="#151b28"/><text x="1125" y="895" fill="#7f899d" font-size="9">Users</text><text x="1125" y="920" fill="#f4f7fb" font-size="18">12,540</text><path d="M1120 956c50-23 82 6 126-22 34-21 56 4 96-36" fill="none" stroke="url(#a)" stroke-width="3"/><rect x="50" y="1018" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1044" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1044" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">10</text><text x="108" y="1050" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_detect</text><text x="420" y="1050" fill="#818ba0" font-size="14">numbered inventory of every element of a kind — original-pixel boxes</text></g><rect x="50" y="1082" width="1340" height="52" rx="14" fill="#0c111c" stroke="#44356f"/><circle cx="72" cy="1108" r="16" fill="#151329" stroke="#7765ff" stroke-width="2"/><text x="72" y="1108" text-anchor="middle" dominant-baseline="central" fill="#f0f3f9" font-size="15" font-weight="800">11</text><text x="108" y="1114" fill="#f0f3f9" font-family="ui-monospace,SFMono-Regular,Menlo,monospace" font-size="16" font-weight="700">vision_long_screenshot_ocr</text><text x="420" y="1114" fill="#818ba0" font-size="14">long-screenshot transcription: overlapping chunks, stitched Markdown</text></svg>
package/index.js CHANGED
@@ -40,10 +40,136 @@ import { createHash, randomBytes } from 'node:crypto'
40
40
  // lazily and cache the resolved factory so a sharp failure degrades only the
41
41
  // pixel-level tools — the routing chain and text tools keep working.
42
42
  let sharpPromise
43
+ // Module-level warning sink installed by apply(): the plugin routes runtime
44
+ // diagnostics through ctx.logger instead of console.warn. Kept as a plain
45
+ // function slot so loadSharp() stays usable outside a Cordis context (tests,
46
+ // the doctor CLI).
47
+ let sharpWarningHook
48
+ export function registerSharpWarningHook(hook) {
49
+ sharpWarningHook = typeof hook === 'function' ? hook : undefined
50
+ }
51
+
52
+ function warnSharp(message) {
53
+ if (sharpWarningHook !== undefined) {
54
+ try {
55
+ sharpWarningHook(message)
56
+ return
57
+ } catch {
58
+ /* fall through to console */
59
+ }
60
+ }
61
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') console.warn(message)
62
+ }
63
+
64
+ /** Split "1.2.3" / "1.2" / "1" / "1.2.3-beta.4" into comparable parts
65
+ * (missing minor/patch default to 0, like semver). */
66
+ export function parseVersionParts(version) {
67
+ const match = String(version ?? '').trim().match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/)
68
+ if (!match) return undefined
69
+ return {
70
+ major: Number(match[1]),
71
+ minor: Number(match[2] ?? 0),
72
+ patch: Number(match[3] ?? 0),
73
+ pre: match[4],
74
+ }
75
+ }
76
+
77
+ function compareVersionParts(a, b) {
78
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1
79
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
80
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
81
+ // A prerelease sorts below its release: 0.35.3-beta < 0.35.3.
82
+ if (a.pre === undefined && b.pre === undefined) return 0
83
+ if (a.pre === undefined) return 1
84
+ if (b.pre === undefined) return -1
85
+ return a.pre < b.pre ? -1 : a.pre > b.pre ? 1 : 0
86
+ }
87
+
88
+ /**
89
+ * Minimal semver range check for the comparator shapes the plugin itself
90
+ * declares (`>=0.35.3 <1`, space-separated clauses, `||` alternatives).
91
+ * @returns true when `version` satisfies `range`, false otherwise (also for
92
+ * malformed inputs, so an unparsable range fails safe and loud).
93
+ */
94
+ export function versionSatisfies(version, range) {
95
+ const parts = parseVersionParts(version)
96
+ if (parts === undefined) return false
97
+ const alternatives = String(range ?? '')
98
+ .split('||')
99
+ .map((alt) => alt.trim())
100
+ .filter((alt) => alt !== '')
101
+ if (alternatives.length === 0) return false
102
+ return alternatives.some((alternative) => {
103
+ const clauses = alternative.split(/\s+/)
104
+ if (clauses.length === 0) return false
105
+ return clauses.every((clause) => {
106
+ const match = clause.match(/^(>=|<=|>|<|=)?\s*(.+)$/)
107
+ if (!match) return false
108
+ const op = match[1] ?? '='
109
+ const other = parseVersionParts(match[2])
110
+ if (other === undefined) return false
111
+ const cmp = compareVersionParts(parts, other)
112
+ switch (op) {
113
+ case '>=': return cmp >= 0
114
+ case '<=': return cmp <= 0
115
+ case '>': return cmp > 0
116
+ case '<': return cmp < 0
117
+ default: return cmp === 0
118
+ }
119
+ })
120
+ })
121
+ }
122
+
123
+ // Read the plugin's own peerDependencies.sharp range from the installed
124
+ // package.json (createRequire resolves it relative to this file, so the value
125
+ // is never hardcoded and follows package.json through releases).
126
+ let sharpPeerRangeCache
127
+ function sharpPeerRange() {
128
+ if (sharpPeerRangeCache === undefined) {
129
+ try {
130
+ const requireLocal = createRequire(import.meta.url)
131
+ const pkg = requireLocal('./package.json')
132
+ sharpPeerRangeCache =
133
+ pkg && pkg.peerDependencies && typeof pkg.peerDependencies.sharp === 'string'
134
+ ? pkg.peerDependencies.sharp
135
+ : undefined
136
+ } catch {
137
+ sharpPeerRangeCache = undefined
138
+ }
139
+ }
140
+ return sharpPeerRangeCache
141
+ }
142
+
43
143
  function loadSharp() {
44
144
  if (!sharpPromise) {
45
145
  sharpPromise = import('sharp')
46
- .then((mod) => mod.default ?? mod)
146
+ .then((mod) => {
147
+ const sharp = mod.default ?? mod
148
+ // issue #75: an upgrade from v1.1.x can leave a stale sharp 0.34.0 in
149
+ // the profile's node_modules; pnpm does not physically remove orphaned
150
+ // peer copies on upgrade. On Windows the stale copy's libvips DLL and
151
+ // the host's coexist in one process and every pixel tool then dies
152
+ // with the cryptic "colourspace: parameter space not set". Detect the
153
+ // violation up front and turn it into an actionable warning.
154
+ try {
155
+ const version = sharp && sharp.versions && typeof sharp.versions.sharp === 'string'
156
+ ? sharp.versions.sharp
157
+ : undefined
158
+ const range = sharpPeerRange()
159
+ if (version !== undefined && range !== undefined && !versionSatisfies(version, range)) {
160
+ warnSharp(
161
+ `dsh-vision-router: the resolved sharp ${version} does not satisfy the plugin peer range "${range}". ` +
162
+ 'This is usually a stale sharp left in the profile from a pre-v1.2 upgrade: remove ' +
163
+ '`<profile>/node_modules/sharp` and `<profile>/node_modules/@img` (or run `pnpm install` in the profile) ' +
164
+ 'and restart, so the plugin falls through to the host sharp. Until then, pixel tools may fail with ' +
165
+ '"colourspace: parameter space not set".',
166
+ )
167
+ }
168
+ } catch {
169
+ /* diagnostics must never break the pixel tools */
170
+ }
171
+ return sharp
172
+ })
47
173
  .catch((cause) => {
48
174
  sharpPromise = undefined // allow a retry after the environment is repaired
49
175
  const error = new Error(
@@ -397,29 +523,100 @@ export function renderVisionPresent(value) {
397
523
  ]
398
524
  }
399
525
 
526
+ /** Text marker replacing a tool-produced image block (shared by the pre-step
527
+ * inbox sanitizer and the session-surface shadow sanitizer). */
528
+ export function toolImageMarker(block) {
529
+ const attachment = block && block.attachment ? block.attachment : {}
530
+ const id = attachment.attachmentId || attachment.id || 'unknown'
531
+ const name = attachment.name || 'tool image'
532
+ return {
533
+ type: 'text',
534
+ text:
535
+ `[tool result produced image "${name}", attachment id "${id}". ` +
536
+ `The image was kept out of the text-model request to prevent session corruption. ` +
537
+ `To inspect it, call vision_describe with attachmentIds: ["${id}"] when available, ` +
538
+ 'or use a path-based vision tool. To show a generated image to the user, use vision_present instead of read_image.]',
539
+ }
540
+ }
541
+
400
542
  export function sanitizeToolResultImages(messages) {
401
543
  let anyChanged = false
402
544
  const rewritten = (messages ?? []).map((message) => {
403
545
  if (!message || !Array.isArray(message.content)) return message
404
- const result = rewriteToolResultImages(message.content, (block) => {
405
- const attachment = block.attachment || {}
406
- const id = attachment.attachmentId || attachment.id || 'unknown'
407
- const name = attachment.name || 'tool image'
408
- return {
409
- type: 'text',
410
- text:
411
- `[tool result produced image "${name}", attachment id "${id}". ` +
412
- `The image was kept out of the text-model request to prevent session corruption. ` +
413
- `To inspect it, call vision_describe with attachmentIds: ["${id}"] when available, ` +
414
- 'or use a path-based vision tool. To show a generated image to the user, use vision_present instead of read_image.]',
415
- }
416
- })
546
+ const result = rewriteToolResultImages(message.content, toolImageMarker)
417
547
  if (result.changed) anyChanged = true
418
548
  return result.changed ? { ...message, content: result.content } : message
419
549
  })
420
550
  return { messages: anyChanged ? rewritten : (messages ?? []), changed: anyChanged }
421
551
  }
422
552
 
553
+ /** Recursively freeze a plain structured-clone tree (the session log keeps its
554
+ * messages deep-frozen; replacements must match). */
555
+ export function deepFreezeLocal(value) {
556
+ if (value !== null && typeof value === 'object') {
557
+ for (const key of Object.keys(value)) deepFreezeLocal(value[key])
558
+ Object.freeze(value)
559
+ }
560
+ return value
561
+ }
562
+
563
+ /**
564
+ * Build the sanitized, deep-frozen copy of a tool-result message: identical
565
+ * to the original except that every image block (top-level or nested inside
566
+ * tool-result content) is replaced with a text marker. Returns the original
567
+ * message object unchanged when it contains no image.
568
+ */
569
+ export function sanitizeToolResultMessage(message) {
570
+ if (!message || !Array.isArray(message.content)) return message
571
+ const result = rewriteImagesDeep(message.content, toolImageMarker)
572
+ if (!result.changed) return message
573
+ const clone = structuredClone(message)
574
+ clone.content = result.content
575
+ return deepFreezeLocal(clone)
576
+ }
577
+
578
+ /**
579
+ * Plan the shadow replacements that keep tool-produced image blocks out of
580
+ * the model-visible session surface.
581
+ *
582
+ * A tool result (e.g. vision_present, or the host read_image) is persisted as
583
+ * a durable `tool/result` event whose message nests an image block. The agent
584
+ * pre-step only sees the inbox claim — never the historical surface — so no
585
+ * pre-step rewrite can catch these blocks before `Session.deriveMessages()`
586
+ * feeds them to the adapter, and a text-only adapter then rejects every
587
+ * subsequent request (issue #74: UNSUPPORTED_CONTENT session lock).
588
+ *
589
+ * The harness supports shadowing a surface node with a replacement event that
590
+ * carries `surfaceOp: {op:'replace', start, end}` + `sourceEventSeqs: [seq]`:
591
+ * the human transcript keeps rendering the append-origin original (the user
592
+ * still sees the image), while every later `deriveMessages()` projection sees
593
+ * the sanitized replacement. This is the same mechanism the host compaction
594
+ * pruner uses, so it is durable, replayable, and survives session resume.
595
+ *
596
+ * This function is pure: it returns the replacement events to append. The
597
+ * apply() side decides which events to strip (route-aware: an image-capable
598
+ * route legitimately uses read_image's result image) and performs the append.
599
+ *
600
+ * @param events - the session event log array (`session.events`).
601
+ * @param surfaceNodes - the ordered seqs of the current surface (`session.surface.nodes`).
602
+ * @param shouldStrip - (seq, event) => boolean; true to plan a replacement.
603
+ * @returns [{ seq, event, message }] where message is the sanitized frozen
604
+ * replacement message for the append at `seq`.
605
+ */
606
+ export function planToolResultImageShadows(events, surfaceNodes, shouldStrip) {
607
+ const plans = []
608
+ for (const seq of surfaceNodes ?? []) {
609
+ const event = events && events[seq]
610
+ if (!event || event.type !== 'tool/result') continue
611
+ const message = event.data && event.data.message
612
+ if (!message || !Array.isArray(message.content) || !blocksHaveImage(message.content)) continue
613
+ if (typeof shouldStrip !== 'function' || shouldStrip(seq, event) !== true) continue
614
+ const sanitized = sanitizeToolResultMessage(message)
615
+ if (sanitized !== message) plans.push({ seq, event, message: sanitized })
616
+ }
617
+ return plans
618
+ }
619
+
423
620
  /** Marker text for an image the text-only model cannot see (see vision_describe). */
424
621
  function imageMarker(id) {
425
622
  return `[attached image: ${id}] The current model cannot see images. To examine it, call vision_describe with attachmentIds: ["${id}"] and a specific question.`
@@ -447,6 +644,51 @@ export function rewriteImageBlocks(messages) {
447
644
  return { messages: anyChanged ? rewritten : (messages ?? []), attachments }
448
645
  }
449
646
 
647
+ /**
648
+ * Collect distinct durable attachment refs from a session event log.
649
+ *
650
+ * The event log is the only place that sees every image that entered the
651
+ * conversation, including host-produced ones such as `read_image` re-uploads,
652
+ * which are persisted as `tool/result` events and never pass through the
653
+ * inbox-claim message stream a plugin sees on `agent/pre-step` (issue #72).
654
+ * Extracting refs here — with full metadata, so `attachments.readImage` can
655
+ * verify the bytes — is what lets `vision_describe` / the pixel tools resolve
656
+ * ids the harness announced but the plugin never indexed.
657
+ *
658
+ * Handles the same message-producing event types the host surface derives
659
+ * (`user/message` carries the message directly; `assistant/message` and
660
+ * `tool/result` nest it under `data.message`) and descends into nested
661
+ * `tool-result` content exactly like `rewriteImageBlocks`.
662
+ *
663
+ * @param events - the session event log (`session.events`), or any array shaped like it.
664
+ * @returns distinct attachment refs in first-seen order.
665
+ */
666
+ export function collectEventAttachmentRefs(events) {
667
+ const refs = []
668
+ const seen = new Set()
669
+ for (const event of events ?? []) {
670
+ if (!event || !event.data) continue
671
+ let message
672
+ if (event.type === 'user/message') {
673
+ message = event.data
674
+ } else if (event.type === 'assistant/message' || event.type === 'tool/result') {
675
+ message = event.data.message
676
+ } else {
677
+ continue
678
+ }
679
+ if (!message || !Array.isArray(message.content)) continue
680
+ rewriteImagesDeep(message.content, (block) => {
681
+ const attachment = block && block.attachment
682
+ if (attachment && attachment.attachmentId && !seen.has(String(attachment.attachmentId))) {
683
+ seen.add(String(attachment.attachmentId))
684
+ refs.push(attachment)
685
+ }
686
+ return block
687
+ })
688
+ }
689
+ return refs
690
+ }
691
+
450
692
  /** Extract a JSON object/array from model output (tolerates fences and prose). */
451
693
  export function extractJson(text) {
452
694
  const source = String(text ?? '')
@@ -1755,6 +1997,11 @@ export function modelInfoAcceptsImages(info) {
1755
1997
  }
1756
1998
 
1757
1999
  export function apply(ctx, config = {}) {
2000
+ // Route sharp version diagnostics (issue #75) through the harness logger
2001
+ // instead of console.warn, so the warning lands in the server log.
2002
+ registerSharpWarningHook((message) => {
2003
+ ctx.logger?.warn(message)
2004
+ })
1758
2005
  // Live configuration: composition entry at boot, then the resolved settings
1759
2006
  // section once the settings service mounts (installSettingsSection below).
1760
2007
  let current = () => config
@@ -2710,6 +2957,39 @@ export function apply(ctx, config = {}) {
2710
2957
  }
2711
2958
  }
2712
2959
 
2960
+ // session id -> event-log length already scanned for attachment refs.
2961
+ // Mirrors sessionAttachmentsById: sessions are long-lived objects, and only
2962
+ // the id string survives a process resume, so the index is keyed by id.
2963
+ const scannedSessionEventSeqs = new Map()
2964
+
2965
+ /**
2966
+ * Index image attachments recorded anywhere in the session event log, not
2967
+ * just in the inbox-claim message stream `agent/pre-step` hands us
2968
+ * (issue #72). Host-produced images (the built-in `read_image` tool's
2969
+ * re-uploads, persisted as `tool/result` events) never enter that stream,
2970
+ * so the harness-announced attachment id stayed unresolvable even though
2971
+ * the UI showed the image and the bytes are durably stored. `session.events`
2972
+ * is the complete append-only log (seeded from storage on resume), so
2973
+ * scanning it — incrementally, per session id — finds every ref with full
2974
+ * metadata for a later `attachments.readImage(ref)`.
2975
+ */
2976
+ const scanSessionEventLog = (session) => {
2977
+ if (!session) return
2978
+ let events
2979
+ try {
2980
+ events = session.events
2981
+ } catch {
2982
+ return // not a host Session (or the getter is unavailable): nothing to scan
2983
+ }
2984
+ if (!Array.isArray(events) || events.length === 0) return
2985
+ const key = session.id !== undefined ? String(session.id) : undefined
2986
+ const last = key !== undefined ? (scannedSessionEventSeqs.get(key) ?? 0) : 0
2987
+ if (last >= events.length) return
2988
+ const refs = collectEventAttachmentRefs(events.slice(last))
2989
+ if (key !== undefined) scannedSessionEventSeqs.set(key, events.length)
2990
+ if (refs.length > 0) recordUploadedAttachments(session, refs)
2991
+ }
2992
+
2713
2993
  const lookupAttachment = (session, id) => {
2714
2994
  const byId = session && session.id !== undefined
2715
2995
  ? sessionAttachmentsById.get(String(session.id))
@@ -2719,7 +2999,163 @@ export function apply(ctx, config = {}) {
2719
2999
  if (hit !== undefined) return hit
2720
3000
  }
2721
3001
  const map = session ? sessionAttachments.get(session) : undefined
2722
- return map ? map.get(String(id)) : undefined
3002
+ const hit = map ? map.get(String(id)) : undefined
3003
+ if (hit !== undefined) return hit
3004
+ // Miss: fall back to the session event log. Ids announced by the harness
3005
+ // for images it persisted itself (read_image re-uploads) live there even
3006
+ // though they never crossed the inbox-claim stream, so this resolves them
3007
+ // exactly like user-uploaded ids (issue #72). Refs from the log carry
3008
+ // full metadata, so a later attachments.readImage(ref) verifies and
3009
+ // returns the bytes.
3010
+ if (session !== undefined) {
3011
+ scanSessionEventLog(session)
3012
+ const afterById = session.id !== undefined
3013
+ ? sessionAttachmentsById.get(String(session.id))
3014
+ : undefined
3015
+ if (afterById !== undefined) {
3016
+ const after = afterById.get(String(id))
3017
+ if (after !== undefined) return after
3018
+ }
3019
+ const afterMap = sessionAttachments.get(session)
3020
+ const afterHit = afterMap ? afterMap.get(String(id)) : undefined
3021
+ if (afterHit !== undefined) return afterHit
3022
+ }
3023
+ return undefined
3024
+ }
3025
+
3026
+ // ── issue #74: shadow-sanitize tool-result image blocks on the surface ────
3027
+ //
3028
+ // A tool result that renders an image block (vision_present, or the host
3029
+ // read_image on image-capable routes) is persisted as a durable
3030
+ // `tool/result` event and then flows into EVERY later request through
3031
+ // `Session.deriveMessages()`. A text-only adapter (DeepSeek native, pi-ai
3032
+ // text routes) rejects nested images with UNSUPPORTED_CONTENT and the
3033
+ // session is locked forever. The pre-step inbox sanitizer cannot see these
3034
+ // historical events, so the surface itself is rewritten instead: each
3035
+ // offending event is shadowed by a sanitized replacement event
3036
+ // (`surfaceOp: {op:'replace'}`, the same mechanism the host compaction
3037
+ // pruner uses). The Web UI transcript renders append-origin events, so the
3038
+ // user still sees the image; only the model-visible surface is sanitized.
3039
+ // The decision is route-aware: an image-capable route legitimately consumes
3040
+ // read_image's result image, mirroring the host's own gate
3041
+ // (`assertImageCapableRoute` in dsh-tool-fs).
3042
+
3043
+ // session -> { count: nodes examined, done: Set<seqs already decided> }
3044
+ const sessionSurfaceScans = new WeakMap()
3045
+
3046
+ const sessionRouteHandlesImages = async (session) => {
3047
+ let provider
3048
+ let model
3049
+ try {
3050
+ const header = typeof session.requestHeader === 'function' ? session.requestHeader() : undefined
3051
+ provider = header && header.config ? header.config.provider : undefined
3052
+ model = header && header.config ? header.config.model : undefined
3053
+ } catch {
3054
+ return false
3055
+ }
3056
+ if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') {
3057
+ return false
3058
+ }
3059
+ // Plugin-owned routes handle image blocks at their stream boundary: the
3060
+ // wrapper and the provider twins rewrite them into markers/descriptions,
3061
+ // the stealth adapter does the same, and the vision-chain route serves
3062
+ // image-capable models.
3063
+ if (provider === wrapperRoute() || provider === chainRoute() || provider.endsWith('-vision')) {
3064
+ return true
3065
+ }
3066
+ if (stealthActive && provider === 'deepseek-official') return true
3067
+ // Routing mode reverse-routes text-only turns back to the text provider;
3068
+ // when neither the wrapper nor the stealth adapter is there to rewrite
3069
+ // images, the reverse target is the bare text adapter, which rejects
3070
+ // tool-result images. Sanitize so text turns stay usable.
3071
+ if (routingEnabled() && reverseRoutingEnabled() && !wrapperRegistered && !stealthActive) {
3072
+ return false
3073
+ }
3074
+ // Same probe the host uses to gate read_image: only routes whose models
3075
+ // declare image input may keep tool-result images.
3076
+ try {
3077
+ const info = await ctx.llm.resolveModelInfo(provider, model)
3078
+ return Array.isArray(info && info.inputModalities) && info.inputModalities.includes('image')
3079
+ } catch {
3080
+ return false // unknown route: fail safe and sanitize
3081
+ }
3082
+ }
3083
+
3084
+ const sanitizeSessionToolResults = async (session) => {
3085
+ if (!session) return
3086
+ let events
3087
+ let nodes
3088
+ try {
3089
+ events = session.events
3090
+ nodes = session.surface && session.surface.nodes
3091
+ } catch {
3092
+ return // not a host Session: nothing to sanitize
3093
+ }
3094
+ if (!Array.isArray(events) || !Array.isArray(nodes) || nodes.length === 0) return
3095
+ let scan = sessionSurfaceScans.get(session)
3096
+ if (!scan) {
3097
+ scan = { count: 0, done: new Set() }
3098
+ sessionSurfaceScans.set(session, scan)
3099
+ }
3100
+ // Compaction replaces the surface wholesale; a shrunk node list means the
3101
+ // positional cursor is stale, so restart from the head. Kept decisions are
3102
+ // memoized in `done`, so a restart is a cheap no-op for examined events.
3103
+ if (nodes.length < scan.count) {
3104
+ scan.count = 0
3105
+ scan.done = new Set()
3106
+ }
3107
+ if (nodes.length === scan.count) return
3108
+ let routeHandlesImages
3109
+ const newSeqs = nodes.slice(scan.count)
3110
+ for (const seq of newSeqs) {
3111
+ const event = events[seq]
3112
+ if (!event || event.type !== 'tool/result' || scan.done.has(seq)) continue
3113
+ const message = event.data && event.data.message
3114
+ if (!message || !Array.isArray(message.content) || !blocksHaveImage(message.content)) {
3115
+ scan.done.add(seq)
3116
+ continue
3117
+ }
3118
+ if (routeHandlesImages === undefined) {
3119
+ routeHandlesImages = await sessionRouteHandlesImages(session)
3120
+ }
3121
+ if (routeHandlesImages) {
3122
+ // Image-capable route: keep the image (read_image's result is the
3123
+ // model's view of the file). The wrapper/twin/stealth routes rewrite
3124
+ // it at stream time anyway.
3125
+ scan.done.add(seq)
3126
+ continue
3127
+ }
3128
+ const sanitized = sanitizeToolResultMessage(message)
3129
+ if (sanitized === message) {
3130
+ scan.done.add(seq)
3131
+ continue
3132
+ }
3133
+ try {
3134
+ session.append(
3135
+ 'tool/result',
3136
+ { ...event.data, message: sanitized },
3137
+ {
3138
+ surfaceOp: { op: 'replace', start: seq, end: seq },
3139
+ sourceEventSeqs: [seq],
3140
+ },
3141
+ )
3142
+ scan.done.add(seq)
3143
+ ctx.logger?.info(
3144
+ 'vision-router: sanitized a tool-result image block out of the model surface (event seq %s)',
3145
+ seq,
3146
+ )
3147
+ } catch (error) {
3148
+ // A failed shadow leaves the original event on the surface: the
3149
+ // session stays usable (today's behavior) instead of crashing the
3150
+ // pre-step.
3151
+ ctx.logger?.warn(
3152
+ 'vision-router: could not sanitize tool-result image at event seq %s (%s)',
3153
+ seq,
3154
+ error && error.message ? error.message : String(error),
3155
+ )
3156
+ }
3157
+ }
3158
+ scan.count += newSeqs.length
2723
3159
  }
2724
3160
 
2725
3161
  // session -> { turn, startIndex, hasImage, routed, failures, lastError }
@@ -2735,12 +3171,37 @@ export function apply(ctx, config = {}) {
2735
3171
  // sanitized. This preserves attachment lookup for a later vision_describe.
2736
3172
  const rawImageRefs = rewriteImageBlocks(rawMessages)
2737
3173
  recordUploadedAttachments(session, rawImageRefs.attachments)
3174
+ // Also index image attachments that live only in the session event log
3175
+ // (read_image re-uploads never cross the inbox-claim message stream).
3176
+ // Incremental: scans only new events (issue #72).
3177
+ scanSessionEventLog(session)
2738
3178
  // Hard invariant: tool-produced image blocks never reach a model request.
2739
- // This is deliberately route-agnostic, because merely having a wrapper
2740
- // registered does not prove that the current request is actually using it.
3179
+ // Two layers: (1) sanitize tool-result images in the inbox claim, and
3180
+ // (2) shadow-sanitize historical tool/result events on the session
3181
+ // surface (issue #74) — the only lever that can rewrite durable history.
2741
3182
  const sanitizedToolResults = sanitizeToolResultImages(rawMessages)
2742
3183
  const messages = sanitizedToolResults.messages
2743
3184
  const hasImage = messages.some((message) => blocksHaveImage(message && message.content))
3185
+ try {
3186
+ await sanitizeSessionToolResults(session)
3187
+ } catch (error) {
3188
+ ctx.logger?.warn(
3189
+ 'vision-router: session-surface sanitization failed (%s)',
3190
+ error && error.message ? error.message : String(error),
3191
+ )
3192
+ }
3193
+ // Register the turn state BEFORE the image-turn branches below: those
3194
+ // branches return early (auto-mount reminder, history rewrite), and the
3195
+ // agent/request hook must still see the state, otherwise an image turn is
3196
+ // served by the text provider and rejected (issue #74, second root cause).
3197
+ if (routingEnabled()) {
3198
+ const events = session.events ?? []
3199
+ turnState.set(session, {
3200
+ turn: payload.turn,
3201
+ startIndex: events.length,
3202
+ hasImage,
3203
+ })
3204
+ }
2744
3205
  if (hasImage) {
2745
3206
  // Auto-mount the deep vision tools on image turns: the model can use
2746
3207
  // them from its very first step without the user asking for them.
@@ -2805,14 +3266,6 @@ export function apply(ctx, config = {}) {
2805
3266
  return { ...decision, messages: cleaned.messages }
2806
3267
  }
2807
3268
  }
2808
- if (routingEnabled()) {
2809
- const events = session.events ?? []
2810
- turnState.set(session, {
2811
- turn: payload.turn,
2812
- startIndex: events.length,
2813
- hasImage,
2814
- })
2815
- }
2816
3269
  return sanitizedToolResults.changed ? { ...decision, messages } : decision
2817
3270
  })
2818
3271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.2.1",
3
+ "version": "1.2.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": {