eigengram commited on
Commit
19d71eb
·
verified ·
1 Parent(s): 0769ff3

feat: upload MCP server (7 tools)

Browse files
mcp/CLAUDE_SESSION_PROTOCOL.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ## Session Memory Protocol (ENGRAM MCP)
3
+
4
+ ### Session Start (ALWAYS)
5
+ 1. Call get_last_session() — inject terminal state as context
6
+ 2. If task is non-trivial: call retrieve_relevant_sessions(task, k=3)
7
+
8
+ ### Session End (ALWAYS)
9
+ Call write_session_engram() with this format:
10
+ VALIDATED: <what was confirmed this session, with metrics>
11
+ CURRENT: <exact file paths, system state, test count>
12
+ NEXT: <next priorities, in order>
13
+ OPEN: <unresolved items, known failures>
14
+
15
+ ### Why This Matters
16
+ Each session_summary stored in ~/.engram/sessions/ is fingerprinted
17
+ using the ENGRAM f0+f1 protocol and becomes retrievable by semantic
18
+ similarity. This is ENGRAM using itself for its own memory.
19
+ The session_summary is the terminal state coordinate — the geodesic
20
+ destination, not the path. Keep it compressed and precise.
mcp/engram_memory.py ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ mcp/engram_memory.py — ENGRAM Session Memory MCP Server
4
+
5
+ Three tools for Claude Code to persist and retrieve session memory
6
+ using the ENGRAM fingerprint protocol.
7
+
8
+ Install:
9
+ claude mcp add --global engram-memory \
10
+ -e ENGRAM_SESSIONS_DIR=~/.engram/sessions \
11
+ -- python3 /path/to/mcp/engram_memory.py
12
+
13
+ Tools:
14
+ write_session_engram Encode + store terminal session state
15
+ get_last_session Fast-path: newest session terminal state
16
+ retrieve_relevant_sessions Semantic search over stored sessions
17
+
18
+ Session summary format (enforce in prompts):
19
+ VALIDATED: <confirmed results, metrics>
20
+ CURRENT: <current system state, file locations>
21
+ NEXT: <next session priorities, in order>
22
+ OPEN: <unresolved items, known failures>
23
+ """
24
+
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import os
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ try:
36
+ from mcp.server.fastmcp import FastMCP
37
+ except ImportError:
38
+ raise ImportError(
39
+ "mcp package required: pip install mcp"
40
+ )
41
+
42
+ SESSIONS_DIR = Path(
43
+ os.environ.get("ENGRAM_SESSIONS_DIR", "~/.engram/sessions")
44
+ ).expanduser()
45
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
46
+
47
+ ENGRAM_PROJECT = Path(
48
+ os.environ.get("ENGRAM_PROJECT_DIR",
49
+ Path(__file__).parent.parent)
50
+ )
51
+
52
+ # Eager imports — load torch/numpy/faiss at startup so the first tool call
53
+ # doesn't hang for 3-5 seconds while Claude Code shows "connecting..."
54
+ sys.path.insert(0, str(ENGRAM_PROJECT))
55
+ import numpy as np # noqa: E402
56
+ import torch # noqa: E402
57
+ import torch.nn.functional as F # noqa: E402
58
+ from kvcos.engram.format import EigramEncoder # noqa: E402
59
+
60
+ _encoder = EigramEncoder()
61
+
62
+ mcp = FastMCP("engram-memory")
63
+
64
+
65
+ # ── Encoding helpers ──────────────────────────────────────────────────
66
+
67
+ from kvcos.engram.embedder import get_fingerprint as _get_fingerprint # noqa: E402
68
+
69
+
70
+ def _write_eng(fp_tensor: torch.Tensor, summary: str, session_id: str,
71
+ domain: str, fp_source: str) -> Path:
72
+ """Write a real EIGENGRAM .eng binary using the format codec."""
73
+ dim = fp_tensor.shape[0]
74
+
75
+ # Placeholder vectors for corpus-specific fields not relevant to sessions
76
+ basis_rank = 116
77
+ vec_perdoc = torch.zeros(basis_rank)
78
+ vec_fcdb = torch.zeros(basis_rank)
79
+ joint_center = torch.zeros(128)
80
+
81
+ blob = _encoder.encode(
82
+ vec_perdoc=vec_perdoc,
83
+ vec_fcdb=vec_fcdb,
84
+ joint_center=joint_center,
85
+ corpus_hash=hashlib.sha256(session_id.encode()).hexdigest()[:32],
86
+ model_id=fp_source[:16],
87
+ basis_rank=basis_rank,
88
+ n_corpus=0,
89
+ layer_range=(0, 0),
90
+ context_len=len(summary),
91
+ l2_norm=float(torch.norm(fp_tensor).item()),
92
+ scs=0.0,
93
+ margin_proof=0.0,
94
+ task_description=summary[:256],
95
+ cache_id=session_id,
96
+ vec_fourier=fp_tensor if dim == 2048 else None,
97
+ vec_fourier_v2=fp_tensor,
98
+ confusion_flag=False,
99
+ )
100
+
101
+ eng_path = SESSIONS_DIR / f"{session_id}.eng"
102
+ with open(eng_path, "wb") as f:
103
+ f.write(blob)
104
+
105
+ # Write a small JSON sidecar for fields the binary doesn't carry
106
+ # (domain, fp_source, full summary beyond 256 chars, timestamp)
107
+ meta_path = SESSIONS_DIR / f"{session_id}.eng.meta.json"
108
+ with open(meta_path, "w") as f:
109
+ json.dump({
110
+ "cache_id": session_id,
111
+ "task_description": summary[:500],
112
+ "domain": domain,
113
+ "fp_source": fp_source,
114
+ "ts": time.time(),
115
+ }, f)
116
+
117
+ return eng_path
118
+
119
+
120
+ def _load_sessions() -> list[dict]:
121
+ """Load all stored session .eng files using the EIGENGRAM codec."""
122
+ records = []
123
+
124
+ for p in sorted(SESSIONS_DIR.glob("*.eng"), key=os.path.getmtime):
125
+ if p.suffix != ".eng":
126
+ continue
127
+ try:
128
+ data = _encoder.decode(p.read_bytes())
129
+ # Merge metadata sidecar if it exists (domain, fp_source, full summary, ts)
130
+ meta_path = Path(str(p) + ".meta.json")
131
+ if meta_path.exists():
132
+ meta = json.loads(meta_path.read_text())
133
+ data["domain"] = meta.get("domain", "")
134
+ data["fp_source"] = meta.get("fp_source", "unknown")
135
+ data["ts"] = meta.get("ts", 0.0)
136
+ # Sidecar may have longer task_description than the 256-char binary limit
137
+ if len(meta.get("task_description", "")) > len(data.get("task_description", "")):
138
+ data["task_description"] = meta["task_description"]
139
+ records.append(data)
140
+ except Exception as exc:
141
+ logger.debug("Skipping session %s: %s", p, exc)
142
+
143
+ return records
144
+
145
+
146
+ def _cosine(a, b) -> float:
147
+ """Cosine similarity between two vectors (list or torch.Tensor)."""
148
+ if not isinstance(a, torch.Tensor):
149
+ a = torch.tensor(a, dtype=torch.float32)
150
+ if not isinstance(b, torch.Tensor):
151
+ b = torch.tensor(b, dtype=torch.float32)
152
+ return float(F.cosine_similarity(a.float().flatten().unsqueeze(0),
153
+ b.float().flatten().unsqueeze(0)).item())
154
+
155
+
156
+ # ── MCP Tools ──────────────────────────────────────────────────────────
157
+
158
+ @mcp.tool()
159
+ def write_session_engram(
160
+ session_summary: str,
161
+ session_id: str = "",
162
+ domain: str = "engram",
163
+ ) -> str:
164
+ """
165
+ Encode the terminal session state and store as a session memory file.
166
+
167
+ Call at the END of every Claude Code session.
168
+
169
+ The session_summary should follow this format for best retrieval:
170
+ VALIDATED: <confirmed results, accuracy metrics>
171
+ CURRENT: <current file locations, system state>
172
+ NEXT: <prioritised next steps>
173
+ OPEN: <unresolved items, known failures>
174
+
175
+ Args:
176
+ session_summary: Terminal session state (use format above).
177
+ session_id: Unique ID, e.g. "s6_2026-04-02".
178
+ Auto-generated from timestamp if empty.
179
+ domain: Domain tag for density hinting (default: "engram").
180
+
181
+ Returns:
182
+ Path to stored .eng file (EIGENGRAM binary format).
183
+ """
184
+ if not session_id:
185
+ session_id = f"session_{int(time.time())}"
186
+
187
+ fp_list, fp_source = _get_fingerprint(session_summary)
188
+ eng_path = _write_eng(fp_list, session_summary, session_id,
189
+ domain, fp_source)
190
+
191
+ return json.dumps({
192
+ "stored": str(eng_path),
193
+ "session_id": session_id,
194
+ "fp_source": fp_source,
195
+ "chars": len(session_summary),
196
+ })
197
+
198
+
199
+ @mcp.tool()
200
+ def get_last_session() -> str:
201
+ """
202
+ Return the terminal state of the most recent stored session.
203
+
204
+ Call at the START of every Claude Code session before doing anything.
205
+ This is the fast path — no semantic search, just the newest file.
206
+
207
+ Returns:
208
+ JSON with session_id and task_description (terminal state summary).
209
+ Returns empty JSON if no sessions are stored yet.
210
+ """
211
+ records = _load_sessions()
212
+ if not records:
213
+ return json.dumps({"status": "no sessions stored"})
214
+
215
+ latest = records[-1]
216
+ return json.dumps({
217
+ "session_id": latest.get("cache_id"),
218
+ "terminal_state": latest.get("task_description"),
219
+ "stored_at": latest.get("ts"),
220
+ "fp_source": latest.get("fp_source"),
221
+ })
222
+
223
+
224
+ @mcp.tool()
225
+ def retrieve_relevant_sessions(
226
+ query: str,
227
+ k: int = 3,
228
+ ) -> str:
229
+ """
230
+ Semantic search over all stored session memories.
231
+
232
+ Call when starting a complex task that may have relevant prior work.
233
+ Returns k most semantically similar prior sessions to the query.
234
+
235
+ Args:
236
+ query: Description of the current task.
237
+ k: Number of sessions to return (default 3).
238
+
239
+ Returns:
240
+ JSON list of k most relevant sessions with their terminal states.
241
+ """
242
+ records = _load_sessions()
243
+ if not records:
244
+ return json.dumps([])
245
+
246
+ query_fp, _ = _get_fingerprint(query)
247
+
248
+ scored = []
249
+ for rec in records:
250
+ # Decoded .eng files have vec_fourier_v2 as torch.Tensor
251
+ fp = rec.get("vec_fourier_v2")
252
+ if fp is None:
253
+ fp = rec.get("vec_fourier")
254
+ if fp is None:
255
+ continue
256
+ sim = _cosine(query_fp, fp)
257
+ scored.append({
258
+ "session_id": rec.get("cache_id"),
259
+ "terminal_state": rec.get("task_description"),
260
+ "similarity": round(sim, 4),
261
+ "fp_source": rec.get("fp_source", "unknown"),
262
+ })
263
+
264
+ scored.sort(key=lambda x: x["similarity"], reverse=True)
265
+ return json.dumps(scored[:k], indent=2)
266
+
267
+
268
+ # ── Knowledge Index Tools ─────────────────────────────────────────────
269
+
270
+ KNOWLEDGE_DIR = Path(
271
+ os.environ.get("ENGRAM_KNOWLEDGE_DIR", "~/.engram/knowledge")
272
+ ).expanduser()
273
+
274
+
275
+ def _load_knowledge(project: str = "") -> list[dict]:
276
+ """Load all .eng files from the knowledge index."""
277
+ records = []
278
+
279
+ if project:
280
+ search_dir = KNOWLEDGE_DIR / project
281
+ if not search_dir.exists():
282
+ return records
283
+ eng_files = sorted(search_dir.glob("*.eng"), key=os.path.getmtime)
284
+ else:
285
+ eng_files = sorted(KNOWLEDGE_DIR.rglob("*.eng"), key=os.path.getmtime)
286
+
287
+ for p in eng_files:
288
+ if p.suffix != ".eng":
289
+ continue
290
+ try:
291
+ data = _encoder.decode(p.read_bytes())
292
+ meta_path = Path(str(p) + ".meta.json")
293
+ if meta_path.exists():
294
+ meta = json.loads(meta_path.read_text())
295
+ data["source_path"] = meta.get("source_path", "")
296
+ data["project"] = meta.get("project", "")
297
+ data["fp_source"] = meta.get("fp_source", "unknown")
298
+ data["chunk_index"] = meta.get("chunk_index", 0)
299
+ data["chunk_total"] = meta.get("chunk_total", 1)
300
+ data["headers"] = meta.get("headers", [])
301
+ data["type"] = meta.get("type", "knowledge")
302
+ if len(meta.get("task_description", "")) > len(
303
+ data.get("task_description", "")
304
+ ):
305
+ data["task_description"] = meta["task_description"]
306
+ records.append(data)
307
+ except Exception as exc:
308
+ logger.debug("Skipping knowledge file %s: %s", p, exc)
309
+
310
+ return records
311
+
312
+
313
+ _knowledge_index = None
314
+ _knowledge_index_mtime = 0.0
315
+
316
+ INDEX_DIR = Path(
317
+ os.environ.get("ENGRAM_INDEX_DIR", "~/.engram/index")
318
+ ).expanduser()
319
+
320
+
321
+ def _get_knowledge_index():
322
+ """Load or rebuild the HNSW knowledge index (cached)."""
323
+ global _knowledge_index, _knowledge_index_mtime
324
+
325
+ faiss_path = INDEX_DIR / "knowledge.faiss"
326
+ if faiss_path.exists():
327
+ current_mtime = faiss_path.stat().st_mtime
328
+ if _knowledge_index is not None and current_mtime <= _knowledge_index_mtime:
329
+ return _knowledge_index
330
+ try:
331
+ from kvcos.engram.knowledge_index import KnowledgeIndex
332
+ _knowledge_index = KnowledgeIndex.load(INDEX_DIR)
333
+ _knowledge_index_mtime = current_mtime
334
+ return _knowledge_index
335
+ except Exception as exc:
336
+ logger.warning("Failed to load knowledge index: %s", exc)
337
+
338
+ # No pre-built index — build on demand
339
+ try:
340
+ from kvcos.engram.knowledge_index import KnowledgeIndex
341
+ kidx = KnowledgeIndex.build_from_knowledge_dir(verbose=False)
342
+ kidx.save(INDEX_DIR)
343
+ _knowledge_index = kidx
344
+ _knowledge_index_mtime = time.time()
345
+ return kidx
346
+ except Exception as exc:
347
+ logger.warning("Failed to build knowledge index: %s", exc)
348
+ return None
349
+
350
+
351
+ @mcp.tool()
352
+ def get_relevant_context(
353
+ query: str,
354
+ k: int = 5,
355
+ project: str = "",
356
+ ) -> str:
357
+ """
358
+ Semantic search over the ENGRAM knowledge index.
359
+
360
+ Searches all indexed markdown files (rules, docs, geodesics, etc.)
361
+ for chunks most relevant to the query. Uses HNSW for sub-ms search.
362
+
363
+ Args:
364
+ query: Description of what you're looking for.
365
+ k: Number of results to return (default 5).
366
+ project: Filter by project namespace (empty = search all).
367
+
368
+ Returns:
369
+ JSON list of k most relevant knowledge chunks with source info.
370
+ """
371
+ kidx = _get_knowledge_index()
372
+
373
+ if kidx is not None:
374
+ # Fast path: HNSW search
375
+ results = kidx.search(query, k=k * 2 if project else k)
376
+ scored = []
377
+ for r in results:
378
+ if project and r.project != project:
379
+ continue
380
+ scored.append({
381
+ "content": r.content,
382
+ "source_path": r.source_path,
383
+ "project": r.project,
384
+ "chunk": r.chunk_info,
385
+ "headers": r.headers,
386
+ "similarity": round(r.score, 4),
387
+ "fp_source": r.doc_id,
388
+ })
389
+ if len(scored) >= k:
390
+ break
391
+ return json.dumps(scored[:k], indent=2)
392
+
393
+ # Fallback: brute-force scan (no HNSW index available)
394
+ records = _load_knowledge(project)
395
+ if not records:
396
+ return json.dumps({"status": "no knowledge indexed",
397
+ "hint": "Run: python scripts/index_knowledge.py"})
398
+
399
+ query_fp, _ = _get_fingerprint(query)
400
+
401
+ scored = []
402
+ for rec in records:
403
+ fp = rec.get("vec_fourier_v2")
404
+ if fp is None:
405
+ fp = rec.get("vec_fourier")
406
+ if fp is None:
407
+ continue
408
+ sim = _cosine(query_fp, fp)
409
+ scored.append({
410
+ "content": rec.get("task_description", ""),
411
+ "source_path": rec.get("source_path", ""),
412
+ "project": rec.get("project", ""),
413
+ "chunk": f"{rec.get('chunk_index', 0)+1}/{rec.get('chunk_total', 1)}",
414
+ "headers": rec.get("headers", []),
415
+ "similarity": round(sim, 4),
416
+ "fp_source": rec.get("fp_source", "unknown"),
417
+ })
418
+
419
+ scored.sort(key=lambda x: x["similarity"], reverse=True)
420
+ return json.dumps(scored[:k], indent=2)
421
+
422
+
423
+ @mcp.tool()
424
+ def list_indexed(
425
+ project: str = "",
426
+ ) -> str:
427
+ """
428
+ List all indexed knowledge files and their chunk counts.
429
+
430
+ Args:
431
+ project: Filter by project namespace (empty = list all).
432
+
433
+ Returns:
434
+ JSON summary of the knowledge index.
435
+ """
436
+ manifest_path = Path(
437
+ os.environ.get("ENGRAM_MANIFEST_PATH", "~/.engram/manifest.json")
438
+ ).expanduser()
439
+
440
+ if not manifest_path.exists():
441
+ return json.dumps({"status": "no manifest found",
442
+ "hint": "Run: python scripts/index_knowledge.py"})
443
+
444
+ data = json.loads(manifest_path.read_text())
445
+ sources = data.get("sources", {})
446
+
447
+ if project:
448
+ sources = {
449
+ k: v for k, v in sources.items()
450
+ if v.get("project") == project
451
+ }
452
+
453
+ summary = {
454
+ "total_sources": len(sources),
455
+ "total_chunks": sum(len(s.get("chunks", [])) for s in sources.values()),
456
+ "projects": sorted({s.get("project", "") for s in sources.values()}),
457
+ "files": [
458
+ {
459
+ "path": s.get("source_path", k).split("/")[-1],
460
+ "project": s.get("project", ""),
461
+ "chunks": len(s.get("chunks", [])),
462
+ "size": s.get("file_size", 0),
463
+ }
464
+ for k, s in sorted(sources.items())
465
+ ],
466
+ }
467
+
468
+ return json.dumps(summary, indent=2)
469
+
470
+
471
+ @mcp.tool()
472
+ def index_knowledge(
473
+ source_path: str,
474
+ project: str = "engram",
475
+ force: bool = False,
476
+ ) -> str:
477
+ """
478
+ Index a markdown file or directory into the ENGRAM knowledge index.
479
+
480
+ Processes markdown files into fingerprinted .eng chunks that
481
+ are searchable via get_relevant_context().
482
+
483
+ Args:
484
+ source_path: Path to a .md file or directory of .md files.
485
+ project: Project namespace (default: "engram").
486
+ force: Re-index even if content unchanged (default: false).
487
+
488
+ Returns:
489
+ JSON summary of indexing results.
490
+ """
491
+ from pathlib import Path as P
492
+ source = P(source_path).expanduser().resolve()
493
+
494
+ if not source.exists():
495
+ return json.dumps({"error": f"Path not found: {source_path}"})
496
+
497
+ try:
498
+ # Import indexer (avoid circular imports)
499
+ sys.path.insert(0, str(ENGRAM_PROJECT / "scripts"))
500
+ from index_knowledge import index_batch
501
+
502
+ stats = index_batch(
503
+ source=source,
504
+ project=project,
505
+ incremental=not force,
506
+ dry_run=False,
507
+ force=force,
508
+ )
509
+ return json.dumps(stats, indent=2)
510
+ except Exception as e:
511
+ return json.dumps({"error": str(e)})
512
+
513
+
514
+ if __name__ == "__main__":
515
+ mcp.run()