ocerebro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/bin/ocerebro.js +98 -0
- package/cerebro/__init__.py +7 -0
- package/cerebro/__main__.py +19 -0
- package/cerebro/cerebro_setup.py +459 -0
- package/hooks/__init__.py +1 -0
- package/hooks/cost_hook.py +45 -0
- package/hooks/coverage_hook.py +39 -0
- package/hooks/error_hook.py +48 -0
- package/hooks/expensive_hook.py +41 -0
- package/hooks/global_logger.py +32 -0
- package/package.json +49 -0
- package/pyproject.toml +77 -0
- package/src/__init__.py +2 -0
- package/src/cli/__init__.py +2 -0
- package/src/cli/dream.py +91 -0
- package/src/cli/gc.py +93 -0
- package/src/cli/main.py +583 -0
- package/src/cli/remember.py +74 -0
- package/src/consolidation/__init__.py +8 -0
- package/src/consolidation/checkpoints.py +96 -0
- package/src/consolidation/dream.py +465 -0
- package/src/consolidation/extractor.py +313 -0
- package/src/consolidation/promoter.py +435 -0
- package/src/consolidation/remember.py +544 -0
- package/src/consolidation/scorer.py +191 -0
- package/src/core/__init__.py +6 -0
- package/src/core/event_schema.py +55 -0
- package/src/core/jsonl_storage.py +238 -0
- package/src/core/paths.py +254 -0
- package/src/core/session_manager.py +76 -0
- package/src/diff/__init__.py +5 -0
- package/src/diff/memory_diff.py +571 -0
- package/src/forgetting/__init__.py +6 -0
- package/src/forgetting/decay.py +86 -0
- package/src/forgetting/gc.py +296 -0
- package/src/forgetting/guard_rails.py +126 -0
- package/src/hooks/__init__.py +11 -0
- package/src/hooks/core_captures.py +170 -0
- package/src/hooks/custom_loader.py +389 -0
- package/src/index/__init__.py +7 -0
- package/src/index/embeddings_db.py +419 -0
- package/src/index/metadata_db.py +230 -0
- package/src/index/queries.py +357 -0
- package/src/mcp/__init__.py +2 -0
- package/src/mcp/server.py +640 -0
- package/src/memdir/__init__.py +19 -0
- package/src/memdir/scanner.py +260 -0
- package/src/official/__init__.py +5 -0
- package/src/official/markdown_storage.py +173 -0
- package/src/official/templates.py +128 -0
- package/src/working/__init__.py +5 -0
- package/src/working/memory_view.py +150 -0
- package/src/working/yaml_storage.py +234 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Carregador e executor de hooks customizados do Cerebro"""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import yaml
|
|
5
|
+
import signal
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from src.core.event_schema import Event
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class HookConfig:
|
|
16
|
+
"""Configuração de um hook"""
|
|
17
|
+
name: str
|
|
18
|
+
event_type: str
|
|
19
|
+
event_subtype: Optional[str]
|
|
20
|
+
module_path: str
|
|
21
|
+
function: str
|
|
22
|
+
config: Dict[str, Any] = None
|
|
23
|
+
timeout: int = 5 # segundos - configurável por hook
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HooksLoader:
|
|
27
|
+
"""
|
|
28
|
+
Carregador dinâmico de hooks customizados via YAML.
|
|
29
|
+
|
|
30
|
+
Configuração em hooks.yaml:
|
|
31
|
+
```yaml
|
|
32
|
+
hooks:
|
|
33
|
+
- name: capture_test_coverage
|
|
34
|
+
event_type: test_result
|
|
35
|
+
module_path: hooks/coverage_hook.py
|
|
36
|
+
function: on_test_result
|
|
37
|
+
config:
|
|
38
|
+
min_coverage: 80
|
|
39
|
+
|
|
40
|
+
- name: track_llm_cost
|
|
41
|
+
event_type: tool_call
|
|
42
|
+
event_subtype: llm
|
|
43
|
+
module_path: hooks/cost_hook.py
|
|
44
|
+
function: on_llm_call
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config_path: Path):
|
|
49
|
+
"""
|
|
50
|
+
Inicializa o HooksLoader.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config_path: Path para hooks.yaml
|
|
54
|
+
"""
|
|
55
|
+
self.config_path = config_path
|
|
56
|
+
self.hooks: List[HookConfig] = []
|
|
57
|
+
self._loaded_modules: Dict[str, Any] = {}
|
|
58
|
+
|
|
59
|
+
if config_path.exists():
|
|
60
|
+
self._load_config()
|
|
61
|
+
|
|
62
|
+
def _load_config(self) -> None:
|
|
63
|
+
"""Carrega configuração dos hooks"""
|
|
64
|
+
config = yaml.safe_load(self.config_path.read_text())
|
|
65
|
+
|
|
66
|
+
for hook_data in config.get("hooks", []):
|
|
67
|
+
hook = HookConfig(
|
|
68
|
+
name=hook_data.get("name", "unknown"),
|
|
69
|
+
event_type=hook_data.get("event_type", "*"),
|
|
70
|
+
event_subtype=hook_data.get("event_subtype"),
|
|
71
|
+
module_path=hook_data.get("module_path", ""),
|
|
72
|
+
function=hook_data.get("function", "execute"),
|
|
73
|
+
config=hook_data.get("config", {})
|
|
74
|
+
)
|
|
75
|
+
self.hooks.append(hook)
|
|
76
|
+
|
|
77
|
+
def _load_module(self, module_path: str) -> Optional[Any]:
|
|
78
|
+
"""
|
|
79
|
+
Carrega módulo Python dinamicamente.
|
|
80
|
+
|
|
81
|
+
SECURITY FIX: Valida path para evitar path traversal
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
module_path: Path do módulo relativo ao projeto
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Módulo carregado ou None
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
PermissionError: Se path estiver fora do diretório permitido
|
|
91
|
+
ValueError: Se arquivo não for .py
|
|
92
|
+
"""
|
|
93
|
+
if module_path in self._loaded_modules:
|
|
94
|
+
return self._loaded_modules[module_path]
|
|
95
|
+
|
|
96
|
+
# SECURITY: Resolve path absoluto e verifica se está dentro do diretório permitido
|
|
97
|
+
path = Path(module_path).resolve()
|
|
98
|
+
allowed_root = self.config_path.parent.resolve()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
# Verifica se o path está dentro do diretório permitido (hooks.yaml location)
|
|
102
|
+
path.relative_to(allowed_root)
|
|
103
|
+
except ValueError:
|
|
104
|
+
raise PermissionError(
|
|
105
|
+
f"Hook path fora do diretório permitido: {path} "
|
|
106
|
+
f"(permitido: {allowed_root})"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
# SECURITY: Só permite arquivos .py
|
|
113
|
+
if path.suffix != ".py":
|
|
114
|
+
raise ValueError(f"Hook deve ser arquivo .py: {path}")
|
|
115
|
+
|
|
116
|
+
spec = importlib.util.spec_from_file_location(
|
|
117
|
+
f"hook_{path.stem}",
|
|
118
|
+
path
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if spec is None or spec.loader is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
module = importlib.util.module_from_spec(spec)
|
|
125
|
+
spec.loader.exec_module(module)
|
|
126
|
+
|
|
127
|
+
self._loaded_modules[module_path] = module
|
|
128
|
+
return module
|
|
129
|
+
|
|
130
|
+
def get_hooks_for_event(self, event: Event) -> List[HookConfig]:
|
|
131
|
+
"""
|
|
132
|
+
Retorna hooks que devem ser executados para um evento.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
event: Evento para filtrar hooks
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Lista de hooks configurados
|
|
139
|
+
"""
|
|
140
|
+
matching = []
|
|
141
|
+
|
|
142
|
+
for hook in self.hooks:
|
|
143
|
+
# Filtra por tipo de evento
|
|
144
|
+
if hook.event_type != "*" and hook.event_type != event.event_type.value:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Filtra por subtipo se especificado
|
|
148
|
+
if hook.event_subtype and hook.event_subtype != event.subtype:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
matching.append(hook)
|
|
152
|
+
|
|
153
|
+
return matching
|
|
154
|
+
|
|
155
|
+
def load_hook_module(self, hook: HookConfig) -> Optional[Any]:
|
|
156
|
+
"""
|
|
157
|
+
Carrega módulo de um hook específico.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
hook: Configuração do hook
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Módulo carregado ou None
|
|
164
|
+
"""
|
|
165
|
+
return self._load_module(hook.module_path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class HookRunner:
|
|
169
|
+
"""
|
|
170
|
+
Executor de hooks customizados.
|
|
171
|
+
|
|
172
|
+
Executa hooks em resposta a eventos do Cerebro.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, loader: HooksLoader, context: Optional[Dict] = None):
|
|
176
|
+
"""
|
|
177
|
+
Inicializa o HookRunner.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
loader: HooksLoader configurado
|
|
181
|
+
context: Contexto global para hooks
|
|
182
|
+
"""
|
|
183
|
+
self.loader = loader
|
|
184
|
+
self.context = context or {}
|
|
185
|
+
|
|
186
|
+
def execute(
|
|
187
|
+
self,
|
|
188
|
+
event: Event,
|
|
189
|
+
hooks: Optional[List[HookConfig]] = None
|
|
190
|
+
) -> Dict[str, Any]:
|
|
191
|
+
"""
|
|
192
|
+
Executa hooks para um evento.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
event: Evento que triggerou os hooks
|
|
196
|
+
hooks: Lista específica de hooks (opcional, usa loader se None)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Resultados de cada hook executado
|
|
200
|
+
"""
|
|
201
|
+
if hooks is None:
|
|
202
|
+
hooks = self.loader.get_hooks_for_event(event)
|
|
203
|
+
|
|
204
|
+
results = {}
|
|
205
|
+
|
|
206
|
+
for hook in hooks:
|
|
207
|
+
try:
|
|
208
|
+
result = self._execute_hook(hook, event)
|
|
209
|
+
results[hook.name] = {
|
|
210
|
+
"success": True,
|
|
211
|
+
"result": result
|
|
212
|
+
}
|
|
213
|
+
except Exception as e:
|
|
214
|
+
results[hook.name] = {
|
|
215
|
+
"success": False,
|
|
216
|
+
"error": str(e)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return results
|
|
220
|
+
|
|
221
|
+
def _execute_hook(
|
|
222
|
+
self,
|
|
223
|
+
hook: HookConfig,
|
|
224
|
+
event: Event
|
|
225
|
+
) -> Any:
|
|
226
|
+
"""
|
|
227
|
+
Executa um hook específico com timeout.
|
|
228
|
+
|
|
229
|
+
HIGH FIX: Adiciona timeout para evitar hooks travados
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
hook: Configuração do hook
|
|
233
|
+
event: Evento para processar
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Resultado da execução
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
TimeoutError: Se hook exceder timeout
|
|
240
|
+
"""
|
|
241
|
+
module = self.loader.load_hook_module(hook)
|
|
242
|
+
|
|
243
|
+
if module is None:
|
|
244
|
+
raise FileNotFoundError(f"Módulo não encontrado: {hook.module_path}")
|
|
245
|
+
|
|
246
|
+
func = getattr(module, hook.function, None)
|
|
247
|
+
|
|
248
|
+
if func is None:
|
|
249
|
+
raise AttributeError(
|
|
250
|
+
f"Função {hook.function} não encontrada em {hook.module_path}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# TIMEOUT FIX: Executa hook em thread com timeout
|
|
254
|
+
timeout_seconds = (hook.config or {}).get("timeout", hook.timeout)
|
|
255
|
+
result_container = [None]
|
|
256
|
+
error_container = [None]
|
|
257
|
+
|
|
258
|
+
def run_hook():
|
|
259
|
+
try:
|
|
260
|
+
result_container[0] = func(
|
|
261
|
+
event=event,
|
|
262
|
+
context=self.context,
|
|
263
|
+
config=hook.config or {}
|
|
264
|
+
)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
error_container[0] = e
|
|
267
|
+
|
|
268
|
+
thread = threading.Thread(target=run_hook, daemon=True)
|
|
269
|
+
thread.start()
|
|
270
|
+
thread.join(timeout=timeout_seconds)
|
|
271
|
+
|
|
272
|
+
if thread.is_alive():
|
|
273
|
+
raise TimeoutError(
|
|
274
|
+
f"Hook '{hook.name}' excedeu timeout de {timeout_seconds}s"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if error_container[0] is not None:
|
|
278
|
+
raise error_container[0]
|
|
279
|
+
|
|
280
|
+
return result_container[0]
|
|
281
|
+
|
|
282
|
+
def register_callback(
|
|
283
|
+
self,
|
|
284
|
+
event_type: str,
|
|
285
|
+
callback: Callable[[Event], Any],
|
|
286
|
+
subtype: Optional[str] = None
|
|
287
|
+
) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Registra callback inline para um tipo de evento.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
event_type: Tipo de evento
|
|
293
|
+
callback: Função callback
|
|
294
|
+
subtype: Subtipo de evento (opcional)
|
|
295
|
+
"""
|
|
296
|
+
# Cria hook config temporário
|
|
297
|
+
hook = HookConfig(
|
|
298
|
+
name=f"callback_{event_type}",
|
|
299
|
+
event_type=event_type,
|
|
300
|
+
event_subtype=subtype,
|
|
301
|
+
module_path="",
|
|
302
|
+
function=""
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Adiciona ao loader
|
|
306
|
+
self.loader.hooks.append(hook)
|
|
307
|
+
|
|
308
|
+
# Registra callback diretamente
|
|
309
|
+
self._callbacks = getattr(self, "_callbacks", {})
|
|
310
|
+
key = f"{event_type}:{subtype or '*'}"
|
|
311
|
+
self._callbacks[key] = callback
|
|
312
|
+
|
|
313
|
+
def execute_callbacks(self, event: Event) -> Dict[str, Any]:
|
|
314
|
+
"""
|
|
315
|
+
Executa callbacks registrados para um evento.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
event: Evento para processar
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Resultados dos callbacks
|
|
322
|
+
"""
|
|
323
|
+
results = {}
|
|
324
|
+
callbacks = getattr(self, "_callbacks", {})
|
|
325
|
+
|
|
326
|
+
for key, callback in callbacks.items():
|
|
327
|
+
event_type, subtype = key.split(":")
|
|
328
|
+
|
|
329
|
+
# Verifica se callback se aplica
|
|
330
|
+
if event_type != "*" and event_type != event.event_type.value:
|
|
331
|
+
continue
|
|
332
|
+
if subtype != "*" and subtype != event.subtype:
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
result = callback(event)
|
|
337
|
+
results[key] = {"success": True, "result": result}
|
|
338
|
+
except Exception as e:
|
|
339
|
+
results[key] = {"success": False, "error": str(e)}
|
|
340
|
+
|
|
341
|
+
return results
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def create_sample_hooks_config(output_path: Path) -> None:
|
|
345
|
+
"""
|
|
346
|
+
Cria arquivo de exemplo de configuração de hooks.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
output_path: Path para salvar o arquivo
|
|
350
|
+
"""
|
|
351
|
+
config = {
|
|
352
|
+
"# Cerebro Hooks Configuration": None,
|
|
353
|
+
"hooks": [
|
|
354
|
+
{
|
|
355
|
+
"name": "test_coverage_check",
|
|
356
|
+
"event_type": "test_result",
|
|
357
|
+
"module_path": "hooks/coverage_hook.py",
|
|
358
|
+
"function": "on_test_result",
|
|
359
|
+
"config": {
|
|
360
|
+
"min_coverage": 80,
|
|
361
|
+
"fail_below_threshold": False
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"name": "expensive_operation_log",
|
|
366
|
+
"event_type": "tool_call",
|
|
367
|
+
"event_subtype": "bash",
|
|
368
|
+
"module_path": "hooks/expensive_hook.py",
|
|
369
|
+
"function": "on_expensive_operation",
|
|
370
|
+
"config": {
|
|
371
|
+
"log_threshold_seconds": 5
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
"name": "error_notification",
|
|
376
|
+
"event_type": "error",
|
|
377
|
+
"module_path": "hooks/error_hook.py",
|
|
378
|
+
"function": "on_error",
|
|
379
|
+
"config": {
|
|
380
|
+
"notify_severity": ["critical", "high"],
|
|
381
|
+
"channel": "slack"
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
]
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
389
|
+
yaml.dump(config, f, allow_unicode=True, default_flow_style=False)
|