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.
- agentcache/__init__.py +29 -0
- agentcache/app.py +312 -0
- agentcache/cli.py +346 -0
- agentcache/connect.py +724 -0
- agentcache/core/__init__.py +19 -0
- agentcache/core/audit_log.py +104 -0
- agentcache/core/config.py +51 -0
- agentcache/core/context_builder.py +209 -0
- agentcache/core/graph.py +120 -0
- agentcache/core/image_store.py +111 -0
- agentcache/core/infer.py +142 -0
- agentcache/core/kv_scopes.py +86 -0
- agentcache/core/lessons.py +242 -0
- agentcache/core/llm.py +596 -0
- agentcache/core/memory_store.py +207 -0
- agentcache/core/observation_store.py +576 -0
- agentcache/core/privacy.py +34 -0
- agentcache/core/project_profile.py +625 -0
- agentcache/core/search_service.py +444 -0
- agentcache/core/session_store.py +382 -0
- agentcache/core/slots.py +504 -0
- agentcache/db.py +359 -0
- agentcache/import_data.py +86 -0
- agentcache/legacy.py +94 -0
- agentcache/mcp_stdio.py +141 -0
- agentcache/py.typed +0 -0
- agentcache/replay_import.py +680 -0
- agentcache/routes/__init__.py +29 -0
- agentcache/routes/_deps.py +46 -0
- agentcache/routes/auth.py +68 -0
- agentcache/routes/graph.py +81 -0
- agentcache/routes/health.py +149 -0
- agentcache/routes/mcp.py +614 -0
- agentcache/routes/memories.py +116 -0
- agentcache/routes/migration.py +32 -0
- agentcache/routes/observations.py +253 -0
- agentcache/routes/search.py +80 -0
- agentcache/search.py +935 -0
- agentcache/storage/__init__.py +29 -0
- agentcache/storage/images.py +102 -0
- agentcache/storage/paths.py +116 -0
- agentcache/storage/scopes.py +9 -0
- agentcache/viewer/favicon.svg +1 -0
- agentcache/viewer/index.html +4235 -0
- agentcache/viewer_helpers.py +61 -0
- agentcache/workers.py +192 -0
- agentcache_core-0.9.9.dist-info/METADATA +194 -0
- agentcache_core-0.9.9.dist-info/RECORD +52 -0
- agentcache_core-0.9.9.dist-info/WHEEL +5 -0
- agentcache_core-0.9.9.dist-info/entry_points.txt +2 -0
- agentcache_core-0.9.9.dist-info/licenses/LICENSE +190 -0
- 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
|
+
}
|