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,571 @@
1
+ """Memory Diff: análise diferencial de memória entre dois pontos no tempo"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ from src.official.markdown_storage import MarkdownStorage
9
+ from src.working.yaml_storage import YAMLStorage
10
+ from src.core.jsonl_storage import JSONLStorage
11
+ from src.core.event_schema import Event
12
+ from src.consolidation.scorer import Scorer, ScoringConfig
13
+
14
+
15
+ @dataclass
16
+ class MemoryDiffResult:
17
+ """Resultado da análise diferencial de memória"""
18
+ # Período analisado
19
+ start_date: str
20
+ end_date: str
21
+
22
+ # Decisões
23
+ decisions_added: List[Dict[str, Any]] = field(default_factory=list)
24
+ decisions_removed: List[Dict[str, Any]] = field(default_factory=list)
25
+
26
+ # Erros
27
+ errors_documented: List[Dict[str, Any]] = field(default_factory=list)
28
+
29
+ # Drafts pendentes
30
+ drafts_pending: List[Dict[str, Any]] = field(default_factory=list)
31
+
32
+ # Memórias em risco de GC
33
+ at_risk: List[Dict[str, Any]] = field(default_factory=list)
34
+
35
+ # Resumo de eventos
36
+ events_summary: Dict[str, Any] = field(default_factory=dict)
37
+
38
+ # Estatísticas
39
+ stats: Dict[str, Any] = field(default_factory=dict)
40
+
41
+
42
+ class MemoryDiff:
43
+ """
44
+ Análise diferencial de memória entre dois pontos no tempo.
45
+
46
+ Compara o estado da memória em dois períodos e gera relatórios sobre:
47
+ - Decisões adicionadas/removidas
48
+ - Erros documentados
49
+ - Drafts pendentes de promoção
50
+ - Memórias em risco de garbage collection
51
+ - Resumo de atividade de eventos
52
+
53
+ Addressed critical issues:
54
+ 1. Uses list_official(project, "decisions") instead of non-existent list_decisions()
55
+ 2. Parses date strings to datetime before passing to scorer
56
+ 3. Checks if promoted_at exists in draft data
57
+ 4. Robust _parse_ts() helper for Event.ts timezone handling
58
+ 5. Handles missing date fields in frontmatter gracefully
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ official_storage: MarkdownStorage,
64
+ working_storage: YAMLStorage,
65
+ raw_storage: JSONLStorage
66
+ ):
67
+ """
68
+ Inicializa o MemoryDiff.
69
+
70
+ Args:
71
+ official_storage: Instância do MarkdownStorage
72
+ working_storage: Instância do YAMLStorage
73
+ raw_storage: Instância do JSONLStorage
74
+ """
75
+ self.official = official_storage
76
+ self.working = working_storage
77
+ self.raw = raw_storage
78
+ self._scorer = Scorer(ScoringConfig())
79
+
80
+ def _parse_ts(self, ts: str) -> datetime:
81
+ """
82
+ Parse robusto de timestamp para datetime.
83
+
84
+ Issue #4: Lida com edge cases de timezone
85
+
86
+ Args:
87
+ ts: Timestamp string (ISO format, com ou sem 'Z')
88
+
89
+ Returns:
90
+ datetime com timezone info
91
+ """
92
+ if not ts:
93
+ return datetime.now(timezone.utc)
94
+
95
+ # Remove 'Z' suffix se presente
96
+ ts_clean = ts.rstrip("Z")
97
+
98
+ try:
99
+ dt = datetime.fromisoformat(ts_clean)
100
+ # Adiciona UTC se nao houver timezone
101
+ if dt.tzinfo is None:
102
+ dt = dt.replace(tzinfo=timezone.utc)
103
+ return dt
104
+ except ValueError:
105
+ # Fallback para now se parse falhar
106
+ return datetime.now(timezone.utc)
107
+
108
+ def _parse_frontmatter_dates(self, fm: Dict[str, Any]) -> Dict[str, Any]:
109
+ """
110
+ Parse date fields from frontmatter para datetime.
111
+
112
+ Issue #2: Scorer.calculate() espera datetime, mas frontmatter armazena strings
113
+
114
+ Args:
115
+ fm: Frontmatter dictionary
116
+
117
+ Returns:
118
+ Dictionary com campos de data parseados
119
+ """
120
+ result = dict(fm) # Copy para nao mutar original
121
+
122
+ # Parse last_accessed
123
+ if isinstance(result.get("last_accessed"), str):
124
+ result["last_accessed"] = self._parse_ts(result["last_accessed"])
125
+
126
+ # Parse date (Issue #5: pode nao existir em todos os frontmatter)
127
+ if isinstance(result.get("date"), str):
128
+ result["date"] = self._parse_ts(result["date"])
129
+
130
+ # Parse created_at se existir
131
+ if isinstance(result.get("created_at"), str):
132
+ result["created_at"] = self._parse_ts(result["created_at"])
133
+
134
+ return result
135
+
136
+ def _is_in_period(self, ts: str, start: datetime, end: datetime) -> bool:
137
+ """Verifica se timestamp está dentro do período"""
138
+ dt = self._parse_ts(ts)
139
+ return start <= dt <= end
140
+
141
+ def _get_period_dates(
142
+ self,
143
+ period_days: Optional[int] = None,
144
+ start_date: Optional[str] = None,
145
+ end_date: Optional[str] = None
146
+ ) -> Tuple[datetime, datetime]:
147
+ """
148
+ Calcula datas de início e fim do período.
149
+
150
+ Args:
151
+ period_days: Dias do período (ex: 7, 30)
152
+ start_date: Data de início explícita (ISO string)
153
+ end_date: Data de fim explícita (ISO string)
154
+
155
+ Returns:
156
+ Tuple (start_date, end_date) como datetime
157
+ """
158
+ now = datetime.now(timezone.utc)
159
+
160
+ if start_date and end_date:
161
+ # Datas explícitas
162
+ start = self._parse_ts(start_date)
163
+ end = self._parse_ts(end_date)
164
+ elif period_days:
165
+ # Período relativo
166
+ end = now
167
+ from datetime import timedelta
168
+ start = now - timedelta(days=period_days)
169
+ else:
170
+ # Default: últimos 7 dias
171
+ from datetime import timedelta
172
+ end = now
173
+ start = now - timedelta(days=7)
174
+
175
+ return start, end
176
+
177
+ def _compare_decisions(
178
+ self,
179
+ project: str,
180
+ start: datetime,
181
+ end: datetime
182
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
183
+ """
184
+ Compara decisões entre dois períodos.
185
+
186
+ Issue #1: Usa list_official(project, "decisions") em vez de list_decisions()
187
+
188
+ Args:
189
+ project: Nome do projeto
190
+ start: Data de início
191
+ end: Data de fim
192
+
193
+ Returns:
194
+ Tuple (decisoes_adicionadas, decisoes_removidas)
195
+ """
196
+ # Issue #1: list_official com subdir "decisions"
197
+ decisions = self.official.list_official(project, "decisions")
198
+
199
+ added = []
200
+ removed = []
201
+
202
+ for decision in decisions:
203
+ # Issue #5: date field pode nao existir
204
+ date_str = decision.get("date")
205
+ if not date_str:
206
+ # Tenta fallback para created_at ou events_from
207
+ date_str = decision.get("created_at") or decision.get("events_from")
208
+
209
+ if date_str:
210
+ decision_date = self._parse_ts(date_str)
211
+ if start <= decision_date <= end:
212
+ added.append({
213
+ "id": decision.get("decision_id", decision.get("id", "unknown")),
214
+ "title": decision.get("title", "Sem título"),
215
+ "date": date_str,
216
+ "status": decision.get("status", "unknown"),
217
+ "tags": decision.get("tags", [])
218
+ })
219
+
220
+ # Nota: decisoes removidas sao mais complexas - requereria snapshot histórico
221
+ # Por enquanto, retornamos lista vazia até que histórico seja implementado
222
+ return added, removed
223
+
224
+ def _get_errors_documented(
225
+ self,
226
+ project: str,
227
+ start: datetime,
228
+ end: datetime
229
+ ) -> List[Dict[str, Any]]:
230
+ """
231
+ Obtém erros documentados no período.
232
+
233
+ Args:
234
+ project: Nome do projeto
235
+ start: Data de início
236
+ end: Data de fim
237
+
238
+ Returns:
239
+ Lista de erros documentados
240
+ """
241
+ errors = self.official.list_official(project, "errors")
242
+ documented = []
243
+
244
+ for error in errors:
245
+ # Issue #5: date field pode nao existir
246
+ date_str = error.get("date") or error.get("created_at")
247
+ if date_str:
248
+ error_date = self._parse_ts(date_str)
249
+ if start <= error_date <= end:
250
+ documented.append({
251
+ "id": error.get("error_id", error.get("id", "unknown")),
252
+ "severity": error.get("severity", "unknown"),
253
+ "status": error.get("status", "unknown"),
254
+ "category": error.get("category", "unknown"),
255
+ "date": date_str
256
+ })
257
+
258
+ return documented
259
+
260
+ def _get_pending_drafts(
261
+ self,
262
+ project: str,
263
+ start: datetime,
264
+ end: datetime
265
+ ) -> List[Dict[str, Any]]:
266
+ """
267
+ Obtém drafts pendentes de promoção.
268
+
269
+ Issue #3: Verifica se promoted_at existe e lida gracefulmente
270
+
271
+ Args:
272
+ project: Nome do projeto
273
+ start: Data de início
274
+ end: Data de fim
275
+
276
+ Returns:
277
+ Lista de drafts pendentes
278
+ """
279
+ pending = []
280
+
281
+ # Sessions
282
+ sessions = self.working.list_sessions(project)
283
+ for session in sessions:
284
+ status = session.get("status", "draft")
285
+ # Issue #3: promoted_at pode nao existir
286
+ promoted_at = session.get("promoted_at")
287
+
288
+ if status in ["draft", "needs_review"] and not promoted_at:
289
+ # Verifica se foi criado/modified no período
290
+ session_ts = session.get("created_at") or session.get("updated_at")
291
+ if not session_ts or self._is_in_period(session_ts, start, end):
292
+ pending.append({
293
+ "type": "session",
294
+ "id": session.get("id", "unknown"),
295
+ "status": status,
296
+ "needs_review": session.get("needs_review", False),
297
+ "created_at": session_ts
298
+ })
299
+
300
+ # Features
301
+ features = self.working.list_features(project)
302
+ for feature in features:
303
+ status = feature.get("status", "draft")
304
+ promoted_at = feature.get("promoted_at")
305
+
306
+ if status in ["draft", "needs_review"] and not promoted_at:
307
+ feature_ts = feature.get("created_at") or feature.get("updated_at")
308
+ if not feature_ts or self._is_in_period(feature_ts, start, end):
309
+ pending.append({
310
+ "type": "feature",
311
+ "id": feature.get("id", "unknown"),
312
+ "status": status,
313
+ "needs_review": feature.get("needs_review", False),
314
+ "created_at": feature_ts
315
+ })
316
+
317
+ return pending
318
+
319
+ def _get_at_risk_memories(
320
+ self,
321
+ project: str,
322
+ threshold: float = 0.3
323
+ ) -> List[Dict[str, Any]]:
324
+ """
325
+ Identifica memórias em risco de garbage collection.
326
+
327
+ Issue #2: Parse dates antes de passar para scorer
328
+
329
+ Args:
330
+ project: Nome do projeto
331
+ threshold: Threshold de score para considerar em risco
332
+
333
+ Returns:
334
+ Lista de memórias em risco
335
+ """
336
+ at_risk = []
337
+
338
+ # Decision memórias
339
+ decisions = self.official.list_official(project, "decisions")
340
+ for decision in decisions:
341
+ # Issue #2: Parse dates antes de scorer
342
+ decision_parsed = self._parse_frontmatter_dates(decision)
343
+
344
+ try:
345
+ score = self._scorer.calculate(decision_parsed)
346
+ if score < threshold:
347
+ at_risk.append({
348
+ "type": "decision",
349
+ "id": decision.get("decision_id", decision.get("id", "unknown")),
350
+ "title": decision.get("title", "Sem título"),
351
+ "score": round(score, 3),
352
+ "last_accessed": decision.get("last_accessed"),
353
+ "risk_level": "high" if score < 0.15 else "medium"
354
+ })
355
+ except Exception:
356
+ # Se scorer falhar, ignora esta memória
357
+ continue
358
+
359
+ # Error memórias
360
+ errors = self.official.list_official(project, "errors")
361
+ for error in errors:
362
+ error_parsed = self._parse_frontmatter_dates(error)
363
+
364
+ try:
365
+ score = self._scorer.calculate(error_parsed)
366
+ if score < threshold:
367
+ at_risk.append({
368
+ "type": "error",
369
+ "id": error.get("error_id", error.get("id", "unknown")),
370
+ "severity": error.get("severity", "unknown"),
371
+ "score": round(score, 3),
372
+ "last_accessed": error.get("last_accessed"),
373
+ "risk_level": "high" if score < 0.15 else "medium"
374
+ })
375
+ except Exception:
376
+ continue
377
+
378
+ return at_risk
379
+
380
+ def _get_events_summary(
381
+ self,
382
+ project: str,
383
+ start: datetime,
384
+ end: datetime
385
+ ) -> Dict[str, Any]:
386
+ """
387
+ Gera resumo de eventos no período.
388
+
389
+ Issue #4: Usa _parse_ts robusto para Event.ts
390
+
391
+ Args:
392
+ project: Nome do projeto
393
+ start: Data de início
394
+ end: Data de fim
395
+
396
+ Returns:
397
+ Resumo de eventos
398
+ """
399
+ # WARN-05 FIX: Usa read_since para filtrar por data durante leitura
400
+ # Em vez de carregar todos os eventos em memória
401
+ events = self.raw.read_since(project, start.isoformat())
402
+
403
+ # Filtra por período (end date)
404
+ filtered = []
405
+ for event in events:
406
+ # Issue #4: Parse robusto do timestamp
407
+ event_ts = self._parse_ts(event.ts if hasattr(event, 'ts') else event.get('ts', ''))
408
+ if event_ts <= end:
409
+ filtered.append(event)
410
+
411
+ # Agrupa por tipo
412
+ by_type: Dict[str, int] = {}
413
+ by_subtype: Dict[str, int] = {}
414
+ by_origin: Dict[str, int] = {}
415
+
416
+ for event in filtered:
417
+ event_type = event.event_type if hasattr(event, 'event_type') else event.get('event_type', 'unknown')
418
+ event_subtype = event.subtype if hasattr(event, 'subtype') else event.get('subtype', 'unknown')
419
+ event_origin = event.origin if hasattr(event, 'origin') else event.get('origin', 'unknown')
420
+
421
+ by_type[event_type] = by_type.get(event_type, 0) + 1
422
+ by_subtype[event_subtype] = by_subtype.get(event_subtype, 0) + 1
423
+ by_origin[event_origin] = by_origin.get(event_origin, 0) + 1
424
+
425
+ return {
426
+ "total_events": len(filtered),
427
+ "by_type": by_type,
428
+ "by_subtype": by_subtype,
429
+ "by_origin": by_origin,
430
+ "period_start": start.isoformat(),
431
+ "period_end": end.isoformat()
432
+ }
433
+
434
+ def analyze(
435
+ self,
436
+ project: str,
437
+ period_days: Optional[int] = None,
438
+ start_date: Optional[str] = None,
439
+ end_date: Optional[str] = None,
440
+ gc_threshold: float = 0.3
441
+ ) -> MemoryDiffResult:
442
+ """
443
+ Analisa diferenças de memória entre dois pontos no tempo.
444
+
445
+ Args:
446
+ project: Nome do projeto
447
+ period_days: Dias do período (ex: 7, 30)
448
+ start_date: Data de início explícita (ISO string)
449
+ end_date: Data de fim explícita (ISO string)
450
+ gc_threshold: Threshold para garbage collection risk
451
+
452
+ Returns:
453
+ MemoryDiffResult com análise completa
454
+ """
455
+ # Calcula datas do período
456
+ start, end = self._get_period_dates(period_days, start_date, end_date)
457
+
458
+ # Compara decisões (Issue #1: usa list_official com subdir)
459
+ decisions_added, decisions_removed = self._compare_decisions(project, start, end)
460
+
461
+ # Erros documentados
462
+ errors_documented = self._get_errors_documented(project, start, end)
463
+
464
+ # Drafts pendentes (Issue #3: checa promoted_at)
465
+ drafts_pending = self._get_pending_drafts(project, start, end)
466
+
467
+ # Memórias em risco (Issue #2: parse dates antes de scorer)
468
+ at_risk = self._get_at_risk_memories(project, gc_threshold)
469
+
470
+ # Resumo de eventos (Issue #4: parse robusto de ts)
471
+ events_summary = self._get_events_summary(project, start, end)
472
+
473
+ # Estatísticas
474
+ stats = {
475
+ "decisions_added": len(decisions_added),
476
+ "decisions_removed": len(decisions_removed),
477
+ "errors_documented": len(errors_documented),
478
+ "drafts_pending": len(drafts_pending),
479
+ "at_risk_count": len(at_risk),
480
+ "total_events": events_summary["total_events"]
481
+ }
482
+
483
+ return MemoryDiffResult(
484
+ start_date=start.isoformat(),
485
+ end_date=end.isoformat(),
486
+ decisions_added=decisions_added,
487
+ decisions_removed=decisions_removed,
488
+ errors_documented=errors_documented,
489
+ drafts_pending=drafts_pending,
490
+ at_risk=at_risk,
491
+ events_summary=events_summary,
492
+ stats=stats
493
+ )
494
+
495
+ def generate_report(self, result: MemoryDiffResult, format: str = "markdown") -> str:
496
+ """
497
+ Gera relatório em formato legível.
498
+
499
+ Args:
500
+ result: Resultado da análise
501
+ format: Formato de saída (markdown, json)
502
+
503
+ Returns:
504
+ Relatório formatado
505
+ """
506
+ if format == "json":
507
+ import json
508
+ from dataclasses import asdict
509
+ return json.dumps(asdict(result), indent=2)
510
+
511
+ # Markdown (default)
512
+ lines = [
513
+ "# Memory Diff Report",
514
+ "",
515
+ f"**Período:** {result.start_date[:10]} até {result.end_date[:10]}",
516
+ "",
517
+ "## Resumo",
518
+ "",
519
+ f"- Decisões adicionadas: {result.stats.get('decisions_added', 0)}",
520
+ # BUG-03 FIX: Não mostra "removidas" como 0 enganoso - nota explicativa
521
+ "- Decisões removidas: _(não rastreado nesta versão)_",
522
+ f"- Erros documentados: {result.stats.get('errors_documented', 0)}",
523
+ f"- Drafts pendentes: {result.stats.get('drafts_pending', 0)}",
524
+ f"- Memórias em risco: {result.stats.get('at_risk_count', 0)}",
525
+ f"- Total de eventos: {result.stats.get('total_events', 0)}",
526
+ "",
527
+ ]
528
+
529
+ # Decisões adicionadas
530
+ if result.decisions_added:
531
+ lines.append("## Decisões Adicionadas")
532
+ lines.append("")
533
+ for d in result.decisions_added:
534
+ lines.append(f"- **[{d['id']}]** {d['title']} ({d['date'][:10]})")
535
+ lines.append("")
536
+
537
+ # Erros documentados
538
+ if result.errors_documented:
539
+ lines.append("## Erros Documentados")
540
+ lines.append("")
541
+ for e in result.errors_documented:
542
+ lines.append(f"- **[{e['id']}]** {e['category']} - {e['severity']} ({e['date'][:10]})")
543
+ lines.append("")
544
+
545
+ # Drafts pendentes
546
+ if result.drafts_pending:
547
+ lines.append("## Drafts Pendentes de Promoção")
548
+ lines.append("")
549
+ for d in result.drafts_pending:
550
+ review_flag = " [NEEDS REVIEW]" if d.get('needs_review') else ""
551
+ lines.append(f"- **[{d['type']}/{d['id']}]**{review_flag}")
552
+ lines.append("")
553
+
554
+ # Memórias em risco
555
+ if result.at_risk:
556
+ lines.append("## Memórias em Risco de GC")
557
+ lines.append("")
558
+ for m in sorted(result.at_risk, key=lambda x: x['score']):
559
+ risk_emoji = "[CRITICAL]" if m['risk_level'] == 'high' else "[WARN]"
560
+ lines.append(f"- {risk_emoji} **[{m['type']}/{m['id']}]** {m.get('title', m.get('severity', ''))} (score: {m['score']:.3f})")
561
+ lines.append("")
562
+
563
+ # Eventos
564
+ if result.events_summary.get('by_type'):
565
+ lines.append("## Eventos por Tipo")
566
+ lines.append("")
567
+ for event_type, count in sorted(result.events_summary['by_type'].items(), key=lambda x: -x[1]):
568
+ lines.append(f"- {event_type}: {count}")
569
+ lines.append("")
570
+
571
+ return "\n".join(lines)
@@ -0,0 +1,6 @@
1
+ """Forgetting do Cerebro: decay, guard rails, garbage collection"""
2
+ from .guard_rails import GuardRails
3
+ from .decay import DecayManager
4
+ from .gc import GarbageCollector
5
+
6
+ __all__ = ["GuardRails", "DecayManager", "GarbageCollector"]
@@ -0,0 +1,86 @@
1
+ """Gerenciamento de decay temporal para memórias"""
2
+
3
+ import math
4
+ from datetime import datetime, timezone
5
+ from typing import Dict, Any
6
+
7
+
8
+ class DecayManager:
9
+ """
10
+ Gerencia decay temporal de scores de memórias.
11
+
12
+ Aplica decaimento exponencial baseado na idade da memória
13
+ e configuração de decay rate.
14
+ """
15
+
16
+ def __init__(self, default_decay_rate: float = 0.01):
17
+ """
18
+ Inicializa o DecayManager.
19
+
20
+ Args:
21
+ default_decay_rate: Taxa de decaimento padrão
22
+ """
23
+ self.default_decay_rate = default_decay_rate
24
+
25
+ def apply_decay(self, score: float, days: int, decay_rate: float = None) -> float:
26
+ """
27
+ Aplica decay temporal ao score.
28
+
29
+ Args:
30
+ score: Score base
31
+ days: Dias de decaimento
32
+ decay_rate: Taxa de decaimento (opcional, usa default se None)
33
+
34
+ Returns:
35
+ Score com decay aplicado
36
+ """
37
+ rate = decay_rate or self.default_decay_rate
38
+ return score * math.exp(-rate * days)
39
+
40
+ def calculate_age_days(self, created_at: str) -> int:
41
+ """
42
+ Calcula idade em dias a partir de timestamp ISO.
43
+
44
+ Args:
45
+ created_at: Timestamp ISO da criação
46
+
47
+ Returns:
48
+ Idade em dias
49
+ """
50
+ created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
51
+ now = datetime.now(timezone.utc)
52
+ return (now - created_dt).days
53
+
54
+ def decay_for_memory(self, memory: Dict[str, Any], base_score: float) -> float:
55
+ """
56
+ Aplica decay para uma memória específica.
57
+
58
+ Args:
59
+ memory: Dados da memória
60
+ base_score: Score base antes do decay
61
+
62
+ Returns:
63
+ Score com decay aplicado
64
+ """
65
+ created_at = memory.get("created_at")
66
+ if not created_at:
67
+ return base_score
68
+
69
+ days = self.calculate_age_days(created_at)
70
+ decay_rate = memory.get("decay_rate", self.default_decay_rate)
71
+
72
+ return self.apply_decay(base_score, days, decay_rate)
73
+
74
+ def get_decay_factor(self, days: int, decay_rate: float = None) -> float:
75
+ """
76
+ Obtém fator de decay (multiplicador).
77
+
78
+ Args:
79
+ days: Dias de decaimento
80
+ decay_rate: Taxa de decaimento
81
+
82
+ Returns:
83
+ Fator de decay entre 0 e 1
84
+ """
85
+ rate = decay_rate or self.default_decay_rate
86
+ return math.exp(-rate * days)