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.

Files changed (54) hide show
  1. spatial_memory/__init__.py +97 -0
  2. spatial_memory/__main__.py +270 -0
  3. spatial_memory/adapters/__init__.py +7 -0
  4. spatial_memory/adapters/lancedb_repository.py +878 -0
  5. spatial_memory/config.py +728 -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 +402 -0
  11. spatial_memory/core/database.py +3069 -0
  12. spatial_memory/core/db_idempotency.py +242 -0
  13. spatial_memory/core/db_indexes.py +575 -0
  14. spatial_memory/core/db_migrations.py +584 -0
  15. spatial_memory/core/db_search.py +509 -0
  16. spatial_memory/core/db_versioning.py +177 -0
  17. spatial_memory/core/embeddings.py +557 -0
  18. spatial_memory/core/errors.py +317 -0
  19. spatial_memory/core/file_security.py +702 -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 +432 -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 +628 -0
  28. spatial_memory/core/rate_limiter.py +326 -0
  29. spatial_memory/core/response_types.py +497 -0
  30. spatial_memory/core/security.py +588 -0
  31. spatial_memory/core/spatial_ops.py +426 -0
  32. spatial_memory/core/tracing.py +300 -0
  33. spatial_memory/core/utils.py +110 -0
  34. spatial_memory/core/validation.py +403 -0
  35. spatial_memory/factory.py +407 -0
  36. spatial_memory/migrations/__init__.py +40 -0
  37. spatial_memory/ports/__init__.py +11 -0
  38. spatial_memory/ports/repositories.py +631 -0
  39. spatial_memory/py.typed +0 -0
  40. spatial_memory/server.py +1141 -0
  41. spatial_memory/services/__init__.py +70 -0
  42. spatial_memory/services/export_import.py +1023 -0
  43. spatial_memory/services/lifecycle.py +1120 -0
  44. spatial_memory/services/memory.py +412 -0
  45. spatial_memory/services/spatial.py +1147 -0
  46. spatial_memory/services/utility.py +409 -0
  47. spatial_memory/tools/__init__.py +5 -0
  48. spatial_memory/tools/definitions.py +695 -0
  49. spatial_memory/verify.py +140 -0
  50. spatial_memory_mcp-1.6.1.dist-info/METADATA +499 -0
  51. spatial_memory_mcp-1.6.1.dist-info/RECORD +54 -0
  52. spatial_memory_mcp-1.6.1.dist-info/WHEEL +4 -0
  53. spatial_memory_mcp-1.6.1.dist-info/entry_points.txt +2 -0
  54. spatial_memory_mcp-1.6.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,326 @@
1
+ """Token bucket rate limiter for API calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ import time
8
+ from typing import Any
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class RateLimiter:
14
+ """Token bucket rate limiter.
15
+
16
+ Limits the rate of operations using a token bucket algorithm:
17
+ - Bucket holds up to `capacity` tokens
18
+ - Tokens are added at `rate` per second
19
+ - Each operation consumes tokens
20
+
21
+ Example:
22
+ limiter = RateLimiter(rate=10.0, capacity=20) # 10 ops/sec, burst of 20
23
+ if limiter.acquire():
24
+ # perform operation
25
+ else:
26
+ # rate limited, try again later
27
+
28
+ # Or blocking wait:
29
+ limiter.wait() # waits until token available
30
+ # perform operation
31
+ """
32
+
33
+ def __init__(self, rate: float, capacity: int | None = None) -> None:
34
+ """Initialize the rate limiter.
35
+
36
+ Args:
37
+ rate: Tokens added per second.
38
+ capacity: Maximum tokens in bucket (default: rate * 2).
39
+ """
40
+ if rate <= 0:
41
+ raise ValueError("rate must be positive")
42
+ self.rate = rate
43
+ self.capacity = capacity or int(rate * 2)
44
+ self._tokens = float(self.capacity)
45
+ self._last_refill = time.monotonic()
46
+ self._lock = threading.Lock()
47
+
48
+ def _refill(self) -> None:
49
+ """Refill tokens based on elapsed time."""
50
+ now = time.monotonic()
51
+ elapsed = now - self._last_refill
52
+ self._tokens = min(self.capacity, self._tokens + elapsed * self.rate)
53
+ self._last_refill = now
54
+
55
+ def can_acquire(self, tokens: int = 1) -> bool:
56
+ """Check if tokens could be acquired without consuming them.
57
+
58
+ Args:
59
+ tokens: Number of tokens to check.
60
+
61
+ Returns:
62
+ True if tokens are available, False otherwise.
63
+ """
64
+ with self._lock:
65
+ self._refill()
66
+ return self._tokens >= tokens
67
+
68
+ def acquire(self, tokens: int = 1) -> bool:
69
+ """Try to acquire tokens without blocking.
70
+
71
+ Args:
72
+ tokens: Number of tokens to acquire.
73
+
74
+ Returns:
75
+ True if tokens were acquired, False if rate limited.
76
+ """
77
+ with self._lock:
78
+ self._refill()
79
+ if self._tokens >= tokens:
80
+ self._tokens -= tokens
81
+ return True
82
+ return False
83
+
84
+ def wait(self, tokens: int = 1, timeout: float | None = None) -> bool:
85
+ """Wait until tokens are available.
86
+
87
+ Args:
88
+ tokens: Number of tokens to acquire.
89
+ timeout: Maximum time to wait (None = no limit).
90
+
91
+ Returns:
92
+ True if tokens were acquired, False if timeout.
93
+ """
94
+ start = time.monotonic()
95
+ while True:
96
+ if self.acquire(tokens):
97
+ return True
98
+
99
+ # Check timeout
100
+ if timeout is not None:
101
+ elapsed = time.monotonic() - start
102
+ if elapsed >= timeout:
103
+ return False
104
+
105
+ # Sleep for estimated time to get a token
106
+ with self._lock:
107
+ wait_time = (tokens - self._tokens) / self.rate
108
+ time.sleep(min(wait_time, 0.1)) # Cap at 100ms to check timeout
109
+
110
+ def stats(self) -> dict[str, Any]:
111
+ """Get rate limiter statistics."""
112
+ with self._lock:
113
+ self._refill()
114
+ return {
115
+ "tokens_available": self._tokens,
116
+ "capacity": self.capacity,
117
+ "rate": self.rate,
118
+ }
119
+
120
+
121
+ class AgentAwareRateLimiter:
122
+ """Rate limiter with per-agent and global limits.
123
+
124
+ Provides two-tier rate limiting:
125
+ - Global limit: Shared across all agents/requests
126
+ - Per-agent limit: Individual limit for each agent ID
127
+
128
+ A request must pass BOTH limits to proceed. This prevents:
129
+ - Any single agent from consuming all available capacity
130
+ - Overall system overload from too many concurrent agents
131
+
132
+ Example:
133
+ limiter = AgentAwareRateLimiter(
134
+ global_rate=100.0, # 100 total ops/sec
135
+ per_agent_rate=25.0, # Each agent limited to 25 ops/sec
136
+ max_agents=20, # Track up to 20 agents
137
+ )
138
+
139
+ if limiter.acquire(agent_id="agent-123"):
140
+ # perform operation
141
+ else:
142
+ # rate limited
143
+
144
+ Thread Safety:
145
+ This class is thread-safe. The global limiter and per-agent dict
146
+ are protected by appropriate locks.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ global_rate: float = 100.0,
152
+ per_agent_rate: float = 25.0,
153
+ max_agents: int = 20,
154
+ ) -> None:
155
+ """Initialize the agent-aware rate limiter.
156
+
157
+ Args:
158
+ global_rate: Tokens per second for global limit.
159
+ per_agent_rate: Tokens per second for each agent.
160
+ max_agents: Maximum number of agent limiters to track.
161
+ When exceeded, oldest (by last access) agents are evicted.
162
+ """
163
+ if global_rate <= 0:
164
+ raise ValueError("global_rate must be positive")
165
+ if per_agent_rate <= 0:
166
+ raise ValueError("per_agent_rate must be positive")
167
+ if max_agents < 1:
168
+ raise ValueError("max_agents must be at least 1")
169
+
170
+ self._global = RateLimiter(rate=global_rate)
171
+ self._per_agent: dict[str, RateLimiter] = {}
172
+ self._per_agent_rate = per_agent_rate
173
+ self._max_agents = max_agents
174
+ self._lock = threading.Lock()
175
+ # Track last access time for LRU eviction
176
+ self._last_access: dict[str, float] = {}
177
+
178
+ def _get_agent_limiter(self, agent_id: str) -> RateLimiter:
179
+ """Get or create a rate limiter for an agent.
180
+
181
+ Args:
182
+ agent_id: The agent identifier.
183
+
184
+ Returns:
185
+ RateLimiter for the agent.
186
+ """
187
+ with self._lock:
188
+ now = time.monotonic()
189
+
190
+ if agent_id not in self._per_agent:
191
+ # Evict oldest agent if at capacity
192
+ if len(self._per_agent) >= self._max_agents:
193
+ self._evict_oldest_agent()
194
+
195
+ self._per_agent[agent_id] = RateLimiter(rate=self._per_agent_rate)
196
+
197
+ self._last_access[agent_id] = now
198
+ return self._per_agent[agent_id]
199
+
200
+ def _evict_oldest_agent(self) -> None:
201
+ """Evict the least recently accessed agent limiter.
202
+
203
+ Must be called with self._lock held.
204
+ """
205
+ if not self._last_access:
206
+ return
207
+
208
+ oldest_agent = min(self._last_access, key=self._last_access.get) # type: ignore[arg-type]
209
+ del self._per_agent[oldest_agent]
210
+ del self._last_access[oldest_agent]
211
+ logger.debug(f"Evicted rate limiter for agent: {oldest_agent}")
212
+
213
+ def acquire(self, agent_id: str | None = None, tokens: int = 1) -> bool:
214
+ """Try to acquire tokens without blocking.
215
+
216
+ Must pass BOTH global AND per-agent limits (if agent_id provided).
217
+ Tokens are only consumed if both limits pass.
218
+
219
+ Args:
220
+ agent_id: Optional agent identifier. If None, only global limit applies.
221
+ tokens: Number of tokens to acquire.
222
+
223
+ Returns:
224
+ True if tokens were acquired, False if rate limited.
225
+ """
226
+ # If no agent_id, only global limit applies
227
+ if agent_id is None:
228
+ return self._global.acquire(tokens)
229
+
230
+ # Check both limits first without consuming
231
+ agent_limiter = self._get_agent_limiter(agent_id)
232
+
233
+ if not self._global.can_acquire(tokens):
234
+ return False
235
+
236
+ if not agent_limiter.can_acquire(tokens):
237
+ return False
238
+
239
+ # Both limits pass, now actually consume tokens from both
240
+ # Note: Small race window here, but acceptable for rate limiting
241
+ self._global.acquire(tokens)
242
+ agent_limiter.acquire(tokens)
243
+
244
+ return True
245
+
246
+ def wait(
247
+ self,
248
+ agent_id: str | None = None,
249
+ tokens: int = 1,
250
+ timeout: float | None = None,
251
+ ) -> bool:
252
+ """Wait until tokens are available from both limiters.
253
+
254
+ Args:
255
+ agent_id: Optional agent identifier.
256
+ tokens: Number of tokens to acquire.
257
+ timeout: Maximum time to wait (None = no limit).
258
+
259
+ Returns:
260
+ True if tokens were acquired, False if timeout.
261
+ """
262
+ start = time.monotonic()
263
+
264
+ while True:
265
+ if self.acquire(agent_id, tokens):
266
+ return True
267
+
268
+ # Check timeout
269
+ if timeout is not None:
270
+ elapsed = time.monotonic() - start
271
+ if elapsed >= timeout:
272
+ return False
273
+
274
+ # Sleep briefly before retry
275
+ time.sleep(0.01) # 10ms
276
+
277
+ def stats(self, agent_id: str | None = None) -> dict[str, Any]:
278
+ """Get rate limiter statistics.
279
+
280
+ Args:
281
+ agent_id: Optional agent ID to include agent-specific stats.
282
+
283
+ Returns:
284
+ Dictionary with global and optionally per-agent statistics.
285
+ """
286
+ result: dict[str, Any] = {
287
+ "global": self._global.stats(),
288
+ "active_agents": len(self._per_agent),
289
+ "max_agents": self._max_agents,
290
+ "per_agent_rate": self._per_agent_rate,
291
+ }
292
+
293
+ if agent_id and agent_id in self._per_agent:
294
+ result["agent"] = self._per_agent[agent_id].stats()
295
+
296
+ return result
297
+
298
+ def reset_agent(self, agent_id: str) -> bool:
299
+ """Reset rate limiter for a specific agent.
300
+
301
+ Useful for testing or when an agent reconnects.
302
+
303
+ Args:
304
+ agent_id: The agent identifier.
305
+
306
+ Returns:
307
+ True if agent was found and reset, False if not found.
308
+ """
309
+ with self._lock:
310
+ if agent_id in self._per_agent:
311
+ del self._per_agent[agent_id]
312
+ del self._last_access[agent_id]
313
+ return True
314
+ return False
315
+
316
+ def clear_all_agents(self) -> int:
317
+ """Clear all per-agent rate limiters.
318
+
319
+ Returns:
320
+ Number of agents cleared.
321
+ """
322
+ with self._lock:
323
+ count = len(self._per_agent)
324
+ self._per_agent.clear()
325
+ self._last_access.clear()
326
+ return count