opencode-vision-analyze 0.3.0 → 0.5.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
@@ -13,9 +13,9 @@ A tool-based vision routing plugin for [opencode](https://opencode.ai): when the
13
13
  ## Features
14
14
 
15
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.
16
- - **Question-aware descriptions.** The model passes its own focused question to `vision_analyze` — not a one-shot generic caption computed at submit time.
16
+ - **Question-aware descriptions.** The model passes its own focused question to `vision_analyze` — not a one-shot generic caption computed at submit time. For a general parse of an image the model leaves `question` empty, and the tool falls back to a fixed prompt so all generic descriptions of the same image share one cache entry (see *Content-addressed cache*).
17
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.
18
- - **Content-addressed cache.** Images are stored as content-addressed `<sha256>.<ext>` files (deduped across sessions within the same store); descriptions are cached per `<image-hash>:<question>` the same image with the same question is described exactly once.
18
+ - **Content-addressed cache.** Images and descriptions are stored by content hash in user-level shared dirs and reused across sessions, projects and restarts — the same image is never described twice, and all generic full-image parses collapse onto that image's single entry. Storage layout, size limits and the canonical prompt are detailed in *Storage and caches* below.
19
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.
20
20
 
21
21
  ## Installation
@@ -69,8 +69,6 @@ Notes for the curl path:
69
69
 
70
70
  Supported image extensions: png / jpg / jpeg / gif / webp.
71
71
 
72
- Image storage: inside a git project images live under `<project>/.opencode/vision`; in non-git directories they go to the user cache dir (`<cache>/opencode-vision-analyze/vision`) — mirroring opencode's own project/global session scoping. 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). In git projects, add `.opencode/vision/` to your `.gitignore` if you don't want the cache tracked. The storage root is re-resolved on every write, so switching scope (e.g. after `git init`) takes effect on the next pasted image — though old session hints keep pointing at stale absolute paths until you re-paste.
73
-
74
72
  An ordered-candidates example with auto-fallback and free-first discovery:
75
73
 
76
74
  ```jsonc
@@ -89,6 +87,12 @@ An ordered-candidates example with auto-fallback and free-first discovery:
89
87
  }
90
88
  ```
91
89
 
90
+ ### Storage and caches
91
+
92
+ 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.
93
+
94
+ Description cache: stored in a **user-level shared directory** `<cache>/opencode-vision-analyze/descriptions` regardless of git scope — one JSON entry per `<image-sha>:<effective-question>` key, named by `sha256(key)`. `question` is optional on the tool: a general full-image parse (empty or omitted) is normalised to the fixed prompt `Describe this image in full detail, including all text, UI elements, diagrams, or content visible.`, so every such request uses the key `<image-sha>:Describe this image in full detail, including all text, UI elements, diagrams, or content visible.` when its wording normalises to that prompt; specific follow-ups keep their own `<image-sha>:<question>` keys (format unchanged, existing entries keep hitting). Generic entries are only written when the description is long enough (≥ 100 chars), so a short refuse/fail answer can't poison the shared full-image entry. 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.
95
+
92
96
  ## How it works
93
97
 
94
98
  ```
@@ -98,8 +102,9 @@ User pastes image + question
98
102
  ├─ main model has image input capability → do nothing (raw image goes to model)
99
103
  ├─ no image-capable model available → do nothing (no hint, no persist;
100
104
  │ core's default image handling applies)
101
- └─ text-only main model → persist image to the vision store
102
- (<sha256>.<ext>; project-local inside a git repo, user cache otherwise)
105
+ └─ text-only main model → persist image to the user-level vision store
106
+ (<sha256>.<ext> under <cache>/opencode-vision-analyze/vision;
107
+ already-local file paths are read in place, never copied)
103
108
  and inject a synthetic hint (hidden in TUI, visible to model):
104
109
  "use the vision_analyze tool with image_path: ..."
105
110
 
@@ -111,8 +116,10 @@ vision_analyze tool:
111
116
  ├─ native fast path: session's main model is vision-capable
112
117
  │ → return raw image as attachment (no vision model call)
113
118
  ├─ http(s) image URL → download (20 MB cap) → same disk path
114
- ├─ description cache hit (sha + question) → return cached text
115
- │ (tagged with the model that produced it)
119
+ ├─ description cache hit (image sha + effective question) → return cached text
120
+ │ (general parses converge on <sha>:<canonical full-detail prompt>;
121
+ │ persisted under <cache>/opencode-vision-analyze/descriptions,
122
+ │ tagged with the model that produced it)
116
123
  └─ candidate chain: sub-session under current session per candidate, in order —
117
124
  parentID, all tools disabled, dedicated system prompt, image + question
118
125
  sent to that vision model → first success returns → sub-session deleted
@@ -127,16 +134,10 @@ Key behaviors:
127
134
  - **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`).
128
135
  - **The tool never throws** — every failure returns readable text so the agent loop can retry, rephrase, or inform the user.
129
136
  - **URL images** — `image_path` accepts `http(s)://...` URLs (must end in a supported image extension: png/jpg/jpeg/gif/webp).
130
-
131
- ## Known limitations
132
-
133
- - **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).
134
- - **Historical images** — images from messages sent before the plugin was enabled can't be described (no hint, no path on disk).
135
- - **Unbounded caches** — both the image store and the description cache grow without eviction (image store: git-project-scoped or user-cache-scoped; description cache: per process).
137
+ - **General parses share one cache entry** — an empty or omitted `question` is treated as a full-image parse and reuses that image's cached description; specific follow-ups keep their own entries (details in *Storage and caches*).
136
138
 
137
139
  ## Roadmap
138
140
 
139
- - [ ] Persistent description cache (content-addressed on disk, with LRU / size cap)
140
141
  - [ ] Region cropping for zooming into image details
141
142
 
142
143
  ## Development
package/README.zh.md CHANGED
@@ -13,9 +13,9 @@
13
13
  ## 特性
14
14
 
15
15
  - **工具化,而非提交时预分析。** 轮次即时启动;模型自己决定何时看图、带着什么问题看。提交零阻塞,失败在 agent 循环里可见、可重试。
16
- - **描述针对问题。** 模型把自己关注的问题传给 `vision_analyze`——而不是提交时预生成的一次性通用描述。
16
+ - **描述针对问题。** 模型把自己关注的问题传给 `vision_analyze`——而不是提交时预生成的一次性通用描述。若只是让描述整张图,模型留空 `question`,工具用固定文案兜底,使同一张图的泛描述共享一条缓存(见"内容寻址缓存")。
17
17
  - **原生快速路径。** 主模型本身有视觉能力时,`vision_analyze` 完全跳过视觉模型,直接把原图作为工具附件返回。
18
- - **内容寻址缓存。** 图片按内容寻址 `<sha256>.<ext>` 落盘(跨会话、同一存储域内天然去重);描述按 `<图片哈希>:<问题>` 缓存——同图同问题只描述一次。
18
+ - **内容寻址缓存。** 图片与描述按内容哈希落到用户级共享目录,跨会话 / 项目 / 重启复用——同一张图不会重复付费描述;同一张图的所有泛解析请求都收敛到同一条缓存。存储路径、容量上限与固定文案详见下文「存储与缓存」。
19
19
  - **统一鉴权。** 视觉调用走 opencode 子会话,复用 opencode 已管理的 provider 凭据,无需额外配置 API Key。
20
20
 
21
21
  ## 安装
@@ -69,8 +69,6 @@ curl 方式说明:
69
69
 
70
70
  受支持的图片扩展名:png / jpg / jpeg / gif / webp。
71
71
 
72
- 图片存储:git 项目内图片放在 `<项目>/.opencode/vision`;非 git 目录则放入用户级缓存目录(`<cache>/opencode-vision-analyze/vision`)——与 opencode 自身的项目/全局会话分域一致。各平台默认:Linux `$XDG_CACHE_HOME || ~/.cache`;macOS `~/Library/Caches`(亦接受 `$XDG_CACHE_HOME` 覆盖);Windows `%LOCALAPPDATA% || ~/AppData/Local`。缓存根 env 为空串视为未设置(回退默认)。git 项目中如不想跟踪缓存图片,请把 `.opencode/vision/` 加入 `.gitignore`。存储根在每次落盘时现算:切换存储范围(如执行 `git init`)后,从下一条贴图起即写入新域;旧会话 hint 里的绝对路径仍指向旧处,重新贴图即注入新 hint。
73
-
74
72
  有序候选 + 自动续接 + 免费优先的配置示例:
75
73
 
76
74
  ```jsonc
@@ -89,6 +87,12 @@ curl 方式说明:
89
87
  }
90
88
  ```
91
89
 
90
+ ### 存储与缓存
91
+
92
+ 图片存储:**恒定放用户级共享目录** `<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` 条目。
93
+
94
+ 描述缓存:**恒定放用户级共享目录** `<cache>/opencode-vision-analyze/descriptions`(与 git / 非 git 分域无关)——每条目一个 JSON 文件,key 为 `<图片sha256>:<生效问题>`、文件名取 `sha256(key)`。`question` 参数可选:描述整张图的泛解析(空 / 省略)会归一化到固定文案 `Describe this image in full detail, including all text, UI elements, diagrams, or content visible.`,措辞与固定文案归一化等价时即落在 key `<图片sha>:Describe this image in full detail, including all text, UI elements, diagrams, or content visible.`;具体追问保留原问句走各自的 `<图片sha>:<问题>` key(格式与旧版一致,存量条目照常命中)。泛解析条目要求描述文本 ≥ 100 字符才写入,防止视觉模型敷衍/拒答的短文本毒化整图共享条目。容量上限 2000 条 / 50MB,任一超限即按文件 mtime 淘汰最久未用的条目(LRU)。写入为原子操作(临时文件 + rename),多个 opencode 进程可安全共享同一描述缓存。
95
+
92
96
  ## 工作原理
93
97
 
94
98
  ```
@@ -98,8 +102,9 @@ curl 方式说明:
98
102
  ├─ 主模型支持图片输入 → 不做任何处理(原图直发)
99
103
  ├─ 无任何 image-capable 模型 → 不做处理(不注入 hint、不落盘;
100
104
  │ 交给核心对图片的默认处理)
101
- └─ 纯文本主模型 → 图片落盘到 vision 存储
102
- (<sha256>.<ext>;git 项目内 → 项目目录,非 git → 用户缓存目录)
105
+ └─ 纯文本主模型 → 图片落盘到用户级 vision 存储
106
+ (<sha256>.<ext>;位于 <cache>/opencode-vision-analyze/vision;
107
+ 本地已存在文件路径原位读用、不复制)
103
108
  并注入 synthetic 提示(TUI 隐藏、模型可见):
104
109
  "用 vision_analyze 工具查看,image_path: ..."
105
110
 
@@ -111,8 +116,10 @@ vision_analyze 工具:
111
116
  ├─ 原生快速路径:会话主模型有视觉能力
112
117
  │ → 原图作为附件直接返回(不调视觉模型)
113
118
  ├─ http(s) 图片 URL → 下载(20 MB 上限)→ 统一磁盘路径
114
- ├─ 描述缓存命中(图片哈希 + 问题)→ 直接返回缓存文本
115
- (标签沿用产出该描述的模型)
119
+ ├─ 描述缓存命中(图片哈希 + 生效问题)→ 直接返回缓存文本
120
+ (泛解析收敛到 <sha>:<固定整图描述文案>;
121
+ │ 持久化于 <cache>/opencode-vision-analyze/descriptions,
122
+ │ 标签沿用产出该描述的模型)
116
123
  └─ 候选链:沿链逐候选建子会话(parentID 挂当前会话、禁用全部工具、
117
124
  专用 system prompt,图片 + 问题发给该候选视觉模型)
118
125
  → 首个成功即返回 → 子会话删除
@@ -127,16 +134,10 @@ vision_analyze 工具:
127
134
  - **免登录免费模型** —— 自动发现与 `/models` 选择器同源(`config.providers()`),免登录也可发现的 zen free 视觉模型会进入候选链(其 provider 为 `custom` 源 → 默认最末档;`free_first: true` 可提到最前)。
128
135
  - **工具永不抛错** —— 所有失败都返回可读文字,agent 循环可以重试、换问题或告知用户。
129
136
  - **URL 图片** —— `image_path` 接受 `http(s)://...` 地址(需以受支持的图片扩展名结尾:png/jpg/jpeg/gif/webp)。
130
-
131
- ## 已知限制
132
-
133
- - **仅 V1 会话流** —— 钩子挂在 V1 `SessionPrompt` 路径上;若 opencode 默认交互切到 V2 会话核心,钩子不会触发(且不报错)。
134
- - **历史图片** —— 插件启用之前发送的图片无法被描述(无提示、磁盘上无路径)。
135
- - **缓存无上限** —— 图片存储与描述缓存均不淘汰(图片存储:git 项目级 / 用户级缓存目录;描述缓存:进程级)。
137
+ - **泛解析共享同一条缓存** —— 空 / 省略的 `question` 视为整图描述,复用该图已缓存的描述;具体追问各自保留条目(细节见「存储与缓存」)。
136
138
 
137
139
  ## Roadmap
138
140
 
139
- - [ ] 描述缓存按内容 sha 落盘持久化(含 LRU / 容量上限)
140
141
  - [ ] 区域裁剪(放大查看图片细节)
141
142
 
142
143
  ## 开发
package/dist/index.d.ts CHANGED
@@ -7,24 +7,78 @@ export declare const providersTimeout: {
7
7
  ms: number;
8
8
  };
9
9
  /**
10
- * 判断目录是否位于 git 项目内:从 dir 向上(含自身)逐级找 `.git`
11
- * (目录,或 worktree/submodule 的 `.git` 指针文件),到文件系统根为止。
12
- * 纯同步、纯内置模块;作为命名导出便于单元测试注入真实临时目录验证。
13
- */
14
- export declare function isInsideGitRepo(dir: string): boolean;
15
- /**
16
- * 用户级(非 git)图片缓存的平台根:cache 目录 + opencode-vision-analyze/vision。
10
+ * 用户级 cache 的平台根(不带应用子目录):cache 目录 + opencode-vision-analyze。
17
11
  * 空字符串 env 视为未设置(XDG/LOCALAPPDATA 官规:空值=未设置)——避免把 "" 当
18
12
  * 有效根导致 path.join("",…) 产出相对路径、相对进程 cwd 落盘。
19
13
  * darwin 亦接受 $XDG_CACHE_HOME 覆盖(跨平台统一 dotfiles 的宽容超集)。
20
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
+ */
21
20
  export declare function userVisionCacheRoot(env: Record<string, string | undefined>, platform: string, home: string): string;
22
21
  /**
23
- * 图片存储根:与 opencode 的项目/全局语义对齐——
24
- * git 项目内 <项目>/.opencode/vision(现状);非 git 目录 用户级缓存目录。
25
- * 解析一次即可(纯函数,入参可注入以便三平台与 env 覆盖的单测)。
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
+ };
51
+ /**
52
+ * 泛解析(描述整张图)用的固定问题文案。
53
+ *
54
+ * 主模型对「解析这张图 / 描述图片」这类请求应省略 question,由工具用这里的文案兜底,
55
+ * 从而让所有泛解析请求的缓存 key 收敛到同一条(key = <图片sha>:GENERIC_QUESTION),
56
+ * 避免主模型每次自编措辞把缓存拆成多份、跨会话永远命中不了。文案点名文字/UI/图表/
57
+ * 可见内容,驱动视觉模型把整图信息尽量带全。
58
+ *
59
+ * 这份文案相当于公开契约:改动它会让旧的泛解析缓存条目失去命中(由 LRU 按 mtime 清理),
60
+ * 属于刻意为之——将来想改泛描述措辞、或按用户/场景定制时,改文案即可自然分开新旧缓存。
61
+ */
62
+ export declare const GENERIC_QUESTION = "Describe this image in full detail, including all text, UI elements, diagrams, or content visible.";
63
+ /**
64
+ * 泛解析缓存条目的最短文本长度(字符)。
65
+ *
66
+ * canonical 描述要求整图加逐字转录,正常结果不可能太短;太短多半是视觉模型敷衍或拒答。
67
+ * 泛解析条目是全图共享的,存进一条垃圾描述会让之后所有泛解析都命中它,所以写入时长度
68
+ * 低于该值就跳过。具体追问不受限制(“答案是 42”是合法答案)。
69
+ * 做成模块级可变对象,测试注入大值/小值验证两侧行为。
70
+ */
71
+ export declare const genericWriteMinText: {
72
+ chars: number;
73
+ };
74
+ /**
75
+ * 归一化问题文本,供「是否为泛解析」判定使用:
76
+ * 去掉首尾空白与引号/括号,把连续空白(含全角/换行)折成一个空格,去掉句末标点,统一小写。
77
+ * 只做字符级归一化,不做语义匹配——误判泛解析会把针对性回答套到错误语义上,宁可 miss。
26
78
  */
27
- export declare function resolveVisionDir(inputDir: string, env: Record<string, string | undefined>, platform: string, home: string): string;
79
+ export declare function normalizeQuestion(q: string): string;
80
+ /** 是否泛解析请求:没给问题(空/纯空白),或归一化后与 GENERIC_QUESTION 一致。 */
81
+ export declare function isGenericQuestion(q: string): boolean;
28
82
  declare const _default: {
29
83
  id: string;
30
84
  server: Plugin;
package/dist/index.js CHANGED
@@ -2,17 +2,26 @@
2
2
  * opencode-vision-analyze
3
3
  *
4
4
  * 为「不具备视觉能力的主模型」提供图片解读路由:当用户在消息中附带图片时,
5
- * 插件把图片落盘到 vision 目录(git 项目内 → <项目>/.opencode/vision
6
- * git 目录 用户级缓存目录),并向模型注入一条 synthetic 提示
5
+ * 插件把图片落盘到**用户级共享目录**(<cache>/opencode-vision-analyze/vision
6
+ * 恒用户级、与 git/非 git 无关),并向模型注入一条 synthetic 提示
7
7
  * (TUI 界面隐藏、模型可见),引导它通过 vision_analyze 工具让指定的视觉
8
8
  * 模型描述图片。若主模型本身支持图片输入,则不做任何干预,原图直接发给主模型。
9
9
  *
10
10
  * 工作方式(vision_analyze 工具路径):主模型调用 vision_analyze 时,插件
11
11
  * 创建一个 parentID 挂在当前会话下的临时子会话(不进会话列表、不生成
12
12
  * 标题、禁用全部工具),把原图以 data URL 发给视觉模型,取回描述文字后
13
- * 删除子会话并返回描述。同一张图 + 同一问题的描述按内容哈希缓存。
14
- * image_path 除了绝对路径也接受 http(s) URL:先下载落盘到同一 vision
15
- * 目录(内容哈希命名,天然与附件落盘去重),再走统一的磁盘加载路径。
13
+ * 删除子会话并返回描述。描述按「<图片sha256>:<问题>」键缓存到**用户级共享目录**
14
+ * (<cache>/opencode-vision-analyze/descriptions,每条目一文件、mtime LRU
15
+ * 时钟、2000 条 / 50MB 双上限、单条超限不入缓存)——跨项目/跨进程/插件重启后
16
+ * 同图同问题只描述一次。
17
+ * 泛解析(描述整图)请求的 question 会归一化到固定文案 GENERIC_QUESTION
18
+ * (主模型省略 question 即走该默认值),key 收敛为 <图片sha>:GENERIC_QUESTION,
19
+ * 避免主模型每次自编措辞把缓存拆碎;有具体追问时保留原问句走各自精确 key。
20
+ * 泛解析条目是全图共享的,写入前按最短文本长度把关,防一条垃圾描述毒化整图。
21
+ * image_path 除了绝对路径也接受 http(s) URL:先下载落盘到同一用户级 vision
22
+ * 目录(内容哈希命名,天然与附件落盘去重;LRU + 容量上限,2000 条 / 500MB),
23
+ * 再走统一的磁盘加载路径。**本地已存在的文件直接原位读取**(不复制、不 touch),
24
+ * 只有贴图/下载才写缓存。
16
25
  * 主模型本身支持图片输入时走快速路径:不做子会话描述,直接把原图作为
17
26
  * 工具附件回传给模型自行查看。
18
27
  *
@@ -24,7 +33,6 @@
24
33
  * 若交互默认切到 V2 Session 核心,本钩子不会触发(也不会报错)。
25
34
  */
26
35
  import { createHash, randomUUID } from "node:crypto";
27
- import { existsSync } from "node:fs";
28
36
  import fs from "node:fs/promises";
29
37
  import { homedir } from "node:os";
30
38
  import path from "node:path";
@@ -74,44 +82,96 @@ const VISION_SYSTEM_PROMPT = [
74
82
  /** data URL 形如 data:<mime>;base64,<payload> */
75
83
  const DATA_URL_PATTERN = /^data:([^;]+);base64,(.+)$/;
76
84
  /**
77
- * 判断目录是否位于 git 项目内:从 dir 向上(含自身)逐级找 `.git`
78
- * (目录,或 worktree/submodule 的 `.git` 指针文件),到文件系统根为止。
79
- * 纯同步、纯内置模块;作为命名导出便于单元测试注入真实临时目录验证。
80
- */
81
- export function isInsideGitRepo(dir) {
82
- let cur = path.resolve(dir);
83
- for (;;) {
84
- if (existsSync(path.join(cur, ".git")))
85
- return true;
86
- const parent = path.dirname(cur);
87
- if (parent === cur)
88
- return false; // 已到文件系统根
89
- cur = parent;
90
- }
91
- }
92
- /**
93
- * 用户级(非 git)图片缓存的平台根:cache 目录 + opencode-vision-analyze/vision。
85
+ * 用户级 cache 的平台根(不带应用子目录):cache 目录 + opencode-vision-analyze。
94
86
  * 空字符串 env 视为未设置(XDG/LOCALAPPDATA 官规:空值=未设置)——避免把 "" 当
95
87
  * 有效根导致 path.join("",…) 产出相对路径、相对进程 cwd 落盘。
96
88
  * darwin 亦接受 $XDG_CACHE_HOME 覆盖(跨平台统一 dotfiles 的宽容超集)。
97
89
  */
98
- export function userVisionCacheRoot(env, platform, home) {
90
+ export function userCacheRootBase(env, platform, home) {
99
91
  const base = platform === "darwin"
100
92
  ? (env.XDG_CACHE_HOME || path.join(home, "Library", "Caches"))
101
93
  : platform === "win32"
102
94
  ? (env.LOCALAPPDATA || path.join(home, "AppData", "Local"))
103
95
  : (env.XDG_CACHE_HOME || path.join(home, ".cache"));
104
- return path.join(base, "opencode-vision-analyze", "vision");
96
+ return path.join(base, "opencode-vision-analyze");
105
97
  }
106
98
  /**
107
- * 图片存储根:与 opencode 的项目/全局语义对齐——
108
- * git 项目内 → <项目>/.opencode/vision(现状);非 git 目录 → 用户级缓存目录。
109
- * 解析一次即可(纯函数,入参可注入以便三平台与 env 覆盖的单测)。
99
+ * 图片缓存的平台根(恒用户级、与 git 无关):cache 目录 + opencode-vision-analyze/vision。
100
+ * 贴图与 http(s) 下载统一落盘于此(内容寻址命名);本地已存在文件不复制、原位读用。
101
+ */
102
+ export function userVisionCacheRoot(env, platform, home) {
103
+ return path.join(userCacheRootBase(env, platform, home), "vision");
104
+ }
105
+ /**
106
+ * 描述缓存的持久化目录:恒走用户级共享 cache(<cache>/opencode-vision-analyze/descriptions),
107
+ * **与 git / 非 git 分域无关**。描述缓存键是「图片内容 sha + 问题」,与项目解耦,放用户级
108
+ * 目录才能在跨项目 / 跨进程 / 重启后共享同一份"同图同问题只描述一次"的结果。
109
+ * 纯函数(入参注入 env/platform/homedir,便于三平台 + 空串 env 回退的单测)。
110
+ */
111
+ export function resolveDescriptionDir(env, platform, home) {
112
+ return path.join(userCacheRootBase(env, platform, home), "descriptions");
113
+ }
114
+ /**
115
+ * 描述缓存的容量上限(模块级可变对象,便于测试注入小值;与 providersTimeout 同风格)。
116
+ * - maxEntries:最大条目数(2000)
117
+ * - maxBytes:条目文件字节总和上限(50 MB)
118
+ * 容量超限触发 LRU 淘汰(按文件 mtime 升序删最旧)。**单条即超 maxBytes 的条目不入缓存**
119
+ * (A / 硬上限,写前 utf8 字节预检跳过),保证磁盘恒 ≤ maxBytes、不因巨值挤掉已付费条目。
120
+ */
121
+ export const descriptionCacheLimits = {
122
+ maxEntries: 2000,
123
+ maxBytes: 50 * 1024 * 1024,
124
+ };
125
+ /**
126
+ * 图片缓存的容量上限(模块级可变对象,便于测试注入小值;与 providersTimeout 同风格)。
127
+ * - maxEntries:最大条目数(2000)
128
+ * - maxBytes:条目文件字节总和上限(500 MB)
129
+ * 容量超限触发 LRU 淘汰(按文件 mtime 升序删最旧)。与描述缓存不同,图片**允许单图超限**
130
+ * (贴图/下载必须给模型稳定 image_path,不能拒绝落盘):单图 > maxBytes 仍写入且当次受
131
+ * protect 保护,容量可短暂超限,待下一次无关写入淘汰时收敛(best-effort)。
110
132
  */
111
- export function resolveVisionDir(inputDir, env, platform, home) {
112
- if (isInsideGitRepo(inputDir))
113
- return path.join(path.resolve(inputDir), ".opencode", "vision");
114
- return userVisionCacheRoot(env, platform, home);
133
+ export const visionCacheLimits = {
134
+ maxEntries: 2000,
135
+ maxBytes: 500 * 1024 * 1024,
136
+ };
137
+ /**
138
+ * 泛解析(描述整张图)用的固定问题文案。
139
+ *
140
+ * 主模型对「解析这张图 / 描述图片」这类请求应省略 question,由工具用这里的文案兜底,
141
+ * 从而让所有泛解析请求的缓存 key 收敛到同一条(key = <图片sha>:GENERIC_QUESTION),
142
+ * 避免主模型每次自编措辞把缓存拆成多份、跨会话永远命中不了。文案点名文字/UI/图表/
143
+ * 可见内容,驱动视觉模型把整图信息尽量带全。
144
+ *
145
+ * 这份文案相当于公开契约:改动它会让旧的泛解析缓存条目失去命中(由 LRU 按 mtime 清理),
146
+ * 属于刻意为之——将来想改泛描述措辞、或按用户/场景定制时,改文案即可自然分开新旧缓存。
147
+ */
148
+ export const GENERIC_QUESTION = "Describe this image in full detail, including all text, UI elements, diagrams, or content visible.";
149
+ /**
150
+ * 泛解析缓存条目的最短文本长度(字符)。
151
+ *
152
+ * canonical 描述要求整图加逐字转录,正常结果不可能太短;太短多半是视觉模型敷衍或拒答。
153
+ * 泛解析条目是全图共享的,存进一条垃圾描述会让之后所有泛解析都命中它,所以写入时长度
154
+ * 低于该值就跳过。具体追问不受限制(“答案是 42”是合法答案)。
155
+ * 做成模块级可变对象,测试注入大值/小值验证两侧行为。
156
+ */
157
+ export const genericWriteMinText = { chars: 100 };
158
+ /**
159
+ * 归一化问题文本,供「是否为泛解析」判定使用:
160
+ * 去掉首尾空白与引号/括号,把连续空白(含全角/换行)折成一个空格,去掉句末标点,统一小写。
161
+ * 只做字符级归一化,不做语义匹配——误判泛解析会把针对性回答套到错误语义上,宁可 miss。
162
+ */
163
+ export function normalizeQuestion(q) {
164
+ return q
165
+ .trim()
166
+ .replace(/^[\s"'“”「」『』()()\[\]]+|[\s"'“”「」『』()()\[\]]+$/g, "")
167
+ .replace(/[\s]+/g, " ")
168
+ .replace(/[.。!!??]+$/g, "")
169
+ .toLowerCase();
170
+ }
171
+ /** 是否泛解析请求:没给问题(空/纯空白),或归一化后与 GENERIC_QUESTION 一致。 */
172
+ export function isGenericQuestion(q) {
173
+ const s = (q ?? "").trim();
174
+ return s === "" || normalizeQuestion(s) === normalizeQuestion(GENERIC_QUESTION);
115
175
  }
116
176
  /**
117
177
  * http(s) 下载图片的大小上限(20 MB)。提示注入可让模型指向超大图片,
@@ -164,12 +224,6 @@ const plugin = async (input, optionsArg) => {
164
224
  const sessionModels = new Map();
165
225
  /** "provider/model" → 是否具备图片输入能力(查询结果缓存,进程级) */
166
226
  const imageCapable = new Map();
167
- /**
168
- * 描述缓存:"<sha>:<question>" → 描述结果(同一张图 + 同一个问题只描述一次)。
169
- * 值带 modelId:记录实际产出该描述的候选模型,缓存命中时标签沿用入库模型,
170
- * 而不是用当前候选链链首近似(链配置变化或 fallback 命中次选时标签才真实)。
171
- */
172
- const descriptions = new Map();
173
227
  /** 本插件创建的子会话 ID 集合(正常路径用后即删,dispose 兜底清理残留) */
174
228
  const subSessions = new Set();
175
229
  /** 任意错误值 → 可读文本:Error 取 message,字符串原样,其余 JSON 序列化兜底。 */
@@ -318,14 +372,14 @@ const plugin = async (input, optionsArg) => {
318
372
  /** 描述标签:标注图片文件名与产出描述的模型引用键(basename 运行时按图传入)。 */
319
373
  const format = (basename, modelId, text) => `[Image: ${basename} — described by ${modelId}]\n${text}`;
320
374
  /**
321
- * 把图片字节内容原子落盘并返回最终 filepath。每次调用现算存储根
322
- * git 项目 项目 .opencode/vision;非 git → 用户级缓存,见 resolveVisionDir):
323
- * 运行中存储范围变化(如 git init)从下一条图片起即时生效,无需重启。
324
- * 先写 `<sha><ext>.tmp-<uuid>` rename:共享目录(用户级/多实例)并发写同一 sha
325
- * 时内容寻址下原子幂等;失败先清理临时文件再抛错,避免孤儿 tmp 累积。
375
+ * 把图片字节内容原子落盘到**用户级缓存目录**(<cache>/opencode-vision-analyze/vision,
376
+ * git 无关)并返回最终 filepath。贴图与 http(s) 下载统一经此写入(本地已存在文件
377
+ * 不复制、直接原位读用)。先写 `<sha><ext>.tmp-<uuid>` 再 rename:共享目录多进程并发
378
+ * 写同一 sha 时内容寻址下原子幂等;失败先清理临时文件再抛错,避免孤儿 tmp 累积。
379
+ * rename 成功后触发图片 LRU 容量淘汰(刚写入文件受 protect 保护)。
326
380
  */
327
381
  const persistImageBytes = async (bytes, ext) => {
328
- const dir = resolveVisionDir(input.directory, process.env, process.platform, homedir());
382
+ const dir = userVisionCacheRoot(process.env, process.platform, homedir());
329
383
  const sha = createHash("sha256").update(bytes).digest("hex");
330
384
  const filepath = path.join(dir, `${sha}${ext}`);
331
385
  const tmpPath = path.join(dir, `${sha}${ext}.tmp-${randomUUID()}`);
@@ -338,6 +392,10 @@ const plugin = async (input, optionsArg) => {
338
392
  await fs.unlink(tmpPath).catch(() => { });
339
393
  throw error;
340
394
  }
395
+ await evictToCaps(dir, visionCacheLimits, {
396
+ filter: (name) => /^[0-9a-f]{64}\.(png|jpe?g|gif|webp)$/i.test(name),
397
+ protectName: path.basename(filepath),
398
+ });
341
399
  return filepath;
342
400
  };
343
401
  /**
@@ -396,6 +454,8 @@ const plugin = async (input, optionsArg) => {
396
454
  };
397
455
  /**
398
456
  * 从磁盘加载图片:按扩展名识别 MIME,读取失败或文件为空返回 undefined。
457
+ * LRU 时钟:**仅当图片位于本插件 vision 缓存根内**(贴图/下载落盘产物)才 touch mtime;
458
+ * 流程 B 直读的外部本地文件绝不触碰(不得改写用户文件 mtime)。
399
459
  */
400
460
  const loadImage = async (filepath) => {
401
461
  const mime = EXT_MIME[path.extname(filepath).toLowerCase()];
@@ -405,12 +465,109 @@ const plugin = async (input, optionsArg) => {
405
465
  const bytes = await fs.readFile(filepath);
406
466
  if (bytes.length === 0)
407
467
  return undefined;
468
+ const visionRoot = userVisionCacheRoot(process.env, process.platform, homedir());
469
+ const rel = path.relative(visionRoot, filepath);
470
+ if (rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)) {
471
+ // 命中缓存内文件 → touch mtime 作 LRU 时钟(best-effort,失败不影响读)
472
+ const now = new Date();
473
+ await fs.utimes(filepath, now, now).catch(() => { });
474
+ }
408
475
  return { bytes, mime };
409
476
  }
410
477
  catch {
411
478
  return undefined;
412
479
  }
413
480
  };
481
+ // ---- 描述缓存(用户级目录落盘,磁盘即事实;全链 fail-open) ---------------------
482
+ /** 描述缓存 key → 磁盘文件路径(<dir>/<sha256(key)>.json)。 */
483
+ const descCacheFile = (key) => path.join(resolveDescriptionDir(process.env, process.platform, homedir()), `${createHash("sha256").update(key).digest("hex")}.json`);
484
+ /** 读取描述缓存:命中(JSON shape 合法)touch mtime 后返回,任何失败一律 miss。 */
485
+ const descCacheGet = async (key) => {
486
+ try {
487
+ const file = descCacheFile(key);
488
+ const raw = JSON.parse(await fs.readFile(file, "utf8"));
489
+ if (typeof raw !== "object" ||
490
+ raw === null ||
491
+ typeof raw.modelId !== "string" ||
492
+ typeof raw.text !== "string") {
493
+ return undefined;
494
+ }
495
+ const value = raw;
496
+ // 命中即触摸 mtime → 作为 LRU 时钟(best-effort)
497
+ const now = new Date();
498
+ await fs.utimes(file, now, now).catch(() => { });
499
+ return value;
500
+ }
501
+ catch {
502
+ return undefined;
503
+ }
504
+ };
505
+ /**
506
+ * 写描述缓存(tmp+rename 原子)并触发容量淘汰;任何失败静默吞掉(best-effort)。
507
+ * 超限策略(A / 硬上限):序列化后先按 utf8 字节预检,单条 > maxBytes 则**不入缓存**
508
+ * (不写盘、不触发淘汰、不删除该 key 已有的旧条目)——避免"超限巨值挤掉全部已付费条目、
509
+ * 且自身活不过下一次无关写入"的写→删抖动,保证磁盘恒 ≤ maxBytes。
510
+ */
511
+ const descCacheSet = async (key, value) => {
512
+ const payload = JSON.stringify(value);
513
+ // 口径与 evict 的 fs.stat().size 一致:磁盘 utf8 字节,非字符串 length(中文/emoji 不等价)
514
+ if (Buffer.byteLength(payload, "utf8") > descriptionCacheLimits.maxBytes)
515
+ return;
516
+ const file = descCacheFile(key);
517
+ const dir = path.dirname(file);
518
+ const tmp = path.join(dir, `${path.basename(file)}.tmp-${randomUUID()}`);
519
+ try {
520
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
521
+ await fs.writeFile(tmp, payload);
522
+ await fs.rename(tmp, file);
523
+ }
524
+ catch {
525
+ await fs.unlink(tmp).catch(() => { });
526
+ return;
527
+ }
528
+ await evictToCaps(dir, descriptionCacheLimits, {
529
+ filter: (name) => name.endsWith(".json"),
530
+ protectName: path.basename(file),
531
+ });
532
+ };
533
+ /**
534
+ * 通用 LRU + 容量淘汰(描述/图片缓存复用):超出 limits.maxEntries / maxBytes 时按
535
+ * mtime 升序删最旧,直到双条件满足。filter 限定参与淘汰的文件(描述 .json;图片 sha 文件,
536
+ * 排除 tmp 残留);protectName 保护刚写入的条目不被本次淘汰自删。
537
+ */
538
+ const evictToCaps = async (dir, limits, opts) => {
539
+ try {
540
+ const names = (await fs.readdir(dir)).filter(opts.filter);
541
+ const stats = await Promise.all(names.map(async (name) => {
542
+ try {
543
+ const s = await fs.stat(path.join(dir, name));
544
+ return { name, size: s.size, mtimeMs: s.mtimeMs };
545
+ }
546
+ catch {
547
+ return undefined;
548
+ }
549
+ }));
550
+ const entries = stats.filter((s) => s !== undefined);
551
+ const { maxEntries, maxBytes } = limits;
552
+ let total = entries.reduce((sum, e) => sum + e.size, 0);
553
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name));
554
+ // 逐条删最旧直至双条件满足;刚写入的条目受保护(极端单条超限时保留最新,不自我删除)。
555
+ // 用额外计数控制,不就地改遍历数组(entries 仅作删除候选快照)。
556
+ let remaining = entries.length;
557
+ for (const entry of entries) {
558
+ if (total <= maxBytes && remaining <= maxEntries)
559
+ break;
560
+ if (entry.name === opts.protectName)
561
+ continue;
562
+ await fs.unlink(path.join(dir, entry.name)).catch(() => { });
563
+ total -= entry.size;
564
+ remaining -= 1;
565
+ }
566
+ }
567
+ catch {
568
+ // 目录扫描/删除失败忽略:淘汰是 best-effort,下次写入再触发
569
+ }
570
+ };
414
571
  /**
415
572
  * vision_analyze 工具:主模型传入图片路径与问题,返回视觉模型给出的描述。
416
573
  * 工具永不抛错——所有失败都以错误文字返回,让 agent 循环可以读到原因并
@@ -419,8 +576,14 @@ const plugin = async (input, optionsArg) => {
419
576
  const visionAnalyze = async (args, ctx) => {
420
577
  const title = "vision_analyze";
421
578
  try {
422
- const question = args.question?.trim() || "Describe this image in full detail.";
423
- // http(s) URL:先下载到本地 vision 目录,再统一走磁盘加载路径。
579
+ // 泛解析判定:没给问题(空/纯空白),或措辞与 GENERIC_QUESTION 归一化一致。
580
+ // 泛解析请求统一改写为 GENERIC_QUESTION——缓存 key 固定成 <图片sha>:GENERIC_QUESTION,
581
+ // 跨会话、跨措辞都能命中;主模型若问的是具体对象/文字/区域/颜色,则保留原问句,
582
+ // 走各自的精确 key(与旧版本格式一致,存量条目不受影响)。
583
+ const trimmed = args.question?.trim() ?? "";
584
+ const generic = trimmed === "" || isGenericQuestion(trimmed);
585
+ const question = generic ? GENERIC_QUESTION : trimmed;
586
+ // http(s) URL:先下载到用户级 vision 缓存目录,再统一走磁盘加载路径。
424
587
  // ctx.abort 传入下载:用户中止即刻断下载(含 pre-abort 不再发请求)。
425
588
  const download = /^https?:\/\//i.test(args.image_path)
426
589
  ? await downloadImage(args.image_path, ctx.abort)
@@ -449,9 +612,10 @@ const plugin = async (input, optionsArg) => {
449
612
  ],
450
613
  };
451
614
  }
452
- // 描述缓存:内容哈希 + 问题作为 key,命中直接复用(title 标注 cached)。
615
+ // 描述缓存:内容哈希 + 生效问题作为 key,命中直接复用(title 标注 cached)。
616
+ // 落盘在用户级共享目录(resolveDescriptionDir)——跨项目/进程/重启命中;磁盘即事实。
453
617
  const key = `${createHash("sha256").update(image.bytes).digest("hex")}:${question}`;
454
- const cached = descriptions.get(key);
618
+ const cached = await descCacheGet(key);
455
619
  if (cached !== undefined) {
456
620
  // 命中时标签沿用入库时的模型(cached.modelId):即便此刻候选链链首
457
621
  // 已与入库模型不同,也保持标签真实、不重写。
@@ -460,8 +624,14 @@ const plugin = async (input, optionsArg) => {
460
624
  const result = await describeWithChain(image, question, ctx);
461
625
  if (!result.ok)
462
626
  return { title, output: `Image analysis failed: ${result.error}` };
627
+ // 泛解析条目是全图共享的:文本太短多半是视觉模型敷衍/拒答,存进去会毒化这条
628
+ // canonical 缓存,让以后所有泛解析都命中垃圾描述,所以低于门槛就跳过落盘。
629
+ // 具体追问不受限("答案是 42" 是合法短答案)。
630
+ if (generic && result.text.length < genericWriteMinText.chars) {
631
+ return { title, output: format(path.basename(imagePath), result.modelId, result.text) };
632
+ }
463
633
  // 入库带上实际产出描述的候选 modelId,供后续缓存命中还原真实标签
464
- descriptions.set(key, { modelId: result.modelId, text: result.text });
634
+ await descCacheSet(key, { modelId: result.modelId, text: result.text });
465
635
  // 成功标签直接用实际产出描述的候选引用键(而非链首近似)
466
636
  return { title, output: format(path.basename(imagePath), result.modelId, result.text) };
467
637
  }
@@ -630,6 +800,10 @@ const plugin = async (input, optionsArg) => {
630
800
  }
631
801
  if (lines.length === 0)
632
802
  return;
803
+ // 泛解析指引:主模型对"描述整图/解析图片"这类请求应省略 question(工具内部会用固定
804
+ // canonical 文案兜底,缓存 key 才能跨会话收敛);只有用户问具体对象/文字/区域/颜色时才
805
+ // 填具体问句。措辞分化的泛问句会让缓存 key 拆碎、永远命中不了。
806
+ lines.push("[When the user asks for a general parse of the whole image, call vision_analyze WITHOUT a question (omit the question parameter). Only pass a question when the user asks about a specific object, text, region, or color.]");
633
807
  // 注入 synthetic text part:TUI 隐藏(不干扰用户输入展示),但会发给模型。
634
808
  // id 需满足 PartID 约定(prt 前缀)。
635
809
  const hint = {
@@ -650,10 +824,13 @@ const plugin = async (input, optionsArg) => {
650
824
  // 既不引入 zod 运行时依赖(本插件只用 node 内置模块),也不使用 any。
651
825
  tool: {
652
826
  vision_analyze: {
653
- 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.",
827
+ 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 is optional: pass it only when you need a specific detail (an object, text, region or color); otherwise omit it to get a full description of the whole image.",
654
828
  args: {
655
829
  image_path: { type: "string", description: "Absolute path to the image file, or an http(s) image URL." },
656
- question: { type: "string", description: "What to look for or answer about the image." },
830
+ question: {
831
+ type: "string",
832
+ description: "Optional. What specific detail to look for (object/text/region/color). Omit for a full description of the whole image.",
833
+ },
657
834
  },
658
835
  execute: visionAnalyze,
659
836
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vision-analyze",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",