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,625 @@
1
+ """Project profile, data export, migration, relations, auto-forget, health check."""
2
+
3
+ import datetime
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from typing import Any, Dict, List, Optional, Set
8
+
9
+ from ..db import StateKV
10
+ from ..storage.paths import generate_id
11
+ from .audit_log import safe_audit
12
+ from .config import commit_if_enabled, get_agent_id, is_agent_scope_isolated
13
+ from .kv_scopes import KV
14
+
15
+
16
+ def get_project_profile(kv: StateKV, project: str) -> Dict[str, Any]:
17
+ prof = kv.get(KV.profiles, project)
18
+ if not prof:
19
+ prof = {
20
+ "project": project,
21
+ "topConcepts": [],
22
+ "topFiles": [],
23
+ "conventions": [],
24
+ "commonErrors": [],
25
+ "updatedAt": datetime.datetime.now(datetime.timezone.utc)
26
+ .isoformat()
27
+ .replace("+00:00", "Z"),
28
+ }
29
+ if not prof.get("topConcepts") and not prof.get("topFiles"):
30
+ prof = build_project_profile(kv, project)
31
+ return prof
32
+
33
+
34
+ def build_project_profile(kv: StateKV, project: str) -> Dict[str, Any]:
35
+ import os.path as _osp
36
+ import re as _re
37
+ from collections import Counter
38
+
39
+ prof = kv.get(KV.profiles, project)
40
+ if not prof:
41
+ prof = {
42
+ "project": project,
43
+ "topConcepts": [],
44
+ "topFiles": [],
45
+ "conventions": [],
46
+ "commonErrors": [],
47
+ "updatedAt": datetime.datetime.now(datetime.timezone.utc)
48
+ .isoformat()
49
+ .replace("+00:00", "Z"),
50
+ }
51
+
52
+ if not prof.get("topConcepts") and not prof.get("topFiles"):
53
+ sessions = kv.list(KV.sessions)
54
+ project_sessions = [s for s in sessions if s.get("project") == project]
55
+ concept_counts = Counter()
56
+ file_counts = Counter()
57
+
58
+ def _harvest_file(path, fc, cc):
59
+ if not isinstance(path, str) or not path:
60
+ return
61
+ fc[path] += 1
62
+ parts = _re.split(r"[\\/]", path)
63
+ fname = parts[-1] if parts else ""
64
+ skip = {"tmp", "temp", "claude", "appdata", "local", "users", "windows"}
65
+ for part in parts[:-1]:
66
+ p = part.lower().strip()
67
+ if (
68
+ p
69
+ and len(p) > 2
70
+ and p not in skip
71
+ and not _re.match(r"^[a-z]:|^\.|^--", p)
72
+ ):
73
+ cc[p] += 1
74
+ stem = _osp.splitext(fname)[0]
75
+ if stem and len(stem) > 2:
76
+ cc[stem.lower()] += 1
77
+ ext = _osp.splitext(fname)[1].lstrip(".")
78
+ if ext in ("py", "ts", "js", "jsx", "tsx", "go", "rs", "java", "cs", "cpp"):
79
+ cc[ext] += 1
80
+
81
+ for s in project_sessions:
82
+ sid = s.get("id", "")
83
+ if not sid:
84
+ continue
85
+ for o in kv.list(KV.observations(sid)):
86
+ for c in o.get("concepts") or []:
87
+ if isinstance(c, str) and c:
88
+ concept_counts[c] += 1
89
+ for f in o.get("files") or []:
90
+ _harvest_file(f, file_counts, concept_counts)
91
+ tn = o.get("toolName")
92
+ if tn:
93
+ concept_counts[tn] += 1
94
+ ti = o.get("toolInput")
95
+ if isinstance(ti, str):
96
+ try:
97
+ ti = json.loads(ti)
98
+ except Exception:
99
+ ti = {}
100
+ if isinstance(ti, dict):
101
+ for fk in ("path", "file_path", "file", "filename"):
102
+ _harvest_file(ti.get(fk, ""), file_counts, concept_counts)
103
+ narr = o.get("narrative") or o.get("raw") or ""
104
+ if isinstance(narr, str) and narr.startswith("{"):
105
+ try:
106
+ nd = json.loads(narr)
107
+ if isinstance(nd, dict):
108
+ tn2 = nd.get("toolName") or nd.get("tool_name")
109
+ if tn2:
110
+ concept_counts[tn2] += 1
111
+ for fk in ("path", "file_path", "file", "filename"):
112
+ _harvest_file(
113
+ nd.get(fk, ""), file_counts, concept_counts
114
+ )
115
+ except Exception:
116
+ pass
117
+
118
+ for m in kv.list(KV.memories):
119
+ if m.get("project") == project:
120
+ for c in m.get("concepts") or []:
121
+ if c:
122
+ concept_counts[c] += 1
123
+ for f in m.get("files") or []:
124
+ _harvest_file(f, file_counts, concept_counts)
125
+
126
+ prof["topConcepts"] = [
127
+ {"concept": c, "frequency": n} for c, n in concept_counts.most_common(20)
128
+ ]
129
+ prof["topFiles"] = [
130
+ {"file": f, "frequency": n} for f, n in file_counts.most_common(20)
131
+ ]
132
+ prof["sessionCount"] = len(project_sessions)
133
+
134
+ return prof
135
+
136
+
137
+ def set_project_profile(
138
+ kv: StateKV, project: str, profile: Dict[str, Any]
139
+ ) -> Dict[str, Any]:
140
+ profile["updatedAt"] = (
141
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
142
+ )
143
+ kv.set(KV.profiles, project, profile)
144
+
145
+ commit_if_enabled(kv, f"Set project profile for {project}", get_agent_id())
146
+
147
+ return profile
148
+
149
+
150
+ def export_data(kv: StateKV, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
151
+ if data is None:
152
+ data = {}
153
+
154
+ exported_at = (
155
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
156
+ )
157
+
158
+ isolated = is_agent_scope_isolated()
159
+ isolated_agent_id = get_agent_id()
160
+
161
+ folder_pairs = kv.list(KV.folders)
162
+ folders_export = []
163
+ for entry in folder_pairs:
164
+ fp = entry.get("folderPath")
165
+ aid = entry.get("agentId")
166
+ if not fp or not aid:
167
+ continue
168
+ if isolated and isolated_agent_id and aid != isolated_agent_id:
169
+ continue
170
+
171
+ meta = kv.get(KV.folder_meta(fp, aid), "meta") or {
172
+ "folderPath": fp,
173
+ "agentId": aid,
174
+ "lastUpdated": entry.get("lastUpdated", ""),
175
+ "obsCount": entry.get("obsCount", 0),
176
+ }
177
+ observations = kv.list(KV.folder_obs(fp, aid))
178
+ folders_export.append(
179
+ {
180
+ "folderPath": fp,
181
+ "agentId": aid,
182
+ "meta": meta,
183
+ "observations": observations,
184
+ }
185
+ )
186
+
187
+ memories = kv.list(KV.memories)
188
+ if isolated and isolated_agent_id:
189
+ memories = [m for m in memories if m.get("agentId") == isolated_agent_id]
190
+
191
+ return {
192
+ "folders": folders_export,
193
+ "memories": memories,
194
+ "exportedAt": exported_at,
195
+ "version": "2.0",
196
+ }
197
+
198
+
199
+ def migrate_sessions_to_folders(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
200
+ """Migrate legacy session-based observations to folder-based storage.
201
+ Non-destructive: old mem:sessions / mem:obs:* scopes are never deleted.
202
+ """
203
+ from .observation_store import normalize_folder_path
204
+
205
+ _MAX_PATH_LEN = 512
206
+
207
+ sessions = kv.list(KV.sessions)
208
+ migrated_sessions = 0
209
+ migrated_observations = 0
210
+ errors = []
211
+
212
+ for session in sessions:
213
+ session_id = session.get("id")
214
+ if not session_id:
215
+ continue
216
+ try:
217
+ fp_raw = session.get("cwd") or session.get("project") or "unknown"
218
+ aid = (session.get("agentId") or "unknown").strip()[:_MAX_PATH_LEN]
219
+ try:
220
+ fp = normalize_folder_path(fp_raw)
221
+ except ValueError:
222
+ fp = "unknown"
223
+
224
+ obs_list = kv.list(KV.observations(session_id))
225
+ session_obs_count = 0
226
+ for obs in obs_list:
227
+ obs_id = obs.get("id", "")
228
+ if obs_id.endswith(":raw"):
229
+ continue
230
+ folder_obs = {
231
+ "id": obs_id,
232
+ "folderPath": fp,
233
+ "agentId": aid,
234
+ "timestamp": obs.get("timestamp", ""),
235
+ "text": obs.get("narrative")
236
+ or obs.get("raw")
237
+ or obs.get("title")
238
+ or "",
239
+ "type": obs.get("type", "other"),
240
+ "title": obs.get("title", ""),
241
+ "concepts": obs.get("concepts") or [],
242
+ "files": obs.get("files") or [],
243
+ "importance": obs.get("importance", 5),
244
+ }
245
+ if isinstance(folder_obs["text"], dict):
246
+ folder_obs["text"] = json.dumps(folder_obs["text"])[:4000]
247
+ folder_obs["text"] = str(folder_obs["text"])[:4000]
248
+
249
+ if not dry_run:
250
+ kv.set(KV.folder_obs(fp, aid), obs_id, folder_obs)
251
+ kv.set(
252
+ KV.obs_lookup,
253
+ obs_id,
254
+ {
255
+ "folderPath": fp,
256
+ "agentId": aid,
257
+ },
258
+ )
259
+ session_obs_count += 1
260
+ migrated_observations += 1
261
+
262
+ if not dry_run and session_obs_count > 0:
263
+ meta_scope = KV.folder_meta(fp, aid)
264
+ meta = kv.get(meta_scope, "meta") or {
265
+ "folderPath": fp,
266
+ "agentId": aid,
267
+ "obsCount": 0,
268
+ "lastUpdated": session.get("updatedAt", ""),
269
+ "summary": None,
270
+ }
271
+ meta["obsCount"] = meta.get("obsCount", 0) + session_obs_count
272
+ meta["lastUpdated"] = (
273
+ session.get("updatedAt", "") or meta["lastUpdated"]
274
+ )
275
+ kv.set(meta_scope, "meta", meta)
276
+
277
+ index_key = f"{fp}:{aid}"
278
+ kv.set(
279
+ KV.folders,
280
+ index_key,
281
+ {
282
+ "folderPath": fp,
283
+ "agentId": aid,
284
+ "lastUpdated": meta["lastUpdated"],
285
+ "obsCount": meta["obsCount"],
286
+ },
287
+ )
288
+
289
+ migrated_sessions += 1
290
+ except Exception as e:
291
+ errors.append({"sessionId": session_id, "error": str(e)})
292
+
293
+ return {
294
+ "migrated_sessions": migrated_sessions,
295
+ "migrated_observations": migrated_observations,
296
+ "errors": errors,
297
+ "dry_run": dry_run,
298
+ }
299
+
300
+
301
+ def get_relations(kv: StateKV) -> List[Dict[str, Any]]:
302
+ return kv.list(KV.relations)
303
+
304
+
305
+ def add_relation(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
306
+ rel = {
307
+ "id": generate_id("rel"),
308
+ "sourceId": data["sourceId"],
309
+ "targetId": data["targetId"],
310
+ "type": data["type"],
311
+ "createdAt": datetime.datetime.now(datetime.timezone.utc)
312
+ .isoformat()
313
+ .replace("+00:00", "Z"),
314
+ }
315
+ kv.set(KV.relations, rel["id"], rel)
316
+
317
+ agent_id = data.get("agentId") or get_agent_id()
318
+ commit_if_enabled(
319
+ kv,
320
+ f"Add relation {rel['type']} between {rel['sourceId']} and {rel['targetId']}",
321
+ agent_id,
322
+ )
323
+
324
+ return rel
325
+
326
+
327
+ def auto_forget(kv: StateKV, dry_run: bool = False) -> Dict[str, Any]:
328
+ from .. import legacy as _legacy
329
+
330
+ now_dt = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
331
+ evicted_memories = []
332
+ evicted_observations = []
333
+ evicted_folder_observations = []
334
+
335
+ memories = kv.list(KV.memories)
336
+ for mem in memories:
337
+ forget_after = mem.get("forgetAfter")
338
+ if forget_after:
339
+ try:
340
+ import dateutil.parser
341
+
342
+ fa_dt = dateutil.parser.parse(forget_after)
343
+ if fa_dt.tzinfo:
344
+ fa_dt = fa_dt.replace(tzinfo=None)
345
+ if fa_dt < now_dt:
346
+ evicted_memories.append(mem["id"])
347
+ except Exception as e:
348
+ print(
349
+ f"[auto_forget] Failed to parse forgetAfter '{forget_after}': {e}"
350
+ )
351
+
352
+ sessions = kv.list(KV.sessions)
353
+ for sess in sessions:
354
+ sid = sess.get("id")
355
+ if not sid:
356
+ continue
357
+ obs_list = kv.list(KV.observations(sid))
358
+ for obs in obs_list:
359
+ importance = obs.get("importance")
360
+ ts = obs.get("timestamp")
361
+ if importance is not None and ts:
362
+ try:
363
+ import dateutil.parser
364
+
365
+ ts_dt = dateutil.parser.parse(ts)
366
+ if ts_dt.tzinfo:
367
+ ts_dt = ts_dt.replace(tzinfo=None)
368
+ age_days = (now_dt - ts_dt).days
369
+ if importance <= 2 and age_days > 180:
370
+ evicted_observations.append((sid, obs["id"]))
371
+ except Exception as e:
372
+ print(f"[auto_forget] Failed to parse timestamp '{ts}': {e}")
373
+
374
+ folder_pairs = kv.list(KV.folders)
375
+ for entry in folder_pairs:
376
+ fp = entry.get("folderPath")
377
+ aid = entry.get("agentId")
378
+ if not fp or not aid:
379
+ continue
380
+ obs_list = kv.list(KV.folder_obs(fp, aid))
381
+ for obs in obs_list:
382
+ obs_id = obs.get("id")
383
+ if not obs_id:
384
+ continue
385
+
386
+ forget_after = obs.get("forgetAfter")
387
+ is_expired = False
388
+ if forget_after:
389
+ try:
390
+ import dateutil.parser
391
+
392
+ fa_dt = dateutil.parser.parse(forget_after)
393
+ if fa_dt.tzinfo:
394
+ fa_dt = fa_dt.replace(tzinfo=None)
395
+ if fa_dt < now_dt:
396
+ is_expired = True
397
+ except Exception as e:
398
+ print(
399
+ f"[auto_forget] Failed to parse folder obs forgetAfter '{forget_after}': {e}"
400
+ )
401
+
402
+ is_stale_low_value = False
403
+ importance = obs.get("importance")
404
+ ts = obs.get("timestamp")
405
+ if importance is not None and ts:
406
+ try:
407
+ import dateutil.parser
408
+
409
+ ts_dt = dateutil.parser.parse(ts)
410
+ if ts_dt.tzinfo:
411
+ ts_dt = ts_dt.replace(tzinfo=None)
412
+ age_days = (now_dt - ts_dt).days
413
+ if importance <= 2 and age_days > 180:
414
+ is_stale_low_value = True
415
+ except Exception as e:
416
+ print(
417
+ f"[auto_forget] Failed to parse folder obs timestamp '{ts}': {e}"
418
+ )
419
+
420
+ if is_expired or is_stale_low_value:
421
+ evicted_folder_observations.append((fp, aid, obs_id, obs))
422
+
423
+ if not dry_run:
424
+ for mem_id in evicted_memories:
425
+ mem = kv.get(KV.memories, mem_id)
426
+ kv.delete(KV.memories, mem_id)
427
+ if mem and mem.get("imageRef"):
428
+ ref = mem["imageRef"]
429
+ refs = kv.get(KV.imageRefs, ref) or 0
430
+ if refs > 0:
431
+ kv.set(KV.imageRefs, ref, refs - 1)
432
+ if _legacy._search_service:
433
+ _legacy._search_service.remove(mem_id)
434
+
435
+ for sid, obs_id in evicted_observations:
436
+ base_oid = obs_id.replace(":raw", "")
437
+ obs = kv.get(KV.observations(sid), base_oid)
438
+ raw_obs = kv.get(KV.observations(sid), f"{base_oid}:raw")
439
+
440
+ kv.delete(KV.observations(sid), base_oid)
441
+ kv.delete(KV.observations(sid), f"{base_oid}:raw")
442
+
443
+ for o in (obs, raw_obs):
444
+ if o:
445
+ img = o.get("imageData") or o.get("imageRef")
446
+ if img:
447
+ refs = kv.get(KV.imageRefs, img) or 0
448
+ if refs > 0:
449
+ kv.set(KV.imageRefs, img, refs - 1)
450
+
451
+ if _legacy._search_service:
452
+ _legacy._search_service.remove(base_oid)
453
+ _legacy._search_service.remove(f"{base_oid}:raw")
454
+
455
+ folder_deletes = {}
456
+ for fp, aid, obs_id, obs in evicted_folder_observations:
457
+ kv.delete(KV.folder_obs(fp, aid), obs_id)
458
+ kv.delete(KV.obs_lookup, obs_id)
459
+
460
+ if obs and isinstance(obs, dict) and obs.get("text"):
461
+ fp_text = obs["text"][:4000]
462
+ dedup_fp = hashlib.sha256(
463
+ fp_text.strip().lower().encode("utf-8")
464
+ ).hexdigest()
465
+ kv.delete(KV.obs_dedup(fp, aid), dedup_fp)
466
+
467
+ if _legacy._search_service:
468
+ _legacy._search_service.remove(obs_id)
469
+
470
+ pair_key = (fp, aid)
471
+ folder_deletes[pair_key] = folder_deletes.get(pair_key, 0) + 1
472
+
473
+ for (fp, aid), count in folder_deletes.items():
474
+ meta_scope = KV.folder_meta(fp, aid)
475
+ meta = kv.get(meta_scope, "meta")
476
+ if meta and isinstance(meta, dict):
477
+ current_count = meta.get("obsCount", 0)
478
+ meta["obsCount"] = max(0, current_count - count)
479
+ kv.set(meta_scope, "meta", meta)
480
+
481
+ index_key = f"{fp}:{aid}"
482
+ index_entry = kv.get(KV.folders, index_key)
483
+ if index_entry and isinstance(index_entry, dict):
484
+ index_entry["obsCount"] = meta["obsCount"]
485
+ kv.set(KV.folders, index_key, index_entry)
486
+
487
+ if evicted_memories or evicted_observations or evicted_folder_observations:
488
+ if _legacy._search_service:
489
+ _legacy._search_service.schedule_persist()
490
+ safe_audit(
491
+ kv,
492
+ "auto_forget",
493
+ "mem::auto_forget",
494
+ evicted_memories
495
+ + [oid for _, oid in evicted_observations]
496
+ + [oid for _, _, oid, _ in evicted_folder_observations],
497
+ {
498
+ "evictedMemoriesCount": len(evicted_memories),
499
+ "evictedObservationsCount": len(evicted_observations)
500
+ + len(evicted_folder_observations),
501
+ "dryRun": False,
502
+ },
503
+ )
504
+ commit_if_enabled(
505
+ kv,
506
+ f"Auto forget: evicted {len(evicted_memories)} memories, {len(evicted_observations) + len(evicted_folder_observations)} observations",
507
+ "system",
508
+ )
509
+
510
+ return {
511
+ "success": True,
512
+ "evictedMemories": evicted_memories,
513
+ "evictedObservations": [oid for _, oid in evicted_observations]
514
+ + [oid for _, _, oid, _ in evicted_folder_observations],
515
+ "evicted": len(evicted_memories)
516
+ + len(evicted_observations)
517
+ + len(evicted_folder_observations),
518
+ "dryRun": dry_run,
519
+ }
520
+
521
+
522
+ def health_check(kv: StateKV) -> Dict[str, Any]:
523
+ from .. import legacy as _legacy
524
+
525
+ db_status = "connected"
526
+ if kv is None:
527
+ db_status = "disconnected"
528
+ else:
529
+ try:
530
+ kv._get_conn()
531
+ except Exception:
532
+ db_status = "disconnected"
533
+
534
+ folder_count = 0
535
+ agent_count = 0
536
+ pair_count = 0
537
+ observation_count = 0
538
+ if kv is not None:
539
+ try:
540
+ folder_pairs = kv.list(KV.folders)
541
+ pair_count = len(folder_pairs)
542
+ unique_folders: Set[str] = set()
543
+ unique_agents: Set[str] = set()
544
+ for entry in folder_pairs:
545
+ fp = entry.get("folderPath")
546
+ aid = entry.get("agentId")
547
+ if fp:
548
+ unique_folders.add(fp)
549
+ if aid:
550
+ unique_agents.add(aid)
551
+ observation_count += int(entry.get("obsCount") or 0)
552
+ folder_count = len(unique_folders)
553
+ agent_count = len(unique_agents)
554
+ except Exception as e:
555
+ print(f"[health_check] folder count failed: {e}")
556
+
557
+ memory_count = 0
558
+ if kv is not None:
559
+ try:
560
+ memory_count = len(kv.list(KV.memories))
561
+ except Exception:
562
+ pass
563
+
564
+ bm25_index_size = 0
565
+ try:
566
+ bm25_index_size = (
567
+ _legacy._search_service.bm25.size if _legacy._search_service else 0
568
+ )
569
+ except Exception:
570
+ pass
571
+
572
+ vector_index_size = 0
573
+ try:
574
+ if _legacy._search_service and _legacy._search_service.vector:
575
+ vector_index_size = _legacy._search_service.vector.size
576
+ except Exception:
577
+ pass
578
+
579
+ sync_status = "never"
580
+ last_sync_at = None
581
+ db_size_bytes = 0
582
+ wal_size_bytes = 0
583
+ try:
584
+ sync_state_path = os.path.join(
585
+ os.path.expanduser("~"), ".agentcache", ".sync_state"
586
+ )
587
+ if os.path.exists(sync_state_path):
588
+ with open(sync_state_path, "r", encoding="utf-8") as _sf:
589
+ _sync = json.loads(_sf.read())
590
+ sync_status = _sync.get("sync_status", "never")
591
+ last_sync_at = _sync.get("last_sync_at")
592
+ except Exception:
593
+ pass
594
+
595
+ if kv is not None:
596
+ try:
597
+ db_stats = kv.stats()
598
+ db_size_bytes = db_stats.get("db_size_bytes", 0)
599
+ wal_size_bytes = db_stats.get("wal_size_bytes", 0)
600
+ except Exception:
601
+ pass
602
+
603
+ return {
604
+ "status": "ok" if db_status == "connected" else "degraded",
605
+ "folderCount": folder_count,
606
+ "agentCount": agent_count,
607
+ "pairCount": pair_count,
608
+ "observationCount": observation_count,
609
+ "memoryCount": memory_count,
610
+ "bm25IndexSize": bm25_index_size,
611
+ "vectorIndexSize": vector_index_size,
612
+ "dbPath": kv.db_path if kv else "",
613
+ "dbSizeBytes": db_size_bytes,
614
+ "walSizeBytes": wal_size_bytes,
615
+ "syncStatus": sync_status,
616
+ "lastSyncAt": last_sync_at,
617
+ }
618
+
619
+
620
+ def rebuild_index(kv: StateKV) -> int:
621
+ """Clear and rebuild the search index from all stored observations."""
622
+ from .session_store import _get_observation_store
623
+
624
+ store = _get_observation_store(kv)
625
+ return store.rebuild_index()