opencode-see-image 0.9.0 → 0.9.2

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.
@@ -0,0 +1,18 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(bun build *)",
5
+ "Bash(npm view *)",
6
+ "Bash(FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f --msg-filter 'sed \"/Co-Authored-By: Claude/d\" | sed -e :a -e \"/^\\\\n*$/{\\\\$d;N;ba\" -e \"}\"' HEAD~2..HEAD)",
7
+ "Bash(echo \"--- created, exit $? ---\")",
8
+ "Bash(node -p \"require\\('./package.json'\\).version\")",
9
+ "Bash(echo \"local package.json version: $\\(node -p \"require\\('./package.json'\\).version\" \\)\")",
10
+ "Bash(bun --version)",
11
+ "Bash(bun pm *)",
12
+ "Read(//Users/alfa/Documents/opencodeprojects/opencode-see-image/bun-types/**)",
13
+ "Bash(bun run *)",
14
+ "WebFetch(domain:docs.z.ai)",
15
+ "Bash(npm publish *)"
16
+ ]
17
+ }
18
+ }
package/README.md CHANGED
@@ -42,7 +42,7 @@ You need a connected vision-capable provider. The plugin auto-detects whichever
42
42
  2. Select **opencode** (OpenCode Zen)
43
43
  3. Paste your API key from [opencode.ai/auth](https://opencode.ai/auth)
44
44
 
45
- The plugin falls back to **big-pickle** (~12000ms). No subscription needed.
45
+ The plugin falls back to **mimo-v2.5-free**. No subscription needed.
46
46
 
47
47
  ### Paid, w/ OpenCode Go
48
48
  1. Run `/connect` in opencode
@@ -55,7 +55,7 @@ The plugin prefers **minimax-m3** via opencode-go (~3000ms) when available.
55
55
 
56
56
  Set the `SEE_IMAGE_*` env vars to point at any Anthropic-Messages-compatible endpoint. See [Configuration](#configuration) below.
57
57
 
58
- **Resolution order:** explicit `SEE_IMAGE_API_KEY` env → configured `SEE_IMAGE_PROVIDER` → `opencode-go` (MiniMax M3) → `opencode` (big-pickle, free).
58
+ **Resolution order:** explicit `SEE_IMAGE_API_KEY` env → configured `SEE_IMAGE_PROVIDER` → `opencode-go` (MiniMax M3) → `opencode` (mimo-v2.5-free, free).
59
59
 
60
60
  ## How it works
61
61
 
@@ -126,7 +126,8 @@ export SEE_IMAGE_MODEL="kimi-k2.7-code"
126
126
 
127
127
  | Model | Speed | Notes |
128
128
  |---|---|---|
129
- | `big-pickle` | ~12000ms | Free. Accurate. Default fallback when only Zen is connected. |
129
+ | `mimo-v2.5-free` | | Free. Default fallback when only Zen is connected (routed via CLI). |
130
+ | `big-pickle` | ~12000ms | Free. Accurate. Alternative Zen fallback. |
130
131
 
131
132
  **Paid (OpenCode Go):**
132
133
 
@@ -139,7 +140,7 @@ export SEE_IMAGE_MODEL="kimi-k2.7-code"
139
140
 
140
141
  ## Updating
141
142
 
142
- **Auto-update (built in):** the plugin checks npm for a newer version on every opencode startup. If one exists, it updates itself via `opencode plugin --force` (uses opencode's bundled bun, no global bun needed) and shows a toast: *"opencode-see-image updated to X.Y.Z, restart opencode to apply"*. You just need to restart opencode to load the new version. Nothing to configure.
143
+ **Auto-update (built in):** the plugin checks npm for a newer version on startup. If one exists, it updates itself via `opencode plugin --force` (uses opencode's bundled bun, no global bun needed) and shows a toast: *"opencode-see-image updated to X.Y.Z, restart opencode to apply"*. You just need to restart opencode to load the new version. Nothing to configure.
143
144
 
144
145
  **Manual update** (if you want to force it now):
145
146
  ```bash
package/index.ts CHANGED
@@ -11,7 +11,7 @@ const ENDPOINT =
11
11
  "https://opencode.ai/zen/go/v1/messages"
12
12
  const MODEL = process.env.SEE_IMAGE_MODEL || "minimax-m3"
13
13
  const PROVIDER_ID = process.env.SEE_IMAGE_PROVIDER || "opencode-go"
14
- const TIMEOUT = parseInt(process.env.SEE_IMAGE_TIMEOUT || "10000", 10)
14
+ const TIMEOUT = parseInt(process.env.SEE_IMAGE_TIMEOUT || "30000", 10)
15
15
  const API_VERSION = process.env.SEE_IMAGE_API_VERSION || "2023-06-01"
16
16
  const USER_AGENT =
17
17
  process.env.SEE_IMAGE_USER_AGENT ||
@@ -47,8 +47,9 @@ function resolveFromDb(
47
47
  const dbPath = opencodeDbPath()
48
48
  if (!fs.existsSync(dbPath)) return null
49
49
 
50
+ let db: Database | undefined
50
51
  try {
51
- const db = new Database(dbPath, { readonly: true })
52
+ db = new Database(dbPath, { readonly: true })
52
53
  let rows: Array<{ data: string }>
53
54
 
54
55
  if (!filename || filename === "clipboard") {
@@ -98,8 +99,6 @@ function resolveFromDb(
98
99
  }
99
100
  }
100
101
 
101
- db.close()
102
-
103
102
  if (!rows.length) return null
104
103
  const part = JSON.parse(rows[0].data)
105
104
  const url: string = part.url || ""
@@ -112,6 +111,8 @@ function resolveFromDb(
112
111
  }
113
112
  } catch {
114
113
  return null
114
+ } finally {
115
+ db?.close()
115
116
  }
116
117
  }
117
118
 
@@ -217,7 +218,6 @@ function readProviderKey(providerID: string): string | null {
217
218
 
218
219
  async function seeImageViaSDK(
219
220
  client: any,
220
- $: any,
221
221
  dataUrl: string,
222
222
  mediaType: string,
223
223
  prompt: string,
@@ -225,21 +225,54 @@ async function seeImageViaSDK(
225
225
  ): Promise<{ text: string; model: string; provider: string }> {
226
226
  const errors: string[] = []
227
227
 
228
- // Write image to a temp file so the server can read it directly
229
228
  const b64 = dataUrl.split(",")[1] || ""
230
- const tmpPath = path.join(os.tmpdir(), `see-image-${Date.now()}.png`)
231
- try {
232
- fs.writeFileSync(tmpPath, Buffer.from(b64, "base64"))
233
- } catch {}
229
+ const ext =
230
+ Object.entries(EXT_MEDIA).find(([, m]) => m === mediaType)?.[0] || "png"
231
+
232
+ // The free CLI fallback needs the image on disk. Write it lazily and only
233
+ // once, so the common SDK/dataURL path never touches the filesystem. Use the
234
+ // real extension so the CLI can sniff the type correctly.
235
+ let tmpPath: string | null = null
236
+ const ensureTmpFile = (): string | null => {
237
+ if (tmpPath) return tmpPath
238
+ const p = path.join(os.tmpdir(), `see-image-${Date.now()}.${ext}`)
239
+ try {
240
+ fs.writeFileSync(p, Buffer.from(b64, "base64"))
241
+ tmpPath = p
242
+ } catch {
243
+ return null
244
+ }
245
+ return tmpPath
246
+ }
234
247
 
235
- // For free opencode models, use CLI instead of SDK (SDK returns empty)
248
+ // For free opencode models, use CLI instead of SDK (SDK returns empty).
249
+ // Use Bun.spawn (not $) so we get a killable handle: Bun's $ ShellPromise
250
+ // has no .kill(), so racing it against a timeout would leak the process.
251
+ // We kill the child on both timeout and external abort.
236
252
  const freeFallback = async (modelID: string, userPrompt: string): Promise<string | null> => {
253
+ const filePath = ensureTmpFile()
254
+ if (!filePath) return null
255
+ const proc = Bun.spawn(
256
+ [
257
+ "opencode",
258
+ "run",
259
+ "-f",
260
+ filePath,
261
+ "-m",
262
+ `opencode/${modelID}`,
263
+ userPrompt,
264
+ "--format",
265
+ "json",
266
+ "--dangerously-skip-permissions",
267
+ ],
268
+ { stdout: "pipe", stderr: "ignore" },
269
+ )
270
+ const timer = setTimeout(() => proc.kill(), TIMEOUT)
271
+ const onAbort = () => proc.kill()
272
+ abort?.addEventListener("abort", onAbort)
237
273
  try {
238
- const controller = new AbortController()
239
- const timer = setTimeout(() => controller.abort(), TIMEOUT)
240
- const proc = $`opencode run -f ${tmpPath} -m opencode/${modelID} ${userPrompt} --format json --dangerously-skip-permissions`
241
- const out = await proc.text()
242
- clearTimeout(timer)
274
+ const out = await new Response(proc.stdout).text()
275
+ await proc.exited
243
276
  for (const line of out.split("\n").filter(Boolean)) {
244
277
  try {
245
278
  const parsed = JSON.parse(line)
@@ -248,11 +281,13 @@ async function seeImageViaSDK(
248
281
  }
249
282
  } catch {}
250
283
  }
251
- } catch {}
284
+ } catch {} finally {
285
+ clearTimeout(timer)
286
+ abort?.removeEventListener("abort", onAbort)
287
+ }
252
288
  return null
253
289
  }
254
290
 
255
- const fileUrl = tmpPath
256
291
  let result: { text: string; model: string; provider: string } | undefined
257
292
 
258
293
  try {
@@ -279,7 +314,15 @@ async function seeImageViaSDK(
279
314
 
280
315
  let sessionID: string | undefined
281
316
  try {
282
- const sessionRes = await client.session.create({ body: {} })
317
+ const sessionRes = await Promise.race([
318
+ client.session.create({ body: {} }),
319
+ new Promise<never>((_, reject) =>
320
+ setTimeout(
321
+ () => reject(new Error(`session.create timed out after ${TIMEOUT}ms`)),
322
+ TIMEOUT,
323
+ ),
324
+ ),
325
+ ])
283
326
  sessionID = sessionRes.data?.id
284
327
  if (!sessionID) {
285
328
  errors.push(`${providerID}/${modelID}: no session ID`)
@@ -287,22 +330,29 @@ async function seeImageViaSDK(
287
330
  }
288
331
 
289
332
  const controller = new AbortController()
333
+ const onAbort = () => controller.abort()
334
+ abort?.addEventListener("abort", onAbort)
290
335
  const timer = setTimeout(() => controller.abort(), TIMEOUT)
291
- const res = await client.session.prompt({
292
- path: { id: sessionID },
293
- body: {
294
- model: { providerID, modelID },
295
- parts: [
296
- { type: "file", mime: mediaType, url: providerID === "opencode" ? fileUrl : dataUrl },
297
- { type: "text", text: prompt },
298
- ],
299
- tools: {},
300
- system:
301
- "You are a vision assistant. Describe the image accurately and concisely. Answer with text only.",
302
- },
303
- signal: controller.signal,
304
- })
305
- clearTimeout(timer)
336
+ let res
337
+ try {
338
+ res = await client.session.prompt({
339
+ path: { id: sessionID },
340
+ body: {
341
+ model: { providerID, modelID },
342
+ parts: [
343
+ { type: "file", mime: mediaType, url: dataUrl },
344
+ { type: "text", text: prompt },
345
+ ],
346
+ tools: {},
347
+ system:
348
+ "You are a vision assistant. Describe the image accurately and concisely. Answer with text only.",
349
+ },
350
+ signal: controller.signal,
351
+ })
352
+ } finally {
353
+ clearTimeout(timer)
354
+ abort?.removeEventListener("abort", onAbort)
355
+ }
306
356
 
307
357
  const parts = res.data?.parts ?? []
308
358
  const text = (parts as any[])
@@ -330,7 +380,10 @@ async function seeImageViaSDK(
330
380
 
331
381
  if (!result) {
332
382
  const apiKey =
333
- process.env.SEE_IMAGE_API_KEY || readProviderKey("opencode-go")
383
+ process.env.SEE_IMAGE_API_KEY ||
384
+ (process.env.SEE_IMAGE_PROVIDER &&
385
+ readProviderKey(process.env.SEE_IMAGE_PROVIDER)) ||
386
+ readProviderKey("opencode-go")
334
387
  if (apiKey) {
335
388
  try {
336
389
  result = await seeImageViaHTTP(b64, mediaType, prompt, abort, apiKey)
@@ -344,13 +397,15 @@ async function seeImageViaSDK(
344
397
 
345
398
  const errMsg = errors.join("; ")
346
399
  const hint = errMsg.includes("usage limit")
347
- ? ` Enable usage from your balance at https://opencode.ai/workspace/wrk_01KVARG0A0Y87XV5JYBNJ0WRXB/go`
400
+ ? ` Enable usage from your balance in your opencode workspace at https://opencode.ai/workspace`
348
401
  : ""
349
402
  throw new Error(
350
403
  `see_image: SDK vision call failed for all candidates. ${errMsg}.${hint}`,
351
404
  )
352
405
  } finally {
353
- try { fs.unlinkSync(tmpPath) } catch {}
406
+ if (tmpPath) {
407
+ try { fs.unlinkSync(tmpPath) } catch {}
408
+ }
354
409
  }
355
410
  }
356
411
 
@@ -486,7 +541,6 @@ const SeeImagePlugin: Plugin = async (ctx) => {
486
541
  } else {
487
542
  result = await seeImageViaSDK(
488
543
  client,
489
- $,
490
544
  resolved.dataUrl,
491
545
  resolved.mediaType,
492
546
  prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-see-image",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
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",
@@ -23,6 +23,6 @@
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
25
  "@opencode-ai/plugin": "^1.15.0",
26
- "opencode-plugin-update-kit": "^0.1.0"
26
+ "opencode-plugin-update-kit": "^0.2.0"
27
27
  }
28
28
  }