memtrust-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memtrust/__init__.py +11 -0
- memtrust/adapters/__init__.py +62 -0
- memtrust/adapters/base.py +2020 -0
- memtrust/adapters/mem0_adapter.py +456 -0
- memtrust/adapters/mem0_direct_adapter.py +1217 -0
- memtrust/adapters/mempalace_adapter.py +1166 -0
- memtrust/adapters/openviking_adapter.py +570 -0
- memtrust/adapters/zep_graphiti_adapter.py +181 -0
- memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
- memtrust/cli.py +1201 -0
- memtrust/evals/__init__.py +5 -0
- memtrust/evals/compression.py +235 -0
- memtrust/evals/contradiction.py +451 -0
- memtrust/evals/crash_recovery.py +290 -0
- memtrust/evals/embedder_cost.py +163 -0
- memtrust/evals/embedding_drift.py +273 -0
- memtrust/evals/episode_temporal_leak.py +182 -0
- memtrust/evals/extraction_quality.py +373 -0
- memtrust/evals/filter_injection.py +371 -0
- memtrust/evals/language_degradation.py +189 -0
- memtrust/evals/lock_contention.py +263 -0
- memtrust/evals/locomo.py +392 -0
- memtrust/evals/longmemeval.py +249 -0
- memtrust/evals/mempalace_metadata_scale.py +494 -0
- memtrust/evals/migration_rollback.py +276 -0
- memtrust/evals/orphan_cleanup.py +235 -0
- memtrust/evals/ranking_quality.py +303 -0
- memtrust/evals/resource_sync_safety.py +336 -0
- memtrust/evals/result_consistency.py +239 -0
- memtrust/evals/scale_fixtures.py +189 -0
- memtrust/evals/scale_stress.py +502 -0
- memtrust/evals/stats_accuracy.py +240 -0
- memtrust/evals/temporal_kg_boundary.py +281 -0
- memtrust/receipt.py +318 -0
- memtrust/scoring/__init__.py +1 -0
- memtrust/scoring/cost_tracker.py +199 -0
- memtrust/scoring/llm_judge.py +161 -0
- memtrust_cli-0.3.0.dist-info/METADATA +624 -0
- memtrust_cli-0.3.0.dist-info/RECORD +42 -0
- memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
- memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
- memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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
|
+
)
|