Chris4K commited on
Commit
86a0172
Β·
verified Β·
1 Parent(s): d24ebda

Upload 17 files

Browse files
config.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "memory-mcp",
3
+ "description": "Three-tier memory system MCP server (session / episodic / semantic-RAG)",
4
+ "version": "0.1.0",
5
+
6
+ "data_root": "data",
7
+ "embedding_model": "all-MiniLM-L6-v2",
8
+ "session_ttl_seconds": 3600,
9
+
10
+ "transport": "stdio",
11
+ "sse_port": 8765,
12
+
13
+ "memory_tiers": {
14
+ "session": {
15
+ "description": "Short-term conversation context",
16
+ "storage": "data/session",
17
+ "max_entries_per_session": 50,
18
+ "ttl_seconds": 3600
19
+ },
20
+ "episodic": {
21
+ "description": "Mid-term task & event history",
22
+ "storage": "data/events"
23
+ },
24
+ "semantic": {
25
+ "description": "Long-term vector RAG knowledge base",
26
+ "vector_storage": "data/vector",
27
+ "md_storage": "data/vector/docs",
28
+ "collection_name": "memory_semantic"
29
+ }
30
+ }
31
+ }
data/events/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Placeholder β€” episodic event files stored here as *.md
data/session/.gitkeep ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Placeholder β€” session data stored here
2
+ # Each session gets a subfolder: session/<session_id>/*.md
data/vector/.gitkeep ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Placeholder β€” semantic vector data & markdown docs stored here
2
+ # Subfolders: chroma_db/ and docs/
data/vector/docs/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+ # Placeholder β€” markdown mirrors of vector entries
memory/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Memory System MCP Server
3
+ ========================
4
+ Three-tier memory architecture for AI agents:
5
+ - Short-Term (Session): Conversation context, ephemeral
6
+ - Episodic (Events): Past tasks and interactions, mid-term
7
+ - Semantic (Vector): RAG-backed long-term knowledge base
8
+ """
9
+
10
+ from memory.models import MemoryEntry, MemoryTier, SearchResult
11
+ from memory.session import SessionMemory
12
+ from memory.events import EpisodicMemory
13
+ from memory.vector import SemanticMemory
14
+
15
+ __all__ = [
16
+ "MemoryEntry",
17
+ "MemoryTier",
18
+ "SearchResult",
19
+ "SessionMemory",
20
+ "EpisodicMemory",
21
+ "SemanticMemory",
22
+ ]
memory/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (757 Bytes). View file
 
memory/__pycache__/events.cpython-313.pyc ADDED
Binary file (9.06 kB). View file
 
memory/__pycache__/models.cpython-313.pyc ADDED
Binary file (7.03 kB). View file
 
memory/__pycache__/session.cpython-313.pyc ADDED
Binary file (9.9 kB). View file
 
memory/__pycache__/vector.cpython-313.pyc ADDED
Binary file (16.9 kB). View file
 
memory/events.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Episodic Memory – Past Tasks & Events
3
+ =======================================
4
+ Stores discrete events / task completions as Markdown files
5
+ under memory/events/*.md
6
+
7
+ Each event has a timestamp, outcome, and optional linked entities.
8
+ Supports keyword search and time-range queries.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from .models import MemoryEntry, MemoryTier
19
+
20
+
21
+ class EpisodicMemory:
22
+ """File-backed store for task / event memories."""
23
+
24
+ def __init__(self, base_dir: str = "memory/events"):
25
+ self.base_dir = Path(base_dir)
26
+ self.base_dir.mkdir(parents=True, exist_ok=True)
27
+ # id β†’ MemoryEntry (in-memory index)
28
+ self._index: Dict[str, MemoryEntry] = {}
29
+ self._load_from_disk()
30
+
31
+ # ── CRUD ─────────────────────────────────────────────────
32
+
33
+ def create(
34
+ self,
35
+ content: str,
36
+ title: str = "",
37
+ tags: Optional[List[str]] = None,
38
+ importance: float = 0.5,
39
+ metadata: Optional[Dict[str, Any]] = None,
40
+ source: str = "",
41
+ ) -> MemoryEntry:
42
+ entry = MemoryEntry(
43
+ content=content,
44
+ title=title or self._auto_title(content),
45
+ tier=MemoryTier.EPISODIC,
46
+ tags=tags or [],
47
+ importance=importance,
48
+ metadata=metadata or {},
49
+ source=source,
50
+ created_at=datetime.utcnow().isoformat(),
51
+ updated_at=datetime.utcnow().isoformat(),
52
+ )
53
+ self._index[entry.id] = entry
54
+ self._persist(entry)
55
+ return entry
56
+
57
+ def read(self, entry_id: str) -> Optional[MemoryEntry]:
58
+ entry = self._index.get(entry_id)
59
+ if entry:
60
+ entry.access_count += 1
61
+ entry.updated_at = datetime.utcnow().isoformat()
62
+ self._persist(entry)
63
+ return entry
64
+
65
+ def update(self, entry_id: str, **kwargs) -> Optional[MemoryEntry]:
66
+ entry = self._index.get(entry_id)
67
+ if not entry:
68
+ return None
69
+ for k, v in kwargs.items():
70
+ if hasattr(entry, k) and k not in ("id", "tier", "created_at"):
71
+ setattr(entry, k, v)
72
+ entry.updated_at = datetime.utcnow().isoformat()
73
+ self._persist(entry)
74
+ return entry
75
+
76
+ def delete(self, entry_id: str) -> bool:
77
+ if entry_id not in self._index:
78
+ return False
79
+ del self._index[entry_id]
80
+ path = self._entry_path(entry_id)
81
+ if path.exists():
82
+ path.unlink()
83
+ return True
84
+
85
+ def list_entries(
86
+ self,
87
+ tag: Optional[str] = None,
88
+ since: Optional[str] = None,
89
+ until: Optional[str] = None,
90
+ limit: int = 50,
91
+ ) -> List[MemoryEntry]:
92
+ """List events, optionally filtered by tag and/or time range."""
93
+ entries = list(self._index.values())
94
+
95
+ if tag:
96
+ entries = [e for e in entries if tag in e.tags]
97
+ if since:
98
+ entries = [e for e in entries if e.created_at >= since]
99
+ if until:
100
+ entries = [e for e in entries if e.created_at <= until]
101
+
102
+ # newest first
103
+ entries.sort(key=lambda e: e.created_at, reverse=True)
104
+ return entries[:limit]
105
+
106
+ def search(self, query: str, limit: int = 10) -> List[MemoryEntry]:
107
+ """Keyword search across episodic memories."""
108
+ q = query.lower()
109
+ scored: List[tuple] = []
110
+ for entry in self._index.values():
111
+ text = f"{entry.title} {entry.content} {' '.join(entry.tags)}".lower()
112
+ if q in text:
113
+ # rudimentary relevance: importance + recency
114
+ scored.append((entry, entry.importance))
115
+ scored.sort(key=lambda x: x[1], reverse=True)
116
+ return [e for e, _ in scored[:limit]]
117
+
118
+ def count(self) -> int:
119
+ return len(self._index)
120
+
121
+ # ── timeline helpers ─────────────────────────────────────
122
+
123
+ def recent(self, n: int = 10) -> List[MemoryEntry]:
124
+ """Get the N most recent events."""
125
+ entries = sorted(self._index.values(), key=lambda e: e.created_at, reverse=True)
126
+ return entries[:n]
127
+
128
+ def by_tag(self, tag: str) -> List[MemoryEntry]:
129
+ return [e for e in self._index.values() if tag in e.tags]
130
+
131
+ # ── persistence ──────────────────────────────────────────
132
+
133
+ def _entry_path(self, entry_id: str) -> Path:
134
+ return self.base_dir / f"{entry_id}.md"
135
+
136
+ def _persist(self, entry: MemoryEntry):
137
+ path = self._entry_path(entry.id)
138
+ path.write_text(entry.to_markdown(), encoding="utf-8")
139
+
140
+ def _load_from_disk(self):
141
+ for md_file in self.base_dir.glob("*.md"):
142
+ try:
143
+ text = md_file.read_text(encoding="utf-8")
144
+ entry = MemoryEntry.from_markdown(text)
145
+ entry.tier = MemoryTier.EPISODIC
146
+ self._index[entry.id] = entry
147
+ except Exception:
148
+ pass
149
+
150
+ @staticmethod
151
+ def _auto_title(content: str) -> str:
152
+ """Generate a short title from content."""
153
+ first_line = content.strip().split("\n")[0][:80]
154
+ return first_line if first_line else "Untitled Event"
memory/models.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data models for the Memory System."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass, field, asdict
7
+ from datetime import datetime
8
+ from enum import Enum
9
+ from typing import Any, Dict, List, Optional
10
+
11
+
12
+ class MemoryTier(str, Enum):
13
+ """Which memory layer an entry belongs to."""
14
+ SESSION = "session" # short-term / conversation context
15
+ EPISODIC = "episodic" # mid-term / past tasks & events
16
+ SEMANTIC = "semantic" # long-term / vector-backed knowledge
17
+
18
+
19
+ @dataclass
20
+ class MemoryEntry:
21
+ """A single memory record stored across tiers."""
22
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
23
+ content: str = ""
24
+ title: str = ""
25
+ tier: MemoryTier = MemoryTier.SESSION
26
+ tags: List[str] = field(default_factory=list)
27
+ metadata: Dict[str, Any] = field(default_factory=dict)
28
+ importance: float = 0.5 # 0.0 – 1.0
29
+ access_count: int = 0
30
+ created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
31
+ updated_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
32
+ session_id: Optional[str] = None # groups session memories
33
+ source: str = "" # origin of the memory
34
+
35
+ # ── helpers ──────────────────────────────────────────────
36
+ def to_dict(self) -> Dict[str, Any]:
37
+ d = asdict(self)
38
+ d["tier"] = self.tier.value
39
+ return d
40
+
41
+ @classmethod
42
+ def from_dict(cls, data: Dict[str, Any]) -> "MemoryEntry":
43
+ data = dict(data) # shallow copy
44
+ if "tier" in data and isinstance(data["tier"], str):
45
+ data["tier"] = MemoryTier(data["tier"])
46
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
47
+
48
+ def to_markdown(self) -> str:
49
+ """Render as a Markdown document with YAML front-matter."""
50
+ lines = [
51
+ "---",
52
+ f"id: {self.id}",
53
+ f"title: \"{self.title}\"",
54
+ f"tier: {self.tier.value}",
55
+ f"tags: [{', '.join(self.tags)}]",
56
+ f"importance: {self.importance}",
57
+ f"access_count: {self.access_count}",
58
+ f"created_at: {self.created_at}",
59
+ f"updated_at: {self.updated_at}",
60
+ ]
61
+ if self.session_id:
62
+ lines.append(f"session_id: {self.session_id}")
63
+ if self.source:
64
+ lines.append(f"source: \"{self.source}\"")
65
+ if self.metadata:
66
+ import json
67
+ lines.append(f"metadata: {json.dumps(self.metadata)}")
68
+ lines.append("---")
69
+ lines.append("")
70
+ lines.append(self.content)
71
+ return "\n".join(lines)
72
+
73
+ @classmethod
74
+ def from_markdown(cls, text: str) -> "MemoryEntry":
75
+ """Parse a Markdown document with YAML front-matter."""
76
+ import re, json as _json
77
+
78
+ fm_match = re.match(r"^---\n(.*?)\n---\n?(.*)", text, re.DOTALL)
79
+ if not fm_match:
80
+ return cls(content=text)
81
+
82
+ front, body = fm_match.group(1), fm_match.group(2).strip()
83
+ data: Dict[str, Any] = {"content": body}
84
+
85
+ for line in front.splitlines():
86
+ line = line.strip()
87
+ if not line or ":" not in line:
88
+ continue
89
+ key, _, val = line.partition(":")
90
+ key = key.strip()
91
+ val = val.strip().strip('"')
92
+
93
+ if key == "tags":
94
+ # parse [tag1, tag2]
95
+ inner = val.strip("[]")
96
+ data["tags"] = [t.strip() for t in inner.split(",") if t.strip()]
97
+ elif key == "importance":
98
+ data["importance"] = float(val)
99
+ elif key == "access_count":
100
+ data["access_count"] = int(val)
101
+ elif key == "metadata":
102
+ try:
103
+ data["metadata"] = _json.loads(val)
104
+ except _json.JSONDecodeError:
105
+ data["metadata"] = {}
106
+ else:
107
+ data[key] = val
108
+
109
+ return cls.from_dict(data)
110
+
111
+
112
+ @dataclass
113
+ class SearchResult:
114
+ """Wrapper returned by semantic search."""
115
+ entry: MemoryEntry
116
+ score: float = 0.0 # similarity / relevance
117
+ distance: float = 0.0 # raw distance from vector DB
memory/session.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Short-Term / Session Memory
3
+ ============================
4
+ Stores conversation context and ephemeral data as Markdown files
5
+ under memory/session/<session_id>/*.md
6
+
7
+ Entries expire after a configurable TTL (default 1 hour).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import time
15
+ from collections import OrderedDict
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Dict, List, Optional
19
+
20
+ from .models import MemoryEntry, MemoryTier
21
+
22
+
23
+ class SessionMemory:
24
+ """In-memory + file-backed short-term memory store."""
25
+
26
+ DEFAULT_TTL = 3600 # seconds – 1 hour
27
+ MAX_ENTRIES_PER_SESSION = 50
28
+
29
+ def __init__(self, base_dir: str = "memory/session", ttl: int = DEFAULT_TTL):
30
+ self.base_dir = Path(base_dir)
31
+ self.base_dir.mkdir(parents=True, exist_ok=True)
32
+ self.ttl = ttl
33
+ # session_id β†’ OrderedDict[entry_id, MemoryEntry]
34
+ self._cache: Dict[str, OrderedDict[str, MemoryEntry]] = {}
35
+ self._load_from_disk()
36
+
37
+ # ── CRUD ─────────────────────────────────────────────────
38
+
39
+ def create(self, entry: MemoryEntry, session_id: str = "default") -> MemoryEntry:
40
+ """Add a new entry to a session."""
41
+ entry.tier = MemoryTier.SESSION
42
+ entry.session_id = session_id
43
+ entry.created_at = datetime.utcnow().isoformat()
44
+ entry.updated_at = entry.created_at
45
+
46
+ bucket = self._cache.setdefault(session_id, OrderedDict())
47
+ # evict oldest when full
48
+ while len(bucket) >= self.MAX_ENTRIES_PER_SESSION:
49
+ bucket.popitem(last=False)
50
+ bucket[entry.id] = entry
51
+ self._persist(entry, session_id)
52
+ return entry
53
+
54
+ def read(self, entry_id: str, session_id: str = "default") -> Optional[MemoryEntry]:
55
+ """Retrieve a single entry by ID."""
56
+ bucket = self._cache.get(session_id, {})
57
+ entry = bucket.get(entry_id)
58
+ if entry:
59
+ entry.access_count += 1
60
+ entry.updated_at = datetime.utcnow().isoformat()
61
+ self._persist(entry, session_id)
62
+ return entry
63
+
64
+ def update(self, entry_id: str, session_id: str = "default", **kwargs) -> Optional[MemoryEntry]:
65
+ """Update fields on an existing entry."""
66
+ bucket = self._cache.get(session_id, {})
67
+ entry = bucket.get(entry_id)
68
+ if not entry:
69
+ return None
70
+ for k, v in kwargs.items():
71
+ if hasattr(entry, k) and k not in ("id", "tier", "created_at"):
72
+ setattr(entry, k, v)
73
+ entry.updated_at = datetime.utcnow().isoformat()
74
+ self._persist(entry, session_id)
75
+ return entry
76
+
77
+ def delete(self, entry_id: str, session_id: str = "default") -> bool:
78
+ """Remove an entry."""
79
+ bucket = self._cache.get(session_id, {})
80
+ if entry_id not in bucket:
81
+ return False
82
+ del bucket[entry_id]
83
+ path = self._entry_path(entry_id, session_id)
84
+ if path.exists():
85
+ path.unlink()
86
+ return True
87
+
88
+ def list_entries(self, session_id: str = "default", tag: Optional[str] = None) -> List[MemoryEntry]:
89
+ """List all entries in a session, optionally filtered by tag."""
90
+ bucket = self._cache.get(session_id, OrderedDict())
91
+ entries = list(bucket.values())
92
+ if tag:
93
+ entries = [e for e in entries if tag in e.tags]
94
+ return entries
95
+
96
+ def list_sessions(self) -> List[str]:
97
+ """List all known session IDs."""
98
+ return list(self._cache.keys())
99
+
100
+ def clear_session(self, session_id: str = "default") -> int:
101
+ """Drop all entries in a session. Returns count deleted."""
102
+ bucket = self._cache.pop(session_id, OrderedDict())
103
+ count = len(bucket)
104
+ session_dir = self.base_dir / session_id
105
+ if session_dir.exists():
106
+ for f in session_dir.glob("*.md"):
107
+ f.unlink()
108
+ try:
109
+ session_dir.rmdir()
110
+ except OSError:
111
+ pass
112
+ return count
113
+
114
+ def gc(self) -> int:
115
+ """Garbage-collect expired entries across all sessions. Returns count removed."""
116
+ now = time.time()
117
+ removed = 0
118
+ for sid in list(self._cache.keys()):
119
+ for eid in list(self._cache[sid].keys()):
120
+ entry = self._cache[sid][eid]
121
+ created_ts = datetime.fromisoformat(entry.created_at).timestamp()
122
+ if now - created_ts > self.ttl:
123
+ self.delete(eid, sid)
124
+ removed += 1
125
+ return removed
126
+
127
+ # ── search helpers ───────────────────────────────────────
128
+
129
+ def search(self, query: str, session_id: Optional[str] = None, limit: int = 10) -> List[MemoryEntry]:
130
+ """Simple keyword search across session memories."""
131
+ query_lower = query.lower()
132
+ results: List[MemoryEntry] = []
133
+
134
+ sessions = [session_id] if session_id else list(self._cache.keys())
135
+ for sid in sessions:
136
+ for entry in self._cache.get(sid, {}).values():
137
+ text = f"{entry.title} {entry.content} {' '.join(entry.tags)}".lower()
138
+ if query_lower in text:
139
+ results.append(entry)
140
+ if len(results) >= limit:
141
+ return results
142
+ return results
143
+
144
+ # ── persistence ──────────────────────────────────────────
145
+
146
+ def _entry_path(self, entry_id: str, session_id: str) -> Path:
147
+ d = self.base_dir / session_id
148
+ d.mkdir(parents=True, exist_ok=True)
149
+ return d / f"{entry_id}.md"
150
+
151
+ def _persist(self, entry: MemoryEntry, session_id: str):
152
+ path = self._entry_path(entry.id, session_id)
153
+ path.write_text(entry.to_markdown(), encoding="utf-8")
154
+
155
+ def _load_from_disk(self):
156
+ """Bootstrap cache from existing .md files."""
157
+ if not self.base_dir.exists():
158
+ return
159
+ for session_dir in self.base_dir.iterdir():
160
+ if not session_dir.is_dir():
161
+ continue
162
+ sid = session_dir.name
163
+ bucket = self._cache.setdefault(sid, OrderedDict())
164
+ for md_file in sorted(session_dir.glob("*.md")):
165
+ try:
166
+ text = md_file.read_text(encoding="utf-8")
167
+ entry = MemoryEntry.from_markdown(text)
168
+ entry.session_id = sid
169
+ entry.tier = MemoryTier.SESSION
170
+ bucket[entry.id] = entry
171
+ except Exception:
172
+ pass # skip corrupt files
memory/vector.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Semantic / Vector Memory – RAG Layer
3
+ =====================================
4
+ Long-term knowledge stored in ChromaDB with sentence-transformer embeddings.
5
+ Also persists each entry as a Markdown file under memory/vector/*.md
6
+ for human-readability and version control.
7
+
8
+ This is the RAG backbone:
9
+ β€’ Add documents β†’ embed + store
10
+ β€’ Query by natural language β†’ cosine similarity search
11
+ β€’ Full CRUD with automatic re-embedding on update
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from .models import MemoryEntry, MemoryTier, SearchResult
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ── optional heavy deps (graceful fallback) ──────────────────
27
+ try:
28
+ import chromadb
29
+ from chromadb.config import Settings as ChromaSettings
30
+ CHROMA_AVAILABLE = True
31
+ except ImportError:
32
+ CHROMA_AVAILABLE = False
33
+
34
+ try:
35
+ from sentence_transformers import SentenceTransformer
36
+ ST_AVAILABLE = True
37
+ except ImportError:
38
+ ST_AVAILABLE = False
39
+
40
+
41
+ class _SentenceTransformerEmbedder:
42
+ """Wraps sentence-transformers for ChromaDB's EmbeddingFunction protocol."""
43
+
44
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
45
+ if not ST_AVAILABLE:
46
+ raise ImportError("sentence-transformers is required for semantic memory")
47
+ self.model = SentenceTransformer(model_name)
48
+ self.model_name = model_name
49
+
50
+ def __call__(self, input: List[str]) -> List[List[float]]:
51
+ embeddings = self.model.encode(input, show_progress_bar=False)
52
+ return embeddings.tolist()
53
+
54
+
55
+ class SemanticMemory:
56
+ """ChromaDB-backed vector store with Markdown file mirror."""
57
+
58
+ COLLECTION_NAME = "memory_semantic"
59
+ DEFAULT_MODEL = "all-MiniLM-L6-v2"
60
+
61
+ def __init__(
62
+ self,
63
+ vector_dir: str = "memory/vector",
64
+ md_dir: str = "memory/vector/docs",
65
+ model_name: str = DEFAULT_MODEL,
66
+ collection_name: str = COLLECTION_NAME,
67
+ ):
68
+ self.vector_dir = Path(vector_dir)
69
+ self.md_dir = Path(md_dir)
70
+ self.vector_dir.mkdir(parents=True, exist_ok=True)
71
+ self.md_dir.mkdir(parents=True, exist_ok=True)
72
+ self.model_name = model_name
73
+ self.collection_name = collection_name
74
+
75
+ # ChromaDB setup
76
+ if CHROMA_AVAILABLE:
77
+ self._client = chromadb.PersistentClient(
78
+ path=str(self.vector_dir / "chroma_db"),
79
+ )
80
+ # Embedding function
81
+ if ST_AVAILABLE:
82
+ self._embed_fn = _SentenceTransformerEmbedder(model_name)
83
+ self._collection = self._client.get_or_create_collection(
84
+ name=collection_name,
85
+ embedding_function=self._embed_fn,
86
+ metadata={"hnsw:space": "cosine"},
87
+ )
88
+ else:
89
+ # fall back to Chroma's built-in default embedder
90
+ self._collection = self._client.get_or_create_collection(
91
+ name=collection_name,
92
+ metadata={"hnsw:space": "cosine"},
93
+ )
94
+ self._embed_fn = None
95
+ logger.info(
96
+ "SemanticMemory ready – ChromaDB @ %s | model=%s | docs=%d",
97
+ self.vector_dir, model_name, self._collection.count(),
98
+ )
99
+ else:
100
+ self._client = None
101
+ self._collection = None
102
+ self._embed_fn = None
103
+ logger.warning("chromadb not installed – semantic memory operates in file-only mode")
104
+
105
+ # ── CRUD ─────────────────────────────────────────────────
106
+
107
+ def create(
108
+ self,
109
+ content: str,
110
+ title: str = "",
111
+ tags: Optional[List[str]] = None,
112
+ importance: float = 0.5,
113
+ metadata: Optional[Dict[str, Any]] = None,
114
+ source: str = "",
115
+ ) -> MemoryEntry:
116
+ """Add a new document to the vector store + Markdown mirror."""
117
+ entry = MemoryEntry(
118
+ content=content,
119
+ title=title or content[:80],
120
+ tier=MemoryTier.SEMANTIC,
121
+ tags=tags or [],
122
+ importance=importance,
123
+ metadata=metadata or {},
124
+ source=source,
125
+ created_at=datetime.utcnow().isoformat(),
126
+ updated_at=datetime.utcnow().isoformat(),
127
+ )
128
+ self._upsert_vector(entry)
129
+ self._persist_md(entry)
130
+ return entry
131
+
132
+ def read(self, entry_id: str) -> Optional[MemoryEntry]:
133
+ """Retrieve by ID."""
134
+ if self._collection is None:
135
+ return self._read_from_md(entry_id)
136
+ try:
137
+ result = self._collection.get(ids=[entry_id], include=["documents", "metadatas"])
138
+ if not result["ids"]:
139
+ return None
140
+ entry = self._result_to_entry(result, 0)
141
+ entry.access_count += 1
142
+ entry.updated_at = datetime.utcnow().isoformat()
143
+ self._upsert_vector(entry)
144
+ self._persist_md(entry)
145
+ return entry
146
+ except Exception as exc:
147
+ logger.error("read failed: %s", exc)
148
+ return self._read_from_md(entry_id)
149
+
150
+ def update(self, entry_id: str, **kwargs) -> Optional[MemoryEntry]:
151
+ """Update fields and re-embed if content changed."""
152
+ entry = self.read(entry_id)
153
+ if not entry:
154
+ return None
155
+ for k, v in kwargs.items():
156
+ if hasattr(entry, k) and k not in ("id", "tier", "created_at"):
157
+ setattr(entry, k, v)
158
+ entry.updated_at = datetime.utcnow().isoformat()
159
+ self._upsert_vector(entry)
160
+ self._persist_md(entry)
161
+ return entry
162
+
163
+ def delete(self, entry_id: str) -> bool:
164
+ """Remove from vector store and disk."""
165
+ if self._collection is not None:
166
+ try:
167
+ self._collection.delete(ids=[entry_id])
168
+ except Exception:
169
+ pass
170
+ md_path = self.md_dir / f"{entry_id}.md"
171
+ if md_path.exists():
172
+ md_path.unlink()
173
+ return True
174
+ return False
175
+
176
+ # ── search / RAG ─────────────────────────────────────────
177
+
178
+ def search(
179
+ self,
180
+ query: str,
181
+ limit: int = 5,
182
+ where: Optional[Dict[str, Any]] = None,
183
+ ) -> List[SearchResult]:
184
+ """Semantic similarity search. This is the RAG retrieval endpoint."""
185
+ if self._collection is None:
186
+ return self._keyword_fallback(query, limit)
187
+
188
+ kwargs: Dict[str, Any] = {
189
+ "query_texts": [query],
190
+ "n_results": min(limit, self._collection.count() or 1),
191
+ "include": ["documents", "metadatas", "distances"],
192
+ }
193
+ if where:
194
+ kwargs["where"] = where
195
+
196
+ try:
197
+ results = self._collection.query(**kwargs)
198
+ except Exception as exc:
199
+ logger.error("vector search failed: %s", exc)
200
+ return self._keyword_fallback(query, limit)
201
+
202
+ search_results: List[SearchResult] = []
203
+ if results and results["ids"] and results["ids"][0]:
204
+ for idx in range(len(results["ids"][0])):
205
+ entry = self._query_result_to_entry(results, idx)
206
+ dist = results["distances"][0][idx] if results.get("distances") else 0
207
+ score = max(0.0, 1.0 - dist) # cosine distance β†’ similarity
208
+ search_results.append(SearchResult(entry=entry, score=score, distance=dist))
209
+
210
+ return search_results
211
+
212
+ def list_entries(self, limit: int = 100, tag: Optional[str] = None) -> List[MemoryEntry]:
213
+ """List all stored entries (up to limit)."""
214
+ if self._collection is None:
215
+ return self._list_from_md(limit, tag)
216
+
217
+ result = self._collection.get(
218
+ include=["documents", "metadatas"],
219
+ limit=limit,
220
+ )
221
+ entries = []
222
+ for idx in range(len(result["ids"])):
223
+ entry = self._result_to_entry(result, idx)
224
+ if tag and tag not in entry.tags:
225
+ continue
226
+ entries.append(entry)
227
+ return entries
228
+
229
+ def count(self) -> int:
230
+ if self._collection is not None:
231
+ return self._collection.count()
232
+ return len(list(self.md_dir.glob("*.md")))
233
+
234
+ # ── internals ────────────────────────────────────────────
235
+
236
+ def _upsert_vector(self, entry: MemoryEntry):
237
+ if self._collection is None:
238
+ return
239
+ meta = {
240
+ "title": entry.title,
241
+ "tier": entry.tier.value,
242
+ "tags": json.dumps(entry.tags),
243
+ "importance": entry.importance,
244
+ "access_count": entry.access_count,
245
+ "created_at": entry.created_at,
246
+ "updated_at": entry.updated_at,
247
+ "source": entry.source,
248
+ }
249
+ self._collection.upsert(
250
+ ids=[entry.id],
251
+ documents=[entry.content],
252
+ metadatas=[meta],
253
+ )
254
+
255
+ def _persist_md(self, entry: MemoryEntry):
256
+ path = self.md_dir / f"{entry.id}.md"
257
+ path.write_text(entry.to_markdown(), encoding="utf-8")
258
+
259
+ def _read_from_md(self, entry_id: str) -> Optional[MemoryEntry]:
260
+ path = self.md_dir / f"{entry_id}.md"
261
+ if not path.exists():
262
+ return None
263
+ text = path.read_text(encoding="utf-8")
264
+ return MemoryEntry.from_markdown(text)
265
+
266
+ def _result_to_entry(self, result: dict, idx: int) -> MemoryEntry:
267
+ meta = result["metadatas"][idx] if result.get("metadatas") else {}
268
+ doc = result["documents"][idx] if result.get("documents") else ""
269
+ entry_id = result["ids"][idx]
270
+ tags = []
271
+ if "tags" in meta:
272
+ try:
273
+ tags = json.loads(meta["tags"])
274
+ except (json.JSONDecodeError, TypeError):
275
+ tags = []
276
+ return MemoryEntry(
277
+ id=entry_id,
278
+ content=doc,
279
+ title=meta.get("title", ""),
280
+ tier=MemoryTier.SEMANTIC,
281
+ tags=tags,
282
+ importance=float(meta.get("importance", 0.5)),
283
+ access_count=int(meta.get("access_count", 0)),
284
+ created_at=meta.get("created_at", ""),
285
+ updated_at=meta.get("updated_at", ""),
286
+ source=meta.get("source", ""),
287
+ )
288
+
289
+ def _query_result_to_entry(self, results: dict, idx: int) -> MemoryEntry:
290
+ meta = results["metadatas"][0][idx] if results.get("metadatas") else {}
291
+ doc = results["documents"][0][idx] if results.get("documents") else ""
292
+ entry_id = results["ids"][0][idx]
293
+ tags = []
294
+ if "tags" in meta:
295
+ try:
296
+ tags = json.loads(meta["tags"])
297
+ except (json.JSONDecodeError, TypeError):
298
+ tags = []
299
+ return MemoryEntry(
300
+ id=entry_id,
301
+ content=doc,
302
+ title=meta.get("title", ""),
303
+ tier=MemoryTier.SEMANTIC,
304
+ tags=tags,
305
+ importance=float(meta.get("importance", 0.5)),
306
+ access_count=int(meta.get("access_count", 0)),
307
+ created_at=meta.get("created_at", ""),
308
+ updated_at=meta.get("updated_at", ""),
309
+ source=meta.get("source", ""),
310
+ )
311
+
312
+ def _keyword_fallback(self, query: str, limit: int) -> List[SearchResult]:
313
+ """When ChromaDB is unavailable, fall back to keyword search over MD files."""
314
+ q = query.lower()
315
+ results: List[SearchResult] = []
316
+ for md_file in self.md_dir.glob("*.md"):
317
+ try:
318
+ text = md_file.read_text(encoding="utf-8")
319
+ if q in text.lower():
320
+ entry = MemoryEntry.from_markdown(text)
321
+ entry.tier = MemoryTier.SEMANTIC
322
+ results.append(SearchResult(entry=entry, score=0.5))
323
+ if len(results) >= limit:
324
+ break
325
+ except Exception:
326
+ pass
327
+ return results
328
+
329
+ def _list_from_md(self, limit: int, tag: Optional[str]) -> List[MemoryEntry]:
330
+ entries: List[MemoryEntry] = []
331
+ for md_file in sorted(self.md_dir.glob("*.md"), reverse=True):
332
+ try:
333
+ text = md_file.read_text(encoding="utf-8")
334
+ entry = MemoryEntry.from_markdown(text)
335
+ entry.tier = MemoryTier.SEMANTIC
336
+ if tag and tag not in entry.tags:
337
+ continue
338
+ entries.append(entry)
339
+ if len(entries) >= limit:
340
+ break
341
+ except Exception:
342
+ pass
343
+ return entries
memory_server.py ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Memory System MCP Server
4
+ =========================
5
+ A three-tier memory architecture exposed as MCP tools for AI agents.
6
+
7
+ Tiers
8
+ -----
9
+ 1. **Session** (short-term) – conversation context, auto-expiring
10
+ 2. **Episodic** (mid-term) – past tasks & events, searchable timeline
11
+ 3. **Semantic** (long-term) – vector-backed RAG knowledge base
12
+
13
+ Every entry is also persisted as a human-readable Markdown file.
14
+
15
+ Usage
16
+ -----
17
+ python memory_server.py # stdio transport (for MCP clients)
18
+ python memory_server.py --sse 8765 # SSE transport on port 8765
19
+
20
+ Transport is auto-detected via MCP protocol when run from an MCP host.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional
31
+
32
+ from mcp.server.fastmcp import FastMCP
33
+
34
+ # ── local imports ────────────────────────────────────────────
35
+ from memory.session import SessionMemory
36
+ from memory.events import EpisodicMemory
37
+ from memory.vector import SemanticMemory
38
+ from memory.models import MemoryEntry, MemoryTier
39
+
40
+ # ── logging ──────────────────────────────────────────────────
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
44
+ )
45
+ logger = logging.getLogger("memory-mcp")
46
+
47
+ # ── resolve data root ───────────────────────────────────────
48
+ DATA_ROOT = Path(os.environ.get("MEMORY_DATA_ROOT", Path(__file__).parent / "data"))
49
+ DATA_ROOT.mkdir(parents=True, exist_ok=True)
50
+
51
+ EMBEDDING_MODEL = os.environ.get("MEMORY_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
52
+ SESSION_TTL = int(os.environ.get("MEMORY_SESSION_TTL", "3600"))
53
+
54
+ # ── initialise stores ───────────────────────────────────────
55
+ session_store = SessionMemory(
56
+ base_dir=str(DATA_ROOT / "session"),
57
+ ttl=SESSION_TTL,
58
+ )
59
+ episodic_store = EpisodicMemory(
60
+ base_dir=str(DATA_ROOT / "events"),
61
+ )
62
+ semantic_store = SemanticMemory(
63
+ vector_dir=str(DATA_ROOT / "vector"),
64
+ md_dir=str(DATA_ROOT / "vector" / "docs"),
65
+ model_name=EMBEDDING_MODEL,
66
+ )
67
+
68
+ logger.info("🧠 Memory stores initialised – data_root=%s", DATA_ROOT)
69
+
70
+ # ── MCP server ───────────────────────────────────────────────
71
+ mcp = FastMCP("memory")
72
+
73
+
74
+ # =====================================================================
75
+ # RESOURCES – browse memory state
76
+ # =====================================================================
77
+
78
+ @mcp.resource("memory://status")
79
+ def memory_status() -> str:
80
+ """Overview of the memory system."""
81
+ return json.dumps({
82
+ "session": {
83
+ "sessions": session_store.list_sessions(),
84
+ "total_entries": sum(
85
+ len(session_store.list_entries(sid))
86
+ for sid in session_store.list_sessions()
87
+ ),
88
+ },
89
+ "episodic": {
90
+ "total_entries": episodic_store.count(),
91
+ },
92
+ "semantic": {
93
+ "total_entries": semantic_store.count(),
94
+ "embedding_model": EMBEDDING_MODEL,
95
+ },
96
+ }, indent=2)
97
+
98
+
99
+ @mcp.resource("memory://session/{session_id}")
100
+ def session_resource(session_id: str) -> str:
101
+ """List entries in a session."""
102
+ entries = session_store.list_entries(session_id)
103
+ return json.dumps([e.to_dict() for e in entries], indent=2)
104
+
105
+
106
+ @mcp.resource("memory://events/recent")
107
+ def recent_events_resource() -> str:
108
+ """The 20 most recent episodic events."""
109
+ entries = episodic_store.recent(20)
110
+ return json.dumps([e.to_dict() for e in entries], indent=2)
111
+
112
+
113
+ # =====================================================================
114
+ # PROMPTS
115
+ # =====================================================================
116
+
117
+ @mcp.prompt()
118
+ def memory_context_prompt(query: str = "", session_id: str = "default") -> str:
119
+ """Build a comprehensive memory context for an LLM prompt."""
120
+ parts: List[str] = ["# Agent Memory Context\n"]
121
+
122
+ # session context
123
+ session_entries = session_store.list_entries(session_id)
124
+ if session_entries:
125
+ parts.append("## Recent Conversation (Session)")
126
+ for e in session_entries[-5:]:
127
+ parts.append(f"- [{e.created_at}] {e.title}: {e.content[:200]}")
128
+ parts.append("")
129
+
130
+ # episodic
131
+ recent = episodic_store.recent(5)
132
+ if recent:
133
+ parts.append("## Recent Tasks (Episodic)")
134
+ for e in recent:
135
+ parts.append(f"- [{e.created_at}] {e.title}: {e.content[:200]}")
136
+ parts.append("")
137
+
138
+ # semantic / RAG
139
+ if query:
140
+ hits = semantic_store.search(query, limit=3)
141
+ if hits:
142
+ parts.append("## Relevant Knowledge (Semantic / RAG)")
143
+ for h in hits:
144
+ parts.append(f"- [score={h.score:.2f}] {h.entry.title}: {h.entry.content[:300]}")
145
+ parts.append("")
146
+
147
+ return "\n".join(parts)
148
+
149
+
150
+ # =====================================================================
151
+ # TOOLS – full CRUD for each tier
152
+ # =====================================================================
153
+
154
+ # ─── Session (short-term) ───────────────────────────────────
155
+
156
+ @mcp.tool()
157
+ def session_create(
158
+ content: str,
159
+ title: str = "",
160
+ tags: str = "",
161
+ session_id: str = "default",
162
+ importance: float = 0.5,
163
+ ) -> Dict[str, Any]:
164
+ """
165
+ Create a new short-term / session memory entry.
166
+
167
+ Stores conversation context that auto-expires after the configured TTL.
168
+ Persisted as a Markdown file under data/session/<session_id>/.
169
+ """
170
+ entry = MemoryEntry(
171
+ content=content,
172
+ title=title or content[:60],
173
+ tags=[t.strip() for t in tags.split(",") if t.strip()] if tags else [],
174
+ importance=importance,
175
+ )
176
+ result = session_store.create(entry, session_id=session_id)
177
+ return {"status": "created", "entry": result.to_dict()}
178
+
179
+
180
+ @mcp.tool()
181
+ def session_read(entry_id: str, session_id: str = "default") -> Dict[str, Any]:
182
+ """Read a single session memory entry by ID."""
183
+ entry = session_store.read(entry_id, session_id)
184
+ if not entry:
185
+ return {"status": "not_found", "entry_id": entry_id}
186
+ return {"status": "ok", "entry": entry.to_dict()}
187
+
188
+
189
+ @mcp.tool()
190
+ def session_update(
191
+ entry_id: str,
192
+ session_id: str = "default",
193
+ content: str = "",
194
+ title: str = "",
195
+ tags: str = "",
196
+ importance: float = -1,
197
+ ) -> Dict[str, Any]:
198
+ """Update a session memory entry. Only provided fields are changed."""
199
+ kwargs: Dict[str, Any] = {}
200
+ if content:
201
+ kwargs["content"] = content
202
+ if title:
203
+ kwargs["title"] = title
204
+ if tags:
205
+ kwargs["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
206
+ if importance >= 0:
207
+ kwargs["importance"] = importance
208
+ entry = session_store.update(entry_id, session_id, **kwargs)
209
+ if not entry:
210
+ return {"status": "not_found", "entry_id": entry_id}
211
+ return {"status": "updated", "entry": entry.to_dict()}
212
+
213
+
214
+ @mcp.tool()
215
+ def session_delete(entry_id: str, session_id: str = "default") -> Dict[str, Any]:
216
+ """Delete a session memory entry."""
217
+ ok = session_store.delete(entry_id, session_id)
218
+ return {"status": "deleted" if ok else "not_found", "entry_id": entry_id}
219
+
220
+
221
+ @mcp.tool()
222
+ def session_list(session_id: str = "default", tag: str = "") -> Dict[str, Any]:
223
+ """List all entries in a session, optionally filtered by tag."""
224
+ entries = session_store.list_entries(session_id, tag=tag or None)
225
+ return {"count": len(entries), "entries": [e.to_dict() for e in entries]}
226
+
227
+
228
+ @mcp.tool()
229
+ def session_search(query: str, session_id: str = "", limit: int = 10) -> Dict[str, Any]:
230
+ """Keyword search across session memories."""
231
+ results = session_store.search(query, session_id=session_id or None, limit=limit)
232
+ return {"count": len(results), "entries": [e.to_dict() for e in results]}
233
+
234
+
235
+ @mcp.tool()
236
+ def session_clear(session_id: str = "default") -> Dict[str, Any]:
237
+ """Clear all entries from a session."""
238
+ count = session_store.clear_session(session_id)
239
+ return {"status": "cleared", "session_id": session_id, "deleted": count}
240
+
241
+
242
+ @mcp.tool()
243
+ def session_gc() -> Dict[str, Any]:
244
+ """Garbage-collect expired session entries across all sessions."""
245
+ removed = session_store.gc()
246
+ return {"status": "gc_complete", "removed": removed}
247
+
248
+
249
+ # ─── Episodic (mid-term) ────────────────────────────────────
250
+
251
+ @mcp.tool()
252
+ def episodic_create(
253
+ content: str,
254
+ title: str = "",
255
+ tags: str = "",
256
+ importance: float = 0.5,
257
+ source: str = "",
258
+ ) -> Dict[str, Any]:
259
+ """
260
+ Record a new episodic memory (task completion, event, interaction).
261
+
262
+ Stored as a timestamped Markdown file under data/events/.
263
+ """
264
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
265
+ entry = episodic_store.create(
266
+ content=content,
267
+ title=title,
268
+ tags=tag_list,
269
+ importance=importance,
270
+ source=source,
271
+ )
272
+ return {"status": "created", "entry": entry.to_dict()}
273
+
274
+
275
+ @mcp.tool()
276
+ def episodic_read(entry_id: str) -> Dict[str, Any]:
277
+ """Read a single episodic memory by ID."""
278
+ entry = episodic_store.read(entry_id)
279
+ if not entry:
280
+ return {"status": "not_found", "entry_id": entry_id}
281
+ return {"status": "ok", "entry": entry.to_dict()}
282
+
283
+
284
+ @mcp.tool()
285
+ def episodic_update(
286
+ entry_id: str,
287
+ content: str = "",
288
+ title: str = "",
289
+ tags: str = "",
290
+ importance: float = -1,
291
+ ) -> Dict[str, Any]:
292
+ """Update an episodic memory entry."""
293
+ kwargs: Dict[str, Any] = {}
294
+ if content:
295
+ kwargs["content"] = content
296
+ if title:
297
+ kwargs["title"] = title
298
+ if tags:
299
+ kwargs["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
300
+ if importance >= 0:
301
+ kwargs["importance"] = importance
302
+ entry = episodic_store.update(entry_id, **kwargs)
303
+ if not entry:
304
+ return {"status": "not_found", "entry_id": entry_id}
305
+ return {"status": "updated", "entry": entry.to_dict()}
306
+
307
+
308
+ @mcp.tool()
309
+ def episodic_delete(entry_id: str) -> Dict[str, Any]:
310
+ """Delete an episodic memory entry."""
311
+ ok = episodic_store.delete(entry_id)
312
+ return {"status": "deleted" if ok else "not_found", "entry_id": entry_id}
313
+
314
+
315
+ @mcp.tool()
316
+ def episodic_list(
317
+ tag: str = "",
318
+ since: str = "",
319
+ until: str = "",
320
+ limit: int = 50,
321
+ ) -> Dict[str, Any]:
322
+ """List episodic memories, optionally filtered by tag and/or time range (ISO format)."""
323
+ entries = episodic_store.list_entries(
324
+ tag=tag or None,
325
+ since=since or None,
326
+ until=until or None,
327
+ limit=limit,
328
+ )
329
+ return {"count": len(entries), "entries": [e.to_dict() for e in entries]}
330
+
331
+
332
+ @mcp.tool()
333
+ def episodic_search(query: str, limit: int = 10) -> Dict[str, Any]:
334
+ """Keyword search across episodic memories."""
335
+ results = episodic_store.search(query, limit=limit)
336
+ return {"count": len(results), "entries": [e.to_dict() for e in results]}
337
+
338
+
339
+ @mcp.tool()
340
+ def episodic_recent(n: int = 10) -> Dict[str, Any]:
341
+ """Get the N most recent episodic events."""
342
+ entries = episodic_store.recent(n)
343
+ return {"count": len(entries), "entries": [e.to_dict() for e in entries]}
344
+
345
+
346
+ # ─── Semantic / RAG (long-term) ─────────────────────────────
347
+
348
+ @mcp.tool()
349
+ def semantic_create(
350
+ content: str,
351
+ title: str = "",
352
+ tags: str = "",
353
+ importance: float = 0.5,
354
+ source: str = "",
355
+ ) -> Dict[str, Any]:
356
+ """
357
+ Add a document to the semantic / RAG knowledge base.
358
+
359
+ The content is embedded via sentence-transformers and stored in ChromaDB
360
+ for similarity search. Also persisted as a Markdown file.
361
+ """
362
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
363
+ entry = semantic_store.create(
364
+ content=content,
365
+ title=title,
366
+ tags=tag_list,
367
+ importance=importance,
368
+ source=source,
369
+ )
370
+ return {"status": "created", "entry": entry.to_dict()}
371
+
372
+
373
+ @mcp.tool()
374
+ def semantic_read(entry_id: str) -> Dict[str, Any]:
375
+ """Read a single semantic memory by ID."""
376
+ entry = semantic_store.read(entry_id)
377
+ if not entry:
378
+ return {"status": "not_found", "entry_id": entry_id}
379
+ return {"status": "ok", "entry": entry.to_dict()}
380
+
381
+
382
+ @mcp.tool()
383
+ def semantic_update(
384
+ entry_id: str,
385
+ content: str = "",
386
+ title: str = "",
387
+ tags: str = "",
388
+ importance: float = -1,
389
+ ) -> Dict[str, Any]:
390
+ """Update a semantic memory entry. Re-embeds automatically if content changes."""
391
+ kwargs: Dict[str, Any] = {}
392
+ if content:
393
+ kwargs["content"] = content
394
+ if title:
395
+ kwargs["title"] = title
396
+ if tags:
397
+ kwargs["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
398
+ if importance >= 0:
399
+ kwargs["importance"] = importance
400
+ entry = semantic_store.update(entry_id, **kwargs)
401
+ if not entry:
402
+ return {"status": "not_found", "entry_id": entry_id}
403
+ return {"status": "updated", "entry": entry.to_dict()}
404
+
405
+
406
+ @mcp.tool()
407
+ def semantic_delete(entry_id: str) -> Dict[str, Any]:
408
+ """Delete a semantic memory entry from vector store and disk."""
409
+ ok = semantic_store.delete(entry_id)
410
+ return {"status": "deleted" if ok else "not_found", "entry_id": entry_id}
411
+
412
+
413
+ @mcp.tool()
414
+ def semantic_search(query: str, limit: int = 5) -> Dict[str, Any]:
415
+ """
416
+ Semantic similarity search (RAG retrieval).
417
+
418
+ Finds the most relevant documents in the knowledge base using
419
+ vector cosine similarity. This is the primary RAG endpoint.
420
+ """
421
+ results = semantic_store.search(query, limit=limit)
422
+ return {
423
+ "count": len(results),
424
+ "results": [
425
+ {
426
+ "score": round(r.score, 4),
427
+ "distance": round(r.distance, 4),
428
+ "entry": r.entry.to_dict(),
429
+ }
430
+ for r in results
431
+ ],
432
+ }
433
+
434
+
435
+ @mcp.tool()
436
+ def semantic_list(limit: int = 100, tag: str = "") -> Dict[str, Any]:
437
+ """List all entries in the semantic knowledge base."""
438
+ entries = semantic_store.list_entries(limit=limit, tag=tag or None)
439
+ return {"count": len(entries), "entries": [e.to_dict() for e in entries]}
440
+
441
+
442
+ # ─── Cross-tier utilities ───────────────────────────────────
443
+
444
+ @mcp.tool()
445
+ def memory_search_all(query: str, limit: int = 5) -> Dict[str, Any]:
446
+ """
447
+ Search across ALL memory tiers (session + episodic + semantic).
448
+
449
+ Combines keyword search from session & episodic with
450
+ semantic vector search. Returns unified results sorted by relevance.
451
+ """
452
+ results: Dict[str, Any] = {}
453
+
454
+ # session
455
+ s_hits = session_store.search(query, limit=limit)
456
+ results["session"] = [e.to_dict() for e in s_hits]
457
+
458
+ # episodic
459
+ e_hits = episodic_store.search(query, limit=limit)
460
+ results["episodic"] = [e.to_dict() for e in e_hits]
461
+
462
+ # semantic (RAG)
463
+ v_hits = semantic_store.search(query, limit=limit)
464
+ results["semantic"] = [
465
+ {"score": round(r.score, 4), "entry": r.entry.to_dict()}
466
+ for r in v_hits
467
+ ]
468
+
469
+ results["total"] = len(s_hits) + len(e_hits) + len(v_hits)
470
+ return results
471
+
472
+
473
+ @mcp.tool()
474
+ def memory_promote(entry_id: str, from_tier: str, to_tier: str) -> Dict[str, Any]:
475
+ """
476
+ Promote a memory entry from one tier to another.
477
+
478
+ E.g. promote a session memory to episodic, or episodic to semantic.
479
+ The entry is copied to the target tier (source is kept).
480
+ """
481
+ # read from source
482
+ source_entry: Optional[MemoryEntry] = None
483
+ if from_tier == "session":
484
+ source_entry = session_store.read(entry_id)
485
+ elif from_tier == "episodic":
486
+ source_entry = episodic_store.read(entry_id)
487
+ elif from_tier == "semantic":
488
+ source_entry = semantic_store.read(entry_id)
489
+
490
+ if not source_entry:
491
+ return {"status": "not_found", "entry_id": entry_id, "tier": from_tier}
492
+
493
+ # write to target
494
+ if to_tier == "session":
495
+ new_entry = MemoryEntry(
496
+ content=source_entry.content,
497
+ title=source_entry.title,
498
+ tags=source_entry.tags,
499
+ importance=source_entry.importance,
500
+ metadata=source_entry.metadata,
501
+ source=f"promoted from {from_tier}:{entry_id}",
502
+ )
503
+ result = session_store.create(new_entry)
504
+ elif to_tier == "episodic":
505
+ result = episodic_store.create(
506
+ content=source_entry.content,
507
+ title=source_entry.title,
508
+ tags=source_entry.tags,
509
+ importance=source_entry.importance,
510
+ metadata=source_entry.metadata,
511
+ source=f"promoted from {from_tier}:{entry_id}",
512
+ )
513
+ elif to_tier == "semantic":
514
+ result = semantic_store.create(
515
+ content=source_entry.content,
516
+ title=source_entry.title,
517
+ tags=source_entry.tags,
518
+ importance=source_entry.importance,
519
+ metadata=source_entry.metadata,
520
+ source=f"promoted from {from_tier}:{entry_id}",
521
+ )
522
+ else:
523
+ return {"status": "error", "message": f"Unknown target tier: {to_tier}"}
524
+
525
+ return {
526
+ "status": "promoted",
527
+ "from": from_tier,
528
+ "to": to_tier,
529
+ "original_id": entry_id,
530
+ "new_entry": result.to_dict(),
531
+ }
532
+
533
+
534
+ @mcp.tool()
535
+ def memory_stats() -> Dict[str, Any]:
536
+ """Get statistics about all memory tiers."""
537
+ sessions = session_store.list_sessions()
538
+ session_total = sum(len(session_store.list_entries(sid)) for sid in sessions)
539
+ return {
540
+ "session": {
541
+ "sessions": len(sessions),
542
+ "total_entries": session_total,
543
+ "ttl_seconds": SESSION_TTL,
544
+ },
545
+ "episodic": {
546
+ "total_entries": episodic_store.count(),
547
+ },
548
+ "semantic": {
549
+ "total_entries": semantic_store.count(),
550
+ "embedding_model": EMBEDDING_MODEL,
551
+ },
552
+ "data_root": str(DATA_ROOT),
553
+ }
554
+
555
+
556
+ # =====================================================================
557
+ # ENTRY POINT
558
+ # =====================================================================
559
+
560
+ if __name__ == "__main__":
561
+ import argparse
562
+
563
+ parser = argparse.ArgumentParser(description="Memory System MCP Server")
564
+ parser.add_argument("--sse", type=int, default=0, help="Run SSE transport on this port")
565
+ args = parser.parse_args()
566
+
567
+ if args.sse:
568
+ logger.info("πŸš€ Starting Memory MCP server (SSE) on port %d", args.sse)
569
+ mcp.run(transport="sse", sse_params={"port": args.sse})
570
+ else:
571
+ logger.info("πŸš€ Starting Memory MCP server (stdio)")
572
+ mcp.run(transport="stdio")
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Memory System MCP Server – Dependencies
2
+
3
+ # MCP SDK
4
+ mcp[cli]>=1.0.0
5
+
6
+ # Vector store
7
+ chromadb>=0.5.0
8
+
9
+ # Embeddings (HuggingFace sentence-transformers)
10
+ sentence-transformers>=2.2.0
11
+
12
+ # Utilities
13
+ numpy>=1.24.0