alma-memory 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.whl

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 (111) hide show
  1. alma/__init__.py +296 -226
  2. alma/compression/__init__.py +33 -0
  3. alma/compression/pipeline.py +980 -0
  4. alma/confidence/__init__.py +47 -47
  5. alma/confidence/engine.py +540 -540
  6. alma/confidence/types.py +351 -351
  7. alma/config/loader.py +157 -157
  8. alma/consolidation/__init__.py +23 -23
  9. alma/consolidation/engine.py +678 -678
  10. alma/consolidation/prompts.py +84 -84
  11. alma/core.py +1189 -430
  12. alma/domains/__init__.py +30 -30
  13. alma/domains/factory.py +359 -359
  14. alma/domains/schemas.py +448 -448
  15. alma/domains/types.py +272 -272
  16. alma/events/__init__.py +75 -75
  17. alma/events/emitter.py +285 -284
  18. alma/events/storage_mixin.py +246 -246
  19. alma/events/types.py +126 -126
  20. alma/events/webhook.py +425 -425
  21. alma/exceptions.py +49 -49
  22. alma/extraction/__init__.py +31 -31
  23. alma/extraction/auto_learner.py +265 -265
  24. alma/extraction/extractor.py +420 -420
  25. alma/graph/__init__.py +106 -106
  26. alma/graph/backends/__init__.py +32 -32
  27. alma/graph/backends/kuzu.py +624 -624
  28. alma/graph/backends/memgraph.py +432 -432
  29. alma/graph/backends/memory.py +236 -236
  30. alma/graph/backends/neo4j.py +417 -417
  31. alma/graph/base.py +159 -159
  32. alma/graph/extraction.py +198 -198
  33. alma/graph/store.py +860 -860
  34. alma/harness/__init__.py +35 -35
  35. alma/harness/base.py +386 -386
  36. alma/harness/domains.py +705 -705
  37. alma/initializer/__init__.py +37 -37
  38. alma/initializer/initializer.py +418 -418
  39. alma/initializer/types.py +250 -250
  40. alma/integration/__init__.py +62 -62
  41. alma/integration/claude_agents.py +444 -444
  42. alma/integration/helena.py +423 -423
  43. alma/integration/victor.py +471 -471
  44. alma/learning/__init__.py +101 -86
  45. alma/learning/decay.py +878 -0
  46. alma/learning/forgetting.py +1446 -1446
  47. alma/learning/heuristic_extractor.py +390 -390
  48. alma/learning/protocols.py +374 -374
  49. alma/learning/validation.py +346 -346
  50. alma/mcp/__init__.py +123 -45
  51. alma/mcp/__main__.py +156 -156
  52. alma/mcp/resources.py +122 -122
  53. alma/mcp/server.py +955 -591
  54. alma/mcp/tools.py +3254 -509
  55. alma/observability/__init__.py +91 -84
  56. alma/observability/config.py +302 -302
  57. alma/observability/guidelines.py +170 -0
  58. alma/observability/logging.py +424 -424
  59. alma/observability/metrics.py +583 -583
  60. alma/observability/tracing.py +440 -440
  61. alma/progress/__init__.py +21 -21
  62. alma/progress/tracker.py +607 -607
  63. alma/progress/types.py +250 -250
  64. alma/retrieval/__init__.py +134 -53
  65. alma/retrieval/budget.py +525 -0
  66. alma/retrieval/cache.py +1304 -1061
  67. alma/retrieval/embeddings.py +202 -202
  68. alma/retrieval/engine.py +850 -427
  69. alma/retrieval/modes.py +365 -0
  70. alma/retrieval/progressive.py +560 -0
  71. alma/retrieval/scoring.py +344 -344
  72. alma/retrieval/trust_scoring.py +637 -0
  73. alma/retrieval/verification.py +797 -0
  74. alma/session/__init__.py +19 -19
  75. alma/session/manager.py +442 -399
  76. alma/session/types.py +288 -288
  77. alma/storage/__init__.py +101 -90
  78. alma/storage/archive.py +233 -0
  79. alma/storage/azure_cosmos.py +1259 -1259
  80. alma/storage/base.py +1083 -583
  81. alma/storage/chroma.py +1443 -1443
  82. alma/storage/constants.py +103 -103
  83. alma/storage/file_based.py +614 -614
  84. alma/storage/migrations/__init__.py +21 -21
  85. alma/storage/migrations/base.py +321 -321
  86. alma/storage/migrations/runner.py +323 -323
  87. alma/storage/migrations/version_stores.py +337 -337
  88. alma/storage/migrations/versions/__init__.py +11 -11
  89. alma/storage/migrations/versions/v1_0_0.py +373 -373
  90. alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
  91. alma/storage/pinecone.py +1080 -1080
  92. alma/storage/postgresql.py +1948 -1559
  93. alma/storage/qdrant.py +1306 -1306
  94. alma/storage/sqlite_local.py +3041 -1457
  95. alma/testing/__init__.py +46 -46
  96. alma/testing/factories.py +301 -301
  97. alma/testing/mocks.py +389 -389
  98. alma/types.py +292 -264
  99. alma/utils/__init__.py +19 -0
  100. alma/utils/tokenizer.py +521 -0
  101. alma/workflow/__init__.py +83 -0
  102. alma/workflow/artifacts.py +170 -0
  103. alma/workflow/checkpoint.py +311 -0
  104. alma/workflow/context.py +228 -0
  105. alma/workflow/outcomes.py +189 -0
  106. alma/workflow/reducers.py +393 -0
  107. {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
  108. alma_memory-0.7.0.dist-info/RECORD +112 -0
  109. alma_memory-0.5.1.dist-info/RECORD +0 -93
  110. {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
  111. {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/top_level.txt +0 -0
alma/learning/decay.py ADDED
@@ -0,0 +1,878 @@
1
+ """
2
+ ALMA Decay-Based Forgetting.
3
+
4
+ Implements memory strength tracking with natural decay over time,
5
+ reinforced by access patterns - mimicking human memory behavior.
6
+
7
+ Key Principles from Memory Wall:
8
+ - "Forgetting is a technology" - lossy compression with importance weighting
9
+ - Memories weaken over time if not accessed
10
+ - Accessing a memory strengthens it
11
+ - Users can explicitly mark memories as important
12
+ - Weak memories can be rescued before deletion
13
+
14
+ Strength Formula:
15
+ Strength = (Base Decay + Access Bonus + Reinforcement Bonus) × Importance Factor
16
+
17
+ Where:
18
+ - Base Decay = e^(-0.693 × days_since_access / half_life)
19
+ - Access Bonus = min(0.4, log(1 + access_count) × 0.1)
20
+ - Reinforcement Bonus = min(0.3, recent_reinforcements × 0.1)
21
+ - Importance Factor = 0.5 + (explicit_importance × 0.5)
22
+ """
23
+
24
+ import logging
25
+ import math
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from enum import Enum
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class StrengthState(Enum):
35
+ """Memory strength states based on current strength value."""
36
+
37
+ STRONG = "strong" # > 0.7 - Normal retrieval, highly likely to be returned
38
+ NORMAL = "normal" # 0.3 - 0.7 - Normal retrieval
39
+ WEAK = "weak" # 0.1 - 0.3 - Recoverable, shown in warnings
40
+ FORGETTABLE = "forgettable" # < 0.1 - Ready for archive/deletion
41
+
42
+
43
+ @dataclass
44
+ class MemoryStrength:
45
+ """
46
+ Tracks decay and reinforcement of a memory.
47
+
48
+ Implements human-like memory behavior where memories naturally
49
+ decay over time but are strengthened through access and
50
+ explicit reinforcement.
51
+
52
+ Attributes:
53
+ memory_id: Unique identifier for the memory
54
+ memory_type: Type of memory (heuristic, outcome, knowledge, etc.)
55
+ initial_strength: Starting strength when created (default 1.0)
56
+ decay_half_life_days: Days until strength halves without access
57
+ created_at: When the memory was created
58
+ last_accessed: When the memory was last accessed
59
+ access_count: Number of times memory has been accessed
60
+ reinforcement_events: Timestamps of explicit reinforcement
61
+ explicit_importance: User-set importance (0-1), affects decay rate
62
+ """
63
+
64
+ memory_id: str
65
+ memory_type: str = "unknown"
66
+ initial_strength: float = 1.0
67
+ decay_half_life_days: int = 30
68
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
69
+ last_accessed: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
70
+ access_count: int = 0
71
+ reinforcement_events: List[datetime] = field(default_factory=list)
72
+ explicit_importance: float = 0.5 # Default middle importance
73
+
74
+ def current_strength(self) -> float:
75
+ """
76
+ Calculate current memory strength with decay and reinforcement.
77
+
78
+ The formula combines:
79
+ 1. Base decay using half-life formula
80
+ 2. Access bonus with diminishing returns
81
+ 3. Recent reinforcement bonus
82
+ 4. Importance factor multiplier
83
+
84
+ Returns:
85
+ Current strength value between 0.0 and 1.0
86
+ """
87
+ now = datetime.now(timezone.utc)
88
+
89
+ # Handle naive datetimes by assuming UTC
90
+ last_accessed = self.last_accessed
91
+ if last_accessed.tzinfo is None:
92
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
93
+
94
+ days_since_access = (now - last_accessed).total_seconds() / 86400
95
+
96
+ # Base decay using half-life formula
97
+ # After half_life days, strength = 0.5 of original
98
+ if self.decay_half_life_days > 0:
99
+ base_decay = math.exp(
100
+ -0.693 * days_since_access / self.decay_half_life_days
101
+ )
102
+ else:
103
+ base_decay = 1.0 # No decay if half-life is 0
104
+
105
+ # Access reinforcement (diminishing returns via log)
106
+ # More accesses = stronger memory, but capped at 0.4 bonus
107
+ access_bonus = min(0.4, math.log1p(self.access_count) * 0.1)
108
+
109
+ # Recency of reinforcements (within last 7 days)
110
+ # Each recent reinforcement adds 0.1, capped at 0.3
111
+ recent_reinforcements = sum(
112
+ 1 for r in self.reinforcement_events if self._days_ago(r) < 7
113
+ )
114
+ reinforcement_bonus = min(0.3, recent_reinforcements * 0.1)
115
+
116
+ # Explicit importance factor (0.5 to 1.0 range)
117
+ # importance=0 means 0.5x strength, importance=1 means 1x strength
118
+ importance_factor = 0.5 + (self.explicit_importance * 0.5)
119
+
120
+ # Combine factors
121
+ strength = (base_decay + access_bonus + reinforcement_bonus) * importance_factor
122
+ return min(1.0, max(0.0, strength))
123
+
124
+ def _days_ago(self, dt: datetime) -> float:
125
+ """Calculate days since a datetime."""
126
+ now = datetime.now(timezone.utc)
127
+ if dt.tzinfo is None:
128
+ dt = dt.replace(tzinfo=timezone.utc)
129
+ return (now - dt).total_seconds() / 86400
130
+
131
+ def access(self) -> float:
132
+ """
133
+ Record an access to this memory.
134
+
135
+ Returns:
136
+ New strength after access
137
+ """
138
+ self.last_accessed = datetime.now(timezone.utc)
139
+ self.access_count += 1
140
+ return self.current_strength()
141
+
142
+ def reinforce(self) -> float:
143
+ """
144
+ Explicitly reinforce this memory.
145
+
146
+ Reinforcement is stronger than a simple access and records
147
+ a reinforcement event for the bonus calculation.
148
+
149
+ Returns:
150
+ New strength after reinforcement
151
+ """
152
+ now = datetime.now(timezone.utc)
153
+ self.reinforcement_events.append(now)
154
+ self.last_accessed = now
155
+ # Keep only last 10 reinforcement events to prevent unbounded growth
156
+ self.reinforcement_events = self.reinforcement_events[-10:]
157
+ return self.current_strength()
158
+
159
+ def set_importance(self, importance: float) -> float:
160
+ """
161
+ Set explicit importance level.
162
+
163
+ Args:
164
+ importance: Value between 0.0 and 1.0
165
+
166
+ Returns:
167
+ New strength after importance change
168
+ """
169
+ self.explicit_importance = max(0.0, min(1.0, importance))
170
+ return self.current_strength()
171
+
172
+ def get_state(self) -> StrengthState:
173
+ """
174
+ Get the current state based on strength.
175
+
176
+ Returns:
177
+ StrengthState enum value
178
+ """
179
+ strength = self.current_strength()
180
+ if strength > 0.7:
181
+ return StrengthState.STRONG
182
+ elif strength >= 0.3:
183
+ return StrengthState.NORMAL
184
+ elif strength >= 0.1:
185
+ return StrengthState.WEAK
186
+ else:
187
+ return StrengthState.FORGETTABLE
188
+
189
+ def should_forget(self, threshold: float = 0.1) -> bool:
190
+ """
191
+ Determine if memory should be forgotten.
192
+
193
+ Args:
194
+ threshold: Strength threshold below which to forget
195
+
196
+ Returns:
197
+ True if strength is below threshold
198
+ """
199
+ return self.current_strength() < threshold
200
+
201
+ def is_recoverable(self) -> bool:
202
+ """
203
+ Check if memory is weak but can be recovered.
204
+
205
+ Recoverable memories are weak enough to warrant attention
206
+ but not so weak they should be deleted.
207
+
208
+ Returns:
209
+ True if in recoverable range (0.1 <= strength < 0.3)
210
+ """
211
+ strength = self.current_strength()
212
+ return 0.1 <= strength < 0.3
213
+
214
+ def to_dict(self) -> Dict[str, Any]:
215
+ """Convert to dictionary for storage."""
216
+ return {
217
+ "memory_id": self.memory_id,
218
+ "memory_type": self.memory_type,
219
+ "initial_strength": self.initial_strength,
220
+ "decay_half_life_days": self.decay_half_life_days,
221
+ "created_at": self.created_at.isoformat(),
222
+ "last_accessed": self.last_accessed.isoformat(),
223
+ "access_count": self.access_count,
224
+ "reinforcement_events": [r.isoformat() for r in self.reinforcement_events],
225
+ "explicit_importance": self.explicit_importance,
226
+ }
227
+
228
+ @classmethod
229
+ def from_dict(cls, data: Dict[str, Any]) -> "MemoryStrength":
230
+ """Create from dictionary."""
231
+ reinforcement_events = []
232
+ for r in data.get("reinforcement_events", []):
233
+ if isinstance(r, str):
234
+ # Parse ISO format, handle both Z and +00:00 suffixes
235
+ dt_str = r.replace("Z", "+00:00")
236
+ reinforcement_events.append(datetime.fromisoformat(dt_str))
237
+ elif isinstance(r, datetime):
238
+ reinforcement_events.append(r)
239
+
240
+ created_at = data.get("created_at")
241
+ if isinstance(created_at, str):
242
+ created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
243
+ elif created_at is None:
244
+ created_at = datetime.now(timezone.utc)
245
+
246
+ last_accessed = data.get("last_accessed")
247
+ if isinstance(last_accessed, str):
248
+ last_accessed = datetime.fromisoformat(last_accessed.replace("Z", "+00:00"))
249
+ elif last_accessed is None:
250
+ last_accessed = datetime.now(timezone.utc)
251
+
252
+ return cls(
253
+ memory_id=data["memory_id"],
254
+ memory_type=data.get("memory_type", "unknown"),
255
+ initial_strength=data.get("initial_strength", 1.0),
256
+ decay_half_life_days=data.get("decay_half_life_days", 30),
257
+ created_at=created_at,
258
+ last_accessed=last_accessed,
259
+ access_count=data.get("access_count", 0),
260
+ reinforcement_events=reinforcement_events,
261
+ explicit_importance=data.get("explicit_importance", 0.5),
262
+ )
263
+
264
+
265
+ @dataclass
266
+ class DecayConfig:
267
+ """
268
+ Configuration for decay-based forgetting.
269
+
270
+ Allows customization of half-lives per memory type and
271
+ global forget thresholds.
272
+ """
273
+
274
+ enabled: bool = True
275
+ default_half_life_days: int = 30
276
+ forget_threshold: float = 0.1
277
+ weak_threshold: float = 0.3
278
+ strong_threshold: float = 0.7
279
+
280
+ # Half-life by memory type (days until half strength)
281
+ half_life_by_type: Dict[str, int] = field(
282
+ default_factory=lambda: {
283
+ "heuristic": 60, # Heuristics are valuable, decay slowly
284
+ "outcome": 30, # Outcomes decay at normal rate
285
+ "preference": 365, # User preferences are very stable
286
+ "knowledge": 90, # Domain knowledge decays slowly
287
+ "anti_pattern": 45, # Anti-patterns decay moderately
288
+ }
289
+ )
290
+
291
+ def get_half_life(self, memory_type: str) -> int:
292
+ """Get half-life for a memory type."""
293
+ return self.half_life_by_type.get(memory_type, self.default_half_life_days)
294
+
295
+ @classmethod
296
+ def from_dict(cls, data: Dict[str, Any]) -> "DecayConfig":
297
+ """Create from configuration dictionary."""
298
+ return cls(
299
+ enabled=data.get("enabled", True),
300
+ default_half_life_days=data.get("default_half_life_days", 30),
301
+ forget_threshold=data.get("forget_threshold", 0.1),
302
+ weak_threshold=data.get("weak_threshold", 0.3),
303
+ strong_threshold=data.get("strong_threshold", 0.7),
304
+ half_life_by_type=data.get(
305
+ "half_life_by_type",
306
+ {
307
+ "heuristic": 60,
308
+ "outcome": 30,
309
+ "preference": 365,
310
+ "knowledge": 90,
311
+ "anti_pattern": 45,
312
+ },
313
+ ),
314
+ )
315
+
316
+
317
+ class DecayManager:
318
+ """
319
+ Manages memory decay across the system.
320
+
321
+ Provides methods to:
322
+ - Track and update memory strength
323
+ - Record accesses and reinforcements
324
+ - Find memories ready to forget
325
+ - Find weak but recoverable memories
326
+ """
327
+
328
+ def __init__(
329
+ self,
330
+ storage: Any, # StorageBackend with strength methods
331
+ config: Optional[DecayConfig] = None,
332
+ ):
333
+ """
334
+ Initialize decay manager.
335
+
336
+ Args:
337
+ storage: Storage backend with memory strength methods
338
+ config: Decay configuration (uses defaults if not provided)
339
+ """
340
+ self.storage = storage
341
+ self.config = config or DecayConfig()
342
+ self._strength_cache: Dict[str, MemoryStrength] = {}
343
+
344
+ def get_strength(
345
+ self, memory_id: str, memory_type: str = "unknown"
346
+ ) -> MemoryStrength:
347
+ """
348
+ Get or create strength record for a memory.
349
+
350
+ Args:
351
+ memory_id: Memory identifier
352
+ memory_type: Type of memory for half-life lookup
353
+
354
+ Returns:
355
+ MemoryStrength record
356
+ """
357
+ # Check cache first
358
+ if memory_id in self._strength_cache:
359
+ return self._strength_cache[memory_id]
360
+
361
+ # Try to load from storage
362
+ strength = self.storage.get_memory_strength(memory_id)
363
+
364
+ if strength is None:
365
+ # Create new strength record with appropriate half-life
366
+ half_life = self.config.get_half_life(memory_type)
367
+ strength = MemoryStrength(
368
+ memory_id=memory_id,
369
+ memory_type=memory_type,
370
+ decay_half_life_days=half_life,
371
+ )
372
+ self.storage.save_memory_strength(strength)
373
+
374
+ self._strength_cache[memory_id] = strength
375
+ return strength
376
+
377
+ def record_access(self, memory_id: str, memory_type: str = "unknown") -> float:
378
+ """
379
+ Record memory access, return new strength.
380
+
381
+ Args:
382
+ memory_id: Memory that was accessed
383
+ memory_type: Type of memory
384
+
385
+ Returns:
386
+ New strength value after access
387
+ """
388
+ strength = self.get_strength(memory_id, memory_type)
389
+ new_strength = strength.access()
390
+ self.storage.save_memory_strength(strength)
391
+ return new_strength
392
+
393
+ def reinforce_memory(self, memory_id: str, memory_type: str = "unknown") -> float:
394
+ """
395
+ Explicitly reinforce a memory.
396
+
397
+ Args:
398
+ memory_id: Memory to reinforce
399
+ memory_type: Type of memory
400
+
401
+ Returns:
402
+ New strength value after reinforcement
403
+ """
404
+ strength = self.get_strength(memory_id, memory_type)
405
+ new_strength = strength.reinforce()
406
+ self.storage.save_memory_strength(strength)
407
+ logger.info(f"Reinforced memory {memory_id}, new strength: {new_strength:.3f}")
408
+ return new_strength
409
+
410
+ def set_importance(
411
+ self,
412
+ memory_id: str,
413
+ importance: float,
414
+ memory_type: str = "unknown",
415
+ ) -> float:
416
+ """
417
+ Set explicit importance for a memory.
418
+
419
+ Args:
420
+ memory_id: Memory to update
421
+ importance: Importance value (0.0 to 1.0)
422
+ memory_type: Type of memory
423
+
424
+ Returns:
425
+ New strength value after update
426
+ """
427
+ strength = self.get_strength(memory_id, memory_type)
428
+ new_strength = strength.set_importance(importance)
429
+ self.storage.save_memory_strength(strength)
430
+ logger.info(
431
+ f"Set importance for {memory_id} to {importance:.2f}, "
432
+ f"new strength: {new_strength:.3f}"
433
+ )
434
+ return new_strength
435
+
436
+ def get_forgettable_memories(
437
+ self,
438
+ project_id: str,
439
+ agent: Optional[str] = None,
440
+ threshold: Optional[float] = None,
441
+ ) -> List[Tuple[str, str, float]]:
442
+ """
443
+ Get memories that should be forgotten.
444
+
445
+ Args:
446
+ project_id: Project to scan
447
+ agent: Specific agent or None for all
448
+ threshold: Custom threshold or use config default
449
+
450
+ Returns:
451
+ List of (memory_id, memory_type, strength) tuples
452
+ """
453
+ threshold = threshold or self.config.forget_threshold
454
+ all_strengths = self.storage.get_all_memory_strengths(project_id, agent)
455
+
456
+ forgettable = []
457
+ for strength in all_strengths:
458
+ current = strength.current_strength()
459
+ if current < threshold:
460
+ forgettable.append(
461
+ (
462
+ strength.memory_id,
463
+ strength.memory_type,
464
+ current,
465
+ )
466
+ )
467
+
468
+ # Sort by strength (weakest first)
469
+ return sorted(forgettable, key=lambda x: x[2])
470
+
471
+ def get_weak_memories(
472
+ self,
473
+ project_id: str,
474
+ agent: Optional[str] = None,
475
+ ) -> List[Tuple[str, str, float]]:
476
+ """
477
+ Get recoverable memories sorted by strength.
478
+
479
+ These are memories that are weak but can still be saved
480
+ through reinforcement.
481
+
482
+ Args:
483
+ project_id: Project to scan
484
+ agent: Specific agent or None for all
485
+
486
+ Returns:
487
+ List of (memory_id, memory_type, strength) tuples
488
+ """
489
+ all_strengths = self.storage.get_all_memory_strengths(project_id, agent)
490
+
491
+ weak = []
492
+ for strength in all_strengths:
493
+ if strength.is_recoverable():
494
+ weak.append(
495
+ (
496
+ strength.memory_id,
497
+ strength.memory_type,
498
+ strength.current_strength(),
499
+ )
500
+ )
501
+
502
+ # Sort by strength (weakest first for prioritization)
503
+ return sorted(weak, key=lambda x: x[2])
504
+
505
+ def get_strong_memories(
506
+ self,
507
+ project_id: str,
508
+ agent: Optional[str] = None,
509
+ ) -> List[Tuple[str, str, float]]:
510
+ """
511
+ Get strong memories.
512
+
513
+ Args:
514
+ project_id: Project to scan
515
+ agent: Specific agent or None for all
516
+
517
+ Returns:
518
+ List of (memory_id, memory_type, strength) tuples
519
+ """
520
+ all_strengths = self.storage.get_all_memory_strengths(project_id, agent)
521
+
522
+ strong = []
523
+ for strength in all_strengths:
524
+ current = strength.current_strength()
525
+ if current > self.config.strong_threshold:
526
+ strong.append(
527
+ (
528
+ strength.memory_id,
529
+ strength.memory_type,
530
+ current,
531
+ )
532
+ )
533
+
534
+ # Sort by strength (strongest first)
535
+ return sorted(strong, key=lambda x: x[2], reverse=True)
536
+
537
+ def get_memory_stats(
538
+ self,
539
+ project_id: str,
540
+ agent: Optional[str] = None,
541
+ ) -> Dict[str, Any]:
542
+ """
543
+ Get statistics about memory strength distribution.
544
+
545
+ Args:
546
+ project_id: Project to analyze
547
+ agent: Specific agent or None for all
548
+
549
+ Returns:
550
+ Dictionary with strength statistics
551
+ """
552
+ all_strengths = self.storage.get_all_memory_strengths(project_id, agent)
553
+
554
+ if not all_strengths:
555
+ return {
556
+ "total": 0,
557
+ "strong": 0,
558
+ "normal": 0,
559
+ "weak": 0,
560
+ "forgettable": 0,
561
+ "average_strength": 0.0,
562
+ "by_type": {},
563
+ }
564
+
565
+ stats = {
566
+ "total": len(all_strengths),
567
+ "strong": 0,
568
+ "normal": 0,
569
+ "weak": 0,
570
+ "forgettable": 0,
571
+ "by_type": {},
572
+ }
573
+
574
+ strength_sum = 0.0
575
+ for s in all_strengths:
576
+ current = s.current_strength()
577
+ strength_sum += current
578
+
579
+ state = s.get_state()
580
+ if state == StrengthState.STRONG:
581
+ stats["strong"] += 1
582
+ elif state == StrengthState.NORMAL:
583
+ stats["normal"] += 1
584
+ elif state == StrengthState.WEAK:
585
+ stats["weak"] += 1
586
+ else:
587
+ stats["forgettable"] += 1
588
+
589
+ # Track by type
590
+ if s.memory_type not in stats["by_type"]:
591
+ stats["by_type"][s.memory_type] = {
592
+ "count": 0,
593
+ "avg_strength": 0.0,
594
+ "strength_sum": 0.0,
595
+ }
596
+ stats["by_type"][s.memory_type]["count"] += 1
597
+ stats["by_type"][s.memory_type]["strength_sum"] += current
598
+
599
+ stats["average_strength"] = strength_sum / len(all_strengths)
600
+
601
+ # Calculate per-type averages
602
+ for type_stats in stats["by_type"].values():
603
+ if type_stats["count"] > 0:
604
+ type_stats["avg_strength"] = (
605
+ type_stats["strength_sum"] / type_stats["count"]
606
+ )
607
+ del type_stats["strength_sum"]
608
+
609
+ return stats
610
+
611
+ def cleanup_forgettable(
612
+ self,
613
+ project_id: str,
614
+ agent: Optional[str] = None,
615
+ dry_run: bool = True,
616
+ ) -> Dict[str, Any]:
617
+ """
618
+ Clean up memories below forget threshold.
619
+
620
+ Args:
621
+ project_id: Project to clean
622
+ agent: Specific agent or None for all
623
+ dry_run: If True, only report what would be deleted
624
+
625
+ Returns:
626
+ Cleanup results
627
+ """
628
+ forgettable = self.get_forgettable_memories(project_id, agent)
629
+
630
+ result = {
631
+ "dry_run": dry_run,
632
+ "count": len(forgettable),
633
+ "memories": forgettable,
634
+ "deleted": 0,
635
+ }
636
+
637
+ if not dry_run:
638
+ for memory_id, memory_type, _ in forgettable:
639
+ try:
640
+ # Delete from main storage based on type
641
+ deleted = self._delete_memory(memory_id, memory_type)
642
+ if deleted:
643
+ # Also delete strength record
644
+ self.storage.delete_memory_strength(memory_id)
645
+ # Clear from cache
646
+ self._strength_cache.pop(memory_id, None)
647
+ result["deleted"] += 1
648
+ except Exception as e:
649
+ logger.warning(f"Failed to delete memory {memory_id}: {e}")
650
+
651
+ return result
652
+
653
+ def _delete_memory(self, memory_id: str, memory_type: str) -> bool:
654
+ """Delete a memory from main storage."""
655
+ try:
656
+ if memory_type == "heuristic":
657
+ return self.storage.delete_heuristic(memory_id)
658
+ elif memory_type == "outcome":
659
+ return self.storage.delete_outcome(memory_id)
660
+ elif memory_type == "knowledge":
661
+ return self.storage.delete_domain_knowledge(memory_id)
662
+ elif memory_type == "anti_pattern":
663
+ return self.storage.delete_anti_pattern(memory_id)
664
+ else:
665
+ logger.warning(f"Unknown memory type for deletion: {memory_type}")
666
+ return False
667
+ except Exception as e:
668
+ logger.error(f"Error deleting memory {memory_id}: {e}")
669
+ return False
670
+
671
+ def smart_forget(
672
+ self,
673
+ project_id: str,
674
+ agent: Optional[str] = None,
675
+ threshold: Optional[float] = None,
676
+ archive: bool = True,
677
+ dry_run: bool = False,
678
+ ) -> Dict[str, Any]:
679
+ """
680
+ Forget weak memories with optional archiving.
681
+
682
+ This is the recommended method for memory cleanup as it:
683
+ 1. Identifies memories below the forget threshold
684
+ 2. Archives them before deletion (if enabled)
685
+ 3. Deletes the memory and its strength record
686
+ 4. Returns a detailed report
687
+
688
+ Args:
689
+ project_id: Project to scan and clean
690
+ agent: Specific agent or None for all
691
+ threshold: Custom forget threshold or use config default
692
+ archive: If True, archive memories before deletion
693
+ dry_run: If True, only report what would be done
694
+
695
+ Returns:
696
+ Dictionary with:
697
+ - dry_run: Whether this was a dry run
698
+ - threshold: Threshold used
699
+ - archive_enabled: Whether archiving was enabled
700
+ - total_found: Number of forgettable memories found
701
+ - archived: List of archived memory IDs
702
+ - deleted: List of deleted memory IDs
703
+ - errors: List of any errors encountered
704
+ """
705
+ threshold = threshold or self.config.forget_threshold
706
+ forgettable = self.get_forgettable_memories(project_id, agent, threshold)
707
+
708
+ result = {
709
+ "dry_run": dry_run,
710
+ "threshold": threshold,
711
+ "archive_enabled": archive,
712
+ "total_found": len(forgettable),
713
+ "archived": [],
714
+ "deleted": [],
715
+ "errors": [],
716
+ }
717
+
718
+ if dry_run:
719
+ result["would_archive"] = (
720
+ [
721
+ {"id": mid, "type": mtype, "strength": s}
722
+ for mid, mtype, s in forgettable
723
+ ]
724
+ if archive
725
+ else []
726
+ )
727
+ result["would_delete"] = [
728
+ {"id": mid, "type": mtype, "strength": s}
729
+ for mid, mtype, s in forgettable
730
+ ]
731
+ return result
732
+
733
+ for memory_id, memory_type, current_strength in forgettable:
734
+ try:
735
+ # Archive before deletion if enabled
736
+ if archive:
737
+ try:
738
+ archived = self.storage.archive_memory(
739
+ memory_id=memory_id,
740
+ memory_type=memory_type,
741
+ reason="decay",
742
+ final_strength=current_strength,
743
+ )
744
+ result["archived"].append(
745
+ {
746
+ "memory_id": memory_id,
747
+ "archive_id": archived.id,
748
+ "memory_type": memory_type,
749
+ "final_strength": current_strength,
750
+ }
751
+ )
752
+ logger.info(
753
+ f"Archived memory {memory_id} as {archived.id} "
754
+ f"(strength: {current_strength:.3f})"
755
+ )
756
+ except Exception as e:
757
+ # Log but continue - still try to delete
758
+ logger.warning(f"Failed to archive memory {memory_id}: {e}")
759
+ result["errors"].append(
760
+ {
761
+ "memory_id": memory_id,
762
+ "operation": "archive",
763
+ "error": str(e),
764
+ }
765
+ )
766
+
767
+ # Delete the memory
768
+ deleted = self._delete_memory(memory_id, memory_type)
769
+ if deleted:
770
+ # Also delete strength record
771
+ self.storage.delete_memory_strength(memory_id)
772
+ # Clear from cache
773
+ self._strength_cache.pop(memory_id, None)
774
+ result["deleted"].append(
775
+ {
776
+ "memory_id": memory_id,
777
+ "memory_type": memory_type,
778
+ }
779
+ )
780
+ logger.info(
781
+ f"Deleted memory {memory_id} (type: {memory_type}, "
782
+ f"strength: {current_strength:.3f})"
783
+ )
784
+ else:
785
+ result["errors"].append(
786
+ {
787
+ "memory_id": memory_id,
788
+ "operation": "delete",
789
+ "error": "Delete returned False",
790
+ }
791
+ )
792
+
793
+ except Exception as e:
794
+ logger.error(f"Error processing memory {memory_id}: {e}")
795
+ result["errors"].append(
796
+ {
797
+ "memory_id": memory_id,
798
+ "operation": "process",
799
+ "error": str(e),
800
+ }
801
+ )
802
+
803
+ return result
804
+
805
+ def invalidate_cache(self, memory_id: Optional[str] = None) -> None:
806
+ """
807
+ Invalidate strength cache.
808
+
809
+ Args:
810
+ memory_id: Specific memory to invalidate, or None for all
811
+ """
812
+ if memory_id:
813
+ self._strength_cache.pop(memory_id, None)
814
+ else:
815
+ self._strength_cache.clear()
816
+
817
+
818
+ def calculate_projected_strength(
819
+ strength: MemoryStrength,
820
+ days_ahead: int,
821
+ ) -> float:
822
+ """
823
+ Project what strength will be in the future.
824
+
825
+ Useful for predicting when a memory will become weak/forgettable.
826
+
827
+ Args:
828
+ strength: Current MemoryStrength record
829
+ days_ahead: Days to project forward
830
+
831
+ Returns:
832
+ Projected strength value
833
+ """
834
+ if days_ahead <= 0:
835
+ return strength.current_strength()
836
+
837
+ # Calculate decay factor for future date
838
+ if strength.decay_half_life_days > 0:
839
+ decay_factor = math.exp(-0.693 * days_ahead / strength.decay_half_life_days)
840
+ else:
841
+ decay_factor = 1.0
842
+
843
+ # Current strength minus future decay
844
+ current = strength.current_strength()
845
+ # Simplified projection: apply decay to current strength
846
+ return max(0.0, current * decay_factor)
847
+
848
+
849
+ def days_until_threshold(
850
+ strength: MemoryStrength,
851
+ threshold: float = 0.1,
852
+ ) -> Optional[int]:
853
+ """
854
+ Calculate days until memory reaches threshold.
855
+
856
+ Args:
857
+ strength: MemoryStrength record
858
+ threshold: Target threshold
859
+
860
+ Returns:
861
+ Days until threshold, or None if already below or won't reach
862
+ """
863
+ current = strength.current_strength()
864
+
865
+ if current <= threshold:
866
+ return 0
867
+
868
+ if strength.decay_half_life_days <= 0:
869
+ return None # No decay, won't reach threshold
870
+
871
+ # Solve: threshold = current * e^(-0.693 * days / half_life)
872
+ # days = -half_life * ln(threshold / current) / 0.693
873
+ ratio = threshold / current
874
+ if ratio >= 1:
875
+ return 0
876
+
877
+ days = -strength.decay_half_life_days * math.log(ratio) / 0.693
878
+ return int(math.ceil(days))