opencode-vision-analyze 0.1.0-beta.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,22 +8,7 @@ A tool-based vision routing plugin for [opencode](https://opencode.ai): when the
8
8
 
9
9
  **Zero runtime dependencies.** Only node builtins (`crypto`/`fs`/`path`) and type-only imports — nothing to install beyond the plugin itself.
10
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:
11
+ ## Features
27
12
 
28
13
  - **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
14
  - **Question-aware descriptions.** The model passes its own focused question to `vision_analyze` — not a one-shot generic caption computed at submit time.
@@ -31,12 +16,6 @@ Highlights:
31
16
  - **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
17
  - **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
18
 
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
19
  ## Installation
41
20
 
42
21
  ### Option A — npm (recommended)
@@ -45,7 +24,7 @@ Highlights:
45
24
  // opencode.json (project or global)
46
25
  {
47
26
  "plugin": [
48
- ["opencode-vision-analyze", { "model": "openai/gpt-4o-mini" }]
27
+ ["opencode-vision-analyze", { "models": ["openai/gpt-4o-mini"] }]
49
28
  ]
50
29
  }
51
30
  ```
@@ -66,14 +45,14 @@ curl -fsSL https://raw.githubusercontent.com/MwumLi/opencode-vision-analyze/main
66
45
  // opencode.json
67
46
  {
68
47
  "plugin": [
69
- ["./.opencode/vision-analyze.ts", { "model": "openai/gpt-4o-mini" }]
48
+ ["./.opencode/vision-analyze.ts", { "models": ["openai/gpt-4o-mini"] }]
70
49
  ]
71
50
  }
72
51
  ```
73
52
 
74
53
  Notes for the curl path:
75
54
 
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.
55
+ - The URL above points at the `main` branch (latest source); for a pinned release, swap `main` for a release tag (e.g. `v0.1.0`) and re-run curl.
77
56
  - The file is TypeScript source — opencode loads plugins with Bun, so this works as-is.
78
57
  - Options must be passed via the `plugin` tuple (the auto-discovered `.opencode/plugins/` directory can't carry options).
79
58
 
@@ -81,15 +60,40 @@ Notes for the curl path:
81
60
 
82
61
  | Option | Required | Default | Description |
83
62
  |---|---|---|---|
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 |
63
+ | `models` | no | — | Ordered candidate list of vision models (`provider/model`), tried one after another until one succeeds. A single vision model is written as `models: ["..."]`. When omitted (or an empty array) the plugin auto-discovers all image-capable models. |
64
+ | `unlisted_fallback` | no | `false` | When an explicit `models` chain is configured and it is exhausted, keep going with image-capable models that were not listed. |
65
+ | `free_first` | no | `false` | In auto-discovery, prefer anonymous/built-in free providers (`custom` source) ahead of config-defined ones — reverses the source-tier order. |
66
+ | `timeout_ms` | no | `60000` | Timeout (ms) budget for each individual `create`/`prompt` request inside the vision sub-session |
67
+
68
+ Supported image extensions: png / jpg / jpeg / gif / webp.
69
+
70
+ An ordered-candidates example with auto-fallback and free-first discovery:
71
+
72
+ ```jsonc
73
+ // opencode.json
74
+ {
75
+ "plugin": [
76
+ [
77
+ "opencode-vision-analyze",
78
+ {
79
+ "models": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o-mini"],
80
+ "unlisted_fallback": true,
81
+ "free_first": true
82
+ }
83
+ ]
84
+ ]
85
+ }
86
+ ```
86
87
 
87
88
  ## How it works
88
89
 
89
90
  ```
90
91
  User pastes image + question
91
92
  └─ chat.message hook (before persist)
93
+ ├─ message model ∈ candidate chain → do nothing (recursion guard)
92
94
  ├─ main model has image input capability → do nothing (raw image goes to model)
95
+ ├─ no image-capable model available → do nothing (no hint, no persist;
96
+ │ core's default image handling applies)
93
97
  └─ text-only main model → persist image to .opencode/vision/<sha256>.<ext>
94
98
  and inject a synthetic hint (hidden in TUI, visible to model):
95
99
  "use the vision_analyze tool with image_path: ..."
@@ -103,15 +107,19 @@ vision_analyze tool:
103
107
  │ → return raw image as attachment (no vision model call)
104
108
  ├─ http(s) image URL → download (20 MB cap) → same disk path
105
109
  ├─ 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
110
+ │ (tagged with the model that produced it)
111
+ └─ candidate chain: sub-session under current session per candidate, in order —
112
+ parentID, all tools disabled, dedicated system prompt, image + question
113
+ sent to that vision model → first success returns → sub-session deleted
109
114
  ```
110
115
 
111
116
  Key behaviors:
112
117
 
113
118
  - **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.
119
+ - **Candidate chain** — the `models` list is tried in order until one succeeds. Explicit models always head the chain. With no explicit config (or an empty `models` list) the plugin auto-discovers every image-capable model, ordered by provider source (config first, then env/api, then custom/anonymous; reversed with `free_first: true`). With `unlisted_fallback: true`, an exhausted explicit chain continues onto unlisted image-capable models.
120
+ - **Recursion guard (whole chain)** — messages from the candidate chain's own sub-sessions are never re-processed.
121
+ - **Empty chain degradation** — if no vision model is available at all, the plugin still loads: pasted images are left untouched (no hint injected) and the tool returns a clear error instead of routing.
122
+ - **Loginless free models** — auto-discovery uses `config.providers()`, the same source as the `/models` picker, so image-capable zen free models are found even without login (their provider is `custom` source → default last tier; put them first with `free_first: true`).
115
123
  - **The tool never throws** — every failure returns readable text so the agent loop can retry, rephrase, or inform the user.
116
124
  - **URL images** — `image_path` accepts `http(s)://...` URLs (must end in a supported image extension: png/jpg/jpeg/gif/webp).
117
125
 
@@ -122,11 +130,9 @@ Key behaviors:
122
130
  - **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
131
  - **Historical images** — images from messages sent before the plugin was enabled can't be described (no hint, no path on disk).
124
132
  - **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
133
 
127
134
  ## Roadmap
128
135
 
129
- - [ ] `model: undefined` fallback for first messages without an explicit model
130
136
  - [ ] Timeout wrapping for the capability query (`config.providers()`)
131
137
  - [ ] Abort sub-session (`/session/{id}/abort`) before delete on timeout
132
138
  - [ ] LRU / size cap for the description cache
@@ -144,6 +150,31 @@ bun run build # tsc → dist/
144
150
 
145
151
  The unit tests stub the plugin input/client — no running opencode instance is required.
146
152
 
153
+ ## Release
154
+
155
+ Versioning is driven entirely by `npm version` — no manual `package.json` edits. It updates the version, creates a commit and an annotated `v<version>` tag, and (via hooks) runs a local gate then pushes to trigger the GitHub release workflow that publishes to npm.
156
+
157
+ ```bash
158
+ npm version patch # 0.1.x → 0.1.(x+1): commit + tag v0.1.x, auto-push → release
159
+ npm version 1.2.0 # explicit full version
160
+ npm version prerelease --preid beta # beta smoke: 0.1.1 → 0.1.2-beta.0
161
+ ```
162
+
163
+ Hooks configured in `package.json`:
164
+
165
+ - `preversion` — runs `typecheck && test && build` locally; if any fails the version is not bumped or tagged.
166
+ - `postversion` — `git push --follow-tags`; pushes the commit and its tag, which triggers the GitHub Actions `release.yml` (`on.push.tags: ["v*"]`) that runs the checks again and `npm publish --access public` using the `NPM_TOKEN` secret.
167
+
168
+ Beta smoke → stable flow:
169
+
170
+ ```bash
171
+ npm version prerelease --preid beta # publish a beta to npm
172
+ # verify the beta on npm, then:
173
+ npm version patch # drops the pre-release and bumps to the stable version
174
+ ```
175
+
176
+ Escape hatches: `npm version 1.2.3 --no-git-tag-version` (only bump the file) or `--ignore-scripts` (skip all hooks). `npm version` requires a clean working tree. If the `postversion` push fails, run `git push --follow-tags` manually.
177
+
147
178
  ## License
148
179
 
149
180
  [MIT](./LICENSE)
package/README.zh.md CHANGED
@@ -10,22 +10,7 @@
10
10
 
11
11
  **零运行时依赖。** 只用 node 内置模块(`crypto`/`fs`/`path`)和纯类型导入——除插件本身外无需安装任何东西。
12
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
- 亮点:
13
+ ## 特性
29
14
 
30
15
  - **工具化,而非提交时预分析。** 轮次即时启动;模型自己决定何时看图、带着什么问题看。提交零阻塞,失败在 agent 循环里可见、可重试。
31
16
  - **描述针对问题。** 模型把自己关注的问题传给 `vision_analyze`——而不是提交时预生成的一次性通用描述。
@@ -33,12 +18,6 @@
33
18
  - **内容寻址缓存。** 图片按 `<sha256>.<ext>` 落盘(跨会话天然去重);描述按 `<图片哈希>:<问题>` 缓存——同图同问题只描述一次。
34
19
  - **统一鉴权。** 视觉调用走 opencode 子会话,复用 opencode 已管理的 provider 凭据,无需额外配置 API Key。
35
20
 
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
21
  ## 安装
43
22
 
44
23
  ### 方式 A —— npm(推荐)
@@ -47,7 +26,7 @@
47
26
  // opencode.json(项目级或全局)
48
27
  {
49
28
  "plugin": [
50
- ["opencode-vision-analyze", { "model": "openai/gpt-4o-mini" }]
29
+ ["opencode-vision-analyze", { "models": ["openai/gpt-4o-mini"] }]
51
30
  ]
52
31
  }
53
32
  ```
@@ -68,14 +47,14 @@ curl -fsSL https://raw.githubusercontent.com/MwumLi/opencode-vision-analyze/main
68
47
  // opencode.json
69
48
  {
70
49
  "plugin": [
71
- ["./.opencode/vision-analyze.ts", { "model": "openai/gpt-4o-mini" }]
50
+ ["./.opencode/vision-analyze.ts", { "models": ["openai/gpt-4o-mini"] }]
72
51
  ]
73
52
  }
74
53
  ```
75
54
 
76
55
  curl 方式说明:
77
56
 
78
- - 建议固定到发布 tag 而非 `main`,如 `.../opencode-vision-analyze/v0.1.0/src/index.ts`;升级即重新 curl。
57
+ - 上方 URL 指向 `main` 分支(最新源码);如需固定版本,把 `main` 换成发布 tag(如 `v0.1.0`)再重新 curl。
79
58
  - 文件是 TypeScript 源码——opencode 用 Bun 加载插件,直接可用。
80
59
  - 选项必须通过 `plugin` 元组传入(`.opencode/plugins/` 自动发现目录无法携带选项)。
81
60
 
@@ -83,15 +62,40 @@ curl 方式说明:
83
62
 
84
63
  | 选项 | 必填 | 默认值 | 说明 |
85
64
  |---|---|---|---|
86
- | `model` | | — | 视觉模型,`provider/model` 格式,如 `"anthropic/claude-sonnet-4-5"`、`"openai/gpt-4o-mini"` |
87
- | `timeout_ms` | 否 | `60000` | 视觉子会话请求超时(毫秒) |
65
+ | `models` | | — | 有序候选视觉模型数组(`provider/model`),逐个尝试直到成功即止;单个视觉模型写作 `models: ["..."]`。缺省或空数组时自动发现全部 image-capable 模型。 |
66
+ | `unlisted_fallback` | 否 | `false` | 显式 `models` 链耗尽后,自动续试未列入清单的 image-capable 模型。 |
67
+ | `free_first` | 否 | `false` | 自动发现时优先匿名/内置免费(`custom` 源)provider,置于 config 源之前——反转 source 档序。 |
68
+ | `timeout_ms` | 否 | `60000` | 子会话内每次 create/prompt 请求各自的超时预算(毫秒) |
69
+
70
+ 受支持的图片扩展名:png / jpg / jpeg / gif / webp。
71
+
72
+ 有序候选 + 自动续接 + 免费优先的配置示例:
73
+
74
+ ```jsonc
75
+ // opencode.json
76
+ {
77
+ "plugin": [
78
+ [
79
+ "opencode-vision-analyze",
80
+ {
81
+ "models": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o-mini"],
82
+ "unlisted_fallback": true,
83
+ "free_first": true
84
+ }
85
+ ]
86
+ ]
87
+ }
88
+ ```
88
89
 
89
90
  ## 工作原理
90
91
 
91
92
  ```
92
93
  用户贴图 + 提问
93
94
  └─ chat.message 钩子(消息持久化前)
95
+ ├─ 消息模型 ∈ 候选链 → 不做任何处理(递归防护)
94
96
  ├─ 主模型支持图片输入 → 不做任何处理(原图直发)
97
+ ├─ 无任何 image-capable 模型 → 不做处理(不注入 hint、不落盘;
98
+ │ 交给核心对图片的默认处理)
95
99
  └─ 纯文本主模型 → 图片落盘 .opencode/vision/<sha256>.<ext>
96
100
  并注入 synthetic 提示(TUI 隐藏、模型可见):
97
101
  "用 vision_analyze 工具查看,image_path: ..."
@@ -105,15 +109,19 @@ vision_analyze 工具:
105
109
  │ → 原图作为附件直接返回(不调视觉模型)
106
110
  ├─ http(s) 图片 URL → 下载(20 MB 上限)→ 统一磁盘路径
107
111
  ├─ 描述缓存命中(图片哈希 + 问题)→ 直接返回缓存文本
108
- └─ 子会话:parentID 挂当前会话、禁用全部工具、专用 system
109
- prompt,图片 + 问题发给你的视觉模型
110
- 返回描述文字 子会话立即删除
112
+ │ (标签沿用产出该描述的模型)
113
+ └─ 候选链:沿链逐候选建子会话(parentID 挂当前会话、禁用全部工具、
114
+ 专用 system prompt,图片 + 问题发给该候选视觉模型)
115
+ → 首个成功即返回 → 子会话删除
111
116
  ```
112
117
 
113
118
  关键行为:
114
119
 
115
120
  - **能力门控** —— 查询 `config.providers()` 能力字段,进程级缓存;有视觉能力的主模型永远不会收到提示或被路由。
116
- - **递归防护** —— 视觉模型自己的消息(来自子会话)不会被再次处理。
121
+ - **候选链** —— `models` 列表按序逐个尝试直到成功。显式模型恒在链首;无显式配置(或 `models` 为空数组)时自动发现全部 image-capable 模型,按 provider 来源排序(config 最前 → env/api → custom/匿名;`free_first: true` 时反转)。`unlisted_fallback: true` 时显式链耗尽后会续试未列出的 image-capable 模型。
122
+ - **递归防护(整链)** —— 候选链子会话发起的消息不会被再次处理。
123
+ - **空链降级** —— 完全没有可用视觉模型时插件仍正常加载:贴图保持原样(不注入 hint),工具返回清晰错误而非路由。
124
+ - **免登录免费模型** —— 自动发现与 `/models` 选择器同源(`config.providers()`),免登录也可发现的 zen free 视觉模型会进入候选链(其 provider 为 `custom` 源 → 默认最末档;`free_first: true` 可提到最前)。
117
125
  - **工具永不抛错** —— 所有失败都返回可读文字,agent 循环可以重试、换问题或告知用户。
118
126
  - **URL 图片** —— `image_path` 接受 `http(s)://...` 地址(需以受支持的图片扩展名结尾:png/jpg/jpeg/gif/webp)。
119
127
 
@@ -124,11 +132,9 @@ vision_analyze 工具:
124
132
  - **中止不传导** —— 用户中止不会取消进行中的下载/子会话请求,它们会跑到各自的 deadline(下载 30 秒、子会话 `timeout_ms`)。超时/中止后子会话虽被删除,但 provider 端的孤儿回合仍可能计费。
125
133
  - **历史图片** —— 插件启用之前发送的图片无法被描述(无提示、磁盘上无路径)。
126
134
  - **缓存无上限** —— 图片存储与描述缓存均不淘汰(进程级 / 项目目录级)。
127
- - **单模型无备选链** —— 只有一个显式 `model` 选项;失败时返回错误文字,不会尝试其他 provider。
128
135
 
129
136
  ## Roadmap
130
137
 
131
- - [ ] `model` 未显式指定时对首条消息的回退处理
132
138
  - [ ] 能力查询(`config.providers()`)加超时保护
133
139
  - [ ] 超时路径先中止子会话(`/session/{id}/abort`)再删除
134
140
  - [ ] 描述缓存 LRU / 容量上限
@@ -146,6 +152,31 @@ bun run build # tsc → dist/
146
152
 
147
153
  单元测试使用 stub 的插件输入/client——不需要运行中的 opencode。
148
154
 
155
+ ## 发布
156
+
157
+ 版本变更完全由 `npm version` 驱动——无需手改 `package.json`。它会更新版本号、创建 commit 与 annotated `v<版本>` tag,并通过钩子先跑本地门禁再自动推送,触发 GitHub release workflow 发布到 npm。
158
+
159
+ ```bash
160
+ npm version patch # 0.1.x → 0.1.(x+1):commit + tag v0.1.x,自动推送 → 发布
161
+ npm version 1.2.0 # 显式指定完整版本
162
+ npm version prerelease --preid beta # beta 冒烟:0.1.1 → 0.1.2-beta.0
163
+ ```
164
+
165
+ `package.json` 中配置的钩子:
166
+
167
+ - `preversion` —— 本地执行 `typecheck && test && build`;任一失败则不 bump / 不打 tag。
168
+ - `postversion` —— `git push --follow-tags`;推送 commit 及其 tag,触发 GitHub Actions `release.yml`(`on.push.tags: ["v*"]`),先复跑全部检查再用 `NPM_TOKEN` secret 执行 `npm publish --access public`。
169
+
170
+ beta 冒烟 → 正式两段式:
171
+
172
+ ```bash
173
+ npm version prerelease --preid beta # 先发一个 beta 到 npm
174
+ # 在 npm 上验证 beta 无误后:
175
+ npm version patch # 去掉 pre 段并升到正式版本
176
+ ```
177
+
178
+ 逃逸舱:`npm version 1.2.3 --no-git-tag-version`(只改版本文件)或 `--ignore-scripts`(跳过全部钩子)。`npm version` 要求工作区干净;若 `postversion` 推送失败,手动执行 `git push --follow-tags`。
179
+
149
180
  ## 许可证
150
181
 
151
182
  [MIT](./LICENSE)
package/dist/index.js CHANGED
@@ -9,19 +9,26 @@
9
9
  *
10
10
  * 安装方式一(npm):
11
11
  * {
12
- * "plugin": [["opencode-vision-analyze", { "model": "provider/vision-model" }]]
12
+ * "plugin": [["opencode-vision-analyze", { "models": ["provider/vision-model"] }]]
13
13
  * }
14
14
  *
15
15
  * 安装方式二(curl 下载单文件,免 npm):
16
16
  * mkdir -p .opencode
17
17
  * curl -fsSL <raw-url>/src/index.ts -o .opencode/vision-analyze.ts
18
18
  * {
19
- * "plugin": [["./.opencode/vision-analyze.ts", { "model": "provider/vision-model" }]]
19
+ * "plugin": [["./.opencode/vision-analyze.ts", { "models": ["provider/vision-model"] }]]
20
20
  * }
21
21
  *
22
22
  * 选项:
23
- * - model(必填):视觉模型的 "provider/model" 标识,例如 "anthropic/claude-sonnet-4-5"
24
- * - timeout_ms:子会话请求的超时毫秒数(正数,默认 60000)
23
+ * - models(可选,缺省/空数组 = 自动模式):有序视觉候选数组,如
24
+ * ["provider-a/m1", "provider-b/m2"];单模型写 ["provider/model"] 即可
25
+ * - unlisted_fallback(可选,默认 false):显式候选耗尽后自动续接未列出的 image-capable 模型
26
+ * - free_first(可选,默认 false):自动发现档序反转(custom/匿名免费源优先,默认 config 优先)
27
+ * - timeout_ms:单候选子会话请求的超时毫秒数(正数,默认 60000)
28
+ *
29
+ * 候选链语义:显式 models 恒在链首;缺省/空数组 → 自动发现全部 image-capable
30
+ * 模型并按 Provider.source 档序排列。链上候选逐个尝试,成功即止,全败聚合报错。
31
+ * 描述子会话的模型属于候选链,chat.message 递归防护以整链成员为集。
25
32
  *
26
33
  * 工作方式(vision_analyze 工具路径):主模型调用 vision_analyze 时,插件
27
34
  * 创建一个 parentID 挂在当前会话下的临时子会话(不进会话列表、不生成
@@ -91,13 +98,35 @@ const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
91
98
  */
92
99
  const plugin = async (input, optionsArg) => {
93
100
  // ---- 选项解析与校验 ----------------------------------------------------
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)}`);
101
+ // 归一化规则:models 是唯一显式入口 —— 有序候选数组(保序去重,重复只留首个)。
102
+ // 缺省或空数组 explicit 为空(进入自动发现,见 resolveChain)。传了 models
103
+ // 类型不对(非字符串数组)直接抛错,避免用户配错被静默当成自动模式。
104
+ const modelsOption = optionsArg?.models;
105
+ if (optionsArg?.models !== undefined && !Array.isArray(modelsOption)) {
106
+ throw new Error('opencode-vision-analyze option "models" must be an array of "provider/model" strings');
97
107
  }
98
- const separator = model.indexOf("/");
99
- const visionProviderID = model.slice(0, separator);
100
- const visionModelID = model.slice(separator + 1);
108
+ const hasModels = Array.isArray(modelsOption) && modelsOption.length > 0;
109
+ // 收集字符串候选并逐项校验 provider/model 格式(modelID 允许含 "/",按首个 "/" 切分)。
110
+ // 逐项 throw:元素非法报 label "models[i]";保序去重(重复只留首个)。
111
+ const raw = hasModels ? modelsOption : [];
112
+ const explicitModels = [];
113
+ const seenKeys = new Set();
114
+ raw.forEach((item, index) => {
115
+ const label = `models[${index}]`;
116
+ if (typeof item !== "string" || !item.includes("/")) {
117
+ throw new Error(`opencode-vision-analyze option "${label}" must be in "provider/model" format, got: ${JSON.stringify(item)}`);
118
+ }
119
+ const sep = item.indexOf("/");
120
+ if (seenKeys.has(item))
121
+ return; // 重复模型只保留首个
122
+ seenKeys.add(item);
123
+ explicitModels.push({ providerID: item.slice(0, sep), modelID: item.slice(sep + 1) });
124
+ });
125
+ // unlisted_fallback:显式链耗尽后是否自动续接未列出的 image-capable 模型(仅显式配置时
126
+ // 生效)。free_first:自动发现档序是否反转(匿名/内置 custom 优先,默认 config 优先)。
127
+ // 二者按严格布尔取真,非布尔值宽容忽略按 false 处理(与下方 timeout_ms 的宽容校验一致)。
128
+ const fallbackUnlisted = optionsArg?.unlisted_fallback === true;
129
+ const freeFirst = optionsArg?.free_first === true;
101
130
  // 子会话请求的超时时间:timeout_ms 为正数时生效,默认 60 秒。
102
131
  const timeoutOption = optionsArg?.timeout_ms;
103
132
  const timeoutMs = typeof timeoutOption === "number" && Number.isFinite(timeoutOption) && timeoutOption > 0 ? timeoutOption : 60000;
@@ -106,7 +135,11 @@ const plugin = async (input, optionsArg) => {
106
135
  const sessionModels = new Map();
107
136
  /** "provider/model" → 是否具备图片输入能力(查询结果缓存,进程级) */
108
137
  const imageCapable = new Map();
109
- /** 描述缓存:"<sha>:<question>" → 描述文本(同一张图 + 同一个问题只描述一次) */
138
+ /**
139
+ * 描述缓存:"<sha>:<question>" → 描述结果(同一张图 + 同一个问题只描述一次)。
140
+ * 值带 modelId:记录实际产出该描述的候选模型,缓存命中时标签沿用入库模型,
141
+ * 而不是用当前候选链链首近似(链配置变化或 fallback 命中次选时标签才真实)。
142
+ */
110
143
  const descriptions = new Map();
111
144
  /** 本插件创建的子会话 ID 集合(正常路径用后即删,dispose 兜底清理残留) */
112
145
  const subSessions = new Set();
@@ -143,13 +176,16 @@ const plugin = async (input, optionsArg) => {
143
176
  ctx.abort.removeEventListener("abort", onAbort);
144
177
  });
145
178
  };
179
+ /** 判断错误是否为中止信号(AbortError),供 attemptModel 标记 / 链循环中止整链。 */
180
+ const isAbortError = (error) => error instanceof Error && error.name === "AbortError";
146
181
  /**
147
- * 创建临时子会话(parentID 挂在当前会话下,不进会话列表、不生成标题),
148
- * 让视觉模型描述一张图片并返回描述文本。
149
- * 任何失败(会话创建 / 请求 / 超时 / abort / 无文本)都返回 { ok: false, error }。
150
- * 无论成败,finally 中都会删除子会话——用后即删,不留孤儿。
182
+ * 单个候选的尝试:创建子会话(parentID 挂当前会话)→ 用该候选模型描述 →
183
+ * 删除子会话。任何失败(创建 / 请求 / 超时 / 中止 / 底层抛错 / 无文本)都
184
+ * 收敛为 { ok: false } 返回而不向上抛——推进与否交给 describeWithChain 的
185
+ * 链循环决策;中止额外打 aborted 标记,链循环据此立即停整链。无论成败,
186
+ * finally 中都删除子会话——用后即删,不留孤儿。
151
187
  */
152
- const describeImage = async (image, question, ctx) => {
188
+ const attemptModel = async (candidate, image, question, ctx) => {
153
189
  const dataURL = `data:${image.mime};base64,${image.bytes.toString("base64")}`;
154
190
  let subID;
155
191
  try {
@@ -162,7 +198,7 @@ const plugin = async (input, optionsArg) => {
162
198
  const response = await withDeadline(input.client.session.prompt({
163
199
  path: { id: subID },
164
200
  body: {
165
- model: { providerID: visionProviderID, modelID: visionModelID },
201
+ model: { providerID: candidate.providerID, modelID: candidate.modelID },
166
202
  agent: "build",
167
203
  // 子会话禁用全部工具:视觉模型只做纯文本描述,避免它反过来调用
168
204
  // vision_analyze 形成递归,也避免任何副作用。
@@ -185,6 +221,10 @@ const plugin = async (input, optionsArg) => {
185
221
  return { ok: false, error: "vision model returned no text" };
186
222
  return { ok: true, text };
187
223
  }
224
+ catch (error) {
225
+ // 异常(超时 / 中止 / 底层抛错)同样收敛为失败结果;aborted 标记交由链循环判断
226
+ return { ok: false, error: errText(error), aborted: isAbortError(error) };
227
+ }
188
228
  finally {
189
229
  if (subID) {
190
230
  subSessions.delete(subID);
@@ -192,6 +232,36 @@ const plugin = async (input, optionsArg) => {
192
232
  }
193
233
  }
194
234
  };
235
+ /**
236
+ * 候选链描述:沿 resolveChain() 产出的候选链逐个尝试,首个成功即返回(附带
237
+ * 成功候选的引用键作标签来源);单个候选失败记录 `${key}: ${error}` 并推进
238
+ * 下一个;收到 abort(pre-abort 短路或尝试结果的 aborted 标记)立即中止整链;
239
+ * 全部失败聚合各候选原因;空链返回友好错误。本函数永不抛错。
240
+ */
241
+ const describeWithChain = async (image, question, ctx) => {
242
+ // pre-abort 短路:不解析候选链、不建子会话,直接以 Aborted 收尾
243
+ if (ctx.abort.aborted)
244
+ return { ok: false, error: "Aborted" };
245
+ const chain = await resolveChain();
246
+ const failures = [];
247
+ for (const candidate of chain) {
248
+ const attempt = await attemptModel(candidate, image, question, ctx);
249
+ if (attempt.ok)
250
+ return { ok: true, text: attempt.text, modelId: modelRefKey(candidate) };
251
+ if (attempt.aborted)
252
+ return { ok: false, error: attempt.error }; // abort 中止整链
253
+ failures.push(`${modelRefKey(candidate)}: ${attempt.error}`);
254
+ }
255
+ if (failures.length === 0) {
256
+ return {
257
+ ok: false,
258
+ error: "no image-capable model configured (set the plugin models option or configure an image-capable provider model)",
259
+ };
260
+ }
261
+ return { ok: false, error: `all ${failures.length} candidate model(s) failed: ${failures.join("; ")}` };
262
+ };
263
+ /** 描述标签:标注图片文件名与产出描述的模型引用键(basename 运行时按图传入)。 */
264
+ const format = (basename, modelId, text) => `[Image: ${basename} — described by ${modelId}]\n${text}`;
195
265
  /**
196
266
  * 下载 http(s) URL 指向的图片并落盘到 <visionDir>/<sha256><ext>:
197
267
  * 与 chat.message 落盘路径一致,内容哈希命名天然去重。
@@ -285,15 +355,19 @@ const plugin = async (input, optionsArg) => {
285
355
  }
286
356
  // 描述缓存:内容哈希 + 问题作为 key,命中直接复用(title 标注 cached)。
287
357
  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
358
  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);
359
+ if (cached !== undefined) {
360
+ // 命中时标签沿用入库时的模型(cached.modelId):即便此刻候选链链首
361
+ // 已与入库模型不同,也保持标签真实、不重写。
362
+ return { title: `${title} (cached)`, output: format(path.basename(imagePath), cached.modelId, cached.text) };
363
+ }
364
+ const result = await describeWithChain(image, question, ctx);
293
365
  if (!result.ok)
294
366
  return { title, output: `Image analysis failed: ${result.error}` };
295
- descriptions.set(key, result.text);
296
- return { title, output: format(result.text) };
367
+ // 入库带上实际产出描述的候选 modelId,供后续缓存命中还原真实标签
368
+ descriptions.set(key, { modelId: result.modelId, text: result.text });
369
+ // 成功标签直接用实际产出描述的候选引用键(而非链首近似)
370
+ return { title, output: format(path.basename(imagePath), result.modelId, result.text) };
297
371
  }
298
372
  catch (error) {
299
373
  return { title, output: `Image analysis failed: ${errText(error)}` };
@@ -326,6 +400,71 @@ const plugin = async (input, optionsArg) => {
326
400
  return false;
327
401
  }
328
402
  };
403
+ /** Provider.source → 自动发现档位:config 最优先,env/api 次之,custom 与未知值最末 */
404
+ const tierOfSource = (source) => source === "config" ? 0 : source === "env" || source === "api" ? 1 : 2;
405
+ /** "provider/model" 引用键(与 imageCapable 缓存的键格式一致) */
406
+ const modelRefKey = (c) => `${c.providerID}/${c.modelID}`;
407
+ // 候选链 memoize:chat.message 钩子与 vision_analyze 工具共享一次 providers 查询
408
+ let chainPromise;
409
+ /**
410
+ * 归一化产出最终候选链(统一数组,运行时只做逐个尝试):
411
+ * - 显式非空:显式链恒在链首(不受 free_first/档序影响);
412
+ * unlisted_fallback=true 时再追加未列出的 image-capable 模型
413
+ * - 显式为空:整链 = 自动发现(全部 image-capable 模型,按 source 档序)
414
+ */
415
+ const resolveChain = () => {
416
+ chainPromise ??= (async () => {
417
+ // 自动模式:整链由自动发现决定
418
+ if (explicitModels.length === 0)
419
+ return listImageCapableModels();
420
+ // 显式模式:默认只用显式链;unlisted_fallback=true 时追加 inventory 中未列出的模型
421
+ if (!fallbackUnlisted)
422
+ return explicitModels;
423
+ const inventory = await listImageCapableModels();
424
+ const explicitSet = new Set(explicitModels.map(modelRefKey));
425
+ return [...explicitModels, ...inventory.filter((c) => !explicitSet.has(modelRefKey(c)))];
426
+ })();
427
+ return chainPromise;
428
+ };
429
+ /**
430
+ * 枚举 config.providers() 中全部 image-capable 模型,并按 Provider.source 档位
431
+ * 稳定排序(档内保持 providers 返回顺序):默认 config > env/api > custom;
432
+ * free_first=true 时档序反转(custom 优先)。顺带预填 imageCapable 缓存
433
+ * (与 imageSupport 同源,避免后续重复请求)。providers 查询瞬时失败返回空数组
434
+ * 并记日志:显式链仍可用(fallback 追加部分静默跳过),自动模式退化为空链——
435
+ * 因 resolveChain 的 memoize,本次空链会持续整个进程(见 docs/superpowers/specs/2026-09-05-opencode-vision-analyze-design.md 已知限制)。
436
+ */
437
+ const listImageCapableModels = async () => {
438
+ try {
439
+ const result = await input.client.config.providers();
440
+ if (!result.data)
441
+ return [];
442
+ const found = [];
443
+ for (const provider of result.data.providers ?? []) {
444
+ const tier = tierOfSource(provider.source);
445
+ for (const [modelID, model] of Object.entries(provider.models ?? {})) {
446
+ if (model?.capabilities?.input?.image !== true)
447
+ continue;
448
+ imageCapable.set(`${provider.id}/${modelID}`, true);
449
+ found.push({ providerID: provider.id, modelID, tier });
450
+ }
451
+ }
452
+ // 稳定排序:默认按档位升序(config 优先);free_first 反转成降序(custom 优先)
453
+ found.sort((a, b) => (freeFirst ? b.tier - a.tier : a.tier - b.tier));
454
+ return found.map(({ providerID, modelID }) => ({ providerID, modelID }));
455
+ }
456
+ catch (error) {
457
+ // 自动发现的关键 providers 查询失败要可观测:不静默吞掉,记一行日志便于定位。
458
+ // 显式链不受影响;自动模式按空链处理(本次进程内不再重试,见已知限制)。
459
+ console.error("[opencode-vision-analyze] config.providers() failed; auto vision discovery disabled", error);
460
+ return [];
461
+ }
462
+ };
463
+ /** 判断某 model 是否为当前候选链成员(chat.message 递归防护用) */
464
+ const isCandidateModel = async (model) => {
465
+ const chain = await resolveChain();
466
+ return chain.some((c) => c.providerID === model.providerID && c.modelID === model.modelID);
467
+ };
329
468
  /**
330
469
  * 把一个图片 file part 落盘到 <directory>/.opencode/vision/<sha256>.<ext>。
331
470
  * 文件名用内容哈希,天然去重(同一张图多次发送只落一份)。
@@ -358,11 +497,12 @@ const plugin = async (input, optionsArg) => {
358
497
  * push 进去的 part 会一并入库)。
359
498
  *
360
499
  * 职责:
361
- * 1. 递归防护——视觉模型自身的消息(例如子会话)不做任何处理;
362
- * 2. 记录会话当前模型;
363
- * 3. 收集图片 part,没有图片则直接返回;
500
+ * 1. 记录会话当前模型(先于递归防护:会话模型恰为视觉模型时也需记录);
501
+ * 2. 收集图片 part,没有图片则直接返回;
502
+ * 3. 递归防护——消息模型 ∈ 候选链全体成员(我们的描述子会话)则放行;
364
503
  * 4. 能力门控——主模型本身能看图则不注入提示;
365
- * 5. 图片落盘,并注入一条 synthetic text part 引导模型使用 vision_analyze。
504
+ * 5. 空链降级——没有任何可用视觉模型时不注入 hint;
505
+ * 6. 图片落盘,并注入一条 synthetic text part 引导模型使用 vision_analyze。
366
506
  */
367
507
  const onChatMessage = async (hookInput, output) => {
368
508
  // 记录会话当前模型,供后续 vision_analyze 快速路径与未显式指定 model 的
@@ -371,18 +511,24 @@ const plugin = async (input, optionsArg) => {
371
511
  // 看不到该模型,会退化为子会话描述。
372
512
  if (hookInput.model)
373
513
  sessionModels.set(hookInput.sessionID, hookInput.model);
374
- // 递归防护:视觉模型自身的 prompt(描述子会话)直接放行,
375
- // 避免插件处理自己发起的消息造成循环。
376
- if (hookInput.model?.providerID === visionProviderID && hookInput.model?.modelID === visionModelID)
377
- return;
378
514
  // 只处理 base64 图片附件;没有图片就没有副作用。
379
515
  const images = output.parts.filter((part) => part.type === "file" && part.mime.startsWith("image/"));
380
516
  if (images.length === 0)
381
517
  return;
518
+ // 递归防护:防护集 = 整条候选链(而非单模型/链首)。描述子会话用的模型是
519
+ // 链上任意候选,只要消息模型命中任一成员就放行,避免插件处理自己发起的
520
+ // 消息形成循环。resolveChain 懒加载 + memoize,只在首次触发一次 providers 查询。
521
+ if (hookInput.model && (await isCandidateModel(hookInput.model)))
522
+ return;
382
523
  // 能力门控:主模型有视觉能力时原图直发,不需要任何提示。
383
524
  const current = hookInput.model ?? sessionModels.get(hookInput.sessionID);
384
525
  if (current && (await imageSupport(current.providerID, current.modelID)))
385
526
  return;
527
+ // 空链降级:没有任何可用视觉模型时不注入 hint、不落盘——落盘只会制造没有
528
+ // 视觉模型可消费的垃圾文件;交给核心 unsupportedParts 对图片 part 的默认处理。
529
+ const chain = await resolveChain();
530
+ if (chain.length === 0)
531
+ return;
386
532
  // 每张图落盘并生成两行提示;任何一张落盘失败就跳过该图(不影响其余图片)。
387
533
  const lines = [];
388
534
  for (const part of images) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vision-analyze",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,9 @@
18
18
  "typecheck": "tsc --noEmit",
19
19
  "build": "tsc",
20
20
  "test": "bun test",
21
- "prepublishOnly": "npm run build"
21
+ "prepublishOnly": "npm run build",
22
+ "preversion": "bun run typecheck && bun test && bun run build",
23
+ "postversion": "git push --follow-tags"
22
24
  },
23
25
  "keywords": [
24
26
  "opencode",