Crucix — agent with dashboard, delta engine, Telegram/Discord bots
This commit is contained in:
244
server.mjs
244
server.mjs
@@ -14,6 +14,7 @@ import { MemoryManager } from './lib/delta/index.mjs';
|
||||
import { createLLMProvider } from './lib/llm/index.mjs';
|
||||
import { generateLLMIdeas } from './lib/llm/ideas.mjs';
|
||||
import { TelegramAlerter } from './lib/alerts/telegram.mjs';
|
||||
import { DiscordAlerter } from './lib/alerts/discord.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = __dirname;
|
||||
@@ -35,12 +36,195 @@ const sseClients = new Set();
|
||||
// === Delta/Memory ===
|
||||
const memory = new MemoryManager(RUNS_DIR);
|
||||
|
||||
// === LLM + Telegram ===
|
||||
// === LLM + Telegram + Discord ===
|
||||
const llmProvider = createLLMProvider(config.llm);
|
||||
const telegramAlerter = new TelegramAlerter(config.telegram);
|
||||
const discordAlerter = new DiscordAlerter(config.discord || {});
|
||||
|
||||
if (llmProvider) console.log(`[Crucix] LLM enabled: ${llmProvider.name} (${llmProvider.model})`);
|
||||
if (telegramAlerter.isConfigured) console.log('[Crucix] Telegram alerts enabled');
|
||||
if (telegramAlerter.isConfigured) {
|
||||
console.log('[Crucix] Telegram alerts enabled');
|
||||
|
||||
// ─── Two-Way Bot Commands ───────────────────────────────────────────────
|
||||
|
||||
telegramAlerter.onCommand('/status', async () => {
|
||||
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
||||
const h = Math.floor(uptime / 3600);
|
||||
const m = Math.floor((uptime % 3600) / 60);
|
||||
const sourcesOk = currentData?.meta?.sourcesOk || 0;
|
||||
const sourcesTotal = currentData?.meta?.sourcesQueried || 0;
|
||||
const sourcesFailed = currentData?.meta?.sourcesFailed || 0;
|
||||
const llmStatus = llmProvider?.isConfigured ? `✅ ${llmProvider.name}` : '❌ Disabled';
|
||||
const nextSweep = lastSweepTime
|
||||
? new Date(new Date(lastSweepTime).getTime() + config.refreshIntervalMinutes * 60000).toLocaleTimeString()
|
||||
: 'pending';
|
||||
|
||||
return [
|
||||
`🖥️ *CRUCIX STATUS*`,
|
||||
``,
|
||||
`Uptime: ${h}h ${m}m`,
|
||||
`Last sweep: ${lastSweepTime ? new Date(lastSweepTime).toLocaleTimeString() + ' UTC' : 'never'}`,
|
||||
`Next sweep: ${nextSweep} UTC`,
|
||||
`Sweep in progress: ${sweepInProgress ? '🔄 Yes' : '⏸️ No'}`,
|
||||
`Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`,
|
||||
`LLM: ${llmStatus}`,
|
||||
`SSE clients: ${sseClients.size}`,
|
||||
`Dashboard: http://localhost:${config.port}`,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
telegramAlerter.onCommand('/sweep', async () => {
|
||||
if (sweepInProgress) return '🔄 Sweep already in progress. Please wait.';
|
||||
// Fire and forget — don't block the bot response
|
||||
runSweepCycle().catch(err => console.error('[Crucix] Manual sweep failed:', err.message));
|
||||
return '🚀 Manual sweep triggered. You\'ll receive alerts if anything significant is detected.';
|
||||
});
|
||||
|
||||
telegramAlerter.onCommand('/brief', async () => {
|
||||
if (!currentData) return '⏳ No data yet — waiting for first sweep to complete.';
|
||||
|
||||
const tg = currentData.tg || {};
|
||||
const energy = currentData.energy || {};
|
||||
const delta = memory.getLastDelta();
|
||||
const ideas = (currentData.ideas || []).slice(0, 3);
|
||||
|
||||
const sections = [
|
||||
`📋 *CRUCIX BRIEF*`,
|
||||
`_${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC_`,
|
||||
``,
|
||||
];
|
||||
|
||||
// Delta direction
|
||||
if (delta?.summary) {
|
||||
const dirEmoji = { 'risk-off': '📉', 'risk-on': '📈', 'mixed': '↔️' }[delta.summary.direction] || '↔️';
|
||||
sections.push(`${dirEmoji} Direction: *${delta.summary.direction.toUpperCase()}* | ${delta.summary.totalChanges} changes, ${delta.summary.criticalChanges} critical`);
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
// Key metrics
|
||||
const vix = currentData.fred?.find(f => f.id === 'VIXCLS');
|
||||
const hy = currentData.fred?.find(f => f.id === 'BAMLH0A0HYM2');
|
||||
if (vix || energy.wti) {
|
||||
sections.push(`📊 VIX: ${vix?.value || '--'} | WTI: $${energy.wti || '--'} | Brent: $${energy.brent || '--'}`);
|
||||
if (hy) sections.push(` HY Spread: ${hy.value} | NatGas: $${energy.natgas || '--'}`);
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
// OSINT
|
||||
if (tg.urgent?.length > 0) {
|
||||
sections.push(`📡 OSINT: ${tg.urgent.length} urgent signals, ${tg.posts || 0} total posts`);
|
||||
// Top 2 urgent
|
||||
for (const p of tg.urgent.slice(0, 2)) {
|
||||
sections.push(` • ${(p.text || '').substring(0, 80)}`);
|
||||
}
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
// Top ideas
|
||||
if (ideas.length > 0) {
|
||||
sections.push(`💡 *Top Ideas:*`);
|
||||
for (const idea of ideas) {
|
||||
sections.push(` ${idea.type === 'long' ? '📈' : idea.type === 'hedge' ? '🛡️' : '👁️'} ${idea.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
});
|
||||
|
||||
telegramAlerter.onCommand('/portfolio', async () => {
|
||||
return '📊 Portfolio integration requires Alpaca MCP connection.\nUse the Crucix dashboard or Claude agent for portfolio queries.';
|
||||
});
|
||||
|
||||
// Start polling for bot commands
|
||||
telegramAlerter.startPolling(config.telegram.botPollingInterval);
|
||||
}
|
||||
|
||||
// === Discord Bot ===
|
||||
if (discordAlerter.isConfigured) {
|
||||
console.log('[Crucix] Discord bot enabled');
|
||||
|
||||
// Reuse the same command handlers as Telegram (DRY)
|
||||
discordAlerter.onCommand('status', async () => {
|
||||
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
||||
const h = Math.floor(uptime / 3600);
|
||||
const m = Math.floor((uptime % 3600) / 60);
|
||||
const sourcesOk = currentData?.meta?.sourcesOk || 0;
|
||||
const sourcesTotal = currentData?.meta?.sourcesQueried || 0;
|
||||
const sourcesFailed = currentData?.meta?.sourcesFailed || 0;
|
||||
const llmStatus = llmProvider?.isConfigured ? `✅ ${llmProvider.name}` : '❌ Disabled';
|
||||
const nextSweep = lastSweepTime
|
||||
? new Date(new Date(lastSweepTime).getTime() + config.refreshIntervalMinutes * 60000).toLocaleTimeString()
|
||||
: 'pending';
|
||||
|
||||
return [
|
||||
`**🖥️ CRUCIX STATUS**\n`,
|
||||
`Uptime: ${h}h ${m}m`,
|
||||
`Last sweep: ${lastSweepTime ? new Date(lastSweepTime).toLocaleTimeString() + ' UTC' : 'never'}`,
|
||||
`Next sweep: ${nextSweep} UTC`,
|
||||
`Sweep in progress: ${sweepInProgress ? '🔄 Yes' : '⏸️ No'}`,
|
||||
`Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`,
|
||||
`LLM: ${llmStatus}`,
|
||||
`SSE clients: ${sseClients.size}`,
|
||||
`Dashboard: http://localhost:${config.port}`,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
discordAlerter.onCommand('sweep', async () => {
|
||||
if (sweepInProgress) return '🔄 Sweep already in progress. Please wait.';
|
||||
runSweepCycle().catch(err => console.error('[Crucix] Manual sweep failed:', err.message));
|
||||
return '🚀 Manual sweep triggered. You\'ll receive alerts if anything significant is detected.';
|
||||
});
|
||||
|
||||
discordAlerter.onCommand('brief', async () => {
|
||||
if (!currentData) return '⏳ No data yet — waiting for first sweep to complete.';
|
||||
|
||||
const tg = currentData.tg || {};
|
||||
const energy = currentData.energy || {};
|
||||
const delta = memory.getLastDelta();
|
||||
const ideas = (currentData.ideas || []).slice(0, 3);
|
||||
|
||||
const sections = [`**📋 CRUCIX BRIEF**\n_${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC_\n`];
|
||||
|
||||
if (delta?.summary) {
|
||||
const dirEmoji = { 'risk-off': '📉', 'risk-on': '📈', 'mixed': '↔️' }[delta.summary.direction] || '↔️';
|
||||
sections.push(`${dirEmoji} Direction: **${delta.summary.direction.toUpperCase()}** | ${delta.summary.totalChanges} changes, ${delta.summary.criticalChanges} critical\n`);
|
||||
}
|
||||
|
||||
const vix = currentData.fred?.find(f => f.id === 'VIXCLS');
|
||||
const hy = currentData.fred?.find(f => f.id === 'BAMLH0A0HYM2');
|
||||
if (vix || energy.wti) {
|
||||
sections.push(`📊 VIX: ${vix?.value || '--'} | WTI: $${energy.wti || '--'} | Brent: $${energy.brent || '--'}`);
|
||||
if (hy) sections.push(` HY Spread: ${hy.value} | NatGas: $${energy.natgas || '--'}`);
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
if (tg.urgent?.length > 0) {
|
||||
sections.push(`📡 OSINT: ${tg.urgent.length} urgent signals, ${tg.posts || 0} total posts`);
|
||||
for (const p of tg.urgent.slice(0, 2)) {
|
||||
sections.push(` • ${(p.text || '').substring(0, 80)}`);
|
||||
}
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
if (ideas.length > 0) {
|
||||
sections.push(`**💡 Top Ideas:**`);
|
||||
for (const idea of ideas) {
|
||||
sections.push(` ${idea.type === 'long' ? '📈' : idea.type === 'hedge' ? '🛡️' : '👁️'} ${idea.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
});
|
||||
|
||||
discordAlerter.onCommand('portfolio', async () => {
|
||||
return '📊 Portfolio integration requires Alpaca MCP connection.\nUse the Crucix dashboard or Claude agent for portfolio queries.';
|
||||
});
|
||||
|
||||
// Start the Discord bot (non-blocking — connection happens async)
|
||||
discordAlerter.start().catch(err => {
|
||||
console.error('[Crucix] Discord bot startup failed (non-fatal):', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// === Express Server ===
|
||||
const app = express();
|
||||
@@ -149,11 +333,18 @@ async function runSweepCycle() {
|
||||
synthesized.ideasSource = 'disabled';
|
||||
}
|
||||
|
||||
// 6. Telegram alert evaluation (LLM-gated)
|
||||
if (telegramAlerter.isConfigured && llmProvider?.isConfigured && delta?.summary?.criticalChanges > 0) {
|
||||
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
|
||||
console.error('[Crucix] Telegram alert error:', err.message);
|
||||
});
|
||||
// 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup)
|
||||
if (delta?.summary?.totalChanges > 0) {
|
||||
if (telegramAlerter.isConfigured) {
|
||||
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
|
||||
console.error('[Crucix] Telegram alert error:', err.message);
|
||||
});
|
||||
}
|
||||
if (discordAlerter.isConfigured) {
|
||||
discordAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
|
||||
console.error('[Crucix] Discord alert error:', err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prune old alerted signals
|
||||
@@ -190,15 +381,33 @@ async function start() {
|
||||
║ Health: http://localhost:${port}/api/health${' '.repeat(4 - String(port).length)}║
|
||||
║ Refresh: Every ${config.refreshIntervalMinutes} min${' '.repeat(20 - String(config.refreshIntervalMinutes).length)}║
|
||||
║ LLM: ${(config.llm.provider || 'disabled').padEnd(31)}║
|
||||
║ Alerts: ${config.telegram.botToken ? 'Telegram enabled' : 'disabled'}${' '.repeat(config.telegram.botToken ? 14 : 23)}║
|
||||
║ Telegram: ${config.telegram.botToken ? 'enabled' : 'disabled'}${' '.repeat(config.telegram.botToken ? 24 : 23)}║
|
||||
║ Discord: ${config.discord?.botToken ? 'enabled' : config.discord?.webhookUrl ? 'webhook only' : 'disabled'}${' '.repeat(config.discord?.botToken ? 24 : config.discord?.webhookUrl ? 20 : 23)}║
|
||||
╚══════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
app.listen(port, () => {
|
||||
const server = app.listen(port);
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`\n[Crucix] FATAL: Port ${port} is already in use!`);
|
||||
console.error(`[Crucix] A previous Crucix instance may still be running.`);
|
||||
console.error(`[Crucix] Fix: taskkill /F /IM node.exe (Windows)`);
|
||||
console.error(`[Crucix] kill $(lsof -ti:${port}) (macOS/Linux)`);
|
||||
console.error(`[Crucix] Or change PORT in .env\n`);
|
||||
} else {
|
||||
console.error(`[Crucix] Server error:`, err.stack || err.message);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
console.log(`[Crucix] Server running on http://localhost:${port}`);
|
||||
|
||||
// Auto-open browser
|
||||
const openCmd = process.platform === 'win32' ? 'start ""' :
|
||||
// NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start.
|
||||
// We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell.
|
||||
const openCmd = process.platform === 'win32' ? 'cmd /c start ""' :
|
||||
process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||
exec(`${openCmd} "http://localhost:${port}"`, (err) => {
|
||||
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
|
||||
@@ -216,19 +425,24 @@ async function start() {
|
||||
|
||||
// Run first sweep
|
||||
console.log('[Crucix] Running initial sweep...');
|
||||
runSweepCycle();
|
||||
runSweepCycle().catch(err => {
|
||||
console.error('[Crucix] Initial sweep failed:', err.message || err);
|
||||
});
|
||||
|
||||
// Schedule recurring sweeps
|
||||
setInterval(runSweepCycle, config.refreshIntervalMinutes * 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful error handling
|
||||
// Graceful error handling — log full stack traces for diagnosis
|
||||
process.on('unhandledRejection', (err) => {
|
||||
console.error('[Crucix] Unhandled rejection:', err.message || err);
|
||||
console.error('[Crucix] Unhandled rejection:', err?.stack || err?.message || err);
|
||||
});
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[Crucix] Uncaught exception:', err.message || err);
|
||||
console.error('[Crucix] Uncaught exception:', err?.stack || err?.message || err);
|
||||
});
|
||||
|
||||
start();
|
||||
start().catch(err => {
|
||||
console.error('[Crucix] FATAL — Server failed to start:', err?.stack || err?.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user