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.
Files changed (63) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +112 -15
  3. package/bin/pantheon-init.mjs +93 -0
  4. package/commands/pantheon-vision.md +67 -0
  5. package/opencode.json +14 -10
  6. package/package.json +20 -4
  7. package/scripts/_pantheon_paths.py +0 -2
  8. package/scripts/changelog-extract.mjs +112 -0
  9. package/scripts/doctor.mjs +307 -15
  10. package/scripts/hooks/audit-imports.sh +43 -0
  11. package/scripts/hooks/format-multi-language.sh +96 -0
  12. package/scripts/hooks/log-session-start.sh +24 -0
  13. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  14. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  15. package/scripts/hooks/run-type-check.sh +21 -0
  16. package/scripts/hooks/scan-secrets.sh +55 -0
  17. package/scripts/hooks/validate-post-conditions.sh +21 -0
  18. package/scripts/hooks/validate-talos-scope.sh +43 -0
  19. package/scripts/hooks/validate-tool-safety.sh +46 -0
  20. package/scripts/install/health-check.mjs +101 -1
  21. package/scripts/install/model-picker.mjs +118 -0
  22. package/scripts/install/opencode.mjs +155 -43
  23. package/scripts/install/plugin.mjs +4 -2
  24. package/scripts/install/venv.mjs +25 -2
  25. package/scripts/install-mcp.mjs +170 -37
  26. package/scripts/mcp_persistence_server.py +2 -1
  27. package/scripts/prune.mjs +359 -0
  28. package/scripts/secret-scan.mjs +88 -0
  29. package/scripts/uninstall.mjs +3 -3
  30. package/scripts/validate-routing.mjs +15 -0
  31. package/scripts/version-check.mjs +146 -0
  32. package/scripts/versioning.mjs +13 -14
  33. package/src/agents/hermes.md +2 -2
  34. package/src/agents/mnemosyne.md +1 -1
  35. package/src/agents/zeus.md +1 -1
  36. package/src/mcp/_pantheon_paths.py +0 -2
  37. package/src/mcp/install-mcp.mjs +6 -913
  38. package/src/mcp/mcp_persistence_server.py +2 -1
  39. package/src/mcp/pantheon_vision_server.py +495 -0
  40. package/src/mcp/requirements-mcp.txt +8 -5
  41. package/src/mcp/requirements-vision.txt +3 -0
  42. package/src/pantheon/auto-wake.ts +91 -0
  43. package/src/pantheon/background-job-board.ts +568 -0
  44. package/src/pantheon/file-persistence.ts +95 -0
  45. package/src/pantheon/presets.d.mts +108 -0
  46. package/src/pantheon/presets.mjs +554 -0
  47. package/src/pantheon/vision.ts +1437 -0
  48. package/src/plugin.ts +69 -25
  49. package/src/plugins/hook-runner.ts +186 -0
  50. package/src/plugins/pantheon-hooks.ts +153 -0
  51. package/src/plugins/tui/dist/tui.d.ts +1 -1
  52. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  53. package/src/plugins/tui/dist/tui.js +812 -223
  54. package/src/plugins/tui/dist/tui.js.map +1 -1
  55. package/src/plugins/tui/dist/tui.tsx +881 -121
  56. package/src/plugins/tui/package.json +3 -3
  57. package/src/plugins/tui/src/index.tsx +881 -121
  58. package/src/routing.yml +148 -0
  59. package/src/skills/context-compression/SKILL.md +1 -1
  60. package/src/skills/loop-engineering/SKILL.md +1 -1
  61. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  62. package/scripts/scrub_secrets.py +0 -281
  63. 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
- mcp>=1.28.0
2
- fastmcp>=3.4.0
3
- pyyaml>=6.0
4
- sqlite-vec>=0.1.7
5
- fastembed>=0.5.0
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,3 @@
1
+ mcp==1.29.0
2
+ fastmcp==3.4.6
3
+ httpx==0.28.1
@@ -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
+ }