"""Memory management for Rex AI Assistant. This module provides memory utilities for user profiles and conversation history, as well as structured memory capabilities: - Working Memory: Short-term buffer for recent interactions and task summaries - Long-Term Memory: Structured entries with categories, expiration, and search Re-exports from memory_utils for backward compatibility. Ownership model (US-303 per-user isolation): - Every working-memory and long-term-memory operation requires an explicit validated ``user_id`` (see :func:`rex.identity.validate_user_id`). - Stores are partitioned on disk per user:: data/memory//working_memory.json data/memory//long_term_memory.json - Missing, blank, malformed, or traversal-style identity fails closed (``TypeError``/``ValueError``); there is no silent fallback to ``default``, the active user, or any other profile. - Process-level instances are cached in dictionaries keyed by validated ``user_id``; one user never receives an object backed by another user's path or in-memory entries. - User IDs that differ only by case from an existing profile are rejected on every platform: Windows/macOS filesystems are case-insensitive, so "James" and "james" would otherwise silently alias one on-disk store. Legacy unscoped-file migration: - The pre-isolation shared files ``data/memory/working_memory.json`` and ``data/memory/long_term_memory.json`` belong only to the distinct ``default`` profile. They are never shared and never reassigned to a named user, and named users never read them. - Migration runs only when a caller explicitly requests ``user_id="default"``. It is idempotent and crash-safe: the legacy file is copied into ``data/memory/default/`` via a temp file + atomic replace, and only after the default-profile copy exists is the original moved aside as ``.json.pre-user-isolation.bak`` (never deleted). A failed or partial migration leaves the original untouched and is retried on the next explicit ``default`` access. To recover manually, restore the ``.bak`` file to its original name and remove ``data/memory/default/.json``. Usage: from rex.memory import ( get_working_memory, get_long_term_memory, add_user_preference, remember_context, ) # Working memory (owner-scoped) wm = get_working_memory(user_id="james") wm.add_entry("User asked about weather") # Long-term memory (owner-scoped) ltm = get_long_term_memory(user_id="james") entry = ltm.add_entry("preferences", {"theme": "dark"}) """ from __future__ import annotations import json import logging import os import shutil import uuid from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any from pydantic import BaseModel, Field from rex.identity import validate_user_id from rex.runtime_paths import data_dir, user_data_dir, users_data_dir # Re-export existing memory utilities for backward compatibility from .memory_utils import ( # noqa: F401 append_history_entry, export_transcript, extract_voice_reference, load_all_profiles, load_memory_profile, load_recent_history, load_users_map, resolve_user_key, trim_history, ) logger = logging.getLogger(__name__) # Compatibility seam for callers/tests that replace the structured-memory # root. Production defaults use the canonical private-user layout. _DATA_DIR = data_dir() / "memory" _INITIAL_DATA_DIR = _DATA_DIR def _legacy_memory_dir() -> Path: """Return the pre-canonical structured-memory root.""" return _DATA_DIR # The distinct profile that owns pre-isolation legacy data. Never used as an # implicit fallback; callers must select it explicitly. DEFAULT_PROFILE = "default" # Suffix appended to a legacy shared store file once its content has been # migrated into the explicit ``default`` profile. LEGACY_BACKUP_SUFFIX = ".pre-user-isolation.bak" _WORKING_MEMORY_FILENAME = "working_memory.json" _LONG_TERM_MEMORY_FILENAME = "long_term_memory.json" def _utc_now() -> datetime: """Return the current UTC datetime.""" return datetime.now(UTC) def _reject_case_conflicts(user_id: str) -> None: """Fail closed when *user_id* differs only by case from a known profile. Windows (NTFS) and macOS (APFS/HFS+) filesystems are case-insensitive, so two IDs differing only by case would silently alias one on-disk store — user "James" would read and rewrite user "james"'s files. The later spelling is rejected on every platform so behavior is consistent and fail-closed. Raises: ValueError: If a case-variant of *user_id* already exists in the process registries or as a memory directory on disk. """ folded = user_id.casefold() known_ids: set[str] = set(_working_memories) | set(_long_term_memories) private_root = users_data_dir() if private_root.is_dir(): known_ids.update(entry.name for entry in private_root.iterdir() if entry.is_dir()) legacy_root = _legacy_memory_dir() if legacy_root.is_dir(): known_ids.update(entry.name for entry in legacy_root.iterdir() if entry.is_dir()) for existing in known_ids: if existing != user_id and existing.casefold() == folded: raise ValueError( f"Invalid user_id: {user_id!r} (conflicts with an existing memory profile)" ) def _user_memory_dir(user_id: str) -> Path: """Return private memory storage and copy a legacy user store on first access.""" user_id = validate_user_id(user_id) _reject_case_conflicts(user_id) target = ( _DATA_DIR / user_id if _DATA_DIR != _INITIAL_DATA_DIR else user_data_dir(user_id) / "memory" ) legacy = _legacy_memory_dir() / user_id if legacy.is_dir() and not target.exists(): target.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(legacy, target) logger.info("Copied legacy memory profile '%s' into canonical private storage", user_id) return target def _migrate_legacy_default_store(filename: str) -> None: """Migrate a pre-isolation shared store file to the ``default`` profile. Called only when a caller explicitly requests ``user_id="default"``. Idempotent and crash-safe: 1. If ``data/memory/`` does not exist, do nothing. 2. If the default-profile copy does not exist yet, copy the legacy file via a temp file and atomic replace. A failure here propagates (the caller fails closed), leaves the original untouched, and is retried on the next explicit ``default`` access. 3. Only once the default-profile copy exists is the original moved aside as ``.pre-user-isolation.bak`` — preserved, never deleted. If the move fails, the original stays in place and the move is retried later; migration itself is already complete. """ legacy_root = _legacy_memory_dir() legacy = legacy_root / filename if not legacy.is_file(): return target = _user_memory_dir(DEFAULT_PROFILE) / filename if not target.exists(): target.parent.mkdir(parents=True, exist_ok=True) # PID-unique temp file: concurrent first-time migrations (e.g. two # GUI bridge processes) each copy privately, and os.replace is # atomic, so interleaved writes can never publish a corrupt store. tmp_copy = target.with_name(f"{target.name}.migrating.{os.getpid()}") try: shutil.copyfile(legacy, tmp_copy) os.replace(tmp_copy, target) finally: tmp_copy.unlink(missing_ok=True) logger.info("Migrated legacy shared %s into the 'default' profile store", filename) backup = legacy_root / (filename + LEGACY_BACKUP_SUFFIX) if not backup.exists(): try: legacy.rename(backup) logger.info("Preserved legacy %s as %s", filename, backup.name) except OSError as exc: logger.warning( "Could not move legacy %s aside: %s (original left in place)", filename, exc, ) # ============================================================================= # Working Memory # ============================================================================= class WorkingMemory: """Short-term memory buffer for recent interactions and task summaries. Holds an ordered list of recent entries, automatically persisting to disk and loading on startup. Useful for maintaining immediate conversational context. Each instance is backed by exactly one owner's store. Attributes: max_entries: Maximum number of entries to retain (default 100). storage_path: Path to the persistence file. user_id: Validated owner of this store, or None for an explicit custom ``storage_path`` (unit-test instances). Example: wm = WorkingMemory(user_id="james") wm.add_entry("User asked about the weather in Dallas") wm.add_entry("Checked weather API - sunny, 72°F") recent = wm.get_recent(5) for entry in recent: print(entry) """ def __init__( self, storage_path: Path | str | None = None, max_entries: int = 100, *, user_id: str | None = None, ) -> None: """Initialize working memory for one owner. Args: storage_path: Explicit persistence file (unit-test support). When omitted, a validated ``user_id`` is required and the store lives at ``data/memory//working_memory.json``. max_entries: Maximum entries to retain before oldest are removed. user_id: Owner of this store. Validated; fails closed when the default per-user path is used and no owner is given. Raises: ValueError: If neither ``storage_path`` nor a valid ``user_id`` is provided, or if ``user_id`` is invalid. """ if user_id is not None: user_id = validate_user_id(user_id) if storage_path is None: if user_id is None: raise ValueError("WorkingMemory requires a user_id (or an explicit storage_path)") if user_id == DEFAULT_PROFILE: _migrate_legacy_default_store(_WORKING_MEMORY_FILENAME) storage_path = _user_memory_dir(user_id) / _WORKING_MEMORY_FILENAME self.user_id = user_id self.storage_path = Path(storage_path) self.max_entries = max_entries self._entries: list[dict[str, Any]] = [] self._load() def _owner_label(self) -> str: """Owner identifier for logging (never memory content).""" return self.user_id if self.user_id is not None else "" def _load(self) -> None: """Load entries from disk.""" if self.storage_path.exists(): try: with open(self.storage_path, encoding="utf-8") as f: data = json.load(f) self._entries = data.get("entries", []) logger.debug( "Loaded %d working memory entries for owner '%s'", len(self._entries), self._owner_label(), ) except (json.JSONDecodeError, OSError) as e: logger.warning( "Failed to load working memory for owner '%s': %s", self._owner_label(), e, ) self._entries = [] def _save(self) -> None: """Save entries to disk.""" self.storage_path.parent.mkdir(parents=True, exist_ok=True) try: with open(self.storage_path, "w", encoding="utf-8") as f: json.dump({"entries": self._entries}, f, indent=2, default=str) except OSError as e: logger.error( "Failed to save working memory for owner '%s': %s", self._owner_label(), e, ) def add_entry(self, content: str) -> None: """Add a new entry to working memory. Args: content: The content to add (interaction summary, note, etc.) """ entry = { "content": content, "timestamp": _utc_now().isoformat(), } self._entries.append(entry) # Trim to max entries if len(self._entries) > self.max_entries: self._entries = self._entries[-self.max_entries :] self._save() def get_recent(self, n: int = 10) -> list[str]: """Get the most recent entries. Args: n: Number of entries to retrieve. Returns: List of content strings, most recent last. """ recent = self._entries[-n:] if n < len(self._entries) else self._entries return [entry["content"] for entry in recent] def get_recent_with_timestamps(self, n: int = 10) -> list[dict[str, Any]]: """Get recent entries with their timestamps. Args: n: Number of entries to retrieve. Returns: List of entry dicts with 'content' and 'timestamp' keys. """ return self._entries[-n:] if n < len(self._entries) else self._entries.copy() def clear(self) -> None: """Clear all working memory entries for this owner.""" self._entries = [] self._save() logger.debug("Cleared working memory for owner '%s'", self._owner_label()) def __len__(self) -> int: """Return the number of entries.""" return len(self._entries) def stats(self) -> dict[str, Any]: """Return summary statistics for working memory.""" return { "entries": len(self._entries), "max_entries": self.max_entries, } # ============================================================================= # Long-Term Memory # ============================================================================= class MemoryEntry(BaseModel): """A structured long-term memory entry. Attributes: entry_id: Unique identifier for this entry. category: Category for organizing entries (e.g., 'preferences', 'facts'). content: The stored data as a dictionary. created_at: When the entry was created. expires_at: When the entry should expire (None for no expiration). sensitive: Whether this entry contains sensitive data. """ entry_id: str = Field( default_factory=lambda: f"mem_{uuid.uuid4().hex[:12]}", description="Unique identifier for this entry", ) category: str = Field( ..., description="Category for organizing entries", ) content: dict[str, Any] = Field( default_factory=dict, description="The stored data", ) created_at: datetime = Field( default_factory=_utc_now, description="When the entry was created", ) expires_at: datetime | None = Field( default=None, description="When the entry should expire (None for no expiration)", ) sensitive: bool = Field( default=False, description="Whether this entry contains sensitive data", ) def is_expired(self) -> bool: """Check if this entry has expired.""" if self.expires_at is None: return False return _utc_now() > self.expires_at def to_safe_dict(self) -> dict[str, Any]: """Return a dictionary with sensitive content redacted.""" data = self.model_dump() if self.sensitive: data["content"] = {"[SENSITIVE]": "Content hidden"} return data model_config = { "json_schema_extra": { "examples": [ { "entry_id": "mem_abc123def456", "category": "preferences", "content": {"theme": "dark", "language": "en"}, "created_at": "2024-01-15T10:30:00Z", "expires_at": None, "sensitive": False, } ] } } class LongTermMemory: """Long-term structured memory with retention policies. Stores entries organized by category with support for: - Expiration dates and automatic cleanup - Sensitive data flagging - Keyword-based search across content Entries are persisted to disk as JSON. Each instance is backed by exactly one owner's store; entry IDs may repeat across owners without collision. Example: ltm = LongTermMemory(user_id="james") # Add a preference that expires in 30 days entry = ltm.add_entry( category="preferences", content={"theme": "dark"}, expires_in=timedelta(days=30), ) # Search for entries results = ltm.search(keyword="theme") # Clean up expired entries ltm.run_retention_policy() """ def __init__( self, storage_path: Path | str | None = None, *, user_id: str | None = None, ) -> None: """Initialize long-term memory for one owner. Args: storage_path: Explicit persistence file (unit-test support). When omitted, a validated ``user_id`` is required and the store lives at ``data/memory//long_term_memory.json``. user_id: Owner of this store. Validated; fails closed when the default per-user path is used and no owner is given. Raises: ValueError: If neither ``storage_path`` nor a valid ``user_id`` is provided, or if ``user_id`` is invalid. """ if user_id is not None: user_id = validate_user_id(user_id) if storage_path is None: if user_id is None: raise ValueError("LongTermMemory requires a user_id (or an explicit storage_path)") if user_id == DEFAULT_PROFILE: _migrate_legacy_default_store(_LONG_TERM_MEMORY_FILENAME) storage_path = _user_memory_dir(user_id) / _LONG_TERM_MEMORY_FILENAME self.user_id = user_id self.storage_path = Path(storage_path) self._entries: dict[str, MemoryEntry] = {} self._load() self.run_retention_policy() def _owner_label(self) -> str: """Owner identifier for logging (never memory content).""" return self.user_id if self.user_id is not None else "" def _load(self) -> None: """Load entries from disk.""" if self.storage_path.exists(): try: with open(self.storage_path, encoding="utf-8") as f: data = json.load(f) for entry_data in data.get("entries", []): entry = MemoryEntry.model_validate(entry_data) self._entries[entry.entry_id] = entry logger.debug( "Loaded %d long-term memory entries for owner '%s'", len(self._entries), self._owner_label(), ) except (json.JSONDecodeError, OSError) as e: logger.warning( "Failed to load long-term memory for owner '%s': %s", self._owner_label(), e, ) self._entries = {} def _save(self) -> None: """Save entries to disk.""" self.storage_path.parent.mkdir(parents=True, exist_ok=True) try: entries_data = [entry.model_dump(mode="json") for entry in self._entries.values()] with open(self.storage_path, "w", encoding="utf-8") as f: json.dump({"entries": entries_data}, f, indent=2, default=str) except OSError as e: logger.error( "Failed to save long-term memory for owner '%s': %s", self._owner_label(), e, ) def add_entry( self, category: str, content: dict[str, Any], expires_in: timedelta | None = None, sensitive: bool = False, entry_id: str | None = None, ) -> MemoryEntry: """Add a new entry to long-term memory. Args: category: Category for the entry (e.g., 'preferences', 'facts'). content: The data to store as a dictionary. expires_in: Time until expiration (None for no expiration). sensitive: Whether this contains sensitive data. entry_id: Optional custom entry ID. Returns: The created MemoryEntry. """ expires_at = None if expires_in is not None: expires_at = _utc_now() + expires_in entry = MemoryEntry( entry_id=entry_id or f"mem_{uuid.uuid4().hex[:12]}", category=category, content=content, expires_at=expires_at, sensitive=sensitive, ) self._entries[entry.entry_id] = entry self._save() logger.debug( "Added memory entry %s in category '%s' for owner '%s'", entry.entry_id, category, self._owner_label(), ) return entry def get_entry(self, entry_id: str) -> MemoryEntry | None: """Get a specific entry by ID. Args: entry_id: The entry ID to look up. Returns: The MemoryEntry if found and not expired, else None. """ entry = self._entries.get(entry_id) if entry is None or entry.is_expired(): return None return entry def search( self, category: str | None = None, keyword: str | None = None, include_sensitive: bool = True, include_expired: bool = False, ) -> list[MemoryEntry]: """Search for entries by category and/or keyword. Args: category: Filter by category (exact match). keyword: Search for keyword in content keys and values (substring). include_sensitive: Whether to include sensitive entries. include_expired: Whether to include expired entries. Returns: List of matching MemoryEntry objects. """ results = [] keyword_lower = keyword.lower() if keyword else None for entry in self._entries.values(): # Skip expired entries unless requested if not include_expired and entry.is_expired(): continue # Skip sensitive entries if not requested if not include_sensitive and entry.sensitive: continue # Category filter if category is not None and entry.category != category: continue # Keyword filter - search in content keys and values if keyword_lower is not None: if not self._matches_keyword(entry.content, keyword_lower): # Also check category if keyword_lower not in entry.category.lower(): continue results.append(entry) # Sort by created_at descending (newest first) results.sort(key=lambda e: e.created_at, reverse=True) return results def _matches_keyword(self, content: dict[str, Any], keyword: str) -> bool: """Check if content matches keyword (recursive substring search).""" def search_value(value: Any) -> bool: if isinstance(value, str): return keyword in value.lower() elif isinstance(value, dict): for k, v in value.items(): if keyword in str(k).lower(): return True if search_value(v): return True elif isinstance(value, (list, tuple)): for item in value: if search_value(item): return True else: return keyword in str(value).lower() return False return search_value(content) def forget(self, entry_id: str) -> bool: """Delete a specific entry. Args: entry_id: The entry ID to delete. Returns: True if the entry was deleted, False if not found. """ if entry_id in self._entries: del self._entries[entry_id] self._save() logger.debug("Deleted memory entry %s for owner '%s'", entry_id, self._owner_label()) return True return False def run_retention_policy(self) -> int: """Delete all expired entries. This is automatically called on startup and can be called manually. Returns: Number of entries deleted. """ expired_ids = [entry_id for entry_id, entry in self._entries.items() if entry.is_expired()] for entry_id in expired_ids: del self._entries[entry_id] if expired_ids: self._save() logger.info( "Retention policy deleted %d expired entries for owner '%s'", len(expired_ids), self._owner_label(), ) return len(expired_ids) def compact(self) -> int: """Remove expired entries and rewrite storage to reclaim space. Unlike run_retention_policy which only saves when entries were deleted, compact always rewrites the storage file to ensure it contains only current entries with no stale data. Returns: Number of expired entries removed. """ removed = self.run_retention_policy() # Always rewrite storage to compact the file self._save() logger.info( "Memory store compacted for owner '%s': %d expired entries removed, " "%d active entries retained", self._owner_label(), removed, len(self), ) return removed def list_categories(self) -> list[str]: """List all unique categories. Returns: Sorted list of category names. """ categories = set() for entry in self._entries.values(): if not entry.is_expired(): categories.add(entry.category) return sorted(categories) def count_by_category(self) -> dict[str, int]: """Count non-expired entries per category. Returns: Dictionary of category name to count. """ counts: dict[str, int] = {} for entry in self._entries.values(): if not entry.is_expired(): counts[entry.category] = counts.get(entry.category, 0) + 1 return counts def __len__(self) -> int: """Return the number of non-expired entries.""" return sum(1 for e in self._entries.values() if not e.is_expired()) def stats(self) -> dict[str, Any]: """Return summary statistics for long-term memory.""" return { "entries": len(self), "categories": self.count_by_category(), } # ============================================================================= # Per-User Instance Registries # ============================================================================= _working_memories: dict[str, WorkingMemory] = {} _long_term_memories: dict[str, LongTermMemory] = {} def get_working_memory(*, user_id: str) -> WorkingMemory: """Get the working memory instance owned by *user_id*. Args: user_id: Validated owner of the store. Required; missing or invalid identity fails closed (``TypeError``/``ValueError``). Returns: The per-user WorkingMemory instance (created lazily). """ user_id = validate_user_id(user_id) instance = _working_memories.get(user_id) if instance is None: instance = WorkingMemory(user_id=user_id) _working_memories[user_id] = instance return instance def set_working_memory(wm: WorkingMemory | None, *, user_id: str) -> None: """Set (or clear) the working memory instance for *user_id* (for testing).""" user_id = validate_user_id(user_id) if wm is None: _working_memories.pop(user_id, None) else: _reject_case_conflicts(user_id) _working_memories[user_id] = wm def get_long_term_memory(*, user_id: str) -> LongTermMemory: """Get the long-term memory instance owned by *user_id*. Args: user_id: Validated owner of the store. Required; missing or invalid identity fails closed (``TypeError``/``ValueError``). Returns: The per-user LongTermMemory instance (created lazily). """ user_id = validate_user_id(user_id) instance = _long_term_memories.get(user_id) if instance is None: instance = LongTermMemory(user_id=user_id) _long_term_memories[user_id] = instance return instance def set_long_term_memory(ltm: LongTermMemory | None, *, user_id: str) -> None: """Set (or clear) the long-term memory instance for *user_id* (for testing).""" user_id = validate_user_id(user_id) if ltm is None: _long_term_memories.pop(user_id, None) else: _reject_case_conflicts(user_id) _long_term_memories[user_id] = ltm # ============================================================================= # Convenience Functions # ============================================================================= def add_user_preference( key: str, value: Any, expires_in: timedelta | None = None, sensitive: bool = False, *, user_id: str, ) -> MemoryEntry: """Add a user preference to *user_id*'s long-term memory. Args: key: Preference key (e.g., 'theme', 'language'). value: Preference value. expires_in: Optional expiration time. sensitive: Whether this is sensitive data. user_id: Owner of the preference. Required; fails closed on missing or invalid identity. Returns: The created MemoryEntry. """ ltm = get_long_term_memory(user_id=user_id) return ltm.add_entry( category="user_preferences", content={key: value}, expires_in=expires_in, sensitive=sensitive, ) def get_user_preferences(key: str | None = None, *, user_id: str) -> list[MemoryEntry]: """Get *user_id*'s preferences from long-term memory. Args: key: Optional key to search for. user_id: Owner whose preferences to read. Required; fails closed on missing or invalid identity. Returns: List of matching preference entries. """ ltm = get_long_term_memory(user_id=user_id) return ltm.search(category="user_preferences", keyword=key) def add_fact( topic: str, content: dict[str, Any], expires_in: timedelta | None = None, *, user_id: str, ) -> MemoryEntry: """Add a fact to *user_id*'s long-term memory. Args: topic: The topic or subject of the fact. content: Fact data. expires_in: Optional expiration time. user_id: Owner of the fact. Required; fails closed on missing or invalid identity. Returns: The created MemoryEntry. """ ltm = get_long_term_memory(user_id=user_id) return ltm.add_entry( category="facts", content={"topic": topic, **content}, expires_in=expires_in, ) def remember_context(summary: str, *, user_id: str) -> None: """Add a context summary to *user_id*'s working memory. Args: summary: A summary of the current context or interaction. user_id: Owner of the context. Required; fails closed on missing or invalid identity. """ wm = get_working_memory(user_id=user_id) wm.add_entry(summary) def get_recent_context(n: int = 5, *, user_id: str) -> list[str]: """Get recent context from *user_id*'s working memory. Args: n: Number of entries to retrieve. user_id: Owner whose context to read. Required; fails closed on missing or invalid identity. Returns: List of recent context summaries. """ wm = get_working_memory(user_id=user_id) return wm.get_recent(n) # ============================================================================= # Per-User Maintenance (cleanup scheduling and metrics) # ============================================================================= def list_memory_user_ids() -> list[str]: """Discover user IDs that have a per-user memory directory on disk. Only directory names that pass :func:`rex.identity.validate_user_id` are returned; anything else (including the legacy shared files and their backups at the data-dir root) is ignored and never treated as a user. """ root = _DATA_DIR if _DATA_DIR != _INITIAL_DATA_DIR else users_data_dir() if not root.is_dir(): return [] users: list[str] = [] for entry in sorted(root.iterdir()): if not entry.is_dir(): continue try: validate_user_id(entry.name) except ValueError: logger.warning("Ignoring invalid memory directory name: %r", entry.name) continue users.append(entry.name) return users def run_memory_cleanup() -> dict[str, int]: """Compact every known user's long-term store, each independently. Covers users discovered on disk plus any live registry instances (e.g. test-injected stores). A failure in one user's store is logged and never affects another user's store. Returns: Mapping of user_id to the number of expired entries removed. Users whose cleanup failed are omitted. """ user_ids = sorted(set(list_memory_user_ids()) | set(_long_term_memories)) results: dict[str, int] = {} for user_id in user_ids: try: removed = get_long_term_memory(user_id=user_id).compact() except Exception: logger.exception( "Memory cleanup failed for owner '%s'; other stores unaffected", user_id ) continue results[user_id] = removed return results def schedule_memory_cleanup( scheduler: Any, interval_seconds: int = 3600, job_id: str = "memory_cleanup", ) -> None: """Register a scheduled memory cleanup job with the scheduler. Registers a callback that runs :func:`run_memory_cleanup` at the given interval: every user's long-term store is compacted independently, one user's failure never touches another user's file, and invalid directory names are ignored. Args: scheduler: A Scheduler instance from rex.scheduler. interval_seconds: How often to run cleanup (default: 3600 = 1 hour). job_id: Unique ID for the scheduler job. """ def _cleanup_callback(job: Any) -> None: # noqa: ARG001 results = run_memory_cleanup() removed = sum(results.values()) logger.info( "Scheduled memory cleanup complete: %d expired entries removed " "across %d user store(s)", removed, len(results), ) # Register callback and add the job scheduler.register_callback("memory_cleanup", _cleanup_callback) scheduler.add_job( job_id=job_id, name="Memory Cleanup", schedule=f"interval:{interval_seconds}", callback_name="memory_cleanup", ) logger.info(f"Memory cleanup scheduled every {interval_seconds}s (job_id={job_id})") def memory_store_metrics() -> dict[str, Any]: """Aggregate entry counts across all per-user stores (no content). Used by the service supervisor for health metrics. Counts only — never memory content, categories, or entry IDs. """ user_ids = sorted( set(list_memory_user_ids()) | set(_working_memories) | set(_long_term_memories) ) working_total = 0 long_term_total = 0 for user_id in user_ids: try: working_total += len(get_working_memory(user_id=user_id)) long_term_total += len(get_long_term_memory(user_id=user_id)) except Exception: logger.exception("Failed to collect memory metrics for owner '%s'", user_id) return { "user_count": len(user_ids), "working_memory_entries": working_total, "long_term_memory_entries": long_term_total, } # Update __all__ to include new exports __all__ = [ # Legacy memory utilities "load_users_map", "resolve_user_key", "load_memory_profile", "load_all_profiles", "extract_voice_reference", "trim_history", "append_history_entry", "load_recent_history", "export_transcript", # Ownership constants "DEFAULT_PROFILE", "LEGACY_BACKUP_SUFFIX", # Working memory "WorkingMemory", "get_working_memory", "set_working_memory", # Long-term memory "MemoryEntry", "LongTermMemory", "get_long_term_memory", "set_long_term_memory", # Convenience functions "add_user_preference", "get_user_preferences", "add_fact", "remember_context", "get_recent_context", # Per-user maintenance "list_memory_user_ids", "run_memory_cleanup", "schedule_memory_cleanup", "memory_store_metrics", ]