mindcore-memory 0.1.0__py3-none-any.whl
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.
- mindcore_memory-0.1.0.dist-info/METADATA +217 -0
- mindcore_memory-0.1.0.dist-info/RECORD +10 -0
- mindcore_memory-0.1.0.dist-info/WHEEL +4 -0
- mindcore_memory-0.1.0.dist-info/entry_points.txt +2 -0
- mindcore_memory-0.1.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +13 -0
- src/eval_framework.py +353 -0
- src/http_app.py +118 -0
- src/memory_engine.py +369 -0
- src/server.py +334 -0
src/http_app.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP transport for MindCore Memory MCP Server.
|
|
3
|
+
Production-grade: Bearer token auth, Origin validation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, Request, HTTPException, Depends, Header
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from mcp.server import Server
|
|
15
|
+
from mcp.server.sse import SseServerTransport
|
|
16
|
+
import structlog
|
|
17
|
+
|
|
18
|
+
from .memory_engine import MemoryEngine
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_http_app(token: Optional[str] = None) -> FastAPI:
|
|
24
|
+
"""Create FastAPI app with MCP HTTP endpoint."""
|
|
25
|
+
|
|
26
|
+
app = FastAPI(title="MindCore Memory MCP", version="0.1.0")
|
|
27
|
+
|
|
28
|
+
# CORS: restrict to known origins in production
|
|
29
|
+
app.add_middleware(
|
|
30
|
+
CORSMiddleware,
|
|
31
|
+
allow_origins=["*"], # Tighten in production
|
|
32
|
+
allow_credentials=True,
|
|
33
|
+
allow_methods=["*"],
|
|
34
|
+
allow_headers=["*"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def verify_token(authorization: Optional[str] = Header(None)) -> bool:
|
|
38
|
+
"""Verify Bearer token if configured."""
|
|
39
|
+
if not token:
|
|
40
|
+
return True # No auth configured
|
|
41
|
+
if not authorization:
|
|
42
|
+
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
|
43
|
+
if not authorization.startswith("Bearer "):
|
|
44
|
+
raise HTTPException(status_code=401, detail="Invalid Authorization format")
|
|
45
|
+
provided = authorization[7:]
|
|
46
|
+
if provided != token:
|
|
47
|
+
raise HTTPException(status_code=403, detail="Invalid token")
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
@app.get("/health")
|
|
51
|
+
async def health():
|
|
52
|
+
"""Health check endpoint."""
|
|
53
|
+
return {"status": "ok", "service": "mindcore-memory-mcp"}
|
|
54
|
+
|
|
55
|
+
@app.get("/stats")
|
|
56
|
+
async def stats(_: bool = Depends(verify_token)):
|
|
57
|
+
"""Get memory stats (requires auth if configured)."""
|
|
58
|
+
engine = MemoryEngine()
|
|
59
|
+
return engine.get_stats()
|
|
60
|
+
|
|
61
|
+
@app.post("/mcp")
|
|
62
|
+
async def mcp_endpoint(request: Request, _: bool = Depends(verify_token)):
|
|
63
|
+
"""
|
|
64
|
+
MCP HTTP endpoint - accepts JSON-RPC 2.0 requests.
|
|
65
|
+
|
|
66
|
+
Supports:
|
|
67
|
+
- tools/list: List available tools
|
|
68
|
+
- tools/call: Call a tool
|
|
69
|
+
"""
|
|
70
|
+
body = await request.json()
|
|
71
|
+
method = body.get("method")
|
|
72
|
+
request_id = body.get("id")
|
|
73
|
+
|
|
74
|
+
# Import here to avoid circular import
|
|
75
|
+
from . import server as mcp_server
|
|
76
|
+
|
|
77
|
+
if method == "tools/list":
|
|
78
|
+
tools = await mcp_server.list_tools()
|
|
79
|
+
return {
|
|
80
|
+
"jsonrpc": "2.0",
|
|
81
|
+
"id": request_id,
|
|
82
|
+
"result": {
|
|
83
|
+
"tools": [
|
|
84
|
+
{
|
|
85
|
+
"name": t.name,
|
|
86
|
+
"description": t.description,
|
|
87
|
+
"inputSchema": t.inputSchema,
|
|
88
|
+
}
|
|
89
|
+
for t in tools
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
elif method == "tools/call":
|
|
95
|
+
args = body.get("params", {}).get("arguments", {})
|
|
96
|
+
tool_name = body.get("params", {}).get("name")
|
|
97
|
+
|
|
98
|
+
if not tool_name:
|
|
99
|
+
raise HTTPException(status_code=400, detail="Missing tool name")
|
|
100
|
+
|
|
101
|
+
result = await mcp_server.call_tool(tool_name, args)
|
|
102
|
+
return {
|
|
103
|
+
"jsonrpc": "2.0",
|
|
104
|
+
"id": request_id,
|
|
105
|
+
"result": {
|
|
106
|
+
"content": [{"type": "text", "text": c.text} for c in result.content],
|
|
107
|
+
"isError": result.isError,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
else:
|
|
112
|
+
return {
|
|
113
|
+
"jsonrpc": "2.0",
|
|
114
|
+
"id": request_id,
|
|
115
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return app
|
src/memory_engine.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindCore Memory Engine - Production-grade long-term memory for AI agents.
|
|
3
|
+
|
|
4
|
+
Key design decisions:
|
|
5
|
+
- User owns their data (stored in local filesystem, not vendor lock-in)
|
|
6
|
+
- JSON-based storage for transparency and portability
|
|
7
|
+
- Semantic similarity search via embedding-free keyword matching
|
|
8
|
+
- Context window optimization: only recall what's relevant
|
|
9
|
+
- Confidence scoring for each memory retrieval
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field, asdict
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import structlog
|
|
25
|
+
|
|
26
|
+
logger = structlog.get_logger()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryImportance(Enum):
|
|
30
|
+
"""Memory importance levels."""
|
|
31
|
+
EPISODIC = 1 # Momentary, short-lived context
|
|
32
|
+
WORKING = 2 # Current task context
|
|
33
|
+
SEMANTIC = 3 # Long-term knowledge
|
|
34
|
+
CRITICAL = 4 # Key facts, user preferences
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MemoryEntry:
|
|
39
|
+
"""A single memory entry."""
|
|
40
|
+
id: str
|
|
41
|
+
content: str
|
|
42
|
+
importance: int # 1-4
|
|
43
|
+
tags: list[str] = field(default_factory=list)
|
|
44
|
+
created_at: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
|
45
|
+
last_accessed: str = field(default_factory=lambda: datetime.utcnow().isoformat())
|
|
46
|
+
access_count: int = 0
|
|
47
|
+
session_id: Optional[str] = None
|
|
48
|
+
confidence: float = 0.5 # 0.0-1.0, how confident we are this memory is accurate
|
|
49
|
+
source: str = "agent" # "agent" | "user" | "tool"
|
|
50
|
+
metadata: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return asdict(self)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: dict) -> "MemoryEntry":
|
|
57
|
+
return cls(**data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RetrievalResult:
|
|
62
|
+
"""Memory retrieval result with confidence."""
|
|
63
|
+
memory: MemoryEntry
|
|
64
|
+
relevance_score: float # 0.0-1.0, how relevant to the query
|
|
65
|
+
confidence: float # 0.0-1.0, how confident the memory is accurate
|
|
66
|
+
snippet: str # Relevant excerpt
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MemoryEngine:
|
|
70
|
+
"""
|
|
71
|
+
Production-grade memory engine.
|
|
72
|
+
|
|
73
|
+
Stores memories as JSON files in user-controlled directory.
|
|
74
|
+
Supports semantic search, importance weighting, and confidence tracking.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
storage_path: Optional[str] = None,
|
|
80
|
+
max_memories: int = 10000,
|
|
81
|
+
recall_limit: int = 20,
|
|
82
|
+
):
|
|
83
|
+
if storage_path:
|
|
84
|
+
self.storage_path = Path(storage_path)
|
|
85
|
+
else:
|
|
86
|
+
# Default: ~/.mindcore/memory/
|
|
87
|
+
self.storage_path = Path.home() / ".mindcore" / "memory"
|
|
88
|
+
|
|
89
|
+
self.max_memories = max_memories
|
|
90
|
+
self.recall_limit = recall_limit
|
|
91
|
+
self._memories: dict[str, MemoryEntry] = {}
|
|
92
|
+
self._index: dict[str, set[str]] = {} # tag -> memory_ids
|
|
93
|
+
|
|
94
|
+
# Create storage directory
|
|
95
|
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
self.memory_file = self.storage_path / "memories.jsonl"
|
|
97
|
+
|
|
98
|
+
# Load existing memories
|
|
99
|
+
self._load()
|
|
100
|
+
|
|
101
|
+
logger.info(
|
|
102
|
+
"memory_engine_initialized",
|
|
103
|
+
storage_path=str(self.storage_path),
|
|
104
|
+
memory_count=len(self._memories),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _load(self) -> None:
|
|
108
|
+
"""Load memories from disk."""
|
|
109
|
+
if not self.memory_file.exists():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
count = 0
|
|
113
|
+
with open(self.memory_file, "r", encoding="utf-8") as f:
|
|
114
|
+
for line in f:
|
|
115
|
+
if line.strip():
|
|
116
|
+
try:
|
|
117
|
+
data = json.loads(line)
|
|
118
|
+
entry = MemoryEntry.from_dict(data)
|
|
119
|
+
self._memories[entry.id] = entry
|
|
120
|
+
for tag in entry.tags:
|
|
121
|
+
if tag not in self._index:
|
|
122
|
+
self._index[tag] = set()
|
|
123
|
+
self._index[tag].add(entry.id)
|
|
124
|
+
count += 1
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning("failed_to_load_memory", error=str(e))
|
|
127
|
+
|
|
128
|
+
logger.info("memories_loaded", count=count)
|
|
129
|
+
|
|
130
|
+
def _save(self, entry: MemoryEntry) -> None:
|
|
131
|
+
"""Append memory to disk (append-only for durability)."""
|
|
132
|
+
with open(self.memory_file, "a", encoding="utf-8") as f:
|
|
133
|
+
f.write(json.dumps(entry.to_dict(), ensure_ascii=False) + "\n")
|
|
134
|
+
|
|
135
|
+
def store(
|
|
136
|
+
self,
|
|
137
|
+
content: str,
|
|
138
|
+
importance: int = 2,
|
|
139
|
+
tags: Optional[list[str]] = None,
|
|
140
|
+
session_id: Optional[str] = None,
|
|
141
|
+
confidence: float = 0.5,
|
|
142
|
+
source: str = "agent",
|
|
143
|
+
metadata: Optional[dict] = None,
|
|
144
|
+
) -> str:
|
|
145
|
+
"""
|
|
146
|
+
Store a new memory.
|
|
147
|
+
|
|
148
|
+
Returns the memory ID.
|
|
149
|
+
"""
|
|
150
|
+
# Enforce max memories (LRU eviction of lowest importance)
|
|
151
|
+
if len(self._memories) >= self.max_memories:
|
|
152
|
+
self._evict_low_importance()
|
|
153
|
+
|
|
154
|
+
entry = MemoryEntry(
|
|
155
|
+
id=str(uuid.uuid4()),
|
|
156
|
+
content=content,
|
|
157
|
+
importance=max(1, min(4, importance)),
|
|
158
|
+
tags=tags or [],
|
|
159
|
+
session_id=session_id,
|
|
160
|
+
confidence=confidence,
|
|
161
|
+
source=source,
|
|
162
|
+
metadata=metadata or {},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self._memories[entry.id] = entry
|
|
166
|
+
for tag in entry.tags:
|
|
167
|
+
if tag not in self._index:
|
|
168
|
+
self._index[tag] = set()
|
|
169
|
+
self._index[tag].add(entry.id)
|
|
170
|
+
|
|
171
|
+
self._save(entry)
|
|
172
|
+
|
|
173
|
+
logger.info(
|
|
174
|
+
"memory_stored",
|
|
175
|
+
memory_id=entry.id,
|
|
176
|
+
importance=entry.importance,
|
|
177
|
+
tags=entry.tags,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return entry.id
|
|
181
|
+
|
|
182
|
+
def recall(
|
|
183
|
+
self,
|
|
184
|
+
query: str,
|
|
185
|
+
tags: Optional[list[str]] = None,
|
|
186
|
+
session_id: Optional[str] = None,
|
|
187
|
+
limit: Optional[int] = None,
|
|
188
|
+
) -> list[RetrievalResult]:
|
|
189
|
+
"""
|
|
190
|
+
Recall relevant memories based on query and filters.
|
|
191
|
+
|
|
192
|
+
Uses keyword matching + importance weighting + recency boost.
|
|
193
|
+
"""
|
|
194
|
+
limit = limit or self.recall_limit
|
|
195
|
+
query_lower = query.lower()
|
|
196
|
+
query_words = set(query_lower.split())
|
|
197
|
+
|
|
198
|
+
candidates: list[tuple[str, float]] = []
|
|
199
|
+
|
|
200
|
+
for mem_id, mem in self._memories.items():
|
|
201
|
+
# Filter by session
|
|
202
|
+
if session_id and mem.session_id and mem.session_id != session_id:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Filter by tags
|
|
206
|
+
if tags:
|
|
207
|
+
if not any(tag in mem.tags for tag in tags):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
# Compute relevance score
|
|
211
|
+
relevance = 0.0
|
|
212
|
+
|
|
213
|
+
# Keyword match (simple but fast)
|
|
214
|
+
content_words = set(mem.content.lower().split())
|
|
215
|
+
overlap = query_words & content_words
|
|
216
|
+
if overlap:
|
|
217
|
+
relevance = len(overlap) / max(len(query_words), 1) * 0.4
|
|
218
|
+
|
|
219
|
+
# Tag match bonus
|
|
220
|
+
if tags and any(tag in mem.tags for tag in tags):
|
|
221
|
+
relevance += 0.3
|
|
222
|
+
|
|
223
|
+
# Importance bonus (exponential)
|
|
224
|
+
relevance += mem.importance * 0.1
|
|
225
|
+
|
|
226
|
+
# Recency boost (last 1 hour = bonus)
|
|
227
|
+
try:
|
|
228
|
+
last_access = datetime.fromisoformat(mem.last_accessed)
|
|
229
|
+
age_hours = (datetime.utcnow() - last_access).total_seconds() / 3600
|
|
230
|
+
if age_hours < 1:
|
|
231
|
+
relevance += 0.15
|
|
232
|
+
elif age_hours < 24:
|
|
233
|
+
relevance += 0.05
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
if relevance > 0:
|
|
238
|
+
candidates.append((mem_id, relevance))
|
|
239
|
+
|
|
240
|
+
# Sort by relevance
|
|
241
|
+
candidates.sort(key=lambda x: x[1], reverse=True)
|
|
242
|
+
|
|
243
|
+
results = []
|
|
244
|
+
for mem_id, relevance in candidates[:limit]:
|
|
245
|
+
mem = self._memories[mem_id]
|
|
246
|
+
# Update access stats
|
|
247
|
+
mem.access_count += 1
|
|
248
|
+
mem.last_accessed = datetime.utcnow().isoformat()
|
|
249
|
+
|
|
250
|
+
# Truncate snippet
|
|
251
|
+
snippet = mem.content[:200] + "..." if len(mem.content) > 200 else mem.content
|
|
252
|
+
|
|
253
|
+
results.append(RetrievalResult(
|
|
254
|
+
memory=mem,
|
|
255
|
+
relevance_score=min(relevance, 1.0),
|
|
256
|
+
confidence=mem.confidence,
|
|
257
|
+
snippet=snippet,
|
|
258
|
+
))
|
|
259
|
+
|
|
260
|
+
logger.info(
|
|
261
|
+
"memory_recalled",
|
|
262
|
+
query=query[:50],
|
|
263
|
+
results_count=len(results),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return results
|
|
267
|
+
|
|
268
|
+
def get_context_window(
|
|
269
|
+
self,
|
|
270
|
+
query: str,
|
|
271
|
+
max_tokens: int = 4000,
|
|
272
|
+
session_id: Optional[str] = None,
|
|
273
|
+
) -> str:
|
|
274
|
+
"""
|
|
275
|
+
Build a context window optimized for the current query.
|
|
276
|
+
|
|
277
|
+
Fills up to max_tokens with the most relevant memories,
|
|
278
|
+
prioritizing: CRITICAL > SEMANTIC > WORKING > EPISODIC.
|
|
279
|
+
"""
|
|
280
|
+
results = self.recall(query, session_id=session_id, limit=50)
|
|
281
|
+
|
|
282
|
+
context_parts = []
|
|
283
|
+
current_tokens = 0
|
|
284
|
+
|
|
285
|
+
# Sort by importance then relevance
|
|
286
|
+
sorted_results = sorted(
|
|
287
|
+
results,
|
|
288
|
+
key=lambda r: (r.memory.importance, r.relevance_score),
|
|
289
|
+
reverse=True,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
for result in sorted_results:
|
|
293
|
+
mem = result.memory
|
|
294
|
+
# Rough token estimate: ~4 chars per token
|
|
295
|
+
mem_tokens = len(mem.content) // 4
|
|
296
|
+
|
|
297
|
+
if current_tokens + mem_tokens > max_tokens:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
context_parts.append(f"[{mem.importance}★] {mem.content}")
|
|
301
|
+
current_tokens += mem_tokens
|
|
302
|
+
|
|
303
|
+
if not context_parts:
|
|
304
|
+
return ""
|
|
305
|
+
|
|
306
|
+
return "\n\n".join(context_parts)
|
|
307
|
+
|
|
308
|
+
def _evict_low_importance(self) -> None:
|
|
309
|
+
"""Evict lowest importance memories when storage is full."""
|
|
310
|
+
if not self._memories:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# Find lowest importance, oldest memories
|
|
314
|
+
sorted_memories = sorted(
|
|
315
|
+
self._memories.items(),
|
|
316
|
+
key=lambda x: (x[1].importance, -x[1].access_count),
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Evict 10% of memories
|
|
320
|
+
evict_count = max(1, len(self._memories) // 10)
|
|
321
|
+
to_evict = [mid for mid, _ in sorted_memories[:evict_count]]
|
|
322
|
+
|
|
323
|
+
for mid in to_evict:
|
|
324
|
+
del self._memories[mid]
|
|
325
|
+
|
|
326
|
+
# Rebuild index
|
|
327
|
+
self._index.clear()
|
|
328
|
+
for mem_id, mem in self._memories.items():
|
|
329
|
+
for tag in mem.tags:
|
|
330
|
+
if tag not in self._index:
|
|
331
|
+
self._index[tag] = set()
|
|
332
|
+
self._index[tag].add(mem_id)
|
|
333
|
+
|
|
334
|
+
logger.warning("memories_evicted", count=evict_count, remaining=len(self._memories))
|
|
335
|
+
|
|
336
|
+
def update_confidence(self, memory_id: str, confidence: float) -> bool:
|
|
337
|
+
"""Update confidence score for a memory (e.g., after user correction)."""
|
|
338
|
+
if memory_id not in self._memories:
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
mem = self._memories[memory_id]
|
|
342
|
+
mem.confidence = max(0.0, min(1.0, confidence))
|
|
343
|
+
mem.last_accessed = datetime.utcnow().isoformat()
|
|
344
|
+
|
|
345
|
+
# Note: We don't rewrite the JSONL file (append-only design)
|
|
346
|
+
# Confidence updates are tracked in memory only
|
|
347
|
+
logger.info("memory_confidence_updated", memory_id=memory_id, confidence=confidence)
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
def get_stats(self) -> dict:
|
|
351
|
+
"""Get memory system statistics."""
|
|
352
|
+
total = len(self._memories)
|
|
353
|
+
by_importance = {}
|
|
354
|
+
for imp in range(1, 5):
|
|
355
|
+
by_importance[imp] = sum(1 for m in self._memories.values() if m.importance == imp)
|
|
356
|
+
|
|
357
|
+
avg_confidence = (
|
|
358
|
+
sum(m.confidence for m in self._memories.values()) / total
|
|
359
|
+
if total > 0 else 0.0
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
"total_memories": total,
|
|
364
|
+
"max_memories": self.max_memories,
|
|
365
|
+
"by_importance": by_importance,
|
|
366
|
+
"avg_confidence": round(avg_confidence, 3),
|
|
367
|
+
"tag_count": len(self._index),
|
|
368
|
+
"storage_path": str(self.storage_path),
|
|
369
|
+
}
|