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,242 @@
|
|
|
1
|
+
"""Idempotency key management for LanceDB database.
|
|
2
|
+
|
|
3
|
+
Provides idempotency key storage and lookup to prevent duplicate
|
|
4
|
+
memory creation from retried requests.
|
|
5
|
+
|
|
6
|
+
This module is part of the database.py refactoring to separate concerns:
|
|
7
|
+
- IdempotencyManager handles all idempotency key operations
|
|
8
|
+
- Database class delegates to IdempotencyManager for these operations
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
17
|
+
|
|
18
|
+
import pyarrow as pa
|
|
19
|
+
|
|
20
|
+
from spatial_memory.core.errors import StorageError, ValidationError
|
|
21
|
+
from spatial_memory.core.utils import to_aware_utc, utc_now
|
|
22
|
+
from spatial_memory.core.validation import sanitize_string as _sanitize_string
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import lancedb
|
|
26
|
+
from lancedb.table import Table as LanceTable
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class IdempotencyRecord:
|
|
33
|
+
"""Record for idempotency key tracking."""
|
|
34
|
+
|
|
35
|
+
key: str
|
|
36
|
+
memory_id: str
|
|
37
|
+
created_at: Any # datetime
|
|
38
|
+
expires_at: Any # datetime
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IdempotencyManagerProtocol(Protocol):
|
|
42
|
+
"""Protocol defining what IdempotencyManager needs from Database.
|
|
43
|
+
|
|
44
|
+
This protocol enables loose coupling between IdempotencyManager and Database,
|
|
45
|
+
preventing circular imports while maintaining type safety.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def _db(self) -> lancedb.DBConnection | None:
|
|
50
|
+
"""Access to the LanceDB connection."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IdempotencyManager:
|
|
55
|
+
"""Manages idempotency keys for deduplication.
|
|
56
|
+
|
|
57
|
+
Provides storage and lookup of idempotency keys to prevent
|
|
58
|
+
duplicate memory creation from retried requests.
|
|
59
|
+
|
|
60
|
+
Keys are stored with TTL and automatically expire. The main
|
|
61
|
+
table is created lazily on first access.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
idem_mgr = IdempotencyManager(database)
|
|
65
|
+
existing = idem_mgr.get_by_idempotency_key("request-123")
|
|
66
|
+
if existing:
|
|
67
|
+
return existing.memory_id # Return cached result
|
|
68
|
+
# ... create new memory ...
|
|
69
|
+
idem_mgr.store_idempotency_key("request-123", new_memory_id)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, db: IdempotencyManagerProtocol) -> None:
|
|
73
|
+
"""Initialize the idempotency manager.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
db: Database instance providing connection access.
|
|
77
|
+
"""
|
|
78
|
+
self._db_ref = db
|
|
79
|
+
self._table: LanceTable | None = None
|
|
80
|
+
|
|
81
|
+
def _ensure_idempotency_table(self) -> None:
|
|
82
|
+
"""Ensure the idempotency keys table exists with proper indexes."""
|
|
83
|
+
db_conn = self._db_ref._db
|
|
84
|
+
if db_conn is None:
|
|
85
|
+
raise StorageError("Database not connected")
|
|
86
|
+
|
|
87
|
+
existing_tables_result = db_conn.list_tables()
|
|
88
|
+
if hasattr(existing_tables_result, "tables"):
|
|
89
|
+
existing_tables = existing_tables_result.tables
|
|
90
|
+
else:
|
|
91
|
+
existing_tables = existing_tables_result
|
|
92
|
+
|
|
93
|
+
if "idempotency_keys" not in existing_tables:
|
|
94
|
+
schema = pa.schema([
|
|
95
|
+
pa.field("key", pa.string()),
|
|
96
|
+
pa.field("memory_id", pa.string()),
|
|
97
|
+
pa.field("created_at", pa.timestamp("us")),
|
|
98
|
+
pa.field("expires_at", pa.timestamp("us")),
|
|
99
|
+
])
|
|
100
|
+
table = db_conn.create_table("idempotency_keys", schema=schema)
|
|
101
|
+
logger.info("Created idempotency_keys table")
|
|
102
|
+
|
|
103
|
+
# Create BTREE index on key column for fast lookups
|
|
104
|
+
try:
|
|
105
|
+
table.create_scalar_index("key", index_type="BTREE", replace=True)
|
|
106
|
+
logger.info("Created BTREE index on idempotency_keys.key")
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning(f"Could not create index on idempotency_keys.key: {e}")
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def idempotency_table(self) -> LanceTable:
|
|
112
|
+
"""Get the idempotency keys table, creating if needed."""
|
|
113
|
+
db_conn = self._db_ref._db
|
|
114
|
+
if db_conn is None:
|
|
115
|
+
raise StorageError("Database not connected")
|
|
116
|
+
self._ensure_idempotency_table()
|
|
117
|
+
return db_conn.open_table("idempotency_keys")
|
|
118
|
+
|
|
119
|
+
def get_by_idempotency_key(self, key: str) -> IdempotencyRecord | None:
|
|
120
|
+
"""Look up an idempotency record by key.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
key: The idempotency key to look up.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
IdempotencyRecord if found and not expired, None otherwise.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
StorageError: If database operation fails.
|
|
130
|
+
"""
|
|
131
|
+
if not key:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
safe_key = _sanitize_string(key)
|
|
136
|
+
results = (
|
|
137
|
+
self.idempotency_table.search()
|
|
138
|
+
.where(f"key = '{safe_key}'")
|
|
139
|
+
.limit(1)
|
|
140
|
+
.to_list()
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if not results:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
record = results[0]
|
|
147
|
+
now = utc_now()
|
|
148
|
+
|
|
149
|
+
# Check if expired (convert DB naive datetime to aware for comparison)
|
|
150
|
+
expires_at = record.get("expires_at")
|
|
151
|
+
if expires_at is not None:
|
|
152
|
+
expires_at_aware = to_aware_utc(expires_at)
|
|
153
|
+
if expires_at_aware < now:
|
|
154
|
+
# Expired - clean it up and return None
|
|
155
|
+
logger.debug(f"Idempotency key '{key}' has expired")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
return IdempotencyRecord(
|
|
159
|
+
key=record["key"],
|
|
160
|
+
memory_id=record["memory_id"],
|
|
161
|
+
created_at=record["created_at"],
|
|
162
|
+
expires_at=record["expires_at"],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
except Exception as e:
|
|
166
|
+
raise StorageError(f"Failed to look up idempotency key: {e}") from e
|
|
167
|
+
|
|
168
|
+
def store_idempotency_key(
|
|
169
|
+
self,
|
|
170
|
+
key: str,
|
|
171
|
+
memory_id: str,
|
|
172
|
+
ttl_hours: float = 24.0,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Store an idempotency key mapping.
|
|
175
|
+
|
|
176
|
+
Note: This method should be called within a write lock context
|
|
177
|
+
(the Database class handles locking).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
key: The idempotency key.
|
|
181
|
+
memory_id: The memory ID that was created.
|
|
182
|
+
ttl_hours: Time-to-live in hours (default: 24 hours).
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
ValidationError: If inputs are invalid.
|
|
186
|
+
StorageError: If database operation fails.
|
|
187
|
+
"""
|
|
188
|
+
if not key:
|
|
189
|
+
raise ValidationError("Idempotency key cannot be empty")
|
|
190
|
+
if not memory_id:
|
|
191
|
+
raise ValidationError("Memory ID cannot be empty")
|
|
192
|
+
if ttl_hours <= 0:
|
|
193
|
+
raise ValidationError("TTL must be positive")
|
|
194
|
+
|
|
195
|
+
now = utc_now()
|
|
196
|
+
expires_at = now + timedelta(hours=ttl_hours)
|
|
197
|
+
|
|
198
|
+
record = {
|
|
199
|
+
"key": key,
|
|
200
|
+
"memory_id": memory_id,
|
|
201
|
+
"created_at": now,
|
|
202
|
+
"expires_at": expires_at,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
self.idempotency_table.add([record])
|
|
207
|
+
logger.debug(
|
|
208
|
+
f"Stored idempotency key '{key}' -> memory '{memory_id}' "
|
|
209
|
+
f"(expires in {ttl_hours}h)"
|
|
210
|
+
)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
raise StorageError(f"Failed to store idempotency key: {e}") from e
|
|
213
|
+
|
|
214
|
+
def cleanup_expired_idempotency_keys(self) -> int:
|
|
215
|
+
"""Remove expired idempotency keys.
|
|
216
|
+
|
|
217
|
+
Note: This method should be called within a write lock context
|
|
218
|
+
(the Database class handles locking).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Number of keys removed.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
StorageError: If cleanup fails.
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
now = utc_now()
|
|
228
|
+
count_before: int = self.idempotency_table.count_rows()
|
|
229
|
+
|
|
230
|
+
# Delete expired keys
|
|
231
|
+
predicate = f"expires_at < timestamp '{now.isoformat()}'"
|
|
232
|
+
self.idempotency_table.delete(predicate)
|
|
233
|
+
|
|
234
|
+
count_after: int = self.idempotency_table.count_rows()
|
|
235
|
+
deleted: int = count_before - count_after
|
|
236
|
+
|
|
237
|
+
if deleted > 0:
|
|
238
|
+
logger.info(f"Cleaned up {deleted} expired idempotency keys")
|
|
239
|
+
|
|
240
|
+
return deleted
|
|
241
|
+
except Exception as e:
|
|
242
|
+
raise StorageError(f"Failed to cleanup idempotency keys: {e}") from e
|