opencode-see-image 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -5
  2. package/index.ts +137 -61
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -70,9 +70,9 @@ plugin's system-prompt instructions tell the model to call see_image
70
70
 
71
71
 
72
72
  see_image tool:
73
- 1. locates the file (macOS screenshot temp dirs, ~/Desktop, ~/Downloads, cwd)
74
- 2. base64-encodes it
75
- 3. routes it to the vision model via opencode's SDK (or direct HTTP if SEE_IMAGE_API_KEY is set)
73
+ 1. queries opencode's SQLite DB for the image (handles clipboard pastes, dragged files, screenshots)
74
+ 2. falls back to filesystem search if not in DB
75
+ 3. sends the image to the vision model via opencode's SDK
76
76
  4. returns the textual description
77
77
 
78
78
 
@@ -153,8 +153,7 @@ Then restart opencode. (No bun required, this uses opencode's own bun.)
153
153
 
154
154
  ## Limitations
155
155
 
156
- - **Clipboard pastes don't work** — when you paste an image from clipboard (Cmd+V), opencode processes it in-memory but discards it before writing to disk if the model doesn't support image input. The plugin can't access it. **Drag screenshots instead**, or save the clipboard image to a file first.
157
- - **macOS only** — file search locations target macOS screenshot temp dirs. Linux/Windows users need to pass absolute paths.
156
+ - **macOS-only filesystem search** — the filesystem fallback targets macOS screenshot temp dirs. Linux/Windows users should rely on the DB lookup (which is cross-platform) or pass absolute paths.
158
157
 
159
158
  ## File search locations
160
159
 
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
2
2
  import path from "path"
3
3
  import os from "os"
4
4
  import fs from "fs"
5
+ import { Database } from "bun:sqlite"
5
6
  import type { Plugin } from "@opencode-ai/plugin"
6
7
 
7
8
  const ENDPOINT =
@@ -23,61 +24,139 @@ const EXT_MEDIA: Record<string, string> = {
23
24
  bmp: "image/bmp",
24
25
  }
25
26
 
26
- function resolveFilePath(name: string, cwd: string): string {
27
- if (path.isAbsolute(name) && fs.existsSync(name)) return name
27
+ type ResolvedImage = {
28
+ dataUrl: string
29
+ mediaType: string
30
+ source: string
31
+ }
28
32
 
29
- const resolved = path.resolve(cwd, name)
30
- if (fs.existsSync(resolved)) return resolved
33
+ function opencodeDbPath(): string {
34
+ const dataDir =
35
+ process.env.OPENCODE_DATA_DIR ||
36
+ process.env.XDG_DATA_HOME ||
37
+ path.join(os.homedir(), ".local/share/opencode")
38
+ return path.join(dataDir, "opencode.db")
39
+ }
31
40
 
32
- const tmpdir = process.env.TMPDIR || "/tmp"
33
- const searchDirs: string[] = []
41
+ function resolveFromDb(filename: string): ResolvedImage | null {
42
+ const dbPath = opencodeDbPath()
43
+ if (!fs.existsSync(dbPath)) return null
34
44
 
35
- const tempItems = path.join(tmpdir, "TemporaryItems")
36
- if (fs.existsSync(tempItems)) {
37
- try {
38
- for (const sub of fs.readdirSync(tempItems, { withFileTypes: true })) {
39
- if (sub.isDirectory() && sub.name.startsWith("NSIRD_screencaptureui")) {
40
- searchDirs.push(path.join(tempItems, sub.name))
41
- }
42
- }
43
- } catch {}
45
+ try {
46
+ const db = new Database(dbPath, { readonly: true })
47
+ let rows: Array<{ data: string }>
48
+
49
+ if (!filename || filename === "clipboard") {
50
+ // No filename: get the most recent file part (handles clipboard pastes
51
+ // where opencode labels the error as "clipboard" but the DB part has
52
+ // the original filename).
53
+ rows = db
54
+ .query(
55
+ `SELECT data FROM part
56
+ WHERE json_extract(data, '$.type') = 'file'
57
+ AND json_extract(data, '$.url') LIKE 'data:%'
58
+ ORDER BY time_created DESC LIMIT 1`,
59
+ )
60
+ .all() as Array<{ data: string }>
61
+ } else {
62
+ rows = db
63
+ .query(
64
+ `SELECT data FROM part
65
+ WHERE json_extract(data, '$.type') = 'file'
66
+ AND json_extract(data, '$.filename') = ?
67
+ ORDER BY time_created DESC LIMIT 1`,
68
+ )
69
+ .all(filename) as Array<{ data: string }>
70
+ }
71
+
72
+ db.close()
73
+
74
+ if (!rows.length) return null
75
+ const part = JSON.parse(rows[0].data)
76
+ const url: string = part.url || ""
77
+ if (!url.startsWith("data:")) return null
78
+
79
+ return {
80
+ dataUrl: url,
81
+ mediaType: part.mime || "image/png",
82
+ source: "opencode-db",
83
+ }
84
+ } catch {
85
+ return null
44
86
  }
45
- searchDirs.push(tempItems)
46
- searchDirs.push(path.join(os.homedir(), "Desktop"))
47
- searchDirs.push(path.join(os.homedir(), "Downloads"))
48
- searchDirs.push(cwd)
87
+ }
49
88
 
50
- for (const dir of searchDirs) {
51
- if (!dir) continue
52
- try {
53
- const full = path.join(dir, name)
54
- if (fs.existsSync(full)) return full
55
- } catch {}
89
+ function resolveFromFilesystem(
90
+ name: string,
91
+ cwd: string,
92
+ ): ResolvedImage | null {
93
+ let absPath: string | null = null
94
+
95
+ if (path.isAbsolute(name) && fs.existsSync(name)) {
96
+ absPath = name
97
+ } else {
98
+ const resolved = path.resolve(cwd, name)
99
+ if (fs.existsSync(resolved)) absPath = resolved
56
100
  }
57
101
 
58
- for (const dir of searchDirs) {
59
- if (!dir || !fs.existsSync(dir)) continue
60
- try {
61
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
62
- if (entry.name === name) return path.join(dir, name)
63
- }
64
- } catch {}
102
+ if (!absPath) {
103
+ const tmpdir = process.env.TMPDIR || "/tmp"
104
+ const searchDirs: string[] = []
105
+ const tempItems = path.join(tmpdir, "TemporaryItems")
106
+ if (fs.existsSync(tempItems)) {
107
+ try {
108
+ for (const sub of fs.readdirSync(tempItems, { withFileTypes: true })) {
109
+ if (
110
+ sub.isDirectory() &&
111
+ sub.name.startsWith("NSIRD_screencaptureui")
112
+ ) {
113
+ searchDirs.push(path.join(tempItems, sub.name))
114
+ }
115
+ }
116
+ } catch {}
117
+ }
118
+ searchDirs.push(tempItems)
119
+ searchDirs.push(path.join(os.homedir(), "Desktop"))
120
+ searchDirs.push(path.join(os.homedir(), "Downloads"))
121
+ searchDirs.push(cwd)
122
+
123
+ for (const dir of searchDirs) {
124
+ if (!dir) continue
125
+ try {
126
+ const full = path.join(dir, name)
127
+ if (fs.existsSync(full)) {
128
+ absPath = full
129
+ break
130
+ }
131
+ } catch {}
132
+ }
133
+ }
134
+
135
+ if (!absPath || !fs.existsSync(absPath)) return null
136
+
137
+ const ext = path.extname(absPath).slice(1).toLowerCase()
138
+ const mediaType = EXT_MEDIA[ext] || "image/png"
139
+ const b64 = Buffer.from(fs.readFileSync(absPath)).toString("base64")
140
+
141
+ return {
142
+ dataUrl: `data:${mediaType};base64,${b64}`,
143
+ mediaType,
144
+ source: absPath,
65
145
  }
146
+ }
147
+
148
+ function resolveImage(name: string, cwd: string): ResolvedImage {
149
+ // DB first: handles clipboard pastes, dragged files, screenshots.
150
+ // For "clipboard" or empty name, gets the most recent file part.
151
+ const fromDb = resolveFromDb(name)
152
+ if (fromDb) return fromDb
153
+
154
+ // Filesystem fallback for files not yet in the DB.
155
+ const fromFs = resolveFromFilesystem(name, cwd)
156
+ if (fromFs) return fromFs
66
157
 
67
- const nsirdCount = searchDirs.filter((d) =>
68
- String(d).includes("NSIRD_screencaptureui"),
69
- ).length
70
- const summary = [
71
- `${nsirdCount} macOS screenshot temp dirs`,
72
- "~/Desktop",
73
- "~/Downloads",
74
- cwd,
75
- ]
76
- .filter(Boolean)
77
- .join(", ")
78
158
  throw new Error(
79
- `see_image: could not find "${name}". Searched ${summary}. ` +
80
- `Pass an absolute filePath instead.`,
159
+ `see_image: could not find "${name}". Searched opencode DB and filesystem. Pass an absolute filePath instead.`,
81
160
  )
82
161
  }
83
162
 
@@ -286,16 +365,12 @@ async function maybeAutoUpdate(
286
365
 
287
366
  log(`update available: ${current} -> ${latest}; updating`, "info")
288
367
 
289
- // Use opencode's own plugin command to re-resolve from npm. This uses
290
- // opencode's bundled bun, so it works even when bun isn't installed
291
- // globally on the user's PATH.
292
368
  const opencodeBin =
293
369
  process.env.OPENCODE_BIN ||
294
370
  path.join(os.homedir(), ".opencode/bin/opencode")
295
371
  try {
296
372
  await $`${opencodeBin} plugin ${PKG_NAME} --force --global`.quiet()
297
373
  } catch (e: any) {
298
- // Fallback: try bare `opencode` on PATH
299
374
  try {
300
375
  await $`opencode plugin ${PKG_NAME} --force --global`.quiet()
301
376
  } catch (e2: any) {
@@ -329,7 +404,7 @@ const SeeImagePlugin: Plugin = async (ctx) => {
329
404
 
330
405
  const seeImageTool = tool({
331
406
  description:
332
- 'See an image/screenshot that the current model cannot view. Use when the user attaches an image and you get a "this model does not support image input" / "Cannot read" error, or when a screenshot/image is referenced ("see this", "can you see", .png/.jpg). Routes the image to a vision-capable model and returns a detailed textual description you can reason about as if you saw it. Pass filePath as an absolute path OR a bare filename (auto-located in macOS screenshot temp dirs, ~/Desktop, ~/Downloads, cwd).',
407
+ 'See an image/screenshot that the current model cannot view. Use when the user attaches an image and you get a "this model does not support image input" / "Cannot read" error, or when a screenshot/image is referenced ("see this", "can you see", .png/.jpg). Routes the image to a vision-capable model and returns a detailed textual description you can reason about as if you saw it. Pass filePath as an absolute path OR a bare filename (auto-located from opencode DB or filesystem).',
333
408
  args: {
334
409
  filePath: tool.schema
335
410
  .string()
@@ -344,13 +419,7 @@ const SeeImagePlugin: Plugin = async (ctx) => {
344
419
  ),
345
420
  },
346
421
  async execute(args, context) {
347
- const fullPath = resolveFilePath(args.filePath, context.directory)
348
- const ext = path.extname(fullPath).slice(1).toLowerCase()
349
- const mediaType = EXT_MEDIA[ext] || "image/png"
350
-
351
- const buf = fs.readFileSync(fullPath)
352
- const b64 = Buffer.from(buf).toString("base64")
353
- const dataUrl = `data:${mediaType};base64,${b64}`
422
+ const resolved = resolveImage(args.filePath, context.directory)
354
423
 
355
424
  const prompt =
356
425
  args.question && args.question.trim().length > 0
@@ -360,17 +429,24 @@ const SeeImagePlugin: Plugin = async (ctx) => {
360
429
  let result: { text: string; model: string; provider: string }
361
430
 
362
431
  if (process.env.SEE_IMAGE_API_KEY) {
363
- result = await seeImageViaHTTP(b64, mediaType, prompt, context.abort)
432
+ const b64 = resolved.dataUrl.split(",")[1] || ""
433
+ result = await seeImageViaHTTP(b64, resolved.mediaType, prompt, context.abort)
364
434
  } else {
365
- result = await seeImageViaSDK(client, dataUrl, mediaType, prompt, context.abort)
435
+ result = await seeImageViaSDK(
436
+ client,
437
+ resolved.dataUrl,
438
+ resolved.mediaType,
439
+ prompt,
440
+ context.abort,
441
+ )
366
442
  }
367
443
 
368
444
  context.metadata({
369
- title: `see_image: ${path.basename(fullPath)}`,
445
+ title: `see_image: ${args.filePath}`,
370
446
  metadata: {
371
447
  model: result.model,
372
448
  provider: result.provider,
373
- file: fullPath,
449
+ source: resolved.source,
374
450
  },
375
451
  })
376
452
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-see-image",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Give non-vision opencode models the ability to see images/screenshots by routing them to a vision-capable model (MiniMax M3 via opencode-go by default).",
5
5
  "type": "module",
6
6
  "main": "index.ts",