"""Structured memory manager (SPEC-002). Observations in, facts + episodes out — via a batched, lock-guarded consolidation pass on `memory-model`. Recall assembly is participant-scoped (MEM-04): the model never sees facts of users who are not part of the conversation. """ import asyncio import logging from typing import Any, Awaitable, Callable, Dict, List, Optional from .persistence import PersistentStore Consolidator = Callable[[List[Dict[str, Any]], List[Dict[str, Any]]], Awaitable[Optional[Dict[str, Any]]]] DEFAULT_CONSOLIDATE_EVERY = 20 DEFAULT_EPISODES_PER_CHANNEL = 10 DEFAULT_FACT_RETENTION_DAYS = 180 OBSERVATION_EXCERPT = 500 class MemoryManager: def __init__( self, store: Optional[PersistentStore], config_getter: Callable[[], Dict[str, Any]], consolidator: Consolidator, channel: str, ) -> None: self.store = store self._config = config_getter self._consolidator = consolidator self.channel = channel self._lock = asyncio.Lock() def active(self) -> bool: return self.store is not None and "memory-model" in self._config() async def observe(self, user: str, kind: str, content: str) -> None: """Record one event; trigger consolidation when the batch is full (MEM-01/02).""" if not self.active(): return assert self.store is not None await asyncio.to_thread(self.store.add_observation, self.channel, user, kind, str(content)[:OBSERVATION_EXCERPT]) every = int(self._config().get("memory-consolidate-every", DEFAULT_CONSOLIDATE_EVERY)) if await asyncio.to_thread(self.store.unconsumed_observations, self.channel) >= every: asyncio.get_running_loop().create_task(self.consolidate_now()) async def consolidate_now(self) -> None: """One batched pass: observations -> self-authored facts + episode (MEM-02/03/05/06).""" if not self.active() or self._lock.locked(): return assert self.store is not None async with self._lock: observations = await asyncio.to_thread(self.store.peek_observations, self.channel) if not observations: return authors = {observation["user"] for observation in observations} known_facts = await asyncio.to_thread(self.store.facts_for, sorted(authors)) result = await self._consolidator(observations, known_facts) if result is None: return # model call failed — observations stay for the next trigger for fact in result.get("facts", []): if fact.get("user") in authors: await asyncio.to_thread(self.store.add_user_fact, fact["user"], str(fact["fact"]), "self") else: logging.warning(f"memory: dropped third-party fact about {fact.get('user')!r} (MEM-03)") episode = result.get("episode") if episode: await asyncio.to_thread(self.store.add_episode, self.channel, str(episode)) await asyncio.to_thread(self.store.consume_observations, self.channel, observations[-1]["id"]) config = self._config() await asyncio.to_thread( self.store.trim_episodes, self.channel, int(config.get("memory-episodes-per-channel", DEFAULT_EPISODES_PER_CHANNEL)) ) await asyncio.to_thread(self.store.purge_old_facts, int(config.get("memory-fact-retention-days", DEFAULT_FACT_RETENTION_DAYS))) def memory_block(self, participants: List[str], legacy: str) -> str: """Assemble the {memory} block, participant-scoped (MEM-04/10).""" if not self.active(): return legacy assert self.store is not None sections: List[str] = [] pinned = self.store.pinned_for(self.channel) if pinned: sections.append("Operator notes:\n" + "\n".join(f"- {pin['fact']}" for pin in pinned)) facts = self.store.facts_for(sorted(set(participants))) if facts: sections.append("What users told about themselves:\n" + "\n".join(f"- {fact['user']}: {fact['fact']}" for fact in facts)) config = self._config() episodes = self.store.recent_episodes(self.channel, int(config.get("memory-episodes-per-channel", DEFAULT_EPISODES_PER_CHANNEL))) if episodes: sections.append("Recent conversation summaries:\n" + "\n".join(f"- {episode}" for episode in episodes)) return "\n\n".join(sections)