memtrust-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,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())