ai-coding-assistant 0.5.0__py3-none-any.whl

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 (89) hide show
  1. ai_coding_assistant-0.5.0.dist-info/METADATA +226 -0
  2. ai_coding_assistant-0.5.0.dist-info/RECORD +89 -0
  3. ai_coding_assistant-0.5.0.dist-info/WHEEL +4 -0
  4. ai_coding_assistant-0.5.0.dist-info/entry_points.txt +3 -0
  5. ai_coding_assistant-0.5.0.dist-info/licenses/LICENSE +21 -0
  6. coding_assistant/__init__.py +3 -0
  7. coding_assistant/__main__.py +19 -0
  8. coding_assistant/cli/__init__.py +1 -0
  9. coding_assistant/cli/app.py +158 -0
  10. coding_assistant/cli/commands/__init__.py +19 -0
  11. coding_assistant/cli/commands/ask.py +178 -0
  12. coding_assistant/cli/commands/config.py +438 -0
  13. coding_assistant/cli/commands/diagram.py +267 -0
  14. coding_assistant/cli/commands/document.py +410 -0
  15. coding_assistant/cli/commands/explain.py +192 -0
  16. coding_assistant/cli/commands/fix.py +249 -0
  17. coding_assistant/cli/commands/index.py +162 -0
  18. coding_assistant/cli/commands/refactor.py +245 -0
  19. coding_assistant/cli/commands/search.py +182 -0
  20. coding_assistant/cli/commands/serve_docs.py +128 -0
  21. coding_assistant/cli/repl.py +381 -0
  22. coding_assistant/cli/theme.py +90 -0
  23. coding_assistant/codebase/__init__.py +1 -0
  24. coding_assistant/codebase/crawler.py +93 -0
  25. coding_assistant/codebase/parser.py +266 -0
  26. coding_assistant/config/__init__.py +25 -0
  27. coding_assistant/config/config_manager.py +615 -0
  28. coding_assistant/config/settings.py +82 -0
  29. coding_assistant/context/__init__.py +19 -0
  30. coding_assistant/context/chunker.py +443 -0
  31. coding_assistant/context/enhanced_retriever.py +322 -0
  32. coding_assistant/context/hybrid_search.py +311 -0
  33. coding_assistant/context/ranker.py +355 -0
  34. coding_assistant/context/retriever.py +119 -0
  35. coding_assistant/context/window.py +362 -0
  36. coding_assistant/documentation/__init__.py +23 -0
  37. coding_assistant/documentation/agents/__init__.py +27 -0
  38. coding_assistant/documentation/agents/coordinator.py +510 -0
  39. coding_assistant/documentation/agents/module_documenter.py +111 -0
  40. coding_assistant/documentation/agents/synthesizer.py +139 -0
  41. coding_assistant/documentation/agents/task_delegator.py +100 -0
  42. coding_assistant/documentation/decomposition/__init__.py +21 -0
  43. coding_assistant/documentation/decomposition/context_preserver.py +477 -0
  44. coding_assistant/documentation/decomposition/module_detector.py +302 -0
  45. coding_assistant/documentation/decomposition/partitioner.py +621 -0
  46. coding_assistant/documentation/generators/__init__.py +14 -0
  47. coding_assistant/documentation/generators/dataflow_generator.py +440 -0
  48. coding_assistant/documentation/generators/diagram_generator.py +511 -0
  49. coding_assistant/documentation/graph/__init__.py +13 -0
  50. coding_assistant/documentation/graph/dependency_builder.py +468 -0
  51. coding_assistant/documentation/graph/module_analyzer.py +475 -0
  52. coding_assistant/documentation/writers/__init__.py +11 -0
  53. coding_assistant/documentation/writers/markdown_writer.py +322 -0
  54. coding_assistant/embeddings/__init__.py +0 -0
  55. coding_assistant/embeddings/generator.py +89 -0
  56. coding_assistant/embeddings/store.py +187 -0
  57. coding_assistant/exceptions/__init__.py +50 -0
  58. coding_assistant/exceptions/base.py +110 -0
  59. coding_assistant/exceptions/llm.py +249 -0
  60. coding_assistant/exceptions/recovery.py +263 -0
  61. coding_assistant/exceptions/storage.py +213 -0
  62. coding_assistant/exceptions/validation.py +230 -0
  63. coding_assistant/llm/__init__.py +1 -0
  64. coding_assistant/llm/client.py +277 -0
  65. coding_assistant/llm/gemini_client.py +181 -0
  66. coding_assistant/llm/groq_client.py +160 -0
  67. coding_assistant/llm/prompts.py +98 -0
  68. coding_assistant/llm/together_client.py +160 -0
  69. coding_assistant/operations/__init__.py +13 -0
  70. coding_assistant/operations/differ.py +369 -0
  71. coding_assistant/operations/generator.py +347 -0
  72. coding_assistant/operations/linter.py +430 -0
  73. coding_assistant/operations/validator.py +406 -0
  74. coding_assistant/storage/__init__.py +9 -0
  75. coding_assistant/storage/database.py +363 -0
  76. coding_assistant/storage/session.py +231 -0
  77. coding_assistant/utils/__init__.py +31 -0
  78. coding_assistant/utils/cache.py +477 -0
  79. coding_assistant/utils/hardware.py +132 -0
  80. coding_assistant/utils/keystore.py +206 -0
  81. coding_assistant/utils/logger.py +32 -0
  82. coding_assistant/utils/progress.py +311 -0
  83. coding_assistant/validation/__init__.py +13 -0
  84. coding_assistant/validation/files.py +305 -0
  85. coding_assistant/validation/inputs.py +335 -0
  86. coding_assistant/validation/params.py +280 -0
  87. coding_assistant/validation/sanitizers.py +243 -0
  88. coding_assistant/vcs/__init__.py +5 -0
  89. coding_assistant/vcs/git.py +269 -0
@@ -0,0 +1,477 @@
1
+ """Caching system for embeddings, responses, and search results."""
2
+
3
+ import hashlib
4
+ import pickle
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Optional, Dict, List
8
+ from datetime import datetime, timedelta
9
+ from dataclasses import dataclass, asdict
10
+ import threading
11
+
12
+
13
+ @dataclass
14
+ class CacheEntry:
15
+ """Cache entry with metadata."""
16
+ key: str
17
+ value: Any
18
+ created_at: datetime
19
+ expires_at: Optional[datetime]
20
+ hits: int = 0
21
+ size_bytes: int = 0
22
+
23
+ def is_expired(self) -> bool:
24
+ """Check if entry is expired."""
25
+ if self.expires_at is None:
26
+ return False
27
+ return datetime.now() > self.expires_at
28
+
29
+ def to_dict(self) -> Dict:
30
+ """Convert to dictionary for serialization."""
31
+ return {
32
+ 'key': self.key,
33
+ 'created_at': self.created_at.isoformat(),
34
+ 'expires_at': self.expires_at.isoformat() if self.expires_at else None,
35
+ 'hits': self.hits,
36
+ 'size_bytes': self.size_bytes,
37
+ }
38
+
39
+
40
+ class Cache:
41
+ """
42
+ LRU cache with TTL support and persistence.
43
+
44
+ Features:
45
+ - In-memory caching with disk persistence
46
+ - TTL (Time To Live) support
47
+ - LRU eviction policy
48
+ - Thread-safe operations
49
+ - Cache statistics
50
+ - File hash-based invalidation
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ cache_dir: Path,
56
+ max_size_mb: int = 500,
57
+ default_ttl: int = 3600 # 1 hour
58
+ ):
59
+ """
60
+ Initialize cache.
61
+
62
+ Args:
63
+ cache_dir: Directory to store cache files
64
+ max_size_mb: Maximum cache size in MB
65
+ default_ttl: Default TTL in seconds
66
+ """
67
+ self.cache_dir = Path(cache_dir)
68
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ self.max_size_bytes = max_size_mb * 1024 * 1024
71
+ self.default_ttl = default_ttl
72
+
73
+ # In-memory cache for fast access
74
+ self._memory_cache: Dict[str, CacheEntry] = {}
75
+ self._lock = threading.Lock()
76
+
77
+ # Statistics
78
+ self.stats = {
79
+ 'hits': 0,
80
+ 'misses': 0,
81
+ 'evictions': 0,
82
+ 'size_bytes': 0
83
+ }
84
+
85
+ def get(self, key: str) -> Optional[Any]:
86
+ """
87
+ Get cached value.
88
+
89
+ Args:
90
+ key: Cache key
91
+
92
+ Returns:
93
+ Cached value or None if not found/expired
94
+ """
95
+ with self._lock:
96
+ # Check memory cache first
97
+ if key in self._memory_cache:
98
+ entry = self._memory_cache[key]
99
+
100
+ if entry.is_expired():
101
+ # Remove expired entry
102
+ self._remove_entry(key)
103
+ self.stats['misses'] += 1
104
+ return None
105
+
106
+ # Update stats
107
+ entry.hits += 1
108
+ self.stats['hits'] += 1
109
+ return entry.value
110
+
111
+ # Check disk cache
112
+ cache_file = self._get_cache_file(key)
113
+ if cache_file.exists():
114
+ try:
115
+ entry = self._load_from_disk(key)
116
+ if entry and not entry.is_expired():
117
+ # Load into memory
118
+ self._memory_cache[key] = entry
119
+ self.stats['hits'] += 1
120
+ return entry.value
121
+ else:
122
+ # Remove expired file
123
+ cache_file.unlink()
124
+ except Exception:
125
+ pass
126
+
127
+ self.stats['misses'] += 1
128
+ return None
129
+
130
+ def set(
131
+ self,
132
+ key: str,
133
+ value: Any,
134
+ ttl: Optional[int] = None
135
+ ) -> bool:
136
+ """
137
+ Set cached value with TTL.
138
+
139
+ Args:
140
+ key: Cache key
141
+ value: Value to cache
142
+ ttl: Time to live in seconds (None = use default)
143
+
144
+ Returns:
145
+ True if cached successfully
146
+ """
147
+ with self._lock:
148
+ ttl = ttl if ttl is not None else self.default_ttl
149
+
150
+ # Create cache entry
151
+ expires_at = datetime.now() + timedelta(seconds=ttl) if ttl > 0 else None
152
+
153
+ # Serialize value to get size
154
+ try:
155
+ serialized = pickle.dumps(value)
156
+ size_bytes = len(serialized)
157
+ except Exception:
158
+ return False
159
+
160
+ entry = CacheEntry(
161
+ key=key,
162
+ value=value,
163
+ created_at=datetime.now(),
164
+ expires_at=expires_at,
165
+ size_bytes=size_bytes
166
+ )
167
+
168
+ # Check if we need to evict
169
+ while self._should_evict(size_bytes):
170
+ self._evict_lru()
171
+
172
+ # Store in memory
173
+ self._memory_cache[key] = entry
174
+ self.stats['size_bytes'] += size_bytes
175
+
176
+ # Persist to disk
177
+ try:
178
+ self._save_to_disk(key, entry, serialized)
179
+ except Exception:
180
+ pass
181
+
182
+ return True
183
+
184
+ def delete(self, key: str) -> bool:
185
+ """
186
+ Delete cached value.
187
+
188
+ Args:
189
+ key: Cache key
190
+
191
+ Returns:
192
+ True if deleted
193
+ """
194
+ with self._lock:
195
+ return self._remove_entry(key)
196
+
197
+ def invalidate_pattern(self, pattern: str) -> int:
198
+ """
199
+ Invalidate cache entries matching pattern.
200
+
201
+ Args:
202
+ pattern: Glob pattern (e.g., "embeddings:*")
203
+
204
+ Returns:
205
+ Number of entries invalidated
206
+ """
207
+ count = 0
208
+
209
+ with self._lock:
210
+ # Invalidate memory cache
211
+ keys_to_remove = [
212
+ k for k in self._memory_cache.keys()
213
+ if self._matches_pattern(k, pattern)
214
+ ]
215
+
216
+ for key in keys_to_remove:
217
+ if self._remove_entry(key):
218
+ count += 1
219
+
220
+ # Invalidate disk cache
221
+ for cache_file in self.cache_dir.glob("*"):
222
+ try:
223
+ meta_file = cache_file.with_suffix('.meta')
224
+ if meta_file.exists():
225
+ with open(meta_file, 'r') as f:
226
+ meta = json.load(f)
227
+ if self._matches_pattern(meta['key'], pattern):
228
+ cache_file.unlink()
229
+ meta_file.unlink()
230
+ count += 1
231
+ except Exception:
232
+ pass
233
+
234
+ return count
235
+
236
+ def clear(self) -> None:
237
+ """Clear all cache entries."""
238
+ with self._lock:
239
+ self._memory_cache.clear()
240
+ self.stats['size_bytes'] = 0
241
+ self.stats['evictions'] = 0
242
+
243
+ # Clear disk cache
244
+ for cache_file in self.cache_dir.glob("*"):
245
+ try:
246
+ cache_file.unlink()
247
+ except Exception:
248
+ pass
249
+
250
+ def get_stats(self) -> Dict[str, Any]:
251
+ """
252
+ Get cache statistics.
253
+
254
+ Returns:
255
+ Dictionary with cache stats
256
+ """
257
+ with self._lock:
258
+ total = self.stats['hits'] + self.stats['misses']
259
+ hit_rate = (self.stats['hits'] / total * 100) if total > 0 else 0
260
+
261
+ return {
262
+ 'hits': self.stats['hits'],
263
+ 'misses': self.stats['misses'],
264
+ 'hit_rate': f"{hit_rate:.1f}%",
265
+ 'entries': len(self._memory_cache),
266
+ 'size_mb': self.stats['size_bytes'] / (1024 * 1024),
267
+ 'max_size_mb': self.max_size_bytes / (1024 * 1024),
268
+ 'evictions': self.stats['evictions'],
269
+ }
270
+
271
+ def _should_evict(self, new_size: int) -> bool:
272
+ """Check if we need to evict entries."""
273
+ return self.stats['size_bytes'] + new_size > self.max_size_bytes
274
+
275
+ def _evict_lru(self) -> None:
276
+ """Evict least recently used entry."""
277
+ if not self._memory_cache:
278
+ return
279
+
280
+ # Find LRU entry (lowest hits, oldest)
281
+ lru_key = min(
282
+ self._memory_cache.keys(),
283
+ key=lambda k: (
284
+ self._memory_cache[k].hits,
285
+ self._memory_cache[k].created_at
286
+ )
287
+ )
288
+
289
+ self._remove_entry(lru_key)
290
+ self.stats['evictions'] += 1
291
+
292
+ def _remove_entry(self, key: str) -> bool:
293
+ """Remove entry from cache."""
294
+ if key in self._memory_cache:
295
+ entry = self._memory_cache.pop(key)
296
+ self.stats['size_bytes'] -= entry.size_bytes
297
+
298
+ # Remove from disk
299
+ cache_file = self._get_cache_file(key)
300
+ meta_file = cache_file.with_suffix('.meta')
301
+
302
+ try:
303
+ if cache_file.exists():
304
+ cache_file.unlink()
305
+ if meta_file.exists():
306
+ meta_file.unlink()
307
+ except Exception:
308
+ pass
309
+
310
+ return True
311
+
312
+ return False
313
+
314
+ def _get_cache_file(self, key: str) -> Path:
315
+ """Get cache file path for key."""
316
+ key_hash = hashlib.sha256(key.encode()).hexdigest()
317
+ return self.cache_dir / f"{key_hash}.cache"
318
+
319
+ def _save_to_disk(self, key: str, entry: CacheEntry, serialized: bytes) -> None:
320
+ """Save cache entry to disk."""
321
+ cache_file = self._get_cache_file(key)
322
+ meta_file = cache_file.with_suffix('.meta')
323
+
324
+ # Save data
325
+ with open(cache_file, 'wb') as f:
326
+ f.write(serialized)
327
+
328
+ # Save metadata
329
+ with open(meta_file, 'w') as f:
330
+ json.dump(entry.to_dict(), f)
331
+
332
+ def _load_from_disk(self, key: str) -> Optional[CacheEntry]:
333
+ """Load cache entry from disk."""
334
+ cache_file = self._get_cache_file(key)
335
+ meta_file = cache_file.with_suffix('.meta')
336
+
337
+ if not cache_file.exists() or not meta_file.exists():
338
+ return None
339
+
340
+ try:
341
+ # Load metadata
342
+ with open(meta_file, 'r') as f:
343
+ meta = json.load(f)
344
+
345
+ # Load data
346
+ with open(cache_file, 'rb') as f:
347
+ value = pickle.load(f)
348
+
349
+ # Reconstruct entry
350
+ entry = CacheEntry(
351
+ key=key,
352
+ value=value,
353
+ created_at=datetime.fromisoformat(meta['created_at']),
354
+ expires_at=datetime.fromisoformat(meta['expires_at']) if meta['expires_at'] else None,
355
+ hits=meta['hits'],
356
+ size_bytes=meta['size_bytes']
357
+ )
358
+
359
+ return entry
360
+
361
+ except Exception:
362
+ return None
363
+
364
+ def _matches_pattern(self, key: str, pattern: str) -> bool:
365
+ """Check if key matches glob pattern."""
366
+ import fnmatch
367
+ return fnmatch.fnmatch(key, pattern)
368
+
369
+
370
+ class EmbeddingCache(Cache):
371
+ """Specialized cache for code embeddings."""
372
+
373
+ def __init__(self, cache_dir: Path):
374
+ super().__init__(
375
+ cache_dir=cache_dir / "embeddings",
376
+ max_size_mb=1000, # 1GB for embeddings
377
+ default_ttl=0 # No expiration for embeddings
378
+ )
379
+
380
+ def get_embedding(self, file_path: str, file_hash: str) -> Optional[List[float]]:
381
+ """
382
+ Get cached embedding for file.
383
+
384
+ Args:
385
+ file_path: Path to file
386
+ file_hash: Hash of file content
387
+
388
+ Returns:
389
+ Embedding vector or None
390
+ """
391
+ key = f"{file_path}:{file_hash}"
392
+ return self.get(key)
393
+
394
+ def set_embedding(self, file_path: str, file_hash: str, embedding: List[float]) -> bool:
395
+ """
396
+ Cache embedding for file.
397
+
398
+ Args:
399
+ file_path: Path to file
400
+ file_hash: Hash of file content
401
+ embedding: Embedding vector
402
+
403
+ Returns:
404
+ True if cached successfully
405
+ """
406
+ key = f"{file_path}:{file_hash}"
407
+ return self.set(key, embedding, ttl=0) # No expiration
408
+
409
+ def invalidate_file(self, file_path: str) -> int:
410
+ """Invalidate cache for specific file."""
411
+ return self.invalidate_pattern(f"{file_path}:*")
412
+
413
+
414
+ class ResponseCache(Cache):
415
+ """Specialized cache for LLM responses."""
416
+
417
+ def __init__(self, cache_dir: Path):
418
+ super().__init__(
419
+ cache_dir=cache_dir / "responses",
420
+ max_size_mb=200, # 200MB for responses
421
+ default_ttl=1800 # 30 minutes
422
+ )
423
+
424
+ def get_response(self, query: str, context_hash: str) -> Optional[str]:
425
+ """
426
+ Get cached response.
427
+
428
+ Args:
429
+ query: User query
430
+ context_hash: Hash of context chunks
431
+
432
+ Returns:
433
+ Cached response or None
434
+ """
435
+ key = self._make_key(query, context_hash)
436
+ return self.get(key)
437
+
438
+ def set_response(self, query: str, context_hash: str, response: str, ttl: int = 1800) -> bool:
439
+ """
440
+ Cache response.
441
+
442
+ Args:
443
+ query: User query
444
+ context_hash: Hash of context chunks
445
+ response: LLM response
446
+ ttl: Time to live in seconds
447
+
448
+ Returns:
449
+ True if cached successfully
450
+ """
451
+ key = self._make_key(query, context_hash)
452
+ return self.set(key, response, ttl=ttl)
453
+
454
+ def _make_key(self, query: str, context_hash: str) -> str:
455
+ """Create cache key from query and context."""
456
+ query_hash = hashlib.md5(query.encode()).hexdigest()[:16]
457
+ return f"response:{query_hash}:{context_hash}"
458
+
459
+
460
+ def compute_context_hash(chunks: List[Dict]) -> str:
461
+ """
462
+ Compute hash of context chunks for cache keying.
463
+
464
+ Args:
465
+ chunks: List of context chunks
466
+
467
+ Returns:
468
+ Hash string
469
+ """
470
+ # Create deterministic string from chunks
471
+ chunk_strings = []
472
+ for chunk in chunks:
473
+ chunk_str = f"{chunk.get('file', '')}:{chunk.get('start_line', 0)}"
474
+ chunk_strings.append(chunk_str)
475
+
476
+ combined = "|".join(sorted(chunk_strings))
477
+ return hashlib.md5(combined.encode()).hexdigest()[:16]
@@ -0,0 +1,132 @@
1
+ """Hardware detection for LLM provider recommendations."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+ import psutil
6
+
7
+
8
+ @dataclass
9
+ class HardwareInfo:
10
+ """Hardware information and recommendations."""
11
+
12
+ total_ram_gb: float
13
+ available_ram_gb: float
14
+ recommended_provider: str
15
+ recommended_model: str
16
+ can_run_local: bool
17
+
18
+ def to_dict(self):
19
+ """Convert to dictionary."""
20
+ return {
21
+ 'total_ram_gb': self.total_ram_gb,
22
+ 'available_ram_gb': self.available_ram_gb,
23
+ 'recommended_provider': self.recommended_provider,
24
+ 'recommended_model': self.recommended_model,
25
+ 'can_run_local': self.can_run_local
26
+ }
27
+
28
+
29
+ class HardwareDetector:
30
+ """
31
+ Hardware detection for optimal LLM provider selection.
32
+
33
+ Detects available RAM and recommends the best provider based on hardware capabilities.
34
+ """
35
+
36
+ # RAM thresholds in GB
37
+ MIN_RAM_FOR_7B = 8.0
38
+ MIN_RAM_FOR_32B = 32.0
39
+
40
+ @staticmethod
41
+ def get_hardware_info() -> HardwareInfo:
42
+ """
43
+ Get hardware information and provider recommendations.
44
+
45
+ Returns:
46
+ HardwareInfo with system specs and recommendations
47
+ """
48
+ # Get memory info
49
+ mem = psutil.virtual_memory()
50
+ total_ram_gb = mem.total / (1024 ** 3)
51
+ available_ram_gb = mem.available / (1024 ** 3)
52
+
53
+ # Determine recommendations based on available RAM
54
+ if total_ram_gb >= HardwareDetector.MIN_RAM_FOR_32B:
55
+ recommended_provider = "ollama"
56
+ recommended_model = "qwen2.5-coder:32b"
57
+ can_run_local = True
58
+ elif total_ram_gb >= HardwareDetector.MIN_RAM_FOR_7B:
59
+ recommended_provider = "ollama"
60
+ recommended_model = "qwen2.5-coder:7b"
61
+ can_run_local = True
62
+ else:
63
+ recommended_provider = "groq"
64
+ recommended_model = "llama-3.3-70b-versatile"
65
+ can_run_local = False
66
+
67
+ return HardwareInfo(
68
+ total_ram_gb=total_ram_gb,
69
+ available_ram_gb=available_ram_gb,
70
+ recommended_provider=recommended_provider,
71
+ recommended_model=recommended_model,
72
+ can_run_local=can_run_local
73
+ )
74
+
75
+ @staticmethod
76
+ def format_recommendation(info: HardwareInfo) -> str:
77
+ """
78
+ Format hardware info as user-friendly message.
79
+
80
+ Args:
81
+ info: Hardware information
82
+
83
+ Returns:
84
+ Formatted recommendation message
85
+ """
86
+ message = f"💻 System RAM: {info.total_ram_gb:.1f}GB total, {info.available_ram_gb:.1f}GB available\n\n"
87
+
88
+ if info.can_run_local:
89
+ message += f"✓ Your system can run local models!\n"
90
+ message += f" Recommended: {info.recommended_provider} with {info.recommended_model}\n\n"
91
+ message += "To get started:\n"
92
+ message += f" 1. Install Ollama: curl -fsSL https://ollama.com/install.sh | sh\n"
93
+ message += f" 2. Pull model: ollama pull {info.recommended_model}\n"
94
+ message += f" 3. Start using: assistant ask \"your question\"\n\n"
95
+ message += "Alternative: Use cloud providers (Groq free tier, Together AI free trial)\n"
96
+ message += " Run: assistant config set-api-key groq <your-key>"
97
+ else:
98
+ message += f"⚠️ Limited RAM detected ({info.total_ram_gb:.1f}GB)\n"
99
+ message += f" Local models need {HardwareDetector.MIN_RAM_FOR_7B}GB+ RAM\n\n"
100
+ message += f"✓ Recommended: Use cloud provider ({info.recommended_provider})\n"
101
+ message += f" Model: {info.recommended_model}\n\n"
102
+ message += "To get started with Groq (FREE tier):\n"
103
+ message += " 1. Get API key: https://console.groq.com\n"
104
+ message += " 2. Set key: assistant config set-api-key groq <your-key>\n"
105
+ message += " 3. Start using: assistant ask \"your question\"\n\n"
106
+ message += "Alternative: Together AI ($25 free trial)\n"
107
+ message += " 1. Get API key: https://api.together.xyz\n"
108
+ message += " 2. Set key: assistant config set-api-key together <your-key>"
109
+
110
+ return message
111
+
112
+ @staticmethod
113
+ def get_recommended_provider() -> str:
114
+ """
115
+ Get recommended provider name based on hardware.
116
+
117
+ Returns:
118
+ Provider name (ollama, groq, or together)
119
+ """
120
+ info = HardwareDetector.get_hardware_info()
121
+ return info.recommended_provider
122
+
123
+ @staticmethod
124
+ def can_run_local_models() -> bool:
125
+ """
126
+ Check if system can run local LLM models.
127
+
128
+ Returns:
129
+ True if system has sufficient RAM for local models
130
+ """
131
+ info = HardwareDetector.get_hardware_info()
132
+ return info.can_run_local