spatial-memory-mcp 1.0.2__py3-none-any.whl → 1.5.3__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.

Potentially problematic release.


This version of spatial-memory-mcp might be problematic. Click here for more details.

@@ -1,97 +1,97 @@
1
- """Spatial Memory MCP Server - Vector-based semantic memory for LLMs."""
2
-
3
- __version__ = "1.0.1"
4
- __author__ = "arman-tech"
5
-
6
- # Re-export core components for convenience
7
- # Adapters
8
- from spatial_memory.adapters.lancedb_repository import LanceDBMemoryRepository
9
- from spatial_memory.config import Settings, get_settings
10
- from spatial_memory.core import (
11
- ClusterInfo,
12
- ClusteringError,
13
- ConfigurationError,
14
- # Core services
15
- Database,
16
- EmbeddingError,
17
- EmbeddingService,
18
- Filter,
19
- FilterGroup,
20
- FilterOperator,
21
- JourneyStep,
22
- # Models
23
- Memory,
24
- MemoryNotFoundError,
25
- MemoryResult,
26
- MemorySource,
27
- NamespaceNotFoundError,
28
- # Errors
29
- SpatialMemoryError,
30
- StorageError,
31
- ValidationError,
32
- VisualizationCluster,
33
- VisualizationData,
34
- VisualizationEdge,
35
- VisualizationError,
36
- VisualizationNode,
37
- )
38
-
39
- # Server
40
- from spatial_memory.server import SpatialMemoryServer, create_server
41
-
42
- # Services
43
- from spatial_memory.services.memory import (
44
- ForgetResult,
45
- MemoryService,
46
- NearbyResult,
47
- RecallResult,
48
- RememberBatchResult,
49
- RememberResult,
50
- )
51
-
52
- __all__ = [
53
- # Version info
54
- "__version__",
55
- "__author__",
56
- # Configuration
57
- "Settings",
58
- "get_settings",
59
- # Errors
60
- "SpatialMemoryError",
61
- "MemoryNotFoundError",
62
- "NamespaceNotFoundError",
63
- "EmbeddingError",
64
- "StorageError",
65
- "ValidationError",
66
- "ConfigurationError",
67
- "ClusteringError",
68
- "VisualizationError",
69
- # Models
70
- "Memory",
71
- "MemorySource",
72
- "MemoryResult",
73
- "ClusterInfo",
74
- "JourneyStep",
75
- "VisualizationNode",
76
- "VisualizationEdge",
77
- "VisualizationCluster",
78
- "VisualizationData",
79
- "Filter",
80
- "FilterOperator",
81
- "FilterGroup",
82
- # Core services
83
- "Database",
84
- "EmbeddingService",
85
- # Services
86
- "MemoryService",
87
- "RememberResult",
88
- "RememberBatchResult",
89
- "RecallResult",
90
- "NearbyResult",
91
- "ForgetResult",
92
- # Adapters
93
- "LanceDBMemoryRepository",
94
- # Server
95
- "SpatialMemoryServer",
96
- "create_server",
97
- ]
1
+ """Spatial Memory MCP Server - Vector-based semantic memory for LLMs."""
2
+
3
+ __version__ = "1.5.3"
4
+ __author__ = "arman-tech"
5
+
6
+ # Re-export core components for convenience
7
+ # Adapters
8
+ from spatial_memory.adapters.lancedb_repository import LanceDBMemoryRepository
9
+ from spatial_memory.config import Settings, get_settings
10
+ from spatial_memory.core import (
11
+ ClusterInfo,
12
+ ClusteringError,
13
+ ConfigurationError,
14
+ # Core services
15
+ Database,
16
+ EmbeddingError,
17
+ EmbeddingService,
18
+ Filter,
19
+ FilterGroup,
20
+ FilterOperator,
21
+ JourneyStep,
22
+ # Models
23
+ Memory,
24
+ MemoryNotFoundError,
25
+ MemoryResult,
26
+ MemorySource,
27
+ NamespaceNotFoundError,
28
+ # Errors
29
+ SpatialMemoryError,
30
+ StorageError,
31
+ ValidationError,
32
+ VisualizationCluster,
33
+ VisualizationData,
34
+ VisualizationEdge,
35
+ VisualizationError,
36
+ VisualizationNode,
37
+ )
38
+
39
+ # Server
40
+ from spatial_memory.server import SpatialMemoryServer, create_server
41
+
42
+ # Services
43
+ from spatial_memory.services.memory import (
44
+ ForgetResult,
45
+ MemoryService,
46
+ NearbyResult,
47
+ RecallResult,
48
+ RememberBatchResult,
49
+ RememberResult,
50
+ )
51
+
52
+ __all__ = [
53
+ # Version info
54
+ "__version__",
55
+ "__author__",
56
+ # Configuration
57
+ "Settings",
58
+ "get_settings",
59
+ # Errors
60
+ "SpatialMemoryError",
61
+ "MemoryNotFoundError",
62
+ "NamespaceNotFoundError",
63
+ "EmbeddingError",
64
+ "StorageError",
65
+ "ValidationError",
66
+ "ConfigurationError",
67
+ "ClusteringError",
68
+ "VisualizationError",
69
+ # Models
70
+ "Memory",
71
+ "MemorySource",
72
+ "MemoryResult",
73
+ "ClusterInfo",
74
+ "JourneyStep",
75
+ "VisualizationNode",
76
+ "VisualizationEdge",
77
+ "VisualizationCluster",
78
+ "VisualizationData",
79
+ "Filter",
80
+ "FilterOperator",
81
+ "FilterGroup",
82
+ # Core services
83
+ "Database",
84
+ "EmbeddingService",
85
+ # Services
86
+ "MemoryService",
87
+ "RememberResult",
88
+ "RememberBatchResult",
89
+ "RecallResult",
90
+ "NearbyResult",
91
+ "ForgetResult",
92
+ # Adapters
93
+ "LanceDBMemoryRepository",
94
+ # Server
95
+ "SpatialMemoryServer",
96
+ "create_server",
97
+ ]
spatial_memory/config.py CHANGED
@@ -480,6 +480,111 @@ class Settings(BaseSettings):
480
480
  description="Maximum alpha for hybrid search (1.0=pure vector)",
481
481
  )
482
482
 
483
+ # =========================================================================
484
+ # v1.5.3 Phase 1: Observability Settings
485
+ # =========================================================================
486
+
487
+ include_request_meta: bool = Field(
488
+ default=False,
489
+ description="Include _meta object in responses (request_id, timing, etc.)",
490
+ )
491
+ log_include_trace_context: bool = Field(
492
+ default=True,
493
+ description="Add [req=][agent=] trace context to log messages",
494
+ )
495
+ include_timing_breakdown: bool = Field(
496
+ default=False,
497
+ description="Include timing_ms breakdown in _meta (requires include_request_meta)",
498
+ )
499
+
500
+ # =========================================================================
501
+ # v1.5.3 Phase 2: Efficiency Settings
502
+ # =========================================================================
503
+
504
+ warm_up_on_start: bool = Field(
505
+ default=True,
506
+ description="Pre-load embedding model on startup for faster first request",
507
+ )
508
+ response_cache_enabled: bool = Field(
509
+ default=True,
510
+ description="Enable response caching for idempotent operations",
511
+ )
512
+ response_cache_max_size: int = Field(
513
+ default=1000,
514
+ ge=100,
515
+ le=100000,
516
+ description="Maximum number of cached responses (LRU eviction)",
517
+ )
518
+ response_cache_default_ttl: float = Field(
519
+ default=60.0,
520
+ ge=1.0,
521
+ le=3600.0,
522
+ description="Default TTL in seconds for cached responses",
523
+ )
524
+ response_cache_regions_ttl: float = Field(
525
+ default=300.0,
526
+ ge=60.0,
527
+ le=3600.0,
528
+ description="TTL in seconds for regions() responses (expensive operation)",
529
+ )
530
+ idempotency_enabled: bool = Field(
531
+ default=True,
532
+ description="Enable idempotency key support for write operations",
533
+ )
534
+ idempotency_key_ttl_hours: float = Field(
535
+ default=24.0,
536
+ ge=1.0,
537
+ le=168.0,
538
+ description="Hours to remember idempotency keys (max 7 days)",
539
+ )
540
+
541
+ # =========================================================================
542
+ # v1.5.3 Phase 3: Resilience Settings
543
+ # =========================================================================
544
+
545
+ rate_limit_per_agent_enabled: bool = Field(
546
+ default=True,
547
+ description="Enable per-agent rate limiting",
548
+ )
549
+ rate_limit_per_agent_rate: float = Field(
550
+ default=25.0,
551
+ ge=1.0,
552
+ le=1000.0,
553
+ description="Maximum operations per second per agent",
554
+ )
555
+ rate_limit_max_tracked_agents: int = Field(
556
+ default=20,
557
+ ge=1,
558
+ le=1000,
559
+ description="Maximum number of agents to track for rate limiting (LRU eviction)",
560
+ )
561
+ circuit_breaker_enabled: bool = Field(
562
+ default=True,
563
+ description="Enable circuit breaker for external dependencies",
564
+ )
565
+ circuit_breaker_failure_threshold: int = Field(
566
+ default=5,
567
+ ge=1,
568
+ le=100,
569
+ description="Number of consecutive failures before circuit opens",
570
+ )
571
+ circuit_breaker_reset_timeout: float = Field(
572
+ default=60.0,
573
+ ge=5.0,
574
+ le=600.0,
575
+ description="Seconds to wait before attempting half-open state",
576
+ )
577
+ backpressure_queue_enabled: bool = Field(
578
+ default=False,
579
+ description="Enable backpressure queue for overload protection (future)",
580
+ )
581
+ backpressure_queue_max_size: int = Field(
582
+ default=100,
583
+ ge=10,
584
+ le=10000,
585
+ description="Maximum queue depth when backpressure is enabled",
586
+ )
587
+
483
588
  model_config = {
484
589
  "env_prefix": "SPATIAL_MEMORY_",
485
590
  "env_file": ".env",
@@ -1,5 +1,10 @@
1
1
  """Core components for Spatial Memory MCP Server."""
2
2
 
3
+ from spatial_memory.core.circuit_breaker import (
4
+ CircuitBreaker,
5
+ CircuitOpenError,
6
+ CircuitState,
7
+ )
3
8
  from spatial_memory.core.database import Database
4
9
  from spatial_memory.core.embeddings import EmbeddingService
5
10
  from spatial_memory.core.rate_limiter import RateLimiter
@@ -37,6 +42,15 @@ from spatial_memory.core.models import (
37
42
  VisualizationNode,
38
43
  )
39
44
  from spatial_memory.core.utils import to_aware_utc, to_naive_utc, utc_now, utc_now_naive
45
+ from spatial_memory.core.tracing import (
46
+ RequestContext,
47
+ TimingContext,
48
+ clear_context,
49
+ format_context_prefix,
50
+ get_current_context,
51
+ request_context,
52
+ set_context,
53
+ )
40
54
 
41
55
  __all__ = [
42
56
  # Errors - Base
@@ -80,4 +94,16 @@ __all__ = [
80
94
  "utc_now_naive",
81
95
  "to_naive_utc",
82
96
  "to_aware_utc",
97
+ # Tracing
98
+ "RequestContext",
99
+ "TimingContext",
100
+ "get_current_context",
101
+ "set_context",
102
+ "clear_context",
103
+ "request_context",
104
+ "format_context_prefix",
105
+ # Circuit Breaker
106
+ "CircuitBreaker",
107
+ "CircuitOpenError",
108
+ "CircuitState",
83
109
  ]
@@ -0,0 +1,317 @@
1
+ """Response cache with TTL and LRU eviction for Spatial Memory MCP Server.
2
+
3
+ This module provides a thread-safe response cache using:
4
+ - LRU (Least Recently Used) eviction when at capacity
5
+ - TTL (Time To Live) based expiration checked on get()
6
+ - Namespace-based invalidation for targeted cache clearing
7
+
8
+ Usage:
9
+ from spatial_memory.core.cache import ResponseCache
10
+
11
+ cache = ResponseCache(max_size=1000, default_ttl=60.0)
12
+
13
+ # Basic get/set
14
+ cache.set("recall:default:query:5", results, ttl=30.0)
15
+ cached = cache.get("recall:default:query:5")
16
+
17
+ # Namespace invalidation
18
+ cache.invalidate_namespace("default") # Clears all keys containing "default"
19
+
20
+ # Stats
21
+ stats = cache.stats()
22
+ print(f"Hit rate: {stats.hits / (stats.hits + stats.misses):.2%}")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ import threading
29
+ import time
30
+ from collections import OrderedDict
31
+ from dataclasses import dataclass
32
+ from typing import Any
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ @dataclass
38
+ class CacheEntry:
39
+ """A single cache entry with value and expiration metadata.
40
+
41
+ Attributes:
42
+ value: The cached value.
43
+ expires_at: Monotonic timestamp when this entry expires.
44
+ created_at: Monotonic timestamp when this entry was created.
45
+ """
46
+
47
+ value: Any
48
+ expires_at: float
49
+ created_at: float
50
+
51
+
52
+ @dataclass
53
+ class CacheStats:
54
+ """Statistics about cache performance and usage.
55
+
56
+ Attributes:
57
+ hits: Number of successful cache hits.
58
+ misses: Number of cache misses (key not found or expired).
59
+ evictions: Number of entries evicted due to capacity limits.
60
+ size: Current number of entries in the cache.
61
+ max_size: Maximum capacity of the cache.
62
+ """
63
+
64
+ hits: int
65
+ misses: int
66
+ evictions: int
67
+ size: int
68
+ max_size: int
69
+
70
+ @property
71
+ def hit_rate(self) -> float:
72
+ """Calculate the cache hit rate.
73
+
74
+ Returns:
75
+ Hit rate as a float between 0.0 and 1.0, or 0.0 if no requests.
76
+ """
77
+ total = self.hits + self.misses
78
+ return self.hits / total if total > 0 else 0.0
79
+
80
+
81
+ class ResponseCache:
82
+ """Thread-safe LRU cache with TTL expiration.
83
+
84
+ This cache is designed for caching MCP tool responses. Keys are strings
85
+ typically formatted as "tool:namespace:query:limit" to enable targeted
86
+ namespace invalidation.
87
+
88
+ The cache uses:
89
+ - OrderedDict for O(1) LRU operations
90
+ - time.monotonic() for TTL (immune to system clock changes)
91
+ - threading.Lock() for thread safety
92
+
93
+ Example:
94
+ cache = ResponseCache(max_size=1000, default_ttl=60.0)
95
+
96
+ # Set with default TTL
97
+ cache.set("recall:ns:query:10", result)
98
+
99
+ # Set with custom TTL
100
+ cache.set("recall:ns:query:10", result, ttl=30.0)
101
+
102
+ # Get (returns None on miss)
103
+ result = cache.get("recall:ns:query:10")
104
+
105
+ # Invalidate all entries for a namespace
106
+ count = cache.invalidate_namespace("ns")
107
+
108
+ # Get statistics
109
+ stats = cache.stats()
110
+ """
111
+
112
+ def __init__(self, max_size: int = 1000, default_ttl: float = 60.0) -> None:
113
+ """Initialize the response cache.
114
+
115
+ Args:
116
+ max_size: Maximum number of entries to store. Must be positive.
117
+ default_ttl: Default time-to-live in seconds. Must be positive.
118
+
119
+ Raises:
120
+ ValueError: If max_size or default_ttl is not positive.
121
+ """
122
+ if max_size <= 0:
123
+ raise ValueError("max_size must be positive")
124
+ if default_ttl <= 0:
125
+ raise ValueError("default_ttl must be positive")
126
+
127
+ self._max_size = max_size
128
+ self._default_ttl = default_ttl
129
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
130
+ self._lock = threading.Lock()
131
+ self._hits = 0
132
+ self._misses = 0
133
+ self._evictions = 0
134
+
135
+ def get(self, key: str) -> Any | None:
136
+ """Get a value from the cache.
137
+
138
+ Returns the cached value if the key exists and has not expired.
139
+ On cache hit, the entry is moved to the end (most recently used).
140
+ On cache miss (not found or expired), returns None and increments
141
+ the miss counter.
142
+
143
+ Args:
144
+ key: The cache key to look up.
145
+
146
+ Returns:
147
+ The cached value, or None if not found or expired.
148
+
149
+ Example:
150
+ result = cache.get("recall:default:test:5")
151
+ if result is not None:
152
+ print("Cache hit!")
153
+ """
154
+ with self._lock:
155
+ entry = self._cache.get(key)
156
+
157
+ if entry is None:
158
+ self._misses += 1
159
+ return None
160
+
161
+ # Check if expired
162
+ if time.monotonic() > entry.expires_at:
163
+ # Remove expired entry
164
+ del self._cache[key]
165
+ self._misses += 1
166
+ logger.debug("Cache miss (expired): %s", key)
167
+ return None
168
+
169
+ # Cache hit - move to end (most recently used)
170
+ self._cache.move_to_end(key)
171
+ self._hits += 1
172
+ logger.debug("Cache hit: %s", key)
173
+ return entry.value
174
+
175
+ def set(self, key: str, value: Any, ttl: float | None = None) -> None:
176
+ """Set a value in the cache.
177
+
178
+ If the key already exists, it is updated and moved to the end.
179
+ If the cache is at capacity, the least recently used entry is evicted.
180
+
181
+ Args:
182
+ key: The cache key.
183
+ value: The value to cache.
184
+ ttl: Time-to-live in seconds. Uses default_ttl if not specified.
185
+ Must be positive if specified.
186
+
187
+ Raises:
188
+ ValueError: If ttl is not positive.
189
+
190
+ Example:
191
+ # With default TTL
192
+ cache.set("recall:default:test:5", result)
193
+
194
+ # With custom TTL
195
+ cache.set("recall:default:test:5", result, ttl=120.0)
196
+ """
197
+ if ttl is not None and ttl <= 0:
198
+ raise ValueError("ttl must be positive")
199
+
200
+ effective_ttl = ttl if ttl is not None else self._default_ttl
201
+ now = time.monotonic()
202
+ entry = CacheEntry(
203
+ value=value,
204
+ expires_at=now + effective_ttl,
205
+ created_at=now,
206
+ )
207
+
208
+ with self._lock:
209
+ # If key exists, update it
210
+ if key in self._cache:
211
+ self._cache[key] = entry
212
+ self._cache.move_to_end(key)
213
+ logger.debug("Cache update: %s (ttl=%.1fs)", key, effective_ttl)
214
+ return
215
+
216
+ # Evict LRU if at capacity
217
+ while len(self._cache) >= self._max_size:
218
+ # popitem(last=False) removes the first item (LRU)
219
+ evicted_key, _ = self._cache.popitem(last=False)
220
+ self._evictions += 1
221
+ logger.debug("Cache eviction (LRU): %s", evicted_key)
222
+
223
+ # Add new entry
224
+ self._cache[key] = entry
225
+ logger.debug("Cache set: %s (ttl=%.1fs)", key, effective_ttl)
226
+
227
+ def invalidate_namespace(self, namespace: str) -> int:
228
+ """Invalidate all entries containing the given namespace.
229
+
230
+ This is useful when data in a namespace changes and cached
231
+ query results should be refreshed.
232
+
233
+ Args:
234
+ namespace: The namespace string to match. All keys containing
235
+ this string will be invalidated.
236
+
237
+ Returns:
238
+ The number of entries invalidated.
239
+
240
+ Example:
241
+ # After modifying memories in "work" namespace
242
+ count = cache.invalidate_namespace("work")
243
+ print(f"Invalidated {count} cached entries")
244
+ """
245
+ with self._lock:
246
+ keys_to_remove = [key for key in self._cache if namespace in key]
247
+ for key in keys_to_remove:
248
+ del self._cache[key]
249
+
250
+ if keys_to_remove:
251
+ logger.debug(
252
+ "Cache invalidate namespace '%s': %d entries",
253
+ namespace,
254
+ len(keys_to_remove),
255
+ )
256
+
257
+ return len(keys_to_remove)
258
+
259
+ def invalidate_all(self) -> int:
260
+ """Clear the entire cache.
261
+
262
+ Returns:
263
+ The number of entries cleared.
264
+
265
+ Example:
266
+ count = cache.invalidate_all()
267
+ print(f"Cleared {count} cached entries")
268
+ """
269
+ with self._lock:
270
+ count = len(self._cache)
271
+ self._cache.clear()
272
+ logger.debug("Cache cleared: %d entries", count)
273
+ return count
274
+
275
+ def stats(self) -> CacheStats:
276
+ """Get current cache statistics.
277
+
278
+ Returns:
279
+ CacheStats with hits, misses, evictions, size, and max_size.
280
+
281
+ Example:
282
+ stats = cache.stats()
283
+ print(f"Hit rate: {stats.hit_rate:.2%}")
284
+ print(f"Size: {stats.size}/{stats.max_size}")
285
+ """
286
+ with self._lock:
287
+ return CacheStats(
288
+ hits=self._hits,
289
+ misses=self._misses,
290
+ evictions=self._evictions,
291
+ size=len(self._cache),
292
+ max_size=self._max_size,
293
+ )
294
+
295
+ def reset_stats(self) -> None:
296
+ """Reset hit/miss/eviction counters to zero.
297
+
298
+ This does not clear the cache itself, only the statistics.
299
+
300
+ Example:
301
+ cache.reset_stats()
302
+ """
303
+ with self._lock:
304
+ self._hits = 0
305
+ self._misses = 0
306
+ self._evictions = 0
307
+ logger.debug("Cache stats reset")
308
+
309
+ @property
310
+ def max_size(self) -> int:
311
+ """Get the maximum cache size."""
312
+ return self._max_size
313
+
314
+ @property
315
+ def default_ttl(self) -> float:
316
+ """Get the default TTL in seconds."""
317
+ return self._default_ttl