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,544 @@
1
+ """Remember: revisão e promoção de memórias (replica /remember interno da Anthropic).
2
+
3
+ Replica o fluxo do remember.ts do Claude Code:
4
+ 1. Lê todas as camadas de memória (MEMORY.md, CLAUDE.md, CLAUDE.local.md)
5
+ 2. Classifica entradas por tipo (user/feedback/project/reference)
6
+ 3. Detecta duplicatas, conflitos e entradas desatualizadas
7
+ 4. Propõe relatório antes de aplicar qualquer mudança
8
+ 5. Só aplica com aprovação explícita
9
+
10
+ O /remember interno é bloqueado por USER_TYPE === 'ant' — só funcionários
11
+ da Anthropic têm acesso. Este módulo entrega o mesmo fluxo para todos.
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Literal, Optional, Tuple
18
+
19
+ from src.core.paths import (
20
+ get_auto_mem_path,
21
+ get_memory_index,
22
+ get_user_memory_path,
23
+ get_project_memory_path,
24
+ )
25
+ from src.memdir.scanner import scan_memory_files, MemoryHeader, parse_frontmatter
26
+ from src.working.yaml_storage import YAMLStorage
27
+ from src.official.markdown_storage import MarkdownStorage
28
+ from src.consolidation.promoter import Promoter
29
+
30
+
31
+ MemoryType = Literal['user', 'feedback', 'project', 'reference']
32
+ MemoryScope = Literal['private', 'team']
33
+ MemoryDest = Literal['claude_md', 'claude_local', 'team', 'stay', 'ambiguous', 'reject']
34
+
35
+
36
+ @dataclass
37
+ class MemoryEntry:
38
+ """Entrada de memória de qualquer camada.
39
+
40
+ Attributes:
41
+ source: Arquivo de origem (path absoluto)
42
+ layer: Camada (memory, claude_md, claude_local)
43
+ type: Tipo de memória
44
+ name: Nome da memória
45
+ description: Descrição
46
+ content: Conteúdo completo
47
+ mtime: Timestamp de modificação
48
+ """
49
+ source: Path
50
+ layer: str
51
+ type: Optional[MemoryType] = None
52
+ name: Optional[str] = None
53
+ description: Optional[str] = None
54
+ content: str = ""
55
+ mtime: float = 0.0
56
+
57
+
58
+ @dataclass
59
+ class ClassificationResult:
60
+ """Resultado da classificação de uma memória.
61
+
62
+ Attributes:
63
+ entry: Entrada original
64
+ proposed_type: Tipo proposto
65
+ proposed_scope: Escopo proposto (private/team)
66
+ proposed_dest: Destino proposto
67
+ reason: Razão da classificação
68
+ conflicts: Conflitos detectados com outras camadas
69
+ is_duplicate: Se é duplicata de memória existente
70
+ """
71
+ entry: MemoryEntry
72
+ proposed_type: Optional[MemoryType] = None
73
+ proposed_scope: MemoryScope = 'private'
74
+ proposed_dest: MemoryDest = 'stay'
75
+ reason: str = ""
76
+ conflicts: List[str] = field(default_factory=list)
77
+ is_duplicate: bool = False
78
+
79
+
80
+ @dataclass
81
+ class RememberReport:
82
+ """Relatório completo do remember.
83
+
84
+ Attributes:
85
+ promotions: Memórias para promover
86
+ cleanup: Duplicatas, conflitos, desatualizadas para remover
87
+ ambiguous: Entradas que precisam de input do usuário
88
+ no_action: Entradas que não requerem ação
89
+ """
90
+ promotions: List[Tuple[MemoryEntry, ClassificationResult]] = field(default_factory=list)
91
+ cleanup: List[Tuple[MemoryEntry, str]] = field(default_factory=list)
92
+ ambiguous: List[Tuple[MemoryEntry, str]] = field(default_factory=list)
93
+ no_action: List[MemoryEntry] = field(default_factory=list)
94
+
95
+
96
+ # ============================================================================
97
+ # CLASSIFICADOR DE MEMÓRIAS
98
+ # ============================================================================
99
+
100
+ class MemoryClassifier:
101
+ """Classifica memórias usando taxonomia da Anthropic.
102
+
103
+ Replica a lógica de classification do remember.ts interno.
104
+ """
105
+
106
+ # Regras de classificação baseadas em memoryTypes.ts
107
+ TYPE_KEYWORDS = {
108
+ 'user': ['preferência', 'objetivo', 'papel', 'experiência', 'background', 'skill'],
109
+ 'feedback': ['não', 'evite', 'prefira', 'sempre', 'nunca', 'corrija', 'confirme'],
110
+ 'project': ['deadline', 'release', 'entrega', 'milestone', 'sprint', 'projeto'],
111
+ 'reference': ['link', 'url', 'docs', 'documentação', 'repositório', 'dashboard'],
112
+ }
113
+
114
+ DEST_RULES = {
115
+ 'user': 'claude_local', # Sempre privado
116
+ 'feedback': 'claude_local', # Padrão: privado, team se convenção de projeto
117
+ 'project': 'claude_md', # Tendência forte para team
118
+ 'reference': 'claude_md', # Geralmente team
119
+ }
120
+
121
+ def classify(self, entry: MemoryEntry, existing: List[MemoryEntry] = None) -> ClassificationResult:
122
+ """
123
+ Classifica uma entrada de memória.
124
+
125
+ Args:
126
+ entry: Entrada para classificar
127
+ existing: Lista de memórias existentes para verificar conflitos
128
+
129
+ Returns:
130
+ ClassificationResult com tipo, escopo e destino propostos
131
+ """
132
+ result = ClassificationResult(entry=entry)
133
+
134
+ # Passo 1: Determina tipo baseado em palavras-chave e contexto
135
+ result.proposed_type = self._infer_type(entry)
136
+
137
+ # Passo 2: Determina escopo (private vs team)
138
+ result.proposed_scope = self._infer_scope(result.proposed_type, entry)
139
+
140
+ # Passo 3: Determina destino
141
+ result.proposed_dest = self.DEST_RULES.get(result.proposed_type, 'stay')
142
+
143
+ # Passo 4: Verifica duplicatas e conflitos
144
+ if existing:
145
+ self._check_conflicts(entry, existing, result)
146
+
147
+ # Passo 5: Gera razão
148
+ result.reason = self._generate_reason(result)
149
+
150
+ return result
151
+
152
+ def _infer_type(self, entry: MemoryEntry) -> MemoryType:
153
+ """Infere tipo baseado em conteúdo e descrição."""
154
+ text = (entry.description or "") + " " + (entry.content or "")[:500]
155
+ text_lower = text.lower()
156
+
157
+ scores = {}
158
+ for mem_type, keywords in self.TYPE_KEYWORDS.items():
159
+ score = sum(1 for kw in keywords if kw.lower() in text_lower)
160
+ scores[mem_type] = score
161
+
162
+ # Retorna tipo com maior score
163
+ if max(scores.values()) > 0:
164
+ return max(scores, key=scores.get)
165
+
166
+ # Fallback: tenta inferir por contexto
167
+ if entry.layer == 'claude_local':
168
+ return 'feedback'
169
+ if entry.layer == 'claude_md':
170
+ return 'project'
171
+
172
+ return 'project' # Default
173
+
174
+ def _infer_scope(self, mem_type: MemoryType, entry: MemoryEntry) -> MemoryScope:
175
+ """Infere escopo (private vs team)."""
176
+ if mem_type == 'user':
177
+ return 'private' # Sempre privado
178
+
179
+ if mem_type == 'feedback':
180
+ # Feedback é privado por padrão, team se for convenção de projeto
181
+ text = (entry.description or "").lower()
182
+ if any(kw in text for kw in ['projeto', 'time', 'convenção', 'padrão']):
183
+ return 'team'
184
+ return 'private'
185
+
186
+ if mem_type in ['project', 'reference']:
187
+ # Tendência para team
188
+ return 'team'
189
+
190
+ return 'private'
191
+
192
+ def _check_conflicts(
193
+ self,
194
+ entry: MemoryEntry,
195
+ existing: List[MemoryEntry],
196
+ result: ClassificationResult
197
+ ) -> None:
198
+ """Verifica duplicatas e conflitos com outras camadas."""
199
+ for existing_entry in existing:
200
+ # Verifica duplicata por nome
201
+ if entry.name and existing_entry.name:
202
+ if entry.name.lower() == existing_entry.name.lower():
203
+ result.is_duplicate = True
204
+ result.conflicts.append(f"Duplicata de {existing_entry.source}")
205
+ return
206
+
207
+ # Verifica conflito de conteúdo
208
+ if entry.description and existing_entry.description:
209
+ if entry.description.lower() == existing_entry.description.lower():
210
+ result.conflicts.append(f"Conteúdo idêntico em {existing_entry.source}")
211
+
212
+ def _generate_reason(self, result: ClassificationResult) -> str:
213
+ """Gera razão legível para a classificação."""
214
+ reasons = []
215
+
216
+ reasons.append(f"Tipo: {result.proposed_type or 'não determinado'}")
217
+ reasons.append(f"Escopo: {result.proposed_scope}")
218
+
219
+ if result.is_duplicate:
220
+ reasons.append("Duplicata detectada")
221
+
222
+ if result.conflicts:
223
+ reasons.append(f"Conflitos: {', '.join(result.conflicts)}")
224
+
225
+ return "; ".join(reasons)
226
+
227
+
228
+ # ============================================================================
229
+ # GATHER LAYERS
230
+ # ============================================================================
231
+
232
+ def gather_layers(project_root: Path = None) -> Tuple[List[MemoryEntry], Dict[str, Any]]:
233
+ """
234
+ Lê todas as camadas de memória.
235
+
236
+ Replica gatherLayers() do remember.ts interno.
237
+
238
+ Camadas lidas:
239
+ 1. MEMORY.md + arquivos linkados (memória automática)
240
+ 2. CLAUDE.md (se existir)
241
+ 3. CLAUDE.local.md (se existir)
242
+
243
+ Args:
244
+ project_root: Raiz do projeto (default: git root)
245
+
246
+ Returns:
247
+ Tuple (lista de todas as entradas, metadados das camadas)
248
+ """
249
+ memory_dir = get_auto_mem_path(project_root)
250
+ entries = []
251
+ layers = {
252
+ 'memory': [],
253
+ 'claude_md': None,
254
+ 'claude_local': None,
255
+ }
256
+
257
+ # Camada 1: MEMORY.md + arquivos linkados
258
+ memory_index = get_memory_index(memory_dir)
259
+ if memory_index.exists():
260
+ content = memory_index.read_text(encoding="utf-8")
261
+ layers['memory'] = content
262
+
263
+ # Parse linhas do índice para extrair links
264
+ for line in content.split("\n"):
265
+ line = line.strip()
266
+ if line.startswith("- [") or line.startswith("- "):
267
+ # Extrai nome do arquivo do link
268
+ # Formato: "- [type] filename.md (timestamp): description"
269
+ import re
270
+ match = re.search(r'\]\s+(\S+\.md)', line)
271
+ if match:
272
+ filename = match.group(1)
273
+ file_path = memory_dir / filename
274
+
275
+ if file_path.exists():
276
+ entry = read_memory_file(file_path, 'memory')
277
+ if entry:
278
+ entries.append(entry)
279
+
280
+ # Camada 2: CLAUDE.md (projeto)
281
+ claude_md_path = get_project_memory_path(memory_dir)
282
+ if claude_md_path.exists():
283
+ entry = read_memory_file(claude_md_path, 'claude_md')
284
+ if entry:
285
+ entries.append(entry)
286
+ layers['claude_md'] = entry.content
287
+
288
+ # Camada 3: CLAUDE.local.md (usuário)
289
+ claude_local_path = get_user_memory_path(memory_dir)
290
+ if claude_local_path.exists():
291
+ entry = read_memory_file(claude_local_path, 'claude_local')
292
+ if entry:
293
+ entries.append(entry)
294
+ layers['claude_local'] = entry.content
295
+
296
+ return entries, layers
297
+
298
+
299
+ def read_memory_file(file_path: Path, layer: str) -> Optional[MemoryEntry]:
300
+ """
301
+ Lê arquivo de memória e parseia frontmatter.
302
+
303
+ Args:
304
+ file_path: Path do arquivo
305
+ layer: Camada de origem
306
+
307
+ Returns:
308
+ MemoryEntry ou None se falhar
309
+ """
310
+ try:
311
+ content = file_path.read_text(encoding="utf-8")
312
+ stat = file_path.stat()
313
+
314
+ # Parse frontmatter
315
+ frontmatter = parse_frontmatter(content)
316
+
317
+ return MemoryEntry(
318
+ source=file_path,
319
+ layer=layer,
320
+ type=frontmatter.get("type"),
321
+ name=frontmatter.get("name"),
322
+ description=frontmatter.get("description"),
323
+ content=content,
324
+ mtime=stat.st_mtime
325
+ )
326
+ except Exception:
327
+ return None
328
+
329
+
330
+ # ============================================================================
331
+ # FIND CLEANUP
332
+ # ============================================================================
333
+
334
+ def find_cleanup(entries: List[MemoryEntry], classifications: Dict[str, ClassificationResult]) -> List[Tuple[MemoryEntry, str]]:
335
+ """
336
+ Encontra memórias candidatas a cleanup.
337
+
338
+ Replica findCleanup() do remember.ts interno.
339
+
340
+ Critérios:
341
+ - Duplicatas: entrada em memory/ já em CLAUDE.md
342
+ - Desatualizadas: CLAUDE.md contradiz entry mais recente
343
+ - Conflitos: contradição entre camadas
344
+
345
+ Args:
346
+ entries: Lista de todas as entradas
347
+ classifications: Classificações de cada entrada
348
+
349
+ Returns:
350
+ Lista de (entrada, razão para cleanup)
351
+ """
352
+ cleanup = []
353
+
354
+ # Agrupa entradas por nome
355
+ by_name: Dict[str, List[MemoryEntry]] = {}
356
+ for entry in entries:
357
+ if entry.name:
358
+ key = entry.name.lower()
359
+ by_name.setdefault(key, []).append(entry)
360
+
361
+ # Verifica duplicatas
362
+ for name, group in by_name.items():
363
+ if len(group) > 1:
364
+ # Ordena por mtime (mais recente primeiro)
365
+ group.sort(key=lambda e: e.mtime, reverse=True)
366
+
367
+ # Mantém a mais recente, marca outras como cleanup
368
+ for entry in group[1:]:
369
+ cleanup.append((entry, f"Duplicata de {group[0].source}"))
370
+
371
+ # Verifica conflitos entre camadas
372
+ memory_entries = [e for e in entries if e.layer == 'memory']
373
+ claude_md_entries = [e for e in entries if e.layer == 'claude_md']
374
+
375
+ for mem_entry in memory_entries:
376
+ for claude_entry in claude_md_entries:
377
+ if mem_entry.name and claude_entry.name:
378
+ if mem_entry.name.lower() == claude_entry.name.lower():
379
+ # Mesmo nome, camadas diferentes — verifica conflito
380
+ if mem_entry.description != claude_entry.description:
381
+ # Desatualizada — mantém a mais recente
382
+ if mem_entry.mtime > claude_entry.mtime:
383
+ cleanup.append((claude_entry, f"Desatualizada (memória mais recente: {mem_entry.source})"))
384
+ else:
385
+ cleanup.append((mem_entry, f"Desatualizada (CLAUDE.md mais recente: {claude_entry.source})"))
386
+
387
+ return cleanup
388
+
389
+
390
+ # ============================================================================
391
+ # ORQUESTRADOR PRINCIPAL
392
+ # ============================================================================
393
+
394
+ def run_remember(
395
+ project_root: Path = None,
396
+ dry_run: bool = True,
397
+ ) -> RememberReport:
398
+ """
399
+ Executa fluxo remember de revisão e promoção.
400
+
401
+ Replica o fluxo do /remember interno da Anthropic.
402
+
403
+ Passos:
404
+ 1. gather_layers() — lê todas as camadas
405
+ 2. classify_entries() — classifica por tipo
406
+ 3. find_cleanup() — detecta duplicatas e conflitos
407
+ 4. build_report() — gera relatório em 4 seções
408
+ 5. apply() — NUNCA chamado sem aprovação explícita
409
+
410
+ Args:
411
+ project_root: Raiz do projeto (default: git root)
412
+ dry_run: Se True, apenas gera relatório, não aplica
413
+
414
+ Returns:
415
+ RememberReport com promoções, cleanup, ambiguous e no_action
416
+ """
417
+ # Passo 1: Lê todas as camadas
418
+ entries, layers = gather_layers(project_root)
419
+
420
+ # Passo 2: Classifica entradas
421
+ classifier = MemoryClassifier()
422
+ classifications = {}
423
+
424
+ for entry in entries:
425
+ classification = classifier.classify(entry, entries)
426
+ classifications[entry.source] = classification
427
+
428
+ # Passo 3: Encontra cleanup
429
+ cleanup = find_cleanup(entries, classifications)
430
+
431
+ # Passo 4: Separa por categoria
432
+ report = RememberReport()
433
+
434
+ for entry in entries:
435
+ classification = classifications.get(entry.source)
436
+
437
+ if not classification:
438
+ report.no_action.append(entry)
439
+ continue
440
+
441
+ if classification.is_duplicate:
442
+ report.cleanup.append((entry, "Duplicata"))
443
+ continue
444
+
445
+ if classification.conflicts:
446
+ report.ambiguous.append((entry, "; ".join(classification.conflicts)))
447
+ continue
448
+
449
+ # Verifica se precisa promoção
450
+ if classification.proposed_dest != 'stay':
451
+ report.promotions.append((entry, classification))
452
+ else:
453
+ report.no_action.append(entry)
454
+
455
+ # Adiciona cleanup encontrado
456
+ for entry, reason in cleanup:
457
+ if (entry, reason) not in report.cleanup:
458
+ report.cleanup.append((entry, reason))
459
+
460
+ return report
461
+
462
+
463
+ def generate_remember_report(report: RememberReport) -> str:
464
+ """
465
+ Gera relatório legível do remember.
466
+
467
+ Formato em 4 seções:
468
+ 1. Promotions (destino + racional)
469
+ 2. Cleanup (duplicatas, conflitos, desatualizadas)
470
+ 3. Ambiguous (precisa input do usuário)
471
+ 4. No action
472
+
473
+ Args:
474
+ report: RememberReport para formatar
475
+
476
+ Returns:
477
+ Relatório em markdown
478
+ """
479
+ lines = [
480
+ "# Remember Report — Revisão de Memórias",
481
+ "",
482
+ "Este relatório propõe mudanças nas camadas de memória.",
483
+ "Nenhuma modificação será feita sem aprovação explícita.",
484
+ "",
485
+ ]
486
+
487
+ # Seção 1: Promotions
488
+ lines.append("## 1. Promoções Propostas")
489
+ lines.append("")
490
+
491
+ if report.promotions:
492
+ for entry, classification in report.promotions:
493
+ lines.append(f"### {entry.name or entry.source.name}")
494
+ lines.append("")
495
+ lines.append(f"- **Tipo:** {classification.proposed_type}")
496
+ lines.append(f"- **Escopo:** {classification.proposed_scope}")
497
+ lines.append(f"- **Destino:** {classification.proposed_dest}")
498
+ lines.append(f"- **Razão:** {classification.reason}")
499
+ lines.append(f"- **Origem:** {entry.source}")
500
+ lines.append("")
501
+ else:
502
+ lines.append("Nenhuma promoção proposta.")
503
+ lines.append("")
504
+
505
+ # Seção 2: Cleanup
506
+ lines.append("## 2. Cleanup (Duplicatas/Conflitos)")
507
+ lines.append("")
508
+
509
+ if report.cleanup:
510
+ for entry, reason in report.cleanup:
511
+ name = entry.name or entry.source.name
512
+ lines.append(f"- **{name}** ({entry.source})")
513
+ lines.append(f" - Razão: {reason}")
514
+ lines.append("")
515
+ else:
516
+ lines.append("Nenhum cleanup necessário.")
517
+ lines.append("")
518
+
519
+ # Seção 3: Ambiguous
520
+ lines.append("## 3. Ambíguos (Requer Input)")
521
+ lines.append("")
522
+
523
+ if report.ambiguous:
524
+ for entry, conflicts in report.ambiguous:
525
+ name = entry.name or entry.source.name
526
+ lines.append(f"- **{name}** ({entry.source})")
527
+ lines.append(f" - Conflitos: {conflicts}")
528
+ lines.append("")
529
+ else:
530
+ lines.append("Nenhum conflito ambíguo.")
531
+ lines.append("")
532
+
533
+ # Seção 4: No action
534
+ lines.append("## 4. Sem Ação")
535
+ lines.append("")
536
+
537
+ if report.no_action:
538
+ lines.append(f"{len(report.no_action)} entradas não requerem ação.")
539
+ lines.append("")
540
+ else:
541
+ lines.append("Todas as entradas requerem ação.")
542
+ lines.append("")
543
+
544
+ return "\n".join(lines)