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.
- package/.env.example +107 -0
- package/README.md +200 -0
- package/agent_card.py +512 -0
- package/bin/cli.js +181 -0
- package/bin/postinstall.js +216 -0
- package/config.py +104 -0
- package/dashboard.html +2689 -0
- package/hooks/README.md +196 -0
- package/hooks/__pycache__/auto-detect-response.cpython-312.pyc +0 -0
- package/hooks/__pycache__/auto_capture.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_end.cpython-312.pyc +0 -0
- package/hooks/__pycache__/session_start.cpython-312.pyc +0 -0
- package/hooks/auto-detect-response.py +348 -0
- package/hooks/auto_capture.py +255 -0
- package/hooks/detect-correction.py +173 -0
- package/hooks/grounding-hook.py +348 -0
- package/hooks/log-tool-use.py +234 -0
- package/hooks/log-user-request.py +208 -0
- package/hooks/pre-tool-decision.py +218 -0
- package/hooks/problem-detector.py +343 -0
- package/hooks/session_end.py +192 -0
- package/hooks/session_start.py +227 -0
- package/install.py +887 -0
- package/main.py +2859 -0
- package/manager.py +997 -0
- package/package.json +55 -0
- package/requirements.txt +8 -0
- package/run_server.py +136 -0
- package/services/__init__.py +50 -0
- package/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/services/__pycache__/agent_registry.cpython-312.pyc +0 -0
- package/services/__pycache__/auth.cpython-312.pyc +0 -0
- package/services/__pycache__/auto_inject.cpython-312.pyc +0 -0
- package/services/__pycache__/claude_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/services/__pycache__/compaction_flush.cpython-312.pyc +0 -0
- package/services/__pycache__/confidence.cpython-312.pyc +0 -0
- package/services/__pycache__/daily_log.cpython-312.pyc +0 -0
- package/services/__pycache__/database.cpython-312.pyc +0 -0
- package/services/__pycache__/embeddings.cpython-312.pyc +0 -0
- package/services/__pycache__/insights.cpython-312.pyc +0 -0
- package/services/__pycache__/llm_analyzer.cpython-312.pyc +0 -0
- package/services/__pycache__/memory_md_sync.cpython-312.pyc +0 -0
- package/services/__pycache__/retry_queue.cpython-312.pyc +0 -0
- package/services/__pycache__/timeline.cpython-312.pyc +0 -0
- package/services/__pycache__/vector_index.cpython-312.pyc +0 -0
- package/services/__pycache__/websocket.cpython-312.pyc +0 -0
- package/services/agent_registry.py +753 -0
- package/services/auth.py +331 -0
- package/services/auto_inject.py +250 -0
- package/services/claude_md_sync.py +275 -0
- package/services/cleanup.py +667 -0
- package/services/compaction_flush.py +447 -0
- package/services/confidence.py +301 -0
- package/services/daily_log.py +333 -0
- package/services/database.py +2485 -0
- package/services/embeddings.py +358 -0
- package/services/insights.py +632 -0
- package/services/llm_analyzer.py +595 -0
- package/services/memory_md_sync.py +409 -0
- package/services/retry_queue.py +453 -0
- package/services/timeline.py +579 -0
- package/services/vector_index.py +398 -0
- package/services/websocket.py +257 -0
- package/skills/__init__.py +6 -0
- package/skills/__pycache__/__init__.cpython-312.pyc +0 -0
- package/skills/__pycache__/admin.cpython-312.pyc +0 -0
- package/skills/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/skills/__pycache__/claude_md.cpython-312.pyc +0 -0
- package/skills/__pycache__/cleanup.cpython-312.pyc +0 -0
- package/skills/__pycache__/grounding.cpython-312.pyc +0 -0
- package/skills/__pycache__/insights.cpython-312.pyc +0 -0
- package/skills/__pycache__/natural_language.cpython-312.pyc +0 -0
- package/skills/__pycache__/retrieve.cpython-312.pyc +0 -0
- package/skills/__pycache__/search.cpython-312.pyc +0 -0
- package/skills/__pycache__/state.cpython-312.pyc +0 -0
- package/skills/__pycache__/store.cpython-312.pyc +0 -0
- package/skills/__pycache__/summarize.cpython-312.pyc +0 -0
- package/skills/__pycache__/timeline.cpython-312.pyc +0 -0
- package/skills/__pycache__/verification.cpython-312.pyc +0 -0
- package/skills/admin.py +469 -0
- package/skills/checkpoint.py +198 -0
- package/skills/claude_md.py +363 -0
- package/skills/cleanup.py +241 -0
- package/skills/grounding.py +801 -0
- package/skills/insights.py +231 -0
- package/skills/natural_language.py +277 -0
- package/skills/retrieve.py +67 -0
- package/skills/search.py +213 -0
- package/skills/state.py +182 -0
- package/skills/store.py +179 -0
- package/skills/summarize.py +588 -0
- package/skills/timeline.py +387 -0
- package/skills/verification.py +391 -0
- package/start_daemon.py +155 -0
- package/test_automation.py +221 -0
- package/test_complete.py +338 -0
- package/test_full.py +322 -0
- package/update_system.py +817 -0
- 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
|