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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +288 -0
  3. package/bin/ocerebro.js +98 -0
  4. package/cerebro/__init__.py +7 -0
  5. package/cerebro/__main__.py +19 -0
  6. package/cerebro/cerebro_setup.py +459 -0
  7. package/hooks/__init__.py +1 -0
  8. package/hooks/cost_hook.py +45 -0
  9. package/hooks/coverage_hook.py +39 -0
  10. package/hooks/error_hook.py +48 -0
  11. package/hooks/expensive_hook.py +41 -0
  12. package/hooks/global_logger.py +32 -0
  13. package/package.json +49 -0
  14. package/pyproject.toml +77 -0
  15. package/src/__init__.py +2 -0
  16. package/src/cli/__init__.py +2 -0
  17. package/src/cli/dream.py +91 -0
  18. package/src/cli/gc.py +93 -0
  19. package/src/cli/main.py +583 -0
  20. package/src/cli/remember.py +74 -0
  21. package/src/consolidation/__init__.py +8 -0
  22. package/src/consolidation/checkpoints.py +96 -0
  23. package/src/consolidation/dream.py +465 -0
  24. package/src/consolidation/extractor.py +313 -0
  25. package/src/consolidation/promoter.py +435 -0
  26. package/src/consolidation/remember.py +544 -0
  27. package/src/consolidation/scorer.py +191 -0
  28. package/src/core/__init__.py +6 -0
  29. package/src/core/event_schema.py +55 -0
  30. package/src/core/jsonl_storage.py +238 -0
  31. package/src/core/paths.py +254 -0
  32. package/src/core/session_manager.py +76 -0
  33. package/src/diff/__init__.py +5 -0
  34. package/src/diff/memory_diff.py +571 -0
  35. package/src/forgetting/__init__.py +6 -0
  36. package/src/forgetting/decay.py +86 -0
  37. package/src/forgetting/gc.py +296 -0
  38. package/src/forgetting/guard_rails.py +126 -0
  39. package/src/hooks/__init__.py +11 -0
  40. package/src/hooks/core_captures.py +170 -0
  41. package/src/hooks/custom_loader.py +389 -0
  42. package/src/index/__init__.py +7 -0
  43. package/src/index/embeddings_db.py +419 -0
  44. package/src/index/metadata_db.py +230 -0
  45. package/src/index/queries.py +357 -0
  46. package/src/mcp/__init__.py +2 -0
  47. package/src/mcp/server.py +640 -0
  48. package/src/memdir/__init__.py +19 -0
  49. package/src/memdir/scanner.py +260 -0
  50. package/src/official/__init__.py +5 -0
  51. package/src/official/markdown_storage.py +173 -0
  52. package/src/official/templates.py +128 -0
  53. package/src/working/__init__.py +5 -0
  54. package/src/working/memory_view.py +150 -0
  55. 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)
@@ -0,0 +1,7 @@
1
+ """Índice do Cerebro: SQLite + FTS + embeddings"""
2
+ from .metadata_db import MetadataDB
3
+ from .embeddings_db import EmbeddingsDB
4
+ from .queries import QueryEngine, QueryResult
5
+
6
+
7
+ __all__ = ["MetadataDB", "EmbeddingsDB", "QueryEngine", "QueryResult"]