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,1166 @@
1
+ """Adapter for MemPalace (https://github.com/mempalace/mempalace), PyPI
2
+ package `mempalace` (installed and live-verified at version 3.5.0 during
3
+ this rewrite).
4
+
5
+ CONFIRMED-REAL API, NOT A GUESS
6
+ --------------------------------
7
+ Every previous version of this module was written against a fictional
8
+ `mempalace.Palace` class (a `Palace(storage_path=...)` object exposing
9
+ `remember()`/`recall()`/`invalidate()`) that was never confirmed against
10
+ the real, installed package -- and turned out not to exist at all.
11
+ Independently verified during this rewrite: `python3 -c "import mempalace;
12
+ hasattr(mempalace, 'Palace')"` -> `False`; every `^class ` in every file of
13
+ the installed package was grepped, and `mempalace/palace.py` only defines
14
+ two exception classes (`MineAlreadyRunning`, `MineValidationError`), no
15
+ `Palace` class anywhere. So `store()`/`query()`/`update()` in every prior
16
+ version of this file called a method that never existed, on an object that
17
+ was never constructible -- these paths have never worked against the real
18
+ package, ever, in this project's history. Every test that appeared to pass
19
+ was exercising a hand-written fake standing in for that guess, never the
20
+ real thing.
21
+
22
+ This rewrite instead calls the REAL, plain module-level functions in
23
+ `mempalace.mcp_server` -- the actual implementation the MCP server's tool
24
+ calls dispatch to -- exactly the same lazy `import mempalace.mcp_server as
25
+ _mcp_server` pattern this module already used, correctly, for
26
+ `metadata_overview()`/`list_metadata_categories()`/
27
+ `list_metadata_subcategories()` (see the "MCP metadata-tool coverage"
28
+ history below). `_get_mcp_tools()` is now the ONE lazy-import point for
29
+ every real vendor call this adapter makes -- `_get_palace()` and
30
+ `_PalaceProtocol` are gone; there is no fictional-API code path left to
31
+ silently fall back to.
32
+
33
+ Every return shape documented below was captured by calling the real
34
+ functions live, against a real local chromadb-backed palace (`pip install
35
+ mempalace`, `MEMPALACE_PALACE_PATH` pointed at a temp dir, `import
36
+ mempalace.mcp_server as mcp; mcp.tool_add_drawer(...)` etc., matching the
37
+ same seeding pattern `evals/mempalace_metadata_scale.py`'s
38
+ `build_chroma_metadata_seeder()` already established for this repo) -- not
39
+ read off a docstring and trusted. Exact captured shapes:
40
+
41
+ tool_add_drawer(wing, room, content, source_file=None, added_by="mcp")
42
+ -> {"success": True, "drawer_id": str, "wing": str, "room": str,
43
+ "chunks": int, ["chunk_ids": [str, ...]]}
44
+ -> {"success": True, "reason": "already_exists", "drawer_id": str}
45
+ (idempotent re-add of byte-identical content under the same
46
+ wing/room)
47
+ -> {"success": False, "error": str}
48
+ (sanitizer rejection, e.g. an invalid wing/room name)
49
+
50
+ tool_search(query, limit=5, wing=None, room=None, source_file=None,
51
+ max_distance=1.5, min_similarity=None, context=None)
52
+ -> {"query": str, "filters": {"wing":..., "room":..., "source_file":...},
53
+ "total_before_filter": int,
54
+ "results": [
55
+ {"text": str, "wing": str, "room": str, "source_file": str,
56
+ "source_path": str, "created_at": str, "similarity": float,
57
+ "distance": float, "effective_distance": float,
58
+ "closet_boost": float, "matched_via": str, "bm25_score": float,
59
+ ["closet_preview": str]},
60
+ ...
61
+ ]}
62
+ -> {"error": str} (sanitizer rejection; no "results" key at all)
63
+
64
+ tool_update_drawer(drawer_id, content=None, wing=None, room=None)
65
+ -> {"success": True, "drawer_id": str, "wing": str, "room": str}
66
+ -> {"success": False, "error": f"Drawer not found: {drawer_id}"}
67
+
68
+ tool_delete_drawer(drawer_id)
69
+ -> {"success": True, "drawer_id": str, "deleted_ids": [str, ...],
70
+ "chunks_deleted": int}
71
+ -> {"success": False, "error": f"Drawer not found: {drawer_id}"}
72
+
73
+ tool_kg_add(subject, predicate, object, valid_from=None, valid_to=None,
74
+ source_closet=None, source_file=None, source_drawer_id=None)
75
+ -> {"success": True, "triple_id": str, "fact": f"{subject} → {predicate} → {object}"}
76
+
77
+ tool_kg_invalidate(subject, predicate, object, ended=None)
78
+ -> {"success": True, "fact": str, "ended": str}
79
+ (live-verified: this succeeds even when no matching fact was ever
80
+ added -- the real function does not check prior existence before
81
+ recording an invalidation; see kg_invalidate()'s docstring below)
82
+
83
+ tool_kg_query(entity, as_of=None, direction="both")
84
+ -> {"entity": str, "as_of": str | None,
85
+ "facts": [
86
+ {"direction": "outgoing"|"incoming", "subject": str,
87
+ "predicate": str, "object": str, "valid_from": str | None,
88
+ "valid_to": str | None, "confidence": float,
89
+ "source_closet": str | None, "current": bool},
90
+ ...
91
+ ],
92
+ "count": int}
93
+
94
+ tool_status()/tool_list_wings()/tool_list_rooms(wing=None): unchanged
95
+ from this module's pre-existing, already-confirmed "MCP metadata-tool
96
+ coverage" work (see that section below) -- `{"error": ..., "details":
97
+ ..., "hint": ..., "backend": ...}` when no palace/collection exists yet
98
+ (no drawer has ever been written), the documented wing/room-count shape
99
+ otherwise.
100
+
101
+ WING/ROOM MAPPING FOR store()/query()/update()/delete()
102
+ ---------------------------------------------------------
103
+ `mempalace`'s real drawer API needs BOTH a `wing` and a `room` to file or
104
+ search content under -- there is no single-dimension scope the way the
105
+ fictional `Palace.remember(room=...)` guess assumed. This adapter maps
106
+ memtrust's single `session_id` parameter onto `wing` (not `room`): every
107
+ memtrust query() call already hard-scopes to one session, and `wing` is
108
+ the dimension `tool_search`'s own `wing=` filter uses to exclude every
109
+ other session's drawers from a result set, so `wing=session_id` reproduces
110
+ the old adapter's full per-session isolation. `room` -- the real API's
111
+ second, finer-grained scoping dimension, with no memtrust-side equivalent
112
+ -- defaults to `MemPalaceAdapter.DEFAULT_ROOM` ("memtrust") unless the
113
+ caller's `metadata` dict passed to `store()` supplies an explicit
114
+ `metadata["room"]` override, so a caller that wants finer scoping still
115
+ can, without it being required.
116
+
117
+ `store()`'s `metadata` PARAMETER NO LONGER CARRIES ARBITRARY KEY/VALUE
118
+ TAGS INTO THE VENDOR CALL -- A REAL, CONFIRMED LIMITATION
119
+ -------------------------------------------------------------------------
120
+ The old, fictional `Palace.remember(metadata=...)` guess accepted an
121
+ arbitrary string-keyed metadata dict per record (this is what let the old
122
+ test suite inject synthetic `importance`/`emotional_weight` values to
123
+ drive RankingSignal). The REAL, confirmed `tool_add_drawer(wing, room,
124
+ content, source_file=None, added_by="mcp")` has no such parameter at all
125
+ -- there is no way to attach arbitrary per-record metadata to a drawer
126
+ through this tool. `store()` here still accepts memtrust's generic
127
+ `metadata: dict[str, str] | None` parameter (required by
128
+ `MemoryBackendAdapter`'s abstract signature), but only reads two
129
+ recognized keys out of it for real vendor parameters (`"room"`,
130
+ `"source_file"`, `"added_by"`) -- any other key a caller passes is
131
+ silently dropped, not forwarded anywhere, and not an adapter bug: there is
132
+ genuinely nowhere in the real API to put it.
133
+
134
+ RANKING SIGNAL: RE-POINTED AT A REAL FIELD, NOT THE ORIGINAL BUG'S FIELD
135
+ -------------------------------------------------------------------------
136
+ The original `_classify_ranking_signal` (mempalace/mempalace#1733,
137
+ contributor Kartalops) checked `importance`/`emotional_weight`/`weight`/
138
+ `authored_at` -- fields `mempalace/layers.py`'s `Layer1.generate()`
139
+ ("wake-up") sorts by. Two things are now confirmed that were not before:
140
+ (1) since `tool_add_drawer` has no metadata-injection parameter (see
141
+ above), this adapter can never cause those fields to exist on a drawer in
142
+ the first place; (2) `tool_search` -- the function this adapter actually
143
+ calls, confirmed live above -- sorts its own results by `effective_distance`
144
+ (vector cosine distance, optionally closet-boosted), not by
145
+ `importance`/`emotional_weight`/`weight` at all. Those are two different
146
+ code paths in the real package; `Layer1.generate()`'s "wake-up" sort was
147
+ never reachable from this adapter even under the old fictional API. So
148
+ checking for `importance` et al. was never a meaningful test of what this
149
+ adapter actually calls -- it was checking a field belonging to a method
150
+ this module never calls. `_classify_ranking_signal` below is re-pointed at
151
+ `similarity` -- the field `tool_search`'s own confirmed sort key
152
+ (`effective_distance`) directly determines, live-verified above (the
153
+ higher-similarity record came back first) -- so a MISSING_ORDERING_KEY
154
+ verdict now means "this response's own real ranking field was constant or
155
+ absent," the same concept the original signal was trying to express, just
156
+ pointed at a field this adapter's real vendor call actually produces.
157
+
158
+ CONFLICT SIGNAL: NOW HONESTLY NOT_APPLICABLE FOR DRAWER-BACKED query()
159
+ -------------------------------------------------------------------------
160
+ The old adapter set `ConflictSignal.FLAGGED` when a returned record's
161
+ `metadata.get("invalidated") == "true"`. That was an assumption about the
162
+ fictional `Palace.invalidate()`'s write-side effect on `recall()`'s
163
+ future output, never confirmed against a real response. The real
164
+ `tool_search` response items (shape confirmed live above) never carry an
165
+ `"invalidated"` key or any other conflict/invalidation marker -- drawers
166
+ have no such concept in the real package at all; that concept only exists
167
+ on the KG side (see below). `query()` below therefore always reports
168
+ `ConflictSignal.NOT_APPLICABLE` for drawer-backed queries -- an honest
169
+ downgrade from the old (fictional, unverified) FLAGGED path, not a
170
+ regression this rewrite introduced silently.
171
+
172
+ CONTRADICTION/STALENESS DETECTION MOVED TO THE KG API, AS A NEW,
173
+ ADDITIVE CAPABILITY -- NOT A REPLACEMENT FOR query()/update()
174
+ -------------------------------------------------------------------------
175
+ The real, installed `mempalace` package cleanly separates "drawer" (free-
176
+ text content storage, what store()/query()/update()/delete() below map
177
+ onto) from "knowledge graph" (subject-predicate-object facts with
178
+ temporal validity, `tool_kg_add`/`tool_kg_invalidate`/`tool_kg_query`).
179
+ The KG's `tool_kg_invalidate` writes a genuine, real `ended`/`valid_to`
180
+ timestamp and flips a real `current: bool` field `tool_kg_query` reports
181
+ back (both confirmed live above) -- exactly the "this fact was explicitly
182
+ invalidated" signal `ConflictSignal.FLAGGED` was designed to detect, and a
183
+ categorically stronger, more real signal than the old fictional drawer
184
+ `"invalidated"` metadata marker ever was.
185
+
186
+ Rather than force memtrust's generic `store()`/`query()`/`update()`
187
+ contract (free-text content, one call per memory) onto the structurally
188
+ different subject/predicate/object KG shape, this adapter keeps
189
+ store()/query()/update()/delete() mapped to the drawer API (the natural,
190
+ same-shape mapping `MemoryBackendAdapter`'s abstract signatures already
191
+ assume) and adds `kg_add()`/`kg_invalidate()`/`kg_query()` as new,
192
+ additive, MemPalace-specific methods -- not part of the shared
193
+ `MemoryBackendAdapter` contract, the same convention
194
+ `metadata_overview()`/`list_metadata_categories()`/
195
+ `list_metadata_subcategories()` already established for this adapter's
196
+ other MemPalace-specific, non-universal capabilities. A future
197
+ contradiction-detection eval that wants the KG's genuinely stronger signal
198
+ can call these three directly; see tests/test_adapters.py for an
199
+ end-to-end proof against the real package that `kg_invalidate()` after
200
+ `kg_add()` genuinely flips `current` to `False` and stamps a real `ended`
201
+ date on live re-query.
202
+
203
+ NO PER-RECORD ID IN query() RESPONSES -- A REAL, CONFIRMED GAP
204
+ -------------------------------------------------------------------------
205
+ `tool_search`'s confirmed response items (see the shape above) carry no
206
+ `"id"`/`"drawer_id"` field at all -- only `text`/`wing`/`room`/
207
+ `source_file`/`source_path`/`created_at`/scoring fields. This was not
208
+ obvious from the docstring; it required reading `mempalace/searcher.py`'s
209
+ result-building code directly (the only near-miss is an internal, always-
210
+ stripped `_parent_drawer_id` key used for chunk-neighbor lookups, never
211
+ returned to the caller). Recomputing an id client-side via
212
+ `mempalace.ids.make_drawer_id_from_content(wing, room, content)` was
213
+ considered and rejected: `update_drawer()` deliberately keeps a drawer's
214
+ ORIGINAL id stable across a content edit (live-verified above -- updating
215
+ a drawer's content still reports the pre-update drawer_id), so recomputing
216
+ from a query response's CURRENT content would silently produce the wrong
217
+ id for any drawer that has ever been updated. Every `MemoryRecord` a
218
+ `query()` call returns below therefore has `memory_id=""` -- an honest,
219
+ explicit "the real vendor response has nothing to put here" rather than a
220
+ guessed or recomputed value that would be wrong some of the time.
221
+ Consequence: `MemoryBackendAdapter.verify_store()`'s id-match branch can
222
+ never fire against this adapter (empty string never equals a real
223
+ drawer_id) -- verification always falls through to its content-substring
224
+ fallback branch instead, which still works correctly, since `text` in a
225
+ query response is the real stored content. This is a real behavior change
226
+ from the old, fictional adapter (whose fake `Palace.recall()` in tests
227
+ always echoed back a matching id), not a bug in this rewrite.
228
+
229
+ CHUNKED CONTENT: update()/delete() VERIFIED TO WORK CORRECTLY, DESPITE A
230
+ STALE VENDOR DOCSTRING CLAIMING OTHERWISE
231
+ -------------------------------------------------------------------------
232
+ `tool_add_drawer`'s own docstring in the installed package claims: "To
233
+ delete or fetch the underlying drawers, iterate `chunk_ids` or query by
234
+ `parent_drawer_id` -- `tool_get_drawer(drawer_id)` and
235
+ `tool_delete_drawer(drawer_id)` report 'not found' on the chunked path
236
+ because no row is stored under the logical group id." Content above the
237
+ real `chunk_size` (800 chars by default,
238
+ `mempalace.config.DEFAULT_CHUNK_SIZE`) is indeed split into multiple
239
+ physical chunk drawers on `store()`. But that docstring's claim does NOT
240
+ hold up: reading `_logical_drawer_record()` (used by both
241
+ `tool_update_drawer` and `tool_delete_drawer`) shows it explicitly
242
+ resolves a logical group id to its full chunk set via
243
+ `_logical_chunk_group()` when a direct single-row lookup misses -- and
244
+ live verification confirms it: storing 2000 characters of content (well
245
+ over the 800-char default), then calling `tool_update_drawer(drawer_id=
246
+ <the logical id>, content="replacement")` correctly de-chunks it back down
247
+ to a single row, and `tool_delete_drawer(drawer_id=<the logical id>)`
248
+ against a separate 2000-character record correctly deletes all 3
249
+ underlying chunk rows and the content becomes genuinely unsearchable
250
+ afterward. This adapter's `update()`/`delete()` therefore work correctly
251
+ against `store()`-returned ids regardless of content length -- an initial
252
+ draft of this module trusted the vendor's own (stale, contradicted-by-its-
253
+ own-code) docstring text here without verifying it live, which would have
254
+ shipped a false "confirmed" limitation; see
255
+ `test_real_mempalace_chunked_content_update_delete_round_trip` below for
256
+ the live proof this claim is based on, not `tool_add_drawer`'s docstring
257
+ text.
258
+
259
+ DEGRADED-RETRIEVAL WARNINGS: PARSING KEPT, BUT NEVER OBSERVED REAL
260
+ -------------------------------------------------------------------------
261
+ A previous version of this module claimed MemPalace/mempalace#1005 (a
262
+ `{"results": [...], "warnings": [...], "available_in_scope": N}` degraded-
263
+ response shape) was "confirmed against the real, merged PR diff." That
264
+ claim does not hold up against the actually-installed 3.5.0 package: a
265
+ grep for `"warnings"` and `available_in_scope` across
266
+ `mempalace/searcher.py` and `mempalace/mcp_server.py` returns zero matches
267
+ anywhere in the installed package -- no code path in this version ever
268
+ populates either key.
269
+ `query()` below still parses `warnings`/`available_in_scope` defensively
270
+ (harmless, forward-compatible if a future mempalace version reintroduces
271
+ this shape), but `degraded_retrieval` should be expected to always be
272
+ `None` against the real package as installed today; do not read a `None`
273
+ here as "this backend never degrades," only as "this specific installed
274
+ version's `tool_search` has no code path that reports degrading."
275
+
276
+ CROSS-PATH RELIABILITY GAP -- A REAL, LIVE-REPRODUCED VENDOR BUG, NOT AN
277
+ ADAPTER BUG, WITH A CONFIRMED MITIGATION APPLIED BELOW
278
+ -------------------------------------------------------------------------
279
+ Live-reproduced during this rewrite (not from a GitHub issue -- found by
280
+ exercising this adapter's own multi-instance test setup): within a single
281
+ long-lived Python process, pointing `MEMPALACE_PALACE_PATH` at a SECOND,
282
+ never-before-seen palace directory and then immediately calling
283
+ `tool_search()` there can spuriously return `{"error": "No palace found",
284
+ ...}` even though `tool_add_drawer()` against that exact same path, one
285
+ call earlier, succeeded and reported a real `drawer_id`. Root cause,
286
+ confirmed by reading `mempalace/mcp_server.py`'s `_get_client()`: its
287
+ client-cache invalidation keys off the CURRENT palace_path's
288
+ `chroma.sqlite3` file's `(inode, mtime)` compared against the PREVIOUSLY
289
+ cached values, but the surrounding code that decides whether to probe
290
+ `_refresh_vector_disabled_flag()` at all does not appear to always
291
+ re-trigger cleanly across a same-process path switch -- the net effect,
292
+ confirmed by direct reproduction, is a stale-negative on the very first
293
+ search against a freshly-switched path. A further reproduction also
294
+ showed switching BACK to an already-used path can return leftover results
295
+ from whichever path was visited in between, i.e. this is a same-process
296
+ cross-path staleness problem in both directions, not only "new path
297
+ first-search fails." The real, confirmed `tool_reconnect()` function
298
+ reliably clears this when called once immediately after the path changes
299
+ (live-verified: reconnect-then-add-then-search against a brand-new path
300
+ returned exactly the right content, zero errors, across repeated tries).
301
+
302
+ `_sync_mcp_palace_path()` below now detects a real path change (comparing
303
+ the current `MEMPALACE_PALACE_PATH` env value against the one this call is
304
+ about to set) and calls `tool_reconnect()` once when it fires, rather than
305
+ on every call -- so a single long-lived `MemPalaceAdapter` instance
306
+ pointed at one fixed storage path for its whole life (memtrust's normal
307
+ usage pattern: one adapter, one env var, for the duration of one eval run)
308
+ pays zero extra vendor calls, while a process that constructs multiple
309
+ `MemPalaceAdapter` instances against different storage paths (this
310
+ module's own test suite does exactly this) gets a real, working fix
311
+ instead of the silent corruption `_sync_mcp_palace_path()`'s pre-existing
312
+ docstring claimed was already safe, but -- per this reproduction -- was
313
+ not. `tool_reconnect()` itself failing is swallowed (best-effort only,
314
+ matching a real, confirmed vendor function that can itself report
315
+ `{"success": False, "message": "Chroma database missing", ...}` for a
316
+ palace that doesn't exist yet) -- the very next real call still surfaces
317
+ its own error normally if the underlying problem persists.
318
+
319
+ KNOWLEDGE-GRAPH STORAGE IGNORES `MEMPALACE_PALACE_PATH` ENTIRELY WHEN
320
+ CALLED AS A LIBRARY -- A REAL, CONFIRMED, SEPARATE LIMITATION
321
+ -------------------------------------------------------------------------
322
+ Live-reproduced and root-caused by reading `mempalace/mcp_server.py`'s
323
+ `_resolve_kg_path()`: it returns
324
+ `os.path.join(_config.palace_path, "knowledge_graph.sqlite3")` ONLY when a
325
+ module-level `_palace_flag_given` flag is `True` -- and that flag is set
326
+ exactly once, at import time, from `bool(_args.palace)`, i.e. whether the
327
+ process's `sys.argv` carried an explicit CLI `--palace` flag. A library
328
+ caller (this adapter, or any other in-process Python caller that never
329
+ went through `mempalace`'s own CLI argument parser) always has
330
+ `_palace_flag_given = False`, so `_resolve_kg_path()` unconditionally
331
+ returns `mempalace.knowledge_graph.DEFAULT_KG_PATH` --
332
+ `~/.mempalace/knowledge_graph.sqlite3`, a SINGLE FIXED FILE IN THE REAL
333
+ CALLING USER'S HOME DIRECTORY, completely ignoring
334
+ `MEMPALACE_PALACE_PATH`/this adapter's `MEMPALACE_STORAGE_PATH` for the
335
+ knowledge-graph subsystem specifically. Confirmed live: two
336
+ `MemPalaceAdapter` instances constructed against two different
337
+ `MEMPALACE_STORAGE_PATH` values in the same process/environment both read
338
+ and write the exact same physical KG file -- `kg_add()`/`kg_invalidate()`/
339
+ `kg_query()` below carry NO per-instance storage isolation at all, unlike
340
+ every drawer method above them. This is a real, load-bearing limitation
341
+ for any caller (including this module's own test suite -- see
342
+ `test_real_mempalace_kg_*` below, which uses a randomized, per-run-unique
343
+ subject entity specifically because it cannot assume a clean KG slate) and
344
+ should be treated as environment-global, shared state, not scoped to any
345
+ one adapter instance or palace directory.
346
+
347
+ Mode variants ("raw"/"AAAK") REMOVED -- CONFIRMED ABSENT, NOT JUST
348
+ UNCONFIRMED
349
+ -------------------------------------------------------------------------
350
+ The previous `supported_modes = ("raw", "AAAK")` (from
351
+ mempalace/mempalace#27) was already flagged LOW confidence -- a guessed
352
+ constructor/method parameter, never confirmed. It is now definitively
353
+ confirmed ABSENT: neither `tool_add_drawer` nor `tool_search` accepts a
354
+ `mode` keyword at all. `supported_modes` is now `()`; `store()`/`query()`
355
+ below still accept a `mode` parameter (required by
356
+ `MemoryBackendAdapter`'s abstract signature) and silently ignore it, per
357
+ that signature's own documented contract for adapters with no mode
358
+ variants.
359
+
360
+ Migration-rollback simulation (still NOT implemented here, unchanged
361
+ reasoning): see this module's git history for the original "no adapter in
362
+ this repo has real process/filesystem-lifecycle control over a live
363
+ backend" reasoning -- nothing about this rewrite changes that; still
364
+ covered instead by evals/migration_rollback.py's harness-side simulation.
365
+
366
+ MCP metadata-tool coverage (mempalace_status/list_wings/list_rooms):
367
+ unchanged by this rewrite -- `metadata_overview()`/
368
+ `list_metadata_categories()`/`list_metadata_subcategories()` below still
369
+ wrap the same confirmed-real `tool_status()`/`tool_list_wings()`/
370
+ `tool_list_rooms()` functions this module already used correctly before
371
+ this change (MemPalace/mempalace#1871, contributor alionar). They now
372
+ share `_get_mcp_tools()` with every other method in this file instead of
373
+ their own separate `_get_mcp_metadata_tools()` -- one lazy-import point
374
+ for the whole confirmed-real `mempalace.mcp_server` surface this adapter
375
+ uses, since there is no longer a second, fictional-API code path to keep
376
+ separate from it.
377
+
378
+ NO CLOUD API KEY REQUIRED -- BUT NETWORK ACCESS IS, ON FIRST USE
379
+ (MemPalace/mempalace#524, contributor gaby)
380
+ -------------------------------------------------------------------------
381
+ This adapter's own "configuration" is just `MEMPALACE_STORAGE_PATH` --
382
+ unlike Mem0Adapter/ZepGraphitiAdapter, there is no `MEMPALACE_API_KEY`
383
+ env var, because MemPalace's default embedder is local (ChromaDB's
384
+ bundled embedding function), not a cloud API call. That is a narrower
385
+ claim than "fully offline," and this rewrite's live testing reproduced
386
+ exactly the gap gaby reported: on first use in a given environment,
387
+ ChromaDB's default embedder downloads its ONNX model from S3 the first
388
+ time it is actually invoked -- so `mempalace mine .` (and, by extension,
389
+ this adapter's first `store()`/`tool_add_drawer()` call against a fresh
390
+ palace) fails outright on a network-restricted host, even though no API
391
+ key was ever required. Once that one-time download has succeeded (or the
392
+ model is pre-cached), no further network access is needed for that
393
+ palace. Treat "no API key" and "no network access" as two independent
394
+ claims -- this adapter only confirms the former.
395
+ """
396
+
397
+ from __future__ import annotations
398
+
399
+ import contextlib
400
+ import os
401
+ from collections import defaultdict
402
+ from dataclasses import dataclass, field
403
+ from typing import Any, Protocol, cast
404
+
405
+ from memtrust.adapters.base import (
406
+ BackendAPIError,
407
+ BackendNotConfiguredError,
408
+ ConflictSignal,
409
+ DeleteResult,
410
+ MemoryBackendAdapter,
411
+ MemoryRecord,
412
+ MetadataCategoryCountsResult,
413
+ MetadataOverviewResult,
414
+ QueryResult,
415
+ RankingSignal,
416
+ RetrievalWarning,
417
+ StoreResult,
418
+ TemporalBoundarySignal,
419
+ UpdateResult,
420
+ )
421
+
422
+ #: Env var the real installed `mempalace` package's `mempalace.mcp_server`
423
+ #: module reads its palace location from (confirmed via
424
+ #: `mempalace.config.MempalaceConfig.palace_path`), distinct from this
425
+ #: adapter's own MEMPALACE_STORAGE_PATH -- see `_sync_mcp_palace_path()`.
426
+ _MCP_PALACE_PATH_ENV_VAR = "MEMPALACE_PALACE_PATH"
427
+
428
+ #: The real, confirmed per-record fields ranking signal can be derived
429
+ #: from. `similarity` (surfaced from `effective_distance`) is the primary
430
+ #: signal `tool_search`'s own ranking is actually driven by. `authored_at`
431
+ #: is real too -- confirmed via `gh pr diff 1890` and a live read of the
432
+ #: installed package's `mempalace/searcher.py` (MemPalace/mempalace#1890,
433
+ #: contributor JosefAschauer): `search_memories()` sets it as a flat,
434
+ #: top-level field on every result
435
+ #: (`meta.get("authored_at", meta.get("filed_at", "unknown"))`), which
436
+ #: `tool_search` passes through unchanged. It is checked second, as a
437
+ #: fallback signal only used when `similarity` itself is entirely absent
438
+ #: from a response -- unlike the old, removed importance/emotional_weight/
439
+ #: weight keys, which belong to `mempalace/layers.py`'s `Layer1.generate()`
440
+ #: "wake-up" sort, a code path this adapter has never called, in any
441
+ #: version.
442
+ _RANKING_METADATA_KEYS = ("similarity", "authored_at")
443
+
444
+
445
+ def _classify_ranking_signal(records: list[MemoryRecord]) -> RankingSignal:
446
+ """Inspect a query response's records for a ranking-relevant metadata
447
+ field and report whether a real per-record signal appears to exist.
448
+
449
+ Checks `similarity` first (the field the real, confirmed `tool_search`
450
+ actually sorts by), then falls back to `authored_at` (also real and
451
+ confirmed, per `_RANKING_METADATA_KEYS`'s comment above) when
452
+ `similarity` is absent -- rather than the fictional-API-era
453
+ importance/emotional_weight/weight keys, which belong to a different
454
+ method (`Layer1.generate()`) this adapter has never called.
455
+
456
+ Fewer than 2 records is treated as NOT_APPLICABLE -- there is nothing
457
+ to compare an "identical across records" claim against with 0 or 1
458
+ record.
459
+ """
460
+ if len(records) < 2:
461
+ return RankingSignal.NOT_APPLICABLE
462
+
463
+ for key in _RANKING_METADATA_KEYS:
464
+ values = [r.metadata[key] for r in records if key in r.metadata]
465
+ if not values:
466
+ continue
467
+ if len(values) < len(records):
468
+ return RankingSignal.MISSING_ORDERING_KEY
469
+ if len(set(values)) == 1:
470
+ return RankingSignal.MISSING_ORDERING_KEY
471
+ return RankingSignal.SIGNAL_DRIVEN
472
+
473
+ return RankingSignal.MISSING_ORDERING_KEY
474
+
475
+
476
+ def _classify_boundary_signal(facts: list[KGFact], as_of: str | None) -> TemporalBoundarySignal:
477
+ """Inspect a `kg_query(as_of=...)` response's own facts for the exact
478
+ MemPalace/mempalace#1913 boundary-double-count shape (fixed by merged
479
+ PR#1914) -- see `TemporalBoundarySignal` (adapters/base.py) for the
480
+ full bug write-up and the "self-report, never trusted blindly by the
481
+ eval" convention this follows, same as `_classify_ranking_signal`
482
+ above.
483
+
484
+ `as_of=None` (no point-in-time query at all) and an empty `facts` list
485
+ are both NOT_APPLICABLE -- there is no boundary to have resolved
486
+ either way. Otherwise, facts are grouped by `(subject, predicate)` --
487
+ the real `_temporal_filter_sql` matches per-triple, so a boundary
488
+ double-count can only be observed within one subject/predicate group,
489
+ never across two different predicates that happen to share an
490
+ `as_of`. A group is only ever classified DOUBLE_COUNT when it carries
491
+ 2+ distinct `object` values AND at least one fact's `valid_to` equals
492
+ `as_of` exactly AND at least one fact's `valid_from` equals `as_of`
493
+ exactly -- the literal "one fact ended here, another started here"
494
+ shared-instant shape PR#1914's half-open fix targets, not just "this
495
+ predicate happens to have multiple values" (which could be a
496
+ legitimately multi-valued predicate this adapter has no schema to
497
+ rule out, and would be a false positive to flag on that basis alone).
498
+ """
499
+ if as_of is None or not facts:
500
+ return TemporalBoundarySignal.NOT_APPLICABLE
501
+
502
+ groups: dict[tuple[str, str], list[KGFact]] = defaultdict(list)
503
+ for fact in facts:
504
+ groups[(fact.subject, fact.predicate)].append(fact)
505
+
506
+ for group in groups.values():
507
+ distinct_objects = {f.object for f in group}
508
+ if len(distinct_objects) < 2:
509
+ continue
510
+ ended_at_boundary = any(f.valid_to == as_of for f in group)
511
+ started_at_boundary = any(f.valid_from == as_of for f in group)
512
+ if ended_at_boundary and started_at_boundary:
513
+ return TemporalBoundarySignal.DOUBLE_COUNT
514
+
515
+ return TemporalBoundarySignal.CLEAN
516
+
517
+
518
+ def _record_metadata(item: dict[str, Any]) -> dict[str, str]:
519
+ """Build a query() result item's metadata dict from a real, confirmed
520
+ `tool_search` response item -- every top-level field except `text`
521
+ (which becomes `MemoryRecord.content`), stringified, with `None`
522
+ values dropped. Deliberately unopinionated about which keys exist
523
+ (rather than hardcoding the exact confirmed field list) so a future
524
+ `mempalace` version that adds or renames a field still surfaces it
525
+ here instead of silently dropping it.
526
+ """
527
+ return {
528
+ str(key): str(value) for key, value in item.items() if key != "text" and value is not None
529
+ }
530
+
531
+
532
+ class _MCPToolsProtocol(Protocol):
533
+ """Shape this adapter expects from `mempalace.mcp_server` (or a fake
534
+ standing in for it in tests). Every method here is confirmed real and
535
+ live-verified against the installed `mempalace` package (see the
536
+ module docstring) -- unlike the removed `_PalaceProtocol`, whose
537
+ method names were never more than an unconfirmed guess at an API that
538
+ turned out not to exist.
539
+ """
540
+
541
+ def tool_status(self) -> dict[str, Any]: ...
542
+
543
+ def tool_list_wings(self) -> dict[str, Any]: ...
544
+
545
+ def tool_list_rooms(self, wing: str | None = None) -> dict[str, Any]: ...
546
+
547
+ def tool_add_drawer(
548
+ self,
549
+ wing: str,
550
+ room: str,
551
+ content: str,
552
+ source_file: str | None = None,
553
+ added_by: str = "mcp",
554
+ ) -> dict[str, Any]: ...
555
+
556
+ def tool_search(
557
+ self,
558
+ query: str,
559
+ limit: int = 5,
560
+ wing: str | None = None,
561
+ room: str | None = None,
562
+ source_file: str | None = None,
563
+ max_distance: float = 1.5,
564
+ min_similarity: float | None = None,
565
+ context: str | None = None,
566
+ ) -> dict[str, Any]: ...
567
+
568
+ def tool_update_drawer(
569
+ self,
570
+ drawer_id: str,
571
+ content: str | None = None,
572
+ wing: str | None = None,
573
+ room: str | None = None,
574
+ ) -> dict[str, Any]: ...
575
+
576
+ def tool_delete_drawer(self, drawer_id: str) -> dict[str, Any]: ...
577
+
578
+ def tool_reconnect(self) -> dict[str, Any]: ...
579
+
580
+ def tool_kg_add(
581
+ self,
582
+ subject: str,
583
+ predicate: str,
584
+ object: str,
585
+ valid_from: str | None = None,
586
+ valid_to: str | None = None,
587
+ source_closet: str | None = None,
588
+ source_file: str | None = None,
589
+ source_drawer_id: str | None = None,
590
+ ) -> dict[str, Any]: ...
591
+
592
+ def tool_kg_invalidate(
593
+ self, subject: str, predicate: str, object: str, ended: str | None = None
594
+ ) -> dict[str, Any]: ...
595
+
596
+ def tool_kg_query(
597
+ self, entity: str, as_of: str | None = None, direction: str = "both"
598
+ ) -> dict[str, Any]: ...
599
+
600
+
601
+ @dataclass
602
+ class KGFactResult:
603
+ """Result of MemPalaceAdapter.kg_add() -- see the module docstring's
604
+ "CONTRADICTION/STALENESS DETECTION MOVED TO THE KG API" section.
605
+ MemPalace-specific, not part of the shared MemoryBackendAdapter
606
+ contract."""
607
+
608
+ success: bool
609
+ triple_id: str | None
610
+ fact: str | None
611
+ latency_ms: float
612
+ raw: dict[str, Any] = field(default_factory=dict)
613
+ error: str | None = None
614
+
615
+
616
+ @dataclass
617
+ class KGInvalidateResult:
618
+ """Result of MemPalaceAdapter.kg_invalidate(). MemPalace-specific."""
619
+
620
+ success: bool
621
+ fact: str | None
622
+ ended: str | None
623
+ latency_ms: float
624
+ raw: dict[str, Any] = field(default_factory=dict)
625
+ error: str | None = None
626
+
627
+
628
+ @dataclass
629
+ class KGFact:
630
+ """One fact returned by MemPalaceAdapter.kg_query() -- mirrors the
631
+ real, confirmed `tool_kg_query()` per-fact shape field-for-field."""
632
+
633
+ direction: str
634
+ subject: str
635
+ predicate: str
636
+ object: str
637
+ valid_from: str | None
638
+ valid_to: str | None
639
+ confidence: float | None
640
+ source_closet: str | None
641
+ current: bool
642
+
643
+
644
+ @dataclass
645
+ class KGQueryResult:
646
+ """Result of MemPalaceAdapter.kg_query(). MemPalace-specific."""
647
+
648
+ entity: str
649
+ as_of: str | None
650
+ facts: list[KGFact]
651
+ count: int
652
+ latency_ms: float
653
+ boundary_signal: TemporalBoundarySignal = TemporalBoundarySignal.NOT_APPLICABLE
654
+ """This adapter's OWN classification of whether `facts` shows the
655
+ MemPalace/mempalace#1913 boundary-double-count shape at `as_of` -- see
656
+ TemporalBoundarySignal (adapters/base.py) and `_classify_boundary_signal`
657
+ above. Defaults to NOT_APPLICABLE (mirrors every other signal field's
658
+ backward-compatible-default convention in this package); `kg_query()`
659
+ below always computes a real value. A self-report only -- see
660
+ evals/temporal_kg_boundary.py for the eval that independently re-derives
661
+ this from `facts` rather than trusting this field as the final verdict.
662
+ """
663
+ raw: dict[str, Any] = field(default_factory=dict)
664
+
665
+
666
+ class MemPalaceAdapter(MemoryBackendAdapter):
667
+ name = "mempalace"
668
+ env_var = "MEMPALACE_STORAGE_PATH"
669
+ supports_update = True
670
+ #: Confirmed empty -- see the module docstring's "Mode variants"
671
+ #: section. Neither tool_add_drawer nor tool_search accepts a `mode`
672
+ #: keyword in the real, installed package.
673
+ supported_modes: tuple[str, ...] = ()
674
+ supports_metadata_overview = True
675
+
676
+ #: Default `room` for store()/query()/update() when the caller's
677
+ #: `metadata` dict passed to store() doesn't supply `metadata["room"]`
678
+ #: explicitly -- see the module docstring's "WING/ROOM MAPPING"
679
+ #: section.
680
+ DEFAULT_ROOM = "memtrust"
681
+
682
+ def __init__(self, mcp_tools: _MCPToolsProtocol | None = None) -> None:
683
+ storage_path = os.environ.get(self.env_var)
684
+ if not storage_path and mcp_tools is None:
685
+ raise BackendNotConfiguredError(self.name, self.env_var)
686
+ self._storage_path = storage_path
687
+ #: See `_get_mcp_tools()` below -- the ONE lazy-import point for
688
+ #: every real `mempalace.mcp_server` call this adapter makes,
689
+ #: unless a fake is injected here (tests only; see
690
+ #: tests/test_adapters.py). Replaces the removed
691
+ #: `_palace`/`_PalaceProtocol`/`_get_palace()` machinery, which
692
+ #: called a `mempalace.Palace` class that was never real -- see
693
+ #: the module docstring's opening section.
694
+ self._mcp_tools = mcp_tools
695
+
696
+ def _get_mcp_tools(self) -> _MCPToolsProtocol:
697
+ if self._mcp_tools is not None:
698
+ return self._mcp_tools
699
+ try:
700
+ import mempalace.mcp_server as _mcp_server # type: ignore[import-not-found]
701
+ except ImportError as exc:
702
+ raise BackendAPIError(
703
+ self.name,
704
+ "the `mempalace` package is not installed, or its "
705
+ "`mcp_server` submodule failed to import. Install it with "
706
+ "`pip install mempalace` (see this module's docstring for "
707
+ "the confirmed-real API surface this adapter calls).",
708
+ ) from exc
709
+ self._mcp_tools = cast(_MCPToolsProtocol, _mcp_server)
710
+ return self._mcp_tools
711
+
712
+ def _sync_mcp_palace_path(self) -> None:
713
+ """Bridge this adapter's MEMPALACE_STORAGE_PATH into the real
714
+ `mempalace.mcp_server` module's actual config env var,
715
+ MEMPALACE_PALACE_PATH (confirmed different names). Re-set
716
+ immediately before every call (not once at __init__ time) so a
717
+ single process can point this adapter at more than one storage
718
+ path across its lifetime; a no-op when `_storage_path` was never
719
+ set (fake-only unit tests that inject `mcp_tools` directly).
720
+
721
+ See the module docstring's "CROSS-PATH RELIABILITY GAP" section:
722
+ when the env var's value is actually about to change (not the
723
+ common case -- a single adapter's `_storage_path` never changes
724
+ after `__init__`, so this only fires when some OTHER code in the
725
+ same process, e.g. a second `MemPalaceAdapter` instance, last
726
+ pointed `MEMPALACE_PALACE_PATH` somewhere else), this calls the
727
+ real, confirmed `tool_reconnect()` once to clear mempalace's own
728
+ stale client/vector-disabled cache -- live-verified necessary and
729
+ sufficient to avoid a spurious "No palace found" on the very next
730
+ real call.
731
+ """
732
+ if not self._storage_path:
733
+ return
734
+ previous = os.environ.get(_MCP_PALACE_PATH_ENV_VAR)
735
+ os.environ[_MCP_PALACE_PATH_ENV_VAR] = self._storage_path
736
+ if previous is not None and previous != self._storage_path:
737
+ # Best-effort: a real failure here still surfaces normally
738
+ # from the actual vendor call that follows this sync.
739
+ with contextlib.suppress(Exception):
740
+ self._get_mcp_tools().tool_reconnect()
741
+
742
+ def metadata_overview(self) -> MetadataOverviewResult:
743
+ """Real, confirmed library-level equivalent of MemPalace's
744
+ `mempalace_status` MCP tool -- see the module docstring and
745
+ MemPalace/mempalace#1871 for the O(N^2) full-collection-scan bug
746
+ that made this code path worth covering at all.
747
+ """
748
+ timer = self._timed()
749
+ tools = self._get_mcp_tools()
750
+ self._sync_mcp_palace_path()
751
+ try:
752
+ raw = tools.tool_status()
753
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
754
+ raise BackendAPIError(self.name, str(exc)) from exc
755
+ if not isinstance(raw, dict):
756
+ raise BackendAPIError(
757
+ self.name,
758
+ f"tool_status() returned {type(raw).__name__}, expected dict",
759
+ )
760
+ error = raw.get("error")
761
+ return MetadataOverviewResult(
762
+ total_records=raw.get("total_drawers"),
763
+ categories=dict(raw.get("wings") or {}),
764
+ subcategories=dict(raw.get("rooms") or {}),
765
+ latency_ms=timer.elapsed_ms(),
766
+ partial=bool(raw.get("partial")) or error is not None,
767
+ error=str(error) if error is not None else None,
768
+ )
769
+
770
+ def list_metadata_categories(self) -> MetadataCategoryCountsResult:
771
+ """Real, confirmed library-level equivalent of MemPalace's
772
+ `mempalace_list_wings` MCP tool -- see metadata_overview() above.
773
+ """
774
+ timer = self._timed()
775
+ tools = self._get_mcp_tools()
776
+ self._sync_mcp_palace_path()
777
+ try:
778
+ raw = tools.tool_list_wings()
779
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
780
+ raise BackendAPIError(self.name, str(exc)) from exc
781
+ if not isinstance(raw, dict):
782
+ raise BackendAPIError(
783
+ self.name,
784
+ f"tool_list_wings() returned {type(raw).__name__}, expected dict",
785
+ )
786
+ error = raw.get("error")
787
+ return MetadataCategoryCountsResult(
788
+ counts=dict(raw.get("wings") or {}),
789
+ scope=None,
790
+ latency_ms=timer.elapsed_ms(),
791
+ partial=bool(raw.get("partial")) or error is not None,
792
+ error=str(error) if error is not None else None,
793
+ )
794
+
795
+ def list_metadata_subcategories(
796
+ self, category: str | None = None
797
+ ) -> MetadataCategoryCountsResult:
798
+ """Real, confirmed library-level equivalent of MemPalace's
799
+ `mempalace_list_rooms` MCP tool -- see metadata_overview() above.
800
+
801
+ Args:
802
+ category: restrict the listing to this wing name, or None to
803
+ list rooms across every wing (mirrors the real
804
+ `tool_list_rooms(wing=None)` signature exactly).
805
+ """
806
+ timer = self._timed()
807
+ tools = self._get_mcp_tools()
808
+ self._sync_mcp_palace_path()
809
+ try:
810
+ raw = tools.tool_list_rooms(wing=category)
811
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
812
+ raise BackendAPIError(self.name, str(exc)) from exc
813
+ if not isinstance(raw, dict):
814
+ raise BackendAPIError(
815
+ self.name,
816
+ f"tool_list_rooms() returned {type(raw).__name__}, expected dict",
817
+ )
818
+ error = raw.get("error")
819
+ reported_scope = raw.get("wing")
820
+ return MetadataCategoryCountsResult(
821
+ counts=dict(raw.get("rooms") or {}),
822
+ scope=str(reported_scope) if reported_scope not in (None, "all") else category,
823
+ latency_ms=timer.elapsed_ms(),
824
+ partial=bool(raw.get("partial")) or error is not None,
825
+ error=str(error) if error is not None else None,
826
+ )
827
+
828
+ def store(
829
+ self,
830
+ session_id: str,
831
+ content: str,
832
+ metadata: dict[str, str] | None = None,
833
+ mode: str | None = None,
834
+ *,
835
+ verify: bool = False,
836
+ ) -> StoreResult:
837
+ """Store a memory, optionally confirming it durably landed.
838
+
839
+ Maps onto the real, confirmed `tool_add_drawer(wing=session_id,
840
+ room=..., content=content, source_file=..., added_by=...)` -- see
841
+ the module docstring's "WING/ROOM MAPPING" and "`store()`'s
842
+ `metadata` PARAMETER" sections for exactly which `metadata` keys
843
+ are read (`room`, `source_file`, `added_by`) and why the rest are
844
+ not forwarded anywhere.
845
+
846
+ `mode` is accepted (required by the abstract signature) and
847
+ silently ignored -- see the module docstring's "Mode variants"
848
+ section; the real API has no mode concept.
849
+
850
+ `verify` behaves exactly as MemoryBackendAdapter.verify_store()
851
+ documents, with one adapter-specific caveat: because the real
852
+ `tool_search` response carries no per-record id (see the module
853
+ docstring), verification here always resolves through
854
+ verify_store()'s content-substring fallback branch, never its
855
+ id-match branch.
856
+ """
857
+ timer = self._timed()
858
+ tools = self._get_mcp_tools()
859
+ self._sync_mcp_palace_path()
860
+ metadata = metadata or {}
861
+ room = metadata.get("room", self.DEFAULT_ROOM)
862
+ source_file = metadata.get("source_file")
863
+ added_by = metadata.get("added_by", "memtrust")
864
+ try:
865
+ raw = tools.tool_add_drawer(
866
+ wing=session_id,
867
+ room=room,
868
+ content=content,
869
+ source_file=source_file,
870
+ added_by=added_by,
871
+ )
872
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
873
+ raise BackendAPIError(self.name, str(exc)) from exc
874
+ if not isinstance(raw, dict):
875
+ raise BackendAPIError(
876
+ self.name,
877
+ f"tool_add_drawer() returned {type(raw).__name__}, expected dict",
878
+ )
879
+ if not raw.get("success"):
880
+ raise BackendAPIError(
881
+ self.name, raw.get("error") or "tool_add_drawer() reported failure"
882
+ )
883
+ memory_id = str(raw.get("drawer_id", ""))
884
+ result = StoreResult(memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=raw)
885
+ if verify:
886
+ result.verified = self.verify_store(result, session_id, content)
887
+ return result
888
+
889
+ def query(
890
+ self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
891
+ ) -> QueryResult:
892
+ """Maps onto the real, confirmed `tool_search(query=query,
893
+ limit=top_k, wing=session_id)`. See the module docstring's
894
+ "NO PER-RECORD ID", "CONFLICT SIGNAL", and "RANKING SIGNAL"
895
+ sections for the honest, confirmed limitations this method's
896
+ output now carries relative to the old, fictional-API adapter.
897
+ """
898
+ timer = self._timed()
899
+ tools = self._get_mcp_tools()
900
+ self._sync_mcp_palace_path()
901
+ try:
902
+ raw = tools.tool_search(query=query, limit=top_k, wing=session_id)
903
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
904
+ raise BackendAPIError(self.name, str(exc)) from exc
905
+ if not isinstance(raw, dict):
906
+ raise BackendAPIError(
907
+ self.name,
908
+ f"tool_search() returned {type(raw).__name__}, expected dict",
909
+ )
910
+ raw_results = raw.get("results")
911
+ if raw_results is None:
912
+ detail = f" (error: {raw['error']})" if "error" in raw else ""
913
+ raise BackendAPIError(
914
+ self.name,
915
+ "tool_search() returned no 'results' key -- expected the "
916
+ "confirmed real response shape "
917
+ "{'query':..., 'filters':..., 'total_before_filter':..., "
918
+ f"'results': [...]}}. Got keys: {sorted(raw.keys())}.{detail}",
919
+ )
920
+
921
+ records = [
922
+ MemoryRecord(
923
+ # See the module docstring's "NO PER-RECORD ID" section --
924
+ # the real tool_search response never carries an id, and
925
+ # this adapter deliberately does not guess one.
926
+ memory_id="",
927
+ content=str(item.get("text", "")),
928
+ score=item.get("similarity"),
929
+ created_at=item.get("created_at"),
930
+ metadata=_record_metadata(item),
931
+ raw=item,
932
+ )
933
+ for item in raw_results
934
+ ]
935
+
936
+ degraded_retrieval: RetrievalWarning | None = None
937
+ raw_warnings = raw.get("warnings")
938
+ if raw_warnings:
939
+ if not isinstance(raw_warnings, list):
940
+ raise BackendAPIError(
941
+ self.name,
942
+ "tool_search() response's 'warnings' field must be a "
943
+ f"list, got {type(raw_warnings).__name__}.",
944
+ )
945
+ warnings = [str(w) for w in raw_warnings]
946
+ available_in_scope = raw.get("available_in_scope")
947
+ if not isinstance(available_in_scope, int) or isinstance(available_in_scope, bool):
948
+ available_in_scope = None
949
+ degraded_retrieval = RetrievalWarning(
950
+ warnings=warnings, available_in_scope=available_in_scope
951
+ )
952
+
953
+ ranking_signal = _classify_ranking_signal(records)
954
+ return QueryResult(
955
+ records=records,
956
+ # See the module docstring's "CONFLICT SIGNAL" section -- the
957
+ # real tool_search response has no invalidation marker at all;
958
+ # this is now always NOT_APPLICABLE for drawer-backed queries.
959
+ conflict_signal=ConflictSignal.NOT_APPLICABLE,
960
+ latency_ms=timer.elapsed_ms(),
961
+ ranking_signal=ranking_signal,
962
+ degraded_retrieval=degraded_retrieval,
963
+ raw=raw,
964
+ )
965
+
966
+ def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
967
+ """Maps onto the real, confirmed `tool_update_drawer(drawer_id=
968
+ memory_id, content=content)`. `session_id` is accepted (required
969
+ by the abstract signature) and unused -- the real
970
+ `tool_update_drawer` addresses a drawer by id alone, with no wing/
971
+ room/session scoping parameter at all.
972
+
973
+ `acknowledged=False` (not a raised BackendAPIError) reports a
974
+ vendor-acknowledged "no such drawer" -- e.g. a memory_id that was
975
+ already deleted, or never real to begin with. This does NOT
976
+ happen for a legitimately chunked `store()`-returned memory_id --
977
+ see the module docstring's "CHUNKED CONTENT" section for why that
978
+ case works correctly despite `tool_add_drawer`'s own stale
979
+ docstring claiming otherwise. A genuine transport/vendor exception
980
+ still raises BackendAPIError, same as every other method here.
981
+ """
982
+ del session_id # unused: the real tool_update_drawer has no scoping param
983
+ timer = self._timed()
984
+ tools = self._get_mcp_tools()
985
+ self._sync_mcp_palace_path()
986
+ try:
987
+ raw = tools.tool_update_drawer(drawer_id=memory_id, content=content)
988
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
989
+ raise BackendAPIError(self.name, str(exc)) from exc
990
+ if not isinstance(raw, dict):
991
+ raise BackendAPIError(
992
+ self.name,
993
+ f"tool_update_drawer() returned {type(raw).__name__}, expected dict",
994
+ )
995
+ acknowledged = bool(raw.get("success"))
996
+ new_id = str(raw.get("drawer_id", memory_id)) if acknowledged else memory_id
997
+ return UpdateResult(
998
+ memory_id=new_id, acknowledged=acknowledged, latency_ms=timer.elapsed_ms(), raw=raw
999
+ )
1000
+
1001
+ def delete(self, memory_id: str) -> DeleteResult:
1002
+ """Maps onto the real, confirmed `tool_delete_drawer(drawer_id=
1003
+ memory_id)`. Previously always raised BackendAPIError ("no
1004
+ documented delete/forget primitive was confirmed") -- that gap is
1005
+ closed now that the real package's actual delete tool is known.
1006
+
1007
+ `success=False` (not a raised BackendAPIError) reports a vendor-
1008
+ acknowledged "no such drawer," matching DeleteResult's own
1009
+ documented convention ("success here reports the vendor's own
1010
+ acknowledgement shape... not whether the HTTP call itself
1011
+ succeeded") -- e.g. a memory_id that was already deleted. This
1012
+ does NOT happen for a legitimately chunked `store()`-returned
1013
+ memory_id -- see the module docstring's "CHUNKED CONTENT" section.
1014
+ """
1015
+ timer = self._timed()
1016
+ tools = self._get_mcp_tools()
1017
+ self._sync_mcp_palace_path()
1018
+ try:
1019
+ raw = tools.tool_delete_drawer(drawer_id=memory_id)
1020
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
1021
+ raise BackendAPIError(self.name, str(exc)) from exc
1022
+ if not isinstance(raw, dict):
1023
+ raise BackendAPIError(
1024
+ self.name,
1025
+ f"tool_delete_drawer() returned {type(raw).__name__}, expected dict",
1026
+ )
1027
+ success = bool(raw.get("success"))
1028
+ return DeleteResult(
1029
+ success=success, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=raw
1030
+ )
1031
+
1032
+ # -------------------------------------------------------------------
1033
+ # Knowledge-graph API -- MemPalace-specific, additive capability, NOT
1034
+ # part of the shared MemoryBackendAdapter contract. See the module
1035
+ # docstring's "CONTRADICTION/STALENESS DETECTION MOVED TO THE KG API"
1036
+ # section for why these exist alongside (not instead of) store()/
1037
+ # query()/update()/delete() above.
1038
+ # -------------------------------------------------------------------
1039
+
1040
+ def kg_add(
1041
+ self,
1042
+ subject: str,
1043
+ predicate: str,
1044
+ object: str, # noqa: A002 - mirrors the real tool_kg_add() parameter name exactly
1045
+ *,
1046
+ valid_from: str | None = None,
1047
+ valid_to: str | None = None,
1048
+ source_closet: str | None = None,
1049
+ source_file: str | None = None,
1050
+ source_drawer_id: str | None = None,
1051
+ ) -> KGFactResult:
1052
+ """Maps onto the real, confirmed `tool_kg_add(...)`."""
1053
+ timer = self._timed()
1054
+ tools = self._get_mcp_tools()
1055
+ self._sync_mcp_palace_path()
1056
+ try:
1057
+ raw = tools.tool_kg_add(
1058
+ subject=subject,
1059
+ predicate=predicate,
1060
+ object=object,
1061
+ valid_from=valid_from,
1062
+ valid_to=valid_to,
1063
+ source_closet=source_closet,
1064
+ source_file=source_file,
1065
+ source_drawer_id=source_drawer_id,
1066
+ )
1067
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
1068
+ raise BackendAPIError(self.name, str(exc)) from exc
1069
+ if not isinstance(raw, dict):
1070
+ raise BackendAPIError(
1071
+ self.name, f"tool_kg_add() returned {type(raw).__name__}, expected dict"
1072
+ )
1073
+ return KGFactResult(
1074
+ success=bool(raw.get("success")),
1075
+ triple_id=raw.get("triple_id"),
1076
+ fact=raw.get("fact"),
1077
+ latency_ms=timer.elapsed_ms(),
1078
+ raw=raw,
1079
+ error=raw.get("error"),
1080
+ )
1081
+
1082
+ def kg_invalidate(
1083
+ self,
1084
+ subject: str,
1085
+ predicate: str,
1086
+ object: str, # noqa: A002 - mirrors the real tool_kg_invalidate() parameter name exactly
1087
+ *,
1088
+ ended: str | None = None,
1089
+ ) -> KGInvalidateResult:
1090
+ """Maps onto the real, confirmed `tool_kg_invalidate(...)`.
1091
+
1092
+ Live-verified: the real function succeeds even when no matching
1093
+ fact was ever added via kg_add() -- it does not check prior
1094
+ existence before recording an invalidation. Callers that need to
1095
+ distinguish "invalidated a real fact" from "invalidated a fact
1096
+ that was never there" must check that themselves (e.g. via
1097
+ kg_query() before calling this); this adapter does not
1098
+ second-guess the vendor's own accepted result.
1099
+ """
1100
+ timer = self._timed()
1101
+ tools = self._get_mcp_tools()
1102
+ self._sync_mcp_palace_path()
1103
+ try:
1104
+ raw = tools.tool_kg_invalidate(
1105
+ subject=subject, predicate=predicate, object=object, ended=ended
1106
+ )
1107
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
1108
+ raise BackendAPIError(self.name, str(exc)) from exc
1109
+ if not isinstance(raw, dict):
1110
+ raise BackendAPIError(
1111
+ self.name,
1112
+ f"tool_kg_invalidate() returned {type(raw).__name__}, expected dict",
1113
+ )
1114
+ return KGInvalidateResult(
1115
+ success=bool(raw.get("success")),
1116
+ fact=raw.get("fact"),
1117
+ ended=raw.get("ended"),
1118
+ latency_ms=timer.elapsed_ms(),
1119
+ raw=raw,
1120
+ error=raw.get("error"),
1121
+ )
1122
+
1123
+ def kg_query(
1124
+ self, entity: str, *, as_of: str | None = None, direction: str = "both"
1125
+ ) -> KGQueryResult:
1126
+ """Maps onto the real, confirmed `tool_kg_query(...)`."""
1127
+ timer = self._timed()
1128
+ tools = self._get_mcp_tools()
1129
+ self._sync_mcp_palace_path()
1130
+ try:
1131
+ raw = tools.tool_kg_query(entity=entity, as_of=as_of, direction=direction)
1132
+ except Exception as exc: # noqa: BLE001 - real vendor call, wrap uniformly
1133
+ raise BackendAPIError(self.name, str(exc)) from exc
1134
+ if not isinstance(raw, dict):
1135
+ raise BackendAPIError(
1136
+ self.name, f"tool_kg_query() returned {type(raw).__name__}, expected dict"
1137
+ )
1138
+ if "facts" not in raw:
1139
+ raise BackendAPIError(
1140
+ self.name,
1141
+ "tool_kg_query() returned no 'facts' key -- expected the "
1142
+ f"confirmed real response shape. Got keys: {sorted(raw.keys())}.",
1143
+ )
1144
+ facts = [
1145
+ KGFact(
1146
+ direction=str(f.get("direction", "")),
1147
+ subject=str(f.get("subject", "")),
1148
+ predicate=str(f.get("predicate", "")),
1149
+ object=str(f.get("object", "")),
1150
+ valid_from=f.get("valid_from"),
1151
+ valid_to=f.get("valid_to"),
1152
+ confidence=f.get("confidence"),
1153
+ source_closet=f.get("source_closet"),
1154
+ current=bool(f.get("current")),
1155
+ )
1156
+ for f in raw["facts"]
1157
+ ]
1158
+ return KGQueryResult(
1159
+ entity=str(raw.get("entity", entity)),
1160
+ as_of=raw.get("as_of"),
1161
+ facts=facts,
1162
+ count=int(raw.get("count", len(facts))),
1163
+ latency_ms=timer.elapsed_ms(),
1164
+ boundary_signal=_classify_boundary_signal(facts, raw.get("as_of")),
1165
+ raw=raw,
1166
+ )