claude-memory-agent 2.0.0

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 (100) hide show
  1. package/.env.example +107 -0
  2. package/README.md +200 -0
  3. package/agent_card.py +512 -0
  4. package/bin/cli.js +181 -0
  5. package/bin/postinstall.js +216 -0
  6. package/config.py +104 -0
  7. package/dashboard.html +2689 -0
  8. package/hooks/README.md +196 -0
  9. package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
  10. package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
  11. package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
  12. package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
  13. package/hooks/auto-detect-response.py +348 -0
  14. package/hooks/auto_capture.py +255 -0
  15. package/hooks/detect-correction.py +173 -0
  16. package/hooks/grounding-hook.py +348 -0
  17. package/hooks/log-tool-use.py +234 -0
  18. package/hooks/log-user-request.py +208 -0
  19. package/hooks/pre-tool-decision.py +218 -0
  20. package/hooks/problem-detector.py +343 -0
  21. package/hooks/session_end.py +192 -0
  22. package/hooks/session_start.py +227 -0
  23. package/install.py +887 -0
  24. package/main.py +2859 -0
  25. package/manager.py +997 -0
  26. package/package.json +55 -0
  27. package/requirements.txt +8 -0
  28. package/run_server.py +136 -0
  29. package/services/__init__.py +50 -0
  30. package/services/__pycache__/__init__.cpython-312.pyc +0 -0
  31. package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
  32. package/services/__pycache__/auth.cpython-312.pyc +0 -0
  33. package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
  34. package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
  35. package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
  36. package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
  37. package/services/__pycache__/confidence.cpython-312.pyc +0 -0
  38. package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
  39. package/services/__pycache__/database.cpython-312.pyc +0 -0
  40. package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
  41. package/services/__pycache__/insights.cpython-312.pyc +0 -0
  42. package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
  43. package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
  44. package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
  45. package/services/__pycache__/timeline.cpython-312.pyc +0 -0
  46. package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
  47. package/services/__pycache__/websocket.cpython-312.pyc +0 -0
  48. package/services/agent_registry.py +753 -0
  49. package/services/auth.py +331 -0
  50. package/services/auto_inject.py +250 -0
  51. package/services/claude_md_sync.py +275 -0
  52. package/services/cleanup.py +667 -0
  53. package/services/compaction_flush.py +447 -0
  54. package/services/confidence.py +301 -0
  55. package/services/daily_log.py +333 -0
  56. package/services/database.py +2485 -0
  57. package/services/embeddings.py +358 -0
  58. package/services/insights.py +632 -0
  59. package/services/llm_analyzer.py +595 -0
  60. package/services/memory_md_sync.py +409 -0
  61. package/services/retry_queue.py +453 -0
  62. package/services/timeline.py +579 -0
  63. package/services/vector_index.py +398 -0
  64. package/services/websocket.py +257 -0
  65. package/skills/__init__.py +6 -0
  66. package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
  67. package/skills/__pycache__/admin.cpython-312.pyc +0 -0
  68. package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
  69. package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
  70. package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
  71. package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
  72. package/skills/__pycache__/insights.cpython-312.pyc +0 -0
  73. package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
  74. package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
  75. package/skills/__pycache__/search.cpython-312.pyc +0 -0
  76. package/skills/__pycache__/state.cpython-312.pyc +0 -0
  77. package/skills/__pycache__/store.cpython-312.pyc +0 -0
  78. package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
  79. package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
  80. package/skills/__pycache__/verification.cpython-312.pyc +0 -0
  81. package/skills/admin.py +469 -0
  82. package/skills/checkpoint.py +198 -0
  83. package/skills/claude_md.py +363 -0
  84. package/skills/cleanup.py +241 -0
  85. package/skills/grounding.py +801 -0
  86. package/skills/insights.py +231 -0
  87. package/skills/natural_language.py +277 -0
  88. package/skills/retrieve.py +67 -0
  89. package/skills/search.py +213 -0
  90. package/skills/state.py +182 -0
  91. package/skills/store.py +179 -0
  92. package/skills/summarize.py +588 -0
  93. package/skills/timeline.py +387 -0
  94. package/skills/verification.py +391 -0
  95. package/start_daemon.py +155 -0
  96. package/test_automation.py +221 -0
  97. package/test_complete.py +338 -0
  98. package/test_full.py +322 -0
  99. package/update_system.py +817 -0
  100. package/verify_db.py +134 -0
@@ -0,0 +1,579 @@
1
+ """Timeline service for session event tracking and management."""
2
+ import os
3
+ import re
4
+ import uuid
5
+ from datetime import datetime, timedelta
6
+ from typing import List, Optional, Dict, Any
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ # Flag to enable/disable LLM-based analysis
12
+ USE_LLM_ANALYSIS = os.getenv("USE_LLM_ANALYSIS", "true").lower() == "true"
13
+
14
+ # Session gap threshold - 4 hours
15
+ SESSION_GAP_SECONDS = int(os.getenv("SESSION_GAP_HOURS", "4")) * 60 * 60
16
+
17
+ # Checkpoint thresholds
18
+ CHECKPOINT_EVENT_THRESHOLD = int(os.getenv("CHECKPOINT_EVENT_THRESHOLD", "25"))
19
+ CHECKPOINT_TIME_THRESHOLD_MINUTES = int(os.getenv("CHECKPOINT_TIME_MINUTES", "15"))
20
+
21
+
22
+ class TimelineService:
23
+ """Service for managing session timelines and event tracking."""
24
+
25
+ def __init__(self, db_service, embedding_service):
26
+ self.db = db_service
27
+ self.embeddings = embedding_service
28
+
29
+ # ============================================================
30
+ # SESSION MANAGEMENT
31
+ # ============================================================
32
+
33
+ async def get_or_create_session(
34
+ self,
35
+ project_path: str
36
+ ) -> tuple[str, bool, Optional[Dict[str, Any]]]:
37
+ """
38
+ Get current session or create new one if gap exceeded.
39
+
40
+ Returns:
41
+ tuple: (session_id, is_new_session, previous_session_state)
42
+ """
43
+ last_state = await self.db.get_latest_session_for_project(project_path)
44
+
45
+ if last_state:
46
+ last_activity = self._parse_datetime(last_state["last_activity_at"])
47
+ gap = (datetime.now() - last_activity).total_seconds()
48
+
49
+ if gap < SESSION_GAP_SECONDS:
50
+ # Continue existing session
51
+ return last_state["session_id"], False, None
52
+ else:
53
+ # Gap exceeded - create handoff checkpoint for old session
54
+ await self._create_handoff_checkpoint(last_state)
55
+
56
+ # Create new session
57
+ new_session_id = str(uuid.uuid4())
58
+ await self.db.get_or_create_session_state(new_session_id, project_path)
59
+
60
+ return new_session_id, True, last_state
61
+
62
+ async def _create_handoff_checkpoint(self, session_state: Dict[str, Any]):
63
+ """Create a handoff checkpoint when session times out."""
64
+ summary = f"Session paused after inactivity."
65
+ if session_state.get("current_goal"):
66
+ summary += f" Last goal: {session_state['current_goal']}"
67
+
68
+ await self.db.store_checkpoint(
69
+ session_id=session_state["session_id"],
70
+ summary=summary,
71
+ current_goal=session_state.get("current_goal"),
72
+ entities=session_state.get("entity_registry"),
73
+ pending_items=session_state.get("pending_questions")
74
+ )
75
+
76
+ def _parse_datetime(self, dt_str: str) -> datetime:
77
+ """Parse ISO datetime string."""
78
+ if not dt_str:
79
+ return datetime.now()
80
+ try:
81
+ return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
82
+ except ValueError:
83
+ return datetime.now()
84
+
85
+ # ============================================================
86
+ # EVENT LOGGING
87
+ # ============================================================
88
+
89
+ async def log_events_batch(
90
+ self,
91
+ session_id: str,
92
+ events: List[Dict[str, Any]],
93
+ project_path: Optional[str] = None,
94
+ parent_event_id: Optional[int] = None,
95
+ root_event_id: Optional[int] = None,
96
+ generate_embeddings: bool = True
97
+ ) -> List[int]:
98
+ """
99
+ Log multiple timeline events in a single batch operation.
100
+
101
+ This is more efficient than calling log_event() multiple times because:
102
+ 1. Single database transaction for all events
103
+ 2. Batch embedding generation (if supported)
104
+ 3. Single checkpoint check at the end
105
+
106
+ Args:
107
+ session_id: The session ID
108
+ events: List of event dicts, each containing:
109
+ - event_type: Type of event (required)
110
+ - summary: Brief description (required)
111
+ - details: Full context (optional)
112
+ - entities: Entity references (optional)
113
+ - status: Event status (optional, default "completed")
114
+ - outcome: Result or error message (optional)
115
+ - confidence: Confidence level 0-1 (optional)
116
+ - is_anchor: Whether this is a verified fact (optional)
117
+ project_path: Project path for all events (optional)
118
+ parent_event_id: ID of parent event for all events (optional)
119
+ root_event_id: ID of root user request for all events (optional)
120
+ generate_embeddings: Whether to generate embeddings (default True)
121
+
122
+ Returns:
123
+ List of event IDs in the same order as input events
124
+ """
125
+ if not events:
126
+ return []
127
+
128
+ event_ids = []
129
+
130
+ # Generate embeddings in batch if enabled
131
+ embeddings_list = []
132
+ if generate_embeddings and self.embeddings:
133
+ embed_texts = []
134
+ for event in events:
135
+ summary = event.get("summary", "")
136
+ details = event.get("details", "")
137
+ embed_text = summary
138
+ if details:
139
+ embed_text += f"\n{details[:500]}"
140
+ embed_texts.append(embed_text)
141
+
142
+ # Try batch embedding if available, otherwise fall back to sequential
143
+ try:
144
+ if hasattr(self.embeddings, 'generate_embeddings_batch'):
145
+ embeddings_list = await self.embeddings.generate_embeddings_batch(embed_texts)
146
+ else:
147
+ # Fall back to sequential embedding generation
148
+ for text in embed_texts:
149
+ emb = await self.embeddings.generate_embedding(text)
150
+ embeddings_list.append(emb)
151
+ except Exception:
152
+ # If embedding fails, continue without embeddings
153
+ embeddings_list = [None] * len(events)
154
+ else:
155
+ embeddings_list = [None] * len(events)
156
+
157
+ # Store all events (database service should handle transaction)
158
+ for i, event in enumerate(events):
159
+ event_id = await self.db.store_timeline_event(
160
+ session_id=session_id,
161
+ event_type=event.get("event_type", "observation"),
162
+ summary=event.get("summary", "")[:200],
163
+ details=event.get("details"),
164
+ embedding=embeddings_list[i] if i < len(embeddings_list) else None,
165
+ project_path=project_path,
166
+ parent_event_id=parent_event_id,
167
+ root_event_id=root_event_id,
168
+ entities=event.get("entities"),
169
+ status=event.get("status", "completed"),
170
+ outcome=event.get("outcome"),
171
+ confidence=event.get("confidence"),
172
+ is_anchor=event.get("is_anchor", False)
173
+ )
174
+ event_ids.append(event_id)
175
+
176
+ # Single checkpoint check after all events (not per-event)
177
+ await self._maybe_create_auto_checkpoint(session_id, project_path)
178
+
179
+ return event_ids
180
+
181
+ async def log_event(
182
+ self,
183
+ session_id: str,
184
+ event_type: str,
185
+ summary: str,
186
+ details: Optional[str] = None,
187
+ project_path: Optional[str] = None,
188
+ parent_event_id: Optional[int] = None,
189
+ root_event_id: Optional[int] = None,
190
+ entities: Optional[Dict[str, List[str]]] = None,
191
+ status: str = "completed",
192
+ outcome: Optional[str] = None,
193
+ confidence: Optional[float] = None,
194
+ is_anchor: bool = False,
195
+ generate_embedding: bool = True
196
+ ) -> int:
197
+ """
198
+ Log a timeline event.
199
+
200
+ Args:
201
+ session_id: The session ID
202
+ event_type: Type of event (user_request, decision, action, observation, error, checkpoint)
203
+ summary: Brief description (<200 chars)
204
+ details: Full context (optional)
205
+ project_path: Project path (optional)
206
+ parent_event_id: ID of parent event (causal chain)
207
+ root_event_id: ID of root user request
208
+ entities: Dict of entity references {"files": [], "functions": [], etc.}
209
+ status: Event status (pending, in_progress, completed, failed)
210
+ outcome: Result or error message
211
+ confidence: Confidence level 0-1
212
+ is_anchor: Whether this is a verified fact
213
+ generate_embedding: Whether to generate embedding for semantic search
214
+
215
+ Returns:
216
+ Event ID
217
+ """
218
+ embedding = None
219
+ if generate_embedding and self.embeddings:
220
+ # Generate embedding from summary + details for semantic search
221
+ embed_text = summary
222
+ if details:
223
+ embed_text += f"\n{details[:500]}" # Limit details for embedding
224
+ embedding = await self.embeddings.generate_embedding(embed_text)
225
+
226
+ event_id = await self.db.store_timeline_event(
227
+ session_id=session_id,
228
+ event_type=event_type,
229
+ summary=summary,
230
+ details=details,
231
+ embedding=embedding,
232
+ project_path=project_path,
233
+ parent_event_id=parent_event_id,
234
+ root_event_id=root_event_id,
235
+ entities=entities,
236
+ status=status,
237
+ outcome=outcome,
238
+ confidence=confidence,
239
+ is_anchor=is_anchor
240
+ )
241
+
242
+ # Check if checkpoint is needed
243
+ await self._maybe_create_auto_checkpoint(session_id, project_path)
244
+
245
+ return event_id
246
+
247
+ async def get_events(
248
+ self,
249
+ session_id: str,
250
+ limit: int = 20,
251
+ event_type: Optional[str] = None,
252
+ since_event_id: Optional[int] = None,
253
+ anchors_only: bool = False
254
+ ) -> List[Dict[str, Any]]:
255
+ """Get timeline events for a session."""
256
+ return await self.db.get_timeline_events(
257
+ session_id=session_id,
258
+ limit=limit,
259
+ event_type=event_type,
260
+ since_event_id=since_event_id,
261
+ anchors_only=anchors_only
262
+ )
263
+
264
+ async def search_events(
265
+ self,
266
+ query: str,
267
+ session_id: Optional[str] = None,
268
+ limit: int = 10,
269
+ threshold: float = 0.5
270
+ ) -> List[Dict[str, Any]]:
271
+ """Semantic search across timeline events."""
272
+ if not self.embeddings:
273
+ return []
274
+
275
+ embedding = await self.embeddings.generate_embedding(query)
276
+ return await self.db.search_timeline_events(
277
+ embedding=embedding,
278
+ session_id=session_id,
279
+ limit=limit,
280
+ threshold=threshold
281
+ )
282
+
283
+ # ============================================================
284
+ # AUTO-DETECTION (Parse responses for decisions/observations)
285
+ # ============================================================
286
+
287
+ # Decision patterns
288
+ DECISION_PATTERNS = [
289
+ r"I'll use (.+?) instead of",
290
+ r"Let's go with (.+)",
291
+ r"The best approach is (.+)",
292
+ r"I've decided to (.+)",
293
+ r"I'm going to (.+)",
294
+ r"We should (.+)",
295
+ r"I recommend (.+)",
296
+ ]
297
+
298
+ # Observation patterns
299
+ OBSERVATION_PATTERNS = [
300
+ r"I notice that (.+)",
301
+ r"Found: (.+)",
302
+ r"The issue is (.+)",
303
+ r"Looking at .+?, I see (.+)",
304
+ r"The problem is (.+)",
305
+ r"This shows that (.+)",
306
+ r"It appears that (.+)",
307
+ # File structure discoveries
308
+ r"The (?:file|directory|folder) (?:structure|layout) shows (.+)",
309
+ r"There (?:is|are) (\d+ (?:files?|directories|folders).+)",
310
+ r"The codebase (?:has|contains|includes) (.+)",
311
+ # Error encounters
312
+ r"(?:An? )?[Ee]rror (?:occurred|happened): (.+)",
313
+ r"(?:The )?(?:test|build|command) failed (?:because|with) (.+)",
314
+ r"There's an? (?:issue|bug|error) (?:in|with) (.+)",
315
+ # Configuration findings
316
+ r"The config(?:uration)? (?:shows|indicates|has) (.+)",
317
+ r"(?:This|The) (?:setting|option|flag) (?:is set to|controls|enables) (.+)",
318
+ r"The (?:database|server|API) is (?:configured to|set up for|running) (.+)",
319
+ # Pattern discoveries
320
+ r"(?:The )?code (?:follows|uses) (?:the )?(.+) pattern",
321
+ r"(?:This|The) (?:function|method|class) (?:implements|handles|manages) (.+)",
322
+ ]
323
+
324
+ def detect_decisions(self, text: str) -> List[str]:
325
+ """Detect decisions from text."""
326
+ decisions = []
327
+ for pattern in self.DECISION_PATTERNS:
328
+ matches = re.findall(pattern, text, re.IGNORECASE)
329
+ decisions.extend(matches)
330
+ return decisions
331
+
332
+ def detect_observations(self, text: str) -> List[str]:
333
+ """Detect observations from text."""
334
+ observations = []
335
+ for pattern in self.OBSERVATION_PATTERNS:
336
+ matches = re.findall(pattern, text, re.IGNORECASE)
337
+ observations.extend(matches)
338
+ return observations
339
+
340
+ def extract_entities(self, text: str) -> Dict[str, List[str]]:
341
+ """Extract entity references from text."""
342
+ entities = {
343
+ "files": [],
344
+ "functions": [],
345
+ "variables": [],
346
+ "urls": []
347
+ }
348
+
349
+ # File patterns (common extensions)
350
+ file_pattern = r'[\w\-./\\]+\.(py|js|ts|tsx|jsx|json|md|yaml|yml|toml|sql|html|css|scss)'
351
+ entities["files"] = list(set(re.findall(file_pattern, text)))
352
+
353
+ # Function/method patterns
354
+ func_pattern = r'(?:function|def|async def|class)\s+(\w+)'
355
+ entities["functions"] = list(set(re.findall(func_pattern, text)))
356
+
357
+ # Variable assignment patterns
358
+ var_pattern = r'(\w+)\s*[=:]\s*[^=]'
359
+ potential_vars = re.findall(var_pattern, text)
360
+ # Filter common keywords
361
+ keywords = {'if', 'else', 'for', 'while', 'return', 'class', 'def', 'async', 'await', 'import', 'from'}
362
+ entities["variables"] = [v for v in potential_vars if v.lower() not in keywords][:10]
363
+
364
+ # URL patterns
365
+ url_pattern = r'https?://[^\s<>"{}|\\^`\[\]]+'
366
+ entities["urls"] = list(set(re.findall(url_pattern, text)))
367
+
368
+ # Clean up empty lists
369
+ return {k: v for k, v in entities.items() if v}
370
+
371
+ async def auto_log_from_response(
372
+ self,
373
+ session_id: str,
374
+ response_text: str,
375
+ project_path: Optional[str] = None,
376
+ parent_event_id: Optional[int] = None,
377
+ root_event_id: Optional[int] = None
378
+ ) -> List[int]:
379
+ """
380
+ Auto-detect and log decisions/observations from a response.
381
+
382
+ Uses LLM-based analysis when available, falls back to regex.
383
+
384
+ Args:
385
+ session_id: The session ID
386
+ response_text: Claude's response text to analyze
387
+ project_path: Project path (optional)
388
+ parent_event_id: Parent event ID for causal chain (optional)
389
+ root_event_id: Root user request event ID (optional)
390
+
391
+ Returns list of created event IDs.
392
+ """
393
+ event_ids = []
394
+ decisions = []
395
+ observations = []
396
+
397
+ # Try LLM-based analysis first (more accurate)
398
+ if USE_LLM_ANALYSIS:
399
+ try:
400
+ from services.llm_analyzer import LLMAnalyzer
401
+ analyzer = LLMAnalyzer()
402
+ result = await analyzer.extract_decisions_and_observations(
403
+ response_text,
404
+ max_decisions=3,
405
+ max_observations=3
406
+ )
407
+ if result.get("success"):
408
+ decisions = result.get("decisions", [])
409
+ observations = result.get("observations", [])
410
+ except Exception as e:
411
+ # LLM not available, fall back to regex
412
+ pass
413
+
414
+ # Fall back to regex-based detection if LLM didn't find anything
415
+ if not decisions:
416
+ decisions = self.detect_decisions(response_text)
417
+ if not observations:
418
+ observations = self.detect_observations(response_text)
419
+
420
+ # Log decisions (higher confidence if from LLM)
421
+ confidence_boost = 0.1 if USE_LLM_ANALYSIS else 0.0
422
+ for decision in decisions[:3]: # Limit to top 3
423
+ event_id = await self.log_event(
424
+ session_id=session_id,
425
+ event_type="decision",
426
+ summary=decision[:200],
427
+ project_path=project_path,
428
+ parent_event_id=parent_event_id,
429
+ root_event_id=root_event_id,
430
+ confidence=0.7 + confidence_boost, # Higher if LLM-detected
431
+ generate_embedding=True
432
+ )
433
+ event_ids.append(event_id)
434
+
435
+ # Log observations
436
+ for observation in observations[:3]: # Limit to top 3
437
+ event_id = await self.log_event(
438
+ session_id=session_id,
439
+ event_type="observation",
440
+ summary=observation[:200],
441
+ project_path=project_path,
442
+ parent_event_id=parent_event_id,
443
+ root_event_id=root_event_id,
444
+ confidence=0.6 + confidence_boost,
445
+ generate_embedding=True
446
+ )
447
+ event_ids.append(event_id)
448
+
449
+ return event_ids
450
+
451
+ # ============================================================
452
+ # AUTO-CHECKPOINT
453
+ # ============================================================
454
+
455
+ async def _maybe_create_auto_checkpoint(
456
+ self,
457
+ session_id: str,
458
+ project_path: Optional[str] = None
459
+ ):
460
+ """Check if automatic checkpoint is needed and create if so."""
461
+ state = await self.db.get_or_create_session_state(session_id, project_path)
462
+
463
+ events_since = state.get("events_since_checkpoint", 0)
464
+
465
+ if events_since >= CHECKPOINT_EVENT_THRESHOLD:
466
+ await self.create_checkpoint(
467
+ session_id=session_id,
468
+ auto_generated=True
469
+ )
470
+
471
+ async def create_checkpoint(
472
+ self,
473
+ session_id: str,
474
+ summary: Optional[str] = None,
475
+ auto_generated: bool = False
476
+ ) -> int:
477
+ """Create a checkpoint for the session."""
478
+ state = await self.db.get_or_create_session_state(session_id)
479
+
480
+ # Get recent events for summary
481
+ recent_events = await self.db.get_timeline_events(
482
+ session_id=session_id,
483
+ limit=state.get("events_since_checkpoint", 25)
484
+ )
485
+
486
+ # Build summary if not provided
487
+ if not summary:
488
+ summary = self._generate_checkpoint_summary(recent_events, state, auto_generated)
489
+
490
+ # Extract key facts (anchors and high-confidence decisions)
491
+ key_facts = [
492
+ e["summary"] for e in recent_events
493
+ if e.get("is_anchor") or (e.get("event_type") == "decision" and e.get("confidence", 0) >= 0.8)
494
+ ]
495
+
496
+ # Extract decisions
497
+ decisions = [
498
+ e["summary"] for e in recent_events
499
+ if e.get("event_type") == "decision"
500
+ ]
501
+
502
+ # Get last event ID
503
+ event_id = recent_events[0]["id"] if recent_events else None
504
+
505
+ checkpoint_id = await self.db.store_checkpoint(
506
+ session_id=session_id,
507
+ summary=summary,
508
+ event_id=event_id,
509
+ key_facts=key_facts[:10], # Limit to top 10
510
+ decisions=decisions[:10],
511
+ entities=state.get("entity_registry"),
512
+ current_goal=state.get("current_goal"),
513
+ pending_items=state.get("pending_questions"),
514
+ event_count=len(recent_events)
515
+ )
516
+
517
+ return checkpoint_id
518
+
519
+ def _generate_checkpoint_summary(
520
+ self,
521
+ events: List[Dict[str, Any]],
522
+ state: Dict[str, Any],
523
+ auto_generated: bool
524
+ ) -> str:
525
+ """Generate a summary for auto-checkpoint."""
526
+ parts = []
527
+
528
+ if auto_generated:
529
+ parts.append(f"Auto-checkpoint ({len(events)} events)")
530
+
531
+ if state.get("current_goal"):
532
+ parts.append(f"Goal: {state['current_goal']}")
533
+
534
+ # Count event types
535
+ type_counts = {}
536
+ for e in events:
537
+ t = e.get("event_type", "unknown")
538
+ type_counts[t] = type_counts.get(t, 0) + 1
539
+
540
+ if type_counts:
541
+ type_summary = ", ".join(f"{c} {t}s" for t, c in type_counts.items())
542
+ parts.append(f"Activity: {type_summary}")
543
+
544
+ return ". ".join(parts) if parts else "Checkpoint created"
545
+
546
+ # ============================================================
547
+ # CONTEXT LOADING (for session resume)
548
+ # ============================================================
549
+
550
+ async def load_session_context(
551
+ self,
552
+ session_id: str,
553
+ include_checkpoint: bool = True,
554
+ include_recent_events: int = 10
555
+ ) -> Dict[str, Any]:
556
+ """
557
+ Load full context for a session.
558
+
559
+ Returns dict with state, recent events, and latest checkpoint.
560
+ """
561
+ state = await self.db.get_or_create_session_state(session_id)
562
+
563
+ context = {
564
+ "session_id": session_id,
565
+ "state": state,
566
+ "recent_events": [],
567
+ "checkpoint": None
568
+ }
569
+
570
+ if include_recent_events > 0:
571
+ context["recent_events"] = await self.db.get_timeline_events(
572
+ session_id=session_id,
573
+ limit=include_recent_events
574
+ )
575
+
576
+ if include_checkpoint:
577
+ context["checkpoint"] = await self.db.get_latest_checkpoint(session_id)
578
+
579
+ return context