Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| def from_dict(cls, d: Dict) -> "MemCell": | |
| return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| ) | |