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,595 @@
1
+ """Enhanced LLM-based text analysis service using Ollama.
2
+
3
+ Features:
4
+ - Structured extraction of decisions, observations, errors, learnings
5
+ - Confidence scores for extracted items
6
+ - Caching to avoid re-analyzing the same content
7
+ - Pattern deduplication using embeddings
8
+ - Fallback to regex when LLM unavailable
9
+ """
10
+ import os
11
+ import re
12
+ import json
13
+ import time
14
+ import hashlib
15
+ import asyncio
16
+ from typing import List, Dict, Any, Optional, Tuple
17
+ from functools import lru_cache
18
+ from dotenv import load_dotenv
19
+
20
+ load_dotenv()
21
+
22
+ # Try to import ollama
23
+ OLLAMA_AVAILABLE = False
24
+ try:
25
+ import ollama
26
+ OLLAMA_AVAILABLE = True
27
+ except ImportError:
28
+ ollama = None
29
+
30
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
31
+ ANALYSIS_MODEL = os.getenv("ANALYSIS_MODEL", "llama3.2:3b")
32
+ USE_LLM_ANALYSIS = os.getenv("USE_LLM_ANALYSIS", "true").lower() == "true"
33
+ CACHE_TTL = int(os.getenv("LLM_CACHE_TTL", "3600")) # 1 hour default
34
+
35
+
36
+ class AnalysisCache:
37
+ """Simple in-memory cache for analysis results."""
38
+
39
+ def __init__(self, max_size: int = 1000, ttl: int = CACHE_TTL):
40
+ self._cache: Dict[str, Tuple[Dict, float]] = {}
41
+ self._max_size = max_size
42
+ self._ttl = ttl
43
+
44
+ def _hash_key(self, text: str, analysis_type: str) -> str:
45
+ """Generate a cache key from text and analysis type."""
46
+ content = f"{analysis_type}:{text[:500]}" # Use first 500 chars for key
47
+ return hashlib.md5(content.encode()).hexdigest()
48
+
49
+ def get(self, text: str, analysis_type: str) -> Optional[Dict]:
50
+ """Get cached result if available and not expired."""
51
+ key = self._hash_key(text, analysis_type)
52
+ if key in self._cache:
53
+ result, timestamp = self._cache[key]
54
+ if time.time() - timestamp < self._ttl:
55
+ return result
56
+ else:
57
+ del self._cache[key]
58
+ return None
59
+
60
+ def set(self, text: str, analysis_type: str, result: Dict):
61
+ """Cache an analysis result."""
62
+ # Evict oldest entries if cache is full
63
+ if len(self._cache) >= self._max_size:
64
+ oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k][1])
65
+ del self._cache[oldest_key]
66
+
67
+ key = self._hash_key(text, analysis_type)
68
+ self._cache[key] = (result, time.time())
69
+
70
+ def clear(self):
71
+ """Clear the cache."""
72
+ self._cache.clear()
73
+
74
+ def stats(self) -> Dict[str, int]:
75
+ """Get cache statistics."""
76
+ return {
77
+ "size": len(self._cache),
78
+ "max_size": self._max_size,
79
+ "ttl": self._ttl
80
+ }
81
+
82
+
83
+ # Regex patterns for fallback detection
84
+ DECISION_PATTERNS = [
85
+ r"(?:I(?:'ll| will)|Let(?:'s| me)|Going to|Decided to|Choosing|Using|Will use)\s+(.+?)(?:\.|$)",
86
+ r"(?:better to|should|recommend)\s+(.+?)(?:\.|$)",
87
+ r"(?:instead of|rather than)\s+(.+?),?\s+(?:I'll|we'll|let's)\s+(.+?)(?:\.|$)",
88
+ ]
89
+
90
+ OBSERVATION_PATTERNS = [
91
+ r"(?:I (?:notice|see|found|discovered)|Found that|The issue is|Problem is)\s+(.+?)(?:\.|$)",
92
+ r"(?:Looking at|Checking|Examining)\s+.+?,?\s+(.+?)(?:\.|$)",
93
+ r"(?:It (?:appears|seems|looks like))\s+(.+?)(?:\.|$)",
94
+ ]
95
+
96
+ ERROR_PATTERNS = [
97
+ r"(?:Error|Exception|Failed|Failure):\s*(.+?)(?:\.|$)",
98
+ r"(?:Bug|Issue|Problem)(?:\s+(?:is|was|with))?\s*:?\s*(.+?)(?:\.|$)",
99
+ r"(?:doesn't work|not working|broken|failing)\s*(?:because|due to)?\s*(.+?)(?:\.|$)",
100
+ ]
101
+
102
+ LEARNING_PATTERNS = [
103
+ r"(?:Learned|Discovered|Realized|TIL|Note to self)\s*:?\s*(.+?)(?:\.|$)",
104
+ r"(?:Key (?:insight|learning|takeaway))\s*:?\s*(.+?)(?:\.|$)",
105
+ r"(?:Remember|Important)\s*:?\s*(.+?)(?:\.|$)",
106
+ ]
107
+
108
+
109
+ class LLMAnalyzer:
110
+ """Enhanced service for LLM-based text analysis.
111
+
112
+ Features:
113
+ - Structured extraction with confidence scores
114
+ - Caching to avoid duplicate analysis
115
+ - Fallback to regex patterns when LLM unavailable
116
+ - Health checking and graceful degradation
117
+ """
118
+
119
+ def __init__(self):
120
+ self.model = ANALYSIS_MODEL
121
+ self.host = OLLAMA_HOST
122
+ self.use_llm = USE_LLM_ANALYSIS and OLLAMA_AVAILABLE
123
+ self._client = None
124
+ self._cache = AnalysisCache()
125
+ self._degraded_mode = False
126
+ self._last_health_check = 0
127
+ self._health_cache_ttl = 60 # Check health every 60s
128
+
129
+ @property
130
+ def client(self):
131
+ """Lazy-load the Ollama client."""
132
+ if self._client is None and OLLAMA_AVAILABLE:
133
+ self._client = ollama.Client(host=self.host)
134
+ return self._client
135
+
136
+ async def check_health(self) -> bool:
137
+ """Check if LLM service is available."""
138
+ if not OLLAMA_AVAILABLE:
139
+ return False
140
+
141
+ now = time.time()
142
+ if now - self._last_health_check < self._health_cache_ttl:
143
+ return not self._degraded_mode
144
+
145
+ try:
146
+ loop = asyncio.get_event_loop()
147
+ await asyncio.wait_for(
148
+ loop.run_in_executor(None, lambda: self.client.list()),
149
+ timeout=2.0
150
+ )
151
+ self._degraded_mode = False
152
+ self._last_health_check = now
153
+ return True
154
+ except Exception:
155
+ self._degraded_mode = True
156
+ self._last_health_check = now
157
+ return False
158
+
159
+ def _extract_with_regex(
160
+ self,
161
+ text: str,
162
+ patterns: List[str],
163
+ max_items: int = 3
164
+ ) -> List[Dict[str, Any]]:
165
+ """Extract items using regex patterns (fallback method)."""
166
+ results = []
167
+ for pattern in patterns:
168
+ matches = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE)
169
+ for match in matches:
170
+ # Handle tuple results from groups
171
+ if isinstance(match, tuple):
172
+ match = " ".join(m for m in match if m)
173
+ if match and len(match) > 10:
174
+ results.append({
175
+ "content": match.strip()[:200],
176
+ "confidence": 0.5, # Lower confidence for regex
177
+ "method": "regex"
178
+ })
179
+ if len(results) >= max_items:
180
+ break
181
+ if len(results) >= max_items:
182
+ break
183
+ return results
184
+
185
+ async def extract_patterns(
186
+ self,
187
+ response_text: str,
188
+ max_decisions: int = 3,
189
+ max_observations: int = 3,
190
+ max_errors: int = 2,
191
+ max_learnings: int = 2
192
+ ) -> Dict[str, Any]:
193
+ """
194
+ Extract all pattern types from Claude's response.
195
+
196
+ Args:
197
+ response_text: The text to analyze
198
+ max_decisions: Maximum decisions to extract
199
+ max_observations: Maximum observations to extract
200
+ max_errors: Maximum errors to extract
201
+ max_learnings: Maximum learnings to extract
202
+
203
+ Returns:
204
+ Dict with 'decisions', 'observations', 'errors', 'learnings' lists
205
+ Each item has 'content', 'confidence', and 'method' fields
206
+ """
207
+ # Check cache first
208
+ cached = self._cache.get(response_text, "patterns")
209
+ if cached:
210
+ return {**cached, "cached": True}
211
+
212
+ # Truncate very long responses
213
+ text = response_text[:4000] if len(response_text) > 4000 else response_text
214
+
215
+ # Try LLM analysis first
216
+ if self.use_llm and not self._degraded_mode:
217
+ result = await self._extract_with_llm(
218
+ text, max_decisions, max_observations, max_errors, max_learnings
219
+ )
220
+ if result.get("success"):
221
+ self._cache.set(response_text, "patterns", result)
222
+ return result
223
+
224
+ # Fallback to regex
225
+ result = {
226
+ "decisions": self._extract_with_regex(text, DECISION_PATTERNS, max_decisions),
227
+ "observations": self._extract_with_regex(text, OBSERVATION_PATTERNS, max_observations),
228
+ "errors": self._extract_with_regex(text, ERROR_PATTERNS, max_errors),
229
+ "learnings": self._extract_with_regex(text, LEARNING_PATTERNS, max_learnings),
230
+ "success": True,
231
+ "method": "regex",
232
+ "degraded_mode": True
233
+ }
234
+
235
+ self._cache.set(response_text, "patterns", result)
236
+ return result
237
+
238
+ async def _extract_with_llm(
239
+ self,
240
+ text: str,
241
+ max_decisions: int,
242
+ max_observations: int,
243
+ max_errors: int,
244
+ max_learnings: int
245
+ ) -> Dict[str, Any]:
246
+ """Extract patterns using LLM analysis."""
247
+ prompt = f"""Analyze this AI assistant response and extract structured information.
248
+
249
+ RESPONSE TO ANALYZE:
250
+ ---
251
+ {text}
252
+ ---
253
+
254
+ Extract the following (if present):
255
+ 1. DECISIONS - Choices made about implementation, architecture, or approach
256
+ 2. OBSERVATIONS - Things noticed, discovered, or found
257
+ 3. ERRORS - Bugs, issues, or problems identified
258
+ 4. LEARNINGS - Insights, lessons learned, or important notes
259
+
260
+ Return JSON only with this exact structure:
261
+ {{
262
+ "decisions": [
263
+ {{"content": "short description", "confidence": 0.0-1.0}}
264
+ ],
265
+ "observations": [
266
+ {{"content": "short description", "confidence": 0.0-1.0}}
267
+ ],
268
+ "errors": [
269
+ {{"content": "short description", "confidence": 0.0-1.0}}
270
+ ],
271
+ "learnings": [
272
+ {{"content": "short description", "confidence": 0.0-1.0}}
273
+ ]
274
+ }}
275
+
276
+ Rules:
277
+ - Each content should be a clear, concise phrase (under 150 chars)
278
+ - Confidence: 0.9+ for explicit statements, 0.7-0.9 for implicit, 0.5-0.7 for inferred
279
+ - Max items: {max_decisions} decisions, {max_observations} observations, {max_errors} errors, {max_learnings} learnings
280
+ - Return empty arrays [] if nothing found for a category
281
+ - Only include meaningful, actionable items"""
282
+
283
+ try:
284
+ loop = asyncio.get_event_loop()
285
+
286
+ def _generate():
287
+ return self.client.generate(
288
+ model=self.model,
289
+ prompt=prompt,
290
+ options={
291
+ "temperature": 0.1,
292
+ "num_predict": 800
293
+ }
294
+ )
295
+
296
+ response = await asyncio.wait_for(
297
+ loop.run_in_executor(None, _generate),
298
+ timeout=30.0
299
+ )
300
+
301
+ result_text = response.get("response", "{}")
302
+
303
+ # Extract JSON from response
304
+ json_start = result_text.find("{")
305
+ json_end = result_text.rfind("}") + 1
306
+
307
+ if json_start >= 0 and json_end > json_start:
308
+ json_str = result_text[json_start:json_end]
309
+ parsed = json.loads(json_str)
310
+
311
+ # Normalize the results
312
+ def normalize_items(items: List, max_count: int) -> List[Dict]:
313
+ normalized = []
314
+ for item in (items or [])[:max_count]:
315
+ if isinstance(item, str):
316
+ normalized.append({
317
+ "content": item[:200],
318
+ "confidence": 0.7,
319
+ "method": "llm"
320
+ })
321
+ elif isinstance(item, dict):
322
+ normalized.append({
323
+ "content": str(item.get("content", item))[:200],
324
+ "confidence": float(item.get("confidence", 0.7)),
325
+ "method": "llm"
326
+ })
327
+ return normalized
328
+
329
+ return {
330
+ "decisions": normalize_items(parsed.get("decisions"), max_decisions),
331
+ "observations": normalize_items(parsed.get("observations"), max_observations),
332
+ "errors": normalize_items(parsed.get("errors"), max_errors),
333
+ "learnings": normalize_items(parsed.get("learnings"), max_learnings),
334
+ "success": True,
335
+ "method": "llm"
336
+ }
337
+
338
+ except asyncio.TimeoutError:
339
+ self._degraded_mode = True
340
+ except json.JSONDecodeError:
341
+ pass
342
+ except Exception:
343
+ self._degraded_mode = True
344
+
345
+ return {"success": False}
346
+
347
+ async def extract_decisions_and_observations(
348
+ self,
349
+ response_text: str,
350
+ max_decisions: int = 3,
351
+ max_observations: int = 3
352
+ ) -> Dict[str, Any]:
353
+ """
354
+ Legacy method for backward compatibility.
355
+ Extracts decisions and observations from Claude's response.
356
+ """
357
+ result = await self.extract_patterns(
358
+ response_text,
359
+ max_decisions=max_decisions,
360
+ max_observations=max_observations,
361
+ max_errors=0,
362
+ max_learnings=0
363
+ )
364
+
365
+ return {
366
+ "decisions": [d["content"] for d in result.get("decisions", [])],
367
+ "observations": [o["content"] for o in result.get("observations", [])],
368
+ "success": result.get("success", False),
369
+ "fallback": result.get("method") == "regex"
370
+ }
371
+
372
+ async def check_statement_against_facts(
373
+ self,
374
+ statement: str,
375
+ facts: List[str]
376
+ ) -> Dict[str, Any]:
377
+ """
378
+ Check if a statement contradicts known facts using LLM.
379
+
380
+ Args:
381
+ statement: The statement to check
382
+ facts: List of known facts/anchors
383
+
384
+ Returns:
385
+ Dict with contradiction analysis
386
+ """
387
+ if not facts:
388
+ return {"has_contradiction": False, "details": None}
389
+
390
+ # Check cache
391
+ cache_key = f"{statement}|{','.join(facts[:5])}"
392
+ cached = self._cache.get(cache_key, "contradiction")
393
+ if cached:
394
+ return {**cached, "cached": True}
395
+
396
+ facts_str = "\n".join(f"- {f}" for f in facts[:10])
397
+
398
+ # Try LLM first
399
+ if self.use_llm and not self._degraded_mode:
400
+ prompt = f"""Check if this statement contradicts any of the known facts.
401
+
402
+ KNOWN FACTS:
403
+ {facts_str}
404
+
405
+ STATEMENT TO CHECK:
406
+ {statement}
407
+
408
+ Return JSON only:
409
+ {{"has_contradiction": true/false, "conflicting_fact": "the fact it conflicts with or null", "reason": "brief explanation or null", "confidence": 0.0-1.0}}"""
410
+
411
+ try:
412
+ loop = asyncio.get_event_loop()
413
+
414
+ def _generate():
415
+ return self.client.generate(
416
+ model=self.model,
417
+ prompt=prompt,
418
+ options={"temperature": 0.1, "num_predict": 200}
419
+ )
420
+
421
+ response = await asyncio.wait_for(
422
+ loop.run_in_executor(None, _generate),
423
+ timeout=15.0
424
+ )
425
+
426
+ result_text = response.get("response", "{}")
427
+ json_start = result_text.find("{")
428
+ json_end = result_text.rfind("}") + 1
429
+
430
+ if json_start >= 0 and json_end > json_start:
431
+ json_str = result_text[json_start:json_end]
432
+ result = json.loads(json_str)
433
+ result["method"] = "llm"
434
+ self._cache.set(cache_key, "contradiction", result)
435
+ return result
436
+
437
+ except Exception:
438
+ pass
439
+
440
+ # Fallback: simple keyword matching
441
+ statement_lower = statement.lower()
442
+ for fact in facts:
443
+ fact_lower = fact.lower()
444
+ # Check for negation patterns
445
+ if "not" in statement_lower and any(
446
+ word in fact_lower for word in statement_lower.split() if len(word) > 3
447
+ ):
448
+ result = {
449
+ "has_contradiction": True,
450
+ "conflicting_fact": fact,
451
+ "reason": "Potential negation detected",
452
+ "confidence": 0.5,
453
+ "method": "regex"
454
+ }
455
+ self._cache.set(cache_key, "contradiction", result)
456
+ return result
457
+
458
+ result = {"has_contradiction": False, "details": None, "method": "regex"}
459
+ self._cache.set(cache_key, "contradiction", result)
460
+ return result
461
+
462
+ async def summarize_session_context(
463
+ self,
464
+ events: List[Dict[str, Any]],
465
+ current_goal: Optional[str] = None
466
+ ) -> str:
467
+ """
468
+ Generate a concise summary of session context.
469
+
470
+ Args:
471
+ events: List of timeline events
472
+ current_goal: Current session goal
473
+
474
+ Returns:
475
+ Concise summary string
476
+ """
477
+ events_str = "\n".join(
478
+ f"- [{e.get('event_type', '?')}] {e.get('summary', '')}"
479
+ for e in events[:15]
480
+ )
481
+
482
+ goal_str = f"Goal: {current_goal}" if current_goal else "No explicit goal set"
483
+
484
+ # Try LLM first
485
+ if self.use_llm and not self._degraded_mode:
486
+ prompt = f"""Summarize this session context in 2-3 sentences.
487
+
488
+ {goal_str}
489
+
490
+ Recent events:
491
+ {events_str}
492
+
493
+ Write a brief summary focusing on: what's being worked on, key decisions made, current status."""
494
+
495
+ try:
496
+ loop = asyncio.get_event_loop()
497
+
498
+ def _generate():
499
+ return self.client.generate(
500
+ model=self.model,
501
+ prompt=prompt,
502
+ options={"temperature": 0.3, "num_predict": 150}
503
+ )
504
+
505
+ response = await asyncio.wait_for(
506
+ loop.run_in_executor(None, _generate),
507
+ timeout=15.0
508
+ )
509
+ return response.get("response", "").strip()
510
+
511
+ except Exception:
512
+ pass
513
+
514
+ # Fallback
515
+ return f"Session with {len(events)} events. {goal_str}"
516
+
517
+ async def deduplicate_patterns(
518
+ self,
519
+ patterns: List[Dict[str, Any]],
520
+ embeddings_service,
521
+ threshold: float = 0.85
522
+ ) -> List[Dict[str, Any]]:
523
+ """
524
+ Remove semantically duplicate patterns using embeddings.
525
+
526
+ Args:
527
+ patterns: List of pattern dicts with 'content' field
528
+ embeddings_service: Embedding service for similarity check
529
+ threshold: Similarity threshold for considering duplicates
530
+
531
+ Returns:
532
+ Deduplicated list of patterns
533
+ """
534
+ if len(patterns) <= 1:
535
+ return patterns
536
+
537
+ unique = []
538
+ embeddings = []
539
+
540
+ for pattern in patterns:
541
+ content = pattern.get("content", "")
542
+ if not content:
543
+ continue
544
+
545
+ # Generate embedding
546
+ embedding = await embeddings_service.generate_embedding(content)
547
+ if embedding is None:
548
+ unique.append(pattern)
549
+ continue
550
+
551
+ # Check similarity with existing patterns
552
+ is_duplicate = False
553
+ for existing_emb in embeddings:
554
+ # Cosine similarity
555
+ import numpy as np
556
+ a = np.array(embedding)
557
+ b = np.array(existing_emb)
558
+ similarity = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
559
+
560
+ if similarity >= threshold:
561
+ is_duplicate = True
562
+ break
563
+
564
+ if not is_duplicate:
565
+ unique.append(pattern)
566
+ embeddings.append(embedding)
567
+
568
+ return unique
569
+
570
+ def get_stats(self) -> Dict[str, Any]:
571
+ """Get analyzer statistics."""
572
+ return {
573
+ "model": self.model,
574
+ "host": self.host,
575
+ "use_llm": self.use_llm,
576
+ "ollama_available": OLLAMA_AVAILABLE,
577
+ "degraded_mode": self._degraded_mode,
578
+ "cache": self._cache.stats()
579
+ }
580
+
581
+ def clear_cache(self):
582
+ """Clear the analysis cache."""
583
+ self._cache.clear()
584
+
585
+
586
+ # Global instance
587
+ _analyzer: Optional[LLMAnalyzer] = None
588
+
589
+
590
+ def get_analyzer() -> LLMAnalyzer:
591
+ """Get the global LLM analyzer instance."""
592
+ global _analyzer
593
+ if _analyzer is None:
594
+ _analyzer = LLMAnalyzer()
595
+ return _analyzer