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,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
|