From f3c4a8fe5ada5843b618a1784ed005f33c349e8a Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Fri, 15 May 2026 13:54:54 +0300 Subject: [PATCH] feat(memory): add automated nightly consolidation at 4:00 AM UTC Step 2 of memory system overhaul: automated scheduling. - New consolidation_scheduler.py: run_nightly_consolidation() function that checks Cat health, triggers consolidation via WebSocket, and tracks run history with success/failure stats - bot.py on_ready: register APScheduler cron job (hour=4, minute=0) alongside the existing daily DM analysis job - routes/memory.py: expose consolidation status (last_run, last_result, last_error, is_running, total_runs, successful_runs) in the /memory/status API response - Web UI: show consolidation schedule info (last run time, success/fail, run counts) below the manual consolidate button, with 'running now' indicator when active The 'sleep consolidation' metaphor is now actually automated instead of being manual-only. --- bot/bot.py | 11 +++ bot/routes/memory.py | 6 +- bot/static/index.html | 2 + bot/static/js/memories.js | 24 ++++++ bot/utils/consolidation_scheduler.py | 105 +++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 bot/utils/consolidation_scheduler.py diff --git a/bot/bot.py b/bot/bot.py index 3ef3ee1..4964b04 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -122,6 +122,17 @@ async def on_ready(): else: logger.warning("OWNER_USER_ID not set, DM analysis feature disabled") + # Schedule nightly memory consolidation (runs at 4 AM UTC every day) + from utils.consolidation_scheduler import run_nightly_consolidation + globals.scheduler.add_job( + run_nightly_consolidation, + 'cron', + hour=4, + minute=0, + id='nightly_consolidation' + ) + logger.info("🌙 Scheduled nightly memory consolidation at 4:00 AM UTC") + # Setup autonomous speaking (now handled by server manager) setup_autonomous_speaking() load_last_sent_tweets() diff --git a/bot/routes/memory.py b/bot/routes/memory.py index 45d95f3..83a6ca3 100644 --- a/bot/routes/memory.py +++ b/bot/routes/memory.py @@ -14,15 +14,17 @@ router = APIRouter() @router.get("/memory/status") async def get_cat_memory_status(): - """Get Cheshire Cat connection status and feature flag.""" + """Get Cheshire Cat connection status, feature flag, and consolidation state.""" from utils.cat_client import cat_adapter + from utils.consolidation_scheduler import get_consolidation_status is_healthy = await cat_adapter.health_check() return { "enabled": globals.USE_CHESHIRE_CAT, "healthy": is_healthy, "url": globals.CHESHIRE_CAT_URL, "circuit_breaker_active": cat_adapter._is_circuit_broken(), - "consecutive_failures": cat_adapter._consecutive_failures + "consecutive_failures": cat_adapter._consecutive_failures, + "consolidation": get_consolidation_status(), } diff --git a/bot/static/index.html b/bot/static/index.html index 1e0e5cc..815c75d 100644 --- a/bot/static/index.html +++ b/bot/static/index.html @@ -1034,6 +1034,7 @@

🌙 Memory Consolidation

Trigger the sleep consolidation process: analyzes episodic memories, extracts important facts, and removes trivial entries. +
⏰ Scheduled nightly at 4:00 AM UTC

+
diff --git a/bot/static/js/memories.js b/bot/static/js/memories.js index 7353347..39732c3 100644 --- a/bot/static/js/memories.js +++ b/bot/static/js/memories.js @@ -39,6 +39,30 @@ async function refreshMemoryStats() { document.getElementById('stat-declarative-count').textContent = '—'; document.getElementById('stat-procedural-count').textContent = '—'; } + + // Show consolidation schedule info if available + const consInfo = document.getElementById('consolidation-schedule-info'); + if (consInfo && statusData.consolidation) { + let infoHtml = ''; + const cons = statusData.consolidation; + if (cons.last_run) { + const lastRun = new Date(cons.last_run).toLocaleString(); + infoHtml += `🕐 Last run: ${lastRun}`; + if (cons.last_error) { + infoHtml += ` ❌ ${escapeHtml(cons.last_error)}`; + } else if (cons.last_result) { + infoHtml += ` `; + } + infoHtml += `
`; + } else { + infoHtml += `🕐 Last run: never
`; + } + infoHtml += `📊 Runs: ${cons.successful_runs}/${cons.total_runs} successful`; + if (cons.is_running) { + infoHtml += ` ⏳ (running now)`; + } + consInfo.innerHTML = infoHtml; + } } catch (err) { console.error('Error refreshing memory stats:', err); document.getElementById('cat-status-indicator').innerHTML = '● Error checking status'; diff --git a/bot/utils/consolidation_scheduler.py b/bot/utils/consolidation_scheduler.py new file mode 100644 index 0000000..2a12ffc --- /dev/null +++ b/bot/utils/consolidation_scheduler.py @@ -0,0 +1,105 @@ +# utils/consolidation_scheduler.py +""" +Nightly memory consolidation scheduler. + +Runs the Cheshire Cat memory consolidation pipeline on a fixed schedule +(4:00 AM UTC by default) to mimic human REM sleep consolidation: + +1. Query all unconsolidated episodic memories from Qdrant +2. Classify and delete trivial messages +3. Mark kept memories as consolidated +4. Extract declarative facts per user via LLM +5. Store facts in the declarative memory collection +""" + +import asyncio +import time +from datetime import datetime + +import globals +from utils.logger import get_logger + +logger = get_logger('consolidation') + +# Tracks the last consolidation run time and result +_last_consolidation = { + 'last_run': None, # ISO timestamp of last run + 'last_result': None, # Result string from Cat + 'last_error': None, # Error message if last run failed + 'is_running': False, # Whether consolidation is currently in progress + 'total_runs': 0, # Total number of consolidation runs + 'successful_runs': 0, # Number of successful runs +} + + +def get_consolidation_status() -> dict: + """Return the current consolidation status for the Web UI.""" + return { + 'last_run': _last_consolidation['last_run'], + 'last_result': _last_consolidation['last_result'], + 'last_error': _last_consolidation['last_error'], + 'is_running': _last_consolidation['is_running'], + 'total_runs': _last_consolidation['total_runs'], + 'successful_runs': _last_consolidation['successful_runs'], + } + + +async def run_nightly_consolidation(): + """ + Run the nightly memory consolidation process. + + This is the entry point called by APScheduler. It: + 1. Checks if Cheshire Cat is enabled and healthy + 2. Skips if consolidation is already running + 3. Sends a 'consolidate now' message via WebSocket to Cat + 4. Logs the result and updates tracking state + """ + # Prevent overlapping runs + if _last_consolidation['is_running']: + logger.warning("🌙 Consolidation already running, skipping scheduled run") + return + + if not globals.USE_CHESHIRE_CAT: + logger.info("🌙 Skipping scheduled consolidation: Cheshire Cat is disabled") + return + + _last_consolidation['is_running'] = True + _last_consolidation['last_run'] = datetime.now().isoformat() + start_time = time.time() + + try: + from utils.cat_client import cat_adapter + + # Check Cat health before attempting + if not await cat_adapter.health_check(): + logger.warning("🌙 Skipping scheduled consolidation: Cat is not healthy") + _last_consolidation['last_error'] = 'Cat health check failed' + _last_consolidation['is_running'] = False + _last_consolidation['total_runs'] += 1 + return + + logger.info("🌙 Starting nightly memory consolidation (scheduled)...") + + # Trigger consolidation via WebSocket + # This runs synchronously within the hook and can take several minutes + result = await cat_adapter.trigger_consolidation() + + elapsed = time.time() - start_time + + if result: + logger.info(f"🌙 Nightly consolidation completed in {elapsed:.1f}s: {result[:200]}") + _last_consolidation['last_result'] = result + _last_consolidation['last_error'] = None + _last_consolidation['successful_runs'] += 1 + else: + logger.error(f"🌙 Nightly consolidation returned no result after {elapsed:.1f}s") + _last_consolidation['last_error'] = 'No result returned (timeout or connection error)' + + except Exception as e: + elapsed = time.time() - start_time + logger.error(f"🌙 Nightly consolidation failed after {elapsed:.1f}s: {e}") + _last_consolidation['last_error'] = str(e) + + finally: + _last_consolidation['total_runs'] += 1 + _last_consolidation['is_running'] = False