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
agentcache/search.py ADDED
@@ -0,0 +1,935 @@
1
+ import array
2
+ import base64
3
+ import json
4
+ import math
5
+ import re
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Dict, List, Optional, Set, Tuple
9
+
10
+ # =====================================================================
11
+ # Custom Porter-like Stemmer (Ported from stemmer.ts)
12
+ # =====================================================================
13
+
14
+ step2map = {
15
+ "ational": "ate",
16
+ "tional": "tion",
17
+ "enci": "ence",
18
+ "anci": "ance",
19
+ "izer": "ize",
20
+ "iser": "ise",
21
+ "abli": "able",
22
+ "alli": "al",
23
+ "entli": "ent",
24
+ "eli": "e",
25
+ "ousli": "ous",
26
+ "ization": "ize",
27
+ "isation": "ise",
28
+ "ation": "ate",
29
+ "ator": "ate",
30
+ "alism": "al",
31
+ "iveness": "ive",
32
+ "fulness": "ful",
33
+ "ousness": "ous",
34
+ "aliti": "al",
35
+ "iviti": "ive",
36
+ "biliti": "ble",
37
+ }
38
+
39
+ step3map = {
40
+ "icate": "ic",
41
+ "ative": "",
42
+ "alize": "al",
43
+ "alise": "al",
44
+ "iciti": "ic",
45
+ "ical": "ic",
46
+ "ful": "",
47
+ "ness": "",
48
+ }
49
+
50
+
51
+ def _has_vowel(s: str) -> bool:
52
+ return any(c in "aeiou" for c in s)
53
+
54
+
55
+ def _measure(s: str) -> int:
56
+ # Reduce non-vowels (excluding y) to C, vowels (+y) to V
57
+ reduced = ""
58
+ for c in s:
59
+ if c in "aeiouy":
60
+ if not reduced or reduced[-1] != "V":
61
+ reduced += "V"
62
+ else:
63
+ if not reduced or reduced[-1] != "C":
64
+ reduced += "C"
65
+ # count "VC" patterns
66
+ return len(re.findall(r"VC", reduced))
67
+
68
+
69
+ def _ends_double_consonant(s: str) -> bool:
70
+ return len(s) >= 2 and s[-1] == s[-2] and s[-1] not in "aeiou"
71
+
72
+
73
+ def _ends_cvc(s: str) -> bool:
74
+ if len(s) < 3:
75
+ return False
76
+ c1, v, c2 = s[-3], s[-2], s[-1]
77
+ return c1 not in "aeiou" and v in "aeiou" and c2 not in "aeiouwxy"
78
+
79
+
80
+ def stem(word: str) -> str:
81
+ if len(word) <= 2:
82
+ return word
83
+
84
+ w = word
85
+
86
+ # Step 1a
87
+ if w.endswith("sses"):
88
+ w = w[:-2]
89
+ elif w.endswith("ies"):
90
+ w = w[:-2]
91
+ elif not w.endswith("ss") and w.endswith("s"):
92
+ w = w[:-1]
93
+
94
+ # Step 1b
95
+ if w.endswith("eed"):
96
+ if _measure(w[:-3]) > 0:
97
+ w = w[:-1]
98
+ elif w.endswith("ed") and _has_vowel(w[:-2]):
99
+ w = w[:-2]
100
+ if w.endswith("at") or w.endswith("bl") or w.endswith("iz"):
101
+ w += "e"
102
+ elif _ends_double_consonant(w) and not w.endswith(("l", "s", "z")):
103
+ w = w[:-1]
104
+ elif _measure(w) == 1 and _ends_cvc(w):
105
+ w += "e"
106
+ elif w.endswith("ing") and _has_vowel(w[:-3]):
107
+ w = w[:-3]
108
+ if w.endswith("at") or w.endswith("bl") or w.endswith("iz"):
109
+ w += "e"
110
+ elif _ends_double_consonant(w) and not w.endswith(("l", "s", "z")):
111
+ w = w[:-1]
112
+ elif _measure(w) == 1 and _ends_cvc(w):
113
+ w += "e"
114
+
115
+ # Step 1c
116
+ if w.endswith("y") and _has_vowel(w[:-1]):
117
+ w = w[:-1] + "i"
118
+
119
+ # Step 2
120
+ for suffix, replacement in step2map.items():
121
+ if w.endswith(suffix):
122
+ base = w[: -len(suffix)]
123
+ if _measure(base) > 0:
124
+ w = base + replacement
125
+ break
126
+
127
+ # Step 3
128
+ for suffix, replacement in step3map.items():
129
+ if w.endswith(suffix):
130
+ base = w[: -len(suffix)]
131
+ if _measure(base) > 0:
132
+ w = base + replacement
133
+ break
134
+
135
+ # Step 4
136
+ suffixes_step4 = (
137
+ "al",
138
+ "ance",
139
+ "ence",
140
+ "er",
141
+ "ic",
142
+ "able",
143
+ "ible",
144
+ "ant",
145
+ "ement",
146
+ "ment",
147
+ "ent",
148
+ "tion",
149
+ "sion",
150
+ "ou",
151
+ "ism",
152
+ "ate",
153
+ "iti",
154
+ "ous",
155
+ "ive",
156
+ "ize",
157
+ "ise",
158
+ )
159
+ if w.endswith(suffixes_step4):
160
+ # find matching suffix length
161
+ match = re.search(
162
+ r"(ement|ment|tion|sion|ance|ence|able|ible|ism|ate|iti|ous|ive|ize|ise|ant|ent|al|er|ic|ou)$",
163
+ w,
164
+ )
165
+ if match:
166
+ suffix_len = len(match.group(1))
167
+ base = w[:-suffix_len]
168
+ if _measure(base) > 1:
169
+ w = base
170
+
171
+ # Step 5a
172
+ if w.endswith("e"):
173
+ base = w[:-1]
174
+ if _measure(base) > 1 or (_measure(base) == 1 and not _ends_cvc(base)):
175
+ w = base
176
+
177
+ # Step 5b
178
+ if _ends_double_consonant(w) and w.endswith("l") and _measure(w[:-1]) > 1:
179
+ w = w[:-1]
180
+
181
+ return w
182
+
183
+
184
+ # =====================================================================
185
+ # Synonym Map (Ported from synonyms.ts)
186
+ # =====================================================================
187
+
188
+ SYNONYM_GROUPS = [
189
+ ["auth", "authentication", "authn", "authenticating"],
190
+ ["authz", "authorization", "authorizing"],
191
+ ["db", "database", "datastore"],
192
+ ["perf", "performance", "latency", "throughput", "slow", "bottleneck"],
193
+ ["optim", "optimization", "optimizing", "optimise", "query-optimization"],
194
+ ["k8s", "kubernetes", "kube"],
195
+ ["config", "configuration", "configuring", "setup"],
196
+ ["deps", "dependencies", "dependency"],
197
+ ["env", "environment"],
198
+ ["fn", "function"],
199
+ ["impl", "implementation", "implementing"],
200
+ ["msg", "message", "messaging"],
201
+ ["repo", "repository"],
202
+ ["req", "request"],
203
+ ["res", "response"],
204
+ ["ts", "typescript"],
205
+ ["js", "javascript"],
206
+ ["pg", "postgres", "postgresql"],
207
+ ["err", "error", "errors"],
208
+ ["api", "endpoint", "endpoints"],
209
+ ["ci", "continuous-integration"],
210
+ ["cd", "continuous-deployment"],
211
+ ["test", "testing", "tests"],
212
+ ["doc", "documentation", "docs"],
213
+ ["infra", "infrastructure"],
214
+ ["deploy", "deployment", "deploying"],
215
+ ["cache", "caching", "cached"],
216
+ ["log", "logging", "logs"],
217
+ ["monitor", "monitoring"],
218
+ ["observe", "observability"],
219
+ ["sec", "security", "secure"],
220
+ ["validate", "validation", "validating"],
221
+ ["migrate", "migration", "migrations"],
222
+ ["debug", "debugging"],
223
+ ["container", "containerization", "docker"],
224
+ ["crash", "crashloop", "crashloopbackoff"],
225
+ ["webhook", "webhooks", "callback"],
226
+ ["middleware", "mw"],
227
+ ["paginate", "pagination"],
228
+ ["serialize", "serialization"],
229
+ ["encrypt", "encryption"],
230
+ ["hash", "hashing"],
231
+ ]
232
+
233
+ synonymMap: Dict[str, Set[str]] = {}
234
+ for group in SYNONYM_GROUPS:
235
+ stemmed = [stem(t.lower()) for t in group]
236
+ for s in stemmed:
237
+ if s not in synonymMap:
238
+ synonymMap[s] = set()
239
+ for other in stemmed:
240
+ if other != s:
241
+ synonymMap[s].add(other)
242
+
243
+
244
+ def get_synonyms(stemmed_term: str) -> List[str]:
245
+ return list(synonymMap.get(stemmed_term, []))
246
+
247
+
248
+ # =====================================================================
249
+ # CJK Segmenter (Ported from cjk-segmenter.ts)
250
+ # =====================================================================
251
+
252
+ CJK_RE = re.compile(
253
+ r"[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\uac00-\ud7a3]"
254
+ )
255
+ CJK_RUN_RE = re.compile(
256
+ r"[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\uac00-\ud7a3]+"
257
+ )
258
+ HANGUL_RE = re.compile(r"[\uac00-\ud7a3]")
259
+ KANA_RE = re.compile(r"[\u3040-\u309f\u30a0-\u30ff]")
260
+ HANGUL_BLOCK_RE = re.compile(r"[가-힯]+")
261
+
262
+ jieba_loaded = False
263
+ jieba_instance = None
264
+
265
+
266
+ def get_jieba():
267
+ global jieba_loaded, jieba_instance
268
+ if jieba_loaded:
269
+ return jieba_instance
270
+ jieba_loaded = True
271
+ try:
272
+ import jieba
273
+
274
+ jieba_instance = jieba
275
+ except ImportError:
276
+ print(
277
+ "[search] Install jieba to improve Chinese word segmentation (pip install jieba)"
278
+ )
279
+ return jieba_instance
280
+
281
+
282
+ def has_cjk(text: str) -> bool:
283
+ return bool(CJK_RE.search(text))
284
+
285
+
286
+ def segment_cjk(text: str) -> List[str]:
287
+ if not has_cjk(text):
288
+ return [text]
289
+
290
+ out: List[str] = []
291
+ cursor = 0
292
+
293
+ for match in CJK_RUN_RE.finditer(text):
294
+ start = match.start()
295
+ run = match.group(0)
296
+ end = match.end()
297
+
298
+ if start > cursor:
299
+ piece = text[cursor:start].strip()
300
+ if piece:
301
+ out.append(piece)
302
+
303
+ if HANGUL_RE.search(run):
304
+ # Hangul: split by blocks
305
+ out.extend(HANGUL_BLOCK_RE.findall(run))
306
+ elif KANA_RE.search(run):
307
+ # Japanese Kana fallback: split every character
308
+ out.extend(list(run))
309
+ else:
310
+ # Chinese Han: use jieba if available
311
+ jb = get_jieba()
312
+ if jb:
313
+ out.extend([t.strip() for t in jb.cut(run, cut_all=False) if t.strip()])
314
+ else:
315
+ out.extend(list(run))
316
+
317
+ cursor = end
318
+
319
+ if cursor < len(text):
320
+ trailing = text[cursor:].strip()
321
+ if trailing:
322
+ out.append(trailing)
323
+
324
+ return out
325
+
326
+
327
+ # =====================================================================
328
+ # SearchIndex (BM25 - Ported from search-index.ts)
329
+ # =====================================================================
330
+
331
+
332
+ class SearchIndex:
333
+ def __init__(self):
334
+ self.entries: Dict[str, Dict[str, Any]] = {}
335
+ self.inverted_index: Dict[str, Set[str]] = {}
336
+ self.doc_term_counts: Dict[str, Dict[str, int]] = {}
337
+ self.total_doc_length = 0
338
+ self.sorted_terms: Optional[List[str]] = None
339
+ self._dirty: bool = False # A4.2
340
+
341
+ self.k1 = 1.2
342
+ self.b = 0.75
343
+
344
+ def add(self, obs: Dict[str, Any]) -> None:
345
+ obs_id = obs.get("id")
346
+ if not obs_id:
347
+ return
348
+
349
+ terms = self.extract_terms(obs)
350
+ term_freq: Dict[str, int] = {}
351
+ term_count = 0
352
+
353
+ for term in terms:
354
+ term_freq[term] = term_freq.get(term, 0) + 1
355
+ term_count += 1
356
+
357
+ self.entries[obs_id] = {
358
+ "obsId": obs_id,
359
+ "sessionId": obs.get("sessionId", ""),
360
+ "termCount": term_count,
361
+ }
362
+ self.doc_term_counts[obs_id] = term_freq
363
+ self.total_doc_length += term_count
364
+
365
+ for term in term_freq.keys():
366
+ if term not in self.inverted_index:
367
+ self.inverted_index[term] = set()
368
+ self.inverted_index[term].add(obs_id)
369
+
370
+ self.sorted_terms = None
371
+ self._dirty = True # A4.2
372
+
373
+ def has(self, id: str) -> bool:
374
+ return id in self.entries
375
+
376
+ def remove(self, id: str) -> None:
377
+ entry = self.entries.get(id)
378
+ if not entry:
379
+ return
380
+
381
+ term_freq = self.doc_term_counts.get(id)
382
+ if term_freq:
383
+ for term in term_freq.keys():
384
+ posting_list = self.inverted_index.get(term)
385
+ if posting_list:
386
+ posting_list.discard(id)
387
+ if not posting_list:
388
+ self.inverted_index.pop(term, None)
389
+ self.doc_term_counts.pop(id, None)
390
+
391
+ self.total_doc_length = max(0, self.total_doc_length - entry["termCount"])
392
+ self.entries.pop(id, None)
393
+ self.sorted_terms = None
394
+ self._dirty = True # A4.2
395
+
396
+ def search(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
397
+ raw_terms = self.tokenize(query.lower())
398
+ if not raw_terms:
399
+ return []
400
+
401
+ N = len(self.entries)
402
+ if N == 0:
403
+ return []
404
+ avg_doc_len = self.total_doc_length / N
405
+
406
+ query_terms: List[Dict[str, Any]] = []
407
+ seen = set()
408
+ for term in raw_terms:
409
+ if term not in seen:
410
+ seen.add(term)
411
+ query_terms.append({"term": term, "weight": 1.0})
412
+ for syn in get_synonyms(term):
413
+ if syn not in seen:
414
+ seen.add(syn)
415
+ query_terms.append({"term": syn, "weight": 0.7})
416
+
417
+ scores: Dict[str, float] = {}
418
+ sorted_terms = self.get_sorted_terms()
419
+
420
+ for q_item in query_terms:
421
+ term = q_item["term"]
422
+ weight = q_item["weight"]
423
+
424
+ matching_docs = self.inverted_index.get(term)
425
+ if matching_docs:
426
+ df = len(matching_docs)
427
+ idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
428
+
429
+ for obs_id in matching_docs:
430
+ entry = self.entries[obs_id]
431
+ doc_terms = self.doc_term_counts.get(obs_id, {})
432
+ tf = doc_terms.get(term, 0)
433
+ doc_len = entry["termCount"]
434
+
435
+ numerator = tf * (self.k1 + 1)
436
+ denominator = tf + self.k1 * (
437
+ 1 - self.b + self.b * (doc_len / avg_doc_len)
438
+ )
439
+ bm25_score = idf * (numerator / denominator) * weight
440
+
441
+ scores[obs_id] = scores.get(obs_id, 0.0) + bm25_score
442
+
443
+ # Prefix matching (binary search)
444
+ start_idx = self.lower_bound(sorted_terms, term)
445
+ for si in range(start_idx, len(sorted_terms)):
446
+ index_term = sorted_terms[si]
447
+ if not index_term.startswith(term):
448
+ break
449
+ if index_term == term:
450
+ continue
451
+
452
+ obs_ids = self.inverted_index.get(index_term, set())
453
+ prefix_df = len(obs_ids)
454
+ prefix_idf = (
455
+ math.log((N - prefix_df + 0.5) / (prefix_df + 0.5) + 1) * 0.5
456
+ )
457
+
458
+ for obs_id in obs_ids:
459
+ entry = self.entries[obs_id]
460
+ doc_terms = self.doc_term_counts.get(obs_id, {})
461
+ tf = doc_terms.get(index_term, 0)
462
+ doc_len = entry["termCount"]
463
+ numerator = tf * (self.k1 + 1)
464
+ denominator = tf + self.k1 * (
465
+ 1 - self.b + self.b * (doc_len / avg_doc_len)
466
+ )
467
+ scores[obs_id] = (
468
+ scores.get(obs_id, 0.0)
469
+ + prefix_idf * (numerator / denominator) * weight
470
+ )
471
+
472
+ results = []
473
+ for obs_id, score in scores.items():
474
+ entry = self.entries[obs_id]
475
+ results.append(
476
+ {"obsId": obs_id, "sessionId": entry["sessionId"], "score": score}
477
+ )
478
+
479
+ results.sort(key=lambda x: x["score"], reverse=True)
480
+ return results[:limit]
481
+
482
+ @property
483
+ def size(self) -> int:
484
+ return len(self.entries)
485
+
486
+ def clear(self) -> None:
487
+ self.entries.clear()
488
+ self.inverted_index.clear()
489
+ self.doc_term_counts.clear()
490
+ self.total_doc_length = 0
491
+ self.sorted_terms = None
492
+
493
+ def restore_from_data(self, data: Dict[str, Any]) -> None:
494
+ self.clear()
495
+ if not data:
496
+ return
497
+
498
+ for k, v in data.get("entries", []):
499
+ self.entries[k] = v
500
+ for term, ids in data.get("inverted", []):
501
+ self.inverted_index[term] = set(ids)
502
+ for id_, counts in data.get("docTerms", []):
503
+ self.doc_term_counts[id_] = dict(counts)
504
+ self.total_doc_length = int(data.get("totalDocLength", 0))
505
+ self._dirty = False # A4.2 — freshly loaded, not dirty
506
+
507
+ def serialize_data(self) -> Dict[str, Any]:
508
+ entries = list(self.entries.items())
509
+ inverted = [(term, list(ids)) for term, ids in self.inverted_index.items()]
510
+ doc_terms = [
511
+ (id_, list(counts.items())) for id_, counts in self.doc_term_counts.items()
512
+ ]
513
+ return {
514
+ "v": 2,
515
+ "entries": entries,
516
+ "inverted": inverted,
517
+ "docTerms": doc_terms,
518
+ "totalDocLength": self.total_doc_length,
519
+ }
520
+
521
+ def extract_terms(self, obs: Dict[str, Any]) -> List[str]:
522
+ parts = [
523
+ obs.get("title", ""),
524
+ obs.get("subtitle", "") or "",
525
+ obs.get("text", "") or "",
526
+ obs.get("narrative", "") or "",
527
+ " ".join(obs.get("facts", []) or []),
528
+ " ".join(obs.get("concepts", []) or []),
529
+ " ".join(obs.get("files", []) or []),
530
+ obs.get("type", ""),
531
+ ]
532
+ return self.tokenize(" ".join(parts).lower())
533
+
534
+ def tokenize(self, text: str) -> List[str]:
535
+ # Strip special characters except valid separators
536
+ cleaned = re.sub(r"[^\w\s/.\\-_]", " ", text)
537
+ out = []
538
+ for raw in cleaned.split():
539
+ if len(raw) < 2:
540
+ continue
541
+ if has_cjk(raw):
542
+ for seg in segment_cjk(raw):
543
+ if len(seg) >= 1:
544
+ out.append(seg)
545
+ else:
546
+ out.append(stem(raw))
547
+ return out
548
+
549
+ def get_sorted_terms(self) -> List[str]:
550
+ if not self.sorted_terms:
551
+ self.sorted_terms = sorted(self.inverted_index.keys())
552
+ return self.sorted_terms
553
+
554
+ def lower_bound(self, arr: List[str], target: str) -> int:
555
+ lo = 0
556
+ hi = len(arr)
557
+ while lo < hi:
558
+ mid = (lo + hi) // 2
559
+ if arr[mid] < target:
560
+ lo = mid + 1
561
+ else:
562
+ hi = mid
563
+ return lo
564
+
565
+
566
+ # =====================================================================
567
+ # VectorIndex (Cosine Similarity - Ported from vector-index.ts)
568
+ # =====================================================================
569
+
570
+
571
+ def float32_to_base64(floats: List[float]) -> str:
572
+ arr = array.array("f", floats)
573
+ return base64.b64encode(arr.tobytes()).decode("utf-8")
574
+
575
+
576
+ def base64_to_float32(b64: str) -> List[float]:
577
+ arr = array.array("f")
578
+ arr.frombytes(base64.b64decode(b64))
579
+ return list(arr)
580
+
581
+
582
+ def cosine_similarity(a: List[float], b: List[float]) -> float:
583
+ if len(a) != len(b) or len(a) == 0:
584
+ return 0.0
585
+ dot = 0.0
586
+ norm_a = 0.0
587
+ norm_b = 0.0
588
+ for x, y in zip(a, b):
589
+ dot += x * y
590
+ norm_a += x * x
591
+ norm_b += y * y
592
+ denom = math.sqrt(norm_a) * math.sqrt(norm_b)
593
+ return dot / denom if denom != 0.0 else 0.0
594
+
595
+
596
+ class VectorIndex:
597
+ def __init__(self):
598
+ self.vectors: Dict[str, Dict[str, Any]] = {}
599
+ self._dirty: bool = False # A4.2
600
+
601
+ def add(self, obs_id: str, session_id: str, embedding: List[float]) -> None:
602
+ self.vectors[obs_id] = {"embedding": embedding, "sessionId": session_id}
603
+ self._dirty = True # A4.2
604
+
605
+ def remove(self, obs_id: str) -> None:
606
+ if obs_id in self.vectors:
607
+ self.vectors.pop(obs_id, None)
608
+ self._dirty = True # A4.2
609
+
610
+ def search(self, query: List[float], limit: int = 20) -> List[Dict[str, Any]]:
611
+ results = []
612
+ for obs_id, entry in self.vectors.items():
613
+ score = cosine_similarity(query, entry["embedding"])
614
+ results.append(
615
+ {"obsId": obs_id, "sessionId": entry["sessionId"], "score": score}
616
+ )
617
+
618
+ results.sort(key=lambda x: x["score"], reverse=True)
619
+ return results[:limit]
620
+
621
+ @property
622
+ def size(self) -> int:
623
+ return len(self.vectors)
624
+
625
+ def validate_dimensions(
626
+ self, expected: int
627
+ ) -> Tuple[List[Dict[str, Any]], Set[int]]:
628
+ mismatches = []
629
+ seen_dimensions = set()
630
+ for obs_id, entry in self.vectors.items():
631
+ dim = len(entry["embedding"])
632
+ seen_dimensions.add(dim)
633
+ if dim != expected:
634
+ mismatches.append({"obsId": obs_id, "dim": dim})
635
+ return mismatches, seen_dimensions
636
+
637
+ def clear(self) -> None:
638
+ self.vectors.clear()
639
+
640
+ def serialize_data(self) -> List[Any]:
641
+ data = []
642
+ for obs_id, entry in self.vectors.items():
643
+ data.append(
644
+ [
645
+ obs_id,
646
+ {
647
+ "embedding": float32_to_base64(entry["embedding"]),
648
+ "sessionId": entry["sessionId"],
649
+ },
650
+ ]
651
+ )
652
+ return data
653
+
654
+ def restore_from_data(self, data: List[Any]) -> None:
655
+ self.clear()
656
+ if not isinstance(data, list):
657
+ return
658
+ for row in data:
659
+ try:
660
+ if not isinstance(row, list) or len(row) < 2:
661
+ continue
662
+ obs_id, entry = row
663
+ if not isinstance(obs_id, str) or not isinstance(entry, dict):
664
+ continue
665
+ emb_b64 = entry.get("embedding")
666
+ sess_id = entry.get("sessionId")
667
+ if not isinstance(emb_b64, str) or not isinstance(sess_id, str):
668
+ continue
669
+ self.vectors[obs_id] = {
670
+ "embedding": base64_to_float32(emb_b64),
671
+ "sessionId": sess_id,
672
+ }
673
+ except Exception:
674
+ continue
675
+
676
+
677
+ # =====================================================================
678
+ # Gemini Embedding Client (Urllib POST completion)
679
+ # =====================================================================
680
+
681
+
682
+ class GeminiEmbeddingProvider:
683
+ def __init__(self, api_key: str):
684
+ self.name = "gemini"
685
+ self.dimensions = 768
686
+ self.api_key = api_key
687
+ self.model = "models/gemini-embedding-001"
688
+ self.api_url = f"https://generativelanguage.googleapis.com/v1beta/{self.model}:batchEmbedContents"
689
+
690
+ def embed(self, text: str) -> List[float]:
691
+ results = self.embed_batch([text])
692
+ return results[0]
693
+
694
+ def embed_batch(self, texts: List[str]) -> List[List[float]]:
695
+ results: List[List[float]] = []
696
+ batch_limit = 100
697
+
698
+ for i in range(0, len(texts), batch_limit):
699
+ chunk = texts[i : i + batch_limit]
700
+
701
+ payload = {
702
+ "requests": [
703
+ {
704
+ "model": self.model,
705
+ "content": {"parts": [{"text": t}]},
706
+ "outputDimensionality": self.dimensions,
707
+ }
708
+ for t in chunk
709
+ ]
710
+ }
711
+
712
+ req_data = json.dumps(payload).encode("utf-8")
713
+ url = f"{self.api_url}?key={self.api_key}"
714
+
715
+ req = urllib.request.Request(
716
+ url,
717
+ data=req_data,
718
+ headers={"Content-Type": "application/json"},
719
+ method="POST",
720
+ )
721
+
722
+ try:
723
+ with urllib.request.urlopen(req, timeout=30.0) as response: # nosec B310
724
+ resp_data = json.loads(response.read().decode("utf-8"))
725
+
726
+ for emb in resp_data.get("embeddings", []):
727
+ values = emb.get("values", [])
728
+ results.append(self._l2_normalize(values))
729
+ except Exception as e:
730
+ raise RuntimeError(f"Gemini embedding batch call failed: {e}")
731
+
732
+ return results
733
+
734
+ def _l2_normalize(self, vec: List[float]) -> List[float]:
735
+ sum_sq = sum(x * x for x in vec)
736
+ norm = math.sqrt(sum_sq)
737
+ if norm == 0:
738
+ return vec
739
+ return [x / norm for x in vec]
740
+
741
+
742
+ # =====================================================================
743
+ # HybridSearch (Triple Stream - Ported from hybrid-search.ts)
744
+ # =====================================================================
745
+
746
+
747
+ class HybridSearch:
748
+ def __init__(
749
+ self,
750
+ bm25: SearchIndex,
751
+ vector: Optional[VectorIndex],
752
+ embedding_provider: Optional[GeminiEmbeddingProvider],
753
+ kv: Any,
754
+ bm25_weight: float = 0.4,
755
+ vector_weight: float = 0.6,
756
+ graph_weight: float = 0.3,
757
+ ):
758
+ self.bm25 = bm25
759
+ self.vector = vector
760
+ self.embedding_provider = embedding_provider
761
+ self.kv = kv
762
+ self.bm25_weight = bm25_weight
763
+ self.vector_weight = vector_weight
764
+ self.graph_weight = graph_weight
765
+
766
+ def search(self, query: str, limit: int = 20) -> List[Dict[str, Any]]:
767
+ # Triple-stream search combining BM25, vectors, and graph weights
768
+ bm25_results = self.bm25.search(query, limit * 2)
769
+
770
+ vector_results: List[Dict[str, Any]] = []
771
+ if self.vector and self.embedding_provider and self.vector.size > 0:
772
+ try:
773
+ query_embedding = self.embedding_provider.embed(query)
774
+ vector_results = self.vector.search(query_embedding, limit * 2)
775
+ except Exception:
776
+ pass # Fallback to BM25
777
+
778
+ # Build scores mapping
779
+ scores: Dict[str, Dict[str, Any]] = {}
780
+ RRF_K = 60
781
+
782
+ for idx, r in enumerate(bm25_results):
783
+ obs_id = r["obsId"]
784
+ scores[obs_id] = {
785
+ "bm25Rank": idx + 1,
786
+ "vectorRank": float("inf"),
787
+ "sessionId": r["sessionId"],
788
+ "bm25Score": r["score"],
789
+ "vectorScore": 0.0,
790
+ "graphScore": 0.0,
791
+ }
792
+
793
+ for idx, r in enumerate(vector_results):
794
+ obs_id = r["obsId"]
795
+ if obs_id in scores:
796
+ scores[obs_id]["vectorRank"] = idx + 1
797
+ scores[obs_id]["vectorScore"] = r["score"]
798
+ else:
799
+ scores[obs_id] = {
800
+ "bm25Rank": float("inf"),
801
+ "vectorRank": idx + 1,
802
+ "sessionId": r["sessionId"],
803
+ "bm25Score": 0.0,
804
+ "vectorScore": r["score"],
805
+ "graphScore": 0.0,
806
+ }
807
+
808
+ has_vector = len(vector_results) > 0
809
+
810
+ effective_bm25_w = self.bm25_weight
811
+ effective_vector_w = self.vector_weight if has_vector else 0.0
812
+
813
+ total_w = effective_bm25_w + effective_vector_w
814
+ if total_w > 0:
815
+ effective_bm25_w /= total_w
816
+ effective_vector_w /= total_w
817
+
818
+ combined = []
819
+ for obs_id, s in scores.items():
820
+ combined.append(
821
+ {
822
+ "obsId": obs_id,
823
+ "sessionId": s["sessionId"],
824
+ "bm25Score": s["bm25Score"],
825
+ "vectorScore": s["vectorScore"],
826
+ "graphScore": s["graphScore"],
827
+ "combinedScore": (
828
+ effective_bm25_w * (1.0 / (RRF_K + s["bm25Rank"]))
829
+ + effective_vector_w * (1.0 / (RRF_K + s["vectorRank"]))
830
+ ),
831
+ }
832
+ )
833
+
834
+ combined.sort(key=lambda x: x["combinedScore"], reverse=True)
835
+ return combined[:limit]
836
+
837
+
838
+ # =====================================================================
839
+ # OpenAI Embedding Client (D5.1)
840
+ # =====================================================================
841
+
842
+
843
+ class OpenAIEmbeddingProvider:
844
+ """OpenAI text-embedding-3-small provider (1536 dims).
845
+
846
+ Uses urllib.request only — no new dependencies.
847
+ Reads API key from OPENAI_API_KEY env var.
848
+ """
849
+
850
+ def __init__(self, api_key: str):
851
+ self.name = "openai"
852
+ self.dimensions = 1536
853
+ self.api_key = api_key
854
+ self.model = "text-embedding-3-small"
855
+ self.api_url = "https://api.openai.com/v1/embeddings"
856
+
857
+ def embed(self, text: str) -> List[float]:
858
+ return self.embed_batch([text])[0]
859
+
860
+ def embed_batch(self, texts: List[str]) -> List[List[float]]:
861
+ results: List[List[float]] = []
862
+ # OpenAI supports up to 2048 inputs per request; batch in chunks of 100
863
+ batch_limit = 100
864
+ for i in range(0, len(texts), batch_limit):
865
+ chunk = texts[i : i + batch_limit]
866
+ payload = json.dumps({"model": self.model, "input": chunk}).encode("utf-8")
867
+ req = urllib.request.Request(
868
+ self.api_url,
869
+ data=payload,
870
+ headers={
871
+ "Content-Type": "application/json",
872
+ "Authorization": f"Bearer {self.api_key}",
873
+ },
874
+ method="POST",
875
+ )
876
+ try:
877
+ with urllib.request.urlopen(req, timeout=30.0) as response: # nosec B310
878
+ resp_data = json.loads(response.read().decode("utf-8"))
879
+ # Sort by index to preserve order
880
+ embeddings_sorted = sorted(
881
+ resp_data.get("data", []), key=lambda e: e["index"]
882
+ )
883
+ for emb in embeddings_sorted:
884
+ results.append(self._l2_normalize(emb["embedding"]))
885
+ except Exception as e:
886
+ raise RuntimeError(f"OpenAI embedding batch call failed: {e}")
887
+ return results
888
+
889
+ def _l2_normalize(self, vec: List[float]) -> List[float]:
890
+ sum_sq = sum(x * x for x in vec)
891
+ norm = math.sqrt(sum_sq)
892
+ if norm == 0:
893
+ return vec
894
+ return [x / norm for x in vec]
895
+
896
+
897
+ # =====================================================================
898
+ # SentenceTransformer Local Provider (D5.2)
899
+ # =====================================================================
900
+
901
+
902
+ class SentenceTransformerProvider:
903
+ """Local sentence-transformers provider (optional install).
904
+
905
+ Default model: all-MiniLM-L6-v2 (384 dims).
906
+ Override via AGENTMEMORY_LOCAL_EMBEDDING_MODEL env var.
907
+
908
+ Install: pip install sentence-transformers
909
+ """
910
+
911
+ def __init__(self, model_name: Optional[str] = None):
912
+ model_name = model_name or "all-MiniLM-L6-v2"
913
+ self.name = "sentence-transformers"
914
+ try:
915
+ from sentence_transformers import SentenceTransformer # type: ignore
916
+
917
+ self._model = SentenceTransformer(model_name)
918
+ self.dimensions = self._model.get_sentence_embedding_dimension()
919
+ print(
920
+ f"[search] SentenceTransformerProvider loaded: {model_name} ({self.dimensions} dims)"
921
+ )
922
+ except ImportError:
923
+ raise ImportError(
924
+ "sentence-transformers is not installed. "
925
+ "Run: pip install sentence-transformers or pip install agentmemory[local-embeddings]"
926
+ )
927
+
928
+ def embed(self, text: str) -> List[float]:
929
+ return self.embed_batch([text])[0]
930
+
931
+ def embed_batch(self, texts: List[str]) -> List[List[float]]:
932
+ embeddings = self._model.encode(
933
+ texts, show_progress_bar=False, normalize_embeddings=True
934
+ )
935
+ return [emb.tolist() for emb in embeddings]