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
|
@@ -15,6 +15,7 @@ import json
|
|
|
15
15
|
import logging
|
|
16
16
|
import mimetypes
|
|
17
17
|
import os
|
|
18
|
+
import re
|
|
18
19
|
import sqlite3
|
|
19
20
|
import struct
|
|
20
21
|
import sys
|
|
@@ -24,7 +25,7 @@ from typing import Any
|
|
|
24
25
|
from urllib.parse import unquote, urlparse
|
|
25
26
|
|
|
26
27
|
import httpx
|
|
27
|
-
from
|
|
28
|
+
from fastmcp import FastMCP
|
|
28
29
|
|
|
29
30
|
mcp = FastMCP("pantheon-vision")
|
|
30
31
|
|
|
@@ -59,9 +60,24 @@ if not _logger.handlers:
|
|
|
59
60
|
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
|
60
61
|
REQUEST_TIMEOUT_SECONDS = 20.0
|
|
61
62
|
HTTP_ERROR_STATUS = 400
|
|
63
|
+
HTTP_BAD_REQUEST = 400
|
|
64
|
+
HTTP_UNAUTHORIZED = 401
|
|
65
|
+
HTTP_FORBIDDEN = 403
|
|
62
66
|
DEFAULT_MODEL = "opencode-go/mimo-v2.5"
|
|
63
67
|
GO_ENDPOINT = "https://opencode.ai/zen/go/v1/chat/completions"
|
|
64
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._:-]+")
|
|
65
81
|
_ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
|
|
66
82
|
_EXTENSION_MIME_TYPES = {
|
|
67
83
|
".png": "image/png",
|
|
@@ -269,6 +285,36 @@ def _endpoint_for_model(model: str) -> str:
|
|
|
269
285
|
return OPENCODE_ENDPOINT if model.startswith("opencode/") else GO_ENDPOINT
|
|
270
286
|
|
|
271
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
|
+
|
|
272
318
|
def _strip_provider_prefix(model: str) -> str:
|
|
273
319
|
"""Return the model ID without its provider prefix.
|
|
274
320
|
|
|
@@ -283,19 +329,39 @@ def _strip_provider_prefix(model: str) -> str:
|
|
|
283
329
|
|
|
284
330
|
|
|
285
331
|
def _opencode_data_dir() -> Path:
|
|
286
|
-
"""Locate the OpenCode data directory on either V1 or V2 hosts.
|
|
287
|
-
|
|
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"
|
|
288
354
|
|
|
289
355
|
|
|
290
356
|
def _key_from_credential_db(provider: str) -> str | None:
|
|
291
357
|
"""Read a provider key from the V2 SQLite credential store.
|
|
292
358
|
|
|
293
359
|
OpenCode V2 moved credentials out of ``<data dir>/auth.json`` and into a
|
|
294
|
-
``credential`` table in
|
|
295
|
-
|
|
296
|
-
unresolvable. The value is
|
|
297
|
-
``{"type": "key", "key": "..."}``, so the
|
|
298
|
-
dependency.
|
|
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.
|
|
299
365
|
|
|
300
366
|
The database is opened read-only via a URI so a missing or locked file
|
|
301
367
|
cannot be created or mutated by a health check.
|
|
@@ -306,7 +372,7 @@ def _key_from_credential_db(provider: str) -> str | None:
|
|
|
306
372
|
Returns:
|
|
307
373
|
The stored key, or None when the store, table, row, or key is absent.
|
|
308
374
|
"""
|
|
309
|
-
db_path =
|
|
375
|
+
db_path = _credential_db_path()
|
|
310
376
|
if not db_path.is_file():
|
|
311
377
|
return None
|
|
312
378
|
uri = f"file:{db_path}?mode=ro"
|
|
@@ -343,9 +409,9 @@ def _resolve_auth() -> tuple[str | None, str]:
|
|
|
343
409
|
4. a legacy V1 ``<data dir>/auth.json`` file, for hosts that still have one
|
|
344
410
|
|
|
345
411
|
The V1 file is checked last and only when it exists; it is absent on a V2
|
|
346
|
-
host. When no source yields a key this returns None
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
349
415
|
"""
|
|
350
416
|
model = os.getenv("PANTHEON_VISION_MODEL") or DEFAULT_MODEL
|
|
351
417
|
env_key = os.getenv("PANTHEON_OPENCODE_API_KEY") or os.getenv("OPENCODE_API_KEY")
|
|
@@ -375,14 +441,6 @@ def _resolve_auth() -> tuple[str | None, str]:
|
|
|
375
441
|
if key:
|
|
376
442
|
return key, endpoint
|
|
377
443
|
|
|
378
|
-
_logger.error(
|
|
379
|
-
"No OpenCode credential found. Set PANTHEON_OPENCODE_API_KEY (or "
|
|
380
|
-
"OPENCODE_API_KEY), or store a key for 'opencode-go' in %s "
|
|
381
|
-
"(tried: env, the credential table in %s, and the legacy %s).",
|
|
382
|
-
_opencode_data_dir() / "opencode.db",
|
|
383
|
-
_opencode_data_dir() / "opencode.db",
|
|
384
|
-
auth_path,
|
|
385
|
-
)
|
|
386
444
|
return None, _endpoint_for_model(model)
|
|
387
445
|
|
|
388
446
|
|
|
@@ -405,6 +463,86 @@ def _metadata(image: ImageInput) -> dict[str, Any]:
|
|
|
405
463
|
}
|
|
406
464
|
|
|
407
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
|
+
|
|
408
546
|
def _content_text(value: Any) -> str:
|
|
409
547
|
"""Extract text from OpenAI-compatible message content."""
|
|
410
548
|
if isinstance(value, str):
|
|
@@ -465,14 +603,66 @@ def _parse_json_object(value: str) -> dict[str, Any] | None:
|
|
|
465
603
|
return None
|
|
466
604
|
|
|
467
605
|
|
|
468
|
-
|
|
469
|
-
"""
|
|
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
|
+
"""
|
|
470
647
|
key, endpoint = _resolve_auth()
|
|
471
648
|
if not key:
|
|
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
|
+
)
|
|
472
662
|
raise VisionError(
|
|
473
|
-
"OpenCode
|
|
474
|
-
"OPENCODE_API_KEY, or store a key
|
|
475
|
-
"credential database (
|
|
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}). "
|
|
476
666
|
"See the pantheon.vision error log for the paths tried."
|
|
477
667
|
)
|
|
478
668
|
safe_prompt = prompt.replace(key, "[redacted]")
|
|
@@ -486,21 +676,25 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
486
676
|
}
|
|
487
677
|
if structured:
|
|
488
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
|
|
489
686
|
try:
|
|
490
687
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
|
|
491
688
|
response = await client.post(
|
|
492
689
|
endpoint,
|
|
493
|
-
headers=
|
|
494
|
-
"Authorization": f"Bearer {key}",
|
|
495
|
-
"Content-Type": "application/json",
|
|
496
|
-
},
|
|
690
|
+
headers=headers,
|
|
497
691
|
json=body,
|
|
498
692
|
)
|
|
499
693
|
if response.status_code >= HTTP_ERROR_STATUS:
|
|
500
694
|
# Sanitized diagnostic: status, endpoint, bare model, truncated
|
|
501
695
|
# gateway detail (scrubbed), and a body summary with no keys or
|
|
502
696
|
# image bytes. Logged to stderr so the MCP stdio protocol is
|
|
503
|
-
# never corrupted.
|
|
697
|
+
# never corrupted. The session id is NEVER logged.
|
|
504
698
|
detail = _scrub(str(getattr(response, "text", "")), key)
|
|
505
699
|
_logger.warning(
|
|
506
700
|
"vision gateway error status=%s endpoint=%s model=%s detail=%s body=%s",
|
|
@@ -510,15 +704,30 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
510
704
|
detail[:200] or "no detail",
|
|
511
705
|
json.dumps(_sanitized_gateway_body(body)),
|
|
512
706
|
)
|
|
513
|
-
raise VisionError(
|
|
707
|
+
raise VisionError(_gateway_http_error(response.status_code))
|
|
514
708
|
payload = response.json()
|
|
515
709
|
choices = payload.get("choices") if isinstance(payload, dict) else None
|
|
516
|
-
|
|
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
|
+
)
|
|
517
714
|
text = _content_text(
|
|
518
715
|
message.get("content") if isinstance(message, dict) else ""
|
|
519
716
|
)
|
|
520
717
|
if not text:
|
|
521
|
-
|
|
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}.")
|
|
522
731
|
return _scrub(text, key)
|
|
523
732
|
except VisionError:
|
|
524
733
|
raise
|
|
@@ -563,21 +772,50 @@ _ANALYZE_PROMPT = (
|
|
|
563
772
|
|
|
564
773
|
|
|
565
774
|
@mcp.tool(description="Describe an image faithfully using the Pantheon vision gateway.")
|
|
566
|
-
async def vision_describe(
|
|
567
|
-
|
|
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
|
+
"""
|
|
568
789
|
try:
|
|
569
790
|
image = await _prepare_input(path)
|
|
570
|
-
|
|
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)
|
|
571
798
|
except Exception as exc:
|
|
572
799
|
return _error_message(exc)
|
|
573
800
|
|
|
574
801
|
|
|
575
802
|
@mcp.tool(description="Extract visible text from an image with OCR.")
|
|
576
|
-
async def vision_ocr(path: str) -> str:
|
|
577
|
-
"""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
|
+
"""
|
|
578
810
|
try:
|
|
579
811
|
image = await _prepare_input(path)
|
|
580
|
-
|
|
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)
|
|
581
819
|
except Exception as exc:
|
|
582
820
|
return _error_message(exc)
|
|
583
821
|
|
|
@@ -585,11 +823,17 @@ async def vision_ocr(path: str) -> str:
|
|
|
585
823
|
@mcp.tool(
|
|
586
824
|
description="Analyze an image and return metadata, description, and OCR as JSON."
|
|
587
825
|
)
|
|
588
|
-
async def vision_analyze(path: str) -> str:
|
|
589
|
-
"""Return local metadata plus one structured description/OCR gateway call.
|
|
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
|
+
"""
|
|
590
832
|
try:
|
|
591
833
|
image = await _prepare_input(path)
|
|
592
|
-
content = await _gateway(
|
|
834
|
+
content = await _gateway(
|
|
835
|
+
image, _ANALYZE_PROMPT, structured=True, session_id=session_id
|
|
836
|
+
)
|
|
593
837
|
parsed = _parse_json_object(content)
|
|
594
838
|
if parsed is None:
|
|
595
839
|
parsed = {"description": content, "ocr": "No structured OCR was returned."}
|
|
@@ -1,10 +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)'
|
|
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
4
|
# Memory search is FTS5 (SQLite stdlib): sqlite-vec and fastembed were removed
|
|
5
5
|
# with the vector pipeline — the server no longer imports either.
|
|
6
|
-
mcp==
|
|
7
|
-
fastmcp==
|
|
6
|
+
mcp==2.2.0
|
|
7
|
+
fastmcp==4.0.10
|
|
8
8
|
pydantic==2.12.5
|
|
9
9
|
pydantic-settings==2.14.2
|
|
10
10
|
pyyaml==6.0.3
|
|
11
|
+
# Vision imports httpx; MCP 2.2.0 provides httpx2, a different package.
|
|
12
|
+
httpx==0.28.1
|