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,343 @@
1
+ # hanus/instincts/manager.py
2
+ """
3
+ Gestor de instintos.
4
+
5
+ Maneja el almacenamiento, carga, y persistencia de instintos
6
+ con scope por proyecto y global.
7
+ """
8
+ from __future__ import annotations
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Tuple
13
+ from datetime import datetime
14
+
15
+ from hanus.instincts.types import (
16
+ Instinct, InstinctScope, InstinctStatus, InstinctDomain,
17
+ InstinctEvidence, InstinctGroup, ProjectInfo
18
+ )
19
+
20
+
21
+ class InstinctManager:
22
+ """
23
+ Gestiona el ciclo de vida de los instintos.
24
+
25
+ Los instintos se almacenan en:
26
+ - ~/.hanus/instincts/global/ - Instintos globales
27
+ - ~/.hanus/projects/<id>/instincts/ - Instintos del proyecto
28
+
29
+ Estructura de archivos:
30
+ - active/ - Instintos activos
31
+ - dormant/ - Instintos inactivos
32
+ - evolved/ - Instintos convertidos en skills
33
+ - inherited/ - Instintos importados
34
+ """
35
+
36
+ def __init__(self, base_dir: Path = None, project_path: Path = None):
37
+ self.base_dir = base_dir or Path.home() / ".hanus"
38
+ self.project_path = project_path or Path.cwd()
39
+
40
+ # Directorios
41
+ self.global_dir = self.base_dir / "instincts" / "global"
42
+ self.projects_dir = self.base_dir / "projects"
43
+
44
+ # Crear directorios si no existen
45
+ self.global_dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ # Detectar proyecto
48
+ self.project_info = ProjectInfo.from_path(str(self.project_path))
49
+ self.project_dir = self.projects_dir / self.project_info.id
50
+ self.project_instincts_dir = self.project_dir / "instincts"
51
+
52
+ # Crear estructura de proyecto
53
+ self._ensure_project_structure()
54
+
55
+ # Cache de instintos
56
+ self._global_instincts: Dict[str, Instinct] = {}
57
+ self._project_instincts: Dict[str, Instinct] = {}
58
+ self._loaded = False
59
+
60
+ def _ensure_project_structure(self) -> None:
61
+ """Asegura que la estructura de directorios existe."""
62
+ for subdir in ["active", "dormant", "evolved", "inherited"]:
63
+ (self.project_instincts_dir / subdir).mkdir(parents=True, exist_ok=True)
64
+
65
+ def load_all(self) -> Tuple[Dict[str, Instinct], Dict[str, Instinct]]:
66
+ """Carga todos los instintos (globales y del proyecto)."""
67
+ self._global_instincts = self._load_instincts(self.global_dir)
68
+ self._project_instincts = self._load_instincts(self.project_instincts_dir)
69
+ self._loaded = True
70
+ return self._global_instincts, self._project_instincts
71
+
72
+ def _load_instincts(self, base_dir: Path) -> Dict[str, Instinct]:
73
+ """Carga instintos desde un directorio."""
74
+ instincts = {}
75
+
76
+ for subdir in ["active", "inherited"]:
77
+ instinct_dir = base_dir / subdir
78
+ if not instinct_dir.exists():
79
+ continue
80
+
81
+ for file_path in instinct_dir.glob("*.json"):
82
+ try:
83
+ content = file_path.read_text(encoding="utf-8")
84
+ data = json.loads(content)
85
+ instinct = Instinct.from_dict(data)
86
+ instincts[instinct.id] = instinct
87
+ except Exception as e:
88
+ print(f"[InstinctManager] Error loading {file_path}: {e}")
89
+
90
+ return instincts
91
+
92
+ def get_all_instincts(self) -> Dict[str, Instinct]:
93
+ """Obtiene todos los instintos aplicables (proyecto + global)."""
94
+ if not self._loaded:
95
+ self.load_all()
96
+
97
+ # Fusionar con precedencia: proyecto > global
98
+ all_instincts = {}
99
+
100
+ # Primero globales
101
+ for id_, instinct in self._global_instincts.items():
102
+ if instinct.status == InstinctStatus.ACTIVE:
103
+ all_instincts[id_] = instinct
104
+
105
+ # Luego proyecto (sobrescribe globales con mismo ID)
106
+ for id_, instinct in self._project_instincts.items():
107
+ if instinct.status == InstinctStatus.ACTIVE:
108
+ all_instincts[id_] = instinct
109
+
110
+ return all_instincts
111
+
112
+ def get_instinct(self, instinct_id: str) -> Optional[Instinct]:
113
+ """Obtiene un instinto por ID."""
114
+ if not self._loaded:
115
+ self.load_all()
116
+
117
+ # Buscar primero en proyecto, luego en global
118
+ if instinct_id in self._project_instincts:
119
+ return self._project_instincts[instinct_id]
120
+ return self._global_instincts.get(instinct_id)
121
+
122
+ def add_instinct(self, instinct: Instinct) -> None:
123
+ """Añade un nuevo instinto."""
124
+ if instinct.scope == InstinctScope.GLOBAL:
125
+ self._global_instincts[instinct.id] = instinct
126
+ self._save_instinct(instinct, self.global_dir)
127
+ else:
128
+ instinct.project_id = self.project_info.id
129
+ self._project_instincts[instinct.id] = instinct
130
+ self._save_instinct(instinct, self.project_instincts_dir)
131
+
132
+ def _save_instinct(self, instinct: Instinct, base_dir: Path) -> None:
133
+ """Guarda un instinto en disco."""
134
+ subdir = "active" if instinct.status == InstinctStatus.ACTIVE else "dormant"
135
+ file_path = base_dir / subdir / f"{instinct.id}.json"
136
+
137
+ file_path.parent.mkdir(parents=True, exist_ok=True)
138
+ file_path.write_text(
139
+ json.dumps(instinct.to_dict(), indent=2, ensure_ascii=False),
140
+ encoding="utf-8"
141
+ )
142
+
143
+ def update_instinct(self, instinct: Instinct) -> None:
144
+ """Actualiza un instinto existente."""
145
+ instinct.updated_at = datetime.now().isoformat()
146
+
147
+ if instinct.scope == InstinctScope.GLOBAL:
148
+ self._global_instincts[instinct.id] = instinct
149
+ self._save_instinct(instinct, self.global_dir)
150
+ else:
151
+ self._project_instincts[instinct.id] = instinct
152
+ self._save_instinct(instinct, self.project_instincts_dir)
153
+
154
+ def record_usage(self, instinct_id: str, success: bool,
155
+ evidence: Optional[InstinctEvidence] = None) -> None:
156
+ """Registra el uso de un instinto."""
157
+ instinct = self.get_instinct(instinct_id)
158
+ if not instinct:
159
+ return
160
+
161
+ instinct.last_used = datetime.now().isoformat()
162
+ instinct.update_confidence(success)
163
+
164
+ if evidence:
165
+ instinct.evidence.append(evidence)
166
+
167
+ self.update_instinct(instinct)
168
+
169
+ def get_by_domain(self, domain: InstinctDomain) -> List[Instinct]:
170
+ """Obtiene instintos por dominio."""
171
+ all_instincts = self.get_all_instincts()
172
+ return [i for i in all_instincts.values() if i.domain == domain]
173
+
174
+ def get_by_confidence(self, min_confidence: float = 0.7) -> List[Instinct]:
175
+ """Obtiene instintos con confianza mínima."""
176
+ all_instincts = self.get_all_instincts()
177
+ return [i for i in all_instincts.values() if i.confidence >= min_confidence]
178
+
179
+ def group_by_domain(self) -> InstinctGroup:
180
+ """Agrupa instintos por dominio."""
181
+ all_instincts = self.get_all_instincts()
182
+ groups: InstinctGroup = {}
183
+
184
+ for instinct in all_instincts.values():
185
+ domain = instinct.domain.value
186
+ if domain not in groups:
187
+ groups[domain] = []
188
+ groups[domain].append(instinct)
189
+
190
+ return groups
191
+
192
+ def search(self, query: str) -> List[Instinct]:
193
+ """Busca instintos por nombre, descripción o tags."""
194
+ all_instincts = self.get_all_instincts()
195
+ query_lower = query.lower()
196
+
197
+ results = []
198
+ for instinct in all_instincts.values():
199
+ if (query_lower in instinct.name.lower() or
200
+ query_lower in instinct.description.lower() or
201
+ query_lower in instinct.trigger.lower() or
202
+ any(query_lower in tag.lower() for tag in instinct.tags)):
203
+ results.append(instinct)
204
+
205
+ return results
206
+
207
+ def export_instincts(self, scope: InstinctScope = None,
208
+ min_confidence: float = 0.5,
209
+ domains: List[InstinctDomain] = None) -> Dict:
210
+ """Exporta instintos a formato portable."""
211
+ if not self._loaded:
212
+ self.load_all()
213
+
214
+ instincts_to_export = []
215
+
216
+ if scope == InstinctScope.GLOBAL:
217
+ instincts_to_export = list(self._global_instincts.values())
218
+ elif scope == InstinctScope.PROJECT:
219
+ instincts_to_export = list(self._project_instincts.values())
220
+ else:
221
+ instincts_to_export = list(self.get_all_instincts().values())
222
+
223
+ # Filtrar
224
+ filtered = [
225
+ i for i in instincts_to_export
226
+ if i.confidence >= min_confidence and
227
+ (domains is None or i.domain in domains)
228
+ ]
229
+
230
+ return {
231
+ "version": "1.0",
232
+ "exported_at": datetime.now().isoformat(),
233
+ "project": {
234
+ "id": self.project_info.id,
235
+ "name": self.project_info.name,
236
+ } if scope != InstinctScope.GLOBAL else None,
237
+ "instincts": [i.to_dict() for i in filtered],
238
+ }
239
+
240
+ def import_instincts(self, data: Dict, scope: InstinctScope,
241
+ force: bool = False,
242
+ min_confidence: float = 0.5) -> Tuple[int, List[str]]:
243
+ """
244
+ Importa instintos desde un diccionario.
245
+
246
+ Returns:
247
+ Tuple de (número importados, lista de mensajes)
248
+ """
249
+ if not self._loaded:
250
+ self.load_all()
251
+
252
+ imported = 0
253
+ messages = []
254
+
255
+ for instinct_data in data.get("instincts", []):
256
+ try:
257
+ instinct = Instinct.from_dict(instinct_data)
258
+
259
+ # Filtrar por confianza
260
+ if instinct.confidence < min_confidence:
261
+ messages.append(f"Skipped {instinct.name}: confidence {instinct.confidence} < {min_confidence}")
262
+ continue
263
+
264
+ # Verificar duplicados
265
+ existing = self.get_instinct(instinct.id)
266
+ if existing:
267
+ if existing.confidence >= instinct.confidence:
268
+ messages.append(f"Skipped {instinct.name}: existing has higher/equal confidence")
269
+ continue
270
+ if not force:
271
+ messages.append(f"Skipped {instinct.name}: duplicate (use --force)")
272
+ continue
273
+
274
+ # Ajustar scope
275
+ instinct.scope = scope
276
+ if scope == InstinctScope.PROJECT:
277
+ instinct.project_id = self.project_info.id
278
+
279
+ # Guardar en inherited
280
+ target_dir = (self.project_instincts_dir if scope == InstinctScope.PROJECT
281
+ else self.global_dir) / "inherited"
282
+ file_path = target_dir / f"{instinct.id}.json"
283
+ file_path.parent.mkdir(parents=True, exist_ok=True)
284
+ file_path.write_text(
285
+ json.dumps(instinct.to_dict(), indent=2, ensure_ascii=False),
286
+ encoding="utf-8"
287
+ )
288
+
289
+ imported += 1
290
+ messages.append(f"Imported: {instinct.name} ({instinct.confidence:.0%})")
291
+
292
+ except Exception as e:
293
+ messages.append(f"Error importing: {e}")
294
+
295
+ # Recargar
296
+ self._loaded = False
297
+ self.load_all()
298
+
299
+ return imported, messages
300
+
301
+ def get_stats(self) -> Dict:
302
+ """Obtiene estadísticas de los instintos."""
303
+ if not self._loaded:
304
+ self.load_all()
305
+
306
+ global_count = len([i for i in self._global_instincts.values()
307
+ if i.status == InstinctStatus.ACTIVE])
308
+ project_count = len([i for i in self._project_instincts.values()
309
+ if i.status == InstinctStatus.ACTIVE])
310
+
311
+ # Por dominio
312
+ by_domain = {}
313
+ for instinct in self.get_all_instincts().values():
314
+ domain = instinct.domain.value
315
+ by_domain[domain] = by_domain.get(domain, 0) + 1
316
+
317
+ # Por confianza
318
+ high_confidence = len([i for i in self.get_all_instincts().values()
319
+ if i.confidence >= 0.8])
320
+
321
+ return {
322
+ "total": global_count + project_count,
323
+ "global": global_count,
324
+ "project": project_count,
325
+ "by_domain": by_domain,
326
+ "high_confidence": high_confidence,
327
+ "project_info": {
328
+ "id": self.project_info.id,
329
+ "name": self.project_info.name,
330
+ }
331
+ }
332
+
333
+
334
+ # Instancia global
335
+ _manager: Optional[InstinctManager] = None
336
+
337
+
338
+ def get_instinct_manager(project_path: Path = None) -> InstinctManager:
339
+ """Obtiene el gestor de instintos."""
340
+ global _manager
341
+ if _manager is None or (project_path and _manager.project_path != project_path):
342
+ _manager = InstinctManager(project_path=project_path)
343
+ return _manager
@@ -0,0 +1,253 @@
1
+ # hanus/instincts/types.py
2
+ """
3
+ Tipos de datos para el sistema de instintos.
4
+ """
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+ from typing import List, Dict, Optional, Any
8
+ from enum import Enum
9
+ from datetime import datetime
10
+ import hashlib
11
+ import json
12
+
13
+
14
+ class InstinctScope(Enum):
15
+ """Alcance del instinto."""
16
+ PROJECT = "project" # Solo para el proyecto actual
17
+ GLOBAL = "global" # Aplica a todos los proyectos
18
+
19
+
20
+ class InstinctStatus(Enum):
21
+ """Estado del instinto."""
22
+ ACTIVE = "active" # Activo y siendo usado
23
+ DORMANT = "dormant" # No usado recientemente
24
+ EVOLVED = "evolved" # Convertido en skill/comando/agente
25
+
26
+
27
+ class InstinctDomain(Enum):
28
+ """Dominios de conocimiento."""
29
+ WORKFLOW = "workflow" # Flujos de trabajo
30
+ SECURITY = "security" # Seguridad
31
+ TESTING = "testing" # Testing
32
+ PERFORMANCE = "performance" # Performance
33
+ PATTERNS = "patterns" # Patrones de código
34
+ ARCHITECTURE = "architecture" # Arquitectura
35
+ DEBUGGING = "debugging" # Debugging
36
+ REFACTORING = "refactoring" # Refactoring
37
+ DOCUMENTATION = "documentation" # Documentación
38
+ GIT = "git" # Git y versionado
39
+ DATABASE = "database" # Base de datos
40
+ API = "api" # APIs
41
+ UI = "ui" # UI/Frontend
42
+ GENERAL = "general" # General
43
+
44
+
45
+ @dataclass
46
+ class InstinctEvidence:
47
+ """Evidencia que respalda un instinto."""
48
+ timestamp: str
49
+ action: str # Acción realizada
50
+ context: str # Contexto en el que ocurrió
51
+ outcome: str # Resultado (success/failure)
52
+ file_path: Optional[str] = None
53
+ snippet: Optional[str] = None
54
+
55
+ def to_dict(self) -> Dict:
56
+ return {
57
+ "timestamp": self.timestamp,
58
+ "action": self.action,
59
+ "context": self.context,
60
+ "outcome": self.outcome,
61
+ "file_path": self.file_path,
62
+ "snippet": self.snippet,
63
+ }
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: Dict) -> 'InstinctEvidence':
67
+ return cls(
68
+ timestamp=data["timestamp"],
69
+ action=data["action"],
70
+ context=data["context"],
71
+ outcome=data["outcome"],
72
+ file_path=data.get("file_path"),
73
+ snippet=data.get("snippet"),
74
+ )
75
+
76
+
77
+ @dataclass
78
+ class Instinct:
79
+ """
80
+ Un instinto es un comportamiento aprendido.
81
+
82
+ Es atómico, tiene un score de confianza (0.3-0.9), está
83
+ etiquetado por dominio, respaldado por evidencia, y
84
+ tiene scope (proyecto o global).
85
+ """
86
+ id: str # ID único
87
+ name: str # Nombre descriptivo
88
+ description: str # Descripción del comportamiento
89
+ trigger: str # Cuándo aplicar
90
+ action: str # Qué hacer
91
+ domain: InstinctDomain # Dominio de conocimiento
92
+ scope: InstinctScope # Proyecto o global
93
+ confidence: float # 0.3 - 0.9
94
+ evidence: List[InstinctEvidence] = field(default_factory=list)
95
+ observations: int = 0 # Número de veces observado
96
+ successes: int = 0 # Número de éxitos
97
+ failures: int = 0 # Número de fallos
98
+ created_at: str = ""
99
+ updated_at: str = ""
100
+ last_used: str = ""
101
+ project_id: Optional[str] = None # ID del proyecto (si scope=project)
102
+ evolved_to: Optional[str] = None # ID del skill/comando/agente evolucionado
103
+ status: InstinctStatus = InstinctStatus.ACTIVE
104
+ tags: List[str] = field(default_factory=list)
105
+
106
+ def __post_init__(self):
107
+ if not self.created_at:
108
+ self.created_at = datetime.now().isoformat()
109
+ if not self.updated_at:
110
+ self.updated_at = self.created_at
111
+
112
+ @property
113
+ def success_rate(self) -> float:
114
+ """Calcula la tasa de éxito."""
115
+ total = self.successes + self.failures
116
+ if total == 0:
117
+ return 0.5
118
+ return self.successes / total
119
+
120
+ @property
121
+ def confidence_bar(self) -> str:
122
+ """Barra de confianza visual."""
123
+ filled = int(self.confidence * 10)
124
+ empty = 10 - filled
125
+ return "█" * filled + "░" * empty
126
+
127
+ def update_confidence(self, success: bool) -> None:
128
+ """Actualiza la confianza basada en un resultado."""
129
+ # Factor de aprendizaje (más lento conforme más observaciones)
130
+ alpha = 1.0 / (self.observations + 1)
131
+
132
+ if success:
133
+ # Aumentar confianza, pero nunca más de 0.95
134
+ self.confidence = min(0.95, self.confidence + alpha * (1 - self.confidence) * 0.5)
135
+ self.successes += 1
136
+ else:
137
+ # Disminuir confianza, pero nunca menos de 0.3
138
+ self.confidence = max(0.3, self.confidence - alpha * self.confidence * 0.3)
139
+ self.failures += 1
140
+
141
+ self.observations += 1
142
+ self.updated_at = datetime.now().isoformat()
143
+
144
+ def add_evidence(self, evidence: InstinctEvidence) -> None:
145
+ """Añade evidencia al instinto."""
146
+ self.evidence.append(evidence)
147
+ self.update_confidence(evidence.outcome == "success")
148
+
149
+ def to_dict(self) -> Dict:
150
+ """Convierte a diccionario para serialización."""
151
+ return {
152
+ "id": self.id,
153
+ "name": self.name,
154
+ "description": self.description,
155
+ "trigger": self.trigger,
156
+ "action": self.action,
157
+ "domain": self.domain.value,
158
+ "scope": self.scope.value,
159
+ "confidence": self.confidence,
160
+ "evidence": [e.to_dict() for e in self.evidence],
161
+ "observations": self.observations,
162
+ "successes": self.successes,
163
+ "failures": self.failures,
164
+ "created_at": self.created_at,
165
+ "updated_at": self.updated_at,
166
+ "last_used": self.last_used,
167
+ "project_id": self.project_id,
168
+ "evolved_to": self.evolved_to,
169
+ "status": self.status.value,
170
+ "tags": self.tags,
171
+ }
172
+
173
+ @classmethod
174
+ def from_dict(cls, data: Dict) -> 'Instinct':
175
+ """Crea desde diccionario."""
176
+ return cls(
177
+ id=data["id"],
178
+ name=data["name"],
179
+ description=data["description"],
180
+ trigger=data["trigger"],
181
+ action=data["action"],
182
+ domain=InstinctDomain(data["domain"]),
183
+ scope=InstinctScope(data["scope"]),
184
+ confidence=data["confidence"],
185
+ evidence=[InstinctEvidence.from_dict(e) for e in data.get("evidence", [])],
186
+ observations=data.get("observations", 0),
187
+ successes=data.get("successes", 0),
188
+ failures=data.get("failures", 0),
189
+ created_at=data.get("created_at", ""),
190
+ updated_at=data.get("updated_at", ""),
191
+ last_used=data.get("last_used", ""),
192
+ project_id=data.get("project_id"),
193
+ evolved_to=data.get("evolved_to"),
194
+ status=InstinctStatus(data.get("status", "active")),
195
+ tags=data.get("tags", []),
196
+ )
197
+
198
+ @classmethod
199
+ def generate_id(cls, name: str, trigger: str) -> str:
200
+ """Genera un ID único para un instinto."""
201
+ content = f"{name}:{trigger}"
202
+ return hashlib.md5(content.encode()).hexdigest()[:12]
203
+
204
+
205
+ @dataclass
206
+ class ProjectInfo:
207
+ """Información del proyecto actual."""
208
+ id: str
209
+ name: str
210
+ path: str
211
+ remote_url: Optional[str] = None
212
+
213
+ @classmethod
214
+ def from_path(cls, path: str) -> 'ProjectInfo':
215
+ """Detecta información del proyecto desde el path."""
216
+ import os
217
+ import subprocess
218
+
219
+ # Intentar obtener git remote
220
+ remote_url = None
221
+ try:
222
+ result = subprocess.run(
223
+ ["git", "remote", "get-url", "origin"],
224
+ cwd=path,
225
+ capture_output=True,
226
+ text=True,
227
+ timeout=5
228
+ )
229
+ if result.returncode == 0:
230
+ remote_url = result.stdout.strip()
231
+ except Exception:
232
+ pass
233
+
234
+ # Generar ID único
235
+ if remote_url:
236
+ project_id = hashlib.md5(remote_url.encode()).hexdigest()[:12]
237
+ else:
238
+ # Usar path como fallback
239
+ project_id = hashlib.md5(path.encode()).hexdigest()[:12]
240
+
241
+ # Nombre del directorio
242
+ name = os.path.basename(path)
243
+
244
+ return cls(
245
+ id=project_id,
246
+ name=name,
247
+ path=path,
248
+ remote_url=remote_url,
249
+ )
250
+
251
+
252
+ # Type alias para instintos agrupados
253
+ InstinctGroup = Dict[str, List[Instinct]]
hanus/logger.py ADDED
@@ -0,0 +1,81 @@
1
+ # hanus/logger.py — Sistema de logs centralizado
2
+ """
3
+ Escribe logs a ~/.hanus/logs/hanus_YYYYMMDD.log
4
+ Niveles: DEBUG, INFO, WARN, ERROR
5
+ """
6
+ from __future__ import annotations
7
+ import logging
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ LOG_DIR = Path.home() / ".hanus" / "logs"
13
+
14
+ _logger: Optional[logging.Logger] = None
15
+
16
+
17
+ def setup(verbose: bool = False) -> logging.Logger:
18
+ global _logger
19
+ if _logger:
20
+ return _logger
21
+
22
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
23
+ log_file = LOG_DIR / f"hanus_{time.strftime('%Y%m%d')}.log"
24
+
25
+ fmt = logging.Formatter(
26
+ "%(asctime)s %(levelname)-7s %(name)s %(message)s",
27
+ datefmt="%H:%M:%S",
28
+ )
29
+
30
+ fh = logging.FileHandler(log_file, encoding="utf-8")
31
+ fh.setLevel(logging.DEBUG)
32
+ fh.setFormatter(fmt)
33
+
34
+ _logger = logging.getLogger("hanus")
35
+ _logger.setLevel(logging.DEBUG if verbose else logging.INFO)
36
+ _logger.addHandler(fh)
37
+
38
+ # No propagar al root logger (evita doble salida)
39
+ _logger.propagate = False
40
+
41
+ return _logger
42
+
43
+
44
+ def get() -> logging.Logger:
45
+ global _logger
46
+ if _logger is None:
47
+ _logger = setup()
48
+ return _logger
49
+
50
+
51
+ # Shortcuts
52
+ def debug(msg: str, **kw): get().debug(msg, **kw)
53
+ def info(msg: str, **kw): get().info(msg, **kw)
54
+ def warn(msg: str, **kw): get().warning(msg, **kw)
55
+ def error(msg: str, **kw): get().error(msg, **kw)
56
+
57
+
58
+ def log_request(provider: str, model: str, n_messages: int, attempt: int = 0):
59
+ get().info(f"REQUEST {provider}/{model} msgs={n_messages} attempt={attempt}")
60
+
61
+
62
+ def log_response(provider: str, model: str, tokens_in: int, tokens_out: int,
63
+ cost: float, elapsed: float, stop_reason: str):
64
+ get().info(
65
+ f"RESPONSE {provider}/{model} "
66
+ f"in={tokens_in} out={tokens_out} cost=${cost:.5f} "
67
+ f"elapsed={elapsed:.1f}s stop={stop_reason}"
68
+ )
69
+
70
+
71
+ def log_tool(tool: str, args: dict, success: bool, elapsed: float):
72
+ status = "OK" if success else "FAIL"
73
+ get().info(f"TOOL {tool:<20} {status} elapsed={elapsed:.2f}s args={str(args)[:120]}")
74
+
75
+
76
+ def log_plugin(name: str, args: str, elapsed: float):
77
+ get().info(f"PLUGIN {name:<20} elapsed={elapsed:.2f}s args={args[:80]}")
78
+
79
+
80
+ def log_error(ctx: str, exc: Exception):
81
+ get().error(f"ERROR {ctx}: {type(exc).__name__}: {exc}")
@@ -0,0 +1,8 @@
1
+ # hanus/memory/__init__.py
2
+ """
3
+ Sistema de memoria persistente entre sesiones.
4
+ """
5
+ from hanus.memory.types import MemoryType, MemoryEntry
6
+ from hanus.memory.manager import MemoryManager
7
+
8
+ __all__ = ["MemoryType", "MemoryEntry", "MemoryManager"]