opencode-vision-analyze 0.1.0-beta.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MwumLi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # opencode-vision-analyze
2
+
3
+ [![npm](https://img.shields.io/npm/v/opencode-vision-analyze)](https://www.npmjs.com/package/opencode-vision-analyze)
4
+ [![license](https://img.shields.io/npm/l/opencode-vision-analyze)](./LICENSE)
5
+ [![opencode plugin](https://img.shields.io/badge/opencode-plugin-blue)](https://opencode.ai/docs/plugins)
6
+
7
+ A tool-based vision routing plugin for [opencode](https://opencode.ai): when the main model can't see images, it calls the `vision_analyze` tool on demand — your dedicated vision model describes the image and the description flows straight back into the conversation. When the main model already supports images, pasted images pass through untouched and the tool short-circuits to return raw pixels.
8
+
9
+ **Zero runtime dependencies.** Only node builtins (`crypto`/`fs`/`path`) and type-only imports — nothing to install beyond the plugin itself.
10
+
11
+ ## Why this one
12
+
13
+ There are already a few vision plugins in the ecosystem. The differences:
14
+
15
+ | | opencode-vision | opencode-vision-router | opencode-image-vision | **opencode-vision-analyze** |
16
+ |---|---|---|---|---|
17
+ | Mechanism | skill + subagent delegation | pointer + subagent delegation | direct SDK call (read-image / read-ocr) | **tool + plugin-managed sub-session** |
18
+ | Vision model source | auto-discovered image models | single `model` option | per-feature provider/model | single `model` option |
19
+ | Main-model capability detection | models.dev catalog + auth | `chat.params` live learning | name regex (fragile) | `config.providers()` capabilities (cached) |
20
+ | Image persistence | /tmp (session+part hash) | tmpDir | user dir / clipboard dir | `.opencode/vision/` content-addressed sha256 |
21
+ | Output | subagent answers itself | subagent answers itself | description / OCR text | description text (with cache) |
22
+ | Vision-capable main model | skip registration | skip routing (`force` to override) | skipModels / forceDescription | skip injection + **native fast path** (tool returns the raw image as attachment) |
23
+ | Request path | opencode session | opencode session | **direct third-party SDK** | opencode sub-session (unified auth, no extra credentials) |
24
+ | Failure visibility | via subagent tool chain | via subagent | tool output | tool output (never throws) |
25
+
26
+ Highlights:
27
+
28
+ - **Tool-based, not pre-analysis.** The turn starts immediately; the model decides when (and with which question) to look. No blocking on submit, failures are visible and retryable inside the agent loop. Same philosophy as production-proven agent designs.
29
+ - **Question-aware descriptions.** The model passes its own focused question to `vision_analyze` — not a one-shot generic caption computed at submit time.
30
+ - **Native fast path.** If the main model is vision-capable, `vision_analyze` skips the vision model entirely and returns the raw image as a tool attachment.
31
+ - **Content-addressed cache.** Images are stored as `<sha256>.<ext>` (deduped across sessions); descriptions are cached per `<image-hash>:<question>` — the same image with the same question is described exactly once.
32
+ - **Unified auth.** The vision call runs through an opencode sub-session, so it reuses the provider credentials opencode already manages. No extra API key plumbing.
33
+
34
+ ## Requirements
35
+
36
+ - [opencode](https://opencode.ai) (plugins are loaded with Bun; npm plugins are installed automatically at startup)
37
+ - a vision model you have access to, referenced as `provider/model` (e.g. `"openai/gpt-4o-mini"`, `"anthropic/claude-sonnet-4-5"`)
38
+ - supported image extensions: png / jpg / jpeg / gif / webp
39
+
40
+ ## Installation
41
+
42
+ ### Option A — npm (recommended)
43
+
44
+ ```jsonc
45
+ // opencode.json (project or global)
46
+ {
47
+ "plugin": [
48
+ ["opencode-vision-analyze", { "model": "openai/gpt-4o-mini" }]
49
+ ]
50
+ }
51
+ ```
52
+
53
+ opencode installs npm plugins automatically at startup.
54
+
55
+ ### Option B — curl single file (no npm)
56
+
57
+ The plugin is a single self-contained TypeScript file with zero runtime dependencies — you can just download it:
58
+
59
+ ```bash
60
+ mkdir -p .opencode
61
+ curl -fsSL https://raw.githubusercontent.com/MwumLi/opencode-vision-analyze/main/src/index.ts \
62
+ -o .opencode/vision-analyze.ts
63
+ ```
64
+
65
+ ```jsonc
66
+ // opencode.json
67
+ {
68
+ "plugin": [
69
+ ["./.opencode/vision-analyze.ts", { "model": "openai/gpt-4o-mini" }]
70
+ ]
71
+ }
72
+ ```
73
+
74
+ Notes for the curl path:
75
+
76
+ - Pin a release tag instead of `main` for stability, e.g. `.../opencode-vision-analyze/v0.1.0/src/index.ts`. Upgrading = re-run curl.
77
+ - The file is TypeScript source — opencode loads plugins with Bun, so this works as-is.
78
+ - Options must be passed via the `plugin` tuple (the auto-discovered `.opencode/plugins/` directory can't carry options).
79
+
80
+ ### Options
81
+
82
+ | Option | Required | Default | Description |
83
+ |---|---|---|---|
84
+ | `model` | yes | — | Vision model in `provider/model` format, e.g. `"anthropic/claude-sonnet-4-5"`, `"openai/gpt-4o-mini"` |
85
+ | `timeout_ms` | no | `60000` | Timeout (ms) for vision sub-session requests |
86
+
87
+ ## How it works
88
+
89
+ ```
90
+ User pastes image + question
91
+ └─ chat.message hook (before persist)
92
+ ├─ main model has image input capability → do nothing (raw image goes to model)
93
+ └─ text-only main model → persist image to .opencode/vision/<sha256>.<ext>
94
+ and inject a synthetic hint (hidden in TUI, visible to model):
95
+ "use the vision_analyze tool with image_path: ..."
96
+
97
+ Main model processes:
98
+ ├─ vision-capable: sees the original image directly (zero cost)
99
+ └─ text-only: sees the hint, calls vision_analyze(image_path, question)
100
+
101
+ vision_analyze tool:
102
+ ├─ native fast path: session's main model is vision-capable
103
+ │ → return raw image as attachment (no vision model call)
104
+ ├─ http(s) image URL → download (20 MB cap) → same disk path
105
+ ├─ description cache hit (sha + question) → return cached text
106
+ └─ sub-session: parentID under current session, all tools disabled,
107
+ dedicated system prompt, image + question sent to YOUR vision model
108
+ → description text returned → sub-session deleted immediately
109
+ ```
110
+
111
+ Key behaviors:
112
+
113
+ - **Capability gating** — queries `config.providers()` capabilities; results cached per process. A vision-capable main model never gets hints or routing.
114
+ - **Recursion guard** — the vision model's own messages (from the sub-session) are never re-processed.
115
+ - **The tool never throws** — every failure returns readable text so the agent loop can retry, rephrase, or inform the user.
116
+ - **URL images** — `image_path` accepts `http(s)://...` URLs (must end in a supported image extension: png/jpg/jpeg/gif/webp).
117
+
118
+ ## Known limitations
119
+
120
+ - **V1 session flow only** — hooks are attached to the V1 `SessionPrompt` path; if opencode's default interaction moves to the V2 session core, hooks won't fire (silently).
121
+ - **SSRF surface** — URL downloads follow redirects and don't block private-range / cloud-metadata addresses. Acceptable for a local single-user CLI; add address filtering before using in multi-tenant environments.
122
+ - **Abort doesn't propagate** — user aborts don't cancel in-flight downloads/sub-session requests; they run to their own deadlines (30s download, `timeout_ms` sub-session). After timeout/abort the sub-session is deleted, but the orphan turn may still be billed by the provider.
123
+ - **Historical images** — images from messages sent before the plugin was enabled can't be described (no hint, no path on disk).
124
+ - **Unbounded caches** — both the image store and description cache grow without eviction (per process / per project dir).
125
+ - **Single model, no fallback chain** — one explicit `model` option; if it fails, the tool returns an error message instead of trying other providers.
126
+
127
+ ## Roadmap
128
+
129
+ - [ ] `model: undefined` fallback for first messages without an explicit model
130
+ - [ ] Timeout wrapping for the capability query (`config.providers()`)
131
+ - [ ] Abort sub-session (`/session/{id}/abort`) before delete on timeout
132
+ - [ ] LRU / size cap for the description cache
133
+ - [ ] Optional private-address blocking for URL downloads
134
+ - [ ] Region cropping for zooming into image details
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ bun install
140
+ bun run typecheck # tsc --noEmit
141
+ bun test # unit tests (stub client, no opencode needed)
142
+ bun run build # tsc → dist/
143
+ ```
144
+
145
+ The unit tests stub the plugin input/client — no running opencode instance is required.
146
+
147
+ ## License
148
+
149
+ [MIT](./LICENSE)
package/README.zh.md ADDED
@@ -0,0 +1,151 @@
1
+ # opencode-vision-analyze
2
+
3
+ [![npm](https://img.shields.io/npm/v/opencode-vision-analyze)](https://www.npmjs.com/package/opencode-vision-analyze)
4
+ [![license](https://img.shields.io/npm/l/opencode-vision-analyze)](./LICENSE)
5
+ [![opencode plugin](https://img.shields.io/badge/opencode-plugin-blue)](https://opencode.ai/docs/plugins)
6
+
7
+ [English](./README.md) | 简体中文
8
+
9
+ 一个面向 [opencode](https://opencode.ai) 的工具化视觉路由插件:当主模型看不了图片时,由它按需调用 `vision_analyze` 工具——你指定的视觉模型描述图片,描述文字直接回到对话中。当主模型本身支持图片时,贴图原样直发,工具短路返回原图。
10
+
11
+ **零运行时依赖。** 只用 node 内置模块(`crypto`/`fs`/`path`)和纯类型导入——除插件本身外无需安装任何东西。
12
+
13
+ ## 为什么选这个
14
+
15
+ 生态里已有若干视觉插件,差异如下:
16
+
17
+ | | opencode-vision | opencode-vision-router | opencode-image-vision | **opencode-vision-analyze** |
18
+ |---|---|---|---|---|
19
+ | 机制 | skill + 子代理委托 | 指针 + 子代理委托 | 直连 SDK(read-image / read-ocr) | **工具 + 插件自管子会话** |
20
+ | 视觉模型来源 | 自动发现的视觉模型 | 单一 `model` 选项 | 每功能独立 provider/model | 单一 `model` 选项 |
21
+ | 主模型能力判定 | models.dev 目录 + auth | `chat.params` 实时学习 | 名字正则(脆弱) | `config.providers()` 能力查询(缓存) |
22
+ | 图片落盘 | /tmp(会话+part 哈希) | tmpDir | 用户目录 / 剪贴板目录 | `.opencode/vision/` 内容寻址 sha256 |
23
+ | 产出 | 子代理自行作答 | 子代理自行作答 | 描述 / OCR 文本 | 描述文本(带缓存) |
24
+ | 有视觉主模型 | 跳过注册 | 跳过路由(`force` 可强制) | skipModels / forceDescription | 跳过注入 + **原生快速路径**(工具直接回传原图附件) |
25
+ | 请求路径 | opencode 会话 | opencode 会话 | **第三方 SDK 直连** | opencode 子会话(统一鉴权,无需额外密钥) |
26
+ | 失败可见性 | 经子代理工具链 | 经子代理 | 工具输出 | 工具输出(永不抛错) |
27
+
28
+ 亮点:
29
+
30
+ - **工具化,而非提交时预分析。** 轮次即时启动;模型自己决定何时看图、带着什么问题看。提交零阻塞,失败在 agent 循环里可见、可重试。
31
+ - **描述针对问题。** 模型把自己关注的问题传给 `vision_analyze`——而不是提交时预生成的一次性通用描述。
32
+ - **原生快速路径。** 主模型本身有视觉能力时,`vision_analyze` 完全跳过视觉模型,直接把原图作为工具附件返回。
33
+ - **内容寻址缓存。** 图片按 `<sha256>.<ext>` 落盘(跨会话天然去重);描述按 `<图片哈希>:<问题>` 缓存——同图同问题只描述一次。
34
+ - **统一鉴权。** 视觉调用走 opencode 子会话,复用 opencode 已管理的 provider 凭据,无需额外配置 API Key。
35
+
36
+ ## 运行前提
37
+
38
+ - [opencode](https://opencode.ai)(插件由 Bun 加载;npm 插件启动时自动安装)
39
+ - 一个你可用的视觉模型,以 `provider/model` 引用(如 `"openai/gpt-4o-mini"`、`"anthropic/claude-sonnet-4-5"`)
40
+ - 受支持的图片扩展名:png / jpg / jpeg / gif / webp
41
+
42
+ ## 安装
43
+
44
+ ### 方式 A —— npm(推荐)
45
+
46
+ ```jsonc
47
+ // opencode.json(项目级或全局)
48
+ {
49
+ "plugin": [
50
+ ["opencode-vision-analyze", { "model": "openai/gpt-4o-mini" }]
51
+ ]
52
+ }
53
+ ```
54
+
55
+ opencode 启动时会自动安装 npm 插件。
56
+
57
+ ### 方式 B —— curl 单文件(免 npm)
58
+
59
+ 本插件是零运行时依赖的单文件 TypeScript 源码,直接下载即可使用:
60
+
61
+ ```bash
62
+ mkdir -p .opencode
63
+ curl -fsSL https://raw.githubusercontent.com/MwumLi/opencode-vision-analyze/main/src/index.ts \
64
+ -o .opencode/vision-analyze.ts
65
+ ```
66
+
67
+ ```jsonc
68
+ // opencode.json
69
+ {
70
+ "plugin": [
71
+ ["./.opencode/vision-analyze.ts", { "model": "openai/gpt-4o-mini" }]
72
+ ]
73
+ }
74
+ ```
75
+
76
+ curl 方式说明:
77
+
78
+ - 建议固定到发布 tag 而非 `main`,如 `.../opencode-vision-analyze/v0.1.0/src/index.ts`;升级即重新 curl。
79
+ - 文件是 TypeScript 源码——opencode 用 Bun 加载插件,直接可用。
80
+ - 选项必须通过 `plugin` 元组传入(`.opencode/plugins/` 自动发现目录无法携带选项)。
81
+
82
+ ### 选项
83
+
84
+ | 选项 | 必填 | 默认值 | 说明 |
85
+ |---|---|---|---|
86
+ | `model` | 是 | — | 视觉模型,`provider/model` 格式,如 `"anthropic/claude-sonnet-4-5"`、`"openai/gpt-4o-mini"` |
87
+ | `timeout_ms` | 否 | `60000` | 视觉子会话请求超时(毫秒) |
88
+
89
+ ## 工作原理
90
+
91
+ ```
92
+ 用户贴图 + 提问
93
+ └─ chat.message 钩子(消息持久化前)
94
+ ├─ 主模型支持图片输入 → 不做任何处理(原图直发)
95
+ └─ 纯文本主模型 → 图片落盘 .opencode/vision/<sha256>.<ext>
96
+ 并注入 synthetic 提示(TUI 隐藏、模型可见):
97
+ "用 vision_analyze 工具查看,image_path: ..."
98
+
99
+ 主模型处理:
100
+ ├─ 有视觉:直接看原图(零成本)
101
+ └─ 无视觉:看到提示,调用 vision_analyze(image_path, question)
102
+
103
+ vision_analyze 工具:
104
+ ├─ 原生快速路径:会话主模型有视觉能力
105
+ │ → 原图作为附件直接返回(不调视觉模型)
106
+ ├─ http(s) 图片 URL → 下载(20 MB 上限)→ 统一磁盘路径
107
+ ├─ 描述缓存命中(图片哈希 + 问题)→ 直接返回缓存文本
108
+ └─ 子会话:parentID 挂当前会话、禁用全部工具、专用 system
109
+ prompt,图片 + 问题发给你的视觉模型
110
+ → 返回描述文字 → 子会话立即删除
111
+ ```
112
+
113
+ 关键行为:
114
+
115
+ - **能力门控** —— 查询 `config.providers()` 能力字段,进程级缓存;有视觉能力的主模型永远不会收到提示或被路由。
116
+ - **递归防护** —— 视觉模型自己的消息(来自子会话)不会被再次处理。
117
+ - **工具永不抛错** —— 所有失败都返回可读文字,agent 循环可以重试、换问题或告知用户。
118
+ - **URL 图片** —— `image_path` 接受 `http(s)://...` 地址(需以受支持的图片扩展名结尾:png/jpg/jpeg/gif/webp)。
119
+
120
+ ## 已知限制
121
+
122
+ - **仅 V1 会话流** —— 钩子挂在 V1 `SessionPrompt` 路径上;若 opencode 默认交互切到 V2 会话核心,钩子不会触发(且不报错)。
123
+ - **SSRF 面** —— URL 下载跟随重定向、不拦截私网/云元数据地址。本地单用户 CLI 信任级别下可接受;多租户环境使用前应加地址过滤。
124
+ - **中止不传导** —— 用户中止不会取消进行中的下载/子会话请求,它们会跑到各自的 deadline(下载 30 秒、子会话 `timeout_ms`)。超时/中止后子会话虽被删除,但 provider 端的孤儿回合仍可能计费。
125
+ - **历史图片** —— 插件启用之前发送的图片无法被描述(无提示、磁盘上无路径)。
126
+ - **缓存无上限** —— 图片存储与描述缓存均不淘汰(进程级 / 项目目录级)。
127
+ - **单模型无备选链** —— 只有一个显式 `model` 选项;失败时返回错误文字,不会尝试其他 provider。
128
+
129
+ ## Roadmap
130
+
131
+ - [ ] `model` 未显式指定时对首条消息的回退处理
132
+ - [ ] 能力查询(`config.providers()`)加超时保护
133
+ - [ ] 超时路径先中止子会话(`/session/{id}/abort`)再删除
134
+ - [ ] 描述缓存 LRU / 容量上限
135
+ - [ ] URL 下载可选私网地址拦截
136
+ - [ ] 区域裁剪(放大查看图片细节)
137
+
138
+ ## 开发
139
+
140
+ ```bash
141
+ bun install
142
+ bun run typecheck # tsc --noEmit
143
+ bun test # 单元测试(stub client,无需 opencode 实例)
144
+ bun run build # tsc → dist/
145
+ ```
146
+
147
+ 单元测试使用 stub 的插件输入/client——不需要运行中的 opencode。
148
+
149
+ ## 许可证
150
+
151
+ [MIT](./LICENSE)
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ declare const _default: {
3
+ id: string;
4
+ server: Plugin;
5
+ };
6
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * opencode-vision-analyze
3
+ *
4
+ * 为「不具备视觉能力的主模型」提供图片解读路由:当用户在消息中附带图片时,
5
+ * 插件把图片落盘到 .opencode/vision/<sha256>.<ext>,并向模型注入一条
6
+ * synthetic 提示(TUI 界面隐藏、模型可见),引导它通过 vision_analyze 工具
7
+ * 让指定的视觉模型描述图片。若主模型本身支持图片输入,则不做任何干预,
8
+ * 原图直接发给主模型。
9
+ *
10
+ * 安装方式一(npm):
11
+ * {
12
+ * "plugin": [["opencode-vision-analyze", { "model": "provider/vision-model" }]]
13
+ * }
14
+ *
15
+ * 安装方式二(curl 下载单文件,免 npm):
16
+ * mkdir -p .opencode
17
+ * curl -fsSL <raw-url>/src/index.ts -o .opencode/vision-analyze.ts
18
+ * {
19
+ * "plugin": [["./.opencode/vision-analyze.ts", { "model": "provider/vision-model" }]]
20
+ * }
21
+ *
22
+ * 选项:
23
+ * - model(必填):视觉模型的 "provider/model" 标识,例如 "anthropic/claude-sonnet-4-5"
24
+ * - timeout_ms:子会话请求的超时毫秒数(正数,默认 60000)
25
+ *
26
+ * 工作方式(vision_analyze 工具路径):主模型调用 vision_analyze 时,插件
27
+ * 创建一个 parentID 挂在当前会话下的临时子会话(不进会话列表、不生成
28
+ * 标题、禁用全部工具),把原图以 data URL 发给视觉模型,取回描述文字后
29
+ * 删除子会话并返回描述。同一张图 + 同一问题的描述按内容哈希缓存。
30
+ * image_path 除了绝对路径也接受 http(s) URL:先下载落盘到同一 vision
31
+ * 目录(内容哈希命名,天然与附件落盘去重),再走统一的磁盘加载路径。
32
+ * 主模型本身支持图片输入时走快速路径:不做子会话描述,直接把原图作为
33
+ * 工具附件回传给模型自行查看。
34
+ *
35
+ * 说明:本插件只用 node 内置模块(crypto/fs/path),无任何运行时外部依赖,
36
+ * 类型依赖仅 @opencode-ai/plugin 与 @opencode-ai/sdk 的 type import。
37
+ *
38
+ * 已知限制:
39
+ * - SSRF 面:downloadImage 的 fetch 跟随重定向、不拦截私网/云元数据地址。
40
+ * 本地单用户 CLI 的信任级别下可接受;生产多租户环境使用前应加私网
41
+ * 地址拦截。
42
+ * - 中止不传导:用户中止不会取消进行中的下载/子会话请求,最长空跑至
43
+ * 各自的 deadline(下载 30 秒、子会话 timeout_ms);超时/中止后子会话
44
+ * 虽被删除,但 provider 端已发出的孤儿回合仍可能计入用量。
45
+ * - 仅 V1 会话流有效:chat.message 钩子挂在 V1 SessionPrompt 路径上;
46
+ * 若交互默认切到 V2 Session 核心,本钩子不会触发(也不会报错)。
47
+ */
48
+ import { createHash, randomUUID } from "node:crypto";
49
+ import fs from "node:fs/promises";
50
+ import path from "node:path";
51
+ /** 支持的图片扩展名 → MIME 类型(vision_analyze 加载磁盘图片时使用) */
52
+ const EXT_MIME = {
53
+ ".png": "image/png",
54
+ ".jpg": "image/jpeg",
55
+ ".jpeg": "image/jpeg",
56
+ ".gif": "image/gif",
57
+ ".webp": "image/webp",
58
+ };
59
+ /** MIME 类型 → 落盘使用的扩展名(与 EXT_MIME 互为反向映射) */
60
+ const MIME_EXT = {
61
+ "image/png": ".png",
62
+ "image/jpeg": ".jpg",
63
+ "image/gif": ".gif",
64
+ "image/webp": ".webp",
65
+ };
66
+ /**
67
+ * 视觉子会话使用的系统提示词。
68
+ * 要求:精确转录图中文字,描述 UI/布局/对象/颜色等,优先回答用户问题,
69
+ * 不使用工具,纯文本回复。
70
+ */
71
+ const VISION_SYSTEM_PROMPT = [
72
+ "You are an image analysis assistant.",
73
+ "Transcribe any text in the image exactly as it appears.",
74
+ "Describe the UI layout, objects, positions, and colors when present.",
75
+ "Answer the user's question first and foremost.",
76
+ "Do not use tools. Reply with plain text only.",
77
+ ].join("\n");
78
+ /** data URL 形如 data:<mime>;base64,<payload> */
79
+ const DATA_URL_PATTERN = /^data:([^;]+);base64,(.+)$/;
80
+ /**
81
+ * http(s) 下载图片的大小上限(20 MB)。提示注入可让模型指向超大图片,
82
+ * 下载不限长是成本/健壮性放大器:先按 content-length 头提前拒绝,
83
+ * 读取后再按实际字节数复核(防御不带 content-length 的响应)。
84
+ */
85
+ const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
86
+ /**
87
+ * 插件服务端入口。
88
+ *
89
+ * @param input opencode 插件运行环境(client / directory / project 等)
90
+ * @param optionsArg 插件配置,来自 opencode.json 中 plugin 数组的第二项
91
+ */
92
+ const plugin = async (input, optionsArg) => {
93
+ // ---- 选项解析与校验 ----------------------------------------------------
94
+ const model = optionsArg?.model;
95
+ if (typeof model !== "string" || !model.includes("/")) {
96
+ throw new Error(`opencode-vision-analyze requires a "model" option in "provider/model" format, got: ${JSON.stringify(model)}`);
97
+ }
98
+ const separator = model.indexOf("/");
99
+ const visionProviderID = model.slice(0, separator);
100
+ const visionModelID = model.slice(separator + 1);
101
+ // 子会话请求的超时时间:timeout_ms 为正数时生效,默认 60 秒。
102
+ const timeoutOption = optionsArg?.timeout_ms;
103
+ const timeoutMs = typeof timeoutOption === "number" && Number.isFinite(timeoutOption) && timeoutOption > 0 ? timeoutOption : 60000;
104
+ // ---- 闭包状态 ----------------------------------------------------------
105
+ /** sessionID → 该会话最近一次 prompt 的模型(prompt 未显式指定 model 时回退使用) */
106
+ const sessionModels = new Map();
107
+ /** "provider/model" → 是否具备图片输入能力(查询结果缓存,进程级) */
108
+ const imageCapable = new Map();
109
+ /** 描述缓存:"<sha>:<question>" → 描述文本(同一张图 + 同一个问题只描述一次) */
110
+ const descriptions = new Map();
111
+ /** 本插件创建的子会话 ID 集合(正常路径用后即删,dispose 兜底清理残留) */
112
+ const subSessions = new Set();
113
+ /** 任意错误值 → 可读文本:Error 取 message,字符串原样,其余 JSON 序列化兜底。 */
114
+ const errText = (error) => {
115
+ if (error instanceof Error)
116
+ return error.message;
117
+ if (typeof error === "string")
118
+ return error;
119
+ return JSON.stringify(error) ?? String(error);
120
+ };
121
+ /**
122
+ * 给子会话请求加超时与 abort 保护:任一触发即让 Promise 以错误结束,
123
+ * 不再等待底层请求;finally 中清理 timer 与监听器,避免泄漏。
124
+ */
125
+ const withDeadline = (promise, ctx) => {
126
+ let timer;
127
+ let onAbort;
128
+ const guarded = new Promise((_, reject) => {
129
+ // ctx.abort 已中止时 abort 事件不会再触发,必须立即拒绝,
130
+ // 否则 race 只能干等 timer 超时。
131
+ if (ctx.abort.aborted) {
132
+ reject(new DOMException("Aborted", "AbortError"));
133
+ return;
134
+ }
135
+ timer = setTimeout(() => reject(new Error(`vision model call timed out after ${timeoutMs}ms`)), timeoutMs);
136
+ onAbort = () => reject(new DOMException("Aborted", "AbortError"));
137
+ ctx.abort.addEventListener("abort", onAbort, { once: true });
138
+ });
139
+ return Promise.race([promise, guarded]).finally(() => {
140
+ if (timer)
141
+ clearTimeout(timer);
142
+ if (onAbort)
143
+ ctx.abort.removeEventListener("abort", onAbort);
144
+ });
145
+ };
146
+ /**
147
+ * 创建临时子会话(parentID 挂在当前会话下,不进会话列表、不生成标题),
148
+ * 让视觉模型描述一张图片并返回描述文本。
149
+ * 任何失败(会话创建 / 请求 / 超时 / abort / 无文本)都返回 { ok: false, error }。
150
+ * 无论成败,finally 中都会删除子会话——用后即删,不留孤儿。
151
+ */
152
+ const describeImage = async (image, question, ctx) => {
153
+ const dataURL = `data:${image.mime};base64,${image.bytes.toString("base64")}`;
154
+ let subID;
155
+ try {
156
+ const created = await withDeadline(input.client.session.create({ body: { parentID: ctx.sessionID, title: "vision analysis" } }), ctx);
157
+ if (created.error || !created.data) {
158
+ return { ok: false, error: `session create failed: ${errText(created.error)}` };
159
+ }
160
+ subID = created.data.id;
161
+ subSessions.add(subID);
162
+ const response = await withDeadline(input.client.session.prompt({
163
+ path: { id: subID },
164
+ body: {
165
+ model: { providerID: visionProviderID, modelID: visionModelID },
166
+ agent: "build",
167
+ // 子会话禁用全部工具:视觉模型只做纯文本描述,避免它反过来调用
168
+ // vision_analyze 形成递归,也避免任何副作用。
169
+ tools: { "*": false },
170
+ system: VISION_SYSTEM_PROMPT,
171
+ parts: [
172
+ { type: "file", mime: image.mime, url: dataURL },
173
+ { type: "text", text: question },
174
+ ],
175
+ },
176
+ }), ctx);
177
+ if (response.error || !response.data) {
178
+ return { ok: false, error: errText(response.error) || "vision model prompt returned no data" };
179
+ }
180
+ const text = response.data.parts
181
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
182
+ .join("\n")
183
+ .trim();
184
+ if (!text)
185
+ return { ok: false, error: "vision model returned no text" };
186
+ return { ok: true, text };
187
+ }
188
+ finally {
189
+ if (subID) {
190
+ subSessions.delete(subID);
191
+ await input.client.session.delete({ path: { id: subID } }).catch(() => { });
192
+ }
193
+ }
194
+ };
195
+ /**
196
+ * 下载 http(s) URL 指向的图片并落盘到 <visionDir>/<sha256><ext>:
197
+ * 与 chat.message 落盘路径一致,内容哈希命名天然去重。
198
+ * 扩展名不受支持、HTTP 非 2xx、网络失败(含 30 秒下载超时)、超过
199
+ * 20 MB 下载上限(content-length 预检 + 读后复核)都返回 { error },
200
+ * 由调用方转成可读的错误文字。
201
+ */
202
+ const downloadImage = async (url) => {
203
+ try {
204
+ // URL 解析与扩展名提取放在 try 内:畸形 URL 在 new URL 处抛错时,
205
+ // 错误以 "Image download failed" 前缀返回,而不是漏到外层的
206
+ // "Image analysis failed"。
207
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
208
+ const mime = EXT_MIME[ext];
209
+ if (!mime)
210
+ return { error: `unsupported image URL extension: ${ext || "(none)"}` };
211
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
212
+ if (!response.ok)
213
+ return { error: `HTTP ${response.status}` };
214
+ // 头字段缺失时 Number(null) 为 NaN,比较结果为 false,自然放行到读后复核。
215
+ if (Number(response.headers.get("content-length")) > MAX_DOWNLOAD_BYTES) {
216
+ return { error: "image exceeds 20 MB download limit" };
217
+ }
218
+ const bytes = Buffer.from(await response.arrayBuffer());
219
+ // 复核实际字节数:chunked 等无 content-length 的响应只有读后才能判大小。
220
+ if (bytes.length > MAX_DOWNLOAD_BYTES) {
221
+ return { error: "image exceeds 20 MB download limit" };
222
+ }
223
+ const sha = createHash("sha256").update(bytes).digest("hex");
224
+ const dir = path.join(input.directory, ".opencode", "vision");
225
+ await fs.mkdir(dir, { recursive: true });
226
+ const filepath = path.join(dir, `${sha}${ext}`);
227
+ await fs.writeFile(filepath, bytes);
228
+ return { filepath };
229
+ }
230
+ catch (error) {
231
+ return { error: errText(error) };
232
+ }
233
+ };
234
+ /**
235
+ * 从磁盘加载图片:按扩展名识别 MIME,读取失败或文件为空返回 undefined。
236
+ */
237
+ const loadImage = async (filepath) => {
238
+ const mime = EXT_MIME[path.extname(filepath).toLowerCase()];
239
+ if (!mime)
240
+ return undefined;
241
+ try {
242
+ const bytes = await fs.readFile(filepath);
243
+ if (bytes.length === 0)
244
+ return undefined;
245
+ return { bytes, mime };
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ };
251
+ /**
252
+ * vision_analyze 工具:主模型传入图片路径与问题,返回视觉模型给出的描述。
253
+ * 工具永不抛错——所有失败都以错误文字返回,让 agent 循环可以读到原因并
254
+ * 自行决定下一步(重试、换问题或告知用户)。
255
+ */
256
+ const visionAnalyze = async (args, ctx) => {
257
+ const title = "vision_analyze";
258
+ try {
259
+ const question = args.question?.trim() || "Describe this image in full detail.";
260
+ // http(s) URL:先下载到本地 vision 目录,再统一走磁盘加载路径。
261
+ const download = /^https?:\/\//i.test(args.image_path) ? await downloadImage(args.image_path) : undefined;
262
+ if (download && "error" in download) {
263
+ return { title, output: `Image download failed: ${download.error}` };
264
+ }
265
+ const imagePath = download ? download.filepath : args.image_path;
266
+ const image = await loadImage(imagePath);
267
+ if (!image) {
268
+ return { title, output: `Image not found or unsupported: ${args.image_path}` };
269
+ }
270
+ // 快速路径:主模型本身具备视觉能力时,不再走子会话描述,直接把原图
271
+ // 作为附件回传给模型自行查看(省一次往返,模型看到的是原图而非转述)。
272
+ const current = sessionModels.get(ctx.sessionID);
273
+ if (current && (await imageSupport(current.providerID, current.modelID))) {
274
+ return {
275
+ title,
276
+ output: `[Image attached for direct inspection: ${path.basename(imagePath)}]`,
277
+ attachments: [
278
+ {
279
+ type: "file",
280
+ mime: image.mime,
281
+ url: `data:${image.mime};base64,${image.bytes.toString("base64")}`,
282
+ },
283
+ ],
284
+ };
285
+ }
286
+ // 描述缓存:内容哈希 + 问题作为 key,命中直接复用(title 标注 cached)。
287
+ const key = `${createHash("sha256").update(image.bytes).digest("hex")}:${question}`;
288
+ const format = (text) => `[Image: ${path.basename(imagePath)} — described by ${visionProviderID}/${visionModelID}]\n${text}`;
289
+ const cached = descriptions.get(key);
290
+ if (cached !== undefined)
291
+ return { title: `${title} (cached)`, output: format(cached) };
292
+ const result = await describeImage(image, question, ctx);
293
+ if (!result.ok)
294
+ return { title, output: `Image analysis failed: ${result.error}` };
295
+ descriptions.set(key, result.text);
296
+ return { title, output: format(result.text) };
297
+ }
298
+ catch (error) {
299
+ return { title, output: `Image analysis failed: ${errText(error)}` };
300
+ }
301
+ };
302
+ /**
303
+ * 查询某个模型是否支持图片输入。
304
+ * 通过 server 的 /config/providers 接口读取模型 capabilities.input.image,
305
+ * 结果按 "provider/model" 缓存;任何失败都静默返回 false(保守处理:
306
+ * 宁可多注入提示,也不让钩子抛错阻断消息持久化)。
307
+ */
308
+ const imageSupport = async (providerID, modelID) => {
309
+ const key = `${providerID}/${modelID}`;
310
+ const cached = imageCapable.get(key);
311
+ if (cached !== undefined)
312
+ return cached;
313
+ try {
314
+ const result = await input.client.config.providers();
315
+ // HTTP 非 2xx 时 openapi-fetch 不抛错而是返回 { error }(data 为空)。
316
+ // 「查询失败」不能缓存成 false——那是一次瞬时故障而非「确认不支持」,
317
+ // 缓存会永久关闭能力门控;本次保守返回 false,下次再重试。
318
+ if (!result.data)
319
+ return false;
320
+ const provider = result.data.providers?.find((item) => item.id === providerID);
321
+ const capable = provider?.models?.[modelID]?.capabilities?.input?.image === true;
322
+ imageCapable.set(key, capable);
323
+ return capable;
324
+ }
325
+ catch {
326
+ return false;
327
+ }
328
+ };
329
+ /**
330
+ * 把一个图片 file part 落盘到 <directory>/.opencode/vision/<sha256>.<ext>。
331
+ * 文件名用内容哈希,天然去重(同一张图多次发送只落一份)。
332
+ * 返回落盘信息;MIME 不受支持或 URL 不是 base64 data URL 时返回 undefined。
333
+ */
334
+ const persistImage = async (part) => {
335
+ const ext = MIME_EXT[part.mime];
336
+ if (!ext)
337
+ return undefined;
338
+ const match = DATA_URL_PATTERN.exec(part.url);
339
+ if (!match)
340
+ return undefined;
341
+ try {
342
+ const bytes = Buffer.from(match[2], "base64");
343
+ const sha = createHash("sha256").update(bytes).digest("hex");
344
+ const dir = path.join(input.directory, ".opencode", "vision");
345
+ await fs.mkdir(dir, { recursive: true });
346
+ const filepath = path.join(dir, `${sha}${ext}`);
347
+ await fs.writeFile(filepath, bytes);
348
+ return { filepath };
349
+ }
350
+ catch {
351
+ // fail-open 原则:图片落盘失败(EACCES/ENOSPC 等)只是少了 vision_analyze
352
+ // 提示,不应让用户消息落库失败。返回 undefined,外层逐图跳过。
353
+ return undefined;
354
+ }
355
+ };
356
+ /**
357
+ * chat.message 钩子:用户消息持久化前触发(parts 数组与持久化同引用,
358
+ * push 进去的 part 会一并入库)。
359
+ *
360
+ * 职责:
361
+ * 1. 递归防护——视觉模型自身的消息(例如子会话)不做任何处理;
362
+ * 2. 记录会话当前模型;
363
+ * 3. 收集图片 part,没有图片则直接返回;
364
+ * 4. 能力门控——主模型本身能看图则不注入提示;
365
+ * 5. 图片落盘,并注入一条 synthetic text part 引导模型使用 vision_analyze。
366
+ */
367
+ const onChatMessage = async (hookInput, output) => {
368
+ // 记录会话当前模型,供后续 vision_analyze 快速路径与未显式指定 model 的
369
+ // prompt 回退判断。必须先于递归防护:会话模型恰好就是视觉模型时(用户
370
+ // 直接用视觉模型开会话),sessionModels 也要记录,否则快速路径门控永远
371
+ // 看不到该模型,会退化为子会话描述。
372
+ if (hookInput.model)
373
+ sessionModels.set(hookInput.sessionID, hookInput.model);
374
+ // 递归防护:视觉模型自身的 prompt(描述子会话)直接放行,
375
+ // 避免插件处理自己发起的消息造成循环。
376
+ if (hookInput.model?.providerID === visionProviderID && hookInput.model?.modelID === visionModelID)
377
+ return;
378
+ // 只处理 base64 图片附件;没有图片就没有副作用。
379
+ const images = output.parts.filter((part) => part.type === "file" && part.mime.startsWith("image/"));
380
+ if (images.length === 0)
381
+ return;
382
+ // 能力门控:主模型有视觉能力时原图直发,不需要任何提示。
383
+ const current = hookInput.model ?? sessionModels.get(hookInput.sessionID);
384
+ if (current && (await imageSupport(current.providerID, current.modelID)))
385
+ return;
386
+ // 每张图落盘并生成两行提示;任何一张落盘失败就跳过该图(不影响其余图片)。
387
+ const lines = [];
388
+ for (const part of images) {
389
+ const persisted = await persistImage(part);
390
+ if (!persisted)
391
+ continue;
392
+ lines.push(`[The user attached an image: ${part.filename ?? "image"}]`);
393
+ lines.push(`[Examine it with the vision_analyze tool using image_path: ${persisted.filepath}]`);
394
+ }
395
+ if (lines.length === 0)
396
+ return;
397
+ // 注入 synthetic text part:TUI 隐藏(不干扰用户输入展示),但会发给模型。
398
+ // id 需满足 PartID 约定(prt 前缀)。
399
+ const hint = {
400
+ id: `prt_${randomUUID()}`,
401
+ sessionID: hookInput.sessionID,
402
+ messageID: hookInput.messageID ?? output.message.id,
403
+ type: "text",
404
+ synthetic: true,
405
+ text: lines.join("\n"),
406
+ };
407
+ output.parts.push(hint);
408
+ };
409
+ return {
410
+ "chat.message": onChatMessage,
411
+ // 工具注册。参数用 JSON-Schema 形式描述(image_path / question)。
412
+ // 类型签名上 args 是 zod RawShape,但注册表对非 zod 的参数值走
413
+ // JSON-Schema 兼容路径运行时处理;这里做一次受控的边界转换,
414
+ // 既不引入 zod 运行时依赖(本插件只用 node 内置模块),也不使用 any。
415
+ tool: {
416
+ vision_analyze: {
417
+ description: "Analyze an image with the dedicated vision model. image_path is an absolute file path (as given in the user's attachment hint) or an http(s) image URL. question describes what to look for; be specific.",
418
+ args: {
419
+ image_path: { type: "string", description: "Absolute path to the image file, or an http(s) image URL." },
420
+ question: { type: "string", description: "What to look for or answer about the image." },
421
+ },
422
+ execute: visionAnalyze,
423
+ },
424
+ },
425
+ // dispose:清理可能残留的子会话(正常路径用后即删,这里兜底异常路径),
426
+ // 删除失败静默忽略——插件卸载不应因清理失败而报错。
427
+ dispose: async () => {
428
+ for (const id of subSessions) {
429
+ await input.client.session.delete({ path: { id } }).catch(() => { });
430
+ }
431
+ subSessions.clear();
432
+ },
433
+ };
434
+ };
435
+ export default { id: "opencode-vision-analyze", server: plugin };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "opencode-vision-analyze",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "OpenCode plugin: vision_analyze tool — describe images & image URLs with your own vision model for text-only main models; native-image fast path for multimodal models. Zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "README.zh.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit",
19
+ "build": "tsc",
20
+ "test": "bun test",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "opencode",
25
+ "opencode-plugin",
26
+ "vision",
27
+ "vision-model",
28
+ "image",
29
+ "multimodal",
30
+ "plugin"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/MwumLi/opencode-vision-analyze.git"
36
+ },
37
+ "homepage": "https://github.com/MwumLi/opencode-vision-analyze#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/MwumLi/opencode-vision-analyze/issues"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "devDependencies": {
48
+ "@opencode-ai/plugin": "^1.16.2",
49
+ "@opencode-ai/sdk": "^1.16.2",
50
+ "@types/node": "^22.13.9",
51
+ "typescript": "^5.8.2"
52
+ }
53
+ }