spatial-memory-mcp 1.5.3__py3-none-any.whl → 1.6.0__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.

Files changed (34) hide show
  1. spatial_memory/__init__.py +1 -1
  2. spatial_memory/__main__.py +241 -2
  3. spatial_memory/adapters/lancedb_repository.py +74 -5
  4. spatial_memory/config.py +10 -2
  5. spatial_memory/core/__init__.py +9 -0
  6. spatial_memory/core/connection_pool.py +41 -3
  7. spatial_memory/core/consolidation_strategies.py +402 -0
  8. spatial_memory/core/database.py +774 -918
  9. spatial_memory/core/db_idempotency.py +242 -0
  10. spatial_memory/core/db_indexes.py +575 -0
  11. spatial_memory/core/db_migrations.py +584 -0
  12. spatial_memory/core/db_search.py +509 -0
  13. spatial_memory/core/db_versioning.py +177 -0
  14. spatial_memory/core/embeddings.py +65 -18
  15. spatial_memory/core/errors.py +75 -3
  16. spatial_memory/core/filesystem.py +178 -0
  17. spatial_memory/core/models.py +4 -0
  18. spatial_memory/core/rate_limiter.py +26 -9
  19. spatial_memory/core/response_types.py +497 -0
  20. spatial_memory/core/validation.py +86 -2
  21. spatial_memory/factory.py +407 -0
  22. spatial_memory/migrations/__init__.py +40 -0
  23. spatial_memory/ports/repositories.py +52 -2
  24. spatial_memory/server.py +131 -189
  25. spatial_memory/services/export_import.py +61 -43
  26. spatial_memory/services/lifecycle.py +397 -122
  27. spatial_memory/services/memory.py +2 -2
  28. spatial_memory/services/spatial.py +129 -46
  29. {spatial_memory_mcp-1.5.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/METADATA +83 -3
  30. spatial_memory_mcp-1.6.0.dist-info/RECORD +54 -0
  31. spatial_memory_mcp-1.5.3.dist-info/RECORD +0 -44
  32. {spatial_memory_mcp-1.5.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/WHEEL +0 -0
  33. {spatial_memory_mcp-1.5.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/entry_points.txt +0 -0
  34. {spatial_memory_mcp-1.5.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/licenses/LICENSE +0 -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 = 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 = self.idempotency_table.count_rows()
235
+ deleted = 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