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,96 @@
1
+ """Gerenciamento de triggers de checkpoint"""
2
+
3
+ from enum import Enum
4
+ from typing import Dict, List
5
+
6
+
7
+ class CheckpointTrigger(Enum):
8
+ """Tipos de triggers de checkpoint"""
9
+ FEATURE_DONE = "feature_done"
10
+ SESSION_END = "session_end"
11
+ ERROR_CRITICAL = "error_critical"
12
+ MANUAL = "manual"
13
+
14
+
15
+ class CheckpointManager:
16
+ """
17
+ Gerencia triggers de checkpoint.
18
+
19
+ Triggers disponíveis:
20
+ - feature_done: Feature implementada com testes passando
21
+ - session_end: Fim de sessão do desenvolvedor
22
+ - error_critical: Erro crítico que deve ser documentado
23
+ - manual: Trigger manual solicitada pelo usuário
24
+ """
25
+
26
+ def __init__(self, config_path):
27
+ """
28
+ Inicializa o CheckpointManager.
29
+
30
+ Args:
31
+ config_path: Path para configuração
32
+ """
33
+ self.config_path = config_path
34
+
35
+ def check_triggers(self, context: Dict) -> List[CheckpointTrigger]:
36
+ """
37
+ Verifica triggers baseado no contexto.
38
+
39
+ Args:
40
+ context: Dicionário com dados do contexto
41
+
42
+ Returns:
43
+ Lista de triggers ativados
44
+ """
45
+ triggers = []
46
+
47
+ if context.get("tests_passed") and context.get("files_changed"):
48
+ triggers.append(CheckpointTrigger.FEATURE_DONE)
49
+
50
+ if context.get("session_ending"):
51
+ triggers.append(CheckpointTrigger.SESSION_END)
52
+
53
+ if context.get("error_severity") in ["critical", "high"]:
54
+ triggers.append(CheckpointTrigger.ERROR_CRITICAL)
55
+
56
+ return triggers
57
+
58
+ def should_checkpoint(self, context: Dict) -> bool:
59
+ """
60
+ Decide se deve fazer checkpoint.
61
+
62
+ Args:
63
+ context: Dicionário com dados do contexto
64
+
65
+ Returns:
66
+ True se deve fazer checkpoint, False caso contrário
67
+ """
68
+ return len(self.check_triggers(context)) > 0
69
+
70
+ def get_trigger_reason(self, context: Dict) -> str:
71
+ """
72
+ Obtém descrição do motivo do checkpoint.
73
+
74
+ Args:
75
+ context: Dicionário com dados do contexto
76
+
77
+ Returns:
78
+ String descrevendo o motivo
79
+ """
80
+ triggers = self.check_triggers(context)
81
+
82
+ if not triggers:
83
+ return "Nenhum trigger ativado"
84
+
85
+ reasons = []
86
+ for trigger in triggers:
87
+ if trigger == CheckpointTrigger.FEATURE_DONE:
88
+ reasons.append("Feature concluída com testes passando")
89
+ elif trigger == CheckpointTrigger.SESSION_END:
90
+ reasons.append("Fim de sessão")
91
+ elif trigger == CheckpointTrigger.ERROR_CRITICAL:
92
+ reasons.append("Erro crítico detectado")
93
+ elif trigger == CheckpointTrigger.MANUAL:
94
+ reasons.append("Solicitação manual")
95
+
96
+ return "; ".join(reasons)
@@ -0,0 +1,465 @@
1
+ """Dream: extração automática de memórias (extractMemories desbloqueado).
2
+
3
+ Replica o fluxo do extractMemories.ts do Claude Code:
4
+ 1. Scan de memórias existentes (evita duplicatas)
5
+ 2. Contagem de mensagens novas no transcript
6
+ 3. Build do prompt de extração
7
+ 4. Chamada à API Claude com max 5 turns
8
+ 5. Report do que foi escrito
9
+
10
+ O dream é o "extractMemories desbloqueado" — a feature que a Anthropic
11
+ gateou com tengu_passport_quail=false para usuários não-Anthropic.
12
+ """
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timedelta
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from src.core.paths import get_auto_mem_path, get_memory_index, MEMORY_INDEX_MAX_LINES
20
+ from src.memdir.scanner import scan_memory_files, format_memory_manifest, MemoryHeader
21
+
22
+
23
+ @dataclass
24
+ class DreamResult:
25
+ """Resultado da extração de memórias.
26
+
27
+ Attributes:
28
+ written_files: Lista de arquivos criados/atualizados
29
+ memory_dir: Diretório de memória processado
30
+ dry_run: Se True, nada foi modificado
31
+ period_days: Período analisado
32
+ prompt_preview: Preview do prompt usado na extração
33
+ """
34
+ written_files: List[Path]
35
+ memory_dir: Path
36
+ dry_run: bool
37
+ period_days: int
38
+ new_memories: List[str] = None
39
+ updated_memories: List[str] = None
40
+ prompt_preview: str = ""
41
+
42
+
43
+ # ============================================================================
44
+ # PROMPT DE EXTRAÇÃO — Tradução direta de buildExtractAutoOnlyPrompt()
45
+ # ============================================================================
46
+
47
+ TYPES_SECTION_INDIVIDUAL = """
48
+ ## Tipos de Memória (Taxonomia Fechada)
49
+
50
+ Você DEVE classificar cada memória em um destes 4 tipos:
51
+
52
+ ### 1. user (Sempre Privado)
53
+ <scope>always private</scope>
54
+ <description>Informações sobre o papel, objetivos, responsabilidades e conhecimento do usuário</description>
55
+ <when_to_save>Quando você aprende detalhes sobre o papel, preferências, responsabilidades ou conhecimento do usuário</when_to_save>
56
+ <how_to_use>Quando seu trabalho deve ser informado pelo perfil ou perspectiva do usuário</how_to_use>
57
+
58
+ ### 2. feedback (Padrão: Privado, Team apenas para convenções de projeto)
59
+ <scope>default to private. Save as team only when the guidance is clearly a project-wide convention</scope>
60
+ <description>Orientação que o usuário deu sobre como abordar o trabalho - tanto o que evitar quanto o que continuar fazendo</description>
61
+ <when_to_save>Qualquer momento que o usuário corrigir sua abordagem ("não isso", "não faça X") OU confirmar que uma abordagem funcionou ("isso mesmo", "perfeito, continue assim")</when_to_save>
62
+ <body_structure>Comece com a regra, depois **Why:** (razão) e **How to apply:** (quando/onde aplicar)</body_structure>
63
+
64
+ ### 3. project (Privado ou Team, tendência forte para Team)
65
+ <scope>private or team, but strongly bias toward team</scope>
66
+ <description>Informação sobre trabalho em andamento, metas, iniciativas, bugs ou incidentes no projeto que não é derivável do código ou histórico git</description>
67
+ <when_to_save>Quando você aprende quem está fazendo o quê, por quê, ou até quando. Sempre converta datas relativas para absolutas</when_to_save>
68
+ <body_structure>Comece com o fato/decisão, depois **Why:** (motivação) e **How to apply:** (como isso deve moldar suas sugestões)</body_structure>
69
+
70
+ ### 4. reference (Geralmente Team)
71
+ <scope>usually team</scope>
72
+ <description>Ponteiros para onde a informação pode ser encontrada em sistemas externos</description>
73
+ <when_to_save>Quando você aprende sobre recursos em sistemas externos e seu propósito</when_to_save>
74
+ """
75
+
76
+ WHAT_NOT_TO_SAVE_SECTION = """
77
+ ## O Que NÃO Salvar em Memória
78
+
79
+ - Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.
80
+ - Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.
81
+ - Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.
82
+ - Anything already documented in CLAUDE.md files.
83
+ - Ephemeral task details: in-progress work, temporary state, current conversation context.
84
+
85
+ These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.
86
+ """
87
+
88
+ TRUSTING_RECALL_SECTION = """
89
+ ## Before Recommending from Memory
90
+
91
+ A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:
92
+
93
+ - If the memory names a file path: check the file exists.
94
+ - If the memory names a function or flag: grep for it.
95
+ - If the user is about to act on your recommendation (not just asking about history), verify first.
96
+
97
+ "The memory says X exists" is not the same as "X exists now."
98
+ """
99
+
100
+
101
+ def build_opener(new_message_count: int, existing_memories: str) -> str:
102
+ """
103
+ Constrói opener do prompt de extração.
104
+
105
+ Replica opener() de prompts.ts do Claude Code.
106
+
107
+ Args:
108
+ new_message_count: Número de mensagens novas para analisar
109
+ existing_memories: Manifesto de memórias existentes
110
+
111
+ Returns:
112
+ String opener do prompt
113
+ """
114
+ return f"""You are now acting as the memory extraction subagent. Analyze the most recent ~{new_message_count} messages above and use them to update your persistent memory systems.
115
+
116
+ Available tools: FileRead, Grep, Glob, read-only Bash (ls/find/cat/stat/wc/head/tail and similar), and FileEdit/FileWrite for paths inside the memory directory only. Bash rm is not permitted. All other tools will be denied.
117
+
118
+ You have a limited turn budget. FileEdit requires a prior FileRead of the same file, so the efficient strategy is: turn 1 — issue all FileRead calls in parallel for every file you might update; turn 2 — issue all FileWrite/FileEdit calls in parallel. Do not interleave reads and writes across multiple turns.
119
+
120
+ You MUST only use content from the last ~{new_message_count} messages to update your persistent memories. Do not waste any turns attempting to investigate or verify that content further — no grepping source files, no reading code to confirm a pattern exists, no git commands.
121
+
122
+ {existing_memories}
123
+ """
124
+
125
+
126
+ def build_how_to_save_section(memory_dir: Path) -> str:
127
+ """
128
+ Constrói seção de como salvar memórias.
129
+
130
+ Args:
131
+ memory_dir: Diretório de memória para incluir no prompt
132
+
133
+ Returns:
134
+ String com instruções de como salvar
135
+ """
136
+ return f"""
137
+ ## Como Salvar Memórias
138
+
139
+ ### Formato do Arquivo
140
+
141
+ Cada memória é um arquivo .md separado em: {memory_dir}
142
+
143
+ **Frontmatter (obrigatório):**
144
+ ```markdown
145
+ ---
146
+ name: {{memory name}}
147
+ description: {{one-line description — usada para decidir relevância em conversas futuras, seja específico}}
148
+ type: {{user, feedback, project, reference}}
149
+ ---
150
+
151
+ {{memory content — para tipos feedback/project, estruture como: regra/fato, depois **Why:** e **How to apply:**}}
152
+ ```
153
+
154
+ ### Atualizando MEMORY.md (Índice)
155
+
156
+ O arquivo MEMORY.md é o índice de todas as memórias. Após criar/atualizar uma memória:
157
+
158
+ 1. Leia MEMORY.md existente
159
+ 2. Adicione uma linha no formato: `- [{{type}}] {{filename}} ({{timestamp}}): {{description}}`
160
+ 3. Mantenha o limite de {MEMORY_INDEX_MAX_LINES} linhas máximo
161
+
162
+ ### Regras de Escrita
163
+
164
+ - **Sempre** verifique memórias existentes antes de criar novas (evite duplicatas)
165
+ - **Sempre** use frontmatter com name, description, type
166
+ - **Sempre** inclua **Why:** e **How to apply:** para feedback e project
167
+ - **Nunca** salve o que é derivável do código ou git
168
+ - **Priorize** o que é surpreendente ou não-óbvio
169
+ """
170
+
171
+
172
+ def build_extract_dream_prompt(
173
+ new_message_count: int,
174
+ existing_memories: str,
175
+ memory_dir: Path,
176
+ skip_index: bool = False,
177
+ ) -> List[str]:
178
+ """
179
+ Constrói prompt completo de extração (dream).
180
+
181
+ Replica buildExtractAutoOnlyPrompt() do Claude Code (prompts.ts).
182
+
183
+ Args:
184
+ new_message_count: Número de mensagens novas para analisar
185
+ existing_memories: Manifesto de memórias existentes
186
+ memory_dir: Diretório de memória (para instruções de path)
187
+ skip_index: Se True, não atualiza MEMORY.md (modo direto)
188
+
189
+ Returns:
190
+ Lista de seções do prompt (para join com \\n)
191
+ """
192
+ how_to_save = build_how_to_save_section(memory_dir)
193
+
194
+ sections = [
195
+ build_opener(new_message_count, existing_memories),
196
+ "",
197
+ "If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.",
198
+ "",
199
+ TYPES_SECTION_INDIVIDUAL.strip(),
200
+ "",
201
+ WHAT_NOT_TO_SAVE_SECTION.strip(),
202
+ "",
203
+ how_to_save.strip(),
204
+ "",
205
+ TRUSTING_RECALL_SECTION.strip(),
206
+ ]
207
+
208
+ return sections
209
+
210
+
211
+ # ============================================================================
212
+ # CONTADOR DE MENSAGENS DO TRANSCRIPT
213
+ # ============================================================================
214
+
215
+ def count_transcript_messages(since_days: int = 7) -> int:
216
+ """
217
+ Conta mensagens no transcript do Claude Code.
218
+
219
+ Lê ~/.claude/projects/<slug>/*.jsonl e conta mensagens model-visible
220
+ (type=user ou type=assistant) no período especificado.
221
+
222
+ Args:
223
+ since_days: Dias para analisar (default: 7)
224
+
225
+ Returns:
226
+ Número de mensagens novas
227
+ """
228
+ memory_dir = get_auto_mem_path()
229
+ projects_dir = memory_dir.parent # ~/.claude/projects/<slug>/
230
+
231
+ if not projects_dir.exists():
232
+ return 0
233
+
234
+ cutoff = datetime.now() - timedelta(days=since_days)
235
+ message_count = 0
236
+
237
+ # Lê todos os jsonl do diretório
238
+ jsonl_files = list(projects_dir.glob("*.jsonl"))
239
+
240
+ for jsonl_file in jsonl_files:
241
+ try:
242
+ with open(jsonl_file, "r", encoding="utf-8") as f:
243
+ for line in f:
244
+ line = line.strip()
245
+ if not line:
246
+ continue
247
+
248
+ import json
249
+ try:
250
+ msg = json.loads(line)
251
+ msg_type = msg.get("type", "")
252
+
253
+ # Conta apenas mensagens model-visible
254
+ if msg_type in ["user", "assistant"]:
255
+ # Verifica timestamp se disponível
256
+ ts = msg.get("ts", "")
257
+ if ts:
258
+ try:
259
+ msg_dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
260
+ if msg_dt.replace(tzinfo=None) < cutoff:
261
+ continue
262
+ except ValueError:
263
+ pass
264
+
265
+ message_count += 1
266
+ except json.JSONDecodeError:
267
+ continue
268
+ except Exception:
269
+ continue
270
+
271
+ return message_count
272
+
273
+
274
+ # ============================================================================
275
+ # ORQUESTRADOR PRINCIPAL
276
+ # ============================================================================
277
+
278
+ def run_dream(
279
+ memory_dir: Path = None,
280
+ since_days: int = 7,
281
+ dry_run: bool = False,
282
+ ) -> DreamResult:
283
+ """
284
+ Executa extração automática de memórias (dream).
285
+
286
+ Replica o fluxo do extractMemories.ts do Claude Code:
287
+ 1. Scan de memórias existentes
288
+ 2. Contagem de mensagens novas
289
+ 3. Build do prompt
290
+ 4. Chamada à API Claude (max 5 turns) — SIMULADO neste método
291
+ 5. Report
292
+
293
+ Args:
294
+ memory_dir: Diretório de memória (default: auto-detected)
295
+ since_days: Dias para analisar (default: 7)
296
+ dry_run: Se True, apenas simula, não modifica nada
297
+
298
+ Returns:
299
+ DreamResult com arquivos processados
300
+ """
301
+ if memory_dir is None:
302
+ memory_dir = get_auto_mem_path()
303
+
304
+ # Passo 1: Scan de memórias existentes
305
+ existing = scan_memory_files(memory_dir)
306
+ existing_manifest = format_memory_manifest(existing)
307
+
308
+ # Passo 2: Contagem de mensagens novas
309
+ message_count = count_transcript_messages(since_days)
310
+
311
+ if message_count == 0:
312
+ return DreamResult(
313
+ written_files=[],
314
+ memory_dir=memory_dir,
315
+ dry_run=dry_run,
316
+ period_days=since_days,
317
+ new_memories=[],
318
+ updated_memories=[]
319
+ )
320
+
321
+ # Passo 3: Build do prompt
322
+ prompt_sections = build_extract_dream_prompt(
323
+ new_message_count=message_count,
324
+ existing_memories=existing_manifest,
325
+ memory_dir=memory_dir,
326
+ )
327
+ full_prompt = "\n".join(prompt_sections)
328
+
329
+ # Passo 4: Chamada à API Claude
330
+ import re
331
+ import os
332
+
333
+ prompt_preview = full_prompt
334
+
335
+ if dry_run:
336
+ return DreamResult(
337
+ written_files=[],
338
+ memory_dir=memory_dir,
339
+ dry_run=dry_run,
340
+ period_days=since_days,
341
+ new_memories=[],
342
+ updated_memories=[],
343
+ prompt_preview=prompt_preview,
344
+ )
345
+
346
+ # Chamada real à API
347
+ try:
348
+ import anthropic
349
+ _api_key = (
350
+ os.environ.get("ANTHROPIC_AUTH_TOKEN")
351
+ or os.environ.get("ANTHROPIC_API_KEY")
352
+ )
353
+ _base_url = os.environ.get("ANTHROPIC_BASE_URL")
354
+ model = (
355
+ os.environ.get("CEREBRO_MODEL")
356
+ or os.environ.get("ANTHROPIC_MODEL")
357
+ or "claude-opus-4-5"
358
+ )
359
+ client = anthropic.Anthropic(
360
+ api_key=_api_key,
361
+ base_url=_base_url,
362
+ )
363
+ response = client.messages.create(
364
+ model=model,
365
+ max_tokens=8096,
366
+ system=full_prompt,
367
+ messages=[{
368
+ "role": "user",
369
+ "content": (
370
+ "Analyze the conversation transcript and extract memories "
371
+ "worth saving. Focus on decisions, feedback, and project facts."
372
+ )
373
+ }]
374
+ )
375
+ response_text = response.content[0].text if response.content else ""
376
+ except Exception as e:
377
+ return DreamResult(
378
+ written_files=[],
379
+ memory_dir=memory_dir,
380
+ dry_run=dry_run,
381
+ period_days=since_days,
382
+ new_memories=[],
383
+ updated_memories=[],
384
+ prompt_preview=f"ERRO na chamada LLM: {e}",
385
+ )
386
+
387
+ # Parse: extrai filenames .md mencionados na resposta
388
+ mentioned = re.findall(r'[\w\-]+\.md', response_text)
389
+ mentioned = list(dict.fromkeys(mentioned)) # dedup mantendo ordem
390
+
391
+ written_files = []
392
+ new_memories = []
393
+ updated_memories = []
394
+
395
+ for fname in mentioned:
396
+ if fname == "MEMORY.md":
397
+ continue
398
+ fpath = memory_dir / fname
399
+ if fpath.exists():
400
+ updated_memories.append(fname)
401
+ written_files.append(fpath)
402
+ else:
403
+ new_memories.append(fname)
404
+
405
+ return DreamResult(
406
+ written_files=written_files,
407
+ memory_dir=memory_dir,
408
+ dry_run=dry_run,
409
+ period_days=since_days,
410
+ new_memories=new_memories,
411
+ updated_memories=updated_memories,
412
+ prompt_preview=prompt_preview,
413
+ )
414
+
415
+
416
+ def generate_dream_report(result: DreamResult) -> str:
417
+ """
418
+ Gera relatório da extração dream.
419
+
420
+ Args:
421
+ result: DreamResult da execução
422
+
423
+ Returns:
424
+ Relatório formatado em markdown
425
+ """
426
+ lines = [
427
+ "# Dream Report — Extração de Memórias",
428
+ "",
429
+ f"**Período:** últimos {result.period_days} dias",
430
+ f"**Diretório:** {result.memory_dir}",
431
+ f"**Modo:** {'dry-run (nenhuma modificação)' if result.dry_run else 'aplicação direta'}",
432
+ "",
433
+ "## Resumo",
434
+ "",
435
+ ]
436
+
437
+ if result.new_memories:
438
+ lines.append("### Novas Memórias")
439
+ lines.append("")
440
+ for mem in result.new_memories:
441
+ lines.append(f"- {mem}")
442
+ lines.append("")
443
+ else:
444
+ lines.append("Nenhuma memória nova criada.")
445
+ lines.append("")
446
+
447
+ if result.updated_memories:
448
+ lines.append("### Memórias Atualizadas")
449
+ lines.append("")
450
+ for mem in result.updated_memories:
451
+ lines.append(f"- {mem}")
452
+ lines.append("")
453
+
454
+ if result.written_files:
455
+ lines.append("### Arquivos Modificados")
456
+ lines.append("")
457
+ for f in result.written_files:
458
+ lines.append(f"- {f}")
459
+ lines.append("")
460
+
461
+ if not result.new_memories and not result.updated_memories and not result.written_files:
462
+ lines.append("Nenhuma mudança foi necessária.")
463
+ lines.append("")
464
+
465
+ return "\n".join(lines)