opencode-vision-analyze 0.3.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
@@ -15,7 +15,7 @@ A tool-based vision routing plugin for [opencode](https://opencode.ai): when the
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
16
  - **Question-aware descriptions.** The model passes its own focused question to `vision_analyze` — not a one-shot generic caption computed at submit time.
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 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).
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,7 +69,9 @@ 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.
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.
73
75
 
74
76
  An ordered-candidates example with auto-fallback and free-first discovery:
75
77
 
@@ -98,8 +100,9 @@ User pastes image + question
98
100
  ├─ main model has image input capability → do nothing (raw image goes to model)
99
101
  ├─ no image-capable model available → do nothing (no hint, no persist;
100
102
  │ 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)
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)
103
106
  and inject a synthetic hint (hidden in TUI, visible to model):
104
107
  "use the vision_analyze tool with image_path: ..."
105
108
 
@@ -112,7 +115,8 @@ vision_analyze tool:
112
115
  │ → return raw image as attachment (no vision model call)
113
116
  ├─ http(s) image URL → download (20 MB cap) → same disk path
114
117
  ├─ description cache hit (sha + question) → return cached text
115
- │ (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)
116
120
  └─ candidate chain: sub-session under current session per candidate, in order —
117
121
  parentID, all tools disabled, dedicated system prompt, image + question
118
122
  sent to that vision model → first success returns → sub-session deleted
@@ -131,12 +135,9 @@ Key behaviors:
131
135
  ## Known limitations
132
136
 
133
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).
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).
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
@@ -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,7 +69,9 @@ 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。
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 进程可安全共享同一描述缓存。
73
75
 
74
76
  有序候选 + 自动续接 + 免费优先的配置示例:
75
77
 
@@ -98,8 +100,9 @@ curl 方式说明:
98
100
  ├─ 主模型支持图片输入 → 不做任何处理(原图直发)
99
101
  ├─ 无任何 image-capable 模型 → 不做处理(不注入 hint、不落盘;
100
102
  │ 交给核心对图片的默认处理)
101
- └─ 纯文本主模型 → 图片落盘到 vision 存储
102
- (<sha256>.<ext>;git 项目内 → 项目目录,非 git → 用户缓存目录)
103
+ └─ 纯文本主模型 → 图片落盘到用户级 vision 存储
104
+ (<sha256>.<ext>;位于 <cache>/opencode-vision-analyze/vision;
105
+ 本地已存在文件路径原位读用、不复制)
103
106
  并注入 synthetic 提示(TUI 隐藏、模型可见):
104
107
  "用 vision_analyze 工具查看,image_path: ..."
105
108
 
@@ -112,7 +115,8 @@ vision_analyze 工具:
112
115
  │ → 原图作为附件直接返回(不调视觉模型)
113
116
  ├─ http(s) 图片 URL → 下载(20 MB 上限)→ 统一磁盘路径
114
117
  ├─ 描述缓存命中(图片哈希 + 问题)→ 直接返回缓存文本
115
- (标签沿用产出该描述的模型)
118
+ (持久化于 <cache>/opencode-vision-analyze/descriptions,
119
+ │ 标签沿用产出该描述的模型;LRU 上限 2000 条 / 50MB)
116
120
  └─ 候选链:沿链逐候选建子会话(parentID 挂当前会话、禁用全部工具、
117
121
  专用 system prompt,图片 + 问题发给该候选视觉模型)
118
122
  → 首个成功即返回 → 子会话删除
@@ -131,12 +135,9 @@ vision_analyze 工具:
131
135
  ## 已知限制
132
136
 
133
137
  - **仅 V1 会话流** —— 钩子挂在 V1 `SessionPrompt` 路径上;若 opencode 默认交互切到 V2 会话核心,钩子不会触发(且不报错)。
134
- - **历史图片** —— 插件启用之前发送的图片无法被描述(无提示、磁盘上无路径)。
135
- - **缓存无上限** —— 图片存储与描述缓存均不淘汰(图片存储:git 项目级 / 用户级缓存目录;描述缓存:进程级)。
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,47 @@ 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、不因巨值挤掉已付费条目。
26
34
  */
27
- export declare function resolveVisionDir(inputDir: string, env: Record<string, string | undefined>, platform: string, home: string): string;
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
+ };
28
51
  declare const _default: {
29
52
  id: string;
30
53
  server: Plugin;
package/dist/index.js CHANGED
@@ -2,17 +2,22 @@
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,
15
+ * 每条目一文件、mtime 作 LRU 时钟、2000 条 / 50MB 双上限、单条超限不入缓存)——
16
+ * 跨项目/跨进程/插件重启后同图同问题只描述一次。
17
+ * image_path 除了绝对路径也接受 http(s) URL:先下载落盘到同一用户级 vision
18
+ * 目录(内容哈希命名,天然与附件落盘去重;LRU + 容量上限,2000 条 / 500MB),
19
+ * 再走统一的磁盘加载路径。**本地已存在的文件直接原位读取**(不复制、不 touch),
20
+ * 只有贴图/下载才写缓存。
16
21
  * 主模型本身支持图片输入时走快速路径:不做子会话描述,直接把原图作为
17
22
  * 工具附件回传给模型自行查看。
18
23
  *
@@ -24,7 +29,6 @@
24
29
  * 若交互默认切到 V2 Session 核心,本钩子不会触发(也不会报错)。
25
30
  */
26
31
  import { createHash, randomUUID } from "node:crypto";
27
- import { existsSync } from "node:fs";
28
32
  import fs from "node:fs/promises";
29
33
  import { homedir } from "node:os";
30
34
  import path from "node:path";
@@ -74,45 +78,58 @@ const VISION_SYSTEM_PROMPT = [
74
78
  /** data URL 形如 data:<mime>;base64,<payload> */
75
79
  const DATA_URL_PATTERN = /^data:([^;]+);base64,(.+)$/;
76
80
  /**
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。
81
+ * 用户级 cache 的平台根(不带应用子目录):cache 目录 + opencode-vision-analyze。
94
82
  * 空字符串 env 视为未设置(XDG/LOCALAPPDATA 官规:空值=未设置)——避免把 "" 当
95
83
  * 有效根导致 path.join("",…) 产出相对路径、相对进程 cwd 落盘。
96
84
  * darwin 亦接受 $XDG_CACHE_HOME 覆盖(跨平台统一 dotfiles 的宽容超集)。
97
85
  */
98
- export function userVisionCacheRoot(env, platform, home) {
86
+ export function userCacheRootBase(env, platform, home) {
99
87
  const base = platform === "darwin"
100
88
  ? (env.XDG_CACHE_HOME || path.join(home, "Library", "Caches"))
101
89
  : platform === "win32"
102
90
  ? (env.LOCALAPPDATA || path.join(home, "AppData", "Local"))
103
91
  : (env.XDG_CACHE_HOME || path.join(home, ".cache"));
104
- return path.join(base, "opencode-vision-analyze", "vision");
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");
105
100
  }
106
101
  /**
107
- * 图片存储根:与 opencode 的项目/全局语义对齐——
108
- * git 项目内 <项目>/.opencode/vision(现状);非 git 目录 用户级缓存目录。
109
- * 解析一次即可(纯函数,入参可注入以便三平台与 env 覆盖的单测)。
102
+ * 描述缓存的持久化目录:恒走用户级共享 cache(<cache>/opencode-vision-analyze/descriptions),
103
+ * **与 git / git 分域无关**。描述缓存键是「图片内容 sha + 问题」,与项目解耦,放用户级
104
+ * 目录才能在跨项目 / 跨进程 / 重启后共享同一份"同图同问题只描述一次"的结果。
105
+ * 纯函数(入参注入 env/platform/homedir,便于三平台 + 空串 env 回退的单测)。
110
106
  */
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);
107
+ export function resolveDescriptionDir(env, platform, home) {
108
+ return path.join(userCacheRootBase(env, platform, home), "descriptions");
115
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
+ };
116
133
  /**
117
134
  * http(s) 下载图片的大小上限(20 MB)。提示注入可让模型指向超大图片,
118
135
  * 下载不限长是成本/健壮性放大器:先按 content-length 头提前拒绝,
@@ -164,12 +181,6 @@ const plugin = async (input, optionsArg) => {
164
181
  const sessionModels = new Map();
165
182
  /** "provider/model" → 是否具备图片输入能力(查询结果缓存,进程级) */
166
183
  const imageCapable = new Map();
167
- /**
168
- * 描述缓存:"<sha>:<question>" → 描述结果(同一张图 + 同一个问题只描述一次)。
169
- * 值带 modelId:记录实际产出该描述的候选模型,缓存命中时标签沿用入库模型,
170
- * 而不是用当前候选链链首近似(链配置变化或 fallback 命中次选时标签才真实)。
171
- */
172
- const descriptions = new Map();
173
184
  /** 本插件创建的子会话 ID 集合(正常路径用后即删,dispose 兜底清理残留) */
174
185
  const subSessions = new Set();
175
186
  /** 任意错误值 → 可读文本:Error 取 message,字符串原样,其余 JSON 序列化兜底。 */
@@ -318,14 +329,14 @@ const plugin = async (input, optionsArg) => {
318
329
  /** 描述标签:标注图片文件名与产出描述的模型引用键(basename 运行时按图传入)。 */
319
330
  const format = (basename, modelId, text) => `[Image: ${basename} — described by ${modelId}]\n${text}`;
320
331
  /**
321
- * 把图片字节内容原子落盘并返回最终 filepath。每次调用现算存储根
322
- * git 项目 项目 .opencode/vision;非 git → 用户级缓存,见 resolveVisionDir):
323
- * 运行中存储范围变化(如 git init)从下一条图片起即时生效,无需重启。
324
- * 先写 `<sha><ext>.tmp-<uuid>` rename:共享目录(用户级/多实例)并发写同一 sha
325
- * 时内容寻址下原子幂等;失败先清理临时文件再抛错,避免孤儿 tmp 累积。
332
+ * 把图片字节内容原子落盘到**用户级缓存目录**(<cache>/opencode-vision-analyze/vision,
333
+ * git 无关)并返回最终 filepath。贴图与 http(s) 下载统一经此写入(本地已存在文件
334
+ * 不复制、直接原位读用)。先写 `<sha><ext>.tmp-<uuid>` 再 rename:共享目录多进程并发
335
+ * 写同一 sha 时内容寻址下原子幂等;失败先清理临时文件再抛错,避免孤儿 tmp 累积。
336
+ * rename 成功后触发图片 LRU 容量淘汰(刚写入文件受 protect 保护)。
326
337
  */
327
338
  const persistImageBytes = async (bytes, ext) => {
328
- const dir = resolveVisionDir(input.directory, process.env, process.platform, homedir());
339
+ const dir = userVisionCacheRoot(process.env, process.platform, homedir());
329
340
  const sha = createHash("sha256").update(bytes).digest("hex");
330
341
  const filepath = path.join(dir, `${sha}${ext}`);
331
342
  const tmpPath = path.join(dir, `${sha}${ext}.tmp-${randomUUID()}`);
@@ -338,6 +349,10 @@ const plugin = async (input, optionsArg) => {
338
349
  await fs.unlink(tmpPath).catch(() => { });
339
350
  throw error;
340
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
+ });
341
356
  return filepath;
342
357
  };
343
358
  /**
@@ -396,6 +411,8 @@ const plugin = async (input, optionsArg) => {
396
411
  };
397
412
  /**
398
413
  * 从磁盘加载图片:按扩展名识别 MIME,读取失败或文件为空返回 undefined。
414
+ * LRU 时钟:**仅当图片位于本插件 vision 缓存根内**(贴图/下载落盘产物)才 touch mtime;
415
+ * 流程 B 直读的外部本地文件绝不触碰(不得改写用户文件 mtime)。
399
416
  */
400
417
  const loadImage = async (filepath) => {
401
418
  const mime = EXT_MIME[path.extname(filepath).toLowerCase()];
@@ -405,12 +422,109 @@ const plugin = async (input, optionsArg) => {
405
422
  const bytes = await fs.readFile(filepath);
406
423
  if (bytes.length === 0)
407
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
+ }
408
432
  return { bytes, mime };
409
433
  }
410
434
  catch {
411
435
  return undefined;
412
436
  }
413
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
+ };
414
528
  /**
415
529
  * vision_analyze 工具:主模型传入图片路径与问题,返回视觉模型给出的描述。
416
530
  * 工具永不抛错——所有失败都以错误文字返回,让 agent 循环可以读到原因并
@@ -420,7 +534,7 @@ const plugin = async (input, optionsArg) => {
420
534
  const title = "vision_analyze";
421
535
  try {
422
536
  const question = args.question?.trim() || "Describe this image in full detail.";
423
- // http(s) URL:先下载到本地 vision 目录,再统一走磁盘加载路径。
537
+ // http(s) URL:先下载到用户级 vision 缓存目录,再统一走磁盘加载路径。
424
538
  // ctx.abort 传入下载:用户中止即刻断下载(含 pre-abort 不再发请求)。
425
539
  const download = /^https?:\/\//i.test(args.image_path)
426
540
  ? await downloadImage(args.image_path, ctx.abort)
@@ -450,8 +564,9 @@ const plugin = async (input, optionsArg) => {
450
564
  };
451
565
  }
452
566
  // 描述缓存:内容哈希 + 问题作为 key,命中直接复用(title 标注 cached)。
567
+ // 落盘在用户级共享目录(resolveDescriptionDir)——跨项目/进程/重启命中;磁盘即事实。
453
568
  const key = `${createHash("sha256").update(image.bytes).digest("hex")}:${question}`;
454
- const cached = descriptions.get(key);
569
+ const cached = await descCacheGet(key);
455
570
  if (cached !== undefined) {
456
571
  // 命中时标签沿用入库时的模型(cached.modelId):即便此刻候选链链首
457
572
  // 已与入库模型不同,也保持标签真实、不重写。
@@ -461,7 +576,7 @@ const plugin = async (input, optionsArg) => {
461
576
  if (!result.ok)
462
577
  return { title, output: `Image analysis failed: ${result.error}` };
463
578
  // 入库带上实际产出描述的候选 modelId,供后续缓存命中还原真实标签
464
- descriptions.set(key, { modelId: result.modelId, text: result.text });
579
+ await descCacheSet(key, { modelId: result.modelId, text: result.text });
465
580
  // 成功标签直接用实际产出描述的候选引用键(而非链首近似)
466
581
  return { title, output: format(path.basename(imagePath), result.modelId, result.text) };
467
582
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-vision-analyze",
3
- "version": "0.3.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",