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