pantheon-opencode 1.3.5 → 1.3.7
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/README.md +33 -1
- package/package.json +3 -3
- package/scripts/_pantheon_paths.py +0 -1
- package/scripts/build-agents-md.mjs +4 -2
- package/scripts/code_mode_server.py +307 -21
- package/scripts/doctor.mjs +26 -13
- package/scripts/hooks/format-multi-language.sh +6 -3
- package/scripts/hooks/log-session-start.sh +6 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
- package/scripts/hooks/validate-post-conditions.sh +1 -1
- package/scripts/hooks/validate-tool-safety.sh +5 -2
- package/scripts/install/agents-md.mjs +3 -1
- package/scripts/install/cli-ui.mjs +24 -4
- package/scripts/install/interactive.mjs +18 -7
- package/scripts/install/migrate-config.mjs +13 -19
- package/scripts/install/migrate.mjs +8 -5
- package/scripts/install/model-picker.mjs +4 -1
- package/scripts/install/opencode.mjs +2 -0
- package/scripts/install/shared.mjs +6 -4
- package/scripts/install/state.mjs +9 -6
- package/scripts/mcp_persistence_server.py +23 -22
- package/scripts/mcp_resources_server.py +60 -1
- package/scripts/memory_mcp_server.py +80 -4
- package/scripts/postinstall.mjs +27 -25
- package/scripts/prune.mjs +1 -1
- package/scripts/redaction-gate.mjs +3 -1
- package/scripts/sync-tui.mjs +148 -0
- package/scripts/uninstall.mjs +0 -2
- package/scripts/validation-policy.mjs +6 -1
- package/scripts/version-check.mjs +9 -4
- package/src/agents/aphrodite.md +1 -1
- package/src/agents/demeter.md +1 -1
- package/src/agents/hephaestus.md +1 -1
- package/src/agents/hermes.md +1 -1
- package/src/agents/themis.md +4 -0
- package/src/mcp/code_mode_server.py +304 -18
- package/src/mcp/eval_store.py +233 -0
- package/src/mcp/mcp_persistence_server.py +23 -22
- package/src/mcp/mcp_resources_server.py +58 -0
- package/src/mcp/memory_mcp_server.py +80 -3
- package/src/pantheon/auto-wake.ts +2 -4
- package/src/pantheon/background-job-board.ts +17 -4
- package/src/pantheon/comment-checker.ts +48 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +21 -21
- package/src/pantheon/delegation-finalize.ts +44 -4
- package/src/pantheon/delegation-notify.ts +97 -0
- package/src/pantheon/delegation.ts +413 -23
- package/src/pantheon/dispatch-guard.ts +2 -0
- package/src/pantheon/file-persistence.ts +7 -3
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/permission-globs.ts +86 -0
- package/src/pantheon/presets.d.mts +41 -0
- package/src/pantheon/presets.mjs +134 -0
- package/src/pantheon/retry-policy.ts +212 -0
- package/src/pantheon/session-guard.ts +1 -3
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/tui-version-check.ts +72 -0
- package/src/pantheon/vision.ts +7 -5
- package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
- package/src/plugin.ts +57 -3
- package/src/plugins/hook-runner.ts +1 -1
- package/src/plugins/pantheon-hooks.ts +1 -1
- 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 +19 -14
- package/src/plugins/tui/dist/tui.tsx +52 -18
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +106 -24
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +42 -0
|
@@ -16,10 +16,18 @@ Or via MCP client (stdio transport):
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import logging
|
|
19
20
|
import os
|
|
21
|
+
import signal
|
|
22
|
+
import shutil
|
|
20
23
|
import stat
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
21
26
|
from contextlib import suppress
|
|
22
27
|
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
23
31
|
|
|
24
32
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
25
33
|
from mcp.server.fastmcp import FastMCP
|
|
@@ -27,6 +35,98 @@ from mcp.server.fastmcp import FastMCP
|
|
|
27
35
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
28
36
|
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({".sh", ".py"})
|
|
29
37
|
SCRIPT_TIMEOUT: int = 30
|
|
38
|
+
# Hard ceiling for frontmatter `timeout:` overrides — no script may pin the
|
|
39
|
+
# executor for longer than 5 minutes.
|
|
40
|
+
MAX_SCRIPT_TIMEOUT: int = 300
|
|
41
|
+
|
|
42
|
+
# ── Env Allowlist ──────────────────────────────────────────────────────────────
|
|
43
|
+
# Only these variables are passed to script subprocesses. Everything else
|
|
44
|
+
# (secrets, cloud credentials, etc.) is stripped to limit blast radius.
|
|
45
|
+
_CODE_MODE_ENV_ALLOWLIST: frozenset[str] = frozenset({
|
|
46
|
+
"PATH",
|
|
47
|
+
"HOME",
|
|
48
|
+
"LANG",
|
|
49
|
+
"PANTHEON_HOME",
|
|
50
|
+
"PANTHEON_PROJECT",
|
|
51
|
+
"OPENAI_API_KEY",
|
|
52
|
+
"OPENAI_BASE_URL",
|
|
53
|
+
"EVAL_JUDGE_MODEL",
|
|
54
|
+
"SHELL",
|
|
55
|
+
"USER",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
# Optional extra vars that callers may opt-in to pass through. Populate
|
|
59
|
+
# this set at startup if needed (e.g. ``CODE_MODE_EXTRA_ENV.add("MY_VAR")``).
|
|
60
|
+
CODE_MODE_EXTRA_ENV: set[str] = set()
|
|
61
|
+
|
|
62
|
+
# Safe defaults when the host env is completely empty.
|
|
63
|
+
_SAFE_DEFAULTS: dict[str, str] = {
|
|
64
|
+
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
|
65
|
+
"HOME": "/tmp",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_log = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_script_env(
|
|
72
|
+
host_env: dict[str, str] | None = None,
|
|
73
|
+
) -> dict[str, str]:
|
|
74
|
+
"""Construct a minimal env dict for script subprocesses.
|
|
75
|
+
|
|
76
|
+
Only allowlisted vars (plus any in ``CODE_MODE_EXTRA_ENV``) are
|
|
77
|
+
forwarded from *host_env* (defaults to ``os.environ``). Missing
|
|
78
|
+
allowlisted vars receive safe defaults from ``_SAFE_DEFAULTS``.
|
|
79
|
+
|
|
80
|
+
Returns a **new** dict — the input is never mutated.
|
|
81
|
+
"""
|
|
82
|
+
if host_env is None:
|
|
83
|
+
host_env = dict(os.environ)
|
|
84
|
+
|
|
85
|
+
allowed = _CODE_MODE_ENV_ALLOWLIST | CODE_MODE_EXTRA_ENV
|
|
86
|
+
env: dict[str, str] = {}
|
|
87
|
+
|
|
88
|
+
for key in allowed:
|
|
89
|
+
if key in host_env:
|
|
90
|
+
env[key] = host_env[key]
|
|
91
|
+
elif key in _SAFE_DEFAULTS:
|
|
92
|
+
env[key] = _SAFE_DEFAULTS[key]
|
|
93
|
+
|
|
94
|
+
# Always ensure PATH is present even if allowlist + defaults both miss it.
|
|
95
|
+
if "PATH" not in env:
|
|
96
|
+
env["PATH"] = _SAFE_DEFAULTS.get("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
97
|
+
|
|
98
|
+
return env
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _prlimit_prefix(
|
|
102
|
+
prlimit_path: str | None,
|
|
103
|
+
timeout_s: int = SCRIPT_TIMEOUT,
|
|
104
|
+
) -> list[str] | None:
|
|
105
|
+
"""Return a ``prlimit`` command prefix to wrap a subprocess, or *None*.
|
|
106
|
+
|
|
107
|
+
On Linux with ``prlimit`` available the prefix adds:
|
|
108
|
+
--nproc=512 --as=1073741824 --cpu=<timeout_s + 5>
|
|
109
|
+
|
|
110
|
+
On non-Linux or when *prlimit_path* is falsy, returns *None* (fail-open).
|
|
111
|
+
"""
|
|
112
|
+
if not prlimit_path:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
cpu_limit = timeout_s + 5
|
|
116
|
+
return [
|
|
117
|
+
prlimit_path,
|
|
118
|
+
"--nproc=512",
|
|
119
|
+
"--as=1073741824", # 1 GiB in bytes
|
|
120
|
+
f"--cpu={cpu_limit}",
|
|
121
|
+
"--",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _detect_prlimit() -> str | None:
|
|
126
|
+
"""Return the path to ``prlimit`` if available, else *None*."""
|
|
127
|
+
if sys.platform != "linux":
|
|
128
|
+
return None
|
|
129
|
+
return shutil.which("prlimit")
|
|
30
130
|
|
|
31
131
|
# ── Scripts Directory Resolution ─────────────────────────────────────────────
|
|
32
132
|
# Priority:
|
|
@@ -87,12 +187,16 @@ def _validate_script_name(script_name: str) -> Path:
|
|
|
87
187
|
|
|
88
188
|
|
|
89
189
|
def _format_output(
|
|
90
|
-
stdout: str,
|
|
190
|
+
stdout: str,
|
|
191
|
+
stderr: str,
|
|
192
|
+
exit_code: int,
|
|
193
|
+
timed_out: bool = False,
|
|
194
|
+
timeout_s: int = SCRIPT_TIMEOUT,
|
|
91
195
|
) -> str:
|
|
92
196
|
"""Format script execution output into a readable string."""
|
|
93
197
|
parts: list[str] = []
|
|
94
198
|
if timed_out:
|
|
95
|
-
parts.append(f"[TIMEOUT] Script exceeded {
|
|
199
|
+
parts.append(f"[TIMEOUT] Script exceeded {timeout_s}s limit")
|
|
96
200
|
if stdout:
|
|
97
201
|
parts.append(stdout)
|
|
98
202
|
if stderr:
|
|
@@ -101,6 +205,123 @@ def _format_output(
|
|
|
101
205
|
return "\n".join(parts)
|
|
102
206
|
|
|
103
207
|
|
|
208
|
+
# ── Script Metadata (YAML frontmatter) ────────────────────────────────────────
|
|
209
|
+
# Optional comment-style frontmatter at the top of a script (after an optional
|
|
210
|
+
# shebang). Comment lines keep the script a valid executable in both bash and
|
|
211
|
+
# python while carrying metadata:
|
|
212
|
+
#
|
|
213
|
+
# #!/usr/bin/env python3
|
|
214
|
+
# # ---
|
|
215
|
+
# # description: Runs the checkpoint save
|
|
216
|
+
# # timeout: 5
|
|
217
|
+
# # allowed_args:
|
|
218
|
+
# # - compress
|
|
219
|
+
# # - --text
|
|
220
|
+
# # ---
|
|
221
|
+
#
|
|
222
|
+
# Supported keys: description (str), timeout (int seconds, overrides the 30s
|
|
223
|
+
# default), allowed_args (list of allowed CLI args — validated on execute).
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_frontmatter(script_path: Path) -> dict[str, Any]:
|
|
227
|
+
"""Parse comment-style YAML frontmatter from a script.
|
|
228
|
+
|
|
229
|
+
Returns an empty dict when the script has no frontmatter or the block is
|
|
230
|
+
malformed (fail-open — never blocks execution).
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
text = script_path.read_text(encoding="utf-8")
|
|
234
|
+
except OSError:
|
|
235
|
+
return {}
|
|
236
|
+
lines = text.splitlines()
|
|
237
|
+
|
|
238
|
+
# Locate the opening delimiter: a comment line that is exactly "# ---".
|
|
239
|
+
# Only scan the header region (before the first non-comment code line).
|
|
240
|
+
start: int | None = None
|
|
241
|
+
for i, line in enumerate(lines):
|
|
242
|
+
stripped = line.strip()
|
|
243
|
+
if stripped == "# ---":
|
|
244
|
+
start = i
|
|
245
|
+
break
|
|
246
|
+
if i > 0 and stripped and not stripped.startswith("#"):
|
|
247
|
+
break
|
|
248
|
+
if start is None:
|
|
249
|
+
return {}
|
|
250
|
+
|
|
251
|
+
body_lines: list[str] = []
|
|
252
|
+
for j in range(start + 1, len(lines)):
|
|
253
|
+
stripped = lines[j].strip()
|
|
254
|
+
if stripped == "# ---":
|
|
255
|
+
break
|
|
256
|
+
if not stripped.startswith("#"):
|
|
257
|
+
return {} # non-comment line inside the block → not frontmatter
|
|
258
|
+
body_lines.append(stripped[1:].lstrip() if stripped.startswith("# ") else stripped[1:])
|
|
259
|
+
else:
|
|
260
|
+
return {} # no closing delimiter
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
data = yaml.safe_load("\n".join(body_lines))
|
|
264
|
+
except yaml.YAMLError:
|
|
265
|
+
return {}
|
|
266
|
+
return data if isinstance(data, dict) else {}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _script_timeout(script_path: Path) -> int:
|
|
270
|
+
"""Per-script timeout override from frontmatter, capped at MAX_SCRIPT_TIMEOUT.
|
|
271
|
+
|
|
272
|
+
Honors any positive integer `timeout:` from the script frontmatter, but
|
|
273
|
+
never returns more than 300s (5 min) regardless of what frontmatter asks.
|
|
274
|
+
"""
|
|
275
|
+
timeout = _parse_frontmatter(script_path).get("timeout")
|
|
276
|
+
if isinstance(timeout, int) and timeout > 0:
|
|
277
|
+
return min(timeout, MAX_SCRIPT_TIMEOUT)
|
|
278
|
+
return SCRIPT_TIMEOUT
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _validate_args(script_path: Path, args: list[str]) -> str | None:
|
|
282
|
+
"""Validate args against the frontmatter ``allowed_args`` allowlist.
|
|
283
|
+
|
|
284
|
+
Returns an error message when a passed arg is not allowed, else None.
|
|
285
|
+
A missing or malformed allowlist fails open (no restriction).
|
|
286
|
+
"""
|
|
287
|
+
allowed = _parse_frontmatter(script_path).get("allowed_args")
|
|
288
|
+
if allowed is None:
|
|
289
|
+
return None
|
|
290
|
+
if not isinstance(allowed, list) or not all(isinstance(a, str) for a in allowed):
|
|
291
|
+
return None
|
|
292
|
+
denied = [a for a in args if a not in allowed]
|
|
293
|
+
if denied:
|
|
294
|
+
return (
|
|
295
|
+
f"Argument(s) {denied} not allowed for script '{script_path.name}'. "
|
|
296
|
+
f"Allowed: {allowed}"
|
|
297
|
+
)
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _build_result(
|
|
302
|
+
stdout: str,
|
|
303
|
+
stderr: str,
|
|
304
|
+
exit_code: int,
|
|
305
|
+
timed_out: bool,
|
|
306
|
+
duration_ms: int,
|
|
307
|
+
metadata: dict[str, Any],
|
|
308
|
+
json_output: bool,
|
|
309
|
+
timeout_s: int,
|
|
310
|
+
) -> str | dict[str, Any]:
|
|
311
|
+
"""Build the tool result in plain-text or structured JSON form."""
|
|
312
|
+
if json_output:
|
|
313
|
+
return {
|
|
314
|
+
"stdout": stdout,
|
|
315
|
+
"stderr": stderr,
|
|
316
|
+
"exit_code": exit_code,
|
|
317
|
+
"timed_out": timed_out,
|
|
318
|
+
"duration_ms": duration_ms,
|
|
319
|
+
"timeout_s": timeout_s,
|
|
320
|
+
"metadata": metadata,
|
|
321
|
+
}
|
|
322
|
+
return _format_output(stdout, stderr, exit_code, timed_out, timeout_s)
|
|
323
|
+
|
|
324
|
+
|
|
104
325
|
# ── Static Resources ──────────────────────────────────────────────────────────
|
|
105
326
|
|
|
106
327
|
|
|
@@ -126,15 +347,20 @@ async def list_code_mode_scripts() -> str:
|
|
|
126
347
|
|
|
127
348
|
@mcp.resource(
|
|
128
349
|
"pantheon://code-mode/scripts/{script_name}",
|
|
129
|
-
description="Content of a code-mode script by name",
|
|
350
|
+
description="Content of a code-mode script by name, with parsed frontmatter metadata",
|
|
130
351
|
)
|
|
131
352
|
async def get_code_mode_script(script_name: str) -> str:
|
|
132
|
-
"""Return the source content of a code-mode script."""
|
|
353
|
+
"""Return the source content of a code-mode script plus its metadata."""
|
|
133
354
|
try:
|
|
134
355
|
script_path = _validate_script_name(script_name)
|
|
135
|
-
return script_path.read_text(encoding="utf-8")
|
|
136
356
|
except ValueError as e:
|
|
137
357
|
return str(e)
|
|
358
|
+
metadata = _parse_frontmatter(script_path)
|
|
359
|
+
source = script_path.read_text(encoding="utf-8")
|
|
360
|
+
if not metadata:
|
|
361
|
+
return source
|
|
362
|
+
meta_lines = "\n".join(f"{k}: {v}" for k, v in sorted(metadata.items()))
|
|
363
|
+
return f"# metadata\n{meta_lines}\n\n{source}"
|
|
138
364
|
|
|
139
365
|
|
|
140
366
|
# ── Tools ─────────────────────────────────────────────────────────────────────
|
|
@@ -142,16 +368,27 @@ async def get_code_mode_script(script_name: str) -> str:
|
|
|
142
368
|
|
|
143
369
|
@mcp.tool(
|
|
144
370
|
name="execute_code_script",
|
|
145
|
-
description="Run a .sh/.py script from .pantheon/code-mode/ "
|
|
146
|
-
"
|
|
371
|
+
description="Run a .sh/.py script from .pantheon/code-mode/ with optional args. "
|
|
372
|
+
"30s default timeout (override via YAML frontmatter `timeout`). "
|
|
373
|
+
"Set json_output=true for structured JSON output.",
|
|
147
374
|
)
|
|
148
|
-
async def execute_code_script(
|
|
375
|
+
async def execute_code_script(
|
|
376
|
+
script_name: str,
|
|
377
|
+
args: list[str] | None = None,
|
|
378
|
+
json_output: bool = False,
|
|
379
|
+
) -> str | dict[str, Any]:
|
|
149
380
|
"""Execute a code-mode script with confinement and timeout.
|
|
150
381
|
|
|
151
382
|
Args:
|
|
152
383
|
script_name: Name of the script in the code-mode directory.
|
|
153
384
|
args: Optional CLI arguments forwarded to the subprocess (e.g.
|
|
154
385
|
``["compress", "--text", "..."]``). Defaults to no args.
|
|
386
|
+
json_output: When True, return a structured dict with stdout, stderr,
|
|
387
|
+
exit_code, duration_ms, timed_out and frontmatter metadata
|
|
388
|
+
instead of the plain-text summary.
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
Plain-text summary (default) or structured dict (json_output=True).
|
|
155
392
|
"""
|
|
156
393
|
args = args or []
|
|
157
394
|
try:
|
|
@@ -159,6 +396,13 @@ async def execute_code_script(script_name: str, args: list[str] | None = None) -
|
|
|
159
396
|
except ValueError as e:
|
|
160
397
|
return str(e)
|
|
161
398
|
|
|
399
|
+
metadata = _parse_frontmatter(script_path)
|
|
400
|
+
denied = _validate_args(script_path, args)
|
|
401
|
+
if denied is not None:
|
|
402
|
+
return denied
|
|
403
|
+
|
|
404
|
+
timeout_s = _script_timeout(script_path)
|
|
405
|
+
|
|
162
406
|
# Ensure script is executable
|
|
163
407
|
if not os.access(script_path, os.X_OK):
|
|
164
408
|
with suppress(OSError):
|
|
@@ -166,34 +410,76 @@ async def execute_code_script(script_name: str, args: list[str] | None = None) -
|
|
|
166
410
|
script_path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
167
411
|
|
|
168
412
|
script_dir = script_path.parent
|
|
413
|
+
started = time.monotonic()
|
|
414
|
+
|
|
415
|
+
# ── Build sanitized env & optional prlimit prefix ────────────────────
|
|
416
|
+
env_dict = _build_script_env()
|
|
417
|
+
prlimit = _prlimit_prefix(_detect_prlimit(), timeout_s)
|
|
418
|
+
|
|
419
|
+
# Command: optionally wrapped with prlimit for resource limits.
|
|
420
|
+
if prlimit is not None:
|
|
421
|
+
cmd = [*prlimit, str(script_path), *args]
|
|
422
|
+
else:
|
|
423
|
+
cmd = [str(script_path), *args]
|
|
424
|
+
|
|
169
425
|
try:
|
|
170
426
|
proc = await asyncio.create_subprocess_exec(
|
|
171
|
-
|
|
172
|
-
*args,
|
|
427
|
+
*cmd,
|
|
173
428
|
cwd=str(script_dir),
|
|
174
429
|
stdout=asyncio.subprocess.PIPE,
|
|
175
430
|
stderr=asyncio.subprocess.PIPE,
|
|
431
|
+
env=env_dict,
|
|
432
|
+
start_new_session=True,
|
|
176
433
|
)
|
|
177
434
|
try:
|
|
178
435
|
stdout, stderr = await asyncio.wait_for(
|
|
179
|
-
proc.communicate(), timeout=
|
|
436
|
+
proc.communicate(), timeout=timeout_s
|
|
180
437
|
)
|
|
181
|
-
|
|
438
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
439
|
+
return _build_result(
|
|
182
440
|
stdout.decode("utf-8", errors="replace"),
|
|
183
441
|
stderr.decode("utf-8", errors="replace"),
|
|
184
442
|
proc.returncode or 0,
|
|
443
|
+
timed_out=False,
|
|
444
|
+
duration_ms=duration_ms,
|
|
445
|
+
metadata=metadata,
|
|
446
|
+
json_output=json_output,
|
|
447
|
+
timeout_s=timeout_s,
|
|
185
448
|
)
|
|
186
449
|
except TimeoutError:
|
|
187
|
-
|
|
450
|
+
# Kill the entire process group so child processes don't survive.
|
|
451
|
+
try:
|
|
452
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
453
|
+
except (ProcessLookupError, OSError):
|
|
454
|
+
proc.kill() # fallback: kill main process only
|
|
188
455
|
await proc.wait()
|
|
189
|
-
|
|
456
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
457
|
+
return _build_result(
|
|
458
|
+
"", "", -1, timed_out=True, duration_ms=duration_ms,
|
|
459
|
+
metadata=metadata, json_output=json_output, timeout_s=timeout_s,
|
|
460
|
+
)
|
|
190
461
|
except FileNotFoundError:
|
|
191
|
-
return (
|
|
192
|
-
|
|
193
|
-
f"or interpreter missing
|
|
462
|
+
return _build_result(
|
|
463
|
+
"",
|
|
464
|
+
f"Script '{script_name}' not found or interpreter missing.",
|
|
465
|
+
-1,
|
|
466
|
+
timed_out=False,
|
|
467
|
+
duration_ms=0,
|
|
468
|
+
metadata=metadata,
|
|
469
|
+
json_output=json_output,
|
|
470
|
+
timeout_s=timeout_s,
|
|
194
471
|
)
|
|
195
472
|
except OSError as e:
|
|
196
|
-
return
|
|
473
|
+
return _build_result(
|
|
474
|
+
"",
|
|
475
|
+
f"Failed to execute script: {e}",
|
|
476
|
+
-1,
|
|
477
|
+
timed_out=False,
|
|
478
|
+
duration_ms=0,
|
|
479
|
+
metadata=metadata,
|
|
480
|
+
json_output=json_output,
|
|
481
|
+
timeout_s=timeout_s,
|
|
482
|
+
)
|
|
197
483
|
|
|
198
484
|
|
|
199
485
|
# ── Main Entrypoint ───────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Plugin eval certification storage — ``plugin_eval`` memory namespace.
|
|
3
|
+
|
|
4
|
+
Thin stdlib-only wrapper around the memory SQLite DB (same schema as
|
|
5
|
+
``src/mcp/memory_mcp_server.py``) for plugin-eval certification results.
|
|
6
|
+
|
|
7
|
+
Namespace: ``plugin_eval``
|
|
8
|
+
Key format: ``eval:<name>:<date>`` (e.g. ``eval:tdd-with-agents:2026-08-21``)
|
|
9
|
+
Metadata: ``{"type": "eval", "score": N}``
|
|
10
|
+
|
|
11
|
+
Follows the ``council_decisions`` namespace pattern (same shape, different
|
|
12
|
+
namespace). Write path is used by the eval scripts (``eval-static.py``);
|
|
13
|
+
read path is used by the resources server (``pantheon://eval`` resources).
|
|
14
|
+
|
|
15
|
+
Deliberately dependency-free (stdlib only) so it can be loaded from
|
|
16
|
+
``.pantheon/code-mode/`` scripts via importlib without sys.path games.
|
|
17
|
+
The memory DB path resolution mirrors ``_pantheon_paths.pantheon_home()``
|
|
18
|
+
(canonical source) — kept inline to avoid the import.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sqlite3
|
|
26
|
+
import time
|
|
27
|
+
from datetime import date
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
NAMESPACE = "plugin_eval"
|
|
32
|
+
|
|
33
|
+
# Minimal schema — matches memory_mcp_server.SCHEMA_SQL for the memories
|
|
34
|
+
# table (vec/FTS tables are initialized by the memory MCP server on first
|
|
35
|
+
# run; eval entries remain readable via list/recall regardless).
|
|
36
|
+
_SCHEMA_SQL = """
|
|
37
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
namespace TEXT NOT NULL DEFAULT 'default',
|
|
40
|
+
key TEXT,
|
|
41
|
+
value TEXT NOT NULL,
|
|
42
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
43
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
44
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_ns_key
|
|
48
|
+
ON memories(namespace, key);
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Path resolution ───────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _db_path() -> Path:
|
|
56
|
+
"""Return the memory SQLite DB path (mirrors pantheon_home())."""
|
|
57
|
+
env = os.environ.get("PANTHEON_HOME")
|
|
58
|
+
if env:
|
|
59
|
+
return Path(env).expanduser().resolve() / "memory" / "memory.db"
|
|
60
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
61
|
+
if xdg:
|
|
62
|
+
return Path(xdg).expanduser().resolve() / "opencode" / "memory" / "memory.db"
|
|
63
|
+
return Path.home() / ".config" / "opencode" / "memory" / "memory.db"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _connect() -> sqlite3.Connection:
|
|
67
|
+
"""Open (creating if needed) the memory DB with row access."""
|
|
68
|
+
path = _db_path()
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
conn = sqlite3.connect(str(path))
|
|
71
|
+
conn.row_factory = sqlite3.Row
|
|
72
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
73
|
+
conn.executescript(_SCHEMA_SQL)
|
|
74
|
+
return conn
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _now_iso() -> str:
|
|
78
|
+
"""Return current UTC time as ISO 8601 string."""
|
|
79
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_metadata(raw: str) -> dict[str, Any]:
|
|
83
|
+
"""Parse the metadata JSON field, defaulting to {} on failure."""
|
|
84
|
+
try:
|
|
85
|
+
return json.loads(raw) if raw else {}
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _entry_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
|
91
|
+
"""Convert a memory row to a plain dict with parsed metadata."""
|
|
92
|
+
entry = dict(row)
|
|
93
|
+
entry["metadata"] = _parse_metadata(entry.get("metadata", "{}"))
|
|
94
|
+
return entry
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Write path (eval scripts) ─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def store_eval(
|
|
101
|
+
name: str,
|
|
102
|
+
report: dict[str, Any],
|
|
103
|
+
score: int,
|
|
104
|
+
when: str | None = None,
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
"""Store an eval certification result in the ``plugin_eval`` namespace.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
name: Plugin/skill/agent name (used in the key).
|
|
110
|
+
report: The eval report dict (JSON-serialized as the value).
|
|
111
|
+
score: Numeric certification score (0-100), stored in metadata.
|
|
112
|
+
when: Optional ISO date for the key (default: today).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict with id, namespace, key, status — or {"error": ...} on
|
|
116
|
+
duplicate key / failure.
|
|
117
|
+
"""
|
|
118
|
+
key = f"eval:{name}:{when or date.today().isoformat()}"
|
|
119
|
+
value = json.dumps(report, ensure_ascii=False)
|
|
120
|
+
metadata = json.dumps({"type": "eval", "score": int(score)})
|
|
121
|
+
now = _now_iso()
|
|
122
|
+
|
|
123
|
+
conn = _connect()
|
|
124
|
+
try:
|
|
125
|
+
cur = conn.execute(
|
|
126
|
+
"""INSERT INTO memories (namespace, key, value, metadata,
|
|
127
|
+
created_at, updated_at)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
129
|
+
[NAMESPACE, key, value, metadata, now, now],
|
|
130
|
+
)
|
|
131
|
+
conn.commit()
|
|
132
|
+
return {
|
|
133
|
+
"id": cur.lastrowid,
|
|
134
|
+
"namespace": NAMESPACE,
|
|
135
|
+
"key": key,
|
|
136
|
+
"status": "stored",
|
|
137
|
+
}
|
|
138
|
+
except sqlite3.IntegrityError:
|
|
139
|
+
conn.rollback()
|
|
140
|
+
return {"error": f"Duplicate key or constraint violation: {key}"}
|
|
141
|
+
except Exception as e: # noqa: BLE001
|
|
142
|
+
conn.rollback()
|
|
143
|
+
return {"error": f"Failed to store eval: {e}"}
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── Read path (resources server) ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def list_evals(limit: int = 100) -> list[dict[str, Any]]:
|
|
152
|
+
"""List the latest eval entry per plugin, newest eval date first.
|
|
153
|
+
|
|
154
|
+
Returns a list of entry dicts (id, namespace, key, value, metadata,
|
|
155
|
+
created_at) with an extra ``name`` field parsed from the key. Only the
|
|
156
|
+
most recent entry per plugin name is returned, ordered by the eval
|
|
157
|
+
date embedded in the key (``eval:<name>:<date>``) descending.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
limit: Maximum entries (default 100).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List of entry dicts, newest eval date first. Empty if none exist.
|
|
164
|
+
"""
|
|
165
|
+
limit = max(1, min(500, int(limit)))
|
|
166
|
+
conn = _connect()
|
|
167
|
+
try:
|
|
168
|
+
rows = conn.execute(
|
|
169
|
+
"""SELECT id, namespace, key, value, metadata, created_at
|
|
170
|
+
FROM memories WHERE namespace = ? ORDER BY id DESC""",
|
|
171
|
+
[NAMESPACE],
|
|
172
|
+
).fetchall()
|
|
173
|
+
except sqlite3.Error:
|
|
174
|
+
return []
|
|
175
|
+
finally:
|
|
176
|
+
conn.close()
|
|
177
|
+
|
|
178
|
+
# Latest entry per plugin name, keyed by the eval date in the key.
|
|
179
|
+
latest: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
180
|
+
for row in rows:
|
|
181
|
+
entry = _entry_from_row(row)
|
|
182
|
+
name, when = _name_and_date(entry.get("key", ""))
|
|
183
|
+
if name is None:
|
|
184
|
+
continue
|
|
185
|
+
if name not in latest or when > latest[name][0]:
|
|
186
|
+
entry["name"] = name
|
|
187
|
+
latest[name] = (when, entry)
|
|
188
|
+
|
|
189
|
+
ordered = sorted(latest.values(), key=lambda t: t[0], reverse=True)
|
|
190
|
+
return [entry for _, entry in ordered[:limit]]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_latest_eval(name: str) -> dict[str, Any] | None:
|
|
194
|
+
"""Return the latest eval entry for a plugin name, or None.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
name: Plugin/skill/agent name.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Entry dict (with parsed metadata and ``name``) or None.
|
|
201
|
+
"""
|
|
202
|
+
conn = _connect()
|
|
203
|
+
try:
|
|
204
|
+
# Exact prefix match via substr (NOT LIKE): plugin names may contain
|
|
205
|
+
# '_' or '%', which would act as wildcards under LIKE semantics.
|
|
206
|
+
prefix = f"eval:{name}:"
|
|
207
|
+
rows = conn.execute(
|
|
208
|
+
"""SELECT id, namespace, key, value, metadata, created_at
|
|
209
|
+
FROM memories
|
|
210
|
+
WHERE namespace = ? AND substr(key, 1, length(?)) = ?
|
|
211
|
+
ORDER BY created_at DESC, id DESC LIMIT 1""",
|
|
212
|
+
[NAMESPACE, prefix, prefix],
|
|
213
|
+
).fetchall()
|
|
214
|
+
except sqlite3.Error:
|
|
215
|
+
return None
|
|
216
|
+
finally:
|
|
217
|
+
conn.close()
|
|
218
|
+
|
|
219
|
+
if not rows:
|
|
220
|
+
return None
|
|
221
|
+
entry = _entry_from_row(rows[0])
|
|
222
|
+
entry["name"] = name
|
|
223
|
+
return entry
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _name_and_date(key: str) -> tuple[str | None, str]:
|
|
227
|
+
"""Split an ``eval:<name>:<date>`` key into (name, date)."""
|
|
228
|
+
if not key.startswith("eval:"):
|
|
229
|
+
return None, ""
|
|
230
|
+
parts = key.split(":")
|
|
231
|
+
if len(parts) < 3:
|
|
232
|
+
return None, ""
|
|
233
|
+
return ":".join(parts[1:-1]) or None, parts[-1]
|