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,680 @@
1
+ import datetime
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Any, Dict, List, Tuple
7
+
8
+ # Constants
9
+ MAX_FILES_DEFAULT = 200
10
+ MAX_FILES_UPPER_BOUND = 1000
11
+
12
+ SENSITIVE_PATH_PATTERNS = [
13
+ re.compile(r"(^|[\\/_.-])secret([\\/_.-]|s?$)", re.IGNORECASE),
14
+ re.compile(r"(^|[\\/_.-])credentials?([\\/_.-]|$)", re.IGNORECASE),
15
+ re.compile(r"(^|[\\/_.-])private[_-]?key([\\/_.-]|$)", re.IGNORECASE),
16
+ re.compile(r"(^|[\\/])\.env(\.[\w-]+)?$", re.IGNORECASE),
17
+ re.compile(r"(^|[\\/_.-])id_rsa([\\/_.-]|$)", re.IGNORECASE),
18
+ re.compile(r"(^|[\\/])auth[_-]?token([\\/_.-]|$)", re.IGNORECASE),
19
+ re.compile(r"(^|[\\/])bearer[_-]?token([\\/_.-]|$)", re.IGNORECASE),
20
+ re.compile(r"(^|[\\/])access[_-]?token([\\/_.-]|$)", re.IGNORECASE),
21
+ re.compile(r"(^|[\\/])api[_-]?token([\\/_.-]|$)", re.IGNORECASE),
22
+ ]
23
+
24
+ LESSON_PATTERNS = [
25
+ re.compile(
26
+ r"\b(always|never|don'?t|do not|make sure|remember to|note:|caveat:|warning:)\b[^.\n]{10,200}[.!\n]",
27
+ re.IGNORECASE,
28
+ ),
29
+ re.compile(r"\b(prefer|avoid)\s[^.\n]{10,200}[.!\n]", re.IGNORECASE),
30
+ ]
31
+
32
+
33
+ def generate_id(prefix: str) -> str:
34
+ import uuid
35
+
36
+ return f"{prefix}_{uuid.uuid4().hex}"
37
+
38
+
39
+ def fingerprint_id(prefix: str, content: str) -> str:
40
+ # Hash content for stable ID (similar to TS fingerprintId)
41
+ h = hashlib.sha256(content.strip().encode("utf-8")).hexdigest()
42
+ return f"{prefix}_{h[:32]}"
43
+
44
+
45
+ def is_sensitive(path: str) -> bool:
46
+ return any(pattern.search(path) for pattern in SENSITIVE_PATH_PATTERNS)
47
+
48
+
49
+ def derive_project(cwd: str) -> str:
50
+ if not cwd:
51
+ return "unknown"
52
+ parts = [p for p in re.split(r"[\\/]", cwd) if p]
53
+ return parts[-1] if parts else "unknown"
54
+
55
+
56
+ def to_text(content: Any) -> str:
57
+ if isinstance(content, str):
58
+ return content
59
+ if not isinstance(content, list):
60
+ return ""
61
+ parts = []
62
+ for item in content:
63
+ if (
64
+ isinstance(item, dict)
65
+ and item.get("type") == "text"
66
+ and isinstance(item.get("text"), str)
67
+ ):
68
+ parts.append(item["text"])
69
+ return "\n".join(parts)
70
+
71
+
72
+ def extract_tool_uses(content: Any) -> List[Dict[str, Any]]:
73
+ if not isinstance(content, list):
74
+ return []
75
+ out = []
76
+ for item in content:
77
+ if isinstance(item, dict) and item.get("type") == "tool_use":
78
+ out.append(
79
+ {
80
+ "id": item.get("id", ""),
81
+ "name": item.get("name", "unknown"),
82
+ "input": item.get("input"),
83
+ }
84
+ )
85
+ return out
86
+
87
+
88
+ def extract_tool_results(content: Any) -> List[Dict[str, Any]]:
89
+ if not isinstance(content, list):
90
+ return []
91
+ out = []
92
+ for item in content:
93
+ if isinstance(item, dict) and item.get("type") == "tool_result":
94
+ out.append(
95
+ {
96
+ "toolUseId": item.get("tool_use_id", ""),
97
+ "output": item.get("content"),
98
+ "isError": item.get("is_error") is True,
99
+ }
100
+ )
101
+ return out
102
+
103
+
104
+ def parse_jsonl_text(text: str, fallback_session_id: str = None) -> Dict[str, Any]:
105
+ lines = [line_str for line_str in text.split("\n") if line_str.strip()]
106
+ entries = []
107
+ for line in lines:
108
+ try:
109
+ parsed = json.loads(line)
110
+ if isinstance(parsed, dict):
111
+ entries.append(parsed)
112
+ except Exception:
113
+ pass
114
+
115
+ session_id = ""
116
+ cwd = ""
117
+ first_ts = ""
118
+ last_ts = ""
119
+ observations = []
120
+
121
+ for entry in entries:
122
+ if entry.get("sessionId") and not session_id:
123
+ session_id = entry["sessionId"]
124
+ if entry.get("cwd") and not cwd:
125
+ cwd = entry["cwd"]
126
+
127
+ ts = entry.get("timestamp") or datetime.datetime.now(
128
+ datetime.timezone.utc
129
+ ).isoformat().replace("+00:00", "Z")
130
+ if not first_ts:
131
+ first_ts = ts
132
+ last_ts = ts
133
+
134
+ msg = entry.get("message") or {}
135
+ role = msg.get("role")
136
+ content = msg.get("content")
137
+
138
+ if entry.get("type") == "user" and role == "user":
139
+ tool_results = extract_tool_results(content)
140
+ if tool_results:
141
+ for result in tool_results:
142
+ observations.append(
143
+ {
144
+ "id": generate_id("obs"),
145
+ "sessionId": session_id or "imported",
146
+ "timestamp": ts,
147
+ "hookType": "post_tool_failure"
148
+ if result["isError"]
149
+ else "post_tool_use",
150
+ "toolName": None,
151
+ "toolInput": {"toolUseId": result["toolUseId"]},
152
+ "toolOutput": result["output"],
153
+ "raw": entry,
154
+ }
155
+ )
156
+ else:
157
+ txt = to_text(content)
158
+ if txt.strip():
159
+ observations.append(
160
+ {
161
+ "id": generate_id("obs"),
162
+ "sessionId": session_id or "imported",
163
+ "timestamp": ts,
164
+ "hookType": "prompt_submit",
165
+ "userPrompt": txt,
166
+ "raw": entry,
167
+ }
168
+ )
169
+ elif entry.get("type") == "assistant" and role == "assistant":
170
+ txt = to_text(content)
171
+ tools = extract_tool_uses(content)
172
+ if txt.strip():
173
+ observations.append(
174
+ {
175
+ "id": generate_id("obs"),
176
+ "sessionId": session_id or "imported",
177
+ "timestamp": ts,
178
+ "hookType": "stop",
179
+ "assistantResponse": txt,
180
+ "raw": entry,
181
+ }
182
+ )
183
+ for tool in tools:
184
+ observations.append(
185
+ {
186
+ "id": generate_id("obs"),
187
+ "sessionId": session_id or "imported",
188
+ "timestamp": ts,
189
+ "hookType": "pre_tool_use",
190
+ "toolName": tool["name"],
191
+ "toolInput": tool["input"],
192
+ "raw": {"toolUseId": tool["id"], "entry": entry},
193
+ }
194
+ )
195
+
196
+ effective_session_id = session_id or fallback_session_id or generate_id("sess")
197
+ for obs in observations:
198
+ if obs["sessionId"] == "imported":
199
+ obs["sessionId"] = effective_session_id
200
+
201
+ now_iso = (
202
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
203
+ )
204
+ return {
205
+ "sessionId": effective_session_id,
206
+ "project": derive_project(cwd),
207
+ "cwd": cwd or os.getcwd(),
208
+ "startedAt": first_ts or now_iso,
209
+ "endedAt": last_ts or now_iso,
210
+ "observations": observations,
211
+ }
212
+
213
+
214
+ def derive_crystal_and_lessons(
215
+ kv,
216
+ session_id: str,
217
+ project: str,
218
+ raw_obs: List[Dict[str, Any]],
219
+ compressed: List[Dict[str, Any]],
220
+ first_prompt: str = None,
221
+ ) -> None:
222
+ from .core import KV
223
+
224
+ if not raw_obs:
225
+ return
226
+ created_at = (
227
+ datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
228
+ )
229
+
230
+ files = set()
231
+ tools = set()
232
+ for c in compressed:
233
+ for f in c.get("files", []):
234
+ files.add(f)
235
+ if c.get("type") and c.get("type") != "conversation" and c.get("title"):
236
+ tools.add(c["title"])
237
+
238
+ assistant_texts = []
239
+ user_prompts = []
240
+ for r in raw_obs:
241
+ if (
242
+ isinstance(r.get("assistantResponse"), str)
243
+ and r["assistantResponse"].strip()
244
+ ):
245
+ assistant_texts.append(r["assistantResponse"])
246
+ if isinstance(r.get("userPrompt"), str) and r["userPrompt"].strip():
247
+ user_prompts.append(r["userPrompt"])
248
+
249
+ lesson_matches = {}
250
+ for text in (assistant_texts + user_prompts)[:200]:
251
+ for pat in LESSON_PATTERNS:
252
+ for m in pat.finditer(text):
253
+ if len(lesson_matches) >= 40:
254
+ break
255
+ snippet = re.sub(r"\s+", " ", m.group(0)).strip()
256
+ if 20 <= len(snippet) <= 220:
257
+ key = snippet.lower()
258
+ if key not in lesson_matches:
259
+ lesson_matches[key] = snippet
260
+
261
+ lesson_entries = list(lesson_matches.values())[:20]
262
+ lesson_ids = []
263
+ for content in lesson_entries:
264
+ lesson_id = fingerprint_id("lesson", content.lower())
265
+ try:
266
+ existing = kv.get(KV.lessons, lesson_id)
267
+ if existing:
268
+ existing_sources = existing.get("sourceIds", [])
269
+ merged_sources = (
270
+ existing_sources
271
+ if session_id in existing_sources
272
+ else (existing_sources + [session_id])
273
+ )
274
+ existing_tags = existing.get("tags", [])
275
+ merged_tags = (
276
+ existing_tags
277
+ if "auto-import" in existing_tags
278
+ else (existing_tags + ["auto-import"])
279
+ )
280
+
281
+ existing["sourceIds"] = merged_sources
282
+ existing["tags"] = merged_tags
283
+ existing["reinforcements"] = existing.get("reinforcements", 0) + 1
284
+ existing["updatedAt"] = created_at
285
+ existing["lastReinforcedAt"] = created_at
286
+ kv.set(KV.lessons, lesson_id, existing)
287
+ else:
288
+ lesson = {
289
+ "id": lesson_id,
290
+ "content": content,
291
+ "context": first_prompt or project,
292
+ "confidence": 0.4,
293
+ "reinforcements": 0,
294
+ "source": "consolidation",
295
+ "sourceIds": [session_id],
296
+ "project": project,
297
+ "tags": ["auto-import"],
298
+ "createdAt": created_at,
299
+ "updatedAt": created_at,
300
+ "decayRate": 0.05,
301
+ }
302
+ kv.set(KV.lessons, lesson_id, lesson)
303
+ lesson_ids.append(lesson_id)
304
+ except Exception:
305
+ pass
306
+
307
+ crystal_id = fingerprint_id("crystal", session_id)
308
+ if first_prompt:
309
+ narrative_preview = first_prompt[:300]
310
+ else:
311
+ previews = []
312
+ for c in compressed[:5]:
313
+ p = c.get("narrative") or c.get("title")
314
+ if p:
315
+ previews.append(p)
316
+ narrative_preview = (" · ".join(previews))[:300]
317
+
318
+ try:
319
+ existing_crystal = kv.get(KV.crystals, crystal_id) or {}
320
+ crystal = {
321
+ "id": crystal_id,
322
+ "narrative": narrative_preview
323
+ or f"Session {session_id[:12]} ({len(raw_obs)} observations)",
324
+ "keyOutcomes": list(tools)[:8],
325
+ "filesAffected": list(files)[:20],
326
+ "lessons": lesson_ids,
327
+ "sourceActionIds": existing_crystal.get("sourceActionIds", []),
328
+ "sessionId": session_id,
329
+ "project": project,
330
+ "createdAt": existing_crystal.get("createdAt", created_at),
331
+ }
332
+ kv.set(KV.crystals, crystal_id, crystal)
333
+ except Exception:
334
+ pass
335
+
336
+
337
+ def find_jsonl_files(root: str, limit=200) -> Tuple[List[str], bool, int, bool]:
338
+ out = []
339
+ discovered = 0
340
+ walked = 0
341
+ traversal_cap = max(limit * 50, 50000)
342
+
343
+ for dirpath, dirnames, filenames in os.walk(root):
344
+ if walked >= traversal_cap:
345
+ break
346
+
347
+ # skip symlinks or hidden directories
348
+ dirnames[:] = [
349
+ d
350
+ for d in dirnames
351
+ if not d.startswith(".") and not os.path.islink(os.path.join(dirpath, d))
352
+ ]
353
+
354
+ for name in filenames:
355
+ walked += 1
356
+ if walked >= traversal_cap:
357
+ break
358
+ if name.endswith(".jsonl"):
359
+ full = os.path.join(dirpath, name)
360
+ if not os.path.islink(full):
361
+ discovered += 1
362
+ if len(out) < limit:
363
+ out.append(full)
364
+
365
+ traversal_capped = walked >= traversal_cap
366
+ truncated = discovered > len(out) or traversal_capped
367
+ return out, truncated, discovered, traversal_capped
368
+
369
+
370
+ def import_jsonl_data(kv, path: str = None, max_files: int = None) -> Dict[str, Any]:
371
+ from .core import KV
372
+ from .legacy import build_synthetic_compression
373
+
374
+ default_root = os.path.expanduser(os.path.join("~", ".claude", "projects"))
375
+ raw_path = path or default_root
376
+
377
+ expanded = os.path.expanduser(raw_path)
378
+ abs_path = os.path.abspath(expanded)
379
+
380
+ if is_sensitive(abs_path):
381
+ return {"success": False, "error": "refusing to process sensitive-looking path"}
382
+ if os.path.islink(abs_path):
383
+ return {"success": False, "error": "symlinks are not supported"}
384
+ if not os.path.exists(abs_path):
385
+ return {"success": False, "error": "path not found"}
386
+
387
+ limit = (
388
+ max_files if isinstance(max_files, int) and max_files > 0 else MAX_FILES_DEFAULT
389
+ )
390
+ limit = min(limit, MAX_FILES_UPPER_BOUND)
391
+
392
+ files = []
393
+ truncated = False
394
+ discovered = 0
395
+ traversal_capped = False
396
+
397
+ if os.path.isdir(abs_path):
398
+ files, truncated, discovered, traversal_capped = find_jsonl_files(
399
+ abs_path, limit
400
+ )
401
+ elif os.path.isfile(abs_path) and abs_path.endswith(".jsonl"):
402
+ files = [abs_path]
403
+ discovered = 1
404
+ else:
405
+ return {"success": False, "error": "path must be a .jsonl file or directory"}
406
+
407
+ if not files:
408
+ return {
409
+ "success": True,
410
+ "imported": 0,
411
+ "sessionIds": [],
412
+ "observations": 0,
413
+ "discovered": discovered,
414
+ "truncated": truncated,
415
+ "traversalCapped": traversal_capped,
416
+ "maxFiles": limit,
417
+ "maxFilesUpperBound": MAX_FILES_UPPER_BOUND,
418
+ }
419
+
420
+ session_ids = []
421
+ observation_count = 0
422
+
423
+ for file in files:
424
+ if is_sensitive(file):
425
+ continue
426
+ if os.path.islink(file):
427
+ continue
428
+
429
+ try:
430
+ with open(file, "r", encoding="utf-8", errors="ignore") as f:
431
+ text = f.read()
432
+ except Exception as e:
433
+ print(f"[import-jsonl] Failed to read {file}: {e}")
434
+ continue
435
+
436
+ parsed = parse_jsonl_text(text, generate_id("sess"))
437
+ if not parsed["observations"]:
438
+ continue
439
+
440
+ first_prompt_obs = None
441
+ for o in parsed["observations"]:
442
+ if isinstance(o.get("userPrompt"), str) and o["userPrompt"].strip():
443
+ first_prompt_obs = o
444
+ break
445
+
446
+ first_prompt = None
447
+ if first_prompt_obs:
448
+ first_prompt = re.sub(r"\s+", " ", first_prompt_obs["userPrompt"]).strip()[
449
+ :200
450
+ ]
451
+
452
+ existing = kv.get(KV.sessions, parsed["sessionId"])
453
+ if existing:
454
+ existing["observationCount"] = existing.get("observationCount", 0) + len(
455
+ parsed["observations"]
456
+ )
457
+ if parsed["endedAt"] > existing.get("endedAt", ""):
458
+ existing["endedAt"] = parsed["endedAt"]
459
+ if existing.get("status") == "active":
460
+ existing["status"] = "completed"
461
+
462
+ existing_tags = existing.get("tags", [])
463
+ if "jsonl-import" not in existing_tags:
464
+ existing["tags"] = existing_tags + ["jsonl-import"]
465
+ if not existing.get("firstPrompt") and first_prompt:
466
+ existing["firstPrompt"] = first_prompt
467
+ if not existing.get("id"):
468
+ existing["id"] = parsed["sessionId"]
469
+
470
+ kv.set(KV.sessions, parsed["sessionId"], existing)
471
+ else:
472
+ session = {
473
+ "id": parsed["sessionId"],
474
+ "project": parsed["project"],
475
+ "cwd": parsed["cwd"],
476
+ "startedAt": parsed["startedAt"],
477
+ "endedAt": parsed["endedAt"],
478
+ "status": "completed",
479
+ "observationCount": len(parsed["observations"]),
480
+ "tags": ["jsonl-import"],
481
+ "firstPrompt": first_prompt,
482
+ }
483
+ kv.set(KV.sessions, session["id"], session)
484
+
485
+ compressed = []
486
+ for obs in parsed["observations"]:
487
+ synthetic = build_synthetic_compression(obs)
488
+ compressed.append(synthetic)
489
+ kv.set(KV.observations(parsed["sessionId"]), obs["id"], synthetic)
490
+
491
+ observation_count += len(parsed["observations"])
492
+ session_ids.append(parsed["sessionId"])
493
+
494
+ derive_crystal_and_lessons(
495
+ kv,
496
+ parsed["sessionId"],
497
+ parsed["project"],
498
+ parsed["observations"],
499
+ compressed,
500
+ first_prompt,
501
+ )
502
+
503
+ # Audit trail
504
+ try:
505
+ from .legacy import log_audit
506
+
507
+ log_audit(
508
+ kv,
509
+ "import",
510
+ "mem::replay::import-jsonl",
511
+ f"Imported {len(session_ids)} sessions: {','.join(session_ids[:3])}...",
512
+ )
513
+ except Exception:
514
+ pass
515
+
516
+ # Dolt commit if enabled
517
+ try:
518
+ kv.commit_version(
519
+ f"Import {len(session_ids)} Claude Code sessions from JSONL", "system"
520
+ )
521
+ except Exception:
522
+ pass
523
+
524
+ return {
525
+ "success": True,
526
+ "imported": len(files),
527
+ "sessionIds": session_ids,
528
+ "observations": observation_count,
529
+ "discovered": discovered,
530
+ "truncated": truncated,
531
+ "traversalCapped": traversal_capped,
532
+ "maxFiles": limit,
533
+ "maxFilesUpperBound": MAX_FILES_UPPER_BOUND,
534
+ }
535
+
536
+
537
+ def kind_from_hook(obs: Dict[str, Any]) -> str:
538
+ ht = obs.get("hookType")
539
+ if ht == "session_start":
540
+ return "session_start"
541
+ elif ht == "session_end":
542
+ return "session_end"
543
+ elif ht == "prompt_submit":
544
+ return "prompt"
545
+ elif ht == "stop":
546
+ return "response" if obs.get("assistantResponse") else "hook"
547
+ elif ht == "pre_tool_use":
548
+ return "tool_call"
549
+ elif ht == "post_tool_use":
550
+ return "tool_result"
551
+ elif ht == "post_tool_failure":
552
+ return "tool_error"
553
+ else:
554
+ return "hook"
555
+
556
+
557
+ def label_for(obs: Dict[str, Any], kind: str) -> str:
558
+ if kind == "prompt":
559
+ val = obs.get("userPrompt") or "User prompt"
560
+ return val[:79] + "…" if len(val) > 80 else val
561
+ elif kind == "response":
562
+ val = obs.get("assistantResponse") or "Assistant response"
563
+ return val[:79] + "…" if len(val) > 80 else val
564
+ elif kind == "tool_call":
565
+ return f"{obs.get('toolName') or 'tool'} ▸ call"
566
+ elif kind == "tool_result":
567
+ return f"{obs.get('toolName') or 'tool'} ▸ result"
568
+ elif kind == "tool_error":
569
+ return f"{obs.get('toolName') or 'tool'} ▸ error"
570
+ elif kind == "session_start":
571
+ return "Session start"
572
+ elif kind == "session_end":
573
+ return "Session end"
574
+ else:
575
+ return obs.get("hookType") or ""
576
+
577
+
578
+ def estimate_duration_ms(event: Dict[str, Any]) -> int:
579
+ body = event.get("body") or ""
580
+ tool_input = event.get("toolInput") or ""
581
+ tool_output = event.get("toolOutput") or ""
582
+
583
+ chars = len(body)
584
+ if isinstance(tool_input, str):
585
+ chars += len(tool_input)
586
+ elif tool_input is not None:
587
+ chars += len(json.dumps(tool_input))
588
+
589
+ if isinstance(tool_output, str):
590
+ chars += len(tool_output)
591
+ elif tool_output is not None:
592
+ chars += len(json.dumps(tool_output))
593
+
594
+ if chars == 0:
595
+ return 300
596
+ ms = round((chars / 40) * 1000)
597
+ return max(300, min(20000, ms))
598
+
599
+
600
+ def project_timeline(observations: List[Dict[str, Any]]) -> Dict[str, Any]:
601
+ if not observations:
602
+ now = (
603
+ datetime.datetime.now(datetime.timezone.utc)
604
+ .isoformat()
605
+ .replace("+00:00", "Z")
606
+ )
607
+ return {
608
+ "sessionId": "",
609
+ "startedAt": now,
610
+ "endedAt": now,
611
+ "totalDurationMs": 0,
612
+ "eventCount": 0,
613
+ "events": [],
614
+ }
615
+
616
+ sorted_obs = sorted(observations, key=lambda o: o.get("timestamp", ""))
617
+ started_at = sorted_obs[0].get("timestamp", "")
618
+
619
+ try:
620
+ import dateutil.parser
621
+
622
+ start_dt = dateutil.parser.isoparse(started_at)
623
+ start_ms = start_dt.timestamp() * 1000
624
+ except Exception:
625
+ start_ms = 0
626
+
627
+ events = []
628
+ synthetic_offset = 0
629
+ all_same_ts = all(o.get("timestamp") == started_at for o in sorted_obs)
630
+
631
+ for obs in sorted_obs:
632
+ kind = kind_from_hook(obs)
633
+ body = (
634
+ obs.get("userPrompt")
635
+ if kind == "prompt"
636
+ else (obs.get("assistantResponse") if kind == "response" else None)
637
+ )
638
+
639
+ try:
640
+ import dateutil.parser
641
+
642
+ obs_dt = dateutil.parser.isoparse(obs.get("timestamp", ""))
643
+ obs_ms = obs_dt.timestamp() * 1000
644
+ offset_ms = (
645
+ int(max(0, obs_ms - start_ms)) if not all_same_ts else synthetic_offset
646
+ )
647
+ except Exception:
648
+ offset_ms = synthetic_offset
649
+
650
+ event = {
651
+ "id": obs.get("id"),
652
+ "sessionId": obs.get("sessionId"),
653
+ "ts": obs.get("timestamp"),
654
+ "offsetMs": offset_ms,
655
+ "durationMs": 0,
656
+ "kind": kind,
657
+ "label": label_for(obs, kind),
658
+ "body": body,
659
+ "toolName": obs.get("toolName"),
660
+ "toolInput": obs.get("toolInput"),
661
+ "toolOutput": obs.get("toolOutput"),
662
+ }
663
+ event["durationMs"] = estimate_duration_ms(event)
664
+ events.append(event)
665
+ synthetic_offset += event["durationMs"]
666
+
667
+ if not events:
668
+ total_duration_ms = 0
669
+ else:
670
+ last = events[-1]
671
+ total_duration_ms = last["offsetMs"] + last["durationMs"]
672
+
673
+ return {
674
+ "sessionId": sorted_obs[0].get("sessionId"),
675
+ "startedAt": started_at,
676
+ "endedAt": sorted_obs[-1].get("timestamp"),
677
+ "totalDurationMs": total_duration_ms,
678
+ "eventCount": len(events),
679
+ "events": events,
680
+ }
@@ -0,0 +1,29 @@
1
+ """
2
+ Flask blueprints for agentmemory-python.
3
+
4
+ Import and register all blueprints via register_blueprints(app).
5
+ """
6
+
7
+ from .graph import graph_bp
8
+ from .health import health_bp
9
+ from .mcp import mcp_bp
10
+ from .memories import memories_bp
11
+ from .migration import migration_bp
12
+ from .observations import create_observations_bp, observations_bp
13
+ from .search import search_bp
14
+
15
+
16
+ def register_blueprints(app, observation_store=None, search_service=None):
17
+ """Register all route blueprints on a Flask application instance."""
18
+ obs_bp = (
19
+ create_observations_bp(observation_store)
20
+ if observation_store
21
+ else observations_bp
22
+ )
23
+ app.register_blueprint(obs_bp)
24
+ app.register_blueprint(memories_bp)
25
+ app.register_blueprint(search_bp)
26
+ app.register_blueprint(graph_bp)
27
+ app.register_blueprint(health_bp)
28
+ app.register_blueprint(mcp_bp)
29
+ app.register_blueprint(migration_bp)