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.
- spatial_memory/__init__.py +97 -0
- spatial_memory/__main__.py +271 -0
- spatial_memory/adapters/__init__.py +7 -0
- spatial_memory/adapters/lancedb_repository.py +880 -0
- spatial_memory/config.py +769 -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 +401 -0
- spatial_memory/core/database.py +3072 -0
- spatial_memory/core/db_idempotency.py +242 -0
- spatial_memory/core/db_indexes.py +576 -0
- spatial_memory/core/db_migrations.py +588 -0
- spatial_memory/core/db_search.py +512 -0
- spatial_memory/core/db_versioning.py +178 -0
- spatial_memory/core/embeddings.py +558 -0
- spatial_memory/core/errors.py +317 -0
- spatial_memory/core/file_security.py +701 -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 +433 -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 +660 -0
- spatial_memory/core/rate_limiter.py +326 -0
- spatial_memory/core/response_types.py +500 -0
- spatial_memory/core/security.py +588 -0
- spatial_memory/core/spatial_ops.py +430 -0
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/utils.py +110 -0
- spatial_memory/core/validation.py +406 -0
- spatial_memory/factory.py +444 -0
- spatial_memory/migrations/__init__.py +40 -0
- spatial_memory/ports/__init__.py +11 -0
- spatial_memory/ports/repositories.py +630 -0
- spatial_memory/py.typed +0 -0
- spatial_memory/server.py +1214 -0
- spatial_memory/services/__init__.py +70 -0
- spatial_memory/services/decay_manager.py +411 -0
- spatial_memory/services/export_import.py +1031 -0
- spatial_memory/services/lifecycle.py +1139 -0
- spatial_memory/services/memory.py +412 -0
- spatial_memory/services/spatial.py +1152 -0
- spatial_memory/services/utility.py +429 -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.9.1.dist-info/METADATA +509 -0
- spatial_memory_mcp-1.9.1.dist-info/RECORD +55 -0
- spatial_memory_mcp-1.9.1.dist-info/WHEEL +4 -0
- spatial_memory_mcp-1.9.1.dist-info/entry_points.txt +2 -0
- spatial_memory_mcp-1.9.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Memory service for core operations.
|
|
2
|
+
|
|
3
|
+
This service provides the application layer for memory operations:
|
|
4
|
+
- remember: Store new memories
|
|
5
|
+
- recall: Search for similar memories
|
|
6
|
+
- nearby: Find neighbors of a memory
|
|
7
|
+
- forget: Delete memories
|
|
8
|
+
|
|
9
|
+
The service uses dependency injection for repository and embedding services.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
17
|
+
|
|
18
|
+
from spatial_memory.core.errors import MemoryNotFoundError, ValidationError
|
|
19
|
+
from spatial_memory.core.models import Memory, MemorySource
|
|
20
|
+
from spatial_memory.core.validation import validate_content, validate_importance
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from spatial_memory.core.db_idempotency import IdempotencyRecord
|
|
26
|
+
from spatial_memory.core.models import MemoryResult
|
|
27
|
+
from spatial_memory.ports.repositories import (
|
|
28
|
+
EmbeddingServiceProtocol,
|
|
29
|
+
MemoryRepositoryProtocol,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IdempotencyProviderProtocol(Protocol):
|
|
34
|
+
"""Protocol for idempotency key storage and lookup.
|
|
35
|
+
|
|
36
|
+
Implementations should handle key-to-memory-id mappings with TTL support.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def get_by_idempotency_key(self, key: str) -> IdempotencyRecord | None:
|
|
40
|
+
"""Look up an idempotency record by key.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
key: The idempotency key to look up.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
IdempotencyRecord if found and not expired, None otherwise.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def store_idempotency_key(
|
|
51
|
+
self,
|
|
52
|
+
key: str,
|
|
53
|
+
memory_id: str,
|
|
54
|
+
ttl_hours: float = 24.0,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Store an idempotency key mapping.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
key: The idempotency key.
|
|
60
|
+
memory_id: The memory ID that was created.
|
|
61
|
+
ttl_hours: Time-to-live in hours (default: 24 hours).
|
|
62
|
+
"""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class RememberResult:
|
|
68
|
+
"""Result of storing a memory."""
|
|
69
|
+
|
|
70
|
+
id: str
|
|
71
|
+
content: str
|
|
72
|
+
namespace: str
|
|
73
|
+
deduplicated: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class RememberBatchResult:
|
|
78
|
+
"""Result of storing multiple memories."""
|
|
79
|
+
|
|
80
|
+
ids: list[str]
|
|
81
|
+
count: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class RecallResult:
|
|
86
|
+
"""Result of recalling memories."""
|
|
87
|
+
|
|
88
|
+
memories: list[MemoryResult]
|
|
89
|
+
total: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class NearbyResult:
|
|
94
|
+
"""Result of finding nearby memories."""
|
|
95
|
+
|
|
96
|
+
reference: Memory
|
|
97
|
+
neighbors: list[MemoryResult]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class ForgetResult:
|
|
102
|
+
"""Result of forgetting memories."""
|
|
103
|
+
|
|
104
|
+
deleted: int
|
|
105
|
+
ids: list[str] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MemoryService:
|
|
109
|
+
"""Service for memory operations.
|
|
110
|
+
|
|
111
|
+
Uses Clean Architecture - depends on protocol interfaces, not implementations.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
repository: MemoryRepositoryProtocol,
|
|
117
|
+
embeddings: EmbeddingServiceProtocol,
|
|
118
|
+
idempotency_provider: IdempotencyProviderProtocol | None = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Initialize the memory service.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
repository: Repository for memory storage.
|
|
124
|
+
embeddings: Service for generating embeddings.
|
|
125
|
+
idempotency_provider: Optional provider for idempotency key support.
|
|
126
|
+
"""
|
|
127
|
+
self._repo = repository
|
|
128
|
+
self._embeddings = embeddings
|
|
129
|
+
self._idempotency = idempotency_provider
|
|
130
|
+
|
|
131
|
+
# Use centralized validation functions
|
|
132
|
+
_validate_content = staticmethod(validate_content)
|
|
133
|
+
_validate_importance = staticmethod(validate_importance)
|
|
134
|
+
|
|
135
|
+
def remember(
|
|
136
|
+
self,
|
|
137
|
+
content: str,
|
|
138
|
+
namespace: str = "default",
|
|
139
|
+
tags: list[str] | None = None,
|
|
140
|
+
importance: float = 0.5,
|
|
141
|
+
metadata: dict[str, Any] | None = None,
|
|
142
|
+
idempotency_key: str | None = None,
|
|
143
|
+
) -> RememberResult:
|
|
144
|
+
"""Store a new memory.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
content: Text content of the memory.
|
|
148
|
+
namespace: Namespace for organization.
|
|
149
|
+
tags: Optional list of tags.
|
|
150
|
+
importance: Importance score (0-1).
|
|
151
|
+
metadata: Optional metadata dict.
|
|
152
|
+
idempotency_key: Optional key for idempotent requests. If provided
|
|
153
|
+
and a memory was already created with this key, returns the
|
|
154
|
+
existing memory ID with deduplicated=True.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
RememberResult with the new memory's ID. If idempotency_key was
|
|
158
|
+
provided and matched an existing request, deduplicated=True.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValidationError: If input validation fails.
|
|
162
|
+
"""
|
|
163
|
+
# Check idempotency key first (before any expensive operations)
|
|
164
|
+
if idempotency_key and self._idempotency:
|
|
165
|
+
existing = self._idempotency.get_by_idempotency_key(idempotency_key)
|
|
166
|
+
if existing:
|
|
167
|
+
logger.debug(
|
|
168
|
+
f"Idempotency key '{idempotency_key}' matched existing "
|
|
169
|
+
f"memory '{existing.memory_id}'"
|
|
170
|
+
)
|
|
171
|
+
# Return cached result - fetch the memory to get content
|
|
172
|
+
cached_memory = self._repo.get(existing.memory_id)
|
|
173
|
+
if cached_memory:
|
|
174
|
+
return RememberResult(
|
|
175
|
+
id=existing.memory_id,
|
|
176
|
+
content=cached_memory.content,
|
|
177
|
+
namespace=cached_memory.namespace,
|
|
178
|
+
deduplicated=True,
|
|
179
|
+
)
|
|
180
|
+
# Memory was deleted but key exists - proceed with new insert
|
|
181
|
+
logger.warning(
|
|
182
|
+
f"Idempotency key '{idempotency_key}' references deleted "
|
|
183
|
+
f"memory '{existing.memory_id}', creating new memory"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Validate inputs
|
|
187
|
+
self._validate_content(content)
|
|
188
|
+
self._validate_importance(importance)
|
|
189
|
+
|
|
190
|
+
# Generate embedding
|
|
191
|
+
vector = self._embeddings.embed(content)
|
|
192
|
+
|
|
193
|
+
# Create memory object (ID will be assigned by repository)
|
|
194
|
+
memory = Memory(
|
|
195
|
+
id="", # Will be replaced by repository
|
|
196
|
+
content=content,
|
|
197
|
+
namespace=namespace,
|
|
198
|
+
tags=tags or [],
|
|
199
|
+
importance=importance,
|
|
200
|
+
source=MemorySource.MANUAL,
|
|
201
|
+
metadata=metadata or {},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Store in repository
|
|
205
|
+
memory_id = self._repo.add(memory, vector)
|
|
206
|
+
|
|
207
|
+
# Store idempotency key mapping if provided
|
|
208
|
+
if idempotency_key and self._idempotency:
|
|
209
|
+
try:
|
|
210
|
+
self._idempotency.store_idempotency_key(idempotency_key, memory_id)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
# Log but don't fail the memory creation
|
|
213
|
+
logger.warning(
|
|
214
|
+
f"Failed to store idempotency key '{idempotency_key}': {e}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return RememberResult(
|
|
218
|
+
id=memory_id,
|
|
219
|
+
content=content,
|
|
220
|
+
namespace=namespace,
|
|
221
|
+
deduplicated=False,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def remember_batch(
|
|
225
|
+
self,
|
|
226
|
+
memories: list[dict[str, Any]],
|
|
227
|
+
) -> RememberBatchResult:
|
|
228
|
+
"""Store multiple memories efficiently.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
memories: List of dicts with content and optional fields.
|
|
232
|
+
Each dict can have: content, namespace, tags, importance, metadata.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
RememberBatchResult with IDs and count.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValidationError: If input validation fails.
|
|
239
|
+
"""
|
|
240
|
+
if not memories:
|
|
241
|
+
raise ValidationError("Memory list cannot be empty")
|
|
242
|
+
|
|
243
|
+
# Validate all memories first
|
|
244
|
+
for mem_dict in memories:
|
|
245
|
+
content = mem_dict.get("content", "")
|
|
246
|
+
self._validate_content(content)
|
|
247
|
+
importance = mem_dict.get("importance", 0.5)
|
|
248
|
+
self._validate_importance(importance)
|
|
249
|
+
|
|
250
|
+
# Extract content for batch embedding
|
|
251
|
+
contents = [m["content"] for m in memories]
|
|
252
|
+
vectors = self._embeddings.embed_batch(contents)
|
|
253
|
+
|
|
254
|
+
# Create Memory objects
|
|
255
|
+
memory_objects: list[Memory] = []
|
|
256
|
+
for mem_dict in memories:
|
|
257
|
+
memory = Memory(
|
|
258
|
+
id="", # Will be replaced by repository
|
|
259
|
+
content=mem_dict["content"],
|
|
260
|
+
namespace=mem_dict.get("namespace", "default"),
|
|
261
|
+
tags=mem_dict.get("tags", []),
|
|
262
|
+
importance=mem_dict.get("importance", 0.5),
|
|
263
|
+
source=MemorySource.MANUAL,
|
|
264
|
+
metadata=mem_dict.get("metadata", {}),
|
|
265
|
+
)
|
|
266
|
+
memory_objects.append(memory)
|
|
267
|
+
|
|
268
|
+
# Store in repository
|
|
269
|
+
ids = self._repo.add_batch(memory_objects, vectors)
|
|
270
|
+
|
|
271
|
+
return RememberBatchResult(
|
|
272
|
+
ids=ids,
|
|
273
|
+
count=len(ids),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def recall(
|
|
277
|
+
self,
|
|
278
|
+
query: str,
|
|
279
|
+
limit: int = 5,
|
|
280
|
+
namespace: str | None = None,
|
|
281
|
+
min_similarity: float = 0.0,
|
|
282
|
+
) -> RecallResult:
|
|
283
|
+
"""Search for similar memories.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
query: Query text to search for.
|
|
287
|
+
limit: Maximum number of results.
|
|
288
|
+
namespace: Optional namespace filter.
|
|
289
|
+
min_similarity: Minimum similarity threshold.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
RecallResult with matching memories.
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
ValidationError: If input validation fails.
|
|
296
|
+
"""
|
|
297
|
+
# Validate inputs
|
|
298
|
+
if not query or not query.strip():
|
|
299
|
+
raise ValidationError("Query cannot be empty")
|
|
300
|
+
if limit < 1:
|
|
301
|
+
raise ValidationError("Limit must be at least 1")
|
|
302
|
+
|
|
303
|
+
# Generate query embedding
|
|
304
|
+
vector = self._embeddings.embed(query)
|
|
305
|
+
|
|
306
|
+
# Search repository
|
|
307
|
+
results = self._repo.search(vector, limit=limit, namespace=namespace)
|
|
308
|
+
|
|
309
|
+
# Filter by minimum similarity
|
|
310
|
+
filtered_results = [r for r in results if r.similarity >= min_similarity]
|
|
311
|
+
|
|
312
|
+
# Update access stats for returned memories (batch for efficiency)
|
|
313
|
+
if filtered_results:
|
|
314
|
+
memory_ids = [r.id for r in filtered_results]
|
|
315
|
+
try:
|
|
316
|
+
self._repo.update_access_batch(memory_ids)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
# Log but don't fail the search if access update fails
|
|
319
|
+
logger.warning(f"Failed to update access stats: {e}")
|
|
320
|
+
|
|
321
|
+
return RecallResult(
|
|
322
|
+
memories=filtered_results,
|
|
323
|
+
total=len(filtered_results),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def nearby(
|
|
327
|
+
self,
|
|
328
|
+
memory_id: str,
|
|
329
|
+
limit: int = 5,
|
|
330
|
+
namespace: str | None = None,
|
|
331
|
+
) -> NearbyResult:
|
|
332
|
+
"""Find memories similar to a reference memory.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
memory_id: ID of the reference memory.
|
|
336
|
+
limit: Maximum number of neighbors.
|
|
337
|
+
namespace: Optional namespace filter.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
NearbyResult with reference and neighbors.
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
MemoryNotFoundError: If reference memory not found.
|
|
344
|
+
"""
|
|
345
|
+
# Get reference memory with its vector
|
|
346
|
+
result = self._repo.get_with_vector(memory_id)
|
|
347
|
+
if result is None:
|
|
348
|
+
raise MemoryNotFoundError(memory_id)
|
|
349
|
+
|
|
350
|
+
reference_memory, reference_vector = result
|
|
351
|
+
|
|
352
|
+
# Search for similar memories (request limit+1 to account for self)
|
|
353
|
+
search_results = self._repo.search(
|
|
354
|
+
reference_vector,
|
|
355
|
+
limit=limit + 1,
|
|
356
|
+
namespace=namespace,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Filter out the reference memory itself
|
|
360
|
+
neighbors = [r for r in search_results if r.id != memory_id]
|
|
361
|
+
|
|
362
|
+
# Limit to requested count
|
|
363
|
+
neighbors = neighbors[:limit]
|
|
364
|
+
|
|
365
|
+
return NearbyResult(
|
|
366
|
+
reference=reference_memory,
|
|
367
|
+
neighbors=neighbors,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def forget(
|
|
371
|
+
self,
|
|
372
|
+
memory_id: str,
|
|
373
|
+
) -> ForgetResult:
|
|
374
|
+
"""Delete a memory.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
memory_id: ID of memory to delete.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
ForgetResult with deletion count.
|
|
381
|
+
"""
|
|
382
|
+
deleted = self._repo.delete(memory_id)
|
|
383
|
+
|
|
384
|
+
return ForgetResult(
|
|
385
|
+
deleted=1 if deleted else 0,
|
|
386
|
+
ids=[memory_id] if deleted else [],
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def forget_batch(
|
|
390
|
+
self,
|
|
391
|
+
memory_ids: list[str],
|
|
392
|
+
) -> ForgetResult:
|
|
393
|
+
"""Delete multiple memories.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
memory_ids: List of memory IDs to delete.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
ForgetResult with deletion count.
|
|
400
|
+
|
|
401
|
+
Raises:
|
|
402
|
+
ValidationError: If input validation fails.
|
|
403
|
+
"""
|
|
404
|
+
if not memory_ids:
|
|
405
|
+
raise ValidationError("Memory ID list cannot be empty")
|
|
406
|
+
|
|
407
|
+
deleted_count, deleted_ids = self._repo.delete_batch(memory_ids)
|
|
408
|
+
|
|
409
|
+
return ForgetResult(
|
|
410
|
+
deleted=deleted_count,
|
|
411
|
+
ids=deleted_ids,
|
|
412
|
+
)
|