agentcache-core 0.9.9__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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- agentcache_core-0.9.9.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SearchService — owns BM25 + vector index management and querying.
|
|
3
|
+
|
|
4
|
+
Single seam for all search index operations. Callers (ObservationStore,
|
|
5
|
+
MemoryStore, routes) call this service instead of touching index globals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from ..search import HybridSearch, SearchIndex, VectorIndex
|
|
17
|
+
from ..storage.paths import generate_id
|
|
18
|
+
from .kv_scopes import KV
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IndexPersistence:
|
|
22
|
+
"""Persist BM25 and vector indexes to the KV store with a debounce queue."""
|
|
23
|
+
|
|
24
|
+
DEBOUNCE_SECONDS: float = 5.0
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, kv: Any, bm25: SearchIndex, vector: Optional[VectorIndex] = None
|
|
28
|
+
):
|
|
29
|
+
self.kv = kv
|
|
30
|
+
self.bm25 = bm25
|
|
31
|
+
self.vector = vector
|
|
32
|
+
self._timer: Optional[threading.Timer] = None
|
|
33
|
+
self._timer_lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
def schedule_save(self) -> None:
|
|
36
|
+
"""Schedule a debounced save — resets the 5-second timer on each call."""
|
|
37
|
+
with self._timer_lock:
|
|
38
|
+
if self._timer is not None:
|
|
39
|
+
self._timer.cancel()
|
|
40
|
+
self._timer = threading.Timer(self.DEBOUNCE_SECONDS, self._fire_save)
|
|
41
|
+
self._timer.daemon = True
|
|
42
|
+
self._timer.start()
|
|
43
|
+
|
|
44
|
+
def _fire_save(self) -> None:
|
|
45
|
+
"""Called by the timer after DEBOUNCE_SECONDS of inactivity."""
|
|
46
|
+
with self._timer_lock:
|
|
47
|
+
self._timer = None
|
|
48
|
+
self.save()
|
|
49
|
+
|
|
50
|
+
def flush(self) -> None:
|
|
51
|
+
"""Cancel any pending debounce timer and save immediately (used on shutdown)."""
|
|
52
|
+
with self._timer_lock:
|
|
53
|
+
if self._timer is not None:
|
|
54
|
+
self._timer.cancel()
|
|
55
|
+
self._timer = None
|
|
56
|
+
self.save()
|
|
57
|
+
|
|
58
|
+
def save(self) -> None:
|
|
59
|
+
try:
|
|
60
|
+
bm25_dirty = getattr(self.bm25, "_dirty", True)
|
|
61
|
+
vector_dirty = self.vector and getattr(self.vector, "_dirty", True)
|
|
62
|
+
|
|
63
|
+
if bm25_dirty:
|
|
64
|
+
self.save_sharded_index(
|
|
65
|
+
json.dumps(self.bm25.serialize_data()),
|
|
66
|
+
"data:manifest",
|
|
67
|
+
"data",
|
|
68
|
+
"mem:index:bm25:bm25:",
|
|
69
|
+
)
|
|
70
|
+
self.bm25._dirty = False
|
|
71
|
+
|
|
72
|
+
if self.vector and vector_dirty:
|
|
73
|
+
self.save_sharded_index(
|
|
74
|
+
json.dumps(self.vector.serialize_data()),
|
|
75
|
+
"vectors:manifest",
|
|
76
|
+
"vectors",
|
|
77
|
+
"mem:index:bm25:vectors:",
|
|
78
|
+
)
|
|
79
|
+
self.vector._dirty = False
|
|
80
|
+
|
|
81
|
+
if not bm25_dirty and not vector_dirty:
|
|
82
|
+
print("[index persistence] indexes not dirty — skipping save")
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"[index persistence] failed to save index: {e}")
|
|
85
|
+
|
|
86
|
+
def save_sharded_index(
|
|
87
|
+
self, serialized: str, manifest_key: str, legacy_key: str, scope_prefix: str
|
|
88
|
+
) -> None:
|
|
89
|
+
previous = self.kv.get(KV.bm25Index, manifest_key)
|
|
90
|
+
generation = generate_id("idx")
|
|
91
|
+
chunk_chars = 2000000
|
|
92
|
+
shards = []
|
|
93
|
+
chunks = []
|
|
94
|
+
|
|
95
|
+
offset = 0
|
|
96
|
+
shard_idx = 0
|
|
97
|
+
while offset < len(serialized):
|
|
98
|
+
scope = f"{scope_prefix}{generation}:{str(shard_idx).zfill(5)}"
|
|
99
|
+
chunk = serialized[offset : offset + chunk_chars]
|
|
100
|
+
shards.append({"scope": scope, "key": "data", "chars": len(chunk)})
|
|
101
|
+
chunks.append(chunk)
|
|
102
|
+
offset += chunk_chars
|
|
103
|
+
shard_idx += 1
|
|
104
|
+
|
|
105
|
+
for shard, chunk in zip(shards, chunks):
|
|
106
|
+
self.kv.set(shard["scope"], shard["key"], chunk)
|
|
107
|
+
|
|
108
|
+
next_manifest = {
|
|
109
|
+
"v": 1,
|
|
110
|
+
"generation": generation,
|
|
111
|
+
"shards": shards,
|
|
112
|
+
"chars": len(serialized),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
self.kv.set(KV.bm25Index, manifest_key, next_manifest)
|
|
116
|
+
self.kv.delete(KV.bm25Index, legacy_key)
|
|
117
|
+
|
|
118
|
+
# Cleanup obsolete shards
|
|
119
|
+
if hasattr(self.kv, "_lock") and hasattr(self.kv, "_get_conn"):
|
|
120
|
+
with self.kv._lock:
|
|
121
|
+
max_retries = 5
|
|
122
|
+
delay = 0.05
|
|
123
|
+
for attempt in range(max_retries):
|
|
124
|
+
try:
|
|
125
|
+
conn = self.kv._get_conn()
|
|
126
|
+
cursor = conn.cursor()
|
|
127
|
+
try:
|
|
128
|
+
cursor.execute(
|
|
129
|
+
"SELECT DISTINCT scope FROM kv_store WHERE scope LIKE ?",
|
|
130
|
+
(scope_prefix + "%",),
|
|
131
|
+
)
|
|
132
|
+
rows = cursor.fetchall()
|
|
133
|
+
current_scopes = {s["scope"] for s in shards}
|
|
134
|
+
to_delete = [
|
|
135
|
+
row["scope"]
|
|
136
|
+
for row in rows
|
|
137
|
+
if row["scope"] not in current_scopes
|
|
138
|
+
]
|
|
139
|
+
if to_delete:
|
|
140
|
+
for i in range(0, len(to_delete), 50):
|
|
141
|
+
chunk_delete = to_delete[i : i + 50]
|
|
142
|
+
format_strings = ",".join(["?"] * len(chunk_delete))
|
|
143
|
+
# format_strings contains only '?' placeholders derived from len();
|
|
144
|
+
# all values are bound via the parameter tuple below.
|
|
145
|
+
cursor.execute(
|
|
146
|
+
f"DELETE FROM kv_store WHERE scope IN ({format_strings})", # nosec B608
|
|
147
|
+
tuple(chunk_delete),
|
|
148
|
+
)
|
|
149
|
+
conn.commit()
|
|
150
|
+
break
|
|
151
|
+
finally:
|
|
152
|
+
cursor.close()
|
|
153
|
+
except sqlite3.OperationalError as ex:
|
|
154
|
+
err_msg = str(ex).lower()
|
|
155
|
+
if (
|
|
156
|
+
"locked" in err_msg or "busy" in err_msg
|
|
157
|
+
) and attempt < max_retries - 1:
|
|
158
|
+
time.sleep(delay)
|
|
159
|
+
delay *= 2
|
|
160
|
+
continue
|
|
161
|
+
print(
|
|
162
|
+
f"[index persistence] error cleaning up obsolete shards: {ex}"
|
|
163
|
+
)
|
|
164
|
+
break
|
|
165
|
+
except Exception as ex:
|
|
166
|
+
print(
|
|
167
|
+
f"[index persistence] error cleaning up obsolete shards: {ex}"
|
|
168
|
+
)
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
previous
|
|
173
|
+
and isinstance(previous, dict)
|
|
174
|
+
and previous.get("v") == 1
|
|
175
|
+
and isinstance(previous.get("shards"), list)
|
|
176
|
+
):
|
|
177
|
+
current_shards = {(s["scope"], s["key"]) for s in shards}
|
|
178
|
+
for old_shard in previous["shards"]:
|
|
179
|
+
if (old_shard["scope"], old_shard["key"]) not in current_shards:
|
|
180
|
+
self.kv.delete(old_shard["scope"], old_shard["key"])
|
|
181
|
+
|
|
182
|
+
def load(self) -> Dict[str, Any]:
|
|
183
|
+
bm25_data = self.load_sharded_data("data", "data:manifest")
|
|
184
|
+
bm25_loaded = False
|
|
185
|
+
if bm25_data:
|
|
186
|
+
try:
|
|
187
|
+
self.bm25.restore_from_data(json.loads(bm25_data))
|
|
188
|
+
bm25_loaded = True
|
|
189
|
+
except Exception as e:
|
|
190
|
+
print(f"[index persistence] failed to restore BM25: {e}")
|
|
191
|
+
|
|
192
|
+
vector_loaded = False
|
|
193
|
+
if self.vector:
|
|
194
|
+
vector_data = self.load_sharded_data("vectors", "vectors:manifest")
|
|
195
|
+
if vector_data:
|
|
196
|
+
try:
|
|
197
|
+
self.vector.restore_from_data(json.loads(vector_data))
|
|
198
|
+
vector_loaded = True
|
|
199
|
+
except Exception as e:
|
|
200
|
+
print(f"[index persistence] failed to restore vectors: {e}")
|
|
201
|
+
|
|
202
|
+
return {"bm25": bm25_loaded, "vector": vector_loaded}
|
|
203
|
+
|
|
204
|
+
def load_sharded_data(self, legacy_key: str, manifest_key: str) -> Optional[str]:
|
|
205
|
+
manifest = self.kv.get(KV.bm25Index, manifest_key)
|
|
206
|
+
if manifest and isinstance(manifest, dict) and manifest.get("v") == 1:
|
|
207
|
+
shards = manifest.get("shards", [])
|
|
208
|
+
chunks = []
|
|
209
|
+
for shard in shards:
|
|
210
|
+
chunk = self.kv.get(shard["scope"], shard["key"])
|
|
211
|
+
if chunk is None:
|
|
212
|
+
print(f"[index persistence] missing shard {shard['scope']}")
|
|
213
|
+
return None
|
|
214
|
+
chunks.append(chunk)
|
|
215
|
+
return "".join(chunks)
|
|
216
|
+
|
|
217
|
+
legacy = self.kv.get(KV.bm25Index, legacy_key)
|
|
218
|
+
if isinstance(legacy, str):
|
|
219
|
+
return legacy
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class SearchService:
|
|
224
|
+
"""Owns the BM25 index, vector index, and hybrid search.
|
|
225
|
+
|
|
226
|
+
Dependencies are injected via the constructor so tests can pass
|
|
227
|
+
lightweight fakes without any Flask or SQLite machinery.
|
|
228
|
+
|
|
229
|
+
Public interface
|
|
230
|
+
----------------
|
|
231
|
+
index(obs) — add/replace an observation in both indexes
|
|
232
|
+
remove(obs_id) — remove from both indexes
|
|
233
|
+
search(query, limit, folder_path, ...) — hydrated hybrid search results
|
|
234
|
+
schedule_persist() — debounce-schedule an index save
|
|
235
|
+
flush_persist() — flush immediately (used on shutdown)
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
bm25_index: Optional[SearchIndex] = None,
|
|
241
|
+
vector_index: Optional[VectorIndex] = None,
|
|
242
|
+
embedding_provider: Any = None,
|
|
243
|
+
persistence: Any = None,
|
|
244
|
+
kv: Any = None,
|
|
245
|
+
# Backward-compatibility alias parameters
|
|
246
|
+
bm25: Optional[SearchIndex] = None,
|
|
247
|
+
vector: Optional[VectorIndex] = None,
|
|
248
|
+
) -> None:
|
|
249
|
+
actual_bm25 = bm25_index if bm25_index is not None else bm25
|
|
250
|
+
if actual_bm25 is None:
|
|
251
|
+
actual_bm25 = SearchIndex()
|
|
252
|
+
actual_vector = vector_index if vector_index is not None else vector
|
|
253
|
+
|
|
254
|
+
actual_kv = kv
|
|
255
|
+
actual_persistence = None
|
|
256
|
+
if isinstance(persistence, IndexPersistence):
|
|
257
|
+
actual_persistence = persistence
|
|
258
|
+
elif persistence is not None:
|
|
259
|
+
# 4th positional parameter passed was kv
|
|
260
|
+
actual_kv = persistence
|
|
261
|
+
|
|
262
|
+
self.bm25 = actual_bm25
|
|
263
|
+
self.vector = actual_vector
|
|
264
|
+
self.embedding_provider = embedding_provider
|
|
265
|
+
self._kv = actual_kv
|
|
266
|
+
|
|
267
|
+
self.hybrid = HybridSearch(
|
|
268
|
+
self.bm25,
|
|
269
|
+
self.vector if self.vector else None,
|
|
270
|
+
self.embedding_provider,
|
|
271
|
+
self._kv,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if actual_persistence is not None:
|
|
275
|
+
self._persistence: Optional[IndexPersistence] = actual_persistence
|
|
276
|
+
elif self._kv is not None:
|
|
277
|
+
self._persistence = IndexPersistence(self._kv, self.bm25, self.vector)
|
|
278
|
+
else:
|
|
279
|
+
self._persistence = None
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# Index mutations
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def index(self, obs: Dict[str, Any]) -> None:
|
|
286
|
+
"""Add an observation to BM25 and (if available) vector indexes."""
|
|
287
|
+
self.bm25.add(obs)
|
|
288
|
+
if self.vector and self.embedding_provider:
|
|
289
|
+
obs_id = obs.get("id", "")
|
|
290
|
+
session_id = obs.get("sessionId") or obs.get("folderPath") or "unknown"
|
|
291
|
+
title = obs.get("title", "")
|
|
292
|
+
text = obs.get("text") or obs.get("narrative") or ""
|
|
293
|
+
combined = (title + " " + text).strip()[:16000]
|
|
294
|
+
try:
|
|
295
|
+
embedding = self.embedding_provider.embed(combined)
|
|
296
|
+
if len(embedding) == self.embedding_provider.dimensions:
|
|
297
|
+
self.vector.add(obs_id, session_id, embedding)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print(f"[search_service] vector embed failed for {obs_id}: {e}")
|
|
300
|
+
|
|
301
|
+
def remove(self, obs_id: str) -> None:
|
|
302
|
+
"""Remove an observation from both BM25 and vector indexes."""
|
|
303
|
+
self.bm25.remove(obs_id)
|
|
304
|
+
if self.vector:
|
|
305
|
+
self.vector.remove(obs_id)
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
# Querying
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def search(
|
|
312
|
+
self,
|
|
313
|
+
query: str,
|
|
314
|
+
limit: int = 20,
|
|
315
|
+
folder_path: Optional[str] = None,
|
|
316
|
+
agent_id: Optional[str] = None,
|
|
317
|
+
kv: Any = None,
|
|
318
|
+
) -> List[Dict[str, Any]]:
|
|
319
|
+
"""Hybrid BM25 + vector search. Returns hydrated result dicts.
|
|
320
|
+
|
|
321
|
+
Results include all fields from the stored observation plus a
|
|
322
|
+
``score`` key. Filtered by ``folder_path`` and ``agent_id``
|
|
323
|
+
when provided.
|
|
324
|
+
"""
|
|
325
|
+
if not query or not query.strip():
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
active_kv = kv if kv is not None else self._kv
|
|
329
|
+
|
|
330
|
+
candidates = self.hybrid.search(query, limit * 2)
|
|
331
|
+
|
|
332
|
+
results: List[Dict[str, Any]] = []
|
|
333
|
+
seen_ids: set = set()
|
|
334
|
+
|
|
335
|
+
for candidate in candidates:
|
|
336
|
+
obs_id = candidate.get("obsId") or candidate.get("id", "")
|
|
337
|
+
score = candidate.get("combinedScore") or candidate.get("score", 0.0)
|
|
338
|
+
|
|
339
|
+
if not obs_id or obs_id in seen_ids:
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
if active_kv is not None:
|
|
343
|
+
# 1. Try O(1) lookup index first
|
|
344
|
+
lookup = active_kv.get(KV.obs_lookup, obs_id)
|
|
345
|
+
if lookup and isinstance(lookup, dict):
|
|
346
|
+
fp = lookup.get("folderPath")
|
|
347
|
+
aid = lookup.get("agentId")
|
|
348
|
+
if fp and aid:
|
|
349
|
+
if folder_path is not None and fp != folder_path:
|
|
350
|
+
continue
|
|
351
|
+
if agent_id is not None and aid != agent_id:
|
|
352
|
+
continue
|
|
353
|
+
obs = active_kv.get(KV.folder_obs(fp, aid), obs_id)
|
|
354
|
+
if obs and isinstance(obs, dict):
|
|
355
|
+
result = dict(obs)
|
|
356
|
+
result["score"] = score
|
|
357
|
+
result.setdefault("folderPath", fp)
|
|
358
|
+
result.setdefault("agentId", aid)
|
|
359
|
+
results.append(result)
|
|
360
|
+
seen_ids.add(obs_id)
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
# 2. Fallback scan for unindexed folder observations
|
|
364
|
+
if obs_id.startswith("fobs_"):
|
|
365
|
+
found = False
|
|
366
|
+
for entry in active_kv.list(KV.folders):
|
|
367
|
+
fp = entry.get("folderPath", "")
|
|
368
|
+
aid = entry.get("agentId", "")
|
|
369
|
+
if not fp or not aid:
|
|
370
|
+
continue
|
|
371
|
+
if folder_path is not None and fp != folder_path:
|
|
372
|
+
continue
|
|
373
|
+
if agent_id is not None and aid != agent_id:
|
|
374
|
+
continue
|
|
375
|
+
obs = active_kv.get(KV.folder_obs(fp, aid), obs_id)
|
|
376
|
+
if obs and isinstance(obs, dict):
|
|
377
|
+
result = dict(obs)
|
|
378
|
+
result["score"] = score
|
|
379
|
+
result.setdefault("folderPath", fp)
|
|
380
|
+
result.setdefault("agentId", aid)
|
|
381
|
+
results.append(result)
|
|
382
|
+
seen_ids.add(obs_id)
|
|
383
|
+
# Lazy backfill lookup
|
|
384
|
+
active_kv.set(
|
|
385
|
+
KV.obs_lookup,
|
|
386
|
+
obs_id,
|
|
387
|
+
{"folderPath": fp, "agentId": aid},
|
|
388
|
+
)
|
|
389
|
+
found = True
|
|
390
|
+
break
|
|
391
|
+
if found:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
# 3. Try global memories
|
|
395
|
+
mem = active_kv.get(KV.memories, obs_id)
|
|
396
|
+
if mem and isinstance(mem, dict):
|
|
397
|
+
if mem.get("isLatest") is not False:
|
|
398
|
+
result = dict(mem)
|
|
399
|
+
result["score"] = score
|
|
400
|
+
result.setdefault("folderPath", "")
|
|
401
|
+
result.setdefault("agentId", mem.get("agentId") or "")
|
|
402
|
+
results.append(result)
|
|
403
|
+
seen_ids.add(obs_id)
|
|
404
|
+
else:
|
|
405
|
+
# If no KV available for hydration, return raw candidate dict with score
|
|
406
|
+
result = dict(candidate)
|
|
407
|
+
result["score"] = score
|
|
408
|
+
results.append(result)
|
|
409
|
+
seen_ids.add(obs_id)
|
|
410
|
+
|
|
411
|
+
results.sort(key=lambda r: r.get("score", 0.0), reverse=True)
|
|
412
|
+
return results[:limit]
|
|
413
|
+
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
# Persistence
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def schedule_persist(self) -> None:
|
|
419
|
+
"""Debounce-schedule an index persistence save."""
|
|
420
|
+
if self._persistence:
|
|
421
|
+
self._persistence.schedule_save()
|
|
422
|
+
|
|
423
|
+
def flush_persist(self) -> None:
|
|
424
|
+
"""Flush the debounce timer and save immediately (used on shutdown)."""
|
|
425
|
+
if self._persistence:
|
|
426
|
+
self._persistence.flush()
|
|
427
|
+
|
|
428
|
+
def load_persisted(self) -> Dict[str, Any]:
|
|
429
|
+
"""Load previously saved indexes from the KV store. Called on startup."""
|
|
430
|
+
if self._persistence:
|
|
431
|
+
return self._persistence.load()
|
|
432
|
+
return {"bm25": False, "vector": False}
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------------
|
|
435
|
+
# Index size (used by health check and index-sync verification)
|
|
436
|
+
# ------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
@property
|
|
439
|
+
def bm25_size(self) -> int:
|
|
440
|
+
return self.bm25.size
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def vector_size(self) -> int:
|
|
444
|
+
return self.vector.size if self.vector else 0
|