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.
Files changed (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. 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 mcp.server.fastmcp import FastMCP
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("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
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 auth-store order."""
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
- auth_path = Path.home() / ".local" / "share" / "opencode" / "auth.json"
292
- try:
293
- auth = json.loads(auth_path.read_text(encoding="utf-8"))
294
- except (OSError, json.JSONDecodeError, TypeError):
295
- auth = {}
296
- if isinstance(auth, dict):
297
- for provider, endpoint in (("opencode-go", GO_ENDPOINT), ("opencode", OPENCODE_ENDPOINT)):
298
- key = _extract_key(auth.get(provider))
299
- if key:
300
- return key, endpoint
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 = messages[0].get("content") if isinstance(messages, list) and messages else None
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({"type": "text", "chars": len(text) if isinstance(text, str) else 0})
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
- async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False) -> str:
380
- """Send one multimodal request to the selected OpenCode gateway."""
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
- raise VisionError("OpenCode API key not configured.")
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={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
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("Vision gateway returned an HTTP error.")
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
- message = choices[0].get("message") if choices else None
420
- text = _content_text(message.get("content") if isinstance(message, dict) else "")
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
- raise VisionError("Vision gateway returned an empty response.")
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 (httpx.HTTPError, json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
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 f"Error: {exc}" if isinstance(exc, VisionError) else "Error: Vision request failed."
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. If there is no "
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(path: str, prompt: str | None = None) -> str:
457
- """Describe image content, text, layout, objects, and uncertainty."""
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
- return await _gateway(image, prompt or _DESCRIBE_PROMPT)
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
- return await _gateway(image, _OCR_PROMPT)
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(description="Analyze an image and return metadata, description, and OCR as JSON.")
476
- async def vision_analyze(path: str) -> str:
477
- """Return local metadata plus one structured description/OCR gateway call."""
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(image, _ANALYZE_PROMPT, structured=True)
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
- # Pinned to the versions validated in the sandbox venv (2026-08-05).
3
- # Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml|sqlite-vec|fastembed)'
4
- mcp==1.29.0
5
- fastmcp==3.4.6
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
- sqlite-vec==0.1.9
8
- fastembed==0.8.0
11
+ # Vision imports httpx; MCP 2.2.0 provides httpx2, a different package.
12
+ httpx==0.28.1
@@ -1,3 +1,5 @@
1
- mcp==1.29.0
2
- fastmcp==3.4.6
1
+ mcp==2.2.0
2
+ fastmcp==4.0.10
3
+ pydantic==2.12.5
4
+ pydantic-settings==2.14.2
3
5
  httpx==0.28.1