pantheon-opencode 1.2.0 → 1.2.2
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.md +9 -0
- package/README.md +112 -15
- package/bin/pantheon-init.mjs +93 -0
- package/commands/pantheon-vision.md +67 -0
- package/opencode.json +14 -10
- package/package.json +20 -4
- package/scripts/_pantheon_paths.py +0 -2
- package/scripts/changelog-extract.mjs +112 -0
- package/scripts/doctor.mjs +307 -15
- package/scripts/hooks/audit-imports.sh +43 -0
- package/scripts/hooks/format-multi-language.sh +96 -0
- package/scripts/hooks/log-session-start.sh +24 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
- package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
- package/scripts/hooks/run-type-check.sh +21 -0
- package/scripts/hooks/scan-secrets.sh +55 -0
- package/scripts/hooks/validate-post-conditions.sh +21 -0
- package/scripts/hooks/validate-talos-scope.sh +43 -0
- package/scripts/hooks/validate-tool-safety.sh +46 -0
- package/scripts/install/health-check.mjs +101 -1
- package/scripts/install/model-picker.mjs +118 -0
- package/scripts/install/opencode.mjs +155 -43
- package/scripts/install/plugin.mjs +4 -2
- package/scripts/install/venv.mjs +25 -2
- package/scripts/install-mcp.mjs +170 -37
- package/scripts/mcp_persistence_server.py +2 -1
- package/scripts/prune.mjs +359 -0
- package/scripts/secret-scan.mjs +88 -0
- package/scripts/uninstall.mjs +3 -3
- package/scripts/validate-routing.mjs +15 -0
- package/scripts/version-check.mjs +146 -0
- package/scripts/versioning.mjs +13 -14
- package/src/agents/hermes.md +2 -2
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +1 -1
- package/src/mcp/_pantheon_paths.py +0 -2
- package/src/mcp/install-mcp.mjs +6 -913
- package/src/mcp/mcp_persistence_server.py +2 -1
- package/src/mcp/pantheon_vision_server.py +495 -0
- package/src/mcp/requirements-mcp.txt +8 -5
- package/src/mcp/requirements-vision.txt +3 -0
- package/src/pantheon/auto-wake.ts +91 -0
- package/src/pantheon/background-job-board.ts +568 -0
- package/src/pantheon/file-persistence.ts +95 -0
- package/src/pantheon/presets.d.mts +108 -0
- package/src/pantheon/presets.mjs +554 -0
- package/src/pantheon/vision.ts +1437 -0
- package/src/plugin.ts +69 -25
- package/src/plugins/hook-runner.ts +186 -0
- package/src/plugins/pantheon-hooks.ts +153 -0
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +812 -223
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +881 -121
- package/src/plugins/tui/package.json +3 -3
- package/src/plugins/tui/src/index.tsx +881 -121
- package/src/routing.yml +148 -0
- package/src/skills/context-compression/SKILL.md +1 -1
- package/src/skills/loop-engineering/SKILL.md +1 -1
- package/src/skills/orchestration-workflow/SKILL.md +11 -11
- package/scripts/scrub_secrets.py +0 -281
- package/src/mcp/requirements-mcp-core.txt +0 -5
|
@@ -14,6 +14,7 @@ import argparse
|
|
|
14
14
|
import re
|
|
15
15
|
import sqlite3
|
|
16
16
|
import sys
|
|
17
|
+
import uuid
|
|
17
18
|
from datetime import datetime, timedelta, timezone
|
|
18
19
|
from pathlib import Path
|
|
19
20
|
|
|
@@ -233,7 +234,7 @@ async def kv_store(
|
|
|
233
234
|
|
|
234
235
|
# Opportunistic auto-purge: if namespace has >500 entries, purge expired
|
|
235
236
|
_opportunistic_auto_purge(conn, namespace)
|
|
236
|
-
|
|
237
|
+
|
|
237
238
|
return {"status": "stored", "namespace": namespace, "key": key}
|
|
238
239
|
|
|
239
240
|
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small OpenCode-backed vision MCP server.
|
|
3
|
+
|
|
4
|
+
The server deliberately keeps image handling in the standard library. Local
|
|
5
|
+
images are turned into data URIs; remote images remain URLs so the gateway can
|
|
6
|
+
fetch them. No image, API key, or request payload is written to disk.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import binascii
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import mimetypes
|
|
17
|
+
import os
|
|
18
|
+
import struct
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import unquote, urlparse
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
from mcp.server.fastmcp import FastMCP
|
|
27
|
+
|
|
28
|
+
mcp = FastMCP("pantheon-vision")
|
|
29
|
+
|
|
30
|
+
# MCP talks JSON-RPC over stdout; all diagnostic logging must go to stderr so
|
|
31
|
+
# the stdio protocol never sees it.
|
|
32
|
+
_logger = logging.getLogger("pantheon.vision")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _LateStderr:
|
|
36
|
+
"""File-like object resolving ``sys.stderr`` at write time.
|
|
37
|
+
|
|
38
|
+
A plain ``logging.StreamHandler`` binds the import-time stderr, which
|
|
39
|
+
pytest's capsys (or any runtime that swaps stderr) cannot observe.
|
|
40
|
+
Resolving at emit time keeps diagnostics off stdout while remaining
|
|
41
|
+
observable and redirectable.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def write(self, message: str) -> int:
|
|
45
|
+
return sys.stderr.write(message)
|
|
46
|
+
|
|
47
|
+
def flush(self) -> None:
|
|
48
|
+
sys.stderr.flush()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if not _logger.handlers:
|
|
52
|
+
_handler = logging.StreamHandler(_LateStderr())
|
|
53
|
+
_handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
|
|
54
|
+
_logger.addHandler(_handler)
|
|
55
|
+
_logger.setLevel(logging.INFO)
|
|
56
|
+
_logger.propagate = False
|
|
57
|
+
|
|
58
|
+
MAX_IMAGE_BYTES = 25 * 1024 * 1024
|
|
59
|
+
REQUEST_TIMEOUT_SECONDS = 20.0
|
|
60
|
+
HTTP_ERROR_STATUS = 400
|
|
61
|
+
DEFAULT_MODEL = "opencode-go/mimo-v2.5"
|
|
62
|
+
GO_ENDPOINT = "https://opencode.ai/zen/go/v1/chat/completions"
|
|
63
|
+
OPENCODE_ENDPOINT = "https://opencode.ai/zen/v1/chat/completions"
|
|
64
|
+
_ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
|
|
65
|
+
_EXTENSION_MIME_TYPES = {
|
|
66
|
+
".png": "image/png",
|
|
67
|
+
".jpg": "image/jpeg",
|
|
68
|
+
".jpeg": "image/jpeg",
|
|
69
|
+
".webp": "image/webp",
|
|
70
|
+
".gif": "image/gif",
|
|
71
|
+
}
|
|
72
|
+
_JPEG_MARKER = 0xFF
|
|
73
|
+
_JPEG_MIN_SEGMENT = 2
|
|
74
|
+
_JPEG_SOF_MIN_SEGMENT = 7
|
|
75
|
+
_PNG_HEADER_END = 24
|
|
76
|
+
_GIF_HEADER_END = 10
|
|
77
|
+
_WEBP_VP8X_END = 30
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class VisionError(Exception):
|
|
81
|
+
"""Expected, user-facing vision error."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class ImageInput:
|
|
86
|
+
"""Validated image source and metadata available without Pillow."""
|
|
87
|
+
|
|
88
|
+
source: str
|
|
89
|
+
mime: str
|
|
90
|
+
size: int | None
|
|
91
|
+
width: int | None
|
|
92
|
+
height: int | None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _mime_from_bytes(data: bytes) -> str | None:
|
|
96
|
+
"""Identify one of the supported formats from its file signature."""
|
|
97
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
98
|
+
return "image/png"
|
|
99
|
+
if data.startswith(b"\xff\xd8\xff"):
|
|
100
|
+
return "image/jpeg"
|
|
101
|
+
if data.startswith((b"GIF87a", b"GIF89a")):
|
|
102
|
+
return "image/gif"
|
|
103
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
104
|
+
return "image/webp"
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mime_from_path(path: Path) -> str | None:
|
|
109
|
+
"""Return a supported MIME type inferred from a path extension."""
|
|
110
|
+
mime = _EXTENSION_MIME_TYPES.get(path.suffix.lower())
|
|
111
|
+
if mime:
|
|
112
|
+
return mime
|
|
113
|
+
guessed, _ = mimetypes.guess_type(path.name)
|
|
114
|
+
return guessed if guessed in _ALLOWED_MIME_TYPES else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _jpeg_dimensions(data: bytes) -> tuple[int | None, int | None]:
|
|
118
|
+
"""Read JPEG dimensions from a start-of-frame marker."""
|
|
119
|
+
if not data.startswith(b"\xff\xd8"):
|
|
120
|
+
return None, None
|
|
121
|
+
index = 2
|
|
122
|
+
sof_markers = set(range(0xC0, 0xC4)) | set(range(0xC5, 0xC8))
|
|
123
|
+
sof_markers |= set(range(0xC9, 0xCC)) | set(range(0xCD, 0xD0))
|
|
124
|
+
try:
|
|
125
|
+
while index + 9 < len(data):
|
|
126
|
+
if data[index] != _JPEG_MARKER:
|
|
127
|
+
index += 1
|
|
128
|
+
continue
|
|
129
|
+
while index < len(data) and data[index] == _JPEG_MARKER:
|
|
130
|
+
index += 1
|
|
131
|
+
marker = data[index]
|
|
132
|
+
index += 1
|
|
133
|
+
if marker in (0xD8, 0xD9):
|
|
134
|
+
continue
|
|
135
|
+
if index + 2 > len(data):
|
|
136
|
+
break
|
|
137
|
+
segment_length = int.from_bytes(data[index : index + 2], "big")
|
|
138
|
+
if segment_length < _JPEG_MIN_SEGMENT or index + segment_length > len(data):
|
|
139
|
+
break
|
|
140
|
+
if marker in sof_markers and segment_length >= _JPEG_SOF_MIN_SEGMENT:
|
|
141
|
+
height = int.from_bytes(data[index + 3 : index + 5], "big")
|
|
142
|
+
width = int.from_bytes(data[index + 5 : index + 7], "big")
|
|
143
|
+
return width, height
|
|
144
|
+
index += segment_length
|
|
145
|
+
except (IndexError, struct.error, ValueError):
|
|
146
|
+
pass
|
|
147
|
+
return None, None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _dimensions(data: bytes, mime: str) -> tuple[int | None, int | None]:
|
|
151
|
+
"""Read dimensions for PNG/JPEG/WebP/GIF without a third-party decoder."""
|
|
152
|
+
if mime == "image/png" and len(data) >= _PNG_HEADER_END:
|
|
153
|
+
return struct.unpack(">II", data[16:_PNG_HEADER_END])
|
|
154
|
+
if mime == "image/gif" and len(data) >= _GIF_HEADER_END:
|
|
155
|
+
return struct.unpack("<HH", data[6:_GIF_HEADER_END])
|
|
156
|
+
if mime == "image/webp" and len(data) >= _WEBP_VP8X_END and data[12:16] == b"VP8X":
|
|
157
|
+
width = 1 + int.from_bytes(data[24:27], "little")
|
|
158
|
+
height = 1 + int.from_bytes(data[27:30], "little")
|
|
159
|
+
return width, height
|
|
160
|
+
if mime == "image/jpeg":
|
|
161
|
+
return _jpeg_dimensions(data)
|
|
162
|
+
return None, None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _read_local(path: Path) -> bytes:
|
|
166
|
+
"""Read a local file after applying the size cap."""
|
|
167
|
+
try:
|
|
168
|
+
size = path.stat().st_size
|
|
169
|
+
except FileNotFoundError as exc:
|
|
170
|
+
raise VisionError("Image file not found.") from exc
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
raise VisionError("Image file cannot be accessed.") from exc
|
|
173
|
+
if not path.is_file():
|
|
174
|
+
raise VisionError("Image path is not a file.")
|
|
175
|
+
if size > MAX_IMAGE_BYTES:
|
|
176
|
+
raise VisionError("Image exceeds the 25 MB size limit.")
|
|
177
|
+
try:
|
|
178
|
+
data = path.read_bytes()
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
raise VisionError("Image file cannot be read.") from exc
|
|
181
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
182
|
+
raise VisionError("Image exceeds the 25 MB size limit.")
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _data_uri_input(value: str) -> ImageInput:
|
|
187
|
+
"""Validate and decode a base64 image data URI without persisting it."""
|
|
188
|
+
header, separator, encoded = value.partition(",")
|
|
189
|
+
if not separator or not header.lower().startswith("data:"):
|
|
190
|
+
raise VisionError("Invalid image data URI.")
|
|
191
|
+
pieces = header[5:].split(";")
|
|
192
|
+
mime = pieces[0].lower()
|
|
193
|
+
if mime not in _ALLOWED_MIME_TYPES:
|
|
194
|
+
raise VisionError("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
|
|
195
|
+
if "base64" not in {piece.lower() for piece in pieces[1:]}:
|
|
196
|
+
raise VisionError("Only base64 image data URIs are supported.")
|
|
197
|
+
if len(encoded) > ((MAX_IMAGE_BYTES + 2) // 3) * 4:
|
|
198
|
+
raise VisionError("Image exceeds the 25 MB size limit.")
|
|
199
|
+
try:
|
|
200
|
+
data = base64.b64decode(encoded, validate=True)
|
|
201
|
+
except (ValueError, binascii.Error) as exc:
|
|
202
|
+
raise VisionError("Invalid base64 image data.") from exc
|
|
203
|
+
if len(data) > MAX_IMAGE_BYTES:
|
|
204
|
+
raise VisionError("Image exceeds the 25 MB size limit.")
|
|
205
|
+
width, height = _dimensions(data, mime)
|
|
206
|
+
return ImageInput(value, mime, len(data), width, height)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def _prepare_input(value: str) -> ImageInput:
|
|
210
|
+
"""Accept local paths, file URIs, data URIs, and remote HTTP URLs."""
|
|
211
|
+
if not value or not value.strip():
|
|
212
|
+
raise VisionError("Image path or URL cannot be empty.")
|
|
213
|
+
value = value.strip()
|
|
214
|
+
if value.lower().startswith("data:"):
|
|
215
|
+
return _data_uri_input(value)
|
|
216
|
+
|
|
217
|
+
parsed = urlparse(value)
|
|
218
|
+
if parsed.scheme in {"http", "https"}:
|
|
219
|
+
if not parsed.netloc:
|
|
220
|
+
raise VisionError("Invalid image URL.")
|
|
221
|
+
mime = _mime_from_path(Path(parsed.path))
|
|
222
|
+
if mime is None and Path(parsed.path).suffix:
|
|
223
|
+
raise VisionError("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
|
|
224
|
+
# The remote server owns the bytes. The gateway receives the URL and
|
|
225
|
+
# enforces its own fetch limits; unknown extensions remain possible.
|
|
226
|
+
return ImageInput(value, mime or "image/jpeg", None, None, None)
|
|
227
|
+
if parsed.scheme == "file":
|
|
228
|
+
local_path = Path(unquote(parsed.path))
|
|
229
|
+
elif parsed.scheme:
|
|
230
|
+
raise VisionError("Only file, HTTP, and HTTPS image sources are supported.")
|
|
231
|
+
else:
|
|
232
|
+
local_path = Path(value).expanduser()
|
|
233
|
+
data = await asyncio.to_thread(_read_local, local_path)
|
|
234
|
+
detected_mime = _mime_from_bytes(data)
|
|
235
|
+
extension_mime = _mime_from_path(local_path)
|
|
236
|
+
mime = detected_mime or extension_mime
|
|
237
|
+
if mime not in _ALLOWED_MIME_TYPES or detected_mime is None:
|
|
238
|
+
raise VisionError("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
|
|
239
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
240
|
+
width, height = _dimensions(data, mime)
|
|
241
|
+
return ImageInput(
|
|
242
|
+
f"data:{mime};base64,{encoded}", mime, len(data), width, height
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _extract_key(value: Any) -> str | None:
|
|
247
|
+
"""Extract a key from the known OpenCode auth store shapes."""
|
|
248
|
+
if isinstance(value, str) and value:
|
|
249
|
+
return value
|
|
250
|
+
if isinstance(value, dict):
|
|
251
|
+
for field in (
|
|
252
|
+
"key",
|
|
253
|
+
"apiKey",
|
|
254
|
+
"api_key",
|
|
255
|
+
"token",
|
|
256
|
+
"accessToken",
|
|
257
|
+
"access_token",
|
|
258
|
+
"access",
|
|
259
|
+
):
|
|
260
|
+
candidate = value.get(field)
|
|
261
|
+
if isinstance(candidate, str) and candidate:
|
|
262
|
+
return candidate
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _endpoint_for_model(model: str) -> str:
|
|
267
|
+
"""Select the OpenCode endpoint from a model provider prefix."""
|
|
268
|
+
return OPENCODE_ENDPOINT if model.startswith("opencode/") else GO_ENDPOINT
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _strip_provider_prefix(model: str) -> str:
|
|
272
|
+
"""Return the model ID without its provider prefix.
|
|
273
|
+
|
|
274
|
+
The Zen gateway already encodes the provider in the endpoint URL (see
|
|
275
|
+
``_endpoint_for_model``); the /chat/completions payload must carry the bare
|
|
276
|
+
model name, e.g. ``opencode-go/mimo-v2.5`` → ``mimo-v2.5``. A qualified
|
|
277
|
+
model ID is rejected by the gateway with a 401 ("Model ... is not
|
|
278
|
+
supported"). Models without a ``/`` are returned unchanged.
|
|
279
|
+
"""
|
|
280
|
+
prefix, separator, model_name = model.rpartition("/")
|
|
281
|
+
return model_name if separator else model
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _resolve_auth() -> tuple[str | None, str]:
|
|
285
|
+
"""Resolve auth in env-first, then OpenCode auth-store order."""
|
|
286
|
+
model = os.getenv("PANTHEON_VISION_MODEL") or DEFAULT_MODEL
|
|
287
|
+
env_key = os.getenv("PANTHEON_OPENCODE_API_KEY") or os.getenv("OPENCODE_API_KEY")
|
|
288
|
+
if env_key:
|
|
289
|
+
return env_key, _endpoint_for_model(model)
|
|
290
|
+
|
|
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
|
|
301
|
+
return None, _endpoint_for_model(model)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _model() -> str:
|
|
305
|
+
"""Return the configured model without exposing configuration secrets."""
|
|
306
|
+
return os.getenv("PANTHEON_VISION_MODEL") or DEFAULT_MODEL
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _metadata(image: ImageInput) -> dict[str, Any]:
|
|
310
|
+
"""Build stable, JSON-friendly local/remote image metadata."""
|
|
311
|
+
dimensions = None
|
|
312
|
+
if image.width is not None and image.height is not None:
|
|
313
|
+
dimensions = {"width": image.width, "height": image.height}
|
|
314
|
+
return {
|
|
315
|
+
"mime": image.mime,
|
|
316
|
+
"size": image.size,
|
|
317
|
+
"width": image.width,
|
|
318
|
+
"height": image.height,
|
|
319
|
+
"dimensions": dimensions,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _content_text(value: Any) -> str:
|
|
324
|
+
"""Extract text from OpenAI-compatible message content."""
|
|
325
|
+
if isinstance(value, str):
|
|
326
|
+
return value
|
|
327
|
+
if isinstance(value, list):
|
|
328
|
+
chunks = []
|
|
329
|
+
for item in value:
|
|
330
|
+
if isinstance(item, str):
|
|
331
|
+
chunks.append(item)
|
|
332
|
+
elif isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
333
|
+
chunks.append(item["text"])
|
|
334
|
+
return "".join(chunks)
|
|
335
|
+
return ""
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _scrub(value: str, key: str | None) -> str:
|
|
339
|
+
"""Prevent an accidentally echoed API key from reaching the caller."""
|
|
340
|
+
return value.replace(key, "[redacted]") if key else value
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _sanitized_gateway_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
344
|
+
"""Non-sensitive summary of a gateway request body (no keys, no image bytes)."""
|
|
345
|
+
messages = body.get("messages")
|
|
346
|
+
content = messages[0].get("content") if isinstance(messages, list) and messages else None
|
|
347
|
+
chunks = content if isinstance(content, list) else [content]
|
|
348
|
+
parts: list[dict[str, Any]] = []
|
|
349
|
+
for chunk in chunks:
|
|
350
|
+
if not isinstance(chunk, dict):
|
|
351
|
+
continue
|
|
352
|
+
if chunk.get("type") == "image_url":
|
|
353
|
+
parts.append({"type": "image_url"})
|
|
354
|
+
else:
|
|
355
|
+
text = chunk.get("text")
|
|
356
|
+
parts.append({"type": "text", "chars": len(text) if isinstance(text, str) else 0})
|
|
357
|
+
return {
|
|
358
|
+
"model": body.get("model"),
|
|
359
|
+
"response_format": body.get("response_format"),
|
|
360
|
+
"content": parts,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _parse_json_object(value: str) -> dict[str, Any] | None:
|
|
365
|
+
"""Parse strict JSON and the fenced JSON commonly returned by models."""
|
|
366
|
+
candidates = [value.strip()]
|
|
367
|
+
if candidates[0].startswith("```") and candidates[0].endswith("```"):
|
|
368
|
+
candidates.append(candidates[0].split("\n", 1)[-1][:-3].strip())
|
|
369
|
+
for candidate in candidates:
|
|
370
|
+
try:
|
|
371
|
+
parsed = json.loads(candidate)
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
continue
|
|
374
|
+
if isinstance(parsed, dict):
|
|
375
|
+
return parsed
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False) -> str:
|
|
380
|
+
"""Send one multimodal request to the selected OpenCode gateway."""
|
|
381
|
+
key, endpoint = _resolve_auth()
|
|
382
|
+
if not key:
|
|
383
|
+
raise VisionError("OpenCode API key not configured.")
|
|
384
|
+
safe_prompt = prompt.replace(key, "[redacted]")
|
|
385
|
+
content: list[dict[str, Any]] = [
|
|
386
|
+
{"type": "text", "text": safe_prompt},
|
|
387
|
+
{"type": "image_url", "image_url": {"url": image.source}},
|
|
388
|
+
]
|
|
389
|
+
body: dict[str, Any] = {
|
|
390
|
+
"model": _strip_provider_prefix(_model()),
|
|
391
|
+
"messages": [{"role": "user", "content": content}],
|
|
392
|
+
}
|
|
393
|
+
if structured:
|
|
394
|
+
body["response_format"] = {"type": "json_object"}
|
|
395
|
+
try:
|
|
396
|
+
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
|
|
397
|
+
response = await client.post(
|
|
398
|
+
endpoint,
|
|
399
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
400
|
+
json=body,
|
|
401
|
+
)
|
|
402
|
+
if response.status_code >= HTTP_ERROR_STATUS:
|
|
403
|
+
# Sanitized diagnostic: status, endpoint, bare model, truncated
|
|
404
|
+
# gateway detail (scrubbed), and a body summary with no keys or
|
|
405
|
+
# image bytes. Logged to stderr so the MCP stdio protocol is
|
|
406
|
+
# never corrupted.
|
|
407
|
+
detail = _scrub(str(getattr(response, "text", "")), key)
|
|
408
|
+
_logger.warning(
|
|
409
|
+
"vision gateway error status=%s endpoint=%s model=%s detail=%s body=%s",
|
|
410
|
+
response.status_code,
|
|
411
|
+
endpoint,
|
|
412
|
+
body.get("model"),
|
|
413
|
+
detail[:200] or "no detail",
|
|
414
|
+
json.dumps(_sanitized_gateway_body(body)),
|
|
415
|
+
)
|
|
416
|
+
raise VisionError("Vision gateway returned an HTTP error.")
|
|
417
|
+
payload = response.json()
|
|
418
|
+
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 "")
|
|
421
|
+
if not text:
|
|
422
|
+
raise VisionError("Vision gateway returned an empty response.")
|
|
423
|
+
return _scrub(text, key)
|
|
424
|
+
except VisionError:
|
|
425
|
+
raise
|
|
426
|
+
except (httpx.TimeoutException, TimeoutError) as exc:
|
|
427
|
+
raise VisionError("Vision gateway request timed out.") from exc
|
|
428
|
+
except (httpx.HTTPError, json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
|
|
429
|
+
raise VisionError("Could not read the vision gateway response.") from exc
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
raise VisionError("Vision gateway request failed.") from exc
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _error_message(exc: Exception) -> str:
|
|
435
|
+
"""Return a stable, non-sensitive tool error."""
|
|
436
|
+
return f"Error: {exc}" if isinstance(exc, VisionError) else "Error: Vision request failed."
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
_DESCRIBE_PROMPT = (
|
|
440
|
+
"Describe faithfully the image content, visible text, layout, and objects. "
|
|
441
|
+
"Mention uncertainty rather than guessing. Respond in clear prose."
|
|
442
|
+
)
|
|
443
|
+
_OCR_PROMPT = (
|
|
444
|
+
"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 "
|
|
446
|
+
"text, say clearly that no text was found. Do not describe the image."
|
|
447
|
+
)
|
|
448
|
+
_ANALYZE_PROMPT = (
|
|
449
|
+
"Analyze this image and return only a JSON object with string fields "
|
|
450
|
+
"description and ocr. Describe content, layout, objects, and uncertainty; "
|
|
451
|
+
"ocr must preserve visible text formatting or say no text was found."
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@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."""
|
|
458
|
+
try:
|
|
459
|
+
image = await _prepare_input(path)
|
|
460
|
+
return await _gateway(image, prompt or _DESCRIBE_PROMPT)
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
return _error_message(exc)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@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."""
|
|
468
|
+
try:
|
|
469
|
+
image = await _prepare_input(path)
|
|
470
|
+
return await _gateway(image, _OCR_PROMPT)
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
return _error_message(exc)
|
|
473
|
+
|
|
474
|
+
|
|
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."""
|
|
478
|
+
try:
|
|
479
|
+
image = await _prepare_input(path)
|
|
480
|
+
content = await _gateway(image, _ANALYZE_PROMPT, structured=True)
|
|
481
|
+
parsed = _parse_json_object(content)
|
|
482
|
+
if parsed is None:
|
|
483
|
+
parsed = {"description": content, "ocr": "No structured OCR was returned."}
|
|
484
|
+
result = {
|
|
485
|
+
"metadata": _metadata(image),
|
|
486
|
+
"description": str(parsed.get("description", "")),
|
|
487
|
+
"ocr": str(parsed.get("ocr", "No text found.")),
|
|
488
|
+
}
|
|
489
|
+
return json.dumps(result, ensure_ascii=False)
|
|
490
|
+
except Exception as exc:
|
|
491
|
+
return _error_message(exc)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
if __name__ == "__main__":
|
|
495
|
+
mcp.run()
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
pyyaml
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
6
|
+
pyyaml==6.0.3
|
|
7
|
+
sqlite-vec==0.1.9
|
|
8
|
+
fastembed==0.8.0
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Wake Consumer — scans signal files from the BackgroundJobBoard
|
|
3
|
+
* and returns completed job signals for Zeus to process.
|
|
4
|
+
*
|
|
5
|
+
* Signal files are created by the board on every terminal state transition
|
|
6
|
+
* (completed/error/cancelled). This module consumes them atomically by
|
|
7
|
+
* reading the `.signal.json` content, then renaming to `.consumed.json`,
|
|
8
|
+
* preventing race conditions where the same signal is processed twice.
|
|
9
|
+
*
|
|
10
|
+
* The rename is the commit point: if Zeus crashes between the read and the
|
|
11
|
+
* rename, the `.signal.json` file remains and will be re-processed on restart.
|
|
12
|
+
* This gives **at-least-once** delivery, which is acceptable because the board's
|
|
13
|
+
* `markReconciled()` is idempotent.
|
|
14
|
+
*
|
|
15
|
+
* @module auto-wake
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdir, readFile, rename } from 'node:fs/promises'
|
|
19
|
+
import { join } from 'node:path'
|
|
20
|
+
import { existsSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
/** Signal structure written by BackgroundJobBoard.writeSignal(). */
|
|
23
|
+
export interface WakeSignal {
|
|
24
|
+
taskID: string
|
|
25
|
+
alias: string
|
|
26
|
+
agent: string
|
|
27
|
+
state: string
|
|
28
|
+
summary: string | null
|
|
29
|
+
timestamp: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Default signal directory (matches BoardOptions.signalDir default in plugin.ts). */
|
|
33
|
+
const DEFAULT_SIGNAL_DIR = '.pantheon/deepwork/board-signals'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Scan the signal directory for `*.signal.json` files, atomically consume
|
|
37
|
+
* each by renaming to `*.consumed.json`, and return the parsed signals.
|
|
38
|
+
*
|
|
39
|
+
* Safe to call multiple times — already-consumed files (`.consumed.json`)
|
|
40
|
+
* are ignored, and the atomic rename prevents double-processing.
|
|
41
|
+
*
|
|
42
|
+
* The flow per signal file:
|
|
43
|
+
* 1. Read the `.signal.json` content
|
|
44
|
+
* 2. Rename `.signal.json` → `.consumed.json` (atomic: OS-level rename)
|
|
45
|
+
* 3. If rename succeeds, push the signal into the result
|
|
46
|
+
* 4. If rename fails (another process consumed it first), skip
|
|
47
|
+
*
|
|
48
|
+
* @param signalDir Directory to scan (default: `.pantheon/deepwork/board-signals`)
|
|
49
|
+
* @returns List of wake signals from newly consumed files
|
|
50
|
+
*/
|
|
51
|
+
export async function consumeWakeSignals(
|
|
52
|
+
signalDir?: string,
|
|
53
|
+
): Promise<WakeSignal[]> {
|
|
54
|
+
const dir = signalDir ?? DEFAULT_SIGNAL_DIR
|
|
55
|
+
|
|
56
|
+
// If the signal directory doesn't exist, there are no signals
|
|
57
|
+
if (!existsSync(dir)) {
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
62
|
+
const signals: WakeSignal[] = []
|
|
63
|
+
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isFile()) continue
|
|
66
|
+
if (!entry.name.endsWith('.signal.json')) continue
|
|
67
|
+
|
|
68
|
+
const signalPath = join(dir, entry.name)
|
|
69
|
+
const consumedName = entry.name.replace(/\.signal\.json$/, '.consumed.json')
|
|
70
|
+
const consumedPath = join(dir, consumedName)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Step 1: Read the file content
|
|
74
|
+
const content = await readFile(signalPath, 'utf-8')
|
|
75
|
+
const data = JSON.parse(content) as WakeSignal
|
|
76
|
+
|
|
77
|
+
// Step 2: Atomically rename — this is the commit point.
|
|
78
|
+
// If this fails, the .signal.json remains and we'll retry next call.
|
|
79
|
+
await rename(signalPath, consumedPath)
|
|
80
|
+
|
|
81
|
+
// Step 3: Only add to results after successful rename
|
|
82
|
+
signals.push(data)
|
|
83
|
+
} catch {
|
|
84
|
+
// Rename failed (another consumer got there first) or file is corrupted.
|
|
85
|
+
// Skip silently in either case.
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return signals
|
|
91
|
+
}
|