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,429 @@
1
+ """Utility service for database management operations.
2
+
3
+ This service provides the application layer for utility operations:
4
+ - stats: Get database statistics and health metrics
5
+ - namespaces: List namespaces with memory counts
6
+ - delete_namespace: Delete all memories in a namespace
7
+ - rename_namespace: Rename namespace (move all memories)
8
+ - hybrid_recall: Combined vector + FTS search
9
+
10
+ The service uses dependency injection for repository and embedding services.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import TYPE_CHECKING
17
+
18
+ from spatial_memory.core.errors import (
19
+ NamespaceNotFoundError,
20
+ NamespaceOperationError,
21
+ ValidationError,
22
+ )
23
+ from spatial_memory.core.models import (
24
+ DeleteNamespaceResult,
25
+ HybridMemoryMatch,
26
+ HybridRecallResult,
27
+ IndexInfo,
28
+ NamespaceInfo,
29
+ NamespacesResult,
30
+ RenameNamespaceResult,
31
+ StatsResult,
32
+ UtilityConfig,
33
+ )
34
+ from spatial_memory.core.validation import validate_namespace
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Explicit exports for mypy
39
+ __all__ = [
40
+ "UtilityConfig",
41
+ "UtilityService",
42
+ ]
43
+
44
+ if TYPE_CHECKING:
45
+ from spatial_memory.ports.repositories import (
46
+ EmbeddingServiceProtocol,
47
+ MemoryRepositoryProtocol,
48
+ )
49
+
50
+
51
+ class UtilityService:
52
+ """Service for database utility operations.
53
+
54
+ Uses Clean Architecture - depends on protocol interfaces, not implementations.
55
+ Provides database statistics, namespace management, and hybrid search.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ repository: MemoryRepositoryProtocol,
61
+ embeddings: EmbeddingServiceProtocol,
62
+ config: UtilityConfig | None = None,
63
+ ) -> None:
64
+ """Initialize the utility service.
65
+
66
+ Args:
67
+ repository: Repository for memory storage.
68
+ embeddings: Service for generating embeddings.
69
+ config: Optional configuration (uses defaults if not provided).
70
+ """
71
+ self._repo = repository
72
+ self._embeddings = embeddings
73
+ self._config = config or UtilityConfig()
74
+
75
+ def stats(
76
+ self,
77
+ namespace: str | None = None,
78
+ include_index_details: bool = True,
79
+ ) -> StatsResult:
80
+ """Get comprehensive database statistics.
81
+
82
+ Retrieves statistics about the memory database including total counts,
83
+ storage size, index information, and health metrics.
84
+
85
+ Args:
86
+ namespace: Filter statistics to a specific namespace.
87
+ If None, returns statistics for all namespaces.
88
+ include_index_details: Include detailed index statistics.
89
+
90
+ Returns:
91
+ StatsResult with database information.
92
+
93
+ Raises:
94
+ ValidationError: If namespace is invalid.
95
+ NamespaceOperationError: If stats retrieval fails.
96
+ """
97
+ # Validate namespace if provided
98
+ if namespace is not None:
99
+ namespace = validate_namespace(namespace)
100
+
101
+ try:
102
+ # Get stats from repository
103
+ raw_stats = self._repo.get_stats(namespace=namespace)
104
+
105
+ # Transform indices to IndexInfo objects
106
+ indices: list[IndexInfo] = []
107
+ if include_index_details:
108
+ for idx_data in raw_stats.get("indices", []):
109
+ indices.append(
110
+ IndexInfo(
111
+ name=idx_data.get("name", "unknown"),
112
+ index_type=idx_data.get("index_type", "unknown"),
113
+ column=idx_data.get("column", "unknown"),
114
+ num_indexed_rows=idx_data.get("num_indexed_rows", 0),
115
+ status=idx_data.get("status", "unknown"),
116
+ )
117
+ )
118
+
119
+ # Get namespace breakdown from raw_stats
120
+ memories_by_namespace: dict[str, int] = raw_stats.get("namespaces", {})
121
+
122
+ # Calculate estimated vector bytes (dims * 4 bytes per float * num memories)
123
+ total_memories = raw_stats.get("total_memories", 0)
124
+ embedding_dims = self._embeddings.dimensions
125
+ estimated_vector_bytes = total_memories * embedding_dims * 4
126
+
127
+ return StatsResult(
128
+ total_memories=total_memories,
129
+ memories_by_namespace=memories_by_namespace,
130
+ storage_bytes=raw_stats.get("storage_bytes", 0),
131
+ storage_mb=raw_stats.get("storage_mb", 0.0),
132
+ estimated_vector_bytes=estimated_vector_bytes,
133
+ has_vector_index=raw_stats.get("has_vector_index", False),
134
+ has_fts_index=raw_stats.get("has_fts_index", False),
135
+ indices=indices,
136
+ num_fragments=raw_stats.get("num_fragments", 0),
137
+ needs_compaction=raw_stats.get("needs_compaction", False),
138
+ table_version=raw_stats.get("table_version", 1),
139
+ oldest_memory_date=raw_stats.get("oldest_memory_date"),
140
+ newest_memory_date=raw_stats.get("newest_memory_date"),
141
+ avg_content_length=raw_stats.get("avg_content_length"),
142
+ )
143
+
144
+ except ValidationError:
145
+ raise
146
+ except Exception as e:
147
+ raise NamespaceOperationError(f"Failed to get stats: {e}") from e
148
+
149
+ def namespaces(
150
+ self,
151
+ include_stats: bool = True,
152
+ ) -> NamespacesResult:
153
+ """List all namespaces with optional statistics.
154
+
155
+ Args:
156
+ include_stats: Include memory counts and date ranges per namespace.
157
+
158
+ Returns:
159
+ NamespacesResult with namespace list and totals.
160
+
161
+ Raises:
162
+ NamespaceOperationError: If namespace listing fails.
163
+ """
164
+ try:
165
+ # Get list of namespaces from repository
166
+ namespace_names = self._repo.get_namespaces()
167
+
168
+ namespaces: list[NamespaceInfo] = []
169
+ total_memories = 0
170
+
171
+ for ns_name in namespace_names:
172
+ if include_stats:
173
+ # Get detailed stats for each namespace
174
+ try:
175
+ ns_stats = self._repo.get_namespace_stats(ns_name)
176
+ memory_count = ns_stats.get("memory_count", 0)
177
+ namespaces.append(
178
+ NamespaceInfo(
179
+ name=ns_name,
180
+ memory_count=memory_count,
181
+ oldest_memory=ns_stats.get("oldest_memory"),
182
+ newest_memory=ns_stats.get("newest_memory"),
183
+ )
184
+ )
185
+ total_memories += memory_count
186
+ except Exception as e:
187
+ logger.warning(f"Failed to get stats for namespace {ns_name}: {e}")
188
+ # Still include namespace with zero count
189
+ namespaces.append(
190
+ NamespaceInfo(
191
+ name=ns_name,
192
+ memory_count=0,
193
+ )
194
+ )
195
+ else:
196
+ # Just include name without stats
197
+ namespaces.append(
198
+ NamespaceInfo(
199
+ name=ns_name,
200
+ memory_count=0,
201
+ )
202
+ )
203
+
204
+ # If we didn't include stats, get total from count
205
+ if not include_stats:
206
+ total_memories = self._repo.count()
207
+
208
+ return NamespacesResult(
209
+ namespaces=namespaces,
210
+ total_namespaces=len(namespaces),
211
+ total_memories=total_memories,
212
+ )
213
+
214
+ except Exception as e:
215
+ raise NamespaceOperationError(f"Failed to list namespaces: {e}") from e
216
+
217
+ def delete_namespace(
218
+ self,
219
+ namespace: str,
220
+ confirm: bool = False,
221
+ dry_run: bool = True,
222
+ ) -> DeleteNamespaceResult:
223
+ """Delete all memories in a namespace.
224
+
225
+ DESTRUCTIVE OPERATION - requires explicit confirmation unless dry_run.
226
+
227
+ Args:
228
+ namespace: Namespace to delete.
229
+ confirm: Set to True to confirm deletion (required for non-dry-run).
230
+ dry_run: Preview deletion without executing (default True).
231
+
232
+ Returns:
233
+ DeleteNamespaceResult with deletion details.
234
+
235
+ Raises:
236
+ ValidationError: If namespace is invalid or confirmation missing.
237
+ NamespaceOperationError: If deletion fails.
238
+ """
239
+ # Validate namespace
240
+ namespace = validate_namespace(namespace)
241
+
242
+ try:
243
+ # Get count of memories that would be deleted
244
+ memory_count = self._repo.count(namespace=namespace)
245
+
246
+ # If dry run, just return preview
247
+ if dry_run:
248
+ return DeleteNamespaceResult(
249
+ namespace=namespace,
250
+ memories_deleted=memory_count,
251
+ success=True,
252
+ message=f"DRY RUN: Would delete {memory_count} memories "
253
+ f"from namespace '{namespace}'",
254
+ dry_run=True,
255
+ )
256
+
257
+ # Not a dry run - require confirmation
258
+ if not confirm:
259
+ raise ValidationError(
260
+ f"Deletion of {memory_count} memories in namespace '{namespace}' "
261
+ "requires confirm=True. Use dry_run=True to preview first."
262
+ )
263
+
264
+ # Perform actual deletion
265
+ deleted_count = self._repo.delete_by_namespace(namespace)
266
+
267
+ return DeleteNamespaceResult(
268
+ namespace=namespace,
269
+ memories_deleted=deleted_count,
270
+ success=True,
271
+ message=f"Deleted {deleted_count} memories from namespace '{namespace}'",
272
+ dry_run=False,
273
+ )
274
+
275
+ except ValidationError:
276
+ raise
277
+ except Exception as e:
278
+ raise NamespaceOperationError(
279
+ f"Failed to delete namespace '{namespace}': {e}"
280
+ ) from e
281
+
282
+ def rename_namespace(
283
+ self,
284
+ old_namespace: str,
285
+ new_namespace: str,
286
+ ) -> RenameNamespaceResult:
287
+ """Rename all memories from one namespace to another.
288
+
289
+ Atomically updates the namespace field for all memories belonging
290
+ to the source namespace.
291
+
292
+ Args:
293
+ old_namespace: The current namespace name (source).
294
+ new_namespace: The new namespace name (target).
295
+
296
+ Returns:
297
+ RenameNamespaceResult with rename details.
298
+
299
+ Raises:
300
+ ValidationError: If namespace names are invalid or the same.
301
+ NamespaceNotFoundError: If old_namespace doesn't exist.
302
+ NamespaceOperationError: If rename fails.
303
+ """
304
+ # Validate both namespaces
305
+ old_namespace = validate_namespace(old_namespace)
306
+ new_namespace = validate_namespace(new_namespace)
307
+
308
+ # Check they are different
309
+ if old_namespace == new_namespace:
310
+ raise ValidationError(
311
+ f"Cannot rename namespace to same name: '{old_namespace}'"
312
+ )
313
+
314
+ try:
315
+ # Call repository to perform rename
316
+ renamed_count = self._repo.rename_namespace(old_namespace, new_namespace)
317
+
318
+ return RenameNamespaceResult(
319
+ old_namespace=old_namespace,
320
+ new_namespace=new_namespace,
321
+ memories_renamed=renamed_count,
322
+ success=True,
323
+ message=f"Renamed {renamed_count} memories "
324
+ f"from '{old_namespace}' to '{new_namespace}'",
325
+ )
326
+
327
+ except NamespaceNotFoundError:
328
+ raise
329
+ except ValidationError:
330
+ raise
331
+ except Exception as e:
332
+ raise NamespaceOperationError(
333
+ f"Failed to rename namespace '{old_namespace}' to '{new_namespace}': {e}"
334
+ ) from e
335
+
336
+ def hybrid_recall(
337
+ self,
338
+ query: str,
339
+ alpha: float = 0.5,
340
+ limit: int = 5,
341
+ namespace: str | None = None,
342
+ min_similarity: float = 0.0,
343
+ ) -> HybridRecallResult:
344
+ """Search using combined vector similarity and keyword matching.
345
+
346
+ Performs hybrid search combining semantic similarity (vector search)
347
+ and keyword matching (full-text search). Alpha parameter controls
348
+ the balance: 1.0 = pure vector, 0.0 = pure keyword, 0.5 = balanced.
349
+
350
+ Args:
351
+ query: Search query text.
352
+ alpha: Balance between vector (1.0) and keyword (0.0) search.
353
+ limit: Maximum number of results.
354
+ namespace: Filter to specific namespace.
355
+ min_similarity: Minimum similarity threshold (0-1).
356
+
357
+ Returns:
358
+ HybridRecallResult with matching memories.
359
+
360
+ Raises:
361
+ ValidationError: If input validation fails.
362
+ """
363
+ # Validate query
364
+ if not query or not query.strip():
365
+ raise ValidationError("Query cannot be empty")
366
+
367
+ # Validate alpha
368
+ if alpha < self._config.hybrid_min_alpha or alpha > self._config.hybrid_max_alpha:
369
+ raise ValidationError(
370
+ f"Alpha must be between {self._config.hybrid_min_alpha} "
371
+ f"and {self._config.hybrid_max_alpha}, got {alpha}"
372
+ )
373
+
374
+ # Validate namespace if provided
375
+ if namespace is not None:
376
+ namespace = validate_namespace(namespace)
377
+
378
+ # Generate query embedding
379
+ query_vector = self._embeddings.embed(query)
380
+
381
+ # Perform hybrid search
382
+ results = self._repo.hybrid_search(
383
+ query_vector=query_vector,
384
+ query_text=query,
385
+ limit=limit,
386
+ namespace=namespace,
387
+ alpha=alpha,
388
+ )
389
+
390
+ # Transform results to HybridMemoryMatch and filter by min_similarity
391
+ memories: list[HybridMemoryMatch] = []
392
+ for result in results:
393
+ if result.similarity >= min_similarity:
394
+ memories.append(
395
+ HybridMemoryMatch(
396
+ id=result.id,
397
+ content=result.content,
398
+ similarity=result.similarity,
399
+ namespace=result.namespace,
400
+ tags=list(result.tags),
401
+ importance=result.importance,
402
+ created_at=result.created_at,
403
+ metadata=dict(result.metadata),
404
+ # These may be populated if repository provides them
405
+ vector_score=getattr(result, "vector_score", None),
406
+ fts_score=getattr(result, "fts_score", None),
407
+ combined_score=result.similarity,
408
+ # For auto-decay support
409
+ last_accessed=result.last_accessed,
410
+ access_count=result.access_count,
411
+ )
412
+ )
413
+
414
+ # Update access stats for returned memories (batch for efficiency)
415
+ if memories:
416
+ memory_ids = [m.id for m in memories]
417
+ try:
418
+ self._repo.update_access_batch(memory_ids)
419
+ except Exception as e:
420
+ # Log but don't fail the search if access update fails
421
+ logger.warning(f"Failed to update access stats: {e}")
422
+
423
+ return HybridRecallResult(
424
+ query=query,
425
+ alpha=alpha,
426
+ memories=memories,
427
+ total=len(memories),
428
+ search_type="hybrid",
429
+ )
@@ -0,0 +1,5 @@
1
+ """MCP Tool definitions for Spatial Memory Server."""
2
+
3
+ from spatial_memory.tools.definitions import TOOLS
4
+
5
+ __all__ = ["TOOLS"]