pantheon-opencode 1.5.2 → 1.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +17 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -15,6 +15,7 @@ import json
|
|
|
15
15
|
import logging
|
|
16
16
|
import mimetypes
|
|
17
17
|
import os
|
|
18
|
+
import sqlite3
|
|
18
19
|
import struct
|
|
19
20
|
import sys
|
|
20
21
|
from dataclasses import dataclass
|
|
@@ -220,7 +221,9 @@ async def _prepare_input(value: str) -> ImageInput:
|
|
|
220
221
|
raise VisionError("Invalid image URL.")
|
|
221
222
|
mime = _mime_from_path(Path(parsed.path))
|
|
222
223
|
if mime is None and Path(parsed.path).suffix:
|
|
223
|
-
raise VisionError(
|
|
224
|
+
raise VisionError(
|
|
225
|
+
"Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF."
|
|
226
|
+
)
|
|
224
227
|
# The remote server owns the bytes. The gateway receives the URL and
|
|
225
228
|
# enforces its own fetch limits; unknown extensions remain possible.
|
|
226
229
|
return ImageInput(value, mime or "image/jpeg", None, None, None)
|
|
@@ -238,9 +241,7 @@ async def _prepare_input(value: str) -> ImageInput:
|
|
|
238
241
|
raise VisionError("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
|
|
239
242
|
encoded = base64.b64encode(data).decode("ascii")
|
|
240
243
|
width, height = _dimensions(data, mime)
|
|
241
|
-
return ImageInput(
|
|
242
|
-
f"data:{mime};base64,{encoded}", mime, len(data), width, height
|
|
243
|
-
)
|
|
244
|
+
return ImageInput(f"data:{mime};base64,{encoded}", mime, len(data), width, height)
|
|
244
245
|
|
|
245
246
|
|
|
246
247
|
def _extract_key(value: Any) -> str | None:
|
|
@@ -281,23 +282,107 @@ def _strip_provider_prefix(model: str) -> str:
|
|
|
281
282
|
return model_name if separator else model
|
|
282
283
|
|
|
283
284
|
|
|
285
|
+
def _opencode_data_dir() -> Path:
|
|
286
|
+
"""Locate the OpenCode data directory on either V1 or V2 hosts."""
|
|
287
|
+
return Path.home() / ".local" / "share" / "opencode"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _key_from_credential_db(provider: str) -> str | None:
|
|
291
|
+
"""Read a provider key from the V2 SQLite credential store.
|
|
292
|
+
|
|
293
|
+
OpenCode V2 moved credentials out of ``<data dir>/auth.json`` and into a
|
|
294
|
+
``credential`` table in ``<data dir>/opencode.db``; the V1 JSON file no
|
|
295
|
+
longer exists on a V2 host, so reading it alone left auth silently
|
|
296
|
+
unresolvable. The value is plaintext JSON of the form
|
|
297
|
+
``{"type": "key", "key": "..."}``, so the stdlib is sufficient — no new
|
|
298
|
+
dependency.
|
|
299
|
+
|
|
300
|
+
The database is opened read-only via a URI so a missing or locked file
|
|
301
|
+
cannot be created or mutated by a health check.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
provider: Integration id to look up, e.g. ``"opencode-go"``.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
The stored key, or None when the store, table, row, or key is absent.
|
|
308
|
+
"""
|
|
309
|
+
db_path = _opencode_data_dir() / "opencode.db"
|
|
310
|
+
if not db_path.is_file():
|
|
311
|
+
return None
|
|
312
|
+
uri = f"file:{db_path}?mode=ro"
|
|
313
|
+
conn = None
|
|
314
|
+
try:
|
|
315
|
+
conn = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
316
|
+
row = conn.execute(
|
|
317
|
+
"SELECT value FROM credential "
|
|
318
|
+
"WHERE integration_id = ? AND active = 1 LIMIT 1",
|
|
319
|
+
(provider,),
|
|
320
|
+
).fetchone()
|
|
321
|
+
except (sqlite3.Error, ValueError):
|
|
322
|
+
_logger.warning("Could not read the OpenCode credential store at %s", db_path)
|
|
323
|
+
return None
|
|
324
|
+
finally:
|
|
325
|
+
if conn is not None:
|
|
326
|
+
conn.close()
|
|
327
|
+
if row is None:
|
|
328
|
+
return None
|
|
329
|
+
try:
|
|
330
|
+
return _extract_key(json.loads(row[0]))
|
|
331
|
+
except (json.JSONDecodeError, TypeError):
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
284
335
|
def _resolve_auth() -> tuple[str | None, str]:
|
|
285
|
-
"""Resolve auth in env-first, then OpenCode
|
|
336
|
+
"""Resolve auth in env-first, then OpenCode credential-store order.
|
|
337
|
+
|
|
338
|
+
Precedence, highest first:
|
|
339
|
+
|
|
340
|
+
1. ``PANTHEON_OPENCODE_API_KEY``
|
|
341
|
+
2. ``OPENCODE_API_KEY``
|
|
342
|
+
3. the V2 SQLite ``credential`` table (``opencode-go``, then ``opencode``)
|
|
343
|
+
4. a legacy V1 ``<data dir>/auth.json`` file, for hosts that still have one
|
|
344
|
+
|
|
345
|
+
The V1 file is checked last and only when it exists; it is absent on a V2
|
|
346
|
+
host. When no source yields a key this returns None and the caller raises,
|
|
347
|
+
so the failure names the paths and env vars that were tried rather than
|
|
348
|
+
surfacing as a confusing 401 from the gateway.
|
|
349
|
+
"""
|
|
286
350
|
model = os.getenv("PANTHEON_VISION_MODEL") or DEFAULT_MODEL
|
|
287
351
|
env_key = os.getenv("PANTHEON_OPENCODE_API_KEY") or os.getenv("OPENCODE_API_KEY")
|
|
288
352
|
if env_key:
|
|
289
353
|
return env_key, _endpoint_for_model(model)
|
|
290
354
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
355
|
+
for provider, endpoint in (
|
|
356
|
+
("opencode-go", GO_ENDPOINT),
|
|
357
|
+
("opencode", OPENCODE_ENDPOINT),
|
|
358
|
+
):
|
|
359
|
+
key = _key_from_credential_db(provider)
|
|
360
|
+
if key:
|
|
361
|
+
return key, endpoint
|
|
362
|
+
|
|
363
|
+
auth_path = _opencode_data_dir() / "auth.json"
|
|
364
|
+
if auth_path.is_file():
|
|
365
|
+
try:
|
|
366
|
+
auth = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
367
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
368
|
+
auth = {}
|
|
369
|
+
if isinstance(auth, dict):
|
|
370
|
+
for provider, endpoint in (
|
|
371
|
+
("opencode-go", GO_ENDPOINT),
|
|
372
|
+
("opencode", OPENCODE_ENDPOINT),
|
|
373
|
+
):
|
|
374
|
+
key = _extract_key(auth.get(provider))
|
|
375
|
+
if key:
|
|
376
|
+
return key, endpoint
|
|
377
|
+
|
|
378
|
+
_logger.error(
|
|
379
|
+
"No OpenCode credential found. Set PANTHEON_OPENCODE_API_KEY (or "
|
|
380
|
+
"OPENCODE_API_KEY), or store a key for 'opencode-go' in %s "
|
|
381
|
+
"(tried: env, the credential table in %s, and the legacy %s).",
|
|
382
|
+
_opencode_data_dir() / "opencode.db",
|
|
383
|
+
_opencode_data_dir() / "opencode.db",
|
|
384
|
+
auth_path,
|
|
385
|
+
)
|
|
301
386
|
return None, _endpoint_for_model(model)
|
|
302
387
|
|
|
303
388
|
|
|
@@ -343,7 +428,9 @@ def _scrub(value: str, key: str | None) -> str:
|
|
|
343
428
|
def _sanitized_gateway_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
344
429
|
"""Non-sensitive summary of a gateway request body (no keys, no image bytes)."""
|
|
345
430
|
messages = body.get("messages")
|
|
346
|
-
content =
|
|
431
|
+
content = (
|
|
432
|
+
messages[0].get("content") if isinstance(messages, list) and messages else None
|
|
433
|
+
)
|
|
347
434
|
chunks = content if isinstance(content, list) else [content]
|
|
348
435
|
parts: list[dict[str, Any]] = []
|
|
349
436
|
for chunk in chunks:
|
|
@@ -353,7 +440,9 @@ def _sanitized_gateway_body(body: dict[str, Any]) -> dict[str, Any]:
|
|
|
353
440
|
parts.append({"type": "image_url"})
|
|
354
441
|
else:
|
|
355
442
|
text = chunk.get("text")
|
|
356
|
-
parts.append(
|
|
443
|
+
parts.append(
|
|
444
|
+
{"type": "text", "chars": len(text) if isinstance(text, str) else 0}
|
|
445
|
+
)
|
|
357
446
|
return {
|
|
358
447
|
"model": body.get("model"),
|
|
359
448
|
"response_format": body.get("response_format"),
|
|
@@ -380,7 +469,12 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
380
469
|
"""Send one multimodal request to the selected OpenCode gateway."""
|
|
381
470
|
key, endpoint = _resolve_auth()
|
|
382
471
|
if not key:
|
|
383
|
-
raise VisionError(
|
|
472
|
+
raise VisionError(
|
|
473
|
+
"OpenCode API key not configured. Set PANTHEON_OPENCODE_API_KEY or "
|
|
474
|
+
"OPENCODE_API_KEY, or store a key for 'opencode-go' in the OpenCode "
|
|
475
|
+
"credential database (~/.local/share/opencode/opencode.db). "
|
|
476
|
+
"See the pantheon.vision error log for the paths tried."
|
|
477
|
+
)
|
|
384
478
|
safe_prompt = prompt.replace(key, "[redacted]")
|
|
385
479
|
content: list[dict[str, Any]] = [
|
|
386
480
|
{"type": "text", "text": safe_prompt},
|
|
@@ -396,7 +490,10 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
396
490
|
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client:
|
|
397
491
|
response = await client.post(
|
|
398
492
|
endpoint,
|
|
399
|
-
headers={
|
|
493
|
+
headers={
|
|
494
|
+
"Authorization": f"Bearer {key}",
|
|
495
|
+
"Content-Type": "application/json",
|
|
496
|
+
},
|
|
400
497
|
json=body,
|
|
401
498
|
)
|
|
402
499
|
if response.status_code >= HTTP_ERROR_STATUS:
|
|
@@ -417,7 +514,9 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
417
514
|
payload = response.json()
|
|
418
515
|
choices = payload.get("choices") if isinstance(payload, dict) else None
|
|
419
516
|
message = choices[0].get("message") if choices else None
|
|
420
|
-
text = _content_text(
|
|
517
|
+
text = _content_text(
|
|
518
|
+
message.get("content") if isinstance(message, dict) else ""
|
|
519
|
+
)
|
|
421
520
|
if not text:
|
|
422
521
|
raise VisionError("Vision gateway returned an empty response.")
|
|
423
522
|
return _scrub(text, key)
|
|
@@ -425,7 +524,13 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
425
524
|
raise
|
|
426
525
|
except (httpx.TimeoutException, TimeoutError) as exc:
|
|
427
526
|
raise VisionError("Vision gateway request timed out.") from exc
|
|
428
|
-
except (
|
|
527
|
+
except (
|
|
528
|
+
httpx.HTTPError,
|
|
529
|
+
json.JSONDecodeError,
|
|
530
|
+
KeyError,
|
|
531
|
+
IndexError,
|
|
532
|
+
TypeError,
|
|
533
|
+
) as exc:
|
|
429
534
|
raise VisionError("Could not read the vision gateway response.") from exc
|
|
430
535
|
except Exception as exc:
|
|
431
536
|
raise VisionError("Vision gateway request failed.") from exc
|
|
@@ -433,7 +538,11 @@ async def _gateway(image: ImageInput, prompt: str, *, structured: bool = False)
|
|
|
433
538
|
|
|
434
539
|
def _error_message(exc: Exception) -> str:
|
|
435
540
|
"""Return a stable, non-sensitive tool error."""
|
|
436
|
-
return
|
|
541
|
+
return (
|
|
542
|
+
f"Error: {exc}"
|
|
543
|
+
if isinstance(exc, VisionError)
|
|
544
|
+
else "Error: Vision request failed."
|
|
545
|
+
)
|
|
437
546
|
|
|
438
547
|
|
|
439
548
|
_DESCRIBE_PROMPT = (
|
|
@@ -442,7 +551,8 @@ _DESCRIBE_PROMPT = (
|
|
|
442
551
|
)
|
|
443
552
|
_OCR_PROMPT = (
|
|
444
553
|
"Perform OCR on this image. Transcribe all visible text exactly, preserving "
|
|
445
|
-
"lines, spacing, punctuation, and formatting (formatação) where possible.
|
|
554
|
+
"lines, spacing, punctuation, and formatting (formatação) where possible. "
|
|
555
|
+
"If there is no "
|
|
446
556
|
"text, say clearly that no text was found. Do not describe the image."
|
|
447
557
|
)
|
|
448
558
|
_ANALYZE_PROMPT = (
|
|
@@ -472,7 +582,9 @@ async def vision_ocr(path: str) -> str:
|
|
|
472
582
|
return _error_message(exc)
|
|
473
583
|
|
|
474
584
|
|
|
475
|
-
@mcp.tool(
|
|
585
|
+
@mcp.tool(
|
|
586
|
+
description="Analyze an image and return metadata, description, and OCR as JSON."
|
|
587
|
+
)
|
|
476
588
|
async def vision_analyze(path: str) -> str:
|
|
477
589
|
"""Return local metadata plus one structured description/OCR gateway call."""
|
|
478
590
|
try:
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Pantheon MCP runtime dependencies — EXACT pins (issue #21)
|
|
2
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
|
|
3
|
+
# Update deliberately via: pip install -U <pkg> && pip freeze | grep -iE '^(mcp|fastmcp|pyyaml)'
|
|
4
|
+
# Memory search is FTS5 (SQLite stdlib): sqlite-vec and fastembed were removed
|
|
5
|
+
# with the vector pipeline — the server no longer imports either.
|
|
4
6
|
mcp==1.29.0
|
|
5
7
|
fastmcp==3.4.6
|
|
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
|
|
@@ -350,7 +350,9 @@ function presetPathForScope(options: ModelCommandOptions, scope: ModelScope): st
|
|
|
350
350
|
const globalCandidates = globalConfigCandidates(options).map((p) =>
|
|
351
351
|
resolve(join(dirname(p), '.pantheon', 'active-preset.json')),
|
|
352
352
|
)
|
|
353
|
-
|
|
353
|
+
const fallback = globalCandidates[0] ?? candidates[0]
|
|
354
|
+
if (!fallback) throw new ModelCommandError('global preset path is unavailable')
|
|
355
|
+
return fallback
|
|
354
356
|
}
|
|
355
357
|
|
|
356
358
|
async function readActivePresetRaw(path: string, label: string): Promise<ReadFileResult> {
|
|
@@ -552,10 +554,16 @@ async function updateActivePreset(
|
|
|
552
554
|
const overrides = shaped.overrides as Record<string, unknown>
|
|
553
555
|
const agents = overrides.agents as Record<string, unknown>
|
|
554
556
|
|
|
555
|
-
|
|
557
|
+
if (typeof args.agent !== 'string') {
|
|
558
|
+
throw new ModelCommandError(`${action} requires --agent <name>`)
|
|
559
|
+
}
|
|
560
|
+
const agentKey = validateAgent(args.agent)
|
|
556
561
|
|
|
557
562
|
if (action === 'set') {
|
|
558
|
-
|
|
563
|
+
if (typeof args.model !== 'string') {
|
|
564
|
+
throw new ModelCommandError('set requires --model <provider/model-id>')
|
|
565
|
+
}
|
|
566
|
+
const model = args.model
|
|
559
567
|
const effort = args.effort
|
|
560
568
|
let variantInfo: { variant: 'low' | 'medium' | 'high' | null; clamped: boolean }
|
|
561
569
|
try {
|
|
@@ -778,22 +786,25 @@ async function promptViaReadline(
|
|
|
778
786
|
): Promise<Record<string, unknown>> {
|
|
779
787
|
const answers: Record<string, unknown> = {}
|
|
780
788
|
// Q1 agent
|
|
781
|
-
const agentQ = questions[0]
|
|
789
|
+
const agentQ = questions[0]
|
|
790
|
+
const modelQ = questions[1]
|
|
791
|
+
const effortQ = questions[2]
|
|
792
|
+
const scopeQ = questions[3]
|
|
793
|
+
if (!agentQ || !modelQ || !effortQ || !scopeQ) {
|
|
794
|
+
throw new ModelCommandError('wizard requires agent, model, effort, and scope questions')
|
|
795
|
+
}
|
|
782
796
|
const agentPrompt = `${agentQ.question} [${KNOWN_AGENTS.join('/')}] : `
|
|
783
797
|
const agentAns = (await rl.question(agentPrompt)).trim().toLowerCase()
|
|
784
798
|
answers.agent = agentAns || KNOWN_AGENTS[0]
|
|
785
799
|
|
|
786
|
-
const modelQ = questions[1]!
|
|
787
800
|
const modelAns = (await rl.question(`${modelQ.question} : `)).trim()
|
|
788
801
|
answers.model = modelAns
|
|
789
802
|
|
|
790
|
-
const effortQ = questions[2]!
|
|
791
803
|
const effortAns = (await rl.question(`${effortQ.question} [low/medium/high] : `))
|
|
792
804
|
.trim()
|
|
793
805
|
.toLowerCase()
|
|
794
806
|
answers.effort = effortAns || 'medium'
|
|
795
807
|
|
|
796
|
-
const scopeQ = questions[3]!
|
|
797
808
|
const scopeAns = (await rl.question(`${scopeQ.question} [project/global] : `))
|
|
798
809
|
.trim()
|
|
799
810
|
.toLowerCase()
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
51
51
|
import { createPantheonLogger } from './logger.ts'
|
|
52
|
-
import {
|
|
52
|
+
import { safeSessionPath } from './session-guard.ts'
|
|
53
53
|
|
|
54
54
|
// Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
|
|
55
55
|
// fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
|
package/src/pantheon/vision.ts
CHANGED
|
@@ -1251,7 +1251,7 @@ export function createVisionHandler(input: PluginInput) {
|
|
|
1251
1251
|
const chatMessage: NonNullable<Hooks['chat.message']> = async (hookInput, output) => {
|
|
1252
1252
|
try {
|
|
1253
1253
|
const message: UserMessage | undefined = output?.message
|
|
1254
|
-
if (
|
|
1254
|
+
if (message?.role !== 'user') return
|
|
1255
1255
|
const model = hookInput?.model ?? message.model
|
|
1256
1256
|
if (modelAcceptsImages(model, model?.providerID)) {
|
|
1257
1257
|
nativeVisionSessions.add(hookInput.sessionID)
|