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,882 @@
|
|
|
1
|
+
"""Adapter for self-hosted `graphiti-core` (https://github.com/getzep/graphiti),
|
|
2
|
+
the open-source temporal-knowledge-graph engine that also powers Zep Cloud.
|
|
3
|
+
|
|
4
|
+
Confidence: MEDIUM on wire-level shape, LOW on live end-to-end behavior.
|
|
5
|
+
|
|
6
|
+
`zep_graphiti_adapter.py`'s `ZepGraphitiAdapter` targets Zep Cloud's hosted
|
|
7
|
+
REST API. This adapter is the second, separately-configured adapter
|
|
8
|
+
docs/methodology.md already calls for under "Why Zep targets the hosted
|
|
9
|
+
Cloud API, not self-hosted Graphiti": *"If self-hosted Graphiti support is
|
|
10
|
+
wanted later, it should be a second adapter (e.g.
|
|
11
|
+
`zep_graphiti_selfhosted_adapter.py`) with its own configuration story, not
|
|
12
|
+
a silent branch inside this one."* That is exactly what this file is.
|
|
13
|
+
|
|
14
|
+
## Why this adapter exists
|
|
15
|
+
|
|
16
|
+
Ten real, independently-verified graphiti-core bugs live entirely in the
|
|
17
|
+
self-hosted library layer that `ZepGraphitiAdapter` (hosted REST) cannot
|
|
18
|
+
reach at all, because Zep Cloud's REST surface never exposes graphiti-core's
|
|
19
|
+
internals to a caller:
|
|
20
|
+
|
|
21
|
+
* **getzep/graphiti#1302** (open as of this build): `lucene_sanitize()`
|
|
22
|
+
in `graphiti_core/helpers.py` builds a `str.translate()` escape map
|
|
23
|
+
that includes every uppercase `O`, `R`, `N`, `T`, `A`, `D` character,
|
|
24
|
+
intending to escape the Lucene boolean operators `AND`/`OR`/`NOT` --
|
|
25
|
+
but the map operates per-character, not per-token, so it backslash-
|
|
26
|
+
escapes those letters *anywhere* they appear in a query string (e.g.
|
|
27
|
+
"ORION" becomes "\\O\\RION"), silently degrading BM25 full-text
|
|
28
|
+
ranking for any query containing those letters. No exception, no
|
|
29
|
+
error -- just worse-ranked (or unranked) results. Confirmed by fetching
|
|
30
|
+
`graphiti_core/helpers.py` from `getzep/graphiti`'s `main` branch
|
|
31
|
+
directly on 2026-07-16; the six single-letter translate entries
|
|
32
|
+
(`'O': r'\\O', 'R': r'\\R', 'N': r'\\N', 'T': r'\\T', 'A': r'\\A',
|
|
33
|
+
'D': r'\\D'`) are present verbatim, confirming the bug is still live
|
|
34
|
+
upstream as of this adapter's build.
|
|
35
|
+
* **getzep/graphiti#836** (open as of this build): `add_episode(...,
|
|
36
|
+
update_communities=True)` raises `ValueError: too many values to
|
|
37
|
+
unpack` (or "not enough values to unpack") whenever the episode's
|
|
38
|
+
extracted node count is not exactly 2. Confirmed by fetching
|
|
39
|
+
`graphiti_core/graphiti.py` and
|
|
40
|
+
`graphiti_core/utils/maintenance/community_operations.py` from
|
|
41
|
+
`main` on 2026-07-16: `add_episode()`'s community-update branch does
|
|
42
|
+
`communities, community_edges = await semaphore_gather(
|
|
43
|
+
*[update_community(...) for node in nodes], ...
|
|
44
|
+
)`
|
|
45
|
+
but `semaphore_gather` returns a plain `list` with one 2-tuple
|
|
46
|
+
`(list[CommunityNode], list[CommunityEdge])` per node in `nodes` --
|
|
47
|
+
so this line only succeeds when `len(nodes) == 2`. Any episode that
|
|
48
|
+
extracts 0, 1, or 3+ entities (the overwhelmingly common case) raises
|
|
49
|
+
`ValueError` from this unpack, not from anything resembling
|
|
50
|
+
documented, expected behavior.
|
|
51
|
+
* **getzep/graphiti#920** (open as of this build, contributor
|
|
52
|
+
markwkiehl): `add_episode(..., reference_time=<tz-aware datetime>)`
|
|
53
|
+
raises `TypeError: can't compare offset-naive and offset-aware
|
|
54
|
+
datetimes` from `resolve_edge_contradictions()` in
|
|
55
|
+
`graphiti_core/utils/maintenance/edge_operations.py` (line ~371,
|
|
56
|
+
`edge.valid_at < resolved_edge.valid_at`), reached via
|
|
57
|
+
`add_episode()` -> `resolve_extracted_edges()` ->
|
|
58
|
+
`resolve_extracted_edge()` -> `resolve_edge_contradictions()`.
|
|
59
|
+
Confirmed via the issue's own filed traceback (`gh issue view 920
|
|
60
|
+
--repo getzep/graphiti`, fetched 2026-07-16): a caller who passes a
|
|
61
|
+
timezone-*aware* `reference_time` (e.g. `datetime.now(timezone.utc)`,
|
|
62
|
+
which is exactly what Python best practice recommends, and exactly
|
|
63
|
+
what this adapter's own `store()` passes -- see `datetime.now(UTC)`
|
|
64
|
+
below) can hit a stored edge whose `valid_at` was persisted as
|
|
65
|
+
timezone-*naive*, and the bare `<` comparison between the two raises
|
|
66
|
+
instead of normalizing either side first.
|
|
67
|
+
* **getzep/graphiti#1013** (merged/fixed upstream): the Neo4j bulk
|
|
68
|
+
edge-save Cypher query used to build its `SET` clause from an
|
|
69
|
+
enumerated field list that omitted `EntityEdge.attributes` and
|
|
70
|
+
`reference_time`, so a bulk-saved edge could silently come back
|
|
71
|
+
missing properties a single-edge `save()` call would have written.
|
|
72
|
+
Confirmed fixed on `main` as of 2026-07-16:
|
|
73
|
+
`graphiti_core/models/edges/edge_db_queries.py`'s
|
|
74
|
+
`get_entity_edge_save_bulk_query()` now emits `SET e = edge` for the
|
|
75
|
+
Neo4j provider (the default case; FalkorDB's own branch of the same
|
|
76
|
+
function uses `SET r = edge`, same shape, different Cypher variable
|
|
77
|
+
name) -- an unconditional property copy, not an enumerated list --
|
|
78
|
+
which is the documented fix shape for #1013.
|
|
79
|
+
* **getzep/graphiti#1001** (closed via #1013): FalkorDB's older
|
|
80
|
+
`add_triplet()`-era edge-creation path created edges via `MATCH`
|
|
81
|
+
(which silently no-ops if either endpoint node is absent) and never
|
|
82
|
+
set `source_node_uuid`/`target_node_uuid` as edge properties at all.
|
|
83
|
+
Confirmed closed: `add_triplet()` no longer exists anywhere in
|
|
84
|
+
`graphiti_core/driver/falkordb_driver.py` on `main` as of 2026-07-16
|
|
85
|
+
-- the FalkorDB driver was rewritten around a
|
|
86
|
+
`graphiti_core/driver/falkordb/operations/` module structure that
|
|
87
|
+
post-dates this bug report.
|
|
88
|
+
* **getzep/graphiti#1222** (closed, superseded by #1475): a query whose
|
|
89
|
+
sanitized/stopword-filtered token list comes back empty -- e.g. an
|
|
90
|
+
empty query string, the exact shape `explore_node` hits when called
|
|
91
|
+
with only a `node_uuid` and no `node_name` -- makes
|
|
92
|
+
`build_fulltext_query()` in `graphiti_core/driver/falkordb_driver.py`
|
|
93
|
+
append empty parentheses to the group filter, producing the invalid
|
|
94
|
+
RediSearch syntax `(@group_id:"...") ()`, raised as `RediSearch:
|
|
95
|
+
Syntax error at offset 22 near my_graph`. Confirmed via the PR's own
|
|
96
|
+
filed reproduction (`gh issue view 1222 --repo getzep/graphiti`,
|
|
97
|
+
fetched 2026-07-16); closed as a duplicate of #1475 ("taking as the
|
|
98
|
+
FalkorDB fulltext query-sanitization fix"), which is the PR that
|
|
99
|
+
actually closes this gap upstream, not #1222 itself.
|
|
100
|
+
* **getzep/graphiti#1183** (merged): before this fix, `sanitize()`'s
|
|
101
|
+
character-replacement map in the same `falkordb_driver.py` omitted
|
|
102
|
+
pipe (`|`), slash (`/`), and backslash (`\\`) -- episode text
|
|
103
|
+
containing those characters (e.g. `"install.sh | bash"`, the PR's own
|
|
104
|
+
reported production trigger) survived sanitization, tokenized on
|
|
105
|
+
whitespace into a stray `|` token, and got rejoined with `" | "` as a
|
|
106
|
+
RediSearch OR separator, producing an adjacent-pipe malformed query
|
|
107
|
+
(`"sh | | | bash"`) RediSearch rejects as `RediSearch: Syntax error at
|
|
108
|
+
offset 178 near sh`. Confirmed via the merged PR's own filed
|
|
109
|
+
reproduction and diff (`gh pr view 1183 --repo getzep/graphiti`,
|
|
110
|
+
fetched 2026-07-16): the fix added those three characters to
|
|
111
|
+
`sanitize()`'s map and filters empty tokens before pipe-joining in
|
|
112
|
+
`build_fulltext_query()`.
|
|
113
|
+
|
|
114
|
+
* **getzep/graphiti#1467** (open as of this build, contributor
|
|
115
|
+
elimydlarz): `GeminiEmbedder.create_batch()`
|
|
116
|
+
(`graphiti_core/embedder/gemini.py`) special-cases `batch_size=1` only
|
|
117
|
+
for `"gemini-embedding-001"` -- confirmed by fetching that file from
|
|
118
|
+
`main` on 2026-07-16, the `elif batch_size is None:` branch falls
|
|
119
|
+
straight to `DEFAULT_BATCH_SIZE = 100` for every other model name,
|
|
120
|
+
including `"gemini-embedding-2-preview"`/`"gemini-embedding-2"`. Those
|
|
121
|
+
two models return exactly one embedding per `embed_content()` call
|
|
122
|
+
regardless of how many strings are in the batch, and `create_batch()`
|
|
123
|
+
never checks `len(result.embeddings) != len(batch)` before returning,
|
|
124
|
+
so it silently hands back a too-short list. graphiti-core's own dedup
|
|
125
|
+
pipeline (`_semantic_candidate_search()` in
|
|
126
|
+
`graphiti_core/utils/maintenance/node_operations.py`, reached from
|
|
127
|
+
`add_episode()` once 2+ entities are extracted) then zips the
|
|
128
|
+
extracted-node list against this too-short embeddings list with
|
|
129
|
+
`strict=True`, raising `ValueError: zip() argument 2 is shorter than
|
|
130
|
+
argument 1`. This adapter has no code path to select a Gemini embedder
|
|
131
|
+
at all before this build -- see "GeminiEmbedder support" below.
|
|
132
|
+
* **getzep/graphiti#1525** (closed, contributor maui314159, fixed
|
|
133
|
+
upstream via merged PR#1531): a NUL byte (`\\x00`) embedded in episode
|
|
134
|
+
content -- common in text extracted from PDFs/PPTX -- survives
|
|
135
|
+
FalkorDB's client-side query-parameter serialization
|
|
136
|
+
(`falkordb/helpers.py::quote_string`, which only escapes backslash and
|
|
137
|
+
double-quote) and makes FalkorDB's parser reject the entire bulk
|
|
138
|
+
episode-save query with `redis.exceptions.ResponseError: Failed to
|
|
139
|
+
parse query parameter '<name>' value`, silently dropping every episode
|
|
140
|
+
in that call. Confirmed via the issue's own filed reproduction and
|
|
141
|
+
PR#1531's diff (`gh issue view 1525`/`gh pr view 1531 --repo
|
|
142
|
+
getzep/graphiti`, fetched 2026-07-16); already fixed on `main`, so this
|
|
143
|
+
signal exists to classify the shape if reproduced against an
|
|
144
|
+
older/unpinned graphiti-core version, same role every other
|
|
145
|
+
already-fixed `CrashSignal` member here plays.
|
|
146
|
+
|
|
147
|
+
* **getzep/graphiti#1625** (open as of this build, contributor pcy06):
|
|
148
|
+
FalkorDB's Cypher query for `EpisodeNodeOperations.retrieve_episodes()`
|
|
149
|
+
intends to filter with `e.valid_at <= $reference_time`, but FalkorDB
|
|
150
|
+
can return rows for which that same expression evaluates `False` when
|
|
151
|
+
projected as a column -- a future-dated episode can leak into a
|
|
152
|
+
point-in-time query. Confirmed via the issue's own filed reproduction
|
|
153
|
+
(`gh issue view 1625 --repo getzep/graphiti`, fetched 2026-07-16),
|
|
154
|
+
which demonstrates a `valid_at=2024-03-01` episode returned for a
|
|
155
|
+
`reference_time=2024-02-01` query. This adapter's `query()` never
|
|
156
|
+
calls `retrieve_episodes()` at all -- it calls `Graphiti.search()`,
|
|
157
|
+
which returns edges, never episodes -- so this bug was previously
|
|
158
|
+
entirely unreachable through this adapter. See `retrieve_episodes()`
|
|
159
|
+
below and `evals/episode_temporal_leak.py`, the new eval this build
|
|
160
|
+
added specifically to detect (not fix -- the real bug lives inside
|
|
161
|
+
graphiti-core's FalkorDB driver's Cypher query construction) this
|
|
162
|
+
leak shape.
|
|
163
|
+
|
|
164
|
+
## GeminiEmbedder support
|
|
165
|
+
|
|
166
|
+
Set `GRAPHITI_EMBEDDER_PROVIDER=gemini` (or pass `embedder_provider="gemini"`
|
|
167
|
+
to the constructor) plus `GRAPHITI_GEMINI_API_KEY` (or `gemini_api_key=`) to
|
|
168
|
+
construct a real `graphiti_core.embedder.gemini.GeminiEmbedder` and pass it
|
|
169
|
+
as `Graphiti(embedder=...)` -- confirmed against the real, current
|
|
170
|
+
`Graphiti.__init__` signature on `main` (2026-07-16), which accepts an
|
|
171
|
+
optional `embedder: EmbedderClient | None` and falls back to its own
|
|
172
|
+
default `OpenAIEmbedder()` when `None`, exactly this adapter's prior,
|
|
173
|
+
unconfigurable behavior. `GRAPHITI_GEMINI_EMBEDDING_MODEL` (or
|
|
174
|
+
`gemini_embedding_model=`) optionally overrides the embedding model name
|
|
175
|
+
(e.g. `"gemini-embedding-2-preview"`, the model getzep/graphiti#1467
|
|
176
|
+
concerns); left unset, `GeminiEmbedderConfig`'s own default
|
|
177
|
+
(`"text-embedding-001"`) applies. Leaving `GRAPHITI_EMBEDDER_PROVIDER`
|
|
178
|
+
unset (the default) is fully backward compatible: `_build_embedder()`
|
|
179
|
+
returns `None`, and `Graphiti(embedder=None)` behaves identically to never
|
|
180
|
+
passing the keyword at all. This is what makes getzep/graphiti#1467's bug
|
|
181
|
+
class reachable through this adapter at all -- see `CrashSignal
|
|
182
|
+
.EMBEDDING_BATCH_COUNT_MISMATCH` in base.py and `_classify_crash()` below
|
|
183
|
+
for the classification this build added alongside it. Requires the
|
|
184
|
+
optional `graphiti-core[google-genai]` extra; a missing `google-genai`
|
|
185
|
+
install raises `BackendAPIError` naming that extra, the same "fail loudly
|
|
186
|
+
and specifically" convention `_get_client()`'s FalkorDB-extra check
|
|
187
|
+
already establishes.
|
|
188
|
+
|
|
189
|
+
None of the above nine confirmations came from running graphiti-core
|
|
190
|
+
against a live Neo4j or FalkorDB instance in this environment -- seven came
|
|
191
|
+
from fetching the real source files from `getzep/graphiti`'s `main` branch
|
|
192
|
+
on GitHub (`raw.githubusercontent.com/getzep/graphiti/main/...`) during
|
|
193
|
+
this build (five on 2026-07-16, two more -- #1467, #1525 -- also on
|
|
194
|
+
2026-07-16), and reading them directly; #1222 and #1183 came from reading
|
|
195
|
+
each issue/PR's own filed reproduction and diff via `gh issue view`/`gh pr
|
|
196
|
+
view` the same day. That is stronger evidence than documentation, but it is
|
|
197
|
+
still a snapshot of an unpinned, moving branch (or, for #1222/#1183/#1525,
|
|
198
|
+
a point-in-time reading of the issue tracker), and it is not a substitute
|
|
199
|
+
for exercising this adapter against a real deployment. See "What this
|
|
200
|
+
adapter does NOT prove" below.
|
|
201
|
+
|
|
202
|
+
## Design: why a separate class/file, not a flag on ZepGraphitiAdapter
|
|
203
|
+
|
|
204
|
+
Same reasoning `Mem0SelfHostedAdapter` documents in `mem0_adapter.py`:
|
|
205
|
+
hosted Zep Cloud and self-hosted graphiti-core are materially different
|
|
206
|
+
deployment shapes (REST vs. a direct Python client against a graph
|
|
207
|
+
driver), with different configuration stories (one API key vs. a database
|
|
208
|
+
connection) and different confidence levels. Collapsing them into one
|
|
209
|
+
class with an if/else would hide that from callers and from
|
|
210
|
+
docs/methodology.md's confidence table.
|
|
211
|
+
|
|
212
|
+
## Configuration: two backends, one env-var contract
|
|
213
|
+
|
|
214
|
+
Self-hosted graphiti-core has no single API key -- it needs a running
|
|
215
|
+
graph database (Neo4j by default, or FalkorDB) plus its own LLM/embedder
|
|
216
|
+
credentials for entity extraction. This adapter is gated on
|
|
217
|
+
`GRAPHITI_NEO4J_URI` (its primary, class-level `env_var`, consistent with
|
|
218
|
+
the "one env var, or SKIPPED" contract every other adapter follows) OR
|
|
219
|
+
`GRAPHITI_FALKORDB_URL` as an alternate. `BackendNotConfiguredError` is
|
|
220
|
+
only raised when *neither* is set. Supporting env vars:
|
|
221
|
+
|
|
222
|
+
* `GRAPHITI_NEO4J_URI` -- e.g. `bolt://localhost:7687`
|
|
223
|
+
* `GRAPHITI_NEO4J_USER` -- defaults to `"neo4j"` if unset
|
|
224
|
+
* `GRAPHITI_NEO4J_PASSWORD`
|
|
225
|
+
* `GRAPHITI_FALKORDB_URL` -- e.g. `redis://localhost:6379`; when set,
|
|
226
|
+
this takes precedence over the Neo4j variables and a `FalkorDriver`
|
|
227
|
+
is constructed instead of the default `Neo4jDriver`.
|
|
228
|
+
* `GRAPHITI_UPDATE_COMMUNITIES` -- `"1"`/`"true"`/`"yes"` (case
|
|
229
|
+
insensitive) threads `update_communities=True` through every
|
|
230
|
+
`add_episode()` call this adapter makes, which is the toggle that
|
|
231
|
+
would reach getzep/graphiti#836's code path. Defaults to `False`.
|
|
232
|
+
Can also be set per-instance via the `update_communities` constructor
|
|
233
|
+
kwarg, which takes precedence over the env var when explicitly passed
|
|
234
|
+
(not `None`).
|
|
235
|
+
|
|
236
|
+
graphiti-core's real public API (`Graphiti.add_episode()`,
|
|
237
|
+
`Graphiti.search()`, `Graphiti.remove_episode()`) is entirely `async def`.
|
|
238
|
+
`MemoryBackendAdapter`'s `store()`/`query()`/`update()`/`delete()` are
|
|
239
|
+
synchronous, matching every other adapter in this repo -- this adapter
|
|
240
|
+
bridges the two with `asyncio.run(...)` inside each sync method, the same
|
|
241
|
+
pattern any synchronous caller of an async-only library has to use.
|
|
242
|
+
|
|
243
|
+
## What this adapter does NOT prove
|
|
244
|
+
|
|
245
|
+
This adapter was built and unit-tested (see tests/test_adapters.py)
|
|
246
|
+
entirely against a `graphiti_core`-shaped Protocol double -- the real
|
|
247
|
+
`graphiti-core` package is not installed in this build environment
|
|
248
|
+
(`ModuleNotFoundError: No module named 'graphiti_core'`), and no Neo4j or
|
|
249
|
+
FalkorDB instance was started or reached during this build. That means:
|
|
250
|
+
|
|
251
|
+
* The exact constructor/method signatures this file calls
|
|
252
|
+
(`Graphiti(uri=, user=, password=)`, `Graphiti(graph_driver=)`,
|
|
253
|
+
`FalkorDriver(host=, port=, username=, password=)`,
|
|
254
|
+
`add_episode(name=, episode_body=, source_description=,
|
|
255
|
+
reference_time=, group_id=, update_communities=)`,
|
|
256
|
+
`search(query, group_ids=, num_results=)`,
|
|
257
|
+
`remove_episode(episode_uuid)`) were confirmed by reading the real
|
|
258
|
+
source on GitHub, not by importing and calling the real package.
|
|
259
|
+
* The `update_communities` toggle demonstrably threads through to
|
|
260
|
+
`add_episode()` (see `store()` below and its unit test), but this
|
|
261
|
+
build has no way to demonstrate it actually *triggers*
|
|
262
|
+
getzep/graphiti#836's `ValueError` without a live Neo4j instance and
|
|
263
|
+
real LLM credentials to run entity extraction against -- the bug
|
|
264
|
+
lives inside `semaphore_gather`'s interaction with however many
|
|
265
|
+
entities the LLM extraction step returns, which nothing in this
|
|
266
|
+
adapter's mocked test path exercises.
|
|
267
|
+
* `lucene_sanitize()` (getzep/graphiti#1302) is exercised nowhere in
|
|
268
|
+
this adapter's own code -- it is an internal helper graphiti-core's
|
|
269
|
+
search pipeline calls on the caller's query string before it ever
|
|
270
|
+
reaches this adapter. This adapter cannot patch, bypass, or directly
|
|
271
|
+
unit-test that function; it can only route eval queries containing
|
|
272
|
+
the trigger characters (see tests/fixtures/contradiction_cases.json)
|
|
273
|
+
at a live self-hosted instance, so a contributor who runs this
|
|
274
|
+
adapter against a real deployment can compare BM25 ranking with and
|
|
275
|
+
without those letters and observe the degradation directly.
|
|
276
|
+
* `EDGE_INTEGRITY_VIOLATION` (getzep/graphiti#1013/#1001) is checked at
|
|
277
|
+
the harness level, in `evals/contradiction.py`'s `classify_case()`,
|
|
278
|
+
against whatever `source_node_uuid`/`target_node_uuid` values this
|
|
279
|
+
adapter's `query()` puts in `MemoryRecord.raw`. Since both bugs are
|
|
280
|
+
confirmed fixed/closed on the version of graphiti-core this adapter
|
|
281
|
+
was built against, a live run today should not actually trigger this
|
|
282
|
+
signal -- it exists so the check is *available* if this adapter is
|
|
283
|
+
ever pinned to, or someone's self-hosted deployment happens to run,
|
|
284
|
+
an older graphiti-core version where the bug is still present.
|
|
285
|
+
* `store()`'s `CrashSignal` classification (`CrashSignal.UNPACK_ERROR`
|
|
286
|
+
for #836's shape, `CrashSignal.TYPE_COMPARISON_ERROR` for #920's
|
|
287
|
+
shape -- see `_classify_crash()` below and `CrashSignal` in
|
|
288
|
+
`base.py`) is unit-tested against fake clients that raise the exact
|
|
289
|
+
`ValueError`/`TypeError` message strings each real issue's filed
|
|
290
|
+
traceback reports (confirmed via `gh issue view` for both issues,
|
|
291
|
+
2026-07-16). That proves this adapter correctly recognizes those two
|
|
292
|
+
message shapes once graphiti-core has already raised them -- it does
|
|
293
|
+
NOT prove, and does not attempt to prove, that this adapter's own
|
|
294
|
+
calls into `add_episode()` actually trigger either crash against a
|
|
295
|
+
live instance (same limitation as the `update_communities` bullet
|
|
296
|
+
above), and it does nothing to prevent either crash or fix
|
|
297
|
+
graphiti-core itself. It is a diagnostic classifier applied after the
|
|
298
|
+
fact to whatever exception a live instance (if reached) raises, nothing
|
|
299
|
+
more.
|
|
300
|
+
* `query()`'s `CrashSignal` classification (`CrashSignal
|
|
301
|
+
.QUERY_SANITIZATION_ERROR` for #1222's/#1183's shared "RediSearch:
|
|
302
|
+
Syntax error ..." shape -- same `_classify_crash()` and `CrashSignal`
|
|
303
|
+
referenced above) is unit-tested against fake clients that raise the
|
|
304
|
+
exact `RediSearch: Syntax error at offset N near ...` message strings
|
|
305
|
+
each PR's own filed reproduction reports (`gh issue view 1222`/
|
|
306
|
+
`gh pr view 1183 --repo getzep/graphiti`, fetched 2026-07-16), for
|
|
307
|
+
both an empty query string (#1222's trigger) and a query containing
|
|
308
|
+
pipe/slash characters (#1183's trigger). Same honesty caveat as the
|
|
309
|
+
`store()` bullet immediately above: this proves the classifier
|
|
310
|
+
recognizes the shared crash *shape* once a live FalkorDB instance has
|
|
311
|
+
already raised it, not that this adapter's own `query()` calls
|
|
312
|
+
actually trigger a RediSearch syntax error against a live instance --
|
|
313
|
+
the fixtures this adapter's own mocked test double uses never
|
|
314
|
+
exercise FalkorDB's real `sanitize()`/`build_fulltext_query()`
|
|
315
|
+
functions at all, the same limitation the `lucene_sanitize()` bullet
|
|
316
|
+
above already states for #1302.
|
|
317
|
+
|
|
318
|
+
Anyone relying on this adapter to reproduce any of the seven issues above
|
|
319
|
+
against a real deployment should verify against a live Neo4j/FalkorDB
|
|
320
|
+
instance first, exactly the same caveat docs/methodology.md already
|
|
321
|
+
states for `Mem0SelfHostedAdapter`, `MemPalaceAdapter`, and
|
|
322
|
+
`OpenVikingAdapter`.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
from __future__ import annotations
|
|
326
|
+
|
|
327
|
+
import asyncio
|
|
328
|
+
import os
|
|
329
|
+
from datetime import UTC, datetime
|
|
330
|
+
from typing import Any, Protocol
|
|
331
|
+
from urllib.parse import urlsplit
|
|
332
|
+
|
|
333
|
+
from memtrust.adapters.base import (
|
|
334
|
+
BackendAPIError,
|
|
335
|
+
BackendNotConfiguredError,
|
|
336
|
+
ConflictSignal,
|
|
337
|
+
CrashSignal,
|
|
338
|
+
DeleteResult,
|
|
339
|
+
MemoryBackendAdapter,
|
|
340
|
+
MemoryRecord,
|
|
341
|
+
QueryResult,
|
|
342
|
+
StoreResult,
|
|
343
|
+
UpdateResult,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
DEFAULT_NEO4J_USER = "neo4j"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class _GraphitiProtocol(Protocol):
|
|
350
|
+
"""Shape this adapter expects from a real `graphiti_core.Graphiti`
|
|
351
|
+
instance (Neo4j- or FalkorDB-backed). Defined as a Protocol, not
|
|
352
|
+
imported from the package, so tests can inject a fake/mock
|
|
353
|
+
implementation without the real `graphiti-core` package (and its
|
|
354
|
+
Neo4j/FalkorDB driver dependencies) installed -- the same convention
|
|
355
|
+
`mempalace_adapter.py`'s `_PalaceProtocol` already establishes in this
|
|
356
|
+
repo.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
async def add_episode(
|
|
360
|
+
self,
|
|
361
|
+
name: str,
|
|
362
|
+
episode_body: str,
|
|
363
|
+
source_description: str,
|
|
364
|
+
reference_time: datetime,
|
|
365
|
+
group_id: str | None = None,
|
|
366
|
+
update_communities: bool = False,
|
|
367
|
+
) -> Any: ...
|
|
368
|
+
|
|
369
|
+
async def search(
|
|
370
|
+
self, query: str, group_ids: list[str] | None = None, num_results: int = 10
|
|
371
|
+
) -> list[Any]: ...
|
|
372
|
+
|
|
373
|
+
async def remove_episode(self, episode_uuid: str) -> None: ...
|
|
374
|
+
|
|
375
|
+
async def close(self) -> None: ...
|
|
376
|
+
|
|
377
|
+
driver: Any
|
|
378
|
+
"""The real `Graphiti` instance's own `.driver` attribute (a
|
|
379
|
+
`GraphDriver` -- `Neo4jDriver` or `FalkorDriver`), confirmed present on
|
|
380
|
+
every real `Graphiti` instance by reading `graphiti_core/graphiti.py`'s
|
|
381
|
+
`__init__` on 2026-07-16 (`self.driver = graph_driver or Neo4jDriver(...)`).
|
|
382
|
+
Only accessed by `retrieve_episodes()` below, via
|
|
383
|
+
`driver.episode_node_ops` -- a real, confirmed property on `GraphDriver`
|
|
384
|
+
(`graphiti_core/driver/driver.py`) exposing an `EpisodeNodeOperations`
|
|
385
|
+
instance. See `retrieve_episodes()` and
|
|
386
|
+
`evals/episode_temporal_leak.py` for why this needs the raw driver
|
|
387
|
+
rather than going through `Graphiti.search()` (which only ever returns
|
|
388
|
+
edges -- `EntityEdge` -- never episodes)."""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _to_plain_dict(obj: object) -> dict[str, object]:
|
|
392
|
+
"""Best-effort conversion of a graphiti_core Pydantic model (or a test
|
|
393
|
+
double standing in for one) to a plain dict, without importing
|
|
394
|
+
pydantic or graphiti_core here -- this adapter only duck-types against
|
|
395
|
+
whatever `model_dump()`/dict shape it's handed, matching how
|
|
396
|
+
`_GraphitiProtocol` above never imports the real package either."""
|
|
397
|
+
model_dump = getattr(obj, "model_dump", None)
|
|
398
|
+
if callable(model_dump):
|
|
399
|
+
result = model_dump()
|
|
400
|
+
return result if isinstance(result, dict) else {}
|
|
401
|
+
if isinstance(obj, dict):
|
|
402
|
+
return obj
|
|
403
|
+
try:
|
|
404
|
+
return dict(vars(obj))
|
|
405
|
+
except TypeError:
|
|
406
|
+
return {}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _classify_crash(exc: Exception) -> CrashSignal:
|
|
410
|
+
"""Pattern-match a caught vendor exception's type and message against
|
|
411
|
+
the three known graphiti-core crash shapes `CrashSignal` (base.py)
|
|
412
|
+
documents, and fall back to `CrashSignal.UNKNOWN` for everything else.
|
|
413
|
+
|
|
414
|
+
This is deliberately narrow: the first two checks look at the
|
|
415
|
+
exception's Python type first (ValueError vs. TypeError), then a
|
|
416
|
+
specific substring within that type's message, so an unrelated
|
|
417
|
+
ValueError (say, a malformed UUID) or an unrelated TypeError (say, a
|
|
418
|
+
wrong-argument-count bug) is never miscategorized as getzep/graphiti
|
|
419
|
+
#836 or #920. Matching a substring of `str(exc)` is the only surface
|
|
420
|
+
available here -- once a vendor library has already raised, this
|
|
421
|
+
adapter has no access to the original traceback's source location,
|
|
422
|
+
only what the exception itself reports. See CrashSignal's docstring in
|
|
423
|
+
base.py for the full honesty caveat: this recognizes a known crash
|
|
424
|
+
*shape*, it does not prove (or require) that the crash occurred at the
|
|
425
|
+
exact upstream line this adapter's module docstring cites.
|
|
426
|
+
|
|
427
|
+
The third and fourth checks (RediSearch `Syntax error`, getzep/graphiti
|
|
428
|
+
#1222/#1183; "failed to parse query parameter", getzep/graphiti#1525)
|
|
429
|
+
are message-only, with no `isinstance` gate: the real exceptions both
|
|
430
|
+
FalkorDB's RediSearch fulltext-query path and its query-parameter
|
|
431
|
+
serialization raise are typically a `redis`-client `ResponseError`,
|
|
432
|
+
and this adapter deliberately does not import the optional
|
|
433
|
+
`redis`/`falkordb` packages just to `isinstance`-check against them
|
|
434
|
+
(see `_get_client()` above -- graphiti-core's FalkorDB extra is
|
|
435
|
+
optional and may not be installed). Requiring both "redisearch" and
|
|
436
|
+
"syntax error" as substrings (not "syntax error" alone) keeps the
|
|
437
|
+
third check from ever matching an unrelated Python `SyntaxError` or a
|
|
438
|
+
different vendor's syntax-error message; requiring both "failed to
|
|
439
|
+
parse query parameter" and "value" in the fourth keeps it from
|
|
440
|
+
matching an unrelated parse failure that merely mentions "value".
|
|
441
|
+
"""
|
|
442
|
+
message = str(exc).lower()
|
|
443
|
+
if isinstance(exc, ValueError) and "values to unpack" in message:
|
|
444
|
+
# getzep/graphiti#836: add_episode(update_communities=True)'s
|
|
445
|
+
# `communities, community_edges = await semaphore_gather(...)`
|
|
446
|
+
# unpack only succeeds when semaphore_gather returns exactly 2
|
|
447
|
+
# items -- covers both "too many values to unpack" and "not
|
|
448
|
+
# enough values to unpack" phrasings of the same shape.
|
|
449
|
+
return CrashSignal.UNPACK_ERROR
|
|
450
|
+
if isinstance(exc, TypeError) and "offset-naive and offset-aware" in message:
|
|
451
|
+
# getzep/graphiti#920: comparing a tz-naive stored timestamp
|
|
452
|
+
# against a tz-aware datetime in edge-contradiction resolution.
|
|
453
|
+
return CrashSignal.TYPE_COMPARISON_ERROR
|
|
454
|
+
if isinstance(exc, ValueError) and "zip()" in message and "shorter than" in message:
|
|
455
|
+
# getzep/graphiti#1467: GeminiEmbedder.create_batch() silently
|
|
456
|
+
# returns fewer vectors than inputs for the gemini-embedding-2*
|
|
457
|
+
# model family, later tripping a strict-zip ValueError several
|
|
458
|
+
# frames away in graphiti-core's own dedup pipeline. See
|
|
459
|
+
# CrashSignal.EMBEDDING_BATCH_COUNT_MISMATCH's docstring in
|
|
460
|
+
# base.py for the full citation trail.
|
|
461
|
+
return CrashSignal.EMBEDDING_BATCH_COUNT_MISMATCH
|
|
462
|
+
if "redisearch" in message and "syntax error" in message:
|
|
463
|
+
# getzep/graphiti#1222 (empty sanitized query -> "(@group_id:...)
|
|
464
|
+
# ()") / #1183 (unescaped |, /, \ in episode text -> an empty
|
|
465
|
+
# token between RediSearch OR-pipe delimiters) -- both raise the
|
|
466
|
+
# identical "RediSearch: Syntax error at offset N near ..." shape
|
|
467
|
+
# from FalkorDB's fulltext-query path. See CrashSignal
|
|
468
|
+
# .QUERY_SANITIZATION_ERROR's docstring in base.py for the full
|
|
469
|
+
# citation trail.
|
|
470
|
+
return CrashSignal.QUERY_SANITIZATION_ERROR
|
|
471
|
+
if "failed to parse query parameter" in message and "value" in message:
|
|
472
|
+
# getzep/graphiti#1525 (maui314159, fixed upstream via merged
|
|
473
|
+
# PR#1531): a NUL byte embedded in episode content (commonly from
|
|
474
|
+
# PDF/PPTX extraction) survives FalkorDB's client-side parameter
|
|
475
|
+
# serialization and makes FalkorDB's parser reject the entire
|
|
476
|
+
# bulk episode-save query with "Failed to parse query parameter
|
|
477
|
+
# '<name>' value" -- silently dropping every episode in that
|
|
478
|
+
# call, not just the offending one. See CrashSignal
|
|
479
|
+
# .QUERY_PARAMETER_PARSE_ERROR's docstring in base.py for the
|
|
480
|
+
# full citation trail.
|
|
481
|
+
return CrashSignal.QUERY_PARAMETER_PARSE_ERROR
|
|
482
|
+
return CrashSignal.UNKNOWN
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _parse_falkordb_url(url: str) -> tuple[str, int, str | None, str | None]:
|
|
486
|
+
"""Parse `GRAPHITI_FALKORDB_URL` into the (host, port, username,
|
|
487
|
+
password) tuple `graphiti_core.driver.falkordb_driver.FalkorDriver`'s
|
|
488
|
+
constructor accepts. Accepts a bare `host:port` as well as a full
|
|
489
|
+
`redis://user:pass@host:port` URL -- FalkorDB is Redis-protocol
|
|
490
|
+
compatible and its own docs use `redis://` URLs, but a caller who just
|
|
491
|
+
writes `localhost:6379` should not be forced to add a scheme.
|
|
492
|
+
"""
|
|
493
|
+
candidate = url if "://" in url else f"redis://{url}"
|
|
494
|
+
parsed = urlsplit(candidate)
|
|
495
|
+
return (parsed.hostname or "localhost", parsed.port or 6379, parsed.username, parsed.password)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class ZepGraphitiSelfHostedAdapter(MemoryBackendAdapter):
|
|
499
|
+
"""Adapter for self-hosted `graphiti-core` (Neo4j by default, optional
|
|
500
|
+
FalkorDB). See this module's docstring for the confidence level, the
|
|
501
|
+
five bug classes this adapter exists to make reachable, and exactly
|
|
502
|
+
what was -- and was not -- confirmed against real source vs. a live
|
|
503
|
+
instance.
|
|
504
|
+
"""
|
|
505
|
+
|
|
506
|
+
name = "graphiti_selfhosted"
|
|
507
|
+
env_var = "GRAPHITI_NEO4J_URI"
|
|
508
|
+
supports_update = True
|
|
509
|
+
|
|
510
|
+
def __init__(
|
|
511
|
+
self,
|
|
512
|
+
neo4j_uri: str | None = None,
|
|
513
|
+
neo4j_user: str | None = None,
|
|
514
|
+
neo4j_password: str | None = None,
|
|
515
|
+
falkordb_url: str | None = None,
|
|
516
|
+
update_communities: bool | None = None,
|
|
517
|
+
embedder_provider: str | None = None,
|
|
518
|
+
gemini_api_key: str | None = None,
|
|
519
|
+
gemini_embedding_model: str | None = None,
|
|
520
|
+
graphiti_client: _GraphitiProtocol | None = None,
|
|
521
|
+
) -> None:
|
|
522
|
+
resolved_neo4j_uri = neo4j_uri or os.environ.get(self.env_var)
|
|
523
|
+
resolved_falkordb_url = falkordb_url or os.environ.get("GRAPHITI_FALKORDB_URL")
|
|
524
|
+
if graphiti_client is None and not resolved_neo4j_uri and not resolved_falkordb_url:
|
|
525
|
+
# Either GRAPHITI_NEO4J_URI or GRAPHITI_FALKORDB_URL configures
|
|
526
|
+
# this adapter -- env_var names the primary one for the error
|
|
527
|
+
# message the same way every other adapter's does, but the
|
|
528
|
+
# constructor itself accepts either. See module docstring.
|
|
529
|
+
raise BackendNotConfiguredError(self.name, self.env_var)
|
|
530
|
+
if update_communities is None:
|
|
531
|
+
update_communities = os.environ.get(
|
|
532
|
+
"GRAPHITI_UPDATE_COMMUNITIES", ""
|
|
533
|
+
).strip().lower() in ("1", "true", "yes")
|
|
534
|
+
self._update_communities = update_communities
|
|
535
|
+
self._client = graphiti_client
|
|
536
|
+
self._neo4j_uri = resolved_neo4j_uri
|
|
537
|
+
self._neo4j_user = neo4j_user or os.environ.get("GRAPHITI_NEO4J_USER", DEFAULT_NEO4J_USER)
|
|
538
|
+
self._neo4j_password = neo4j_password or os.environ.get("GRAPHITI_NEO4J_PASSWORD")
|
|
539
|
+
self._falkordb_url = resolved_falkordb_url
|
|
540
|
+
# Embedder selection -- see "GeminiEmbedder support" in this
|
|
541
|
+
# module's docstring for getzep/graphiti#1467's motivation.
|
|
542
|
+
# `None`/unset (the default) leaves graphiti-core's own default
|
|
543
|
+
# OpenAIEmbedder() untouched, same backward-compatible-default
|
|
544
|
+
# convention every other optional field on this adapter follows.
|
|
545
|
+
self._embedder_provider = embedder_provider or os.environ.get("GRAPHITI_EMBEDDER_PROVIDER")
|
|
546
|
+
self._gemini_api_key = gemini_api_key or os.environ.get("GRAPHITI_GEMINI_API_KEY")
|
|
547
|
+
self._gemini_embedding_model = gemini_embedding_model or os.environ.get(
|
|
548
|
+
"GRAPHITI_GEMINI_EMBEDDING_MODEL"
|
|
549
|
+
)
|
|
550
|
+
gemini_requested = self._embedder_provider == "gemini"
|
|
551
|
+
if gemini_requested and graphiti_client is None and not self._gemini_api_key:
|
|
552
|
+
raise BackendNotConfiguredError(self.name, "GRAPHITI_GEMINI_API_KEY")
|
|
553
|
+
|
|
554
|
+
def _build_embedder(self) -> Any:
|
|
555
|
+
"""Construct the `EmbedderClient` to pass as `Graphiti(embedder=...)`,
|
|
556
|
+
or `None` to let graphiti-core fall back to its own default
|
|
557
|
+
`OpenAIEmbedder()` -- see `__init__`'s `embedder_provider` param and
|
|
558
|
+
this module's docstring, "GeminiEmbedder support" section.
|
|
559
|
+
|
|
560
|
+
Only `"gemini"` is wired up today -- the one provider
|
|
561
|
+
getzep/graphiti#1467 concerns. An unrecognized `embedder_provider`
|
|
562
|
+
value raises BackendAPIError naming exactly what this adapter
|
|
563
|
+
supports, rather than silently falling back to the OpenAI default
|
|
564
|
+
a caller who set it clearly did not intend.
|
|
565
|
+
"""
|
|
566
|
+
if self._embedder_provider is None:
|
|
567
|
+
return None
|
|
568
|
+
if self._embedder_provider != "gemini":
|
|
569
|
+
raise BackendAPIError(
|
|
570
|
+
self.name,
|
|
571
|
+
f"unsupported embedder_provider {self._embedder_provider!r}; this adapter "
|
|
572
|
+
"only wires up 'gemini' today -- the provider getzep/graphiti#1467 concerns. "
|
|
573
|
+
"See module docstring.",
|
|
574
|
+
)
|
|
575
|
+
try:
|
|
576
|
+
from graphiti_core.embedder.gemini import ( # type: ignore[import-not-found]
|
|
577
|
+
GeminiEmbedder,
|
|
578
|
+
GeminiEmbedderConfig,
|
|
579
|
+
)
|
|
580
|
+
except ImportError as exc:
|
|
581
|
+
raise BackendAPIError(
|
|
582
|
+
self.name,
|
|
583
|
+
"graphiti-core is installed without Gemini embedder support. Install "
|
|
584
|
+
"`pip install graphiti-core[google-genai]`, or unset "
|
|
585
|
+
"GRAPHITI_EMBEDDER_PROVIDER to use graphiti-core's default OpenAIEmbedder.",
|
|
586
|
+
) from exc
|
|
587
|
+
config_kwargs: dict[str, Any] = {"api_key": self._gemini_api_key}
|
|
588
|
+
if self._gemini_embedding_model:
|
|
589
|
+
config_kwargs["embedding_model"] = self._gemini_embedding_model
|
|
590
|
+
return GeminiEmbedder(GeminiEmbedderConfig(**config_kwargs))
|
|
591
|
+
|
|
592
|
+
def _get_client(self) -> _GraphitiProtocol:
|
|
593
|
+
if self._client is not None:
|
|
594
|
+
return self._client
|
|
595
|
+
try:
|
|
596
|
+
from graphiti_core import Graphiti # type: ignore[import-not-found]
|
|
597
|
+
except ImportError as exc:
|
|
598
|
+
raise BackendAPIError(
|
|
599
|
+
self.name,
|
|
600
|
+
"the `graphiti-core` package is not installed. Install it "
|
|
601
|
+
"with `pip install graphiti-core` (add the `[falkordb]` "
|
|
602
|
+
"extra for FalkorDB support, or `[google-genai]` for Gemini "
|
|
603
|
+
"embedder support). See docs/methodology.md for this "
|
|
604
|
+
"adapter's confidence level and what was confirmed against "
|
|
605
|
+
"the real graphiti_core source vs. a live instance.",
|
|
606
|
+
) from exc
|
|
607
|
+
|
|
608
|
+
embedder = self._build_embedder()
|
|
609
|
+
|
|
610
|
+
if self._falkordb_url:
|
|
611
|
+
try:
|
|
612
|
+
from graphiti_core.driver.falkordb_driver import ( # type: ignore[import-not-found]
|
|
613
|
+
FalkorDriver,
|
|
614
|
+
)
|
|
615
|
+
except ImportError as exc:
|
|
616
|
+
raise BackendAPIError(
|
|
617
|
+
self.name,
|
|
618
|
+
"graphiti-core is installed without FalkorDB support. "
|
|
619
|
+
"Install `pip install graphiti-core[falkordb]`, or unset "
|
|
620
|
+
"GRAPHITI_FALKORDB_URL and configure GRAPHITI_NEO4J_URI "
|
|
621
|
+
"instead.",
|
|
622
|
+
) from exc
|
|
623
|
+
host, port, username, password = _parse_falkordb_url(self._falkordb_url)
|
|
624
|
+
driver = FalkorDriver(host=host, port=port, username=username, password=password)
|
|
625
|
+
self._client = Graphiti(graph_driver=driver, embedder=embedder)
|
|
626
|
+
else:
|
|
627
|
+
self._client = Graphiti(
|
|
628
|
+
uri=self._neo4j_uri,
|
|
629
|
+
user=self._neo4j_user,
|
|
630
|
+
password=self._neo4j_password,
|
|
631
|
+
embedder=embedder,
|
|
632
|
+
)
|
|
633
|
+
return self._client
|
|
634
|
+
|
|
635
|
+
def store(
|
|
636
|
+
self,
|
|
637
|
+
session_id: str,
|
|
638
|
+
content: str,
|
|
639
|
+
metadata: dict[str, str] | None = None,
|
|
640
|
+
mode: str | None = None,
|
|
641
|
+
*,
|
|
642
|
+
verify: bool = False,
|
|
643
|
+
) -> StoreResult:
|
|
644
|
+
"""Store an episode via `Graphiti.add_episode()`.
|
|
645
|
+
|
|
646
|
+
`mode` is a no-op, same convention as every other adapter (see
|
|
647
|
+
`MemoryBackendAdapter.supported_modes`). `metadata` is also
|
|
648
|
+
accepted-and-ignored: graphiti-core's real `add_episode()`
|
|
649
|
+
signature (confirmed against `graphiti_core/graphiti.py` on
|
|
650
|
+
`main`, 2026-07-16) has no generic key/value metadata parameter --
|
|
651
|
+
unlike Mem0/MemPalace, there is nothing to thread it into without
|
|
652
|
+
fabricating a parameter the real API doesn't accept. This is
|
|
653
|
+
stated here explicitly rather than silently dropping the value
|
|
654
|
+
with no comment.
|
|
655
|
+
"""
|
|
656
|
+
del mode
|
|
657
|
+
del metadata
|
|
658
|
+
timer = self._timed()
|
|
659
|
+
client = self._get_client()
|
|
660
|
+
try:
|
|
661
|
+
result = asyncio.run(
|
|
662
|
+
client.add_episode(
|
|
663
|
+
name=f"memtrust-{session_id}",
|
|
664
|
+
episode_body=content,
|
|
665
|
+
source_description="memtrust-eval",
|
|
666
|
+
reference_time=datetime.now(UTC),
|
|
667
|
+
group_id=session_id,
|
|
668
|
+
update_communities=self._update_communities,
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
except Exception as exc: # noqa: BLE001 - vendor call, wrap uniformly
|
|
672
|
+
# A single except-Exception handler, routed through
|
|
673
|
+
# _classify_crash() for every failure shape -- not just the two
|
|
674
|
+
# (ValueError, TypeError) shapes this adapter's own module
|
|
675
|
+
# docstring calls out by name (getzep/graphiti#836/#920).
|
|
676
|
+
# _classify_crash() itself still narrows #836/#920 by exact
|
|
677
|
+
# type+message before falling through to message-only checks
|
|
678
|
+
# (see that function's docstring), so this single handler loses
|
|
679
|
+
# no classification precision -- it's what lets a non-
|
|
680
|
+
# ValueError/TypeError crash shape (e.g. getzep/graphiti#1525's
|
|
681
|
+
# `redis.exceptions.ResponseError` "Failed to parse query
|
|
682
|
+
# parameter ... value", raised from this same add_episode()
|
|
683
|
+
# bulk-episode-save call) also get classified instead of
|
|
684
|
+
# silently collapsing to a hardcoded UNKNOWN default the way an
|
|
685
|
+
# earlier version of this method did. See CrashSignal.UNKNOWN's
|
|
686
|
+
# docstring for why "not classified" (None, never used here)
|
|
687
|
+
# and "classified as UNKNOWN" (this handler's honest fallback
|
|
688
|
+
# for anything _classify_crash() doesn't recognize) are
|
|
689
|
+
# different facts.
|
|
690
|
+
raise BackendAPIError(self.name, str(exc), crash_signal=_classify_crash(exc)) from exc
|
|
691
|
+
|
|
692
|
+
result_dict = _to_plain_dict(result)
|
|
693
|
+
episode = getattr(result, "episode", None) if not isinstance(result, dict) else None
|
|
694
|
+
if episode is not None:
|
|
695
|
+
episode_uuid = str(getattr(episode, "uuid", "") or "")
|
|
696
|
+
else:
|
|
697
|
+
episode_field = result_dict.get("episode")
|
|
698
|
+
episode_uuid = (
|
|
699
|
+
str(episode_field.get("uuid", "")) if isinstance(episode_field, dict) else ""
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
store_result = StoreResult(
|
|
703
|
+
memory_id=episode_uuid, latency_ms=timer.elapsed_ms(), raw=result_dict
|
|
704
|
+
)
|
|
705
|
+
if verify:
|
|
706
|
+
store_result.verified = self.verify_store(store_result, session_id, content)
|
|
707
|
+
return store_result
|
|
708
|
+
|
|
709
|
+
def query(
|
|
710
|
+
self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
|
|
711
|
+
) -> QueryResult:
|
|
712
|
+
"""Search via `Graphiti.search()`, which returns `list[EntityEdge]`
|
|
713
|
+
directly (confirmed against `graphiti_core/graphiti.py` on `main`,
|
|
714
|
+
2026-07-16) -- unlike the hosted `ZepGraphitiAdapter`, there is no
|
|
715
|
+
REST envelope to unwrap.
|
|
716
|
+
|
|
717
|
+
Note on getzep/graphiti#1302 (`lucene_sanitize`): the `query`
|
|
718
|
+
string passed here reaches graphiti-core's own internal sanitizer
|
|
719
|
+
before it ever hits the database -- this adapter has no surface to
|
|
720
|
+
intercept or bypass that. Fixture cases containing uppercase
|
|
721
|
+
O/R/N/T/A/D (tests/fixtures/contradiction_cases.json) are what let
|
|
722
|
+
an eval run against a *live* self-hosted instance demonstrate the
|
|
723
|
+
BM25 ranking degradation; against the mocked test double used in
|
|
724
|
+
this repo's own test suite, the sanitizer never runs at all -- see
|
|
725
|
+
module docstring's "What this adapter does NOT prove."
|
|
726
|
+
|
|
727
|
+
Note on getzep/graphiti#1222/#1183 (RediSearch `Syntax error`):
|
|
728
|
+
unlike #1302 above, this adapter DOES classify this crash shape --
|
|
729
|
+
see `_classify_crash()` below and `CrashSignal
|
|
730
|
+
.QUERY_SANITIZATION_ERROR` in base.py. A single `except Exception`
|
|
731
|
+
handler (rather than store()'s `(ValueError, TypeError)`
|
|
732
|
+
pre-filter) is the right shape here: the real exception FalkorDB's
|
|
733
|
+
RediSearch fulltext-query path raises for this bug class is
|
|
734
|
+
neither a `ValueError` nor a `TypeError`, so narrowing the catch
|
|
735
|
+
by type first -- the way store() does for #836/#920 -- would skip
|
|
736
|
+
right past it. `_classify_crash()` itself still falls back to
|
|
737
|
+
`CrashSignal.UNKNOWN` for anything that doesn't match a known
|
|
738
|
+
shape, same convention store() uses.
|
|
739
|
+
"""
|
|
740
|
+
del mode
|
|
741
|
+
timer = self._timed()
|
|
742
|
+
client = self._get_client()
|
|
743
|
+
try:
|
|
744
|
+
edges = asyncio.run(client.search(query, group_ids=[session_id], num_results=top_k))
|
|
745
|
+
except Exception as exc: # noqa: BLE001 - vendor call, wrap uniformly
|
|
746
|
+
raise BackendAPIError(self.name, str(exc), crash_signal=_classify_crash(exc)) from exc
|
|
747
|
+
|
|
748
|
+
records: list[MemoryRecord] = []
|
|
749
|
+
any_invalidated = False
|
|
750
|
+
for edge in edges:
|
|
751
|
+
edge_dict = _to_plain_dict(edge)
|
|
752
|
+
invalid_at = edge_dict.get("invalid_at")
|
|
753
|
+
if invalid_at:
|
|
754
|
+
any_invalidated = True
|
|
755
|
+
created_at = edge_dict.get("valid_at") or edge_dict.get("created_at")
|
|
756
|
+
attributes = edge_dict.get("attributes")
|
|
757
|
+
records.append(
|
|
758
|
+
MemoryRecord(
|
|
759
|
+
memory_id=str(edge_dict.get("uuid", "")),
|
|
760
|
+
content=str(edge_dict.get("fact", "")),
|
|
761
|
+
score=None,
|
|
762
|
+
created_at=str(created_at) if created_at else None,
|
|
763
|
+
metadata={"invalid_at": str(invalid_at)} if invalid_at else {},
|
|
764
|
+
attributes=dict(attributes) if isinstance(attributes, dict) else {},
|
|
765
|
+
raw=edge_dict,
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# graphiti-core's bi-temporal invalid_at is the same documented
|
|
770
|
+
# signal ZepGraphitiAdapter (hosted) reads -- see that module's
|
|
771
|
+
# docstring for the citation. No invalidated edge in the result
|
|
772
|
+
# set does not prove no contradiction occurred, only that this
|
|
773
|
+
# call alone cannot tell FLAGGED from an invisible resolution --
|
|
774
|
+
# same disambiguation note as the hosted adapter.
|
|
775
|
+
conflict_signal = ConflictSignal.FLAGGED if any_invalidated else ConflictSignal.SERVED_STALE
|
|
776
|
+
return QueryResult(
|
|
777
|
+
records=records,
|
|
778
|
+
conflict_signal=conflict_signal,
|
|
779
|
+
latency_ms=timer.elapsed_ms(),
|
|
780
|
+
raw={"edges": [r.raw for r in records]},
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
|
|
784
|
+
# graphiti-core has no separate "update" verb, same as hosted Zep
|
|
785
|
+
# Cloud -- a contradicting fact is submitted through add_episode()
|
|
786
|
+
# again and the extraction pipeline resolves the contradiction
|
|
787
|
+
# itself. See ZepGraphitiAdapter.update() for the identical
|
|
788
|
+
# reasoning against the hosted API.
|
|
789
|
+
timer = self._timed()
|
|
790
|
+
result = self.store(session_id, content)
|
|
791
|
+
return UpdateResult(
|
|
792
|
+
memory_id=result.memory_id,
|
|
793
|
+
acknowledged=True,
|
|
794
|
+
latency_ms=timer.elapsed_ms(),
|
|
795
|
+
raw=result.raw,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
def delete(self, memory_id: str) -> DeleteResult:
|
|
799
|
+
"""Delete via `Graphiti.remove_episode(episode_uuid)`, confirmed
|
|
800
|
+
against `graphiti_core/graphiti.py` on `main` (2026-07-16): it
|
|
801
|
+
looks up the episode, deletes entity edges/nodes exclusively
|
|
802
|
+
mentioned by it, and deletes the episode itself.
|
|
803
|
+
"""
|
|
804
|
+
timer = self._timed()
|
|
805
|
+
client = self._get_client()
|
|
806
|
+
try:
|
|
807
|
+
asyncio.run(client.remove_episode(memory_id))
|
|
808
|
+
except Exception as exc: # noqa: BLE001 - vendor call, wrap uniformly
|
|
809
|
+
raise BackendAPIError(self.name, str(exc)) from exc
|
|
810
|
+
return DeleteResult(success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms())
|
|
811
|
+
|
|
812
|
+
def retrieve_episodes(
|
|
813
|
+
self,
|
|
814
|
+
reference_time: datetime,
|
|
815
|
+
group_ids: list[str] | None = None,
|
|
816
|
+
last_n: int = 10,
|
|
817
|
+
) -> list[dict[str, object]]:
|
|
818
|
+
"""Call graphiti-core's real, driver-level
|
|
819
|
+
`EpisodeNodeOperations.retrieve_episodes()` directly, bypassing
|
|
820
|
+
`Graphiti.search()`/`query()` entirely -- `search()` only ever
|
|
821
|
+
returns edges (`EntityEdge`), never episodes (`EpisodicNode`), so
|
|
822
|
+
there is no way to reach point-in-time episode retrieval through
|
|
823
|
+
this adapter's normal `query()` path at all. This is the primitive
|
|
824
|
+
`evals/episode_temporal_leak.py` needs to reproduce
|
|
825
|
+
getzep/graphiti#1625 (contributor pcy06, open as of this build):
|
|
826
|
+
FalkorDB's Cypher query for `retrieve_episodes()` intends to filter
|
|
827
|
+
by `e.valid_at <= $reference_time`, but FalkorDB can return rows
|
|
828
|
+
for which that same expression evaluates `False` when projected --
|
|
829
|
+
confirmed via the issue's own filed reproduction (`gh issue view
|
|
830
|
+
1625 --repo getzep/graphiti`, fetched 2026-07-16), which shows a
|
|
831
|
+
future-dated episode (`valid_at=2024-03-01`) returned for a
|
|
832
|
+
`reference_time=2024-02-01` query. Real signature confirmed by
|
|
833
|
+
reading `graphiti_core/driver/operations/episode_node_ops.py` on
|
|
834
|
+
`main`, 2026-07-16:
|
|
835
|
+
`EpisodeNodeOperations.retrieve_episodes(self, executor,
|
|
836
|
+
reference_time, last_n=3, group_ids=None, source=None,
|
|
837
|
+
saga=None) -> list[EpisodicNode]` -- `executor` is the driver
|
|
838
|
+
itself (confirmed against the issue's own repro code, which calls
|
|
839
|
+
`driver.episode_node_ops.retrieve_episodes(driver,
|
|
840
|
+
reference_time, ...)`).
|
|
841
|
+
|
|
842
|
+
This is detection, not resolution -- the bug this classifies lives
|
|
843
|
+
entirely inside graphiti-core's FalkorDB driver's Cypher query
|
|
844
|
+
construction (pcy06 already proposed the real upstream fix: project
|
|
845
|
+
the temporal comparison first, then filter on that boolean), not in
|
|
846
|
+
this adapter's own code. memtrust's role is surfacing whether a
|
|
847
|
+
given self-hosted deployment's `retrieve_episodes()` call actually
|
|
848
|
+
exhibits the leak, not fixing graphiti-core itself.
|
|
849
|
+
|
|
850
|
+
Raises:
|
|
851
|
+
BackendAPIError: if this graphiti_core driver has no
|
|
852
|
+
`episode_node_ops` surface at all (e.g. an injected test
|
|
853
|
+
double that doesn't model one), or if the real call itself
|
|
854
|
+
fails -- classified via `_classify_crash()` the same as
|
|
855
|
+
every other vendor call this adapter wraps.
|
|
856
|
+
"""
|
|
857
|
+
client = self._get_client()
|
|
858
|
+
driver = getattr(client, "driver", None)
|
|
859
|
+
episode_ops = getattr(driver, "episode_node_ops", None) if driver is not None else None
|
|
860
|
+
if episode_ops is None:
|
|
861
|
+
raise BackendAPIError(
|
|
862
|
+
self.name,
|
|
863
|
+
"this graphiti_core driver has no episode_node_ops surface -- "
|
|
864
|
+
"cannot call retrieve_episodes() directly. See "
|
|
865
|
+
"evals/episode_temporal_leak.py's module docstring.",
|
|
866
|
+
)
|
|
867
|
+
try:
|
|
868
|
+
episodes = asyncio.run(
|
|
869
|
+
episode_ops.retrieve_episodes(
|
|
870
|
+
driver, reference_time, last_n=last_n, group_ids=group_ids
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
except Exception as exc: # noqa: BLE001 - vendor call, wrap uniformly
|
|
874
|
+
raise BackendAPIError(self.name, str(exc), crash_signal=_classify_crash(exc)) from exc
|
|
875
|
+
return [_to_plain_dict(ep) for ep in episodes]
|
|
876
|
+
|
|
877
|
+
def close(self) -> None:
|
|
878
|
+
if self._client is None:
|
|
879
|
+
return
|
|
880
|
+
close = getattr(self._client, "close", None)
|
|
881
|
+
if callable(close):
|
|
882
|
+
asyncio.run(close())
|