Spaces:
Runtime error
Runtime error
File size: 13,756 Bytes
1342767 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 | """
packages/memory/lifecycle.py
EverMemOS-inspired memory lifecycle engine for Ultron V4.
Pattern source: EverMind-AI/EverOS + BAI-LAB/MemoryOS (mid_term.py heat model).
Lifecycle:
raw text β MemCell (atomic episode)
MemCell β MemScene (thematic cluster, heat-based)
MemScene β Foresight ("Ghost will need X next" prediction)
Foresight β rd_loop.py (autonomous R&D trigger)
Heat formula (MemoryOS):
H = Ξ±*N_visit + Ξ²*L_interaction + Ξ³*R_recency
R_recency = exp(-(now - last_visit) / TAU_HOURS)
Pre-registered bugs:
LC1 [HIGH] Foresight LLM call fails β return cached or empty, never crash worker
LC2 [HIGH] Scene clustering with no embedding β skip cluster, add to scene-less pool
LC3 [MED] Heat rebuild on every ingest is O(N) β cap scene count per user at 200
LC4 [MED] Foresight TTL not set in Redis β stale predictions β set 6h TTL always
LC5 [LOW] Cell eviction during concurrent ingests β asyncio.Lock per user_id
"""
from __future__ import annotations
import asyncio
import json
import math
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone, timedelta
from typing import Any, Callable, Dict, List, Optional
# Heat constants (tunable)
HEAT_ALPHA = 1.0 # N_visit weight
HEAT_BETA = 0.5 # L_interaction (cell count) weight
HEAT_GAMMA = 2.0 # recency weight
RECENCY_TAU_HOURS = 24.0 # decay half-life
MAX_SCENES_PER_USER = 200 # LC3
FORESIGHT_TTL_SECONDS = 6 * 3600 # LC4 β 6h Redis TTL
CELL_WINDOW = 20 # max cells in STM per user (Redis list)
# βββββββββββββββββββββββββββββββββββββββββββββ
# Time helpers
# βββββββββββββββββββββββββββββββββββββββββββββ
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _recency(last_visit_iso: str) -> float:
"""Exponential decay R = exp(-Ξt / Ο). Returns 0..1."""
try:
last = datetime.fromisoformat(last_visit_iso)
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
delta_h = (datetime.now(timezone.utc) - last).total_seconds() / 3600.0
return math.exp(-delta_h / RECENCY_TAU_HOURS)
except Exception:
return 1.0
def _heat(n_visit: int, l_interaction: int, last_visit_iso: str) -> float:
R = _recency(last_visit_iso)
return HEAT_ALPHA * n_visit + HEAT_BETA * l_interaction + HEAT_GAMMA * R
# βββββββββββββββββββββββββββββββββββββββββββββ
# Data models
# βββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class MemCell:
"""Atomic episode β raw text, never paraphrased at write time."""
cell_id: str = field(default_factory=lambda: str(uuid.uuid4()))
user_id: str = ""
channel_id: str = ""
raw_text: str = "" # verbatim message(s)
timestamp: str = field(default_factory=_now_iso)
scene_id: Optional[str] = None
heat: float = 1.0
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict:
return asdict(self)
@classmethod
def from_dict(cls, d: Dict) -> "MemCell":
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
@dataclass
class MemScene:
"""Thematic cluster of MemCells. Equivalent to MemoryOS session."""
scene_id: str = field(default_factory=lambda: str(uuid.uuid4()))
user_id: str = ""
topic: str = "" # LLM-extracted topic label
summary: str = "" # LLM-generated on promotion
cell_ids: List[str] = field(default_factory=list)
created_at: str = field(default_factory=_now_iso)
last_visited: str = field(default_factory=_now_iso)
n_visit: int = 0
heat: float = 1.0
def to_dict(self) -> Dict:
return asdict(self)
@classmethod
def from_dict(cls, d: Dict) -> "MemScene":
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
def recompute_heat(self) -> None:
self.heat = _heat(self.n_visit, len(self.cell_ids), self.last_visited)
@dataclass
class Foresight:
"""EverMemOS Foresight: time-bounded predictions of what Ghost needs next."""
foresight_id: str = field(default_factory=lambda: str(uuid.uuid4()))
user_id: str = ""
predictions: List[str] = field(default_factory=list) # ranked list
generated_at: str = field(default_factory=_now_iso)
valid_until: str = ""
context_cells: List[str] = field(default_factory=list) # cell_ids used
def to_dict(self) -> Dict:
return asdict(self)
@classmethod
def from_dict(cls, d: Dict) -> "Foresight":
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
def is_valid(self) -> bool:
try:
vt = datetime.fromisoformat(self.valid_until)
if vt.tzinfo is None:
vt = vt.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) < vt
except Exception:
return False
# βββββββββββββββββββββββββββββββββββββββββββββ
# LifecycleEngine
# βββββββββββββββββββββββββββββββββββββββββββββ
class LifecycleEngine:
"""
Manages full memory lifecycle per user.
Redis key schema:
lifecycle:cell:{user_id}:{cell_id} β MemCell JSON
lifecycle:stm:{user_id} β Redis list of cell_ids (20-item window)
lifecycle:scene:{user_id}:{scene_id}β MemScene JSON
lifecycle:scene_index:{user_id} β JSON list of scene_ids
lifecycle:foresight:{user_id} β Foresight JSON (TTL 6h)
"""
def __init__(self, redis_client):
self.redis = redis_client
self._locks: Dict[str, asyncio.Lock] = {} # per-user locks (LC5)
def _lock(self, user_id: str) -> asyncio.Lock:
if user_id not in self._locks:
self._locks[user_id] = asyncio.Lock()
return self._locks[user_id]
# ββ STM (Redis list) βββββββββββββββββββββ
async def ingest(self, user_id: str, channel_id: str, raw_text: str,
metadata: Optional[Dict] = None) -> str:
"""Write raw text β MemCell β STM window. Returns cell_id."""
cell = MemCell(
user_id=user_id,
channel_id=channel_id,
raw_text=raw_text,
metadata=metadata or {},
)
async with self._lock(user_id):
# persist cell
cell_key = f"lifecycle:cell:{user_id}:{cell.cell_id}"
await self.redis.set(cell_key, json.dumps(cell.to_dict()))
# push to STM list, trim to CELL_WINDOW
stm_key = f"lifecycle:stm:{user_id}"
pipe = self.redis.pipeline()
pipe.rpush(stm_key, cell.cell_id)
pipe.ltrim(stm_key, -CELL_WINDOW, -1)
await pipe.execute()
return cell.cell_id
async def get_stm(self, user_id: str) -> List[MemCell]:
"""Return all MemCells in STM window."""
stm_key = f"lifecycle:stm:{user_id}"
cell_ids = await self.redis.lrange(stm_key, 0, -1)
cells = []
for cid in cell_ids:
raw = await self.redis.get(f"lifecycle:cell:{user_id}:{cid}")
if raw:
cells.append(MemCell.from_dict(json.loads(raw)))
return cells
# ββ MTM (MemScene clusters) ββββββββββββββ
async def promote_to_scene(
self,
user_id: str,
topic: str,
summary: str,
cell_ids: List[str],
) -> str:
"""Promote a group of cells into a named MemScene. Returns scene_id."""
scene = MemScene(
user_id=user_id,
topic=topic,
summary=summary,
cell_ids=cell_ids,
)
async with self._lock(user_id):
scene_key = f"lifecycle:scene:{user_id}:{scene.scene_id}"
await self.redis.set(scene_key, json.dumps(scene.to_dict()))
await self._append_scene_index(user_id, scene.scene_id)
return scene.scene_id
async def get_scene(self, user_id: str, scene_id: str) -> Optional[MemScene]:
raw = await self.redis.get(f"lifecycle:scene:{user_id}:{scene_id}")
return MemScene.from_dict(json.loads(raw)) if raw else None
async def visit_scene(self, user_id: str, scene_id: str) -> None:
"""Increment N_visit, update heat. Called on retrieval hit."""
scene = await self.get_scene(user_id, scene_id)
if not scene:
return
scene.n_visit += 1
scene.last_visited = _now_iso()
scene.recompute_heat()
await self.redis.set(
f"lifecycle:scene:{user_id}:{scene_id}",
json.dumps(scene.to_dict()),
)
async def list_scenes(self, user_id: str) -> List[MemScene]:
"""Return all scenes for user, sorted by heat descending."""
index = await self._get_scene_index(user_id)
scenes = []
for sid in index:
s = await self.get_scene(user_id, sid)
if s:
s.recompute_heat()
scenes.append(s)
scenes.sort(key=lambda x: x.heat, reverse=True)
return scenes
async def evict_cold_scenes(self, user_id: str) -> int:
"""Remove scenes beyond MAX_SCENES_PER_USER by lowest heat (LFU analog). LC3."""
scenes = await self.list_scenes(user_id) # already sorted hotβcold
if len(scenes) <= MAX_SCENES_PER_USER:
return 0
to_evict = scenes[MAX_SCENES_PER_USER:]
for s in to_evict:
await self.redis.delete(f"lifecycle:scene:{user_id}:{s.scene_id}")
kept = [s.scene_id for s in scenes[:MAX_SCENES_PER_USER]]
await self.redis.set(
f"lifecycle:scene_index:{user_id}", json.dumps(kept)
)
return len(to_evict)
# ββ Foresight ββββββββββββββββββββββββββββ
async def generate_foresight(
self,
user_id: str,
llm_fn: Callable, # async fn(messages) β str
) -> Foresight:
"""
Generate Foresight: what Ghost will need next.
Uses hot scenes + recent STM cells as context.
LC1: LLM failure returns cached or empty Foresight, never raises.
"""
# check cache first
existing = await self.get_foresight(user_id)
if existing and existing.is_valid():
return existing
# build context
scenes = (await self.list_scenes(user_id))[:5] # top 5 hottest
cells = await self.get_stm(user_id)
recent_texts = [c.raw_text for c in cells[-5:]]
scene_summaries = [f"[{s.topic}] {s.summary}" for s in scenes]
context = "\n".join(scene_summaries + recent_texts)
prompt = [
{"role": "system", "content": (
"You are Ultron's memory foresight engine. "
"Based on recent activity, predict what the user will likely need or ask about next. "
"Return a JSON list of 3-5 short predictions, most likely first. "
"Example: [\"ChemE calculator improvements\", \"PDF export feature\", \"Deploy voice space\"]"
)},
{"role": "user", "content": f"Recent activity:\n{context}\n\nPredict next 3-5 needs:"}
]
predictions: List[str] = []
try:
raw_response = await llm_fn(prompt)
# strip markdown fences
clean = raw_response.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
parsed = json.loads(clean)
if isinstance(parsed, list):
predictions = [str(p) for p in parsed[:5]]
except Exception:
pass # LC1: never crash
valid_until = (datetime.now(timezone.utc) + timedelta(hours=6)).isoformat()
foresight = Foresight(
user_id=user_id,
predictions=predictions,
valid_until=valid_until,
context_cells=[c.cell_id for c in cells],
)
# persist with TTL (LC4)
foresight_key = f"lifecycle:foresight:{user_id}"
await self.redis.set(
foresight_key,
json.dumps(foresight.to_dict()),
ex=FORESIGHT_TTL_SECONDS,
)
return foresight
async def get_foresight(self, user_id: str) -> Optional[Foresight]:
raw = await self.redis.get(f"lifecycle:foresight:{user_id}")
if not raw:
return None
try:
return Foresight.from_dict(json.loads(raw))
except Exception:
return None
# ββ Index helpers ββββββββββββββββββββββββ
async def _get_scene_index(self, user_id: str) -> List[str]:
raw = await self.redis.get(f"lifecycle:scene_index:{user_id}")
return json.loads(raw) if raw else []
async def _append_scene_index(self, user_id: str, scene_id: str) -> None:
index = await self._get_scene_index(user_id)
if scene_id not in index:
index.append(scene_id)
await self.redis.set(
f"lifecycle:scene_index:{user_id}", json.dumps(index)
)
|