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,382 @@
1
+ """Session store — observe, list/get/create/end sessions, timeline, folder operations."""
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from ..db import StateKV
9
+ from ..storage.paths import generate_id
10
+ from .config import commit_if_enabled, get_agent_id
11
+ from .image_store import extract_image, save_image_to_disk
12
+ from .infer import build_synthetic_compression, vector_index_add_guarded
13
+ from .kv_scopes import KV
14
+ from .privacy import strip_private_data
15
+
16
+
17
+ def auto_complete_old_active_sessions(
18
+ kv: StateKV,
19
+ current_session_id: str,
20
+ project: Optional[str] = None,
21
+ agent_id: Optional[str] = None,
22
+ ) -> int:
23
+ sessions = kv.list(KV.sessions)
24
+ count = 0
25
+ now = (
26
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
27
+ )
28
+ for s in sessions:
29
+ if s.get("id") != current_session_id and s.get("status") == "active":
30
+ if project and s.get("project") != project:
31
+ continue
32
+ if agent_id and s.get("agentId") != agent_id:
33
+ continue
34
+ s["status"] = "completed"
35
+ if "endedAt" not in s:
36
+ s["endedAt"] = now
37
+ s["updatedAt"] = now
38
+ kv.set(KV.sessions, s["id"], s)
39
+ count += 1
40
+ if count > 0:
41
+ print(f"[session] Auto-completed {count} dangling active sessions.")
42
+ return count
43
+
44
+
45
+ def _get_observation_store(kv: StateKV):
46
+ from .. import app as app_module
47
+ from .. import legacy as _legacy
48
+
49
+ if getattr(app_module, "observation_store", None) is not None:
50
+ return app_module.observation_store
51
+ from .observation_store import ObservationStore
52
+
53
+ return ObservationStore(kv, search_service=_legacy._search_service)
54
+
55
+
56
+ def observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
57
+ from .. import legacy as _legacy
58
+
59
+ session_id = payload.get("sessionId")
60
+ hook_type = payload.get("hookType")
61
+ timestamp = payload.get("timestamp")
62
+
63
+ if not session_id or not hook_type or not timestamp:
64
+ raise ValueError(
65
+ "Invalid payload: sessionId, hookType, and timestamp are required"
66
+ )
67
+
68
+ obs_id = generate_id("obs")
69
+ sanitized_data = payload.get("data")
70
+ try:
71
+ json_str = json.dumps(payload.get("data"))
72
+ sanitized = strip_private_data(json_str)
73
+ sanitized_data = json.loads(sanitized)
74
+ except Exception:
75
+ sanitized_data = strip_private_data(str(payload.get("data")))
76
+
77
+ raw = {
78
+ "id": obs_id,
79
+ "sessionId": session_id,
80
+ "timestamp": timestamp,
81
+ "hookType": hook_type,
82
+ "raw": sanitized_data,
83
+ }
84
+
85
+ extracted_img = extract_image(sanitized_data)
86
+ if isinstance(sanitized_data, dict):
87
+ if hook_type in ("post_tool_use", "post_tool_failure"):
88
+ raw["toolName"] = sanitized_data.get("tool_name")
89
+ raw["toolInput"] = sanitized_data.get("tool_input")
90
+ raw["toolOutput"] = sanitized_data.get("tool_output") or sanitized_data.get(
91
+ "error"
92
+ )
93
+ if hook_type == "prompt_submit":
94
+ raw["userPrompt"] = sanitized_data.get("prompt")
95
+ if extracted_img:
96
+ raw["modality"] = (
97
+ "mixed"
98
+ if (
99
+ raw.get("toolInput")
100
+ or raw.get("toolOutput")
101
+ or raw.get("userPrompt")
102
+ )
103
+ else "image"
104
+ )
105
+ elif isinstance(sanitized_data, str) and extracted_img:
106
+ raw["modality"] = "image"
107
+
108
+ max_obs = int(os.getenv("MAX_OBS_PER_SESSION", "500"))
109
+ if max_obs > 0:
110
+ existing = kv.list(KV.observations(session_id))
111
+ actual_obs_count = sum(
112
+ 1 for o in existing if not str(o.get("id", "")).endswith(":raw")
113
+ )
114
+ if actual_obs_count >= max_obs:
115
+ raise ValueError(f"Session observation limit reached ({max_obs})")
116
+
117
+ existing_session = kv.get(KV.sessions, session_id)
118
+ inherited_agent_id = (
119
+ existing_session.get("agentId") if existing_session else get_agent_id()
120
+ )
121
+ if inherited_agent_id:
122
+ raw["agentId"] = inherited_agent_id
123
+
124
+ if extracted_img and (
125
+ extracted_img.startswith("data:image/")
126
+ or extracted_img.startswith("iVBORw0KGgo")
127
+ or extracted_img.startswith("/9j/")
128
+ ):
129
+ try:
130
+ file_path, bytes_written = save_image_to_disk(extracted_img)
131
+ raw["imageData"] = file_path
132
+
133
+ img_refs = kv.get(KV.imageRefs, file_path) or 0
134
+ kv.set(KV.imageRefs, file_path, img_refs + 1)
135
+ except Exception as ex:
136
+ print(f"[image store] failed: {ex}")
137
+
138
+ raw["id"] = f"{obs_id}:raw"
139
+ kv.set(KV.observations(session_id), raw["id"], raw)
140
+
141
+ _legacy.broadcast_stream(
142
+ {
143
+ "type": "raw_observation",
144
+ "sessionId": session_id,
145
+ "data": {"type": "raw", "observation": raw, "sessionId": session_id},
146
+ }
147
+ )
148
+
149
+ if existing_session:
150
+ updates = [
151
+ {
152
+ "type": "set",
153
+ "path": "updatedAt",
154
+ "value": datetime.datetime.now(datetime.timezone.utc)
155
+ .isoformat()
156
+ .replace("+00:00", "Z"),
157
+ },
158
+ {
159
+ "type": "set",
160
+ "path": "observationCount",
161
+ "value": (existing_session.get("observationCount") or 0) + 1,
162
+ },
163
+ ]
164
+ if not existing_session.get("firstPrompt") and isinstance(
165
+ raw.get("userPrompt"), str
166
+ ):
167
+ trimmed = " ".join(raw["userPrompt"].split()).strip()
168
+ if trimmed:
169
+ updates.append(
170
+ {"type": "set", "path": "firstPrompt", "value": trimmed[:200]}
171
+ )
172
+ kv.update(KV.sessions, session_id, updates)
173
+ else:
174
+ project = payload.get("project") or "unknown"
175
+ auto_complete_old_active_sessions(
176
+ kv, session_id, project=project, agent_id=inherited_agent_id
177
+ )
178
+ cwd = payload.get("cwd") or os.getcwd()
179
+ trimmed_prompt = None
180
+ if isinstance(raw.get("userPrompt"), str):
181
+ trimmed_prompt = " ".join(raw["userPrompt"].split()).strip()[:200]
182
+ ts = (
183
+ datetime.datetime.now(datetime.timezone.utc)
184
+ .isoformat()
185
+ .replace("+00:00", "Z")
186
+ )
187
+ new_sess = {
188
+ "id": session_id,
189
+ "project": project,
190
+ "cwd": cwd,
191
+ "startedAt": payload.get("timestamp") or ts,
192
+ "updatedAt": ts,
193
+ "status": "active",
194
+ "observationCount": 1,
195
+ }
196
+ if inherited_agent_id:
197
+ new_sess["agentId"] = inherited_agent_id
198
+ if trimmed_prompt:
199
+ new_sess["firstPrompt"] = trimmed_prompt
200
+ kv.set(KV.sessions, session_id, new_sess)
201
+
202
+ raw_for_synthetic = dict(raw)
203
+ raw_for_synthetic["id"] = obs_id
204
+ synthetic = build_synthetic_compression(raw_for_synthetic)
205
+ for k in ["hookType", "raw", "toolName", "toolInput", "toolOutput", "userPrompt"]:
206
+ if k in raw_for_synthetic:
207
+ synthetic[k] = raw_for_synthetic[k]
208
+ kv.set(KV.observations(session_id), obs_id, synthetic)
209
+ if _legacy._search_service:
210
+ _legacy._search_service.bm25.add(synthetic)
211
+
212
+ comb_text = synthetic["title"] + " " + (synthetic.get("narrative") or "")
213
+ vector_index_add_guarded(
214
+ synthetic["id"],
215
+ synthetic["sessionId"],
216
+ comb_text,
217
+ {"kind": "synthetic", "logId": synthetic["id"]},
218
+ )
219
+
220
+ if _legacy._search_service:
221
+ _legacy._search_service.schedule_persist()
222
+
223
+ _legacy.broadcast_stream(
224
+ {
225
+ "type": "compressed_observation",
226
+ "sessionId": session_id,
227
+ "data": {
228
+ "type": "compressed",
229
+ "observation": synthetic,
230
+ "sessionId": session_id,
231
+ },
232
+ }
233
+ )
234
+
235
+ commit_if_enabled(
236
+ kv,
237
+ f"Observe: {synthetic.get('title', 'observation')} in session {session_id[:8]}",
238
+ synthetic.get("agentId"),
239
+ )
240
+
241
+ return {"observationId": obs_id}
242
+
243
+
244
+ def folder_observe(kv: StateKV, payload: Dict[str, Any]) -> Dict[str, Any]:
245
+ """Ingest a new observation scoped to a (folder_path, agent_id) pair."""
246
+ store = _get_observation_store(kv)
247
+ return store.ingest(payload)
248
+
249
+
250
+ def dedup_folder_observations(
251
+ kv: StateKV,
252
+ folder_path_raw: Optional[str],
253
+ agent_id_raw: Optional[str],
254
+ ) -> Dict[str, Any]:
255
+ """Remove duplicate observations from one or all (folder, agent) pairs."""
256
+ store = _get_observation_store(kv)
257
+ return store.dedup(folder_path_raw, agent_id_raw)
258
+
259
+
260
+ def folder_search(
261
+ kv: StateKV,
262
+ query: str,
263
+ limit: int = 20,
264
+ folder_path: Optional[str] = None,
265
+ agent_id: Optional[str] = None,
266
+ ) -> List[Dict[str, Any]]:
267
+ """Search across all folder observations (and global memories) using BM25 + vector hybrid search."""
268
+ from .. import legacy as _legacy
269
+
270
+ if not query or not query.strip():
271
+ return []
272
+ if _legacy._search_service is None:
273
+ return []
274
+ return _legacy._search_service.search(
275
+ query=query, limit=limit, folder_path=folder_path, agent_id=agent_id, kv=kv
276
+ )
277
+
278
+
279
+ def folder_timeline(
280
+ kv: StateKV,
281
+ limit: int = 100,
282
+ folder_path: Optional[str] = None,
283
+ agent_id: Optional[str] = None,
284
+ before: Optional[str] = None,
285
+ after: Optional[str] = None,
286
+ ) -> List[Dict[str, Any]]:
287
+ """Return a folder activity feed — observations sorted by timestamp descending."""
288
+ store = _get_observation_store(kv)
289
+ return store.timeline(
290
+ limit=limit,
291
+ folder_path=folder_path,
292
+ agent_id=agent_id,
293
+ before=before,
294
+ after=after,
295
+ )
296
+
297
+
298
+ def list_sessions(kv: StateKV) -> List[Dict[str, Any]]:
299
+ sessions = kv.list(KV.sessions)
300
+ for s in sessions:
301
+ sid = s.get("id")
302
+ if sid:
303
+ summary = kv.get(KV.summaries, sid)
304
+ if summary:
305
+ s["title"] = summary.get("title")
306
+ s["summary"] = summary.get("narrative")
307
+ sessions.sort(key=lambda s: s.get("startedAt", ""), reverse=True)
308
+ return sessions
309
+
310
+
311
+ def get_session(kv: StateKV, session_id: str) -> Optional[Dict[str, Any]]:
312
+ s = kv.get(KV.sessions, session_id)
313
+ if s:
314
+ summary = kv.get(KV.summaries, session_id)
315
+ if summary:
316
+ s["title"] = summary.get("title")
317
+ s["summary"] = summary.get("narrative")
318
+ return s
319
+
320
+
321
+ def create_session(kv: StateKV, session: Dict[str, Any]) -> Dict[str, Any]:
322
+ auto_complete_old_active_sessions(
323
+ kv,
324
+ session["id"],
325
+ project=session.get("project"),
326
+ agent_id=session.get("agentId"),
327
+ )
328
+ kv.set(KV.sessions, session["id"], session)
329
+ return session
330
+
331
+
332
+ def end_session(kv: StateKV, session_id: str) -> bool:
333
+ now = (
334
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
335
+ )
336
+ kv.update(
337
+ KV.sessions,
338
+ session_id,
339
+ [
340
+ {"type": "set", "path": "endedAt", "value": now},
341
+ {"type": "set", "path": "status", "value": "completed"},
342
+ ],
343
+ )
344
+ return True
345
+
346
+
347
+ def timeline(kv: StateKV, data: Dict[str, Any]) -> Dict[str, Any]:
348
+ anchor = data.get("anchor")
349
+ project = data.get("project")
350
+ session_id = data.get("sessionId")
351
+ before = data.get("before") or 10
352
+ after = data.get("after") or 10
353
+
354
+ sessions = kv.list(KV.sessions)
355
+ if session_id:
356
+ sessions = [s for s in sessions if s.get("id") == session_id]
357
+ elif project:
358
+ sessions = [s for s in sessions if s.get("project") == project]
359
+
360
+ all_obs = []
361
+ for s in sessions:
362
+ all_obs.extend(kv.list(KV.observations(s["id"])))
363
+
364
+ all_obs.sort(key=lambda x: x.get("timestamp", ""))
365
+
366
+ anchor_idx = -1
367
+ for idx, obs in enumerate(all_obs):
368
+ if obs["id"] == anchor or obs.get("timestamp", "") >= (anchor or ""):
369
+ anchor_idx = idx
370
+ break
371
+
372
+ if anchor_idx == -1:
373
+ anchor_idx = len(all_obs) // 2
374
+
375
+ start = max(0, anchor_idx - before)
376
+ end = min(len(all_obs), anchor_idx + after + 1)
377
+
378
+ return {
379
+ "success": True,
380
+ "observations": all_obs[start:end],
381
+ "anchorIndex": anchor_idx - start,
382
+ }