spatial-memory-mcp 1.0.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.
- spatial_memory/__init__.py +97 -97
- spatial_memory/__main__.py +241 -2
- spatial_memory/adapters/lancedb_repository.py +74 -5
- spatial_memory/config.py +115 -2
- spatial_memory/core/__init__.py +35 -0
- spatial_memory/core/cache.py +317 -0
- spatial_memory/core/circuit_breaker.py +297 -0
- spatial_memory/core/connection_pool.py +41 -3
- spatial_memory/core/consolidation_strategies.py +402 -0
- spatial_memory/core/database.py +791 -769
- 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 +156 -19
- spatial_memory/core/errors.py +75 -3
- spatial_memory/core/filesystem.py +178 -0
- spatial_memory/core/logging.py +194 -103
- spatial_memory/core/models.py +4 -0
- spatial_memory/core/rate_limiter.py +326 -105
- spatial_memory/core/response_types.py +497 -0
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/validation.py +403 -319
- spatial_memory/factory.py +407 -0
- spatial_memory/migrations/__init__.py +40 -0
- spatial_memory/ports/repositories.py +52 -2
- spatial_memory/server.py +329 -188
- spatial_memory/services/export_import.py +61 -43
- spatial_memory/services/lifecycle.py +397 -122
- spatial_memory/services/memory.py +81 -4
- spatial_memory/services/spatial.py +129 -46
- spatial_memory/tools/definitions.py +695 -671
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/METADATA +83 -3
- spatial_memory_mcp-1.6.0.dist-info/RECORD +54 -0
- spatial_memory_mcp-1.0.3.dist-info/RECORD +0 -41
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/WHEEL +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/entry_points.txt +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,105 +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
|
|
56
|
-
"""
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
tokens: Number of tokens to
|
|
60
|
-
|
|
61
|
-
Returns:
|
|
62
|
-
True if tokens
|
|
63
|
-
"""
|
|
64
|
-
with self._lock:
|
|
65
|
-
self._refill()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
tokens
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|