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.
Files changed (52) hide show
  1. agentcache/__init__.py +29 -0
  2. agentcache/app.py +312 -0
  3. agentcache/cli.py +346 -0
  4. agentcache/connect.py +724 -0
  5. agentcache/core/__init__.py +19 -0
  6. agentcache/core/audit_log.py +104 -0
  7. agentcache/core/config.py +51 -0
  8. agentcache/core/context_builder.py +209 -0
  9. agentcache/core/graph.py +120 -0
  10. agentcache/core/image_store.py +111 -0
  11. agentcache/core/infer.py +142 -0
  12. agentcache/core/kv_scopes.py +86 -0
  13. agentcache/core/lessons.py +242 -0
  14. agentcache/core/llm.py +596 -0
  15. agentcache/core/memory_store.py +207 -0
  16. agentcache/core/observation_store.py +576 -0
  17. agentcache/core/privacy.py +34 -0
  18. agentcache/core/project_profile.py +625 -0
  19. agentcache/core/search_service.py +444 -0
  20. agentcache/core/session_store.py +382 -0
  21. agentcache/core/slots.py +504 -0
  22. agentcache/db.py +359 -0
  23. agentcache/import_data.py +86 -0
  24. agentcache/legacy.py +94 -0
  25. agentcache/mcp_stdio.py +141 -0
  26. agentcache/py.typed +0 -0
  27. agentcache/replay_import.py +680 -0
  28. agentcache/routes/__init__.py +29 -0
  29. agentcache/routes/_deps.py +46 -0
  30. agentcache/routes/auth.py +68 -0
  31. agentcache/routes/graph.py +81 -0
  32. agentcache/routes/health.py +149 -0
  33. agentcache/routes/mcp.py +614 -0
  34. agentcache/routes/memories.py +116 -0
  35. agentcache/routes/migration.py +32 -0
  36. agentcache/routes/observations.py +253 -0
  37. agentcache/routes/search.py +80 -0
  38. agentcache/search.py +935 -0
  39. agentcache/storage/__init__.py +29 -0
  40. agentcache/storage/images.py +102 -0
  41. agentcache/storage/paths.py +116 -0
  42. agentcache/storage/scopes.py +9 -0
  43. agentcache/viewer/favicon.svg +1 -0
  44. agentcache/viewer/index.html +4235 -0
  45. agentcache/viewer_helpers.py +61 -0
  46. agentcache/workers.py +192 -0
  47. agentcache_core-0.9.9.dist-info/METADATA +194 -0
  48. agentcache_core-0.9.9.dist-info/RECORD +52 -0
  49. agentcache_core-0.9.9.dist-info/WHEEL +5 -0
  50. agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
  51. agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
  52. 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