pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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 +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- 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 +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -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/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- 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/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- 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 +7 -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 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- 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 +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- 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} +52 -17
- 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 +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- 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-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -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 +1158 -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 +52 -35
- 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,682 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vision-status.mjs — report the effective vision mode and the resolved
|
|
4
|
+
* vision target WITH its origin, for the `/pantheon-vision` status step.
|
|
5
|
+
*
|
|
6
|
+
* Resolution is deterministic and offline, mirroring the plugin's model
|
|
7
|
+
* precedence: env `PANTHEON_VISION_MODEL` > config `visionModel` (project
|
|
8
|
+
* `.opencode/opencode-vision.json`, then global) > active preset `vision.model`
|
|
9
|
+
* > discovery > built-in default.
|
|
10
|
+
*
|
|
11
|
+
* Discovery needs the live host catalog (`GET /config/providers`), which only
|
|
12
|
+
* exists inside the plugin runtime. When no explicit model is configured this
|
|
13
|
+
* prints `auto (resolved at session start)` rather than inventing a value; the
|
|
14
|
+
* built-in default is shown separately as the fallback.
|
|
15
|
+
*
|
|
16
|
+
* Never prints tokens — only whether a credential source is present.
|
|
17
|
+
*
|
|
18
|
+
* Two extra modes support the interactive picker:
|
|
19
|
+
* --list enumerate image-capable candidates from the best
|
|
20
|
+
* available source (live host catalog when supplied,
|
|
21
|
+
* else an offline `opencode models` name heuristic),
|
|
22
|
+
* always printing WHICH source was used.
|
|
23
|
+
* --set <provider/model> merge `visionModel` into the project (default) or
|
|
24
|
+
* global config, reusing the installer's atomic merge.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* node scripts/vision-status.mjs [--dir <project> | --dir=<project>] [--json]
|
|
28
|
+
* node scripts/vision-status.mjs --list [--catalog <file>] [--json]
|
|
29
|
+
* node scripts/vision-status.mjs --set <provider/model> [--global|--project]
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process'
|
|
32
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
33
|
+
import { createRequire } from 'node:module'
|
|
34
|
+
import { homedir } from 'node:os'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
import { hasVision, resolveActivePreset } from '../src/pantheon/presets.mjs'
|
|
37
|
+
import { mergeVisionModel } from './install/vision-prompt.mjs'
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url)
|
|
40
|
+
|
|
41
|
+
const CONFIG_FILENAME = 'opencode-vision.json'
|
|
42
|
+
/** Built-in fallback when nothing explicit is configured and discovery finds none. */
|
|
43
|
+
export const DEFAULT_VISION_MODEL = 'opencode-go/mimo-v2.5'
|
|
44
|
+
const VISION_MODES = new Set(['native', 'tool', 'auto'])
|
|
45
|
+
const LEGACY_ZEN_IDS = ['opencode-go', 'opencode']
|
|
46
|
+
const silentLogger = { log() {}, warn() {}, error() {} }
|
|
47
|
+
|
|
48
|
+
/** Global vision config path, honoring `XDG_CONFIG_HOME` (same rule as the plugin). */
|
|
49
|
+
export function globalVisionConfigPath(env = process.env) {
|
|
50
|
+
const home = env.HOME?.trim() || homedir()
|
|
51
|
+
const xdg = env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
|
|
52
|
+
return join(xdg, 'opencode', CONFIG_FILENAME)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Active-preset candidate order — same list the plugin passes to the resolver. */
|
|
56
|
+
export function activePresetCandidates(dir = process.cwd(), env = process.env) {
|
|
57
|
+
const home = env.HOME?.trim() || ''
|
|
58
|
+
const xdg = env.XDG_CONFIG_HOME?.trim() || `${home}/.config`
|
|
59
|
+
return [
|
|
60
|
+
join(dir, '.pantheon', 'active-preset.json'),
|
|
61
|
+
join(xdg, 'opencode', '.pantheon', 'active-preset.json'),
|
|
62
|
+
join(home, '.opencode', '.pantheon', 'active-preset.json'),
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readVisionField(configPath, field) {
|
|
67
|
+
let raw
|
|
68
|
+
try {
|
|
69
|
+
raw = readFileSync(configPath, 'utf8')
|
|
70
|
+
} catch {
|
|
71
|
+
return null // missing/unreadable
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(raw)
|
|
75
|
+
const value = parsed?.[field]
|
|
76
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
|
77
|
+
} catch {
|
|
78
|
+
return null // corrupted JSON → treated as absent
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when a V1 auth.json entry carries a non-empty credential. */
|
|
83
|
+
function hasAuthCredential(entry) {
|
|
84
|
+
if (!entry || typeof entry !== 'object') return false
|
|
85
|
+
for (const key of ['access', 'key', 'apiKey']) {
|
|
86
|
+
if (typeof entry[key] === 'string' && entry[key].trim() !== '') return true
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* OpenCode data directory, honoring `XDG_DATA_HOME` (same rule as the plugin,
|
|
93
|
+
* the native vision path, and the MCP): `$XDG_DATA_HOME/opencode` when set,
|
|
94
|
+
* else `~/.local/share/opencode`. Keeping the V1 `auth.json` and the V2
|
|
95
|
+
* credential db on this base means a host with a relocated data root resolves
|
|
96
|
+
* the same stores as every other vision consumer.
|
|
97
|
+
*/
|
|
98
|
+
function opencodeDataDir(env, home) {
|
|
99
|
+
const dataHome = env.XDG_DATA_HOME?.trim() || join(home, '.local', 'share')
|
|
100
|
+
return join(dataHome, 'opencode')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Provider ids with an ACTIVE credential in the V2 SQLite store (the
|
|
105
|
+
* `credential` table). The database is the file named by `OPENCODE_DB` when
|
|
106
|
+
* set, else the default `<data dir>/opencode.db`. Best-effort and read-only: a
|
|
107
|
+
* missing DB/table, a locked file, or an unavailable `node:sqlite` yields
|
|
108
|
+
* `[]`. Never returns secret material — only the integration ids.
|
|
109
|
+
*/
|
|
110
|
+
function credentialDbProviderIds(home, env = process.env) {
|
|
111
|
+
const override = (env.OPENCODE_DB ?? '').trim()
|
|
112
|
+
const dbPath = override || join(opencodeDataDir(env, home), 'opencode.db')
|
|
113
|
+
if (!existsSync(dbPath)) return []
|
|
114
|
+
let sqlite
|
|
115
|
+
try {
|
|
116
|
+
sqlite = require('node:sqlite')
|
|
117
|
+
} catch {
|
|
118
|
+
return []
|
|
119
|
+
}
|
|
120
|
+
const DatabaseSync = sqlite?.DatabaseSync
|
|
121
|
+
if (typeof DatabaseSync !== 'function') return []
|
|
122
|
+
let db
|
|
123
|
+
try {
|
|
124
|
+
db = new DatabaseSync(`file:${dbPath}?mode=ro`, { readOnly: true })
|
|
125
|
+
const rows = db.prepare('SELECT integration_id FROM credential WHERE active = 1').all()
|
|
126
|
+
const ids = []
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
const id = row?.integration_id
|
|
129
|
+
if (typeof id === 'string' && id.trim() !== '' && !ids.includes(id)) ids.push(id)
|
|
130
|
+
}
|
|
131
|
+
return ids
|
|
132
|
+
} catch {
|
|
133
|
+
return []
|
|
134
|
+
} finally {
|
|
135
|
+
try {
|
|
136
|
+
db?.close()
|
|
137
|
+
} catch {
|
|
138
|
+
// best-effort close
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Describe credential availability without exposing any secret.
|
|
145
|
+
* @returns {{
|
|
146
|
+
* envKey: boolean, authStorePath: string, authStoreIds: string[],
|
|
147
|
+
* authStoreProviderIds: string[]
|
|
148
|
+
* }}
|
|
149
|
+
*/
|
|
150
|
+
export function describeVisionAuth(env = process.env, home = env.HOME?.trim() || homedir()) {
|
|
151
|
+
const envKey = Boolean(
|
|
152
|
+
(env.PANTHEON_OPENCODE_API_KEY ?? '').trim() || (env.OPENCODE_API_KEY ?? '').trim(),
|
|
153
|
+
)
|
|
154
|
+
const authStorePath = join(opencodeDataDir(env, home), 'auth.json')
|
|
155
|
+
let authStoreIds = []
|
|
156
|
+
const authStoreProviderIds = []
|
|
157
|
+
try {
|
|
158
|
+
const auth = JSON.parse(readFileSync(authStorePath, 'utf8'))
|
|
159
|
+
authStoreIds = LEGACY_ZEN_IDS.filter((id) => {
|
|
160
|
+
const entry = auth?.[id]
|
|
161
|
+
return entry && typeof entry.access === 'string' && entry.access.trim() !== ''
|
|
162
|
+
})
|
|
163
|
+
if (auth && typeof auth === 'object' && !Array.isArray(auth)) {
|
|
164
|
+
for (const [id, entry] of Object.entries(auth)) {
|
|
165
|
+
if (hasAuthCredential(entry)) authStoreProviderIds.push(id)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
authStoreIds = []
|
|
170
|
+
}
|
|
171
|
+
for (const id of credentialDbProviderIds(home, env)) {
|
|
172
|
+
if (!authStoreProviderIds.includes(id)) authStoreProviderIds.push(id)
|
|
173
|
+
}
|
|
174
|
+
return { envKey, authStorePath, authStoreIds, authStoreProviderIds }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Zen endpoints share the env keys and the legacy auth-store entries. */
|
|
178
|
+
function isZenProvider(providerID) {
|
|
179
|
+
return providerID === 'opencode' || providerID.startsWith('opencode-go')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Credential presence for a provider, mirroring the plugin's rule: Zen
|
|
184
|
+
* endpoints accept the shared env keys then the auth store (the provider's own
|
|
185
|
+
* entry, then the legacy `opencode-go` / `opencode` entries); non-Zen endpoints
|
|
186
|
+
* accept ONLY their own auth-store entry. Returns `'env' | 'store' | null` —
|
|
187
|
+
* never a token.
|
|
188
|
+
*/
|
|
189
|
+
export function visionCredentialSource(providerID, auth) {
|
|
190
|
+
if (!providerID || !auth) return null
|
|
191
|
+
const ids = auth.authStoreProviderIds ?? []
|
|
192
|
+
if (isZenProvider(providerID)) {
|
|
193
|
+
if (auth.envKey) return 'env'
|
|
194
|
+
if (ids.includes(providerID)) return 'store'
|
|
195
|
+
if (ids.includes('opencode-go') || ids.includes('opencode')) return 'store'
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
return ids.includes(providerID) ? 'store' : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve mode + target + origin offline.
|
|
203
|
+
* @returns {{
|
|
204
|
+
* mode: string, modeOrigin: 'env'|'config'|'default',
|
|
205
|
+
* model: string|null, origin: 'env'|'config'|'preset'|'auto', sourcePath: string|null,
|
|
206
|
+
* fallbackModel: string, auth: {envKey: boolean, authStorePath: string, authStoreIds: string[],
|
|
207
|
+
* authStoreProviderIds: string[]}
|
|
208
|
+
* }}
|
|
209
|
+
*/
|
|
210
|
+
export function resolveVisionStatus({ dir = process.cwd(), env = process.env } = {}) {
|
|
211
|
+
const projectConfigPath = join(dir, '.opencode', CONFIG_FILENAME)
|
|
212
|
+
const globalConfigPath = globalVisionConfigPath(env)
|
|
213
|
+
|
|
214
|
+
const envModel = (env.PANTHEON_VISION_MODEL ?? '').trim()
|
|
215
|
+
const projectModel = readVisionField(projectConfigPath, 'visionModel')
|
|
216
|
+
const globalModel = readVisionField(globalConfigPath, 'visionModel')
|
|
217
|
+
|
|
218
|
+
let model = null
|
|
219
|
+
let origin = 'auto'
|
|
220
|
+
let sourcePath = null
|
|
221
|
+
if (envModel) {
|
|
222
|
+
model = envModel
|
|
223
|
+
origin = 'env'
|
|
224
|
+
} else if (projectModel) {
|
|
225
|
+
model = projectModel
|
|
226
|
+
origin = 'config'
|
|
227
|
+
sourcePath = projectConfigPath
|
|
228
|
+
} else if (globalModel) {
|
|
229
|
+
model = globalModel
|
|
230
|
+
origin = 'config'
|
|
231
|
+
sourcePath = globalConfigPath
|
|
232
|
+
} else {
|
|
233
|
+
const preset = resolveActivePreset({
|
|
234
|
+
env,
|
|
235
|
+
candidates: activePresetCandidates(dir, env),
|
|
236
|
+
logger: silentLogger,
|
|
237
|
+
})
|
|
238
|
+
const presetModel = preset?.vision?.model
|
|
239
|
+
if (typeof presetModel === 'string' && presetModel.trim() !== '') {
|
|
240
|
+
model = presetModel.trim()
|
|
241
|
+
origin = 'preset'
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const envMode = (env.PANTHEON_VISION_MODE ?? '').trim()
|
|
246
|
+
const projectMode = readVisionField(projectConfigPath, 'mode')
|
|
247
|
+
const globalMode = readVisionField(globalConfigPath, 'mode')
|
|
248
|
+
let mode = 'auto'
|
|
249
|
+
let modeOrigin = 'default'
|
|
250
|
+
if (VISION_MODES.has(envMode)) {
|
|
251
|
+
mode = envMode
|
|
252
|
+
modeOrigin = 'env'
|
|
253
|
+
} else if (VISION_MODES.has(projectMode)) {
|
|
254
|
+
mode = projectMode
|
|
255
|
+
modeOrigin = 'config'
|
|
256
|
+
} else if (VISION_MODES.has(globalMode)) {
|
|
257
|
+
mode = globalMode
|
|
258
|
+
modeOrigin = 'config'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
mode,
|
|
263
|
+
modeOrigin,
|
|
264
|
+
model,
|
|
265
|
+
origin,
|
|
266
|
+
sourcePath,
|
|
267
|
+
fallbackModel: DEFAULT_VISION_MODEL,
|
|
268
|
+
auth: describeVisionAuth(env),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function formatStatus(status) {
|
|
273
|
+
const lines = ['Pantheon Vision — status', '']
|
|
274
|
+
lines.push(`Mode: ${status.mode} (origin: ${status.modeOrigin})`)
|
|
275
|
+
if (status.model) {
|
|
276
|
+
lines.push(`Target: ${status.model} (origin: ${status.origin})`)
|
|
277
|
+
if (status.sourcePath) lines.push(` from ${status.sourcePath}`)
|
|
278
|
+
} else {
|
|
279
|
+
lines.push('Target: auto (resolved at session start — host catalog discovery)')
|
|
280
|
+
lines.push(` fallback default: ${status.fallbackModel}`)
|
|
281
|
+
}
|
|
282
|
+
// Extended detection: env keys PLUS every provider with a credential in the
|
|
283
|
+
// V1 auth.json and the V2 credential db (best-effort). A V2 host with a db
|
|
284
|
+
// credential must never read as "none" here — same rule as the picker's
|
|
285
|
+
// `Credential` column.
|
|
286
|
+
const credentialProviders = status.auth.authStoreProviderIds ?? []
|
|
287
|
+
lines.push(
|
|
288
|
+
`Auth: env key ${status.auth.envKey ? 'set' : 'not set'}` +
|
|
289
|
+
`; credential store ${
|
|
290
|
+
credentialProviders.length > 0 ? credentialProviders.join(', ') : 'none'
|
|
291
|
+
}`,
|
|
292
|
+
)
|
|
293
|
+
return lines.join('\n')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Candidate enumeration (`--list`) ─────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/** Name-based vision families — a GUESS, used only when no catalog exists. */
|
|
299
|
+
const VISION_NAME_PATTERNS = [
|
|
300
|
+
{ label: 'vl', re: /(?:^|[/-])vl(?:[/.-]|$)/i },
|
|
301
|
+
{ label: 'glm-v', re: /(?:^|\/)glm-[\w.]*v/i },
|
|
302
|
+
{ label: 'mimo', re: /mimo/i },
|
|
303
|
+
{ label: 'gemini', re: /gemini/i },
|
|
304
|
+
{ label: 'gpt-4o/4.1/5', re: /gpt-(?:4o|4\.1|5)/i },
|
|
305
|
+
{ label: 'claude', re: /claude/i },
|
|
306
|
+
{ label: 'qwen-vl', re: /qwen.*vl/i },
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
function asRecord(value) {
|
|
310
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function visionNameMatch(model) {
|
|
314
|
+
return VISION_NAME_PATTERNS.some((pattern) => pattern.re.test(model))
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Vision support for a model from OFFLINE knowledge. Prefers the repo's
|
|
319
|
+
* CAPABILITY_TABLE (known models, `confidence: 'table'`); otherwise guesses
|
|
320
|
+
* from the model name (`confidence: 'name'`). A guess, never a guarantee.
|
|
321
|
+
*/
|
|
322
|
+
export function offlineVisionFor(model) {
|
|
323
|
+
try {
|
|
324
|
+
return { vision: hasVision(model), confidence: 'table' }
|
|
325
|
+
} catch {
|
|
326
|
+
return { vision: visionNameMatch(model), confidence: 'name' }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Parse `opencode models` output into `provider/model` ids (one per line).
|
|
332
|
+
* Ignores blank lines and lines containing whitespace.
|
|
333
|
+
*/
|
|
334
|
+
export function parseModelsOutput(text) {
|
|
335
|
+
if (typeof text !== 'string') return []
|
|
336
|
+
const seen = new Set()
|
|
337
|
+
for (const line of text.split('\n')) {
|
|
338
|
+
const model = line.trim()
|
|
339
|
+
if (model === '' || !model.includes('/') || /\s/.test(model)) continue
|
|
340
|
+
seen.add(model)
|
|
341
|
+
}
|
|
342
|
+
return [...seen]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Best-effort `opencode models`; null when the CLI is missing or fails. */
|
|
346
|
+
function readModelsOutput(env = process.env) {
|
|
347
|
+
try {
|
|
348
|
+
const result = spawnSync('opencode', ['models'], { encoding: 'utf8', timeout: 20_000, env })
|
|
349
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') return null
|
|
350
|
+
return result.stdout
|
|
351
|
+
} catch {
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function accessOf(costInput) {
|
|
357
|
+
if (costInput === 0) return 'free'
|
|
358
|
+
if (typeof costInput === 'number' && costInput > 0) return 'paid'
|
|
359
|
+
return 'unknown'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Flatten a `GET /config/providers` payload (canonical `{providers:[{id,
|
|
364
|
+
* models:{...}}]}` or a top-level `{models:[...]}`) into image-capable,
|
|
365
|
+
* non-deprecated candidates. Mirrors the plugin's discovery predicate
|
|
366
|
+
* (`capabilities.input.image === true`) without importing the plugin runtime.
|
|
367
|
+
*/
|
|
368
|
+
export function catalogImageCandidates(catalog) {
|
|
369
|
+
const root = asRecord(catalog)
|
|
370
|
+
if (!root) return []
|
|
371
|
+
const out = []
|
|
372
|
+
const add = (raw, providerID) => {
|
|
373
|
+
const model = asRecord(raw)
|
|
374
|
+
if (!model) return
|
|
375
|
+
const id = typeof model.id === 'string' && model.id.trim() !== '' ? model.id.trim() : null
|
|
376
|
+
const pid =
|
|
377
|
+
typeof model.providerID === 'string' && model.providerID.trim() !== ''
|
|
378
|
+
? model.providerID.trim()
|
|
379
|
+
: providerID
|
|
380
|
+
if (!id || !pid) return
|
|
381
|
+
if (asRecord(asRecord(model.capabilities)?.input)?.image !== true) return
|
|
382
|
+
if (model.status === 'deprecated') return
|
|
383
|
+
const cost = asRecord(model.cost)
|
|
384
|
+
const costInput =
|
|
385
|
+
typeof cost?.input === 'number' && Number.isFinite(cost.input) ? cost.input : null
|
|
386
|
+
out.push({ model: `${pid}/${id}`, providerID: pid, costInput, confidence: 'catalog' })
|
|
387
|
+
}
|
|
388
|
+
if (Array.isArray(root.providers)) {
|
|
389
|
+
for (const provider of root.providers) {
|
|
390
|
+
const record = asRecord(provider)
|
|
391
|
+
if (!record) continue
|
|
392
|
+
const pid = typeof record.id === 'string' ? record.id : ''
|
|
393
|
+
const models = asRecord(record.models)
|
|
394
|
+
if (!models) continue
|
|
395
|
+
for (const model of Object.values(models)) add(model, pid)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (Array.isArray(root.models)) {
|
|
399
|
+
for (const model of root.models) add(model, '')
|
|
400
|
+
}
|
|
401
|
+
return out
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function emptyList(dir, reason) {
|
|
405
|
+
return { source: 'none', reason, candidates: [], status: resolveVisionStatus({ dir }) }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Enumerate image-capable candidates from the best available source:
|
|
410
|
+
* - `catalog` — an injected/loaded `GET /config/providers` payload
|
|
411
|
+
* (`capabilities.input.image === true`); authoritative.
|
|
412
|
+
* - `heuristic`— offline `opencode models` filtered by known vision families;
|
|
413
|
+
* a guess, explicitly marked as such.
|
|
414
|
+
* - `none` — nothing could be enumerated; `reason` explains why.
|
|
415
|
+
*
|
|
416
|
+
* @param {{dir?: string, env?: Record<string,string|undefined>, catalog?: unknown,
|
|
417
|
+
* modelsText?: string|null, runModels?: (env?: object) => string|null}} [opts]
|
|
418
|
+
*/
|
|
419
|
+
export function listVisionCandidates({
|
|
420
|
+
dir = process.cwd(),
|
|
421
|
+
env = process.env,
|
|
422
|
+
catalog,
|
|
423
|
+
modelsText,
|
|
424
|
+
runModels,
|
|
425
|
+
} = {}) {
|
|
426
|
+
const status = resolveVisionStatus({ dir, env })
|
|
427
|
+
const auth = status.auth
|
|
428
|
+
const hasCatalog = catalog !== undefined && catalog !== null
|
|
429
|
+
|
|
430
|
+
let source
|
|
431
|
+
let reason = null
|
|
432
|
+
let raw
|
|
433
|
+
if (hasCatalog) {
|
|
434
|
+
source = 'catalog'
|
|
435
|
+
raw = catalogImageCandidates(catalog)
|
|
436
|
+
if (raw.length === 0) reason = 'host catalog has no image-capable, non-deprecated entries'
|
|
437
|
+
} else {
|
|
438
|
+
const text = modelsText !== undefined ? modelsText : (runModels ?? readModelsOutput)(env)
|
|
439
|
+
if (typeof text === 'string') {
|
|
440
|
+
source = 'heuristic'
|
|
441
|
+
raw = []
|
|
442
|
+
for (const model of parseModelsOutput(text)) {
|
|
443
|
+
const { vision, confidence } = offlineVisionFor(model)
|
|
444
|
+
if (vision) {
|
|
445
|
+
raw.push({
|
|
446
|
+
model,
|
|
447
|
+
providerID: model.slice(0, model.indexOf('/')),
|
|
448
|
+
costInput: null,
|
|
449
|
+
confidence,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (raw.length === 0) reason = 'no vision-family models found in `opencode models` output'
|
|
454
|
+
} else {
|
|
455
|
+
source = 'none'
|
|
456
|
+
raw = []
|
|
457
|
+
reason = '`opencode models` unavailable — cannot enumerate the host model list'
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const candidates = raw
|
|
462
|
+
.map((candidate) => {
|
|
463
|
+
const credentialSource = visionCredentialSource(candidate.providerID, auth)
|
|
464
|
+
return {
|
|
465
|
+
model: candidate.model,
|
|
466
|
+
providerID: candidate.providerID,
|
|
467
|
+
access: accessOf(candidate.costInput),
|
|
468
|
+
costInput: candidate.costInput,
|
|
469
|
+
credential: credentialSource !== null,
|
|
470
|
+
credentialSource,
|
|
471
|
+
confidence: candidate.confidence,
|
|
472
|
+
target: status.model === candidate.model,
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
.sort((left, right) => {
|
|
476
|
+
const free = (entry) => (entry.access === 'free' ? 0 : 1)
|
|
477
|
+
// Codepoint comparison (not localeCompare) keeps numbering stable across
|
|
478
|
+
// machines/locales for the same input.
|
|
479
|
+
const byName = left.model < right.model ? -1 : left.model > right.model ? 1 : 0
|
|
480
|
+
return free(left) - free(right) || byName
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
return { source, reason, candidates, status }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Render the `--list` output: numbered, stable, with an honest source line. */
|
|
487
|
+
export function formatVisionList(list) {
|
|
488
|
+
const lines = ['Pantheon Vision — model candidates', '']
|
|
489
|
+
if (list.source === 'catalog') {
|
|
490
|
+
lines.push('Source: host catalog (capabilities.input.image) — authoritative')
|
|
491
|
+
} else if (list.source === 'heuristic') {
|
|
492
|
+
lines.push('Source: heuristic (`opencode models` + known vision families) — NOT authoritative')
|
|
493
|
+
} else {
|
|
494
|
+
lines.push('Source: none')
|
|
495
|
+
}
|
|
496
|
+
lines.push('')
|
|
497
|
+
|
|
498
|
+
const candidates = list.candidates ?? []
|
|
499
|
+
if (candidates.length === 0) {
|
|
500
|
+
lines.push(`No vision-capable models enumerated: ${list.reason ?? 'unknown reason'}`)
|
|
501
|
+
} else {
|
|
502
|
+
const modelWidth = Math.max(5, ...candidates.map((candidate) => candidate.model.length))
|
|
503
|
+
lines.push(` # ${'Model'.padEnd(modelWidth)} ${'Access'.padEnd(7)} Credential`)
|
|
504
|
+
candidates.forEach((candidate, index) => {
|
|
505
|
+
const num = String(index + 1).padStart(3)
|
|
506
|
+
const credential = candidate.credential ? `yes (${candidate.credentialSource})` : 'no'
|
|
507
|
+
const marker = candidate.target ? ' <- current target' : ''
|
|
508
|
+
lines.push(
|
|
509
|
+
` ${num} ${candidate.model.padEnd(modelWidth)} ${candidate.access.padEnd(7)} ${credential}${marker}`,
|
|
510
|
+
)
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
lines.push('')
|
|
515
|
+
if (list.status.model) {
|
|
516
|
+
lines.push(`Target: ${list.status.model} (origin: ${list.status.origin})`)
|
|
517
|
+
} else {
|
|
518
|
+
lines.push('Target: auto (resolved at session start — host catalog discovery)')
|
|
519
|
+
}
|
|
520
|
+
lines.push(
|
|
521
|
+
'Choose a number or type provider/model, then run: node scripts/vision-status.mjs --set <provider/model> [--global|--project]',
|
|
522
|
+
)
|
|
523
|
+
return lines.join('\n')
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ─── Config write (`--set`) ────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* A `provider/model` id the plugin can address: non-empty, at least one slash,
|
|
530
|
+
* no whitespace. Multi-slash ids (e.g. `openrouter/google/gemini-2.5-flash`)
|
|
531
|
+
* are valid opencode model ids, so they are accepted here — the picker must
|
|
532
|
+
* never list an unpinnable candidate.
|
|
533
|
+
*/
|
|
534
|
+
function isPinnableVisionModel(value) {
|
|
535
|
+
return typeof value === 'string' && /^\S+\/\S+$/.test(value.trim())
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Merge `visionModel` into the project (default) or global vision config,
|
|
540
|
+
* reusing the installer's atomic `mergeVisionModel` (backup, merge, corrupt
|
|
541
|
+
* JSON left untouched). Never throws: a write failure is reported and the
|
|
542
|
+
* target file is left intact.
|
|
543
|
+
*
|
|
544
|
+
* @param {string} model `provider/model`
|
|
545
|
+
* @param {{dir?: string, env?: object, target?: 'project'|'global', dryRun?: boolean,
|
|
546
|
+
* beforeRecheck?: (existing: string|null) => void, configPath?: string}} [opts]
|
|
547
|
+
* @returns {{ok: boolean, wrote?: boolean, reason?: string, path?: string,
|
|
548
|
+
* model?: string, error?: string, detail?: string}}
|
|
549
|
+
*/
|
|
550
|
+
export function setVisionModel(
|
|
551
|
+
model,
|
|
552
|
+
{
|
|
553
|
+
dir = process.cwd(),
|
|
554
|
+
env = process.env,
|
|
555
|
+
target = 'project',
|
|
556
|
+
dryRun = false,
|
|
557
|
+
beforeRecheck,
|
|
558
|
+
configPath,
|
|
559
|
+
} = {},
|
|
560
|
+
) {
|
|
561
|
+
const value = typeof model === 'string' ? model.trim() : ''
|
|
562
|
+
if (!isPinnableVisionModel(value)) {
|
|
563
|
+
return {
|
|
564
|
+
ok: false,
|
|
565
|
+
error: 'invalid-format',
|
|
566
|
+
detail: `"${value}" is not provider/model (no spaces, at least one slash)`,
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const resolvedPath =
|
|
570
|
+
configPath ??
|
|
571
|
+
(target === 'global' ? globalVisionConfigPath(env) : join(dir, '.opencode', CONFIG_FILENAME))
|
|
572
|
+
let result
|
|
573
|
+
try {
|
|
574
|
+
result = mergeVisionModel(value, { env, dryRun, beforeRecheck, configPath: resolvedPath })
|
|
575
|
+
} catch (err) {
|
|
576
|
+
return {
|
|
577
|
+
ok: false,
|
|
578
|
+
error: 'write-failed',
|
|
579
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
580
|
+
path: resolvedPath,
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (result.reason === 'corrupt') return { ok: false, error: 'corrupt', path: result.path }
|
|
584
|
+
if (result.reason === 'conflict') return { ok: false, error: 'conflict', path: result.path }
|
|
585
|
+
return { ok: true, wrote: result.wrote, reason: result.reason, path: result.path, model: value }
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function formatSetError(result) {
|
|
589
|
+
switch (result.error) {
|
|
590
|
+
case 'invalid-format':
|
|
591
|
+
return `Vision: not saved — ${result.detail}.`
|
|
592
|
+
case 'corrupt':
|
|
593
|
+
return `Vision: not saved — config at ${result.path} is not valid JSON; left untouched. Fix it and re-run.`
|
|
594
|
+
case 'conflict':
|
|
595
|
+
return `Vision: not saved — config at ${result.path} changed concurrently; re-run to retry.`
|
|
596
|
+
default:
|
|
597
|
+
return `Vision: not saved — write failed for ${result.path ?? 'config'}: ${result.detail ?? 'unknown error'}.`
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Resolve the `--dir` value from argv, accepting BOTH `--dir <path>` and
|
|
603
|
+
* `--dir=<path>` (the command spec uses the `=` form). A missing/empty value
|
|
604
|
+
* falls back to the current working directory.
|
|
605
|
+
*/
|
|
606
|
+
function parseDirArg(argv) {
|
|
607
|
+
const equals = argv.find((arg) => arg.startsWith('--dir='))
|
|
608
|
+
if (equals) {
|
|
609
|
+
const value = equals.slice('--dir='.length)
|
|
610
|
+
if (value) return value
|
|
611
|
+
}
|
|
612
|
+
const dirFlag = argv.indexOf('--dir')
|
|
613
|
+
return dirFlag !== -1 && argv[dirFlag + 1] ? argv[dirFlag + 1] : process.cwd()
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function parseCatalogArg(argv) {
|
|
617
|
+
const equals = argv.find((arg) => arg.startsWith('--catalog='))
|
|
618
|
+
if (equals) return equals.slice('--catalog='.length) || null
|
|
619
|
+
const dirFlag = argv.indexOf('--catalog')
|
|
620
|
+
return dirFlag !== -1 && argv[dirFlag + 1] ? argv[dirFlag + 1] : null
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function parseSetArg(argv) {
|
|
624
|
+
const equals = argv.find((arg) => arg.startsWith('--set='))
|
|
625
|
+
if (equals) return equals.slice('--set='.length)
|
|
626
|
+
const dirFlag = argv.indexOf('--set')
|
|
627
|
+
return dirFlag !== -1 && argv[dirFlag + 1] && !argv[dirFlag + 1].startsWith('--')
|
|
628
|
+
? argv[dirFlag + 1]
|
|
629
|
+
: ''
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function main(argv = process.argv.slice(2)) {
|
|
633
|
+
const dir = parseDirArg(argv)
|
|
634
|
+
const json = argv.includes('--json')
|
|
635
|
+
|
|
636
|
+
const hasSet = argv.some((arg) => arg === '--set' || arg.startsWith('--set='))
|
|
637
|
+
if (hasSet) {
|
|
638
|
+
const model = parseSetArg(argv)
|
|
639
|
+
if (!model) {
|
|
640
|
+
process.stdout.write('Usage: vision-status.mjs --set <provider/model> [--global|--project]\n')
|
|
641
|
+
return 1
|
|
642
|
+
}
|
|
643
|
+
const target = argv.includes('--global') ? 'global' : 'project'
|
|
644
|
+
const result = setVisionModel(model, { dir, target })
|
|
645
|
+
if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
646
|
+
else if (result.ok) {
|
|
647
|
+
process.stdout.write(`Vision: pinned ${result.model} in ${result.path} (${result.reason}).\n`)
|
|
648
|
+
} else process.stdout.write(`${formatSetError(result)}\n`)
|
|
649
|
+
return result.ok ? 0 : 1
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (argv.includes('--list')) {
|
|
653
|
+
const catalogPath = parseCatalogArg(argv)
|
|
654
|
+
let list
|
|
655
|
+
if (catalogPath) {
|
|
656
|
+
try {
|
|
657
|
+
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'))
|
|
658
|
+
list = listVisionCandidates({ dir, catalog })
|
|
659
|
+
} catch (err) {
|
|
660
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
661
|
+
list = emptyList(dir, `could not read catalog ${catalogPath}: ${detail}`)
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
list = listVisionCandidates({ dir })
|
|
665
|
+
}
|
|
666
|
+
if (json) process.stdout.write(`${JSON.stringify(list, null, 2)}\n`)
|
|
667
|
+
else process.stdout.write(`${formatVisionList(list)}\n`)
|
|
668
|
+
return 0
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const status = resolveVisionStatus({ dir })
|
|
672
|
+
if (json) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`)
|
|
673
|
+
else process.stdout.write(`${formatStatus(status)}\n`)
|
|
674
|
+
return 0
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Only run when invoked directly, so tests can import the resolvers.
|
|
678
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
679
|
+
process.exit(main())
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export { formatStatus, main, parseDirArg }
|