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