memcp-server 0.1.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.
- memcp/__init__.py +3 -0
- memcp/__main__.py +38 -0
- memcp/auth.py +156 -0
- memcp/backend/__init__.py +5 -0
- memcp/backend/base.py +115 -0
- memcp/backend/in_memory.py +261 -0
- memcp/backend/mem0.py +298 -0
- memcp/config.py +54 -0
- memcp/logging.py +106 -0
- memcp/server.py +99 -0
- memcp/tools.py +577 -0
- memcp/types.py +149 -0
- memcp_server-0.1.0.dist-info/METADATA +187 -0
- memcp_server-0.1.0.dist-info/RECORD +17 -0
- memcp_server-0.1.0.dist-info/WHEEL +4 -0
- memcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- memcp_server-0.1.0.dist-info/licenses/LICENSE +663 -0
memcp/__init__.py
ADDED
memcp/__main__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""python -m memcp entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from memcp.config import Config
|
|
12
|
+
from memcp.logging import setup_logging
|
|
13
|
+
from memcp.server import create_app
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
try:
|
|
20
|
+
config = Config() # type: ignore[call-arg]
|
|
21
|
+
except ValidationError as e:
|
|
22
|
+
print(f"Configuration error:\n{e}", file=sys.stderr)
|
|
23
|
+
raise SystemExit(1) from None
|
|
24
|
+
setup_logging(level=config.log_level, fmt=config.log_format)
|
|
25
|
+
|
|
26
|
+
if not config.memcp_auth_tokens:
|
|
27
|
+
logger.warning("MEMCP_AUTH_TOKENS is unset — the MCP endpoint is UNAUTHENTICATED.")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
app, _backend = create_app(config)
|
|
31
|
+
except ValueError as e:
|
|
32
|
+
logger.critical("Configuration error: %s", e)
|
|
33
|
+
raise SystemExit(1) from None
|
|
34
|
+
uvicorn.run(app, host=config.host, port=config.port, log_config=None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
memcp/auth.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Authentication — tenant resolution, context propagation, ASGI middleware.
|
|
2
|
+
|
|
3
|
+
Tenant identity flows: Bearer token → Resolver → ContextVar → tool handlers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
from memcp.types import canonical_error
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Tenant context (per-request via contextvars)
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
_tenant_var: ContextVar[str] = ContextVar("tenant_user_id")
|
|
23
|
+
|
|
24
|
+
_DEFAULT_USER = "default_user"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_tenant() -> str:
|
|
28
|
+
"""Read the current request's user_id. Falls back to default in dev mode."""
|
|
29
|
+
return _tenant_var.get(_DEFAULT_USER)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_tenant(user_id: str) -> Any:
|
|
33
|
+
"""Set the current request's user_id. Returns a reset token."""
|
|
34
|
+
return _tenant_var.set(user_id)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reset_tenant(token: Any) -> None:
|
|
38
|
+
"""Reset the contextvar to its previous value."""
|
|
39
|
+
_tenant_var.reset(token)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Resolver protocol
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@runtime_checkable
|
|
48
|
+
class Resolver(Protocol):
|
|
49
|
+
async def resolve(self, token: str) -> str | None:
|
|
50
|
+
"""Map a bearer token to a user_id. Returns None for invalid tokens (never raises)."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class StaticResolver:
|
|
55
|
+
"""Resolves tokens from a static dict (parsed from env var)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, mapping: dict[str, str]):
|
|
58
|
+
self._mapping = mapping
|
|
59
|
+
|
|
60
|
+
async def resolve(self, token: str) -> str | None:
|
|
61
|
+
# Iterate all tokens without early return — constant-time to prevent timing oracle
|
|
62
|
+
matched: str | None = None
|
|
63
|
+
for known_token, user_id in self._mapping.items():
|
|
64
|
+
if hmac.compare_digest(token.encode(), known_token.encode()):
|
|
65
|
+
matched = user_id
|
|
66
|
+
return matched
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_env(cls, raw: str) -> StaticResolver:
|
|
70
|
+
"""Parse 'token1:user1,token2:user2' format."""
|
|
71
|
+
mapping: dict[str, str] = {}
|
|
72
|
+
for pair in raw.split(","):
|
|
73
|
+
pair = pair.strip()
|
|
74
|
+
if not pair:
|
|
75
|
+
continue
|
|
76
|
+
if ":" not in pair:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Invalid token mapping: {pair!r}. Expected format: token:user_id"
|
|
79
|
+
)
|
|
80
|
+
token, user_id = pair.split(":", 1)
|
|
81
|
+
token, user_id = token.strip(), user_id.strip()
|
|
82
|
+
if not token or not user_id:
|
|
83
|
+
raise ValueError(f"Empty token or user_id in mapping: {pair!r}")
|
|
84
|
+
mapping[token] = user_id
|
|
85
|
+
if not mapping:
|
|
86
|
+
raise ValueError("MEMCP_AUTH_TOKENS is set but contains no valid mappings")
|
|
87
|
+
return cls(mapping)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# ASGI middleware
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BearerGate:
|
|
96
|
+
"""ASGI middleware that resolves bearer tokens to tenant identity.
|
|
97
|
+
|
|
98
|
+
Raw ASGI (not BaseHTTPMiddleware) to avoid buffering MCP streaming.
|
|
99
|
+
Non-HTTP scopes (lifespan) pass through.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, app: Any, resolver: Resolver | None):
|
|
103
|
+
self.app = app
|
|
104
|
+
self.resolver = resolver
|
|
105
|
+
|
|
106
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
|
107
|
+
if scope["type"] != "http":
|
|
108
|
+
await self.app(scope, receive, send)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if not self.resolver:
|
|
112
|
+
# Dev mode: no auth, default user
|
|
113
|
+
token = set_tenant(_DEFAULT_USER)
|
|
114
|
+
try:
|
|
115
|
+
await self.app(scope, receive, send)
|
|
116
|
+
finally:
|
|
117
|
+
reset_tenant(token)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
headers = dict(scope.get("headers") or [])
|
|
121
|
+
provided = headers.get(b"authorization", b"").decode("utf-8", errors="replace")
|
|
122
|
+
|
|
123
|
+
if not provided.startswith("Bearer "):
|
|
124
|
+
logger.warning("Rejected request: missing Bearer prefix (path=%s)", scope.get("path"))
|
|
125
|
+
await self._send_401(send)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
bearer_token = provided[7:]
|
|
129
|
+
user_id = await self.resolver.resolve(bearer_token)
|
|
130
|
+
|
|
131
|
+
if user_id is None:
|
|
132
|
+
logger.warning("Rejected request: invalid token (path=%s)", scope.get("path"))
|
|
133
|
+
await self._send_401(send)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
token = set_tenant(user_id)
|
|
137
|
+
try:
|
|
138
|
+
await self.app(scope, receive, send)
|
|
139
|
+
finally:
|
|
140
|
+
reset_tenant(token)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
async def _send_401(send: Any) -> None:
|
|
144
|
+
err = canonical_error("unauthorized", "Invalid or missing token")
|
|
145
|
+
body = json.dumps(err).encode()
|
|
146
|
+
await send(
|
|
147
|
+
{
|
|
148
|
+
"type": "http.response.start",
|
|
149
|
+
"status": 401,
|
|
150
|
+
"headers": [
|
|
151
|
+
(b"content-type", b"application/json"),
|
|
152
|
+
(b"www-authenticate", b"Bearer"),
|
|
153
|
+
],
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
await send({"type": "http.response.body", "body": body, "more_body": False})
|
memcp/backend/base.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""MemoryBackend ABC — @experimental, will change in v0.2 when second backend is added."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from memcp.types import (
|
|
9
|
+
AddResult,
|
|
10
|
+
EntitiesResult,
|
|
11
|
+
HealthStatus,
|
|
12
|
+
HistoryEntry,
|
|
13
|
+
ListResult,
|
|
14
|
+
Memory,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MemoryBackend(ABC):
|
|
19
|
+
"""Abstract base class for memory storage backends.
|
|
20
|
+
|
|
21
|
+
@experimental — validated against mem0 and in-memory adapters.
|
|
22
|
+
Will be refined when Cognee adapter is added in v0.2.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# --- required (universal tools) ---
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
async def add(
|
|
29
|
+
self,
|
|
30
|
+
user_id: str,
|
|
31
|
+
content: str,
|
|
32
|
+
*,
|
|
33
|
+
scope: dict[str, Any] | None = None,
|
|
34
|
+
metadata: dict[str, Any] | None = None,
|
|
35
|
+
infer: bool = True,
|
|
36
|
+
) -> AddResult | list[AddResult]: ...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def search(
|
|
40
|
+
self,
|
|
41
|
+
user_id: str,
|
|
42
|
+
query: str,
|
|
43
|
+
*,
|
|
44
|
+
scope: dict[str, Any] | None = None,
|
|
45
|
+
limit: int = 10,
|
|
46
|
+
threshold: float = 0.0,
|
|
47
|
+
) -> list[Memory]: ...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def delete(self, user_id: str, memory_id: str) -> bool:
|
|
51
|
+
"""Raises MemoryAPIError(404) if not found or not owned by user_id."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def delete_all(self, user_id: str, scope: dict[str, Any]) -> int | None:
|
|
56
|
+
"""Returns count of deleted memories, or None if backend doesn't report counts."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
async def health(self) -> HealthStatus: ...
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def capabilities(self) -> set[str]: ...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def scope_keys(self) -> list[str]: ...
|
|
67
|
+
|
|
68
|
+
# --- optional (declared in capabilities()) ---
|
|
69
|
+
|
|
70
|
+
async def get(self, user_id: str, memory_id: str) -> Memory | None:
|
|
71
|
+
"""Returns None if not found or not owned by user_id."""
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
async def update(
|
|
75
|
+
self,
|
|
76
|
+
user_id: str,
|
|
77
|
+
memory_id: str,
|
|
78
|
+
content: str,
|
|
79
|
+
*,
|
|
80
|
+
metadata: dict[str, Any] | None = None,
|
|
81
|
+
) -> Memory:
|
|
82
|
+
"""Raises MemoryAPIError(404) if not found or not owned by user_id."""
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
async def list_memories(
|
|
86
|
+
self,
|
|
87
|
+
user_id: str,
|
|
88
|
+
*,
|
|
89
|
+
scope: dict[str, Any] | None = None,
|
|
90
|
+
limit: int = 100,
|
|
91
|
+
cursor: str | None = None,
|
|
92
|
+
) -> ListResult:
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
async def history(self, user_id: str, memory_id: str) -> list[HistoryEntry]:
|
|
96
|
+
"""Raises MemoryAPIError(404) if not found or not owned by user_id.
|
|
97
|
+
|
|
98
|
+
Note: action strings are backend-specific (e.g. "created"/"updated" vs
|
|
99
|
+
"add"/"update"). Normalize in v0.2 when the Protocol stabilizes.
|
|
100
|
+
"""
|
|
101
|
+
raise NotImplementedError
|
|
102
|
+
|
|
103
|
+
async def entities(
|
|
104
|
+
self,
|
|
105
|
+
user_id: str,
|
|
106
|
+
*,
|
|
107
|
+
scope: dict[str, Any] | None = None,
|
|
108
|
+
limit: int = 100,
|
|
109
|
+
) -> EntitiesResult:
|
|
110
|
+
raise NotImplementedError
|
|
111
|
+
|
|
112
|
+
# --- lifecycle ---
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None: # noqa: B027
|
|
115
|
+
"""Clean up resources. Called on shutdown."""
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""In-memory backend adapter — for conformance tests, dev mode, and demos.
|
|
2
|
+
|
|
3
|
+
Stores memories in plain dicts. No persistence, no extraction, no vector search.
|
|
4
|
+
Search uses substring matching on content as a trivial approximation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from memcp.types import (
|
|
14
|
+
AddResult,
|
|
15
|
+
EntitiesResult,
|
|
16
|
+
HealthStatus,
|
|
17
|
+
HistoryEntry,
|
|
18
|
+
ListResult,
|
|
19
|
+
Memory,
|
|
20
|
+
MemoryAPIError,
|
|
21
|
+
paginate,
|
|
22
|
+
reject_nested_filters,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .base import MemoryBackend
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InMemoryBackend(MemoryBackend):
|
|
29
|
+
"""Trivial in-memory implementation of the MemoryBackend protocol."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
# {memory_id: {user_id, content, scope, metadata, created_at, updated_at}}
|
|
33
|
+
self._store: dict[str, dict[str, Any]] = {}
|
|
34
|
+
# {memory_id: [HistoryEntry, ...]}
|
|
35
|
+
self._history: dict[str, list[dict[str, Any]]] = {}
|
|
36
|
+
|
|
37
|
+
# --- required ---
|
|
38
|
+
|
|
39
|
+
async def add(
|
|
40
|
+
self,
|
|
41
|
+
user_id: str,
|
|
42
|
+
content: str,
|
|
43
|
+
*,
|
|
44
|
+
scope: dict[str, Any] | None = None,
|
|
45
|
+
metadata: dict[str, Any] | None = None,
|
|
46
|
+
infer: bool = True,
|
|
47
|
+
) -> list[AddResult]:
|
|
48
|
+
if scope:
|
|
49
|
+
reject_nested_filters(scope)
|
|
50
|
+
memory_id = str(uuid.uuid4())
|
|
51
|
+
now = datetime.now(UTC).isoformat()
|
|
52
|
+
self._store[memory_id] = {
|
|
53
|
+
"user_id": user_id,
|
|
54
|
+
"content": content,
|
|
55
|
+
"scope": scope or {},
|
|
56
|
+
"metadata": metadata or {},
|
|
57
|
+
"created_at": now,
|
|
58
|
+
"updated_at": None,
|
|
59
|
+
}
|
|
60
|
+
self._history[memory_id] = [
|
|
61
|
+
{
|
|
62
|
+
"action": "created",
|
|
63
|
+
"timestamp": now,
|
|
64
|
+
"content_before": None,
|
|
65
|
+
"content_after": content,
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
return [AddResult(id=memory_id, status="ready", created_at=now)]
|
|
69
|
+
|
|
70
|
+
async def search(
|
|
71
|
+
self,
|
|
72
|
+
user_id: str,
|
|
73
|
+
query: str,
|
|
74
|
+
*,
|
|
75
|
+
scope: dict[str, Any] | None = None,
|
|
76
|
+
limit: int = 10,
|
|
77
|
+
threshold: float = 0.0,
|
|
78
|
+
) -> list[Memory]:
|
|
79
|
+
if scope:
|
|
80
|
+
reject_nested_filters(scope)
|
|
81
|
+
results = []
|
|
82
|
+
query_lower = query.lower()
|
|
83
|
+
for mid, entry in self._store.items():
|
|
84
|
+
if entry["user_id"] != user_id:
|
|
85
|
+
continue
|
|
86
|
+
if scope:
|
|
87
|
+
entry_scope = entry.get("scope", {})
|
|
88
|
+
if not all(entry_scope.get(k) == v for k, v in scope.items()):
|
|
89
|
+
continue
|
|
90
|
+
content_lower = entry["content"].lower()
|
|
91
|
+
# Trivial relevance: count query word matches
|
|
92
|
+
words = query_lower.split()
|
|
93
|
+
matches = sum(1 for w in words if w in content_lower)
|
|
94
|
+
if matches == 0 and query_lower not in content_lower:
|
|
95
|
+
continue
|
|
96
|
+
score = matches / max(len(words), 1)
|
|
97
|
+
results.append((score, mid, entry))
|
|
98
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
99
|
+
return [
|
|
100
|
+
Memory(
|
|
101
|
+
id=mid,
|
|
102
|
+
content=entry["content"],
|
|
103
|
+
score=score,
|
|
104
|
+
scope=entry.get("scope", {}),
|
|
105
|
+
metadata=entry.get("metadata", {}),
|
|
106
|
+
created_at=entry["created_at"],
|
|
107
|
+
updated_at=entry.get("updated_at"),
|
|
108
|
+
)
|
|
109
|
+
for score, mid, entry in results[:limit]
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
async def delete(self, user_id: str, memory_id: str) -> bool:
|
|
113
|
+
entry = self._store.get(memory_id)
|
|
114
|
+
if entry is None:
|
|
115
|
+
raise MemoryAPIError(404, "Not found")
|
|
116
|
+
if entry["user_id"] != user_id:
|
|
117
|
+
raise MemoryAPIError(404, "Not found")
|
|
118
|
+
del self._store[memory_id]
|
|
119
|
+
self._history.pop(memory_id, None)
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
async def delete_all(self, user_id: str, scope: dict[str, Any]) -> int:
|
|
123
|
+
reject_nested_filters(scope)
|
|
124
|
+
to_delete = []
|
|
125
|
+
for mid, entry in self._store.items():
|
|
126
|
+
if entry["user_id"] != user_id:
|
|
127
|
+
continue
|
|
128
|
+
entry_scope = entry.get("scope", {})
|
|
129
|
+
if all(entry_scope.get(k) == v for k, v in scope.items()):
|
|
130
|
+
to_delete.append(mid)
|
|
131
|
+
for mid in to_delete:
|
|
132
|
+
del self._store[mid]
|
|
133
|
+
self._history.pop(mid, None)
|
|
134
|
+
return len(to_delete)
|
|
135
|
+
|
|
136
|
+
async def health(self) -> HealthStatus:
|
|
137
|
+
return HealthStatus(status="healthy", backend="in_memory", latency_ms=0.0)
|
|
138
|
+
|
|
139
|
+
def capabilities(self) -> set[str]:
|
|
140
|
+
return {
|
|
141
|
+
"get_memory",
|
|
142
|
+
"update_memory",
|
|
143
|
+
"list_memories",
|
|
144
|
+
"memory_history",
|
|
145
|
+
"memory_entities",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
def scope_keys(self) -> list[str]:
|
|
149
|
+
return ["agent_id", "run_id"]
|
|
150
|
+
|
|
151
|
+
# --- optional ---
|
|
152
|
+
|
|
153
|
+
async def get(self, user_id: str, memory_id: str) -> Memory | None:
|
|
154
|
+
entry = self._store.get(memory_id)
|
|
155
|
+
if entry is None or entry["user_id"] != user_id:
|
|
156
|
+
return None
|
|
157
|
+
return Memory(
|
|
158
|
+
id=memory_id,
|
|
159
|
+
content=entry["content"],
|
|
160
|
+
scope=entry.get("scope", {}),
|
|
161
|
+
metadata=entry.get("metadata", {}),
|
|
162
|
+
created_at=entry["created_at"],
|
|
163
|
+
updated_at=entry.get("updated_at"),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def update(
|
|
167
|
+
self,
|
|
168
|
+
user_id: str,
|
|
169
|
+
memory_id: str,
|
|
170
|
+
content: str,
|
|
171
|
+
*,
|
|
172
|
+
metadata: dict[str, Any] | None = None,
|
|
173
|
+
) -> Memory:
|
|
174
|
+
entry = self._store.get(memory_id)
|
|
175
|
+
if entry is None or entry["user_id"] != user_id:
|
|
176
|
+
raise MemoryAPIError(404, "Not found")
|
|
177
|
+
now = datetime.now(UTC).isoformat()
|
|
178
|
+
old_content = entry["content"]
|
|
179
|
+
entry["content"] = content
|
|
180
|
+
entry["updated_at"] = now
|
|
181
|
+
if metadata is not None:
|
|
182
|
+
entry["metadata"] = metadata
|
|
183
|
+
if memory_id in self._history:
|
|
184
|
+
self._history[memory_id].append(
|
|
185
|
+
{
|
|
186
|
+
"action": "updated",
|
|
187
|
+
"timestamp": now,
|
|
188
|
+
"content_before": old_content,
|
|
189
|
+
"content_after": content,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
return Memory(
|
|
193
|
+
id=memory_id,
|
|
194
|
+
content=content,
|
|
195
|
+
scope=entry.get("scope", {}),
|
|
196
|
+
metadata=entry.get("metadata", {}),
|
|
197
|
+
created_at=entry["created_at"],
|
|
198
|
+
updated_at=now,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def list_memories(
|
|
202
|
+
self,
|
|
203
|
+
user_id: str,
|
|
204
|
+
*,
|
|
205
|
+
scope: dict[str, Any] | None = None,
|
|
206
|
+
limit: int = 100,
|
|
207
|
+
cursor: str | None = None,
|
|
208
|
+
) -> ListResult:
|
|
209
|
+
memories = []
|
|
210
|
+
for mid, entry in self._store.items():
|
|
211
|
+
if entry["user_id"] != user_id:
|
|
212
|
+
continue
|
|
213
|
+
if scope:
|
|
214
|
+
entry_scope = entry.get("scope", {})
|
|
215
|
+
if not all(entry_scope.get(k) == v for k, v in scope.items()):
|
|
216
|
+
continue
|
|
217
|
+
memories.append(
|
|
218
|
+
Memory(
|
|
219
|
+
id=mid,
|
|
220
|
+
content=entry["content"],
|
|
221
|
+
scope=entry.get("scope", {}),
|
|
222
|
+
metadata=entry.get("metadata", {}),
|
|
223
|
+
created_at=entry["created_at"],
|
|
224
|
+
updated_at=entry.get("updated_at"),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
return paginate(memories, cursor, limit)
|
|
228
|
+
|
|
229
|
+
async def history(self, user_id: str, memory_id: str) -> list[HistoryEntry]:
|
|
230
|
+
entry = self._store.get(memory_id)
|
|
231
|
+
if entry is None or entry["user_id"] != user_id:
|
|
232
|
+
raise MemoryAPIError(404, "Not found")
|
|
233
|
+
raw = self._history.get(memory_id, [])
|
|
234
|
+
return [
|
|
235
|
+
HistoryEntry(
|
|
236
|
+
action=h["action"],
|
|
237
|
+
timestamp=h["timestamp"],
|
|
238
|
+
content_before=h.get("content_before"),
|
|
239
|
+
content_after=h.get("content_after"),
|
|
240
|
+
)
|
|
241
|
+
for h in raw
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
async def entities(
|
|
245
|
+
self,
|
|
246
|
+
user_id: str,
|
|
247
|
+
*,
|
|
248
|
+
scope: dict[str, Any] | None = None,
|
|
249
|
+
limit: int = 100,
|
|
250
|
+
) -> EntitiesResult:
|
|
251
|
+
count = sum(1 for e in self._store.values() if e["user_id"] == user_id)
|
|
252
|
+
if count == 0:
|
|
253
|
+
return EntitiesResult(entities=[], relationships=[])
|
|
254
|
+
return EntitiesResult(
|
|
255
|
+
entities=[{"id": user_id, "type": "user", "total_memories": count}],
|
|
256
|
+
relationships=[],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def close(self) -> None:
|
|
260
|
+
self._store.clear()
|
|
261
|
+
self._history.clear()
|