opencode-vision-analyze 0.2.0 → 0.4.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
@@ -4,6 +4,8 @@
4
4
  [![license](https://img.shields.io/npm/l/opencode-vision-analyze)](./LICENSE)
5
5
  [![opencode plugin](https://img.shields.io/badge/opencode-plugin-blue)](https://opencode.ai/docs/plugins)
6
6
 
7
+ English | [简体中文](./README.zh.md)
8
+
7
9
  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
10
 
9
11
  **Zero runtime dependencies.** Only node builtins (`crypto`/`fs`/`path`) and type-only imports — nothing to install beyond the plugin itself.
@@ -13,7 +15,7 @@ A tool-based vision routing plugin for [opencode](https://opencode.ai): when the
13
15
  - **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.
14
16
  - **Question-aware descriptions.** The model passes its own focused question to `vision_analyze` — not a one-shot generic caption computed at submit time.
15
17
  - **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.
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.
18
+ - **Content-addressed cache.** Images are stored as content-addressed `<sha256>.<ext>` files in a **user-level shared directory** (`<cache>/opencode-vision-analyze/vision`, deduped across projects/sessions, LRU-capped 2000 entries / 500 MB); descriptions are cached per `<image-hash>:<question>` in a **user-level shared directory** (`<cache>/opencode-vision-analyze/descriptions`) — the same image with the same question is described exactly once, across projects, processes, and plugin restarts. The description cache is LRU-capped (2000 entries / 50 MB).
17
19
  - **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.
18
20
 
19
21
  ## Installation
@@ -67,6 +69,10 @@ Notes for the curl path:
67
69
 
68
70
  Supported image extensions: png / jpg / jpeg / gif / webp.
69
71
 
72
+ Image storage: stored in a **user-level shared directory** `<cache>/opencode-vision-analyze/vision` regardless of git scope — one content-addressed `<sha256>.<ext>` file per unique image, shared across all projects. Pasted images and `http(s)` downloads are written here (atomic temp-file + rename, so concurrent opencode processes can safely share the store); **already-local image paths passed straight to the tool are read in place and never copied**. Defaults per platform: Linux `$XDG_CACHE_HOME || ~/.cache`, macOS `~/Library/Caches` (a `$XDG_CACHE_HOME` override is honored), Windows `%LOCALAPPDATA% || ~/AppData/Local`. An empty cache-root env var is treated as unset (falls back to the default). The store is LRU-capped (2000 entries / 500 MB; oldest by file mtime is evicted when either limit is exceeded), so no `.gitignore` entry is needed anywhere.
73
+
74
+ Description cache: stored in a **user-level shared directory** `<cache>/opencode-vision-analyze/descriptions` regardless of git scope — one JSON entry per `<image-sha>:<question>` key, named by `sha256(key)`. It is capped at 2000 entries / 50 MB with LRU eviction (oldest by file mtime is removed when either limit is exceeded). Writes are atomic (temp file + rename), so concurrent opencode processes can safely share the cache.
75
+
70
76
  An ordered-candidates example with auto-fallback and free-first discovery:
71
77
 
72
78
  ```jsonc
@@ -94,7 +100,9 @@ User pastes image + question
94
100
  ├─ main model has image input capability → do nothing (raw image goes to model)
95
101
  ├─ no image-capable model available → do nothing (no hint, no persist;
96
102
  │ core's default image handling applies)
97
- └─ text-only main model → persist image to .opencode/vision/<sha256>.<ext>
103
+ └─ text-only main model → persist image to the user-level vision store
104
+ (<sha256>.<ext> under <cache>/opencode-vision-analyze/vision;
105
+ already-local file paths are read in place, never copied)
98
106
  and inject a synthetic hint (hidden in TUI, visible to model):
99
107
  "use the vision_analyze tool with image_path: ..."
100
108
 
@@ -107,7 +115,8 @@ vision_analyze tool:
107
115
  │ → return raw image as attachment (no vision model call)
108
116
  ├─ http(s) image URL → download (20 MB cap) → same disk path
109
117
  ├─ description cache hit (sha + question) → return cached text
110
- │ (tagged with the model that produced it)
118
+ │ (persisted under <cache>/opencode-vision-analyze/descriptions,
119
+ │ tagged with the model that produced it, LRU-capped 2000 entries / 50 MB)
111
120
  └─ candidate chain: sub-session under current session per candidate, in order —
112
121
  parentID, all tools disabled, dedicated system prompt, image + question
113
122
  sent to that vision model → first success returns → sub-session deleted
@@ -126,17 +135,9 @@ Key behaviors:
126
135
  ## Known limitations
127
136
 
128
137
  - **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).
129
- - **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.
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.
131
- - **Historical images** — images from messages sent before the plugin was enabled can't be described (no hint, no path on disk).
132
- - **Unbounded caches** — both the image store and description cache grow without eviction (per process / per project dir).
133
138
 
134
139
  ## Roadmap
135
140
 
136
- - [ ] Timeout wrapping for the capability query (`config.providers()`)
137
- - [ ] Abort sub-session (`/session/{id}/abort`) before delete on timeout
138
- - [ ] LRU / size cap for the description cache
139
- - [ ] Optional private-address blocking for URL downloads
140
141
  - [ ] Region cropping for zooming into image details
141
142
 
142
143
  ## Development
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
  - **工具化,而非提交时预分析。** 轮次即时启动;模型自己决定何时看图、带着什么问题看。提交零阻塞,失败在 agent 循环里可见、可重试。
16
16
  - **描述针对问题。** 模型把自己关注的问题传给 `vision_analyze`——而不是提交时预生成的一次性通用描述。
17
17
  - **原生快速路径。** 主模型本身有视觉能力时,`vision_analyze` 完全跳过视觉模型,直接把原图作为工具附件返回。
18
- - **内容寻址缓存。** 图片按 `<sha256>.<ext>` 落盘(跨会话天然去重);描述按 `<图片哈希>:<问题>` 缓存——同图同问题只描述一次。
18
+ - **内容寻址缓存。** 图片按内容寻址 `<sha256>.<ext>` 落盘到**用户级共享目录**(`<cache>/opencode-vision-analyze/vision`,跨项目/会话天然去重,LRU 上限 2000 条 / 500MB);描述按 `<图片哈希>:<问题>` 缓存到**用户级共享目录**(`<cache>/opencode-vision-analyze/descriptions`)——跨项目/进程/插件重启同图同问题只描述一次;描述缓存 LRU 上限 2000 条 / 50MB。
19
19
  - **统一鉴权。** 视觉调用走 opencode 子会话,复用 opencode 已管理的 provider 凭据,无需额外配置 API Key。
20
20
 
21
21
  ## 安装
@@ -69,6 +69,10 @@ curl 方式说明:
69
69
 
70
70
  受支持的图片扩展名:png / jpg / jpeg / gif / webp。
71
71
 
72
+ 图片存储:**恒定放用户级共享目录** `<cache>/opencode-vision-analyze/vision`(与 git / 非 git 分域无关),同一用户所有项目共享;每个唯一图片一个内容寻址 `<sha256>.<ext>` 文件。贴图与 http(s) 下载写入于此(临时文件 + rename 原子写,多个 opencode 进程可安全共享);**模型直接把本地已存在文件路径传给工具时不复制、原位读用**。各平台默认:Linux `$XDG_CACHE_HOME || ~/.cache`;macOS `~/Library/Caches`(亦接受 `$XDG_CACHE_HOME` 覆盖);Windows `%LOCALAPPDATA% || ~/AppData/Local`。缓存根 env 为空串视为未设置(回退默认)。图片缓存 LRU 上限 2000 条 / 500MB,任一超限即按文件 mtime 淘汰最久未用的条目——无需任何 `.gitignore` 条目。
73
+
74
+ 描述缓存:**恒定放用户级共享目录** `<cache>/opencode-vision-analyze/descriptions`(与 git / 非 git 分域无关)——每条目一个 JSON 文件,key 为 `<图片sha256>:<问题>`、文件名取 `sha256(key)`。容量上限 2000 条 / 50MB,任一超限即按文件 mtime 淘汰最久未用的条目(LRU)。写入为原子操作(临时文件 + rename),多个 opencode 进程可安全共享同一描述缓存。
75
+
72
76
  有序候选 + 自动续接 + 免费优先的配置示例:
73
77
 
74
78
  ```jsonc
@@ -96,7 +100,9 @@ curl 方式说明:
96
100
  ├─ 主模型支持图片输入 → 不做任何处理(原图直发)
97
101
  ├─ 无任何 image-capable 模型 → 不做处理(不注入 hint、不落盘;
98
102
  │ 交给核心对图片的默认处理)
99
- └─ 纯文本主模型 → 图片落盘 .opencode/vision/<sha256>.<ext>
103
+ └─ 纯文本主模型 → 图片落盘到用户级 vision 存储
104
+ (<sha256>.<ext>;位于 <cache>/opencode-vision-analyze/vision;
105
+ 本地已存在文件路径原位读用、不复制)
100
106
  并注入 synthetic 提示(TUI 隐藏、模型可见):
101
107
  "用 vision_analyze 工具查看,image_path: ..."
102
108
 
@@ -109,7 +115,8 @@ vision_analyze 工具:
109
115
  │ → 原图作为附件直接返回(不调视觉模型)
110
116
  ├─ http(s) 图片 URL → 下载(20 MB 上限)→ 统一磁盘路径
111
117
  ├─ 描述缓存命中(图片哈希 + 问题)→ 直接返回缓存文本
112
- (标签沿用产出该描述的模型)
118
+ (持久化于 <cache>/opencode-vision-analyze/descriptions,
119
+ │ 标签沿用产出该描述的模型;LRU 上限 2000 条 / 50MB)
113
120
  └─ 候选链:沿链逐候选建子会话(parentID 挂当前会话、禁用全部工具、
114
121
  专用 system prompt,图片 + 问题发给该候选视觉模型)
115
122
  → 首个成功即返回 → 子会话删除
@@ -128,17 +135,9 @@ vision_analyze 工具:
128
135
  ## 已知限制
129
136
 
130
137
  - **仅 V1 会话流** —— 钩子挂在 V1 `SessionPrompt` 路径上;若 opencode 默认交互切到 V2 会话核心,钩子不会触发(且不报错)。
131
- - **SSRF 面** —— URL 下载跟随重定向、不拦截私网/云元数据地址。本地单用户 CLI 信任级别下可接受;多租户环境使用前应加地址过滤。
132
- - **中止不传导** —— 用户中止不会取消进行中的下载/子会话请求,它们会跑到各自的 deadline(下载 30 秒、子会话 `timeout_ms`)。超时/中止后子会话虽被删除,但 provider 端的孤儿回合仍可能计费。
133
- - **历史图片** —— 插件启用之前发送的图片无法被描述(无提示、磁盘上无路径)。
134
- - **缓存无上限** —— 图片存储与描述缓存均不淘汰(进程级 / 项目目录级)。
135
138
 
136
139
  ## Roadmap
137
140
 
138
- - [ ] 能力查询(`config.providers()`)加超时保护
139
- - [ ] 超时路径先中止子会话(`/session/{id}/abort`)再删除
140
- - [ ] 描述缓存 LRU / 容量上限
141
- - [ ] URL 下载可选私网地址拦截
142
141
  - [ ] 区域裁剪(放大查看图片细节)
143
142
 
144
143
  ## 开发
package/dist/index.d.ts CHANGED
@@ -1,4 +1,53 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
+ /**
3
+ * `config.providers()` 能力查询的超时预算(毫秒)。做成可改写对象(而非常量/选项):
4
+ * 避免选项膨胀;测试把 ms 调小即可缩短等待(TS 不允许对 import 的 let 绑定赋值)。
5
+ */
6
+ export declare const providersTimeout: {
7
+ ms: number;
8
+ };
9
+ /**
10
+ * 用户级 cache 的平台根(不带应用子目录):cache 目录 + opencode-vision-analyze。
11
+ * 空字符串 env 视为未设置(XDG/LOCALAPPDATA 官规:空值=未设置)——避免把 "" 当
12
+ * 有效根导致 path.join("",…) 产出相对路径、相对进程 cwd 落盘。
13
+ * darwin 亦接受 $XDG_CACHE_HOME 覆盖(跨平台统一 dotfiles 的宽容超集)。
14
+ */
15
+ export declare function userCacheRootBase(env: Record<string, string | undefined>, platform: string, home: string): string;
16
+ /**
17
+ * 图片缓存的平台根(恒用户级、与 git 无关):cache 目录 + opencode-vision-analyze/vision。
18
+ * 贴图与 http(s) 下载统一落盘于此(内容寻址命名);本地已存在文件不复制、原位读用。
19
+ */
20
+ export declare function userVisionCacheRoot(env: Record<string, string | undefined>, platform: string, home: string): string;
21
+ /**
22
+ * 描述缓存的持久化目录:恒走用户级共享 cache(<cache>/opencode-vision-analyze/descriptions),
23
+ * **与 git / 非 git 分域无关**。描述缓存键是「图片内容 sha + 问题」,与项目解耦,放用户级
24
+ * 目录才能在跨项目 / 跨进程 / 重启后共享同一份"同图同问题只描述一次"的结果。
25
+ * 纯函数(入参注入 env/platform/homedir,便于三平台 + 空串 env 回退的单测)。
26
+ */
27
+ export declare function resolveDescriptionDir(env: Record<string, string | undefined>, platform: string, home: string): string;
28
+ /**
29
+ * 描述缓存的容量上限(模块级可变对象,便于测试注入小值;与 providersTimeout 同风格)。
30
+ * - maxEntries:最大条目数(2000)
31
+ * - maxBytes:条目文件字节总和上限(50 MB)
32
+ * 容量超限触发 LRU 淘汰(按文件 mtime 升序删最旧)。**单条即超 maxBytes 的条目不入缓存**
33
+ * (A / 硬上限,写前 utf8 字节预检跳过),保证磁盘恒 ≤ maxBytes、不因巨值挤掉已付费条目。
34
+ */
35
+ export declare const descriptionCacheLimits: {
36
+ maxEntries: number;
37
+ maxBytes: number;
38
+ };
39
+ /**
40
+ * 图片缓存的容量上限(模块级可变对象,便于测试注入小值;与 providersTimeout 同风格)。
41
+ * - maxEntries:最大条目数(2000)
42
+ * - maxBytes:条目文件字节总和上限(500 MB)
43
+ * 容量超限触发 LRU 淘汰(按文件 mtime 升序删最旧)。与描述缓存不同,图片**允许单图超限**
44
+ * (贴图/下载必须给模型稳定 image_path,不能拒绝落盘):单图 > maxBytes 仍写入且当次受
45
+ * protect 保护,容量可短暂超限,待下一次无关写入淘汰时收敛(best-effort)。
46
+ */
47
+ export declare const visionCacheLimits: {
48
+ maxEntries: number;
49
+ maxBytes: number;
50
+ };
2
51
  declare const _default: {
3
52
  id: string;
4
53
  server: Plugin;
package/dist/index.js CHANGED
@@ -2,40 +2,22 @@
2
2
  * opencode-vision-analyze
3
3
  *
4
4
  * 为「不具备视觉能力的主模型」提供图片解读路由:当用户在消息中附带图片时,
5
- * 插件把图片落盘到 .opencode/vision/<sha256>.<ext>,并向模型注入一条
6
- * synthetic 提示(TUI 界面隐藏、模型可见),引导它通过 vision_analyze 工具
7
- * 让指定的视觉模型描述图片。若主模型本身支持图片输入,则不做任何干预,
8
- * 原图直接发给主模型。
9
- *
10
- * 安装方式一(npm):
11
- * {
12
- * "plugin": [["opencode-vision-analyze", { "models": ["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", { "models": ["provider/vision-model"] }]]
20
- * }
21
- *
22
- * 选项:
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 递归防护以整链成员为集。
5
+ * 插件把图片落盘到**用户级共享目录**(<cache>/opencode-vision-analyze/vision
6
+ * 恒用户级、与 git/非 git 无关),并向模型注入一条 synthetic 提示
7
+ * (TUI 界面隐藏、模型可见),引导它通过 vision_analyze 工具让指定的视觉
8
+ * 模型描述图片。若主模型本身支持图片输入,则不做任何干预,原图直接发给主模型。
32
9
  *
33
10
  * 工作方式(vision_analyze 工具路径):主模型调用 vision_analyze 时,插件
34
11
  * 创建一个 parentID 挂在当前会话下的临时子会话(不进会话列表、不生成
35
12
  * 标题、禁用全部工具),把原图以 data URL 发给视觉模型,取回描述文字后
36
- * 删除子会话并返回描述。同一张图 + 同一问题的描述按内容哈希缓存。
37
- * image_path 除了绝对路径也接受 http(s) URL:先下载落盘到同一 vision
38
- * 目录(内容哈希命名,天然与附件落盘去重),再走统一的磁盘加载路径。
13
+ * 删除子会话并返回描述。同一张图 + 同一问题的描述按「<图片sha256>:<问题>」
14
+ * 键缓存到**用户级共享目录**(<cache>/opencode-vision-analyze/descriptions,
15
+ * 每条目一文件、mtime 作 LRU 时钟、2000 条 / 50MB 双上限、单条超限不入缓存)——
16
+ * 跨项目/跨进程/插件重启后同图同问题只描述一次。
17
+ * image_path 除了绝对路径也接受 http(s) URL:先下载落盘到同一用户级 vision
18
+ * 目录(内容哈希命名,天然与附件落盘去重;LRU + 容量上限,2000 条 / 500MB),
19
+ * 再走统一的磁盘加载路径。**本地已存在的文件直接原位读取**(不复制、不 touch),
20
+ * 只有贴图/下载才写缓存。
39
21
  * 主模型本身支持图片输入时走快速路径:不做子会话描述,直接把原图作为
40
22
  * 工具附件回传给模型自行查看。
41
23
  *
@@ -43,17 +25,12 @@
43
25
  * 类型依赖仅 @opencode-ai/plugin 与 @opencode-ai/sdk 的 type import。
44
26
  *
45
27
  * 已知限制:
46
- * - SSRF 面:downloadImage 的 fetch 跟随重定向、不拦截私网/云元数据地址。
47
- * 本地单用户 CLI 的信任级别下可接受;生产多租户环境使用前应加私网
48
- * 地址拦截。
49
- * - 中止不传导:用户中止不会取消进行中的下载/子会话请求,最长空跑至
50
- * 各自的 deadline(下载 30 秒、子会话 timeout_ms);超时/中止后子会话
51
- * 虽被删除,但 provider 端已发出的孤儿回合仍可能计入用量。
52
28
  * - 仅 V1 会话流有效:chat.message 钩子挂在 V1 SessionPrompt 路径上;
53
29
  * 若交互默认切到 V2 Session 核心,本钩子不会触发(也不会报错)。
54
30
  */
55
31
  import { createHash, randomUUID } from "node:crypto";
56
32
  import fs from "node:fs/promises";
33
+ import { homedir } from "node:os";
57
34
  import path from "node:path";
58
35
  /** 支持的图片扩展名 → MIME 类型(vision_analyze 加载磁盘图片时使用) */
59
36
  const EXT_MIME = {
@@ -70,6 +47,22 @@ const MIME_EXT = {
70
47
  "image/gif": ".gif",
71
48
  "image/webp": ".webp",
72
49
  };
50
+ /**
51
+ * 内部超时错误类型(name = "DeadlineError")。
52
+ * 与 AbortError 并列可判别:超时路径(providers 查询 / 子会话请求)据此判断
53
+ * "底层请求可能仍在飞",供调用方决定是否需要 abort 取消(见 attemptModel)。
54
+ */
55
+ class DeadlineError extends Error {
56
+ constructor(message) {
57
+ super(message);
58
+ this.name = "DeadlineError";
59
+ }
60
+ }
61
+ /**
62
+ * `config.providers()` 能力查询的超时预算(毫秒)。做成可改写对象(而非常量/选项):
63
+ * 避免选项膨胀;测试把 ms 调小即可缩短等待(TS 不允许对 import 的 let 绑定赋值)。
64
+ */
65
+ export const providersTimeout = { ms: 5000 };
73
66
  /**
74
67
  * 视觉子会话使用的系统提示词。
75
68
  * 要求:精确转录图中文字,描述 UI/布局/对象/颜色等,优先回答用户问题,
@@ -84,6 +77,59 @@ const VISION_SYSTEM_PROMPT = [
84
77
  ].join("\n");
85
78
  /** data URL 形如 data:<mime>;base64,<payload> */
86
79
  const DATA_URL_PATTERN = /^data:([^;]+);base64,(.+)$/;
80
+ /**
81
+ * 用户级 cache 的平台根(不带应用子目录):cache 目录 + opencode-vision-analyze。
82
+ * 空字符串 env 视为未设置(XDG/LOCALAPPDATA 官规:空值=未设置)——避免把 "" 当
83
+ * 有效根导致 path.join("",…) 产出相对路径、相对进程 cwd 落盘。
84
+ * darwin 亦接受 $XDG_CACHE_HOME 覆盖(跨平台统一 dotfiles 的宽容超集)。
85
+ */
86
+ export function userCacheRootBase(env, platform, home) {
87
+ const base = platform === "darwin"
88
+ ? (env.XDG_CACHE_HOME || path.join(home, "Library", "Caches"))
89
+ : platform === "win32"
90
+ ? (env.LOCALAPPDATA || path.join(home, "AppData", "Local"))
91
+ : (env.XDG_CACHE_HOME || path.join(home, ".cache"));
92
+ return path.join(base, "opencode-vision-analyze");
93
+ }
94
+ /**
95
+ * 图片缓存的平台根(恒用户级、与 git 无关):cache 目录 + opencode-vision-analyze/vision。
96
+ * 贴图与 http(s) 下载统一落盘于此(内容寻址命名);本地已存在文件不复制、原位读用。
97
+ */
98
+ export function userVisionCacheRoot(env, platform, home) {
99
+ return path.join(userCacheRootBase(env, platform, home), "vision");
100
+ }
101
+ /**
102
+ * 描述缓存的持久化目录:恒走用户级共享 cache(<cache>/opencode-vision-analyze/descriptions),
103
+ * **与 git / 非 git 分域无关**。描述缓存键是「图片内容 sha + 问题」,与项目解耦,放用户级
104
+ * 目录才能在跨项目 / 跨进程 / 重启后共享同一份"同图同问题只描述一次"的结果。
105
+ * 纯函数(入参注入 env/platform/homedir,便于三平台 + 空串 env 回退的单测)。
106
+ */
107
+ export function resolveDescriptionDir(env, platform, home) {
108
+ return path.join(userCacheRootBase(env, platform, home), "descriptions");
109
+ }
110
+ /**
111
+ * 描述缓存的容量上限(模块级可变对象,便于测试注入小值;与 providersTimeout 同风格)。
112
+ * - maxEntries:最大条目数(2000)
113
+ * - maxBytes:条目文件字节总和上限(50 MB)
114
+ * 容量超限触发 LRU 淘汰(按文件 mtime 升序删最旧)。**单条即超 maxBytes 的条目不入缓存**
115
+ * (A / 硬上限,写前 utf8 字节预检跳过),保证磁盘恒 ≤ maxBytes、不因巨值挤掉已付费条目。
116
+ */
117
+ export const descriptionCacheLimits = {
118
+ maxEntries: 2000,
119
+ maxBytes: 50 * 1024 * 1024,
120
+ };
121
+ /**
122
+ * 图片缓存的容量上限(模块级可变对象,便于测试注入小值;与 providersTimeout 同风格)。
123
+ * - maxEntries:最大条目数(2000)
124
+ * - maxBytes:条目文件字节总和上限(500 MB)
125
+ * 容量超限触发 LRU 淘汰(按文件 mtime 升序删最旧)。与描述缓存不同,图片**允许单图超限**
126
+ * (贴图/下载必须给模型稳定 image_path,不能拒绝落盘):单图 > maxBytes 仍写入且当次受
127
+ * protect 保护,容量可短暂超限,待下一次无关写入淘汰时收敛(best-effort)。
128
+ */
129
+ export const visionCacheLimits = {
130
+ maxEntries: 2000,
131
+ maxBytes: 500 * 1024 * 1024,
132
+ };
87
133
  /**
88
134
  * http(s) 下载图片的大小上限(20 MB)。提示注入可让模型指向超大图片,
89
135
  * 下载不限长是成本/健壮性放大器:先按 content-length 头提前拒绝,
@@ -135,12 +181,6 @@ const plugin = async (input, optionsArg) => {
135
181
  const sessionModels = new Map();
136
182
  /** "provider/model" → 是否具备图片输入能力(查询结果缓存,进程级) */
137
183
  const imageCapable = new Map();
138
- /**
139
- * 描述缓存:"<sha>:<question>" → 描述结果(同一张图 + 同一个问题只描述一次)。
140
- * 值带 modelId:记录实际产出该描述的候选模型,缓存命中时标签沿用入库模型,
141
- * 而不是用当前候选链链首近似(链配置变化或 fallback 命中次选时标签才真实)。
142
- */
143
- const descriptions = new Map();
144
184
  /** 本插件创建的子会话 ID 集合(正常路径用后即删,dispose 兜底清理残留) */
145
185
  const subSessions = new Set();
146
186
  /** 任意错误值 → 可读文本:Error 取 message,字符串原样,其余 JSON 序列化兜底。 */
@@ -151,33 +191,49 @@ const plugin = async (input, optionsArg) => {
151
191
  return error;
152
192
  return JSON.stringify(error) ?? String(error);
153
193
  };
194
+ /**
195
+ * 无 ToolContext 的超时原语:到期以 DeadlineError(timeoutMessage) 拒绝。
196
+ * 与 withDeadline 的差别是它不感知 abort——能力查询(providers)等无 ctx 的
197
+ * 请求只关心"别永久挂起",不需要监听用户中止;子会话请求由 withDeadline 组合
198
+ * abort 信号后复用它,保证全插件只有一套计时机制。
199
+ */
200
+ const withTimeout = async (promise, ms, timeoutMessage) => {
201
+ let timer;
202
+ const guard = new Promise((_, reject) => {
203
+ timer = setTimeout(() => reject(new DeadlineError(timeoutMessage)), ms);
204
+ });
205
+ try {
206
+ return await Promise.race([promise, guard]);
207
+ }
208
+ finally {
209
+ if (timer)
210
+ clearTimeout(timer);
211
+ }
212
+ };
154
213
  /**
155
214
  * 给子会话请求加超时与 abort 保护:任一触发即让 Promise 以错误结束,
156
- * 不再等待底层请求;finally 中清理 timer 与监听器,避免泄漏。
215
+ * 不再等待底层请求;超时复用 withTimeout(DeadlineError),abort 仍以
216
+ * AbortError 拒绝;finally 中清理 timer 与监听器,避免泄漏。
157
217
  */
158
218
  const withDeadline = (promise, ctx) => {
159
- let timer;
219
+ // ctx.abort 已中止时 abort 事件不会再触发,必须立即拒绝,
220
+ // 否则 race 只能干等 timer 超时。
221
+ if (ctx.abort.aborted)
222
+ return Promise.reject(new DOMException("Aborted", "AbortError"));
160
223
  let onAbort;
161
- const guarded = new Promise((_, reject) => {
162
- // ctx.abort 已中止时 abort 事件不会再触发,必须立即拒绝,
163
- // 否则 race 只能干等 timer 超时。
164
- if (ctx.abort.aborted) {
165
- reject(new DOMException("Aborted", "AbortError"));
166
- return;
167
- }
168
- timer = setTimeout(() => reject(new Error(`vision model call timed out after ${timeoutMs}ms`)), timeoutMs);
224
+ const abortGuard = new Promise((_, reject) => {
169
225
  onAbort = () => reject(new DOMException("Aborted", "AbortError"));
170
226
  ctx.abort.addEventListener("abort", onAbort, { once: true });
171
227
  });
172
- return Promise.race([promise, guarded]).finally(() => {
173
- if (timer)
174
- clearTimeout(timer);
228
+ return withTimeout(Promise.race([promise, abortGuard]), timeoutMs, `vision model call timed out after ${timeoutMs}ms`).finally(() => {
175
229
  if (onAbort)
176
230
  ctx.abort.removeEventListener("abort", onAbort);
177
231
  });
178
232
  };
179
233
  /** 判断错误是否为中止信号(AbortError),供 attemptModel 标记 / 链循环中止整链。 */
180
234
  const isAbortError = (error) => error instanceof Error && error.name === "AbortError";
235
+ /** 判断错误是否为本插件超时信号(DeadlineError)——与 AbortError 并列,表示"请求到期被本地掐断"。 */
236
+ const isDeadlineError = (error) => error instanceof Error && error.name === "DeadlineError";
181
237
  /**
182
238
  * 单个候选的尝试:创建子会话(parentID 挂当前会话)→ 用该候选模型描述 →
183
239
  * 删除子会话。任何失败(创建 / 请求 / 超时 / 中止 / 底层抛错 / 无文本)都
@@ -188,6 +244,9 @@ const plugin = async (input, optionsArg) => {
188
244
  const attemptModel = async (candidate, image, question, ctx) => {
189
245
  const dataURL = `data:${image.mime};base64,${image.bytes.toString("base64")}`;
190
246
  let subID;
247
+ // "回合可能仍在飞"标记:请求被本地 deadline(超时)或用户 abort 掐断时置位,
248
+ // finally 据此先 abort 子会话(取消 provider 端孤儿回合)再 delete。
249
+ let endedByDeadline = false;
191
250
  try {
192
251
  const created = await withDeadline(input.client.session.create({ body: { parentID: ctx.sessionID, title: "vision analysis" } }), ctx);
193
252
  if (created.error || !created.data) {
@@ -222,12 +281,19 @@ const plugin = async (input, optionsArg) => {
222
281
  return { ok: true, text };
223
282
  }
224
283
  catch (error) {
225
- // 异常(超时 / 中止 / 底层抛错)同样收敛为失败结果;aborted 标记交由链循环判断
284
+ // 异常(超时 / 中止 / 底层抛错)同样收敛为失败结果;aborted 标记交由链循环判断。
285
+ // 超时与中止都意味着底层请求可能仍在飞 → 需要先 abort 再 delete。
286
+ endedByDeadline = isDeadlineError(error) || isAbortError(error);
226
287
  return { ok: false, error: errText(error), aborted: isAbortError(error) };
227
288
  }
228
289
  finally {
229
290
  if (subID) {
230
291
  subSessions.delete(subID);
292
+ // 先 abort(best-effort,取消 provider 端孤儿回合)再 delete;
293
+ // 成功/普通失败路径 turn 已自然结束,无需 abort。
294
+ if (endedByDeadline) {
295
+ await input.client.session.abort({ path: { id: subID } }).catch(() => { });
296
+ }
231
297
  await input.client.session.delete({ path: { id: subID } }).catch(() => { });
232
298
  }
233
299
  }
@@ -263,13 +329,45 @@ const plugin = async (input, optionsArg) => {
263
329
  /** 描述标签:标注图片文件名与产出描述的模型引用键(basename 运行时按图传入)。 */
264
330
  const format = (basename, modelId, text) => `[Image: ${basename} — described by ${modelId}]\n${text}`;
265
331
  /**
266
- * 下载 http(s) URL 指向的图片并落盘到 <visionDir>/<sha256><ext>:
267
- * 与 chat.message 落盘路径一致,内容哈希命名天然去重。
268
- * 扩展名不受支持、HTTP 2xx、网络失败(含 30 秒下载超时)、超过
269
- * 20 MB 下载上限(content-length 预检 + 读后复核)都返回 { error },
332
+ * 把图片字节内容原子落盘到**用户级缓存目录**(<cache>/opencode-vision-analyze/vision,
333
+ * 与 git 无关)并返回最终 filepath。贴图与 http(s) 下载统一经此写入(本地已存在文件
334
+ * 不复制、直接原位读用)。先写 `<sha><ext>.tmp-<uuid>` rename:共享目录多进程并发
335
+ * 写同一 sha 时内容寻址下原子幂等;失败先清理临时文件再抛错,避免孤儿 tmp 累积。
336
+ * rename 成功后触发图片 LRU 容量淘汰(刚写入文件受 protect 保护)。
337
+ */
338
+ const persistImageBytes = async (bytes, ext) => {
339
+ const dir = userVisionCacheRoot(process.env, process.platform, homedir());
340
+ const sha = createHash("sha256").update(bytes).digest("hex");
341
+ const filepath = path.join(dir, `${sha}${ext}`);
342
+ const tmpPath = path.join(dir, `${sha}${ext}.tmp-${randomUUID()}`);
343
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
344
+ try {
345
+ await fs.writeFile(tmpPath, bytes);
346
+ await fs.rename(tmpPath, filepath);
347
+ }
348
+ catch (error) {
349
+ await fs.unlink(tmpPath).catch(() => { });
350
+ throw error;
351
+ }
352
+ await evictToCaps(dir, visionCacheLimits, {
353
+ filter: (name) => /^[0-9a-f]{64}\.(png|jpe?g|gif|webp)$/i.test(name),
354
+ protectName: path.basename(filepath),
355
+ });
356
+ return filepath;
357
+ };
358
+ /**
359
+ * 下载 http(s) URL 指向的图片并落盘(内容哈希命名天然去重,写盘细节见
360
+ * persistImageBytes)。扩展名不受支持、HTTP 非 2xx、网络失败(含 30 秒下载
361
+ * 超时)、超过 20 MB 下载上限(content-length 预检 + 读后复核)都返回 { error },
270
362
  * 由调用方转成可读的错误文字。
363
+ *
364
+ * 中止传导:abort(用户中止)与 30 秒超时共同驱动一个 AbortController——
365
+ * 中止即刻断请求,不空跑满超时;pre-abort 直接放弃、不发请求。手动组合信号
366
+ * 而非 AbortSignal.any():engines node>=18(any 需 18.17+/20.3+),且与
367
+ * withDeadline 的 addEventListener 风格同构。错误以 AbortError/超时形式落入
368
+ * catch,统一转可读文字。
271
369
  */
272
- const downloadImage = async (url) => {
370
+ const downloadImage = async (url, abort) => {
273
371
  try {
274
372
  // URL 解析与扩展名提取放在 try 内:畸形 URL 在 new URL 处抛错时,
275
373
  // 错误以 "Image download failed" 前缀返回,而不是漏到外层的
@@ -278,24 +376,34 @@ const plugin = async (input, optionsArg) => {
278
376
  const mime = EXT_MIME[ext];
279
377
  if (!mime)
280
378
  return { error: `unsupported image URL extension: ${ext || "(none)"}` };
281
- const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
282
- if (!response.ok)
283
- return { error: `HTTP ${response.status}` };
284
- // 头字段缺失时 Number(null) 为 NaN,比较结果为 false,自然放行到读后复核。
285
- if (Number(response.headers.get("content-length")) > MAX_DOWNLOAD_BYTES) {
286
- return { error: "image exceeds 20 MB download limit" };
379
+ // pre-abort:已中止则不必发请求,直接以 Aborted 收尾
380
+ if (abort.aborted)
381
+ return { error: "Aborted" };
382
+ const controller = new AbortController();
383
+ const onAbort = () => controller.abort();
384
+ abort.addEventListener("abort", onAbort, { once: true });
385
+ const timer = setTimeout(() => controller.abort(), 30_000);
386
+ try {
387
+ // 整个下载(fetch 响应头 + arrayBuffer 读 body)都在同一 guard 内:
388
+ // 30 秒预算与用户中止都覆盖到 body 读取阶段;收尾再清 timer/listener。
389
+ const response = await fetch(url, { signal: controller.signal });
390
+ if (!response.ok)
391
+ return { error: `HTTP ${response.status}` };
392
+ // 头字段缺失时 Number(null) 为 NaN,比较结果为 false,自然放行到读后复核。
393
+ if (Number(response.headers.get("content-length")) > MAX_DOWNLOAD_BYTES) {
394
+ return { error: "image exceeds 20 MB download limit" };
395
+ }
396
+ const bytes = Buffer.from(await response.arrayBuffer());
397
+ // 复核实际字节数:chunked 等无 content-length 的响应只有读后才能判大小。
398
+ if (bytes.length > MAX_DOWNLOAD_BYTES) {
399
+ return { error: "image exceeds 20 MB download limit" };
400
+ }
401
+ return { filepath: await persistImageBytes(bytes, ext) };
287
402
  }
288
- const bytes = Buffer.from(await response.arrayBuffer());
289
- // 复核实际字节数:chunked 等无 content-length 的响应只有读后才能判大小。
290
- if (bytes.length > MAX_DOWNLOAD_BYTES) {
291
- return { error: "image exceeds 20 MB download limit" };
403
+ finally {
404
+ clearTimeout(timer);
405
+ abort.removeEventListener("abort", onAbort);
292
406
  }
293
- const sha = createHash("sha256").update(bytes).digest("hex");
294
- const dir = path.join(input.directory, ".opencode", "vision");
295
- await fs.mkdir(dir, { recursive: true });
296
- const filepath = path.join(dir, `${sha}${ext}`);
297
- await fs.writeFile(filepath, bytes);
298
- return { filepath };
299
407
  }
300
408
  catch (error) {
301
409
  return { error: errText(error) };
@@ -303,6 +411,8 @@ const plugin = async (input, optionsArg) => {
303
411
  };
304
412
  /**
305
413
  * 从磁盘加载图片:按扩展名识别 MIME,读取失败或文件为空返回 undefined。
414
+ * LRU 时钟:**仅当图片位于本插件 vision 缓存根内**(贴图/下载落盘产物)才 touch mtime;
415
+ * 流程 B 直读的外部本地文件绝不触碰(不得改写用户文件 mtime)。
306
416
  */
307
417
  const loadImage = async (filepath) => {
308
418
  const mime = EXT_MIME[path.extname(filepath).toLowerCase()];
@@ -312,12 +422,109 @@ const plugin = async (input, optionsArg) => {
312
422
  const bytes = await fs.readFile(filepath);
313
423
  if (bytes.length === 0)
314
424
  return undefined;
425
+ const visionRoot = userVisionCacheRoot(process.env, process.platform, homedir());
426
+ const rel = path.relative(visionRoot, filepath);
427
+ if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) {
428
+ // 命中缓存内文件 → touch mtime 作 LRU 时钟(best-effort,失败不影响读)
429
+ const now = new Date();
430
+ await fs.utimes(filepath, now, now).catch(() => { });
431
+ }
315
432
  return { bytes, mime };
316
433
  }
317
434
  catch {
318
435
  return undefined;
319
436
  }
320
437
  };
438
+ // ---- 描述缓存(用户级目录落盘,磁盘即事实;全链 fail-open) ---------------------
439
+ /** 描述缓存 key → 磁盘文件路径(<dir>/<sha256(key)>.json)。 */
440
+ const descCacheFile = (key) => path.join(resolveDescriptionDir(process.env, process.platform, homedir()), `${createHash("sha256").update(key).digest("hex")}.json`);
441
+ /** 读取描述缓存:命中(JSON shape 合法)touch mtime 后返回,任何失败一律 miss。 */
442
+ const descCacheGet = async (key) => {
443
+ try {
444
+ const file = descCacheFile(key);
445
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
446
+ if (typeof raw !== "object" ||
447
+ raw === null ||
448
+ typeof raw.modelId !== "string" ||
449
+ typeof raw.text !== "string") {
450
+ return undefined;
451
+ }
452
+ const value = raw;
453
+ // 命中即触摸 mtime → 作为 LRU 时钟(best-effort)
454
+ const now = new Date();
455
+ await fs.utimes(file, now, now).catch(() => { });
456
+ return value;
457
+ }
458
+ catch {
459
+ return undefined;
460
+ }
461
+ };
462
+ /**
463
+ * 写描述缓存(tmp+rename 原子)并触发容量淘汰;任何失败静默吞掉(best-effort)。
464
+ * 超限策略(A / 硬上限):序列化后先按 utf8 字节预检,单条 > maxBytes 则**不入缓存**
465
+ * (不写盘、不触发淘汰、不删除该 key 已有的旧条目)——避免"超限巨值挤掉全部已付费条目、
466
+ * 且自身活不过下一次无关写入"的写→删抖动,保证磁盘恒 ≤ maxBytes。
467
+ */
468
+ const descCacheSet = async (key, value) => {
469
+ const payload = JSON.stringify(value);
470
+ // 口径与 evict 的 fs.stat().size 一致:磁盘 utf8 字节,非字符串 length(中文/emoji 不等价)
471
+ if (Buffer.byteLength(payload, "utf8") > descriptionCacheLimits.maxBytes)
472
+ return;
473
+ const file = descCacheFile(key);
474
+ const dir = path.dirname(file);
475
+ const tmp = path.join(dir, `${path.basename(file)}.tmp-${randomUUID()}`);
476
+ try {
477
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
478
+ await fs.writeFile(tmp, payload);
479
+ await fs.rename(tmp, file);
480
+ }
481
+ catch {
482
+ await fs.unlink(tmp).catch(() => { });
483
+ return;
484
+ }
485
+ await evictToCaps(dir, descriptionCacheLimits, {
486
+ filter: (name) => name.endsWith(".json"),
487
+ protectName: path.basename(file),
488
+ });
489
+ };
490
+ /**
491
+ * 通用 LRU + 容量淘汰(描述/图片缓存复用):超出 limits.maxEntries / maxBytes 时按
492
+ * mtime 升序删最旧,直到双条件满足。filter 限定参与淘汰的文件(描述 .json;图片 sha 文件,
493
+ * 排除 tmp 残留);protectName 保护刚写入的条目不被本次淘汰自删。
494
+ */
495
+ const evictToCaps = async (dir, limits, opts) => {
496
+ try {
497
+ const names = (await fs.readdir(dir)).filter(opts.filter);
498
+ const stats = await Promise.all(names.map(async (name) => {
499
+ try {
500
+ const s = await fs.stat(path.join(dir, name));
501
+ return { name, size: s.size, mtimeMs: s.mtimeMs };
502
+ }
503
+ catch {
504
+ return undefined;
505
+ }
506
+ }));
507
+ const entries = stats.filter((s) => s !== undefined);
508
+ const { maxEntries, maxBytes } = limits;
509
+ let total = entries.reduce((sum, e) => sum + e.size, 0);
510
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name));
511
+ // 逐条删最旧直至双条件满足;刚写入的条目受保护(极端单条超限时保留最新,不自我删除)。
512
+ // 用额外计数控制,不就地改遍历数组(entries 仅作删除候选快照)。
513
+ let remaining = entries.length;
514
+ for (const entry of entries) {
515
+ if (total <= maxBytes && remaining <= maxEntries)
516
+ break;
517
+ if (entry.name === opts.protectName)
518
+ continue;
519
+ await fs.unlink(path.join(dir, entry.name)).catch(() => { });
520
+ total -= entry.size;
521
+ remaining -= 1;
522
+ }
523
+ }
524
+ catch {
525
+ // 目录扫描/删除失败忽略:淘汰是 best-effort,下次写入再触发
526
+ }
527
+ };
321
528
  /**
322
529
  * vision_analyze 工具:主模型传入图片路径与问题,返回视觉模型给出的描述。
323
530
  * 工具永不抛错——所有失败都以错误文字返回,让 agent 循环可以读到原因并
@@ -327,8 +534,11 @@ const plugin = async (input, optionsArg) => {
327
534
  const title = "vision_analyze";
328
535
  try {
329
536
  const question = args.question?.trim() || "Describe this image in full detail.";
330
- // http(s) URL:先下载到本地 vision 目录,再统一走磁盘加载路径。
331
- const download = /^https?:\/\//i.test(args.image_path) ? await downloadImage(args.image_path) : undefined;
537
+ // http(s) URL:先下载到用户级 vision 缓存目录,再统一走磁盘加载路径。
538
+ // ctx.abort 传入下载:用户中止即刻断下载(含 pre-abort 不再发请求)。
539
+ const download = /^https?:\/\//i.test(args.image_path)
540
+ ? await downloadImage(args.image_path, ctx.abort)
541
+ : undefined;
332
542
  if (download && "error" in download) {
333
543
  return { title, output: `Image download failed: ${download.error}` };
334
544
  }
@@ -354,8 +564,9 @@ const plugin = async (input, optionsArg) => {
354
564
  };
355
565
  }
356
566
  // 描述缓存:内容哈希 + 问题作为 key,命中直接复用(title 标注 cached)。
567
+ // 落盘在用户级共享目录(resolveDescriptionDir)——跨项目/进程/重启命中;磁盘即事实。
357
568
  const key = `${createHash("sha256").update(image.bytes).digest("hex")}:${question}`;
358
- const cached = descriptions.get(key);
569
+ const cached = await descCacheGet(key);
359
570
  if (cached !== undefined) {
360
571
  // 命中时标签沿用入库时的模型(cached.modelId):即便此刻候选链链首
361
572
  // 已与入库模型不同,也保持标签真实、不重写。
@@ -365,7 +576,7 @@ const plugin = async (input, optionsArg) => {
365
576
  if (!result.ok)
366
577
  return { title, output: `Image analysis failed: ${result.error}` };
367
578
  // 入库带上实际产出描述的候选 modelId,供后续缓存命中还原真实标签
368
- descriptions.set(key, { modelId: result.modelId, text: result.text });
579
+ await descCacheSet(key, { modelId: result.modelId, text: result.text });
369
580
  // 成功标签直接用实际产出描述的候选引用键(而非链首近似)
370
581
  return { title, output: format(path.basename(imagePath), result.modelId, result.text) };
371
582
  }
@@ -385,7 +596,7 @@ const plugin = async (input, optionsArg) => {
385
596
  if (cached !== undefined)
386
597
  return cached;
387
598
  try {
388
- const result = await input.client.config.providers();
599
+ const result = await withTimeout(input.client.config.providers(), providersTimeout.ms, `config.providers() timed out after ${providersTimeout.ms}ms`);
389
600
  // HTTP 非 2xx 时 openapi-fetch 不抛错而是返回 { error }(data 为空)。
390
601
  // 「查询失败」不能缓存成 false——那是一次瞬时故障而非「确认不支持」,
391
602
  // 缓存会永久关闭能力门控;本次保守返回 false,下次再重试。
@@ -436,7 +647,7 @@ const plugin = async (input, optionsArg) => {
436
647
  */
437
648
  const listImageCapableModels = async () => {
438
649
  try {
439
- const result = await input.client.config.providers();
650
+ const result = await withTimeout(input.client.config.providers(), providersTimeout.ms, `config.providers() timed out after ${providersTimeout.ms}ms`);
440
651
  if (!result.data)
441
652
  return [];
442
653
  const found = [];
@@ -466,8 +677,7 @@ const plugin = async (input, optionsArg) => {
466
677
  return chain.some((c) => c.providerID === model.providerID && c.modelID === model.modelID);
467
678
  };
468
679
  /**
469
- * 把一个图片 file part 落盘到 <directory>/.opencode/vision/<sha256>.<ext>。
470
- * 文件名用内容哈希,天然去重(同一张图多次发送只落一份)。
680
+ * 把一个图片 file part 落盘(内容哈希命名去重,写盘细节见 persistImageBytes)。
471
681
  * 返回落盘信息;MIME 不受支持或 URL 不是 base64 data URL 时返回 undefined。
472
682
  */
473
683
  const persistImage = async (part) => {
@@ -479,12 +689,7 @@ const plugin = async (input, optionsArg) => {
479
689
  return undefined;
480
690
  try {
481
691
  const bytes = Buffer.from(match[2], "base64");
482
- const sha = createHash("sha256").update(bytes).digest("hex");
483
- const dir = path.join(input.directory, ".opencode", "vision");
484
- await fs.mkdir(dir, { recursive: true });
485
- const filepath = path.join(dir, `${sha}${ext}`);
486
- await fs.writeFile(filepath, bytes);
487
- return { filepath };
692
+ return { filepath: await persistImageBytes(bytes, ext) };
488
693
  }
489
694
  catch {
490
695
  // fail-open 原则:图片落盘失败(EACCES/ENOSPC 等)只是少了 vision_analyze
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vision-analyze",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",