OSINT Stream
${D.tg.urgent.length} URGENT
diff --git a/diag.mjs b/diag.mjs
new file mode 100644
index 0000000..c61b65f
--- /dev/null
+++ b/diag.mjs
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+// Crucix Diagnostic — run this to find out why server.mjs fails silently
+// Usage: node diag.mjs
+
+console.log('=== CRUCIX DIAGNOSTICS ===\n');
+console.log('Node version:', process.version);
+console.log('Platform:', process.platform, process.arch);
+console.log('CWD:', process.cwd());
+console.log('');
+
+// Step 1: Check Node version
+const major = parseInt(process.version.slice(1));
+if (major < 22) {
+ console.error('❌ Node.js >= 22 required, you have', process.version);
+ console.error(' Download: https://nodejs.org/');
+ process.exit(1);
+}
+console.log('✅ Node version OK');
+
+// Step 2: Check express
+try {
+ await import('express');
+ console.log('✅ express imported OK');
+} catch (err) {
+ console.error('❌ express import failed:', err.message);
+ console.error(' Run: npm install');
+ process.exit(1);
+}
+
+// Step 3: Check crypto (used by delta engine)
+try {
+ const { createHash } = await import('crypto');
+ createHash('sha256').update('test').digest('hex');
+ console.log('✅ crypto OK');
+} catch (err) {
+ console.error('❌ crypto failed:', err.message);
+ process.exit(1);
+}
+
+// Step 4: Check each local module
+const modules = [
+ ['./crucix.config.mjs', 'config'],
+ ['./apis/utils/env.mjs', 'env loader'],
+ ['./lib/delta/engine.mjs', 'delta engine'],
+ ['./lib/delta/memory.mjs', 'memory manager'],
+ ['./lib/delta/index.mjs', 'delta index'],
+ ['./lib/llm/index.mjs', 'LLM factory'],
+ ['./lib/llm/ideas.mjs', 'LLM ideas'],
+ ['./lib/alerts/telegram.mjs', 'telegram alerter'],
+ ['./dashboard/inject.mjs', 'dashboard inject'],
+ ['./apis/briefing.mjs', 'briefing orchestrator'],
+];
+
+for (const [path, label] of modules) {
+ try {
+ await import(path);
+ console.log(`✅ ${label} (${path})`);
+ } catch (err) {
+ console.error(`❌ ${label} FAILED: ${err.message}`);
+ if (err.stack) console.error(' ', err.stack.split('\n').slice(1, 3).join('\n '));
+ }
+}
+
+// Step 5: Check port availability
+console.log('');
+const net = await import('net');
+const port = 3117;
+const server = net.default.createServer();
+try {
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(port, () => { server.close(); resolve(); });
+ });
+ console.log(`✅ Port ${port} is available`);
+} catch (err) {
+ if (err.code === 'EADDRINUSE') {
+ console.error(`❌ Port ${port} is already in use!`);
+ console.error(' A previous Crucix instance may still be running.');
+ console.error(' Fix: taskkill /F /IM node.exe (kills all Node processes)');
+ console.error(' Or: npx kill-port 3117');
+ } else {
+ console.error(`❌ Port ${port} error:`, err.message);
+ }
+}
+
+// Step 6: Try full server import
+console.log('\n--- Attempting full server import ---');
+try {
+ await import('./server.mjs');
+ console.log('✅ server.mjs loaded and running');
+} catch (err) {
+ console.error('❌ server.mjs CRASHED:', err.message);
+ console.error(err.stack);
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..2724ce4
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,11 @@
+version: '3.8'
+services:
+ crucix:
+ build: .
+ ports:
+ - "${PORT:-3117}:${PORT:-3117}"
+ env_file:
+ - .env
+ volumes:
+ - ./runs:/app/runs
+ restart: unless-stopped
diff --git a/docs/boot.png b/docs/boot.png
new file mode 100644
index 0000000..04c2911
Binary files /dev/null and b/docs/boot.png differ
diff --git a/docs/dashboard.png b/docs/dashboard.png
new file mode 100644
index 0000000..e76cc0a
Binary files /dev/null and b/docs/dashboard.png differ
diff --git a/docs/map.png b/docs/map.png
new file mode 100644
index 0000000..8f8ab98
Binary files /dev/null and b/docs/map.png differ
diff --git a/lib/alerts/discord.mjs b/lib/alerts/discord.mjs
new file mode 100644
index 0000000..24b9969
--- /dev/null
+++ b/lib/alerts/discord.mjs
@@ -0,0 +1,549 @@
+// Discord Alerter — Multi-tier alerts + slash commands via discord.js
+// Mirrors TelegramAlerter architecture: same eval logic, same tier system, same dedup
+
+import { createHash } from 'crypto';
+
+// ─── Alert Tiers (shared with Telegram) ─────────────────────────────────────
+
+const TIER_CONFIG = {
+ FLASH: { color: 0xFF0000, label: 'FLASH', cooldownMs: 5 * 60 * 1000, maxPerHour: 6 },
+ PRIORITY: { color: 0xFFAA00, label: 'PRIORITY', cooldownMs: 30 * 60 * 1000, maxPerHour: 4 },
+ ROUTINE: { color: 0x3498DB, label: 'ROUTINE', cooldownMs: 60 * 60 * 1000, maxPerHour: 2 },
+};
+
+// Slash command definitions for Discord's API
+const SLASH_COMMANDS = [
+ { name: 'status', description: 'System health, last sweep time, source status' },
+ { name: 'sweep', description: 'Trigger a manual sweep cycle' },
+ { name: 'brief', description: 'Compact intelligence summary' },
+ { name: 'portfolio', description: 'Portfolio status (if Alpaca connected)' },
+ { name: 'alerts', description: 'Recent alert history' },
+ { name: 'mute', description: 'Mute alerts (default 1h)',
+ options: [{ name: 'hours', description: 'Hours to mute (default: 1)', type: 10, required: false }] },
+ { name: 'unmute', description: 'Resume alerts' },
+];
+
+export class DiscordAlerter {
+ constructor({ botToken, channelId, guildId, webhookUrl }) {
+ this.botToken = botToken;
+ this.channelId = channelId;
+ this.guildId = guildId; // Server ID for slash command registration
+ this.webhookUrl = webhookUrl; // Fallback: webhook-only mode (no bot needed)
+ this._client = null;
+ this._alertHistory = [];
+ this._contentHashes = {};
+ this._muteUntil = null;
+ this._commandHandlers = {};
+ this._ready = false;
+ }
+
+ get isConfigured() {
+ return !!(this.botToken && this.channelId) || !!this.webhookUrl;
+ }
+
+ // ─── Bot Lifecycle ──────────────────────────────────────────────────────
+
+ /**
+ * Start the Discord bot. Connects to the gateway, registers slash commands,
+ * and begins listening for interactions.
+ */
+ async start() {
+ if (!this.isConfigured) return;
+
+ try {
+ // Dynamic import — discord.js is optional, only loaded if configured
+ const { Client, GatewayIntentBits, REST, Routes, EmbedBuilder, SlashCommandBuilder } = await import('discord.js');
+ this._EmbedBuilder = EmbedBuilder;
+
+ this._client = new Client({
+ intents: [GatewayIntentBits.Guilds],
+ });
+
+ // Register slash commands
+ await this._registerCommands(REST, Routes, SlashCommandBuilder);
+
+ // Handle slash command interactions
+ this._client.on('interactionCreate', async (interaction) => {
+ if (!interaction.isChatInputCommand()) return;
+ await this._handleCommand(interaction);
+ });
+
+ // Connect
+ await this._client.login(this.botToken);
+
+ this._client.once('ready', () => {
+ this._ready = true;
+ console.log(`[Discord] Bot online as ${this._client.user.tag}`);
+ });
+
+ } catch (err) {
+ if (err.code === 'MODULE_NOT_FOUND' || err.message?.includes('Cannot find')) {
+ console.warn('[Discord] discord.js not installed. Run: npm install discord.js');
+ console.warn('[Discord] Falling back to webhook-only mode (if DISCORD_WEBHOOK_URL is set).');
+ } else {
+ console.error('[Discord] Failed to start bot:', err.message);
+ }
+ }
+ }
+
+ /**
+ * Stop the bot gracefully.
+ */
+ async stop() {
+ if (this._client) {
+ this._client.destroy();
+ this._client = null;
+ this._ready = false;
+ console.log('[Discord] Bot disconnected');
+ }
+ }
+
+ // ─── Slash Command Registration ─────────────────────────────────────────
+
+ async _registerCommands(REST, Routes, SlashCommandBuilder) {
+ const rest = new REST({ version: '10' }).setToken(this.botToken);
+
+ const commands = SLASH_COMMANDS.map(cmd => {
+ const builder = new SlashCommandBuilder()
+ .setName(cmd.name)
+ .setDescription(cmd.description);
+
+ if (cmd.options) {
+ for (const opt of cmd.options) {
+ if (opt.type === 10) { // NUMBER
+ builder.addNumberOption(o =>
+ o.setName(opt.name).setDescription(opt.description).setRequired(opt.required ?? false)
+ );
+ }
+ }
+ }
+ return builder.toJSON();
+ });
+
+ try {
+ if (this.guildId) {
+ // Guild commands (instant, for development)
+ await rest.put(Routes.applicationGuildCommands(this._client?.user?.id || 'me', this.guildId), { body: commands });
+ console.log(`[Discord] Registered ${commands.length} guild slash commands`);
+ } else {
+ // Global commands (can take up to 1h to propagate)
+ const appId = this._client?.application?.id;
+ if (appId) {
+ await rest.put(Routes.applicationCommands(appId), { body: commands });
+ console.log(`[Discord] Registered ${commands.length} global slash commands`);
+ }
+ }
+ } catch (err) {
+ console.error('[Discord] Failed to register slash commands:', err.message);
+ }
+ }
+
+ // ─── Command Handling ───────────────────────────────────────────────────
+
+ /**
+ * Register a command handler.
+ * @param {string} name - command name (without /)
+ * @param {Function} handler - async (args) => responseText
+ */
+ onCommand(name, handler) {
+ this._commandHandlers[name.toLowerCase()] = handler;
+ }
+
+ async _handleCommand(interaction) {
+ const name = interaction.commandName;
+
+ // Built-in commands
+ if (name === 'mute') {
+ const hours = interaction.options.getNumber('hours') || 1;
+ this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
+ await interaction.reply({
+ embeds: [this._embed('Alerts Muted', `Alerts silenced for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC.\nUse \`/unmute\` to resume.`, 0x95A5A6)],
+ ephemeral: true,
+ });
+ return;
+ }
+
+ if (name === 'unmute') {
+ this._muteUntil = null;
+ await interaction.reply({
+ embeds: [this._embed('Alerts Resumed', 'You will receive the next signal evaluation.', 0x2ECC71)],
+ ephemeral: true,
+ });
+ return;
+ }
+
+ if (name === 'alerts') {
+ const recent = this._alertHistory.slice(-10);
+ if (recent.length === 0) {
+ await interaction.reply({ content: 'No recent alerts.', ephemeral: true });
+ return;
+ }
+ const tierEmoji = { FLASH: '🔴', PRIORITY: '🟡', ROUTINE: '🔵' };
+ const lines = recent.map(a =>
+ `${tierEmoji[a.tier] || '⚪'} **${a.tier}** — ${new Date(a.timestamp).toLocaleTimeString()}`
+ );
+ await interaction.reply({
+ embeds: [this._embed(`Recent Alerts (${recent.length})`, lines.join('\n'), 0x3498DB)],
+ ephemeral: true,
+ });
+ return;
+ }
+
+ // Delegate to registered handlers
+ const handler = this._commandHandlers[name];
+ if (handler) {
+ await interaction.deferReply({ ephemeral: true });
+ try {
+ const args = interaction.options.getString('input') || '';
+ const response = await handler(args);
+ if (response) {
+ // If response is long, send as embed; otherwise plain text
+ if (response.length > 200) {
+ await interaction.editReply({ embeds: [this._embed('Crucix', response, 0x00E5FF)] });
+ } else {
+ await interaction.editReply({ content: response });
+ }
+ } else {
+ await interaction.editReply({ content: 'Done.' });
+ }
+ } catch (err) {
+ console.error(`[Discord] Command /${name} error:`, err.message);
+ await interaction.editReply({ content: `Command failed: ${err.message}` });
+ }
+ } else {
+ await interaction.reply({ content: `Unknown command: /${name}`, ephemeral: true });
+ }
+ }
+
+ // ─── Sending Messages ───────────────────────────────────────────────────
+
+ /**
+ * Send a message to the configured channel.
+ * Works with the bot client or falls back to webhook URL.
+ */
+ async sendMessage(content, embeds = []) {
+ if (!this.isConfigured) return false;
+
+ // Try bot client first
+ if (this._ready && this._client) {
+ try {
+ const channel = await this._client.channels.fetch(this.channelId);
+ if (channel) {
+ await channel.send({ content: content || undefined, embeds });
+ return true;
+ }
+ } catch (err) {
+ console.error('[Discord] Send via bot failed:', err.message);
+ }
+ }
+
+ // Fallback: webhook URL
+ if (this.webhookUrl) {
+ return this._sendWebhook(this.webhookUrl, content, embeds);
+ }
+
+ console.warn('[Discord] Cannot send — bot not ready and no webhook URL configured');
+ return false;
+ }
+
+ async _sendWebhook(url, content, embeds) {
+ try {
+ const body = {};
+ if (content) body.content = content;
+ if (embeds?.length > 0) {
+ body.embeds = embeds.map(e => e.toJSON ? e.toJSON() : e);
+ }
+
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(15000),
+ });
+
+ if (!res.ok) {
+ const err = await res.text().catch(() => '');
+ console.error(`[Discord] Webhook failed (${res.status}): ${err.substring(0, 200)}`);
+ return false;
+ }
+ return true;
+ } catch (err) {
+ console.error('[Discord] Webhook error:', err.message);
+ return false;
+ }
+ }
+
+ // Backward-compatible alias
+ async sendAlert(message) {
+ return this.sendMessage(message);
+ }
+
+ // ─── Multi-Tier Alert Evaluation ────────────────────────────────────────
+ // Identical logic to TelegramAlerter — shared eval pipeline
+
+ async evaluateAndAlert(llmProvider, delta, memory) {
+ if (!this.isConfigured) return false;
+ if (!delta?.summary?.totalChanges) return false;
+ if (this._isMuted()) {
+ console.log('[Discord] Alerts muted until', new Date(this._muteUntil).toLocaleTimeString());
+ return false;
+ }
+
+ const allSignals = [
+ ...(delta.signals?.new || []),
+ ...(delta.signals?.escalated || []),
+ ];
+
+ const newSignals = allSignals.filter(s => {
+ const key = this._signalKey(s);
+ if (typeof memory.isSignalSuppressed === 'function') {
+ if (memory.isSignalSuppressed(key)) return false;
+ } else {
+ const alerted = memory.getAlertedSignals();
+ if (alerted[key]) return false;
+ }
+ if (this._isSemanticDuplicate(s)) return false;
+ return true;
+ });
+
+ if (newSignals.length === 0) return false;
+
+ // LLM evaluation with rule-based fallback (reuse from Telegram)
+ let evaluation = null;
+
+ if (llmProvider?.isConfigured) {
+ try {
+ const { TelegramAlerter } = await import('./telegram.mjs');
+ const tgInstance = new TelegramAlerter({ botToken: null, chatId: null });
+ const systemPrompt = tgInstance._buildEvaluationPrompt();
+ const userMessage = tgInstance._buildSignalContext(newSignals, delta);
+ const result = await llmProvider.complete(systemPrompt, userMessage, { maxTokens: 800, timeout: 30000 });
+ evaluation = parseJSON(result.text);
+ } catch (err) {
+ console.warn('[Discord] LLM evaluation failed, falling back to rules:', err.message);
+ }
+ }
+
+ if (!evaluation || typeof evaluation.shouldAlert !== 'boolean') {
+ evaluation = this._ruleBasedEvaluation(newSignals, delta);
+ if (evaluation) evaluation._source = 'rules';
+ }
+
+ if (!evaluation?.shouldAlert) {
+ console.log('[Discord] No alert —', evaluation?.reason || 'no qualifying signals');
+ return false;
+ }
+
+ const tier = TIER_CONFIG[evaluation.tier] ? evaluation.tier : 'ROUTINE';
+ if (!this._checkRateLimit(tier)) {
+ console.log(`[Discord] Rate limited for tier ${tier}`);
+ return false;
+ }
+
+ // Build Discord embed
+ const embed = this._buildAlertEmbed(evaluation, delta, tier);
+ const sent = await this.sendMessage(null, [embed]);
+
+ if (sent) {
+ for (const s of newSignals) {
+ const key = this._signalKey(s);
+ memory.markAsAlerted(key, new Date().toISOString());
+ this._recordContentHash(s);
+ }
+ this._recordAlert(tier);
+ console.log(`[Discord] ${tier} alert sent (${evaluation._source || 'llm'}): ${evaluation.headline}`);
+ }
+
+ return sent;
+ }
+
+ // ─── Discord-Native Rich Embed Formatting ───────────────────────────────
+
+ _buildAlertEmbed(evaluation, delta, tier) {
+ const tc = TIER_CONFIG[tier];
+ const tierEmoji = { FLASH: '🔴', PRIORITY: '🟡', ROUTINE: '🔵' }[tier] || '⚪';
+ const confidenceEmoji = { HIGH: '🟢', MEDIUM: '🟡', LOW: '⚪' }[evaluation.confidence] || '⚪';
+
+ const embed = this._embed(
+ `${tierEmoji} CRUCIX ${tc.label}`,
+ `**${evaluation.headline}**\n\n${evaluation.reason}`,
+ tc.color
+ );
+
+ // Add fields
+ const fields = [
+ { name: 'Direction', value: delta.summary.direction.toUpperCase(), inline: true },
+ { name: 'Confidence', value: `${confidenceEmoji} ${evaluation.confidence || 'MEDIUM'}`, inline: true },
+ ];
+
+ if (evaluation.crossCorrelation) {
+ fields.push({ name: 'Cross-Correlation', value: evaluation.crossCorrelation, inline: true });
+ }
+
+ if (evaluation.actionable && evaluation.actionable !== 'Monitor') {
+ fields.push({ name: '💡 Action', value: evaluation.actionable, inline: false });
+ }
+
+ if (evaluation.signals?.length) {
+ fields.push({ name: 'Signals', value: evaluation.signals.join(' · '), inline: false });
+ }
+
+ // discord.js EmbedBuilder style
+ if (embed.setFields) {
+ embed.setFields(fields);
+ embed.setFooter({ text: `Crucix Intelligence · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` });
+ } else {
+ // Raw embed object for webhook fallback
+ embed.fields = fields;
+ embed.footer = { text: `Crucix Intelligence · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` };
+ }
+
+ return embed;
+ }
+
+ /**
+ * Create a simple embed. Returns EmbedBuilder if available, otherwise raw object.
+ */
+ _embed(title, description, color) {
+ if (this._EmbedBuilder) {
+ return new this._EmbedBuilder()
+ .setTitle(title)
+ .setDescription(description)
+ .setColor(color)
+ .setTimestamp();
+ }
+ // Raw embed for webhook mode (no discord.js loaded)
+ return {
+ title,
+ description,
+ color,
+ timestamp: new Date().toISOString(),
+ };
+ }
+
+ // ─── Rule-Based Fallback (same logic as Telegram) ───────────────────────
+
+ _ruleBasedEvaluation(signals, delta) {
+ const criticals = signals.filter(s => s.severity === 'critical');
+ const highs = signals.filter(s => s.severity === 'high');
+ const nukeSignal = signals.find(s => s.key === 'nuke_anomaly');
+ const osintNew = signals.filter(s => s.key?.startsWith('tg_urgent'));
+ const marketSignals = signals.filter(s => ['vix', 'hy_spread', 'wti', 'brent', '10y2y'].includes(s.key));
+ const conflictSignals = signals.filter(s => ['conflict_events', 'conflict_fatalities', 'thermal_total'].includes(s.key));
+
+ if (nukeSignal) {
+ return { shouldAlert: true, tier: 'FLASH', confidence: 'HIGH', headline: 'Nuclear Anomaly Detected',
+ reason: 'Safecast radiation monitors have flagged an anomaly.', actionable: 'Check dashboard immediately.',
+ signals: ['nuke_anomaly'], crossCorrelation: 'radiation monitors' };
+ }
+
+ const hasCriticalMarket = criticals.some(s => marketSignals.includes(s));
+ const hasCriticalConflict = criticals.some(s => conflictSignals.includes(s) || osintNew.includes(s));
+ if (criticals.length >= 2 && hasCriticalMarket && hasCriticalConflict) {
+ return { shouldAlert: true, tier: 'FLASH', confidence: 'HIGH',
+ headline: `${criticals.length} Critical Cross-Domain Signals`,
+ reason: `Critical signals across market and conflict domains.`,
+ actionable: 'Review dashboard. Assess exposure.',
+ signals: criticals.map(s => s.label || s.key).slice(0, 5), crossCorrelation: 'market + conflict' };
+ }
+
+ const escalatedHighs = [...criticals, ...highs].filter(s => s.direction === 'up');
+ if (escalatedHighs.length >= 2) {
+ return { shouldAlert: true, tier: 'PRIORITY', confidence: 'MEDIUM',
+ headline: `${escalatedHighs.length} Escalating Signals`,
+ reason: `Multiple indicators escalating: ${escalatedHighs.map(s => s.label || s.key).slice(0, 3).join(', ')}.`,
+ actionable: 'Monitor for continuation.',
+ signals: escalatedHighs.map(s => s.label || s.key).slice(0, 5), crossCorrelation: 'multi-indicator' };
+ }
+
+ if (osintNew.length >= 5) {
+ return { shouldAlert: true, tier: 'PRIORITY', confidence: 'MEDIUM',
+ headline: `OSINT Surge: ${osintNew.length} New Urgent Posts`,
+ reason: `${osintNew.length} new urgent OSINT signals. Elevated conflict tempo.`,
+ actionable: 'Review OSINT stream.',
+ signals: osintNew.map(s => (s.text || '').substring(0, 40)).slice(0, 3), crossCorrelation: 'telegram OSINT' };
+ }
+
+ if (criticals.length >= 1 || highs.length >= 3) {
+ const top = criticals[0] || highs[0];
+ return { shouldAlert: true, tier: 'ROUTINE', confidence: 'LOW',
+ headline: top.label || top.reason || 'Signal Change Detected',
+ reason: `${criticals.length} critical, ${highs.length} high-severity signals.`,
+ actionable: 'Monitor', signals: [...criticals, ...highs].map(s => s.label || s.key).slice(0, 4),
+ crossCorrelation: 'single-domain' };
+ }
+
+ return { shouldAlert: false, reason: `${signals.length} signals below alert threshold.` };
+ }
+
+ // ─── Semantic Dedup (same as Telegram) ──────────────────────────────────
+
+ _contentHash(signal) {
+ let content = '';
+ if (signal.text) {
+ content = signal.text.toLowerCase().replace(/\d{1,2}:\d{2}/g, '').replace(/\d+\.\d+%?/g, 'NUM').replace(/\s+/g, ' ').trim().substring(0, 120);
+ } else if (signal.label) {
+ content = `${signal.label}:${signal.direction || 'none'}`;
+ } else {
+ content = signal.key || JSON.stringify(signal).substring(0, 80);
+ }
+ return createHash('sha256').update(content).digest('hex').substring(0, 16);
+ }
+
+ _isSemanticDuplicate(signal) {
+ const hash = this._contentHash(signal);
+ const lastSeen = this._contentHashes[hash];
+ if (!lastSeen) return false;
+ return new Date(lastSeen).getTime() > (Date.now() - 4 * 60 * 60 * 1000);
+ }
+
+ _recordContentHash(signal) {
+ const hash = this._contentHash(signal);
+ this._contentHashes[hash] = new Date().toISOString();
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ for (const [h, ts] of Object.entries(this._contentHashes)) {
+ if (new Date(ts).getTime() < cutoff) delete this._contentHashes[h];
+ }
+ }
+
+ _signalKey(signal) {
+ if (signal.text) return `dc:${this._contentHash(signal)}`;
+ return signal.key || signal.label || JSON.stringify(signal).substring(0, 60);
+ }
+
+ // ─── Rate Limiting ──────────────────────────────────────────────────────
+
+ _checkRateLimit(tier) {
+ const config = TIER_CONFIG[tier];
+ if (!config) return true;
+ const now = Date.now();
+ const lastSame = this._alertHistory.filter(a => a.tier === tier).pop();
+ if (lastSame && (now - lastSame.timestamp) < config.cooldownMs) return false;
+ const recentCount = this._alertHistory.filter(a => a.tier === tier && a.timestamp > now - 3600000).length;
+ return recentCount < config.maxPerHour;
+ }
+
+ _recordAlert(tier) {
+ this._alertHistory.push({ tier, timestamp: Date.now() });
+ if (this._alertHistory.length > 50) this._alertHistory = this._alertHistory.slice(-50);
+ }
+
+ _isMuted() {
+ if (!this._muteUntil) return false;
+ if (Date.now() > this._muteUntil) { this._muteUntil = null; return false; }
+ return true;
+ }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────
+
+function parseJSON(text) {
+ if (!text) return null;
+ let cleaned = text.trim();
+ if (cleaned.startsWith('```')) cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
+ try { return JSON.parse(cleaned); } catch {
+ const match = cleaned.match(/\{[\s\S]*\}/);
+ if (match) { try { return JSON.parse(match[0]); } catch { } }
+ return null;
+ }
+}
diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs
index 66ecdbd..be93968 100644
--- a/lib/alerts/telegram.mjs
+++ b/lib/alerts/telegram.mjs
@@ -1,24 +1,59 @@
-// Telegram Alerter — sends breaking news alerts via Telegram Bot API (LLM-gated)
+// Telegram Alerter v2 — Multi-tier alerts, semantic dedup, two-way bot commands
+// USP feature: Crucix becomes a conversational intelligence agent via Telegram
+
+import { createHash } from 'crypto';
const TELEGRAM_API = 'https://api.telegram.org';
+// ─── Alert Tiers ────────────────────────────────────────────────────────────
+// FLASH: Immediate action required — market-moving, time-critical (e.g. war escalation, flash crash)
+// PRIORITY: Important signal cluster — act within hours (e.g. rate surprise, major OSINT shift)
+// ROUTINE: Noteworthy change — FYI, no urgency (e.g. trend continuation, moderate delta)
+
+const TIER_CONFIG = {
+ FLASH: { emoji: '🔴', label: 'FLASH', cooldownMs: 5 * 60 * 1000, maxPerHour: 6 },
+ PRIORITY: { emoji: '🟡', label: 'PRIORITY', cooldownMs: 30 * 60 * 1000, maxPerHour: 4 },
+ ROUTINE: { emoji: '🔵', label: 'ROUTINE', cooldownMs: 60 * 60 * 1000, maxPerHour: 2 },
+};
+
+// ─── Bot Commands ───────────────────────────────────────────────────────────
+const COMMANDS = {
+ '/status': 'Get current system health, last sweep time, source status',
+ '/sweep': 'Trigger a manual sweep cycle',
+ '/brief': 'Get a compact text summary of the latest intelligence',
+ '/portfolio': 'Show current positions and P&L (if Alpaca connected)',
+ '/alerts': 'Show recent alert history',
+ '/mute': 'Mute alerts for 1h (or /mute 2h, /mute 4h)',
+ '/unmute': 'Resume alerts',
+ '/help': 'Show available commands',
+};
+
export class TelegramAlerter {
constructor({ botToken, chatId }) {
this.botToken = botToken;
this.chatId = chatId;
+ this._alertHistory = []; // Recent alerts for rate limiting
+ this._contentHashes = {}; // Semantic dedup: hash → timestamp
+ this._muteUntil = null; // Mute timestamp
+ this._lastUpdateId = 0; // For polling bot commands
+ this._commandHandlers = {}; // Registered command callbacks
+ this._pollingInterval = null;
}
get isConfigured() {
return !!(this.botToken && this.chatId);
}
+ // ─── Core Messaging ─────────────────────────────────────────────────────
+
/**
* Send a message via Telegram Bot API.
* @param {string} message - markdown-formatted message
- * @returns {Promise
} - true if sent successfully
+ * @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId }
+ * @returns {Promise<{ok: boolean, messageId?: number}>}
*/
- async sendAlert(message) {
- if (!this.isConfigured) return false;
+ async sendMessage(message, opts = {}) {
+ if (!this.isConfigured) return { ok: false };
try {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
@@ -27,103 +62,580 @@ export class TelegramAlerter {
body: JSON.stringify({
chat_id: this.chatId,
text: message,
- parse_mode: 'Markdown',
- disable_web_page_preview: true,
+ parse_mode: opts.parseMode || 'Markdown',
+ disable_web_page_preview: opts.disablePreview !== false,
+ ...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}),
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
- console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 100)}`);
- return false;
+ console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
+ return { ok: false };
}
- return true;
+ const data = await res.json();
+ return { ok: true, messageId: data.result?.message_id };
} catch (err) {
console.error('[Telegram] Send error:', err.message);
- return false;
+ return { ok: false };
}
}
+ // Backward-compatible alias
+ async sendAlert(message) {
+ const result = await this.sendMessage(message);
+ return result.ok;
+ }
+
+ // ─── Multi-Tier Alert Evaluation ────────────────────────────────────────
+
/**
- * Evaluate delta signals with LLM and send alert if warranted.
- * @param {LLMProvider} llmProvider - configured LLM provider
- * @param {object} delta - delta from current sweep
- * @param {MemoryManager} memory - memory manager for dedup
- * @returns {Promise} - true if alert was sent
+ * Evaluate delta signals with LLM and send tiered alert if warranted.
+ * Uses semantic dedup, rate limiting, and a much richer evaluation prompt.
*/
async evaluateAndAlert(llmProvider, delta, memory) {
- if (!this.isConfigured || !llmProvider?.isConfigured) return false;
- if (!delta?.summary?.criticalChanges) return false;
+ if (!this.isConfigured) return false;
+ if (!delta?.summary?.totalChanges) return false;
+ if (this._isMuted()) {
+ console.log('[Telegram] Alerts muted until', new Date(this._muteUntil).toLocaleTimeString());
+ return false;
+ }
- // Filter out already-alerted signals
- const alerted = memory.getAlertedSignals();
- const newSignals = [
+ // 1. Gather new signals — filter already-alerted AND semantically duplicate
+ const allSignals = [
...(delta.signals?.new || []),
...(delta.signals?.escalated || []),
- ].filter(s => {
- const key = s.key || s.label || s.text?.substring(0, 40);
- return !alerted[key];
+ ];
+
+ const newSignals = allSignals.filter(s => {
+ const key = this._signalKey(s);
+ // Check decay-based suppression (if memory supports it)
+ if (typeof memory.isSignalSuppressed === 'function') {
+ if (memory.isSignalSuppressed(key)) return false;
+ } else {
+ // Legacy: check flat alerted map
+ const alerted = memory.getAlertedSignals();
+ if (alerted[key]) return false;
+ }
+ // Check semantic/content hash dedup
+ if (this._isSemanticDuplicate(s)) return false;
+ return true;
});
if (newSignals.length === 0) return false;
- // Ask LLM if these signals warrant an immediate alert
- const systemPrompt = `You are an intelligence alert evaluator. You receive new/escalated signals from an OSINT monitoring system. Your job is to determine if any warrant an IMMEDIATE alert to the user.
+ // 2. Try LLM evaluation first, fall back to rule-based if unavailable
+ let evaluation = null;
-Alert criteria (ALL must be true):
-1. Material market impact likely (>1% move in major index, or >5% move in sector/commodity)
-2. Time-sensitive — acting in the next few hours matters
-3. Not routine data (scheduled economic releases don't count unless they're a major surprise)
+ if (llmProvider?.isConfigured) {
+ try {
+ const systemPrompt = this._buildEvaluationPrompt();
+ const userMessage = this._buildSignalContext(newSignals, delta);
+ const result = await llmProvider.complete(systemPrompt, userMessage, {
+ maxTokens: 800,
+ timeout: 30000,
+ });
+ evaluation = parseJSON(result.text);
+ } catch (err) {
+ console.warn('[Telegram] LLM evaluation failed, falling back to rules:', err.message);
+ // Fall through to rule-based evaluation
+ }
+ }
+
+ // Rule-based fallback: fires when LLM is unavailable or returns garbage
+ if (!evaluation || typeof evaluation.shouldAlert !== 'boolean') {
+ evaluation = this._ruleBasedEvaluation(newSignals, delta);
+ if (evaluation) evaluation._source = 'rules';
+ }
+
+ if (!evaluation?.shouldAlert) {
+ console.log('[Telegram] No alert —', evaluation?.reason || 'no qualifying signals');
+ return false;
+ }
+
+ // 3. Validate tier and check rate limits
+ const tier = TIER_CONFIG[evaluation.tier] ? evaluation.tier : 'ROUTINE';
+ if (!this._checkRateLimit(tier)) {
+ console.log(`[Telegram] Rate limited for tier ${tier}`);
+ return false;
+ }
+
+ // 4. Format and send tiered alert
+ const message = this._formatTieredAlert(evaluation, delta, tier);
+ const sent = await this.sendAlert(message);
+
+ if (sent) {
+ // Mark signals as alerted with content hashing
+ for (const s of newSignals) {
+ const key = this._signalKey(s);
+ memory.markAsAlerted(key, new Date().toISOString());
+ this._recordContentHash(s);
+ }
+ this._recordAlert(tier);
+ console.log(`[Telegram] ${tier} alert sent (${evaluation._source || 'llm'}): ${evaluation.headline}`);
+ }
+
+ return sent;
+ }
+
+ // ─── Rule-Based Alert Fallback ────────────────────────────────────────
+
+ /**
+ * Deterministic alert evaluation when LLM is unavailable.
+ * Uses signal counts, severity, and cross-domain correlation.
+ */
+ _ruleBasedEvaluation(signals, delta) {
+ const criticals = signals.filter(s => s.severity === 'critical');
+ const highs = signals.filter(s => s.severity === 'high');
+ const nukeSignal = signals.find(s => s.key === 'nuke_anomaly');
+ const osintNew = signals.filter(s => s.key?.startsWith('tg_urgent'));
+ const marketSignals = signals.filter(s => ['vix', 'hy_spread', 'wti', 'brent', '10y2y'].includes(s.key));
+ const conflictSignals = signals.filter(s => ['conflict_events', 'conflict_fatalities', 'thermal_total'].includes(s.key));
+
+ // FLASH: nuclear anomaly, or ≥3 critical signals across domains
+ if (nukeSignal) {
+ return {
+ shouldAlert: true, tier: 'FLASH', confidence: 'HIGH',
+ headline: 'Nuclear Anomaly Detected',
+ reason: 'Safecast radiation monitors have flagged an anomaly. This requires immediate attention.',
+ actionable: 'Check dashboard for affected sites. Monitor confirmation from secondary sources.',
+ signals: ['nuke_anomaly'],
+ crossCorrelation: 'radiation monitors',
+ };
+ }
+
+ // FLASH: ≥2 critical signals AND they span multiple domains
+ const hasCriticalMarket = criticals.some(s => marketSignals.includes(s));
+ const hasCriticalConflict = criticals.some(s => conflictSignals.includes(s) || osintNew.includes(s));
+ if (criticals.length >= 2 && hasCriticalMarket && hasCriticalConflict) {
+ return {
+ shouldAlert: true, tier: 'FLASH', confidence: 'HIGH',
+ headline: `${criticals.length} Critical Cross-Domain Signals`,
+ reason: `${criticals.length} critical signals detected across market and conflict domains. Multi-domain correlation suggests systemic event.`,
+ actionable: 'Review dashboard immediately. Assess portfolio exposure.',
+ signals: criticals.map(s => s.label || s.key).slice(0, 5),
+ crossCorrelation: 'market + conflict',
+ };
+ }
+
+ // PRIORITY: ≥2 high/critical signals in same direction
+ const escalatedHighs = [...criticals, ...highs].filter(s => s.direction === 'up');
+ if (escalatedHighs.length >= 2) {
+ return {
+ shouldAlert: true, tier: 'PRIORITY', confidence: 'MEDIUM',
+ headline: `${escalatedHighs.length} Escalating Signals`,
+ reason: `Multiple indicators escalating simultaneously: ${escalatedHighs.map(s => s.label || s.key).slice(0, 3).join(', ')}.`,
+ actionable: 'Monitor for continuation. Check if trend persists in next sweep.',
+ signals: escalatedHighs.map(s => s.label || s.key).slice(0, 5),
+ crossCorrelation: 'multi-indicator',
+ };
+ }
+
+ // PRIORITY: ≥5 new OSINT posts (surge in conflict reporting)
+ if (osintNew.length >= 5) {
+ return {
+ shouldAlert: true, tier: 'PRIORITY', confidence: 'MEDIUM',
+ headline: `OSINT Surge: ${osintNew.length} New Urgent Posts`,
+ reason: `${osintNew.length} new urgent OSINT signals detected. Elevated conflict reporting tempo.`,
+ actionable: 'Review OSINT stream for pattern. Cross-check with satellite and ACLED data.',
+ signals: osintNew.map(s => (s.text || '').substring(0, 40)).slice(0, 3),
+ crossCorrelation: 'telegram OSINT',
+ };
+ }
+
+ // ROUTINE: any critical signal OR ≥3 high signals
+ if (criticals.length >= 1 || highs.length >= 3) {
+ const topSignal = criticals[0] || highs[0];
+ return {
+ shouldAlert: true, tier: 'ROUTINE', confidence: 'LOW',
+ headline: topSignal.label || topSignal.reason || 'Signal Change Detected',
+ reason: `${criticals.length} critical, ${highs.length} high-severity signals. ${delta.summary.direction} bias.`,
+ actionable: 'Monitor',
+ signals: [...criticals, ...highs].map(s => s.label || s.key).slice(0, 4),
+ crossCorrelation: 'single-domain',
+ };
+ }
+
+ // No alert
+ return {
+ shouldAlert: false,
+ reason: `${signals.length} signals, but none meet alert threshold (${criticals.length} critical, ${highs.length} high).`,
+ };
+ }
+
+ // ─── Two-Way Bot Commands ───────────────────────────────────────────────
+
+ /**
+ * Register command handlers that the bot can respond to.
+ * @param {string} command - e.g. '/status'
+ * @param {Function} handler - async (args, messageId) => responseText
+ */
+ onCommand(command, handler) {
+ this._commandHandlers[command.toLowerCase()] = handler;
+ }
+
+ /**
+ * Start polling for incoming messages/commands.
+ * Call this once during server startup.
+ * @param {number} intervalMs - polling interval (default 5000ms)
+ */
+ startPolling(intervalMs = 5000) {
+ if (!this.isConfigured) return;
+ if (this._pollingInterval) return; // Already polling
+
+ console.log('[Telegram] Bot command polling started');
+ this._pollingInterval = setInterval(() => this._pollUpdates(), intervalMs);
+ // Initial poll
+ this._pollUpdates();
+ }
+
+ /**
+ * Stop polling for incoming messages.
+ */
+ stopPolling() {
+ if (this._pollingInterval) {
+ clearInterval(this._pollingInterval);
+ this._pollingInterval = null;
+ console.log('[Telegram] Bot command polling stopped');
+ }
+ }
+
+ async _pollUpdates() {
+ try {
+ const params = new URLSearchParams({
+ offset: String(this._lastUpdateId + 1),
+ timeout: '0',
+ limit: '10',
+ allowed_updates: JSON.stringify(['message']),
+ });
+
+ const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/getUpdates?${params}`, {
+ signal: AbortSignal.timeout(10000),
+ });
+
+ if (!res.ok) return;
+
+ const data = await res.json();
+ if (!data.ok || !Array.isArray(data.result)) return;
+
+ for (const update of data.result) {
+ this._lastUpdateId = Math.max(this._lastUpdateId, update.update_id);
+ const msg = update.message;
+ if (!msg?.text) continue;
+
+ // Only process messages from the configured chat
+ const chatId = String(msg.chat?.id);
+ if (chatId !== String(this.chatId)) continue;
+
+ await this._handleMessage(msg);
+ }
+ } catch (err) {
+ // Silent — polling failures are non-fatal
+ if (!err.message?.includes('aborted')) {
+ console.error('[Telegram] Poll error:', err.message);
+ }
+ }
+ }
+
+ async _handleMessage(msg) {
+ const text = msg.text.trim();
+ const parts = text.split(/\s+/);
+ const command = parts[0].toLowerCase();
+ const args = parts.slice(1).join(' ');
+
+ // Built-in commands
+ if (command === '/help') {
+ const helpText = Object.entries(COMMANDS)
+ .map(([cmd, desc]) => `${cmd} — ${desc}`)
+ .join('\n');
+ await this.sendMessage(
+ `🤖 *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`,
+ { replyToMessageId: msg.message_id }
+ );
+ return;
+ }
+
+ if (command === '/mute') {
+ const hours = parseFloat(args) || 1;
+ this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
+ await this.sendMessage(
+ `🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`,
+ { replyToMessageId: msg.message_id }
+ );
+ return;
+ }
+
+ if (command === '/unmute') {
+ this._muteUntil = null;
+ await this.sendMessage(
+ `🔔 Alerts resumed. You'll receive the next signal evaluation.`,
+ { replyToMessageId: msg.message_id }
+ );
+ return;
+ }
+
+ if (command === '/alerts') {
+ const recent = this._alertHistory.slice(-10);
+ if (recent.length === 0) {
+ await this.sendMessage('No recent alerts.', { replyToMessageId: msg.message_id });
+ return;
+ }
+ const lines = recent.map(a =>
+ `${TIER_CONFIG[a.tier]?.emoji || '⚪'} ${a.tier} — ${new Date(a.timestamp).toLocaleTimeString()}`
+ );
+ await this.sendMessage(
+ `📋 *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`,
+ { replyToMessageId: msg.message_id }
+ );
+ return;
+ }
+
+ // Delegate to registered handlers
+ const handler = this._commandHandlers[command];
+ if (handler) {
+ try {
+ const response = await handler(args, msg.message_id);
+ if (response) {
+ await this.sendMessage(response, { replyToMessageId: msg.message_id });
+ }
+ } catch (err) {
+ console.error(`[Telegram] Command ${command} error:`, err.message);
+ await this.sendMessage(
+ `❌ Command failed: ${err.message}`,
+ { replyToMessageId: msg.message_id }
+ );
+ }
+ }
+ // Unknown commands are silently ignored to avoid spamming
+ }
+
+ // ─── Semantic Dedup ─────────────────────────────────────────────────────
+
+ /**
+ * Generate a content-based hash for a signal to detect near-duplicates.
+ * Uses normalized text + key metrics rather than raw text prefix matching.
+ */
+ _contentHash(signal) {
+ // Normalize: lowercase, strip numbers that change frequently (timestamps, exact values)
+ let content = '';
+ if (signal.text) {
+ content = signal.text.toLowerCase()
+ .replace(/\d{1,2}:\d{2}/g, '') // strip times
+ .replace(/\d+\.\d+%?/g, 'NUM') // normalize numbers
+ .replace(/\s+/g, ' ')
+ .trim()
+ .substring(0, 120);
+ } else if (signal.label) {
+ // For metric signals, hash the label + direction (not exact values)
+ content = `${signal.label}:${signal.direction || 'none'}`;
+ } else {
+ content = signal.key || JSON.stringify(signal).substring(0, 80);
+ }
+
+ return createHash('sha256').update(content).digest('hex').substring(0, 16);
+ }
+
+ _isSemanticDuplicate(signal) {
+ const hash = this._contentHash(signal);
+ const lastSeen = this._contentHashes[hash];
+ if (!lastSeen) return false;
+
+ // Consider duplicate if seen within last 4 hours
+ const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000;
+ return new Date(lastSeen).getTime() > fourHoursAgo;
+ }
+
+ _recordContentHash(signal) {
+ const hash = this._contentHash(signal);
+ this._contentHashes[hash] = new Date().toISOString();
+
+ // Prune hashes older than 24h
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ for (const [h, ts] of Object.entries(this._contentHashes)) {
+ if (new Date(ts).getTime() < cutoff) delete this._contentHashes[h];
+ }
+ }
+
+ _signalKey(signal) {
+ // Improved key generation — use content hash for text signals, structured key for metrics
+ if (signal.text) return `tg:${this._contentHash(signal)}`;
+ return signal.key || signal.label || JSON.stringify(signal).substring(0, 60);
+ }
+
+ // ─── Rate Limiting ──────────────────────────────────────────────────────
+
+ _checkRateLimit(tier) {
+ const config = TIER_CONFIG[tier];
+ if (!config) return true;
+
+ const now = Date.now();
+ const oneHourAgo = now - 60 * 60 * 1000;
+
+ // Check cooldown since last alert of same or lower tier
+ const lastSameTier = this._alertHistory
+ .filter(a => a.tier === tier)
+ .pop();
+ if (lastSameTier && (now - lastSameTier.timestamp) < config.cooldownMs) {
+ return false;
+ }
+
+ // Check hourly cap
+ const recentCount = this._alertHistory
+ .filter(a => a.tier === tier && a.timestamp > oneHourAgo)
+ .length;
+ if (recentCount >= config.maxPerHour) {
+ return false;
+ }
+
+ return true;
+ }
+
+ _recordAlert(tier) {
+ this._alertHistory.push({ tier, timestamp: Date.now() });
+ // Keep only last 50 alerts
+ if (this._alertHistory.length > 50) {
+ this._alertHistory = this._alertHistory.slice(-50);
+ }
+ }
+
+ _isMuted() {
+ if (!this._muteUntil) return false;
+ if (Date.now() > this._muteUntil) {
+ this._muteUntil = null;
+ return false;
+ }
+ return true;
+ }
+
+ // ─── Prompt Engineering ─────────────────────────────────────────────────
+
+ _buildEvaluationPrompt() {
+ return `You are Crucix, an elite intelligence alert evaluator for a personal OSINT monitoring system. You analyze signal deltas from a 25-source intelligence sweep and decide if the user needs to be alerted via Telegram.
+
+## Your Decision Framework
+
+You must classify each evaluation into one of four outcomes:
+
+### NO ALERT — suppress if:
+- Routine scheduled data (NFP, CPI, FOMC minutes on expected dates) UNLESS the deviation from consensus is extreme (>2σ)
+- Continuation of existing trends already flagged in prior sweeps
+- Low-confidence signals from single sources without corroboration
+- Social media noise without hard-data confirmation (Telegram chatter alone is NOT enough)
+
+### 🔴 FLASH — immediate, life-of-portfolio risk:
+- Active military escalation between nuclear powers or NATO-involved states
+- Flash crash indicators (VIX spike >40%, major index down >3% intraday)
+- Central bank emergency action (unscheduled rate decision, emergency lending facility)
+- Nuclear/radiological anomaly confirmed by multiple monitors
+- Sanctions against major economy announced without warning
+FLASH requires: ≥2 corroborating sources across different domains (e.g. OSINT + market data + satellite)
+
+### 🟡 PRIORITY — act within hours:
+- Significant market dislocation (VIX >25 AND credit spreads widening)
+- Geopolitical escalation with clear energy/commodity transmission (conflict + oil move >3%)
+- Unexpected economic data (>1.5σ miss on major indicator)
+- New conflict front or ceasefire collapse confirmed by ACLED + Telegram
+PRIORITY requires: ≥2 signals moving in same direction, at least 1 from hard data
+
+### 🔵 ROUTINE — informational, no urgency:
+- Notable trend shifts or reversals worth tracking
+- Single-source signals of moderate importance
+- Cumulative drift (multiple small moves in same direction over several sweeps)
+
+## Output Format
Respond with ONLY valid JSON:
{
"shouldAlert": true/false,
- "reason": "1-2 sentence explanation",
- "headline": "Alert headline if shouldAlert is true",
- "signals": ["key signals that triggered alert"]
+ "tier": "FLASH" | "PRIORITY" | "ROUTINE",
+ "headline": "10-word max headline",
+ "reason": "2-3 sentences. What happened, why it matters, what to watch next.",
+ "actionable": "Specific action the user could take (or 'Monitor' if just informational)",
+ "signals": ["signal1", "signal2"],
+ "confidence": "HIGH" | "MEDIUM" | "LOW",
+ "crossCorrelation": "Which domains are confirming each other (e.g. 'conflict + energy + satellite')"
}`;
+ }
- const userMessage = `New/escalated signals since last sweep:\n${newSignals.map(s => {
- if (s.changePct !== undefined) return `- ${s.label}: ${s.previous} → ${s.current} (${s.changePct > 0 ? '+' : ''}${s.changePct.toFixed(1)}%)`;
- if (s.text) return `- NEW OSINT: ${s.text.substring(0, 120)}`;
- return `- ${s.label || JSON.stringify(s)}`;
- }).join('\n')}
+ _buildSignalContext(signals, delta) {
+ const sections = [];
-Delta summary: direction=${delta.summary.direction}, total changes=${delta.summary.totalChanges}, critical=${delta.summary.criticalChanges}`;
+ // Categorize signals
+ const marketSignals = signals.filter(s => ['vix', 'hy_spread', 'wti', 'brent', 'natgas', '10y2y', 'fed_funds', '10y_yield', 'usd_index'].includes(s.key));
+ const osintSignals = signals.filter(s => s.key === 'tg_urgent' || s.item?.channel);
+ const conflictSignals = signals.filter(s => ['conflict_events', 'conflict_fatalities', 'thermal_total'].includes(s.key));
+ const otherSignals = signals.filter(s => !marketSignals.includes(s) && !osintSignals.includes(s) && !conflictSignals.includes(s));
- try {
- const result = await llmProvider.complete(systemPrompt, userMessage, { maxTokens: 512, timeout: 30000 });
- const evaluation = parseEvaluation(result.text);
-
- if (!evaluation?.shouldAlert) {
- console.log('[Telegram] LLM says no alert needed:', evaluation?.reason || 'unknown');
- return false;
- }
-
- // Build and send alert message
- const message = formatAlertMessage(evaluation, delta);
- const sent = await this.sendAlert(message);
-
- if (sent) {
- // Mark signals as alerted
- for (const s of newSignals) {
- const key = s.key || s.label || s.text?.substring(0, 40);
- memory.markAsAlerted(key, new Date().toISOString());
- }
- console.log('[Telegram] Alert sent:', evaluation.headline);
- }
-
- return sent;
- } catch (err) {
- console.error('[Telegram] LLM evaluation failed:', err.message);
- return false;
+ if (marketSignals.length > 0) {
+ sections.push('📊 MARKET SIGNALS:\n' + marketSignals.map(s =>
+ ` ${s.label}: ${s.from} → ${s.to} (${s.pctChange > 0 ? '+' : ''}${s.pctChange?.toFixed(1) || s.change}${s.pctChange !== undefined ? '%' : ''})`
+ ).join('\n'));
}
+
+ if (osintSignals.length > 0) {
+ sections.push('📡 OSINT SIGNALS:\n' + osintSignals.map(s => {
+ const post = s.item || s;
+ return ` [${post.channel || 'UNKNOWN'}] ${(post.text || s.reason || '').substring(0, 150)}`;
+ }).join('\n'));
+ }
+
+ if (conflictSignals.length > 0) {
+ sections.push('⚔️ CONFLICT INDICATORS:\n' + conflictSignals.map(s =>
+ ` ${s.label}: ${s.from} → ${s.to} (${s.direction})`
+ ).join('\n'));
+ }
+
+ if (otherSignals.length > 0) {
+ sections.push('📌 OTHER:\n' + otherSignals.map(s =>
+ ` ${s.label || s.key || s.reason}: ${s.from !== undefined ? `${s.from} → ${s.to}` : 'new signal'}`
+ ).join('\n'));
+ }
+
+ sections.push(`\n📈 SWEEP DELTA: direction=${delta.summary.direction}, total=${delta.summary.totalChanges}, critical=${delta.summary.criticalChanges}`);
+
+ return sections.join('\n\n');
+ }
+
+ // ─── Message Formatting ─────────────────────────────────────────────────
+
+ _formatTieredAlert(evaluation, delta, tier) {
+ const tc = TIER_CONFIG[tier];
+ const confidenceEmoji = { HIGH: '🟢', MEDIUM: '🟡', LOW: '⚪' }[evaluation.confidence] || '⚪';
+
+ const lines = [
+ `${tc.emoji} *CRUCIX ${tc.label}*`,
+ ``,
+ `*${evaluation.headline}*`,
+ ``,
+ evaluation.reason,
+ ``,
+ `Confidence: ${confidenceEmoji} ${evaluation.confidence || 'MEDIUM'}`,
+ `Direction: ${delta.summary.direction.toUpperCase()}`,
+ ];
+
+ if (evaluation.crossCorrelation) {
+ lines.push(`Cross-correlation: ${evaluation.crossCorrelation}`);
+ }
+
+ if (evaluation.actionable && evaluation.actionable !== 'Monitor') {
+ lines.push(``, `💡 *Action:* ${evaluation.actionable}`);
+ }
+
+ if (evaluation.signals?.length) {
+ lines.push('', `Signals: ${evaluation.signals.join(' · ')}`);
+ }
+
+ lines.push('', `_${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC_`);
+
+ return lines.join('\n');
}
}
-function parseEvaluation(text) {
+// ─── Helpers ──────────────────────────────────────────────────────────────
+
+function parseJSON(text) {
if (!text) return null;
let cleaned = text.trim();
if (cleaned.startsWith('```')) {
@@ -139,24 +651,3 @@ function parseEvaluation(text) {
return null;
}
}
-
-function formatAlertMessage(evaluation, delta) {
- const lines = [
- `🚨 *CRUCIX ALERT*`,
- ``,
- `*${evaluation.headline}*`,
- ``,
- evaluation.reason,
- ``,
- `Direction: ${delta.summary.direction.toUpperCase()}`,
- `Critical changes: ${delta.summary.criticalChanges}`,
- ];
-
- if (evaluation.signals?.length) {
- lines.push('', `Key signals: ${evaluation.signals.join(', ')}`);
- }
-
- lines.push('', `_${new Date().toLocaleTimeString()} UTC_`);
-
- return lines.join('\n');
-}
diff --git a/lib/delta/engine.mjs b/lib/delta/engine.mjs
index 721e25a..d42a958 100644
--- a/lib/delta/engine.mjs
+++ b/lib/delta/engine.mjs
@@ -1,18 +1,51 @@
-// Delta Engine — compares two synthesized sweep results and produces structured changes
+// Delta Engine v2 — compares two synthesized sweep results and produces structured changes
+// Improvements: count metric thresholds, semantic TG dedup, configurable thresholds, null-safety
+
+import { createHash } from 'crypto';
+
+// ─── Default Thresholds ──────────────────────────────────────────────────────
+// Override via config.delta.thresholds in crucix.config.mjs
+
+const DEFAULT_NUMERIC_THRESHOLDS = {
+ vix: 5, // % change to flag
+ hy_spread: 5,
+ '10y2y': 10,
+ wti: 3,
+ brent: 3,
+ natgas: 5,
+ unemployment: 2,
+ fed_funds: 1,
+ '10y_yield': 3,
+ usd_index: 1,
+ mortgage: 2,
+};
+
+const DEFAULT_COUNT_THRESHOLDS = {
+ urgent_posts: 2, // need ±2 to matter (was 0 — any change)
+ thermal_total: 500, // ±500 detections (was 0 — +1 was noise)
+ air_total: 50, // ±50 aircraft
+ who_alerts: 1, // any new WHO alert matters
+ conflict_events: 5, // ±5 ACLED events
+ conflict_fatalities: 10, // ±10 fatalities
+ sdr_online: 3, // ±3 receivers
+ news_count: 5, // ±5 news items
+ sources_ok: 1, // any source going down matters
+};
+
+// ─── Metric Definitions ──────────────────────────────────────────────────────
-// Metrics we track for delta computation
const NUMERIC_METRICS = [
- { key: 'vix', extract: d => d.fred?.find(f => f.id === 'VIXCLS')?.value, label: 'VIX', threshold: 5 },
- { key: 'hy_spread', extract: d => d.fred?.find(f => f.id === 'BAMLH0A0HYM2')?.value, label: 'HY Spread', threshold: 5 },
- { key: '10y2y', extract: d => d.fred?.find(f => f.id === 'T10Y2Y')?.value, label: '10Y-2Y Spread', threshold: 10 },
- { key: 'wti', extract: d => d.energy?.wti, label: 'WTI Crude', threshold: 3 },
- { key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude', threshold: 3 },
- { key: 'natgas', extract: d => d.energy?.natgas, label: 'Natural Gas', threshold: 5 },
- { key: 'unemployment', extract: d => d.bls?.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE')?.value, label: 'Unemployment', threshold: 2 },
- { key: 'fed_funds', extract: d => d.fred?.find(f => f.id === 'DFF')?.value, label: 'Fed Funds Rate', threshold: 1 },
- { key: '10y_yield', extract: d => d.fred?.find(f => f.id === 'DGS10')?.value, label: '10Y Yield', threshold: 3 },
- { key: 'usd_index', extract: d => d.fred?.find(f => f.id === 'DTWEXBGS')?.value, label: 'USD Index', threshold: 1 },
- { key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage', threshold: 2 },
+ { key: 'vix', extract: d => d.fred?.find(f => f.id === 'VIXCLS')?.value, label: 'VIX' },
+ { key: 'hy_spread', extract: d => d.fred?.find(f => f.id === 'BAMLH0A0HYM2')?.value, label: 'HY Spread' },
+ { key: '10y2y', extract: d => d.fred?.find(f => f.id === 'T10Y2Y')?.value, label: '10Y-2Y Spread' },
+ { key: 'wti', extract: d => d.energy?.wti, label: 'WTI Crude' },
+ { key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude' },
+ { key: 'natgas', extract: d => d.energy?.natgas, label: 'Natural Gas' },
+ { key: 'unemployment', extract: d => d.bls?.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE')?.value, label: 'Unemployment' },
+ { key: 'fed_funds', extract: d => d.fred?.find(f => f.id === 'DFF')?.value, label: 'Fed Funds Rate' },
+ { key: '10y_yield', extract: d => d.fred?.find(f => f.id === 'DGS10')?.value, label: '10Y Yield' },
+ { key: 'usd_index', extract: d => d.fred?.find(f => f.id === 'DTWEXBGS')?.value, label: 'USD Index' },
+ { key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage' },
];
const COUNT_METRICS = [
@@ -23,29 +56,66 @@ const COUNT_METRICS = [
{ key: 'conflict_events', extract: d => d.acled?.totalEvents || 0, label: 'Conflict Events' },
{ key: 'conflict_fatalities', extract: d => d.acled?.totalFatalities || 0, label: 'Conflict Fatalities' },
{ key: 'sdr_online', extract: d => d.sdr?.online || 0, label: 'SDR Receivers' },
- { key: 'news_count', extract: d => d.news?.length || 0, label: 'News Items' },
+ { key: 'news_count', extract: d => (d.news?.length ?? d.news?.count) || 0, label: 'News Items' },
{ key: 'sources_ok', extract: d => d.meta?.sourcesOk || 0, label: 'Sources OK' },
];
-export function computeDelta(current, previous) {
+// Risk-sensitive keys: used for determining overall direction
+const RISK_KEYS = ['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'];
+
+// ─── Semantic Hashing for Telegram Posts ─────────────────────────────────────
+
+/**
+ * Produce a normalized hash of a post's content.
+ * Strips timestamps, normalizes numbers, lowercases — so "BREAKING: 5 missiles at 14:32"
+ * and "Breaking: 7 missiles at 15:01" produce the same hash (both are "missile strike" signals).
+ */
+function contentHash(text) {
+ if (!text) return '';
+ const normalized = text
+ .toLowerCase()
+ .replace(/\d{1,2}:\d{2}(:\d{2})?/g, '') // strip times
+ .replace(/\d+/g, 'N') // normalize all numbers
+ .replace(/[^\w\s]/g, '') // strip punctuation
+ .replace(/\s+/g, ' ')
+ .trim()
+ .substring(0, 100);
+ return createHash('sha256').update(normalized).digest('hex').substring(0, 12);
+}
+
+// ─── Core Delta Computation ──────────────────────────────────────────────────
+
+/**
+ * @param {object} current - current sweep's synthesized data
+ * @param {object|null} previous - previous sweep's synthesized data (null on first run)
+ * @param {object} [thresholdOverrides] - optional: { numeric: {...}, count: {...} }
+ */
+export function computeDelta(current, previous, thresholdOverrides = {}) {
if (!previous) return null;
+ if (!current) return null;
+
+ const numThresholds = { ...DEFAULT_NUMERIC_THRESHOLDS, ...(thresholdOverrides.numeric || {}) };
+ const cntThresholds = { ...DEFAULT_COUNT_THRESHOLDS, ...(thresholdOverrides.count || {}) };
const signals = { new: [], escalated: [], deescalated: [], unchanged: [] };
let criticalChanges = 0;
- // Numeric metrics: track % change
+ // ─── Numeric metrics: track % change ─────────────────────────────────
+
for (const m of NUMERIC_METRICS) {
const curr = m.extract(current);
const prev = m.extract(previous);
if (curr == null || prev == null) continue;
+ const threshold = numThresholds[m.key] ?? 5;
const pctChange = prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : 0;
- if (Math.abs(pctChange) > m.threshold) {
+ if (Math.abs(pctChange) > threshold) {
const entry = {
key: m.key, label: m.label, from: prev, to: curr,
pctChange: parseFloat(pctChange.toFixed(2)),
direction: pctChange > 0 ? 'up' : 'down',
+ severity: Math.abs(pctChange) > threshold * 3 ? 'critical' : Math.abs(pctChange) > threshold * 2 ? 'high' : 'moderate',
};
if (pctChange > 0) signals.escalated.push(entry);
else signals.deescalated.push(entry);
@@ -55,52 +125,78 @@ export function computeDelta(current, previous) {
}
}
- // Count metrics: track absolute change
+ // ─── Count metrics: track absolute change (with minimum thresholds) ──
+
for (const m of COUNT_METRICS) {
const curr = m.extract(current);
const prev = m.extract(previous);
const diff = curr - prev;
+ const threshold = cntThresholds[m.key] ?? 1;
- if (Math.abs(diff) > 0) {
+ if (Math.abs(diff) >= threshold) {
+ const pctChange = prev > 0 ? ((diff / prev) * 100) : (diff > 0 ? 100 : 0);
const entry = {
key: m.key, label: m.label, from: prev, to: curr,
change: diff, direction: diff > 0 ? 'up' : 'down',
+ pctChange: parseFloat(pctChange.toFixed(1)),
+ severity: Math.abs(diff) >= threshold * 5 ? 'critical' : Math.abs(diff) >= threshold * 2 ? 'high' : 'moderate',
};
if (diff > 0) signals.escalated.push(entry);
else signals.deescalated.push(entry);
+ // Count metrics only critical if the change is extreme
+ if (entry.severity === 'critical') criticalChanges++;
} else {
signals.unchanged.push(m.key);
}
}
- // New urgent posts (check by text content)
- const prevUrgentTexts = new Set((previous.tg?.urgent || []).map(p => p.text?.substring(0, 60)));
+ // ─── New urgent Telegram posts (semantic dedup) ──────────────────────
+
+ const prevHashes = new Set(
+ (previous.tg?.urgent || []).map(p => contentHash(p.text))
+ );
+
for (const post of (current.tg?.urgent || [])) {
- const key = post.text?.substring(0, 60);
- if (key && !prevUrgentTexts.has(key)) {
- signals.new.push({ key: 'tg_urgent', item: post, reason: 'New urgent OSINT post' });
+ const hash = contentHash(post.text);
+ if (hash && !prevHashes.has(hash)) {
+ signals.new.push({
+ key: `tg_urgent:${hash}`,
+ text: post.text?.substring(0, 120),
+ item: post,
+ reason: 'New urgent OSINT post',
+ });
criticalChanges++;
}
}
- // Nuclear anomaly change
+ // ─── Nuclear anomaly state change ────────────────────────────────────
+
const currAnom = current.nuke?.some(n => n.anom) || false;
const prevAnom = previous.nuke?.some(n => n.anom) || false;
if (currAnom && !prevAnom) {
- signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected' });
- criticalChanges += 5; // Critical
+ signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected', severity: 'critical' });
+ criticalChanges += 5;
} else if (!currAnom && prevAnom) {
- signals.deescalated.push({ key: 'nuke_anomaly', label: 'Nuclear Anomaly', direction: 'resolved' });
+ signals.deescalated.push({ key: 'nuke_anomaly', label: 'Nuclear Anomaly', direction: 'resolved', severity: 'high' });
}
- // Determine overall direction
+ // ─── Source health degradation ───────────────────────────────────────
+
+ const currSourcesDown = current.health?.filter(s => s.err).length || 0;
+ const prevSourcesDown = previous.health?.filter(s => s.err).length || 0;
+ if (currSourcesDown > prevSourcesDown + 2) {
+ signals.new.push({
+ key: 'source_degradation',
+ reason: `${currSourcesDown - prevSourcesDown} additional sources failing (${currSourcesDown} total down)`,
+ severity: currSourcesDown > 5 ? 'critical' : 'moderate',
+ });
+ }
+
+ // ─── Overall direction ───────────────────────────────────────────────
+
let direction = 'mixed';
- const riskUp = signals.escalated.filter(s =>
- ['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'].includes(s.key)
- ).length;
- const riskDown = signals.deescalated.filter(s =>
- ['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'].includes(s.key)
- ).length;
+ const riskUp = signals.escalated.filter(s => RISK_KEYS.includes(s.key)).length;
+ const riskDown = signals.deescalated.filter(s => RISK_KEYS.includes(s.key)).length;
if (riskUp > riskDown + 1) direction = 'risk-off';
else if (riskDown > riskUp + 1) direction = 'risk-on';
@@ -112,6 +208,15 @@ export function computeDelta(current, previous) {
totalChanges: signals.new.length + signals.escalated.length + signals.deescalated.length,
criticalChanges,
direction,
+ signalBreakdown: {
+ new: signals.new.length,
+ escalated: signals.escalated.length,
+ deescalated: signals.deescalated.length,
+ unchanged: signals.unchanged.length,
+ },
},
};
}
+
+// Export thresholds for external config
+export { DEFAULT_NUMERIC_THRESHOLDS, DEFAULT_COUNT_THRESHOLDS };
diff --git a/lib/delta/memory.mjs b/lib/delta/memory.mjs
index 9dcae5a..238c014 100644
--- a/lib/delta/memory.mjs
+++ b/lib/delta/memory.mjs
@@ -1,11 +1,16 @@
// Memory Manager — hot/cold storage for sweep history and alert tracking
+// v2: Atomic writes, decay-based alert cooldowns, configurable retention
-import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
+import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'fs';
import { join } from 'path';
import { computeDelta } from './engine.mjs';
const MAX_HOT_RUNS = 3;
+// Alert cooldown tiers — repeated signals get progressively longer suppression
+// First alert: 0h wait. Second occurrence within 24h: 6h cooldown. Third: 12h. Fourth+: 24h.
+const ALERT_DECAY_TIERS = [0, 6, 12, 24]; // hours
+
export class MemoryManager {
constructor(runsDir) {
this.runsDir = runsDir;
@@ -23,18 +28,46 @@ export class MemoryManager {
}
_loadHot() {
- try {
- return JSON.parse(readFileSync(this.hotPath, 'utf8'));
- } catch {
- return { runs: [], alertedSignals: {} };
+ // Try primary file first, then backup
+ for (const path of [this.hotPath, this.hotPath + '.bak']) {
+ try {
+ const raw = readFileSync(path, 'utf8');
+ const data = JSON.parse(raw);
+ // Validate structure
+ if (data && Array.isArray(data.runs) && typeof data.alertedSignals === 'object') {
+ return data;
+ }
+ } catch { /* try next */ }
}
+ console.warn('[Memory] No valid hot memory found — starting fresh');
+ return { runs: [], alertedSignals: {} };
}
+ /**
+ * Atomic write: write to .tmp, then rename over target.
+ * Keeps a .bak of the previous version for crash recovery.
+ */
_saveHot() {
+ const tmpPath = this.hotPath + '.tmp';
+ const bakPath = this.hotPath + '.bak';
try {
- writeFileSync(this.hotPath, JSON.stringify(this.hot, null, 2));
+ // 1. Write to temp file (if this crashes, original is untouched)
+ writeFileSync(tmpPath, JSON.stringify(this.hot, null, 2));
+
+ // 2. Back up current file (if it exists)
+ try {
+ if (existsSync(this.hotPath)) {
+ // Copy current → .bak (overwrite previous backup)
+ renameSync(this.hotPath, bakPath);
+ }
+ } catch { /* backup failure is non-fatal */ }
+
+ // 3. Atomic rename: .tmp → hot.json
+ renameSync(tmpPath, this.hotPath);
} catch (err) {
console.error('[Memory] Failed to save hot memory:', err.message);
+ // Clean up tmp if it exists
+ try { unlinkSync(tmpPath); } catch { }
}
}
@@ -79,21 +112,78 @@ export class MemoryManager {
return this.hot.runs[0].delta;
}
- // Track what signals have been alerted on
+ // ─── Alert Signal Tracking (Decay-Based) ───────────────────────────────
+
getAlertedSignals() {
return this.hot.alertedSignals || {};
}
+ /**
+ * Check if a signal should be suppressed based on decay-based cooldown.
+ * Returns true if the signal is still in cooldown.
+ */
+ isSignalSuppressed(signalKey) {
+ const entry = this.hot.alertedSignals[signalKey];
+ if (!entry) return false;
+
+ const now = Date.now();
+ const occurrences = typeof entry === 'object' ? (entry.count || 1) : 1;
+ const lastAlerted = typeof entry === 'object' ? new Date(entry.lastAlerted).getTime() : new Date(entry).getTime();
+
+ // Pick cooldown tier based on how many times this signal has fired
+ const tierIndex = Math.min(occurrences, ALERT_DECAY_TIERS.length - 1);
+ const cooldownHours = ALERT_DECAY_TIERS[tierIndex];
+ const cooldownMs = cooldownHours * 60 * 60 * 1000;
+
+ return (now - lastAlerted) < cooldownMs;
+ }
+
+ /**
+ * Mark a signal as alerted, incrementing its occurrence counter.
+ * Supports both legacy (string timestamp) and new (object with count) formats.
+ */
markAsAlerted(signalKey, timestamp) {
- this.hot.alertedSignals[signalKey] = timestamp || new Date().toISOString();
+ const now = timestamp || new Date().toISOString();
+ const existing = this.hot.alertedSignals[signalKey];
+
+ if (existing && typeof existing === 'object') {
+ // Increment existing
+ existing.count = (existing.count || 1) + 1;
+ existing.lastAlerted = now;
+ existing.firstSeen = existing.firstSeen || now;
+ } else {
+ // New entry (or migrate from legacy string format)
+ this.hot.alertedSignals[signalKey] = {
+ firstSeen: typeof existing === 'string' ? existing : now,
+ lastAlerted: now,
+ count: typeof existing === 'string' ? 2 : 1,
+ };
+ }
this._saveHot();
}
- // Clean up old alerted signals (older than 24h)
+ /**
+ * Prune stale alerted signals.
+ * Signals with 1 occurrence: pruned after 24h.
+ * Signals with 2+ occurrences: pruned after 48h from last alert.
+ * This prevents infinite accumulation while keeping recurring signal awareness.
+ */
pruneAlertedSignals() {
- const cutoff = Date.now() - 24 * 60 * 60 * 1000;
- for (const [key, ts] of Object.entries(this.hot.alertedSignals)) {
- if (new Date(ts).getTime() < cutoff) {
+ const now = Date.now();
+ for (const [key, entry] of Object.entries(this.hot.alertedSignals)) {
+ let lastTime, count;
+
+ if (typeof entry === 'object') {
+ lastTime = new Date(entry.lastAlerted).getTime();
+ count = entry.count || 1;
+ } else {
+ // Legacy string format
+ lastTime = new Date(entry).getTime();
+ count = 1;
+ }
+
+ const maxAge = count >= 2 ? 48 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
+ if ((now - lastTime) > maxAge) {
delete this.hot.alertedSignals[key];
}
}
@@ -116,6 +206,7 @@ export class MemoryManager {
who: (data.who || []).map(w => ({ title: w.title })),
acled: { totalEvents: data.acled?.totalEvents, totalFatalities: data.acled?.totalFatalities },
sdr: { total: data.sdr?.total, online: data.sdr?.online },
+ news: { count: data.news?.length || 0 },
ideas: (data.ideas || []).map(i => ({ title: i.title, type: i.type, confidence: i.confidence })),
};
}
@@ -130,10 +221,14 @@ export class MemoryManager {
try { existing = JSON.parse(readFileSync(coldPath, 'utf8')); } catch { }
existing.push(...runs);
+ // Use atomic write for cold storage too
+ const tmpPath = coldPath + '.tmp';
try {
- writeFileSync(coldPath, JSON.stringify(existing, null, 2));
+ writeFileSync(tmpPath, JSON.stringify(existing, null, 2));
+ renameSync(tmpPath, coldPath);
} catch (err) {
console.error('[Memory] Failed to archive to cold storage:', err.message);
+ try { unlinkSync(tmpPath); } catch { }
}
}
}
diff --git a/lib/llm/anthropic.mjs b/lib/llm/anthropic.mjs
index 075bb5c..2e95247 100644
--- a/lib/llm/anthropic.mjs
+++ b/lib/llm/anthropic.mjs
@@ -7,7 +7,7 @@ export class AnthropicProvider extends LLMProvider {
super(config);
this.name = 'anthropic';
this.apiKey = config.apiKey;
- this.model = config.model || 'claude-sonnet-4-20250514';
+ this.model = config.model || 'claude-sonnet-4-6';
}
get isConfigured() { return !!this.apiKey; }
diff --git a/lib/llm/codex.mjs b/lib/llm/codex.mjs
index 98b3b3d..ad974ef 100644
--- a/lib/llm/codex.mjs
+++ b/lib/llm/codex.mjs
@@ -1,6 +1,6 @@
// OpenAI Codex Provider — uses ChatGPT subscription via chatgpt.com/backend-api/codex/responses
// Auth: reads ~/.codex/auth.json (created by `npx @openai/codex login`)
-// SSE streaming, codex-specific models only (gpt-5.2-codex, gpt-5.3-codex)
+// SSE streaming, codex-specific models only (gpt-5.3-codex, gpt-5.3-codex-spark)
import { readFileSync } from 'fs';
import { join } from 'path';
@@ -14,7 +14,7 @@ export class CodexProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'codex';
- this.model = config.model || 'gpt-5.2-codex';
+ this.model = config.model || 'gpt-5.3-codex';
this._creds = null;
}
diff --git a/lib/llm/gemini.mjs b/lib/llm/gemini.mjs
index 9d4ed6e..04cf65d 100644
--- a/lib/llm/gemini.mjs
+++ b/lib/llm/gemini.mjs
@@ -7,7 +7,7 @@ export class GeminiProvider extends LLMProvider {
super(config);
this.name = 'gemini';
this.apiKey = config.apiKey;
- this.model = config.model || 'gemini-2.0-flash';
+ this.model = config.model || 'gemini-3.1-pro';
}
get isConfigured() { return !!this.apiKey; }
diff --git a/lib/llm/openai.mjs b/lib/llm/openai.mjs
index 7161215..8db5987 100644
--- a/lib/llm/openai.mjs
+++ b/lib/llm/openai.mjs
@@ -7,7 +7,7 @@ export class OpenAIProvider extends LLMProvider {
super(config);
this.name = 'openai';
this.apiKey = config.apiKey;
- this.model = config.model || 'gpt-4o';
+ this.model = config.model || 'gpt-5.4';
}
get isConfigured() { return !!this.apiKey; }
@@ -21,7 +21,7 @@ export class OpenAIProvider extends LLMProvider {
},
body: JSON.stringify({
model: this.model,
- max_tokens: opts.maxTokens || 4096,
+ max_completion_tokens: opts.maxTokens || 4096,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
diff --git a/package.json b/package.json
index 87d96f5..2ac3593 100644
--- a/package.json
+++ b/package.json
@@ -4,11 +4,12 @@
"description": "Local intelligence engine — 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.",
"type": "module",
"scripts": {
- "dev": "node server.mjs",
+ "dev": "node --trace-warnings server.mjs",
"sweep": "node apis/briefing.mjs",
"inject": "node dashboard/inject.mjs",
"brief": "node apis/briefing.mjs",
- "brief:save": "node apis/save-briefing.mjs"
+ "brief:save": "node apis/save-briefing.mjs",
+ "diag": "node diag.mjs"
},
"keywords": ["osint", "intelligence", "dashboard", "geopolitical"],
"author": "Crucix",
@@ -18,5 +19,8 @@
},
"dependencies": {
"express": "^5.1.0"
+ },
+ "optionalDependencies": {
+ "discord.js": "^14.25.0"
}
}
diff --git a/server.mjs b/server.mjs
index 049aae0..f6173f2 100644
--- a/server.mjs
+++ b/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);
+});