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.
- package/README.md +4 -5
- package/index.ts +137 -61
- 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.
|
|
74
|
-
2.
|
|
75
|
-
3.
|
|
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
|
-
- **
|
|
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
|
-
|
|
27
|
-
|
|
27
|
+
type ResolvedImage = {
|
|
28
|
+
dataUrl: string
|
|
29
|
+
mediaType: string
|
|
30
|
+
source: string
|
|
31
|
+
}
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
const
|
|
41
|
+
function resolveFromDb(filename: string): ResolvedImage | null {
|
|
42
|
+
const dbPath = opencodeDbPath()
|
|
43
|
+
if (!fs.existsSync(dbPath)) return null
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
searchDirs.push(path.join(os.homedir(), "Desktop"))
|
|
47
|
-
searchDirs.push(path.join(os.homedir(), "Downloads"))
|
|
48
|
-
searchDirs.push(cwd)
|
|
87
|
+
}
|
|
49
88
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
432
|
+
const b64 = resolved.dataUrl.split(",")[1] || ""
|
|
433
|
+
result = await seeImageViaHTTP(b64, resolved.mediaType, prompt, context.abort)
|
|
364
434
|
} else {
|
|
365
|
-
result = await seeImageViaSDK(
|
|
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: ${
|
|
445
|
+
title: `see_image: ${args.filePath}`,
|
|
370
446
|
metadata: {
|
|
371
447
|
model: result.model,
|
|
372
448
|
provider: result.provider,
|
|
373
|
-
|
|
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.
|
|
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",
|