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:
11
bot/bot.py
11
bot/bot.py
@@ -122,6 +122,17 @@ async def on_ready():
|
|||||||
else:
|
else:
|
||||||
logger.warning("OWNER_USER_ID not set, DM analysis feature disabled")
|
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 (now handled by server manager)
|
||||||
setup_autonomous_speaking()
|
setup_autonomous_speaking()
|
||||||
load_last_sent_tweets()
|
load_last_sent_tweets()
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/memory/status")
|
@router.get("/memory/status")
|
||||||
async def get_cat_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.cat_client import cat_adapter
|
||||||
|
from utils.consolidation_scheduler import get_consolidation_status
|
||||||
is_healthy = await cat_adapter.health_check()
|
is_healthy = await cat_adapter.health_check()
|
||||||
return {
|
return {
|
||||||
"enabled": globals.USE_CHESHIRE_CAT,
|
"enabled": globals.USE_CHESHIRE_CAT,
|
||||||
"healthy": is_healthy,
|
"healthy": is_healthy,
|
||||||
"url": globals.CHESHIRE_CAT_URL,
|
"url": globals.CHESHIRE_CAT_URL,
|
||||||
"circuit_breaker_active": cat_adapter._is_circuit_broken(),
|
"circuit_breaker_active": cat_adapter._is_circuit_broken(),
|
||||||
"consecutive_failures": cat_adapter._consecutive_failures
|
"consecutive_failures": cat_adapter._consecutive_failures,
|
||||||
|
"consolidation": get_consolidation_status(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1034,6 +1034,7 @@
|
|||||||
<h4 style="margin: 0 0 0.5rem 0;">🌙 Memory Consolidation</h4>
|
<h4 style="margin: 0 0 0.5rem 0;">🌙 Memory Consolidation</h4>
|
||||||
<p style="color: #aaa; font-size: 0.85rem; margin-bottom: 0.75rem;">
|
<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.
|
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>
|
</p>
|
||||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
<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;">
|
<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>
|
</button>
|
||||||
<span id="consolidation-status" style="color: #888; font-size: 0.85rem;"></span>
|
<span id="consolidation-status" style="color: #888; font-size: 0.85rem;"></span>
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,30 @@ async function refreshMemoryStats() {
|
|||||||
document.getElementById('stat-declarative-count').textContent = '—';
|
document.getElementById('stat-declarative-count').textContent = '—';
|
||||||
document.getElementById('stat-procedural-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) {
|
} catch (err) {
|
||||||
console.error('Error refreshing memory stats:', err);
|
console.error('Error refreshing memory stats:', err);
|
||||||
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';
|
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';
|
||||||
|
|||||||
105
bot/utils/consolidation_scheduler.py
Normal file
105
bot/utils/consolidation_scheduler.py
Normal 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
|
||||||
Reference in New Issue
Block a user