spatial-memory-mcp 1.0.3__py3-none-any.whl → 1.5.3__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/config.py +105 -0
- spatial_memory/core/__init__.py +26 -0
- spatial_memory/core/cache.py +317 -0
- spatial_memory/core/circuit_breaker.py +297 -0
- spatial_memory/core/database.py +167 -1
- spatial_memory/core/embeddings.py +92 -2
- spatial_memory/core/logging.py +194 -103
- spatial_memory/core/rate_limiter.py +309 -105
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/validation.py +319 -319
- spatial_memory/server.py +229 -30
- spatial_memory/services/memory.py +79 -2
- spatial_memory/tools/definitions.py +695 -671
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/METADATA +1 -1
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/RECORD +19 -16
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/WHEEL +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/entry_points.txt +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/licenses/LICENSE +0 -0
spatial_memory/server.py
CHANGED
|
@@ -13,16 +13,18 @@ import logging
|
|
|
13
13
|
import signal
|
|
14
14
|
import sys
|
|
15
15
|
import uuid
|
|
16
|
-
from dataclasses import asdict
|
|
17
16
|
from collections.abc import Callable
|
|
17
|
+
from dataclasses import asdict
|
|
18
18
|
from typing import TYPE_CHECKING, Any
|
|
19
19
|
|
|
20
20
|
from mcp.server import Server
|
|
21
21
|
from mcp.server.stdio import stdio_server
|
|
22
22
|
from mcp.types import TextContent, Tool
|
|
23
23
|
|
|
24
|
+
from spatial_memory import __version__
|
|
24
25
|
from spatial_memory.adapters.lancedb_repository import LanceDBMemoryRepository
|
|
25
26
|
from spatial_memory.config import ConfigurationError, get_settings, validate_startup
|
|
27
|
+
from spatial_memory.core.cache import ResponseCache
|
|
26
28
|
from spatial_memory.core.database import (
|
|
27
29
|
Database,
|
|
28
30
|
clear_connection_cache,
|
|
@@ -49,13 +51,17 @@ from spatial_memory.core.health import HealthChecker
|
|
|
49
51
|
from spatial_memory.core.logging import configure_logging
|
|
50
52
|
from spatial_memory.core.metrics import is_available as metrics_available
|
|
51
53
|
from spatial_memory.core.metrics import record_request
|
|
52
|
-
from spatial_memory.core.rate_limiter import RateLimiter
|
|
54
|
+
from spatial_memory.core.rate_limiter import AgentAwareRateLimiter, RateLimiter
|
|
55
|
+
from spatial_memory.core.tracing import (
|
|
56
|
+
RequestContext,
|
|
57
|
+
TimingContext,
|
|
58
|
+
request_context,
|
|
59
|
+
)
|
|
53
60
|
from spatial_memory.services.export_import import ExportImportConfig, ExportImportService
|
|
54
61
|
from spatial_memory.services.lifecycle import LifecycleConfig, LifecycleService
|
|
55
62
|
from spatial_memory.services.memory import MemoryService
|
|
56
63
|
from spatial_memory.services.spatial import SpatialConfig, SpatialService
|
|
57
64
|
from spatial_memory.services.utility import UtilityConfig, UtilityService
|
|
58
|
-
from spatial_memory import __version__
|
|
59
65
|
from spatial_memory.tools import TOOLS
|
|
60
66
|
|
|
61
67
|
if TYPE_CHECKING:
|
|
@@ -66,6 +72,33 @@ if TYPE_CHECKING:
|
|
|
66
72
|
|
|
67
73
|
logger = logging.getLogger(__name__)
|
|
68
74
|
|
|
75
|
+
# Tools that can be cached (read-only operations)
|
|
76
|
+
CACHEABLE_TOOLS = frozenset({"recall", "nearby", "hybrid_recall", "regions"})
|
|
77
|
+
|
|
78
|
+
# Tools that invalidate cache by namespace
|
|
79
|
+
NAMESPACE_INVALIDATING_TOOLS = frozenset({"remember", "forget", "forget_batch"})
|
|
80
|
+
|
|
81
|
+
# Tools that invalidate entire cache
|
|
82
|
+
FULL_INVALIDATING_TOOLS = frozenset({"decay", "reinforce", "consolidate"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _generate_cache_key(tool_name: str, arguments: dict[str, Any]) -> str:
|
|
86
|
+
"""Generate a cache key from tool name and arguments.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
tool_name: Name of the tool.
|
|
90
|
+
arguments: Tool arguments (excluding _agent_id).
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
A string cache key suitable for response caching.
|
|
94
|
+
"""
|
|
95
|
+
# Remove _agent_id from cache key computation (same query from different agents = same result)
|
|
96
|
+
cache_args = {k: v for k, v in sorted(arguments.items()) if k != "_agent_id"}
|
|
97
|
+
# Create a stable string representation
|
|
98
|
+
args_str = json.dumps(cache_args, sort_keys=True, default=str)
|
|
99
|
+
return f"{tool_name}:{hash(args_str)}"
|
|
100
|
+
|
|
101
|
+
|
|
69
102
|
# Error type to response name mapping for standardized error responses
|
|
70
103
|
ERROR_MAPPINGS: dict[type[Exception], str] = {
|
|
71
104
|
MemoryNotFoundError: "MemoryNotFound",
|
|
@@ -244,13 +277,35 @@ class SpatialMemoryServer:
|
|
|
244
277
|
self._embeddings = embeddings
|
|
245
278
|
|
|
246
279
|
# Rate limiting for resource protection
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
280
|
+
# Use per-agent rate limiter if enabled, otherwise fall back to simple rate limiter
|
|
281
|
+
self._per_agent_rate_limiting = self._settings.rate_limit_per_agent_enabled
|
|
282
|
+
self._agent_rate_limiter: AgentAwareRateLimiter | None = None
|
|
283
|
+
self._rate_limiter: RateLimiter | None = None
|
|
284
|
+
if self._per_agent_rate_limiting:
|
|
285
|
+
self._agent_rate_limiter = AgentAwareRateLimiter(
|
|
286
|
+
global_rate=self._settings.embedding_rate_limit,
|
|
287
|
+
per_agent_rate=self._settings.rate_limit_per_agent_rate,
|
|
288
|
+
max_agents=self._settings.rate_limit_max_tracked_agents,
|
|
289
|
+
)
|
|
290
|
+
else:
|
|
291
|
+
self._rate_limiter = RateLimiter(
|
|
292
|
+
rate=self._settings.embedding_rate_limit,
|
|
293
|
+
capacity=int(self._settings.embedding_rate_limit * 2)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Response cache for read-only operations
|
|
297
|
+
self._cache_enabled = self._settings.response_cache_enabled
|
|
298
|
+
self._cache: ResponseCache | None = None
|
|
299
|
+
self._regions_cache_ttl = 0.0
|
|
300
|
+
if self._cache_enabled:
|
|
301
|
+
self._cache = ResponseCache(
|
|
302
|
+
max_size=self._settings.response_cache_max_size,
|
|
303
|
+
default_ttl=self._settings.response_cache_default_ttl,
|
|
304
|
+
)
|
|
305
|
+
self._regions_cache_ttl = self._settings.response_cache_regions_ttl
|
|
251
306
|
|
|
252
307
|
# Tool handler registry for dispatch pattern
|
|
253
|
-
self._tool_handlers: dict[str, Callable[[dict], dict]] = {
|
|
308
|
+
self._tool_handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
|
|
254
309
|
"remember": self._handle_remember,
|
|
255
310
|
"remember_batch": self._handle_remember_batch,
|
|
256
311
|
"recall": self._handle_recall,
|
|
@@ -281,8 +336,12 @@ class SpatialMemoryServer:
|
|
|
281
336
|
else:
|
|
282
337
|
logger.info("Prometheus metrics disabled (prometheus_client not installed)")
|
|
283
338
|
|
|
284
|
-
# Create MCP server
|
|
285
|
-
self._server = Server(
|
|
339
|
+
# Create MCP server with behavioral instructions
|
|
340
|
+
self._server = Server(
|
|
341
|
+
name="spatial-memory",
|
|
342
|
+
version=__version__,
|
|
343
|
+
instructions=self._get_server_instructions(),
|
|
344
|
+
)
|
|
286
345
|
self._setup_handlers()
|
|
287
346
|
|
|
288
347
|
def _setup_handlers(self) -> None:
|
|
@@ -295,27 +354,113 @@ class SpatialMemoryServer:
|
|
|
295
354
|
|
|
296
355
|
@self._server.call_tool()
|
|
297
356
|
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
298
|
-
"""Handle tool calls."""
|
|
357
|
+
"""Handle tool calls with tracing, caching, and rate limiting."""
|
|
358
|
+
# Extract _agent_id for tracing and rate limiting (don't pass to handler)
|
|
359
|
+
agent_id = arguments.pop("_agent_id", None)
|
|
360
|
+
|
|
299
361
|
# Apply rate limiting
|
|
300
|
-
if
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
362
|
+
if self._per_agent_rate_limiting and self._agent_rate_limiter is not None:
|
|
363
|
+
if not self._agent_rate_limiter.wait(agent_id=agent_id, timeout=30.0):
|
|
364
|
+
return [TextContent(
|
|
365
|
+
type="text",
|
|
366
|
+
text=json.dumps({
|
|
367
|
+
"error": "RateLimitExceeded",
|
|
368
|
+
"message": "Too many requests. Please wait and try again.",
|
|
369
|
+
"isError": True,
|
|
370
|
+
})
|
|
371
|
+
)]
|
|
372
|
+
elif self._rate_limiter is not None:
|
|
373
|
+
if not self._rate_limiter.wait(timeout=30.0):
|
|
374
|
+
return [TextContent(
|
|
375
|
+
type="text",
|
|
376
|
+
text=json.dumps({
|
|
377
|
+
"error": "RateLimitExceeded",
|
|
378
|
+
"message": "Too many requests. Please wait and try again.",
|
|
379
|
+
"isError": True,
|
|
380
|
+
})
|
|
381
|
+
)]
|
|
382
|
+
|
|
383
|
+
# Use request context for tracing
|
|
384
|
+
namespace = arguments.get("namespace")
|
|
385
|
+
with request_context(tool_name=name, agent_id=agent_id, namespace=namespace) as ctx:
|
|
386
|
+
timing = TimingContext()
|
|
387
|
+
cache_hit = False
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
# Check cache for cacheable tools
|
|
391
|
+
if self._cache_enabled and self._cache is not None and name in CACHEABLE_TOOLS:
|
|
392
|
+
cache_key = _generate_cache_key(name, arguments)
|
|
393
|
+
with timing.measure("cache_lookup"):
|
|
394
|
+
cached_result = self._cache.get(cache_key)
|
|
395
|
+
if cached_result is not None:
|
|
396
|
+
cache_hit = True
|
|
397
|
+
result = cached_result
|
|
398
|
+
else:
|
|
399
|
+
with timing.measure("handler"):
|
|
400
|
+
result = self._handle_tool(name, arguments)
|
|
401
|
+
# Cache the result with appropriate TTL
|
|
402
|
+
ttl = self._regions_cache_ttl if name == "regions" else None
|
|
403
|
+
self._cache.set(cache_key, result, ttl=ttl)
|
|
404
|
+
else:
|
|
405
|
+
with timing.measure("handler"):
|
|
406
|
+
result = self._handle_tool(name, arguments)
|
|
407
|
+
|
|
408
|
+
# Invalidate cache on mutations
|
|
409
|
+
if self._cache_enabled and self._cache is not None:
|
|
410
|
+
self._invalidate_cache_for_tool(name, arguments)
|
|
411
|
+
|
|
412
|
+
# Add _meta to response if enabled
|
|
413
|
+
if self._settings.include_request_meta:
|
|
414
|
+
result["_meta"] = self._build_meta(ctx, timing, cache_hit)
|
|
415
|
+
|
|
416
|
+
return [TextContent(type="text", text=json.dumps(result, default=str))]
|
|
417
|
+
except tuple(ERROR_MAPPINGS.keys()) as e:
|
|
418
|
+
return _create_error_response(e)
|
|
419
|
+
except Exception as e:
|
|
420
|
+
error_id = str(uuid.uuid4())[:8]
|
|
421
|
+
logger.error(f"Unexpected error [{error_id}] in {name}: {e}", exc_info=True)
|
|
422
|
+
return _create_error_response(e, error_id)
|
|
423
|
+
|
|
424
|
+
def _build_meta(
|
|
425
|
+
self,
|
|
426
|
+
ctx: RequestContext,
|
|
427
|
+
timing: TimingContext,
|
|
428
|
+
cache_hit: bool,
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""Build the _meta object for response.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
ctx: The request context.
|
|
434
|
+
timing: The timing context.
|
|
435
|
+
cache_hit: Whether this was a cache hit.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Dictionary with request metadata.
|
|
439
|
+
"""
|
|
440
|
+
meta: dict[str, Any] = {
|
|
441
|
+
"request_id": ctx.request_id,
|
|
442
|
+
"agent_id": ctx.agent_id,
|
|
443
|
+
"cache_hit": cache_hit,
|
|
444
|
+
}
|
|
445
|
+
if self._settings.include_timing_breakdown:
|
|
446
|
+
meta["timing_ms"] = timing.summary()
|
|
447
|
+
return meta
|
|
448
|
+
|
|
449
|
+
def _invalidate_cache_for_tool(self, name: str, arguments: dict[str, Any]) -> None:
|
|
450
|
+
"""Invalidate cache entries based on the tool that was called.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
name: Tool name.
|
|
454
|
+
arguments: Tool arguments.
|
|
455
|
+
"""
|
|
456
|
+
if self._cache is None:
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
if name in FULL_INVALIDATING_TOOLS:
|
|
460
|
+
self._cache.invalidate_all()
|
|
461
|
+
elif name in NAMESPACE_INVALIDATING_TOOLS:
|
|
462
|
+
namespace = arguments.get("namespace", "default")
|
|
463
|
+
self._cache.invalidate_namespace(namespace)
|
|
319
464
|
|
|
320
465
|
# =========================================================================
|
|
321
466
|
# Tool Handler Methods
|
|
@@ -906,6 +1051,60 @@ class SpatialMemoryServer:
|
|
|
906
1051
|
raise ValidationError(f"Unknown tool: {name}")
|
|
907
1052
|
return handler(arguments)
|
|
908
1053
|
|
|
1054
|
+
@staticmethod
|
|
1055
|
+
def _get_server_instructions() -> str:
|
|
1056
|
+
"""Return behavioral instructions for Claude when using spatial-memory.
|
|
1057
|
+
|
|
1058
|
+
These instructions are automatically injected into Claude's system prompt
|
|
1059
|
+
when the MCP server connects, enabling proactive memory management without
|
|
1060
|
+
requiring user configuration.
|
|
1061
|
+
"""
|
|
1062
|
+
return '''## Spatial Memory System
|
|
1063
|
+
|
|
1064
|
+
You have access to a persistent semantic memory system. Use it proactively to build cumulative knowledge across sessions.
|
|
1065
|
+
|
|
1066
|
+
### Session Start
|
|
1067
|
+
At conversation start, call `recall` with the user's apparent task/context to load relevant memories. Present insights naturally:
|
|
1068
|
+
- Good: "Based on previous work, you decided to use PostgreSQL because..."
|
|
1069
|
+
- Bad: "The database returned: [{id: '...', content: '...'}]"
|
|
1070
|
+
|
|
1071
|
+
### Recognizing Memory-Worthy Moments
|
|
1072
|
+
After these events, ask briefly "Save this? y/n" (minimal friction):
|
|
1073
|
+
- **Decisions**: "Let's use X...", "We decided...", "The approach is..."
|
|
1074
|
+
- **Solutions**: "The fix was...", "It failed because...", "The error was..."
|
|
1075
|
+
- **Patterns**: "This pattern works...", "The trick is...", "Always do X when..."
|
|
1076
|
+
- **Discoveries**: "I found that...", "Important:...", "TIL..."
|
|
1077
|
+
|
|
1078
|
+
Do NOT ask for trivial information. Only prompt for insights that would help future sessions.
|
|
1079
|
+
|
|
1080
|
+
### Saving Memories
|
|
1081
|
+
When user confirms, save with:
|
|
1082
|
+
- **Detailed content**: Include full context, reasoning, and specifics. Future agents need complete information.
|
|
1083
|
+
- **Contextual namespace**: Use project name, or categories like "decisions", "errors", "patterns"
|
|
1084
|
+
- **Descriptive tags**: Technologies, concepts, error types involved
|
|
1085
|
+
- **High importance (0.8-1.0)**: For decisions and critical fixes
|
|
1086
|
+
- **Medium importance (0.5-0.7)**: For patterns and learnings
|
|
1087
|
+
|
|
1088
|
+
### Synthesizing Answers
|
|
1089
|
+
When using `recall` or `hybrid_recall`, present results as natural knowledge:
|
|
1090
|
+
- Integrate memories into your response conversationally
|
|
1091
|
+
- Reference prior decisions: "You previously decided X because Y"
|
|
1092
|
+
- Don't expose raw JSON or tool mechanics to the user
|
|
1093
|
+
|
|
1094
|
+
### Auto-Extract for Long Sessions
|
|
1095
|
+
For significant problem-solving conversations (debugging sessions, architecture discussions), offer:
|
|
1096
|
+
"This session had good learnings. Extract key memories? y/n"
|
|
1097
|
+
Then use `extract` to automatically capture important information.
|
|
1098
|
+
|
|
1099
|
+
### Tool Selection Guide
|
|
1100
|
+
- `remember`: Store a single memory with full context
|
|
1101
|
+
- `recall`: Semantic search for relevant memories
|
|
1102
|
+
- `hybrid_recall`: Combined keyword + semantic search (better for specific terms)
|
|
1103
|
+
- `extract`: Auto-extract memories from conversation text
|
|
1104
|
+
- `nearby`: Find memories similar to a known memory
|
|
1105
|
+
- `regions`: Discover topic clusters in memory space
|
|
1106
|
+
- `journey`: Navigate conceptual path between two memories'''
|
|
1107
|
+
|
|
909
1108
|
async def run(self) -> None:
|
|
910
1109
|
"""Run the MCP server using stdio transport."""
|
|
911
1110
|
async with stdio_server() as (read_stream, write_stream):
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import logging
|
|
15
15
|
from dataclasses import dataclass, field
|
|
16
|
-
from typing import TYPE_CHECKING, Any
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
17
17
|
|
|
18
18
|
from spatial_memory.core.errors import MemoryNotFoundError, ValidationError
|
|
19
19
|
from spatial_memory.core.models import Memory, MemorySource
|
|
@@ -22,6 +22,7 @@ from spatial_memory.core.validation import validate_content, validate_importance
|
|
|
22
22
|
logger = logging.getLogger(__name__)
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
|
+
from spatial_memory.core.database import IdempotencyRecord
|
|
25
26
|
from spatial_memory.core.models import MemoryResult
|
|
26
27
|
from spatial_memory.ports.repositories import (
|
|
27
28
|
EmbeddingServiceProtocol,
|
|
@@ -29,6 +30,39 @@ if TYPE_CHECKING:
|
|
|
29
30
|
)
|
|
30
31
|
|
|
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
|
+
|
|
32
66
|
@dataclass
|
|
33
67
|
class RememberResult:
|
|
34
68
|
"""Result of storing a memory."""
|
|
@@ -36,6 +70,7 @@ class RememberResult:
|
|
|
36
70
|
id: str
|
|
37
71
|
content: str
|
|
38
72
|
namespace: str
|
|
73
|
+
deduplicated: bool = False
|
|
39
74
|
|
|
40
75
|
|
|
41
76
|
@dataclass
|
|
@@ -80,15 +115,18 @@ class MemoryService:
|
|
|
80
115
|
self,
|
|
81
116
|
repository: MemoryRepositoryProtocol,
|
|
82
117
|
embeddings: EmbeddingServiceProtocol,
|
|
118
|
+
idempotency_provider: IdempotencyProviderProtocol | None = None,
|
|
83
119
|
) -> None:
|
|
84
120
|
"""Initialize the memory service.
|
|
85
121
|
|
|
86
122
|
Args:
|
|
87
123
|
repository: Repository for memory storage.
|
|
88
124
|
embeddings: Service for generating embeddings.
|
|
125
|
+
idempotency_provider: Optional provider for idempotency key support.
|
|
89
126
|
"""
|
|
90
127
|
self._repo = repository
|
|
91
128
|
self._embeddings = embeddings
|
|
129
|
+
self._idempotency = idempotency_provider
|
|
92
130
|
|
|
93
131
|
# Use centralized validation functions
|
|
94
132
|
_validate_content = staticmethod(validate_content)
|
|
@@ -101,6 +139,7 @@ class MemoryService:
|
|
|
101
139
|
tags: list[str] | None = None,
|
|
102
140
|
importance: float = 0.5,
|
|
103
141
|
metadata: dict[str, Any] | None = None,
|
|
142
|
+
idempotency_key: str | None = None,
|
|
104
143
|
) -> RememberResult:
|
|
105
144
|
"""Store a new memory.
|
|
106
145
|
|
|
@@ -110,13 +149,40 @@ class MemoryService:
|
|
|
110
149
|
tags: Optional list of tags.
|
|
111
150
|
importance: Importance score (0-1).
|
|
112
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.
|
|
113
155
|
|
|
114
156
|
Returns:
|
|
115
|
-
RememberResult with the new memory's ID.
|
|
157
|
+
RememberResult with the new memory's ID. If idempotency_key was
|
|
158
|
+
provided and matched an existing request, deduplicated=True.
|
|
116
159
|
|
|
117
160
|
Raises:
|
|
118
161
|
ValidationError: If input validation fails.
|
|
119
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
|
+
|
|
120
186
|
# Validate inputs
|
|
121
187
|
self._validate_content(content)
|
|
122
188
|
self._validate_importance(importance)
|
|
@@ -138,10 +204,21 @@ class MemoryService:
|
|
|
138
204
|
# Store in repository
|
|
139
205
|
memory_id = self._repo.add(memory, vector)
|
|
140
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
|
+
|
|
141
217
|
return RememberResult(
|
|
142
218
|
id=memory_id,
|
|
143
219
|
content=content,
|
|
144
220
|
namespace=namespace,
|
|
221
|
+
deduplicated=False,
|
|
145
222
|
)
|
|
146
223
|
|
|
147
224
|
def remember_batch(
|