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.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,1217 @@
1
+ """Direct-library adapter for Mem0: constructs a real `mem0.Memory` object
2
+ via `Memory.from_config()`, in-process, instead of going over HTTP like
3
+ `Mem0Adapter`/`Mem0SelfHostedAdapter` in `mem0_adapter.py`.
4
+
5
+ ## Why this adapter exists
6
+
7
+ Neither of the two REST adapters above can select a `graph_store` provider,
8
+ an `embedder` provider, or a `vector_store` provider -- those are
9
+ construction-time Python config (`MemoryConfig(embedder=..., vector_store=...,
10
+ graph_store=...)`), not REST parameters either the hosted Platform API or the
11
+ self-hosted server's `POST /memories` / `POST /search` bodies expose. Five
12
+ real, cited mem0 bug reports trace to exactly that unreachable configuration
13
+ surface:
14
+
15
+ 1. mem0ai/mem0#3558 (merged) -- `kuzu_memory.py` raised `ValueError` if
16
+ `embedding_dims` was `None`/`<=0` before opening the Kuzu graph DB.
17
+ 2. mem0ai/mem0#5671 (merged, fixed upstream) -- `aws_bedrock.py` now
18
+ forwards `embedding_dims` to Bedrock Titan V2 requests.
19
+ 3. mem0ai/mem0#4362 (merged, fixed upstream) -- `redis.py`/`valkey.py`
20
+ now guard against a silent zero-vector corruption on metadata-only
21
+ updates (`if vector is not None:` before overwriting `"embedding"`).
22
+ 4. mem0ai/mem0#4711 (merged, fixed upstream) -- `FastEmbedEmbedding` now
23
+ reads the real dimension from the loaded model at init instead of
24
+ defaulting to 1536.
25
+ 5. mem0ai/mem0#2304 (merged, fixed upstream) -- Gemini/OpenAI embedders
26
+ now forward `embedding_dims`/`dimensions` into the embed call instead
27
+ of silently dropping it.
28
+ 6. mem0ai/mem0#4297 (fixed in the TS SDK; the Python OSS SDK's Qdrant
29
+ path was never patched and still exhibits the bug *class* today --
30
+ see "Qdrant support" below) -- a vector-store collection created with
31
+ a hardcoded/default 1536-dim size regardless of the actual embedder's
32
+ output dimension, causing a dimension-mismatch `Bad Request` on
33
+ insert for any non-1536-dim embedder.
34
+ 7. mem0ai/mem0#4453 (companion bug to #4297; confirmed fixed in the
35
+ installed Python package -- see "Qdrant support" below) -- threshold
36
+ filtering assuming similarity (higher=better) when a vector store
37
+ returns raw distance (lower=better), silently inverting which results
38
+ pass the threshold.
39
+ 8. mem0ai/mem0#5980 (merged, GitHub user HrushiYadav; confirmed fixed in
40
+ the installed Python package -- see "Elasticsearch support" below) --
41
+ `ElasticsearchVectorStore` embedded caller-supplied filter values
42
+ directly into Elasticsearch `term` queries with no type validation, so
43
+ a dict/list-valued filter value (e.g. `{"user_id": {"$ne": ""}}`)
44
+ could inject arbitrary Elasticsearch query-DSL operators, enabling
45
+ access-control bypass / cross-user memory enumeration. Part of a
46
+ coordinated 5-backend injection-prevention series covering
47
+ elasticsearch/neptune/azure/opensearch/databricks; this adapter and
48
+ its eval only concern the elasticsearch fix.
49
+
50
+ ## Confidence and what was actually confirmed against the real package
51
+
52
+ Confidence: HIGH on what the installed package's code actually does --
53
+ every claim below was confirmed by reading the *installed* `mem0ai==2.0.12`
54
+ source directly (the newest version on PyPI as of this build, 2026-07-16;
55
+ `pip index versions mem0ai` lists no newer release), not by re-reading the
56
+ GitHub issues or guessing method signatures. LOW on live end-to-end
57
+ behavior: this adapter has never been run against a live Redis/Valkey
58
+ server, a live Bedrock/Gemini/OpenAI embedding endpoint, or a live FastEmbed
59
+ model download in this environment -- see docs/methodology.md.
60
+
61
+ **mem0ai#5671, #4362, #4711, #2304 are all confirmed fixed in the installed
62
+ package**, by direct source inspection:
63
+
64
+ * `mem0/embeddings/aws_bedrock.py::_get_embedding` forwards
65
+ `self.config.embedding_dims` as `input_body["dimensions"]` whenever it
66
+ is set and the model string contains `"v2"` -- exactly the #5671 fix.
67
+ * `mem0/embeddings/fastembed.py::FastEmbedEmbedding.__init__` sets
68
+ `self.config.embedding_dims = self.dense_model.embedding_size` when the
69
+ caller didn't set one -- exactly the #4711 fix (no more hardcoded 1536
70
+ default).
71
+ * `mem0/embeddings/gemini.py::GoogleGenAIEmbedding.embed`/`embed_batch`
72
+ build an `EmbedContentConfig(output_dimensionality=self.config.
73
+ embedding_dims)` and pass it to every `embed_content` call -- and
74
+ `mem0/embeddings/openai.py::OpenAIEmbedding.embed`/`embed_batch` pass
75
+ `dimensions=self.config.embedding_dims` in the request kwargs whenever
76
+ the caller set one -- exactly the #2304 fix for both vendors.
77
+ * `mem0/vector_stores/redis.py::RedisDB.update` and
78
+ `mem0/vector_stores/valkey.py::ValkeyDB.update` both guard
79
+ `if vector is not None: data["embedding"] = np.array(vector, ...)` --
80
+ exactly the #4362 fix. Neither store zeroes or drops the existing
81
+ embedding when `vector` is omitted.
82
+
83
+ `tests/test_mem0_direct_adapter.py` exercises the real, installed
84
+ `AWSBedrockEmbedding`/`GoogleGenAIEmbedding`/`OpenAIEmbedding`/
85
+ `FastEmbedEmbedding`/`RedisDB`/`ValkeyDB` classes directly (mocking only
86
+ each vendor's own network/model-load boundary -- `boto3`'s client, the
87
+ `openai`/`google.genai` SDK clients, `fastembed.TextEmbedding`, and the
88
+ `redis`/`valkey` wire client), so a regression that reintroduced any of
89
+ these four bugs in a future `mem0ai` release would fail those tests against
90
+ the *installed* package, not against a copy of the fixed logic memtrust
91
+ reimplemented itself. That is what justifies treating these four as
92
+ re-validated PASS against the currently pinned `mem0ai` version, not just
93
+ "the GitHub issue says merged."
94
+
95
+ **Qdrant support (`vector_store_provider="qdrant"`) and #4297/#4453 --
96
+ mixed result, confirmed by reading the installed `mem0.vector_stores.qdrant`,
97
+ `mem0.configs.vector_stores.qdrant`, `mem0.vector_stores.base`, and
98
+ `mem0.utils.scoring` source directly, not by trusting either GitHub issue's
99
+ "merged" status.**
100
+
101
+ *#4297 (embedding-dimension mismatch): the bug class is still reachable in
102
+ the installed Python package, not fixed.* `QdrantConfig.embedding_model_dims`
103
+ (`mem0/configs/vector_stores/qdrant.py`) defaults to `1536` -- the exact
104
+ same hardcoded OpenAI dimension #4297's TS SDK fix removed for the
105
+ equivalent JS config -- and nothing in `Memory.__init__`/`Memory.from_config()`
106
+ (`mem0/memory/main.py`) reconciles that default against the embedder's real
107
+ output dimension: `EmbedderFactory.create(...)` and
108
+ `VectorStoreFactory.create(...)` are constructed independently, from
109
+ `config.embedder.config` and `config.vector_store.config` respectively,
110
+ with no cross-check between them. `tests/test_mem0_direct_adapter.py`
111
+ exercises the real, installed `mem0.vector_stores.qdrant.Qdrant` class
112
+ directly (mocking only the `QdrantClient` wire boundary) and shows it
113
+ creates a collection sized to whatever `embedding_model_dims` it is given
114
+ -- 1536 if a caller relies on `QdrantConfig`'s own default, the wrong size
115
+ for any non-1536-dim embedder (e.g. `fastembed`'s 384-dim model), and the
116
+ correct size only when a caller explicitly overrides it. This is exactly
117
+ the failure shape #4297 originally described for the TS SDK, still present
118
+ in the Python OSS package today. `Mem0DirectAdapter` itself never hits
119
+ this bug in practice, for the same reason it never hits an analogous
120
+ class of bug for Redis/Valkey: `_build_vector_store_config()` below always
121
+ threads the resolved embedder dimension into `embedding_model_dims`
122
+ explicitly, the same defensive pattern already used for
123
+ `vector_store_provider="redis"`/`"valkey"`. That is a property of this
124
+ adapter's own config-building code, not evidence that mem0ai's Qdrant path
125
+ was fixed upstream -- a caller constructing `mem0.Memory.from_config()`
126
+ directly, without this adapter, remains exposed.
127
+
128
+ *#4453 (search-threshold inversion): confirmed fixed in the installed
129
+ package, comprehensively, not just for Qdrant.* `mem0.vector_stores.base
130
+ .VectorStoreBase.search()`'s docstring states an explicit, binding
131
+ contract: "All implementations must return similarity scores where higher
132
+ values indicate greater similarity ... Implementations using distance
133
+ metrics must convert to similarity before returning." Every vector-store
134
+ implementation this build inspected in the installed package complies --
135
+ `chroma.py`, `faiss.py`, `milvus.py`, `pgvector.py`, `redis.py`,
136
+ `valkey.py`, `supabase.py`, `turbopuffer.py`, `vertex_ai_vector_search.py`,
137
+ and `s3_vectors.py` all convert a raw distance to a `[0, 1]`-ish similarity
138
+ score (`max(0, 1 - distance)` or `1 / (1 + distance)`) before returning it;
139
+ `qdrant.py`, `pinecone.py`, `weaviate.py`, `opensearch.py`,
140
+ `elasticsearch.py`, `azure_ai_search.py`, and `mongodb.py` return each
141
+ vendor's own already-higher-is-better native score directly, with no
142
+ conversion needed. `mem0.utils.scoring.score_and_rank()` -- the real
143
+ function `Memory._search_vector_store()` calls to apply `threshold` --
144
+ then filters with `if semantic_score < threshold: continue`, i.e. "drop
145
+ anything below the threshold, keep the rest," which is only correct
146
+ because every store's `score` is already similarity-oriented by the time
147
+ it gets there. `tests/test_mem0_direct_adapter.py` exercises this real
148
+ function directly with similarity-shaped scores and confirms it keeps the
149
+ closest matches and drops the farthest, plus a clearly-labeled hypothetical
150
+ test showing what would happen if a store *didn't* comply (the best match
151
+ gets dropped and the worst kept -- the literal inversion #4453 described)
152
+ to make the contract's importance concrete without claiming any real,
153
+ installed store violates it. `Mem0DirectAdapter.query()`'s new `threshold`
154
+ parameter (forwarded straight through to `Memory.search()`) is what makes
155
+ this reachable through this adapter at all -- the same role
156
+ `Mem0SelfHostedAdapter.query()`'s `threshold` parameter plays over REST.
157
+
158
+ **Elasticsearch support (`vector_store_provider="elasticsearch"`) and
159
+ #5980 -- confirmed fixed in the installed package, by reading
160
+ `mem0.vector_stores.elasticsearch` and `mem0.configs.vector_stores.elasticsearch`
161
+ source directly, not by trusting the GitHub PR's "merged" status.**
162
+ `mem0/vector_stores/elasticsearch.py` defines a module-level
163
+ `_validate_filter(key, value)` helper (compiled against
164
+ `_SAFE_FILTER_KEY = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")` for the key,
165
+ and `isinstance(value, (str, int, float, bool))` for the value) and calls
166
+ it on every `(key, value)` pair immediately before building a `{"term":
167
+ {f"metadata.{key}": value}}` clause, in all three places
168
+ `ElasticsearchDB` constructs term-query filter clauses:
169
+ `search()` (the KNN pre-filter path), `keyword_search()` (the BM25 path),
170
+ and `list()`. A dict/list-valued filter -- the exact #5980 shape, e.g.
171
+ `{"user_id": {"$ne": ""}}` -- fails the `isinstance` check and raises
172
+ `ValueError` before the query is ever built or sent to the Elasticsearch
173
+ client, closing the access-control-bypass/cross-user-enumeration path the
174
+ issue describes. This was confirmed against real, merged upstream state:
175
+ `gh pr view 5980 --repo mem0ai/mem0` shows PR #5980 ("fix(elasticsearch):
176
+ validate filter keys and values to prevent term injection", author
177
+ HrushiYadav, merged 2026-07-02, closing #5976) describes exactly this
178
+ `_validate_filter()` addition, and the installed `mem0ai==2.0.12` source
179
+ matches that description line for line -- this is not a case where the
180
+ issue says "merged" but the pinned version predates the fix.
181
+ `tests/test_mem0_direct_adapter.py` exercises the real, installed
182
+ `mem0.vector_stores.elasticsearch.ElasticsearchDB` class directly (mocking
183
+ only the `elasticsearch.Elasticsearch` wire client) with both a benign,
184
+ scalar-valued filter (accepted, forwarded to a real `client.search()`
185
+ call) and a malicious dict-valued filter (rejected with `ValueError`
186
+ before the client is ever touched) -- see `evals/filter_injection.py` for
187
+ the harness-level eval built on top of this, via
188
+ `Mem0DirectAdapter.probe_raw_filter()` and `MemoryBackendAdapter.
189
+ supports_raw_filter_probe`. `ElasticsearchConfig.embedding_model_dims`
190
+ (`mem0/configs/vector_stores/elasticsearch.py`) is a plain `int` field
191
+ defaulting to `1536`, the same field name Redis/Valkey/Qdrant configs use
192
+ -- `_build_vector_store_config()` below threads the resolved embedder
193
+ dimension into it the same defensive way it already does for the other
194
+ three providers, so this adapter never relies on that 1536 default either.
195
+ `ElasticsearchConfig` also requires either `cloud_id` or `host`, and
196
+ either `api_key` or `user`+`password` (a `model_validator(mode="before")`
197
+ raises `ValueError` otherwise) -- this adapter threads
198
+ `MEM0_DIRECT_VECTOR_STORE_URL`/`MEM0_DIRECT_ELASTICSEARCH_URL` into
199
+ `host` and an optional `MEM0_DIRECT_ELASTICSEARCH_API_KEY` into `api_key`,
200
+ but does not synthesize a fake credential when neither is set: a caller
201
+ who configures `vector_store_provider="elasticsearch"` with no API key
202
+ and no `user`/`password` override in `vector_store_config` gets a real
203
+ `pydantic.ValidationError` from the installed package at first use,
204
+ caught and reported as `CorruptionSignal.CONFIG_REJECTED` -- the same
205
+ honest "fail loudly and specifically" shape Valkey's missing-dims case
206
+ already establishes above, not a bug this adapter works around silently.
207
+ This adapter has never been run against a live Elasticsearch cluster in
208
+ this environment -- see "What this adapter is NOT" below.
209
+
210
+ **mem0ai#3558 (Kuzu) cannot be reproduced against the installed package --
211
+ the code path it lived in does not exist here.** This was the one
212
+ surprising finding of this build: `mem0ai==2.0.12`'s `MemoryConfig`
213
+ (`mem0/configs/base.py`) has no `graph_store` field at all, no `kuzu`
214
+ dependency appears in any of the package's declared extras
215
+ (`mem0ai[extras]`, `[vector-stores]`, `[llms]`, ...), and no graph/kuzu
216
+ module exists anywhere under the installed `mem0/` tree (confirmed via
217
+ `grep -rl kuzu` over the full installed package -- the only hit is an
218
+ illustrative string in `mem0/exceptions.py`'s `DependencyError` docstring,
219
+ not a real code path). `mem0ai/mem0#3558`'s `kuzu_memory.py` is not present
220
+ in this release; whatever GitHub state that issue and its merged fix
221
+ targeted either predates this PyPI release or was refactored out of the
222
+ OSS package entirely. Worse, this was not just "unsupported" -- it was
223
+ **silently unsupported**: `MemoryConfig(graph_store={"provider": "kuzu",
224
+ ...})` raises nothing and produces a `MemoryConfig` with no trace the
225
+ `graph_store` key was ever given (pydantic's default `extra="ignore"`
226
+ behavior on this model, confirmed empirically during this build). That is
227
+ exactly the kind of silent no-op `MemoryBackendAdapter`'s own class
228
+ docstring in `base.py` says an adapter must never let happen ("If a
229
+ backend cannot support an operation ... report that fact ... rather than
230
+ faking a response"). So this adapter refuses a `graph_store_provider`
231
+ request outright, at construction, with a message naming this finding,
232
+ instead of passing it through to `Memory.from_config()` and pretending
233
+ graph-store selection did anything.
234
+
235
+ Because that removes the one bug this adapter was originally supposed to
236
+ make reachable, `Mem0DirectAdapter` instead reproduces the *bug class*
237
+ #3558 established -- a backend rejecting a missing/invalid
238
+ embedding-dimension config at construction time, before any store()/
239
+ query() call can silently corrupt state -- against a component that
240
+ *does* still exist and still has exactly that validation shape:
241
+ `mem0.configs.vector_stores.valkey.ValkeyConfig.embedding_model_dims` is a
242
+ required (no-default) `int` field, so constructing this adapter with
243
+ `vector_store_provider="valkey"` and no `embedding_model_dims` raises a
244
+ real `pydantic.ValidationError` (a `ValueError` subclass) from the
245
+ installed package, at the same point in the lifecycle (construction,
246
+ before any write) that the original Kuzu bug's `ValueError` fired. This is
247
+ an honest substitution for the retired code path, not a re-creation of it
248
+ -- see `CorruptionSignal.CONFIG_REJECTED` in `base.py` and
249
+ `test_config_rejected_...` in the test module for exactly what is and is
250
+ not being claimed.
251
+
252
+ ## What this adapter is NOT
253
+
254
+ It does not support `graph_store` (see above -- refused outright, loudly).
255
+ It does not implement every vector-store or embedder provider mem0 ships,
256
+ only the ones the eight motivating bugs concern: embedders `openai`,
257
+ `aws_bedrock`, `gemini`, `fastembed`; vector stores `redis`, `valkey`,
258
+ `qdrant`, `elasticsearch`. It has never been run against a live backend of
259
+ any kind in this environment -- every test mocks the vendor SDK/wire-client
260
+ boundary, same convention `docs/methodology.md` already documents for
261
+ `mem0_adapter.py`. `query()`'s `threshold` parameter is forwarded to the
262
+ real `Memory.search()` unexercised against a live Qdrant server -- see the
263
+ Qdrant support section above for exactly what was and was not confirmed
264
+ about #4297/#4453 against the installed package alone. `probe_raw_filter()`
265
+ (see base.py) is exercised against the real, installed
266
+ `mem0.vector_stores.elasticsearch.ElasticsearchDB` class with the
267
+ `Elasticsearch` wire client mocked, never against a live Elasticsearch
268
+ cluster -- see the Elasticsearch support section above for exactly what
269
+ was and was not confirmed about #5980 against the installed package alone.
270
+
271
+ ## Configuration
272
+
273
+ Gated on `MEM0_DIRECT_EMBEDDER_PROVIDER` (one of `openai`, `aws_bedrock`,
274
+ `gemini`, `fastembed`) as the primary "one env var, or SKIPPED" switch --
275
+ this backend is not in `cli.ALL_BACKENDS`, the same opt-in-only precedent
276
+ `Mem0SelfHostedAdapter` sets, since it targets a self-assembled in-process
277
+ stack rather than a single hosted vendor API. Depending on the chosen
278
+ embedder, a provider-specific credential is also required (missing ->
279
+ `BackendNotConfiguredError` naming that exact variable, never a silent
280
+ fallback):
281
+
282
+ * `openai` -> `OPENAI_API_KEY`
283
+ * `aws_bedrock` -> `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`
284
+ * `gemini` -> `GOOGLE_API_KEY`
285
+ * `fastembed` -> none (local ONNX model, no credential)
286
+
287
+ `MEM0_DIRECT_VECTOR_STORE_PROVIDER` (`redis`, `valkey`, `qdrant`, or
288
+ `elasticsearch`, default `redis`) selects the vector store;
289
+ `MEM0_DIRECT_VECTOR_STORE_URL` (or the provider-specific
290
+ `MEM0_DIRECT_QDRANT_URL`/`MEM0_DIRECT_REDIS_URL`/`MEM0_DIRECT_VALKEY_URL`/
291
+ `MEM0_DIRECT_ELASTICSEARCH_URL`) is required for whichever one is selected
292
+ -- for `elasticsearch` this value is threaded into `ElasticsearchConfig.host`,
293
+ not a `url` field (the installed config class has no such field; see the
294
+ Elasticsearch support section above), or a caller may instead pass an
295
+ explicit `cloud_id` via `vector_store_config` for Elastic Cloud. An
296
+ optional `MEM0_DIRECT_ELASTICSEARCH_API_KEY` is threaded into
297
+ `ElasticsearchConfig.api_key`; the installed package's own validator
298
+ requires either that or a `user`+`password` override in
299
+ `vector_store_config`, and raises loudly if neither is present (see
300
+ Elasticsearch support section above). `MEM0_DIRECT_EMBEDDING_DIMS`
301
+ optionally overrides the per-provider default dimension count threaded
302
+ into both the embedder config and the vector store config (including
303
+ Qdrant's and Elasticsearch's `embedding_model_dims`, which both otherwise
304
+ default to a hardcoded `1536` in the installed package -- see the Qdrant
305
+ and Elasticsearch support sections above), so they never silently
306
+ disagree. `query()` also accepts an optional `threshold` keyword argument,
307
+ forwarded straight
308
+ through to the real `Memory.search()`.
309
+
310
+ ## Custom fact-extraction prompt passthrough
311
+
312
+ `Mem0DirectAdapter.__init__` accepts an optional `custom_instructions`
313
+ constructor argument, threaded straight into the top-level
314
+ `custom_instructions` key of the config dict passed to
315
+ `mem0.Memory.from_config()`. Confirmed by reading the installed
316
+ `mem0ai==2.0.12` source directly (`mem0/configs/base.py`'s `MemoryConfig`):
317
+ there is no `custom_fact_extraction_prompt` field on the installed
318
+ `MemoryConfig` at all -- mem0 renamed that field to `custom_instructions`
319
+ (mem0ai/mem0#4740, documented in the installed package's own
320
+ `docs/changelog/sdk.mdx` and `docs/migration/oss-v2-to-v3.mdx`), and
321
+ `Memory.from_config()` (`mem0/memory/main.py`) does a plain
322
+ `MemoryConfig(**config_dict)`, so `custom_instructions` is the real,
323
+ current top-level key this adapter must set, not the older, since-renamed
324
+ name.
325
+
326
+ This closes the gap mem0ai/mem0#4573 (jamebobob's 32-day production audit
327
+ -- the founding rationale for `evals/extraction_quality.py` and its
328
+ `ExtractionQualitySignal`) and a follow-up production report from GitHub
329
+ user farrrr documented: farrrr reported that rewriting mem0's default
330
+ fact-extraction prompt measurably reduced the junk-retention rate the
331
+ audit found, but `Mem0DirectAdapter` had no constructor argument to set
332
+ one, so that specific mitigation was unreachable through this adapter.
333
+ With `custom_instructions` now threaded through, a caller can construct
334
+ two adapters -- one with the default prompt, one with a rewritten
335
+ `custom_instructions` -- and compare their junk-retention rate via
336
+ `extraction_quality.py`'s existing `ExtractionQualitySignal` classification
337
+ (building an automated A/B-comparison mode for that eval is a separate,
338
+ larger item, out of scope here).
339
+
340
+ ## Vendor embedder-call counting and cost attribution
341
+
342
+ An optional `cost_tracker` constructor argument (a `scoring.cost_tracker
343
+ .CostTracker`) enables `store()`'s `_CountingEmbedder`, which wraps
344
+ `memory.embedding_model`'s real `embed()`/`embed_batch()` for the
345
+ duration of a single `store()` call and reports vendor-side embedder-
346
+ call count/estimated-cost via `CostTracker.record_embed_calls()` --
347
+ closing an instrumentation gap this adapter previously had entirely
348
+ (`cost_tracker.py` tracked only memtrust's own judge-LLM spend). Closes
349
+ the gap that kept spike-spiegel-21's merged mem0ai/mem0#1900 fix
350
+ unobservable through this adapter -- see `evals/embedder_cost.py`'s
351
+ module docstring for the honest scope of what this measures against the
352
+ currently installed package (that PR's own targeted code path no longer
353
+ exists in `mem0ai==2.0.12`; this build measures the bug *class* against
354
+ the pipeline that exists today instead, the same honest-substitution
355
+ shape already established above for mem0ai/mem0#3558/Kuzu).
356
+
357
+ ## Language-degradation detection (non-Latin-script i18n)
358
+
359
+ `query()`'s new `explain` keyword argument (forwarded straight through
360
+ to the real, installed `Memory.search(explain=...)`) surfaces each
361
+ result's `score_details` breakdown, letting `QueryResult
362
+ .language_degradation_signal` report whether the hybrid retrieval
363
+ pipeline's BM25/entity-boost signals genuinely fired for a query, or
364
+ silently degraded to semantic-only retrieval -- see
365
+ `LanguageDegradationSignal` in `base.py` for the full mem0ai/mem0#4884
366
+ (wangjiawei-vegetable) provenance and `evals/language_degradation.py` for
367
+ the eval built on top of this.
368
+ """
369
+
370
+ from __future__ import annotations
371
+
372
+ import os
373
+ from collections.abc import Mapping
374
+ from contextlib import AbstractContextManager, nullcontext
375
+ from types import TracebackType
376
+ from typing import Any, Protocol
377
+
378
+ from memtrust.adapters.base import (
379
+ BackendAPIError,
380
+ BackendNotConfiguredError,
381
+ ConflictSignal,
382
+ CorruptionSignal,
383
+ DeleteResult,
384
+ ExtractionSignal,
385
+ LanguageDegradationSignal,
386
+ MemoryBackendAdapter,
387
+ MemoryRecord,
388
+ QueryResult,
389
+ RawFilterProbeResult,
390
+ StoreResult,
391
+ UpdateResult,
392
+ )
393
+ from memtrust.scoring.cost_tracker import CostTracker
394
+
395
+ #: The four embedder providers mem0ai#5671 (aws_bedrock), #4711
396
+ #: (fastembed), and #2304 (gemini, openai) concern. This adapter
397
+ #: deliberately wires up only these four, not mem0's full provider list --
398
+ #: see module docstring.
399
+ SUPPORTED_EMBEDDER_PROVIDERS = ("openai", "aws_bedrock", "gemini", "fastembed")
400
+
401
+ #: The vector store providers this adapter wires up: `redis`/`valkey` for
402
+ #: mem0ai#4362 (vector-zeroed-on-metadata-update), `qdrant` for
403
+ #: mem0ai#4297 (embedding-dimension mismatch) and mem0ai#4453 (search-
404
+ #: threshold inversion), and `elasticsearch` for mem0ai#5980 (filter-value
405
+ #: term-injection) -- see module docstring for what this build actually
406
+ #: confirmed about each against the installed package.
407
+ SUPPORTED_VECTOR_STORE_PROVIDERS = ("redis", "valkey", "qdrant", "elasticsearch")
408
+
409
+ #: Field name each installed `mem0.configs.vector_stores.*` config class
410
+ #: uses for its connection endpoint, confirmed by reading all four
411
+ #: installed config classes directly -- `redis`/`valkey` name it
412
+ #: `redis_url`/`valkey_url`, `qdrant` names it plain `url`, and
413
+ #: `elasticsearch` has no `url` field at all, only `host`/`port`/`cloud_id`
414
+ #: (see module docstring's Elasticsearch support section). Shared by
415
+ #: `__init__`'s configuration presence-check and `_build_vector_store_config`
416
+ #: below so the two never disagree about which key a given provider uses.
417
+ _VECTOR_STORE_URL_KEYS: dict[str, str] = {
418
+ "redis": "redis_url",
419
+ "valkey": "valkey_url",
420
+ "qdrant": "url",
421
+ "elasticsearch": "host",
422
+ }
423
+
424
+ #: Per-provider embedding dimension defaults, used only when neither
425
+ #: `embedder_config`/`vector_store_config` nor `MEM0_DIRECT_EMBEDDING_DIMS`
426
+ #: specify one -- keeps the embedder and vector-store dims in agreement by
427
+ #: construction for the common case, without silently overriding a caller
428
+ #: who set one explicitly (including a deliberate `None`, which is what the
429
+ #: CONFIG_REJECTED test case below relies on).
430
+ _DEFAULT_EMBEDDING_DIMS_BY_PROVIDER: dict[str, int] = {
431
+ "openai": 1536,
432
+ "aws_bedrock": 1024,
433
+ "gemini": 768,
434
+ "fastembed": 384,
435
+ }
436
+
437
+ _EMBEDDER_CREDENTIAL_ENV_VARS: dict[str, tuple[str, ...]] = {
438
+ "openai": ("OPENAI_API_KEY",),
439
+ "aws_bedrock": ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"),
440
+ "gemini": ("GOOGLE_API_KEY",),
441
+ "fastembed": (),
442
+ }
443
+
444
+
445
+ class _MemoryProtocol(Protocol):
446
+ """Shape this adapter needs from `mem0.Memory` (or a test double).
447
+
448
+ Defined as a Protocol, same convention `mempalace_adapter.py` uses for
449
+ `_PalaceProtocol`, so tests can inject a fake implementation without
450
+ the real, network-and-credential-dependent `mem0ai` package doing any
451
+ real vendor calls.
452
+ """
453
+
454
+ vector_store: Any
455
+ embedding_model: Any
456
+ """The real, installed `mem0.Memory`'s own embedder instance --
457
+ confirmed present by reading `mem0/memory/main.py` on the installed
458
+ `mem0ai==2.0.12` package (`self.embedding_model = EmbedderFactory
459
+ .create(...)` in `Memory.__init__`). Only read by `store()`'s
460
+ `_CountingEmbedder` wrapper below, which counts real vendor
461
+ embedder-API calls for cost attribution -- see
462
+ `scoring/cost_tracker.py`'s `EmbedCallEntry` and this module's "Vendor
463
+ embedder-call counting" section."""
464
+
465
+ def add(
466
+ self,
467
+ messages: object,
468
+ *,
469
+ user_id: str | None = None,
470
+ metadata: Mapping[str, object] | None = None,
471
+ infer: bool = ...,
472
+ ) -> dict[str, object]: ...
473
+
474
+ def search(
475
+ self,
476
+ query: str,
477
+ *,
478
+ filters: Mapping[str, object] | None = None,
479
+ top_k: int = ...,
480
+ threshold: float | None = ...,
481
+ explain: bool = ...,
482
+ ) -> dict[str, object]: ...
483
+
484
+ def update(
485
+ self, memory_id: str, text: str | None = None, metadata: Mapping[str, object] | None = None
486
+ ) -> dict[str, object]: ...
487
+
488
+ def delete(self, memory_id: str) -> object: ...
489
+
490
+
491
+ class _CountingEmbedder(AbstractContextManager["_CountingEmbedder"]):
492
+ """Wraps a real, installed mem0 embedder instance's `embed()`/
493
+ `embed_batch()` methods for the duration of a single `store()` call,
494
+ counting vendor-side embedding-API calls and total input characters --
495
+ the primitive `scoring/cost_tracker.py`'s `EmbedCallEntry` needs. See
496
+ this module's "Vendor embedder-call counting" section.
497
+
498
+ Every mem0 embedder class this adapter wires up (`OpenAIEmbedding`,
499
+ `AWSBedrockEmbedding`, `GoogleGenAIEmbedding`, `FastEmbedEmbedding`)
500
+ subclasses `mem0.embeddings.base.EmbeddingBase`, which declares both
501
+ `embed(text, action=None)` and `embed_batch(texts, action=None)` --
502
+ confirmed by reading the installed `mem0ai==2.0.12` package's
503
+ `mem0/embeddings/base.py` and every concrete subclass directly. This
504
+ wrapper monkeypatches the two *instance* methods (not the class),
505
+ counts calls/characters, and restores the originals on `__exit__` --
506
+ counting is scoped to exactly one `store()` call's real embedding
507
+ activity, never leaking across calls or adapters sharing the same
508
+ underlying `mem0.Memory`.
509
+
510
+ This does NOT reproduce spike-spiegel-21's original mem0ai/mem0#1900
511
+ diff literally -- that PR targeted an older mem0 architecture (a
512
+ separate LLM-driven ADD/UPDATE/DELETE-decision pass reusing a
513
+ `new_memories_with_actions` embedding) that no longer exists in the
514
+ installed `mem0ai==2.0.12`'s "V3 PHASED BATCH PIPELINE" (confirmed by
515
+ reading `mem0/memory/main.py`'s `_add_to_vector_store()` directly: its
516
+ Phase 1 embeds only the incoming query text for a similarity search,
517
+ and Phase 3 unconditionally batch-embeds every LLM-extracted fact with
518
+ no code path that reuses Phase 1's embedding for unmodified content).
519
+ Same honest-substitution shape this adapter's own module docstring
520
+ already establishes for mem0ai/mem0#3558 (Kuzu) -- see
521
+ `evals/embedder_cost.py`'s module docstring for what this build
522
+ actually measures against the currently installed package instead.
523
+ """
524
+
525
+ def __init__(self, embedder: Any) -> None:
526
+ self._embedder = embedder
527
+ self.call_count = 0
528
+ self.total_chars = 0
529
+ self._orig_embed = getattr(embedder, "embed", None)
530
+ self._orig_embed_batch = getattr(embedder, "embed_batch", None)
531
+
532
+ def __enter__(self) -> _CountingEmbedder:
533
+ if self._orig_embed is not None:
534
+
535
+ def counting_embed(text: str, *args: Any, **kwargs: Any) -> Any:
536
+ self.call_count += 1
537
+ self.total_chars += len(text) if isinstance(text, str) else 0
538
+ return self._orig_embed(text, *args, **kwargs) # type: ignore[misc]
539
+
540
+ self._embedder.embed = counting_embed
541
+ if self._orig_embed_batch is not None:
542
+
543
+ def counting_embed_batch(texts: list[str], *args: Any, **kwargs: Any) -> Any:
544
+ self.call_count += 1
545
+ self.total_chars += sum(len(t) for t in texts if isinstance(t, str))
546
+ return self._orig_embed_batch(texts, *args, **kwargs) # type: ignore[misc]
547
+
548
+ self._embedder.embed_batch = counting_embed_batch
549
+ return self
550
+
551
+ def __exit__(
552
+ self,
553
+ exc_type: type[BaseException] | None,
554
+ exc_value: BaseException | None,
555
+ traceback: TracebackType | None,
556
+ ) -> None:
557
+ if self._orig_embed is not None:
558
+ self._embedder.embed = self._orig_embed
559
+ if self._orig_embed_batch is not None:
560
+ self._embedder.embed_batch = self._orig_embed_batch
561
+
562
+
563
+ class Mem0DirectAdapter(MemoryBackendAdapter):
564
+ """Adapter that holds a direct, in-process `mem0.Memory` handle.
565
+
566
+ See this module's docstring for the full confidence breakdown and the
567
+ Kuzu/graph_store finding in particular before trusting this adapter's
568
+ CONFIG_REJECTED output as a literal reproduction of mem0ai/mem0#3558.
569
+ """
570
+
571
+ name = "mem0_direct"
572
+ env_var = "MEM0_DIRECT_EMBEDDER_PROVIDER"
573
+ supports_update = True
574
+ supports_raw_filter_probe = True
575
+ """This adapter holds a direct, in-process handle to the real
576
+ `mem0.Memory` object, including its constructed `vector_store` -- see
577
+ `probe_raw_filter()` below, which calls straight into
578
+ `vector_store.list(filters=...)`, bypassing `query()`'s hardcoded
579
+ `{"user_id": session_id}` filter. True unconditionally (not gated to
580
+ `vector_store_provider="elasticsearch"`): every installed vector-store
581
+ class this adapter wires up implements the same `list(filters=...)`
582
+ signature (`mem0.vector_stores.base.VectorStoreBase.list`), so the
583
+ probe itself always works -- what differs per provider is whether the
584
+ underlying store validates the filter value before using it, which is
585
+ exactly the fact evals/filter_injection.py exists to surface."""
586
+
587
+ def __init__(
588
+ self,
589
+ embedder_provider: str | None = None,
590
+ embedder_config: dict[str, object] | None = None,
591
+ vector_store_provider: str | None = None,
592
+ vector_store_config: dict[str, object] | None = None,
593
+ graph_store_provider: str | None = None,
594
+ custom_instructions: str | None = None,
595
+ cost_tracker: CostTracker | None = None,
596
+ memory: _MemoryProtocol | None = None,
597
+ ) -> None:
598
+ self._cost_tracker = cost_tracker
599
+ """Optional shared `CostTracker` this adapter reports vendor-side
600
+ embedder-call/cost attribution into (see `store()`'s
601
+ `_CountingEmbedder` usage and `scoring/cost_tracker.py`'s
602
+ `EmbedCallEntry`) -- `None` (the default) means "not tracked,"
603
+ matching every other optional-instrumentation default in this
604
+ codebase (e.g. `StoreResult.verified`'s `None`-means-"not
605
+ attempted" convention). Counting only actually happens when both
606
+ this is set AND `memory.embedding_model` is present -- see
607
+ `store()`."""
608
+ if memory is not None:
609
+ # Test-injection path: skip all env/credential resolution and
610
+ # real mem0.Memory construction entirely. Mirrors
611
+ # MemPalaceAdapter.__init__(palace=...) above.
612
+ self._memory: _MemoryProtocol | None = memory
613
+ self._construction_error: str | None = None
614
+ self._embedder_provider = embedder_provider or "injected"
615
+ self._vector_store_provider = vector_store_provider or "redis"
616
+ self._custom_instructions = custom_instructions
617
+ return
618
+
619
+ resolved_embedder_provider = embedder_provider or os.environ.get(self.env_var)
620
+ if not resolved_embedder_provider:
621
+ raise BackendNotConfiguredError(self.name, self.env_var)
622
+ if resolved_embedder_provider not in SUPPORTED_EMBEDDER_PROVIDERS:
623
+ raise BackendAPIError(
624
+ self.name,
625
+ f"unsupported embedder_provider {resolved_embedder_provider!r}; this adapter "
626
+ f"only wires up {SUPPORTED_EMBEDDER_PROVIDERS} -- the ones mem0ai/mem0#5671, "
627
+ "#4711, and #2304 concern. See module docstring.",
628
+ )
629
+ for required_var in _EMBEDDER_CREDENTIAL_ENV_VARS[resolved_embedder_provider]:
630
+ if not os.environ.get(required_var):
631
+ raise BackendNotConfiguredError(self.name, required_var)
632
+
633
+ resolved_graph_store_provider = graph_store_provider or os.environ.get(
634
+ "MEM0_DIRECT_GRAPH_STORE_PROVIDER"
635
+ )
636
+ if resolved_graph_store_provider:
637
+ raise BackendAPIError(
638
+ self.name,
639
+ f"graph_store_provider={resolved_graph_store_provider!r} was requested, but "
640
+ "the installed mem0ai package has no graph_store support at all: "
641
+ "MemoryConfig has no `graph_store` field, no `kuzu` dependency appears in any "
642
+ "declared extra, and no graph/kuzu module exists in the installed package "
643
+ "tree. mem0ai/mem0#3558's kuzu_memory.py is not present in this release. "
644
+ "Passing graph_store to Memory.from_config() would be silently ignored "
645
+ "(confirmed empirically during this adapter's build), not rejected -- this "
646
+ "adapter refuses the request loudly instead of reproducing that silent "
647
+ "no-op. See module docstring for the full finding.",
648
+ )
649
+
650
+ resolved_dims = _resolve_embedding_dims(embedder_config, resolved_embedder_provider)
651
+ built_embedder_config = _build_embedder_config(
652
+ resolved_embedder_provider, embedder_config or {}, resolved_dims
653
+ )
654
+
655
+ resolved_vector_store_provider = (
656
+ vector_store_provider or os.environ.get("MEM0_DIRECT_VECTOR_STORE_PROVIDER") or "redis"
657
+ )
658
+ if resolved_vector_store_provider not in SUPPORTED_VECTOR_STORE_PROVIDERS:
659
+ raise BackendAPIError(
660
+ self.name,
661
+ f"unsupported vector_store_provider {resolved_vector_store_provider!r}; this "
662
+ f"adapter only wires up {SUPPORTED_VECTOR_STORE_PROVIDERS} -- the ones "
663
+ "mem0ai/mem0#4362, #4297, #4453, and #5980 concern. See module docstring.",
664
+ )
665
+ url_env_var = f"MEM0_DIRECT_{resolved_vector_store_provider.upper()}_URL"
666
+ resolved_url = os.environ.get("MEM0_DIRECT_VECTOR_STORE_URL") or os.environ.get(url_env_var)
667
+ url_key = _VECTOR_STORE_URL_KEYS[resolved_vector_store_provider]
668
+ _configured_via_vector_store_config = url_key in (vector_store_config or {}) or (
669
+ resolved_vector_store_provider == "elasticsearch"
670
+ and "cloud_id" in (vector_store_config or {})
671
+ )
672
+ if not resolved_url and not _configured_via_vector_store_config:
673
+ raise BackendNotConfiguredError(self.name, "MEM0_DIRECT_VECTOR_STORE_URL")
674
+ built_vector_store_config = _build_vector_store_config(
675
+ resolved_vector_store_provider,
676
+ vector_store_config or {},
677
+ resolved_url,
678
+ resolved_dims,
679
+ )
680
+
681
+ self._embedder_provider = resolved_embedder_provider
682
+ self._vector_store_provider = resolved_vector_store_provider
683
+ self._custom_instructions = custom_instructions
684
+ self._config_dict: dict[str, object] = {
685
+ "embedder": {"provider": resolved_embedder_provider, "config": built_embedder_config},
686
+ "vector_store": {
687
+ "provider": resolved_vector_store_provider,
688
+ "config": built_vector_store_config,
689
+ },
690
+ # `MemoryConfig.custom_instructions` (mem0/configs/base.py) is
691
+ # the real, current top-level key -- mem0 renamed the field
692
+ # from `custom_fact_extraction_prompt` to `custom_instructions`
693
+ # in mem0ai/mem0#4740. See module docstring's "Custom
694
+ # fact-extraction prompt passthrough" section. Set
695
+ # unconditionally, same convention `_build_embedder_config`
696
+ # uses for `embedding_dims`: `None` here is what mem0's own
697
+ # `MemoryConfig.custom_instructions` field already defaults to,
698
+ # so passing it explicitly changes nothing when the caller
699
+ # didn't set one.
700
+ "custom_instructions": custom_instructions,
701
+ }
702
+ self._memory = None
703
+ self._construction_error = None
704
+
705
+ def _get_memory(self) -> _MemoryProtocol:
706
+ """Lazily construct the real `mem0.Memory`, same "fails loudly and
707
+ specifically at first use" convention `MemPalaceAdapter._get_palace()`
708
+ uses above -- and, unlike that method, deliberately *not* re-raising
709
+ a caught construction-time `ValueError`/`pydantic.ValidationError`
710
+ here. store() is the one place that classifies that specific
711
+ failure as `CorruptionSignal.CONFIG_REJECTED` instead of letting it
712
+ propagate as an unhandled crash (see module docstring's Kuzu/
713
+ `graph_store` finding and CONFIG_REJECTED's docstring in base.py);
714
+ query()/update()/delete() re-raise it as a normal `BackendAPIError`
715
+ since neither `QueryResult` nor `DeleteResult` has a
716
+ `corruption_signal` field to report it through gracefully.
717
+ """
718
+ if self._memory is not None:
719
+ return self._memory
720
+ if self._construction_error is not None:
721
+ raise ValueError(self._construction_error)
722
+ try:
723
+ # `mem0ai` is declared in the optional `mem0-direct` extra (see
724
+ # pyproject.toml), which CI's typecheck job installs -- so mypy
725
+ # sees it as installed-but-untyped (no py.typed marker in the
726
+ # package) rather than missing. A contributor running
727
+ # `mypy --strict` without that extra installed would see
728
+ # import-not-found here instead; that's expected, not a bug --
729
+ # see module docstring for why this dependency is optional.
730
+ import mem0 # type: ignore[import-untyped]
731
+ except ImportError as exc:
732
+ raise BackendAPIError(
733
+ self.name,
734
+ "the `mem0ai` package is not installed. Install it with `pip install mem0ai` "
735
+ "-- this adapter needs the real library in-process, unlike Mem0Adapter/"
736
+ "Mem0SelfHostedAdapter in mem0_adapter.py, which only need httpx.",
737
+ ) from exc
738
+ try:
739
+ self._memory = mem0.Memory.from_config(self._config_dict)
740
+ except ValueError as exc:
741
+ # Cache the message so a second store()/query() call doesn't
742
+ # need to re-attempt (and re-fail) real construction.
743
+ self._construction_error = str(exc)
744
+ raise
745
+ return self._memory
746
+
747
+ def store(
748
+ self,
749
+ session_id: str,
750
+ content: str,
751
+ metadata: dict[str, str] | None = None,
752
+ mode: str | None = None,
753
+ ) -> StoreResult:
754
+ # No documented operating-mode variant, same no-op convention as
755
+ # every other adapter's store() -- see MemoryBackendAdapter.supported_modes.
756
+ del mode
757
+ timer = self._timed()
758
+ try:
759
+ memory = self._get_memory()
760
+ except ValueError as exc:
761
+ # This is the CONFIG_REJECTED path -- see module docstring and
762
+ # _get_memory()'s docstring for exactly what this does and
763
+ # does not reproduce.
764
+ return StoreResult(
765
+ memory_id="",
766
+ latency_ms=timer.elapsed_ms(),
767
+ raw={"error": str(exc)},
768
+ corruption_signal=CorruptionSignal.CONFIG_REJECTED,
769
+ # NOT_APPLICABLE, not EMPTY_EXTRACTION -- memory_id="" here
770
+ # means construction was rejected before any extraction
771
+ # could run, a different failure class than "extraction ran
772
+ # and found nothing." See ExtractionSignal.NOT_APPLICABLE
773
+ # in base.py.
774
+ extraction_signal=ExtractionSignal.NOT_APPLICABLE,
775
+ )
776
+ embedder = getattr(memory, "embedding_model", None)
777
+ counting = (
778
+ _CountingEmbedder(embedder)
779
+ if (embedder is not None and self._cost_tracker is not None)
780
+ else None
781
+ )
782
+ try:
783
+ with counting or nullcontext():
784
+ data = memory.add(content, user_id=session_id, metadata=metadata or {})
785
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
786
+ raise BackendAPIError(self.name, str(exc)) from exc
787
+ if counting is not None and self._cost_tracker is not None:
788
+ # See scoring/cost_tracker.py's EMBEDDER_PRICING_PER_MILLION_TOKENS
789
+ # docstring for why this is a char/4 heuristic, not a real
790
+ # vendor-reported token count.
791
+ self._cost_tracker.record_embed_calls(
792
+ label=f"{self.name}:store:{session_id}",
793
+ provider=self._embedder_provider,
794
+ call_count=counting.call_count,
795
+ estimated_tokens=counting.total_chars // 4,
796
+ )
797
+ memory_id = _extract_memory_id(data)
798
+ # Same mem0ai/mem0#5178 gap this adapter's REST siblings in
799
+ # mem0_adapter.py guard against -- Memory.add() can return without
800
+ # raising and carry no usable id anywhere in its response.
801
+ extraction_signal = (
802
+ ExtractionSignal.EMPTY_EXTRACTION if not memory_id else ExtractionSignal.FACTS_EXTRACTED
803
+ )
804
+ return StoreResult(
805
+ memory_id=memory_id,
806
+ latency_ms=timer.elapsed_ms(),
807
+ raw=data if isinstance(data, dict) else {"results": data},
808
+ corruption_signal=CorruptionSignal.CLEAN,
809
+ extraction_signal=extraction_signal,
810
+ )
811
+
812
+ def query(
813
+ self,
814
+ session_id: str,
815
+ query: str,
816
+ top_k: int = 5,
817
+ mode: str | None = None,
818
+ threshold: float | None = None,
819
+ explain: bool = False,
820
+ ) -> QueryResult:
821
+ """Retrieve memories relevant to `query` within `session_id`.
822
+
823
+ `threshold` is forwarded straight through to the real, installed
824
+ `mem0.Memory.search()`'s own `threshold` parameter (default `0.1`
825
+ there if this adapter passes `None`, per the installed
826
+ `Memory._search_vector_store()` -- confirmed by reading
827
+ `mem0/memory/main.py` during this build). This is the parameter
828
+ that makes mem0ai/mem0#4453 (search-threshold inversion) reachable
829
+ through this adapter, the same way `Mem0SelfHostedAdapter.query()`'s
830
+ `threshold` parameter makes it reachable over REST -- see module
831
+ docstring for what this build actually confirmed about #4453
832
+ against the installed package (short version: the bug class does
833
+ not currently reproduce -- every installed vector store, including
834
+ Qdrant, is confirmed to return similarity-oriented, higher-is-
835
+ better scores before `Memory`'s own threshold filtering ever sees
836
+ them, per `VectorStoreBase.search()`'s documented contract and
837
+ `tests/test_mem0_direct_adapter.py`'s real-package regression
838
+ tests against `mem0.utils.scoring.score_and_rank`).
839
+
840
+ `explain` is forwarded to the real, installed `Memory.search()`'s
841
+ own `explain` parameter (confirmed by reading `mem0/memory/main.py`
842
+ during this build: `explain (bool, optional): Whether to include
843
+ score_details for each result. Defaults to False.`). When `True`,
844
+ each returned result's `score_details` dict (`bm25_score`,
845
+ `entity_boost`, `semantic_score`, ... -- confirmed by reading
846
+ `mem0/utils/scoring.py::score_and_rank()` directly) is what this
847
+ method inspects to set `QueryResult.language_degradation_signal`
848
+ -- see `LanguageDegradationSignal` in base.py and
849
+ `evals/language_degradation.py` for the mem0ai/mem0#4884
850
+ motivation (wangjiawei-vegetable). `explain=False` (the default)
851
+ leaves `language_degradation_signal` at its own default
852
+ `NOT_APPLICABLE`, same backward-compatible-default convention
853
+ `threshold=None` already establishes for this method.
854
+ """
855
+ del mode # no-op, see store() above
856
+ timer = self._timed()
857
+ try:
858
+ memory = self._get_memory()
859
+ except ValueError as exc:
860
+ raise BackendAPIError(self.name, f"config rejected: {exc}") from exc
861
+ try:
862
+ data = memory.search(
863
+ query,
864
+ filters={"user_id": session_id},
865
+ top_k=top_k,
866
+ threshold=threshold,
867
+ explain=explain,
868
+ )
869
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
870
+ raise BackendAPIError(self.name, str(exc)) from exc
871
+
872
+ raw_results_obj = data.get("results", []) if isinstance(data, dict) else []
873
+ raw_results: list[dict[str, object]] = (
874
+ [item for item in raw_results_obj if isinstance(item, dict)]
875
+ if isinstance(raw_results_obj, list)
876
+ else []
877
+ )
878
+ records = [
879
+ MemoryRecord(
880
+ memory_id=str(item.get("id", "")),
881
+ content=str(item.get("memory", "")),
882
+ score=item.get("score"), # type: ignore[arg-type]
883
+ created_at=item.get("created_at"), # type: ignore[arg-type]
884
+ metadata={
885
+ k: str(v)
886
+ for k, v in item.items()
887
+ if k not in {"id", "memory", "score", "created_at"}
888
+ },
889
+ raw=item,
890
+ )
891
+ for item in raw_results
892
+ ]
893
+ # Same reasoning as Mem0Adapter/Mem0SelfHostedAdapter's query() in
894
+ # mem0_adapter.py: the real Memory.search() response carries no
895
+ # documented per-result conflict marker either -- conflict
896
+ # resolution happens invisibly inside mem0's own add/update
897
+ # pipeline. NOT_APPLICABLE here, not a guess.
898
+ return QueryResult(
899
+ records=records,
900
+ conflict_signal=ConflictSignal.NOT_APPLICABLE,
901
+ latency_ms=timer.elapsed_ms(),
902
+ language_degradation_signal=_classify_language_degradation(raw_results, explain),
903
+ raw=data if isinstance(data, dict) else {"results": data},
904
+ )
905
+
906
+ def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
907
+ """Store a fact that may contradict a previously stored one, via a
908
+ full-content `Memory.update(memory_id, text=content)` call.
909
+
910
+ Because `text` is provided, the real `Memory._update_memory()`
911
+ always computes a fresh embedding for it (see module docstring and
912
+ `update_metadata_only()` below for the metadata-only case, which
913
+ behaves very differently) -- there is no vector-corruption surface
914
+ to inspect for this specific call shape, so `corruption_signal` is
915
+ reported as CLEAN rather than guessed at NOT_APPLICABLE: this
916
+ adapter does have the surface to observe corruption (see
917
+ `update_metadata_only()`), it just has nothing to report for a
918
+ call that always re-embeds.
919
+ """
920
+ del session_id # Memory.update() scopes by memory_id, not session
921
+ timer = self._timed()
922
+ memory = self._get_memory_or_raise_backend_error()
923
+ try:
924
+ data = memory.update(memory_id, text=content)
925
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
926
+ raise BackendAPIError(self.name, str(exc)) from exc
927
+ return UpdateResult(
928
+ memory_id=memory_id,
929
+ acknowledged=True,
930
+ latency_ms=timer.elapsed_ms(),
931
+ raw=data if isinstance(data, dict) else {},
932
+ corruption_signal=CorruptionSignal.CLEAN,
933
+ )
934
+
935
+ def update_metadata_only(self, memory_id: str, metadata: dict[str, str]) -> UpdateResult:
936
+ """Update only a memory's metadata, omitting `text` entirely -- the
937
+ call shape needed to exercise mem0ai/mem0#4362's fixed guard.
938
+
939
+ This deliberately does NOT go through `Memory.update(memory_id,
940
+ text=None, metadata=...)`. Reading the installed
941
+ `Memory._update_memory()` source (see module docstring) shows that
942
+ method falls back to the *previous* text (`data = prev_value`) and
943
+ always recomputes a real embedding for it -- `vector` is never
944
+ `None` by the time it reaches `vector_store.update()` through that
945
+ path in this mem0ai version, so it can never exercise the guard
946
+ #4362 added. Instead, this method calls
947
+ `self._memory.vector_store.update(vector_id=memory_id, vector=None,
948
+ payload=...)` directly -- the exact call shape (and the exact
949
+ component) #4362's bug and fix concern, and, per this build's
950
+ reading of `Memory._add_to_vector_store()`'s entity-linking phase,
951
+ a call shape mem0's own code does make under other circumstances
952
+ (`entity_store.update(..., vector=None, ...)`), just not through
953
+ the metadata-only `Memory.update()` path this method's name
954
+ suggests it would.
955
+
956
+ Returns `corruption_signal=VECTOR_ZEROED` if the embedding this
957
+ adapter can observe after the update no longer matches what was
958
+ there before (best-effort raw-client inspection -- see
959
+ `_read_raw_embedding_bytes()`; this is not part of mem0's own
960
+ `VectorStoreBase.get()` contract, which does not return raw vector
961
+ bytes at all), `CLEAN` if it is unchanged, or `NOT_APPLICABLE` if
962
+ this adapter has no raw-inspection support for the configured
963
+ vector store provider.
964
+ """
965
+ timer = self._timed()
966
+ memory = self._get_memory_or_raise_backend_error()
967
+ vector_store = memory.vector_store
968
+ try:
969
+ existing = vector_store.get(vector_id=memory_id)
970
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
971
+ raise BackendAPIError(self.name, str(exc)) from exc
972
+ if existing is None:
973
+ raise BackendAPIError(self.name, f"no stored memory with id {memory_id!r} to update")
974
+ payload = dict(getattr(existing, "payload", existing) or {})
975
+ payload.update(metadata)
976
+
977
+ before_bytes = _read_raw_embedding_bytes(
978
+ vector_store, self._vector_store_provider, memory_id
979
+ )
980
+ try:
981
+ vector_store.update(vector_id=memory_id, vector=None, payload=payload)
982
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
983
+ raise BackendAPIError(self.name, str(exc)) from exc
984
+ after_bytes = _read_raw_embedding_bytes(
985
+ vector_store, self._vector_store_provider, memory_id
986
+ )
987
+
988
+ if before_bytes is None or after_bytes is None:
989
+ corruption_signal = CorruptionSignal.NOT_APPLICABLE
990
+ elif after_bytes == before_bytes:
991
+ corruption_signal = CorruptionSignal.CLEAN
992
+ else:
993
+ corruption_signal = CorruptionSignal.VECTOR_ZEROED
994
+
995
+ return UpdateResult(
996
+ memory_id=memory_id,
997
+ acknowledged=True,
998
+ latency_ms=timer.elapsed_ms(),
999
+ raw={
1000
+ "before_embedding_bytes": before_bytes,
1001
+ "after_embedding_bytes": after_bytes,
1002
+ },
1003
+ corruption_signal=corruption_signal,
1004
+ )
1005
+
1006
+ def delete(self, memory_id: str) -> DeleteResult:
1007
+ timer = self._timed()
1008
+ memory = self._get_memory_or_raise_backend_error()
1009
+ try:
1010
+ memory.delete(memory_id)
1011
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
1012
+ raise BackendAPIError(self.name, str(exc)) from exc
1013
+ return DeleteResult(success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms())
1014
+
1015
+ def probe_raw_filter(self, filters: dict[str, object]) -> RawFilterProbeResult:
1016
+ """Submit `filters` directly to `self._memory.vector_store.list()`,
1017
+ the real, installed vector store's own filter-query-building layer
1018
+ -- bypassing `query()`'s hardcoded `{"user_id": session_id}` filter
1019
+ entirely. `list()` is used rather than `search()` because it needs
1020
+ no query embedding (`mem0.vector_stores.base.VectorStoreBase.list`
1021
+ takes only `filters`/`top_k`), so this probes the filter-building
1022
+ layer in isolation, without also depending on the embedder being
1023
+ configured/reachable. This is the primitive
1024
+ evals/filter_injection.py needs to reproduce mem0ai/mem0#5980's
1025
+ exact injection shape (a dict/list-valued filter value) against the
1026
+ real, installed `mem0.vector_stores.elasticsearch.ElasticsearchDB`
1027
+ -- see this module's docstring, "Elasticsearch support" section.
1028
+
1029
+ A construction-time config rejection (e.g. Valkey's missing
1030
+ `embedding_model_dims`, or Elasticsearch's missing auth credential
1031
+ -- see module docstring) is reported as `accepted=False` here too,
1032
+ same as any other exception this call can raise -- it is not
1033
+ itself evidence about filter validation, but it is not silently
1034
+ swallowed either.
1035
+ """
1036
+ try:
1037
+ memory = self._get_memory()
1038
+ except ValueError as exc:
1039
+ # Never reached the vector store's filter-building layer at
1040
+ # all -- applicable=False, not a filter-validation verdict.
1041
+ return RawFilterProbeResult(
1042
+ accepted=False, error=f"config rejected: {exc}", applicable=False, raw={}
1043
+ )
1044
+ vector_store = memory.vector_store
1045
+ try:
1046
+ result = vector_store.list(filters=dict(filters))
1047
+ except Exception as exc: # noqa: BLE001 - real vendor call, classify uniformly
1048
+ return RawFilterProbeResult(accepted=False, error=str(exc), raw={})
1049
+ return RawFilterProbeResult(
1050
+ accepted=True, error=None, raw={"result_repr": repr(result)[:500]}
1051
+ )
1052
+
1053
+ def _get_memory_or_raise_backend_error(self) -> _MemoryProtocol:
1054
+ try:
1055
+ return self._get_memory()
1056
+ except ValueError as exc:
1057
+ raise BackendAPIError(self.name, f"config rejected: {exc}") from exc
1058
+
1059
+
1060
+ def _resolve_embedding_dims(embedder_config: dict[str, object] | None, provider: str) -> int | None:
1061
+ """Resolve the embedding dimension count to thread through both the
1062
+ embedder config and the vector-store config, so they agree unless a
1063
+ caller deliberately breaks that (see CONFIG_REJECTED test case).
1064
+
1065
+ Priority: an explicit `embedding_dims` key in `embedder_config` (even
1066
+ `None`, which a caller sets on purpose to reproduce the bad-config
1067
+ class) > `MEM0_DIRECT_EMBEDDING_DIMS` > this module's per-provider
1068
+ default.
1069
+ """
1070
+ if embedder_config is not None and "embedding_dims" in embedder_config:
1071
+ value = embedder_config["embedding_dims"]
1072
+ if value is None:
1073
+ return None
1074
+ if isinstance(value, int | float | str):
1075
+ return int(value)
1076
+ raise TypeError(f"embedding_dims must be an int, float, str, or None, got {type(value)!r}")
1077
+ env_value = os.environ.get("MEM0_DIRECT_EMBEDDING_DIMS")
1078
+ if env_value:
1079
+ return int(env_value)
1080
+ return _DEFAULT_EMBEDDING_DIMS_BY_PROVIDER.get(provider)
1081
+
1082
+
1083
+ def _build_embedder_config(
1084
+ provider: str, embedder_config: dict[str, object], resolved_dims: int | None
1085
+ ) -> dict[str, object]:
1086
+ config: dict[str, object] = dict(embedder_config)
1087
+ config["embedding_dims"] = resolved_dims
1088
+ if provider == "openai" and "api_key" not in config:
1089
+ config["api_key"] = os.environ.get("OPENAI_API_KEY")
1090
+ elif provider == "aws_bedrock":
1091
+ config.setdefault("aws_access_key_id", os.environ.get("AWS_ACCESS_KEY_ID"))
1092
+ config.setdefault("aws_secret_access_key", os.environ.get("AWS_SECRET_ACCESS_KEY"))
1093
+ config.setdefault("aws_region", os.environ.get("AWS_REGION", "us-west-2"))
1094
+ config.setdefault("model", "amazon.titan-embed-text-v2:0")
1095
+ elif provider == "gemini" and "api_key" not in config:
1096
+ config["api_key"] = os.environ.get("GOOGLE_API_KEY")
1097
+ return config
1098
+
1099
+
1100
+ def _build_vector_store_config(
1101
+ provider: str,
1102
+ vector_store_config: dict[str, object],
1103
+ resolved_url: str | None,
1104
+ resolved_dims: int | None,
1105
+ ) -> dict[str, object]:
1106
+ config: dict[str, object] = dict(vector_store_config)
1107
+ # `mem0.configs.vector_stores.{redis,valkey}.py` name their URL field
1108
+ # `redis_url`/`valkey_url`; `mem0.configs.vector_stores.qdrant.QdrantConfig`
1109
+ # names it plain `url`; `mem0.configs.vector_stores.elasticsearch.
1110
+ # ElasticsearchConfig` has no `url` field at all, only `host` (plus
1111
+ # `port`/`cloud_id`) -- confirmed by reading all four installed config
1112
+ # classes directly. See _VECTOR_STORE_URL_KEYS above.
1113
+ url_key = _VECTOR_STORE_URL_KEYS[provider]
1114
+ if url_key not in config and resolved_url is not None:
1115
+ config[url_key] = resolved_url
1116
+ if provider == "elasticsearch" and "api_key" not in config and "user" not in config:
1117
+ # ElasticsearchConfig.validate_auth requires api_key OR user+password
1118
+ # -- threaded only when actually set (never a fake/empty credential;
1119
+ # a caller with neither gets a real, honest pydantic.ValidationError
1120
+ # from the installed package at first use -- see module docstring's
1121
+ # Elasticsearch support section).
1122
+ env_api_key = os.environ.get("MEM0_DIRECT_ELASTICSEARCH_API_KEY")
1123
+ if env_api_key:
1124
+ config["api_key"] = env_api_key
1125
+ if "embedding_model_dims" not in config:
1126
+ config["embedding_model_dims"] = resolved_dims
1127
+ config.setdefault("collection_name", "memtrust_mem0_direct")
1128
+ return config
1129
+
1130
+
1131
+ def _read_raw_embedding_bytes(vector_store: object, provider: str, memory_id: str) -> bytes | None:
1132
+ """Best-effort direct inspection of the raw stored embedding bytes for
1133
+ `memory_id`, reaching past `VectorStoreBase.get()` (which only exposes
1134
+ metadata, not the vector itself -- confirmed by reading
1135
+ `RedisDB.get()`/`ValkeyDB`'s inherited base `get()` during this
1136
+ build) into each store's own real, low-level wire client.
1137
+
1138
+ Returns `None` (never raises) if the configured provider has no known
1139
+ raw-inspection path, or if the underlying client call itself fails --
1140
+ a raw-inspection miss is reported as NOT_APPLICABLE by callers, not
1141
+ treated as proof of corruption.
1142
+ """
1143
+ try:
1144
+ if provider == "redis":
1145
+ prefix = vector_store.schema["index"]["prefix"] # type: ignore[attr-defined]
1146
+ key = f"{prefix}:{memory_id}"
1147
+ raw = vector_store.client.hget(key, "embedding") # type: ignore[attr-defined]
1148
+ elif provider == "valkey":
1149
+ prefix = vector_store.prefix # type: ignore[attr-defined]
1150
+ key = f"{prefix}:{memory_id}"
1151
+ raw = vector_store.client.hget(key, "embedding") # type: ignore[attr-defined]
1152
+ else:
1153
+ return None
1154
+ except Exception: # noqa: BLE001 - best-effort inspection, never fatal
1155
+ return None
1156
+ return raw if isinstance(raw, bytes) else None
1157
+
1158
+
1159
+ def _extract_memory_id(data: object) -> str:
1160
+ if isinstance(data, dict):
1161
+ results = data.get("results")
1162
+ if isinstance(results, list) and results and isinstance(results[0], dict):
1163
+ return str(results[0].get("id", ""))
1164
+ if "id" in data:
1165
+ return str(data["id"])
1166
+ return ""
1167
+
1168
+
1169
+ def _classify_language_degradation(
1170
+ raw_results: list[dict[str, object]], explain: bool
1171
+ ) -> LanguageDegradationSignal:
1172
+ """Classify whether the hybrid retrieval pipeline's BM25/entity-boost
1173
+ signals fired for this query, from each result's real, installed
1174
+ `score_details` breakdown -- see `LanguageDegradationSignal`'s
1175
+ docstring in base.py and `query()`'s own docstring above for exactly
1176
+ where `score_details` comes from.
1177
+
1178
+ Never trusts a single result's `score_details` in isolation: a query
1179
+ is only classified `SEMANTIC_ONLY_DEGRADED` when at least one result
1180
+ actually carries a `score_details` breakdown to inspect AND every such
1181
+ result shows zero contribution from both signals -- one result with a
1182
+ nonzero `bm25_score`/`entity_boost` is concrete evidence the hybrid
1183
+ pipeline genuinely engaged for this query, even if most results didn't
1184
+ individually benefit from it (mirrors `evals/contradiction.py::
1185
+ classify_case()`'s "never a blind pass-through of an adapter's bare
1186
+ self-report" design principle: this reads the same real per-result
1187
+ evidence every result set carries, not a single top-level flag mem0's
1188
+ response never exposes in the first place). If NO result carries a
1189
+ `score_details` field at all (an older mem0 version, or `explain`
1190
+ wasn't actually honored by whatever handled the call), that is a
1191
+ "nothing was observed" case -- NOT_APPLICABLE, never guessed as
1192
+ degradation just because the diagnostic field itself was absent.
1193
+ """
1194
+ if not explain or not raw_results:
1195
+ return LanguageDegradationSignal.NOT_APPLICABLE
1196
+
1197
+ any_score_details_observed = False
1198
+ any_bm25_or_entity = False
1199
+ for item in raw_results:
1200
+ details = item.get("score_details")
1201
+ if not isinstance(details, dict):
1202
+ continue
1203
+ any_score_details_observed = True
1204
+ bm25_score = details.get("bm25_score") or 0.0
1205
+ entity_boost = details.get("entity_boost") or 0.0
1206
+ try:
1207
+ if float(bm25_score) > 0.0 or float(entity_boost) > 0.0:
1208
+ any_bm25_or_entity = True
1209
+ break
1210
+ except (TypeError, ValueError):
1211
+ continue
1212
+
1213
+ if not any_score_details_observed:
1214
+ return LanguageDegradationSignal.NOT_APPLICABLE
1215
+ if any_bm25_or_entity:
1216
+ return LanguageDegradationSignal.HYBRID_SIGNALS_ACTIVE
1217
+ return LanguageDegradationSignal.SEMANTIC_ONLY_DEGRADED