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