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,191 @@
1
+ """Scorer RFM para memórias do Cerebro"""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict
6
+ import math
7
+
8
+
9
+ @dataclass
10
+ class ScoringConfig:
11
+ """Configuração de pesos do scoring RFM"""
12
+ recency_weight: float = 0.3
13
+ frequency_weight: float = 0.2
14
+ importance_weight: float = 0.3
15
+ links_weight: float = 0.2
16
+
17
+
18
+ class Scorer:
19
+ """
20
+ Calcula scores RFM (Recency, Frequency, Importance) para memórias.
21
+
22
+ O score total é uma combinação ponderada de:
23
+ - Recência: quão recente foi o último acesso
24
+ - Frequência: quantas vezes foi acessada
25
+ - Importância: baseada em severity/impact
26
+ - Links: quantas conexões com outras memórias
27
+ """
28
+
29
+ def __init__(self, config: ScoringConfig):
30
+ """
31
+ Inicializa o Scorer.
32
+
33
+ Args:
34
+ config: Configuração de pesos
35
+ """
36
+ self.config = config
37
+
38
+ def calculate(self, memory: Dict[str, Any]) -> float:
39
+ """
40
+ Calcula score total RFM.
41
+
42
+ Args:
43
+ memory: Dados da memória
44
+
45
+ Returns:
46
+ Score entre 0.0 e 1.0
47
+ """
48
+ r = self._recency_score(memory.get("last_accessed"))
49
+ f = self._frequency_score(memory.get("access_count", 0))
50
+ i = self._importance_score(memory)
51
+ l = self._links_score(memory.get("related_to", []))
52
+
53
+ total = (
54
+ self.config.recency_weight * r +
55
+ self.config.frequency_weight * f +
56
+ self.config.importance_weight * i +
57
+ self.config.links_weight * l
58
+ )
59
+
60
+ return min(1.0, max(0.0, total))
61
+
62
+ def _recency_score(self, last_accessed: datetime) -> float:
63
+ """
64
+ Score de recência (0-1).
65
+
66
+ Usa decaimento exponencial baseado em dias desde último acesso.
67
+
68
+ Args:
69
+ last_accessed: Data do último acesso
70
+
71
+ Returns:
72
+ Score de recência
73
+ """
74
+ if not last_accessed:
75
+ return 0.0
76
+
77
+ # BUG-01 FIX: Usa timezone-aware datetime
78
+ now = datetime.now(timezone.utc)
79
+ # Normaliza para timezone-aware se last_accessed for naive (fallback de segurança)
80
+ if last_accessed.tzinfo is None:
81
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
82
+ days_ago = (now - last_accessed).days
83
+ return math.exp(-0.05 * days_ago)
84
+
85
+ def _frequency_score(self, access_count: int) -> float:
86
+ """
87
+ Score de frequência (0-1).
88
+
89
+ Args:
90
+ access_count: Número de acessos
91
+
92
+ Returns:
93
+ Score de frequência
94
+ """
95
+ return 1.0 - math.exp(-0.1 * access_count)
96
+
97
+ def _importance_score(self, memory: Dict[str, Any]) -> float:
98
+ """
99
+ Score de importância baseado em severity/impact.
100
+
101
+ WARN-03 FIX: Considera tanto errors (severity) quanto decisions (status)
102
+
103
+ Args:
104
+ memory: Dados da memória
105
+
106
+ Returns:
107
+ Score de importância
108
+ """
109
+ # WARN-03 FIX: Erros usam severity, decisions usam status
110
+ mem_type = memory.get("type", "")
111
+
112
+ if mem_type == "error":
113
+ severity_map = {"critical": 1.0, "high": 0.8, "medium": 0.5, "low": 0.2}
114
+ return severity_map.get(memory.get("severity", "low"), 0.2)
115
+
116
+ if mem_type == "decision":
117
+ # Decisões approved têm alta importância
118
+ status_map = {
119
+ "approved": 0.8,
120
+ "superseded": 0.4,
121
+ "deprecated": 0.1,
122
+ "draft": 0.3
123
+ }
124
+ return status_map.get(memory.get("status", "draft"), 0.3)
125
+
126
+ return 0.2
127
+
128
+ def _links_score(self, related_to: list) -> float:
129
+ """
130
+ Score de links (0-1).
131
+
132
+ Args:
133
+ related_to: Lista de IDs relacionados
134
+
135
+ Returns:
136
+ Score de links
137
+ """
138
+ if not related_to:
139
+ return 0.0
140
+ return min(1.0, len(related_to) * 0.25)
141
+
142
+ def apply_decay(self, score: float, days: int, decay_rate: float) -> float:
143
+ """
144
+ Aplica decay temporal ao score.
145
+
146
+ Args:
147
+ score: Score base
148
+ days: Dias de decaimento
149
+ decay_rate: Taxa de decaimento
150
+
151
+ Returns:
152
+ Score com decay aplicado
153
+ """
154
+ return score * math.exp(-decay_rate * days)
155
+
156
+ def calculate_all_scores(self, memory: Dict[str, Any]) -> Dict[str, float]:
157
+ """
158
+ Calcula todos os scores individuais e total.
159
+
160
+ Args:
161
+ memory: Dados da memória
162
+
163
+ Returns:
164
+ Dicionário com todos os scores
165
+ """
166
+ # BUG-01 FIX: Usa timezone-aware datetime
167
+ now = datetime.now(timezone.utc)
168
+ last_accessed = memory.get("last_accessed")
169
+ # Normaliza se for naive
170
+ if last_accessed and isinstance(last_accessed, datetime) and last_accessed.tzinfo is None:
171
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
172
+
173
+ r = self._recency_score(last_accessed)
174
+ f = self._frequency_score(memory.get("access_count", 0))
175
+ i = self._importance_score(memory)
176
+ l = self._links_score(memory.get("related_to", []))
177
+
178
+ total = (
179
+ self.config.recency_weight * r +
180
+ self.config.frequency_weight * f +
181
+ self.config.importance_weight * i +
182
+ self.config.links_weight * l
183
+ )
184
+
185
+ return {
186
+ "recency_score": min(1.0, max(0.0, r)),
187
+ "frequency_score": min(1.0, max(0.0, f)),
188
+ "importance_score": min(1.0, max(0.0, i)),
189
+ "links_score": min(1.0, max(0.0, l)),
190
+ "total_score": min(1.0, max(0.0, total))
191
+ }
@@ -0,0 +1,6 @@
1
+ """Core do Cerebro: schema, storage bruto, session manager"""
2
+ from .event_schema import Event, EventType, EventOrigin
3
+ from .jsonl_storage import JSONLStorage
4
+ from .session_manager import SessionManager
5
+
6
+ __all__ = ["Event", "EventType", "EventOrigin", "JSONLStorage", "SessionManager"]
@@ -0,0 +1,55 @@
1
+ import uuid
2
+ from datetime import datetime
3
+ from enum import Enum
4
+ from typing import Any, Dict, Optional
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ class EventType(str, Enum):
9
+ TOOL_CALL = "tool_call"
10
+ GIT_EVENT = "git_event"
11
+ TEST_RESULT = "test_result"
12
+ ERROR = "error"
13
+ CHECKPOINT_CREATED = "checkpoint.created"
14
+ PROMOTION_PERFORMED = "promotion.performed"
15
+ MEMORY_GC = "memory.gc"
16
+
17
+
18
+ class EventOrigin(str, Enum):
19
+ CLAUDE_CODE = "claude-code"
20
+ USER = "user"
21
+ CI = "ci"
22
+ HOOK = "hook"
23
+
24
+
25
+ class Event(BaseModel):
26
+ """Evento bruto do Cerebro"""
27
+
28
+ project: str = Field(..., min_length=1)
29
+ origin: EventOrigin
30
+ event_type: EventType
31
+ subtype: str = Field(default="")
32
+ payload: Dict[str, Any] = Field(default_factory=dict)
33
+ tags: list[str] = Field(default_factory=list)
34
+
35
+ # Campos gerados automaticamente
36
+ event_id: str = Field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:8]}")
37
+ session_id: str = Field(default_factory=lambda: f"sess_{uuid.uuid4().hex[:8]}")
38
+ ts: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
39
+
40
+ @field_validator("tags")
41
+ @classmethod
42
+ def validate_tags(cls, v):
43
+ return [tag.lower().replace(" ", "-") for tag in v]
44
+
45
+ def to_json_line(self) -> str:
46
+ """Serializa para JSON line"""
47
+ import json
48
+ return self.model_dump_json()
49
+
50
+ @classmethod
51
+ def from_json_line(cls, line: str) -> "Event":
52
+ """Deserializa de JSON line"""
53
+ import json
54
+ data = json.loads(line)
55
+ return cls(**data)
@@ -0,0 +1,238 @@
1
+ """Armazenamento JSONL append-only com rotação mensal"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+ from src.core.event_schema import Event
7
+
8
+
9
+ class JSONLStorage:
10
+ """
11
+ Armazenamento append-only de eventos em formato JSONL.
12
+
13
+ Arquivos são organizados por projeto e mês:
14
+ raw/{project}/events-YYYY-MM.jsonl
15
+
16
+ Rotação automática por mês - cada arquivo contém eventos de um mês específico.
17
+ """
18
+
19
+ def __init__(self, base_dir: Path):
20
+ """
21
+ Inicializa o armazenamento JSONL.
22
+
23
+ Args:
24
+ base_dir: Diretório base para a pasta raw/
25
+ """
26
+ self.base_dir = Path(base_dir)
27
+ self.base_dir.mkdir(parents=True, exist_ok=True)
28
+
29
+ def _get_project_dir(self, project: str) -> Path:
30
+ """Retorna diretório do projeto"""
31
+ return self.base_dir / project
32
+
33
+ def _get_current_file(self, project: str) -> Path:
34
+ """
35
+ Retorna o arquivo JSONL atual para um projeto.
36
+
37
+ O arquivo é nomeado com o mês atual: events-YYYY-MM.jsonl
38
+
39
+ WINDOWS FIX: Usa datetime.now(timezone.utc) ao invés de utcnow() deprecated
40
+ """
41
+ project_dir = self._get_project_dir(project)
42
+ project_dir.mkdir(parents=True, exist_ok=True)
43
+
44
+ # WINDOWS FIX: datetime.now(timezone.utc) ao invés de datetime.utcnow()
45
+ from datetime import datetime, timezone
46
+ month_str = datetime.now(timezone.utc).strftime("%Y-%m")
47
+ return project_dir / f"events-{month_str}.jsonl"
48
+
49
+ def append(self, event: Event) -> None:
50
+ """
51
+ Anexa um evento ao arquivo JSONL do projeto.
52
+
53
+ Args:
54
+ event: Evento a ser anexado
55
+ """
56
+ jsonl_file = self._get_current_file(event.project)
57
+
58
+ with open(jsonl_file, "a", encoding="utf-8") as f:
59
+ f.write(event.model_dump_json() + "\n")
60
+
61
+ def read(self, project: str) -> List[Event]:
62
+ """
63
+ Lê todos os eventos de um projeto.
64
+
65
+ Args:
66
+ project: Nome do projeto
67
+
68
+ Returns:
69
+ Lista de eventos ordenados por timestamp
70
+ """
71
+ project_dir = self._get_project_dir(project)
72
+ if not project_dir.exists():
73
+ return []
74
+
75
+ events = []
76
+ for jsonl_file in sorted(project_dir.glob("events-*.jsonl")):
77
+ with open(jsonl_file, "r", encoding="utf-8") as f:
78
+ for line in f:
79
+ line = line.strip()
80
+ if line:
81
+ events.append(Event.model_validate_json(line))
82
+
83
+ return events
84
+
85
+ def read_iter(self, project: str):
86
+ """
87
+ Gerador — não carrega tudo em memória.
88
+
89
+ PERFORMANCE FIX: Permite iterar sobre eventos sem carregar todos em memória
90
+
91
+ Args:
92
+ project: Nome do projeto
93
+
94
+ Yields:
95
+ Eventos um por um
96
+ """
97
+ project_dir = self._get_project_dir(project)
98
+ if not project_dir.exists():
99
+ return
100
+
101
+ for jsonl_file in sorted(project_dir.glob("events-*.jsonl")):
102
+ with open(jsonl_file, "r", encoding="utf-8") as f:
103
+ for line in f:
104
+ line = line.strip()
105
+ if line:
106
+ yield Event.model_validate_json(line)
107
+
108
+ def read_last_n(self, project: str, n: int = 1000) -> List[Event]:
109
+ """
110
+ Lê apenas os N eventos mais recentes.
111
+
112
+ PERFORMANCE FIX: Usa deque com maxlen para memória constante O(n)
113
+
114
+ Args:
115
+ project: Nome do projeto
116
+ n: Número máximo de eventos (padrão: 1000)
117
+
118
+ Returns:
119
+ Lista dos N eventos mais recentes
120
+ """
121
+ from collections import deque
122
+ return list(deque(self.read_iter(project), maxlen=n))
123
+
124
+ def read_since(self, project: str, since: str) -> List[Event]:
125
+ """
126
+ Lê eventos desde uma data específica.
127
+
128
+ WARN-05 FIX: Filtra por data durante a leitura para evitar carregar tudo em memória
129
+
130
+ Args:
131
+ project: Nome do projeto
132
+ since: Data mínima (ISO format, ex: "2026-03-01T00:00:00Z")
133
+
134
+ Returns:
135
+ Lista de eventos após a data especificada
136
+ """
137
+ from datetime import datetime, timezone
138
+
139
+ project_dir = self._get_project_dir(project)
140
+ if not project_dir.exists():
141
+ return []
142
+
143
+ # Parse da data de início
144
+ try:
145
+ since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
146
+ except ValueError:
147
+ # Fallback: lê tudo
148
+ return self.read(project)
149
+
150
+ events = []
151
+ for jsonl_file in sorted(project_dir.glob("events-*.jsonl")):
152
+ with open(jsonl_file, "r", encoding="utf-8") as f:
153
+ for line in f:
154
+ line = line.strip()
155
+ if not line:
156
+ continue
157
+
158
+ try:
159
+ event = Event.model_validate_json(line)
160
+ # Parse do timestamp do evento
161
+ event_ts = event.ts.replace("Z", "+00:00")
162
+ event_dt = datetime.fromisoformat(event_ts)
163
+
164
+ if event_dt >= since_dt:
165
+ events.append(event)
166
+ except Exception:
167
+ # Ignora eventos com timestamp inválido
168
+ continue
169
+
170
+ return events
171
+
172
+ def read_range(self, project: str, start_id: str, end_id: str) -> List[Event]:
173
+ """
174
+ Lê eventos em um intervalo de IDs.
175
+
176
+ HIGH FIX: Valida existência dos IDs antes de filtrar
177
+
178
+ Args:
179
+ project: Nome do projeto
180
+ start_id: ID inicial (inclusive)
181
+ end_id: ID final (inclusive)
182
+
183
+ Returns:
184
+ Lista de eventos no intervalo especificado
185
+
186
+ Raises:
187
+ ValueError: Se start_id ou end_id não existirem
188
+ """
189
+ if not start_id or not end_id:
190
+ raise ValueError("start_id e end_id são obrigatórios")
191
+
192
+ all_events = self.read(project)
193
+ ids = {e.event_id for e in all_events}
194
+
195
+ if start_id not in ids:
196
+ raise ValueError(f"start_id não encontrado: {start_id}")
197
+ if end_id not in ids:
198
+ raise ValueError(f"end_id não encontrado: {end_id}")
199
+
200
+ in_range = False
201
+ result = []
202
+
203
+ for event in all_events:
204
+ if event.event_id == start_id:
205
+ in_range = True
206
+
207
+ if in_range:
208
+ result.append(event)
209
+
210
+ if event.event_id == end_id:
211
+ break
212
+
213
+ return result
214
+
215
+ def get_file_stats(self, project: str) -> dict:
216
+ """
217
+ Retorna estatísticas dos arquivos de um projeto.
218
+
219
+ Args:
220
+ project: Nome do projeto
221
+
222
+ Returns:
223
+ Dicionário com estatísticas por arquivo
224
+ """
225
+ project_dir = self._get_project_dir(project)
226
+ if not project_dir.exists():
227
+ return {}
228
+
229
+ stats = {}
230
+ for jsonl_file in sorted(project_dir.glob("events-*.jsonl")):
231
+ size_bytes = jsonl_file.stat().st_size
232
+ line_count = sum(1 for _ in open(jsonl_file, "r", encoding="utf-8"))
233
+ stats[jsonl_file.name] = {
234
+ "size_bytes": size_bytes,
235
+ "event_count": line_count
236
+ }
237
+
238
+ return stats