hanuscode 1.0.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 (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. hanuscode-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,560 @@
1
+ # hanus/cache/response_cache.py
2
+ """
3
+ Cache Inteligente de Respuestas.
4
+
5
+ Funcionalidades:
6
+ - Hash de consultas frecuentes del proyecto
7
+ - Invalidación smart por cambios en código
8
+ - TTL configurable por tipo de consulta
9
+ - Estadísticas de hit/miss
10
+ - Persistencia a disco
11
+ """
12
+ from __future__ import annotations
13
+ import json
14
+ import hashlib
15
+ import time
16
+ import threading
17
+ from dataclasses import dataclass, field, asdict
18
+ from pathlib import Path
19
+ from typing import Dict, List, Optional, Any, Set, Callable
20
+ from datetime import datetime
21
+ from collections import OrderedDict
22
+
23
+
24
+ @dataclass
25
+ class CacheEntry:
26
+ """Una entrada en el cache."""
27
+ key: str # Hash de la consulta
28
+ query: str # Query original (truncada)
29
+ response: str # Respuesta cacheada
30
+ model: str # Modelo usado
31
+ tokens_in: int # Tokens de entrada
32
+ tokens_out: int # Tokens de salida
33
+ created_at: float # Timestamp de creación
34
+ last_accessed: float # Último acceso
35
+ access_count: int # Veces accedido
36
+ ttl: int # Time-to-live en segundos
37
+ files_hash: str # Hash de archivos involucrados
38
+ tags: List[str] = field(default_factory=list) # Tags para categorización
39
+ metadata: Dict[str, Any] = field(default_factory=dict)
40
+
41
+ @property
42
+ def is_expired(self) -> bool:
43
+ """Verifica si la entrada ha expirado."""
44
+ return time.time() > (self.created_at + self.ttl)
45
+
46
+ @property
47
+ def age_seconds(self) -> float:
48
+ """Edad de la entrada en segundos."""
49
+ return time.time() - self.created_at
50
+
51
+
52
+ @dataclass
53
+ class CacheStats:
54
+ """Estadísticas del cache."""
55
+ hits: int = 0
56
+ misses: int = 0
57
+ evictions: int = 0
58
+ invalidations: int = 0
59
+ total_tokens_saved: int = 0
60
+ total_queries: int = 0
61
+
62
+ @property
63
+ def hit_rate(self) -> float:
64
+ """Tasa de aciertos."""
65
+ total = self.hits + self.misses
66
+ return (self.hits / total * 100) if total > 0 else 0.0
67
+
68
+
69
+ @dataclass
70
+ class FileFingerprint:
71
+ """Huella digital de un archivo para invalidación."""
72
+ path: str
73
+ mtime: float
74
+ size: int
75
+ hash: str
76
+
77
+
78
+ class ResponseCache:
79
+ """
80
+ Cache inteligente de respuestas con invalidación smart.
81
+
82
+ Features:
83
+ - Cache por hash de query + contexto
84
+ - Invalidación por cambios en archivos
85
+ - TTL configurable
86
+ - LRU eviction
87
+ - Estadísticas detalladas
88
+ - Thread-safe
89
+
90
+ Uso:
91
+ cache = ResponseCache()
92
+
93
+ # Intentar obtener del cache
94
+ entry = cache.get(query, model="gpt-4", files=["main.py"])
95
+ if entry:
96
+ return entry.response
97
+
98
+ # Guardar en cache
99
+ cache.set(query, response, model="gpt-4", tokens=(100, 200))
100
+ """
101
+
102
+ DEFAULT_TTL = 3600 * 24 * 7 # 7 días
103
+ MAX_ENTRIES = 1000
104
+ MAX_RESPONSE_SIZE = 50000 # 50KB max por respuesta
105
+
106
+ # TTLs por tipo de consulta
107
+ TTL_BY_TYPE = {
108
+ "code_generation": 3600 * 24 * 7, # 7 días para código
109
+ "explanation": 3600 * 24 * 3, # 3 días para explicaciones
110
+ "analysis": 3600 * 24 * 1, # 1 día para análisis
111
+ "debugging": 3600 * 12, # 12 horas para debugging
112
+ "search": 3600 * 6, # 6 horas para búsquedas
113
+ "default": 3600 * 24, # 1 día por defecto
114
+ }
115
+
116
+ def __init__(
117
+ self,
118
+ cache_dir: Optional[Path] = None,
119
+ max_entries: int = MAX_ENTRIES,
120
+ default_ttl: int = DEFAULT_TTL,
121
+ ):
122
+ self.cache_dir = cache_dir or (Path.home() / ".hanus" / "cache")
123
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
124
+
125
+ self.max_entries = max_entries
126
+ self.default_ttl = default_ttl
127
+
128
+ # Cache en memoria (LRU)
129
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
130
+ self._lock = threading.RLock()
131
+
132
+ # Fingerprints de archivos para invalidación
133
+ self._fingerprints: Dict[str, FileFingerprint] = {}
134
+
135
+ # Estadísticas
136
+ self._stats = CacheStats()
137
+
138
+ # Callbacks de invalidación
139
+ self._invalidation_callbacks: List[Callable[[str], None]] = []
140
+
141
+ # Cargar cache persistido
142
+ self._load_cache()
143
+
144
+ # ══════════════════════════════════════════════════════════════════════════
145
+ # API PÚBLICA
146
+ # ══════════════════════════════════════════════════════════════════════════
147
+
148
+ def get(
149
+ self,
150
+ query: str,
151
+ model: str = "",
152
+ files: Optional[List[str]] = None,
153
+ context: Optional[str] = None,
154
+ ) -> Optional[CacheEntry]:
155
+ """
156
+ Intenta obtener una respuesta del cache.
157
+
158
+ Args:
159
+ query: La consulta del usuario
160
+ model: Modelo usado (para verificar que coincide)
161
+ files: Lista de archivos involucrados
162
+ context: Contexto adicional para el hash
163
+
164
+ Returns:
165
+ CacheEntry si existe y es válido, None si no
166
+ """
167
+ key = self._compute_key(query, model, files, context)
168
+
169
+ with self._lock:
170
+ entry = self._cache.get(key)
171
+
172
+ if entry is None:
173
+ self._stats.misses += 1
174
+ self._stats.total_queries += 1
175
+ return None
176
+
177
+ # Verificar expiración
178
+ if entry.is_expired:
179
+ self._evict(key)
180
+ self._stats.misses += 1
181
+ self._stats.total_queries += 1
182
+ return None
183
+
184
+ # Verificar que los archivos no han cambiado
185
+ if files and self._files_changed(files, entry.files_hash):
186
+ self._evict(key)
187
+ self._stats.invalidations += 1
188
+ self._stats.misses += 1
189
+ self._stats.total_queries += 1
190
+ return None
191
+
192
+ # Verificar modelo
193
+ if model and entry.model != model:
194
+ self._stats.misses += 1
195
+ self._stats.total_queries += 1
196
+ return None
197
+
198
+ # Cache hit!
199
+ entry.last_accessed = time.time()
200
+ entry.access_count += 1
201
+
202
+ # Mover al final (LRU)
203
+ self._cache.move_to_end(key)
204
+
205
+ self._stats.hits += 1
206
+ self._stats.total_queries += 1
207
+ self._stats.total_tokens_saved += entry.tokens_out
208
+
209
+ return entry
210
+
211
+ def set(
212
+ self,
213
+ query: str,
214
+ response: str,
215
+ model: str = "",
216
+ tokens: tuple = (0, 0),
217
+ files: Optional[List[str]] = None,
218
+ context: Optional[str] = None,
219
+ ttl: Optional[int] = None,
220
+ tags: Optional[List[str]] = None,
221
+ query_type: str = "default",
222
+ ) -> CacheEntry:
223
+ """
224
+ Guarda una respuesta en el cache.
225
+
226
+ Args:
227
+ query: La consulta del usuario
228
+ response: La respuesta a cachear
229
+ model: Modelo usado
230
+ tokens: (tokens_in, tokens_out)
231
+ files: Lista de archivos involucrados
232
+ context: Contexto adicional
233
+ ttl: TTL personalizado (usa default si no se especifica)
234
+ tags: Tags para categorización
235
+ query_type: Tipo de consulta para determinar TTL
236
+
237
+ Returns:
238
+ La entrada creada
239
+ """
240
+ # Verificar tamaño
241
+ if len(response) > self.MAX_RESPONSE_SIZE:
242
+ response = response[:self.MAX_RESPONSE_SIZE] + "...[truncated]"
243
+
244
+ key = self._compute_key(query, model, files, context)
245
+ files_hash = self._compute_files_hash(files) if files else ""
246
+
247
+ # Determinar TTL
248
+ if ttl is None:
249
+ ttl = self.TTL_BY_TYPE.get(query_type, self.default_ttl)
250
+
251
+ entry = CacheEntry(
252
+ key=key,
253
+ query=query[:200], # Truncar query para storage
254
+ response=response,
255
+ model=model,
256
+ tokens_in=tokens[0],
257
+ tokens_out=tokens[1],
258
+ created_at=time.time(),
259
+ last_accessed=time.time(),
260
+ access_count=0,
261
+ ttl=ttl,
262
+ files_hash=files_hash,
263
+ tags=tags or [],
264
+ )
265
+
266
+ with self._lock:
267
+ # Evictar si estamos al límite
268
+ while len(self._cache) >= self.max_entries:
269
+ self._evict_oldest()
270
+
271
+ self._cache[key] = entry
272
+
273
+ # Actualizar fingerprints de archivos
274
+ if files:
275
+ self._update_fingerprints(files)
276
+
277
+ # Guardar periódicamente
278
+ if len(self._cache) % 50 == 0:
279
+ self._save_cache()
280
+
281
+ return entry
282
+
283
+ def invalidate(
284
+ self,
285
+ file_path: Optional[str] = None,
286
+ pattern: Optional[str] = None,
287
+ tags: Optional[List[str]] = None,
288
+ ) -> int:
289
+ """
290
+ Invalida entradas del cache.
291
+
292
+ Args:
293
+ file_path: Invalidar entradas que involucran este archivo
294
+ pattern: Invalidar entradas cuyo query coincide con el patrón
295
+ tags: Invalidar entradas con estos tags
296
+
297
+ Returns:
298
+ Número de entradas invalidadas
299
+ """
300
+ count = 0
301
+
302
+ with self._lock:
303
+ keys_to_evict = []
304
+
305
+ for key, entry in self._cache.items():
306
+ should_evict = False
307
+
308
+ # Por archivo
309
+ if file_path:
310
+ if file_path in entry.query or file_path in str(entry.metadata.get("files", [])):
311
+ should_evict = True
312
+
313
+ # Por patrón
314
+ if pattern and not should_evict:
315
+ import re
316
+ if re.search(pattern, entry.query, re.IGNORECASE):
317
+ should_evict = True
318
+
319
+ # Por tags
320
+ if tags and not should_evict:
321
+ if any(tag in entry.tags for tag in tags):
322
+ should_evict = True
323
+
324
+ if should_evict:
325
+ keys_to_evict.append(key)
326
+
327
+ for key in keys_to_evict:
328
+ self._evict(key)
329
+ count += 1
330
+ self._stats.invalidations += 1
331
+
332
+ if count > 0:
333
+ self._save_cache()
334
+
335
+ return count
336
+
337
+ def clear(self) -> int:
338
+ """
339
+ Limpia todo el cache.
340
+
341
+ Returns:
342
+ Número de entradas eliminadas
343
+ """
344
+ with self._lock:
345
+ count = len(self._cache)
346
+ self._cache.clear()
347
+ self._fingerprints.clear()
348
+ self._save_cache()
349
+ return count
350
+
351
+ def get_stats(self) -> Dict[str, Any]:
352
+ """Retorna estadísticas del cache."""
353
+ with self._lock:
354
+ return {
355
+ "hits": self._stats.hits,
356
+ "misses": self._stats.misses,
357
+ "hit_rate": f"{self._stats.hit_rate:.1f}%",
358
+ "evictions": self._stats.evictions,
359
+ "invalidations": self._stats.invalidations,
360
+ "total_tokens_saved": self._stats.total_tokens_saved,
361
+ "total_queries": self._stats.total_queries,
362
+ "entries": len(self._cache),
363
+ "max_entries": self.max_entries,
364
+ "files_tracked": len(self._fingerprints),
365
+ }
366
+
367
+ def get_top_queries(self, limit: int = 10) -> List[Dict]:
368
+ """Retorna las queries más accedidas."""
369
+ with self._lock:
370
+ sorted_entries = sorted(
371
+ self._cache.values(),
372
+ key=lambda e: e.access_count,
373
+ reverse=True
374
+ )[:limit]
375
+
376
+ return [
377
+ {
378
+ "query": e.query[:100],
379
+ "access_count": e.access_count,
380
+ "age_hours": e.age_seconds / 3600,
381
+ "tokens_saved": e.tokens_out * e.access_count,
382
+ }
383
+ for e in sorted_entries
384
+ ]
385
+
386
+ def add_invalidation_callback(self, callback: Callable[[str], None]) -> None:
387
+ """Añade un callback para cuando se invalida una entrada."""
388
+ self._invalidation_callbacks.append(callback)
389
+
390
+ # ══════════════════════════════════════════════════════════════════════════
391
+ # MÉTODOS PRIVADOS
392
+ # ══════════════════════════════════════════════════════════════════════════
393
+
394
+ def _compute_key(
395
+ self,
396
+ query: str,
397
+ model: str,
398
+ files: Optional[List[str]],
399
+ context: Optional[str],
400
+ ) -> str:
401
+ """Computa el hash key para una consulta."""
402
+ content = query.lower().strip()
403
+
404
+ if model:
405
+ content += f"|model:{model}"
406
+
407
+ if files:
408
+ # Solo incluir nombres de archivo, no contenido
409
+ content += f"|files:{','.join(sorted(files))}"
410
+
411
+ if context:
412
+ # Hash del contexto para evitar keys muy largas
413
+ context_hash = hashlib.md5(context.encode()).hexdigest()[:8]
414
+ content += f"|ctx:{context_hash}"
415
+
416
+ return hashlib.sha256(content.encode()).hexdigest()[:32]
417
+
418
+ def _compute_files_hash(self, files: List[str]) -> str:
419
+ """Computa un hash de los archivos involucrados."""
420
+ if not files:
421
+ return ""
422
+
423
+ content = "|".join(sorted(files))
424
+ return hashlib.md5(content.encode()).hexdigest()[:16]
425
+
426
+ def _files_changed(self, files: List[str], old_hash: str) -> bool:
427
+ """Verifica si los archivos han cambiado desde la última vez."""
428
+ if not files or not old_hash:
429
+ return False
430
+
431
+ for file_path in files:
432
+ fingerprint = self._fingerprints.get(file_path)
433
+
434
+ if fingerprint:
435
+ try:
436
+ p = Path(file_path)
437
+ if p.exists():
438
+ if p.stat().st_mtime != fingerprint.mtime:
439
+ return True
440
+ if p.stat().st_size != fingerprint.size:
441
+ return True
442
+ except Exception:
443
+ pass
444
+
445
+ return False
446
+
447
+ def _update_fingerprints(self, files: List[str]) -> None:
448
+ """Actualiza los fingerprints de archivos."""
449
+ for file_path in files:
450
+ try:
451
+ p = Path(file_path)
452
+ if p.exists():
453
+ stat = p.stat()
454
+ self._fingerprints[file_path] = FileFingerprint(
455
+ path=file_path,
456
+ mtime=stat.st_mtime,
457
+ size=stat.st_size,
458
+ hash=hashlib.md5(p.read_bytes()).hexdigest()[:16]
459
+ )
460
+ except Exception:
461
+ pass
462
+
463
+ def _evict(self, key: str) -> None:
464
+ """Evicta una entrada específica."""
465
+ if key in self._cache:
466
+ del self._cache[key]
467
+ self._stats.evictions += 1
468
+
469
+ # Notificar callbacks
470
+ for callback in self._invalidation_callbacks:
471
+ try:
472
+ callback(key)
473
+ except Exception:
474
+ pass
475
+
476
+ def _evict_oldest(self) -> None:
477
+ """Evicta la entrada más antigua (LRU)."""
478
+ if self._cache:
479
+ oldest_key = next(iter(self._cache))
480
+ self._evict(oldest_key)
481
+
482
+ def _load_cache(self) -> None:
483
+ """Carga el cache desde disco."""
484
+ cache_file = self.cache_dir / "response_cache.json"
485
+
486
+ if not cache_file.exists():
487
+ return
488
+
489
+ try:
490
+ data = json.loads(cache_file.read_text(encoding="utf-8"))
491
+
492
+ # Cargar entradas
493
+ for entry_data in data.get("entries", []):
494
+ entry = CacheEntry(**entry_data)
495
+ if not entry.is_expired:
496
+ self._cache[entry.key] = entry
497
+
498
+ # Cargar fingerprints
499
+ for fp_data in data.get("fingerprints", []):
500
+ fp = FileFingerprint(**fp_data)
501
+ self._fingerprints[fp.path] = fp
502
+
503
+ # Cargar estadísticas
504
+ stats_data = data.get("stats", {})
505
+ self._stats = CacheStats(**stats_data)
506
+
507
+ except Exception as e:
508
+ print(f"[Cache] Error loading cache: {e}")
509
+
510
+ def _save_cache(self) -> None:
511
+ """Guarda el cache a disco."""
512
+ cache_file = self.cache_dir / "response_cache.json"
513
+
514
+ try:
515
+ # Solo guardar las 500 entradas más recientes
516
+ entries_to_save = list(self._cache.values())[-500:]
517
+
518
+ data = {
519
+ "entries": [asdict(e) for e in entries_to_save],
520
+ "fingerprints": [asdict(fp) for fp in self._fingerprints.values()],
521
+ "stats": asdict(self._stats),
522
+ "saved_at": datetime.now().isoformat(),
523
+ }
524
+
525
+ cache_file.write_text(
526
+ json.dumps(data, indent=2, ensure_ascii=False),
527
+ encoding="utf-8"
528
+ )
529
+
530
+ except Exception as e:
531
+ print(f"[Cache] Error saving cache: {e}")
532
+
533
+ def __len__(self) -> int:
534
+ return len(self._cache)
535
+
536
+ def __contains__(self, key: str) -> bool:
537
+ return key in self._cache
538
+
539
+
540
+ # ══════════════════════════════════════════════════════════════════════════════
541
+ # INSTANCIA GLOBAL
542
+ # ══════════════════════════════════════════════════════════════════════════════
543
+
544
+ _cache_instance: Optional[ResponseCache] = None
545
+
546
+
547
+ def get_cache() -> ResponseCache:
548
+ """Obtiene la instancia global del cache."""
549
+ global _cache_instance
550
+ if _cache_instance is None:
551
+ _cache_instance = ResponseCache()
552
+ return _cache_instance
553
+
554
+
555
+ def reset_cache() -> None:
556
+ """Resetea la instancia global del cache."""
557
+ global _cache_instance
558
+ if _cache_instance:
559
+ _cache_instance.clear()
560
+ _cache_instance = None