memtrust-cli 0.3.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.
- memtrust/__init__.py +11 -0
- memtrust/adapters/__init__.py +62 -0
- memtrust/adapters/base.py +2020 -0
- memtrust/adapters/mem0_adapter.py +456 -0
- memtrust/adapters/mem0_direct_adapter.py +1217 -0
- memtrust/adapters/mempalace_adapter.py +1166 -0
- memtrust/adapters/openviking_adapter.py +570 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
- memtrust/cli.py +1201 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +451 -0
- memtrust/evals/crash_recovery.py +290 -0
- memtrust/evals/embedder_cost.py +163 -0
- memtrust/evals/embedding_drift.py +273 -0
- memtrust/evals/episode_temporal_leak.py +182 -0
- memtrust/evals/extraction_quality.py +373 -0
- memtrust/evals/filter_injection.py +371 -0
- memtrust/evals/language_degradation.py +189 -0
- memtrust/evals/lock_contention.py +263 -0
- memtrust/evals/locomo.py +392 -0
- memtrust/evals/longmemeval.py +249 -0
- memtrust/evals/mempalace_metadata_scale.py +494 -0
- memtrust/evals/migration_rollback.py +276 -0
- memtrust/evals/orphan_cleanup.py +235 -0
- memtrust/evals/ranking_quality.py +303 -0
- memtrust/evals/resource_sync_safety.py +336 -0
- memtrust/evals/result_consistency.py +239 -0
- memtrust/evals/scale_fixtures.py +189 -0
- memtrust/evals/scale_stress.py +502 -0
- memtrust/evals/stats_accuracy.py +240 -0
- memtrust/evals/temporal_kg_boundary.py +281 -0
- memtrust/receipt.py +318 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +199 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust_cli-0.3.0.dist-info/METADATA +624 -0
- memtrust_cli-0.3.0.dist-info/RECORD +42 -0
- memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
- memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
- memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Adapters for Mem0 (https://mem0.ai): hosted Platform API and self-hosted
|
|
2
|
+
OSS server.
|
|
3
|
+
|
|
4
|
+
`Mem0Adapter` (hosted, this module's original adapter) and
|
|
5
|
+
`Mem0SelfHostedAdapter` (below) are deliberately two separate classes, not
|
|
6
|
+
one adapter with a deployment flag, following the precedent this codebase
|
|
7
|
+
already set for Zep/Graphiti in docs/methodology.md ("If self-hosted
|
|
8
|
+
Graphiti support is wanted later, it should be a second adapter ... with
|
|
9
|
+
its own configuration story, not a silent branch inside this one"). The
|
|
10
|
+
two Mem0 deployments have different base URLs, different route prefixes,
|
|
11
|
+
different auth defaults, and different confidence levels -- collapsing
|
|
12
|
+
them into one class with an if/else would hide that from callers and from
|
|
13
|
+
the confidence table in docs/methodology.md.
|
|
14
|
+
|
|
15
|
+
## Mem0Adapter (hosted Platform API)
|
|
16
|
+
|
|
17
|
+
Confidence: HIGH. Mem0 publishes a documented Python SDK (`mem0ai` on PyPI)
|
|
18
|
+
built around `MemoryClient(api_key=...)` with `.add()`, `.search()`, and
|
|
19
|
+
`.update()` methods, and reads `MEM0_API_KEY` from the environment when no
|
|
20
|
+
key is passed explicitly (docs.mem0.ai/platform/quickstart, June 2026 SDK
|
|
21
|
+
v2.0.8 release notes). This adapter talks to the same REST surface the
|
|
22
|
+
official SDK wraps, using httpx directly rather than depending on the
|
|
23
|
+
`mem0ai` package, to keep memtrust's own dependency tree small and to keep
|
|
24
|
+
every HTTP call independently mockable in tests without a vendor SDK
|
|
25
|
+
installed. Endpoint paths below are best-effort reconstructions of the
|
|
26
|
+
REST API the documented SDK calls sit on top of -- see
|
|
27
|
+
docs/methodology.md for what is confirmed vs. inferred.
|
|
28
|
+
|
|
29
|
+
Mem0's own memory pipeline decides, per stored fact, whether to ADD a new
|
|
30
|
+
memory, UPDATE an existing one, or leave it alone -- this is documented
|
|
31
|
+
Mem0 behavior, not a memtrust assumption, and it is exactly the kind of
|
|
32
|
+
vendor-side conflict resolution the contradiction-detection eval is built
|
|
33
|
+
to observe rather than short-circuit.
|
|
34
|
+
|
|
35
|
+
## Mem0SelfHostedAdapter (self-hosted OSS `server/` FastAPI wrapper)
|
|
36
|
+
|
|
37
|
+
Confidence: MEDIUM-HIGH on route shape and request/response models, LOW on
|
|
38
|
+
end-to-end live behavior. Unlike the hosted adapter above (reconstructed
|
|
39
|
+
from SDK docs) or MemPalace/OpenViking below (reconstructed from product
|
|
40
|
+
docs that could not be fully fetched), this adapter's route shape was
|
|
41
|
+
confirmed by fetching the *actual source code* of `server/main.py` and
|
|
42
|
+
`server/auth.py` from the `mem0ai/mem0` GitHub repository's `main` branch
|
|
43
|
+
directly (`raw.githubusercontent.com/mem0ai/mem0/main/server/...`) during
|
|
44
|
+
this build, 2026-07-11. That is stronger evidence than a documentation
|
|
45
|
+
page, but it is still: (a) a snapshot of an unpinned `main` branch that
|
|
46
|
+
can drift out from under any specific self-hosted deployment's actual
|
|
47
|
+
server version, and (b) never exercised against a live running instance
|
|
48
|
+
in this environment -- no self-hosted mem0 server was started and hit
|
|
49
|
+
with a real HTTP request during this build. Confirmed from that source
|
|
50
|
+
read:
|
|
51
|
+
|
|
52
|
+
* Routes are unprefixed -- `POST /memories`, `GET /memories`,
|
|
53
|
+
`GET /memories/{id}`, `PUT /memories/{id}`, `DELETE /memories/{id}`,
|
|
54
|
+
`DELETE /memories`, `POST /search`, `GET /memories/{id}/history`,
|
|
55
|
+
`POST /reset` -- there is no `/v1/...` prefix anywhere in this
|
|
56
|
+
router, unlike the hosted Platform API.
|
|
57
|
+
* `POST /memories` request body (`MemoryCreate`): `messages`, `user_id`,
|
|
58
|
+
`agent_id`, `run_id`, `metadata`, `expiration_date`, `infer`,
|
|
59
|
+
`memory_type`, `prompt`. The handler 400s unless at least one of
|
|
60
|
+
`user_id`/`agent_id`/`run_id` is set.
|
|
61
|
+
* `POST /search` request body (`SearchRequest`): `query`, `filters`
|
|
62
|
+
(a dict), `top_k`, `threshold`, `explain`, `show_expired`, plus
|
|
63
|
+
deprecated top-level `user_id`/`run_id`/`agent_id` fields that the
|
|
64
|
+
handler merges into `filters` -- but only `if entity_val:`, a
|
|
65
|
+
*truthy* check, not `is not None`. That means the self-hosted
|
|
66
|
+
server's own deprecated-field merge path silently drops a
|
|
67
|
+
deliberately-empty-string `run_id`/`agent_id` instead of scoping the
|
|
68
|
+
filter to "must be empty" -- this is the concrete, source-confirmed
|
|
69
|
+
shape of the entity-id filter-scoping issue (mem0ai/mem0#5973) that
|
|
70
|
+
this adapter exists to make reachable. This adapter always sends
|
|
71
|
+
`run_id`/`agent_id` inside the `filters` dict directly (never through
|
|
72
|
+
the deprecated top-level fields) using an `is not None` check, so a
|
|
73
|
+
caller's deliberate empty string reaches the server's filter-matching
|
|
74
|
+
logic intact rather than being dropped by memtrust itself -- whether
|
|
75
|
+
the self-hosted `Memory.search()` implementation then handles that
|
|
76
|
+
empty-string filter correctly is exactly what the eval is meant to
|
|
77
|
+
observe, not something this adapter should paper over.
|
|
78
|
+
* `PUT /memories/{id}` body (`MemoryUpdate`): `text`, `metadata`,
|
|
79
|
+
`expiration_date`.
|
|
80
|
+
* No auth by default: `verify_auth` requires a bearer JWT or
|
|
81
|
+
`X-API-Key` header unless `AUTH_DISABLED` is set server-side, and
|
|
82
|
+
the project's own Docker self-hosting guide (mem0.ai/blog/
|
|
83
|
+
self-host-mem0-docker) ships `AUTH_DISABLED` on by default for local
|
|
84
|
+
use, mapping the container to `localhost:8888`. That is why this
|
|
85
|
+
adapter's required configuration is a base URL, not an API key (the
|
|
86
|
+
same reasoning docs/methodology.md already gives for
|
|
87
|
+
`MEMPALACE_STORAGE_PATH`), with an optional API key for deployments
|
|
88
|
+
that front the server with their own auth.
|
|
89
|
+
|
|
90
|
+
Not confirmed by this build, and explicitly out of scope for this change:
|
|
91
|
+
the exact JSON shape `Memory.search()`/`Memory.add()` return (the FastAPI
|
|
92
|
+
handlers pass that dict through unmodified, so this adapter reuses the
|
|
93
|
+
hosted adapter's `{"results": [...]}` parsing, which is the OSS `mem0ai`
|
|
94
|
+
SDK's own documented response shape, but that specific field name was not
|
|
95
|
+
re-verified against `server/main.py` beyond what's shown above); the
|
|
96
|
+
`DELETE /memories` multi-filter route that mem0ai/mem0#5936/#5970
|
|
97
|
+
describe as truncating results (there is no `delete()` method on
|
|
98
|
+
`MemoryBackendAdapter` for this adapter to implement it against, and
|
|
99
|
+
adding one is a larger interface change than this backlog item scopes to
|
|
100
|
+
-- see docs/methodology.md); and the embedding-dimension-mismatch failure
|
|
101
|
+
mem0ai/mem0#4297 describes, which lives entirely in self-hosted vector
|
|
102
|
+
store configuration this adapter has no surface to trigger or observe --
|
|
103
|
+
merely routing eval traffic at a self-hosted instance is what makes that
|
|
104
|
+
bug class newly *reachable* by a memtrust user, not something this
|
|
105
|
+
adapter's code exercises directly.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
from __future__ import annotations
|
|
109
|
+
|
|
110
|
+
import os
|
|
111
|
+
|
|
112
|
+
import httpx
|
|
113
|
+
|
|
114
|
+
from memtrust.adapters.base import (
|
|
115
|
+
BackendAPIError,
|
|
116
|
+
BackendNotConfiguredError,
|
|
117
|
+
ConflictSignal,
|
|
118
|
+
DeleteResult,
|
|
119
|
+
ExtractionSignal,
|
|
120
|
+
MemoryBackendAdapter,
|
|
121
|
+
MemoryRecord,
|
|
122
|
+
QueryResult,
|
|
123
|
+
StoreResult,
|
|
124
|
+
UpdateResult,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
DEFAULT_BASE_URL = "https://api.mem0.ai"
|
|
128
|
+
DEFAULT_SELFHOSTED_BASE_URL = "http://localhost:8888"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Mem0Adapter(MemoryBackendAdapter):
|
|
132
|
+
name = "mem0"
|
|
133
|
+
env_var = "MEM0_API_KEY"
|
|
134
|
+
supports_update = True
|
|
135
|
+
|
|
136
|
+
def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0) -> None:
|
|
137
|
+
api_key = os.environ.get(self.env_var)
|
|
138
|
+
if not api_key:
|
|
139
|
+
raise BackendNotConfiguredError(self.name, self.env_var)
|
|
140
|
+
self._http = httpx.Client(
|
|
141
|
+
base_url=base_url,
|
|
142
|
+
headers={
|
|
143
|
+
"Authorization": f"Token {api_key}",
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
},
|
|
146
|
+
timeout=timeout,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def store(
|
|
150
|
+
self,
|
|
151
|
+
session_id: str,
|
|
152
|
+
content: str,
|
|
153
|
+
metadata: dict[str, str] | None = None,
|
|
154
|
+
mode: str | None = None,
|
|
155
|
+
) -> StoreResult:
|
|
156
|
+
# Mem0 has no documented operating-mode variant to select -- `mode`
|
|
157
|
+
# is accepted and ignored (no-op) so callers can pass it uniformly
|
|
158
|
+
# across every adapter. See MemoryBackendAdapter.supported_modes.
|
|
159
|
+
del mode
|
|
160
|
+
timer = self._timed()
|
|
161
|
+
payload: dict[str, object] = {
|
|
162
|
+
"messages": [{"role": "user", "content": content}],
|
|
163
|
+
"user_id": session_id,
|
|
164
|
+
}
|
|
165
|
+
if metadata:
|
|
166
|
+
payload["metadata"] = metadata
|
|
167
|
+
try:
|
|
168
|
+
resp = self._http.post("/v1/memories/", json=payload)
|
|
169
|
+
resp.raise_for_status()
|
|
170
|
+
data = resp.json()
|
|
171
|
+
except httpx.HTTPError as exc:
|
|
172
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
173
|
+
memory_id = _extract_memory_id(data)
|
|
174
|
+
# See ExtractionSignal in base.py -- mem0ai/mem0#5178: a store()
|
|
175
|
+
# call can complete without raising and still carry no usable
|
|
176
|
+
# memory id anywhere in the response, which is byte-identical to a
|
|
177
|
+
# genuine successful store unless flagged explicitly here.
|
|
178
|
+
extraction_signal = (
|
|
179
|
+
ExtractionSignal.EMPTY_EXTRACTION if not memory_id else ExtractionSignal.FACTS_EXTRACTED
|
|
180
|
+
)
|
|
181
|
+
return StoreResult(
|
|
182
|
+
memory_id=memory_id,
|
|
183
|
+
latency_ms=timer.elapsed_ms(),
|
|
184
|
+
raw=data,
|
|
185
|
+
extraction_signal=extraction_signal,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def query(
|
|
189
|
+
self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
|
|
190
|
+
) -> QueryResult:
|
|
191
|
+
del mode # no-op, see store() above
|
|
192
|
+
timer = self._timed()
|
|
193
|
+
payload = {"query": query, "filters": {"user_id": session_id}, "top_k": top_k}
|
|
194
|
+
try:
|
|
195
|
+
resp = self._http.post("/v1/memories/search/", json=payload)
|
|
196
|
+
resp.raise_for_status()
|
|
197
|
+
data = resp.json()
|
|
198
|
+
except httpx.HTTPError as exc:
|
|
199
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
200
|
+
|
|
201
|
+
raw_results = data.get("results", data if isinstance(data, list) else [])
|
|
202
|
+
records = [
|
|
203
|
+
MemoryRecord(
|
|
204
|
+
memory_id=str(item.get("id", "")),
|
|
205
|
+
content=str(item.get("memory", item.get("text", ""))),
|
|
206
|
+
score=item.get("score"),
|
|
207
|
+
created_at=item.get("created_at"),
|
|
208
|
+
metadata=item.get("metadata") or {},
|
|
209
|
+
raw=item,
|
|
210
|
+
)
|
|
211
|
+
for item in raw_results
|
|
212
|
+
]
|
|
213
|
+
# Mem0's search response does not carry an explicit "this result
|
|
214
|
+
# superseded a prior one" marker in the documented API surface --
|
|
215
|
+
# conflict handling happens inside Mem0's own add/update pipeline,
|
|
216
|
+
# invisibly to the caller. Until Mem0 documents a per-result
|
|
217
|
+
# conflict marker, memtrust conservatively records SILENT_OVERWRITE
|
|
218
|
+
# when exactly one record is returned for a query that the
|
|
219
|
+
# contradiction eval knows should have two candidate facts, and
|
|
220
|
+
# FLAGGED only if the response ever includes more than one
|
|
221
|
+
# conflicting record for the same fact slot. See
|
|
222
|
+
# evals/contradiction.py for how this signal is actually derived
|
|
223
|
+
# per test case; this adapter only reports the raw record set.
|
|
224
|
+
conflict_signal = ConflictSignal.NOT_APPLICABLE
|
|
225
|
+
return QueryResult(
|
|
226
|
+
records=records,
|
|
227
|
+
conflict_signal=conflict_signal,
|
|
228
|
+
latency_ms=timer.elapsed_ms(),
|
|
229
|
+
raw=data,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
|
|
233
|
+
timer = self._timed()
|
|
234
|
+
try:
|
|
235
|
+
resp = self._http.put(f"/v1/memories/{memory_id}/", json={"text": content})
|
|
236
|
+
resp.raise_for_status()
|
|
237
|
+
data = resp.json()
|
|
238
|
+
except httpx.HTTPError as exc:
|
|
239
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
240
|
+
return UpdateResult(
|
|
241
|
+
memory_id=memory_id, acknowledged=True, latency_ms=timer.elapsed_ms(), raw=data
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def delete(self, memory_id: str) -> DeleteResult:
|
|
245
|
+
"""Delete a memory via Mem0's documented DELETE /v1/memories/{id}/
|
|
246
|
+
endpoint (docs.mem0.ai/platform/quickstart lists delete alongside
|
|
247
|
+
add/search/update on the same REST surface this adapter targets).
|
|
248
|
+
|
|
249
|
+
This is the primitive an eval needs to reproduce the real,
|
|
250
|
+
merged mem0ai/mem0#5936 / #5970 bug class: a multi-entity delete
|
|
251
|
+
whose client-side aggregation silently truncated to only the
|
|
252
|
+
last response instead of all N. memtrust's own delete_many() in
|
|
253
|
+
base.py is what constructs that N-entity scenario against this
|
|
254
|
+
single-id delete() call.
|
|
255
|
+
"""
|
|
256
|
+
timer = self._timed()
|
|
257
|
+
try:
|
|
258
|
+
resp = self._http.delete(f"/v1/memories/{memory_id}/")
|
|
259
|
+
resp.raise_for_status()
|
|
260
|
+
data = resp.json() if resp.content else {}
|
|
261
|
+
except httpx.HTTPError as exc:
|
|
262
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
263
|
+
return DeleteResult(
|
|
264
|
+
success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=data
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def close(self) -> None:
|
|
268
|
+
self._http.close()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class Mem0SelfHostedAdapter(MemoryBackendAdapter):
|
|
272
|
+
"""Adapter for the self-hosted Mem0 OSS `server/` FastAPI wrapper.
|
|
273
|
+
|
|
274
|
+
See this module's docstring for the confidence level and exactly what
|
|
275
|
+
was, and was not, confirmed against the real `mem0ai/mem0` source.
|
|
276
|
+
|
|
277
|
+
Unlike `Mem0Adapter`, configuration is gated on a base URL
|
|
278
|
+
(`MEM0_SELFHOSTED_BASE_URL`), not an API key, because the self-hosted
|
|
279
|
+
server ships with no auth by default -- the same reasoning
|
|
280
|
+
docs/methodology.md gives for `MemPalaceAdapter`'s
|
|
281
|
+
`MEMPALACE_STORAGE_PATH`. An optional `MEM0_SELFHOSTED_API_KEY` is
|
|
282
|
+
sent as an `X-API-Key` header for deployments that do front the
|
|
283
|
+
server with auth (env's `AUTH_DISABLED` unset).
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
name = "mem0_selfhosted"
|
|
287
|
+
env_var = "MEM0_SELFHOSTED_BASE_URL"
|
|
288
|
+
supports_update = True
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
base_url: str | None = None,
|
|
293
|
+
api_key: str | None = None,
|
|
294
|
+
timeout: float = 30.0,
|
|
295
|
+
) -> None:
|
|
296
|
+
resolved_base_url = base_url or os.environ.get(self.env_var)
|
|
297
|
+
if not resolved_base_url:
|
|
298
|
+
raise BackendNotConfiguredError(self.name, self.env_var)
|
|
299
|
+
resolved_api_key = api_key or os.environ.get("MEM0_SELFHOSTED_API_KEY")
|
|
300
|
+
headers = {"Content-Type": "application/json"}
|
|
301
|
+
if resolved_api_key:
|
|
302
|
+
headers["X-API-Key"] = resolved_api_key
|
|
303
|
+
self._http = httpx.Client(base_url=resolved_base_url, headers=headers, timeout=timeout)
|
|
304
|
+
|
|
305
|
+
def store(
|
|
306
|
+
self,
|
|
307
|
+
session_id: str,
|
|
308
|
+
content: str,
|
|
309
|
+
metadata: dict[str, str] | None = None,
|
|
310
|
+
mode: str | None = None,
|
|
311
|
+
) -> StoreResult:
|
|
312
|
+
# Same no-op convention as Mem0Adapter.store() above -- the
|
|
313
|
+
# self-hosted server has no documented operating-mode variant
|
|
314
|
+
# either, so `mode` is accepted for interface uniformity and
|
|
315
|
+
# otherwise ignored.
|
|
316
|
+
del mode
|
|
317
|
+
timer = self._timed()
|
|
318
|
+
payload: dict[str, object] = {
|
|
319
|
+
"messages": [{"role": "user", "content": content}],
|
|
320
|
+
"user_id": session_id,
|
|
321
|
+
}
|
|
322
|
+
if metadata:
|
|
323
|
+
payload["metadata"] = metadata
|
|
324
|
+
try:
|
|
325
|
+
resp = self._http.post("/memories", json=payload)
|
|
326
|
+
resp.raise_for_status()
|
|
327
|
+
data = resp.json()
|
|
328
|
+
except httpx.HTTPError as exc:
|
|
329
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
330
|
+
memory_id = _extract_memory_id(data)
|
|
331
|
+
# See ExtractionSignal in base.py -- mem0ai/mem0#5178: a store()
|
|
332
|
+
# call can complete without raising and still carry no usable
|
|
333
|
+
# memory id anywhere in the response, which is byte-identical to a
|
|
334
|
+
# genuine successful store unless flagged explicitly here.
|
|
335
|
+
extraction_signal = (
|
|
336
|
+
ExtractionSignal.EMPTY_EXTRACTION if not memory_id else ExtractionSignal.FACTS_EXTRACTED
|
|
337
|
+
)
|
|
338
|
+
return StoreResult(
|
|
339
|
+
memory_id=memory_id,
|
|
340
|
+
latency_ms=timer.elapsed_ms(),
|
|
341
|
+
raw=data,
|
|
342
|
+
extraction_signal=extraction_signal,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def query(
|
|
346
|
+
self,
|
|
347
|
+
session_id: str,
|
|
348
|
+
query: str,
|
|
349
|
+
top_k: int = 5,
|
|
350
|
+
run_id: str | None = None,
|
|
351
|
+
agent_id: str | None = None,
|
|
352
|
+
threshold: float | None = None,
|
|
353
|
+
) -> QueryResult:
|
|
354
|
+
"""Search self-hosted Mem0 memories.
|
|
355
|
+
|
|
356
|
+
`run_id`/`agent_id` default to `None` (omitted from the filter
|
|
357
|
+
entirely), but a caller may deliberately pass `""` -- this
|
|
358
|
+
adapter always includes the key in `filters` when the value is
|
|
359
|
+
not `None` (an `is not None` check, not a truthy check), so an
|
|
360
|
+
empty string is preserved through to the server rather than
|
|
361
|
+
silently dropped the way the server's own deprecated top-level
|
|
362
|
+
`user_id`/`run_id`/`agent_id` merge path drops falsy values (see
|
|
363
|
+
module docstring, mem0ai/mem0#5973). This is what lets the
|
|
364
|
+
contradiction/entity-scoping evals construct the exact filter
|
|
365
|
+
shape that issue describes and observe how the self-hosted
|
|
366
|
+
server actually responds to it, instead of memtrust masking the
|
|
367
|
+
input before it ever reaches the vendor.
|
|
368
|
+
|
|
369
|
+
`threshold` is passed straight through to `SearchRequest.threshold`
|
|
370
|
+
-- confirmed present on the self-hosted server's search model --
|
|
371
|
+
which is what makes mem0ai/mem0#4453 (threshold inversion)
|
|
372
|
+
reachable through this adapter; `Mem0Adapter` (hosted) has no
|
|
373
|
+
equivalent parameter today.
|
|
374
|
+
"""
|
|
375
|
+
timer = self._timed()
|
|
376
|
+
filters: dict[str, str] = {"user_id": session_id}
|
|
377
|
+
if run_id is not None:
|
|
378
|
+
filters["run_id"] = run_id
|
|
379
|
+
if agent_id is not None:
|
|
380
|
+
filters["agent_id"] = agent_id
|
|
381
|
+
payload: dict[str, object] = {"query": query, "filters": filters, "top_k": top_k}
|
|
382
|
+
if threshold is not None:
|
|
383
|
+
payload["threshold"] = threshold
|
|
384
|
+
try:
|
|
385
|
+
resp = self._http.post("/search", json=payload)
|
|
386
|
+
resp.raise_for_status()
|
|
387
|
+
data = resp.json()
|
|
388
|
+
except httpx.HTTPError as exc:
|
|
389
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
390
|
+
|
|
391
|
+
raw_results = data.get("results", data if isinstance(data, list) else [])
|
|
392
|
+
records = [
|
|
393
|
+
MemoryRecord(
|
|
394
|
+
memory_id=str(item.get("id", "")),
|
|
395
|
+
content=str(item.get("memory", item.get("text", ""))),
|
|
396
|
+
score=item.get("score"),
|
|
397
|
+
created_at=item.get("created_at"),
|
|
398
|
+
metadata=item.get("metadata") or {},
|
|
399
|
+
raw=item,
|
|
400
|
+
)
|
|
401
|
+
for item in raw_results
|
|
402
|
+
]
|
|
403
|
+
# Same reasoning as Mem0Adapter.query() above: the self-hosted
|
|
404
|
+
# search response has no documented per-result conflict marker
|
|
405
|
+
# either, so this is recorded as NOT_APPLICABLE rather than
|
|
406
|
+
# guessed. See evals/contradiction.py for how the eval derives
|
|
407
|
+
# its own signal from the raw record set regardless.
|
|
408
|
+
conflict_signal = ConflictSignal.NOT_APPLICABLE
|
|
409
|
+
return QueryResult(
|
|
410
|
+
records=records,
|
|
411
|
+
conflict_signal=conflict_signal,
|
|
412
|
+
latency_ms=timer.elapsed_ms(),
|
|
413
|
+
raw=data,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
|
|
417
|
+
timer = self._timed()
|
|
418
|
+
try:
|
|
419
|
+
resp = self._http.put(f"/memories/{memory_id}", json={"text": content})
|
|
420
|
+
resp.raise_for_status()
|
|
421
|
+
data = resp.json()
|
|
422
|
+
except httpx.HTTPError as exc:
|
|
423
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
424
|
+
return UpdateResult(
|
|
425
|
+
memory_id=memory_id, acknowledged=True, latency_ms=timer.elapsed_ms(), raw=data
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
def delete(self, memory_id: str) -> DeleteResult:
|
|
429
|
+
"""Delete a memory via the self-hosted server's unprefixed
|
|
430
|
+
`DELETE /memories/{id}` route -- same reasoning as `Mem0Adapter.delete()`
|
|
431
|
+
above, against the self-hosted route shape instead of the hosted
|
|
432
|
+
`/v1/...` one.
|
|
433
|
+
"""
|
|
434
|
+
timer = self._timed()
|
|
435
|
+
try:
|
|
436
|
+
resp = self._http.delete(f"/memories/{memory_id}")
|
|
437
|
+
resp.raise_for_status()
|
|
438
|
+
data = resp.json() if resp.content else {}
|
|
439
|
+
except httpx.HTTPError as exc:
|
|
440
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
441
|
+
return DeleteResult(
|
|
442
|
+
success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=data
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def close(self) -> None:
|
|
446
|
+
self._http.close()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _extract_memory_id(data: object) -> str:
|
|
450
|
+
if isinstance(data, dict):
|
|
451
|
+
if "id" in data:
|
|
452
|
+
return str(data["id"])
|
|
453
|
+
results = data.get("results")
|
|
454
|
+
if isinstance(results, list) and results and isinstance(results[0], dict):
|
|
455
|
+
return str(results[0].get("id", ""))
|
|
456
|
+
return ""
|