Crucix — agent with dashboard, delta engine, Telegram/Discord bots
This commit is contained in:
@@ -21,7 +21,7 @@ REFRESH_INTERVAL_MINUTES=15 # Auto-refresh interval (minutes)
|
|||||||
# Provider options: anthropic | openai | gemini | codex
|
# Provider options: anthropic | openai | gemini | codex
|
||||||
LLM_PROVIDER=
|
LLM_PROVIDER=
|
||||||
LLM_API_KEY= # Not needed for codex (uses ~/.codex/auth.json)
|
LLM_API_KEY= # Not needed for codex (uses ~/.codex/auth.json)
|
||||||
LLM_MODEL= # Defaults: claude-sonnet-4-20250514 / gpt-4o / gemini-2.0-flash / gpt-5.2-codex
|
LLM_MODEL= # Optional override. Each provider has a sensible default (claude-sonnet-4-6 / gpt-5.4 / gemini-3.1-pro / gpt-5.3-codex)
|
||||||
|
|
||||||
# === Telegram Alerts (optional, requires LLM) ===
|
# === Telegram Alerts (optional, requires LLM) ===
|
||||||
# Create a bot via @BotFather, get chat ID via @userinfobot
|
# Create a bot via @BotFather, get chat ID via @userinfobot
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -23,6 +23,10 @@ desktop.ini
|
|||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Launch playbook (internal, not for public repo)
|
||||||
|
LAUNCH_PLAYBOOK.md
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
@@ -30,3 +34,12 @@ desktop.ini
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Package lock (optional — remove this line if you want deterministic installs)
|
||||||
|
# package-lock.json
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
|||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files first for better layer caching
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Default port (override with -e PORT=xxxx)
|
||||||
|
EXPOSE 3117
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3117/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
260
README.md
260
README.md
@@ -1,10 +1,43 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# Crucix
|
# Crucix
|
||||||
|
|
||||||
**Local intelligence engine. 26 OSINT sources. One command. Zero cloud dependency.**
|
**Your own intelligence terminal. 26 sources. One command. Zero cloud.**
|
||||||
|
|
||||||
Crucix aggregates open-source intelligence from 26 data sources in parallel — satellite fire detection, flight tracking, radiation monitoring, economic indicators, live market prices, conflict data, sanctions lists, social sentiment, and more — and renders it as a real-time Jarvis-style dashboard that auto-refreshes every 15 minutes.
|
[](#quick-start)
|
||||||
|
[](LICENSE)
|
||||||
|
[-orange)](#architecture)
|
||||||
|
[](#data-sources-26)
|
||||||
|
[](#docker)
|
||||||
|
|
||||||
Everything runs on your machine. No telemetry, no SaaS, no subscriptions required for core functionality.
|

|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|
| Boot Sequence | World Map |
|
||||||
|
|:---:|:---:|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 26 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
|
||||||
|
|
||||||
|
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
|
||||||
|
|
||||||
|
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Exists
|
||||||
|
|
||||||
|
Most of the world's real-time intelligence — satellite imagery, radiation levels, conflict events, economic indicators, flight tracking, maritime activity — is publicly available. It's just scattered across dozens of government APIs, research institutions, and open data feeds that nobody has time to check individually.
|
||||||
|
|
||||||
|
Crucix brings it all into one place. Not behind a paywall, not locked in an enterprise platform, not requiring a security clearance. Just open data, aggregated and cross-correlated on your own machine, updated every 15 minutes.
|
||||||
|
|
||||||
|
It was built for anyone who wants to understand what's actually happening in the world right now — researchers, journalists, traders, OSINT analysts, or just curious people who believe access to information shouldn't depend on your budget.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,10 +58,27 @@ cp .env.example .env
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The dashboard opens automatically at `http://localhost:3117`, runs the first intelligence sweep, and auto-refreshes every 15 minutes via SSE (Server-Sent Events). No manual page refresh needed.
|
> **If `npm run dev` fails silently** (exits with no output), run Node directly instead:
|
||||||
|
> ```bash
|
||||||
|
> node --trace-warnings server.mjs
|
||||||
|
> ```
|
||||||
|
> This bypasses npm's script runner, which can swallow errors on some systems (particularly PowerShell on Windows). You can also run `node diag.mjs` to diagnose the exact issue — it checks your Node version, tests each module import individually, and verifies port availability. See [Troubleshooting](#troubleshooting) for more.
|
||||||
|
|
||||||
|
The dashboard opens automatically at `http://localhost:3117` and immediately begins its first intelligence sweep. This initial sweep queries all 26 sources in parallel and typically takes 30–60 seconds — the dashboard will appear empty until the sweep completes and pushes the first data update. After that, it auto-refreshes every 15 minutes via SSE (Server-Sent Events). No manual page refresh needed.
|
||||||
|
|
||||||
**Requirements:** Node.js 22+ (uses native `fetch`, top-level `await`, ESM)
|
**Requirements:** Node.js 22+ (uses native `fetch`, top-level `await`, ESM)
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/crucix.git
|
||||||
|
cd crucix
|
||||||
|
cp .env.example .env # add your API keys
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard at `http://localhost:3117`. Sweep data persists in `./runs/` via volume mount. Includes a health check endpoint.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What You Get
|
## What You Get
|
||||||
@@ -39,8 +89,10 @@ A self-contained Jarvis-style HUD with:
|
|||||||
- **Region filters** (World, Americas, Europe, Middle East, Asia Pacific, Africa) with smooth zoom transitions
|
- **Region filters** (World, Americas, Europe, Middle East, Asia Pacific, Africa) with smooth zoom transitions
|
||||||
- **Live market data** — indexes, crypto, energy, commodities via Yahoo Finance (no API key needed)
|
- **Live market data** — indexes, crypto, energy, commodities via Yahoo Finance (no API key needed)
|
||||||
- **Risk gauges** — VIX, high-yield spread, supply chain pressure index
|
- **Risk gauges** — VIX, high-yield spread, supply chain pressure index
|
||||||
- **OSINT feed** — English-language posts from 12 Telegram intelligence channels
|
- **OSINT feed** — English-language posts from 17 Telegram intelligence channels (expandable)
|
||||||
- **News ticker** — merged RSS + GDELT headlines + Telegram posts, auto-scrolling
|
- **News ticker** — merged RSS + GDELT headlines + Telegram posts, auto-scrolling
|
||||||
|
- **Sweep delta** — live panel showing what changed since last sweep (new signals, escalations, de-escalations with severity)
|
||||||
|
- **Cross-source signals** — correlated intelligence across satellite, economic, conflict, and social domains
|
||||||
- **Nuclear watch** — real-time radiation readings from Safecast + EPA RadNet
|
- **Nuclear watch** — real-time radiation readings from Safecast + EPA RadNet
|
||||||
- **Leverageable ideas** — AI-generated trade ideas (with LLM) or signal-correlated ideas (without)
|
- **Leverageable ideas** — AI-generated trade ideas (with LLM) or signal-correlated ideas (without)
|
||||||
|
|
||||||
@@ -48,17 +100,52 @@ A self-contained Jarvis-style HUD with:
|
|||||||
The server runs a sweep cycle every 15 minutes (configurable). Each cycle:
|
The server runs a sweep cycle every 15 minutes (configurable). Each cycle:
|
||||||
1. Queries all 26 sources in parallel (~30s)
|
1. Queries all 26 sources in parallel (~30s)
|
||||||
2. Synthesizes raw data into dashboard format
|
2. Synthesizes raw data into dashboard format
|
||||||
3. Computes delta from previous run (what changed, escalated, de-escalated)
|
3. Computes delta from previous run (what changed, escalated, de-escalated) — visible in the **Sweep Delta** panel on the dashboard
|
||||||
4. Generates LLM trade ideas (if configured)
|
4. Generates LLM trade ideas (if configured)
|
||||||
5. Evaluates Telegram breaking news alerts (if configured)
|
5. Evaluates breaking news alerts — multi-tier (FLASH / PRIORITY / ROUTINE) with semantic dedup. Sends to Telegram and/or Discord if configured. Works with LLM evaluation or falls back to rule-based alerting when LLM is unavailable.
|
||||||
6. Pushes update to all connected browsers via SSE
|
6. Pushes update to all connected browsers via SSE
|
||||||
|
|
||||||
|
### Telegram Bot (Two-Way)
|
||||||
|
Crucix doubles as an interactive Telegram bot. Beyond sending alerts, it responds to commands directly from your chat:
|
||||||
|
|
||||||
|
| Command | What It Does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/status` | System health, last sweep time, source status, LLM status |
|
||||||
|
| `/sweep` | Trigger a manual sweep cycle |
|
||||||
|
| `/brief` | Compact text summary of the latest intelligence (direction, key metrics, top OSINT) |
|
||||||
|
| `/portfolio` | Portfolio status (if Alpaca connected) |
|
||||||
|
| `/alerts` | Recent alert history with tiers |
|
||||||
|
| `/mute` / `/mute 2h` | Silence alerts for 1h (or custom duration) |
|
||||||
|
| `/unmute` | Resume alerts |
|
||||||
|
| `/help` | Show all available commands |
|
||||||
|
|
||||||
|
This requires `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` in `.env`. The bot polls for messages every 5 seconds (configurable via `TELEGRAM_POLL_INTERVAL`).
|
||||||
|
|
||||||
|
### Discord Bot (Two-Way)
|
||||||
|
|
||||||
|
Crucix also supports Discord as a full-featured bot with slash commands and rich embed alerts. It mirrors the Telegram bot's capabilities with Discord-native formatting.
|
||||||
|
|
||||||
|
| Command | What It Does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/status` | System health, last sweep time, source status, LLM status |
|
||||||
|
| `/sweep` | Trigger a manual sweep cycle |
|
||||||
|
| `/brief` | Compact text summary of the latest intelligence |
|
||||||
|
| `/portfolio` | Portfolio status (if Alpaca connected) |
|
||||||
|
|
||||||
|
Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, yellow for PRIORITY, blue for ROUTINE. Each embed includes signal details, confidence scores, and cross-domain correlations.
|
||||||
|
|
||||||
|
**Setup requires:** `DISCORD_BOT_TOKEN`, `DISCORD_CHANNEL_ID`, and optionally `DISCORD_GUILD_ID` for instant slash command registration. See [API Keys Setup](#api-keys-setup) for details.
|
||||||
|
|
||||||
|
**Webhook fallback:** If you don't want to run a full bot, set `DISCORD_WEBHOOK_URL` instead. This enables one-way alerts (no slash commands) with zero dependencies — no `discord.js` needed.
|
||||||
|
|
||||||
|
**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode.
|
||||||
|
|
||||||
### Optional LLM Layer
|
### Optional LLM Layer
|
||||||
Connect any of 4 LLM providers for enhanced analysis:
|
Connect any of 4 LLM providers for enhanced analysis:
|
||||||
- **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data
|
- **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data
|
||||||
- **Breaking news alerts** — Telegram notifications when critical signals emerge
|
- **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring
|
||||||
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription)
|
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenAI Codex (ChatGPT subscription)
|
||||||
- Graceful fallback — LLM failures never crash the sweep cycle
|
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -94,19 +181,40 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`
|
|||||||
|
|
||||||
| Provider | Key Required | Default Model |
|
| Provider | Key Required | Default Model |
|
||||||
|----------|-------------|---------------|
|
|----------|-------------|---------------|
|
||||||
| `anthropic` | `LLM_API_KEY` | claude-sonnet-4-20250514 |
|
| `anthropic` | `LLM_API_KEY` | claude-sonnet-4-6 |
|
||||||
| `openai` | `LLM_API_KEY` | gpt-4o |
|
| `openai` | `LLM_API_KEY` | gpt-5.4 |
|
||||||
| `gemini` | `LLM_API_KEY` | gemini-2.0-flash |
|
| `gemini` | `LLM_API_KEY` | gemini-3.1-pro |
|
||||||
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.2-codex |
|
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex |
|
||||||
|
|
||||||
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
|
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
|
||||||
|
|
||||||
### Telegram Alerts (optional, requires LLM)
|
### Telegram Bot + Alerts (optional)
|
||||||
|
|
||||||
| Key | How to Get |
|
| Key | How to Get |
|
||||||
|-----|------------|
|
|-----|------------|
|
||||||
| `TELEGRAM_BOT_TOKEN` | Create via [@BotFather](https://t.me/BotFather) on Telegram |
|
| `TELEGRAM_BOT_TOKEN` | Create via [@BotFather](https://t.me/BotFather) on Telegram |
|
||||||
| `TELEGRAM_CHAT_ID` | Get via [@userinfobot](https://t.me/userinfobot) |
|
| `TELEGRAM_CHAT_ID` | Get via [@userinfobot](https://t.me/userinfobot) |
|
||||||
|
| `TELEGRAM_CHANNELS` | *(Optional)* Comma-separated extra channel IDs to monitor beyond the 17 built-in channels |
|
||||||
|
| `TELEGRAM_POLL_INTERVAL` | *(Optional)* Bot command polling interval in ms (default: 5000) |
|
||||||
|
|
||||||
|
### Discord Bot + Alerts (optional)
|
||||||
|
|
||||||
|
| Key | How to Get |
|
||||||
|
|-----|------------|
|
||||||
|
| `DISCORD_BOT_TOKEN` | Create at [Discord Developer Portal](https://discord.com/developers/applications) → Bot → Token |
|
||||||
|
| `DISCORD_CHANNEL_ID` | Right-click channel in Discord (Developer Mode on) → Copy Channel ID |
|
||||||
|
| `DISCORD_GUILD_ID` | *(Optional)* Right-click server → Copy Server ID. Enables instant slash command registration (otherwise takes up to 1 hour for global commands) |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | *(Optional)* Channel Settings → Integrations → Webhooks → New Webhook → Copy URL. Use this for alert-only mode without a bot |
|
||||||
|
|
||||||
|
**Discord bot setup:**
|
||||||
|
1. Go to [Discord Developer Portal](https://discord.com/developers/applications) and create a new application
|
||||||
|
2. Go to **Bot** → click **Reset Token** → copy the token to `DISCORD_BOT_TOKEN`
|
||||||
|
3. Under **Privileged Gateway Intents**, enable **Message Content Intent**
|
||||||
|
4. Go to **OAuth2** → **URL Generator** → select `bot` + `applications.commands` scopes → select `Send Messages` + `Embed Links` permissions
|
||||||
|
5. Copy the generated URL and open it in your browser to invite the bot to your server
|
||||||
|
6. Install the dependency: `npm install discord.js`
|
||||||
|
|
||||||
|
Alerts work with or without an LLM on both Telegram and Discord. With an LLM configured, signal evaluation is richer and more context-aware. Without one, a deterministic rule engine evaluates signals based on severity, cross-domain correlation, and signal counts.
|
||||||
|
|
||||||
### Without Any Keys
|
### Without Any Keys
|
||||||
|
|
||||||
@@ -118,10 +226,12 @@ Crucix still works with zero API keys. 18+ sources require no authentication at
|
|||||||
|
|
||||||
```
|
```
|
||||||
crucix/
|
crucix/
|
||||||
├── server.mjs # Express dev server (SSE, auto-refresh, LLM orchestration)
|
├── server.mjs # Express dev server (SSE, auto-refresh, LLM, bot commands)
|
||||||
├── crucix.config.mjs # Configuration with env var overrides
|
├── crucix.config.mjs # Configuration with env var overrides + delta thresholds
|
||||||
|
├── diag.mjs # Diagnostic script — run if server fails to start
|
||||||
├── .env.example # All documented env vars
|
├── .env.example # All documented env vars
|
||||||
├── package.json # Single dependency: express
|
├── package.json # Runtime: express | Optional: discord.js
|
||||||
|
├── docs/ # Screenshots for README
|
||||||
│
|
│
|
||||||
├── apis/
|
├── apis/
|
||||||
│ ├── briefing.mjs # Master orchestrator — runs all 26 sources in parallel
|
│ ├── briefing.mjs # Master orchestrator — runs all 26 sources in parallel
|
||||||
@@ -152,20 +262,21 @@ crucix/
|
|||||||
│ │ ├── ideas.mjs # LLM-powered trade idea generation
|
│ │ ├── ideas.mjs # LLM-powered trade idea generation
|
||||||
│ │ └── index.mjs # Factory: createLLMProvider()
|
│ │ └── index.mjs # Factory: createLLMProvider()
|
||||||
│ ├── delta/ # Change tracking between sweeps
|
│ ├── delta/ # Change tracking between sweeps
|
||||||
│ │ ├── engine.mjs # Delta computation (new/escalated/de-escalated/removed)
|
│ │ ├── engine.mjs # Delta computation — semantic dedup, configurable thresholds, severity scoring
|
||||||
│ │ ├── memory.mjs # Hot memory (3 runs) + cold storage (daily archives)
|
│ │ ├── memory.mjs # Hot memory (3 runs, atomic writes) + cold storage (daily archives)
|
||||||
│ │ └── index.mjs # Re-exports
|
│ │ └── index.mjs # Re-exports
|
||||||
│ └── alerts/
|
│ └── alerts/
|
||||||
│ └── telegram.mjs # Breaking news alerts via Telegram
|
│ ├── telegram.mjs # Multi-tier alerts (FLASH/PRIORITY/ROUTINE) + two-way bot commands
|
||||||
|
│ └── discord.mjs # Discord bot (slash commands, rich embeds) + webhook fallback
|
||||||
│
|
│
|
||||||
└── runs/ # Runtime data (gitignored)
|
└── runs/ # Runtime data (gitignored)
|
||||||
├── latest.json # Most recent sweep output
|
├── latest.json # Most recent sweep output
|
||||||
└── memory/ # Delta memory (hot + cold storage)
|
└── memory/ # Delta memory (hot.json + cold/YYYY-MM-DD.json)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Design Principles
|
### Design Principles
|
||||||
- **Pure ESM** — every file is `.mjs` with explicit imports
|
- **Pure ESM** — every file is `.mjs` with explicit imports
|
||||||
- **Minimal dependencies** — Express is the only runtime dependency. LLM providers use raw `fetch()`, no SDKs.
|
- **Minimal dependencies** — Express is the only runtime dependency. `discord.js` is optional (for Discord bot). LLM providers use raw `fetch()`, no SDKs.
|
||||||
- **Parallel execution** — `Promise.allSettled()` fires all 26 sources simultaneously
|
- **Parallel execution** — `Promise.allSettled()` fires all 26 sources simultaneously
|
||||||
- **Graceful degradation** — missing keys produce errors, not crashes. LLM failures don't kill sweeps.
|
- **Graceful degradation** — missing keys produce errors, not crashes. LLM failures don't kill sweeps.
|
||||||
- **Each source is standalone** — run `node apis/sources/gdelt.mjs` to test any source independently
|
- **Each source is standalone** — run `node apis/sources/gdelt.mjs` to test any source independently
|
||||||
@@ -212,7 +323,7 @@ crucix/
|
|||||||
| **USPTO Patents** | Patent filings in 7 strategic tech areas | None |
|
| **USPTO Patents** | Patent filings in 7 strategic tech areas | None |
|
||||||
| **Bluesky** | Social sentiment on geopolitical/market topics | None |
|
| **Bluesky** | Social sentiment on geopolitical/market topics | None |
|
||||||
| **Reddit** | Social sentiment from key subreddits | OAuth |
|
| **Reddit** | Social sentiment from key subreddits | OAuth |
|
||||||
| **Telegram** | 12 curated OSINT/conflict channels (web scraping) | None |
|
| **Telegram** | 17 curated OSINT/conflict/finance channels (web scraping, expandable via config) | None |
|
||||||
| **KiwiSDR** | Global HF radio receiver network (~600 receivers) | None |
|
| **KiwiSDR** | Global HF radio receiver network (~600 receivers) | None |
|
||||||
|
|
||||||
### Tier 4: Live Market Data (1)
|
### Tier 4: Live Market Data (1)
|
||||||
@@ -227,10 +338,11 @@ crucix/
|
|||||||
|
|
||||||
| Script | Command | Description |
|
| Script | Command | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| `npm run dev` | `node server.mjs` | Start dashboard with auto-refresh |
|
| `npm run dev` | `node --trace-warnings server.mjs` | Start dashboard with auto-refresh |
|
||||||
| `npm run sweep` | `node apis/briefing.mjs` | Run a single sweep, output JSON to stdout |
|
| `npm run sweep` | `node apis/briefing.mjs` | Run a single sweep, output JSON to stdout |
|
||||||
| `npm run inject` | `node dashboard/inject.mjs` | Inject latest data into static HTML |
|
| `npm run inject` | `node dashboard/inject.mjs` | Inject latest data into static HTML |
|
||||||
| `npm run brief:save` | `node apis/save-briefing.mjs` | Run sweep + save timestamped JSON |
|
| `npm run brief:save` | `node apis/save-briefing.mjs` | Run sweep + save timestamped JSON |
|
||||||
|
| `npm run diag` | `node diag.mjs` | Run diagnostics (Node version, imports, port check) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,8 +357,16 @@ All settings are in `.env` with sensible defaults:
|
|||||||
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, or `codex` |
|
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, or `codex` |
|
||||||
| `LLM_API_KEY` | — | API key (not needed for codex) |
|
| `LLM_API_KEY` | — | API key (not needed for codex) |
|
||||||
| `LLM_MODEL` | per-provider default | Override model selection |
|
| `LLM_MODEL` | per-provider default | Override model selection |
|
||||||
| `TELEGRAM_BOT_TOKEN` | disabled | For breaking news alerts |
|
| `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands |
|
||||||
| `TELEGRAM_CHAT_ID` | — | Your Telegram chat ID |
|
| `TELEGRAM_CHAT_ID` | — | Your Telegram chat ID |
|
||||||
|
| `TELEGRAM_CHANNELS` | — | Extra channel IDs to monitor (comma-separated) |
|
||||||
|
| `TELEGRAM_POLL_INTERVAL` | `5000` | Bot command polling interval (ms) |
|
||||||
|
| `DISCORD_BOT_TOKEN` | disabled | For Discord alerts + slash commands |
|
||||||
|
| `DISCORD_CHANNEL_ID` | — | Discord channel for alerts |
|
||||||
|
| `DISCORD_GUILD_ID` | — | Server ID (instant slash command registration) |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | — | Webhook URL (alert-only fallback, no bot needed) |
|
||||||
|
|
||||||
|
Delta engine thresholds (how sensitive the system is to changes between sweeps) can be customized in `crucix.config.mjs` under the `delta.thresholds` section. The defaults are tuned to filter out noise while catching meaningful moves.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -263,6 +383,96 @@ When running `npm run dev`:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `npm run dev` exits silently (no output, no error)
|
||||||
|
|
||||||
|
This is a known issue where npm's script runner can swallow errors, particularly on Windows PowerShell. Try these in order:
|
||||||
|
|
||||||
|
**1. Run Node directly (bypasses npm):**
|
||||||
|
```bash
|
||||||
|
node --trace-warnings server.mjs
|
||||||
|
```
|
||||||
|
This is functionally identical to `npm run dev` but gives you full error output.
|
||||||
|
|
||||||
|
**2. Run the diagnostic script:**
|
||||||
|
```bash
|
||||||
|
node diag.mjs
|
||||||
|
```
|
||||||
|
This tests every import one by one, checks your Node.js version, and verifies port 3117 is available. It will tell you exactly what's failing.
|
||||||
|
|
||||||
|
**3. Check if port 3117 is already in use:**
|
||||||
|
|
||||||
|
A previous Crucix instance may still be running in the background.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows PowerShell
|
||||||
|
netstat -ano | findstr 3117
|
||||||
|
taskkill /F /PID <the_PID_from_above>
|
||||||
|
|
||||||
|
# Or kill all Node processes
|
||||||
|
taskkill /F /IM node.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS / Linux
|
||||||
|
lsof -ti:3117 | xargs kill
|
||||||
|
```
|
||||||
|
|
||||||
|
Then try starting again. You can also change the port by setting `PORT=3118` in your `.env` file.
|
||||||
|
|
||||||
|
**4. Check Node.js version:**
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
```
|
||||||
|
Crucix requires Node.js 22 or later. If you have an older version, download the latest LTS from [nodejs.org](https://nodejs.org/).
|
||||||
|
|
||||||
|
### Dashboard shows empty panels after first start
|
||||||
|
|
||||||
|
This is normal — the first sweep takes 30–60 seconds to query all 26 sources. The dashboard will populate automatically once the sweep completes. Check the terminal for sweep progress logs.
|
||||||
|
|
||||||
|
### Some sources show errors
|
||||||
|
|
||||||
|
Expected behavior. Sources that require API keys will return structured errors if the key isn't set. The rest of the sweep continues normally. Check the Source Integrity section in the dashboard (or the server logs) to see which sources failed and why. The 3 most impactful free keys to add are `FRED_API_KEY`, `FIRMS_MAP_KEY`, and `EIA_API_KEY`.
|
||||||
|
|
||||||
|
### Telegram bot not responding to commands
|
||||||
|
|
||||||
|
Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see `[Crucix] Telegram alerts enabled` and `[Crucix] Bot command polling started` in the server logs on startup. If not, double-check your token with `curl https://api.telegram.org/bot<YOUR_TOKEN>/getMe`.
|
||||||
|
|
||||||
|
### Discord bot not responding to slash commands
|
||||||
|
|
||||||
|
Check these in order:
|
||||||
|
1. Make sure `DISCORD_BOT_TOKEN` and `DISCORD_CHANNEL_ID` are set in `.env`
|
||||||
|
2. Verify `discord.js` is installed: `npm ls discord.js`. If missing, run `npm install discord.js`
|
||||||
|
3. If slash commands don't appear, set `DISCORD_GUILD_ID` — without it, global commands can take up to 1 hour to propagate. Guild-specific commands register instantly
|
||||||
|
4. Confirm the bot was invited with `bot` + `applications.commands` scopes and has `Send Messages` + `Embed Links` permissions in the target channel
|
||||||
|
5. Check server logs for `[Discord] Bot logged in as ...` on startup. If you see `[Discord] discord.js not installed`, install it and restart
|
||||||
|
6. **Webhook-only fallback:** If you just want alerts without slash commands, set `DISCORD_WEBHOOK_URL` instead of the bot token. No `discord.js` needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
The `docs/` folder contains dashboard screenshots referenced by this README:
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `docs/dashboard.png` | Full dashboard — hero image at the top of this README |
|
||||||
|
| `docs/boot.png` | Cinematic boot sequence animation |
|
||||||
|
| `docs/map.png` | D3 world map with marker types and flight arcs |
|
||||||
|
|
||||||
|
To update them: run the dashboard, wait for a sweep to complete, then use your browser's DevTools (`F12` → `Ctrl+Shift+P` → "Capture full size screenshot") or a tool like [LICEcap](https://www.cockos.com/licecap/) for GIFs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Found a bug? Want to add a 27th source? PRs welcome. Each source is a standalone module in `apis/sources/` — just export a `briefing()` function that returns structured data and add it to the orchestrator in `apis/briefing.mjs`.
|
||||||
|
|
||||||
|
If you find this useful, a star helps others find it too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|||||||
|
|
||||||
// Curated list of well-known public OSINT / conflict / geopolitics channels
|
// Curated list of well-known public OSINT / conflict / geopolitics channels
|
||||||
// All verified to have public web previews enabled at https://t.me/s/{id}
|
// All verified to have public web previews enabled at https://t.me/s/{id}
|
||||||
const CHANNELS = [
|
// Override with TELEGRAM_CHANNELS env var (comma-separated channel IDs)
|
||||||
|
const DEFAULT_CHANNELS = [
|
||||||
|
// === Conflict: Ukraine/Russia ===
|
||||||
{ id: 'intelslava', label: 'Intel Slava Z', topic: 'conflict', note: 'Conflict updates, pro-Russian perspective' },
|
{ id: 'intelslava', label: 'Intel Slava Z', topic: 'conflict', note: 'Conflict updates, pro-Russian perspective' },
|
||||||
{ id: 'legitimniy', label: 'Legitimniy', topic: 'conflict', note: 'Ukrainian politics & conflict analysis' },
|
{ id: 'legitimniy', label: 'Legitimniy', topic: 'conflict', note: 'Ukrainian politics & conflict analysis' },
|
||||||
{ id: 'wartranslated', label: 'War Translated', topic: 'conflict', note: 'Conflict translations & OSINT' },
|
{ id: 'wartranslated', label: 'War Translated', topic: 'conflict', note: 'Conflict translations & OSINT' },
|
||||||
{ id: 'ukraine_frontline', label: 'Ukraine Frontline', topic: 'conflict', note: 'Frontline situation updates' },
|
{ id: 'ukraine_frontline', label: 'Ukraine Frontline', topic: 'conflict', note: 'Frontline situation updates' },
|
||||||
{ id: 'middleeastosint', label: 'Middle East OSINT', topic: 'osint', note: 'Middle East open source intel' },
|
|
||||||
{ id: 'mod_russia', label: 'Russian MoD', topic: 'conflict', note: 'Russian Ministry of Defense official' },
|
{ id: 'mod_russia', label: 'Russian MoD', topic: 'conflict', note: 'Russian Ministry of Defense official' },
|
||||||
{ id: 'CIG_telegram', label: 'Conflict Intel Team', topic: 'osint', note: 'Conflict Intelligence Team analysis' },
|
{ id: 'CIG_telegram', label: 'Conflict Intel Team', topic: 'osint', note: 'Conflict Intelligence Team analysis' },
|
||||||
{ id: 'RVvoenkor', label: 'Voenkor RV', topic: 'conflict', note: 'Russian military correspondent' },
|
{ id: 'RVvoenkor', label: 'Voenkor RV', topic: 'conflict', note: 'Russian military correspondent' },
|
||||||
@@ -23,14 +24,54 @@ const CHANNELS = [
|
|||||||
{ id: 'DeepStateUA', label: 'DeepState Ukraine', topic: 'conflict', note: 'Ukrainian frontline maps & analysis' },
|
{ id: 'DeepStateUA', label: 'DeepState Ukraine', topic: 'conflict', note: 'Ukrainian frontline maps & analysis' },
|
||||||
{ id: 'operativnoZSU', label: 'ZSU Operative', topic: 'conflict', note: 'Ukrainian armed forces updates' },
|
{ id: 'operativnoZSU', label: 'ZSU Operative', topic: 'conflict', note: 'Ukrainian armed forces updates' },
|
||||||
{ id: 'GeneralStaffZSU', label: 'General Staff ZSU', topic: 'conflict', note: 'Ukrainian General Staff official' },
|
{ id: 'GeneralStaffZSU', label: 'General Staff ZSU', topic: 'conflict', note: 'Ukrainian General Staff official' },
|
||||||
|
// === Middle East ===
|
||||||
|
{ id: 'middleeastosint', label: 'Middle East OSINT', topic: 'osint', note: 'Middle East open source intel' },
|
||||||
|
{ id: 'inikiforv', label: 'Nikiforov OSINT', topic: 'osint', note: 'Cross-regional OSINT analyst' },
|
||||||
|
// === Geopolitics & Analysis ===
|
||||||
|
{ id: 'geaborning', label: 'Geo A. Borning', topic: 'geopolitics', note: 'Geopolitical analysis and forecasting' },
|
||||||
|
{ id: 'TheIntelligencer', label: 'The Intelligencer', topic: 'osint', note: 'Intelligence community news' },
|
||||||
|
// === Markets & Finance ===
|
||||||
|
{ id: 'WallStreetSilver', label: 'Wall St Silver', topic: 'finance', note: 'Commodities and macro commentary' },
|
||||||
|
{ id: 'unusual_whales', label: 'Unusual Whales', topic: 'finance', note: 'Market flow and options analysis' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Allow user to add custom channels via env var
|
||||||
|
function loadChannels() {
|
||||||
|
const custom = process.env.TELEGRAM_CHANNELS;
|
||||||
|
if (!custom) return DEFAULT_CHANNELS;
|
||||||
|
|
||||||
|
const customIds = custom.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
const existing = new Set(DEFAULT_CHANNELS.map(c => c.id));
|
||||||
|
|
||||||
|
const extras = customIds
|
||||||
|
.filter(id => !existing.has(id))
|
||||||
|
.map(id => ({ id, label: id, topic: 'custom', note: 'User-added channel' }));
|
||||||
|
|
||||||
|
return [...DEFAULT_CHANNELS, ...extras];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNELS = loadChannels();
|
||||||
|
|
||||||
// Urgent keywords that flag high-priority posts
|
// Urgent keywords that flag high-priority posts
|
||||||
|
// Organized by domain for maintainability
|
||||||
const URGENT_KEYWORDS = [
|
const URGENT_KEYWORDS = [
|
||||||
'breaking', 'urgent', 'alert', 'missile', 'strike', 'explosion',
|
// Breaking / meta urgency
|
||||||
'nuclear', 'chemical', 'ceasefire', 'escalation', 'invasion',
|
'breaking', 'urgent', 'alert', 'confirmed', 'just in', 'flash',
|
||||||
'offensive', 'airstrike', 'casualties', 'retreat', 'advance',
|
// Military / kinetic
|
||||||
'nato', 'mobilization', 'coup', 'assassination', 'drone',
|
'missile', 'strike', 'explosion', 'airstrike', 'drone', 'bombardment',
|
||||||
|
'shelling', 'intercept', 'ICBM', 'hypersonic', 'F-16', 'ATACMS', 'HIMARS',
|
||||||
|
// Escalation / de-escalation
|
||||||
|
'nuclear', 'chemical', 'biological', 'ceasefire', 'escalation', 'invasion',
|
||||||
|
'offensive', 'retreat', 'advance', 'mobilization', 'martial law',
|
||||||
|
// Geopolitical
|
||||||
|
'nato', 'coup', 'assassination', 'sanctions', 'embargo', 'blockade',
|
||||||
|
'summit', 'ultimatum', 'declaration of war', 'peace deal',
|
||||||
|
// Casualty / humanitarian
|
||||||
|
'casualties', 'killed', 'wounded', 'evacuation', 'refugee', 'humanitarian',
|
||||||
|
// Infrastructure / cyber
|
||||||
|
'blackout', 'sabotage', 'cyberattack', 'pipeline', 'dam', 'nuclear plant',
|
||||||
|
// Financial crisis
|
||||||
|
'default', 'bank run', 'circuit breaker', 'flash crash', 'emergency rate',
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Bot API mode ───────────────────────────────────────────────────────────
|
// ─── Bot API mode ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -15,5 +15,30 @@ export default {
|
|||||||
telegram: {
|
telegram: {
|
||||||
botToken: process.env.TELEGRAM_BOT_TOKEN || null,
|
botToken: process.env.TELEGRAM_BOT_TOKEN || null,
|
||||||
chatId: process.env.TELEGRAM_CHAT_ID || null,
|
chatId: process.env.TELEGRAM_CHAT_ID || null,
|
||||||
|
botPollingInterval: parseInt(process.env.TELEGRAM_POLL_INTERVAL) || 5000,
|
||||||
|
channels: process.env.TELEGRAM_CHANNELS || null, // Comma-separated extra channel IDs
|
||||||
|
},
|
||||||
|
|
||||||
|
discord: {
|
||||||
|
botToken: process.env.DISCORD_BOT_TOKEN || null,
|
||||||
|
channelId: process.env.DISCORD_CHANNEL_ID || null,
|
||||||
|
guildId: process.env.DISCORD_GUILD_ID || null, // Server ID (for instant slash command registration)
|
||||||
|
webhookUrl: process.env.DISCORD_WEBHOOK_URL || null, // Fallback: webhook-only alerts (no bot needed)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delta engine thresholds — override defaults from lib/delta/engine.mjs
|
||||||
|
// Set to null to use built-in defaults
|
||||||
|
delta: {
|
||||||
|
thresholds: {
|
||||||
|
numeric: {
|
||||||
|
// Example overrides (uncomment to customize):
|
||||||
|
// vix: 3, // more sensitive to VIX moves
|
||||||
|
// wti: 5, // less sensitive to oil moves
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
// urgent_posts: 3, // need ±3 urgent posts to flag
|
||||||
|
// thermal_total: 1000, // need ±1000 thermal detections
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -467,7 +467,9 @@ async function cliInject() {
|
|||||||
console.log('Data injected into jarvis.html!');
|
console.log('Data injected into jarvis.html!');
|
||||||
|
|
||||||
// Auto-open dashboard in default browser
|
// Auto-open dashboard in default 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';
|
process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||||
const dashUrl = htmlPath.replace(/\\/g, '/');
|
const dashUrl = htmlPath.replace(/\\/g, '/');
|
||||||
exec(`${openCmd} "${dashUrl}"`, (err) => {
|
exec(`${openCmd} "${dashUrl}"`, (err) => {
|
||||||
|
|||||||
@@ -117,10 +117,15 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
|
|||||||
.news-icon{fill:rgba(129,212,250,0.8);filter:drop-shadow(0 0 3px rgba(129,212,250,0.4));transition:fill .2s}
|
.news-icon{fill:rgba(129,212,250,0.8);filter:drop-shadow(0 0 3px rgba(129,212,250,0.4));transition:fill .2s}
|
||||||
.news-icon:hover{fill:rgba(129,212,250,1)}
|
.news-icon:hover{fill:rgba(129,212,250,1)}
|
||||||
|
|
||||||
/* LOWER GRID */
|
/* LOWER GRID — flex layout for responsive panel sizing */
|
||||||
.lower{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-top:10px}
|
.lower{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;align-items:flex-start}
|
||||||
.lower-wide{grid-column:1/-1}
|
.lower .g-panel{min-width:0;box-sizing:border-box}
|
||||||
.metrics-row{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
|
.lower .lp-ticker{flex:1.2 1 240px;max-width:380px}
|
||||||
|
.lower .lp-delta{flex:1 1 200px;max-width:300px}
|
||||||
|
.lower .lp-macro{flex:2.5 1 360px}
|
||||||
|
.lower .lp-ideas{flex:1.5 1 300px}
|
||||||
|
.lower-wide{width:100%}
|
||||||
|
.metrics-row{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:6px}
|
||||||
.mc{padding:10px;border:1px solid rgba(255,255,255,0.05);background:rgba(255,255,255,0.02)}
|
.mc{padding:10px;border:1px solid rgba(255,255,255,0.05);background:rgba(255,255,255,0.02)}
|
||||||
.mc .ml{font-family:var(--mono);font-size:9px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)}
|
.mc .ml{font-family:var(--mono);font-size:9px;text-transform:uppercase;letter-spacing:0.08em;color:var(--dim)}
|
||||||
.mc .mv{font-family:var(--mono);font-size:18px;font-weight:600;margin-top:6px;display:block}
|
.mc .mv{font-family:var(--mono);font-size:18px;font-weight:600;margin-top:6px;display:block}
|
||||||
@@ -169,7 +174,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
|
|||||||
.disclosure{font-family:var(--mono);font-size:8px;color:rgba(106,138,130,0.6);line-height:1.4;padding:6px;border-top:1px solid rgba(255,255,255,0.04);margin-top:6px}
|
.disclosure{font-family:var(--mono);font-size:8px;color:rgba(106,138,130,0.6);line-height:1.4;padding:6px;border-top:1px solid rgba(255,255,255,0.04);margin-top:6px}
|
||||||
|
|
||||||
/* NEWS TICKER */
|
/* NEWS TICKER */
|
||||||
.ticker-wrap{overflow:hidden;max-height:280px;position:relative;border:1px solid rgba(100,240,200,0.08);background:rgba(0,0,0,0.15)}
|
.ticker-wrap{overflow:hidden;max-height:320px;position:relative;border:1px solid rgba(100,240,200,0.08);background:rgba(0,0,0,0.15)}
|
||||||
.ticker-wrap::before,.ticker-wrap::after{content:'';position:absolute;left:0;right:0;height:30px;z-index:2;pointer-events:none}
|
.ticker-wrap::before,.ticker-wrap::after{content:'';position:absolute;left:0;right:0;height:30px;z-index:2;pointer-events:none}
|
||||||
.ticker-wrap::before{top:0;background:linear-gradient(to bottom,rgba(14,17,22,0.95),transparent)}
|
.ticker-wrap::before{top:0;background:linear-gradient(to bottom,rgba(14,17,22,0.95),transparent)}
|
||||||
.ticker-wrap::after{bottom:0;background:linear-gradient(to top,rgba(14,17,22,0.95),transparent)}
|
.ticker-wrap::after{bottom:0;background:linear-gradient(to top,rgba(14,17,22,0.95),transparent)}
|
||||||
@@ -194,6 +199,11 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
|
|||||||
.delta-badge.up{color:#81c784;border:1px solid rgba(129,199,132,0.3);background:rgba(129,199,132,0.08)}
|
.delta-badge.up{color:#81c784;border:1px solid rgba(129,199,132,0.3);background:rgba(129,199,132,0.08)}
|
||||||
.delta-badge.down{color:#ef5350;border:1px solid rgba(239,83,80,0.3);background:rgba(239,83,80,0.08)}
|
.delta-badge.down{color:#ef5350;border:1px solid rgba(239,83,80,0.3);background:rgba(239,83,80,0.08)}
|
||||||
.delta-badge.new{color:#4dd0e1;border:1px solid rgba(77,208,225,0.3);background:rgba(77,208,225,0.08);animation:pulse-new 2s ease infinite}
|
.delta-badge.new{color:#4dd0e1;border:1px solid rgba(77,208,225,0.3);background:rgba(77,208,225,0.08);animation:pulse-new 2s ease infinite}
|
||||||
|
.delta-list{max-height:160px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(0,229,255,0.2) transparent}
|
||||||
|
.delta-row{display:flex;align-items:center;gap:6px;padding:3px 0;font-family:var(--mono);font-size:10px;border-bottom:1px solid rgba(255,255,255,0.04)}
|
||||||
|
.delta-row.new{background:rgba(77,208,225,0.04)}
|
||||||
|
.delta-label{flex:1;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.delta-val{color:var(--dim);font-size:9px;white-space:nowrap}
|
||||||
@keyframes pulse-new{0%,100%{opacity:0.7}50%{opacity:1}}
|
@keyframes pulse-new{0%,100%{opacity:0.7}50%{opacity:1}}
|
||||||
|
|
||||||
/* IDEAS SOURCE BADGE */
|
/* IDEAS SOURCE BADGE */
|
||||||
@@ -203,7 +213,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
|
|||||||
|
|
||||||
/* RESPONSIVE */
|
/* RESPONSIVE */
|
||||||
@media(max-width:1400px){.grid{grid-template-columns:240px 1fr 320px}.metrics-row{grid-template-columns:repeat(3,1fr)}.src-grid{grid-template-columns:repeat(3,1fr)}}
|
@media(max-width:1400px){.grid{grid-template-columns:240px 1fr 320px}.metrics-row{grid-template-columns:repeat(3,1fr)}.src-grid{grid-template-columns:repeat(3,1fr)}}
|
||||||
@media(max-width:1100px){.grid{grid-template-columns:1fr}.lower{grid-template-columns:1fr}.metrics-row{grid-template-columns:repeat(2,1fr)}.src-grid{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:1100px){.grid{grid-template-columns:1fr}.lower .lp-ticker,.lower .lp-delta,.lower .lp-macro,.lower .lp-ideas{flex:1 1 100%;max-width:none}.metrics-row{grid-template-columns:repeat(2,1fr)}.src-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
|
||||||
/* CONFLICT LAYER */
|
/* CONFLICT LAYER */
|
||||||
@keyframes pulse-conflict{0%,100%{opacity:0.5;stroke-width:1.5}50%{opacity:0.9;stroke-width:2.5}}
|
@keyframes pulse-conflict{0%,100%{opacity:0.5;stroke-width:1.5}50%{opacity:0.9;stroke-width:2.5}}
|
||||||
@@ -788,9 +798,27 @@ function renderLower(){
|
|||||||
const rateCards = (mkt.rates||[]).map(mktCard).join('');
|
const rateCards = (mkt.rates||[]).map(mktCard).join('');
|
||||||
const hasMarkets = indexCards || cryptoCards;
|
const hasMarkets = indexCards || cryptoCards;
|
||||||
|
|
||||||
const signals=D.tSignals.slice(0,6).map((s,i)=>`<div class="signal-row"><strong>Signal ${i+1}</strong><p>${s}</p></div>`).join('');
|
|
||||||
const srcHtml=D.health.map(s=>`<div class="src-item"><div class="sd ${s.err?'err':'ok'}"></div><span>${s.n}</span></div>`).join('');
|
const srcHtml=D.health.map(s=>`<div class="src-item"><div class="sd ${s.err?'err':'ok'}"></div><span>${s.n}</span></div>`).join('');
|
||||||
|
|
||||||
|
// NEWS TICKER — merges RSS + GDELT + Telegram into flowing cards (moved from right rail)
|
||||||
|
const feed = (D.newsFeed || []).slice(0, 40);
|
||||||
|
const srcClass = s => {
|
||||||
|
if (!s) return 'other';
|
||||||
|
const sl = s.toLowerCase();
|
||||||
|
if (sl.includes('bbc')) return 'bbc';
|
||||||
|
if (sl.includes('nyt') || sl.includes('times')) return 'nyt';
|
||||||
|
if (sl.includes('jazeera') || sl.includes('alj')) return 'alj';
|
||||||
|
if (sl.includes('gdelt')) return 'gdelt';
|
||||||
|
if (sl.includes('telegram')) return 'tg';
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
const tickerCards = feed.map(n => {
|
||||||
|
const sc = srcClass(n.source);
|
||||||
|
const age = n.timestamp ? getAge(n.timestamp) : '';
|
||||||
|
return `<div class="tk-card ${n.urgent?'urgent':''}"><span class="tk-src ${sc}">${(n.source||'NEWS').substring(0,12)}</span><span class="tk-time">${age}</span><div class="tk-head">${cleanText(n.headline||'')}</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
const tickerDuration = Math.max(20, feed.length * 2.5);
|
||||||
|
|
||||||
// Leverageable Ideas (LLM-only feature)
|
// Leverageable Ideas (LLM-only feature)
|
||||||
const hasIdeas = D.ideas && D.ideas.length > 0;
|
const hasIdeas = D.ideas && D.ideas.length > 0;
|
||||||
const ideasHtml = hasIdeas ? (D.ideas||[]).map(idea=>`
|
const ideasHtml = hasIdeas ? (D.ideas||[]).map(idea=>`
|
||||||
@@ -808,12 +836,47 @@ function renderLower(){
|
|||||||
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
|
<div style="font-size:9px;margin-top:6px;opacity:0.6">Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// DELTA PANEL — what changed since last sweep
|
||||||
|
const delta = D.delta || {};
|
||||||
|
const ds = delta.summary || {};
|
||||||
|
const hasDelta = ds.totalChanges > 0;
|
||||||
|
const dirEmoji = {'risk-off':'▲','risk-on':'▼','mixed':'◆'}[ds.direction]||'◆';
|
||||||
|
const dirClass = {'risk-off':'up','risk-on':'down','mixed':''}[ds.direction]||'';
|
||||||
|
const escalated = (delta.signals?.escalated || []).slice(0,6);
|
||||||
|
const deescalated = (delta.signals?.deescalated || []).slice(0,4);
|
||||||
|
const newSigs = (delta.signals?.new || []).slice(0,4);
|
||||||
|
const deltaRows = [];
|
||||||
|
for(const s of newSigs){
|
||||||
|
deltaRows.push(`<div class="delta-row new"><span class="delta-badge new">NEW</span><span class="delta-label">${s.reason||s.label||s.key}</span></div>`);
|
||||||
|
}
|
||||||
|
for(const s of escalated){
|
||||||
|
const sev = s.severity==='critical'?'style="color:var(--warn);font-weight:600"':s.severity==='high'?'style="color:#ffab40"':'';
|
||||||
|
const val = s.pctChange!==undefined?`${s.pctChange>0?'+':''}${s.pctChange}%`:`${s.change>0?'+':''}${s.change}`;
|
||||||
|
deltaRows.push(`<div class="delta-row"><span class="delta-badge up">▲</span><span class="delta-label" ${sev}>${s.label}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
||||||
|
}
|
||||||
|
for(const s of deescalated){
|
||||||
|
const val = s.pctChange!==undefined?`${s.pctChange}%`:`${s.change}`;
|
||||||
|
deltaRows.push(`<div class="delta-row"><span class="delta-badge down">▼</span><span class="delta-label">${s.label||s.key}</span><span class="delta-val">${s.from}→${s.to} (${val})</span></div>`);
|
||||||
|
}
|
||||||
|
const deltaHtml = hasDelta ? deltaRows.join('') : '<div style="padding:12px;text-align:center;color:var(--dim);font-family:var(--mono);font-size:10px">No changes since last sweep</div>';
|
||||||
|
|
||||||
document.getElementById('lowerGrid').innerHTML=`
|
document.getElementById('lowerGrid').innerHTML=`
|
||||||
<div class="g-panel">
|
<div class="g-panel lp-ticker" style="display:flex;flex-direction:column">
|
||||||
<div class="sec-head"><h3>Cross-Source Signals</h3><span class="badge">WORLDVIEW</span></div>
|
<div class="sec-head"><h3>Live News Ticker</h3><span class="badge">${feed.length} ITEMS</span></div>
|
||||||
${signals}
|
<div class="ticker-wrap" style="--ticker-duration:${tickerDuration}s">
|
||||||
|
<div class="ticker-track">${tickerCards}${tickerCards}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="g-panel">
|
<div class="g-panel lp-delta">
|
||||||
|
<div class="sec-head"><h3>Sweep Delta</h3><span class="badge ${dirClass}">${dirEmoji} ${ds.direction?ds.direction.toUpperCase():'BASELINE'}</span></div>
|
||||||
|
${hasDelta?`<div style="display:flex;gap:12px;margin-bottom:6px;font-family:var(--mono);font-size:10px">
|
||||||
|
<span style="color:var(--dim)">Changes: <span style="color:var(--accent)">${ds.totalChanges}</span></span>
|
||||||
|
<span style="color:var(--dim)">Critical: <span style="color:${ds.criticalChanges>0?'var(--warn)':'var(--dim)'}">${ds.criticalChanges||0}</span></span>
|
||||||
|
${ds.signalBreakdown?`<span style="color:var(--dim)">New: <span style="color:#4dd0e1">${ds.signalBreakdown.new}</span> ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}</span>`:''}
|
||||||
|
</div>`:''}
|
||||||
|
<div class="delta-list">${deltaHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div class="g-panel lp-macro">
|
||||||
<div class="sec-head"><h3>Macro + Markets</h3><span class="badge">${mkt.timestamp?'LIVE':'DELAYED'}</span></div>
|
<div class="sec-head"><h3>Macro + Markets</h3><span class="badge">${mkt.timestamp?'LIVE':'DELAYED'}</span></div>
|
||||||
${hasMarkets?`<div style="margin-bottom:8px">
|
${hasMarkets?`<div style="margin-bottom:8px">
|
||||||
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:4px;letter-spacing:1px">INDEXES</div>
|
<div style="font-family:var(--mono);font-size:9px;color:var(--dim);margin-bottom:4px;letter-spacing:1px">INDEXES</div>
|
||||||
@@ -835,7 +898,7 @@ function renderLower(){
|
|||||||
<div class="spark">${sparkHtml}</div>
|
<div class="spark">${sparkHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="g-panel">
|
<div class="g-panel lp-ideas">
|
||||||
<div class="sec-head"><h3>Leverageable Ideas</h3>${D.ideasSource==='llm'?'<span class="ideas-src llm">AI ENHANCED</span>':D.ideasSource==='disabled'?'<span class="ideas-src static">LLM OFF</span>':'<span class="ideas-src static">PENDING</span>'}</div>
|
<div class="sec-head"><h3>Leverageable Ideas</h3>${D.ideasSource==='llm'?'<span class="ideas-src llm">AI ENHANCED</span>':D.ideasSource==='disabled'?'<span class="ideas-src static">LLM OFF</span>':'<span class="ideas-src static">PENDING</span>'}</div>
|
||||||
${ideasHtml}
|
${ideasHtml}
|
||||||
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
|
<div class="disclosure">FOR INFORMATIONAL PURPOSES ONLY. This is not financial advice, a recommendation to buy or sell any security, or a solicitation of any kind. All signal-based observations are derived from publicly available OSINT data and should not be relied upon for investment decisions. Consult a licensed financial advisor before making any investment. Past performance does not guarantee future results.</div>
|
||||||
@@ -844,25 +907,8 @@ function renderLower(){
|
|||||||
|
|
||||||
// === RIGHT RAIL ===
|
// === RIGHT RAIL ===
|
||||||
function renderRight(){
|
function renderRight(){
|
||||||
// NEWS TICKER — merges RSS + GDELT + Telegram into flowing cards
|
// CROSS-SOURCE SIGNALS — moved from lower grid to right rail
|
||||||
const feed = (D.newsFeed || []).slice(0, 40);
|
const signals=D.tSignals.slice(0,6).map((s,i)=>`<div class="signal-row"><strong>Signal ${i+1}</strong><p>${s}</p></div>`).join('');
|
||||||
const srcClass = s => {
|
|
||||||
if (!s) return 'other';
|
|
||||||
const sl = s.toLowerCase();
|
|
||||||
if (sl.includes('bbc')) return 'bbc';
|
|
||||||
if (sl.includes('nyt') || sl.includes('times')) return 'nyt';
|
|
||||||
if (sl.includes('jazeera') || sl.includes('alj')) return 'alj';
|
|
||||||
if (sl.includes('gdelt')) return 'gdelt';
|
|
||||||
if (sl.includes('telegram')) return 'tg';
|
|
||||||
return 'other';
|
|
||||||
};
|
|
||||||
const tickerCards = feed.map(n => {
|
|
||||||
const sc = srcClass(n.source);
|
|
||||||
const age = n.timestamp ? getAge(n.timestamp) : '';
|
|
||||||
return `<div class="tk-card ${n.urgent?'urgent':''}"><span class="tk-src ${sc}">${(n.source||'NEWS').substring(0,12)}</span><span class="tk-time">${age}</span><div class="tk-head">${cleanText(n.headline||'')}</div></div>`;
|
|
||||||
}).join('');
|
|
||||||
// Duplicate for seamless infinite scroll
|
|
||||||
const tickerDuration = Math.max(20, feed.length * 2.5);
|
|
||||||
|
|
||||||
// OSINT TICKER — Telegram + WHO as flowing cards
|
// OSINT TICKER — Telegram + WHO as flowing cards
|
||||||
const allPosts=[...D.tg.urgent,...D.tg.topPosts].sort((a,b)=>new Date(b.date||0)-new Date(a.date||0));
|
const allPosts=[...D.tg.urgent,...D.tg.topPosts].sort((a,b)=>new Date(b.date||0)-new Date(a.date||0));
|
||||||
@@ -888,11 +934,9 @@ function renderRight(){
|
|||||||
];
|
];
|
||||||
|
|
||||||
document.getElementById('rightRail').innerHTML=`
|
document.getElementById('rightRail').innerHTML=`
|
||||||
<div class="g-panel" style="display:flex;flex-direction:column">
|
<div class="g-panel">
|
||||||
<div class="sec-head"><h3>Live News Ticker</h3><span class="badge">${feed.length} ITEMS</span></div>
|
<div class="sec-head"><h3>Cross-Source Signals</h3><span class="badge">WORLDVIEW</span></div>
|
||||||
<div class="ticker-wrap" style="--ticker-duration:${tickerDuration}s">
|
${signals}
|
||||||
<div class="ticker-track">${tickerCards}${tickerCards}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="g-panel" style="display:flex;flex-direction:column">
|
<div class="g-panel" style="display:flex;flex-direction:column">
|
||||||
<div class="sec-head"><h3>OSINT Stream</h3><span class="badge">${D.tg.urgent.length} URGENT</span></div>
|
<div class="sec-head"><h3>OSINT Stream</h3><span class="badge">${D.tg.urgent.length} URGENT</span></div>
|
||||||
|
|||||||
94
diag.mjs
Normal file
94
diag.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -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
|
||||||
BIN
docs/boot.png
Normal file
BIN
docs/boot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/dashboard.png
Normal file
BIN
docs/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 881 KiB |
BIN
docs/map.png
Normal file
BIN
docs/map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
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';
|
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 {
|
export class TelegramAlerter {
|
||||||
constructor({ botToken, chatId }) {
|
constructor({ botToken, chatId }) {
|
||||||
this.botToken = botToken;
|
this.botToken = botToken;
|
||||||
this.chatId = chatId;
|
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() {
|
get isConfigured() {
|
||||||
return !!(this.botToken && this.chatId);
|
return !!(this.botToken && this.chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Core Messaging ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a message via Telegram Bot API.
|
* Send a message via Telegram Bot API.
|
||||||
* @param {string} message - markdown-formatted message
|
* @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) {
|
async sendMessage(message, opts = {}) {
|
||||||
if (!this.isConfigured) return false;
|
if (!this.isConfigured) return { ok: false };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
|
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
|
||||||
@@ -27,103 +62,580 @@ export class TelegramAlerter {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chat_id: this.chatId,
|
chat_id: this.chatId,
|
||||||
text: message,
|
text: message,
|
||||||
parse_mode: 'Markdown',
|
parse_mode: opts.parseMode || 'Markdown',
|
||||||
disable_web_page_preview: true,
|
disable_web_page_preview: opts.disablePreview !== false,
|
||||||
|
...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}),
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text().catch(() => '');
|
const err = await res.text().catch(() => '');
|
||||||
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 100)}`);
|
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
|
||||||
return false;
|
return { ok: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
const data = await res.json();
|
||||||
|
return { ok: true, messageId: data.result?.message_id };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Telegram] Send error:', err.message);
|
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.
|
* Evaluate delta signals with LLM and send tiered alert if warranted.
|
||||||
* @param {LLMProvider} llmProvider - configured LLM provider
|
* Uses semantic dedup, rate limiting, and a much richer evaluation prompt.
|
||||||
* @param {object} delta - delta from current sweep
|
|
||||||
* @param {MemoryManager} memory - memory manager for dedup
|
|
||||||
* @returns {Promise<boolean>} - true if alert was sent
|
|
||||||
*/
|
*/
|
||||||
async evaluateAndAlert(llmProvider, delta, memory) {
|
async evaluateAndAlert(llmProvider, delta, memory) {
|
||||||
if (!this.isConfigured || !llmProvider?.isConfigured) return false;
|
if (!this.isConfigured) return false;
|
||||||
if (!delta?.summary?.criticalChanges) 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
|
// 1. Gather new signals — filter already-alerted AND semantically duplicate
|
||||||
const alerted = memory.getAlertedSignals();
|
const allSignals = [
|
||||||
const newSignals = [
|
|
||||||
...(delta.signals?.new || []),
|
...(delta.signals?.new || []),
|
||||||
...(delta.signals?.escalated || []),
|
...(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;
|
if (newSignals.length === 0) return false;
|
||||||
|
|
||||||
// Ask LLM if these signals warrant an immediate alert
|
// 2. Try LLM evaluation first, fall back to rule-based if unavailable
|
||||||
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.
|
let evaluation = null;
|
||||||
|
|
||||||
Alert criteria (ALL must be true):
|
if (llmProvider?.isConfigured) {
|
||||||
1. Material market impact likely (>1% move in major index, or >5% move in sector/commodity)
|
try {
|
||||||
2. Time-sensitive — acting in the next few hours matters
|
const systemPrompt = this._buildEvaluationPrompt();
|
||||||
3. Not routine data (scheduled economic releases don't count unless they're a major surprise)
|
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:
|
Respond with ONLY valid JSON:
|
||||||
{
|
{
|
||||||
"shouldAlert": true/false,
|
"shouldAlert": true/false,
|
||||||
"reason": "1-2 sentence explanation",
|
"tier": "FLASH" | "PRIORITY" | "ROUTINE",
|
||||||
"headline": "Alert headline if shouldAlert is true",
|
"headline": "10-word max headline",
|
||||||
"signals": ["key signals that triggered alert"]
|
"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 => {
|
_buildSignalContext(signals, delta) {
|
||||||
if (s.changePct !== undefined) return `- ${s.label}: ${s.previous} → ${s.current} (${s.changePct > 0 ? '+' : ''}${s.changePct.toFixed(1)}%)`;
|
const sections = [];
|
||||||
if (s.text) return `- NEW OSINT: ${s.text.substring(0, 120)}`;
|
|
||||||
return `- ${s.label || JSON.stringify(s)}`;
|
|
||||||
}).join('\n')}
|
|
||||||
|
|
||||||
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 {
|
if (marketSignals.length > 0) {
|
||||||
const result = await llmProvider.complete(systemPrompt, userMessage, { maxTokens: 512, timeout: 30000 });
|
sections.push('📊 MARKET SIGNALS:\n' + marketSignals.map(s =>
|
||||||
const evaluation = parseEvaluation(result.text);
|
` ${s.label}: ${s.from} → ${s.to} (${s.pctChange > 0 ? '+' : ''}${s.pctChange?.toFixed(1) || s.change}${s.pctChange !== undefined ? '%' : ''})`
|
||||||
|
).join('\n'));
|
||||||
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 (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;
|
if (!text) return null;
|
||||||
let cleaned = text.trim();
|
let cleaned = text.trim();
|
||||||
if (cleaned.startsWith('```')) {
|
if (cleaned.startsWith('```')) {
|
||||||
@@ -139,24 +651,3 @@ function parseEvaluation(text) {
|
|||||||
return null;
|
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 = [
|
const NUMERIC_METRICS = [
|
||||||
{ key: 'vix', extract: d => d.fred?.find(f => f.id === 'VIXCLS')?.value, label: 'VIX', threshold: 5 },
|
{ 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', threshold: 5 },
|
{ 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', threshold: 10 },
|
{ 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', threshold: 3 },
|
{ key: 'wti', extract: d => d.energy?.wti, label: 'WTI Crude' },
|
||||||
{ key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude', threshold: 3 },
|
{ key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude' },
|
||||||
{ key: 'natgas', extract: d => d.energy?.natgas, label: 'Natural Gas', threshold: 5 },
|
{ 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', threshold: 2 },
|
{ 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', threshold: 1 },
|
{ 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', threshold: 3 },
|
{ 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', threshold: 1 },
|
{ 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', threshold: 2 },
|
{ key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COUNT_METRICS = [
|
const COUNT_METRICS = [
|
||||||
@@ -23,29 +56,66 @@ const COUNT_METRICS = [
|
|||||||
{ key: 'conflict_events', extract: d => d.acled?.totalEvents || 0, label: 'Conflict Events' },
|
{ 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: 'conflict_fatalities', extract: d => d.acled?.totalFatalities || 0, label: 'Conflict Fatalities' },
|
||||||
{ key: 'sdr_online', extract: d => d.sdr?.online || 0, label: 'SDR Receivers' },
|
{ 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' },
|
{ 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 (!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: [] };
|
const signals = { new: [], escalated: [], deescalated: [], unchanged: [] };
|
||||||
let criticalChanges = 0;
|
let criticalChanges = 0;
|
||||||
|
|
||||||
// Numeric metrics: track % change
|
// ─── Numeric metrics: track % change ─────────────────────────────────
|
||||||
|
|
||||||
for (const m of NUMERIC_METRICS) {
|
for (const m of NUMERIC_METRICS) {
|
||||||
const curr = m.extract(current);
|
const curr = m.extract(current);
|
||||||
const prev = m.extract(previous);
|
const prev = m.extract(previous);
|
||||||
if (curr == null || prev == null) continue;
|
if (curr == null || prev == null) continue;
|
||||||
|
|
||||||
|
const threshold = numThresholds[m.key] ?? 5;
|
||||||
const pctChange = prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : 0;
|
const pctChange = prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : 0;
|
||||||
|
|
||||||
if (Math.abs(pctChange) > m.threshold) {
|
if (Math.abs(pctChange) > threshold) {
|
||||||
const entry = {
|
const entry = {
|
||||||
key: m.key, label: m.label, from: prev, to: curr,
|
key: m.key, label: m.label, from: prev, to: curr,
|
||||||
pctChange: parseFloat(pctChange.toFixed(2)),
|
pctChange: parseFloat(pctChange.toFixed(2)),
|
||||||
direction: pctChange > 0 ? 'up' : 'down',
|
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);
|
if (pctChange > 0) signals.escalated.push(entry);
|
||||||
else signals.deescalated.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) {
|
for (const m of COUNT_METRICS) {
|
||||||
const curr = m.extract(current);
|
const curr = m.extract(current);
|
||||||
const prev = m.extract(previous);
|
const prev = m.extract(previous);
|
||||||
const diff = curr - prev;
|
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 = {
|
const entry = {
|
||||||
key: m.key, label: m.label, from: prev, to: curr,
|
key: m.key, label: m.label, from: prev, to: curr,
|
||||||
change: diff, direction: diff > 0 ? 'up' : 'down',
|
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);
|
if (diff > 0) signals.escalated.push(entry);
|
||||||
else signals.deescalated.push(entry);
|
else signals.deescalated.push(entry);
|
||||||
|
// Count metrics only critical if the change is extreme
|
||||||
|
if (entry.severity === 'critical') criticalChanges++;
|
||||||
} else {
|
} else {
|
||||||
signals.unchanged.push(m.key);
|
signals.unchanged.push(m.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New urgent posts (check by text content)
|
// ─── New urgent Telegram posts (semantic dedup) ──────────────────────
|
||||||
const prevUrgentTexts = new Set((previous.tg?.urgent || []).map(p => p.text?.substring(0, 60)));
|
|
||||||
|
const prevHashes = new Set(
|
||||||
|
(previous.tg?.urgent || []).map(p => contentHash(p.text))
|
||||||
|
);
|
||||||
|
|
||||||
for (const post of (current.tg?.urgent || [])) {
|
for (const post of (current.tg?.urgent || [])) {
|
||||||
const key = post.text?.substring(0, 60);
|
const hash = contentHash(post.text);
|
||||||
if (key && !prevUrgentTexts.has(key)) {
|
if (hash && !prevHashes.has(hash)) {
|
||||||
signals.new.push({ key: 'tg_urgent', item: post, reason: 'New urgent OSINT post' });
|
signals.new.push({
|
||||||
|
key: `tg_urgent:${hash}`,
|
||||||
|
text: post.text?.substring(0, 120),
|
||||||
|
item: post,
|
||||||
|
reason: 'New urgent OSINT post',
|
||||||
|
});
|
||||||
criticalChanges++;
|
criticalChanges++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nuclear anomaly change
|
// ─── Nuclear anomaly state change ────────────────────────────────────
|
||||||
|
|
||||||
const currAnom = current.nuke?.some(n => n.anom) || false;
|
const currAnom = current.nuke?.some(n => n.anom) || false;
|
||||||
const prevAnom = previous.nuke?.some(n => n.anom) || false;
|
const prevAnom = previous.nuke?.some(n => n.anom) || false;
|
||||||
if (currAnom && !prevAnom) {
|
if (currAnom && !prevAnom) {
|
||||||
signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected' });
|
signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected', severity: 'critical' });
|
||||||
criticalChanges += 5; // Critical
|
criticalChanges += 5;
|
||||||
} else if (!currAnom && prevAnom) {
|
} 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';
|
let direction = 'mixed';
|
||||||
const riskUp = signals.escalated.filter(s =>
|
const riskUp = signals.escalated.filter(s => RISK_KEYS.includes(s.key)).length;
|
||||||
['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'].includes(s.key)
|
const riskDown = signals.deescalated.filter(s => RISK_KEYS.includes(s.key)).length;
|
||||||
).length;
|
|
||||||
const riskDown = signals.deescalated.filter(s =>
|
|
||||||
['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'].includes(s.key)
|
|
||||||
).length;
|
|
||||||
if (riskUp > riskDown + 1) direction = 'risk-off';
|
if (riskUp > riskDown + 1) direction = 'risk-off';
|
||||||
else if (riskDown > riskUp + 1) direction = 'risk-on';
|
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,
|
totalChanges: signals.new.length + signals.escalated.length + signals.deescalated.length,
|
||||||
criticalChanges,
|
criticalChanges,
|
||||||
direction,
|
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
|
// 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 { join } from 'path';
|
||||||
import { computeDelta } from './engine.mjs';
|
import { computeDelta } from './engine.mjs';
|
||||||
|
|
||||||
const MAX_HOT_RUNS = 3;
|
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 {
|
export class MemoryManager {
|
||||||
constructor(runsDir) {
|
constructor(runsDir) {
|
||||||
this.runsDir = runsDir;
|
this.runsDir = runsDir;
|
||||||
@@ -23,18 +28,46 @@ export class MemoryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_loadHot() {
|
_loadHot() {
|
||||||
try {
|
// Try primary file first, then backup
|
||||||
return JSON.parse(readFileSync(this.hotPath, 'utf8'));
|
for (const path of [this.hotPath, this.hotPath + '.bak']) {
|
||||||
} catch {
|
try {
|
||||||
return { runs: [], alertedSignals: {} };
|
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() {
|
_saveHot() {
|
||||||
|
const tmpPath = this.hotPath + '.tmp';
|
||||||
|
const bakPath = this.hotPath + '.bak';
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error('[Memory] Failed to save hot memory:', err.message);
|
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;
|
return this.hot.runs[0].delta;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track what signals have been alerted on
|
// ─── Alert Signal Tracking (Decay-Based) ───────────────────────────────
|
||||||
|
|
||||||
getAlertedSignals() {
|
getAlertedSignals() {
|
||||||
return this.hot.alertedSignals || {};
|
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) {
|
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();
|
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() {
|
pruneAlertedSignals() {
|
||||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
const now = Date.now();
|
||||||
for (const [key, ts] of Object.entries(this.hot.alertedSignals)) {
|
for (const [key, entry] of Object.entries(this.hot.alertedSignals)) {
|
||||||
if (new Date(ts).getTime() < cutoff) {
|
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];
|
delete this.hot.alertedSignals[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +206,7 @@ export class MemoryManager {
|
|||||||
who: (data.who || []).map(w => ({ title: w.title })),
|
who: (data.who || []).map(w => ({ title: w.title })),
|
||||||
acled: { totalEvents: data.acled?.totalEvents, totalFatalities: data.acled?.totalFatalities },
|
acled: { totalEvents: data.acled?.totalEvents, totalFatalities: data.acled?.totalFatalities },
|
||||||
sdr: { total: data.sdr?.total, online: data.sdr?.online },
|
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 })),
|
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 { }
|
try { existing = JSON.parse(readFileSync(coldPath, 'utf8')); } catch { }
|
||||||
|
|
||||||
existing.push(...runs);
|
existing.push(...runs);
|
||||||
|
// Use atomic write for cold storage too
|
||||||
|
const tmpPath = coldPath + '.tmp';
|
||||||
try {
|
try {
|
||||||
writeFileSync(coldPath, JSON.stringify(existing, null, 2));
|
writeFileSync(tmpPath, JSON.stringify(existing, null, 2));
|
||||||
|
renameSync(tmpPath, coldPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Memory] Failed to archive to cold storage:', err.message);
|
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);
|
super(config);
|
||||||
this.name = 'anthropic';
|
this.name = 'anthropic';
|
||||||
this.apiKey = config.apiKey;
|
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; }
|
get isConfigured() { return !!this.apiKey; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// OpenAI Codex Provider — uses ChatGPT subscription via chatgpt.com/backend-api/codex/responses
|
// OpenAI Codex Provider — uses ChatGPT subscription via chatgpt.com/backend-api/codex/responses
|
||||||
// Auth: reads ~/.codex/auth.json (created by `npx @openai/codex login`)
|
// 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 { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
@@ -14,7 +14,7 @@ export class CodexProvider extends LLMProvider {
|
|||||||
constructor(config) {
|
constructor(config) {
|
||||||
super(config);
|
super(config);
|
||||||
this.name = 'codex';
|
this.name = 'codex';
|
||||||
this.model = config.model || 'gpt-5.2-codex';
|
this.model = config.model || 'gpt-5.3-codex';
|
||||||
this._creds = null;
|
this._creds = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class GeminiProvider extends LLMProvider {
|
|||||||
super(config);
|
super(config);
|
||||||
this.name = 'gemini';
|
this.name = 'gemini';
|
||||||
this.apiKey = config.apiKey;
|
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; }
|
get isConfigured() { return !!this.apiKey; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export class OpenAIProvider extends LLMProvider {
|
|||||||
super(config);
|
super(config);
|
||||||
this.name = 'openai';
|
this.name = 'openai';
|
||||||
this.apiKey = config.apiKey;
|
this.apiKey = config.apiKey;
|
||||||
this.model = config.model || 'gpt-4o';
|
this.model = config.model || 'gpt-5.4';
|
||||||
}
|
}
|
||||||
|
|
||||||
get isConfigured() { return !!this.apiKey; }
|
get isConfigured() { return !!this.apiKey; }
|
||||||
@@ -21,7 +21,7 @@ export class OpenAIProvider extends LLMProvider {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
max_tokens: opts.maxTokens || 4096,
|
max_completion_tokens: opts.maxTokens || 4096,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: userMessage },
|
{ role: 'user', content: userMessage },
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
"description": "Local intelligence engine — 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.",
|
"description": "Local intelligence engine — 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node server.mjs",
|
"dev": "node --trace-warnings server.mjs",
|
||||||
"sweep": "node apis/briefing.mjs",
|
"sweep": "node apis/briefing.mjs",
|
||||||
"inject": "node dashboard/inject.mjs",
|
"inject": "node dashboard/inject.mjs",
|
||||||
"brief": "node apis/briefing.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"],
|
"keywords": ["osint", "intelligence", "dashboard", "geopolitical"],
|
||||||
"author": "Crucix",
|
"author": "Crucix",
|
||||||
@@ -18,5 +19,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0"
|
"express": "^5.1.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"discord.js": "^14.25.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
244
server.mjs
244
server.mjs
@@ -14,6 +14,7 @@ import { MemoryManager } from './lib/delta/index.mjs';
|
|||||||
import { createLLMProvider } from './lib/llm/index.mjs';
|
import { createLLMProvider } from './lib/llm/index.mjs';
|
||||||
import { generateLLMIdeas } from './lib/llm/ideas.mjs';
|
import { generateLLMIdeas } from './lib/llm/ideas.mjs';
|
||||||
import { TelegramAlerter } from './lib/alerts/telegram.mjs';
|
import { TelegramAlerter } from './lib/alerts/telegram.mjs';
|
||||||
|
import { DiscordAlerter } from './lib/alerts/discord.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
@@ -35,12 +36,195 @@ const sseClients = new Set();
|
|||||||
// === Delta/Memory ===
|
// === Delta/Memory ===
|
||||||
const memory = new MemoryManager(RUNS_DIR);
|
const memory = new MemoryManager(RUNS_DIR);
|
||||||
|
|
||||||
// === LLM + Telegram ===
|
// === LLM + Telegram + Discord ===
|
||||||
const llmProvider = createLLMProvider(config.llm);
|
const llmProvider = createLLMProvider(config.llm);
|
||||||
const telegramAlerter = new TelegramAlerter(config.telegram);
|
const telegramAlerter = new TelegramAlerter(config.telegram);
|
||||||
|
const discordAlerter = new DiscordAlerter(config.discord || {});
|
||||||
|
|
||||||
if (llmProvider) console.log(`[Crucix] LLM enabled: ${llmProvider.name} (${llmProvider.model})`);
|
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 ===
|
// === Express Server ===
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -149,11 +333,18 @@ async function runSweepCycle() {
|
|||||||
synthesized.ideasSource = 'disabled';
|
synthesized.ideasSource = 'disabled';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Telegram alert evaluation (LLM-gated)
|
// 6. Alert evaluation — Telegram + Discord (LLM with rule-based fallback, multi-tier, semantic dedup)
|
||||||
if (telegramAlerter.isConfigured && llmProvider?.isConfigured && delta?.summary?.criticalChanges > 0) {
|
if (delta?.summary?.totalChanges > 0) {
|
||||||
telegramAlerter.evaluateAndAlert(llmProvider, delta, memory).catch(err => {
|
if (telegramAlerter.isConfigured) {
|
||||||
console.error('[Crucix] Telegram alert error:', err.message);
|
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
|
// Prune old alerted signals
|
||||||
@@ -190,15 +381,33 @@ async function start() {
|
|||||||
║ Health: http://localhost:${port}/api/health${' '.repeat(4 - String(port).length)}║
|
║ Health: http://localhost:${port}/api/health${' '.repeat(4 - String(port).length)}║
|
||||||
║ Refresh: Every ${config.refreshIntervalMinutes} min${' '.repeat(20 - String(config.refreshIntervalMinutes).length)}║
|
║ Refresh: Every ${config.refreshIntervalMinutes} min${' '.repeat(20 - String(config.refreshIntervalMinutes).length)}║
|
||||||
║ LLM: ${(config.llm.provider || 'disabled').padEnd(31)}║
|
║ 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}`);
|
console.log(`[Crucix] Server running on http://localhost:${port}`);
|
||||||
|
|
||||||
// Auto-open browser
|
// 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';
|
process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||||
exec(`${openCmd} "http://localhost:${port}"`, (err) => {
|
exec(`${openCmd} "http://localhost:${port}"`, (err) => {
|
||||||
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
|
if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
|
||||||
@@ -216,19 +425,24 @@ async function start() {
|
|||||||
|
|
||||||
// Run first sweep
|
// Run first sweep
|
||||||
console.log('[Crucix] Running initial sweep...');
|
console.log('[Crucix] Running initial sweep...');
|
||||||
runSweepCycle();
|
runSweepCycle().catch(err => {
|
||||||
|
console.error('[Crucix] Initial sweep failed:', err.message || err);
|
||||||
|
});
|
||||||
|
|
||||||
// Schedule recurring sweeps
|
// Schedule recurring sweeps
|
||||||
setInterval(runSweepCycle, config.refreshIntervalMinutes * 60 * 1000);
|
setInterval(runSweepCycle, config.refreshIntervalMinutes * 60 * 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful error handling
|
// Graceful error handling — log full stack traces for diagnosis
|
||||||
process.on('unhandledRejection', (err) => {
|
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) => {
|
process.on('uncaughtException', (err) => {
|
||||||
console.error('[Crucix] Uncaught exception:', err.message || err);
|
console.error('[Crucix] Uncaught exception:', err?.stack || err?.message || err);
|
||||||
});
|
});
|
||||||
|
|
||||||
start();
|
start().catch(err => {
|
||||||
|
console.error('[Crucix] FATAL — Server failed to start:', err?.stack || err?.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user