pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.10

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 (120) hide show
  1. package/AGENTS.global.md +182 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +326 -0
  4. package/README.md +496 -32
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +26 -3
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +21 -14
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +374 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/config-migration.mjs +317 -4
  22. package/scripts/install/health-check.mjs +21 -19
  23. package/scripts/install/interactive.mjs +1 -1
  24. package/scripts/install/opencode-version.mjs +246 -4
  25. package/scripts/install/opencode.mjs +627 -179
  26. package/scripts/install/plugin.mjs +43 -9
  27. package/scripts/install/shared.mjs +120 -12
  28. package/scripts/install/vision-prompt.mjs +161 -0
  29. package/scripts/install-mcp.mjs +28 -9
  30. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  31. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  32. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  33. package/scripts/probe-context-rehydrate.mjs +8 -2
  34. package/scripts/probe-pantheon-cost.mjs +71 -15
  35. package/scripts/run-node-tests.mjs +189 -0
  36. package/scripts/sync-artifacts.mjs +26 -13
  37. package/scripts/sync-tui.mjs +24 -3
  38. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  39. package/scripts/validate-routing.mjs +3 -3
  40. package/scripts/validate-sandbox-install.mjs +138 -0
  41. package/scripts/versioning.mjs +13 -1
  42. package/scripts/vision-status.mjs +682 -0
  43. package/skills-lock.json +8 -8
  44. package/src/agents/aphrodite.md +20 -37
  45. package/src/agents/apollo.md +7 -3
  46. package/src/agents/athena.md +63 -35
  47. package/src/agents/demeter.md +8 -28
  48. package/src/agents/gaia.md +1 -1
  49. package/src/agents/hephaestus.md +10 -21
  50. package/src/agents/hermes.md +15 -38
  51. package/src/agents/iris.md +1 -1
  52. package/src/agents/mnemosyne.md +18 -54
  53. package/src/agents/nyx.md +1 -1
  54. package/src/agents/prometheus.md +16 -20
  55. package/src/agents/talos.md +10 -2
  56. package/src/agents/themis.md +5 -5
  57. package/src/agents/zeus.md +99 -129
  58. package/src/instructions/agent-return-format.instructions.md +18 -3
  59. package/src/instructions/backend-standards.instructions.md +2 -0
  60. package/src/instructions/frontend-standards.instructions.md +3 -1
  61. package/src/instructions/memory-protocol.instructions.md +11 -110
  62. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  63. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  64. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  65. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  66. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  67. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  68. package/src/mcp/eval_store.py +2 -2
  69. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  70. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  71. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  72. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  73. package/src/mcp/requirements-mcp.txt +6 -4
  74. package/src/mcp/requirements-vision.txt +2 -2
  75. package/src/pantheon/cost-command.ts +399 -101
  76. package/src/pantheon/delegation-enforce.ts +10 -1
  77. package/src/pantheon/presets.d.mts +0 -8
  78. package/src/pantheon/presets.mjs +5 -32
  79. package/src/pantheon/v2-bounded-read.ts +640 -0
  80. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  81. package/src/pantheon/v2-execute-after.ts +186 -0
  82. package/src/pantheon/v2-tools.ts +429 -0
  83. package/src/pantheon/v2-unsupported.d.mts +11 -0
  84. package/src/pantheon/v2-unsupported.mjs +90 -0
  85. package/src/pantheon/v2-vision.ts +622 -0
  86. package/src/pantheon/vision-local.ts +531 -0
  87. package/src/pantheon/vision.ts +1160 -115
  88. package/src/plugin-v2.ts +1145 -219
  89. package/src/plugin.ts +12 -9
  90. package/src/plugins/tui/dist/server.d.ts +1 -2
  91. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  93. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  94. package/src/plugins/tui/dist/tui-v2.js +659 -0
  95. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  96. package/src/plugins/tui/dist/tui.d.ts +85 -82
  97. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  98. package/src/plugins/tui/dist/tui.js +72 -59
  99. package/src/plugins/tui/index-v2.ts +6 -0
  100. package/src/plugins/tui/package-lock.json +5516 -1353
  101. package/src/plugins/tui/package.json +22 -12
  102. package/src/plugins/tui/src/index-v2.tsx +734 -0
  103. package/src/plugins/tui/src/index.tsx +106 -85
  104. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  105. package/src/plugins/tui/tsconfig.build.json +5 -0
  106. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  107. package/src/plugins/tui/tsdown.config.ts +22 -2
  108. package/src/plugins/tui/tui-v2.ts +2 -0
  109. package/src/plugins/tui/tui.ts +3 -0
  110. package/src/routing.yml +11 -21
  111. package/src/skills/README.md +3 -3
  112. package/src/skills/agent-coordination/SKILL.md +7 -2
  113. package/src/skills/artifact-management/SKILL.md +12 -5
  114. package/src/skills/auto-continue/SKILL.md +39 -30
  115. package/src/skills/code-review-checklist/SKILL.md +5 -5
  116. package/src/skills/context-compression/SKILL.md +26 -879
  117. package/src/skills/session-goal/SKILL.md +14 -5
  118. package/src/skills/spec-driven-development/SKILL.md +3 -1
  119. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  120. package/opencode.json +0 -58
@@ -0,0 +1,622 @@
1
+ /** Native OpenCode V2 image analysis using the live model catalog and Zen auth. */
2
+
3
+ import type { LookupAddress } from 'node:dns'
4
+ import { lookup as dnsLookup } from 'node:dns/promises'
5
+ import { readFile } from 'node:fs/promises'
6
+ import { request as httpRequest } from 'node:http'
7
+ import { request as httpsRequest } from 'node:https'
8
+ import { isIP, type LookupFunction } from 'node:net'
9
+ import { homedir } from 'node:os'
10
+ import { join, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { resolveActivePreset } from './presets.mjs'
13
+ import {
14
+ describeImagesNative,
15
+ NATIVE_MAX_TOKENS,
16
+ type NativeVisionFailure,
17
+ type NativeVisionTarget,
18
+ nativeVisionFailureMessage,
19
+ resolveNativeVisionTarget,
20
+ saveDataUrlImageSync,
21
+ truncateUserText,
22
+ } from './vision.ts'
23
+
24
+ // Default native vision model. Must be a PAID OpenCode Go (zen/go) model: the
25
+ // free-tier MiMo (`opencode/mimo-v2.6-flash-free`) is rejected by the direct
26
+ // gateway with HTTP 403 ("free tier can only be used from within OpenCode"),
27
+ // and zen/v1 paid models require account access this plugin cannot assume. The
28
+ // go endpoint accepts the request once the Zen session header is present.
29
+ // NOTE: intentionally diverges from V1's DEFAULT_VISION_MODEL
30
+ // (`opencode-go/mimo-v2.5`) — V2 tracks the newer Zen Go MiMo 2.6 Flash family.
31
+ const DEFAULT_MODEL = 'opencode-go/mimo-v2.6-flash'
32
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024
33
+ const MAX_REDIRECTS = 3
34
+ const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
35
+ /**
36
+ * Generic describe instruction — byte-identical to the pre-contextual fixed
37
+ * IMAGE_PROMPT, so a turn without a user question behaves exactly as before.
38
+ */
39
+ const IMAGE_PROMPT_GENERIC =
40
+ 'Descreva fielmente a imagem anexada. Inclua conteúdo, texto visível, layout e objetos; ' +
41
+ 'declare incerteza em vez de adivinhar e responda em português claro.'
42
+
43
+ /**
44
+ * Describe-prompt template with the `{userText}` slot. Substitution is
45
+ * single-pass (`buildV2ImagePrompt`): the question is inserted via a function
46
+ * replacement and never re-scanned, so `{imageList}`-style literals inside the
47
+ * user's text stay inert data. The question is capped by
48
+ * `truncateUserText` (payload bound); empty/whitespace falls back to the
49
+ * generic prompt above.
50
+ */
51
+ const IMAGE_PROMPT_TEMPLATE = `${IMAGE_PROMPT_GENERIC}
52
+ Pergunta do usuário: {userText}`
53
+
54
+ /** Fill `{userText}` into the describe template (single pass, injection-safe). */
55
+ export function buildV2ImagePrompt(userText?: string): string {
56
+ const question = truncateUserText(userText ?? '')
57
+ if (!question) return IMAGE_PROMPT_GENERIC
58
+ return IMAGE_PROMPT_TEMPLATE.replace('{userText}', () => question)
59
+ }
60
+
61
+ const IMAGE_URI_PATTERN = /\.(?:png|jpe?g|webp|gif)(?:[?#].*)?$/i
62
+
63
+ /**
64
+ * Reference line for one attached image. A `data:` (inline/clipboard)
65
+ * attachment has no path on disk — the bytes live only in the URI — so a path
66
+ * based tool (`vision_describe`, the MCP) could never read it and the model
67
+ * would fall back to guessing a path from the display name (observed as
68
+ * `ENOENT: .../clipboard`). Materialize the bytes once to the shared
69
+ * temp/LRU location and reference THAT path, so the native tool AND the MCP
70
+ * receive a real file. The payload is never copied into the prompt text. Only
71
+ * if materialization fails (no usable bytes) do we fall back to the previous
72
+ * "URI omitted" form.
73
+ */
74
+ function imageReference(uri: string, mime: string, name: string): string {
75
+ if (!/^data:image\//i.test(uri)) {
76
+ return `${JSON.stringify(name)} em ${JSON.stringify(uri)}`
77
+ }
78
+ const path = saveDataUrlImageSync(uri, mime)
79
+ if (path) {
80
+ return `${JSON.stringify(name)} em ${JSON.stringify(path)} (anexo inline materializado em arquivo temporário local; use este caminho — o payload não é repetido no texto)`
81
+ }
82
+ return `${JSON.stringify(name)} (conteúdo anexado; URI codificada omitida)`
83
+ }
84
+
85
+ /**
86
+ * Add model-visible instructions for image files on a V2 prompt admission.
87
+ * Inline image bytes are deliberately never copied into persisted prompt text;
88
+ * a `data:` attachment is materialized to a temp file and referenced by path
89
+ * instead (see `imageReference`).
90
+ * `userText` (the admission's prompt text, when available) is trimmed, capped
91
+ * by `truncateUserText` and appended as the user's question for this turn so
92
+ * the main model calibrates the inspection AND forwards it as the vision
93
+ * tool's `prompt`. NOTE: the description cache keys on the full prompt, so the
94
+ * same image asked with a new question gets a fresh describe — same image +
95
+ * same question within the TTL reuses the stored description.
96
+ * `sessionID` (the V2 `prompt` hook event's live session id, when present) is
97
+ * relayed as an MCP `session_id` instruction: the MCP runs in a separate
98
+ * process the host hands no session id to, and the Zen gateway rejects a
99
+ * sessionless call with HTTP 400 `MissingSessionID` (mirrors
100
+ * `generateInjectionPrompt` on the V1 path).
101
+ * The question and the session id are never logged.
102
+ */
103
+ export function buildV2VisionPromptContext(
104
+ files: unknown,
105
+ userText?: string,
106
+ sessionID?: string,
107
+ ): string | undefined {
108
+ if (!Array.isArray(files)) return undefined
109
+ const images = files.flatMap((value, index) => {
110
+ const file = asRecord(value)
111
+ if (!file) return []
112
+ const uri = typeof file?.uri === 'string' ? file.uri.trim() : ''
113
+ if (!uri) return []
114
+ const mime = typeof file?.mime === 'string' ? file.mime.toLowerCase() : ''
115
+ const image =
116
+ ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(mime) ||
117
+ /^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(uri) ||
118
+ IMAGE_URI_PATTERN.test(uri)
119
+ if (!image) return []
120
+ const name =
121
+ typeof file.filename === 'string'
122
+ ? file.filename
123
+ : typeof file.name === 'string'
124
+ ? file.name
125
+ : `imagem ${index + 1}`
126
+ const reference = imageReference(uri, mime, name)
127
+ return [reference]
128
+ })
129
+ if (images.length === 0) return undefined
130
+ const lines = [
131
+ '<!-- pantheon-v2-visual-context -->',
132
+ '[Contexto visual do Pantheon]',
133
+ `O usuário anexou imagem(ns):\n${images.map((image) => `- ${image}`).join('\n')}`,
134
+ 'Se a imagem orientar uma implementação, inspecione-a com vision_describe ou visão nativa antes de delegar. Inclua a referência e observações visuais relevantes no handoff para @aphrodite. Preserve alterações existentes fora do escopo: a imagem não autoriza apagar nem reverter trabalho; se a substituição for necessária e não estiver explícita, pergunte antes.',
135
+ ]
136
+ const question = truncateUserText(userText ?? '')
137
+ if (question) {
138
+ // JSON.stringify quotes/escapes the question so it cannot spoof the
139
+ // surrounding instruction layout; the value is never re-expanded and
140
+ // never logged.
141
+ lines.push(
142
+ `Pergunta do usuário neste turno: ${JSON.stringify(question)} — ao inspecionar com vision_describe, repasse essa pergunta no parâmetro \`prompt\` da ferramenta para uma análise direcionada.`,
143
+ )
144
+ }
145
+ // MCP session relay (see the doc comment above): JSON.stringify quotes the
146
+ // id so it cannot spoof the surrounding instruction layout; never logged.
147
+ const session = sessionID?.trim()
148
+ if (session) {
149
+ lines.push(
150
+ `Prefira a ferramenta nativa \`vision_describe\` (ela já repassa a sessão automaticamente). Se usar o MCP pantheon-vision (\`vision_describe\`/\`vision_ocr\`/\`vision_analyze\`), repasse session_id=${JSON.stringify(session)} no parâmetro \`session_id\` — sem ele o gateway Zen rejeita a chamada com MissingSessionID.`,
151
+ )
152
+ }
153
+ return lines.join('\n')
154
+ }
155
+
156
+ export interface V2VisionModel {
157
+ id: string
158
+ providerID: string
159
+ enabled?: boolean
160
+ status?: string
161
+ input?: string[] | Record<string, boolean>
162
+ capabilities?: {
163
+ input?: string[] | Record<string, boolean>
164
+ }
165
+ /** Catalog cost (optional); `input: 0` marks a free-tier model. */
166
+ cost?: { input?: unknown }
167
+ }
168
+
169
+ export interface V2VisionRuntime {
170
+ model: { list: (input?: unknown) => Promise<unknown> }
171
+ }
172
+
173
+ export interface V2VisionToolContext {
174
+ sessionID?: string
175
+ directory?: string
176
+ signal?: AbortSignal
177
+ }
178
+
179
+ export interface V2VisionToolDefinition {
180
+ name: string
181
+ description: string
182
+ input: Record<string, unknown>
183
+ output: Record<string, unknown>
184
+ execute: (
185
+ input: Record<string, unknown>,
186
+ context: unknown,
187
+ ) => Promise<{ output: string; title?: string; metadata?: Record<string, unknown> }>
188
+ }
189
+
190
+ type ImageAttachment = { uri: string; name: string }
191
+
192
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
193
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
194
+ ? (value as Record<string, unknown>)
195
+ : undefined
196
+ }
197
+
198
+ function modelList(value: unknown): V2VisionModel[] {
199
+ const root = asRecord(value)
200
+ const candidates = Array.isArray(value) ? value : (root?.data ?? root?.models)
201
+ if (!Array.isArray(candidates)) return []
202
+ return candidates.filter((item): item is V2VisionModel => {
203
+ const model = asRecord(item)
204
+ return typeof model?.id === 'string' && typeof model.providerID === 'string'
205
+ })
206
+ }
207
+
208
+ function acceptsImages(model: V2VisionModel): boolean {
209
+ const input = model.capabilities?.input ?? model.input
210
+ if (Array.isArray(input)) return input.some((modality) => modality.toLowerCase() === 'image')
211
+ return input?.image === true
212
+ }
213
+
214
+ function supportsZenVision(model: V2VisionModel): boolean {
215
+ return model.providerID === 'opencode' || model.providerID.startsWith('opencode-go')
216
+ }
217
+
218
+ /**
219
+ * Whether a catalog model is FREE-TIER. Free-tier Zen models are rejected by
220
+ * the direct gateway with HTTP 403 ("free tier can only be used from within
221
+ * OpenCode"), so they must never be picked on the native describe path. Two
222
+ * conservative signals: the `-free` id suffix (the reliable marker) OR an
223
+ * explicit `cost.input` of 0.
224
+ */
225
+ function isFreeVisionModel(model: V2VisionModel): boolean {
226
+ if (/-free$/i.test(model.id)) return true
227
+ const input = model.cost?.input
228
+ return typeof input === 'number' && input <= 0
229
+ }
230
+
231
+ function modelKey(model: Pick<V2VisionModel, 'providerID' | 'id'>): string {
232
+ return `${model.providerID}/${model.id}`
233
+ }
234
+
235
+ function exactModelMatch(models: V2VisionModel[], requested: string): V2VisionModel | undefined {
236
+ const slash = requested.indexOf('/')
237
+ if (slash < 1) return models.find((model) => model.id === requested)
238
+ const providerID = requested.slice(0, slash)
239
+ const id = requested.slice(slash + 1)
240
+ return models.find((model) => model.providerID === providerID && model.id === id)
241
+ }
242
+
243
+ /**
244
+ * Select a native transport model. An EXPLICIT pin (`preferred` — the env
245
+ * `PANTHEON_VISION_MODEL`, the config file, or the active preset) ALWAYS wins,
246
+ * even when it names a free-tier model: the pin is the user's decision, so the
247
+ * call surfaces the gateway's HTTP 403 with its actionable message instead of
248
+ * silently substituting a different model. The free-tier exclusion
249
+ * (`-free` suffix / cost ≤ 0) applies ONLY to AUTOMATIC selection — discovery
250
+ * then the Go default — because the direct gateway rejects free-tier models
251
+ * with HTTP 403, so they cannot serve the native describe path unasked.
252
+ */
253
+ export function selectV2VisionModel(
254
+ available: unknown,
255
+ preferred?: string,
256
+ ): V2VisionModel | undefined {
257
+ const capable = modelList(available).filter(
258
+ (model) =>
259
+ model.enabled !== false &&
260
+ model.status !== 'deprecated' &&
261
+ supportsZenVision(model) &&
262
+ acceptsImages(model),
263
+ )
264
+ const pinned = preferred?.trim()
265
+ if (pinned) {
266
+ const configured = exactModelMatch(capable, pinned)
267
+ if (configured) return configured
268
+ }
269
+ const candidates = capable.filter((model) => !isFreeVisionModel(model))
270
+ const defaultModel = exactModelMatch(candidates, DEFAULT_MODEL)
271
+ if (defaultModel) return defaultModel
272
+ return candidates.sort((left, right) => {
273
+ const priority = (model: V2VisionModel): number => {
274
+ const key = modelKey(model).toLowerCase()
275
+ if (key === DEFAULT_MODEL) return 0
276
+ // Prefer the Go endpoint (paid, session-header gated) over zen/v1.
277
+ return model.providerID.startsWith('opencode-go')
278
+ ? 1
279
+ : model.providerID === 'opencode'
280
+ ? 2
281
+ : 3
282
+ }
283
+ return priority(left) - priority(right) || modelKey(left).localeCompare(modelKey(right))
284
+ })[0]
285
+ }
286
+
287
+ function activePresetCandidates(directory: string): string[] {
288
+ const home = process.env.HOME || homedir()
289
+ const xdg = process.env.XDG_CONFIG_HOME || join(home, '.config')
290
+ return [
291
+ join(directory, '.pantheon', 'active-preset.json'),
292
+ join(xdg, 'opencode', '.pantheon', 'active-preset.json'),
293
+ join(home, '.opencode', '.pantheon', 'active-preset.json'),
294
+ ]
295
+ }
296
+
297
+ async function configuredVisionModel(directory: string): Promise<string | undefined> {
298
+ const home = process.env.HOME || homedir()
299
+ const xdg = process.env.XDG_CONFIG_HOME || join(home, '.config')
300
+ const paths = [
301
+ join(directory, '.opencode', 'opencode-vision.json'),
302
+ join(xdg, 'opencode', 'opencode-vision.json'),
303
+ ]
304
+ for (const path of paths) {
305
+ try {
306
+ const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
307
+ if (typeof parsed.visionModel === 'string' && parsed.visionModel.trim()) {
308
+ return parsed.visionModel.trim()
309
+ }
310
+ } catch {
311
+ // A missing or malformed optional preference must not disable Vision.
312
+ }
313
+ }
314
+ return undefined
315
+ }
316
+
317
+ async function preferredVisionModel(directory: string): Promise<string> {
318
+ const envModel = process.env.PANTHEON_VISION_MODEL?.trim()
319
+ if (envModel) return envModel
320
+ const configModel = await configuredVisionModel(directory)
321
+ if (configModel) return configModel
322
+ try {
323
+ return (
324
+ resolveActivePreset({
325
+ env: process.env,
326
+ candidates: activePresetCandidates(directory),
327
+ logger: { warn: () => undefined },
328
+ })?.vision?.model ?? DEFAULT_MODEL
329
+ )
330
+ } catch {
331
+ return DEFAULT_MODEL
332
+ }
333
+ }
334
+
335
+ function imageMime(bytes: Buffer): string | undefined {
336
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
337
+ return 'image/png'
338
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
339
+ if (
340
+ bytes
341
+ .subarray(0, 6)
342
+ .toString('ascii')
343
+ .match(/^GIF8[79]a$/)
344
+ )
345
+ return 'image/gif'
346
+ if (
347
+ bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
348
+ bytes.subarray(8, 12).toString('ascii') === 'WEBP'
349
+ ) {
350
+ return 'image/webp'
351
+ }
352
+ return undefined
353
+ }
354
+
355
+ function toImageAttachment(bytes: Buffer, name: string): ImageAttachment {
356
+ if (bytes.length === 0) throw new Error('image is empty')
357
+ if (bytes.length > MAX_IMAGE_BYTES)
358
+ throw new Error('image exceeds OpenCode’s 20 MiB attachment limit')
359
+ const mime = imageMime(bytes)
360
+ if (!mime || !IMAGE_TYPES.has(mime)) {
361
+ throw new Error('unsupported image; use PNG, JPEG, WebP, or GIF')
362
+ }
363
+ return { uri: `data:${mime};base64,${bytes.toString('base64')}`, name }
364
+ }
365
+
366
+ function parseDataUri(value: string): ImageAttachment {
367
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/]*={0,2})$/i.exec(value)
368
+ if (!match?.[1] || match[2] === undefined) throw new Error('invalid base64 image data URI')
369
+ if (match[2].length > Math.ceil((MAX_IMAGE_BYTES * 4) / 3)) {
370
+ throw new Error('image exceeds OpenCode’s 20 MiB attachment limit')
371
+ }
372
+ const bytes = Buffer.from(match[2], 'base64')
373
+ if (bytes.toString('base64') !== match[2]) throw new Error('invalid base64 image data URI')
374
+ return toImageAttachment(bytes, `image.${match[1].slice('image/'.length).replace('jpeg', 'jpg')}`)
375
+ }
376
+
377
+ function isPublicAddress(address: string): boolean {
378
+ if (isIP(address) === 4) {
379
+ const [a = 0, b = 0, c = 0] = address.split('.').map(Number)
380
+ return !(
381
+ a === 0 ||
382
+ a === 10 ||
383
+ a === 127 ||
384
+ a >= 224 ||
385
+ (a === 100 && b >= 64 && b <= 127) ||
386
+ (a === 169 && b === 254) ||
387
+ (a === 172 && b >= 16 && b <= 31) ||
388
+ (a === 192 && (b === 168 || b === 0)) ||
389
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
390
+ (a === 203 && b === 0 && c === 113)
391
+ )
392
+ }
393
+ if (isIP(address) !== 6) return false
394
+ const normalized = address.toLowerCase()
395
+ if (normalized.includes('.')) return false // IPv4-mapped and transition addresses are rejected.
396
+ const first = Number.parseInt(normalized.split(':')[0] || '0', 16)
397
+ return first >= 0x2000 && first <= 0x3fff && !normalized.startsWith('2001:db8:')
398
+ }
399
+
400
+ export function createPinnedLookup(record: LookupAddress): LookupFunction {
401
+ return (_hostname, options, callback) => {
402
+ if (options.all) callback(null, [record])
403
+ else callback(null, record.address, record.family)
404
+ }
405
+ }
406
+
407
+ async function pinnedPublicAddress(hostname: string): Promise<{ address: string; family: number }> {
408
+ const bareHost =
409
+ hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
410
+ const records = await dnsLookup(bareHost, { all: true, verbatim: true })
411
+ if (records.length === 0 || records.some((record) => !isPublicAddress(record.address))) {
412
+ throw new Error('remote image host must resolve only to public IP addresses')
413
+ }
414
+ const record = records[0]
415
+ if (!record) throw new Error('remote image host did not resolve')
416
+ return record
417
+ }
418
+
419
+ async function readRemoteImage(
420
+ url: URL,
421
+ signal: AbortSignal | undefined,
422
+ redirects = 0,
423
+ secureOrigin = url.protocol === 'https:',
424
+ ): Promise<{ bytes: Buffer; name: string }> {
425
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
426
+ throw new Error('only public HTTP(S) image URLs without credentials are supported')
427
+ }
428
+ if (secureOrigin && url.protocol !== 'https:')
429
+ throw new Error('HTTPS image redirects cannot downgrade to HTTP')
430
+ const pinned = await pinnedPublicAddress(url.hostname)
431
+ const requestFn = url.protocol === 'https:' ? httpsRequest : httpRequest
432
+ const response = await new Promise<{ status: number; location?: string; bytes: Buffer }>(
433
+ (resolveResponse, reject) => {
434
+ const request = requestFn(
435
+ url,
436
+ {
437
+ method: 'GET',
438
+ headers: { accept: 'image/png,image/jpeg,image/webp,image/gif' },
439
+ lookup: createPinnedLookup(pinned),
440
+ ...(signal ? { signal } : {}),
441
+ },
442
+ (incoming) => {
443
+ const status = incoming.statusCode ?? 0
444
+ const location = incoming.headers.location
445
+ if (status >= 300 && status < 400 && location) {
446
+ incoming.resume()
447
+ resolveResponse({ status, location, bytes: Buffer.alloc(0) })
448
+ return
449
+ }
450
+ const contentLength = Number(incoming.headers['content-length'] ?? 0)
451
+ if (contentLength > MAX_IMAGE_BYTES) {
452
+ incoming.destroy(new Error('image exceeds OpenCode’s 20 MiB attachment limit'))
453
+ reject(new Error('image exceeds OpenCode’s 20 MiB attachment limit'))
454
+ return
455
+ }
456
+ const chunks: Buffer[] = []
457
+ let total = 0
458
+ incoming.on('data', (chunk: Buffer | string) => {
459
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
460
+ total += data.length
461
+ if (total > MAX_IMAGE_BYTES) {
462
+ incoming.destroy(new Error('image exceeds OpenCode’s 20 MiB attachment limit'))
463
+ reject(new Error('image exceeds OpenCode’s 20 MiB attachment limit'))
464
+ return
465
+ }
466
+ chunks.push(data)
467
+ })
468
+ incoming.on('error', reject)
469
+ incoming.on('end', () => resolveResponse({ status, bytes: Buffer.concat(chunks) }))
470
+ },
471
+ )
472
+ request.setTimeout(15_000, () => request.destroy(new Error('image download timed out')))
473
+ request.on('error', reject)
474
+ request.end()
475
+ },
476
+ )
477
+ if (response.location) {
478
+ if (redirects >= MAX_REDIRECTS) throw new Error('remote image redirected too many times')
479
+ return readRemoteImage(new URL(response.location, url), signal, redirects + 1, secureOrigin)
480
+ }
481
+ if (response.status !== 200) throw new Error(`image download returned HTTP ${response.status}`)
482
+ const name = decodeURIComponent(url.pathname.split('/').at(-1) || 'image')
483
+ return { bytes: response.bytes, name: name || 'image' }
484
+ }
485
+
486
+ async function imageAttachment(
487
+ value: string,
488
+ directory: string,
489
+ signal?: AbortSignal,
490
+ ): Promise<ImageAttachment> {
491
+ const source = value.trim()
492
+ if (!source) throw new Error('path must identify an image file, HTTP(S) URL, or base64 image URI')
493
+ if (source.startsWith('data:')) return parseDataUri(source)
494
+ let url: URL | undefined
495
+ try {
496
+ url = new URL(source)
497
+ } catch {
498
+ // A non-URL string is a local path.
499
+ }
500
+ if (url?.protocol === 'http:' || url?.protocol === 'https:') {
501
+ const remote = await readRemoteImage(url, signal)
502
+ return toImageAttachment(remote.bytes, remote.name)
503
+ }
504
+ let localPath: string
505
+ if (url?.protocol === 'file:') {
506
+ localPath = fileURLToPath(url)
507
+ } else if (url) {
508
+ throw new Error('only file, HTTP, HTTPS, and base64 image sources are supported')
509
+ } else {
510
+ const expanded = source.startsWith('~/') ? join(homedir(), source.slice(2)) : source
511
+ localPath = resolve(directory, expanded)
512
+ }
513
+ let bytes: Buffer
514
+ try {
515
+ bytes = await readFile(localPath, signal ? { signal } : undefined)
516
+ } catch (error) {
517
+ // A clipboard/inline attachment has no path on disk: the model may pass the
518
+ // display name as if it were a file (observed: `ENOENT .../clipboard`).
519
+ // Surface an actionable message instead of the raw ENOENT.
520
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
521
+ throw new Error(
522
+ `image file not found: ${localPath}; pass an existing file path, file URL, public HTTP(S) URL, or base64 image data URI`,
523
+ )
524
+ }
525
+ throw error
526
+ }
527
+ return toImageAttachment(bytes, localPath.split(/[\\/]/).at(-1) || 'image')
528
+ }
529
+
530
+ export function createV2VisionTool(runtime: V2VisionRuntime): V2VisionToolDefinition {
531
+ return {
532
+ name: 'vision_describe',
533
+ description:
534
+ 'Analyze an image with an OpenCode Zen multimodal model confirmed by the current V2 model catalog. ' +
535
+ 'Prefers the OpenCode Go MiMo 2.6 Flash model and falls back to another live image-capable Zen model. ' +
536
+ 'Free-tier models are excluded (the direct gateway rejects them). ' +
537
+ 'Accepts a local path, file URL, public HTTP(S) URL, or base64 image data URI.',
538
+ input: {
539
+ type: 'object',
540
+ additionalProperties: false,
541
+ required: ['path'],
542
+ properties: {
543
+ path: {
544
+ type: 'string',
545
+ minLength: 1,
546
+ description:
547
+ 'Local image path, file URL, public HTTP(S) image URL, or base64 image data URI.',
548
+ },
549
+ prompt: { type: 'string', description: 'Optional image-analysis instruction.' },
550
+ },
551
+ },
552
+ output: {},
553
+ execute: async (input, rawContext) => {
554
+ let stage = 'load image'
555
+ try {
556
+ const pathValue = typeof input.path === 'string' ? input.path : ''
557
+ const call = (asRecord(rawContext) ?? {}) as V2VisionToolContext
558
+ const directory = call.directory || process.cwd()
559
+ const image = await imageAttachment(pathValue, directory, call.signal)
560
+ stage = 'read model catalog'
561
+ const preferred = await preferredVisionModel(directory)
562
+ const catalog = await runtime.model.list()
563
+ const model = selectV2VisionModel(catalog, preferred)
564
+ if (!model) {
565
+ throw new Error(
566
+ 'no available OpenCode Zen multimodal model accepts image input; connect OpenCode Zen and enable an image-capable model',
567
+ )
568
+ }
569
+ stage = 'resolve OpenCode Zen authentication'
570
+ const modelID = modelKey(model)
571
+ const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
572
+ const target = await resolveNativeVisionTarget({
573
+ preset: { vision: { model: modelID } },
574
+ env: { ...process.env, PANTHEON_VISION_MODEL: modelID },
575
+ authStatePath: join(dataHome, 'opencode'),
576
+ })
577
+ if (!target) {
578
+ throw new Error(
579
+ 'OpenCode Zen authentication is missing; run `opencode auth login` ' +
580
+ '(checked the auth store and the V2 credential database)',
581
+ )
582
+ }
583
+ stage = 'send image to the selected multimodal model'
584
+ // Attach the live session id: the Zen gateway rejects a sessionless
585
+ // request (HTTP 400 MissingSessionID). describeImagesNative sends the
586
+ // header ONLY for Zen targets, never to a non-Zen provider.
587
+ const requestTarget: NativeVisionTarget = {
588
+ ...target,
589
+ ...(call.sessionID ? { sessionID: call.sessionID } : {}),
590
+ }
591
+ let failure: NativeVisionFailure | undefined
592
+ const output = await describeImagesNative(
593
+ [{ url: image.uri }],
594
+ requestTarget,
595
+ typeof input.prompt === 'string' && input.prompt.trim()
596
+ ? input.prompt.trim()
597
+ : buildV2ImagePrompt(),
598
+ NATIVE_MAX_TOKENS,
599
+ call.signal,
600
+ (details) => {
601
+ failure = details
602
+ },
603
+ )
604
+ if (!output?.trim()) {
605
+ // Surface the gateway status / finish_reason (never the key or
606
+ // payload) so a 400/401/403 or a max_tokens truncation is actionable.
607
+ throw new Error(nativeVisionFailureMessage(failure, modelKey(model)))
608
+ }
609
+ return {
610
+ output: output.trim(),
611
+ title: `Vision · ${modelKey(model)}`,
612
+ metadata: { model: modelKey(model), attachment: 'image' },
613
+ }
614
+ } catch (error) {
615
+ const message = error instanceof Error ? error.message : 'request failed'
616
+ return {
617
+ output: `Error: Pantheon Vision could not analyze the image during ${stage}: ${message}`,
618
+ }
619
+ }
620
+ },
621
+ }
622
+ }