memtru 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.
- memtru/__init__.py +7 -0
- memtru/adapters/__init__.py +105 -0
- memtru/adapters/base.py +80 -0
- memtru/adapters/http.py +239 -0
- memtru/adapters/in_memory.py +444 -0
- memtru/adapters/lifecycle.py +122 -0
- memtru/adapters/mem0.py +331 -0
- memtru/cli/__init__.py +0 -0
- memtru/cli/app.py +574 -0
- memtru/cloud/__init__.py +146 -0
- memtru/core/__init__.py +0 -0
- memtru/core/config.py +221 -0
- memtru/core/containment.py +172 -0
- memtru/core/engine.py +414 -0
- memtru/core/ledger.py +36 -0
- memtru/core/models.py +120 -0
- memtru/errors.py +65 -0
- memtru/reporting/__init__.py +45 -0
- memtru/reporting/compare.py +133 -0
- memtru/reporting/export_schema.py +31 -0
- memtru/reporting/html.py +41 -0
- memtru/reporting/schema.py +199 -0
- memtru/reporting/templates/report.html.j2 +231 -0
- memtru/reporting/terminal.py +165 -0
- memtru/scenarios/__init__.py +62 -0
- memtru/scenarios/afterlife.py +360 -0
- memtru/scenarios/base.py +390 -0
- memtru/scenarios/isolation.py +222 -0
- memtru/scenarios/lifecycle.py +332 -0
- memtru/scenarios/poisoning.py +89 -0
- memtru/security/__init__.py +0 -0
- memtru/security/redaction.py +87 -0
- memtru/security/sanitize.py +56 -0
- memtru/security/urls.py +101 -0
- memtru/testing/__init__.py +1 -0
- memtru/testing/reference_server.py +195 -0
- memtru-0.1.0.dist-info/METADATA +143 -0
- memtru-0.1.0.dist-info/RECORD +41 -0
- memtru-0.1.0.dist-info/WHEEL +4 -0
- memtru-0.1.0.dist-info/entry_points.txt +2 -0
- memtru-0.1.0.dist-info/licenses/LICENSE +53 -0
memtru/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Adapter registry: builds the adapter described by the configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from memtru.adapters.base import AgentAdapter, MemoryAdapter
|
|
11
|
+
from memtru.adapters.in_memory import InMemoryAdapter
|
|
12
|
+
from memtru.core.config import (
|
|
13
|
+
Config,
|
|
14
|
+
CustomAdapterConfig,
|
|
15
|
+
HttpAdapterConfig,
|
|
16
|
+
InMemoryAdapterConfig,
|
|
17
|
+
Mem0AdapterConfig,
|
|
18
|
+
)
|
|
19
|
+
from memtru.errors import ConfigError
|
|
20
|
+
|
|
21
|
+
__all__ = ["AgentAdapter", "MemoryAdapter", "build_adapter", "describe_builtin_adapters"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AdapterInfo:
|
|
26
|
+
type: str
|
|
27
|
+
summary: str
|
|
28
|
+
verification: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
BUILTIN_ADAPTERS = [
|
|
32
|
+
AdapterInfo(
|
|
33
|
+
"in_memory",
|
|
34
|
+
"Built-in reference store with tenant, user and session scoping. Supports fault injection.",
|
|
35
|
+
"Verified by the Memtru test suite.",
|
|
36
|
+
),
|
|
37
|
+
AdapterInfo(
|
|
38
|
+
"http",
|
|
39
|
+
"Any service implementing the Memtru REST contract (docs/adapters.md).",
|
|
40
|
+
"Verified against the reference server in examples/provider-integration.",
|
|
41
|
+
),
|
|
42
|
+
AdapterInfo(
|
|
43
|
+
"mem0",
|
|
44
|
+
"Mem0 OSS 2.2.1; explicit SDK factory, synthetic search-filter testing.",
|
|
45
|
+
"SDK contract tests and local Qdrant integration; hosted verification is separate.",
|
|
46
|
+
),
|
|
47
|
+
AdapterInfo(
|
|
48
|
+
"custom",
|
|
49
|
+
"Your own Python class implementing memtru.adapters.base.MemoryAdapter.",
|
|
50
|
+
"Verified by you: Memtru only loads it.",
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def describe_builtin_adapters() -> list[AdapterInfo]:
|
|
56
|
+
return list(BUILTIN_ADAPTERS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_custom(config: CustomAdapterConfig) -> MemoryAdapter:
|
|
60
|
+
module_name, _, attr = config.entrypoint.partition(":")
|
|
61
|
+
# Like pytest, resolve user modules relative to the directory the CLI runs in.
|
|
62
|
+
if os.getcwd() not in sys.path:
|
|
63
|
+
sys.path.insert(0, os.getcwd())
|
|
64
|
+
try:
|
|
65
|
+
module = importlib.import_module(module_name)
|
|
66
|
+
except ImportError as err:
|
|
67
|
+
raise ConfigError(
|
|
68
|
+
f"Cannot import adapter module {module_name!r}: {err}",
|
|
69
|
+
hint="Make sure the module is importable from the current directory or PYTHONPATH.",
|
|
70
|
+
) from None
|
|
71
|
+
factory = getattr(module, attr, None)
|
|
72
|
+
if factory is None:
|
|
73
|
+
raise ConfigError(f"{module_name!r} has no attribute {attr!r}.")
|
|
74
|
+
adapter = factory(**config.options)
|
|
75
|
+
if not isinstance(adapter, MemoryAdapter):
|
|
76
|
+
raise ConfigError(f"{config.entrypoint} did not produce a MemoryAdapter instance.")
|
|
77
|
+
return adapter
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_adapter(config: Config) -> MemoryAdapter:
|
|
81
|
+
adapter_config = config.adapter
|
|
82
|
+
if isinstance(adapter_config, InMemoryAdapterConfig):
|
|
83
|
+
return InMemoryAdapter(adapter_config.faults, adapter_config.index_delay_ms)
|
|
84
|
+
if isinstance(adapter_config, HttpAdapterConfig):
|
|
85
|
+
from memtru.adapters.http import HttpAdapter
|
|
86
|
+
|
|
87
|
+
return HttpAdapter(adapter_config, environment=config.execution.environment)
|
|
88
|
+
if isinstance(adapter_config, CustomAdapterConfig):
|
|
89
|
+
return _load_custom(adapter_config)
|
|
90
|
+
if isinstance(adapter_config, Mem0AdapterConfig):
|
|
91
|
+
from memtru.adapters.mem0 import Mem0Adapter
|
|
92
|
+
|
|
93
|
+
# The optional SDK otherwise enables its own telemetry at import time.
|
|
94
|
+
os.environ["MEM0_TELEMETRY"] = "false"
|
|
95
|
+
module_name, _, attr = adapter_config.factory.partition(":")
|
|
96
|
+
if os.getcwd() not in sys.path:
|
|
97
|
+
sys.path.insert(0, os.getcwd())
|
|
98
|
+
try:
|
|
99
|
+
factory = getattr(importlib.import_module(module_name), attr)
|
|
100
|
+
except (ImportError, AttributeError):
|
|
101
|
+
raise ConfigError(
|
|
102
|
+
"Cannot load Mem0 factory. Check adapter.factory and install memtru[mem0]."
|
|
103
|
+
) from None
|
|
104
|
+
return Mem0Adapter(factory())
|
|
105
|
+
raise ConfigError(f"Unknown adapter type {adapter_config!r}") # pragma: no cover
|
memtru/adapters/base.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The adapter contract every memory provider integration implements.
|
|
2
|
+
|
|
3
|
+
Adapters translate Memtru operations into calls on a real memory system.
|
|
4
|
+
They must scope every operation to the given :class:`MemoryContext`; the
|
|
5
|
+
scenarios then verify whether that scoping actually holds.
|
|
6
|
+
|
|
7
|
+
Optional operations raise :class:`UnsupportedCapabilityError` by default.
|
|
8
|
+
Declare what is supported in :meth:`MemoryAdapter.capabilities` so scenarios
|
|
9
|
+
can be skipped, never silently passed, when a prerequisite is missing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from typing import ClassVar
|
|
16
|
+
|
|
17
|
+
from memtru.core.models import Capability, MemoryContext, MemoryRecord, MemoryScope
|
|
18
|
+
from memtru.errors import UnsupportedCapabilityError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MemoryAdapter(ABC):
|
|
22
|
+
name: ClassVar[str]
|
|
23
|
+
version: ClassVar[str] = "1.0"
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def capabilities(self) -> frozenset[Capability]:
|
|
27
|
+
"""Capabilities this adapter supports *and* boundaries it claims to enforce."""
|
|
28
|
+
|
|
29
|
+
def setup(self, namespace: str) -> None: # noqa: B027 - optional hook
|
|
30
|
+
"""Prepare an isolated test namespace. Called once per run."""
|
|
31
|
+
|
|
32
|
+
def teardown(self, namespace: str) -> None: # noqa: B027 - optional hook
|
|
33
|
+
"""Release the namespace created by :meth:`setup`. Must not touch other data."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def add(
|
|
37
|
+
self,
|
|
38
|
+
ctx: MemoryContext,
|
|
39
|
+
content: str,
|
|
40
|
+
*,
|
|
41
|
+
metadata: dict[str, str] | None = None,
|
|
42
|
+
scope: MemoryScope = "user",
|
|
43
|
+
) -> MemoryRecord: ...
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def search(self, ctx: MemoryContext, query: str, *, limit: int = 10) -> list[MemoryRecord]: ...
|
|
47
|
+
|
|
48
|
+
def get(self, ctx: MemoryContext, memory_id: str) -> MemoryRecord | None:
|
|
49
|
+
raise UnsupportedCapabilityError(f"{self.name} does not support read by id")
|
|
50
|
+
|
|
51
|
+
def update(
|
|
52
|
+
self,
|
|
53
|
+
ctx: MemoryContext,
|
|
54
|
+
memory_id: str,
|
|
55
|
+
content: str,
|
|
56
|
+
*,
|
|
57
|
+
metadata: dict[str, str] | None = None,
|
|
58
|
+
) -> MemoryRecord:
|
|
59
|
+
raise UnsupportedCapabilityError(f"{self.name} does not support updates")
|
|
60
|
+
|
|
61
|
+
def delete(self, ctx: MemoryContext, memory_id: str) -> None:
|
|
62
|
+
raise UnsupportedCapabilityError(f"{self.name} does not support deletion")
|
|
63
|
+
|
|
64
|
+
def describe(self) -> dict[str, str]:
|
|
65
|
+
"""Safe, non-secret metadata included in reports."""
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
def close(self) -> None: # noqa: B027 - optional hook
|
|
69
|
+
"""Release connections."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AgentAdapter(MemoryAdapter):
|
|
73
|
+
"""An adapter that can also run the agent on top of the memory store.
|
|
74
|
+
|
|
75
|
+
Required for agent-mode scenarios (MT-POISON-007). ``respond`` must run the
|
|
76
|
+
real agent with the memory visible to ``ctx`` and return its final output.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def respond(self, ctx: MemoryContext, prompt: str) -> str: ...
|
memtru/adapters/http.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Adapter for any memory service that implements the Memtru REST contract.
|
|
2
|
+
|
|
3
|
+
Contract (see docs/adapters.md for the full description)::
|
|
4
|
+
|
|
5
|
+
GET /capabilities -> {"capabilities": ["MEMORY_WRITE", ...]}
|
|
6
|
+
POST /memories body {content, metadata, scope} -> record
|
|
7
|
+
GET /memories/search?q=..&limit= -> {"results": [record, ...]}
|
|
8
|
+
GET /memories/{id} -> record | 404
|
|
9
|
+
PATCH /memories/{id} body {content, metadata} -> record
|
|
10
|
+
DELETE /memories/{id} -> 204
|
|
11
|
+
|
|
12
|
+
Every request carries the context as headers: X-Memtru-Tenant,
|
|
13
|
+
X-Memtru-User, X-Memtru-Session (optional), X-Memtru-Namespace
|
|
14
|
+
and X-Memtru-Run. A service under test maps those onto its own auth model;
|
|
15
|
+
see docs/adapters.md for why that mapping is itself part of what is tested.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, ClassVar
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
28
|
+
|
|
29
|
+
from memtru.adapters.base import MemoryAdapter
|
|
30
|
+
from memtru.core.config import HttpAdapterConfig
|
|
31
|
+
from memtru.core.models import Capability, MemoryContext, MemoryRecord, MemoryScope
|
|
32
|
+
from memtru.errors import (
|
|
33
|
+
AdapterError,
|
|
34
|
+
ConfigError,
|
|
35
|
+
TransientAdapterError,
|
|
36
|
+
UnsafeTargetError,
|
|
37
|
+
)
|
|
38
|
+
from memtru.security.urls import UrlPolicy, check_url
|
|
39
|
+
|
|
40
|
+
MAX_RESPONSE_BYTES = 1_000_000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _RecordModel(BaseModel):
|
|
44
|
+
model_config = ConfigDict(extra="ignore")
|
|
45
|
+
|
|
46
|
+
id: str = Field(min_length=1, max_length=256)
|
|
47
|
+
content: str = Field(max_length=100_000)
|
|
48
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
49
|
+
tenant_id: str | None = None
|
|
50
|
+
user_id: str | None = None
|
|
51
|
+
session_id: str | None = None
|
|
52
|
+
active: bool | None = None
|
|
53
|
+
|
|
54
|
+
def to_record(self) -> MemoryRecord:
|
|
55
|
+
return MemoryRecord(**self.model_dump())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HttpAdapter(MemoryAdapter):
|
|
59
|
+
name: ClassVar[str] = "http"
|
|
60
|
+
version: ClassVar[str] = "1.0"
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: HttpAdapterConfig, *, environment: str) -> None:
|
|
63
|
+
policy = UrlPolicy(
|
|
64
|
+
allow_hosts=tuple(config.allow_hosts),
|
|
65
|
+
allow_loopback=config.allow_loopback,
|
|
66
|
+
allow_private_network=config.allow_private_network,
|
|
67
|
+
)
|
|
68
|
+
checked = check_url(config.base_url, policy)
|
|
69
|
+
if not checked.is_loopback and not config.authorization_confirmed:
|
|
70
|
+
raise UnsafeTargetError(
|
|
71
|
+
f"Remote target {checked.host} requires explicit authorization.",
|
|
72
|
+
hint=(
|
|
73
|
+
"Set adapter.authorization_confirmed: true to confirm you are authorized "
|
|
74
|
+
"to write and delete synthetic records on this system."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
if not checked.is_loopback and environment == "local":
|
|
78
|
+
raise ConfigError("execution.environment is 'local' but the target is remote.")
|
|
79
|
+
self._config = config
|
|
80
|
+
self._base = checked.url
|
|
81
|
+
self._host = checked.host
|
|
82
|
+
headers = {"User-Agent": "memtru", "Accept": "application/json"}
|
|
83
|
+
if config.token_env:
|
|
84
|
+
token = os.environ.get(config.token_env)
|
|
85
|
+
if not token:
|
|
86
|
+
raise ConfigError(
|
|
87
|
+
f"Environment variable {config.token_env} is not set.",
|
|
88
|
+
hint="adapter.token_env names the variable that holds the provider token.",
|
|
89
|
+
)
|
|
90
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
91
|
+
self._client = httpx.Client(
|
|
92
|
+
base_url=self._base,
|
|
93
|
+
headers=headers,
|
|
94
|
+
timeout=config.timeout_seconds,
|
|
95
|
+
follow_redirects=False,
|
|
96
|
+
)
|
|
97
|
+
self._caps: frozenset[Capability] | None = None
|
|
98
|
+
|
|
99
|
+
# -- transport ---------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _ctx_headers(ctx: MemoryContext) -> dict[str, str]:
|
|
103
|
+
headers = {
|
|
104
|
+
"X-Memtru-Tenant": ctx.tenant_id,
|
|
105
|
+
"X-Memtru-User": ctx.user_id,
|
|
106
|
+
"X-Memtru-Namespace": ctx.namespace,
|
|
107
|
+
"X-Memtru-Run": ctx.run_id,
|
|
108
|
+
}
|
|
109
|
+
if ctx.session_id:
|
|
110
|
+
headers["X-Memtru-Session"] = ctx.session_id
|
|
111
|
+
return headers
|
|
112
|
+
|
|
113
|
+
def _request(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path: str,
|
|
117
|
+
ctx: MemoryContext | None = None,
|
|
118
|
+
*,
|
|
119
|
+
params: dict[str, Any] | None = None,
|
|
120
|
+
payload: Any = None,
|
|
121
|
+
allow_404: bool = False,
|
|
122
|
+
) -> Any:
|
|
123
|
+
headers = self._ctx_headers(ctx) if ctx else {}
|
|
124
|
+
attempt = 0
|
|
125
|
+
while True:
|
|
126
|
+
attempt += 1
|
|
127
|
+
try:
|
|
128
|
+
with self._client.stream(
|
|
129
|
+
method, path, params=params, json=payload, headers=headers
|
|
130
|
+
) as response:
|
|
131
|
+
body = b""
|
|
132
|
+
for chunk in response.iter_bytes():
|
|
133
|
+
body += chunk
|
|
134
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
135
|
+
raise AdapterError(
|
|
136
|
+
f"{method} {path}: response exceeded {MAX_RESPONSE_BYTES} bytes"
|
|
137
|
+
)
|
|
138
|
+
status = response.status_code
|
|
139
|
+
content_type = response.headers.get("content-type", "")
|
|
140
|
+
except httpx.TimeoutException:
|
|
141
|
+
error: AdapterError = TransientAdapterError(f"{method} {path}: request timed out")
|
|
142
|
+
except httpx.TransportError as err:
|
|
143
|
+
error = TransientAdapterError(f"{method} {path}: {type(err).__name__}")
|
|
144
|
+
else:
|
|
145
|
+
if status in {429, 502, 503, 504}:
|
|
146
|
+
error = TransientAdapterError(f"{method} {path}: HTTP {status}")
|
|
147
|
+
elif 300 <= status < 400:
|
|
148
|
+
raise AdapterError(
|
|
149
|
+
f"{method} {path}: unexpected redirect (HTTP {status}); redirects are "
|
|
150
|
+
"not followed"
|
|
151
|
+
)
|
|
152
|
+
elif status == 404 and allow_404:
|
|
153
|
+
return None
|
|
154
|
+
elif status >= 400:
|
|
155
|
+
raise AdapterError(f"{method} {path}: HTTP {status}")
|
|
156
|
+
elif status == 204 or not body:
|
|
157
|
+
return None
|
|
158
|
+
elif "json" not in content_type:
|
|
159
|
+
raise AdapterError(f"{method} {path}: expected JSON, got {content_type!r}")
|
|
160
|
+
else:
|
|
161
|
+
try:
|
|
162
|
+
return json.loads(body)
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise AdapterError(f"{method} {path}: invalid JSON") from None
|
|
165
|
+
if attempt > self._config.max_retries:
|
|
166
|
+
raise error
|
|
167
|
+
time.sleep(min(0.25 * 2 ** (attempt - 1), 2.0))
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _parse(data: Any, what: str) -> MemoryRecord:
|
|
171
|
+
try:
|
|
172
|
+
return _RecordModel.model_validate(data).to_record()
|
|
173
|
+
except ValidationError:
|
|
174
|
+
raise AdapterError(f"{what}: response is not a valid memory record") from None
|
|
175
|
+
|
|
176
|
+
# -- contract ----------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def capabilities(self) -> frozenset[Capability]:
|
|
179
|
+
if self._caps is None:
|
|
180
|
+
names = self._config.capabilities
|
|
181
|
+
if names is None:
|
|
182
|
+
data = self._request("GET", "/capabilities")
|
|
183
|
+
if not isinstance(data, dict) or not isinstance(data.get("capabilities"), list):
|
|
184
|
+
raise AdapterError("GET /capabilities: expected {'capabilities': [...]}")
|
|
185
|
+
names = [str(n) for n in data["capabilities"]]
|
|
186
|
+
known = {c.value for c in Capability}
|
|
187
|
+
self._caps = frozenset(Capability(n) for n in names if n in known)
|
|
188
|
+
return self._caps
|
|
189
|
+
|
|
190
|
+
def describe(self) -> dict[str, str]:
|
|
191
|
+
return {"host": self._host}
|
|
192
|
+
|
|
193
|
+
def add(
|
|
194
|
+
self,
|
|
195
|
+
ctx: MemoryContext,
|
|
196
|
+
content: str,
|
|
197
|
+
*,
|
|
198
|
+
metadata: dict[str, str] | None = None,
|
|
199
|
+
scope: MemoryScope = "user",
|
|
200
|
+
) -> MemoryRecord:
|
|
201
|
+
data = self._request(
|
|
202
|
+
"POST",
|
|
203
|
+
"/memories",
|
|
204
|
+
ctx,
|
|
205
|
+
payload={"content": content, "metadata": metadata or {}, "scope": scope},
|
|
206
|
+
)
|
|
207
|
+
return self._parse(data, "POST /memories")
|
|
208
|
+
|
|
209
|
+
def search(self, ctx: MemoryContext, query: str, *, limit: int = 10) -> list[MemoryRecord]:
|
|
210
|
+
data = self._request("GET", "/memories/search", ctx, params={"q": query, "limit": limit})
|
|
211
|
+
if not isinstance(data, dict) or not isinstance(data.get("results"), list):
|
|
212
|
+
raise AdapterError("GET /memories/search: expected {'results': [...]}")
|
|
213
|
+
return [self._parse(item, "search result") for item in data["results"][:limit]]
|
|
214
|
+
|
|
215
|
+
def get(self, ctx: MemoryContext, memory_id: str) -> MemoryRecord | None:
|
|
216
|
+
data = self._request("GET", f"/memories/{quote(memory_id, safe='')}", ctx, allow_404=True)
|
|
217
|
+
return None if data is None else self._parse(data, "GET /memories/{id}")
|
|
218
|
+
|
|
219
|
+
def update(
|
|
220
|
+
self,
|
|
221
|
+
ctx: MemoryContext,
|
|
222
|
+
memory_id: str,
|
|
223
|
+
content: str,
|
|
224
|
+
*,
|
|
225
|
+
metadata: dict[str, str] | None = None,
|
|
226
|
+
) -> MemoryRecord:
|
|
227
|
+
data = self._request(
|
|
228
|
+
"PATCH",
|
|
229
|
+
f"/memories/{quote(memory_id, safe='')}",
|
|
230
|
+
ctx,
|
|
231
|
+
payload={"content": content, "metadata": metadata or {}},
|
|
232
|
+
)
|
|
233
|
+
return self._parse(data, "PATCH /memories/{id}")
|
|
234
|
+
|
|
235
|
+
def delete(self, ctx: MemoryContext, memory_id: str) -> None:
|
|
236
|
+
self._request("DELETE", f"/memories/{quote(memory_id, safe='')}", ctx)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
self._client.close()
|