marm-mcp-server 2.1__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.
- config/__init__.py +1 -0
- config/settings.py +79 -0
- core/__init__.py +1 -0
- core/events.py +87 -0
- core/memory.py +407 -0
- core/models.py +39 -0
- core/rate_limiter.py +130 -0
- core/response_limiter.py +186 -0
- endpoints/__init__.py +1 -0
- endpoints/logging.py +185 -0
- endpoints/memory.py +197 -0
- endpoints/notebook.py +176 -0
- endpoints/reasoning.py +271 -0
- endpoints/session.py +83 -0
- endpoints/system.py +147 -0
- marm_mcp_server-2.1.dist-info/METADATA +485 -0
- marm_mcp_server-2.1.dist-info/RECORD +27 -0
- marm_mcp_server-2.1.dist-info/WHEEL +5 -0
- marm_mcp_server-2.1.dist-info/entry_points.txt +5 -0
- marm_mcp_server-2.1.dist-info/top_level.txt +6 -0
- middleware/__init__.py +1 -0
- middleware/rate_limiting.py +70 -0
- services/__init__.py +1 -0
- services/automation.py +23 -0
- services/documentation.py +251 -0
- utils/__init__.py +1 -0
- utils/helpers.py +80 -0
config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Configuration module package
|
config/settings.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Configuration settings for MARM MCP Server."""
|
|
2
|
+
|
|
3
|
+
# Advanced memory system availability flags
|
|
4
|
+
try:
|
|
5
|
+
from sentence_transformers import SentenceTransformer
|
|
6
|
+
SEMANTIC_SEARCH_AVAILABLE = True
|
|
7
|
+
except ImportError:
|
|
8
|
+
SEMANTIC_SEARCH_AVAILABLE = False
|
|
9
|
+
print("WARNING: Semantic search not available. Install: pip install sentence-transformers")
|
|
10
|
+
|
|
11
|
+
# Automation scheduler availability
|
|
12
|
+
try:
|
|
13
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
14
|
+
SCHEDULER_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
SCHEDULER_AVAILABLE = False
|
|
17
|
+
print("WARNING: Scheduler not available. Install: pip install apscheduler")
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# Database configuration - Official .marm system directory (CLI standard)
|
|
23
|
+
def get_marm_db_path():
|
|
24
|
+
"""Get the official MARM database path, respecting environment variable if set"""
|
|
25
|
+
# Check if MARM_DB_PATH environment variable is set (for Docker)
|
|
26
|
+
env_db_path = os.environ.get('MARM_DB_PATH')
|
|
27
|
+
if env_db_path:
|
|
28
|
+
# Ensure the directory exists
|
|
29
|
+
db_dir = Path(env_db_path).parent
|
|
30
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return env_db_path
|
|
32
|
+
|
|
33
|
+
# Follow professional CLI standard: ~/.marm/ (like ~/.git, ~/.docker, ~/.claude)
|
|
34
|
+
marm_dir = Path.home() / ".marm"
|
|
35
|
+
|
|
36
|
+
# Create .marm directory if it doesn't exist
|
|
37
|
+
marm_dir.mkdir(exist_ok=True)
|
|
38
|
+
|
|
39
|
+
return str(marm_dir / "marm_memory.db")
|
|
40
|
+
|
|
41
|
+
DEFAULT_DB_PATH = get_marm_db_path()
|
|
42
|
+
MAX_DB_CONNECTIONS = 5
|
|
43
|
+
|
|
44
|
+
# Analytics database path
|
|
45
|
+
def get_analytics_db_path():
|
|
46
|
+
"""Get the analytics database path, respecting environment variable if set"""
|
|
47
|
+
# Check if MARM_ANALYTICS_DB_PATH environment variable is set
|
|
48
|
+
env_analytics_db_path = os.environ.get('MARM_ANALYTICS_DB_PATH')
|
|
49
|
+
if env_analytics_db_path:
|
|
50
|
+
# Ensure the directory exists
|
|
51
|
+
analytics_dir = os.path.dirname(env_analytics_db_path)
|
|
52
|
+
if analytics_dir:
|
|
53
|
+
os.makedirs(analytics_dir, exist_ok=True)
|
|
54
|
+
return env_analytics_db_path
|
|
55
|
+
|
|
56
|
+
# For Docker, use /app/data, for local use the current directory or user's home
|
|
57
|
+
if os.path.exists('/app/data'):
|
|
58
|
+
# Docker environment
|
|
59
|
+
return '/app/data/marm_usage_analytics.db'
|
|
60
|
+
else:
|
|
61
|
+
# Local development environment
|
|
62
|
+
return 'marm_usage_analytics.db'
|
|
63
|
+
|
|
64
|
+
ANALYTICS_DB_PATH = get_analytics_db_path()
|
|
65
|
+
|
|
66
|
+
# Semantic search configuration
|
|
67
|
+
DEFAULT_SEMANTIC_MODEL = "all-MiniLM-L6-v2"
|
|
68
|
+
|
|
69
|
+
# Rate limiting configuration (for future Pro version flexibility)
|
|
70
|
+
RATE_LIMIT_ENABLED = True
|
|
71
|
+
RATE_LIMIT_DEFAULT_REQUESTS = 60
|
|
72
|
+
RATE_LIMIT_DEFAULT_WINDOW = 60
|
|
73
|
+
RATE_LIMIT_MEMORY_HEAVY_REQUESTS = 20
|
|
74
|
+
RATE_LIMIT_SEARCH_REQUESTS = 30
|
|
75
|
+
|
|
76
|
+
# Server configuration
|
|
77
|
+
SERVER_HOST = "0.0.0.0"
|
|
78
|
+
SERVER_PORT = int(os.environ.get('SERVER_PORT', 8001))
|
|
79
|
+
SERVER_VERSION = "2.1.0"
|
core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Core module package
|
core/events.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Event-driven automation system for MARM MCP Server."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, List, Callable, Any
|
|
6
|
+
|
|
7
|
+
class MARMEvents:
|
|
8
|
+
"""Built-in automation system with full error isolation"""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.listeners: Dict[str, List[Callable]] = {}
|
|
12
|
+
self.failed_callbacks: Dict[str, int] = {} # Track failed callback counts
|
|
13
|
+
self.logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
def on(self, event_type: str, callback):
|
|
16
|
+
"""Register event listener"""
|
|
17
|
+
if event_type not in self.listeners:
|
|
18
|
+
self.listeners[event_type] = []
|
|
19
|
+
self.listeners[event_type].append(callback)
|
|
20
|
+
|
|
21
|
+
async def emit(self, event_type: str, data: dict):
|
|
22
|
+
"""Trigger automatic actions with full error isolation"""
|
|
23
|
+
if event_type not in self.listeners:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
callbacks = self.listeners[event_type].copy() # Snapshot to prevent modification issues
|
|
27
|
+
successful_callbacks = 0
|
|
28
|
+
failed_callbacks = 0
|
|
29
|
+
|
|
30
|
+
# Execute each callback in complete isolation
|
|
31
|
+
for i, callback in enumerate(callbacks):
|
|
32
|
+
callback_id = f"{event_type}_{id(callback)}"
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Run callback with timeout protection
|
|
36
|
+
await asyncio.wait_for(callback(data), timeout=30.0)
|
|
37
|
+
successful_callbacks += 1
|
|
38
|
+
|
|
39
|
+
# Reset failure count on success
|
|
40
|
+
if callback_id in self.failed_callbacks:
|
|
41
|
+
del self.failed_callbacks[callback_id]
|
|
42
|
+
|
|
43
|
+
except asyncio.TimeoutError:
|
|
44
|
+
failed_callbacks += 1
|
|
45
|
+
self._log_callback_error(callback_id, "Callback timed out after 30 seconds", event_type)
|
|
46
|
+
|
|
47
|
+
except Exception as e:
|
|
48
|
+
failed_callbacks += 1
|
|
49
|
+
self._log_callback_error(callback_id, str(e), event_type)
|
|
50
|
+
|
|
51
|
+
# Log event completion summary
|
|
52
|
+
if failed_callbacks > 0:
|
|
53
|
+
self.logger.warning(
|
|
54
|
+
f"Event '{event_type}' completed: {successful_callbacks} succeeded, {failed_callbacks} failed"
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
self.logger.debug(f"Event '{event_type}' completed successfully: {successful_callbacks} callbacks")
|
|
58
|
+
|
|
59
|
+
def _log_callback_error(self, callback_id: str, error_msg: str, event_type: str):
|
|
60
|
+
"""Log callback errors with failure tracking"""
|
|
61
|
+
# Track failure count
|
|
62
|
+
self.failed_callbacks[callback_id] = self.failed_callbacks.get(callback_id, 0) + 1
|
|
63
|
+
failure_count = self.failed_callbacks[callback_id]
|
|
64
|
+
|
|
65
|
+
# Log with increasing severity based on failure count
|
|
66
|
+
if failure_count == 1:
|
|
67
|
+
self.logger.warning(f"Event callback failed for '{event_type}': {error_msg}")
|
|
68
|
+
elif failure_count <= 5:
|
|
69
|
+
self.logger.error(f"Event callback failed {failure_count} times for '{event_type}': {error_msg}")
|
|
70
|
+
else:
|
|
71
|
+
self.logger.critical(f"Event callback consistently failing ({failure_count} times) for '{event_type}': {error_msg}")
|
|
72
|
+
# Could add auto-disable logic here if needed
|
|
73
|
+
|
|
74
|
+
def get_health_status(self) -> Dict[str, Any]:
|
|
75
|
+
"""Get event system health status for monitoring"""
|
|
76
|
+
total_listeners = sum(len(callbacks) for callbacks in self.listeners.values())
|
|
77
|
+
failed_callback_count = len(self.failed_callbacks)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"total_event_types": len(self.listeners),
|
|
81
|
+
"total_listeners": total_listeners,
|
|
82
|
+
"failed_callbacks": failed_callback_count,
|
|
83
|
+
"health_status": "healthy" if failed_callback_count == 0 else "degraded"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Global events system
|
|
87
|
+
events = MARMEvents()
|
core/memory.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Advanced memory system with semantic search and MARM protocol support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
import queue
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import List, Dict, Optional
|
|
10
|
+
import numpy as np
|
|
11
|
+
import html
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# Import configuration
|
|
15
|
+
from config.settings import (
|
|
16
|
+
SEMANTIC_SEARCH_AVAILABLE,
|
|
17
|
+
DEFAULT_DB_PATH,
|
|
18
|
+
MAX_DB_CONNECTIONS,
|
|
19
|
+
DEFAULT_SEMANTIC_MODEL
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Try to import sentence transformer if available
|
|
23
|
+
if SEMANTIC_SEARCH_AVAILABLE:
|
|
24
|
+
try:
|
|
25
|
+
from sentence_transformers import SentenceTransformer
|
|
26
|
+
except ImportError:
|
|
27
|
+
SEMANTIC_SEARCH_AVAILABLE = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SQLiteConnectionPool:
|
|
31
|
+
"""Simple SQLite connection pool for better performance under load"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, db_path: str, max_connections: int = 5):
|
|
34
|
+
self.db_path = db_path
|
|
35
|
+
self.max_connections = max_connections
|
|
36
|
+
self.pool = queue.Queue(maxsize=max_connections)
|
|
37
|
+
self.created_connections = 0
|
|
38
|
+
self.lock = threading.Lock()
|
|
39
|
+
|
|
40
|
+
# Pre-create initial connections
|
|
41
|
+
self._create_initial_connections()
|
|
42
|
+
|
|
43
|
+
def _create_initial_connections(self):
|
|
44
|
+
"""Create initial pool of connections"""
|
|
45
|
+
for _ in range(2): # Start with 2 connections
|
|
46
|
+
self._create_connection()
|
|
47
|
+
|
|
48
|
+
def _create_connection(self):
|
|
49
|
+
"""Create a new SQLite connection with optimal settings"""
|
|
50
|
+
conn = sqlite3.connect(
|
|
51
|
+
self.db_path,
|
|
52
|
+
check_same_thread=False,
|
|
53
|
+
timeout=20.0, # 20 second timeout
|
|
54
|
+
isolation_level=None # autocommit mode
|
|
55
|
+
)
|
|
56
|
+
# Optimize SQLite settings for concurrent access
|
|
57
|
+
conn.execute('PRAGMA journal_mode=WAL') # Write-Ahead Logging
|
|
58
|
+
conn.execute('PRAGMA synchronous=NORMAL') # Balanced performance/safety
|
|
59
|
+
conn.execute('PRAGMA cache_size=10000') # Larger cache
|
|
60
|
+
conn.execute('PRAGMA temp_store=MEMORY') # In-memory temp tables
|
|
61
|
+
|
|
62
|
+
self.pool.put(conn)
|
|
63
|
+
self.created_connections += 1
|
|
64
|
+
|
|
65
|
+
def get_connection(self):
|
|
66
|
+
"""Get a connection from the pool"""
|
|
67
|
+
try:
|
|
68
|
+
# Try to get existing connection
|
|
69
|
+
return self.pool.get(block=False)
|
|
70
|
+
except queue.Empty:
|
|
71
|
+
# Create new connection if under limit
|
|
72
|
+
with self.lock:
|
|
73
|
+
if self.created_connections < self.max_connections:
|
|
74
|
+
self._create_connection()
|
|
75
|
+
return self.pool.get(block=False)
|
|
76
|
+
|
|
77
|
+
# Wait for available connection
|
|
78
|
+
return self.pool.get(block=True, timeout=10)
|
|
79
|
+
|
|
80
|
+
def return_connection(self, conn):
|
|
81
|
+
"""Return connection to pool"""
|
|
82
|
+
try:
|
|
83
|
+
self.pool.put(conn, block=False)
|
|
84
|
+
except queue.Full:
|
|
85
|
+
# Pool is full, close the connection
|
|
86
|
+
conn.close()
|
|
87
|
+
|
|
88
|
+
def close_all(self):
|
|
89
|
+
"""Close all connections in the pool"""
|
|
90
|
+
while not self.pool.empty():
|
|
91
|
+
try:
|
|
92
|
+
conn = self.pool.get(block=False)
|
|
93
|
+
conn.close()
|
|
94
|
+
except queue.Empty:
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def sanitize_content(content: str) -> str:
|
|
99
|
+
"""Sanitize content to prevent XSS attacks while preserving readability"""
|
|
100
|
+
if not content:
|
|
101
|
+
return content
|
|
102
|
+
|
|
103
|
+
# Remove or neutralize common XSS patterns first (before HTML escaping)
|
|
104
|
+
sanitized = content
|
|
105
|
+
|
|
106
|
+
# Remove script tags entirely
|
|
107
|
+
sanitized = re.sub(r'<script[^>]*>.*?</script>', '', sanitized, flags=re.IGNORECASE | re.DOTALL)
|
|
108
|
+
|
|
109
|
+
# Remove javascript: protocols
|
|
110
|
+
sanitized = re.sub(r'javascript:', 'blocked-javascript:', sanitized, flags=re.IGNORECASE)
|
|
111
|
+
|
|
112
|
+
# Remove on* event handlers (onclick, onload, etc.)
|
|
113
|
+
sanitized = re.sub(r'\son\w+\s*=\s*["\'][^"\']*["\']', '', sanitized, flags=re.IGNORECASE)
|
|
114
|
+
|
|
115
|
+
# Finally, HTML escape any remaining dangerous characters
|
|
116
|
+
sanitized = html.escape(sanitized)
|
|
117
|
+
|
|
118
|
+
return sanitized
|
|
119
|
+
|
|
120
|
+
class MARMMemory:
|
|
121
|
+
"""Advanced memory system with semantic search and MARM protocol support"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, db_path: str = DEFAULT_DB_PATH):
|
|
124
|
+
self.db_path = db_path
|
|
125
|
+
self.db_lock = threading.Lock()
|
|
126
|
+
|
|
127
|
+
# Initialize connection pool with configurable settings
|
|
128
|
+
self.connection_pool = SQLiteConnectionPool(db_path, max_connections=MAX_DB_CONNECTIONS)
|
|
129
|
+
|
|
130
|
+
# Lazy loading for semantic search model
|
|
131
|
+
self.encoder = None
|
|
132
|
+
self._encoder_loading = False
|
|
133
|
+
self._encoder_failed = False
|
|
134
|
+
|
|
135
|
+
self.init_database()
|
|
136
|
+
|
|
137
|
+
# Active sessions and notebook state
|
|
138
|
+
self.active_sessions = {}
|
|
139
|
+
self.active_notebook_entries = []
|
|
140
|
+
|
|
141
|
+
def get_connection(self):
|
|
142
|
+
"""Context manager for getting database connections from pool"""
|
|
143
|
+
class ConnectionContext:
|
|
144
|
+
def __init__(self, pool):
|
|
145
|
+
self.pool = pool
|
|
146
|
+
self.conn = None
|
|
147
|
+
|
|
148
|
+
def __enter__(self):
|
|
149
|
+
self.conn = self.pool.get_connection()
|
|
150
|
+
return self.conn
|
|
151
|
+
|
|
152
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
153
|
+
if self.conn:
|
|
154
|
+
if exc_type is None:
|
|
155
|
+
# Successful transaction
|
|
156
|
+
self.conn.commit()
|
|
157
|
+
else:
|
|
158
|
+
# Error occurred, rollback
|
|
159
|
+
self.conn.rollback()
|
|
160
|
+
self.pool.return_connection(self.conn)
|
|
161
|
+
|
|
162
|
+
return ConnectionContext(self.connection_pool)
|
|
163
|
+
|
|
164
|
+
def init_database(self):
|
|
165
|
+
"""Initialize SQLite database with all MARM tables"""
|
|
166
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
167
|
+
# Main memories table
|
|
168
|
+
conn.execute('''
|
|
169
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
170
|
+
id TEXT PRIMARY KEY,
|
|
171
|
+
session_name TEXT NOT NULL,
|
|
172
|
+
content TEXT NOT NULL,
|
|
173
|
+
embedding BLOB,
|
|
174
|
+
timestamp TEXT NOT NULL,
|
|
175
|
+
context_type TEXT DEFAULT 'general',
|
|
176
|
+
metadata TEXT DEFAULT '{}',
|
|
177
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
178
|
+
)
|
|
179
|
+
''')
|
|
180
|
+
|
|
181
|
+
# Sessions table
|
|
182
|
+
conn.execute('''
|
|
183
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
184
|
+
session_name TEXT PRIMARY KEY,
|
|
185
|
+
marm_active BOOLEAN DEFAULT FALSE,
|
|
186
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
187
|
+
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
188
|
+
metadata TEXT DEFAULT '{}'
|
|
189
|
+
)
|
|
190
|
+
''')
|
|
191
|
+
|
|
192
|
+
# Log entries table (MARM protocol specific)
|
|
193
|
+
conn.execute('''
|
|
194
|
+
CREATE TABLE IF NOT EXISTS log_entries (
|
|
195
|
+
id TEXT PRIMARY KEY,
|
|
196
|
+
session_name TEXT NOT NULL,
|
|
197
|
+
entry_date TEXT NOT NULL,
|
|
198
|
+
topic TEXT NOT NULL,
|
|
199
|
+
summary TEXT NOT NULL,
|
|
200
|
+
full_entry TEXT NOT NULL,
|
|
201
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
202
|
+
)
|
|
203
|
+
''')
|
|
204
|
+
|
|
205
|
+
# Notebook entries table
|
|
206
|
+
conn.execute('''
|
|
207
|
+
CREATE TABLE IF NOT EXISTS notebook_entries (
|
|
208
|
+
name TEXT PRIMARY KEY,
|
|
209
|
+
data TEXT NOT NULL,
|
|
210
|
+
embedding BLOB,
|
|
211
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
212
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
213
|
+
)
|
|
214
|
+
''')
|
|
215
|
+
|
|
216
|
+
# User settings table
|
|
217
|
+
conn.execute('''
|
|
218
|
+
CREATE TABLE IF NOT EXISTS user_settings (
|
|
219
|
+
key TEXT PRIMARY KEY,
|
|
220
|
+
value TEXT NOT NULL,
|
|
221
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
222
|
+
)
|
|
223
|
+
''')
|
|
224
|
+
|
|
225
|
+
conn.commit()
|
|
226
|
+
|
|
227
|
+
def _load_encoder_lazily(self) -> bool:
|
|
228
|
+
"""Lazy load the semantic search model only when needed"""
|
|
229
|
+
if self.encoder is not None or self._encoder_failed:
|
|
230
|
+
return self.encoder is not None
|
|
231
|
+
|
|
232
|
+
if self._encoder_loading:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
if not SEMANTIC_SEARCH_AVAILABLE:
|
|
236
|
+
self._encoder_failed = True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
self._encoder_loading = True
|
|
241
|
+
print(f"🔄 Loading semantic search model ({DEFAULT_SEMANTIC_MODEL}) for memory system...")
|
|
242
|
+
|
|
243
|
+
from sentence_transformers import SentenceTransformer
|
|
244
|
+
self.encoder = SentenceTransformer(DEFAULT_SEMANTIC_MODEL)
|
|
245
|
+
|
|
246
|
+
print("✅ Semantic search model loaded successfully")
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
print(f"⚠️ Failed to load semantic search model: {e}")
|
|
251
|
+
print("🔄 Falling back to text-based search")
|
|
252
|
+
self._encoder_failed = True
|
|
253
|
+
return False
|
|
254
|
+
finally:
|
|
255
|
+
self._encoder_loading = False
|
|
256
|
+
|
|
257
|
+
async def auto_classify_content(self, content: str) -> str:
|
|
258
|
+
"""Auto-classify content type based on keywords"""
|
|
259
|
+
content_lower = content.lower()
|
|
260
|
+
|
|
261
|
+
if any(word in content_lower for word in ['function', 'class', 'code', 'bug', 'debug', 'error', 'fix', 'implement']):
|
|
262
|
+
return 'code'
|
|
263
|
+
elif any(word in content_lower for word in ['project', 'milestone', 'deadline', 'goal', 'sprint', 'task']):
|
|
264
|
+
return 'project'
|
|
265
|
+
elif any(word in content_lower for word in ['character', 'story', 'plot', 'chapter', 'write', 'book']):
|
|
266
|
+
return 'book'
|
|
267
|
+
else:
|
|
268
|
+
return 'general'
|
|
269
|
+
|
|
270
|
+
async def store_memory(self, content: str, session: str, context_type: str = "general", metadata: Dict = None) -> str:
|
|
271
|
+
"""Store content with vector embedding for semantic search"""
|
|
272
|
+
# Sanitize content to prevent XSS attacks
|
|
273
|
+
sanitized_content = sanitize_content(content)
|
|
274
|
+
|
|
275
|
+
if context_type == "general":
|
|
276
|
+
context_type = await self.auto_classify_content(sanitized_content)
|
|
277
|
+
|
|
278
|
+
memory_id = str(uuid.uuid4())
|
|
279
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
280
|
+
metadata = metadata or {}
|
|
281
|
+
|
|
282
|
+
# Generate embedding for semantic search (lazy load encoder if needed)
|
|
283
|
+
embedding_bytes = None
|
|
284
|
+
if sanitized_content.strip() and self._load_encoder_lazily():
|
|
285
|
+
try:
|
|
286
|
+
embedding = self.encoder.encode(sanitized_content)
|
|
287
|
+
embedding_bytes = embedding.tobytes()
|
|
288
|
+
except Exception as e:
|
|
289
|
+
print(f"Failed to generate embedding: {e}")
|
|
290
|
+
|
|
291
|
+
with self.get_connection() as conn:
|
|
292
|
+
# Store sanitized memory
|
|
293
|
+
conn.execute('''
|
|
294
|
+
INSERT INTO memories (id, session_name, content, embedding, timestamp, context_type, metadata)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
296
|
+
''', (memory_id, session, sanitized_content, embedding_bytes, timestamp, context_type, json.dumps(metadata)))
|
|
297
|
+
|
|
298
|
+
# Update session access time
|
|
299
|
+
conn.execute('''
|
|
300
|
+
INSERT OR REPLACE INTO sessions (session_name, last_accessed)
|
|
301
|
+
VALUES (?, ?)
|
|
302
|
+
''', (session, timestamp))
|
|
303
|
+
|
|
304
|
+
# Note: events system will be handled by the main application
|
|
305
|
+
# We don't import it here to avoid circular dependencies
|
|
306
|
+
|
|
307
|
+
return memory_id
|
|
308
|
+
|
|
309
|
+
async def recall_similar(self, query: str, session: str = None, limit: int = 5) -> List[Dict]:
|
|
310
|
+
"""Find semantically similar memories"""
|
|
311
|
+
if not self._load_encoder_lazily():
|
|
312
|
+
return await self.recall_text_search(query, session, limit)
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
query_embedding = self.encoder.encode(query)
|
|
316
|
+
|
|
317
|
+
with self.get_connection() as conn:
|
|
318
|
+
# If session is None, search all sessions
|
|
319
|
+
if session is None:
|
|
320
|
+
cursor = conn.execute('''
|
|
321
|
+
SELECT id, session_name, content, embedding, timestamp, context_type, metadata
|
|
322
|
+
FROM memories
|
|
323
|
+
WHERE embedding IS NOT NULL
|
|
324
|
+
ORDER BY timestamp DESC
|
|
325
|
+
LIMIT 1000
|
|
326
|
+
''')
|
|
327
|
+
else:
|
|
328
|
+
cursor = conn.execute('''
|
|
329
|
+
SELECT id, session_name, content, embedding, timestamp, context_type, metadata
|
|
330
|
+
FROM memories
|
|
331
|
+
WHERE embedding IS NOT NULL
|
|
332
|
+
AND session_name = ?
|
|
333
|
+
ORDER BY timestamp DESC
|
|
334
|
+
LIMIT 1000
|
|
335
|
+
''', (session,))
|
|
336
|
+
|
|
337
|
+
memories = cursor.fetchall()
|
|
338
|
+
similarities = []
|
|
339
|
+
|
|
340
|
+
for memory in memories:
|
|
341
|
+
try:
|
|
342
|
+
memory_embedding = np.frombuffer(memory[3], dtype=np.float32)
|
|
343
|
+
similarity = np.dot(query_embedding, memory_embedding) / (
|
|
344
|
+
np.linalg.norm(query_embedding) * np.linalg.norm(memory_embedding)
|
|
345
|
+
)
|
|
346
|
+
similarities.append((memory, similarity))
|
|
347
|
+
except Exception:
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
|
351
|
+
|
|
352
|
+
results = []
|
|
353
|
+
for memory, similarity in similarities[:limit]:
|
|
354
|
+
results.append({
|
|
355
|
+
"id": memory[0],
|
|
356
|
+
"session_name": memory[1],
|
|
357
|
+
"content": memory[2],
|
|
358
|
+
"timestamp": memory[4],
|
|
359
|
+
"context_type": memory[5],
|
|
360
|
+
"metadata": json.loads(memory[6]) if memory[6] else {},
|
|
361
|
+
"similarity": float(similarity)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
return results
|
|
365
|
+
|
|
366
|
+
except Exception as e:
|
|
367
|
+
print(f"Semantic search failed: {e}")
|
|
368
|
+
return await self.recall_text_search(query, session, limit)
|
|
369
|
+
|
|
370
|
+
async def recall_text_search(self, query: str, session: str = None, limit: int = 5) -> List[Dict]:
|
|
371
|
+
"""Fallback text-based search"""
|
|
372
|
+
with self.get_connection() as conn:
|
|
373
|
+
# If session is None, search all sessions
|
|
374
|
+
if session is None:
|
|
375
|
+
cursor = conn.execute('''
|
|
376
|
+
SELECT id, session_name, content, timestamp, context_type, metadata
|
|
377
|
+
FROM memories
|
|
378
|
+
WHERE content LIKE ?
|
|
379
|
+
ORDER BY timestamp DESC
|
|
380
|
+
LIMIT ?
|
|
381
|
+
''', (f"%{query}%", limit))
|
|
382
|
+
else:
|
|
383
|
+
cursor = conn.execute('''
|
|
384
|
+
SELECT id, session_name, content, timestamp, context_type, metadata
|
|
385
|
+
FROM memories
|
|
386
|
+
WHERE content LIKE ?
|
|
387
|
+
AND session_name = ?
|
|
388
|
+
ORDER BY timestamp DESC
|
|
389
|
+
LIMIT ?
|
|
390
|
+
''', (f"%{query}%", session, limit))
|
|
391
|
+
|
|
392
|
+
results = []
|
|
393
|
+
for row in cursor.fetchall():
|
|
394
|
+
results.append({
|
|
395
|
+
"id": row[0],
|
|
396
|
+
"session_name": row[1],
|
|
397
|
+
"content": row[2],
|
|
398
|
+
"timestamp": row[3],
|
|
399
|
+
"context_type": row[4],
|
|
400
|
+
"metadata": json.loads(row[5]) if row[5] else {},
|
|
401
|
+
"similarity": 0.8 # Default similarity for text matches
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
return results
|
|
405
|
+
|
|
406
|
+
# Global memory instance
|
|
407
|
+
memory = MARMMemory()
|
core/models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Pydantic models for MARM MCP Server endpoints."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SessionRequest(BaseModel):
|
|
8
|
+
session_name: str = Field(..., description="Name of the session")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LogEntryRequest(BaseModel):
|
|
12
|
+
entry: str = Field(..., description="Log entry in format: YYYY-MM-DD-topic-summary")
|
|
13
|
+
session_name: str = Field(default="main", description="Session name")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotebookAddRequest(BaseModel):
|
|
17
|
+
name: str = Field(..., description="Name of the notebook entry")
|
|
18
|
+
data: str = Field(..., description="Content of the notebook entry")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotebookUseRequest(BaseModel):
|
|
22
|
+
names: str = Field(..., description="Comma-separated list of notebook entry names")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContextBridgeRequest(BaseModel):
|
|
26
|
+
new_topic: str = Field(..., description="New topic for context bridging")
|
|
27
|
+
session_name: str = Field(default="main", description="Session name")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SmartRecallRequest(BaseModel):
|
|
31
|
+
query: str = Field(..., description="Query to search for in memory")
|
|
32
|
+
session_name: str = Field(default="main", description="Session to search in")
|
|
33
|
+
limit: int = Field(default=5, description="Maximum number of results")
|
|
34
|
+
search_all: bool = Field(default=False, description="Search across all sessions if True")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ContextualLogRequest(BaseModel):
|
|
38
|
+
content: str = Field(..., description="Content to log with auto-classification")
|
|
39
|
+
session_name: str = Field(default="main", description="Session to log to")
|