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,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
|