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,296 @@
1
+ """Garbage collection para memórias do Cerebro.
2
+
3
+ Replica a lógica de garbage collection do Claude Code:
4
+ - Filtra por mtime do arquivo (última modificação)
5
+ - Nunca remove memórias de tipo 'user' ou 'feedback'
6
+ - Aplica threshold de dias sem acesso para working sessions
7
+ """
8
+
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from src.memdir.scanner import parse_frontmatter
14
+ from src.core.paths import MAX_MEMORY_FILES
15
+
16
+
17
+ class GarbageCollector:
18
+ """
19
+ Garbage collection para memórias.
20
+
21
+ Identifica memórias candidatas para arquivamento ou remoção
22
+ baseado em policies de forgetting.
23
+ """
24
+
25
+ def __init__(self, config_path: Path):
26
+ """
27
+ Inicializa o GarbageCollector.
28
+
29
+ Args:
30
+ config_path: Path para configuração
31
+ """
32
+ self.config_path = config_path
33
+
34
+ def find_candidates_for_archive(
35
+ self,
36
+ memory_dir: Path,
37
+ days_threshold: int
38
+ ) -> List[Dict[str, Any]]:
39
+ """
40
+ Encontra memórias candidatas para arquivamento.
41
+
42
+ Args:
43
+ memory_dir: Diretório de memória para scan
44
+ days_threshold: Dias mínimos para arquivar
45
+
46
+ Returns:
47
+ Lista de memórias candidatas
48
+ """
49
+ candidates = []
50
+ now = datetime.now()
51
+ cutoff_ts = (now.timestamp()) - (days_threshold * 24 * 60 * 60)
52
+
53
+ if not memory_dir.exists():
54
+ return candidates
55
+
56
+ # Scan de arquivos .md (excluindo MEMORY.md)
57
+ for file_path in memory_dir.rglob("*.md"):
58
+ if file_path.name == "MEMORY.md":
59
+ continue
60
+
61
+ try:
62
+ # Usa mtime (última modificação) em vez de created_at
63
+ mtime = file_path.stat().st_mtime
64
+
65
+ if mtime < cutoff_ts:
66
+ # Verifica tipo no frontmatter
67
+ content = file_path.read_text(encoding="utf-8")[:2000]
68
+ frontmatter = parse_frontmatter(content)
69
+ mem_type = frontmatter.get("type")
70
+
71
+ # Nunca arquiva memórias de tipo 'user' ou 'feedback'
72
+ if mem_type in ['user', 'feedback']:
73
+ continue
74
+
75
+ candidates.append({
76
+ "file_path": str(file_path),
77
+ "filename": file_path.name,
78
+ "type": mem_type,
79
+ "name": frontmatter.get("name"),
80
+ "description": frontmatter.get("description"),
81
+ "mtime": mtime,
82
+ "days_since_modified": int((now.timestamp() - mtime) / (24 * 60 * 60))
83
+ })
84
+ except Exception:
85
+ # Silenciosamente ignora arquivos com erro
86
+ continue
87
+
88
+ return candidates
89
+
90
+ def find_candidates_for_deletion(
91
+ self,
92
+ candidates: List[Dict[str, Any]],
93
+ deletion_threshold_days: int = 30
94
+ ) -> List[Dict[str, Any]]:
95
+ """
96
+ Encontra memórias candidatas para deleção.
97
+
98
+ Critérios:
99
+ - mtime > deletion_threshold_days (mais antigo que threshold)
100
+ - Tipo NÃO é 'user', 'feedback', ou 'project' com atividade recente
101
+ - Não está linkada no MEMORY.md
102
+
103
+ Args:
104
+ candidates: Lista de memórias candidatas (do find_candidates_for_archive)
105
+ deletion_threshold_days: Dias para deleção (default: 30)
106
+
107
+ Returns:
108
+ Lista de memórias candidatas para deleção
109
+ """
110
+ deletion_candidates = []
111
+ now = datetime.now()
112
+ deletion_cutoff = now.timestamp() - (deletion_threshold_days * 24 * 60 * 60)
113
+
114
+ for memory in candidates:
115
+ mtime = memory.get("mtime", 0)
116
+ mem_type = memory.get("type")
117
+
118
+ # Nunca deleta user ou feedback
119
+ if mem_type in ['user', 'feedback']:
120
+ continue
121
+
122
+ # Deleta se mtime > threshold
123
+ if mtime < deletion_cutoff:
124
+ deletion_candidates.append(memory)
125
+
126
+ return deletion_candidates
127
+
128
+ def log_gc_event(
129
+ self,
130
+ action: str,
131
+ memory_id: str,
132
+ reason: str,
133
+ log_path: Path
134
+ ) -> None:
135
+ """
136
+ Loga evento de GC.
137
+
138
+ Args:
139
+ action: Ação realizada (archive, delete)
140
+ memory_id: ID da memória
141
+ reason: Motivo da ação
142
+ log_path: Path para arquivo de log
143
+ """
144
+ timestamp = datetime.now(timezone.utc).isoformat()
145
+ log_entry = f"{timestamp} | {action} | {memory_id} | {reason}\n"
146
+
147
+ log_path.parent.mkdir(parents=True, exist_ok=True)
148
+ with open(log_path, "a", encoding="utf-8") as f:
149
+ f.write(log_entry)
150
+
151
+ def run_gc(
152
+ self,
153
+ memory_dir: Path,
154
+ archive_threshold_days: int = 7,
155
+ deletion_threshold_days: int = 30,
156
+ dry_run: bool = True,
157
+ log_path: Optional[Path] = None
158
+ ) -> Dict[str, Any]:
159
+ """
160
+ Executa garbage collection.
161
+
162
+ Args:
163
+ memory_dir: Diretório de memória para scan
164
+ archive_threshold_days: Dias para arquivamento (default: 7)
165
+ deletion_threshold_days: Dias para deleção (default: 30)
166
+ dry_run: Se True, apenas lista candidatas, não remove
167
+ log_path: Path para log (opcional)
168
+
169
+ Returns:
170
+ Dicionário com resultados do GC
171
+ """
172
+ results = {
173
+ "archive_candidates": [],
174
+ "deletion_candidates": [],
175
+ "archived": [],
176
+ "deleted": [],
177
+ "dry_run": dry_run
178
+ }
179
+
180
+ # Passo 1: Encontra candidatas para arquivamento
181
+ archive_candidates = self.find_candidates_for_archive(
182
+ memory_dir, archive_threshold_days
183
+ )
184
+ results["archive_candidates"] = [c["filename"] for c in archive_candidates]
185
+
186
+ # Passo 2: Encontra candidatas para deleção
187
+ deletion_candidates = self.find_candidates_for_deletion(
188
+ archive_candidates, deletion_threshold_days
189
+ )
190
+ results["deletion_candidates"] = [c["filename"] for c in deletion_candidates]
191
+
192
+ # Passo 3: Aplica GC (se não for dry_run)
193
+ if not dry_run:
194
+ for candidate in deletion_candidates:
195
+ try:
196
+ file_path = Path(candidate["file_path"])
197
+ file_path.unlink()
198
+ results["deleted"].append(candidate["filename"])
199
+
200
+ if log_path:
201
+ self.log_gc_event(
202
+ "delete",
203
+ candidate["filename"],
204
+ f"GC: {candidate['days_since_modified']} dias sem modificação",
205
+ log_path
206
+ )
207
+ except Exception as e:
208
+ # Loga erro mas continua
209
+ if log_path:
210
+ self.log_gc_event("error", candidate["filename"], str(e), log_path)
211
+
212
+ # Arquiva as restantes (não deletadas)
213
+ import shutil
214
+
215
+ arquivo_dir = memory_dir / "arquivo"
216
+ arquivo_dir.mkdir(parents=True, exist_ok=True)
217
+
218
+ memory_index = memory_dir / "MEMORY.md"
219
+
220
+ for candidate in archive_candidates:
221
+ if candidate["filename"] not in results["deleted"]:
222
+ src_path = Path(candidate["file_path"])
223
+ dst_path = arquivo_dir / src_path.name
224
+ try:
225
+ shutil.move(str(src_path), str(dst_path))
226
+ results["archived"].append(candidate["filename"])
227
+
228
+ # Remove referência do MEMORY.md
229
+ if memory_index.exists():
230
+ lines = memory_index.read_text(encoding="utf-8").splitlines()
231
+ updated = [
232
+ l for l in lines
233
+ if candidate["filename"] not in l
234
+ ]
235
+ memory_index.write_text(
236
+ "\n".join(updated), encoding="utf-8"
237
+ )
238
+
239
+ if log_path:
240
+ self.log_gc_event(
241
+ "archive",
242
+ candidate["filename"],
243
+ f"GC: {candidate['days_since_modified']} dias sem modificação",
244
+ log_path
245
+ )
246
+ except Exception as e:
247
+ if log_path:
248
+ self.log_gc_event(
249
+ "error", candidate["filename"], str(e), log_path
250
+ )
251
+
252
+ return results
253
+
254
+ def generate_gc_report(self, results: Dict[str, Any]) -> str:
255
+ """
256
+ Gera relatório legível do GC.
257
+
258
+ Args:
259
+ results: Dicionário de resultados do run_gc
260
+
261
+ Returns:
262
+ Relatório em markdown
263
+ """
264
+ lines = [
265
+ "# Garbage Collection Report",
266
+ "",
267
+ f"**Modo:** {'Dry-run (nenhuma modificação)' if results['dry_run'] else 'Aplicação direta'}",
268
+ "",
269
+ "## Resumo",
270
+ "",
271
+ f"- Candidatas para arquivamento: {len(results['archive_candidates'])}",
272
+ f"- Candidatas para deleção: {len(results['deletion_candidates'])}",
273
+ f"- Arquivadas: {len(results['archived'])}",
274
+ f"- Deletadas: {len(results['deleted'])}",
275
+ "",
276
+ ]
277
+
278
+ if results["archive_candidates"]:
279
+ lines.append("## Candidatas para Arquivamento")
280
+ lines.append("")
281
+ for filename in results["archive_candidates"]:
282
+ lines.append(f"- {filename}")
283
+ lines.append("")
284
+
285
+ if results["deletion_candidates"]:
286
+ lines.append("## Candidatas para Deleção")
287
+ lines.append("")
288
+ for filename in results["deletion_candidates"]:
289
+ lines.append(f"- {filename}")
290
+ lines.append("")
291
+
292
+ if not results["archive_candidates"] and not results["deletion_candidates"]:
293
+ lines.append("Nenhuma memória candidata para GC.")
294
+ lines.append("")
295
+
296
+ return "\n".join(lines)
@@ -0,0 +1,126 @@
1
+ """Guard rails para forgetting do Cerebro"""
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+ import yaml
7
+
8
+
9
+ class GuardRails:
10
+ """
11
+ Guard rails para políticas de forgetting.
12
+
13
+ Implementa regras de:
14
+ - never_delete: memórias que nunca podem ser deletadas
15
+ - always_archive: memórias que devem ser arquivadas após período
16
+ """
17
+
18
+ def __init__(self, config_path: Path):
19
+ """
20
+ Inicializa o GuardRails.
21
+
22
+ Args:
23
+ config_path: Path para arquivo de configuração YAML
24
+ """
25
+ self.config_path = config_path
26
+ self.config = self._load_config()
27
+
28
+ def _load_config(self) -> Dict:
29
+ """
30
+ Carrega configuração.
31
+
32
+ Returns:
33
+ Dicionário de configuração
34
+ """
35
+ if not self.config_path.exists():
36
+ return {
37
+ "never_delete": ["decisions.critical", "errors.severity=high"],
38
+ "always_archive": {"raw": 30, "working": 90}
39
+ }
40
+
41
+ return yaml.safe_load(self.config_path.read_text())
42
+
43
+ def can_delete(self, memory: Dict[str, Any]) -> bool:
44
+ """
45
+ Verifica se pode deletar uma memória.
46
+
47
+ Args:
48
+ memory: Dados da memória
49
+
50
+ Returns:
51
+ True se pode deletar, False se está protegida
52
+ """
53
+ rules = self.config.get("never_delete", [])
54
+
55
+ for rule in rules:
56
+ if self._matches_rule(memory, rule):
57
+ return False
58
+
59
+ return True
60
+
61
+ def _matches_rule(self, memory: Dict[str, Any], rule: str) -> bool:
62
+ """
63
+ Verifica se memória corresponde à regra.
64
+
65
+ Args:
66
+ memory: Dados da memória
67
+ rule: Regra a verificar
68
+
69
+ Returns:
70
+ True se corresponde à regra
71
+ """
72
+ if rule == "decisions.critical":
73
+ return memory.get("type") == "decision" and "critical" in memory.get("tags", [])
74
+
75
+ if rule == "errors.severity=high":
76
+ return memory.get("type") == "error" and memory.get("severity") in ["high", "critical"]
77
+
78
+ if rule == "errors.impact=critical":
79
+ return memory.get("type") == "error" and memory.get("impact") == "critical"
80
+
81
+ return False
82
+
83
+ def should_archive(self, memory: Dict[str, Any], days_threshold: int) -> bool:
84
+ """
85
+ Verifica se deve arquivar uma memória.
86
+
87
+ Args:
88
+ memory: Dados da memória
89
+ days_threshold: Dias mínimos para arquivar
90
+
91
+ Returns:
92
+ True se deve arquivar
93
+ """
94
+ created = memory.get("created_at")
95
+ if not created:
96
+ return False
97
+
98
+ created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
99
+ days_old = (datetime.now(timezone.utc) - created_dt).days
100
+
101
+ return days_old > days_threshold
102
+
103
+ def get_archive_threshold(self, layer: str) -> int:
104
+ """
105
+ Obtém threshold de arquivamento por camada.
106
+
107
+ Args:
108
+ layer: Nome da camada (raw, working, official)
109
+
110
+ Returns:
111
+ Dias threshold para arquivamento
112
+ """
113
+ thresholds = self.config.get("always_archive", {})
114
+ return thresholds.get(layer, 90)
115
+
116
+ def is_protected(self, memory: Dict[str, Any]) -> bool:
117
+ """
118
+ Verifica se memória está protegida contra deleção.
119
+
120
+ Args:
121
+ memory: Dados da memória
122
+
123
+ Returns:
124
+ True se está protegida
125
+ """
126
+ return not self.can_delete(memory)
@@ -0,0 +1,11 @@
1
+ """Hooks do Cerebro: captura de eventos"""
2
+ from .core_captures import CoreCaptures
3
+ from .custom_loader import HooksLoader, HookRunner, HookConfig, create_sample_hooks_config
4
+
5
+ __all__ = [
6
+ "CoreCaptures",
7
+ "HooksLoader",
8
+ "HookRunner",
9
+ "HookConfig",
10
+ "create_sample_hooks_config"
11
+ ]
@@ -0,0 +1,170 @@
1
+ """Captura de eventos core do Cerebro"""
2
+
3
+ from typing import Any, Dict, Optional
4
+ from pathlib import Path
5
+ from src.core.jsonl_storage import JSONLStorage
6
+ from src.core.event_schema import Event, EventType, EventOrigin
7
+ from src.hooks.custom_loader import HooksLoader, HookRunner
8
+
9
+
10
+ class CoreCaptures:
11
+ """
12
+ Captura de eventos core do Cerebro.
13
+
14
+ Fornece métodos para capturar:
15
+ - Tool calls (chamadas de ferramentas)
16
+ - Git events (eventos do git)
17
+ - Test results (resultados de testes)
18
+ - Errors (erros críticos)
19
+
20
+ Hooks customizados são executados automaticamente após cada captura.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ storage: JSONLStorage,
26
+ project: str,
27
+ session_id: str,
28
+ hooks_config_path: Optional[Path] = None
29
+ ):
30
+ """
31
+ Inicializa o CoreCaptures.
32
+
33
+ Args:
34
+ storage: Instância do JSONLStorage
35
+ project: Nome do projeto
36
+ session_id: ID da sessão
37
+ hooks_config_path: Path para hooks.yaml (opcional)
38
+ """
39
+ self.storage = storage
40
+ self.project = project
41
+ self.session_id = session_id
42
+
43
+ # Inicializa hooks loader e runner
44
+ if hooks_config_path is None:
45
+ hooks_config_path = Path("hooks.yaml")
46
+
47
+ self.hooks_loader = HooksLoader(hooks_config_path)
48
+ self.hooks_runner = HookRunner(self.hooks_loader, context={
49
+ "project": project,
50
+ "session_id": session_id
51
+ })
52
+
53
+ def _create_event(
54
+ self,
55
+ event_type: EventType,
56
+ subtype: str,
57
+ payload: Dict[str, Any],
58
+ tags: list = None
59
+ ) -> Event:
60
+ """
61
+ Cria e persiste evento, executando hooks customizados.
62
+
63
+ Args:
64
+ event_type: Tipo do evento
65
+ subtype: Subtipo do evento
66
+ payload: Dados do evento
67
+ tags: Tags opcionais
68
+
69
+ Returns:
70
+ Evento criado
71
+ """
72
+ event = Event(
73
+ project=self.project,
74
+ origin=EventOrigin.CLAUDE_CODE,
75
+ event_type=event_type,
76
+ subtype=subtype,
77
+ payload=payload,
78
+ tags=tags or [],
79
+ session_id=self.session_id
80
+ )
81
+ self.storage.append(event)
82
+
83
+ # Executa hooks customizados para este evento
84
+ self.hooks_runner.execute(event)
85
+
86
+ return event
87
+
88
+ def tool_call(self, tool: str, call_data: Dict[str, Any], result: Dict[str, Any]) -> Event:
89
+ """
90
+ Captura tool call.
91
+
92
+ Args:
93
+ tool: Nome da ferramenta
94
+ call_data: Dados da chamada
95
+ result: Resultado da execução
96
+
97
+ Returns:
98
+ Evento capturado
99
+ """
100
+ return self._create_event(
101
+ EventType.TOOL_CALL,
102
+ tool,
103
+ {
104
+ "call": call_data,
105
+ "result": result
106
+ }
107
+ )
108
+
109
+ def git_event(self, action: str, data: Dict[str, Any]) -> Event:
110
+ """
111
+ Captura git event.
112
+
113
+ Args:
114
+ action: Ação do git (commit, branch, merge, etc)
115
+ data: Dados do evento
116
+
117
+ Returns:
118
+ Evento capturado
119
+ """
120
+ return self._create_event(
121
+ EventType.GIT_EVENT,
122
+ action,
123
+ data
124
+ )
125
+
126
+ def test_result(self, test_type: str, test_name: str, status: str, duration: float, error: str = None) -> Event:
127
+ """
128
+ Captura test result.
129
+
130
+ Args:
131
+ test_type: Tipo de teste (unit, integration, e2e)
132
+ test_name: Nome do teste
133
+ status: Status (pass, fail, skip)
134
+ duration: Duração em segundos
135
+ error: Mensagem de erro (opcional)
136
+
137
+ Returns:
138
+ Evento capturado
139
+ """
140
+ payload = {
141
+ "test_name": test_name,
142
+ "status": status,
143
+ "duration": duration
144
+ }
145
+ if error:
146
+ payload["error"] = error
147
+
148
+ return self._create_event(
149
+ EventType.TEST_RESULT,
150
+ test_type,
151
+ payload
152
+ )
153
+
154
+ def error(self, error_type: str, context: Dict[str, Any]) -> Event:
155
+ """
156
+ Captura erro.
157
+
158
+ Args:
159
+ error_type: Tipo do erro
160
+ context: Contexto do erro
161
+
162
+ Returns:
163
+ Evento capturado
164
+ """
165
+ return self._create_event(
166
+ EventType.ERROR,
167
+ error_type,
168
+ context,
169
+ tags=["error", "critical"]
170
+ )