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.
- alma/__init__.py +296 -226
- alma/compression/__init__.py +33 -0
- alma/compression/pipeline.py +980 -0
- alma/confidence/__init__.py +47 -47
- alma/confidence/engine.py +540 -540
- alma/confidence/types.py +351 -351
- alma/config/loader.py +157 -157
- alma/consolidation/__init__.py +23 -23
- alma/consolidation/engine.py +678 -678
- alma/consolidation/prompts.py +84 -84
- alma/core.py +1189 -430
- alma/domains/__init__.py +30 -30
- alma/domains/factory.py +359 -359
- alma/domains/schemas.py +448 -448
- alma/domains/types.py +272 -272
- alma/events/__init__.py +75 -75
- alma/events/emitter.py +285 -284
- alma/events/storage_mixin.py +246 -246
- alma/events/types.py +126 -126
- alma/events/webhook.py +425 -425
- alma/exceptions.py +49 -49
- alma/extraction/__init__.py +31 -31
- alma/extraction/auto_learner.py +265 -265
- alma/extraction/extractor.py +420 -420
- alma/graph/__init__.py +106 -106
- alma/graph/backends/__init__.py +32 -32
- alma/graph/backends/kuzu.py +624 -624
- alma/graph/backends/memgraph.py +432 -432
- alma/graph/backends/memory.py +236 -236
- alma/graph/backends/neo4j.py +417 -417
- alma/graph/base.py +159 -159
- alma/graph/extraction.py +198 -198
- alma/graph/store.py +860 -860
- alma/harness/__init__.py +35 -35
- alma/harness/base.py +386 -386
- alma/harness/domains.py +705 -705
- alma/initializer/__init__.py +37 -37
- alma/initializer/initializer.py +418 -418
- alma/initializer/types.py +250 -250
- alma/integration/__init__.py +62 -62
- alma/integration/claude_agents.py +444 -444
- alma/integration/helena.py +423 -423
- alma/integration/victor.py +471 -471
- alma/learning/__init__.py +101 -86
- alma/learning/decay.py +878 -0
- alma/learning/forgetting.py +1446 -1446
- alma/learning/heuristic_extractor.py +390 -390
- alma/learning/protocols.py +374 -374
- alma/learning/validation.py +346 -346
- alma/mcp/__init__.py +123 -45
- alma/mcp/__main__.py +156 -156
- alma/mcp/resources.py +122 -122
- alma/mcp/server.py +955 -591
- alma/mcp/tools.py +3254 -509
- alma/observability/__init__.py +91 -84
- alma/observability/config.py +302 -302
- alma/observability/guidelines.py +170 -0
- alma/observability/logging.py +424 -424
- alma/observability/metrics.py +583 -583
- alma/observability/tracing.py +440 -440
- alma/progress/__init__.py +21 -21
- alma/progress/tracker.py +607 -607
- alma/progress/types.py +250 -250
- alma/retrieval/__init__.py +134 -53
- alma/retrieval/budget.py +525 -0
- alma/retrieval/cache.py +1304 -1061
- alma/retrieval/embeddings.py +202 -202
- alma/retrieval/engine.py +850 -427
- alma/retrieval/modes.py +365 -0
- alma/retrieval/progressive.py +560 -0
- alma/retrieval/scoring.py +344 -344
- alma/retrieval/trust_scoring.py +637 -0
- alma/retrieval/verification.py +797 -0
- alma/session/__init__.py +19 -19
- alma/session/manager.py +442 -399
- alma/session/types.py +288 -288
- alma/storage/__init__.py +101 -90
- alma/storage/archive.py +233 -0
- alma/storage/azure_cosmos.py +1259 -1259
- alma/storage/base.py +1083 -583
- alma/storage/chroma.py +1443 -1443
- alma/storage/constants.py +103 -103
- alma/storage/file_based.py +614 -614
- alma/storage/migrations/__init__.py +21 -21
- alma/storage/migrations/base.py +321 -321
- alma/storage/migrations/runner.py +323 -323
- alma/storage/migrations/version_stores.py +337 -337
- alma/storage/migrations/versions/__init__.py +11 -11
- alma/storage/migrations/versions/v1_0_0.py +373 -373
- alma/storage/migrations/versions/v1_1_0_workflow_context.py +551 -0
- alma/storage/pinecone.py +1080 -1080
- alma/storage/postgresql.py +1948 -1559
- alma/storage/qdrant.py +1306 -1306
- alma/storage/sqlite_local.py +3041 -1457
- alma/testing/__init__.py +46 -46
- alma/testing/factories.py +301 -301
- alma/testing/mocks.py +389 -389
- alma/types.py +292 -264
- alma/utils/__init__.py +19 -0
- alma/utils/tokenizer.py +521 -0
- alma/workflow/__init__.py +83 -0
- alma/workflow/artifacts.py +170 -0
- alma/workflow/checkpoint.py +311 -0
- alma/workflow/context.py +228 -0
- alma/workflow/outcomes.py +189 -0
- alma/workflow/reducers.py +393 -0
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/METADATA +210 -72
- alma_memory-0.7.0.dist-info/RECORD +112 -0
- alma_memory-0.5.1.dist-info/RECORD +0 -93
- {alma_memory-0.5.1.dist-info → alma_memory-0.7.0.dist-info}/WHEEL +0 -0
- {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))
|