pantheon-opencode 1.0.0
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/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +1013 -0
- package/bin/pantheon-init.mjs +183 -0
- package/commands/pantheon-audit.md +25 -0
- package/commands/pantheon-bg.md +10 -0
- package/commands/pantheon-consolidate.md +11 -0
- package/commands/pantheon-deepwork.md +128 -0
- package/commands/pantheon-doc.md +10 -0
- package/commands/pantheon-focus.md +9 -0
- package/commands/pantheon-forget.md +58 -0
- package/commands/pantheon-hash.md +11 -0
- package/commands/pantheon-optimize.md +79 -0
- package/commands/pantheon-remember.md +44 -0
- package/commands/pantheon-search.md +48 -0
- package/commands/pantheon-status.md +71 -0
- package/commands/pantheon-todo.md +11 -0
- package/commands/pantheon.md +49 -0
- package/docs/AGENT-MCP.md +194 -0
- package/docs/ARCHITECTURE.md +384 -0
- package/docs/BRANCH-PROTECTION.md +142 -0
- package/docs/INDEX.md +81 -0
- package/docs/INSTALLATION.md +217 -0
- package/docs/MCP.md +238 -0
- package/docs/MEMORY.md +471 -0
- package/docs/MIGRATION-MEMORY-BANK.md +139 -0
- package/docs/PLATFORMS.md +5 -0
- package/docs/QUICKSTART.md +49 -0
- package/docs/README.md +18 -0
- package/docs/RELEASING.md +256 -0
- package/docs/SETUP.md +5 -0
- package/docs/UPGRADING.md +41 -0
- package/docs/mcp-recommendations.md +439 -0
- package/docs/mcp-tools.md +156 -0
- package/docs/mcp-user-guide.md +204 -0
- package/docs/persistence-mcp.md +111 -0
- package/package.json +72 -0
- package/pantheon.schema.json +158 -0
- package/scripts/__init__.py +0 -0
- package/scripts/_pantheon_paths.py +68 -0
- package/scripts/check-version-consistency.sh +23 -0
- package/scripts/code_mode_server.py +202 -0
- package/scripts/doctor.mjs +763 -0
- package/scripts/generate-prompts.sh +222 -0
- package/scripts/generate-routing-docs.mjs +104 -0
- package/scripts/hash_verify.py +192 -0
- package/scripts/init-pantheon-mcp.sh +118 -0
- package/scripts/install/agents-md.mjs +214 -0
- package/scripts/install/health-check.mjs +196 -0
- package/scripts/install/migrate.mjs +209 -0
- package/scripts/install/opencode.mjs +645 -0
- package/scripts/install/shared.mjs +655 -0
- package/scripts/install/venv.mjs +116 -0
- package/scripts/install-mcp.mjs +885 -0
- package/scripts/install.mjs +26 -0
- package/scripts/manifest.mjs +622 -0
- package/scripts/mcp_persistence_server.py +459 -0
- package/scripts/mcp_resources_server.py +206 -0
- package/scripts/memory_cache.py +78 -0
- package/scripts/memory_mcp_server.py +605 -0
- package/scripts/paths.py +64 -0
- package/scripts/prune_context.py +72 -0
- package/scripts/release-bundle.mjs +109 -0
- package/scripts/scrub-secrets.py +282 -0
- package/scripts/scrub_secrets.py +281 -0
- package/scripts/test-context-compression.sh +166 -0
- package/scripts/themis_heuristic_scan.py +287 -0
- package/scripts/todo_enforcer.py +242 -0
- package/scripts/uninstall.mjs +1057 -0
- package/scripts/validate-routing.mjs +160 -0
- package/scripts/validate_agent_frontmatter.py +226 -0
- package/scripts/versioning.mjs +254 -0
- package/skills-lock.json +16 -0
- package/src/agents/aphrodite.md +162 -0
- package/src/agents/apollo.md +109 -0
- package/src/agents/athena.md +226 -0
- package/src/agents/demeter.md +146 -0
- package/src/agents/gaia.md +82 -0
- package/src/agents/hephaestus.md +105 -0
- package/src/agents/hermes.md +302 -0
- package/src/agents/iris.md +99 -0
- package/src/agents/mnemosyne.md +226 -0
- package/src/agents/nyx.md +87 -0
- package/src/agents/prometheus.md +199 -0
- package/src/agents/talos.md +93 -0
- package/src/agents/themis.md +187 -0
- package/src/agents/zeus.md +209 -0
- package/src/instructions/agent-return-format.instructions.md +26 -0
- package/src/instructions/backend-standards.instructions.md +45 -0
- package/src/instructions/documentation-standards.instructions.md +53 -0
- package/src/instructions/frontend-standards.instructions.md +46 -0
- package/src/instructions/memory-protocol.instructions.md +67 -0
- package/src/instructions/yagni.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +72 -0
- package/src/instructions/zeus-communication-rules.instructions.md +15 -0
- package/src/instructions/zeus-council-synthesis.instructions.md +105 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +127 -0
- package/src/mcp/_pantheon_paths.py +67 -0
- package/src/mcp/code_mode_server.py +202 -0
- package/src/mcp/init-pantheon-mcp.sh +118 -0
- package/src/mcp/install-mcp.mjs +885 -0
- package/src/mcp/mcp_persistence_server.py +458 -0
- package/src/mcp/mcp_resources_server.py +205 -0
- package/src/mcp/memory_mcp_server.py +604 -0
- package/src/mcp/requirements-mcp-core.txt +5 -0
- package/src/mcp/requirements-mcp.txt +5 -0
- package/src/plugin.ts +19 -0
- package/src/plugins/tui/dist/tui.js +143 -0
- package/src/plugins/tui/dist/tui.tsx +144 -0
- package/src/plugins/tui/package.json +32 -0
- package/src/plugins/tui/src/index.tsx +144 -0
- package/src/plugins/tui/tsdown.config.ts +22 -0
- package/src/routing.yml +499 -0
- package/src/skills/README.md +230 -0
- package/src/skills/agent-coordination/SKILL.md +95 -0
- package/src/skills/artifact-management/SKILL.md +118 -0
- package/src/skills/auto-continue/SKILL.md +280 -0
- package/src/skills/code-review-checklist/SKILL.md +139 -0
- package/src/skills/context-compression/SKILL.md +861 -0
- package/src/skills/git-workflow-and-versioning/SKILL.md +32 -0
- package/src/skills/incremental-implementation/SKILL.md +27 -0
- package/src/skills/memory-bank/SKILL.md +165 -0
- package/src/skills/orchestration-workflow/SKILL.md +311 -0
- package/src/skills/security-hardening/SKILL.md +36 -0
- package/src/skills/session-goal/SKILL.md +138 -0
- package/src/skills/spec-driven-development/SKILL.md +23 -0
- package/src/skills/tdd-with-agents/SKILL.md +170 -0
- package/src/skills/visual-review-pipeline/SKILL.md +200 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# Auto-generated: resolved symlink from ../src/mcp/mcp_persistence_server.py
|
|
2
|
+
#!/usr/bin/env python3
|
|
3
|
+
"""Pantheon Persistence MCP Server.
|
|
4
|
+
|
|
5
|
+
Key-Value store with FTS5 full-text search, TTL-based expiration,
|
|
6
|
+
and namespace isolation. Uses SQLite with zero external dependencies.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python scripts/mcp_persistence_server.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import UTC, datetime, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from _pantheon_paths import pantheon_home, pantheon_project
|
|
22
|
+
from mcp.server.fastmcp import FastMCP
|
|
23
|
+
|
|
24
|
+
# ── Schema ──────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
CREATE_SQL: str = """
|
|
27
|
+
CREATE TABLE IF NOT EXISTS kv_store (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
namespace TEXT NOT NULL,
|
|
30
|
+
key TEXT NOT NULL,
|
|
31
|
+
value TEXT NOT NULL,
|
|
32
|
+
expires_at TEXT, -- ISO 8601, NULL = forever
|
|
33
|
+
deleted_at TEXT, -- NULL = active, set on TTL purge
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
35
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
36
|
+
UNIQUE(namespace, key)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS kv_store_fts USING fts5(
|
|
40
|
+
namespace, key, value,
|
|
41
|
+
content='kv_store', content_rowid='id',
|
|
42
|
+
tokenize='porter unicode61'
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TRIGGER IF NOT EXISTS kv_store_ai AFTER INSERT ON kv_store BEGIN
|
|
46
|
+
INSERT INTO kv_store_fts(rowid, namespace, key, value)
|
|
47
|
+
VALUES (new.id, new.namespace, new.key, new.value);
|
|
48
|
+
END;
|
|
49
|
+
|
|
50
|
+
CREATE TRIGGER IF NOT EXISTS kv_store_ad AFTER DELETE ON kv_store BEGIN
|
|
51
|
+
INSERT INTO kv_store_fts(kv_store_fts, rowid, namespace, key, value)
|
|
52
|
+
VALUES('delete', old.id, old.namespace, old.key, old.value);
|
|
53
|
+
END;
|
|
54
|
+
|
|
55
|
+
CREATE TRIGGER IF NOT EXISTS kv_store_au AFTER UPDATE ON kv_store BEGIN
|
|
56
|
+
INSERT INTO kv_store_fts(kv_store_fts, rowid, namespace, key, value)
|
|
57
|
+
VALUES('delete', old.id, old.namespace, old.key, old.value);
|
|
58
|
+
INSERT INTO kv_store_fts(rowid, namespace, key, value)
|
|
59
|
+
VALUES (new.id, new.namespace, new.key, new.value);
|
|
60
|
+
END;
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# ── FastMCP App ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
mcp = FastMCP(
|
|
66
|
+
"pantheon-persistence",
|
|
67
|
+
instructions="Key-Value store with FTS5 full-text search, "
|
|
68
|
+
"TTL-based expiration, and namespace isolation.",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ── Database Initialization ─────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
_global_db: sqlite3.Connection | None = None
|
|
74
|
+
_project_db: sqlite3.Connection | None = None
|
|
75
|
+
_DELETELOG_MAX_BYTES: int = 1_048_576 # 1 MB
|
|
76
|
+
_DELETELOG_KEEP: int = 3
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _init_db(db_path: Path) -> sqlite3.Connection:
|
|
80
|
+
"""Initialize a SQLite database with WAL mode and schema."""
|
|
81
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
83
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
84
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
85
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
86
|
+
# ── FTS5 Availability Check ──────────────────────────────────────────
|
|
87
|
+
# Check BEFORE schema creation. kv_search uses FTS5, which isn't on all systems.
|
|
88
|
+
row = conn.execute("PRAGMA compile_options").fetchall()
|
|
89
|
+
if not any("ENABLE_FTS5" in r[0] for r in row):
|
|
90
|
+
print(
|
|
91
|
+
"WARNING: SQLite FTS5 not available. kv_search will fail.", file=sys.stderr
|
|
92
|
+
)
|
|
93
|
+
import re as _re # noqa: PLC0415
|
|
94
|
+
|
|
95
|
+
_no_fts = _re.sub(
|
|
96
|
+
r"CREATE VIRTUAL TABLE IF NOT EXISTS kv_store_fts.*?;",
|
|
97
|
+
"",
|
|
98
|
+
CREATE_SQL,
|
|
99
|
+
flags=_re.DOTALL,
|
|
100
|
+
)
|
|
101
|
+
conn.executescript(_no_fts)
|
|
102
|
+
else:
|
|
103
|
+
conn.executescript(CREATE_SQL)
|
|
104
|
+
conn.commit()
|
|
105
|
+
|
|
106
|
+
return conn
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _db(scope: str) -> sqlite3.Connection:
|
|
110
|
+
"""Resolve the connection for the given scope."""
|
|
111
|
+
if scope == "global":
|
|
112
|
+
if _global_db is None:
|
|
113
|
+
raise RuntimeError("Global database not initialized")
|
|
114
|
+
return _global_db
|
|
115
|
+
if scope == "project":
|
|
116
|
+
if _project_db is None:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"Project database not available. "
|
|
119
|
+
"Set PANTHEON_PROJECT or use scope='global'"
|
|
120
|
+
)
|
|
121
|
+
return _project_db
|
|
122
|
+
raise ValueError(f"Unknown scope: {scope!r}. Expected 'global' or 'project'.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── Deletelog ────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _rotate_deletelog(log_path: Path) -> None:
|
|
129
|
+
"""Rotate deletelog at 1MB, keep last 3 rotated files."""
|
|
130
|
+
if not log_path.exists() or log_path.stat().st_size < _DELETELOG_MAX_BYTES:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Shift existing rotated files: .3 → remove, .2 → .3, .1 → .2
|
|
134
|
+
for i in range(_DELETELOG_KEEP, 0, -1):
|
|
135
|
+
older = log_path.with_suffix(f".deletelog.{i}")
|
|
136
|
+
if older.exists():
|
|
137
|
+
if i == _DELETELOG_KEEP:
|
|
138
|
+
older.unlink()
|
|
139
|
+
else:
|
|
140
|
+
older.rename(log_path.with_suffix(f".deletelog.{i + 1}"))
|
|
141
|
+
|
|
142
|
+
# Rename current log to .1
|
|
143
|
+
log_path.rename(log_path.with_suffix(".deletelog.1"))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_deletelog(db_path: Path, count: int, keys: list[str]) -> None:
|
|
147
|
+
"""Append a TTL purge entry to the deletelog."""
|
|
148
|
+
log_path = db_path.with_name(db_path.name + ".deletelog")
|
|
149
|
+
_rotate_deletelog(log_path)
|
|
150
|
+
|
|
151
|
+
timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
152
|
+
keys_str = ",".join(keys)
|
|
153
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
154
|
+
f.write(f"[{timestamp}] PURGED={count} KEYS=[{keys_str}]\n")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Resolve scope and init databases ────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
parser = argparse.ArgumentParser()
|
|
160
|
+
parser.add_argument("--global-db", default=None)
|
|
161
|
+
parser.add_argument("--project-db", default=None)
|
|
162
|
+
args = parser.parse_args()
|
|
163
|
+
|
|
164
|
+
_global_root = (
|
|
165
|
+
Path(args.global_db) if args.global_db else pantheon_home() / "persistence"
|
|
166
|
+
)
|
|
167
|
+
_global_db = _init_db(_global_root / "global.db")
|
|
168
|
+
|
|
169
|
+
_project_db_instance = None
|
|
170
|
+
if args.project_db:
|
|
171
|
+
_project_db_instance = _init_db(Path(args.project_db))
|
|
172
|
+
else:
|
|
173
|
+
_proj = pantheon_project()
|
|
174
|
+
if _proj:
|
|
175
|
+
_project_db_instance = _init_db(
|
|
176
|
+
_proj / ".pantheon" / "persistence" / "project.db"
|
|
177
|
+
)
|
|
178
|
+
_project_db = _project_db_instance
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── Tools ───────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@mcp.tool(
|
|
185
|
+
name="kv_store",
|
|
186
|
+
description="Store a key-value pair in a namespace with optional TTL (seconds). "
|
|
187
|
+
"INSERT OR REPLACE on duplicate (namespace, key).",
|
|
188
|
+
)
|
|
189
|
+
async def kv_store(
|
|
190
|
+
namespace: str,
|
|
191
|
+
key: str,
|
|
192
|
+
value: str,
|
|
193
|
+
ttl: int | None = None,
|
|
194
|
+
scope: str = "project",
|
|
195
|
+
) -> dict:
|
|
196
|
+
"""Store a value under namespace+key with optional TTL.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
namespace: Logical grouping for keys.
|
|
200
|
+
key: Unique key within the namespace.
|
|
201
|
+
value: String value to store.
|
|
202
|
+
ttl: Time-to-live in seconds (None = forever).
|
|
203
|
+
scope: 'project' (default) or 'global'.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Status dict with namespace and key.
|
|
207
|
+
"""
|
|
208
|
+
conn = _db(scope)
|
|
209
|
+
expires_at: str | None = None
|
|
210
|
+
if ttl is not None:
|
|
211
|
+
expires_at = (datetime.now(UTC) + timedelta(seconds=ttl)).isoformat()
|
|
212
|
+
|
|
213
|
+
conn.execute(
|
|
214
|
+
"INSERT INTO kv_store (namespace, key, value, expires_at, "
|
|
215
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))"
|
|
216
|
+
"ON CONFLICT(namespace, key) DO UPDATE SET "
|
|
217
|
+
" value = excluded.value, "
|
|
218
|
+
" expires_at = excluded.expires_at, "
|
|
219
|
+
" updated_at = datetime('now')",
|
|
220
|
+
(namespace, key, value, expires_at),
|
|
221
|
+
)
|
|
222
|
+
conn.commit()
|
|
223
|
+
return {"status": "stored", "namespace": namespace, "key": key}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@mcp.tool(
|
|
227
|
+
name="kv_get",
|
|
228
|
+
description="Retrieve a value by namespace and key. "
|
|
229
|
+
"Returns None if not found or expired.",
|
|
230
|
+
)
|
|
231
|
+
async def kv_get(
|
|
232
|
+
namespace: str,
|
|
233
|
+
key: str,
|
|
234
|
+
scope: str = "project",
|
|
235
|
+
) -> str | None:
|
|
236
|
+
"""Get a value by namespace and key.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
namespace: Logical grouping for keys.
|
|
240
|
+
key: Unique key within the namespace.
|
|
241
|
+
scope: 'project' (default) or 'global'.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Value string or None if not found/expired.
|
|
245
|
+
"""
|
|
246
|
+
conn = _db(scope)
|
|
247
|
+
row = conn.execute(
|
|
248
|
+
"SELECT value FROM kv_store "
|
|
249
|
+
"WHERE namespace = ? AND key = ? "
|
|
250
|
+
"AND (expires_at IS NULL OR expires_at > datetime('now')) "
|
|
251
|
+
"AND deleted_at IS NULL",
|
|
252
|
+
(namespace, key),
|
|
253
|
+
).fetchone()
|
|
254
|
+
return str(row[0]) if row else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@mcp.tool(
|
|
258
|
+
name="kv_delete",
|
|
259
|
+
description="Delete a key-value pair by namespace and key. "
|
|
260
|
+
"FTS trigger cascades automatically.",
|
|
261
|
+
)
|
|
262
|
+
async def kv_delete(
|
|
263
|
+
namespace: str,
|
|
264
|
+
key: str,
|
|
265
|
+
scope: str = "project",
|
|
266
|
+
) -> dict:
|
|
267
|
+
"""Delete a key-value pair.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
namespace: Logical grouping for keys.
|
|
271
|
+
key: Unique key within the namespace.
|
|
272
|
+
scope: 'project' (default) or 'global'.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Status dict indicating deletion outcome.
|
|
276
|
+
"""
|
|
277
|
+
conn = _db(scope)
|
|
278
|
+
cursor = conn.execute(
|
|
279
|
+
"DELETE FROM kv_store WHERE namespace = ? AND key = ?",
|
|
280
|
+
(namespace, key),
|
|
281
|
+
)
|
|
282
|
+
conn.commit()
|
|
283
|
+
if cursor.rowcount > 0:
|
|
284
|
+
return {"status": "deleted"}
|
|
285
|
+
return {"status": "not_found"}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@mcp.tool(
|
|
289
|
+
name="kv_list",
|
|
290
|
+
description="List keys in a namespace with optional prefix filter. "
|
|
291
|
+
"Returns up to 'limit' entries (default 100).",
|
|
292
|
+
)
|
|
293
|
+
async def kv_list(
|
|
294
|
+
namespace: str,
|
|
295
|
+
prefix: str = "",
|
|
296
|
+
scope: str = "project",
|
|
297
|
+
limit: int = 100,
|
|
298
|
+
) -> list[dict]:
|
|
299
|
+
"""List keys in a namespace, filtered by prefix.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
namespace: Logical grouping for keys.
|
|
303
|
+
prefix: Optional key prefix filter.
|
|
304
|
+
scope: 'project' (default) or 'global'.
|
|
305
|
+
limit: Maximum number of results (default 100).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
List of dicts with key, value, created_at, expires_at.
|
|
309
|
+
"""
|
|
310
|
+
conn = _db(scope)
|
|
311
|
+
rows = conn.execute(
|
|
312
|
+
"SELECT key, value, created_at, expires_at FROM kv_store "
|
|
313
|
+
"WHERE namespace = ? AND key LIKE ? "
|
|
314
|
+
"AND (expires_at IS NULL OR expires_at > datetime('now')) "
|
|
315
|
+
"AND deleted_at IS NULL "
|
|
316
|
+
"ORDER BY key LIMIT ?",
|
|
317
|
+
(namespace, f"{prefix}%", limit),
|
|
318
|
+
).fetchall()
|
|
319
|
+
return [
|
|
320
|
+
{
|
|
321
|
+
"key": r[0],
|
|
322
|
+
"value": r[1],
|
|
323
|
+
"created_at": r[2],
|
|
324
|
+
"expires_at": r[3],
|
|
325
|
+
}
|
|
326
|
+
for r in rows
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@mcp.tool(
|
|
331
|
+
name="kv_search",
|
|
332
|
+
description="Full-text search across keys and values using FTS5. "
|
|
333
|
+
"Optionally filter by namespace.",
|
|
334
|
+
)
|
|
335
|
+
async def kv_search(
|
|
336
|
+
query: str,
|
|
337
|
+
namespace: str | None = None,
|
|
338
|
+
scope: str = "project",
|
|
339
|
+
limit: int = 20,
|
|
340
|
+
) -> list[dict]:
|
|
341
|
+
"""Full-text search across keys and values.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
query: Free-text search terms (max 10 terms quoted).
|
|
345
|
+
namespace: Optional namespace filter.
|
|
346
|
+
scope: 'project' (default) or 'global'.
|
|
347
|
+
limit: Maximum number of results (default 20).
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
List of dicts with namespace, key, value, created_at, score.
|
|
351
|
+
"""
|
|
352
|
+
conn = _db(scope)
|
|
353
|
+
|
|
354
|
+
# Sanitize: extract up to 10 word tokens, wrap each in quotes
|
|
355
|
+
terms = re.findall(r"\w+", query)[:10]
|
|
356
|
+
if not terms:
|
|
357
|
+
return []
|
|
358
|
+
fts_query = " OR ".join(f'"{t}"' for t in terms)
|
|
359
|
+
|
|
360
|
+
sql: str = (
|
|
361
|
+
"SELECT kv_store.namespace, kv_store.key, kv_store.value, kv_store.created_at, "
|
|
362
|
+
"BM25(kv_store_fts) AS score "
|
|
363
|
+
"FROM kv_store_fts "
|
|
364
|
+
"JOIN kv_store ON kv_store_fts.rowid = kv_store.id "
|
|
365
|
+
"WHERE kv_store_fts MATCH ? "
|
|
366
|
+
"AND kv_store.deleted_at IS NULL "
|
|
367
|
+
"AND (kv_store.expires_at IS NULL OR kv_store.expires_at > datetime('now'))"
|
|
368
|
+
)
|
|
369
|
+
params: list[str | int] = [fts_query]
|
|
370
|
+
|
|
371
|
+
if namespace is not None:
|
|
372
|
+
sql += " AND kv_store.namespace = ?"
|
|
373
|
+
params.append(namespace)
|
|
374
|
+
|
|
375
|
+
sql += " ORDER BY score LIMIT ?"
|
|
376
|
+
params.append(limit)
|
|
377
|
+
|
|
378
|
+
rows = conn.execute(sql, params).fetchall()
|
|
379
|
+
return [
|
|
380
|
+
{
|
|
381
|
+
"namespace": r[0],
|
|
382
|
+
"key": r[1],
|
|
383
|
+
"value": r[2],
|
|
384
|
+
"created_at": r[3],
|
|
385
|
+
"score": round(float(r[4]), 4),
|
|
386
|
+
}
|
|
387
|
+
for r in rows
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@mcp.tool(
|
|
392
|
+
name="purge_expired",
|
|
393
|
+
description="Purge expired TTL entries. Optionally dry-run to see count "
|
|
394
|
+
"without deleting. Logs purged keys to deletelog.",
|
|
395
|
+
)
|
|
396
|
+
async def purge_expired(
|
|
397
|
+
scope: str = "project",
|
|
398
|
+
dry_run: bool = False,
|
|
399
|
+
) -> dict:
|
|
400
|
+
"""Purge expired TTL entries from the database.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
scope: 'project' (default) or 'global'.
|
|
404
|
+
dry_run: If True, only report count without purging.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Dict with purged count and dry_run flag.
|
|
408
|
+
"""
|
|
409
|
+
conn = _db(scope)
|
|
410
|
+
|
|
411
|
+
# Find expired entries first
|
|
412
|
+
expired = conn.execute(
|
|
413
|
+
"SELECT key FROM kv_store "
|
|
414
|
+
"WHERE expires_at IS NOT NULL "
|
|
415
|
+
"AND expires_at < datetime('now') "
|
|
416
|
+
"AND deleted_at IS NULL",
|
|
417
|
+
).fetchall()
|
|
418
|
+
expired_keys = [r[0] for r in expired]
|
|
419
|
+
count = len(expired_keys)
|
|
420
|
+
|
|
421
|
+
if dry_run:
|
|
422
|
+
return {"purged": count, "dry_run": True}
|
|
423
|
+
|
|
424
|
+
if count == 0:
|
|
425
|
+
return {"purged": 0, "dry_run": False}
|
|
426
|
+
|
|
427
|
+
# Mark as deleted (soft delete)
|
|
428
|
+
conn.execute(
|
|
429
|
+
"UPDATE kv_store SET deleted_at = datetime('now') "
|
|
430
|
+
"WHERE expires_at IS NOT NULL "
|
|
431
|
+
"AND expires_at < datetime('now') "
|
|
432
|
+
"AND deleted_at IS NULL",
|
|
433
|
+
)
|
|
434
|
+
conn.commit()
|
|
435
|
+
|
|
436
|
+
# Write deletelog
|
|
437
|
+
db_path = _resolve_db_path(scope)
|
|
438
|
+
if db_path:
|
|
439
|
+
_write_deletelog(db_path, count, expired_keys)
|
|
440
|
+
|
|
441
|
+
return {"purged": count, "dry_run": False}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _resolve_db_path(scope: str) -> Path | None:
|
|
445
|
+
"""Resolve the database file path for a given scope."""
|
|
446
|
+
if scope == "global":
|
|
447
|
+
return _global_root / "global.db"
|
|
448
|
+
if _project_db_instance:
|
|
449
|
+
# Find the project root from the project DB path
|
|
450
|
+
proj_root = pantheon_project()
|
|
451
|
+
if proj_root:
|
|
452
|
+
return proj_root / ".pantheon" / "persistence" / "project.db"
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ── Main Entrypoint ─────────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
if __name__ == "__main__":
|
|
459
|
+
mcp.run()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Auto-generated: resolved symlink from ../src/mcp/mcp_resources_server.py
|
|
2
|
+
#!/usr/bin/env python3
|
|
3
|
+
"""Pantheon MCP Resources Server.
|
|
4
|
+
|
|
5
|
+
Provides MCP resources for the Pantheon agent framework:
|
|
6
|
+
- Static resources: agents list, skills list, routing.yml
|
|
7
|
+
- Template resources: agent by name, skill by name, deepwork
|
|
8
|
+
plans/status, memory-bank files
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python scripts/mcp_resources_server.py
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
from _pantheon_paths import pantheon_home, pantheon_project
|
|
22
|
+
from mcp.server.fastmcp import FastMCP
|
|
23
|
+
|
|
24
|
+
# ── Path Resolution ──────────────────────────────────────────────────────────
|
|
25
|
+
_PANTHEON_HOME: Path = pantheon_home()
|
|
26
|
+
_PANTHEON_PROJECT: Path | None = pantheon_project()
|
|
27
|
+
|
|
28
|
+
# ── FastMCP App ───────────────────────────────────────────────────────────────
|
|
29
|
+
mcp = FastMCP(
|
|
30
|
+
"Pantheon Resources",
|
|
31
|
+
instructions="MCP Resources for the Pantheon agent framework. "
|
|
32
|
+
"Access agents, skills, routing configuration, deepwork plans, "
|
|
33
|
+
"and memory-bank files via the pantheon:// URI scheme.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _parse_yaml_frontmatter(filepath: Path) -> dict[str, Any]:
|
|
40
|
+
"""Parse YAML frontmatter from a markdown file."""
|
|
41
|
+
content = filepath.read_text(encoding="utf-8")
|
|
42
|
+
match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
|
|
43
|
+
if not match:
|
|
44
|
+
return {}
|
|
45
|
+
try:
|
|
46
|
+
return dict(yaml.safe_load(match.group(1)) or {})
|
|
47
|
+
except yaml.YAMLError:
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_role_from_frontmatter(frontmatter: dict[str, Any]) -> str:
|
|
52
|
+
"""Extract the role/description string from agent frontmatter."""
|
|
53
|
+
desc = frontmatter.get("description", "")
|
|
54
|
+
if not isinstance(desc, str) or not desc:
|
|
55
|
+
return "Pantheon agent"
|
|
56
|
+
if "—" in desc:
|
|
57
|
+
return desc.split("—")[0].strip()
|
|
58
|
+
return desc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Static Resources ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.resource(
|
|
65
|
+
"pantheon://agents",
|
|
66
|
+
description="List of all Pantheon agents with roles from YAML frontmatter",
|
|
67
|
+
)
|
|
68
|
+
async def list_agents() -> str:
|
|
69
|
+
"""Return a markdown list of all agents with their roles."""
|
|
70
|
+
agents_dir = _PANTHEON_HOME / "agents"
|
|
71
|
+
if not agents_dir.is_dir():
|
|
72
|
+
return "Agents directory not found."
|
|
73
|
+
|
|
74
|
+
agents: list[str] = []
|
|
75
|
+
for f in sorted(agents_dir.iterdir()):
|
|
76
|
+
if f.suffix == ".md" and f.stem.lower() != "readme":
|
|
77
|
+
frontmatter = _parse_yaml_frontmatter(f)
|
|
78
|
+
name = frontmatter.get("name", f.stem)
|
|
79
|
+
role = _get_role_from_frontmatter(frontmatter)
|
|
80
|
+
agents.append(f"- **{name}** — {role}")
|
|
81
|
+
|
|
82
|
+
return "\n".join(agents) if agents else "No agents found."
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.resource(
|
|
86
|
+
"pantheon://skills",
|
|
87
|
+
description="List of all Pantheon skills with descriptions",
|
|
88
|
+
)
|
|
89
|
+
async def list_skills() -> str:
|
|
90
|
+
"""Return a markdown list of all skills with descriptions."""
|
|
91
|
+
skills_dir = _PANTHEON_HOME / "skills"
|
|
92
|
+
if not skills_dir.is_dir():
|
|
93
|
+
return "Skills directory not found."
|
|
94
|
+
|
|
95
|
+
skills: list[str] = []
|
|
96
|
+
for f in sorted(skills_dir.iterdir()):
|
|
97
|
+
if f.is_dir():
|
|
98
|
+
skill_file = f / "SKILL.md"
|
|
99
|
+
if skill_file.exists():
|
|
100
|
+
frontmatter = _parse_yaml_frontmatter(skill_file)
|
|
101
|
+
name = frontmatter.get("name", f.name)
|
|
102
|
+
desc = frontmatter.get("description", "No description")
|
|
103
|
+
skills.append(f"- **{name}** — {desc}")
|
|
104
|
+
|
|
105
|
+
return "\n".join(skills) if skills else "No skills found."
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@mcp.resource(
|
|
109
|
+
"pantheon://routing",
|
|
110
|
+
description="Full content of routing.yml (canonical routing source)",
|
|
111
|
+
)
|
|
112
|
+
async def get_routing() -> str:
|
|
113
|
+
"""Return the full content of routing.yml."""
|
|
114
|
+
routing_file = _PANTHEON_HOME / "routing.yml"
|
|
115
|
+
if not routing_file.exists():
|
|
116
|
+
return "routing.yml not found."
|
|
117
|
+
return routing_file.read_text(encoding="utf-8")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Template Resources ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.resource(
|
|
124
|
+
"pantheon://agents/{agent_name}",
|
|
125
|
+
description="Content of an agent file by name (case-insensitive lookup)",
|
|
126
|
+
)
|
|
127
|
+
async def get_agent(agent_name: str) -> str:
|
|
128
|
+
"""Return the full content of an agent file, case-insensitively."""
|
|
129
|
+
agents_dir = _PANTHEON_HOME / "agents"
|
|
130
|
+
name_lower = agent_name.lower()
|
|
131
|
+
for f in agents_dir.iterdir():
|
|
132
|
+
if f.suffix == ".md" and f.stem.lower() == name_lower:
|
|
133
|
+
return f.read_text(encoding="utf-8")
|
|
134
|
+
return f"Agent '{agent_name}' not found."
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.resource(
|
|
138
|
+
"pantheon://deepwork/{slug}",
|
|
139
|
+
description="PLAN.md content for a deepwork task slug",
|
|
140
|
+
)
|
|
141
|
+
async def get_deepwork_plan(slug: str) -> str:
|
|
142
|
+
"""Return PLAN.md content for a deepwork slug."""
|
|
143
|
+
if _PANTHEON_PROJECT is None:
|
|
144
|
+
return f"Deepwork '{slug}' not found. (PANTHEON_PROJECT not set)"
|
|
145
|
+
plan_file = _PANTHEON_PROJECT / ".pantheon" / "deepwork" / slug / "PLAN.md"
|
|
146
|
+
if not plan_file.exists():
|
|
147
|
+
return f"Deepwork '{slug}' not found."
|
|
148
|
+
return plan_file.read_text(encoding="utf-8")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@mcp.resource(
|
|
152
|
+
"pantheon://deepwork/{slug}/status",
|
|
153
|
+
description="STATUS.md content for a deepwork task slug "
|
|
154
|
+
"(or default IN_PROGRESS message if missing)",
|
|
155
|
+
)
|
|
156
|
+
async def get_deepwork_status(slug: str) -> str:
|
|
157
|
+
"""Return STATUS.md content for a deepwork slug, or a default message."""
|
|
158
|
+
if _PANTHEON_PROJECT is None:
|
|
159
|
+
return "STATUS.md not found. (PANTHEON_PROJECT not set)"
|
|
160
|
+
status_file = _PANTHEON_PROJECT / ".pantheon" / "deepwork" / slug / "STATUS.md"
|
|
161
|
+
if not status_file.exists():
|
|
162
|
+
return "STATUS.md not found. Current state: IN PROGRESS"
|
|
163
|
+
return status_file.read_text(encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@mcp.resource(
|
|
167
|
+
"pantheon://skills/{name}",
|
|
168
|
+
description="Content of a skill's SKILL.md file by name",
|
|
169
|
+
)
|
|
170
|
+
async def get_skill(name: str) -> str:
|
|
171
|
+
"""Return SKILL.md content for a skill directory."""
|
|
172
|
+
skill_file = _PANTHEON_HOME / "skills" / name / "SKILL.md"
|
|
173
|
+
if not skill_file.exists():
|
|
174
|
+
return f"Skill '{name}' not found."
|
|
175
|
+
return skill_file.read_text(encoding="utf-8")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@mcp.resource(
|
|
179
|
+
"pantheon://memory-bank/{path}",
|
|
180
|
+
description="Content of a .pantheon/memory-bank/ file by relative path "
|
|
181
|
+
"(path traversal blocked).",
|
|
182
|
+
)
|
|
183
|
+
async def get_memory_bank(path: str) -> str:
|
|
184
|
+
"""Return content of a memory-bank file.
|
|
185
|
+
|
|
186
|
+
Security: resolves absolute path and verifies it stays within
|
|
187
|
+
.pantheon/memory-bank/ to prevent directory traversal attacks.
|
|
188
|
+
"""
|
|
189
|
+
if _PANTHEON_PROJECT is None:
|
|
190
|
+
return "Memory bank not available. (PANTHEON_PROJECT not set)"
|
|
191
|
+
resolved = (_PANTHEON_PROJECT / ".pantheon" / "memory-bank" / path).resolve()
|
|
192
|
+
mb_dir = (_PANTHEON_PROJECT / ".pantheon" / "memory-bank").resolve()
|
|
193
|
+
|
|
194
|
+
if not str(resolved).startswith(str(mb_dir)):
|
|
195
|
+
return "Path traversal blocked: access denied."
|
|
196
|
+
|
|
197
|
+
if not resolved.exists() or not resolved.is_file():
|
|
198
|
+
return f"File '{path}' not found."
|
|
199
|
+
|
|
200
|
+
return resolved.read_text(encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── Main Entrypoint ───────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
mcp.run()
|