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
package/services/auth.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""API authentication service for the Memory Agent.
|
|
2
|
+
|
|
3
|
+
Provides API key-based authentication with:
|
|
4
|
+
- Key generation and storage
|
|
5
|
+
- Request validation via X-Memory-Key header
|
|
6
|
+
- Rate limiting per key
|
|
7
|
+
- Key rotation support
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import secrets
|
|
13
|
+
import hashlib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Any, Optional, List, Tuple
|
|
16
|
+
from threading import Lock
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
load_dotenv()
|
|
21
|
+
|
|
22
|
+
# Configuration
|
|
23
|
+
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "false").lower() == "true" # Default: disabled for local use
|
|
24
|
+
KEY_FILE = os.getenv("AUTH_KEY_FILE", str(Path.home() / ".claude" / "memory-agent-keys.json"))
|
|
25
|
+
DEFAULT_RATE_LIMIT = int(os.getenv("AUTH_RATE_LIMIT", "100")) # requests per minute
|
|
26
|
+
RATE_LIMIT_WINDOW = int(os.getenv("AUTH_RATE_WINDOW", "60")) # seconds
|
|
27
|
+
|
|
28
|
+
# Endpoints that don't require authentication
|
|
29
|
+
EXEMPT_ENDPOINTS = [
|
|
30
|
+
"/health",
|
|
31
|
+
"/health/live",
|
|
32
|
+
"/ready",
|
|
33
|
+
"/.well-known/agent.json",
|
|
34
|
+
"/docs",
|
|
35
|
+
"/openapi.json",
|
|
36
|
+
"/api/auth/stats", # Allow checking auth status without key
|
|
37
|
+
"/dashboard", # Dashboard needs initial access
|
|
38
|
+
"/favicon.ico",
|
|
39
|
+
# Dashboard API endpoints
|
|
40
|
+
"/api/stats",
|
|
41
|
+
"/api/projects",
|
|
42
|
+
"/api/agents",
|
|
43
|
+
"/api/mcps",
|
|
44
|
+
"/api/hooks",
|
|
45
|
+
"/api/sessions",
|
|
46
|
+
"/ws", # WebSocket
|
|
47
|
+
"/a2a", # Agent-to-Agent protocol (dashboard uses this)
|
|
48
|
+
"/api/project/", # Project config endpoints
|
|
49
|
+
# Automation endpoints
|
|
50
|
+
"/api/inject",
|
|
51
|
+
"/api/memory/natural",
|
|
52
|
+
"/api/memory/", # Covers confidence, verify, outdated
|
|
53
|
+
"/api/claude-md",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RateLimiter:
|
|
58
|
+
"""Simple sliding window rate limiter."""
|
|
59
|
+
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self._requests: Dict[str, List[float]] = {}
|
|
62
|
+
self._lock = Lock()
|
|
63
|
+
|
|
64
|
+
def is_allowed(self, key: str, limit: int = DEFAULT_RATE_LIMIT, window: int = RATE_LIMIT_WINDOW) -> Tuple[bool, int]:
|
|
65
|
+
"""Check if a request is allowed under rate limits.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
key: The API key
|
|
69
|
+
limit: Maximum requests per window
|
|
70
|
+
window: Window size in seconds
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (allowed, remaining_requests)
|
|
74
|
+
"""
|
|
75
|
+
now = time.time()
|
|
76
|
+
with self._lock:
|
|
77
|
+
# Initialize or get request list
|
|
78
|
+
if key not in self._requests:
|
|
79
|
+
self._requests[key] = []
|
|
80
|
+
|
|
81
|
+
# Remove expired requests
|
|
82
|
+
cutoff = now - window
|
|
83
|
+
self._requests[key] = [t for t in self._requests[key] if t > cutoff]
|
|
84
|
+
|
|
85
|
+
# Check limit
|
|
86
|
+
current_count = len(self._requests[key])
|
|
87
|
+
if current_count >= limit:
|
|
88
|
+
return False, 0
|
|
89
|
+
|
|
90
|
+
# Record this request
|
|
91
|
+
self._requests[key].append(now)
|
|
92
|
+
return True, limit - current_count - 1
|
|
93
|
+
|
|
94
|
+
def get_stats(self, key: str) -> Dict[str, Any]:
|
|
95
|
+
"""Get rate limit stats for a key."""
|
|
96
|
+
now = time.time()
|
|
97
|
+
with self._lock:
|
|
98
|
+
requests = self._requests.get(key, [])
|
|
99
|
+
recent = [t for t in requests if t > now - RATE_LIMIT_WINDOW]
|
|
100
|
+
return {
|
|
101
|
+
"current_count": len(recent),
|
|
102
|
+
"limit": DEFAULT_RATE_LIMIT,
|
|
103
|
+
"window_seconds": RATE_LIMIT_WINDOW,
|
|
104
|
+
"remaining": max(0, DEFAULT_RATE_LIMIT - len(recent))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AuthService:
|
|
109
|
+
"""API key authentication service.
|
|
110
|
+
|
|
111
|
+
Features:
|
|
112
|
+
- Secure key generation
|
|
113
|
+
- Key storage in JSON file
|
|
114
|
+
- Multiple keys support (for different clients)
|
|
115
|
+
- Rate limiting per key
|
|
116
|
+
- Key rotation
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, key_file: str = KEY_FILE):
|
|
120
|
+
self.key_file = Path(key_file)
|
|
121
|
+
self.enabled = AUTH_ENABLED
|
|
122
|
+
self._keys: Dict[str, Dict[str, Any]] = {}
|
|
123
|
+
self._rate_limiter = RateLimiter()
|
|
124
|
+
self._lock = Lock()
|
|
125
|
+
self._load_keys()
|
|
126
|
+
|
|
127
|
+
def _load_keys(self):
|
|
128
|
+
"""Load keys from file."""
|
|
129
|
+
if self.key_file.exists():
|
|
130
|
+
try:
|
|
131
|
+
with open(self.key_file, 'r') as f:
|
|
132
|
+
data = json.load(f)
|
|
133
|
+
self._keys = data.get("keys", {})
|
|
134
|
+
except (json.JSONDecodeError, IOError):
|
|
135
|
+
self._keys = {}
|
|
136
|
+
|
|
137
|
+
# Generate default key if none exist
|
|
138
|
+
if not self._keys and self.enabled:
|
|
139
|
+
self.generate_key("default", "Default API key")
|
|
140
|
+
|
|
141
|
+
def _save_keys(self):
|
|
142
|
+
"""Save keys to file."""
|
|
143
|
+
self.key_file.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
with open(self.key_file, 'w') as f:
|
|
145
|
+
json.dump({
|
|
146
|
+
"keys": self._keys,
|
|
147
|
+
"updated_at": datetime.now().isoformat()
|
|
148
|
+
}, f, indent=2)
|
|
149
|
+
# Set restrictive permissions (owner read/write only)
|
|
150
|
+
try:
|
|
151
|
+
os.chmod(self.key_file, 0o600)
|
|
152
|
+
except OSError:
|
|
153
|
+
pass # Windows may not support this
|
|
154
|
+
|
|
155
|
+
def _hash_key(self, key: str) -> str:
|
|
156
|
+
"""Hash an API key for storage."""
|
|
157
|
+
return hashlib.sha256(key.encode()).hexdigest()
|
|
158
|
+
|
|
159
|
+
def generate_key(self, name: str, description: str = "", rate_limit: int = DEFAULT_RATE_LIMIT) -> str:
|
|
160
|
+
"""Generate a new API key.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
name: Unique name for the key
|
|
164
|
+
description: Description of what this key is for
|
|
165
|
+
rate_limit: Custom rate limit for this key
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The generated API key (only returned once!)
|
|
169
|
+
"""
|
|
170
|
+
with self._lock:
|
|
171
|
+
# Generate a secure random key
|
|
172
|
+
key = f"mem_{secrets.token_urlsafe(32)}"
|
|
173
|
+
key_hash = self._hash_key(key)
|
|
174
|
+
|
|
175
|
+
self._keys[key_hash] = {
|
|
176
|
+
"name": name,
|
|
177
|
+
"description": description,
|
|
178
|
+
"rate_limit": rate_limit,
|
|
179
|
+
"created_at": datetime.now().isoformat(),
|
|
180
|
+
"last_used": None,
|
|
181
|
+
"use_count": 0,
|
|
182
|
+
"revoked": False
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
self._save_keys()
|
|
186
|
+
return key
|
|
187
|
+
|
|
188
|
+
def validate_key(self, key: str) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
|
189
|
+
"""Validate an API key.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
key: The API key to validate
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Tuple of (valid, error_message, key_info)
|
|
196
|
+
"""
|
|
197
|
+
if not self.enabled:
|
|
198
|
+
return True, None, {"name": "auth_disabled"}
|
|
199
|
+
|
|
200
|
+
if not key:
|
|
201
|
+
return False, "Missing API key", None
|
|
202
|
+
|
|
203
|
+
key_hash = self._hash_key(key)
|
|
204
|
+
|
|
205
|
+
with self._lock:
|
|
206
|
+
if key_hash not in self._keys:
|
|
207
|
+
return False, "Invalid API key", None
|
|
208
|
+
|
|
209
|
+
key_info = self._keys[key_hash]
|
|
210
|
+
|
|
211
|
+
if key_info.get("revoked"):
|
|
212
|
+
return False, "API key has been revoked", None
|
|
213
|
+
|
|
214
|
+
# Check rate limit
|
|
215
|
+
rate_limit = key_info.get("rate_limit", DEFAULT_RATE_LIMIT)
|
|
216
|
+
allowed, remaining = self._rate_limiter.is_allowed(key_hash, rate_limit)
|
|
217
|
+
|
|
218
|
+
if not allowed:
|
|
219
|
+
return False, "Rate limit exceeded", None
|
|
220
|
+
|
|
221
|
+
# Update usage stats
|
|
222
|
+
key_info["last_used"] = datetime.now().isoformat()
|
|
223
|
+
key_info["use_count"] = key_info.get("use_count", 0) + 1
|
|
224
|
+
self._keys[key_hash] = key_info
|
|
225
|
+
|
|
226
|
+
return True, None, {
|
|
227
|
+
"name": key_info["name"],
|
|
228
|
+
"rate_remaining": remaining
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def revoke_key(self, name: str) -> bool:
|
|
232
|
+
"""Revoke a key by name.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
name: Name of the key to revoke
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
True if key was found and revoked
|
|
239
|
+
"""
|
|
240
|
+
with self._lock:
|
|
241
|
+
for key_hash, info in self._keys.items():
|
|
242
|
+
if info["name"] == name:
|
|
243
|
+
info["revoked"] = True
|
|
244
|
+
info["revoked_at"] = datetime.now().isoformat()
|
|
245
|
+
self._keys[key_hash] = info
|
|
246
|
+
self._save_keys()
|
|
247
|
+
return True
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def rotate_key(self, name: str) -> Optional[str]:
|
|
251
|
+
"""Rotate a key (revoke old, generate new with same name).
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
name: Name of the key to rotate
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
New API key, or None if key not found
|
|
258
|
+
"""
|
|
259
|
+
with self._lock:
|
|
260
|
+
# Find and revoke old key
|
|
261
|
+
old_info = None
|
|
262
|
+
for key_hash, info in self._keys.items():
|
|
263
|
+
if info["name"] == name and not info.get("revoked"):
|
|
264
|
+
info["revoked"] = True
|
|
265
|
+
info["revoked_at"] = datetime.now().isoformat()
|
|
266
|
+
old_info = info
|
|
267
|
+
break
|
|
268
|
+
|
|
269
|
+
if old_info is None:
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
# Generate new key with same settings
|
|
273
|
+
return self.generate_key(
|
|
274
|
+
name=name,
|
|
275
|
+
description=old_info.get("description", ""),
|
|
276
|
+
rate_limit=old_info.get("rate_limit", DEFAULT_RATE_LIMIT)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def list_keys(self) -> List[Dict[str, Any]]:
|
|
280
|
+
"""List all keys (without the actual key values)."""
|
|
281
|
+
with self._lock:
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
"name": info["name"],
|
|
285
|
+
"description": info.get("description", ""),
|
|
286
|
+
"created_at": info["created_at"],
|
|
287
|
+
"last_used": info.get("last_used"),
|
|
288
|
+
"use_count": info.get("use_count", 0),
|
|
289
|
+
"rate_limit": info.get("rate_limit", DEFAULT_RATE_LIMIT),
|
|
290
|
+
"revoked": info.get("revoked", False)
|
|
291
|
+
}
|
|
292
|
+
for info in self._keys.values()
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
def is_exempt(self, path: str) -> bool:
|
|
296
|
+
"""Check if a path is exempt from authentication."""
|
|
297
|
+
return any(path.startswith(exempt) for exempt in EXEMPT_ENDPOINTS)
|
|
298
|
+
|
|
299
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
300
|
+
"""Get authentication statistics."""
|
|
301
|
+
with self._lock:
|
|
302
|
+
active_keys = sum(1 for k in self._keys.values() if not k.get("revoked"))
|
|
303
|
+
revoked_keys = sum(1 for k in self._keys.values() if k.get("revoked"))
|
|
304
|
+
total_uses = sum(k.get("use_count", 0) for k in self._keys.values())
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"enabled": self.enabled,
|
|
308
|
+
"active_keys": active_keys,
|
|
309
|
+
"revoked_keys": revoked_keys,
|
|
310
|
+
"total_uses": total_uses,
|
|
311
|
+
"rate_limit_default": DEFAULT_RATE_LIMIT,
|
|
312
|
+
"rate_limit_window": RATE_LIMIT_WINDOW,
|
|
313
|
+
"key_file": str(self.key_file)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# Global instance
|
|
318
|
+
_auth: Optional[AuthService] = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_auth_service() -> AuthService:
|
|
322
|
+
"""Get the global auth service instance."""
|
|
323
|
+
global _auth
|
|
324
|
+
if _auth is None:
|
|
325
|
+
_auth = AuthService()
|
|
326
|
+
return _auth
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def validate_request(key: str) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
|
330
|
+
"""Convenience function to validate a request."""
|
|
331
|
+
return get_auth_service().validate_key(key)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Auto-injection service for mid-task relevance.
|
|
2
|
+
|
|
3
|
+
Analyzes current context and automatically retrieves relevant memories.
|
|
4
|
+
Can be called periodically or triggered by specific events.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from typing import Dict, Any, List, Optional, Set
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class InjectionContext:
|
|
15
|
+
"""Tracks what has been injected to avoid repetition."""
|
|
16
|
+
injected_memory_ids: Set[int] = field(default_factory=set)
|
|
17
|
+
last_query: str = ""
|
|
18
|
+
last_injection_time: float = 0
|
|
19
|
+
injection_count: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AutoInjector:
|
|
23
|
+
"""Automatically injects relevant context during tasks.
|
|
24
|
+
|
|
25
|
+
Features:
|
|
26
|
+
- Analyzes current task/query for keywords
|
|
27
|
+
- Searches memories for relevant context
|
|
28
|
+
- Avoids injecting the same content twice
|
|
29
|
+
- Rate-limits injections to avoid noise
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, db, embeddings):
|
|
33
|
+
self.db = db
|
|
34
|
+
self.embeddings = embeddings
|
|
35
|
+
self._context = InjectionContext()
|
|
36
|
+
self._min_injection_interval = 30 # seconds between injections
|
|
37
|
+
self._max_injections_per_session = 20
|
|
38
|
+
|
|
39
|
+
def _extract_keywords(self, text: str) -> List[str]:
|
|
40
|
+
"""Extract meaningful keywords from text."""
|
|
41
|
+
# Remove common words
|
|
42
|
+
stop_words = {
|
|
43
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
44
|
+
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will',
|
|
45
|
+
'would', 'could', 'should', 'may', 'might', 'must', 'can',
|
|
46
|
+
'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she',
|
|
47
|
+
'it', 'we', 'they', 'what', 'which', 'who', 'whom', 'how',
|
|
48
|
+
'when', 'where', 'why', 'all', 'each', 'every', 'both',
|
|
49
|
+
'few', 'more', 'most', 'other', 'some', 'such', 'no', 'not',
|
|
50
|
+
'only', 'same', 'so', 'than', 'too', 'very', 'just', 'and',
|
|
51
|
+
'but', 'or', 'if', 'then', 'else', 'for', 'of', 'to', 'in',
|
|
52
|
+
'on', 'at', 'by', 'from', 'with', 'about', 'into', 'through',
|
|
53
|
+
'please', 'help', 'me', 'want', 'need', 'let', 'make', 'get'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Extract words
|
|
57
|
+
words = re.findall(r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', text.lower())
|
|
58
|
+
|
|
59
|
+
# Filter and prioritize
|
|
60
|
+
keywords = []
|
|
61
|
+
for word in words:
|
|
62
|
+
if word not in stop_words and len(word) > 2:
|
|
63
|
+
keywords.append(word)
|
|
64
|
+
|
|
65
|
+
# Deduplicate while preserving order
|
|
66
|
+
seen = set()
|
|
67
|
+
unique_keywords = []
|
|
68
|
+
for kw in keywords:
|
|
69
|
+
if kw not in seen:
|
|
70
|
+
seen.add(kw)
|
|
71
|
+
unique_keywords.append(kw)
|
|
72
|
+
|
|
73
|
+
return unique_keywords[:10] # Top 10 keywords
|
|
74
|
+
|
|
75
|
+
def _should_inject(self, query: str) -> bool:
|
|
76
|
+
"""Determine if we should inject context now."""
|
|
77
|
+
now = time.time()
|
|
78
|
+
|
|
79
|
+
# Rate limit
|
|
80
|
+
if now - self._context.last_injection_time < self._min_injection_interval:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
# Max injections per session
|
|
84
|
+
if self._context.injection_count >= self._max_injections_per_session:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Skip if query is too similar to last
|
|
88
|
+
if query and self._context.last_query:
|
|
89
|
+
# Simple similarity check
|
|
90
|
+
query_words = set(query.lower().split())
|
|
91
|
+
last_words = set(self._context.last_query.lower().split())
|
|
92
|
+
overlap = len(query_words & last_words) / max(len(query_words), 1)
|
|
93
|
+
if overlap > 0.8:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
async def get_relevant_context(
|
|
99
|
+
self,
|
|
100
|
+
current_query: str,
|
|
101
|
+
project_path: Optional[str] = None,
|
|
102
|
+
task_type: Optional[str] = None,
|
|
103
|
+
max_results: int = 3
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
"""Get relevant context for the current task.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
current_query: The current user query or task description
|
|
109
|
+
project_path: Current project path
|
|
110
|
+
task_type: Type of task (debug, implement, refactor, etc.)
|
|
111
|
+
max_results: Maximum context items to return
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Dict with relevant memories, patterns, and suggestions
|
|
115
|
+
"""
|
|
116
|
+
if not self._should_inject(current_query):
|
|
117
|
+
return {"injected": False, "reason": "rate_limited"}
|
|
118
|
+
|
|
119
|
+
keywords = self._extract_keywords(current_query)
|
|
120
|
+
if not keywords:
|
|
121
|
+
return {"injected": False, "reason": "no_keywords"}
|
|
122
|
+
|
|
123
|
+
search_query = " ".join(keywords)
|
|
124
|
+
results = {
|
|
125
|
+
"injected": True,
|
|
126
|
+
"keywords": keywords,
|
|
127
|
+
"memories": [],
|
|
128
|
+
"patterns": [],
|
|
129
|
+
"warnings": []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# 1. Search for relevant memories
|
|
133
|
+
try:
|
|
134
|
+
from skills.search import semantic_search
|
|
135
|
+
memories = await semantic_search(
|
|
136
|
+
db=self.db,
|
|
137
|
+
embeddings=self.embeddings,
|
|
138
|
+
query=search_query,
|
|
139
|
+
project_path=project_path,
|
|
140
|
+
limit=max_results * 2, # Get more, filter later
|
|
141
|
+
threshold=0.6 # Higher threshold for relevance
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if memories and memories.get("results"):
|
|
145
|
+
for mem in memories["results"]:
|
|
146
|
+
mem_id = mem.get("id")
|
|
147
|
+
if mem_id and mem_id not in self._context.injected_memory_ids:
|
|
148
|
+
results["memories"].append({
|
|
149
|
+
"content": mem["content"][:300],
|
|
150
|
+
"type": mem.get("type"),
|
|
151
|
+
"relevance": mem.get("similarity", 0)
|
|
152
|
+
})
|
|
153
|
+
self._context.injected_memory_ids.add(mem_id)
|
|
154
|
+
|
|
155
|
+
if len(results["memories"]) >= max_results:
|
|
156
|
+
break
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# 2. Search for relevant patterns
|
|
161
|
+
try:
|
|
162
|
+
from skills.search import search_patterns
|
|
163
|
+
patterns = await search_patterns(
|
|
164
|
+
db=self.db,
|
|
165
|
+
embeddings=self.embeddings,
|
|
166
|
+
query=search_query,
|
|
167
|
+
limit=2,
|
|
168
|
+
threshold=0.6
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if patterns and patterns.get("patterns"):
|
|
172
|
+
for pat in patterns["patterns"][:2]:
|
|
173
|
+
results["patterns"].append({
|
|
174
|
+
"name": pat.get("name"),
|
|
175
|
+
"solution": pat.get("solution", "")[:200]
|
|
176
|
+
})
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
# 3. Check for relevant errors (warnings)
|
|
181
|
+
if task_type in ["debug", "fix", "error"]:
|
|
182
|
+
try:
|
|
183
|
+
errors = await semantic_search(
|
|
184
|
+
db=self.db,
|
|
185
|
+
embeddings=self.embeddings,
|
|
186
|
+
query=search_query,
|
|
187
|
+
project_path=project_path,
|
|
188
|
+
memory_type="error",
|
|
189
|
+
success_only=True,
|
|
190
|
+
limit=2,
|
|
191
|
+
threshold=0.65
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if errors and errors.get("results"):
|
|
195
|
+
for err in errors["results"]:
|
|
196
|
+
results["warnings"].append({
|
|
197
|
+
"past_error": err["content"][:200],
|
|
198
|
+
"had_solution": err.get("success", False)
|
|
199
|
+
})
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
# Update context tracking
|
|
204
|
+
self._context.last_query = current_query
|
|
205
|
+
self._context.last_injection_time = time.time()
|
|
206
|
+
self._context.injection_count += 1
|
|
207
|
+
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
def format_injection(self, context: Dict[str, Any]) -> str:
|
|
211
|
+
"""Format context for injection into conversation."""
|
|
212
|
+
if not context.get("injected"):
|
|
213
|
+
return ""
|
|
214
|
+
|
|
215
|
+
parts = []
|
|
216
|
+
|
|
217
|
+
if context.get("memories"):
|
|
218
|
+
parts.append("**Relevant from memory:**")
|
|
219
|
+
for mem in context["memories"]:
|
|
220
|
+
parts.append(f"- [{mem['type']}] {mem['content']}")
|
|
221
|
+
|
|
222
|
+
if context.get("patterns"):
|
|
223
|
+
parts.append("\n**Useful patterns:**")
|
|
224
|
+
for pat in context["patterns"]:
|
|
225
|
+
parts.append(f"- **{pat['name']}**: {pat['solution']}")
|
|
226
|
+
|
|
227
|
+
if context.get("warnings"):
|
|
228
|
+
parts.append("\n**Past related errors:**")
|
|
229
|
+
for warn in context["warnings"]:
|
|
230
|
+
parts.append(f"- {warn['past_error']}")
|
|
231
|
+
|
|
232
|
+
if parts:
|
|
233
|
+
return "\n".join(parts)
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
def reset_session(self):
|
|
237
|
+
"""Reset injection tracking for new session."""
|
|
238
|
+
self._context = InjectionContext()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# Global injector instance
|
|
242
|
+
_injector: Optional[AutoInjector] = None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_auto_injector(db, embeddings) -> AutoInjector:
|
|
246
|
+
"""Get the global auto-injector instance."""
|
|
247
|
+
global _injector
|
|
248
|
+
if _injector is None:
|
|
249
|
+
_injector = AutoInjector(db, embeddings)
|
|
250
|
+
return _injector
|