spatial-memory-mcp 1.9.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.
Files changed (55) hide show
  1. spatial_memory/__init__.py +97 -0
  2. spatial_memory/__main__.py +271 -0
  3. spatial_memory/adapters/__init__.py +7 -0
  4. spatial_memory/adapters/lancedb_repository.py +880 -0
  5. spatial_memory/config.py +769 -0
  6. spatial_memory/core/__init__.py +118 -0
  7. spatial_memory/core/cache.py +317 -0
  8. spatial_memory/core/circuit_breaker.py +297 -0
  9. spatial_memory/core/connection_pool.py +220 -0
  10. spatial_memory/core/consolidation_strategies.py +401 -0
  11. spatial_memory/core/database.py +3072 -0
  12. spatial_memory/core/db_idempotency.py +242 -0
  13. spatial_memory/core/db_indexes.py +576 -0
  14. spatial_memory/core/db_migrations.py +588 -0
  15. spatial_memory/core/db_search.py +512 -0
  16. spatial_memory/core/db_versioning.py +178 -0
  17. spatial_memory/core/embeddings.py +558 -0
  18. spatial_memory/core/errors.py +317 -0
  19. spatial_memory/core/file_security.py +701 -0
  20. spatial_memory/core/filesystem.py +178 -0
  21. spatial_memory/core/health.py +289 -0
  22. spatial_memory/core/helpers.py +79 -0
  23. spatial_memory/core/import_security.py +433 -0
  24. spatial_memory/core/lifecycle_ops.py +1067 -0
  25. spatial_memory/core/logging.py +194 -0
  26. spatial_memory/core/metrics.py +192 -0
  27. spatial_memory/core/models.py +660 -0
  28. spatial_memory/core/rate_limiter.py +326 -0
  29. spatial_memory/core/response_types.py +500 -0
  30. spatial_memory/core/security.py +588 -0
  31. spatial_memory/core/spatial_ops.py +430 -0
  32. spatial_memory/core/tracing.py +300 -0
  33. spatial_memory/core/utils.py +110 -0
  34. spatial_memory/core/validation.py +406 -0
  35. spatial_memory/factory.py +444 -0
  36. spatial_memory/migrations/__init__.py +40 -0
  37. spatial_memory/ports/__init__.py +11 -0
  38. spatial_memory/ports/repositories.py +630 -0
  39. spatial_memory/py.typed +0 -0
  40. spatial_memory/server.py +1214 -0
  41. spatial_memory/services/__init__.py +70 -0
  42. spatial_memory/services/decay_manager.py +411 -0
  43. spatial_memory/services/export_import.py +1031 -0
  44. spatial_memory/services/lifecycle.py +1139 -0
  45. spatial_memory/services/memory.py +412 -0
  46. spatial_memory/services/spatial.py +1152 -0
  47. spatial_memory/services/utility.py +429 -0
  48. spatial_memory/tools/__init__.py +5 -0
  49. spatial_memory/tools/definitions.py +695 -0
  50. spatial_memory/verify.py +140 -0
  51. spatial_memory_mcp-1.9.1.dist-info/METADATA +509 -0
  52. spatial_memory_mcp-1.9.1.dist-info/RECORD +55 -0
  53. spatial_memory_mcp-1.9.1.dist-info/WHEEL +4 -0
  54. spatial_memory_mcp-1.9.1.dist-info/entry_points.txt +2 -0
  55. spatial_memory_mcp-1.9.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,558 @@
1
+ """Embedding service for Spatial Memory MCP Server."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import re
6
+ import threading
7
+ import time
8
+ from collections import OrderedDict
9
+ from collections.abc import Callable
10
+ from functools import wraps
11
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar
12
+
13
+ import numpy as np
14
+
15
+ from spatial_memory.core.circuit_breaker import (
16
+ CircuitBreaker,
17
+ CircuitOpenError,
18
+ CircuitState,
19
+ )
20
+ from spatial_memory.core.errors import ConfigurationError, EmbeddingError
21
+
22
+ if TYPE_CHECKING:
23
+ from openai import OpenAI
24
+ from sentence_transformers import SentenceTransformer
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Backend type for embedding inference
29
+ EmbeddingBackend = Literal["auto", "onnx", "pytorch"]
30
+
31
+
32
+ def _is_onnx_available() -> bool:
33
+ """Check if ONNX Runtime and Optimum are available.
34
+
35
+ Sentence-transformers requires both onnxruntime and optimum for ONNX support.
36
+ """
37
+ try:
38
+ import onnxruntime # noqa: F401
39
+ import optimum.onnxruntime # noqa: F401
40
+ return True
41
+ except ImportError:
42
+ return False
43
+
44
+
45
+ def _detect_backend(requested: EmbeddingBackend) -> Literal["onnx", "pytorch"]:
46
+ """Detect which backend to use.
47
+
48
+ Args:
49
+ requested: The requested backend ('auto', 'onnx', or 'pytorch').
50
+
51
+ Returns:
52
+ The actual backend to use ('onnx' or 'pytorch').
53
+ """
54
+ if requested == "pytorch":
55
+ return "pytorch"
56
+ elif requested == "onnx":
57
+ if not _is_onnx_available():
58
+ raise ConfigurationError(
59
+ "ONNX Runtime requested but not fully installed. "
60
+ "Install with: pip install sentence-transformers[onnx]"
61
+ )
62
+ return "onnx"
63
+ else: # auto
64
+ if _is_onnx_available():
65
+ return "onnx"
66
+ return "pytorch"
67
+
68
+ # Type variable for retry decorator
69
+ F = TypeVar("F", bound=Callable[..., Any])
70
+
71
+
72
+ def _mask_api_key(text: str) -> str:
73
+ """Mask API keys in error messages.
74
+
75
+ Args:
76
+ text: Error message text that might contain API keys.
77
+
78
+ Returns:
79
+ Text with API keys masked.
80
+ """
81
+ # Mask OpenAI keys (sk-...)
82
+ text = re.sub(r'sk-[a-zA-Z0-9]{20,}', '***OPENAI_KEY***', text)
83
+ # Mask generic api_key patterns
84
+ text = re.sub(
85
+ r'api[_-]?key["\']?\s*[:=]\s*["\']?[\w-]+',
86
+ 'api_key=***MASKED***',
87
+ text,
88
+ flags=re.IGNORECASE
89
+ )
90
+ return text
91
+
92
+
93
+ def retry_on_api_error(
94
+ max_attempts: int = 3,
95
+ backoff: float = 1.0,
96
+ retryable_status_codes: tuple[int, ...] = (429, 500, 502, 503, 504),
97
+ ) -> Callable[[F], F]:
98
+ """Retry decorator for transient API errors.
99
+
100
+ Args:
101
+ max_attempts: Maximum number of retry attempts.
102
+ backoff: Initial backoff time in seconds (doubles each attempt).
103
+ retryable_status_codes: HTTP status codes that should trigger retry.
104
+
105
+ Returns:
106
+ Decorated function with retry logic.
107
+ """
108
+ # Non-retryable auth errors
109
+ non_retryable_codes = (401, 403)
110
+
111
+ def decorator(func: F) -> F:
112
+ @wraps(func)
113
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
114
+ last_error: Exception | None = None
115
+ for attempt in range(max_attempts):
116
+ try:
117
+ return func(*args, **kwargs)
118
+ except Exception as e:
119
+ last_error = e
120
+
121
+ # Check for OpenAI-specific errors
122
+ status_code = None
123
+ if hasattr(e, "status_code"):
124
+ status_code = e.status_code
125
+ elif hasattr(e, "response") and hasattr(e.response, "status_code"):
126
+ status_code = e.response.status_code
127
+
128
+ # Don't retry auth errors
129
+ if status_code in non_retryable_codes:
130
+ logger.warning(f"Non-retryable API error (status {status_code}): {e}")
131
+ raise
132
+
133
+ # Check if we should retry
134
+ should_retry = (
135
+ status_code in retryable_status_codes
136
+ or "rate" in str(e).lower()
137
+ or "timeout" in str(e).lower()
138
+ or "connection" in str(e).lower()
139
+ )
140
+
141
+ if not should_retry or attempt == max_attempts - 1:
142
+ raise
143
+
144
+ # Retry with exponential backoff
145
+ wait_time = backoff * (2 ** attempt)
146
+ logger.warning(
147
+ f"API call failed (attempt {attempt + 1}/{max_attempts}): {e}. "
148
+ f"Retrying in {wait_time:.1f}s..."
149
+ )
150
+ time.sleep(wait_time)
151
+
152
+ if last_error:
153
+ raise last_error
154
+ return None
155
+
156
+ return wrapper # type: ignore
157
+
158
+ return decorator
159
+
160
+
161
+ class EmbeddingService:
162
+ """Service for generating text embeddings.
163
+
164
+ Supports local sentence-transformers models and optional OpenAI API.
165
+ Uses ONNX Runtime by default for 2-3x faster inference.
166
+ Optionally uses a circuit breaker for fault tolerance with external services.
167
+ """
168
+
169
+ def __init__(
170
+ self,
171
+ model_name: str = "all-MiniLM-L6-v2",
172
+ openai_api_key: str | Any | None = None,
173
+ backend: EmbeddingBackend = "auto",
174
+ circuit_breaker: CircuitBreaker | None = None,
175
+ circuit_breaker_enabled: bool = True,
176
+ circuit_breaker_failure_threshold: int = 5,
177
+ circuit_breaker_reset_timeout: float = 60.0,
178
+ cache_max_size: int = 1000,
179
+ ) -> None:
180
+ """Initialize the embedding service.
181
+
182
+ Args:
183
+ model_name: Model name. Use 'openai:model-name' for OpenAI models.
184
+ openai_api_key: OpenAI API key (required for OpenAI models).
185
+ Can be a string or a SecretStr (pydantic).
186
+ backend: Inference backend. 'auto' uses ONNX if available (default),
187
+ 'onnx' forces ONNX Runtime, 'pytorch' forces PyTorch.
188
+ circuit_breaker: Optional pre-configured circuit breaker instance.
189
+ If provided, other circuit breaker parameters are ignored.
190
+ circuit_breaker_enabled: Whether to enable circuit breaker for OpenAI calls.
191
+ Defaults to True. Only applies to OpenAI models.
192
+ circuit_breaker_failure_threshold: Number of consecutive failures before
193
+ opening the circuit. Default is 5.
194
+ circuit_breaker_reset_timeout: Seconds to wait before attempting recovery.
195
+ Default is 60.0 seconds.
196
+ cache_max_size: Maximum number of embeddings to cache (LRU eviction).
197
+ Default is 1000. Set to 0 to disable caching.
198
+ """
199
+ self.model_name = model_name
200
+ # Handle both plain strings and SecretStr (pydantic)
201
+ if openai_api_key is not None and hasattr(openai_api_key, 'get_secret_value'):
202
+ self._openai_api_key: str | None = openai_api_key.get_secret_value()
203
+ else:
204
+ self._openai_api_key = openai_api_key
205
+ self._model: SentenceTransformer | None = None
206
+ self._openai_client: OpenAI | None = None
207
+ self._dimensions: int | None = None
208
+
209
+ # Determine backend for local models
210
+ self._requested_backend = backend
211
+ self._active_backend: Literal["onnx", "pytorch"] | None = None
212
+
213
+ # Embedding cache (LRU with max size)
214
+ self._embed_cache: OrderedDict[str, np.ndarray] = OrderedDict()
215
+ self._cache_max_size = cache_max_size
216
+ self._cache_lock = threading.Lock()
217
+
218
+ # Determine if using OpenAI
219
+ self.use_openai = model_name.startswith("openai:")
220
+ if self.use_openai:
221
+ self.openai_model = model_name.split(":", 1)[1]
222
+ if not self._openai_api_key:
223
+ raise ConfigurationError(
224
+ "OpenAI API key required for OpenAI embedding models"
225
+ )
226
+
227
+ # Circuit breaker for OpenAI API calls (optional)
228
+ if circuit_breaker is not None:
229
+ self._circuit_breaker: CircuitBreaker | None = circuit_breaker
230
+ elif circuit_breaker_enabled and self.use_openai:
231
+ self._circuit_breaker = CircuitBreaker(
232
+ failure_threshold=circuit_breaker_failure_threshold,
233
+ reset_timeout=circuit_breaker_reset_timeout,
234
+ name=f"embedding_service_{model_name}",
235
+ )
236
+ logger.info(
237
+ f"Circuit breaker enabled for embedding service "
238
+ f"(threshold={circuit_breaker_failure_threshold}, "
239
+ f"timeout={circuit_breaker_reset_timeout}s)"
240
+ )
241
+ else:
242
+ self._circuit_breaker = None
243
+
244
+ def _load_local_model(self) -> None:
245
+ """Load local sentence-transformers model with ONNX or PyTorch backend."""
246
+ if self._model is not None:
247
+ return
248
+
249
+ try:
250
+ from sentence_transformers import SentenceTransformer
251
+
252
+ # Detect which backend to use
253
+ self._active_backend = _detect_backend(self._requested_backend)
254
+
255
+ logger.info(
256
+ f"Loading embedding model: {self.model_name} "
257
+ f"(backend: {self._active_backend})"
258
+ )
259
+
260
+ # Load model with appropriate backend
261
+ if self._active_backend == "onnx":
262
+ # Use ONNX Runtime backend for faster inference
263
+ self._model = SentenceTransformer(
264
+ self.model_name,
265
+ backend="onnx",
266
+ )
267
+ logger.info(
268
+ "Using ONNX Runtime backend (2-3x faster inference)"
269
+ )
270
+ else:
271
+ # Use default PyTorch backend
272
+ self._model = SentenceTransformer(self.model_name)
273
+ logger.info(
274
+ "Using PyTorch backend"
275
+ )
276
+
277
+ self._dimensions = self._model.get_sentence_embedding_dimension()
278
+ logger.info(
279
+ f"Loaded model with {self._dimensions} dimensions"
280
+ )
281
+ except Exception as e:
282
+ masked_error = _mask_api_key(str(e))
283
+ raise EmbeddingError(f"Failed to load embedding model: {masked_error}") from e
284
+
285
+ def _load_openai_client(self) -> None:
286
+ """Load OpenAI client."""
287
+ if self._openai_client is not None:
288
+ return
289
+
290
+ try:
291
+ from openai import OpenAI
292
+
293
+ self._openai_client = OpenAI(api_key=self._openai_api_key)
294
+ # Set dimensions based on model
295
+ model_dimensions = {
296
+ "text-embedding-3-small": 1536,
297
+ "text-embedding-3-large": 3072,
298
+ "text-embedding-ada-002": 1536,
299
+ }
300
+ self._dimensions = model_dimensions.get(self.openai_model, 1536)
301
+ logger.info(
302
+ f"Initialized OpenAI client for {self.openai_model} "
303
+ f"({self._dimensions} dimensions)"
304
+ )
305
+ except ImportError:
306
+ raise ConfigurationError(
307
+ "OpenAI package not installed. Run: pip install openai"
308
+ )
309
+ except Exception as e:
310
+ masked_error = _mask_api_key(str(e))
311
+ raise EmbeddingError(f"Failed to initialize OpenAI client: {masked_error}") from e
312
+
313
+ def _get_cache_key(self, text: str) -> str:
314
+ """Generate cache key from text content.
315
+
316
+ Uses MD5 for speed (not security) - collisions are acceptable for cache.
317
+ """
318
+ return hashlib.md5(text.encode(), usedforsecurity=False).hexdigest()
319
+
320
+ @property
321
+ def dimensions(self) -> int:
322
+ """Get the embedding dimensions."""
323
+ if self._dimensions is None:
324
+ if self.use_openai:
325
+ self._load_openai_client()
326
+ else:
327
+ self._load_local_model()
328
+ return self._dimensions # type: ignore
329
+
330
+ @property
331
+ def backend(self) -> str:
332
+ """Get the active embedding backend.
333
+
334
+ Returns:
335
+ 'openai' for OpenAI API, 'onnx' or 'pytorch' for local models.
336
+ """
337
+ if self.use_openai:
338
+ return "openai"
339
+ if self._active_backend is None:
340
+ # Force model load to determine backend
341
+ self._load_local_model()
342
+ return self._active_backend or "pytorch"
343
+
344
+ @property
345
+ def circuit_state(self) -> CircuitState | None:
346
+ """Get the current circuit breaker state.
347
+
348
+ Returns:
349
+ CircuitState if circuit breaker is enabled, None otherwise.
350
+ """
351
+ if self._circuit_breaker is None:
352
+ return None
353
+ return self._circuit_breaker.state
354
+
355
+ @property
356
+ def circuit_breaker(self) -> CircuitBreaker | None:
357
+ """Get the circuit breaker instance.
358
+
359
+ Returns:
360
+ CircuitBreaker if enabled, None otherwise.
361
+ """
362
+ return self._circuit_breaker
363
+
364
+ def embed(self, text: str) -> np.ndarray:
365
+ """Generate embedding for a single text.
366
+
367
+ Args:
368
+ text: Text to embed.
369
+
370
+ Returns:
371
+ Embedding vector as numpy array.
372
+ """
373
+ cache_key = self._get_cache_key(text)
374
+
375
+ # Check cache first
376
+ with self._cache_lock:
377
+ if cache_key in self._embed_cache:
378
+ # Move to end (most recently used) and return copy
379
+ self._embed_cache.move_to_end(cache_key)
380
+ cached: np.ndarray = self._embed_cache[cache_key].copy()
381
+ return cached
382
+
383
+ # Generate embedding (outside lock to allow concurrent generation)
384
+ if self.use_openai:
385
+ embedding = self._embed_openai_with_circuit_breaker([text])[0]
386
+ else:
387
+ embedding = self._embed_local([text])[0]
388
+
389
+ # Cache the result (if caching enabled)
390
+ if self._cache_max_size > 0:
391
+ with self._cache_lock:
392
+ # Check if another thread already cached it
393
+ if cache_key not in self._embed_cache:
394
+ # Evict oldest entries if at capacity
395
+ while len(self._embed_cache) >= self._cache_max_size:
396
+ self._embed_cache.popitem(last=False)
397
+ self._embed_cache[cache_key] = embedding.copy()
398
+ else:
399
+ # Another thread cached it, move to end
400
+ self._embed_cache.move_to_end(cache_key)
401
+
402
+ return embedding
403
+
404
+ def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
405
+ """Generate embeddings for multiple texts.
406
+
407
+ Uses cache for already-embedded texts and only generates
408
+ embeddings for texts not in cache.
409
+
410
+ Args:
411
+ texts: List of texts to embed.
412
+
413
+ Returns:
414
+ List of embedding vectors.
415
+ """
416
+ if not texts:
417
+ logger.debug("embed_batch called with empty input, returning empty list")
418
+ return []
419
+
420
+ # If caching disabled, generate all embeddings directly
421
+ if self._cache_max_size <= 0:
422
+ if self.use_openai:
423
+ return self._embed_openai_with_circuit_breaker(texts)
424
+ else:
425
+ return self._embed_local(texts)
426
+
427
+ # Check cache for each text
428
+ results: list[np.ndarray | None] = [None] * len(texts)
429
+ texts_to_embed: list[tuple[int, str]] = [] # (index, text)
430
+
431
+ with self._cache_lock:
432
+ for i, text in enumerate(texts):
433
+ cache_key = self._get_cache_key(text)
434
+ if cache_key in self._embed_cache:
435
+ self._embed_cache.move_to_end(cache_key)
436
+ results[i] = self._embed_cache[cache_key].copy()
437
+ else:
438
+ texts_to_embed.append((i, text))
439
+
440
+ # Generate embeddings for uncached texts
441
+ if texts_to_embed:
442
+ uncached_texts = [t for _, t in texts_to_embed]
443
+ if self.use_openai:
444
+ new_embeddings = self._embed_openai_with_circuit_breaker(uncached_texts)
445
+ else:
446
+ new_embeddings = self._embed_local(uncached_texts)
447
+
448
+ # Store results and cache them
449
+ with self._cache_lock:
450
+ for (idx, text), embedding in zip(texts_to_embed, new_embeddings):
451
+ results[idx] = embedding
452
+ cache_key = self._get_cache_key(text)
453
+ if cache_key not in self._embed_cache:
454
+ # Evict oldest entries if at capacity
455
+ while len(self._embed_cache) >= self._cache_max_size:
456
+ self._embed_cache.popitem(last=False)
457
+ self._embed_cache[cache_key] = embedding.copy()
458
+
459
+ # Type assertion - all results should be filled
460
+ return [r for r in results if r is not None]
461
+
462
+ def clear_cache(self) -> int:
463
+ """Clear embedding cache. Returns number of entries cleared."""
464
+ with self._cache_lock:
465
+ count = len(self._embed_cache)
466
+ self._embed_cache.clear()
467
+ return count
468
+
469
+ def _embed_local(self, texts: list[str]) -> list[np.ndarray]:
470
+ """Generate embeddings using local model.
471
+
472
+ Args:
473
+ texts: List of texts to embed.
474
+
475
+ Returns:
476
+ List of embedding vectors.
477
+ """
478
+ self._load_local_model()
479
+ assert self._model is not None # _load_local_model() sets this or raises
480
+
481
+ try:
482
+ embeddings = self._model.encode(
483
+ texts,
484
+ convert_to_numpy=True,
485
+ normalize_embeddings=True,
486
+ show_progress_bar=False,
487
+ )
488
+ return [emb for emb in embeddings]
489
+ except Exception as e:
490
+ masked_error = _mask_api_key(str(e))
491
+ raise EmbeddingError(f"Failed to generate embeddings: {masked_error}") from e
492
+
493
+ def _embed_openai_with_circuit_breaker(self, texts: list[str]) -> list[np.ndarray]:
494
+ """Generate embeddings using OpenAI API with circuit breaker protection.
495
+
496
+ Wraps the OpenAI embedding call with a circuit breaker to prevent
497
+ cascading failures when the API is unavailable.
498
+
499
+ Args:
500
+ texts: List of texts to embed.
501
+
502
+ Returns:
503
+ List of embedding vectors.
504
+
505
+ Raises:
506
+ EmbeddingError: If circuit is open or embedding generation fails.
507
+ """
508
+ if self._circuit_breaker is None:
509
+ # No circuit breaker, call directly
510
+ return self._embed_openai(texts)
511
+
512
+ try:
513
+ return self._circuit_breaker.call(self._embed_openai, texts)
514
+ except CircuitOpenError as e:
515
+ logger.warning(
516
+ f"Circuit breaker is open for embedding service, "
517
+ f"time until retry: {e.time_until_retry:.1f}s"
518
+ if e.time_until_retry is not None
519
+ else "Circuit breaker is open for embedding service"
520
+ )
521
+ raise EmbeddingError(
522
+ f"Embedding service temporarily unavailable (circuit open). "
523
+ f"Try again in {e.time_until_retry:.0f} seconds."
524
+ if e.time_until_retry is not None
525
+ else "Embedding service temporarily unavailable (circuit open)."
526
+ ) from e
527
+
528
+ @retry_on_api_error(max_attempts=3, backoff=1.0)
529
+ def _embed_openai(self, texts: list[str]) -> list[np.ndarray]:
530
+ """Generate embeddings using OpenAI API with retry logic.
531
+
532
+ Automatically retries on transient errors (429 rate limit, 5xx server errors).
533
+ Does not retry on auth errors (401, 403).
534
+
535
+ Args:
536
+ texts: List of texts to embed.
537
+
538
+ Returns:
539
+ List of embedding vectors.
540
+ """
541
+ self._load_openai_client()
542
+ assert self._openai_client is not None # _load_openai_client() sets this or raises
543
+
544
+ try:
545
+ response = self._openai_client.embeddings.create(
546
+ model=self.openai_model,
547
+ input=texts,
548
+ )
549
+ embeddings = []
550
+ for item in response.data:
551
+ emb = np.array(item.embedding, dtype=np.float32)
552
+ # Normalize
553
+ emb = emb / np.linalg.norm(emb)
554
+ embeddings.append(emb)
555
+ return embeddings
556
+ except Exception as e:
557
+ masked_error = _mask_api_key(str(e))
558
+ raise EmbeddingError(f"Failed to generate OpenAI embeddings: {masked_error}") from e