pantheon-opencode 1.5.2 → 1.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +17 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -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
|
-
}
|