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,576 @@
1
+ """
2
+ ObservationStore — owns folder-scoped observations lifecycle.
3
+
4
+ Handles ingestion, deduplication, deletion, timeline retrieval,
5
+ and startup backfill for folder observations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import hashlib
12
+ import os
13
+ import threading
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Callable, Dict, List, Optional
16
+
17
+ from ..db import StateKV
18
+ from ..storage.paths import generate_id
19
+ from .kv_scopes import KV
20
+ from .search_service import SearchService
21
+
22
+
23
+ @dataclass
24
+ class ObservationEvents:
25
+ on_added: List[Callable[[Dict[str, Any]], None]] = field(default_factory=list)
26
+ on_deleted: List[Callable[[List[str]], None]] = field(default_factory=list)
27
+ on_folder_deleted: List[Callable[[str, str], None]] = field(default_factory=list)
28
+
29
+
30
+ def normalize_folder_path(path: str) -> str:
31
+ """Normalize a folder path for safe use in KV scope keys."""
32
+ if not path:
33
+ raise ValueError("folder_path must not be empty")
34
+
35
+ path = path[:512]
36
+
37
+ raw_parts = path.replace("\\", "/").split("/")
38
+ if any(part == ".." for part in raw_parts):
39
+ raise ValueError(f"folder_path contains path traversal segment '..': {path!r}")
40
+
41
+ normalized = os.path.normpath(path).replace("\\", "/").strip("/")
42
+
43
+ parts = normalized.split("/")
44
+ if any(part == ".." for part in parts):
45
+ raise ValueError(f"folder_path contains path traversal segment '..': {path!r}")
46
+
47
+ if not normalized:
48
+ raise ValueError("folder_path is empty after normalization")
49
+
50
+ return normalized
51
+
52
+
53
+ def validate_agent_id(agent_id: str) -> str:
54
+ """Validate and sanitize an agent_id before use in KV scope keys."""
55
+ if not agent_id:
56
+ raise ValueError("agent_id must not be empty")
57
+
58
+ agent_id = agent_id.strip()[:512]
59
+
60
+ if not agent_id:
61
+ raise ValueError("agent_id must not be empty")
62
+
63
+ return agent_id
64
+
65
+
66
+ class ObservationStore:
67
+ """Store for folder-scoped observations."""
68
+
69
+ def __init__(
70
+ self,
71
+ kv: StateKV,
72
+ search_service: Optional[SearchService] = None,
73
+ events: Optional[ObservationEvents] = None,
74
+ ) -> None:
75
+ self.kv = kv
76
+ self.search_service = search_service
77
+ self.events = events or ObservationEvents()
78
+ self._dedup_locks: Dict[str, threading.Lock] = {}
79
+ self._locks_mutex = threading.Lock()
80
+
81
+ def _get_dedup_lock(self, folder_path: str, agent_id: str) -> threading.Lock:
82
+ key = f"{folder_path}:{agent_id}"
83
+ with self._locks_mutex:
84
+ if key not in self._dedup_locks:
85
+ self._dedup_locks[key] = threading.Lock()
86
+ return self._dedup_locks[key]
87
+
88
+ def ingest(self, payload: Dict[str, Any]) -> Dict[str, Any]:
89
+ """Validate, deduplicate, write, index, and fire events for an observation."""
90
+ folder_path_raw = payload.get("folderPath")
91
+ agent_id_raw = payload.get("agentId")
92
+ text_raw = payload.get("text")
93
+ timestamp = payload.get("timestamp")
94
+
95
+ if not folder_path_raw:
96
+ raise ValueError("Invalid payload: folderPath is required")
97
+ if not agent_id_raw:
98
+ raise ValueError("Invalid payload: agentId is required")
99
+ if not text_raw:
100
+ raise ValueError("Invalid payload: text is required")
101
+ if not timestamp:
102
+ raise ValueError("Invalid payload: timestamp is required")
103
+
104
+ folder_path = normalize_folder_path(folder_path_raw)
105
+ agent_id = validate_agent_id(agent_id_raw)
106
+
107
+ from .infer import extract_files, infer_type
108
+ from .privacy import strip_private_data
109
+
110
+ safe_text = strip_private_data(text_raw)[:4000]
111
+
112
+ dedup_fp = hashlib.sha256(
113
+ safe_text[:4000].strip().lower().encode("utf-8")
114
+ ).hexdigest()
115
+
116
+ dedup_lock = self._get_dedup_lock(folder_path, agent_id)
117
+ with dedup_lock:
118
+ existing_dedup = self.kv.get(KV.obs_dedup(folder_path, agent_id), dedup_fp)
119
+ if (
120
+ existing_dedup
121
+ and isinstance(existing_dedup, dict)
122
+ and existing_dedup.get("obsId")
123
+ ):
124
+ return {"observationId": existing_dedup["obsId"], "deduplicated": True}
125
+
126
+ max_obs = int(os.getenv("MAX_OBS_PER_FOLDER", "2000"))
127
+ if max_obs > 0:
128
+ existing_obs = self.kv.list(KV.folder_obs(folder_path, agent_id))
129
+ if len(existing_obs) >= max_obs:
130
+ raise ValueError(f"Folder observation limit reached ({max_obs})")
131
+
132
+ obs_id = generate_id("fobs")
133
+
134
+ obs_type = payload.get("type")
135
+ if not obs_type:
136
+ obs_type = infer_type(None, "other")
137
+
138
+ title = payload.get("title")
139
+ if not title:
140
+ title = safe_text[:80]
141
+
142
+ concepts = payload.get("concepts") or []
143
+ if not isinstance(concepts, list):
144
+ concepts = []
145
+
146
+ files = payload.get("files")
147
+ if not isinstance(files, list):
148
+ files = extract_files(payload)
149
+
150
+ raw_importance = payload.get("importance")
151
+ if raw_importance is None:
152
+ importance = 5
153
+ else:
154
+ try:
155
+ importance = max(1, min(10, int(raw_importance)))
156
+ except (TypeError, ValueError):
157
+ importance = 5
158
+
159
+ obs: Dict[str, Any] = {
160
+ "id": obs_id,
161
+ "folderPath": folder_path,
162
+ "agentId": agent_id,
163
+ "timestamp": timestamp,
164
+ "text": safe_text,
165
+ "type": obs_type,
166
+ "title": title,
167
+ "concepts": concepts,
168
+ "files": files,
169
+ "importance": importance,
170
+ }
171
+ if "forgetAfter" in payload:
172
+ obs["forgetAfter"] = payload["forgetAfter"]
173
+ elif (
174
+ payload.get("ttlDays")
175
+ and isinstance(payload["ttlDays"], (int, float))
176
+ and payload["ttlDays"] > 0
177
+ ):
178
+ try:
179
+ import dateutil.parser
180
+
181
+ ts_dt = dateutil.parser.parse(timestamp)
182
+ forget_time = ts_dt + datetime.timedelta(days=payload["ttlDays"])
183
+ obs["forgetAfter"] = forget_time.isoformat().replace("+00:00", "Z")
184
+ except Exception:
185
+ pass
186
+
187
+ self.kv.set(KV.folder_obs(folder_path, agent_id), obs_id, obs)
188
+
189
+ self.kv.set(
190
+ KV.obs_lookup,
191
+ obs_id,
192
+ {
193
+ "folderPath": folder_path,
194
+ "agentId": agent_id,
195
+ },
196
+ )
197
+
198
+ self.kv.set(
199
+ KV.obs_dedup(folder_path, agent_id),
200
+ dedup_fp,
201
+ {"obsId": obs_id, "timestamp": timestamp},
202
+ )
203
+
204
+ meta_scope = KV.folder_meta(folder_path, agent_id)
205
+ meta = self.kv.get(meta_scope, "meta") or {
206
+ "folderPath": folder_path,
207
+ "agentId": agent_id,
208
+ "obsCount": 0,
209
+ "lastUpdated": timestamp,
210
+ "summary": None,
211
+ }
212
+ meta["obsCount"] = meta.get("obsCount", 0) + 1
213
+ meta["lastUpdated"] = timestamp
214
+ self.kv.set(meta_scope, "meta", meta)
215
+
216
+ index_key = f"{folder_path}:{agent_id}"
217
+ self.kv.set(
218
+ KV.folders,
219
+ index_key,
220
+ {
221
+ "folderPath": folder_path,
222
+ "agentId": agent_id,
223
+ "lastUpdated": meta["lastUpdated"],
224
+ "obsCount": meta["obsCount"],
225
+ },
226
+ )
227
+
228
+ if self.search_service:
229
+ try:
230
+ self.search_service.index(obs)
231
+ self.search_service.schedule_persist()
232
+ except Exception as ex:
233
+ print(f"[observation_store] search_service indexing failed: {ex}")
234
+
235
+ self.kv.commit_version(f"folder_observe: {obs_id}", agent_id)
236
+
237
+ event_payload = {
238
+ "type": "folder_observation",
239
+ "folderPath": folder_path,
240
+ "agentId": agent_id,
241
+ "data": obs,
242
+ }
243
+ if self.events and self.events.on_added:
244
+ for cb in self.events.on_added:
245
+ try:
246
+ cb(event_payload)
247
+ except Exception as ex:
248
+ print(f"[observation_store] Error in on_added callback: {ex}")
249
+
250
+ return {"observationId": obs_id}
251
+
252
+ def dedup(
253
+ self, folder_path_raw: Optional[str] = None, agent_id_raw: Optional[str] = None
254
+ ) -> Dict[str, Any]:
255
+ """Remove duplicate observations for one or all (folder, agent) pairs."""
256
+ if folder_path_raw and agent_id_raw:
257
+ try:
258
+ fp = normalize_folder_path(folder_path_raw)
259
+ aid = validate_agent_id(agent_id_raw)
260
+ except ValueError as exc:
261
+ return {"success": False, "error": str(exc)}
262
+ pairs = [{"folderPath": fp, "agentId": aid}]
263
+ else:
264
+ pairs = [
265
+ {"folderPath": e.get("folderPath", ""), "agentId": e.get("agentId", "")}
266
+ for e in self.kv.list(KV.folders)
267
+ if e.get("folderPath") and e.get("agentId")
268
+ ]
269
+
270
+ total_removed = 0
271
+ total_kept = 0
272
+
273
+ for pair in pairs:
274
+ fp = pair["folderPath"]
275
+ aid = pair["agentId"]
276
+ all_obs = self.kv.list(KV.folder_obs(fp, aid))
277
+
278
+ fingerprint_map: Dict[str, Dict[str, Any]] = {}
279
+ duplicates: List[str] = []
280
+
281
+ for obs in all_obs:
282
+ text = obs.get("text") or ""
283
+ fp_hash = hashlib.sha256(
284
+ text[:4000].strip().lower().encode("utf-8")
285
+ ).hexdigest()
286
+ if fp_hash not in fingerprint_map:
287
+ fingerprint_map[fp_hash] = obs
288
+ else:
289
+ existing_ts = fingerprint_map[fp_hash].get("timestamp", "")
290
+ this_ts = obs.get("timestamp", "")
291
+ if this_ts < existing_ts:
292
+ duplicates.append(fingerprint_map[fp_hash]["id"])
293
+ fingerprint_map[fp_hash] = obs
294
+ else:
295
+ duplicates.append(obs["id"])
296
+
297
+ if duplicates:
298
+ self.forget(
299
+ {"folderPath": fp, "agentId": aid, "observationIds": duplicates}
300
+ )
301
+ total_removed += len(duplicates)
302
+
303
+ total_kept += len(fingerprint_map)
304
+
305
+ dedup_scope = KV.obs_dedup(fp, aid)
306
+ for fp_hash, obs in fingerprint_map.items():
307
+ self.kv.set(
308
+ dedup_scope,
309
+ fp_hash,
310
+ {"obsId": obs["id"], "timestamp": obs.get("timestamp", "")},
311
+ )
312
+
313
+ return {
314
+ "success": True,
315
+ "deduplicated": total_removed,
316
+ "pairs_processed": len(pairs),
317
+ "kept": total_kept,
318
+ }
319
+
320
+ def forget(self, data: Dict[str, Any]) -> Dict[str, Any]:
321
+ """Delete a folder pair, specific observations, or a global memory."""
322
+ memory_id = data.get("memoryId")
323
+ session_id = data.get("sessionId")
324
+ folder_path_raw = data.get("folderPath")
325
+ agent_id_raw = data.get("agentId")
326
+ obs_ids = data.get("observationIds") or []
327
+ deleted = 0
328
+ deleted_mem_ids: List[str] = []
329
+ deleted_obs_ids: List[str] = []
330
+
331
+ if memory_id:
332
+ mem = self.kv.get(KV.memories, memory_id)
333
+ self.kv.delete(KV.memories, memory_id)
334
+ if mem and isinstance(mem, dict) and mem.get("imageRef"):
335
+ ref = mem["imageRef"]
336
+ refs = self.kv.get(KV.imageRefs, ref) or 0
337
+ if refs > 0:
338
+ self.kv.set(KV.imageRefs, ref, refs - 1)
339
+ if self.search_service:
340
+ self.search_service.remove(memory_id)
341
+ deleted_mem_ids.append(memory_id)
342
+ deleted += 1
343
+
344
+ if folder_path_raw and agent_id_raw:
345
+ try:
346
+ fp = normalize_folder_path(folder_path_raw)
347
+ aid = validate_agent_id(agent_id_raw)
348
+ except ValueError as exc:
349
+ return {"success": False, "error": str(exc), "deleted": 0}
350
+
351
+ obs_scope = KV.folder_obs(fp, aid)
352
+ meta_scope = KV.folder_meta(fp, aid)
353
+ index_key = f"{fp}:{aid}"
354
+ if "observationIds" in data and data["observationIds"] is not None:
355
+ partial_deleted = 0
356
+ for oid in obs_ids:
357
+ obs = self.kv.get(obs_scope, oid)
358
+ existed = self.kv.delete(obs_scope, oid)
359
+ if existed:
360
+ self.kv.delete(KV.obs_lookup, oid)
361
+ if self.search_service:
362
+ self.search_service.remove(oid)
363
+ if obs and isinstance(obs, dict) and obs.get("text"):
364
+ fp_text = obs["text"][:4000]
365
+ dedup_fp = hashlib.sha256(
366
+ fp_text.strip().lower().encode("utf-8")
367
+ ).hexdigest()
368
+ self.kv.delete(KV.obs_dedup(fp, aid), dedup_fp)
369
+ deleted_obs_ids.append(oid)
370
+ partial_deleted += 1
371
+ deleted += 1
372
+
373
+ if partial_deleted > 0:
374
+ meta = self.kv.get(meta_scope, "meta")
375
+ if meta and isinstance(meta, dict):
376
+ current_count = meta.get("obsCount", 0)
377
+ meta["obsCount"] = max(0, current_count - partial_deleted)
378
+ self.kv.set(meta_scope, "meta", meta)
379
+ index_entry = self.kv.get(KV.folders, index_key)
380
+ if index_entry and isinstance(index_entry, dict):
381
+ index_entry["obsCount"] = meta["obsCount"]
382
+ self.kv.set(KV.folders, index_key, index_entry)
383
+
384
+ if deleted_obs_ids and self.events and self.events.on_deleted:
385
+ for cb in self.events.on_deleted:
386
+ try:
387
+ cb(deleted_obs_ids)
388
+ except Exception as ex:
389
+ print(
390
+ f"[observation_store] Error in on_deleted callback: {ex}"
391
+ )
392
+ else:
393
+ all_obs = self.kv.list(obs_scope)
394
+ for obs in all_obs:
395
+ obs_id = obs.get("id")
396
+ if obs_id:
397
+ self.kv.delete(obs_scope, obs_id)
398
+ self.kv.delete(KV.obs_lookup, obs_id)
399
+ if self.search_service:
400
+ self.search_service.remove(obs_id)
401
+ deleted_obs_ids.append(obs_id)
402
+ deleted += 1
403
+
404
+ self.kv.delete(meta_scope, "meta")
405
+ self.kv.delete(KV.folders, index_key)
406
+
407
+ dedup_scope = KV.obs_dedup(fp, aid)
408
+ for item in self.kv.list(dedup_scope):
409
+ if isinstance(item, dict) and item.get("id"):
410
+ self.kv.delete(dedup_scope, item["id"])
411
+
412
+ if self.events and self.events.on_folder_deleted:
413
+ for cb in self.events.on_folder_deleted:
414
+ try:
415
+ cb(fp, aid)
416
+ except Exception as ex:
417
+ print(
418
+ f"[observation_store] Error in on_folder_deleted callback: {ex}"
419
+ )
420
+
421
+ if session_id and obs_ids:
422
+ for oid in obs_ids:
423
+ base_oid = oid.replace(":raw", "")
424
+ obs = self.kv.get(KV.observations(session_id), base_oid)
425
+ raw_obs = self.kv.get(KV.observations(session_id), f"{base_oid}:raw")
426
+
427
+ self.kv.delete(KV.observations(session_id), base_oid)
428
+ self.kv.delete(KV.observations(session_id), f"{base_oid}:raw")
429
+ self.kv.delete(KV.obs_lookup, base_oid)
430
+
431
+ for o in (obs, raw_obs):
432
+ if o and isinstance(o, dict):
433
+ img = o.get("imageData") or o.get("imageRef")
434
+ if img:
435
+ refs = self.kv.get(KV.imageRefs, img) or 0
436
+ if refs > 0:
437
+ self.kv.set(KV.imageRefs, img, refs - 1)
438
+
439
+ if self.search_service:
440
+ self.search_service.remove(base_oid)
441
+ self.search_service.remove(f"{base_oid}:raw")
442
+ deleted_obs_ids.append(oid)
443
+ deleted += 1
444
+
445
+ if session_id and not obs_ids and not memory_id and not folder_path_raw:
446
+ obs_list = self.kv.list(KV.observations(session_id))
447
+ for obs in obs_list:
448
+ self.kv.delete(KV.observations(session_id), obs["id"])
449
+ self.kv.delete(KV.obs_lookup, obs["id"])
450
+ if isinstance(obs, dict):
451
+ img = obs.get("imageData") or obs.get("imageRef")
452
+ if img:
453
+ refs = self.kv.get(KV.imageRefs, img) or 0
454
+ if refs > 0:
455
+ self.kv.set(KV.imageRefs, img, refs - 1)
456
+ if self.search_service:
457
+ self.search_service.remove(obs["id"])
458
+ deleted_obs_ids.append(obs["id"])
459
+ deleted += 1
460
+ self.kv.delete(KV.sessions, session_id)
461
+ self.kv.delete(KV.summaries, session_id)
462
+ deleted += 2
463
+
464
+ if deleted > 0 and self.search_service:
465
+ self.search_service.schedule_persist()
466
+
467
+ return {"success": True, "deleted": deleted}
468
+
469
+ def timeline(
470
+ self,
471
+ limit: int = 100,
472
+ folder_path: Optional[str] = None,
473
+ agent_id: Optional[str] = None,
474
+ before: Optional[str] = None,
475
+ after: Optional[str] = None,
476
+ ) -> List[Dict[str, Any]]:
477
+ """Return observations sorted by timestamp descending."""
478
+ index_entries = self.kv.list(KV.folders)
479
+
480
+ if folder_path is not None:
481
+ index_entries = [
482
+ e for e in index_entries if e.get("folderPath") == folder_path
483
+ ]
484
+
485
+ if agent_id is not None:
486
+ index_entries = [e for e in index_entries if e.get("agentId") == agent_id]
487
+
488
+ all_obs: List[Dict[str, Any]] = []
489
+
490
+ for entry in index_entries:
491
+ fp = entry.get("folderPath", "")
492
+ aid = entry.get("agentId", "")
493
+ if not fp or not aid:
494
+ continue
495
+
496
+ obs_scope = KV.folder_obs(fp, aid)
497
+ obs_list = self.kv.list(obs_scope)
498
+
499
+ if before is not None:
500
+ obs_list = [o for o in obs_list if o.get("timestamp", "") < before]
501
+
502
+ if after is not None:
503
+ obs_list = [o for o in obs_list if o.get("timestamp", "") > after]
504
+
505
+ all_obs.extend(obs_list)
506
+
507
+ all_obs.sort(key=lambda o: o.get("timestamp", ""), reverse=True)
508
+ return all_obs[:limit]
509
+
510
+ def backfill_lookup(self) -> None:
511
+ """Ensure every folder observation has an entry in KV.obs_lookup."""
512
+ folders = self.kv.list(KV.folders)
513
+ if not folders:
514
+ return
515
+
516
+ for entry in folders:
517
+ fp = entry.get("folderPath")
518
+ aid = entry.get("agentId")
519
+ if not fp or not aid:
520
+ continue
521
+ obs_list = self.kv.list(KV.folder_obs(fp, aid))
522
+ for obs in obs_list:
523
+ oid = obs.get("id")
524
+ if oid and not self.kv.get(KV.obs_lookup, oid):
525
+ self.kv.set(KV.obs_lookup, oid, {"folderPath": fp, "agentId": aid})
526
+
527
+ def rebuild_index(self) -> int:
528
+ """Clear and rebuild the search index from all stored observations."""
529
+ if self.search_service:
530
+ self.search_service.bm25.clear()
531
+ if self.search_service.vector:
532
+ self.search_service.vector.clear()
533
+
534
+ total_indexed = 0
535
+
536
+ # Folder-based observations
537
+ folder_pairs = self.kv.list(KV.folders)
538
+ for entry in folder_pairs:
539
+ fp = entry.get("folderPath")
540
+ aid = entry.get("agentId")
541
+ if not fp or not aid:
542
+ continue
543
+ obs_list = self.kv.list(KV.folder_obs(fp, aid))
544
+ for obs in obs_list:
545
+ oid = obs.get("id")
546
+ if not oid:
547
+ continue
548
+ self.kv.set(KV.obs_lookup, oid, {"folderPath": fp, "agentId": aid})
549
+ if self.search_service:
550
+ self.search_service.index(obs)
551
+ total_indexed += 1
552
+
553
+ # Global memories
554
+ memories = self.kv.list(KV.memories)
555
+ for mem in memories:
556
+ if mem.get("isLatest") is False:
557
+ continue
558
+ if not mem.get("title") or not mem.get("content"):
559
+ continue
560
+ converted = {
561
+ "id": mem["id"],
562
+ "sessionId": "memory",
563
+ "timestamp": mem.get("createdAt", ""),
564
+ "title": mem["title"],
565
+ "text": mem["content"],
566
+ "type": mem.get("type", "fact"),
567
+ "agentId": mem.get("agentId", ""),
568
+ }
569
+ if self.search_service:
570
+ self.search_service.index(converted)
571
+ total_indexed += 1
572
+
573
+ if self.search_service and total_indexed > 0:
574
+ self.search_service.schedule_persist()
575
+
576
+ return total_indexed
@@ -0,0 +1,34 @@
1
+ """Privacy and data-scrubbing helpers. Zero dependencies on other core modules."""
2
+
3
+ import re
4
+
5
+ PRIVATE_TAG_RE = re.compile(r"<private>[\s\S]*?</private>", re.IGNORECASE)
6
+
7
+ SECRET_PATTERN_SOURCES = [
8
+ re.compile(
9
+ r'(?:api[_-]?key|secret|token|password|credential|auth)[\s]*[=:]\s*["\']?[A-Za-z0-9_\-/.+]{20,}["\']?',
10
+ re.IGNORECASE,
11
+ ),
12
+ re.compile(r"Bearer\s+[A-Za-z0-9._\-+/=]{20,}", re.IGNORECASE),
13
+ re.compile(r"sk-proj-[A-Za-z0-9\-_]{20,}", re.IGNORECASE),
14
+ re.compile(r"(?:sk|pk|rk|ak)-[A-Za-z0-9][A-Za-z0-9\-_]{19,}", re.IGNORECASE),
15
+ re.compile(r"sk-ant-[A-Za-z0-9\-_]{20,}", re.IGNORECASE),
16
+ re.compile(r"gh[pus]_[A-Za-z0-9]{36,}", re.IGNORECASE),
17
+ re.compile(r"github_pat_[A-Za-z0-9_]{22,}", re.IGNORECASE),
18
+ re.compile(r"xoxb-[A-Za-z0-9\-]+", re.IGNORECASE),
19
+ re.compile(r"AKIA[0-9A-Z]{16}", re.IGNORECASE),
20
+ re.compile(r"AIza[A-Za-z0-9\-_]{35}", re.IGNORECASE),
21
+ re.compile(
22
+ r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}", re.IGNORECASE
23
+ ),
24
+ re.compile(r"npm_[A-Za-z0-9]{36}", re.IGNORECASE),
25
+ re.compile(r"glpat-[A-Za-z0-9\-_]{20,}", re.IGNORECASE),
26
+ re.compile(r"dop_v1_[A-Za-z0-9]{64}", re.IGNORECASE),
27
+ ]
28
+
29
+
30
+ def strip_private_data(input_str: str) -> str:
31
+ result = PRIVATE_TAG_RE.sub("[REDACTED]", input_str)
32
+ for pattern in SECRET_PATTERN_SOURCES:
33
+ result = pattern.sub("[REDACTED_SECRET]", result)
34
+ return result