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.
This commit is contained in:
2026-05-15 13:54:54 +03:00
parent 811bcc0a5d
commit f3c4a8fe5a
5 changed files with 146 additions and 2 deletions

View File

@@ -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()

View File

@@ -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(),
}

View File

@@ -1034,6 +1034,7 @@
<h4 style="margin: 0 0 0.5rem 0;">🌙 Memory Consolidation</h4>
<p style="color: #aaa; font-size: 0.85rem; margin-bottom: 0.75rem;">
Trigger the sleep consolidation process: analyzes episodic memories, extracts important facts, and removes trivial entries.
<br><span style="color: #888;">⏰ Scheduled nightly at 4:00 AM UTC</span>
</p>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button id="consolidate-btn" onclick="triggerConsolidation()" style="background: #5b3a8c; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
@@ -1041,6 +1042,7 @@
</button>
<span id="consolidation-status" style="color: #888; font-size: 0.85rem;"></span>
</div>
<div id="consolidation-schedule-info" style="margin-top: 0.5rem; color: #666; font-size: 0.78rem;"></div>
<div id="consolidation-result" style="display: none; margin-top: 0.75rem; background: #111; border: 1px solid #333; border-radius: 4px; padding: 0.75rem; font-size: 0.85rem; color: #ccc; white-space: pre-wrap; max-height: 200px; overflow-y: auto;"></div>
</div>

View File

@@ -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 += ` <span style="color: #ff6b6b;">❌ ${escapeHtml(cons.last_error)}</span>`;
} else if (cons.last_result) {
infoHtml += ` <span style="color: #6fdc6f;">✅</span>`;
}
infoHtml += `<br>`;
} else {
infoHtml += `🕐 Last run: never<br>`;
}
infoHtml += `📊 Runs: ${cons.successful_runs}/${cons.total_runs} successful`;
if (cons.is_running) {
infoHtml += ` <span style="color: #dcb06f;">⏳ (running now)</span>`;
}
consInfo.innerHTML = infoHtml;
}
} catch (err) {
console.error('Error refreshing memory stats:', err);
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';

View File

@@ -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