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.
Files changed (71) hide show
  1. package/README.md +33 -1
  2. package/package.json +3 -3
  3. package/scripts/_pantheon_paths.py +0 -1
  4. package/scripts/build-agents-md.mjs +4 -2
  5. package/scripts/code_mode_server.py +307 -21
  6. package/scripts/doctor.mjs +26 -13
  7. package/scripts/hooks/format-multi-language.sh +6 -3
  8. package/scripts/hooks/log-session-start.sh +6 -0
  9. package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
  10. package/scripts/hooks/validate-post-conditions.sh +1 -1
  11. package/scripts/hooks/validate-tool-safety.sh +5 -2
  12. package/scripts/install/agents-md.mjs +3 -1
  13. package/scripts/install/cli-ui.mjs +24 -4
  14. package/scripts/install/interactive.mjs +18 -7
  15. package/scripts/install/migrate-config.mjs +13 -19
  16. package/scripts/install/migrate.mjs +8 -5
  17. package/scripts/install/model-picker.mjs +4 -1
  18. package/scripts/install/opencode.mjs +2 -0
  19. package/scripts/install/shared.mjs +6 -4
  20. package/scripts/install/state.mjs +9 -6
  21. package/scripts/mcp_persistence_server.py +23 -22
  22. package/scripts/mcp_resources_server.py +60 -1
  23. package/scripts/memory_mcp_server.py +80 -4
  24. package/scripts/postinstall.mjs +27 -25
  25. package/scripts/prune.mjs +1 -1
  26. package/scripts/redaction-gate.mjs +3 -1
  27. package/scripts/sync-tui.mjs +148 -0
  28. package/scripts/uninstall.mjs +0 -2
  29. package/scripts/validation-policy.mjs +6 -1
  30. package/scripts/version-check.mjs +9 -4
  31. package/src/agents/aphrodite.md +1 -1
  32. package/src/agents/demeter.md +1 -1
  33. package/src/agents/hephaestus.md +1 -1
  34. package/src/agents/hermes.md +1 -1
  35. package/src/agents/themis.md +4 -0
  36. package/src/mcp/code_mode_server.py +304 -18
  37. package/src/mcp/eval_store.py +233 -0
  38. package/src/mcp/mcp_persistence_server.py +23 -22
  39. package/src/mcp/mcp_resources_server.py +58 -0
  40. package/src/mcp/memory_mcp_server.py +80 -3
  41. package/src/pantheon/auto-wake.ts +2 -4
  42. package/src/pantheon/background-job-board.ts +17 -4
  43. package/src/pantheon/comment-checker.ts +48 -0
  44. package/src/pantheon/delegation-classifier.ts +307 -0
  45. package/src/pantheon/delegation-enforce.ts +21 -21
  46. package/src/pantheon/delegation-finalize.ts +44 -4
  47. package/src/pantheon/delegation-notify.ts +97 -0
  48. package/src/pantheon/delegation.ts +413 -23
  49. package/src/pantheon/dispatch-guard.ts +2 -0
  50. package/src/pantheon/file-persistence.ts +7 -3
  51. package/src/pantheon/permission-eval.ts +284 -0
  52. package/src/pantheon/permission-globs.ts +86 -0
  53. package/src/pantheon/presets.d.mts +41 -0
  54. package/src/pantheon/presets.mjs +134 -0
  55. package/src/pantheon/retry-policy.ts +212 -0
  56. package/src/pantheon/session-guard.ts +1 -3
  57. package/src/pantheon/step-cap.ts +110 -0
  58. package/src/pantheon/tui-version-check.ts +72 -0
  59. package/src/pantheon/vision.ts +7 -5
  60. package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
  61. package/src/plugin.ts +57 -3
  62. package/src/plugins/hook-runner.ts +1 -1
  63. package/src/plugins/pantheon-hooks.ts +1 -1
  64. package/src/plugins/tui/dist/tui.d.ts +1 -1
  65. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  66. package/src/plugins/tui/dist/tui.js +19 -14
  67. package/src/plugins/tui/dist/tui.tsx +52 -18
  68. package/src/plugins/tui/package.json +1 -1
  69. package/src/plugins/tui/src/index.tsx +106 -24
  70. package/src/plugins/tui/tsdown.config.ts +1 -1
  71. 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, stderr: str, exit_code: int, timed_out: bool = False
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 {SCRIPT_TIMEOUT}s limit")
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
- "with optional args. 30s timeout.",
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(script_name: str, args: list[str] | None = None) -> str:
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
- str(script_path),
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=SCRIPT_TIMEOUT
436
+ proc.communicate(), timeout=timeout_s
180
437
  )
181
- return _format_output(
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
- proc.kill()
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
- return _format_output("", "", -1, timed_out=True)
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
- f"Script '{script_name}' not found "
193
- f"or interpreter missing.\n--- exit code: -1"
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 f"Failed to execute script: {e}\n--- exit code: -1"
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]