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.
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +326 -0
- package/README.md +496 -32
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +26 -3
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +21 -14
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +374 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +1 -1
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +627 -179
- package/scripts/install/plugin.mjs +43 -9
- package/scripts/install/shared.mjs +120 -12
- package/scripts/install/vision-prompt.mjs +161 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +1 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -129
- package/src/instructions/agent-return-format.instructions.md +18 -3
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +11 -110
- package/src/instructions/zeus-anti-stall.instructions.md +14 -61
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-tools.ts +429 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1145 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +39 -30
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- 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
|
+
}
|