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.
Files changed (60) 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/CHANGELOG.md +17 -0
  6. package/README.md +86 -52
  7. package/README.pt-BR.md +63 -43
  8. package/commands/pantheon-optimize.md +2 -2
  9. package/package.json +13 -1
  10. package/plugin.json +1 -1
  11. package/pyproject.toml +8 -15
  12. package/scripts/build-agents-md.mjs +2 -2
  13. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  14. package/scripts/ci-validate-yaml.py +7 -5
  15. package/scripts/code_mode_server.py +116 -24
  16. package/scripts/doctor.mjs +85 -28
  17. package/scripts/install/cli-ui.mjs +1 -1
  18. package/scripts/install/config-migration.mjs +2 -2
  19. package/scripts/install/health-check.mjs +6 -19
  20. package/scripts/install/interactive.mjs +6 -6
  21. package/scripts/install/migrate.mjs +1 -1
  22. package/scripts/mcp_persistence_server.py +390 -76
  23. package/scripts/memory_mcp_server.py +370 -196
  24. package/scripts/postinstall.mjs +0 -7
  25. package/scripts/probe-context-rehydrate.mjs +28 -11
  26. package/scripts/prune.mjs +2 -2
  27. package/scripts/test-opencode-v2-sandbox.sh +1759 -0
  28. package/scripts/validate-routing.mjs +40 -0
  29. package/skills-lock.json +9 -10
  30. package/src/agents/mnemosyne.md +15 -16
  31. package/src/agents/zeus.md +1 -2
  32. package/src/mcp/code_mode_server.py +116 -24
  33. package/src/mcp/mcp_codemap_module.py +559 -129
  34. package/src/mcp/mcp_persistence_server.py +390 -76
  35. package/src/mcp/memory_mcp_server.py +371 -248
  36. package/src/mcp/pantheon_vision_server.py +136 -24
  37. package/src/mcp/requirements-mcp.txt +5 -3
  38. package/src/mcp/requirements-vision.txt +2 -0
  39. package/src/pantheon/model-command.ts +18 -7
  40. package/src/pantheon/todo-enforcer.ts +1 -1
  41. package/src/pantheon/vision.ts +1 -1
  42. package/src/plugin-v2.ts +134 -104
  43. package/src/plugins/tui/dist/tui.js +1 -1
  44. package/src/plugins/tui/package-lock.json +20 -20
  45. package/src/plugins/tui/package.json +12 -12
  46. package/src/routing.yml +0 -1
  47. package/src/skills/README.md +31 -36
  48. package/src/skills/auto-continue/SKILL.md +25 -75
  49. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  50. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  51. package/.pantheon/code-mode/eval-run.py +0 -197
  52. package/.pantheon/code-mode/eval-static.py +0 -321
  53. package/.pantheon/code-mode/session-end-save.py +0 -186
  54. package/.pantheon/code-mode/session-end-save.sh +0 -18
  55. package/scripts/recover-zenodo-deposition.mjs +0 -146
  56. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  57. package/scripts/validate-zenodo-release.mjs +0 -154
  58. package/scripts/zenodo-artifact.mjs +0 -94
  59. package/scripts/zenodo-release-state.mjs +0 -53
  60. 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("Unsupported image MIME type. Use PNG, JPEG, WebP, or GIF.")
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 auth-store order."""
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
- 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
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 = messages[0].get("content") if isinstance(messages, list) and messages else None
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({"type": "text", "chars": len(text) if isinstance(text, str) else 0})
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("OpenCode API key not configured.")
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={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
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(message.get("content") if isinstance(message, dict) else "")
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 (httpx.HTTPError, json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
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 f"Error: {exc}" if isinstance(exc, VisionError) else "Error: Vision request failed."
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. If there is no "
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(description="Analyze an image and return metadata, description, and OCR as JSON.")
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|sqlite-vec|fastembed)'
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
@@ -1,3 +1,5 @@
1
1
  mcp==1.29.0
2
2
  fastmcp==3.4.6
3
+ pydantic==2.12.5
4
+ pydantic-settings==2.14.2
3
5
  httpx==0.28.1
@@ -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
- return globalCandidates[0] ?? candidates[0]!
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
- const agentKey = validateAgent(args.agent!)
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
- const model = args.model!
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 { buildChildrenPath, safeSessionPath } from './session-guard.ts'
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
@@ -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 (!message || message.role !== 'user') return
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)