pantheon-opencode 1.5.2 → 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/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/AGENTS.global.md +182 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +343 -0
- package/README.md +577 -79
- package/README.pt-BR.md +345 -60
- 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-optimize.md +2 -2
- package/commands/pantheon-vision.md +61 -12
- package/package.json +30 -11
- package/plugin.json +2 -2
- package/pyproject.toml +11 -16
- package/scripts/build-agents-md.mjs +376 -58
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/{code_mode_server.py → code_mode.py} +119 -27
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +236 -29
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +319 -6
- package/scripts/install/health-check.mjs +26 -37
- package/scripts/install/interactive.mjs +7 -7
- package/scripts/install/migrate.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} +392 -78
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +36 -13
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +2 -2
- 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 +1913 -0
- package/scripts/validate-routing.mjs +43 -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 +15 -16
- 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 +23 -60
- 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 -130
- 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} +119 -27
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
- package/src/mcp/requirements-mcp.txt +10 -6
- package/src/mcp/requirements-vision.txt +4 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/todo-enforcer.ts +1 -1
- 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 +1161 -116
- package/src/plugin-v2.ts +1244 -288
- 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 +5504 -1341
- package/src/plugins/tui/package.json +25 -15
- 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 -22
- package/src/skills/README.md +34 -39
- 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 +54 -95
- 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/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/opencode.json +0 -58
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -15,6 +15,8 @@ import json
|
|
|
15
15
|
import logging
|
|
16
16
|
import mimetypes
|
|
17
17
|
import os
|
|
18
|
+
import re
|
|
19
|
+
import sqlite3
|
|
18
20
|
import struct
|
|
19
21
|
import sys
|
|
20
22
|
from dataclasses import dataclass
|
|
@@ -23,7 +25,7 @@ from typing import Any
|
|
|
23
25
|
from urllib.parse import unquote, urlparse
|
|
24
26
|
|
|
25
27
|
import httpx
|
|
26
|
-
from
|
|
28
|
+
from fastmcp import FastMCP
|
|
27
29
|
|
|
28
30
|
mcp = FastMCP("pantheon-vision")
|
|
29
31
|
|
|
@@ -58,9 +60,24 @@ if not _logger.handlers:
|
|
|
58
60
|
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
|
59
61
|
REQUEST_TIMEOUT_SECONDS = 20.0
|
|
60
62
|
HTTP_ERROR_STATUS = 400
|
|
63
|
+
HTTP_BAD_REQUEST = 400
|
|
64
|
+
HTTP_UNAUTHORIZED = 401
|
|
65
|
+
HTTP_FORBIDDEN = 403
|
|
61
66
|
DEFAULT_MODEL = "opencode-go/mimo-v2.5"
|
|
62
67
|
GO_ENDPOINT = "https://opencode.ai/zen/go/v1/chat/completions"
|
|
63
68
|
OPENCODE_ENDPOINT = "https://opencode.ai/zen/v1/chat/completions"
|
|
69
|
+
# The Zen gateway rejects a sessionless request with HTTP 400 MissingSessionID.
|
|
70
|
+
# Only Zen endpoints accept (and require) this header; it is never sent
|
|
71
|
+
# elsewhere.
|
|
72
|
+
SESSION_HEADER = "x-opencode-session"
|
|
73
|
+
_ZEN_ENDPOINT_PREFIX = "https://opencode.ai/zen/"
|
|
74
|
+
# A session id is an opaque OpenCode token. Only the characters real ids use
|
|
75
|
+
# (`ses_...`) plus the separators existing callers already pass are accepted;
|
|
76
|
+
# the length is bounded. Anything else — empty, whitespace, control characters,
|
|
77
|
+
# quotes, CR/LF — is rejected instead of being copied into the header, where a
|
|
78
|
+
# CRLF would split it and let a caller inject extra headers/requests.
|
|
79
|
+
_SESSION_ID_MAX_LENGTH = 128
|
|
80
|
+
_SESSION_ID_PATTERN = re.compile(r"[A-Za-z0-9._:-]+")
|
|
64
81
|
_ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
|
|
65
82
|
_EXTENSION_MIME_TYPES = {
|
|
66
83
|
".png": "image/png",
|
|
@@ -220,7 +237,9 @@ async def _prepare_input(value: str) -> ImageInput:
|
|
|
220
237
|
raise VisionError("Invalid image URL.")
|
|
221
238
|
mime = _mime_from_path(Path(parsed.path))
|
|
222
239
|
if mime is None and Path(parsed.path).suffix:
|
|
223
|
-
raise VisionError(
|
|
240
|
+
raise VisionError(
|
|
241
|
+
"Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF."
|
|
242
|
+
)
|
|
224
243
|
# The remote server owns the bytes. The gateway receives the URL and
|
|
225
244
|
# enforces its own fetch limits; unknown extensions remain possible.
|
|
226
245
|
return ImageInput(value, mime or "image/jpeg", None, None, None)
|
|
@@ -238,9 +257,7 @@ async def _prepare_input(value: str) -> ImageInput:
|
|
|
238
257
|
raise VisionError("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
|
|
239
258
|
encoded = base64.b64encode(data).decode("ascii")
|
|
240
259
|
width, height = _dimensions(data, mime)
|
|
241
|
-
return ImageInput(
|
|
242
|
-
f"data:{mime};base64,{encoded}", mime, len(data), width, height
|
|
243
|
-
)
|
|
260
|
+
return ImageInput(f"data:{mime};base64,{encoded}", mime, len(data), width, height)
|
|
244
261
|
|
|
245
262
|
|
|
246
263
|
def _extract_key(value: Any) -> str | None:
|
|
@@ -268,6 +285,36 @@ def _endpoint_for_model(model: str) -> str:
|
|
|
268
285
|
return OPENCODE_ENDPOINT if model.startswith("opencode/") else GO_ENDPOINT
|
|
269
286
|
|
|
270
287
|
|
|
288
|
+
def _is_zen_endpoint(endpoint: str) -> bool:
|
|
289
|
+
"""Return whether ``endpoint`` is an OpenCode Zen gateway URL.
|
|
290
|
+
|
|
291
|
+
The destination host is the security boundary for the
|
|
292
|
+
``x-opencode-session`` header: the session identity must reach a Zen
|
|
293
|
+
endpoint and nothing else. This is a prefix check (not equality) so a
|
|
294
|
+
self-hosted Zen path still qualifies while a non-Zen URL never does.
|
|
295
|
+
"""
|
|
296
|
+
return endpoint.startswith(_ZEN_ENDPOINT_PREFIX)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _validated_session_id(value: object) -> str | None:
|
|
300
|
+
"""Return a session id safe to send in the Zen header, or None.
|
|
301
|
+
|
|
302
|
+
The value arrives from a tool parameter the plugin forwards (a local MCP
|
|
303
|
+
process is not handed a session id by the host), so it is untrusted input:
|
|
304
|
+
only a trimmed, bounded, token-shaped value is accepted. An empty or
|
|
305
|
+
whitespace-only string, a control character, a quote, or an embedded
|
|
306
|
+
CR/LF yields None so the header is simply omitted — never copied verbatim.
|
|
307
|
+
"""
|
|
308
|
+
if not isinstance(value, str):
|
|
309
|
+
return None
|
|
310
|
+
candidate = value.strip()
|
|
311
|
+
if not candidate or len(candidate) > _SESSION_ID_MAX_LENGTH:
|
|
312
|
+
return None
|
|
313
|
+
if _SESSION_ID_PATTERN.fullmatch(candidate) is None:
|
|
314
|
+
return None
|
|
315
|
+
return candidate
|
|
316
|
+
|
|
317
|
+
|
|
271
318
|
def _strip_provider_prefix(model: str) -> str:
|
|
272
319
|
"""Return the model ID without its provider prefix.
|
|
273
320
|
|
|
@@ -281,23 +328,119 @@ def _strip_provider_prefix(model: str) -> str:
|
|
|
281
328
|
return model_name if separator else model
|
|
282
329
|
|
|
283
330
|
|
|
331
|
+
def _opencode_data_dir() -> Path:
|
|
332
|
+
"""Locate the OpenCode data directory on either V1 or V2 hosts.
|
|
333
|
+
|
|
334
|
+
Honors ``XDG_DATA_HOME`` (``$XDG_DATA_HOME/opencode`` when set, else
|
|
335
|
+
``~/.local/share/opencode``) so a relocated data root resolves the same
|
|
336
|
+
stores as the plugin. ``OPENCODE_DB`` still names the exact credential db.
|
|
337
|
+
"""
|
|
338
|
+
data_home = os.getenv("XDG_DATA_HOME", "").strip()
|
|
339
|
+
base = Path(data_home) if data_home else Path.home() / ".local" / "share"
|
|
340
|
+
return base / "opencode"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _credential_db_path() -> Path:
|
|
344
|
+
"""Path to the OpenCode V2 credential database.
|
|
345
|
+
|
|
346
|
+
``OPENCODE_DB`` names the exact file when the host relocated it (e.g. a
|
|
347
|
+
sandbox with its own db); otherwise the default ``<data dir>/opencode.db``
|
|
348
|
+
sibling of the V1 ``auth.json`` is used.
|
|
349
|
+
"""
|
|
350
|
+
override = os.getenv("OPENCODE_DB", "").strip()
|
|
351
|
+
if override:
|
|
352
|
+
return Path(override)
|
|
353
|
+
return _opencode_data_dir() / "opencode.db"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _key_from_credential_db(provider: str) -> str | None:
|
|
357
|
+
"""Read a provider key from the V2 SQLite credential store.
|
|
358
|
+
|
|
359
|
+
OpenCode V2 moved credentials out of ``<data dir>/auth.json`` and into a
|
|
360
|
+
``credential`` table in the OpenCode database (``OPENCODE_DB``, else
|
|
361
|
+
``<data dir>/opencode.db``); the V1 JSON file no longer exists on a V2
|
|
362
|
+
host, so reading it alone left auth silently unresolvable. The value is
|
|
363
|
+
plaintext JSON of the form ``{"type": "key", "key": "..."}``, so the
|
|
364
|
+
stdlib is sufficient — no new dependency.
|
|
365
|
+
|
|
366
|
+
The database is opened read-only via a URI so a missing or locked file
|
|
367
|
+
cannot be created or mutated by a health check.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
provider: Integration id to look up, e.g. ``"opencode-go"``.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The stored key, or None when the store, table, row, or key is absent.
|
|
374
|
+
"""
|
|
375
|
+
db_path = _credential_db_path()
|
|
376
|
+
if not db_path.is_file():
|
|
377
|
+
return None
|
|
378
|
+
uri = f"file:{db_path}?mode=ro"
|
|
379
|
+
conn = None
|
|
380
|
+
try:
|
|
381
|
+
conn = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
382
|
+
row = conn.execute(
|
|
383
|
+
"SELECT value FROM credential "
|
|
384
|
+
"WHERE integration_id = ? AND active = 1 LIMIT 1",
|
|
385
|
+
(provider,),
|
|
386
|
+
).fetchone()
|
|
387
|
+
except (sqlite3.Error, ValueError):
|
|
388
|
+
_logger.warning("Could not read the OpenCode credential store at %s", db_path)
|
|
389
|
+
return None
|
|
390
|
+
finally:
|
|
391
|
+
if conn is not None:
|
|
392
|
+
conn.close()
|
|
393
|
+
if row is None:
|
|
394
|
+
return None
|
|
395
|
+
try:
|
|
396
|
+
return _extract_key(json.loads(row[0]))
|
|
397
|
+
except (json.JSONDecodeError, TypeError):
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
284
401
|
def _resolve_auth() -> tuple[str | None, str]:
|
|
285
|
-
"""Resolve auth in env-first, then OpenCode
|
|
402
|
+
"""Resolve auth in env-first, then OpenCode credential-store order.
|
|
403
|
+
|
|
404
|
+
Precedence, highest first:
|
|
405
|
+
|
|
406
|
+
1. ``PANTHEON_OPENCODE_API_KEY``
|
|
407
|
+
2. ``OPENCODE_API_KEY``
|
|
408
|
+
3. the V2 SQLite ``credential`` table (``opencode-go``, then ``opencode``)
|
|
409
|
+
4. a legacy V1 ``<data dir>/auth.json`` file, for hosts that still have one
|
|
410
|
+
|
|
411
|
+
The V1 file is checked last and only when it exists; it is absent on a V2
|
|
412
|
+
host. When no source yields a key this returns ``None`` silently: the
|
|
413
|
+
graceful callers (``vision_describe`` / ``vision_ocr``) degrade to local
|
|
414
|
+
extraction, and only the fatal gateway path logs the paths/env vars tried.
|
|
415
|
+
"""
|
|
286
416
|
model = os.getenv("PANTHEON_VISION_MODEL") or DEFAULT_MODEL
|
|
287
417
|
env_key = os.getenv("PANTHEON_OPENCODE_API_KEY") or os.getenv("OPENCODE_API_KEY")
|
|
288
418
|
if env_key:
|
|
289
419
|
return env_key, _endpoint_for_model(model)
|
|
290
420
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
421
|
+
for provider, endpoint in (
|
|
422
|
+
("opencode-go", GO_ENDPOINT),
|
|
423
|
+
("opencode", OPENCODE_ENDPOINT),
|
|
424
|
+
):
|
|
425
|
+
key = _key_from_credential_db(provider)
|
|
426
|
+
if key:
|
|
427
|
+
return key, endpoint
|
|
428
|
+
|
|
429
|
+
auth_path = _opencode_data_dir() / "auth.json"
|
|
430
|
+
if auth_path.is_file():
|
|
431
|
+
try:
|
|
432
|
+
auth = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
433
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
434
|
+
auth = {}
|
|
435
|
+
if isinstance(auth, dict):
|
|
436
|
+
for provider, endpoint in (
|
|
437
|
+
("opencode-go", GO_ENDPOINT),
|
|
438
|
+
("opencode", OPENCODE_ENDPOINT),
|
|
439
|
+
):
|
|
440
|
+
key = _extract_key(auth.get(provider))
|
|
441
|
+
if key:
|
|
442
|
+
return key, endpoint
|
|
443
|
+
|
|
301
444
|
return None, _endpoint_for_model(model)
|
|
302
445
|
|
|
303
446
|
|
|
@@ -320,6 +463,86 @@ def _metadata(image: ImageInput) -> dict[str, Any]:
|
|
|
320
463
|
}
|
|
321
464
|
|
|
322
465
|
|
|
466
|
+
def _local_fallback_note() -> str:
|
|
467
|
+
"""Local-fallback guidance naming the EFFECTIVE credential database path.
|
|
468
|
+
|
|
469
|
+
Built at call time (not a module constant) so a relocated host — via
|
|
470
|
+
``OPENCODE_DB`` or ``XDG_DATA_HOME`` — points the operator at the file the
|
|
471
|
+
resolver actually reads, matching the ``_gateway`` diagnostics.
|
|
472
|
+
"""
|
|
473
|
+
return (
|
|
474
|
+
"Gateway de visão indisponível (sem credencial). Usando extração local "
|
|
475
|
+
"(metadados + best-effort), sem conteúdo inventado. Rode `opencode auth "
|
|
476
|
+
"login`, configure PANTHEON_OPENCODE_API_KEY ou OPENCODE_API_KEY, ou "
|
|
477
|
+
f"guarde uma chave para 'opencode-go' em {_credential_db_path()}."
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _image_bytes(image: ImageInput) -> bytes | None:
|
|
482
|
+
"""Return the decoded bytes of an in-memory image source, else None.
|
|
483
|
+
|
|
484
|
+
Only data URIs carry bytes locally. Remote URLs are intentionally not
|
|
485
|
+
fetched here: the local fallback must stay offline and deterministic.
|
|
486
|
+
"""
|
|
487
|
+
if not image.source.startswith("data:"):
|
|
488
|
+
return None
|
|
489
|
+
_header, separator, encoded = image.source.partition(",")
|
|
490
|
+
if not separator:
|
|
491
|
+
return None
|
|
492
|
+
try:
|
|
493
|
+
return base64.b64decode(encoded, validate=True)
|
|
494
|
+
except (ValueError, binascii.Error):
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _ocr_bytes(data: bytes) -> str | None:
|
|
499
|
+
"""Best-effort OCR via pytesseract when it is installed.
|
|
500
|
+
|
|
501
|
+
The optional dependency is import-guarded: no pytesseract/Pillow is required
|
|
502
|
+
(neither is added to requirements-vision.txt). Any failure yields None so
|
|
503
|
+
the caller degrades to metadata-only instead of inventing text.
|
|
504
|
+
"""
|
|
505
|
+
try:
|
|
506
|
+
import io # noqa: PLC0415
|
|
507
|
+
|
|
508
|
+
import pytesseract # noqa: PLC0415
|
|
509
|
+
from PIL import Image as PILImage # noqa: PLC0415
|
|
510
|
+
except ImportError:
|
|
511
|
+
return None
|
|
512
|
+
try:
|
|
513
|
+
with PILImage.open(io.BytesIO(data)) as opened:
|
|
514
|
+
text = pytesseract.image_to_string(opened)
|
|
515
|
+
except Exception:
|
|
516
|
+
return None
|
|
517
|
+
stripped = text.strip() if isinstance(text, str) else ""
|
|
518
|
+
return stripped or None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _local_vision_text(image: ImageInput) -> str:
|
|
522
|
+
"""Deterministic local fallback: metadata, plus OCR when available.
|
|
523
|
+
|
|
524
|
+
Never fabricates a description; when pytesseract is absent it states so.
|
|
525
|
+
"""
|
|
526
|
+
metadata = _metadata(image)
|
|
527
|
+
dims = (
|
|
528
|
+
f"{metadata['width']}x{metadata['height']}"
|
|
529
|
+
if metadata["dimensions"]
|
|
530
|
+
else "dimensões desconhecidas"
|
|
531
|
+
)
|
|
532
|
+
size = metadata["size"]
|
|
533
|
+
size_text = f"{size} bytes" if isinstance(size, int) else "tamanho desconhecido"
|
|
534
|
+
data = _image_bytes(image)
|
|
535
|
+
ocr_text = _ocr_bytes(data) if data is not None else None
|
|
536
|
+
lines = [
|
|
537
|
+
f"Imagem local: {dims} {metadata['mime']}, {size_text}.",
|
|
538
|
+
f"Texto OCR: {ocr_text}"
|
|
539
|
+
if ocr_text
|
|
540
|
+
else "Texto OCR: indisponível (tesseract/pytesseract não instalado).",
|
|
541
|
+
_local_fallback_note(),
|
|
542
|
+
]
|
|
543
|
+
return "\n".join(lines)
|
|
544
|
+
|
|
545
|
+
|
|
323
546
|
def _content_text(value: Any) -> str:
|
|
324
547
|
"""Extract text from OpenAI-compatible message content."""
|
|
325
548
|
if isinstance(value, str):
|
|
@@ -343,7 +566,9 @@ def _scrub(value: str, key: str | None) -> str:
|
|
|
343
566
|
def _sanitized_gateway_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
344
567
|
"""Non-sensitive summary of a gateway request body (no keys, no image bytes)."""
|
|
345
568
|
messages = body.get("messages")
|
|
346
|
-
content =
|
|
569
|
+
content = (
|
|
570
|
+
messages[0].get("content") if isinstance(messages, list) and messages else None
|
|
571
|
+
)
|
|
347
572
|
chunks = content if isinstance(content, list) else [content]
|
|
348
573
|
parts: list[dict[str, Any]] = []
|
|
349
574
|
for chunk in chunks:
|
|
@@ -353,7 +578,9 @@ def _sanitized_gateway_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
|
353
578
|
parts.append({"type": "image_url"})
|
|
354
579
|
else:
|
|
355
580
|
text = chunk.get("text")
|
|
356
|
-
parts.append(
|
|
581
|
+
parts.append(
|
|
582
|
+
{"type": "text", "chars": len(text) if isinstance(text, str) else 0}
|
|
583
|
+
)
|
|
357
584
|
return {
|
|
358
585
|
"model": body.get("model"),
|
|
359
586
|
"response_format": body.get("response_format"),
|
|
@@ -376,11 +603,68 @@ def _parse_json_object(value: str) -> dict[str, Any] | None:
|
|
|
376
603
|
return None
|
|
377
604
|
|
|
378
605
|
|
|
379
|
-
|
|
380
|
-
"""
|
|
606
|
+
def _gateway_http_error(status: int) -> str:
|
|
607
|
+
"""Actionable, non-sensitive VisionError text for a gateway HTTP failure.
|
|
608
|
+
|
|
609
|
+
Maps the known Zen failures: 400 ``MissingSessionID`` (session header
|
|
610
|
+
absent), 401 (credential/model), 403 (free-tier model usable only from
|
|
611
|
+
inside OpenCode, or no paid access). Never includes the credential, image
|
|
612
|
+
payload, or session id.
|
|
613
|
+
"""
|
|
614
|
+
if status == HTTP_BAD_REQUEST:
|
|
615
|
+
return (
|
|
616
|
+
"Vision gateway rejected the request (HTTP 400 MissingSessionID): "
|
|
617
|
+
"the x-opencode-session header is missing — call the tool from an "
|
|
618
|
+
"OpenCode session so the session id can be forwarded."
|
|
619
|
+
)
|
|
620
|
+
if status == HTTP_UNAUTHORIZED:
|
|
621
|
+
return "Vision gateway rejected the credential (HTTP 401)."
|
|
622
|
+
if status == HTTP_FORBIDDEN:
|
|
623
|
+
return (
|
|
624
|
+
"Vision gateway denied access (HTTP 403): the model may be "
|
|
625
|
+
"free-tier (usable only from inside OpenCode) or this account has "
|
|
626
|
+
"no paid access to it."
|
|
627
|
+
)
|
|
628
|
+
return "Vision gateway returned an HTTP error."
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def _gateway(
|
|
632
|
+
image: ImageInput,
|
|
633
|
+
prompt: str,
|
|
634
|
+
*,
|
|
635
|
+
structured: bool = False,
|
|
636
|
+
session_id: str | None = None,
|
|
637
|
+
) -> str:
|
|
638
|
+
"""Send one multimodal request to the selected OpenCode gateway.
|
|
639
|
+
|
|
640
|
+
``session_id`` (when present and valid — see ``_validated_session_id``) is
|
|
641
|
+
sent as the ``x-opencode-session`` header — and ONLY for a Zen endpoint,
|
|
642
|
+
which rejects the request with HTTP 400 ``MissingSessionID`` otherwise. An
|
|
643
|
+
unusable value (empty, whitespace, control characters, CR/LF, quotes) is
|
|
644
|
+
dropped rather than copied into the header. It is never logged and never
|
|
645
|
+
sent to a non-Zen host.
|
|
646
|
+
"""
|
|
381
647
|
key, endpoint = _resolve_auth()
|
|
382
648
|
if not key:
|
|
383
|
-
|
|
649
|
+
# Missing credential is FATAL here (unlike the graceful describe/ocr
|
|
650
|
+
# local fallback), so this is the one place that logs the sources tried.
|
|
651
|
+
db_path = _credential_db_path()
|
|
652
|
+
auth_path = _opencode_data_dir() / "auth.json"
|
|
653
|
+
_logger.error(
|
|
654
|
+
"No OpenCode credential found. Run `opencode auth login`, set "
|
|
655
|
+
"PANTHEON_OPENCODE_API_KEY (or OPENCODE_API_KEY), or store a key "
|
|
656
|
+
"for 'opencode-go' in %s (tried: env, the credential table in %s, "
|
|
657
|
+
"and the legacy %s).",
|
|
658
|
+
db_path,
|
|
659
|
+
db_path,
|
|
660
|
+
auth_path,
|
|
661
|
+
)
|
|
662
|
+
raise VisionError(
|
|
663
|
+
"OpenCode credential not configured. Run `opencode auth login`, "
|
|
664
|
+
"set PANTHEON_OPENCODE_API_KEY or OPENCODE_API_KEY, or store a key "
|
|
665
|
+
f"for 'opencode-go' in the OpenCode credential database ({db_path}). "
|
|
666
|
+
"See the pantheon.vision error log for the paths tried."
|
|
667
|
+
)
|
|
384
668
|
safe_prompt = prompt.replace(key, "[redacted]")
|
|
385
669
|
content: list[dict[str, Any]] = [
|
|
386
670
|
{"type": "text", "text": safe_prompt},
|
|
@@ -392,18 +676,25 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
392
676
|
}
|
|
393
677
|
if structured:
|
|
394
678
|
body["response_format"] = {"type": "json_object"}
|
|
679
|
+
headers = {
|
|
680
|
+
"Authorization": f"Bearer {key}",
|
|
681
|
+
"Content-Type": "application/json",
|
|
682
|
+
}
|
|
683
|
+
safe_session_id = _validated_session_id(session_id)
|
|
684
|
+
if safe_session_id is not None and _is_zen_endpoint(endpoint):
|
|
685
|
+
headers[SESSION_HEADER] = safe_session_id
|
|
395
686
|
try:
|
|
396
687
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
|
|
397
688
|
response = await client.post(
|
|
398
689
|
endpoint,
|
|
399
|
-
headers=
|
|
690
|
+
headers=headers,
|
|
400
691
|
json=body,
|
|
401
692
|
)
|
|
402
693
|
if response.status_code >= HTTP_ERROR_STATUS:
|
|
403
694
|
# Sanitized diagnostic: status, endpoint, bare model, truncated
|
|
404
695
|
# gateway detail (scrubbed), and a body summary with no keys or
|
|
405
696
|
# image bytes. Logged to stderr so the MCP stdio protocol is
|
|
406
|
-
# never corrupted.
|
|
697
|
+
# never corrupted. The session id is NEVER logged.
|
|
407
698
|
detail = _scrub(str(getattr(response, "text", "")), key)
|
|
408
699
|
_logger.warning(
|
|
409
700
|
"vision gateway error status=%s endpoint=%s model=%s detail=%s body=%s",
|
|
@@ -413,19 +704,42 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
413
704
|
detail[:200] or "no detail",
|
|
414
705
|
json.dumps(_sanitized_gateway_body(body)),
|
|
415
706
|
)
|
|
416
|
-
raise VisionError(
|
|
707
|
+
raise VisionError(_gateway_http_error(response.status_code))
|
|
417
708
|
payload = response.json()
|
|
418
709
|
choices = payload.get("choices") if isinstance(payload, dict) else None
|
|
419
|
-
|
|
420
|
-
|
|
710
|
+
first_choice = choices[0] if isinstance(choices, list) and choices else None
|
|
711
|
+
message = (
|
|
712
|
+
first_choice.get("message") if isinstance(first_choice, dict) else None
|
|
713
|
+
)
|
|
714
|
+
text = _content_text(
|
|
715
|
+
message.get("content") if isinstance(message, dict) else ""
|
|
716
|
+
)
|
|
421
717
|
if not text:
|
|
422
|
-
|
|
718
|
+
# Surface finish_reason (e.g. `length`) so a reasoning model cut off
|
|
719
|
+
# by max_tokens yields an actionable message, not a bare "empty".
|
|
720
|
+
finish = (
|
|
721
|
+
first_choice.get("finish_reason")
|
|
722
|
+
if isinstance(first_choice, dict)
|
|
723
|
+
else None
|
|
724
|
+
)
|
|
725
|
+
suffix = (
|
|
726
|
+
f" (finish_reason: {finish})"
|
|
727
|
+
if isinstance(finish, str) and finish
|
|
728
|
+
else ""
|
|
729
|
+
)
|
|
730
|
+
raise VisionError(f"Vision gateway returned an empty response{suffix}.")
|
|
423
731
|
return _scrub(text, key)
|
|
424
732
|
except VisionError:
|
|
425
733
|
raise
|
|
426
734
|
except (httpx.TimeoutException, TimeoutError) as exc:
|
|
427
735
|
raise VisionError("Vision gateway request timed out.") from exc
|
|
428
|
-
except (
|
|
736
|
+
except (
|
|
737
|
+
httpx.HTTPError,
|
|
738
|
+
json.JSONDecodeError,
|
|
739
|
+
KeyError,
|
|
740
|
+
IndexError,
|
|
741
|
+
TypeError,
|
|
742
|
+
) as exc:
|
|
429
743
|
raise VisionError("Could not read the vision gateway response.") from exc
|
|
430
744
|
except Exception as exc:
|
|
431
745
|
raise VisionError("Vision gateway request failed.") from exc
|
|
@@ -433,7 +747,11 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
433
747
|
|
|
434
748
|
def _error_message(exc: Exception) -> str:
|
|
435
749
|
"""Return a stable, non-sensitive tool error."""
|
|
436
|
-
return
|
|
750
|
+
return (
|
|
751
|
+
f"Error: {exc}"
|
|
752
|
+
if isinstance(exc, VisionError)
|
|
753
|
+
else "Error: Vision request failed."
|
|
754
|
+
)
|
|
437
755
|
|
|
438
756
|
|
|
439
757
|
_DESCRIBE_PROMPT = (
|
|
@@ -442,7 +760,8 @@ _DESCRIBE_PROMPT = (
|
|
|
442
760
|
)
|
|
443
761
|
_OCR_PROMPT = (
|
|
444
762
|
"Perform OCR on this image. Transcribe all visible text exactly, preserving "
|
|
445
|
-
"lines, spacing, punctuation, and formatting (formatação) where possible.
|
|
763
|
+
"lines, spacing, punctuation, and formatting (formatação) where possible. "
|
|
764
|
+
"If there is no "
|
|
446
765
|
"text, say clearly that no text was found. Do not describe the image."
|
|
447
766
|
)
|
|
448
767
|
_ANALYZE_PROMPT = (
|
|
@@ -453,31 +772,68 @@ _ANALYZE_PROMPT = (
|
|
|
453
772
|
|
|
454
773
|
|
|
455
774
|
@mcp.tool(description="Describe an image faithfully using the Pantheon vision gateway.")
|
|
456
|
-
async def vision_describe(
|
|
457
|
-
|
|
775
|
+
async def vision_describe(
|
|
776
|
+
path: str, prompt: str | None = None, session_id: str | None = None
|
|
777
|
+
) -> str:
|
|
778
|
+
"""Describe image content, text, layout, objects, and uncertainty.
|
|
779
|
+
|
|
780
|
+
When no gateway credential is available the tool degrades to a
|
|
781
|
+
deterministic local extraction (metadata + best-effort OCR) instead of
|
|
782
|
+
failing outright; it never returns an invented description.
|
|
783
|
+
|
|
784
|
+
``session_id`` is the caller's OpenCode session id. The Zen gateway rejects
|
|
785
|
+
a sessionless request (HTTP 400 MissingSessionID); the plugin forwards it
|
|
786
|
+
through the tool call because a local MCP process is not handed one by the
|
|
787
|
+
host. It is sent only to a Zen endpoint and never logged.
|
|
788
|
+
"""
|
|
458
789
|
try:
|
|
459
790
|
image = await _prepare_input(path)
|
|
460
|
-
|
|
791
|
+
except Exception as exc:
|
|
792
|
+
return _error_message(exc)
|
|
793
|
+
key, _endpoint = _resolve_auth()
|
|
794
|
+
if not key:
|
|
795
|
+
return _local_vision_text(image)
|
|
796
|
+
try:
|
|
797
|
+
return await _gateway(image, prompt or _DESCRIBE_PROMPT, session_id=session_id)
|
|
461
798
|
except Exception as exc:
|
|
462
799
|
return _error_message(exc)
|
|
463
800
|
|
|
464
801
|
|
|
465
802
|
@mcp.tool(description="Extract visible text from an image with OCR.")
|
|
466
|
-
async def vision_ocr(path: str) -> str:
|
|
467
|
-
"""Transcribe visible image text while preserving line formatting.
|
|
803
|
+
async def vision_ocr(path: str, session_id: str | None = None) -> str:
|
|
804
|
+
"""Transcribe visible image text while preserving line formatting.
|
|
805
|
+
|
|
806
|
+
Falls back to local, best-effort OCR (metadata + pytesseract when installed)
|
|
807
|
+
when no gateway credential is configured. ``session_id`` reaches the Zen
|
|
808
|
+
gateway as the ``x-opencode-session`` header (see ``vision_describe``).
|
|
809
|
+
"""
|
|
468
810
|
try:
|
|
469
811
|
image = await _prepare_input(path)
|
|
470
|
-
|
|
812
|
+
except Exception as exc:
|
|
813
|
+
return _error_message(exc)
|
|
814
|
+
key, _endpoint = _resolve_auth()
|
|
815
|
+
if not key:
|
|
816
|
+
return _local_vision_text(image)
|
|
817
|
+
try:
|
|
818
|
+
return await _gateway(image, _OCR_PROMPT, session_id=session_id)
|
|
471
819
|
except Exception as exc:
|
|
472
820
|
return _error_message(exc)
|
|
473
821
|
|
|
474
822
|
|
|
475
|
-
@mcp.tool(
|
|
476
|
-
|
|
477
|
-
|
|
823
|
+
@mcp.tool(
|
|
824
|
+
description="Analyze an image and return metadata, description, and OCR as JSON."
|
|
825
|
+
)
|
|
826
|
+
async def vision_analyze(path: str, session_id: str | None = None) -> str:
|
|
827
|
+
"""Return local metadata plus one structured description/OCR gateway call.
|
|
828
|
+
|
|
829
|
+
``session_id`` reaches the Zen gateway as the ``x-opencode-session`` header
|
|
830
|
+
(see ``vision_describe``).
|
|
831
|
+
"""
|
|
478
832
|
try:
|
|
479
833
|
image = await _prepare_input(path)
|
|
480
|
-
content = await _gateway(
|
|
834
|
+
content = await _gateway(
|
|
835
|
+
image, _ANALYZE_PROMPT, structured=True, session_id=session_id
|
|
836
|
+
)
|
|
481
837
|
parsed = _parse_json_object(content)
|
|
482
838
|
if parsed is None:
|
|
483
839
|
parsed = {"description": content, "ocr": "No structured OCR was returned."}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Pantheon MCP runtime dependencies — EXACT pins (issue #21)
|
|
2
|
-
#
|
|
3
|
-
# Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml|
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
# Coordinated MCP SDK/FastMCP major migration (Dependabot PRs #140 and #188).
|
|
3
|
+
# Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml|httpx)=='
|
|
4
|
+
# Memory search is FTS5 (SQLite stdlib): sqlite-vec and fastembed were removed
|
|
5
|
+
# with the vector pipeline — the server no longer imports either.
|
|
6
|
+
mcp==2.2.0
|
|
7
|
+
fastmcp==4.0.10
|
|
8
|
+
pydantic==2.12.5
|
|
9
|
+
pydantic-settings==2.14.2
|
|
6
10
|
pyyaml==6.0.3
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
# Vision imports httpx; MCP 2.2.0 provides httpx2, a different package.
|
|
12
|
+
httpx==0.28.1
|