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,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)
|