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
@@ -1,321 +0,0 @@
1
- #!/usr/bin/env python3
2
- # ---
3
- # description: Static structural certification for a skill/agent directory
4
- # timeout: 30
5
- # ---
6
- """eval-static.py — static structural checks for Pantheon skills/agents.
7
-
8
- Runs deterministic, LLM-free structural checks against a skill or agent
9
- directory and prints a JSON report to stdout:
10
-
11
- frontmatter — SKILL.md (or agent *.md) exists with name + description
12
- referenced_files — files referenced in frontmatter or markdown links exist
13
- secrets — no obvious secrets (API keys, tokens, private keys)
14
- file_size — every file is under MAX_FILE_SIZE (100 KiB)
15
- yaml — YAML frontmatter parses
16
-
17
- Usage:
18
- python eval-static.py <path-to-skill-or-agent-dir>
19
-
20
- Output (stdout):
21
- {"name": ..., "checks": [{"check", "pass", "detail"}, ...], "score": 0-100}
22
-
23
- Exit codes:
24
- 0 — all checks passed
25
- 1 — one or more checks failed
26
- 2 — usage error (missing/invalid path)
27
-
28
- Stdlib only. Paths are self-resolved (expanduser + resolve); nothing is
29
- hardcoded. The frontmatter parser supports the flat YAML subset used by
30
- skill manifests (scalars, flow lists, block lists) and raises on anything
31
- it cannot parse so malformed frontmatter is flagged rather than ignored.
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- import argparse
37
- import json
38
- import re
39
- import sys
40
- from pathlib import Path
41
- from typing import Any
42
-
43
- MAX_FILE_SIZE = 100 * 1024 # every file must be < 100 KB
44
-
45
- # High-confidence credential shapes. Keep in sync with scripts/secret-scan.mjs.
46
- SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
47
- ("openai_key", re.compile(r"\b(sk|pk|rk)-[A-Za-z0-9]{20,}\b")),
48
- ("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
49
- ("github_pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
50
- ("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
51
- ("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
52
- ("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
53
- (
54
- "credential_assignment",
55
- re.compile(
56
- r"\b(?:api[_-]?key|secret|pass" + r"word|token)\s*[:=]\s*['\"]?[A-Za-z0-9_\-./+]{12,}",
57
- re.IGNORECASE,
58
- ),
59
- ),
60
- ]
61
-
62
- # Frontmatter keys whose values may reference local files.
63
- _REFERENCE_KEYS = ("scripts", "files", "location", "reference", "references")
64
-
65
- # Markdown link targets that are NOT local file references.
66
- _EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "#", "data:")
67
-
68
- _FM_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.DOTALL)
69
- _KEY_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$")
70
- _CLOSERS = {"[": "]", "{": "}"}
71
-
72
-
73
- # ── Minimal YAML-subset frontmatter parser (stdlib) ──────────────────────────
74
-
75
-
76
- def _scalar(raw: str) -> str:
77
- """Return a scalar value with optional surrounding quotes stripped."""
78
- raw = raw.strip()
79
- if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"":
80
- return raw[1:-1]
81
- return raw
82
-
83
-
84
- def parse_yaml_subset(text: str) -> dict[str, Any]:
85
- """Parse the flat YAML subset used by skill frontmatter.
86
-
87
- Supports ``key: scalar``, ``key: [a, b]`` flow lists and block lists
88
- (``key:`` followed by ``- item`` lines). Raises ValueError on anything
89
- it cannot parse (e.g. unbalanced brackets), so callers can flag
90
- malformed frontmatter instead of silently ignoring it.
91
- """
92
- data: dict[str, Any] = {}
93
- pending_list: str | None = None
94
- for lineno, line in enumerate(text.splitlines(), start=1):
95
- stripped = line.strip()
96
- if not stripped or stripped.startswith("#"):
97
- continue
98
- if stripped.startswith("-"):
99
- if pending_list is None:
100
- raise ValueError(f"line {lineno}: list item outside a mapping key")
101
- data[pending_list].append(_scalar(stripped[1:]))
102
- continue
103
- match = _KEY_RE.match(line)
104
- if not match:
105
- raise ValueError(f"line {lineno}: unparsable line: {stripped!r}")
106
- key, raw_value = match.group(1), match.group(2).strip()
107
- pending_list = None
108
- if not raw_value:
109
- data[key] = []
110
- pending_list = key # promoted to a real list if "- item" follows
111
- continue
112
- if raw_value[0] in _CLOSERS:
113
- closer = _CLOSERS[raw_value[0]]
114
- if not raw_value.endswith(closer):
115
- raise ValueError(f"line {lineno}: unbalanced {raw_value[0]!r} in {key!r}")
116
- inner = raw_value[1:-1].strip()
117
- data[key] = [_scalar(v) for v in inner.split(",")] if inner else []
118
- continue
119
- data[key] = _scalar(raw_value)
120
- return data
121
-
122
-
123
- def frontmatter_text(content: str) -> str | None:
124
- """Extract the raw frontmatter body from markdown content, or None."""
125
- match = _FM_RE.match(content)
126
- return match.group(1) if match else None
127
-
128
-
129
- def parse_frontmatter(filepath: Path) -> dict[str, Any]:
130
- """Parse frontmatter into a dict; {} when absent or unparsable."""
131
- text = frontmatter_text(filepath.read_text(encoding="utf-8", errors="replace"))
132
- if text is None:
133
- return {}
134
- try:
135
- return parse_yaml_subset(text)
136
- except ValueError:
137
- return {}
138
-
139
-
140
- # ── Path helpers ──────────────────────────────────────────────────────────────
141
-
142
-
143
- def find_manifest(dir_path: Path) -> Path | None:
144
- """Return the primary manifest file for a skill/agent directory.
145
-
146
- Prefers ``SKILL.md``; otherwise the first ``*.md`` file that is not
147
- ``README.md`` (agent files). Returns None if no manifest exists.
148
- """
149
- skill = dir_path / "SKILL.md"
150
- if skill.is_file():
151
- return skill
152
- for f in sorted(dir_path.iterdir()):
153
- if f.is_file() and f.suffix == ".md" and f.stem.lower() != "readme":
154
- return f
155
- return None
156
-
157
-
158
- def _iter_files(dir_path: Path) -> list[Path]:
159
- """Return all regular files under dir_path (recursive, sorted)."""
160
- return sorted(p for p in dir_path.rglob("*") if p.is_file())
161
-
162
-
163
- # ── Checks ────────────────────────────────────────────────────────────────────
164
-
165
-
166
- def check_frontmatter(dir_path: Path) -> tuple[bool, str]:
167
- """Check (a): manifest exists with required name + description."""
168
- manifest = find_manifest(dir_path)
169
- if manifest is None:
170
- return False, "No SKILL.md or agent .md manifest found"
171
- fm = parse_frontmatter(manifest)
172
- missing = [k for k in ("name", "description") if not fm.get(k)]
173
- if missing:
174
- return False, f"{manifest.name} missing frontmatter: {', '.join(missing)}"
175
- return True, f"{manifest.name} has name + description"
176
-
177
-
178
- def _frontmatter_references(fm: dict[str, Any]) -> list[str]:
179
- """Collect file-path-looking values from frontmatter reference keys."""
180
- refs: list[str] = []
181
- for key in _REFERENCE_KEYS:
182
- value = fm.get(key)
183
- if isinstance(value, str):
184
- refs.append(value)
185
- elif isinstance(value, list):
186
- refs.extend(v for v in value if isinstance(v, str))
187
- return [r for r in refs if r and not r.startswith(("http://", "https://", "#"))]
188
-
189
-
190
- def _markdown_references(manifest: Path) -> list[str]:
191
- """Collect relative markdown link targets from the manifest body."""
192
- content = manifest.read_text(encoding="utf-8", errors="replace")
193
- refs: list[str] = []
194
- for target in re.findall(r"\]\(([^)]+)\)", content):
195
- target = target.strip()
196
- if not target or target.startswith(_EXTERNAL_PREFIXES):
197
- continue
198
- # Strip optional anchor suffix (path#section)
199
- refs.append(target.split("#", 1)[0])
200
- return refs
201
-
202
-
203
- def check_referenced_files(dir_path: Path) -> tuple[bool, str]:
204
- """Check (b): referenced scripts/files exist relative to the dir."""
205
- manifest = find_manifest(dir_path)
206
- refs = _frontmatter_references(parse_frontmatter(manifest)) if manifest else []
207
- if manifest is not None:
208
- refs.extend(_markdown_references(manifest))
209
-
210
- missing = [r for r in refs if r and not (dir_path / r).exists()]
211
- if missing:
212
- return False, f"Missing referenced files: {', '.join(sorted(set(missing)))}"
213
- if refs:
214
- return True, f"All {len(refs)} referenced files exist"
215
- return True, "No local file references found"
216
-
217
-
218
- def check_secrets(dir_path: Path) -> tuple[bool, str]:
219
- """Check (c): no obvious secrets (API keys, tokens, private keys)."""
220
- hits: list[str] = []
221
- for f in _iter_files(dir_path):
222
- try:
223
- content = f.read_text(encoding="utf-8", errors="replace")
224
- except OSError:
225
- continue
226
- for name, pattern in SECRET_PATTERNS:
227
- if pattern.search(content):
228
- hits.append(f"{f.name} ({name})")
229
- if hits:
230
- return False, f"Potential secrets: {', '.join(sorted(set(hits)))}"
231
- return True, "No secrets detected"
232
-
233
-
234
- def check_file_sizes(dir_path: Path) -> tuple[bool, str]:
235
- """Check (d): every file is under MAX_FILE_SIZE."""
236
- oversized = [f for f in _iter_files(dir_path) if f.stat().st_size > MAX_FILE_SIZE]
237
- if oversized:
238
- biggest = max(oversized, key=lambda f: f.stat().st_size)
239
- return False, f"Oversized file: {biggest.name} ({biggest.stat().st_size} bytes)"
240
- return True, f"All files under {MAX_FILE_SIZE} bytes"
241
-
242
-
243
- def check_yaml(dir_path: Path) -> tuple[bool, str]:
244
- """Check (e): YAML frontmatter parses."""
245
- manifest = find_manifest(dir_path)
246
- if manifest is None:
247
- return True, "No manifest to parse"
248
- text = frontmatter_text(manifest.read_text(encoding="utf-8", errors="replace"))
249
- if text is None:
250
- return True, "No frontmatter to parse"
251
- try:
252
- parse_yaml_subset(text)
253
- except ValueError as exc:
254
- return False, f"Frontmatter does not parse: {exc}"
255
- return True, "Frontmatter parses"
256
-
257
-
258
- _CHECK_FUNCS: tuple[tuple[str, Any], ...] = (
259
- ("frontmatter", check_frontmatter),
260
- ("referenced_files", check_referenced_files),
261
- ("secrets", check_secrets),
262
- ("file_size", check_file_sizes),
263
- ("yaml", check_yaml),
264
- )
265
-
266
-
267
- # ── Report ────────────────────────────────────────────────────────────────────
268
-
269
-
270
- def run_eval(dir_path: Path) -> dict[str, Any]:
271
- """Run all checks and build the certification report dict."""
272
- results = [(name, fn(dir_path)) for name, fn in _CHECK_FUNCS]
273
- checks = [
274
- {"check": name, "pass": ok, "detail": detail}
275
- for name, (ok, detail) in results
276
- ]
277
- passed = sum(1 for _, (ok, _) in results if ok)
278
- total = len(results)
279
-
280
- manifest = find_manifest(dir_path)
281
- fm = parse_frontmatter(manifest) if manifest else {}
282
- return {
283
- "name": str(fm.get("name") or dir_path.name),
284
- "checks": checks,
285
- "score": round(passed / total * 100) if total else 0,
286
- }
287
-
288
-
289
- # ── CLI ───────────────────────────────────────────────────────────────────────
290
-
291
-
292
- def main(argv: list[str] | None = None) -> int:
293
- """CLI entrypoint: eval a skill/agent dir, print JSON, set exit code."""
294
- argv = sys.argv[1:] if argv is None else argv
295
-
296
- parser = argparse.ArgumentParser(
297
- prog="eval-static.py",
298
- description="Static structural certification for a skill/agent directory.",
299
- )
300
- parser.add_argument("path", nargs="?", help="Path to the skill or agent directory")
301
- args = parser.parse_args(argv)
302
-
303
- if not args.path:
304
- print("Usage: python eval-static.py <skill-or-agent-dir>", file=sys.stderr)
305
- return 2
306
-
307
- dir_path = Path(args.path).expanduser().resolve()
308
- if not dir_path.exists():
309
- print(f"Error: path not found: {args.path}", file=sys.stderr)
310
- return 2
311
- if not dir_path.is_dir():
312
- print(f"Error: not a directory: {args.path}", file=sys.stderr)
313
- return 2
314
-
315
- report = run_eval(dir_path)
316
- print(json.dumps(report, ensure_ascii=False, indent=2))
317
- return 0 if all(c["pass"] for c in report["checks"]) else 1
318
-
319
-
320
- if __name__ == "__main__":
321
- sys.exit(main())
@@ -1,186 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Session-end save — exports Vector DB entries to timestamped backup."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import os
9
- import subprocess
10
- import sys
11
- from datetime import UTC, datetime
12
- from pathlib import Path
13
-
14
- # Bootstrap: resolve PANTHEON_HOME before _pantheon_paths is available
15
- _home_env = os.environ.get("PANTHEON_HOME")
16
- if _home_env:
17
- _BOOT_HOME = Path(_home_env).expanduser().resolve()
18
- else:
19
- _xdg = os.environ.get("XDG_CONFIG_HOME")
20
- _BOOT_HOME = (
21
- Path(_xdg).expanduser().resolve() / "opencode"
22
- if _xdg
23
- else Path.home() / ".config" / "opencode"
24
- )
25
- sys.path.insert(0, str(_BOOT_HOME / "scripts"))
26
-
27
- from _pantheon_paths import pantheon_home, pantheon_project
28
- _PROJECT = pantheon_project()
29
- if _PROJECT is None:
30
- print("ERROR: PANTHEON_PROJECT not set and CWD unavailable")
31
- sys.exit(1)
32
- PROJECT_ROOT: Path = _PROJECT
33
- OUTPUT_DIR: Path = PROJECT_ROOT / ".pantheon" / "memory-bank" / ".tmp"
34
-
35
-
36
- def parse_args() -> argparse.Namespace:
37
- parser = argparse.ArgumentParser(description="Session-end memory save")
38
- parser.add_argument("--dry-run", action="store_true", help="Preview without saving")
39
- parser.add_argument("--agent", default="unknown", help="Agent name triggering save")
40
- return parser.parse_args()
41
-
42
-
43
- def detect_agent(args_agent: str) -> str:
44
- """Return agent name: CLI arg first, then env/fallback."""
45
- if args_agent and args_agent != "unknown":
46
- return args_agent
47
- # Fall back to env var
48
- from_env = sys.argv[0]
49
- for candidate in ("zeus", "hermes", "aphrodite", "demeter",
50
- "themis", "apollo", "athena", "talos",
51
- "prometheus", "hephaestus", "iris",
52
- "nyx", "gaia", "mnemosyne"):
53
- if candidate in from_env.lower():
54
- return candidate
55
- return "unknown"
56
-
57
-
58
- def try_export_memory() -> list[dict] | None:
59
- """Try to export entries from pantheon-memory via MCP.
60
-
61
- Tries three strategies:
62
- 1. Import memory_mcp_server module and call export directly
63
- 2. If ChromaDB unavailable, create placeholder (no crash)
64
-
65
- Returns list of dicts with keys: content, importance, category, session_id
66
- Returns None if absolutely unavailable (MCP down, no data).
67
- Client-side filtering by importance >= 0.4.
68
- """
69
- # Strategy 1: Try direct module import
70
- sys.path.insert(0, str(pantheon_home() / "scripts"))
71
- try:
72
- import vector_memory.index as vmi
73
- # Use memory_sessions-like approach via ChromaDB
74
- import chromadb
75
- db_path = pantheon_home() / "memory" / "chroma_db"
76
- if db_path.exists():
77
- client = chromadb.PersistentClient(str(db_path))
78
- collection = client.get_or_create_collection("pantheon_memory")
79
- results = collection.get()
80
- if results["ids"]:
81
- entries = []
82
- for i, doc_id in enumerate(results["ids"]):
83
- meta = results["metadatas"][i] if results["metadatas"] else {}
84
- doc = results["documents"][i] if results["documents"] else ""
85
- entries.append({
86
- "id": doc_id,
87
- "content": doc[:200] if doc else "",
88
- "importance": meta.get("importance", 0.5),
89
- "category": meta.get("category", "memory"),
90
- "session_id": meta.get("session_id", ""),
91
- "agent": meta.get("agent", "unknown"),
92
- })
93
- # Client-side filter by importance >= 0.4
94
- filtered = [e for e in entries if e["importance"] >= 0.4]
95
- if filtered:
96
- return filtered
97
- # If we have entries but all below threshold, don't return None
98
- # (means MCP is working, just no high-importance entries yet)
99
- return []
100
- except ImportError:
101
- pass
102
- except Exception as e:
103
- print(f" (MCP export note: {e})")
104
- pass
105
-
106
- # Strategy 2: MCP unavailable — return None (caller handles placeholder)
107
- return None
108
-
109
-
110
- def write_session_save(entries: list[dict], agent: str, dry_run: bool = False) -> Path | None:
111
- """Write session save file. Returns path if written, None if dry-run."""
112
- timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
113
- filename = f"session-save-{timestamp}.md"
114
- filepath = OUTPUT_DIR / filename
115
-
116
- if dry_run:
117
- print(f"[dry-run] Would save to: {filepath}")
118
- print(f"[dry-run] Entries: {len(entries)} from agent '{agent}'")
119
- return None
120
-
121
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
122
-
123
- lines = [
124
- f"# Session Save — {timestamp}",
125
- f"**Agent:** {agent}",
126
- f"**Entries:** {len(entries)}",
127
- "",
128
- "## Entries",
129
- ]
130
-
131
- if not entries:
132
- lines.append("*(No entries with importance >= 0.4)*")
133
- else:
134
- for entry in entries:
135
- content = entry.get("content", "—")
136
- importance = entry.get("importance", 0)
137
- category = entry.get("category", "memory")
138
- lines.append(f"- [{importance:.1f}] ({category}) {content[:200]}")
139
-
140
- filepath.write_text("\n".join(lines) + "\n")
141
- print(f" Saved {len(entries)} entries to {filepath}")
142
- return filepath
143
-
144
-
145
- def update_deepwork_status(entries_count: int, agent: str) -> None:
146
- """Append save record to active deepwork STATUS.md if found."""
147
- deepwork_dir = PROJECT_ROOT / ".pantheon" / "deepwork"
148
- if not deepwork_dir.exists():
149
- return
150
- for status_file in sorted(deepwork_dir.glob("*/STATUS.md")):
151
- timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
152
- line = f"- {timestamp}: Session save — {entries_count} entries (agent: {agent})"
153
- with open(status_file, "a") as f:
154
- f.write(f"{line}\n")
155
- print(f" Updated {status_file}")
156
-
157
-
158
- def main() -> None:
159
- args = parse_args()
160
- agent = detect_agent(args.agent)
161
-
162
- print(f"Running session-end save (agent: {agent})")
163
-
164
- if args.dry_run:
165
- print(" --dry-run mode — no files will be written")
166
-
167
- entries = try_export_memory()
168
-
169
- if entries is None:
170
- print(" pantheon-memory unavailable. Writing placeholder.")
171
- entries = []
172
-
173
- path = write_session_save(entries, agent, dry_run=args.dry_run)
174
-
175
- if path and not args.dry_run:
176
- update_deepwork_status(len(entries), agent)
177
-
178
- print()
179
- print(f" Agent: {agent}")
180
- print(f" Entries: {len(entries)}")
181
- print(f" Dry-run: {args.dry_run}")
182
- print("Session-end save complete.")
183
-
184
-
185
- if __name__ == "__main__":
186
- main()
@@ -1,18 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Wrapper for session-end-save.py
3
- # Usage: ./session-end-save.sh [agent_name] [--dry-run]
4
- #
5
- # Persists Vector DB entries to a timestamped backup file in
6
- # .pantheon/memory-bank/.tmp/ with client-side importance filtering.
7
- #
8
- # Arguments:
9
- # agent_name — name of the agent triggering the save (default: unknown)
10
- # --dry-run — preview without writing
11
-
12
- set -euo pipefail
13
-
14
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
- AGENT="${1:-unknown}"
16
- shift || true
17
-
18
- python3 "$SCRIPT_DIR/session-end-save.py" --agent "$AGENT" "$@"
@@ -1,146 +0,0 @@
1
- import { pathToFileURL } from 'node:url'
2
- import { validateZenodoResponse } from './zenodo-release-state.mjs'
3
-
4
- export function zenodoReleaseMarker(tag) {
5
- if (typeof tag !== 'string' || !/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(tag))
6
- throw new Error('Release tag is invalid.')
7
- return `pantheon-opencode-release:${tag}`
8
- }
9
-
10
- function depositionDescription(deposition) {
11
- return [deposition?.metadata?.description, deposition?.description]
12
- .filter((value) => typeof value === 'string')
13
- .join('\n')
14
- }
15
-
16
- function exactTotal(value) {
17
- if (Number.isSafeInteger(value) && value >= 0) return value
18
- if (
19
- value &&
20
- typeof value === 'object' &&
21
- Number.isSafeInteger(value.value) &&
22
- value.value >= 0 &&
23
- (value.relation === undefined || value.relation === 'eq')
24
- )
25
- return value.value
26
- return null
27
- }
28
-
29
- export async function findUniqueZenodoDeposition(
30
- depositionsUrl,
31
- token,
32
- releaseMarker,
33
- fetchImpl = fetch,
34
- ) {
35
- let url
36
- try {
37
- url = new URL(depositionsUrl)
38
- } catch {
39
- throw new Error('Zenodo deposition URL must be a valid absolute HTTPS URL.')
40
- }
41
- if (
42
- url.protocol !== 'https:' ||
43
- !url.hostname ||
44
- url.username ||
45
- url.password ||
46
- url.hash ||
47
- /%(?![0-9A-Fa-f]{2})/.test(depositionsUrl) ||
48
- /[\s{}\\|^`]/.test(depositionsUrl)
49
- )
50
- throw new Error('Zenodo deposition URL must be a valid HTTPS URL without credentials.')
51
- if (typeof releaseMarker !== 'string' || !releaseMarker)
52
- throw new Error('Zenodo release marker is required.')
53
- if (typeof token !== 'string' || !token) throw new Error('Zenodo token is required.')
54
- url.searchParams.set('q', releaseMarker)
55
- // Zenodo documents page/size for this endpoint. The response's links are
56
- // optional, so keep a deterministic fallback for the real hits/total shape.
57
- url.searchParams.set('page', '1')
58
- url.searchParams.set('size', '100')
59
- const candidates = []
60
- const visitedUrls = new Set()
61
- let nextUrl = url
62
- let pageNumber = 1
63
- let expectedTotal = null
64
- while (nextUrl) {
65
- const currentUrl = String(nextUrl)
66
- if (visitedUrls.has(currentUrl)) throw new Error('Zenodo search returned a pagination cycle.')
67
- visitedUrls.add(currentUrl)
68
- const response = await fetchImpl(nextUrl, {
69
- headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
70
- })
71
- if (!response.ok) throw new Error(`Zenodo deposition search failed (${response.status}).`)
72
- const payload = await response.json()
73
- if (Array.isArray(payload)) {
74
- throw new Error('Zenodo search returned ambiguous pagination.')
75
- }
76
- const page = payload?.hits?.hits
77
- const total = payload?.hits?.total
78
- const normalizedTotal = exactTotal(total)
79
- if (!Array.isArray(page) || normalizedTotal === null)
80
- throw new Error('Zenodo search returned ambiguous pagination.')
81
- if (expectedTotal !== null && expectedTotal !== normalizedTotal)
82
- throw new Error('Zenodo search returned inconsistent pagination totals.')
83
- expectedTotal = normalizedTotal
84
- candidates.push(...page)
85
- const rawNext = payload?.links?.next
86
- if (candidates.length >= expectedTotal) {
87
- nextUrl = null
88
- } else if (rawNext === null || rawNext === undefined || rawNext === '') {
89
- // Some Zenodo Cloud responses omit links entirely. Continue with the
90
- // documented page parameter instead of treating the first page as the
91
- // complete search result.
92
- if (page.length === 0)
93
- throw new Error('Zenodo search ended before all results were examined.')
94
- pageNumber += 1
95
- const pagedNext = new URL(url)
96
- pagedNext.searchParams.set('page', String(pageNumber))
97
- nextUrl = pagedNext
98
- } else {
99
- if (typeof rawNext !== 'string')
100
- throw new Error('Zenodo search returned an invalid pagination link.')
101
- let parsedNext
102
- try {
103
- parsedNext = new URL(rawNext, url)
104
- } catch {
105
- throw new Error('Zenodo search returned an invalid pagination link.')
106
- }
107
- if (
108
- parsedNext.protocol !== 'https:' ||
109
- parsedNext.origin !== url.origin ||
110
- parsedNext.pathname !== url.pathname
111
- )
112
- throw new Error('Zenodo search returned an unsafe pagination link.')
113
- const linkedPage = Number(parsedNext.searchParams.get('page'))
114
- if (Number.isSafeInteger(linkedPage) && linkedPage > pageNumber) {
115
- pageNumber = linkedPage
116
- }
117
- nextUrl = parsedNext
118
- }
119
- }
120
- const escapedMarker = releaseMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
121
- const markerPattern = new RegExp(`(?:^|[^A-Za-z0-9._:-])${escapedMarker}(?![A-Za-z0-9.-])`)
122
- const matches = candidates.filter((deposition) =>
123
- markerPattern.test(depositionDescription(deposition)),
124
- )
125
- if (matches.length === 0) return null
126
- if (matches.length !== 1) {
127
- throw new Error(
128
- `Zenodo recovery requires exactly one matching deposition; found ${matches.length}.`,
129
- )
130
- }
131
- return validateZenodoResponse(matches[0])
132
- }
133
-
134
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
135
- try {
136
- const result = await findUniqueZenodoDeposition(
137
- process.env.ZENODO_DEPOSITIONS_URL,
138
- process.env.ZENODO_TOKEN,
139
- process.env.RELEASE_MARKER,
140
- )
141
- process.stdout.write(JSON.stringify(result))
142
- } catch (error) {
143
- console.error(`zenodo recovery: ${error.message}`)
144
- process.exit(1)
145
- }
146
- }