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.
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
+ }