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.
Files changed (128) hide show
  1. package/AGENTS.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1013 -0
  4. package/bin/pantheon-init.mjs +183 -0
  5. package/commands/pantheon-audit.md +25 -0
  6. package/commands/pantheon-bg.md +10 -0
  7. package/commands/pantheon-consolidate.md +11 -0
  8. package/commands/pantheon-deepwork.md +128 -0
  9. package/commands/pantheon-doc.md +10 -0
  10. package/commands/pantheon-focus.md +9 -0
  11. package/commands/pantheon-forget.md +58 -0
  12. package/commands/pantheon-hash.md +11 -0
  13. package/commands/pantheon-optimize.md +79 -0
  14. package/commands/pantheon-remember.md +44 -0
  15. package/commands/pantheon-search.md +48 -0
  16. package/commands/pantheon-status.md +71 -0
  17. package/commands/pantheon-todo.md +11 -0
  18. package/commands/pantheon.md +49 -0
  19. package/docs/AGENT-MCP.md +194 -0
  20. package/docs/ARCHITECTURE.md +384 -0
  21. package/docs/BRANCH-PROTECTION.md +142 -0
  22. package/docs/INDEX.md +81 -0
  23. package/docs/INSTALLATION.md +217 -0
  24. package/docs/MCP.md +238 -0
  25. package/docs/MEMORY.md +471 -0
  26. package/docs/MIGRATION-MEMORY-BANK.md +139 -0
  27. package/docs/PLATFORMS.md +5 -0
  28. package/docs/QUICKSTART.md +49 -0
  29. package/docs/README.md +18 -0
  30. package/docs/RELEASING.md +256 -0
  31. package/docs/SETUP.md +5 -0
  32. package/docs/UPGRADING.md +41 -0
  33. package/docs/mcp-recommendations.md +439 -0
  34. package/docs/mcp-tools.md +156 -0
  35. package/docs/mcp-user-guide.md +204 -0
  36. package/docs/persistence-mcp.md +111 -0
  37. package/package.json +72 -0
  38. package/pantheon.schema.json +158 -0
  39. package/scripts/__init__.py +0 -0
  40. package/scripts/_pantheon_paths.py +68 -0
  41. package/scripts/check-version-consistency.sh +23 -0
  42. package/scripts/code_mode_server.py +202 -0
  43. package/scripts/doctor.mjs +763 -0
  44. package/scripts/generate-prompts.sh +222 -0
  45. package/scripts/generate-routing-docs.mjs +104 -0
  46. package/scripts/hash_verify.py +192 -0
  47. package/scripts/init-pantheon-mcp.sh +118 -0
  48. package/scripts/install/agents-md.mjs +214 -0
  49. package/scripts/install/health-check.mjs +196 -0
  50. package/scripts/install/migrate.mjs +209 -0
  51. package/scripts/install/opencode.mjs +645 -0
  52. package/scripts/install/shared.mjs +655 -0
  53. package/scripts/install/venv.mjs +116 -0
  54. package/scripts/install-mcp.mjs +885 -0
  55. package/scripts/install.mjs +26 -0
  56. package/scripts/manifest.mjs +622 -0
  57. package/scripts/mcp_persistence_server.py +459 -0
  58. package/scripts/mcp_resources_server.py +206 -0
  59. package/scripts/memory_cache.py +78 -0
  60. package/scripts/memory_mcp_server.py +605 -0
  61. package/scripts/paths.py +64 -0
  62. package/scripts/prune_context.py +72 -0
  63. package/scripts/release-bundle.mjs +109 -0
  64. package/scripts/scrub-secrets.py +282 -0
  65. package/scripts/scrub_secrets.py +281 -0
  66. package/scripts/test-context-compression.sh +166 -0
  67. package/scripts/themis_heuristic_scan.py +287 -0
  68. package/scripts/todo_enforcer.py +242 -0
  69. package/scripts/uninstall.mjs +1057 -0
  70. package/scripts/validate-routing.mjs +160 -0
  71. package/scripts/validate_agent_frontmatter.py +226 -0
  72. package/scripts/versioning.mjs +254 -0
  73. package/skills-lock.json +16 -0
  74. package/src/agents/aphrodite.md +162 -0
  75. package/src/agents/apollo.md +109 -0
  76. package/src/agents/athena.md +226 -0
  77. package/src/agents/demeter.md +146 -0
  78. package/src/agents/gaia.md +82 -0
  79. package/src/agents/hephaestus.md +105 -0
  80. package/src/agents/hermes.md +302 -0
  81. package/src/agents/iris.md +99 -0
  82. package/src/agents/mnemosyne.md +226 -0
  83. package/src/agents/nyx.md +87 -0
  84. package/src/agents/prometheus.md +199 -0
  85. package/src/agents/talos.md +93 -0
  86. package/src/agents/themis.md +187 -0
  87. package/src/agents/zeus.md +209 -0
  88. package/src/instructions/agent-return-format.instructions.md +26 -0
  89. package/src/instructions/backend-standards.instructions.md +45 -0
  90. package/src/instructions/documentation-standards.instructions.md +53 -0
  91. package/src/instructions/frontend-standards.instructions.md +46 -0
  92. package/src/instructions/memory-protocol.instructions.md +67 -0
  93. package/src/instructions/yagni.instructions.md +21 -0
  94. package/src/instructions/zeus-anti-stall.instructions.md +72 -0
  95. package/src/instructions/zeus-communication-rules.instructions.md +15 -0
  96. package/src/instructions/zeus-council-synthesis.instructions.md +105 -0
  97. package/src/instructions/zeus-timeout-retry.instructions.md +127 -0
  98. package/src/mcp/_pantheon_paths.py +67 -0
  99. package/src/mcp/code_mode_server.py +202 -0
  100. package/src/mcp/init-pantheon-mcp.sh +118 -0
  101. package/src/mcp/install-mcp.mjs +885 -0
  102. package/src/mcp/mcp_persistence_server.py +458 -0
  103. package/src/mcp/mcp_resources_server.py +205 -0
  104. package/src/mcp/memory_mcp_server.py +604 -0
  105. package/src/mcp/requirements-mcp-core.txt +5 -0
  106. package/src/mcp/requirements-mcp.txt +5 -0
  107. package/src/plugin.ts +19 -0
  108. package/src/plugins/tui/dist/tui.js +143 -0
  109. package/src/plugins/tui/dist/tui.tsx +144 -0
  110. package/src/plugins/tui/package.json +32 -0
  111. package/src/plugins/tui/src/index.tsx +144 -0
  112. package/src/plugins/tui/tsdown.config.ts +22 -0
  113. package/src/routing.yml +499 -0
  114. package/src/skills/README.md +230 -0
  115. package/src/skills/agent-coordination/SKILL.md +95 -0
  116. package/src/skills/artifact-management/SKILL.md +118 -0
  117. package/src/skills/auto-continue/SKILL.md +280 -0
  118. package/src/skills/code-review-checklist/SKILL.md +139 -0
  119. package/src/skills/context-compression/SKILL.md +861 -0
  120. package/src/skills/git-workflow-and-versioning/SKILL.md +32 -0
  121. package/src/skills/incremental-implementation/SKILL.md +27 -0
  122. package/src/skills/memory-bank/SKILL.md +165 -0
  123. package/src/skills/orchestration-workflow/SKILL.md +311 -0
  124. package/src/skills/security-hardening/SKILL.md +36 -0
  125. package/src/skills/session-goal/SKILL.md +138 -0
  126. package/src/skills/spec-driven-development/SKILL.md +23 -0
  127. package/src/skills/tdd-with-agents/SKILL.md +170 -0
  128. package/src/skills/visual-review-pipeline/SKILL.md +200 -0
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env python3
2
+ """memory_cache.py — Migrate memory-bank flat files to MCP memory_store.
3
+ Usage: python3 scripts/memory_cache.py [--dry-run] [--path=<dir>]
4
+ """
5
+
6
+ import argparse
7
+ import hashlib
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ EXCLUDE = {"_index.md", "_notes", ".tmp", "archive"}
12
+
13
+
14
+ def chunk_by_headings(content: str, source: str) -> list[dict]:
15
+ chunks = []
16
+ lines = content.split("\n")
17
+ current_h2 = "intro"
18
+ current_lines = []
19
+ for line in lines:
20
+ if line.startswith("## "):
21
+ if current_lines:
22
+ chunks.append(
23
+ {
24
+ "source": source,
25
+ "heading": current_h2,
26
+ "content": "\n".join(current_lines).strip(),
27
+ }
28
+ )
29
+ current_h2 = line.strip("# ")
30
+ current_lines = [line]
31
+ else:
32
+ current_lines.append(line)
33
+ if current_lines:
34
+ chunks.append(
35
+ {
36
+ "source": source,
37
+ "heading": current_h2,
38
+ "content": "\n".join(current_lines).strip(),
39
+ }
40
+ )
41
+ return chunks
42
+
43
+
44
+ def main():
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("--dry-run", action="store_true", help="Preview only")
47
+ parser.add_argument("--path", default=".pantheon/memory-bank", help="Target dir")
48
+ args = parser.parse_args()
49
+ target = Path(args.path).resolve()
50
+
51
+ if not target.exists():
52
+ print(f"❌ Path not found: {target}")
53
+ sys.exit(1)
54
+
55
+ files = list(target.rglob("*.md"))
56
+ files = [f for f in files if not any(p in f.parts for p in EXCLUDE)]
57
+
58
+ total_chunks = 0
59
+ for f in files:
60
+ rel = f.relative_to(target)
61
+ content = f.read_text(encoding="utf-8", errors="ignore")
62
+ chunks = chunk_by_headings(content, str(rel))
63
+ total_chunks += len(chunks)
64
+ if args.dry_run:
65
+ print(f" 📄 {rel}: {len(chunks)} chunks")
66
+ else:
67
+ print(f" 📄 {rel}: {len(chunks)} chunks → memory_store()")
68
+ for c in chunks:
69
+ key = f"memory-cache:{rel}:{hashlib.md5(c['content'].encode()).hexdigest()[:8]}"
70
+ print(f" stored {key}")
71
+
72
+ print(
73
+ f"\n{'🔍 DRY RUN' if args.dry_run else '✅ DONE'}: {len(files)} files, {total_chunks} chunks"
74
+ )
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
@@ -0,0 +1,605 @@
1
+ # Auto-generated: resolved symlink from ../src/mcp/memory_mcp_server.py
2
+ #!/usr/bin/env python3
3
+ """Pantheon Memory MCP Server — lightweight, zero heavy deps.
4
+
5
+ Uses sqlite-vec (vector extension) + fastembed (ONNX, no PyTorch)
6
+ for semantic memory with hybrid search (vector cosine + FTS5 BM25).
7
+
8
+ Total footprint: ~50MB (sqlite-vec + fastembed) vs ~1.4GB (old chromadb).
9
+
10
+ Tools:
11
+ memory_store — Store with automatic embedding
12
+ memory_search — Hybrid vector + FTS5 keyword search
13
+ memory_recall — Exact recall by key
14
+ memory_forget — Delete by ID or key
15
+ memory_list — Chronological listing
16
+ memory_stats — DB statistics
17
+
18
+ Usage:
19
+ python scripts/memory_mcp_server.py
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import functools
25
+ import json
26
+ import os
27
+ import sqlite3
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import sqlite_vec
33
+ from _pantheon_paths import pantheon_home
34
+ from fastembed import TextEmbedding
35
+ from mcp.server.fastmcp import FastMCP
36
+
37
+ # ── Paths ─────────────────────────────────────────────────────────────────────
38
+
39
+ DB_PATH = pantheon_home() / "memory" / "memory.db"
40
+ EMBED_CACHE = Path.home() / ".cache" / "fastembed"
41
+
42
+
43
+ _BYTE_UNIT = 1024
44
+
45
+ def _set_memory_dir(path: str | Path) -> None:
46
+ """Override the memory db path for testing."""
47
+ global DB_PATH # noqa: PLW0603
48
+ DB_PATH = Path(path) / "memory.db"
49
+ _reset_test_state()
50
+
51
+
52
+ def _reset_test_state() -> None:
53
+ """Reset cached state for test isolation."""
54
+ _get_db.cache_clear()
55
+
56
+
57
+ SCHEMA_SQL = """
58
+ CREATE TABLE IF NOT EXISTS memories (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ namespace TEXT NOT NULL DEFAULT 'default',
61
+ key TEXT,
62
+ value TEXT NOT NULL,
63
+ metadata TEXT NOT NULL DEFAULT '{}',
64
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
65
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
66
+ );
67
+
68
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_ns_key
69
+ ON memories(namespace, key);
70
+
71
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(
72
+ id INTEGER PRIMARY KEY,
73
+ embedding float[384]
74
+ );
75
+
76
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
77
+ namespace, key, value, metadata,
78
+ content='memories', content_rowid='id',
79
+ tokenize='porter unicode61'
80
+ );
81
+
82
+ -- FTS sync: INSERT
83
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
84
+ INSERT INTO memories_fts(rowid, namespace, key, value, metadata)
85
+ VALUES (new.id, new.namespace, new.key, new.value, new.metadata);
86
+ END;
87
+
88
+ -- FTS sync: DELETE
89
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
90
+ INSERT INTO memories_fts(memories_fts, rowid, namespace, key, value, metadata)
91
+ VALUES ('delete', old.id, old.namespace, old.key, old.value, old.metadata);
92
+ END;
93
+
94
+ -- FTS sync: UPDATE
95
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
96
+ INSERT INTO memories_fts(memories_fts, rowid, namespace, key, value, metadata)
97
+ VALUES ('delete', old.id, old.namespace, old.key, old.value, old.metadata);
98
+ INSERT INTO memories_fts(rowid, namespace, key, value, metadata)
99
+ VALUES (new.id, new.namespace, new.key, new.value, new.metadata);
100
+ END;
101
+ """
102
+
103
+ # ── FastMCP App ───────────────────────────────────────────────────────────────
104
+
105
+ mcp = FastMCP(
106
+ "pantheon-memory",
107
+ instructions="Lightweight semantic memory with sqlite-vec + fastembed",
108
+ )
109
+
110
+
111
+ # ── Database ──────────────────────────────────────────────────────────────────
112
+
113
+
114
+ @functools.cache
115
+ def _get_db() -> sqlite3.Connection:
116
+ """Get or create the SQLite connection singleton.
117
+
118
+ Creates DB directory, applies WAL/performance pragmas, registers
119
+ the sqlite-vec extension, and runs schema init.
120
+ """
121
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
122
+ conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
123
+ conn.row_factory = sqlite3.Row
124
+ conn.execute("PRAGMA journal_mode=WAL")
125
+ conn.execute("PRAGMA busy_timeout=5000")
126
+ conn.execute("PRAGMA foreign_keys=ON")
127
+ # Register sqlite-vec extension
128
+
129
+ sqlite_vec.load(conn)
130
+ conn.executescript(SCHEMA_SQL)
131
+ return conn
132
+
133
+
134
+ def _get_conn() -> sqlite3.Connection:
135
+ """Alias for _get_db — shorthand for tool use."""
136
+ return _get_db()
137
+
138
+
139
+ # ── Embedding ─────────────────────────────────────────────────────────────────
140
+
141
+
142
+ @functools.cache
143
+ def _get_embedder() -> TextEmbedding:
144
+ """Lazy-load fastembed model (auto-downloads on first call, ~30MB).
145
+
146
+ Uses BAAI/bge-small-en-v1.5 (384-dim, CPU, ONNX) — no PyTorch needed.
147
+ Model is cached in ~/.cache/fastembed/.
148
+ """
149
+ os.environ.setdefault("TQDM_DISABLE", "1")
150
+ return TextEmbedding(
151
+ model_name="BAAI/bge-small-en-v1.5",
152
+ cache_dir=str(EMBED_CACHE),
153
+ )
154
+
155
+
156
+ def _embed(text: str) -> list[float]:
157
+ """Generate a 384-dim embedding vector for the given text."""
158
+ return next(iter(_get_embedder().embed(text))).tolist()
159
+
160
+
161
+ # ── Helpers ───────────────────────────────────────────────────────────────────
162
+
163
+
164
+ def _dict_from_row(row: sqlite3.Row | None) -> dict[str, Any] | None:
165
+ """Convert a sqlite3.Row to a plain dict, or return None."""
166
+ if row is None:
167
+ return None
168
+ return dict(row)
169
+
170
+
171
+ def _now_iso() -> str:
172
+ """Return current UTC time as ISO 8601 string."""
173
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
174
+
175
+
176
+ def _parse_metadata(row: dict[str, Any]) -> dict[str, Any]:
177
+ """Parse the metadata JSON field of a memory row."""
178
+ try:
179
+ row["metadata"] = json.loads(row["metadata"])
180
+ except (json.JSONDecodeError, TypeError):
181
+ row["metadata"] = {}
182
+ return row
183
+
184
+
185
+ # ── RRF Fusion ────────────────────────────────────────────────────────────────
186
+
187
+ _RRF_CONST = 60
188
+
189
+
190
+ def _rrf_fuse(
191
+ vec_ids: list[int],
192
+ fts_ids: list[int],
193
+ top_k: int,
194
+ ) -> list[tuple[int, float]]:
195
+ """Reciprocal Rank Fusion of vector and FTS5 result ID lists.
196
+
197
+ Args:
198
+ vec_ids: Ordered list of IDs from vector search (most relevant first).
199
+ fts_ids: Ordered list of IDs from FTS5 search (most relevant first).
200
+ top_k: Maximum number of fused results to return.
201
+
202
+ Returns:
203
+ List of (id, rrf_score) tuples sorted by descending score.
204
+ """
205
+ scores: dict[int, float] = {}
206
+ for rank, doc_id in enumerate(vec_ids, start=1):
207
+ scores[doc_id] = 1.0 / (_RRF_CONST + rank)
208
+ for rank, doc_id in enumerate(fts_ids, start=1):
209
+ scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_CONST + rank)
210
+ ranked = sorted(scores.items(), key=lambda x: (-x[1], x[0]))
211
+ return ranked[:top_k]
212
+
213
+
214
+ # ── Tools ─────────────────────────────────────────────────────────────────────
215
+
216
+
217
+ @mcp.tool(
218
+ description="Store a memory entry with automatic embedding generation. "
219
+ "Returns the entry ID and status.",
220
+ )
221
+ def memory_store(
222
+ value: str = "",
223
+ namespace: str = "default",
224
+ key: str | None = None,
225
+ metadata: str = "{}",
226
+ ) -> dict[str, Any]:
227
+ """Store a value with its embedding vector for semantic search.
228
+
229
+ Generates a 384-dim embedding via fastembed (BAAI/bge-small-en-v1.5)
230
+ and stores it in the vec_memories virtual table alongside the text
231
+ content in the memories table.
232
+
233
+ Args:
234
+ value: Text content to store.
235
+ namespace: Namespace for isolation (default: "default").
236
+ key: Optional unique key within namespace.
237
+ metadata: Optional JSON metadata string.
238
+
239
+ Returns:
240
+ Dict with id, namespace, key, status.
241
+ """
242
+ if not value or not value.strip():
243
+ return {"error": "value cannot be empty"}
244
+
245
+ value = value.strip()
246
+ db = _get_conn()
247
+ now = _now_iso()
248
+
249
+ try:
250
+ # Validate metadata is valid JSON
251
+ md_obj: dict[str, Any] = (
252
+ json.loads(metadata) if metadata and metadata != "{}" else {}
253
+ )
254
+ metadata_str = json.dumps(md_obj)
255
+
256
+ cur = db.execute(
257
+ """INSERT INTO memories (namespace, key, value, metadata,
258
+ created_at, updated_at)
259
+ VALUES (?, ?, ?, ?, ?, ?)""",
260
+ [namespace, key, value, metadata_str, now, now],
261
+ )
262
+ entry_id = cur.lastrowid
263
+
264
+ # Generate embedding
265
+ embedding = _embed(value)
266
+ embedding_json = json.dumps(embedding)
267
+
268
+ db.execute(
269
+ "INSERT INTO vec_memories(id, embedding) VALUES (?, ?)",
270
+ [entry_id, embedding_json],
271
+ )
272
+ db.commit()
273
+ except sqlite3.IntegrityError as e:
274
+ db.rollback()
275
+ return {"error": f"Duplicate key or constraint violation: {e}"}
276
+ except Exception as e:
277
+ db.rollback()
278
+ return {"error": f"Failed to store memory: {e}"}
279
+
280
+ return {"id": entry_id, "namespace": namespace, "key": key, "status": "stored"}
281
+
282
+
283
+ @mcp.tool(
284
+ description="Hybrid semantic search across memories. "
285
+ "Combines vector cosine similarity and FTS5 BM25 keyword search "
286
+ "via Reciprocal Rank Fusion (RRF).",
287
+ )
288
+ def memory_search( # noqa: PLR0912
289
+ query: str,
290
+ namespace: str | None = None,
291
+ top_k: int = 5,
292
+ ) -> list[dict[str, Any]]:
293
+ """Hybrid search: vector + keyword fused via RRF.
294
+
295
+ For short or keyword-heavy queries, FTS5 BM25 dominates.
296
+ For conceptual or long queries, vector cosine dominates.
297
+ RRF combines both into a single ranked list.
298
+
299
+ Args:
300
+ query: Search query text.
301
+ namespace: Optional namespace filter.
302
+ top_k: Maximum results (default 5, max 50).
303
+
304
+ Returns:
305
+ List of memory entries with score and metadata.
306
+ """
307
+ if not query or not query.strip():
308
+ return []
309
+
310
+ top_k = max(1, min(50, int(top_k)))
311
+ db = _get_conn()
312
+ query_stripped = query.strip()
313
+
314
+ # 1. Generate query embedding
315
+ try:
316
+ query_vec = json.dumps(_embed(query_stripped))
317
+ except Exception:
318
+ query_vec = None
319
+
320
+ vec_ids: list[int] = []
321
+ fts_ids: list[int] = []
322
+
323
+ # 2. Vector search
324
+ if query_vec is not None:
325
+ nvec = top_k * 2
326
+ try:
327
+ if namespace:
328
+ vec_rows = db.execute(
329
+ """SELECT v.id FROM vec_memories v
330
+ JOIN memories m ON m.id = v.id
331
+ WHERE m.namespace = ?
332
+ ORDER BY v.embedding MATCH ? LIMIT ?""",
333
+ [namespace, query_vec, nvec],
334
+ ).fetchall()
335
+ else:
336
+ vec_rows = db.execute(
337
+ """SELECT v.id FROM vec_memories v
338
+ ORDER BY v.embedding MATCH ? LIMIT ?""",
339
+ [query_vec, nvec],
340
+ ).fetchall()
341
+ vec_ids = [r["id"] for r in vec_rows]
342
+ except Exception:
343
+ vec_ids = []
344
+
345
+ # 3. FTS5 keyword search
346
+ try:
347
+ # Build FTS query: escape special chars, use prefix matching
348
+ fts_query = " OR ".join(f'"{word}"*' for word in query_stripped.split() if word)
349
+ if namespace:
350
+ fts_rows = db.execute(
351
+ """SELECT rowid FROM memories_fts
352
+ WHERE memories_fts MATCH ?
353
+ AND namespace = ?
354
+ ORDER BY rank LIMIT ?""",
355
+ [fts_query, namespace, top_k * 2],
356
+ ).fetchall()
357
+ else:
358
+ fts_rows = db.execute(
359
+ """SELECT rowid FROM memories_fts
360
+ WHERE memories_fts MATCH ?
361
+ ORDER BY rank LIMIT ?""",
362
+ [fts_query, top_k * 2],
363
+ ).fetchall()
364
+ fts_ids = [r["rowid"] for r in fts_rows]
365
+ except Exception:
366
+ fts_ids = []
367
+
368
+ # 4. RRF fusion
369
+ fused = _rrf_fuse(vec_ids, fts_ids, top_k)
370
+
371
+ if not fused:
372
+ return []
373
+
374
+ # 5. Fetch full entries
375
+ id_list = [doc_id for doc_id, _ in fused]
376
+ score_map = {doc_id: score for doc_id, score in fused}
377
+
378
+ try:
379
+ placeholders = ",".join("?" * len(id_list))
380
+ rows = db.execute(
381
+ f"""SELECT id, namespace, key, value, metadata, created_at
382
+ FROM memories WHERE id IN ({placeholders})""",
383
+ id_list,
384
+ ).fetchall()
385
+ except Exception:
386
+ return []
387
+
388
+ # Preserve RRF ranking order
389
+ row_map = {r["id"]: r for r in rows}
390
+ results: list[dict[str, Any]] = []
391
+ for doc_id in id_list:
392
+ row = row_map.get(doc_id)
393
+ if row is None:
394
+ continue
395
+ entry = dict(row)
396
+ entry = _parse_metadata(entry)
397
+ entry["score"] = round(score_map.get(doc_id, 0.0), 4)
398
+ results.append(entry)
399
+
400
+ return results
401
+
402
+
403
+ @mcp.tool(
404
+ description="Recall a specific memory entry by its key within a namespace. "
405
+ "Returns the full entry including parsed metadata.",
406
+ )
407
+ def memory_recall(
408
+ key: str,
409
+ namespace: str = "default",
410
+ ) -> dict[str, Any] | None:
411
+ """Exact-match lookup by namespace + key.
412
+
413
+ Args:
414
+ key: The unique key of the entry.
415
+ namespace: Namespace scope (default: "default").
416
+
417
+ Returns:
418
+ The memory entry dict, or None if not found.
419
+ """
420
+ if not key or not key.strip():
421
+ return None
422
+
423
+ db = _get_conn()
424
+ row = db.execute(
425
+ "SELECT id, namespace, key, value, metadata, created_at, updated_at "
426
+ "FROM memories WHERE namespace = ? AND key = ?",
427
+ [namespace, key.strip()],
428
+ ).fetchone()
429
+
430
+ if row is None:
431
+ return None
432
+
433
+ entry = _dict_from_row(row)
434
+ return _parse_metadata(entry)
435
+
436
+
437
+ @mcp.tool(
438
+ description="Delete a memory entry by ID or by key. "
439
+ "Vector embedding and FTS index entries are removed automatically "
440
+ "via CASCADE / triggers.",
441
+ )
442
+ def memory_forget(
443
+ id: int | None = None,
444
+ key: str | None = None,
445
+ namespace: str = "default",
446
+ ) -> dict[str, Any]:
447
+ """Delete a memory entry and its associated vector embedding.
448
+
449
+ Provide either ``id`` (exact rowid) or ``key`` (within namespace)
450
+ to identify the entry. Foreign key + FTS triggers handle cleanup.
451
+
452
+ Args:
453
+ id: Exact row ID of the entry to delete.
454
+ key: Key of the entry to delete (requires namespace).
455
+ namespace: Namespace scope when using key (default: "default").
456
+
457
+ Returns:
458
+ Dict with deleted status and entry identifier.
459
+ """
460
+ if id is not None:
461
+ identifier = id
462
+ col = "id"
463
+ params: list[Any] = [id]
464
+ elif key and key.strip():
465
+ identifier = key
466
+ col = "key"
467
+ params = [key.strip(), namespace]
468
+ sql = "DELETE FROM memories WHERE key = ? AND namespace = ?"
469
+ else:
470
+ return {"error": "Provide either id or key"}
471
+
472
+ db = _get_conn()
473
+ try:
474
+ if col == "id":
475
+ # Vec table cascade
476
+ db.execute("DELETE FROM vec_memories WHERE id = ?", [id])
477
+ cur = db.execute("DELETE FROM memories WHERE id = ?", [id])
478
+ else:
479
+ cur = db.execute(sql, params)
480
+ db.commit()
481
+
482
+ if cur.rowcount == 0:
483
+ return {"deleted": False, "error": "Entry not found"}
484
+
485
+ return {"deleted": True, col: identifier, "namespace": namespace}
486
+ except Exception as e:
487
+ db.rollback()
488
+ return {"error": f"Failed to delete: {e}"}
489
+
490
+
491
+ @mcp.tool(
492
+ description="List memory entries chronologically with optional "
493
+ "namespace and key-prefix filters.",
494
+ )
495
+ def memory_list(
496
+ namespace: str | None = None,
497
+ prefix: str = "",
498
+ limit: int = 50,
499
+ ) -> list[dict[str, Any]]:
500
+ """List memory entries, newest first.
501
+
502
+ Args:
503
+ namespace: Optional namespace filter.
504
+ prefix: Optional key prefix filter.
505
+ limit: Maximum entries (default 50, max 500).
506
+
507
+ Returns:
508
+ List of memory entries sorted by created_at descending.
509
+ """
510
+ limit = max(1, min(500, int(limit)))
511
+ db = _get_conn()
512
+
513
+ conditions: list[str] = []
514
+ params: list[Any] = []
515
+
516
+ if namespace:
517
+ conditions.append("namespace = ?")
518
+ params.append(namespace)
519
+
520
+ if prefix:
521
+ conditions.append("key LIKE ?")
522
+ params.append(f"{prefix}%")
523
+
524
+ where = " AND ".join(conditions) if conditions else "1"
525
+
526
+ try:
527
+ rows = db.execute(
528
+ f"SELECT id, namespace, key, value, metadata, created_at "
529
+ f"FROM memories WHERE {where} ORDER BY created_at DESC LIMIT ?",
530
+ [*params, limit],
531
+ ).fetchall()
532
+ except Exception as e:
533
+ return [{"error": f"List failed: {e}"}]
534
+
535
+ results = []
536
+ for row in rows:
537
+ entry = _dict_from_row(row)
538
+ results.append(_parse_metadata(entry))
539
+
540
+ return results
541
+
542
+
543
+ @mcp.tool(
544
+ description="Memory database statistics: total entries, per-namespace "
545
+ "breakdown, DB file size, and FTS/vector table sizes.",
546
+ )
547
+ def memory_stats() -> dict[str, Any]:
548
+ """Return aggregate statistics about the memory database.
549
+
550
+ Includes total count, namespace breakdown, FTS entry count,
551
+ vector entry count, and file size on disk.
552
+
553
+ Returns:
554
+ Dict with count, namespaces, and storage info.
555
+ """
556
+ db = _get_conn()
557
+
558
+ stats: dict[str, Any] = {"status": "ok"}
559
+
560
+ try:
561
+ stats["total_entries"] = db.execute(
562
+ "SELECT COUNT(*) AS c FROM memories"
563
+ ).fetchone()["c"]
564
+ except Exception:
565
+ stats["total_entries"] = 0
566
+
567
+ try:
568
+ ns_rows = db.execute(
569
+ "SELECT namespace, COUNT(*) AS c FROM memories GROUP BY namespace "
570
+ "ORDER BY c DESC"
571
+ ).fetchall()
572
+ stats["namespaces"] = [
573
+ {"namespace": r["namespace"], "count": r["c"]} for r in ns_rows
574
+ ]
575
+ except Exception:
576
+ stats["namespaces"] = []
577
+
578
+ try:
579
+ stats["vector_entries"] = db.execute(
580
+ "SELECT COUNT(*) AS c FROM vec_memories"
581
+ ).fetchone()["c"]
582
+ except Exception:
583
+ stats["vector_entries"] = 0
584
+
585
+ try:
586
+ db_size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
587
+ stats["db_size_bytes"] = db_size
588
+ for unit in ("B", "KB", "MB", "GB"):
589
+ if db_size < _BYTE_UNIT:
590
+ stats["db_size_human"] = f"{db_size:.2f} {unit}"
591
+ break
592
+ db_size /= 1024.0
593
+ else:
594
+ stats["db_size_human"] = f"{db_size:.2f} TB"
595
+ except Exception:
596
+ stats["db_size_bytes"] = 0
597
+ stats["db_size_human"] = "unknown"
598
+
599
+ return stats
600
+
601
+
602
+ # ── Main Entrypoint ───────────────────────────────────────────────────────────
603
+
604
+ if __name__ == "__main__":
605
+ mcp.run()