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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|