ultron1 / packages /memory /lifecycle.py
ghostdrive1's picture
feat: memory upgrades (MAGMA/lifecycle/ground_truth), rd_loop, voice Dockerfile
1342767
Raw
History Blame Contribute Delete
13.8 kB
"""
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)
)