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,570 @@
1
+ """Adapter for OpenViking (https://github.com/volcengine/OpenViking).
2
+
3
+ Confidence: MEDIUM on architecture, LOW on exact memory-write/query
4
+ endpoint paths.
5
+
6
+ OpenViking (ByteDance/Volcengine) is confirmed to organize agent context
7
+ as a virtual filesystem addressed by `viking://` URIs, with a REST server
8
+ mode listening on port 1933 and documented Python client classes
9
+ (`OpenViking` for embedded/local mode, `SyncHTTPClient`/`AsyncHTTPClient`
10
+ for remote server mode). The fetched API docs during this build covered
11
+ resource/skill ingestion (`add_resource`, `add_skill`) in detail but did
12
+ not surface a confirmed method name for writing or querying a
13
+ conversational *memory* entry specifically (as opposed to a resource or
14
+ skill file) -- OpenViking's memory layer is described as an automatic,
15
+ session-derived extraction process rather than a direct "store this fact"
16
+ call in the documentation surfaced here.
17
+
18
+ This adapter is written best-effort against the confirmed `viking://`
19
+ filesystem paradigm: store() writes a file under a session-scoped path,
20
+ query() greps/searches that path, update() overwrites the file at the
21
+ same path. If OpenViking's real memory API differs materially from a
22
+ filesystem write/search, this adapter's behavior should be corrected by
23
+ whoever verifies it against a live instance -- flagged explicitly here
24
+ and in docs/methodology.md rather than presented as confirmed.
25
+
26
+ list_resource_paths()/trigger_resync() (supports_resource_sync = True)
27
+ are written against the same `viking://` filesystem paradigm, guessing a
28
+ `/v1/fs/list` listing endpoint and a `/v1/fs/resync` resync-trigger
29
+ endpoint by analogy with the confirmed `/v1/fs/write` and `/v1/search`
30
+ paths above. Neither endpoint was confirmed in this build's research
31
+ pass -- confidence is LOW on the exact paths, same flag as store's
32
+ memory-write endpoint above. This is the capability that lets the
33
+ resource-sync-safety eval (evals/resource_sync_safety.py) exercise
34
+ OpenViking at all; it exists specifically because OpenViking's Feishu
35
+ resync mechanism has a reported data-loss bug (a resync silently
36
+ deleting user-owned files the ingestion watcher did not generate --
37
+ volcengine/OpenViking#3029) that the store/query/update model alone
38
+ cannot observe.
39
+
40
+ store() honors a `resource_path` metadata key (e.g.
41
+ "entities/people/jordan-lee.md") when the caller supplies one, writing
42
+ to that real nested `viking://` path instead of always falling back to
43
+ the flat `memory/{session_id}/{sha256(content)[:16]}` single-level
44
+ filename. Without `resource_path` in metadata, the flat-hash behavior
45
+ is unchanged (backward compatible for every existing caller). This
46
+ closes a structural gap found validating volcengine/OpenViking#1703
47
+ (index_resource() in OpenViking's embedding_utils.py skipped every
48
+ subdirectory during reindex, so nested-directory content was never
49
+ vectorized and searches over it silently returned nothing): before this
50
+ change, memtrust's own store() never actually constructed a real nested
51
+ directory tree against OpenViking, so a directory-indexing bug like
52
+ #1703 was structurally unreachable by this harness regardless of how
53
+ good the eval classification logic was. list_resource_paths() now does
54
+ a real recursive tree walk (bounded by an optional `max_depth`) so
55
+ nested paths a listing response reports as directories are actually
56
+ descended into and returned as leaf file paths, not just whatever a
57
+ single flat response happened to contain. See docs/methodology.md for
58
+ the honest scope of what this closes: it makes the #1703 bug class
59
+ reachable by this harness's storage layer, it does not reproduce
60
+ OpenViking's real server-side reindex bug without a live instance.
61
+
62
+ Gated on OPENVIKING_API_KEY, matching the project's hosted "OpenViking
63
+ Studio" offering; OPENVIKING_BASE_URL may override the default host to
64
+ point at a self-hosted server instead.
65
+
66
+ On volcengine/OpenViking#1523 (contributor A0nameless0man: an embedder
67
+ migration silently degrades search quality mid-migration -- switching
68
+ embedding models overwrites vectors in place with no dimension/model
69
+ validation): the `/v1/search` response shape documented and confirmed
70
+ during this build (see the top of this docstring) surfaces `path`,
71
+ `content`/`snippet`, `score`, `updated_at`, and `metadata` per result --
72
+ nothing that identifies which embedding model or vector dimensionality
73
+ produced a given record. `query()` above therefore cannot report
74
+ `MemoryRecord.embedding_model`/`embedding_dims` (see adapters/base.py) for
75
+ real OpenViking responses; both stay at their default `None`. This is why
76
+ #1523 is scored at the harness level instead, against fake adapters
77
+ engineered to reproduce its exact bug shape -- see
78
+ evals/embedding_drift.py and docs/methodology.md for the eval and its
79
+ honest scope.
80
+
81
+ `supports_crash_recovery_simulation` is NOT set on this adapter (stays at
82
+ the base class default, False). volcengine/OpenViking#2644 (contributor
83
+ yeyitech) reports a local vectordb backend's `_recover()` silently
84
+ skipping index rebuild on server-process restart when index files are
85
+ missing but store data exists -- a real, cited bug this project's
86
+ crash-recovery eval (evals/crash_recovery.py) exists to catch. This
87
+ adapter is a pure HTTP client with no ability to start, kill, or restart
88
+ a live OpenViking server process, and no confirmed endpoint that reads
89
+ raw stored data bypassing the search/index layer `query()` above goes
90
+ through -- both are required for that eval to run against a real
91
+ backend. Until this adapter (or a future one) gains real
92
+ process-lifecycle control, the crash-recovery eval only ever runs
93
+ against a purpose-built fake adapter that models #2644's shape; see
94
+ evals/crash_recovery.py's module docstring and docs/methodology.md for
95
+ exactly what that does and does not prove.
96
+
97
+ `supports_stats = True`: get_stats() hits `GET /api/v1/stats/memories`
98
+ and reads its `total_memories` field. Confidence HIGH on this specific
99
+ endpoint and response shape -- both are quoted verbatim in volcengine/
100
+ OpenViking#1255's bug report (contributor SeeYangZhi), which is also the
101
+ motivating case: the endpoint silently returns an all-zero count even
102
+ when filesystem listing and `/v1/search` both independently confirm real
103
+ memories exist. See evals/stats_accuracy.py, the eval this feeds.
104
+ """
105
+
106
+ from __future__ import annotations
107
+
108
+ import json
109
+ import os
110
+
111
+ import httpx
112
+
113
+ from memtrust.adapters.base import (
114
+ BackendAPIError,
115
+ BackendNotConfiguredError,
116
+ ConflictSignal,
117
+ CrashSignal,
118
+ DeletePrefixResult,
119
+ DeleteResult,
120
+ ExtractionSignal,
121
+ MemoryBackendAdapter,
122
+ MemoryRecord,
123
+ QueryResult,
124
+ RankingSignal,
125
+ StatsResult,
126
+ StoreResult,
127
+ UpdateResult,
128
+ )
129
+
130
+ DEFAULT_BASE_URL = "http://localhost:1933"
131
+
132
+ #: Approximate reranker batch-token budget a typical local reranker (e.g.
133
+ #: qwen3-reranker-0.6B) enforces, per hhspiny's own measured report on
134
+ #: volcengine/OpenViking#2880 (a `QMD_RERANK_CONTEXT_SIZE`-style default).
135
+ #: OpenViking's own rerank-provider config is not confirmed to expose this
136
+ #: value through any endpoint this adapter calls, so this is a
137
+ #: conservative, documented approximation -- not a value read from a live
138
+ #: OpenViking config. See `_rerank_fallback_risk()` below and
139
+ #: RankingSignal.RERANK_FALLBACK in base.py.
140
+ MAX_RERANK_TOKENS = 4096
141
+
142
+
143
+ def _error_detail(exc: httpx.HTTPError) -> str:
144
+ """Build a BackendAPIError detail that includes the real response body
145
+ when one is available, not just the generic status-line `str(exc)`.
146
+
147
+ Motivating case: volcengine/OpenViking#1227, where a server-side
148
+ Pydantic validation error (e.g. `"id" extra_forbidden`) was silently
149
+ swallowed down to a useless status-line-only message like
150
+ "Client error '400 Bad Request' for url ..." -- the actual validation
151
+ detail explaining WHAT failed was discarded entirely. `exc.response`
152
+ is `None` for request-level failures (a connection error, a timeout)
153
+ that never got a response back at all, not just for
154
+ `httpx.HTTPStatusError` -- handled explicitly rather than assumed
155
+ present, since not every `httpx.HTTPError` subclass carries one.
156
+ """
157
+ response = getattr(exc, "response", None)
158
+ if response is None:
159
+ return str(exc)
160
+ body = response.text
161
+ if not body:
162
+ return str(exc)
163
+ return f"{exc} -- response body: {body}"
164
+
165
+
166
+ def _estimate_tokens(text: str) -> int:
167
+ """Cheap, tokenizer-free token-count approximation (~4 chars/token, the
168
+ same rough heuristic widely used for English text when no real
169
+ tokenizer is available). Not a substitute for the reranker's own
170
+ tokenizer -- see MAX_RERANK_TOKENS's docstring for the honesty caveat
171
+ this approximation carries."""
172
+ return max(1, len(text) // 4)
173
+
174
+
175
+ def _rerank_fallback_risk(records: list[MemoryRecord]) -> bool:
176
+ """Whether this response's own candidate set carries the same input
177
+ shape volcengine/OpenViking#1737 (an empty-string document) or
178
+ #2739/#2880 (a batch exceeding the reranker's real token budget)
179
+ report as silently degrading to a raw vector-score fallback with no
180
+ caller-visible signal.
181
+
182
+ This is a heuristic classification computed entirely from records this
183
+ adapter already has in hand -- it cannot intercept OpenViking's actual
184
+ server-side rerank-batch construction, so it flags the SHAPE a
185
+ response's candidates carry (an empty document, or a total estimated
186
+ token count over budget), not confirmed proof this specific live call
187
+ fell back server-side. See RankingSignal.RERANK_FALLBACK in base.py
188
+ for the full honesty caveat and upstream-fix citations.
189
+ """
190
+ if any(record.content == "" for record in records):
191
+ return True
192
+ total_tokens = sum(_estimate_tokens(record.content) for record in records)
193
+ return total_tokens > MAX_RERANK_TOKENS
194
+
195
+
196
+ class OpenVikingAdapter(MemoryBackendAdapter):
197
+ name = "openviking"
198
+ env_var = "OPENVIKING_API_KEY"
199
+ supports_update = True
200
+ supports_resource_sync = True
201
+ supports_stats = True
202
+ supports_prefix_delete = True
203
+
204
+ def __init__(self, base_url: str | None = None, timeout: float = 30.0) -> None:
205
+ api_key = os.environ.get(self.env_var)
206
+ if not api_key:
207
+ raise BackendNotConfiguredError(self.name, self.env_var)
208
+ resolved_base_url = base_url or os.environ.get("OPENVIKING_BASE_URL") or DEFAULT_BASE_URL
209
+ self._http = httpx.Client(
210
+ base_url=resolved_base_url,
211
+ headers={
212
+ "Authorization": f"Bearer {api_key}",
213
+ "Content-Type": "application/json",
214
+ },
215
+ timeout=timeout,
216
+ )
217
+
218
+ def _path(self, session_id: str, key: str) -> str:
219
+ return f"memory/{session_id}/{key}"
220
+
221
+ def store(
222
+ self,
223
+ session_id: str,
224
+ content: str,
225
+ metadata: dict[str, str] | None = None,
226
+ mode: str | None = None,
227
+ ) -> StoreResult:
228
+ # OpenViking's filesystem paradigm has no documented operating-mode
229
+ # variant -- accepted and ignored (no-op); see
230
+ # MemoryBackendAdapter.supported_modes.
231
+ del mode
232
+ timer = self._timed()
233
+ metadata = metadata or {}
234
+ # A caller (e.g. evals/resource_sync_safety.py) that knows the real
235
+ # nested path a piece of content belongs under -- "entities/people/
236
+ # jordan-lee.md", "preferences/user-482/notification-settings.md" --
237
+ # passes it via the `resource_path` metadata key, and store()
238
+ # writes to that real path instead of flattening every write to a
239
+ # single-level content-hash filename. Falls back to the flat hash
240
+ # when no resource_path is supplied, so every existing caller that
241
+ # never sets this key keeps its current behavior unchanged. See the
242
+ # module docstring for why this matters (volcengine/OpenViking#1703).
243
+ resource_path = metadata.get("resource_path")
244
+ memory_key = resource_path.strip("/") if resource_path else _slugify(content)
245
+ payload: dict[str, object] = {
246
+ "path": f"viking://{self._path(session_id, memory_key)}",
247
+ "content": content,
248
+ "metadata": metadata,
249
+ }
250
+ try:
251
+ resp = self._http.post("/v1/fs/write", json=payload)
252
+ resp.raise_for_status()
253
+ data = resp.json()
254
+ except httpx.HTTPError as exc:
255
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
256
+ except json.JSONDecodeError as exc:
257
+ # volcengine/OpenViking#2966: a legacy uint16-length-truncated
258
+ # record's `fields` JSON crashes internal delta-list conversion
259
+ # (_convert_delta_list_for_index -> convert_fields_for_index ->
260
+ # a bare json.loads(fields_json)) on the write/upsert path this
261
+ # call exercises when it overwrites an existing key. This
262
+ # adapter's own `resp.json()` call would otherwise let that raw
263
+ # JSONDecodeError escape as an unclassified bare exception,
264
+ # violating the "always raise BackendAPIError" contract every
265
+ # adapter must honor -- see CrashSignal
266
+ # .LEGACY_CORRUPT_RECORD_UNDELETABLE in base.py for the full
267
+ # honesty caveat on what this classifies.
268
+ raise BackendAPIError(
269
+ self.name,
270
+ f"response body is not valid JSON: {exc}",
271
+ crash_signal=CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE,
272
+ ) from exc
273
+ memory_id = str(data.get("path", payload["path"]))
274
+ # See ExtractionSignal in base.py -- volcengine/OpenViking#2751: a
275
+ # store() call that hits OpenViking's own session-based extraction
276
+ # pipeline can complete without raising and still report zero
277
+ # memories actually extracted (the OpenAI VLM backend's hardcoded
278
+ # max_tokens=32768 exceeds gpt-4o-mini's real 16384 cap; the
279
+ # resulting API 400 gets swallowed inside compressor_v2, and the
280
+ # write commit still returns 200/accepted with total_memories
281
+ # staying 0, silent). This adapter's store() targets the
282
+ # /v1/fs/write filesystem endpoint (see module docstring for the
283
+ # confidence caveat on the real memory-extraction endpoint), which
284
+ # is not confirmed to echo back a `total_memories`/
285
+ # `memories_extracted` count on every deployment/version -- when
286
+ # present and explicitly 0, that is the identical "response looks
287
+ # like a normal 200 but nothing was extracted" shape #2751 reports.
288
+ # Absent entirely, this defaults to FACTS_EXTRACTED whenever a
289
+ # usable path/memory_id came back, the same "presence of an id is
290
+ # a successful store" assumption store() already made before this
291
+ # change -- see docs/methodology.md for the confidence caveat.
292
+ total_memories = data.get("total_memories", data.get("memories_extracted"))
293
+ if total_memories is not None and total_memories == 0:
294
+ extraction_signal = ExtractionSignal.EMPTY_EXTRACTION
295
+ elif memory_id:
296
+ extraction_signal = ExtractionSignal.FACTS_EXTRACTED
297
+ else:
298
+ extraction_signal = ExtractionSignal.EMPTY_EXTRACTION
299
+ return StoreResult(
300
+ memory_id=memory_id,
301
+ latency_ms=timer.elapsed_ms(),
302
+ raw=data,
303
+ extraction_signal=extraction_signal,
304
+ )
305
+
306
+ def query(
307
+ self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
308
+ ) -> QueryResult:
309
+ del mode # no-op, see store() above
310
+ timer = self._timed()
311
+ payload = {"path_prefix": f"viking://memory/{session_id}/", "query": query, "limit": top_k}
312
+ try:
313
+ resp = self._http.post("/v1/search", json=payload)
314
+ resp.raise_for_status()
315
+ data = resp.json()
316
+ except httpx.HTTPError as exc:
317
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
318
+
319
+ items = data.get("results", data.get("matches", []))
320
+ records = [
321
+ MemoryRecord(
322
+ memory_id=str(item.get("path", "")),
323
+ content=str(item.get("content", item.get("snippet", ""))),
324
+ score=item.get("score"),
325
+ created_at=item.get("updated_at"),
326
+ metadata=item.get("metadata") or {},
327
+ raw=item,
328
+ )
329
+ for item in items
330
+ ]
331
+ # OpenViking's filesystem paradigm has no documented conflict-
332
+ # marker field surfaced in this build's research pass -- a
333
+ # contradicting fact written to the same path simply overwrites
334
+ # the file's content with no version history exposed through the
335
+ # search API as documented. Recorded as NOT_APPLICABLE rather than
336
+ # guessed as FLAGGED or SILENT_OVERWRITE; see docs/methodology.md.
337
+ #
338
+ # Rerank-awareness (volcengine/OpenViking#1737, #2739, #2880): a
339
+ # multi-candidate response carrying an empty-string document or an
340
+ # oversized total token budget matches the shape both cited bug
341
+ # reports describe as silently falling back to raw vector scores
342
+ # -- see _rerank_fallback_risk()'s and RankingSignal
343
+ # .RERANK_FALLBACK's own docstrings for the full honesty caveat
344
+ # (both bugs are already fixed upstream; this flags the SHAPE, not
345
+ # confirmed live server-side fallback).
346
+ ranking_signal = (
347
+ RankingSignal.RERANK_FALLBACK
348
+ if len(records) > 1 and _rerank_fallback_risk(records)
349
+ else RankingSignal.NOT_APPLICABLE
350
+ )
351
+ return QueryResult(
352
+ records=records,
353
+ conflict_signal=ConflictSignal.NOT_APPLICABLE,
354
+ latency_ms=timer.elapsed_ms(),
355
+ ranking_signal=ranking_signal,
356
+ raw=data,
357
+ )
358
+
359
+ def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
360
+ timer = self._timed()
361
+ payload = {"path": memory_id, "content": content}
362
+ try:
363
+ resp = self._http.post("/v1/fs/write", json=payload)
364
+ resp.raise_for_status()
365
+ data = resp.json()
366
+ except httpx.HTTPError as exc:
367
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
368
+ except json.JSONDecodeError as exc:
369
+ # Same volcengine/OpenViking#2966 shape as store() above --
370
+ # update()'s /v1/fs/write call is this adapter's own upsert
371
+ # path (it overwrites an existing key), the exact "upsert_data()"
372
+ # write path #2966 reports as crashing on a legacy-corrupt
373
+ # record. See CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE.
374
+ raise BackendAPIError(
375
+ self.name,
376
+ f"response body is not valid JSON: {exc}",
377
+ crash_signal=CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE,
378
+ ) from exc
379
+ return UpdateResult(
380
+ memory_id=memory_id, acknowledged=True, latency_ms=timer.elapsed_ms(), raw=data
381
+ )
382
+
383
+ def delete(self, memory_id: str) -> DeleteResult:
384
+ # Best-effort reconstruction against the same viking:// filesystem
385
+ # paradigm store()/update() above are written against: this build's
386
+ # research pass did not surface a confirmed "delete a memory"
387
+ # method name (see module docstring), so this targets the plain
388
+ # filesystem-delete symmetrical with /v1/fs/write. Whoever verifies
389
+ # this adapter against a live instance should correct the path if
390
+ # OpenViking's real client exposes something else.
391
+ timer = self._timed()
392
+ payload = {"path": memory_id}
393
+ try:
394
+ resp = self._http.post("/v1/fs/delete", json=payload)
395
+ resp.raise_for_status()
396
+ data = resp.json() if resp.content else {}
397
+ except httpx.HTTPError as exc:
398
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
399
+ except json.JSONDecodeError as exc:
400
+ # volcengine/OpenViking#2966: LocalIndex.delete_data() runs the
401
+ # same _convert_delta_list_for_index() -> bare
402
+ # json.loads(fields_json) path store()/update() above hit --
403
+ # this is the literal "delete()" half of "delete/upsert" the
404
+ # issue names. See CrashSignal
405
+ # .LEGACY_CORRUPT_RECORD_UNDELETABLE for the full citation.
406
+ raise BackendAPIError(
407
+ self.name,
408
+ f"response body is not valid JSON: {exc}",
409
+ crash_signal=CrashSignal.LEGACY_CORRUPT_RECORD_UNDELETABLE,
410
+ ) from exc
411
+ return DeleteResult(
412
+ success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=data
413
+ )
414
+
415
+ def list_resource_paths(self, prefix: str, max_depth: int = 8) -> list[str]:
416
+ """Recursively list every leaf file path under `prefix`.
417
+
418
+ A single `/v1/fs/list` response is not assumed to already contain
419
+ every nested file -- each returned entry is inspected, and any
420
+ entry the listing endpoint reports as a directory (a dict with
421
+ `is_dir`/`type: "directory"`, or a bare path string ending in `/`)
422
+ is itself descended into with a follow-up `/v1/fs/list` call,
423
+ rather than being dropped or treated as a leaf. `max_depth` bounds
424
+ that recursion (default 8) so a misbehaving or cyclic listing
425
+ response cannot recurse unboundedly.
426
+
427
+ This exists because a flat, non-recursive read of whatever the
428
+ first response happened to contain would make nested content
429
+ structurally invisible to this harness regardless of what
430
+ store() actually wrote -- see the module docstring and
431
+ volcengine/OpenViking#1703.
432
+ """
433
+ return self._list_resource_paths_recursive(prefix, depth=0, max_depth=max_depth)
434
+
435
+ def _list_resource_paths_recursive(self, prefix: str, depth: int, max_depth: int) -> list[str]:
436
+ payload = {"path_prefix": f"viking://{prefix}"}
437
+ try:
438
+ resp = self._http.post("/v1/fs/list", json=payload)
439
+ resp.raise_for_status()
440
+ data = resp.json()
441
+ except httpx.HTTPError as exc:
442
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
443
+ entries = data.get("paths", data.get("entries", []))
444
+ paths: list[str] = []
445
+ for entry in entries:
446
+ if isinstance(entry, dict):
447
+ entry_path = str(entry.get("path", ""))
448
+ is_dir = bool(entry.get("is_dir") or entry.get("type") == "directory")
449
+ else:
450
+ entry_path = str(entry)
451
+ is_dir = entry_path.endswith("/")
452
+ if not entry_path:
453
+ continue
454
+ if is_dir:
455
+ if depth >= max_depth:
456
+ continue
457
+ sub_prefix = entry_path
458
+ if sub_prefix.startswith("viking://"):
459
+ sub_prefix = sub_prefix[len("viking://") :]
460
+ sub_prefix = sub_prefix.rstrip("/")
461
+ if not sub_prefix or sub_prefix == prefix.rstrip("/"):
462
+ # Guard against a listing entry that just echoes the
463
+ # queried prefix back as a "directory" -- recursing on
464
+ # it would loop forever at constant depth.
465
+ continue
466
+ paths.extend(self._list_resource_paths_recursive(sub_prefix, depth + 1, max_depth))
467
+ else:
468
+ paths.append(entry_path)
469
+ return paths
470
+
471
+ def trigger_resync(self, prefix: str) -> None:
472
+ payload = {"path_prefix": f"viking://{prefix}"}
473
+ try:
474
+ resp = self._http.post("/v1/fs/resync", json=payload)
475
+ resp.raise_for_status()
476
+ except httpx.HTTPError as exc:
477
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
478
+
479
+ def get_stats(self, session_id: str | None = None) -> StatsResult:
480
+ """Read OpenViking's dedicated stats/dashboard endpoint.
481
+
482
+ Confidence: HIGH on the endpoint path and response shape --
483
+ `GET /api/v1/stats/memories` and its `total_memories` field are
484
+ confirmed verbatim from volcengine/OpenViking#1255's real bug
485
+ report (contributor SeeYangZhi), which pastes the exact JSON
486
+ response shape this parses. Distinct from the LOW-confidence
487
+ guessed `/v1/fs/write`/`/v1/search` endpoints elsewhere in this
488
+ adapter (see the module docstring): this path was never confirmed
489
+ against a live instance either, but the exact response shape comes
490
+ from a real, reproduced report rather than being inferred by
491
+ analogy.
492
+
493
+ `session_id` is accepted for interface compatibility with
494
+ MemoryBackendAdapter.get_stats() but ignored -- the real endpoint
495
+ this targets is a tenant-wide/global counter (per #1255's report),
496
+ not session-scoped, so there is nothing to pass it as.
497
+ """
498
+ del session_id
499
+ timer = self._timed()
500
+ try:
501
+ resp = self._http.get("/api/v1/stats/memories")
502
+ resp.raise_for_status()
503
+ data = resp.json()
504
+ except httpx.HTTPError as exc:
505
+ raise BackendAPIError(self.name, _error_detail(exc)) from exc
506
+ total = data.get("total_memories")
507
+ total_memories = int(total) if isinstance(total, int | float) else None
508
+ return StatsResult(total_memories=total_memories, latency_ms=timer.elapsed_ms(), raw=data)
509
+
510
+ def delete_prefix(self, prefix: str, recursive: bool = True) -> DeletePrefixResult:
511
+ """Recursively delete every resource path under `prefix`.
512
+
513
+ Best-effort reconstruction against volcengine/OpenViking#3064's
514
+ recommended fix direction: discover every child path via
515
+ list_resource_paths() (this adapter's own recursive tree walk,
516
+ see that method's docstring) rather than a single-level listing,
517
+ then delete() each discovered path plus the prefix root itself.
518
+
519
+ Honesty caveat (see VectorIntegritySignal in base.py and
520
+ evals/orphan_cleanup.py for the eval this feeds): list_resource_paths()
521
+ goes through the same `/v1/fs/list` AGFS-directory-listing endpoint
522
+ #3064's root cause lives in. If a live OpenViking server still has
523
+ the bug this issue reports -- `_ls_entries()` raising when the
524
+ parent directory no longer exists in AGFS, silently swallowed by a
525
+ bare `except: pass` -- this adapter's own listing call would
526
+ suffer the identical limitation: it would enumerate zero children,
527
+ this method would only delete the prefix root, and any child
528
+ vector-index entries would survive undetected by anything that
529
+ only checks list_resource_paths() again afterward. That is
530
+ precisely why evals/orphan_cleanup.py's classification also
531
+ re-queries for seeded content, not just re-lists paths -- see
532
+ VectorIntegritySignal.ORPHANED_VECTOR_ENTRY.
533
+ """
534
+ timer = self._timed()
535
+ child_paths: list[str] = []
536
+ if recursive:
537
+ try:
538
+ child_paths = self.list_resource_paths(prefix)
539
+ except BackendAPIError:
540
+ child_paths = []
541
+ deleted: list[str] = []
542
+ failed: list[str] = []
543
+ for path in child_paths:
544
+ try:
545
+ result = self.delete(path)
546
+ except BackendAPIError:
547
+ failed.append(path)
548
+ continue
549
+ (deleted if result.success else failed).append(path)
550
+ root_uri = f"viking://{prefix.strip('/')}"
551
+ try:
552
+ root_result = self.delete(root_uri)
553
+ (deleted if root_result.success else failed).append(root_uri)
554
+ except BackendAPIError:
555
+ failed.append(root_uri)
556
+ return DeletePrefixResult(
557
+ prefix=prefix,
558
+ deleted_paths=deleted,
559
+ failed_paths=failed,
560
+ latency_ms=timer.elapsed_ms(),
561
+ )
562
+
563
+ def close(self) -> None:
564
+ self._http.close()
565
+
566
+
567
+ def _slugify(content: str) -> str:
568
+ import hashlib
569
+
570
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]