Crucix — agent with dashboard, delta engine, Telegram/Discord bots
This commit is contained in:
549
lib/alerts/discord.mjs
Normal file
549
lib/alerts/discord.mjs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>} - 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<boolean>} - 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');
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user