Resolve merge conflicts with Mistral provider

Include both Mistral and Ollama providers in factory,
config, and env docs.
This commit is contained in:
R4V3N
2026-03-20 22:37:35 +01:00
23 changed files with 2181 additions and 241 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
node_modules
npm-debug.log*
.git
.gitignore
.github
.omc
.env
.env.*
!.env.example
runs/
docs/
*.md
!README.md
LICENSE

View File

@@ -31,7 +31,7 @@ REFRESH_INTERVAL_MINUTES=15
# === LLM Layer (optional) === # === LLM Layer (optional) ===
# Enables AI-enhanced trade ideas and breaking news Telegram alerts. # Enables AI-enhanced trade ideas and breaking news Telegram alerts.
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | ollama # Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama
LLM_PROVIDER= LLM_PROVIDER=
# Not needed for codex (uses ~/.codex/auth.json) or ollama (local) # Not needed for codex (uses ~/.codex/auth.json) or ollama (local)
LLM_API_KEY= LLM_API_KEY=

68
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Build & Publish Docker Image
on:
push:
branches: [master]
tags: ['v*']
pull_request:
branches: [master]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' && vars.DOCKERHUB_ENABLED == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ vars.DOCKERHUB_ENABLED == 'true' && format('{0}/{1}', secrets.DOCKERHUB_USERNAME, 'crucix') || '' }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

2
.gitignore vendored
View File

@@ -34,8 +34,6 @@ AGENTS.md
*.log *.log
npm-debug.log* npm-debug.log*
# Docker
.dockerignore
# Package lock (optional — remove this line if you want deterministic installs) # Package lock (optional — remove this line if you want deterministic installs)
# package-lock.json # package-lock.json

View File

@@ -15,6 +15,11 @@
[![Sources](https://img.shields.io/badge/OSINT%20sources-27-cyan)](#data-sources-27) [![Sources](https://img.shields.io/badge/OSINT%20sources-27-cyan)](#data-sources-27)
[![Docker](https://img.shields.io/badge/docker-ready-blue?logo=docker)](#docker) [![Docker](https://img.shields.io/badge/docker-ready-blue?logo=docker)](#docker)
**Enter The Signal Network**
[![Signal Wire](https://img.shields.io/badge/Signal%20Wire-%40crucixmonitor-111111?style=for-the-badge&logo=x&logoColor=white)](https://x.com/crucixmonitor)
[![Ops Room](https://img.shields.io/badge/Ops%20Room-Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ChVy7SF4)
![Crucix Dashboard](docs/dashboard.png) ![Crucix Dashboard](docs/dashboard.png)
<details> <details>
@@ -43,6 +48,13 @@ Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/),
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running. No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
## Token / Asset Warning
> [!WARNING]
> **Crucix has not launched any official token, coin, NFT, airdrop, presale, or other blockchain-based asset.**
> Any token or digital asset using the Crucix name, logo, or branding is not affiliated with or endorsed by Crucix.
> Do not buy it, promote it, connect a wallet to claim it, sign transactions, or send funds based on third-party posts, DMs, or websites.
--- ---
## Why This Exists ## Why This Exists
@@ -113,6 +125,22 @@ A self-contained Jarvis-style HUD with:
- **Space watch** — CelesTrak satellite tracking: recent launches, ISS, military constellations, Starlink/OneWeb counts - **Space watch** — CelesTrak satellite tracking: recent launches, ISS, military constellations, Starlink/OneWeb counts
- **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)
### Performance Modes
The `PERF HIGH` / `PERF LOW` button in the top bar only changes rendering behavior - it does **not** remove data sources or reduce sweep coverage.
When you switch to **PERF LOW**, the dashboard:
- Disables decorative background effects such as the radial/grid overlays and scanlines
- Removes expensive blur/backdrop-filter effects on panels and overlays
- Stops non-essential animations like the logo ring blink, conflict rings, and corridor flow effects
- Disables globe auto-rotation and turns off animated flight-arc dashes
- Converts the horizontal news ticker and OSINT stream into static, scrollable lists instead of continuously animated marquees
Mobile-specific behavior:
- On mobile, `PERF LOW` also forces the dashboard into **flat map mode** if you are currently on the globe
- Future mobile loads will continue to start flat while low-perf mode is enabled
The preference is saved in browser local storage, so the UI will remember your last setting.
### Auto-Refresh ### Auto-Refresh
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 27 sources in parallel (~30s) 1. Queries all 27 sources in parallel (~30s)
@@ -161,7 +189,7 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye
Connect any of 6 LLM providers for enhanced analysis: Connect any of 6 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
- **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring - **Smarter alert evaluation** — LLM classifies signals into FLASH/PRIORITY/ROUTINE tiers with cross-domain correlation and confidence scoring
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax - Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax, Mistral
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. 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.
--- ---
@@ -194,7 +222,7 @@ These three unlock the most valuable economic and satellite data. Each takes abo
### LLM Provider (optional, for AI-enhanced ideas) ### LLM Provider (optional, for AI-enhanced ideas)
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax` Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`, `mistral`
| Provider | Key Required | Default Model | | Provider | Key Required | Default Model |
|----------|-------------|---------------| |----------|-------------|---------------|
@@ -204,6 +232,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrou
| `openrouter` | `LLM_API_KEY` | openrouter/auto | | `openrouter` | `LLM_API_KEY` | openrouter/auto |
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex |
| `minimax` | `LLM_API_KEY` | MiniMax-M2.5 | | `minimax` | `LLM_API_KEY` | MiniMax-M2.5 |
| `mistral` | `LLM_API_KEY` | mistral-large-latest |
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.
@@ -281,6 +310,7 @@ crucix/
│ │ ├── openrouter.mjs # OpenRouter (Unified API) │ │ ├── openrouter.mjs # OpenRouter (Unified API)
│ │ ├── codex.mjs # Codex (ChatGPT subscription) │ │ ├── codex.mjs # Codex (ChatGPT subscription)
│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context) │ │ ├── minimax.mjs # MiniMax (M2.5, 204K context)
│ │ ├── mistral.mjs # Mistral AI
│ │ ├── 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
@@ -382,7 +412,7 @@ All settings are in `.env` with sensible defaults:
|----------|---------|-------------| |----------|---------|-------------|
| `PORT` | `3117` | Dashboard server port | | `PORT` | `3117` | Dashboard server port |
| `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval | | `REFRESH_INTERVAL_MINUTES` | `15` | Auto-refresh interval |
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, or `minimax` | | `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`, or `mistral` |
| `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 Telegram alerts + bot commands | | `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands |
@@ -463,6 +493,8 @@ This is normal — the first sweep takes 3060 seconds to query all 27 sources
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`. 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`.
OpenSky can also return `HTTP 429` when its public hotspots are queried too aggressively. Crucix does not try to evade that limit. Instead, it surfaces the throttle/error in source health and preserves the most recent non-empty air traffic snapshot from `runs/` so the dashboard flight layer does not suddenly go blank on a throttled sweep.
### Telegram bot not responding to commands ### 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`. 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`.

View File

@@ -43,13 +43,22 @@ import { briefing as space } from './sources/space.mjs';
// === Tier 5: Live Market Data === // === Tier 5: Live Market Data ===
import { briefing as yfinance } from './sources/yfinance.mjs'; import { briefing as yfinance } from './sources/yfinance.mjs';
const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source
export async function runSource(name, fn, ...args) { export async function runSource(name, fn, ...args) {
const start = Date.now(); const start = Date.now();
let timer;
try { try {
const data = await fn(...args); const dataPromise = fn(...args);
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS);
});
const data = await Promise.race([dataPromise, timeoutPromise]);
return { name, status: 'ok', durationMs: Date.now() - start, data }; return { name, status: 'ok', durationMs: Date.now() - start, data };
} catch (e) { } catch (e) {
return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; return { name, status: 'error', durationMs: Date.now() - start, error: e.message };
} finally {
clearTimeout(timer);
} }
} }
@@ -57,7 +66,7 @@ export async function fullBriefing() {
console.error('[Crucix] Starting intelligence sweep — 27 sources...'); console.error('[Crucix] Starting intelligence sweep — 27 sources...');
const start = Date.now(); const start = Date.now();
const results = await Promise.allSettled([ const allPromises = [
// Tier 1: Core OSINT & Geopolitical // Tier 1: Core OSINT & Geopolitical
runSource('GDELT', gdelt), runSource('GDELT', gdelt),
runSource('OpenSky', opensky), runSource('OpenSky', opensky),
@@ -94,7 +103,11 @@ export async function fullBriefing() {
// Tier 5: Live Market Data // Tier 5: Live Market Data
runSource('YFinance', yfinance), runSource('YFinance', yfinance),
]); ];
// Each runSource has its own 30s timeout, so allSettled will resolve
// within ~30s even if APIs hang. Global timeout is a safety net.
const results = await Promise.allSettled(allPromises);
const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message }); const sources = results.map(r => r.status === 'fulfilled' ? r.value : { status: 'failed', error: r.reason?.message });
const totalMs = Date.now() - start; const totalMs = Date.now() - start;

View File

@@ -37,11 +37,15 @@ export async function getSeries(seriesIds, opts = {}) {
if (apiKey) payload.registrationkey = apiKey; if (apiKey) payload.registrationkey = apiKey;
try { try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
const res = await fetch(base, { const res = await fetch(base, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal: controller.signal,
}); });
clearTimeout(timer);
return await res.json(); return await res.json();
} catch (e) { } catch (e) {
return { error: e.message }; return { error: e.message };

View File

@@ -69,6 +69,7 @@ export async function briefing() {
const results = await Promise.all( const results = await Promise.all(
hotspotEntries.map(async ([key, box]) => { hotspotEntries.map(async ([key, box]) => {
const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax); const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax);
const error = data?.error || null;
const states = data?.states || []; const states = data?.states || [];
return { return {
region: box.label, region: box.label,
@@ -83,14 +84,25 @@ export async function briefing() {
// Flag potentially interesting (military often have no callsign or specific patterns) // Flag potentially interesting (military often have no callsign or specific patterns)
noCallsign: states.filter(s => !s[1]?.trim()).length, noCallsign: states.filter(s => !s[1]?.trim()).length,
highAltitude: states.filter(s => s[7] && s[7] > 12000).length, // >12km altitude highAltitude: states.filter(s => s[7] && s[7] > 12000).length, // >12km altitude
...(error ? { error } : {}),
}; };
}) })
); );
const hotspotErrors = results
.filter(r => r.error)
.map(r => ({ region: r.region, error: r.error }));
return { return {
source: 'OpenSky', source: 'OpenSky',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
hotspots: results, hotspots: results,
...(hotspotErrors.length ? {
error: hotspotErrors.length === results.length
? `OpenSky unavailable across all hotspots: ${hotspotErrors[0].error}`
: `OpenSky unavailable for ${hotspotErrors.length}/${results.length} hotspots`,
hotspotErrors,
} : {}),
}; };
} }

View File

@@ -14,6 +14,7 @@ import { safeFetch } from '../utils/fetch.mjs';
const CHOKEPOINTS = { const CHOKEPOINTS = {
straitOfHormuz: { label: 'Strait of Hormuz', lat: 26.5, lon: 56.5, note: '20% of world oil' }, straitOfHormuz: { label: 'Strait of Hormuz', lat: 26.5, lon: 56.5, note: '20% of world oil' },
suezCanal: { label: 'Suez Canal', lat: 30.5, lon: 32.3, note: '12% of world trade' }, suezCanal: { label: 'Suez Canal', lat: 30.5, lon: 32.3, note: '12% of world trade' },
straitOfGibraltar: { label: 'Strait of Gibraltar', lat: 36.0, lon: -5.7, note: 'Gateway to Mediterranean, ~10-20% global trade influence' },
straitOfMalacca: { label: 'Strait of Malacca', lat: 2.5, lon: 101.5, note: '25% of world trade' }, straitOfMalacca: { label: 'Strait of Malacca', lat: 2.5, lon: 101.5, note: '25% of world trade' },
babElMandeb: { label: 'Bab el-Mandeb', lat: 12.6, lon: 43.3, note: 'Red Sea gateway' }, babElMandeb: { label: 'Bab el-Mandeb', lat: 12.6, lon: 43.3, note: 'Red Sea gateway' },
taiwanStrait: { label: 'Taiwan Strait', lat: 24.0, lon: 119.0, note: '88% of largest container ships' }, taiwanStrait: { label: 'Taiwan Strait', lat: 24.0, lon: 119.0, note: '88% of largest container ships' },

View File

@@ -51,7 +51,11 @@ export async function getOutbreakNews() {
return db - da; return db - da;
}); });
return items.map(item => ({ // Filter to last 30 days only
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recent = items.filter(item => new Date(item.PublicationDate || 0) >= cutoff);
return recent.map(item => ({
title: item.Title, title: item.Title,
date: item.PublicationDate, date: item.PublicationDate,
donId: item.DonId || null, donId: item.DonId || null,

View File

@@ -1,13 +1,13 @@
// Crucix Configuration — all settings with env var overrides // Crucix Configuration — all settings with env var overrides
import './apis/utils/env.mjs'; // Load .env first import "./apis/utils/env.mjs"; // Load .env first
export default { export default {
port: parseInt(process.env.PORT) || 3117, port: parseInt(process.env.PORT) || 3117,
refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15,
llm: { llm: {
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | ollama provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama
apiKey: process.env.LLM_API_KEY || null, apiKey: process.env.LLM_API_KEY || null,
model: process.env.LLM_MODEL || null, model: process.env.LLM_MODEL || null,
baseUrl: process.env.OLLAMA_BASE_URL || null, baseUrl: process.env.OLLAMA_BASE_URL || null,

View File

@@ -5,7 +5,7 @@
// //
// Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs // Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs
import { readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { exec } from 'child_process'; import { exec } from 'child_process';
@@ -102,6 +102,49 @@ function sanitizeExternalUrl(raw) {
} }
} }
function sumAirHotspots(hotspots = []) {
return hotspots.reduce((sum, hotspot) => sum + (hotspot.totalAircraft || 0), 0);
}
function summarizeAirHotspots(hotspots = []) {
return hotspots.map(h => ({
region: h.region,
total: h.totalAircraft || 0,
noCallsign: h.noCallsign || 0,
highAlt: h.highAltitude || 0,
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5),
}));
}
function loadOpenSkyFallback(currentTimestamp) {
const runsDir = join(ROOT, 'runs');
if (!existsSync(runsDir)) return null;
const currentMs = currentTimestamp ? new Date(currentTimestamp).getTime() : NaN;
const files = readdirSync(runsDir)
.filter(name => /^briefing_.*\.json$/.test(name))
.sort()
.reverse();
for (const file of files) {
const filePath = join(runsDir, file);
try {
const prior = JSON.parse(readFileSync(filePath, 'utf8'));
const priorTimestamp = prior.sources?.OpenSky?.timestamp || prior.crucix?.timestamp || null;
if (priorTimestamp && Number.isFinite(currentMs) && new Date(priorTimestamp).getTime() >= currentMs) continue;
const hotspots = prior.sources?.OpenSky?.hotspots || [];
if (sumAirHotspots(hotspots) > 0) {
return { file, timestamp: priorTimestamp, hotspots };
}
} catch {
// Ignore unreadable historical runs and continue searching backward.
}
}
return null;
}
// === RSS Fetching === // === RSS Fetching ===
async function fetchRSS(url, source) { async function fetchRSS(url, source) {
try { try {
@@ -124,15 +167,42 @@ async function fetchRSS(url, source) {
} }
} }
const RSS_SOURCE_FALLBACKS = {
'SBS Australia': { lat: -35.2809, lon: 149.13, region: 'Australia' },
'Indian Express': { lat: 28.6139, lon: 77.209, region: 'India' },
'The Hindu': { lat: 13.0827, lon: 80.2707, region: 'India' },
'MercoPress': { lat: -34.9011, lon: -56.1645, region: 'South America' }
};
const REGIONAL_NEWS_SOURCES = ['MercoPress', 'Indian Express', 'The Hindu', 'SBS Australia'];
export async function fetchAllNews() { export async function fetchAllNews() {
const feeds = [ const feeds = [
// Global
['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'], ['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'],
['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'], ['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'],
['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'], ['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'], // USA
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'], ['https://feeds.npr.org/1001/rss.xml', 'NPR'],
['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'], ['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'],
['http://feeds.bbci.co.uk/news/science_and_environment/rss.xml', 'BBC Science'], ['http://feeds.bbci.co.uk/news/science_and_environment/rss.xml', 'BBC Science'],
['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'],
// Europe
['https://rss.dw.com/rdf/rss-en-all', 'DW'],
['https://www.france24.com/en/rss', 'France 24'],
['https://www.euronews.com/rss?format=mrss', 'Euronews'],
// Africa & Cameroon region
['https://rss.dw.com/rdf/rss-en-africa', 'DW Africa'],
['https://www.rfi.fr/en/rss', 'RFI'],
['https://www.africanews.com/feed/rss', 'Africa News'],
['https://rss.nytimes.com/services/xml/rss/nyt/Africa.xml', 'NYT Africa'],
// Asia-Pacific
['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'],
['https://www.sbs.com.au/news/topic/australia/feed', 'SBS Australia'],
// India
['https://indianexpress.com/section/india/feed/', 'Indian Express'],
['https://www.thehindu.com/news/national/feeder/default.rss', 'The Hindu'],
// South America
['https://en.mercopress.com/rss/latin-america', 'MercoPress'],
]; ];
const results = await Promise.allSettled( const results = await Promise.allSettled(
@@ -150,7 +220,7 @@ export async function fetchAllNews() {
const key = item.title.substring(0, 40).toLowerCase(); const key = item.title.substring(0, 40).toLowerCase();
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
const geo = geoTagText(item.title); const geo = geoTagText(item.title) || RSS_SOURCE_FALLBACKS[item.source];
if (geo) { if (geo) {
geoNews.push({ geoNews.push({
title: item.title.substring(0, 100), title: item.title.substring(0, 100),
@@ -164,8 +234,26 @@ export async function fetchAllNews() {
} }
} }
geoNews.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0)); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return geoNews.slice(0, 50); const filtered = geoNews.filter(n => !n.date || new Date(n.date) >= cutoff);
filtered.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
const selected = [];
const selectedKeys = new Set();
const keyFor = item => `${item.source}|${item.title}|${item.date}`;
const pushUnique = item => {
const key = keyFor(item);
if (selectedKeys.has(key)) return;
selected.push(item);
selectedKeys.add(key);
};
// Reserve a little space so newly-added regional feeds are not crowded out by larger globals.
for (const source of REGIONAL_NEWS_SOURCES) {
filtered.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
}
filtered.forEach(pushUnique);
return selected.slice(0, 50);
} }
// === Leverageable Ideas from Signals === // === Leverageable Ideas from Signals ===
@@ -311,11 +399,12 @@ export function generateIdeas(V2) {
// === Synthesize raw sweep data into dashboard format === // === Synthesize raw sweep data into dashboard format ===
export async function synthesize(data) { export async function synthesize(data) {
const air = (data.sources.OpenSky?.hotspots || []).map(h => ({ const liveAirHotspots = data.sources.OpenSky?.hotspots || [];
region: h.region, total: h.totalAircraft || 0, noCallsign: h.noCallsign || 0, const airFallback = sumAirHotspots(liveAirHotspots) > 0
highAlt: h.highAltitude || 0, ? null
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5) : loadOpenSkyFallback(data.sources.OpenSky?.timestamp || data.crucix?.timestamp);
})); const effectiveAirHotspots = airFallback?.hotspots || liveAirHotspots;
const air = summarizeAirHotspots(effectiveAirHotspots);
const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({ const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({
region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0, region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0,
hc: h.highConfidence || 0, hc: h.highConfidence || 0,
@@ -496,6 +585,14 @@ export async function synthesize(data) {
const V2 = { const V2 = {
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals, meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
airMeta: {
fallback: Boolean(airFallback),
liveTotal: sumAirHotspots(liveAirHotspots),
timestamp: airFallback?.timestamp || data.sources.OpenSky?.timestamp || data.crucix?.timestamp || null,
source: airFallback ? 'OpenSky fallback' : 'OpenSky',
...(airFallback ? { fallbackFile: airFallback.file } : {}),
...(data.sources.OpenSky?.error ? { error: data.sources.OpenSky.error } : {}),
},
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones }, sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news, who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,
@@ -549,9 +646,26 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
}); });
} }
// Sort by timestamp descending, limit to 50 // Filter to last 30 days, sort by timestamp descending, limit to 50
feed.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0)); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return feed.slice(0, 50); const recent = feed.filter(item => !item.timestamp || new Date(item.timestamp) >= cutoff);
recent.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const selected = [];
const selectedKeys = new Set();
const keyFor = item => `${item.type}|${item.source}|${item.headline}|${item.timestamp}`;
const pushUnique = item => {
const key = keyFor(item);
if (selectedKeys.has(key)) return;
selected.push(item);
selectedKeys.add(key);
};
for (const source of REGIONAL_NEWS_SOURCES) {
recent.filter(item => item.source === source).slice(0, 2).forEach(pushUnique);
}
recent.forEach(pushUnique);
return selected.slice(0, 50);
} }
// === CLI Mode: inject into HTML file === // === CLI Mode: inject into HTML file ===

View File

@@ -59,6 +59,10 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.meta-pill{font-family:var(--mono);font-size:11px;color:var(--dim);letter-spacing:0.06em;padding:5px 10px;border:1px solid var(--border)} .meta-pill{font-family:var(--mono);font-size:11px;color:var(--dim);letter-spacing:0.06em;padding:5px 10px;border:1px solid var(--border)}
.meta-pill .v{color:var(--text);font-weight:500} .meta-pill .v{color:var(--text);font-weight:500}
.alert-badge{padding:5px 12px;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;border:1px solid rgba(255,95,99,0.4);color:#fff;background:linear-gradient(135deg,rgba(255,95,99,0.2),rgba(255,95,99,0.08))} .alert-badge{padding:5px 12px;font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;border:1px solid rgba(255,95,99,0.4);color:#fff;background:linear-gradient(135deg,rgba(255,95,99,0.2),rgba(255,95,99,0.08))}
.guide-btn{padding:5px 12px;font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;border:1px solid rgba(68,204,255,0.28);color:var(--accent2);background:rgba(68,204,255,0.07);cursor:pointer;transition:all 0.2s}
.guide-btn:hover{border-color:rgba(68,204,255,0.5);background:rgba(68,204,255,0.12);color:#d9f7ff}
.perf-pill{cursor:pointer;background:rgba(255,255,255,0.05);transition:all 0.2s}
.perf-pill:hover{border-color:var(--accent2);color:var(--text);background:rgba(68,204,255,0.08)}
/* GRID */ /* GRID */
.grid{display:grid;grid-template-columns:240px 1fr 340px;gap:10px;margin-top:10px;min-height:calc(100vh - 100px)} .grid{display:grid;grid-template-columns:240px 1fr 340px;gap:10px;margin-top:10px;min-height:calc(100vh - 100px)}
@@ -93,6 +97,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.econ-row .eval{font-family:var(--mono);font-weight:600} .econ-row .eval{font-family:var(--mono);font-weight:600}
/* CENTER: MAP */ /* CENTER: MAP */
.map-region-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--border);background:var(--panel);backdrop-filter:blur(20px)}
.map-container{flex:1;min-height:560px;border:1px solid var(--border);background:radial-gradient(ellipse at center,rgba(4,12,20,1),rgba(2,4,8,1));position:relative;overflow:hidden} .map-container{flex:1;min-height:560px;border:1px solid var(--border);background:radial-gradient(ellipse at center,rgba(4,12,20,1),rgba(2,4,8,1));position:relative;overflow:hidden}
#globeViz{width:100%;height:100%;cursor:grab} #globeViz{width:100%;height:100%;cursor:grab}
#globeViz:active{cursor:grabbing} #globeViz:active{cursor:grabbing}
@@ -122,6 +127,28 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.map-popup .pp-text{font-size:11px;line-height:1.4;color:#c8d8d2} .map-popup .pp-text{font-size:11px;line-height:1.4;color:#c8d8d2}
.map-popup .pp-meta{font-family:var(--mono);font-size:10px;color:var(--dim);margin-top:6px} .map-popup .pp-meta{font-family:var(--mono);font-size:10px;color:var(--dim);margin-top:6px}
.map-popup .pp-close{position:absolute;top:6px;right:8px;background:none;border:none;color:var(--dim);font-size:14px;cursor:pointer} .map-popup .pp-close{position:absolute;top:6px;right:8px;background:none;border:none;color:var(--dim);font-size:14px;cursor:pointer}
.glossary-overlay{position:fixed;inset:0;z-index:1200;background:rgba(2,6,10,0.72);backdrop-filter:blur(10px);opacity:0;pointer-events:none;transition:opacity 0.25s ease}
.glossary-overlay.show{opacity:1;pointer-events:auto}
.glossary-panel{position:absolute;top:18px;right:18px;width:min(420px,calc(100vw - 32px));max-height:calc(100vh - 36px);display:flex;flex-direction:column;border:1px solid rgba(68,204,255,0.22);background:rgba(5,12,19,0.96);box-shadow:0 18px 48px rgba(0,0,0,0.45)}
.glossary-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:14px 16px 10px;border-bottom:1px solid rgba(255,255,255,0.06)}
.glossary-kicker{font-family:var(--mono);font-size:10px;letter-spacing:0.12em;text-transform:uppercase;color:var(--accent2);margin-bottom:5px}
.glossary-title{font-size:18px;font-weight:600;line-height:1.15}
.glossary-sub{font-size:11px;line-height:1.45;color:var(--dim);margin-top:6px}
.glossary-close{border:1px solid var(--border);background:rgba(255,255,255,0.03);color:var(--dim);width:30px;height:30px;font-size:18px;cursor:pointer;flex-shrink:0}
.glossary-body{overflow:auto;padding:12px 16px 16px;display:flex;flex-direction:column;gap:10px}
.glossary-card{padding:12px;border:1px solid rgba(255,255,255,0.05);background:rgba(255,255,255,0.02)}
.glossary-term{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:7px}
.glossary-term strong{font-family:var(--mono);font-size:11px;letter-spacing:0.08em;text-transform:uppercase}
.glossary-tag{font-family:var(--mono);font-size:9px;letter-spacing:0.08em;text-transform:uppercase;padding:2px 6px;border:1px solid rgba(100,240,200,0.18);color:var(--accent);background:rgba(100,240,200,0.05)}
.glossary-line{font-size:11px;line-height:1.5;color:#c8d8d2}
.glossary-line + .glossary-line{margin-top:5px}
.glossary-label{font-family:var(--mono);font-size:9px;letter-spacing:0.08em;text-transform:uppercase;color:var(--dim);margin-right:6px}
.glossary-foot{padding:10px 16px 14px;border-top:1px solid rgba(255,255,255,0.06);font-family:var(--mono);font-size:9px;line-height:1.5;color:rgba(106,138,130,0.8)}
.map-loading{position:absolute;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:linear-gradient(180deg,rgba(2,6,10,0.78),rgba(2,6,10,0.9));backdrop-filter:blur(10px)}
.map-loading.show{display:flex}
.map-loading-card{display:flex;flex-direction:column;align-items:center;gap:10px;padding:16px 18px;border:1px solid rgba(68,204,255,0.18);background:rgba(6,14,22,0.88);box-shadow:0 12px 32px rgba(0,0,0,0.35)}
.map-loading-ring{width:28px;height:28px;border:2px solid rgba(68,204,255,0.16);border-top-color:var(--accent2);border-radius:50%;animation:spin 1s linear infinite}
.map-loading-text{font-family:var(--mono);font-size:10px;letter-spacing:0.12em;text-transform:uppercase;color:var(--accent2)}
/* News label on map */ /* News label on map */
.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)}
@@ -187,7 +214,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.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)}
.ticker-track{display:flex;flex-direction:column;animation:tickerScroll var(--ticker-duration,30s) linear infinite} .ticker-track{display:flex;flex-direction:column;animation:tickerScroll var(--ticker-duration,30s) linear infinite;will-change:transform;contain:layout style}
.ticker-wrap:hover .ticker-track{animation-play-state:paused} .ticker-wrap:hover .ticker-track{animation-play-state:paused}
@keyframes tickerScroll{0%{transform:translateY(0)}100%{transform:translateY(-50%)}} @keyframes tickerScroll{0%{transform:translateY(0)}100%{transform:translateY(-50%)}}
.tk-card{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.03);cursor:default;transition:background 0.2s} .tk-card{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.03);cursor:default;transition:background 0.2s}
@@ -203,6 +230,13 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.tk-src.alj{color:#ffd54f;border-color:rgba(255,213,79,0.3)} .tk-src.alj{color:#ffd54f;border-color:rgba(255,213,79,0.3)}
.tk-src.gdelt{color:#4dd0e1;border-color:rgba(77,208,225,0.3)} .tk-src.gdelt{color:#4dd0e1;border-color:rgba(77,208,225,0.3)}
.tk-src.tg{color:#ffb74d;border-color:rgba(255,183,77,0.3)} .tk-src.tg{color:#ffb74d;border-color:rgba(255,183,77,0.3)}
.tk-src.dw{color:#ef9a9a;border-color:rgba(239,154,154,0.3)}
.tk-src.eu{color:#ce93d8;border-color:rgba(206,147,216,0.3)}
.tk-src.af{color:#a5d6a7;border-color:rgba(165,214,167,0.3)}
.tk-src.sa{color:#ffab91;border-color:rgba(255,171,145,0.3)}
.tk-src.ind{color:#ffcc80;border-color:rgba(255,204,128,0.3)}
.tk-src.anz{color:#80cbc4;border-color:rgba(128,203,196,0.3)}
.tk-src.us{color:#90caf9;border-color:rgba(144,202,249,0.3)}
.tk-src.other{color:#b0bec5;border-color:rgba(176,190,197,0.2)} .tk-src.other{color:#b0bec5;border-color:rgba(176,190,197,0.2)}
.tk-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px} .tk-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px}
.tk-time{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px} .tk-time{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px}
@@ -224,9 +258,40 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.ideas-src.llm{color:#ce93d8;border-color:rgba(206,147,216,0.4);background:rgba(206,147,216,0.08)} .ideas-src.llm{color:#ce93d8;border-color:rgba(206,147,216,0.4);background:rgba(206,147,216,0.08)}
.ideas-src.static{color:var(--dim);border-color:rgba(255,255,255,0.1);background:rgba(255,255,255,0.03)} .ideas-src.static{color:var(--dim);border-color:rgba(255,255,255,0.1);background:rgba(255,255,255,0.03)}
/* LOW PERFORMANCE MODE */
body.low-perf .bg-grid,body.low-perf .bg-radial,body.low-perf .scanline{display:none!important}
body.low-perf .topbar,body.low-perf .g-panel,body.low-perf .map-popup,body.low-perf .map-loading{backdrop-filter:none!important}
body.low-perf .logo-ring::before,body.low-perf .logo-ring::after,body.low-perf .regime-chip .blink,body.low-perf .conflict-ring,body.low-perf .corridor-flow{animation:none!important}
body.low-perf .ticker-wrap{overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(100,240,200,0.2) transparent}
body.low-perf .ticker-track{animation:none!important;display:block!important}
body.low-perf .ticker-wrap::before,body.low-perf .ticker-wrap::after{display:none}
body.low-perf .ticker-wrap::-webkit-scrollbar{width:4px}
body.low-perf .ticker-wrap::-webkit-scrollbar-track{background:transparent}
body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,0.2);border-radius:2px}
/* 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 .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)}} @media(max-width:1100px){
#main{padding:8px}
.topbar{padding:10px 12px}
.top-left,.top-center,.top-right{width:100%}
.top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
.map-region-bar{display:none}
.top-right{gap:6px}
.region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px}
.grid{display:flex;flex-direction:column}
#centerCol{order:1}
#rightRail{order:2}
#leftRail{order:3}
.map-container{min-height:420px}
.map-hint{font-size:8px;right:8px}
.map-legend{left:8px;right:8px;bottom:8px;gap:4px}
.leg-item{font-size:8px}
.lower .lp-ticker,.lower .lp-osint,.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)}
.glossary-panel{top:auto;right:0;left:0;bottom:0;width:100%;max-height:min(72vh,720px);border-left:none;border-right:none;border-bottom:none}
}
/* 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}}
@@ -271,9 +336,11 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
<div class="grid"> <div class="grid">
<div class="col" id="leftRail"></div> <div class="col" id="leftRail"></div>
<div class="col" id="centerCol"> <div class="col" id="centerCol">
<div class="map-region-bar" id="mapRegionBar"></div>
<div class="map-container" id="mapContainer"> <div class="map-container" id="mapContainer">
<div id="globeViz"></div> <div id="globeViz"></div>
<svg id="flatMapSvg" style="display:none;width:100%;height:100%;position:absolute;top:0;left:0;cursor:grab"></svg> <svg id="flatMapSvg" style="display:none;width:100%;height:100%;position:absolute;top:0;left:0;cursor:grab"></svg>
<div class="map-loading" id="mapLoading"><div class="map-loading-card"><div class="map-loading-ring"></div><div class="map-loading-text" id="mapLoadingText">Initializing 3D Globe</div></div></div>
<div class="map-legend" id="mapLegend"></div> <div class="map-legend" id="mapLegend"></div>
<div class="map-hint" id="mapHint">SCROLL TO ZOOM · DRAG TO PAN</div> <div class="map-hint" id="mapHint">SCROLL TO ZOOM · DRAG TO PAN</div>
<div class="map-controls"> <div class="map-controls">
@@ -289,14 +356,161 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
<div class="col" id="rightRail"></div> <div class="col" id="rightRail"></div>
</div> </div>
</div> </div>
<div class="glossary-overlay" id="glossaryOverlay" onclick="if(event.target===this) closeGlossary()">
<div class="glossary-panel">
<div class="glossary-head">
<div>
<div class="glossary-kicker">Signal Guide</div>
<div class="glossary-title">What the signals actually mean</div>
<div class="glossary-sub">Plain-English interpretation, why it matters, and what you should not infer from it.</div>
</div>
<button class="glossary-close" onclick="closeGlossary()" aria-label="Close signal guide">&times;</button>
</div>
<div class="glossary-body" id="glossaryBody"></div>
<div class="glossary-foot">Treat these as interpretation guides, not conclusions. Stronger judgments should come from corroboration across multiple layers, not from a single signal viewed in isolation.</div>
</div>
</div>
<script> <script>
// === DATA === // === DATA ===
let D = null; let D = null;
// === I18N ===
const L = window.__CRUCIX_LOCALE__ || {};
function t(keyPath, fallback) {
const keys = keyPath.split('.');
let value = L;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
return fallback || keyPath;
}
}
return typeof value === 'string' ? value : (fallback || keyPath);
}
// === GLOBALS === // === GLOBALS ===
let globe = null; let globe = null;
let globeInitialized = false;
let flightsVisible = true; let flightsVisible = true;
let isFlat = true; let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true';
let isFlat = shouldStartFlat();
let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const signalGuideItems = [
{
term:'No Callsign',
category:'Air',
meaning:'OpenSky received an aircraft track without a usable callsign or flight ID in that record.',
matters:'Useful as an opacity signal. A cluster of missing callsigns can indicate incomplete transponder metadata or less transparent traffic.',
notMeaning:'Not proof of military, covert, or hostile activity on its own.',
example:'South China Sea: No Callsign 6 of 152 means 6 tracks in that theater had no usable callsign in the feed.'
},
{
term:'High Altitude',
category:'Air',
meaning:'Aircraft above 12,000 meters, roughly 39,000 feet, in the current OpenSky snapshot.',
matters:'Separates cruise-level traffic from lower-altitude local or regional movement.',
notMeaning:'Not a danger score and not inherently unusual. Commercial jets commonly operate here.',
example:'High Altitude 2 means only 2 tracked aircraft in that hotspot were above the cruise threshold at that snapshot.'
},
{
term:'Top Countries',
category:'Air',
meaning:'The most common OpenSky origin_country values among aircraft in that hotspot.',
matters:'Useful for understanding the rough composition of traffic flowing through a theater.',
notMeaning:'Not who is controlling the aircraft right now and not a direct indicator of military ownership.',
example:'China (61), Philippines (39), Taiwan (17) means those were the top registered origin countries in the snapshot.'
},
{
term:'FRP',
category:'Thermal',
meaning:'Fire Radiative Power. This is the intensity of one specific FIRMS hotspot, measured in megawatts.',
matters:'Higher FRP usually means a hotter, larger, or more energetic fire event at that exact point.',
notMeaning:'Not the intensity of the whole region and not automatic proof of conflict activity.',
example:'Sudan / Horn of Africa: FRP 92.3 MW describes that one hotspot, while Total 1,451 describes the entire regional detection count.'
},
{
term:'Total Detections',
category:'Thermal',
meaning:'The total number of FIRMS thermal detections in the entire region bucket for the current sweep.',
matters:'Useful for spotting unusually active fire clusters, especially when compared with historical baselines or night activity.',
notMeaning:'Not a count for the single map point you clicked and not necessarily a conflict count.',
example:'Total 1,451 means the whole Sudan / Horn of Africa bucket had 1,451 detections in that sweep.'
},
{
term:'Night Detections',
category:'Thermal',
meaning:'Thermal detections tagged as occurring at night inside the broader FIRMS region bucket.',
matters:'Nighttime heat can be more noteworthy because it is less likely to be routine daytime land burning.',
notMeaning:'Not a direct combat indicator. It still needs context from location, baseline, and corroborating sources.',
example:'Night 140 means 140 of the 1,451 regional detections were nighttime detections in that sweep.'
},
{
term:'Chokepoint',
category:'Maritime',
meaning:'A strategic maritime corridor or passage where trade and energy flows can be delayed, diverted, or disrupted.',
matters:'These nodes matter because a disruption here can affect shipping costs, transit times, and commodity pricing globally.',
notMeaning:'Not proof that disruption is happening now. It is a strategic watch location.',
example:'Bab el-Mandeb or the Strait of Hormuz matter because shipping and energy flows concentrate there.'
},
{
term:'SDR Receiver',
category:'Signals',
meaning:'A publicly reachable software-defined radio receiver in or near a region of interest.',
matters:'Dense receiver coverage can give you more ability to monitor communications or signal activity in a theater.',
notMeaning:'Not evidence of hostile emissions or a threat by itself. It is an observation and monitoring layer.',
example:'South China Sea SDR count means publicly accessible KiwiSDR receivers are available in or near that zone.'
},
{
term:'CPM',
category:'Radiation',
meaning:'Counts per minute from a radiation monitoring source, used here for relative radiation status at a site.',
matters:'Useful for spotting anomalies against the sites normal range or comparing consecutive readings.',
notMeaning:'Not a direct safety verdict on its own. Interpretation depends on local baseline and trend, not the raw number alone.',
example:'A site reading 33 CPM can be normal if that locations usual background level is in the same range.'
},
{
term:'HY Spread',
category:'Macro',
meaning:'High-yield credit spread, shown here as a stress proxy from FRED credit data.',
matters:'When spreads widen, markets are usually pricing more credit stress and tighter financial conditions.',
notMeaning:'Not a recession call by itself. It is one stress signal among many.',
example:'A rising HY Spread alongside higher VIX and weaker equities is a stronger risk-off pattern than HY alone.'
},
{
term:'VIX',
category:'Macro',
meaning:'The CBOE Volatility Index, commonly used as a market-implied fear or volatility gauge.',
matters:'Higher VIX often means more expected equity volatility and more defensive market positioning.',
notMeaning:'Not a direct forecast of a crash and not a geopolitical indicator by itself.',
example:'VIX above 20 with widening HY spreads is a stronger stress pattern than VIX alone.'
},
{
term:'GSCPI',
category:'Macro',
meaning:'The Global Supply Chain Pressure Index, a broad indicator of global supply-chain strain.',
matters:'It helps translate geopolitical or weather disruptions into likely pressure on shipping, inventory, and pricing.',
notMeaning:'Not a live market price and not a company-specific supply-chain score by itself.',
example:'A higher GSCPI makes route or energy shocks more likely to spill into broader cost pressure.'
},
{
term:'WHO Alert',
category:'Health',
meaning:'A WHO Disease Outbreak News item or outbreak-related bulletin surfaced in the health layer.',
matters:'Useful for watching outbreaks that could affect travel, supply chains, humanitarian stress, or regional operating conditions.',
notMeaning:'Not a pandemic declaration and not automatically high severity.',
example:'A WHO alert in a port-heavy region matters more if it overlaps shipping, border controls, or local instability signals.'
},
{
term:'Sweep Delta',
category:'Platform',
meaning:'The change summary between the current sweep and the previous one, including new, escalated, and de-escalated signals.',
matters:'Useful for spotting what changed recently instead of re-reading the full dashboard from scratch.',
notMeaning:'Not a full risk model. It is a directional change layer on top of the raw signals.',
example:'A delta marked risk-off with several new and escalated items means the latest sweep materially worsened the signal mix.'
}
];
const regionPOV = { const regionPOV = {
world: { lat: 20, lng: 20, altitude: 1.8 }, world: { lat: 20, lng: 20, altitude: 1.8 },
americas: { lat: 35, lng: -95, altitude: 1.0 }, americas: { lat: 35, lng: -95, altitude: 1.0 },
@@ -306,28 +520,86 @@ const regionPOV = {
africa: { lat: 5, lng: 20, altitude: 1.2 } africa: { lat: 5, lng: 20, altitude: 1.2 }
}; };
if(lowPerfMode) document.body.classList.add('low-perf');
function isWeakMobileDevice(){
const reducedMotion = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const memory = navigator.deviceMemory || 0;
const cores = navigator.hardwareConcurrency || 0;
return reducedMotion || (memory > 0 && memory <= 4) || (cores > 0 && cores <= 4);
}
function shouldStartFlat(){
if(!isMobileLayout()) return true;
return lowPerfMode || isWeakMobileDevice();
}
function setMapLoading(show, text='Initializing 3D Globe'){
const overlay = document.getElementById('mapLoading');
const label = document.getElementById('mapLoadingText');
if(!overlay || !label) return;
label.textContent = text;
overlay.classList.toggle('show', show);
}
function togglePerfMode(){
lowPerfMode = !lowPerfMode;
localStorage.setItem('crucix_low_perf', String(lowPerfMode));
document.body.classList.toggle('low-perf', lowPerfMode);
const perfStatus = document.getElementById('perfStatus');
if(perfStatus) perfStatus.textContent = lowPerfMode ? 'LOW' : 'HIGH';
if(globe){
globe.controls().autoRotate = !lowPerfMode;
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
}
if(lowPerfMode && isMobileLayout() && !isFlat){
toggleMapMode();
} else {
renderLower();
renderRight();
}
}
// === TOPBAR === // === TOPBAR ===
function getRegionControlsMarkup(){
return ['world','americas','europe','middleEast','asiaPacific','africa'].map(r=>
`<button class="region-btn ${r===currentRegion?'active':''}" data-region="${r}" onclick="setRegion('${r}')">${r==='middleEast'?'MIDDLE EAST':r==='asiaPacific'?'ASIA PACIFIC':r.toUpperCase()}</button>`
).join('');
}
function renderRegionControls(){
const mapRegionBar = document.getElementById('mapRegionBar');
if(!mapRegionBar) return;
if(isMobileLayout()){
mapRegionBar.innerHTML = '';
mapRegionBar.style.display = 'none';
return;
}
mapRegionBar.innerHTML = getRegionControlsMarkup();
mapRegionBar.style.display = 'flex';
}
function renderTopbar(){ function renderTopbar(){
const mobile = isMobileLayout();
const ts = new Date(D.meta.timestamp); const ts = new Date(D.meta.timestamp);
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase(); const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
const t = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
document.getElementById('topbar').innerHTML=` document.getElementById('topbar').innerHTML=`
<div class="top-left"> <div class="top-left">
<span class="brand">CRUCIX MONITOR</span> <span class="brand">CRUCIX MONITOR</span>
<span class="regime-chip"><span class="blink"></span>WARTIME STAGFLATION RISK</span> <span class="regime-chip"><span class="blink"></span>WARTIME STAGFLATION RISK</span>
</div> </div>
<div class="top-center"> ${mobile ? `<div class="top-center">${getRegionControlsMarkup()}</div>` : ''}
${['world','americas','europe','middleEast','asiaPacific','africa'].map(r=>
`<button class="region-btn ${r==='world'?'active':''}" data-region="${r}" onclick="setRegion('${r}')">${r==='middleEast'?'MIDDLE EAST':r==='asiaPacific'?'ASIA PACIFIC':r.toUpperCase()}</button>`
).join('')}
</div>
<div class="top-right"> <div class="top-right">
<span class="meta-pill">SWEEP <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span> <button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.perf','PERF')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.perfLow','LOW'):t('dashboard.perfHigh','HIGH')}</span></button>
<span class="meta-pill">${d} <span class="v">${t}</span></span> <span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span>
<span class="meta-pill">SOURCES <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span> <span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
${D.delta?.summary ? `<span class="meta-pill">DELTA <span class="v">${D.delta.summary.direction==='risk-off'?'&#x25B2; RISK-OFF':D.delta.summary.direction==='risk-on'?'&#x25BC; RISK-ON':'&#x25C6; MIXED'}</span></span>` : ''} <span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
<span class="alert-badge">HIGH ALERT</span> ${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${D.delta.summary.direction==='risk-off'?'&#x25B2; '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'&#x25BC; '+t('dashboard.riskOn','RISK-ON'):'&#x25C6; '+t('dashboard.mixed','MIXED')}</span></span>` : ''}
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
</div>`; </div>`;
renderRegionControls();
} }
// === LEFT RAIL === // === LEFT RAIL ===
@@ -339,16 +611,16 @@ function renderLeftRail(){
const conflictEvents = D.acled?.totalEvents || 0; const conflictEvents = D.acled?.totalEvents || 0;
const conflictFatal = D.acled?.totalFatalities || 0; const conflictFatal = D.acled?.totalFatalities || 0;
const layers=[ const layers=[
{name:'Air Activity',count:totalAir,dot:'air',sub:`${D.air.length} theaters`}, {name:t('layers.airActivity','Air Activity'),count:totalAir,dot:'air',sub:`${D.air.length} ${t('layers.theaters','theaters')}`},
{name:'Thermal Spikes',count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} night det.`}, {name:t('layers.thermalSpikes','Thermal Spikes'),count:totalThermal.toLocaleString(),dot:'thermal',sub:`${totalNight.toLocaleString()} ${t('layers.nightDet','night det.')}`},
{name:'SDR Coverage',count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} online`}, {name:t('layers.sdrCoverage','SDR Coverage'),count:D.sdr.total,dot:'sdr',sub:`${D.sdr.online} ${t('layers.online','online')}`},
{name:'Maritime Watch',count:D.chokepoints.length,dot:'maritime',sub:'chokepoints'}, {name:t('layers.maritimeWatch','Maritime Watch'),count:D.chokepoints.length,dot:'maritime',sub:t('layers.chokepoints','chokepoints')},
{name:'Nuclear Sites',count:D.nuke.length,dot:'nuke',sub:'monitors'}, {name:t('layers.nuclearSites','Nuclear Sites'),count:D.nuke.length,dot:'nuke',sub:t('layers.monitors','monitors')},
{name:'Conflict Events',count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} fatalities`}, {name:t('layers.conflictEvents','Conflict Events'),count:conflictEvents,dot:'thermal',sub:`${conflictFatal.toLocaleString()} ${t('layers.fatalities','fatalities')}`},
{name:'Health Watch',count:D.who.length,dot:'health',sub:'WHO alerts'}, {name:t('layers.healthWatch','Health Watch'),count:D.who.length,dot:'health',sub:t('layers.whoAlerts','WHO alerts')},
{name:'World News',count:newsCount,dot:'news',sub:'RSS geolocated'}, {name:t('layers.worldNews','World News'),count:newsCount,dot:'news',sub:t('layers.rssGeolocated','RSS geolocated')},
{name:'OSINT Feed',count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} urgent`}, {name:t('layers.osintFeed','OSINT Feed'),count:D.tg.posts,dot:'incident',sub:`${D.tg.urgent.length} ${t('badges.urgent','urgent').toLowerCase()}`},
{name:'Satellites',count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} new (30d)`} {name:t('layers.spaceActivity','Satellites'),count:D.space?.militarySats||0,dot:'space',sub:`${D.space?.totalNewObjects||0} ${t('space.newLast30d','new (30d)')}`}
]; ];
const allNormal=D.nuke.every(s=>!s.anom); const allNormal=D.nuke.every(s=>!s.anom);
const nukeHtml=D.nuke.map(s=>`<div class="site-row"><span>${s.site}</span><span class="site-val">${s.n>0?(s.cpm?.toFixed(1)||'--')+' CPM':'No data'}</span></div>`).join(''); const nukeHtml=D.nuke.map(s=>`<div class="site-row"><span>${s.site}</span><span class="site-val">${s.n>0?(s.cpm?.toFixed(1)||'--')+' CPM':'No data'}</span></div>`).join('');
@@ -361,26 +633,26 @@ function renderLeftRail(){
document.getElementById('leftRail').innerHTML=` document.getElementById('leftRail').innerHTML=`
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>Sensor Grid</h3><span class="badge">LIVE</span></div> <div class="sec-head"><h3>${t('panels.sensorGrid','Sensor Grid')}</h3><span class="badge">${t('badges.live','LIVE')}</span></div>
${layers.map(l=>`<div class="layer-item"><div class="layer-left"><div class="ldot ${l.dot}"></div><div><div class="layer-name">${l.name}</div><div class="layer-sub">${l.sub}</div></div></div><div class="layer-count">${l.count}</div></div>`).join('')} ${layers.map(l=>`<div class="layer-item"><div class="layer-left"><div class="ldot ${l.dot}"></div><div><div class="layer-name">${l.name}</div><div class="layer-sub">${l.sub}</div></div></div><div class="layer-count">${l.count}</div></div>`).join('')}
</div> </div>
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>Nuclear Watch</h3><span class="badge">RADIATION</span></div> <div class="sec-head"><h3>${t('panels.nuclearWatch','Nuclear Watch')}</h3><span class="badge">${t('badges.radiation','RADIATION')}</span></div>
<div class="nuke-ok">${allNormal?'&#9679; ALL SITES NORMAL':'&#9888; ANOMALY DETECTED'}</div> <div class="nuke-ok">${allNormal?'&#9679; '+t('nuclear.allSitesNormal','ALL SITES NORMAL'):'&#9888; '+t('nuclear.anomalyDetected','ANOMALY DETECTED')}</div>
${nukeHtml} ${nukeHtml}
</div> </div>
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>Risk Gauges</h3><span class="badge">STRESS</span></div> <div class="sec-head"><h3>${t('panels.riskGauges','Risk Gauges')}</h3><span class="badge">${t('badges.stress','STRESS')}</span></div>
<div class="econ-row"><span class="elabel">VIX (Fear)</span><span class="eval" style="color:${vix?.value>20?'var(--warn)':'var(--accent)'}">${vix?.value||'--'}</span></div> <div class="econ-row"><span class="elabel">${t('metrics.vix','VIX')} (Fear)</span><span class="eval" style="color:${vix?.value>20?'var(--warn)':'var(--accent)'}">${vix?.value||'--'}</span></div>
<div class="econ-row"><span class="elabel">HY Spread</span><span class="eval">${hy?.value||'--'}</span></div> <div class="econ-row"><span class="elabel">${t('metrics.hySpread','HY Spread')}</span><span class="eval">${hy?.value||'--'}</span></div>
<div class="econ-row"><span class="elabel">USD Index</span><span class="eval">${usd?.value?.toFixed(1)||'--'}</span></div> <div class="econ-row"><span class="elabel">${t('metrics.usdIndex','USD Index')}</span><span class="eval">${usd?.value?.toFixed(1)||'--'}</span></div>
<div class="econ-row"><span class="elabel">Jobless Claims</span><span class="eval">${claims?.value?.toLocaleString()||'--'}</span></div> <div class="econ-row"><span class="elabel">${t('metrics.joblessClaims','Jobless Claims')}</span><span class="eval">${claims?.value?.toLocaleString()||'--'}</span></div>
<div class="econ-row"><span class="elabel">30Y Mortgage</span><span class="eval">${mort?.value||'--'}%</span></div> <div class="econ-row"><span class="elabel">${t('metrics.mortgage30y','30Y Mortgage')}</span><span class="eval">${mort?.value||'--'}%</span></div>
<div class="econ-row"><span class="elabel">M2 Supply</span><span class="eval">$${(m2?.value/1000)?.toFixed(1)||'--'}T</span></div> <div class="econ-row"><span class="elabel">${t('metrics.m2Supply','M2 Supply')}</span><span class="eval">$${(m2?.value/1000)?.toFixed(1)||'--'}T</span></div>
<div class="econ-row"><span class="elabel">Nat. Debt</span><span class="eval">$${(parseFloat(D.treasury.totalDebt)/1e12).toFixed(2)}T</span></div> <div class="econ-row"><span class="elabel">${t('metrics.natDebt','Nat. Debt')}</span><span class="eval">$${(parseFloat(D.treasury.totalDebt)/1e12).toFixed(2)}T</span></div>
</div> </div>
<div class="g-panel"> <div class="g-panel">
<div class="sec-head"><h3>Space Watch</h3><span class="badge">CELESTRAK</span></div> <div class="sec-head"><h3>${t('panels.spaceWatch','Space Watch')}</h3><span class="badge">${t('badges.orbital','CELESTRAK')}</span></div>
${D.space ? ` ${D.space ? `
<div class="econ-row"><span class="elabel">New Objects (30d)</span><span class="eval" style="color:var(--accent2)">${D.space.totalNewObjects||0}</span></div> <div class="econ-row"><span class="elabel">New Objects (30d)</span><span class="eval" style="color:var(--accent2)">${D.space.totalNewObjects||0}</span></div>
<div class="econ-row"><span class="elabel">Military Sats</span><span class="eval">${D.space.militarySats||0}</span></div> <div class="econ-row"><span class="elabel">Military Sats</span><span class="eval">${D.space.militarySats||0}</span></div>
@@ -394,7 +666,67 @@ function renderLeftRail(){
} }
// === MAP === // === MAP ===
let mapLifecycleBound = false;
function bindMapLifecycleEvents(){
if(mapLifecycleBound) return;
mapLifecycleBound = true;
window.addEventListener('resize', () => syncResponsiveLayout());
window.addEventListener('orientationchange', () => setTimeout(() => syncResponsiveLayout(true), 150));
document.addEventListener('visibilitychange', () => {
if(!document.hidden) setTimeout(() => syncResponsiveLayout(true), 150);
});
window.addEventListener('pageshow', () => setTimeout(() => syncResponsiveLayout(true), 150));
}
function renderMapLegend(){
document.getElementById('mapLegend').innerHTML=
[{c:'#64f0c8',l:t('map.airTraffic','Air Traffic')},{c:'#ff5f63',l:t('map.thermalFire','Thermal/Fire')},{c:'rgba(255,120,80,0.8)',l:t('map.conflict','Conflict')},{c:'#44ccff',l:t('map.sdrReceiver','SDR Receiver')},
{c:'#ffe082',l:t('map.nuclearSite','Nuclear Site')},{c:'#b388ff',l:t('map.chokepoint','Chokepoint')},{c:'#ffb84c',l:t('map.osintEvent','OSINT Event')},{c:'#69f0ae',l:t('map.healthAlert','Health Alert')},{c:'#81d4fa',l:t('map.worldNews','World News')},{c:'#ff9800',l:t('map.weatherAlert','Weather Alert')},{c:'#cddc39',l:t('map.epaRadNet','EPA RadNet')},{c:'#ffffff',l:t('map.spaceStation','Space Station')},{c:'#6495ed',l:t('map.gdeltEvent','GDELT Event')}]
.map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join('');
}
function initMap(){ function initMap(){
bindMapLifecycleEvents();
renderMapLegend();
if(isFlat){
if(globe && typeof globe.pauseAnimation === 'function') globe.pauseAnimation();
document.getElementById('globeViz').style.display = 'none';
document.getElementById('flatMapSvg').style.display = 'block';
document.getElementById('projToggle').textContent = 'GLOBE MODE';
document.getElementById('mapHint').textContent = 'SCROLL TO ZOOM · DRAG TO PAN';
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
setMapLoading(false);
return;
}
setMapLoading(true, 'Initializing 3D Globe');
requestAnimationFrame(() => {
try {
initGlobe();
setMapLoading(false);
} catch {
isFlat = true;
document.getElementById('globeViz').style.display = 'none';
document.getElementById('flatMapSvg').style.display = 'block';
document.getElementById('projToggle').textContent = 'GLOBE MODE';
document.getElementById('mapHint').textContent = '3D LOAD FAILED · FLAT MODE';
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
setMapLoading(false);
}
});
}
function initGlobe(){
if(globeInitialized && globe){
if(typeof globe.resumeAnimation === 'function') globe.resumeAnimation();
document.getElementById('globeViz').style.display = 'block';
document.getElementById('flatMapSvg').style.display = 'none';
document.getElementById('projToggle').textContent = 'FLAT MODE';
document.getElementById('mapHint').textContent = 'DRAG TO ROTATE · SCROLL TO ZOOM';
return;
}
const container = document.getElementById('mapContainer'); const container = document.getElementById('mapContainer');
const w = container.clientWidth; const w = container.clientWidth;
const h = container.clientHeight || 560; const h = container.clientHeight || 560;
@@ -469,7 +801,7 @@ function initMap(){
globe.pointOfView(regionPOV.world, 0); globe.pointOfView(regionPOV.world, 0);
// Auto-rotate slowly // Auto-rotate slowly
globe.controls().autoRotate = true; globe.controls().autoRotate = !lowPerfMode;
globe.controls().autoRotateSpeed = 0.3; globe.controls().autoRotateSpeed = 0.3;
globe.controls().enableDamping = true; globe.controls().enableDamping = true;
globe.controls().dampingFactor = 0.1; globe.controls().dampingFactor = 0.1;
@@ -482,37 +814,35 @@ function initMap(){
clearTimeout(rotateTimeout); clearTimeout(rotateTimeout);
}); });
el.addEventListener('mouseup', () => { el.addEventListener('mouseup', () => {
rotateTimeout = setTimeout(() => { globe.controls().autoRotate = true; }, 10000); rotateTimeout = setTimeout(() => { if(globe && !lowPerfMode) globe.controls().autoRotate = true; }, 10000);
});
// Resize handler
window.addEventListener('resize', () => {
const c = document.getElementById('mapContainer');
globe.width(c.clientWidth).height(c.clientHeight || 560);
}); });
// Plot globe markers (preloaded but hidden) // Plot globe markers (preloaded but hidden)
plotMarkers(); plotMarkers();
// Start in flat mode — hide globe, show flat map // Start in flat mode — hide globe, show flat map
if(isFlat){
document.getElementById('globeViz').style.display = 'none'; document.getElementById('globeViz').style.display = 'none';
document.getElementById('flatMapSvg').style.display = 'block'; document.getElementById('flatMapSvg').style.display = 'block';
initFlatMap(); initFlatMap();
} else {
document.getElementById('globeViz').style.display = 'block';
document.getElementById('flatMapSvg').style.display = 'none';
document.getElementById('projToggle').textContent = 'FLAT MODE';
document.getElementById('mapHint').textContent = 'DRAG TO ROTATE · SCROLL TO ZOOM';
}
// Legend globeInitialized = true;
document.getElementById('mapLegend').innerHTML=
[{c:'#64f0c8',l:'Air Traffic'},{c:'#ff5f63',l:'Thermal/Fire'},{c:'rgba(255,120,80,0.8)',l:'Conflict'},{c:'#44ccff',l:'SDR Receiver'},
{c:'#ffe082',l:'Nuclear Site'},{c:'#b388ff',l:'Chokepoint'},{c:'#ffb84c',l:'OSINT Event'},{c:'#69f0ae',l:'Health Alert'},{c:'#81d4fa',l:'World News'},{c:'#ff9800',l:'Weather Alert'},{c:'#cddc39',l:'EPA RadNet'},{c:'#ffffff',l:'Space Station'},{c:'#6495ed',l:'GDELT Event'}]
.map(x=>`<div class="leg-item"><div class="leg-dot" style="background:${x.c}"></div>${x.l}</div>`).join('');
} }
function plotMarkers(){ function plotMarkers(){
if(!globe) return;
const points = []; const points = [];
const labels = []; const labels = [];
// === Air hotspots (green) === // === Air hotspots (green) ===
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
D.air.forEach((a,i)=>{ if(flightsVisible) D.air.forEach((a,i)=>{
const c=airCoords[i]; if(!c) return; const c=airCoords[i]; if(!c) return;
points.push({ points.push({
lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015, lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015,
@@ -663,6 +993,8 @@ function plotMarkers(){
globe.ringsData(conflictRings); globe.ringsData(conflictRings);
// === FLIGHT CORRIDORS (3D arcs) === // === FLIGHT CORRIDORS (3D arcs) ===
const arcs = [];
if(flightsVisible){
const airCoordsFlight = [ const airCoordsFlight = [
{region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120}, {region:'Middle East',lat:30,lon:44}, {region:'Taiwan Strait',lat:24,lon:120},
{region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24}, {region:'Ukraine Region',lat:49,lon:32}, {region:'Baltic Region',lat:57,lon:24},
@@ -674,7 +1006,6 @@ function plotMarkers(){
{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4}, {lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},
{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5} {lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}
]; ];
const arcs = [];
// Inter-hotspot corridors // Inter-hotspot corridors
for(let i=0; i<D.air.length; i++){ for(let i=0; i<D.air.length; i++){
for(let j=i+1; j<D.air.length; j++){ for(let j=i+1; j<D.air.length; j++){
@@ -709,6 +1040,7 @@ function plotMarkers(){
}); });
}); });
}); });
}
globe.arcsData(arcs); globe.arcsData(arcs);
// Zoom-aware marker sizing: scale markers and labels with camera altitude // Zoom-aware marker sizing: scale markers and labels with camera altitude
@@ -721,6 +1053,7 @@ function plotMarkers(){
globe.labelSize(d => showLabels ? (d.size || 0.4) : 0); globe.labelSize(d => showLabels ? (d.size || 0.4) : 0);
// Scale arc strokes with zoom // Scale arc strokes with zoom
globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt))); globe.arcStroke(d => (d.stroke || 0.4) * Math.max(0.5, Math.min(1.5, 1.2 / alt)));
globe.arcDashAnimateTime(lowPerfMode ? 0 : 2000);
// Priority-based point visibility: hide low-priority markers when zoomed out // Priority-based point visibility: hide low-priority markers when zoomed out
if(alt > 2.0){ if(alt > 2.0){
globe.pointsData(points.filter(p => (p.priority||3) <= 1)); globe.pointsData(points.filter(p => (p.priority||3) <= 1));
@@ -767,6 +1100,16 @@ function toggleFlights() {
flightsVisible = !flightsVisible; flightsVisible = !flightsVisible;
const btn = document.getElementById('flightToggle'); const btn = document.getElementById('flightToggle');
btn.classList.toggle('off', !flightsVisible); btn.classList.toggle('off', !flightsVisible);
if(isFlat){
if(flatG){
flatG.selectAll('*').remove();
drawFlatMap();
}
return;
}
if(!globe){
return;
}
if(flightsVisible) { if(flightsVisible) {
plotMarkers(); // re-render with arcs plotMarkers(); // re-render with arcs
} else { } else {
@@ -794,13 +1137,32 @@ function toggleMapMode(){
const globeEl = document.getElementById('globeViz'); const globeEl = document.getElementById('globeViz');
const flatEl = document.getElementById('flatMapSvg'); const flatEl = document.getElementById('flatMapSvg');
if(isFlat){ if(isFlat){
if(globe && typeof globe.pauseAnimation === 'function') globe.pauseAnimation();
globeEl.style.display = 'none'; globeEl.style.display = 'none';
flatEl.style.display = 'block'; flatEl.style.display = 'block';
setMapLoading(false);
if(!flatSvg) initFlatMap(); if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); } else { flatG.selectAll('*').remove(); drawFlatMap(); }
} else { } else {
globeEl.style.display = 'block';
flatEl.style.display = 'none'; flatEl.style.display = 'none';
setMapLoading(true, 'Initializing 3D Globe');
requestAnimationFrame(() => {
try {
initGlobe();
if(globe && typeof globe.resumeAnimation === 'function') globe.resumeAnimation();
globeEl.style.display = 'block';
setMapLoading(false);
} catch {
isFlat = true;
globeEl.style.display = 'none';
flatEl.style.display = 'block';
btn.textContent = 'GLOBE MODE';
hint.textContent = '3D LOAD FAILED · FLAT MODE';
if(!flatSvg) initFlatMap();
else { flatG.selectAll('*').remove(); drawFlatMap(); }
setMapLoading(false);
}
});
} }
} }
@@ -817,13 +1179,6 @@ function initFlatMap(){
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)}); flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px') flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
.style('display',k>=2.5?'block':'none'); .style('display',k>=2.5?'block':'none');
// Priority-based visibility: hide low-priority markers at low zoom
flatG.selectAll('[data-priority]').style('display',function(){
const p=+this.dataset.priority;
if(p<=1) return 'block';
if(p<=2) return k>=2?'block':'none';
return k>=3.5?'block':'none';
});
}); });
flatSvg.call(flatZoom); flatSvg.call(flatZoom);
drawFlatMap(); drawFlatMap();
@@ -852,12 +1207,14 @@ function plotFlatMarkers(){
}; };
// Air // Air
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
if(flightsVisible){
D.air.forEach((a,i)=>{ D.air.forEach((a,i)=>{
const c=airCoords[i];if(!c)return; const c=airCoords[i];if(!c)return;
const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)', const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)',
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1); ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1);
if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total); if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total);
}); });
}
// Thermal // Thermal
D.thermal.forEach(t=>t.fires.forEach(f=>{ D.thermal.forEach(t=>t.fires.forEach(f=>{
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)', addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
@@ -905,6 +1262,7 @@ function plotFlatMarkers(){
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
}); });
// Flight corridors // Flight corridors
if(flightsVisible){
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
const cG=flatG.append('g').attr('class','corridors-layer'); const cG=flatG.append('g').attr('class','corridors-layer');
@@ -924,6 +1282,7 @@ function plotFlatMarkers(){
const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40));
cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6); cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6);
})}); })});
}
} }
// Update setRegion for flat mode // Update setRegion for flat mode
@@ -933,6 +1292,7 @@ const _origSetRegion = setRegion;
const _origMapZoom = mapZoom; const _origMapZoom = mapZoom;
function setRegion(r){ function setRegion(r){
currentRegion = r;
document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r)); document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r));
closePopup(); closePopup();
if(isFlat && flatSvg && flatZoom){ if(isFlat && flatSvg && flatZoom){
@@ -976,6 +1336,7 @@ function mkSparkSvg(values, isGood){
// === LOWER GRID === // === LOWER GRID ===
function renderLower(){ function renderLower(){
const mobile = isMobileLayout();
const spread=D.fred.find(f=>f.id==='T10Y2Y'); const spread=D.fred.find(f=>f.id==='T10Y2Y');
const ff=D.fred.find(f=>f.id==='DFF'); const ff=D.fred.find(f=>f.id==='DFF');
const ue=D.bls.find(b=>b.id==='LNS14000000'); const ue=D.bls.find(b=>b.id==='LNS14000000');
@@ -1032,15 +1393,23 @@ function renderLower(){
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) // NEWS TICKER — merges RSS + GDELT + Telegram into flowing cards (moved from right rail)
const feed = (D.newsFeed || []).slice(0, 40); const feed = (D.newsFeed || []).slice(0, 20);
const srcClass = s => { const srcClass = s => {
if (!s) return 'other'; if (!s) return 'other';
const sl = s.toLowerCase(); const sl = s.toLowerCase();
// Africa-focused sources first (before generic DW/NYT)
if (sl.includes('dw africa') || sl.includes('africa news') || sl.includes('nyt africa') || sl.includes('rfi')) return 'af';
if (sl.includes('mercopress')) return 'sa';
if (sl.includes('indian express') || sl.includes('the hindu')) return 'ind';
if (sl.includes('sbs')) return 'anz';
if (sl.includes('bbc')) return 'bbc'; 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('jazeera') || sl.includes('alj')) return 'alj';
if (sl.includes('gdelt')) return 'gdelt'; if (sl.includes('gdelt')) return 'gdelt';
if (sl.includes('telegram')) return 'tg'; if (sl.includes('telegram')) return 'tg';
if (sl.includes('npr')) return 'us';
if (sl.includes('dw') || sl.includes('deutsche')) return 'dw';
if (sl.includes('france') || sl.includes('euronews')) return 'eu';
if (sl.includes('nyt') || sl.includes('times')) return 'nyt';
return 'other'; return 'other';
}; };
const tickerCards = feed.map(n => { const tickerCards = feed.map(n => {
@@ -1092,24 +1461,15 @@ function renderLower(){
} }
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>'; 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=` const tickerPanel = `<div class="g-panel lp-ticker" style="display:flex;flex-direction:column">
<div class="g-panel lp-ticker" style="display:flex;flex-direction:column"> <div class="sec-head"><h3>${t('panels.newsTicker','Live News Ticker')}</h3><span class="badge">${feed.length} ${t('badges.items','ITEMS')}</span></div>
<div class="sec-head"><h3>Live News Ticker</h3><span class="badge">${feed.length} ITEMS</span></div>
<div class="ticker-wrap" style="--ticker-duration:${tickerDuration}s"> <div class="ticker-wrap" style="--ticker-duration:${tickerDuration}s">
<div class="ticker-track">${tickerCards}${tickerCards}</div> <div class="ticker-track">${tickerCards}${lowPerfMode ? '' : tickerCards}</div>
</div> </div>
</div> </div>`;
<div class="g-panel lp-delta"> const osintPanel = mobile ? buildOsintPanel('lp-osint', 240) : '';
<div class="sec-head"><h3>Sweep Delta</h3><span class="badge ${dirClass}">${dirEmoji} ${ds.direction?ds.direction.toUpperCase():'BASELINE'}</span></div> const macroPanel = `<div class="g-panel lp-macro">
${hasDelta?`<div style="display:flex;gap:12px;margin-bottom:6px;font-family:var(--mono);font-size:10px"> <div class="sec-head"><h3>${t('panels.macroMarkets','Macro + Markets')}</h3><span class="badge">${mkt.timestamp?t('badges.live','LIVE'):t('badges.delayed','DELAYED')}</span></div>
<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> &#8593;${ds.signalBreakdown.escalated} &#8595;${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>
${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>
<div class="metrics-row">${indexCards}</div> <div class="metrics-row">${indexCards}</div>
@@ -1129,33 +1489,32 @@ function renderLower(){
<div style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-bottom:4px">WTI 5-DAY</div> <div style="font-family:var(--mono);font-size:10px;color:var(--dim);margin-bottom:4px">WTI 5-DAY</div>
<div class="spark">${sparkHtml}</div> <div class="spark">${sparkHtml}</div>
</div> </div>
</div> </div>`;
<div class="g-panel lp-ideas"> const ideasPanel = `<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>${t('panels.tradeIdeas','Leverageable Ideas')}</h3>${D.ideasSource==='llm'?'<span class="ideas-src llm">'+t('ideas.aiEnhanced','AI ENHANCED')+'</span>':D.ideasSource==='disabled'?'<span class="ideas-src static">'+t('ideas.llmOff','LLM OFF')+'</span>':'<span class="ideas-src static">'+t('ideas.pending','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>
</div>`; </div>`;
const deltaPanel = `<div class="g-panel lp-delta">
<div class="sec-head"><h3>${t('panels.sweepDelta','Sweep Delta')}</h3><span class="badge ${dirClass}">${dirEmoji} ${ds.direction?t('delta.'+ds.direction,ds.direction.toUpperCase()):t('delta.baseline','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)">${t('delta.changes','Changes')}: <span style="color:var(--accent)">${ds.totalChanges}</span></span>
<span style="color:var(--dim)">${t('delta.critical','Critical')}: <span style="color:${ds.criticalChanges>0?'var(--warn)':'var(--dim)'}">${ds.criticalChanges||0}</span></span>
${ds.signalBreakdown?`<span style="color:var(--dim)">${t('delta.new','New')}: <span style="color:#4dd0e1">${ds.signalBreakdown.new}</span> &#8593;${ds.signalBreakdown.escalated} &#8595;${ds.signalBreakdown.deescalated}</span>`:''}
</div>`:''}
<div class="delta-list">${deltaHtml}</div>
</div>`;
document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}${deltaPanel}`;
} }
// === RIGHT RAIL === // === RIGHT RAIL ===
function renderRight(){ function renderRight(){
const mobile = isMobileLayout();
// CROSS-SOURCE SIGNALS — moved from lower grid to right rail // CROSS-SOURCE SIGNALS — moved from lower grid to right rail
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 signals=D.tSignals.slice(0,6).map((s,i)=>`<div class="signal-row"><strong>Signal ${i+1}</strong><p>${s}</p></div>`).join('');
// 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 whoItems=D.who.slice(0,4).map(w=>({channel:'WHO ALERT',text:w.title,date:w.date,isWho:true}));
const osintItems=[...allPosts.slice(0,15),...whoItems];
const osintCards=osintItems.map(p=>{
const isU=p.urgentFlags&&p.urgentFlags.length>0;
const views=p.views?p.views>=1000?`${(p.views/1000).toFixed(0)}K`:p.views:'';
const age=p.date?getAge(p.date):'';
const flags=(p.urgentFlags||[]).map(f=>`<span class="tk-src tg" style="margin-right:2px">${f}</span>`).join('');
const srcCls=p.isWho?'style="color:#69f0ae;border-color:rgba(105,240,174,0.4)"':'class="tk-src tg"';
return `<div class="tk-card ${isU?'urgent':''}"><span ${srcCls}>${(p.channel||'OSINT').toUpperCase().substring(0,14)}</span>${views?`<span class="tk-src other">${views}</span>`:''}<span class="tk-time">${age}</span>${flags}<div class="tk-head">${cleanText((p.text||'').substring(0,160))}</div></div>`;
}).join('');
const osintDuration=Math.max(25,osintItems.length*3);
const signalMetrics=[ const signalMetrics=[
{l:'Incident Tempo',v:D.tg.urgent.length,p:70}, {l:'Incident Tempo',v:D.tg.urgent.length,p:70},
{l:'Air Theaters',v:D.air.length,p:60}, {l:'Air Theaters',v:D.air.length,p:60},
@@ -1166,18 +1525,13 @@ function renderRight(){
]; ];
document.getElementById('rightRail').innerHTML=` document.getElementById('rightRail').innerHTML=`
<div class="g-panel"> <div class="g-panel right-signals">
<div class="sec-head"><h3>Cross-Source Signals</h3><span class="badge">WORLDVIEW</span></div> <div class="sec-head"><h3>${t('panels.crossSourceSignals','Cross-Source Signals')}</h3><span class="badge">${t('badges.worldview','WORLDVIEW')}</span></div>
${signals} ${signals}
</div> </div>
<div class="g-panel" style="display:flex;flex-direction:column"> ${mobile ? '' : buildOsintPanel('right-osint', 260)}
<div class="sec-head"><h3>OSINT Stream</h3><span class="badge">${D.tg.urgent.length} URGENT</span></div> <div class="g-panel right-core">
<div class="ticker-wrap" style="--ticker-duration:${osintDuration}s;max-height:260px"> <div class="sec-head"><h3>${t('panels.signalCore','Signal Core')}</h3><span class="badge">${t('badges.hotMetrics','HOT METRICS')}</span></div>
<div class="ticker-track">${osintCards}${osintCards}</div>
</div>
</div>
<div class="g-panel">
<div class="sec-head"><h3>Signal Core</h3><span class="badge">HOT METRICS</span></div>
${signalMetrics.map(s=>`<div class="sm"><span class="sml">${s.l}</span><div class="smb"><span style="width:${s.p}%"></span></div><span class="smv">${s.v}</span></div>`).join('')} ${signalMetrics.map(s=>`<div class="sm"><span class="sml">${s.l}</span><div class="smb"><span style="width:${s.p}%"></span></div><span class="smv">${s.v}</span></div>`).join('')}
</div>`; </div>`;
} }
@@ -1191,18 +1545,19 @@ function safeExternalUrl(raw){try{const u=new URL(raw,location.href);return u.pr
function runBoot(){ function runBoot(){
const acledStatus = D.acled?.totalEvents > 0 ? `<span class="ok">${D.acled.totalEvents} EVENTS</span>` : '<span style="color:var(--warn)">DEGRADED</span>'; const acledStatus = D.acled?.totalEvents > 0 ? `<span class="ok">${D.acled.totalEvents} EVENTS</span>` : '<span style="color:var(--warn)">DEGRADED</span>';
const lines=[ const lines=[
{text:'INITIALIZING CRUCIX ENGINE v2.1.0',delay:0}, {text:t('boot.initializing','INITIALIZING CRUCIX ENGINE v2.1.0'),delay:0},
{text:`CONNECTING ${D.meta.sourcesQueried} OSINT SOURCES...`,delay:400}, {text:t('boot.connecting','CONNECTING {count} OSINT SOURCES...').replace('{count}',D.meta.sourcesQueried),delay:400},
{text:'&#9500;&#9472; OPENSKY &#183; FIRMS &#183; KIWISDR &#183; MARITIME',delay:700}, {text:'&#9500;&#9472; '+t('boot.sourceGroup1','OPENSKY · FIRMS · KIWISDR · MARITIME'),delay:700},
{text:'&#9500;&#9472; FRED &#183; BLS &#183; EIA &#183; TREASURY &#183; GSCPI',delay:900}, {text:'&#9500;&#9472; '+t('boot.sourceGroup2','FRED · BLS · EIA · TREASURY · GSCPI'),delay:900},
{text:'&#9500;&#9472; TELEGRAM &#183; SAFECAST &#183; EPA &#183; WHO &#183; OFAC',delay:1100}, {text:'&#9500;&#9472; '+t('boot.sourceGroup3','TELEGRAM · SAFECAST · EPA · WHO · OFAC'),delay:1100},
{text:'&#9492;&#9472; GDELT &#183; NOAA &#183; PATENTS &#183; BLUESKY &#183; REDDIT',delay:1300}, {text:'&#9492;&#9472; '+t('boot.sourceGroup4','GDELT · NOAA · PATENTS · BLUESKY · REDDIT'),delay:1300},
{text:`SWEEP COMPLETE &#8212; <span class="count">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span> SOURCES <span class="ok">OK</span>`,delay:1700}, {text:t('boot.sweepComplete','SWEEP COMPLETE — {ok}/{total} SOURCES').replace('{ok}',`<span class="count">${D.meta.sourcesOk}</span>`).replace('{total}',D.meta.sourcesQueried)+' <span class="ok">'+t('boot.ok','OK')+'</span>',delay:1700},
{text:`ACLED CONFLICT LAYER: ${acledStatus}`,delay:1900}, {text:t('boot.acledLayer','ACLED CONFLICT LAYER')+': '+acledStatus,delay:1900},
{text:'FLIGHT CORRIDORS: <span class="ok">ACTIVE</span> &#183; DUAL PROJECTION: <span class="ok">READY</span>',delay:2100}, {text:t('boot.flightCorridors','FLIGHT CORRIDORS')+': <span class="ok">'+t('boot.active','ACTIVE')+'</span> &#183; '+t('boot.dualProjection','DUAL PROJECTION')+': <span class="ok">'+t('boot.ready','READY')+'</span>',delay:2100},
{text:'INTELLIGENCE SYNTHESIS: <span class="ok">ACTIVE</span>',delay:2400}, {text:t('boot.intelligenceSynthesis','INTELLIGENCE SYNTHESIS')+': <span class="ok">'+t('boot.active','ACTIVE')+'</span>',delay:2400},
]; ];
const container=document.getElementById('bootLines'); const container=document.getElementById('bootLines');
document.getElementById('bootFinal').textContent=t('dashboard.terminalActive','TERMINAL ACTIVE');
const tl=gsap.timeline(); const tl=gsap.timeline();
tl.to('.logo-ring',{opacity:1,duration:0.6,ease:'power2.out'},0); tl.to('.logo-ring',{opacity:1,duration:0.6,ease:'power2.out'},0);
tl.to(container,{opacity:1,duration:0.3},0.3); tl.to(container,{opacity:1,duration:0.3},0.3);
@@ -1210,7 +1565,7 @@ function runBoot(){
tl.call(()=>{ tl.call(()=>{
const div=document.createElement('div');div.innerHTML=line.text;div.style.opacity='0'; const div=document.createElement('div');div.innerHTML=line.text;div.style.opacity='0';
container.appendChild(div);gsap.to(div,{opacity:1,duration:0.2}); container.appendChild(div);gsap.to(div,{opacity:1,duration:0.2});
},null,line.delay/1000+0.5); },[],line.delay/1000+0.5);
}); });
tl.to('#bootFinal',{opacity:1,duration:0.4},3.1); tl.to('#bootFinal',{opacity:1,duration:0.4},3.1);
tl.to('#boot',{opacity:0,duration:0.5,ease:'power2.in'},3.7); tl.to('#boot',{opacity:0,duration:0.5,ease:'power2.in'},3.7);
@@ -1229,7 +1584,103 @@ function runBoot(){
document.querySelectorAll('.mbar span,.smb span').forEach(bar=>{const w=bar.style.width;bar.style.width='0%';gsap.to(bar,{width:w,duration:1,ease:'power2.out'})}); document.querySelectorAll('.mbar span,.smb span').forEach(bar=>{const w=bar.style.width;bar.style.width='0%';gsap.to(bar,{width:w,duration:1,ease:'power2.out'})});
document.querySelectorAll('.spark-bar').forEach(bar=>{const h=bar.style.height;bar.style.height='0%';gsap.to(bar,{height:h,duration:0.8,ease:'power2.out'})}); document.querySelectorAll('.spark-bar').forEach(bar=>{const h=bar.style.height;bar.style.height='0%';gsap.to(bar,{height:h,duration:0.8,ease:'power2.out'})});
},1000); },1000);
},4.0); },[],4.0);
}
function isMobileLayout(){ return window.innerWidth <= 1100; }
function buildOsintPanel(panelClass='', maxHeight=260){
const allPosts=[...D.tg.urgent,...D.tg.topPosts].sort((a,b)=>new Date(b.date||0)-new Date(a.date||0));
const whoItems=D.who.slice(0,4).map(w=>({channel:'WHO ALERT',text:w.title,date:w.date,isWho:true}));
const osintItems=[...allPosts.slice(0,15),...whoItems];
const osintCards=osintItems.map(p=>{
const isU=p.urgentFlags&&p.urgentFlags.length>0;
const views=p.views?p.views>=1000?`${(p.views/1000).toFixed(0)}K`:p.views:'';
const age=p.date?getAge(p.date):'';
const flags=(p.urgentFlags||[]).map(f=>`<span class="tk-src tg" style="margin-right:2px">${f}</span>`).join('');
const srcCls=p.isWho?'style="color:#69f0ae;border-color:rgba(105,240,174,0.4)"':'class="tk-src tg"';
return `<div class="tk-card ${isU?'urgent':''}"><span ${srcCls}>${(p.channel||'OSINT').toUpperCase().substring(0,14)}</span>${views?`<span class="tk-src other">${views}</span>`:''}<span class="tk-time">${age}</span>${flags}<div class="tk-head">${cleanText((p.text||'').substring(0,160))}</div></div>`;
}).join('');
const osintDuration=Math.max(25,osintItems.length*3);
return `<div class="g-panel ${panelClass}" style="display:flex;flex-direction:column">
<div class="sec-head"><h3>${t('panels.osintStream','OSINT Stream')}</h3><span class="badge">${D.tg.urgent.length} ${t('badges.urgent','URGENT')}</span></div>
<div class="ticker-wrap" style="--ticker-duration:${osintDuration}s;max-height:${maxHeight}px">
<div class="ticker-track">${osintCards}${lowPerfMode ? '' : osintCards}</div>
</div>
</div>`;
}
function renderGlossary(){
const body = document.getElementById('glossaryBody');
if(!body) return;
body.innerHTML = signalGuideItems.map(item => `
<div class="glossary-card">
<div class="glossary-term">
<strong>${item.term}</strong>
<span class="glossary-tag">${item.category}</span>
</div>
<div class="glossary-line"><span class="glossary-label">Meaning</span>${item.meaning}</div>
<div class="glossary-line"><span class="glossary-label">Why it matters</span>${item.matters}</div>
<div class="glossary-line"><span class="glossary-label">Not proof of</span>${item.notMeaning}</div>
<div class="glossary-line"><span class="glossary-label">Example</span>${item.example}</div>
</div>
`).join('');
}
function openGlossary(){
const overlay = document.getElementById('glossaryOverlay');
if(!overlay) return;
overlay.classList.add('show');
document.body.style.overflow = 'hidden';
}
function closeGlossary(){
const overlay = document.getElementById('glossaryOverlay');
if(!overlay) return;
overlay.classList.remove('show');
document.body.style.overflow = '';
}
function refreshMapViewport(forceGlobeReflow=false){
const container = document.getElementById('mapContainer');
if(!container) return;
const width = container.clientWidth;
const height = container.clientHeight || (isMobileLayout() ? 420 : 560);
if(globe){
globe.width(width).height(height);
if(forceGlobeReflow && !isFlat){
const globeEl = document.getElementById('globeViz');
globeEl.style.display = 'none';
requestAnimationFrame(() => {
globeEl.style.display = 'block';
globe.width(width).height(height);
});
}
}
if(flatSvg){
flatW = width;
flatH = height;
flatSvg.attr('viewBox',`0 0 ${flatW} ${flatH}`).attr('preserveAspectRatio','xMidYMid meet');
if(flatProjection && flatG){
flatProjection = d3.geoNaturalEarth1().fitSize([flatW-20,flatH-20],{type:'Sphere'}).translate([flatW/2,flatH/2]);
flatPath = d3.geoPath(flatProjection);
flatG.selectAll('*').remove();
drawFlatMap();
}
}
}
let lastResponsiveMobile = null;
function syncResponsiveLayout(force=false){
const mobileNow = isMobileLayout();
if(force || lastResponsiveMobile === null || mobileNow !== lastResponsiveMobile){
lastResponsiveMobile = mobileNow;
renderTopbar();
renderLeftRail();
renderLower();
renderRight();
}
refreshMapViewport(force && !isFlat);
} }
// === REINIT (for live updates without boot sequence) === // === REINIT (for live updates without boot sequence) ===
@@ -1274,6 +1725,7 @@ function connectSSE(){
let booted = false; let booted = false;
function init(){ function init(){
renderTopbar();renderLeftRail();renderLower();renderRight(); renderTopbar();renderLeftRail();renderLower();renderRight();
renderGlossary();
initMap(); initMap();
if (!booted) { runBoot(); booted = true; } if (!booted) { runBoot(); booted = true; }
// Close popup on click outside markers // Close popup on click outside markers
@@ -1288,6 +1740,10 @@ function init(){
if(url) window.open(url,'_blank','noopener'); if(url) window.open(url,'_blank','noopener');
} }
}); });
document.addEventListener('keydown',e=>{
if(e.key === 'Escape') closeGlossary();
});
syncResponsiveLayout(true);
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -130,18 +130,26 @@ fetch('/api/health')
.catch(() => startCountdown(0)); .catch(() => startCountdown(0));
// === SSE — wait for sweep to complete, then redirect === // === SSE — wait for sweep to complete, then redirect ===
let redirected = false;
function goToDashboard() {
if (redirected) return;
redirected = true;
clearInterval(countdownInterval);
clearInterval(pollInterval);
barFill.style.transition = 'width 0.4s ease';
barFill.style.width = '100%';
etaText.textContent = '';
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
setTimeout(() => location.replace('/'), 800);
}
const es = new EventSource('/events'); const es = new EventSource('/events');
es.onmessage = (e) => { es.onmessage = (e) => {
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'update') { if (msg.type === 'update') {
es.close(); es.close();
clearInterval(countdownInterval); goToDashboard();
barFill.style.transition = 'width 0.4s ease';
barFill.style.width = '100%';
etaText.textContent = '';
statusText.textContent = 'TERMINAL READY — LOADING DASHBOARD';
setTimeout(() => location.replace('/'), 800);
} }
} catch {} } catch {}
}; };
@@ -149,6 +157,13 @@ es.onerror = () => {
es.close(); es.close();
setTimeout(() => location.reload(), 3000); setTimeout(() => location.reload(), 3000);
}; };
// === Fallback polling — in case SSE misses the update ===
const pollInterval = setInterval(() => {
fetch('/api/data').then(r => {
if (r.ok) goToDashboard();
}).catch(() => {});
}, 5000);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -4,6 +4,8 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
const TELEGRAM_API = 'https://api.telegram.org'; const TELEGRAM_API = 'https://api.telegram.org';
/** Telegram Bot API limit for sendMessage text (bytes/characters). */
const TELEGRAM_MAX_TEXT = 4096;
// ─── Alert Tiers ──────────────────────────────────────────────────────────── // ─── Alert Tiers ────────────────────────────────────────────────────────────
// FLASH: Immediate action required — market-moving, time-critical (e.g. war escalation, flash crash) // FLASH: Immediate action required — market-moving, time-critical (e.g. war escalation, flash crash)
@@ -38,6 +40,7 @@ export class TelegramAlerter {
this._lastUpdateId = 0; // For polling bot commands this._lastUpdateId = 0; // For polling bot commands
this._commandHandlers = {}; // Registered command callbacks this._commandHandlers = {}; // Registered command callbacks
this._pollingInterval = null; this._pollingInterval = null;
this._botUsername = null;
} }
get isConfigured() { get isConfigured() {
@@ -47,24 +50,30 @@ export class TelegramAlerter {
// ─── Core Messaging ───────────────────────────────────────────────────── // ─── Core Messaging ─────────────────────────────────────────────────────
/** /**
* Send a message via Telegram Bot API. * Send a message via Telegram Bot API. Splits at TELEGRAM_MAX_TEXT so long messages
* (e.g. /brief) are sent in multiple messages instead of being truncated or failing.
* @param {string} message - markdown-formatted message * @param {string} message - markdown-formatted message
* @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId } * @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId, chatId }
* @returns {Promise<{ok: boolean, messageId?: number}>} * @returns {Promise<{ok: boolean, messageId?: number}>}
*/ */
async sendMessage(message, opts = {}) { async sendMessage(message, opts = {}) {
if (!this.isConfigured) return { ok: false }; if (!this.isConfigured) return { ok: false };
const chatId = opts.chatId ?? this.chatId;
const parseMode = opts.parseMode || 'Markdown';
const chunks = this._chunkText(message, TELEGRAM_MAX_TEXT);
try { try {
let lastResult = { ok: false, messageId: undefined };
for (let i = 0; i < chunks.length; i++) {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, { const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
chat_id: this.chatId, chat_id: chatId,
text: message, text: chunks[i],
parse_mode: opts.parseMode || 'Markdown', parse_mode: parseMode,
disable_web_page_preview: opts.disablePreview !== false, disable_web_page_preview: opts.disablePreview !== false,
...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}), ...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}),
}), }),
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
}); });
@@ -72,17 +81,39 @@ export class TelegramAlerter {
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, 200)}`); console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
return { ok: false }; return lastResult;
} }
const data = await res.json(); const data = await res.json();
return { ok: true, messageId: data.result?.message_id }; lastResult = { ok: true, messageId: data.result?.message_id };
}
return lastResult;
} catch (err) { } catch (err) {
console.error('[Telegram] Send error:', err.message); console.error('[Telegram] Send error:', err.message);
return { ok: false }; return { ok: false };
} }
} }
/**
* Split text into chunks of at most maxLen. Prefer breaking at newlines to avoid
* splitting mid-Markdown.
*/
_chunkText(text, maxLen = TELEGRAM_MAX_TEXT) {
if (!text || text.length <= maxLen) return text ? [text] : [];
const chunks = [];
let start = 0;
while (start < text.length) {
let end = Math.min(start + maxLen, text.length);
if (end < text.length) {
const lastNewline = text.lastIndexOf('\n', end - 1);
if (lastNewline > start) end = lastNewline + 1;
}
chunks.push(text.slice(start, end));
start = end;
}
return chunks;
}
// Backward-compatible alias // Backward-compatible alias
async sendAlert(message) { async sendAlert(message) {
const result = await this.sendMessage(message); const result = await this.sendMessage(message);
@@ -286,6 +317,9 @@ export class TelegramAlerter {
if (this._pollingInterval) return; // Already polling if (this._pollingInterval) return; // Already polling
console.log('[Telegram] Bot command polling started'); console.log('[Telegram] Bot command polling started');
this._initializeBotCommands().catch((err) => {
console.error('[Telegram] Command initialization failed:', err.message);
});
this._pollingInterval = setInterval(() => this._pollUpdates(), intervalMs); this._pollingInterval = setInterval(() => this._pollUpdates(), intervalMs);
// Initial poll // Initial poll
this._pollUpdates(); this._pollUpdates();
@@ -325,8 +359,8 @@ export class TelegramAlerter {
const msg = update.message; const msg = update.message;
if (!msg?.text) continue; if (!msg?.text) continue;
// Only process messages from the configured chat
const chatId = String(msg.chat?.id); const chatId = String(msg.chat?.id);
// Restrict command execution to the configured chat/group only.
if (chatId !== String(this.chatId)) continue; if (chatId !== String(this.chatId)) continue;
await this._handleMessage(msg); await this._handleMessage(msg);
@@ -342,8 +376,11 @@ export class TelegramAlerter {
async _handleMessage(msg) { async _handleMessage(msg) {
const text = msg.text.trim(); const text = msg.text.trim();
const parts = text.split(/\s+/); const parts = text.split(/\s+/);
const command = parts[0].toLowerCase(); const rawCommand = parts[0].toLowerCase();
const command = this._normalizeCommand(rawCommand);
if (!command) return;
const args = parts.slice(1).join(' '); const args = parts.slice(1).join(' ');
const replyChatId = msg.chat?.id;
// Built-in commands // Built-in commands
if (command === '/help') { if (command === '/help') {
@@ -352,7 +389,7 @@ export class TelegramAlerter {
.join('\n'); .join('\n');
await this.sendMessage( await this.sendMessage(
`🤖 *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`, `🤖 *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`,
{ replyToMessageId: msg.message_id } { chatId: replyChatId, replyToMessageId: msg.message_id }
); );
return; return;
} }
@@ -362,7 +399,7 @@ export class TelegramAlerter {
this._muteUntil = Date.now() + hours * 60 * 60 * 1000; this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
await this.sendMessage( await this.sendMessage(
`🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`, `🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`,
{ replyToMessageId: msg.message_id } { chatId: replyChatId, replyToMessageId: msg.message_id }
); );
return; return;
} }
@@ -371,7 +408,7 @@ export class TelegramAlerter {
this._muteUntil = null; this._muteUntil = null;
await this.sendMessage( await this.sendMessage(
`🔔 Alerts resumed. You'll receive the next signal evaluation.`, `🔔 Alerts resumed. You'll receive the next signal evaluation.`,
{ replyToMessageId: msg.message_id } { chatId: replyChatId, replyToMessageId: msg.message_id }
); );
return; return;
} }
@@ -379,7 +416,7 @@ export class TelegramAlerter {
if (command === '/alerts') { if (command === '/alerts') {
const recent = this._alertHistory.slice(-10); const recent = this._alertHistory.slice(-10);
if (recent.length === 0) { if (recent.length === 0) {
await this.sendMessage('No recent alerts.', { replyToMessageId: msg.message_id }); await this.sendMessage('No recent alerts.', { chatId: replyChatId, replyToMessageId: msg.message_id });
return; return;
} }
const lines = recent.map(a => const lines = recent.map(a =>
@@ -387,7 +424,7 @@ export class TelegramAlerter {
); );
await this.sendMessage( await this.sendMessage(
`📋 *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`, `📋 *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`,
{ replyToMessageId: msg.message_id } { chatId: replyChatId, replyToMessageId: msg.message_id }
); );
return; return;
} }
@@ -398,19 +435,86 @@ export class TelegramAlerter {
try { try {
const response = await handler(args, msg.message_id); const response = await handler(args, msg.message_id);
if (response) { if (response) {
await this.sendMessage(response, { replyToMessageId: msg.message_id }); await this.sendMessage(response, { chatId: replyChatId, replyToMessageId: msg.message_id });
} }
} catch (err) { } catch (err) {
console.error(`[Telegram] Command ${command} error:`, err.message); console.error(`[Telegram] Command ${command} error:`, err.message);
await this.sendMessage( await this.sendMessage(
`❌ Command failed: ${err.message}`, `❌ Command failed: ${err.message}`,
{ replyToMessageId: msg.message_id } { chatId: replyChatId, replyToMessageId: msg.message_id }
); );
} }
} }
// Unknown commands are silently ignored to avoid spamming // Unknown commands are silently ignored to avoid spamming
} }
async _initializeBotCommands() {
await this._loadBotIdentity();
const botCommands = Object.entries(COMMANDS).map(([command, description]) => ({
command: command.replace('/', ''),
description: description.substring(0, 256),
}));
// Register commands only for the configured chat to avoid global discovery.
await this._setMyCommands(botCommands, this._buildConfiguredChatScope());
}
async _loadBotIdentity() {
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/getMe`, {
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`getMe failed (${res.status}): ${err.substring(0, 200)}`);
}
const data = await res.json();
if (!data.ok || !data.result?.username) {
throw new Error('getMe returned invalid bot profile');
}
this._botUsername = String(data.result.username).toLowerCase();
}
async _setMyCommands(commands, scope = null) {
const body = { commands };
if (scope) body.scope = scope;
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/setMyCommands`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`setMyCommands failed (${res.status}): ${err.substring(0, 200)}`);
}
const data = await res.json();
if (!data.ok) {
throw new Error(`setMyCommands rejected: ${JSON.stringify(data).substring(0, 200)}`);
}
}
_buildConfiguredChatScope() {
const chatId = Number(this.chatId);
if (!Number.isSafeInteger(chatId)) {
throw new Error(`TELEGRAM_CHAT_ID must be a numeric chat id, got: ${this.chatId}`);
}
return { type: 'chat', chat_id: chatId };
}
_normalizeCommand(rawCommand) {
if (!rawCommand.startsWith('/')) return null;
const atIdx = rawCommand.indexOf('@');
if (atIdx === -1) return rawCommand;
const command = rawCommand.substring(0, atIdx);
const mentionedBot = rawCommand.substring(atIdx + 1).toLowerCase();
if (!this._botUsername || mentionedBot === this._botUsername) return command;
return null;
}
// ─── Semantic Dedup ───────────────────────────────────────────────────── // ─── Semantic Dedup ─────────────────────────────────────────────────────
/** /**

137
lib/i18n.mjs Normal file
View File

@@ -0,0 +1,137 @@
// Internationalization (i18n) Module
// Loads locale files and provides translation functions
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const LOCALES_DIR = join(__dirname, '..', 'locales');
// Supported languages
const SUPPORTED_LOCALES = ['en', 'fr'];
const DEFAULT_LOCALE = 'en';
// Cache loaded locales
const localeCache = new Map();
/**
* Get the current language from environment
* @returns {string} Language code (e.g., 'en', 'fr')
*/
export function getLanguage() {
// CRUCIX_LANG takes priority to avoid conflict with Linux system LANGUAGE variable
const lang = (process.env.CRUCIX_LANG || process.env.LANGUAGE || process.env.LANG || DEFAULT_LOCALE)
.toLowerCase()
.slice(0, 2);
return SUPPORTED_LOCALES.includes(lang) ? lang : DEFAULT_LOCALE;
}
/**
* Load a locale file
* @param {string} lang - Language code
* @returns {object} Locale data
*/
function loadLocale(lang) {
if (localeCache.has(lang)) {
return localeCache.get(lang);
}
const localePath = join(LOCALES_DIR, `${lang}.json`);
if (!existsSync(localePath)) {
console.warn(`[i18n] Locale file not found: ${localePath}, falling back to ${DEFAULT_LOCALE}`);
return loadLocale(DEFAULT_LOCALE);
}
try {
const data = JSON.parse(readFileSync(localePath, 'utf-8'));
localeCache.set(lang, data);
return data;
} catch (err) {
console.error(`[i18n] Failed to load locale ${lang}:`, err.message);
if (lang !== DEFAULT_LOCALE) {
return loadLocale(DEFAULT_LOCALE);
}
return {};
}
}
/**
* Get the current locale data
* @returns {object} Current locale data
*/
export function getLocale() {
return loadLocale(getLanguage());
}
/**
* Translate a key path (e.g., 'dashboard.title')
* @param {string} keyPath - Dot-separated key path
* @param {object} params - Optional parameters for interpolation
* @returns {string} Translated string or key if not found
*/
export function t(keyPath, params = {}) {
const locale = getLocale();
const keys = keyPath.split('.');
let value = locale;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
console.warn(`[i18n] Missing translation: ${keyPath}`);
return keyPath;
}
}
if (typeof value !== 'string') {
return keyPath;
}
// Interpolate parameters: {param} -> value
return value.replace(/\{(\w+)\}/g, (_, key) => {
return params[key] !== undefined ? params[key] : `{${key}}`;
});
}
/**
* Get LLM system prompt in current language
* @returns {string} System prompt for LLM
*/
export function getLLMPrompt() {
const locale = getLocale();
// Use loadLocale('en') for fallback since getLocale() doesn't accept a language argument
const fallbackLocale = loadLocale('en');
return locale.llm?.systemPrompt || fallbackLocale.llm?.systemPrompt || '';
}
/**
* Get all supported locales info
* @returns {Array} Array of locale info objects
*/
export function getSupportedLocales() {
return SUPPORTED_LOCALES.map(code => {
const locale = loadLocale(code);
return {
code,
name: locale.meta?.name || code,
nativeName: locale.meta?.nativeName || code
};
});
}
/**
* Check if a language is supported
* @param {string} lang - Language code
* @returns {boolean}
*/
export function isSupported(lang) {
return SUPPORTED_LOCALES.includes(lang?.toLowerCase()?.slice(0, 2));
}
// Export current language on module load
export const currentLanguage = getLanguage();
console.log(`[i18n] Language: ${currentLanguage}`);

View File

@@ -1,21 +1,23 @@
// LLM Factory — creates the configured provider or returns null // LLM Factory — creates the configured provider or returns null
import { AnthropicProvider } from './anthropic.mjs'; import { AnthropicProvider } from "./anthropic.mjs";
import { OpenAIProvider } from './openai.mjs'; import { OpenAIProvider } from "./openai.mjs";
import { OpenRouterProvider } from './openrouter.mjs'; import { OpenRouterProvider } from "./openrouter.mjs";
import { GeminiProvider } from './gemini.mjs'; import { GeminiProvider } from "./gemini.mjs";
import { CodexProvider } from './codex.mjs'; import { CodexProvider } from "./codex.mjs";
import { MiniMaxProvider } from './minimax.mjs'; import { MiniMaxProvider } from "./minimax.mjs";
import { OllamaProvider } from './ollama.mjs'; import { MistralProvider } from "./mistral.mjs";
import { OllamaProvider } from "./ollama.mjs";
export { LLMProvider } from './provider.mjs'; export { LLMProvider } from "./provider.mjs";
export { AnthropicProvider } from './anthropic.mjs'; export { AnthropicProvider } from "./anthropic.mjs";
export { OpenAIProvider } from './openai.mjs'; export { OpenAIProvider } from "./openai.mjs";
export { OpenRouterProvider } from './openrouter.mjs'; export { OpenRouterProvider } from "./openrouter.mjs";
export { GeminiProvider } from './gemini.mjs'; export { GeminiProvider } from "./gemini.mjs";
export { CodexProvider } from './codex.mjs'; export { CodexProvider } from "./codex.mjs";
export { MiniMaxProvider } from './minimax.mjs'; export { MiniMaxProvider } from "./minimax.mjs";
export { OllamaProvider } from './ollama.mjs'; export { MistralProvider } from "./mistral.mjs";
export { OllamaProvider } from "./ollama.mjs";
/** /**
* Create an LLM provider based on config. * Create an LLM provider based on config.
@@ -28,22 +30,26 @@ export function createLLMProvider(llmConfig) {
const { provider, apiKey, model } = llmConfig; const { provider, apiKey, model } = llmConfig;
switch (provider.toLowerCase()) { switch (provider.toLowerCase()) {
case 'anthropic': case "anthropic":
return new AnthropicProvider({ apiKey, model }); return new AnthropicProvider({ apiKey, model });
case 'openai': case "openai":
return new OpenAIProvider({ apiKey, model }); return new OpenAIProvider({ apiKey, model });
case 'openrouter': case "openrouter":
return new OpenRouterProvider({ apiKey, model }); return new OpenRouterProvider({ apiKey, model });
case 'gemini': case "gemini":
return new GeminiProvider({ apiKey, model }); return new GeminiProvider({ apiKey, model });
case 'codex': case "codex":
return new CodexProvider({ model }); return new CodexProvider({ model });
case 'minimax': case "minimax":
return new MiniMaxProvider({ apiKey, model }); return new MiniMaxProvider({ apiKey, model });
case 'ollama': case "mistral":
return new MistralProvider({ apiKey, model });
case "ollama":
return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl }); return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl });
default: default:
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`); console.warn(
`[LLM] Unknown provider "${provider}". LLM features disabled.`,
);
return null; return null;
} }
} }

51
lib/llm/mistral.mjs Normal file
View File

@@ -0,0 +1,51 @@
// Mistral AI Provider — raw fetch, no SDK
// Uses Mistral's OpenAI-compatible Chat Completions API
import { LLMProvider } from './provider.mjs';
export class MistralProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'mistral';
this.apiKey = config.apiKey;
this.model = config.model || 'mistral-large-latest';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.model,
max_tokens: opts.maxTokens || 4096,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage },
],
}),
signal: AbortSignal.timeout(opts.timeout || 60000),
});
if (!res.ok) {
const err = await res.text().catch(() => '');
throw new Error(`Mistral API ${res.status}: ${err.substring(0, 200)}`);
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content || '';
return {
text,
usage: {
inputTokens: data.usage?.prompt_tokens || 0,
outputTokens: data.usage?.completion_tokens || 0,
},
model: data.model || this.model,
};
}
}

359
locales/en.json Normal file
View File

@@ -0,0 +1,359 @@
{
"meta": {
"code": "en",
"name": "English",
"nativeName": "English"
},
"dashboard": {
"title": "CRUCIX — Intelligence Terminal",
"bootTitle": "CRUCIX INTELLIGENCE ENGINE",
"bootSubtitle": "Local Palantir · 31 Sources",
"waitingForSweep": "Waiting for first sweep...",
"sourcesOk": "Sources OK",
"lastSweep": "Last sweep",
"nextSweep": "Next sweep",
"sweep": "SWEEP",
"sources": "SOURCES",
"delta": "DELTA",
"highAlert": "HIGH ALERT",
"riskOff": "RISK-OFF",
"riskOn": "RISK-ON",
"mixed": "MIXED",
"terminalActive": "TERMINAL ACTIVE",
"perf": "PERF",
"perfLow": "LOW",
"perfHigh": "HIGH",
"guideBtn": "What Signals Mean"
},
"boot": {
"initializing": "INITIALIZING CRUCIX ENGINE v2.1.0",
"connecting": "CONNECTING {count} OSINT SOURCES...",
"sourceGroup1": "OPENSKY · FIRMS · KIWISDR · MARITIME",
"sourceGroup2": "FRED · BLS · EIA · TREASURY · GSCPI",
"sourceGroup3": "TELEGRAM · SAFECAST · EPA · WHO · OFAC",
"sourceGroup4": "GDELT · NOAA · PATENTS · BLUESKY · REDDIT",
"sourceGroup5": "USGS · ECB · CVE · COPERNICUS · CELESTRAK",
"sweepComplete": "SWEEP COMPLETE — {ok}/{total} SOURCES",
"ok": "OK",
"acledLayer": "ACLED CONFLICT LAYER",
"events": "EVENTS",
"degraded": "DEGRADED",
"flightCorridors": "FLIGHT CORRIDORS",
"active": "ACTIVE",
"dualProjection": "DUAL PROJECTION",
"ready": "READY",
"intelligenceSynthesis": "INTELLIGENCE SYNTHESIS"
},
"panels": {
"sensorGrid": "Sensor Grid",
"tradeIdeas": "Leverageable Ideas",
"osintFeed": "OSINT Feed",
"osintStream": "OSINT Stream",
"nuclearWatch": "Nuclear Watch",
"newsTicker": "Live News Ticker",
"sweepDelta": "Sweep Delta",
"macroMarkets": "Macro + Markets",
"healthAlerts": "Health Alerts",
"riskGauges": "Risk Gauges",
"crossSourceSignals": "Cross-Source Signals",
"signalCore": "Signal Core",
"seismicWatch": "Seismic Watch",
"cyberWatch": "Cyber Watch",
"spaceWatch": "Space Watch",
"europeAlerts": "Europe Alerts",
"ecbIndicators": "ECB Indicators"
},
"layers": {
"airActivity": "Air Activity",
"thermalSpikes": "Thermal Spikes",
"sdrCoverage": "SDR Coverage",
"maritimeWatch": "Maritime Watch",
"nuclearSites": "Nuclear Sites",
"conflictEvents": "Conflict Events",
"healthWatch": "Health Watch",
"worldNews": "World News",
"osintFeed": "OSINT Feed",
"theaters": "theaters",
"nightDet": "night det.",
"online": "online",
"chokepoints": "chokepoints",
"monitors": "monitors",
"fatalities": "fatalities",
"whoAlerts": "WHO alerts",
"rssGeolocated": "RSS geolocated",
"earthquakes": "Earthquakes",
"seismicEvents": "Seismic Events",
"cyberVulns": "Cyber Vulnerabilities",
"spaceActivity": "Space Activity",
"europeEmergency": "Europe Emergency"
},
"map": {
"worldNews": "World News",
"healthAlert": "Health Alert",
"chokepoint": "Chokepoint",
"nuclearSite": "Nuclear Site",
"osintEvent": "OSINT Event",
"thermalDetection": "Thermal Detection",
"aircraft": "Aircraft",
"rssGeolocated": "RSS geolocated",
"airTraffic": "Air Traffic",
"thermalFire": "Thermal/Fire",
"conflict": "Conflict",
"sdrReceiver": "SDR Receiver",
"scrollToZoom": "SCROLL TO ZOOM · DRAG TO PAN",
"globeMode": "GLOBE MODE",
"flatMode": "FLAT MODE",
"earthquake": "Earthquake",
"disaster": "Disaster",
"weatherAlert": "Weather Alert",
"epaRadNet": "EPA RadNet",
"spaceStation": "Space Station",
"gdeltEvent": "GDELT Event"
},
"ideas": {
"confidence": "Confidence",
"horizon": "Horizon",
"risk": "Risk",
"signals": "Signals",
"rationale": "Rationale",
"aiEnhanced": "AI ENHANCED",
"llmOff": "LLM OFF",
"pending": "PENDING",
"llmNotConfigured": "LLM NOT CONFIGURED",
"llmHelp": "Set LLM_PROVIDER + credentials in .env to enable AI-powered trade ideas",
"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."
},
"regions": {
"world": "World",
"americas": "Americas",
"europe": "Europe",
"middleEast": "Middle East",
"asiaPacific": "Asia Pacific",
"africa": "Africa"
},
"badges": {
"radiation": "RADIATION",
"live": "LIVE",
"delayed": "DELAYED",
"items": "ITEMS",
"urgent": "URGENT",
"worldview": "WORLDVIEW",
"hotMetrics": "HOT METRICS",
"stress": "STRESS",
"sweeping": "SWEEPING...",
"europe": "EUROPE",
"orbital": "ORBITAL"
},
"delta": {
"baseline": "BASELINE",
"escalation": "ESCALATION",
"deescalation": "DE-ESCALATION",
"stable": "STABLE",
"newSignals": "New Signals",
"resolved": "Resolved",
"noChanges": "No changes since last sweep",
"changes": "Changes",
"critical": "Critical",
"new": "NEW"
},
"metrics": {
"wtiCrude": "WTI Crude",
"brent": "Brent",
"natGas": "Nat Gas",
"vix": "VIX",
"fedFunds": "Fed Funds",
"gscpi": "GSCPI",
"cpiMom": "CPI MoM",
"unemployment": "Unemployment",
"hySpread": "HY Spread",
"usdIndex": "USD Index",
"joblessClaims": "Jobless Claims",
"mortgage30y": "30Y Mortgage",
"m2Supply": "M2 Supply",
"natDebt": "Nat. Debt",
"wti5day": "WTI 5-DAY",
"indexes": "INDEXES",
"crypto": "CRYPTO",
"energyMacro": "ENERGY + MACRO",
"vsPrior": "vs prior",
"ecbRate": "ECB Rate",
"eurusd": "EUR/USD",
"euM3": "EU M3",
"euHicp": "EU HICP",
"earthquakes7d": "Earthquakes (7d)",
"criticalCves": "Critical CVEs",
"spaceObjects": "Space Objects",
"starlink": "Starlink",
"europeAlerts": "EU Alerts"
},
"signalMetrics": {
"incidentTempo": "Incident Tempo",
"airTheaters": "Air Theaters",
"thermalSpikes": "Thermal Spikes",
"sdrNodes": "SDR Nodes",
"chokepoints": "Chokepoints",
"whoAlerts": "WHO Alerts"
},
"nuclear": {
"allSitesNormal": "ALL SITES NORMAL",
"anomalyDetected": "ANOMALY DETECTED",
"noData": "No data"
},
"seismic": {
"noRecentQuakes": "NO SIGNIFICANT QUAKES",
"majorQuake": "MAJOR QUAKE DETECTED",
"tsunamiRisk": "TSUNAMI RISK",
"mag": "M"
},
"cyber": {
"noAlerts": "NO CRITICAL CVES",
"criticalAlert": "CRITICAL VULNERABILITIES",
"cvss": "CVSS",
"critical": "CRITICAL"
},
"space": {
"noActivity": "NORMAL ACTIVITY",
"launchDetected": "LAUNCH ACTIVITY",
"objectsTracked": "objects tracked",
"newLast30d": "new (30d)",
"satellites": "sats",
"recentLaunches": "Recent Launches",
"totalTracked": "Total Tracked",
"byCountry": "BY COUNTRY",
"constellations": "CONSTELLATIONS",
"launches": "launches"
},
"europe": {
"noAlerts": "NO ACTIVE ALERTS",
"activeAlerts": "ACTIVE ALERTS",
"fires": "Fires",
"floods": "Floods",
"types": "types"
},
"time": {
"justNow": "just now",
"hoursAgo": "{hours}h ago",
"daysAgo": "{days}d ago"
},
"bot": {
"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"
},
"messages": {
"alertsMuted": "🔇 Alerts muted for {hours}h — until {time} UTC",
"useUnmute": "Use /unmute to resume.",
"alertsResumed": "🔔 Alerts resumed. You'll receive the next signal evaluation.",
"sweepTriggered": "🚀 Manual sweep triggered. You'll receive alerts if anything significant is detected.",
"sweepInProgress": "🔄 Sweep already in progress. Please wait.",
"noDataYet": "⏳ No data yet — waiting for first sweep to complete.",
"noRecentAlerts": "No recent alerts.",
"recentAlerts": "📋 Recent Alerts (last {count})",
"commandsTip": "Tip: Commands are case-insensitive",
"commandFailed": "❌ Command failed: {error}",
"portfolioNotAvailable": "📊 Portfolio integration requires Alpaca MCP connection.\nUse the Crucix dashboard or Claude agent for portfolio queries."
},
"status": {
"title": "🖥️ CRUCIX STATUS",
"uptime": "Uptime",
"lastSweep": "Last sweep",
"nextSweep": "Next sweep",
"sweepInProgress": "Sweep in progress",
"yes": "🔄 Yes",
"no": "⏸️ No",
"sources": "Sources",
"failed": "failed",
"llm": "LLM",
"enabled": "✅",
"disabled": "❌ Disabled",
"sseClients": "SSE clients",
"dashboard": "Dashboard",
"pending": "pending",
"never": "never"
},
"brief": {
"title": "📋 CRUCIX BRIEF",
"direction": "Direction",
"changes": "changes",
"criticalChanges": "critical",
"osint": "📡 OSINT",
"urgentSignals": "urgent signals",
"totalPosts": "total posts",
"topIdeas": "💡 Top Ideas"
},
"alertTiers": {
"flash": "FLASH",
"priority": "PRIORITY",
"routine": "ROUTINE"
}
},
"alerts": {
"tiers": {
"flash": {
"label": "FLASH",
"description": "Immediate action required — market-moving, time-critical"
},
"priority": {
"label": "PRIORITY",
"description": "Important signal cluster — act within hours"
},
"routine": {
"label": "ROUTINE",
"description": "Noteworthy change — FYI, no urgency"
}
},
"confidence": {
"high": "HIGH",
"medium": "MEDIUM",
"low": "LOW"
},
"fields": {
"direction": "Direction",
"confidence": "Confidence",
"crossCorrelation": "Cross-Correlation",
"action": "Action",
"signals": "Signals",
"monitor": "Monitor"
},
"messages": {
"alertsMuted": "Alerts Muted",
"mutedUntil": "Alerts silenced for {hours}h — until {time} UTC.",
"useUnmuteToResume": "Use /unmute to resume.",
"alertsResumed": "Alerts Resumed",
"willReceiveNext": "You will receive the next signal evaluation."
},
"ruleBasedHeadlines": {
"nuclearAnomaly": "Nuclear Anomaly Detected",
"crossDomainSignals": "{count} Critical Cross-Domain Signals",
"escalatingSignals": "{count} Escalating Signals",
"osintSurge": "OSINT Surge: {count} New Urgent Posts",
"signalChangeDetected": "Signal Change Detected"
}
},
"discord": {
"commands": {
"status": "System health, last sweep time, source status",
"sweep": "Trigger a manual sweep cycle",
"brief": "Compact intelligence summary",
"portfolio": "Portfolio status (if Alpaca connected)",
"alerts": "Recent alert history",
"mute": "Mute alerts (default 1h)",
"unmute": "Resume alerts",
"muteHoursOption": "Hours to mute (default: 1)"
}
},
"llm": {
"systemPrompt": "You are a quantitative analyst at a macro intelligence firm. You receive structured OSINT + economic data from 25 sources and produce 5-8 actionable trade ideas.\n\nRules:\n- Each idea must cite specific data points from the input\n- Include entry rationale, risk factors, and time horizon\n- Blend geopolitical, economic, and market signals — cross-correlate across domains\n- Be specific: name instruments (tickers, futures, ETFs), not vague sectors\n- If delta shows significant changes, lead with those\n- Do NOT repeat ideas from the \"previous ideas\" list unless conditions have materially changed\n- Rate confidence: HIGH (multiple confirming signals), MEDIUM (thesis supported), LOW (speculative)\n\nOutput ONLY valid JSON array. Each object:\n{\n \"title\": \"Short title (max 10 words)\",\n \"type\": \"LONG|SHORT|HEDGE|WATCH|AVOID\",\n \"ticker\": \"Primary instrument\",\n \"confidence\": \"HIGH|MEDIUM|LOW\",\n \"rationale\": \"2-3 sentence explanation citing specific data\",\n \"risk\": \"Key risk factor\",\n \"horizon\": \"Intraday|Days|Weeks|Months\",\n \"signals\": [\"signal1\", \"signal2\"]\n}"
},
"api": {
"errors": {
"noDataYet": "No data yet — first sweep in progress"
}
}
}

359
locales/fr.json Normal file
View File

@@ -0,0 +1,359 @@
{
"meta": {
"code": "fr",
"name": "French",
"nativeName": "Français"
},
"dashboard": {
"title": "CRUCIX — Terminal de Renseignement",
"bootTitle": "CRUCIX MOTEUR DE RENSEIGNEMENT",
"bootSubtitle": "Palantir Local · 31 Sources",
"waitingForSweep": "En attente du premier scan...",
"sourcesOk": "Sources OK",
"lastSweep": "Dernier scan",
"nextSweep": "Prochain scan",
"sweep": "SCAN",
"sources": "SOURCES",
"delta": "DELTA",
"highAlert": "ALERTE HAUTE",
"riskOff": "RISK-OFF",
"riskOn": "RISK-ON",
"mixed": "MIXTE",
"terminalActive": "TERMINAL ACTIF",
"perf": "PERF",
"perfLow": "BAS",
"perfHigh": "HAUT",
"guideBtn": "Signification des Signaux"
},
"boot": {
"initializing": "INITIALISATION MOTEUR CRUCIX v2.1.0",
"connecting": "CONNEXION À {count} SOURCES OSINT...",
"sourceGroup1": "OPENSKY · FIRMS · KIWISDR · MARITIME",
"sourceGroup2": "FRED · BLS · EIA · TREASURY · GSCPI",
"sourceGroup3": "TELEGRAM · SAFECAST · EPA · OMS · OFAC",
"sourceGroup4": "GDELT · NOAA · BREVETS · BLUESKY · REDDIT",
"sourceGroup5": "USGS · BCE · CVE · COPERNICUS · CELESTRAK",
"sweepComplete": "SCAN TERMINÉ — {ok}/{total} SOURCES",
"ok": "OK",
"acledLayer": "COUCHE CONFLIT ACLED",
"events": "ÉVÉNEMENTS",
"degraded": "DÉGRADÉ",
"flightCorridors": "CORRIDORS AÉRIENS",
"active": "ACTIF",
"dualProjection": "DOUBLE PROJECTION",
"ready": "PRÊT",
"intelligenceSynthesis": "SYNTHÈSE RENSEIGNEMENT"
},
"panels": {
"sensorGrid": "Grille de Capteurs",
"tradeIdeas": "Idées de Trade",
"osintFeed": "Flux OSINT",
"osintStream": "Flux OSINT",
"nuclearWatch": "Surveillance Nucléaire",
"newsTicker": "Fil d'Actualités",
"sweepDelta": "Changements",
"macroMarkets": "Macro + Marchés",
"healthAlerts": "Alertes Santé",
"riskGauges": "Indicateurs de Risque",
"crossSourceSignals": "Signaux Multi-Sources",
"signalCore": "Noyau de Signaux",
"seismicWatch": "Surveillance Sismique",
"cyberWatch": "Surveillance Cyber",
"spaceWatch": "Surveillance Spatiale",
"europeAlerts": "Alertes Europe",
"ecbIndicators": "Indicateurs BCE"
},
"layers": {
"airActivity": "Activité Aérienne",
"thermalSpikes": "Pics Thermiques",
"sdrCoverage": "Couverture SDR",
"maritimeWatch": "Surveillance Maritime",
"nuclearSites": "Sites Nucléaires",
"conflictEvents": "Événements Conflits",
"healthWatch": "Surveillance Santé",
"worldNews": "Actualités Mondiales",
"osintFeed": "Flux OSINT",
"theaters": "théâtres",
"nightDet": "dét. nocturnes",
"online": "en ligne",
"chokepoints": "points strat.",
"monitors": "moniteurs",
"fatalities": "victimes",
"whoAlerts": "alertes OMS",
"rssGeolocated": "RSS géolocalisé",
"earthquakes": "Séismes",
"seismicEvents": "Événements Sismiques",
"cyberVulns": "Vulnérabilités Cyber",
"spaceActivity": "Activité Spatiale",
"europeEmergency": "Urgences Europe"
},
"map": {
"worldNews": "Actualités",
"healthAlert": "Alerte Santé",
"chokepoint": "Point Stratégique",
"nuclearSite": "Site Nucléaire",
"osintEvent": "Événement OSINT",
"thermalDetection": "Détection Thermique",
"aircraft": "Aéronef",
"rssGeolocated": "RSS géolocalisé",
"airTraffic": "Trafic Aérien",
"thermalFire": "Thermique/Feu",
"conflict": "Conflit",
"sdrReceiver": "Récepteur SDR",
"scrollToZoom": "MOLETTE POUR ZOOMER · GLISSER POUR DÉPLACER",
"globeMode": "MODE GLOBE",
"flatMode": "MODE PLAT",
"earthquake": "Séisme",
"disaster": "Catastrophe",
"weatherAlert": "Alerte Météo",
"epaRadNet": "EPA RadNet",
"spaceStation": "Station Spatiale",
"gdeltEvent": "Événement GDELT"
},
"ideas": {
"confidence": "Confiance",
"horizon": "Horizon",
"risk": "Risque",
"signals": "Signaux",
"rationale": "Analyse",
"aiEnhanced": "IA AMÉLIORÉE",
"llmOff": "LLM OFF",
"pending": "EN ATTENTE",
"llmNotConfigured": "LLM NON CONFIGURÉ",
"llmHelp": "Définir LLM_PROVIDER + identifiants dans .env pour activer les idées de trade IA",
"disclosure": "À TITRE INFORMATIF UNIQUEMENT. Ceci ne constitue pas un conseil financier, une recommandation d'achat ou de vente de titre, ni une sollicitation quelconque. Toutes les observations basées sur les signaux sont dérivées de données OSINT publiques et ne doivent pas être utilisées pour prendre des décisions d'investissement. Consultez un conseiller financier agréé avant tout investissement. Les performances passées ne garantissent pas les résultats futurs."
},
"regions": {
"world": "Monde",
"americas": "Amériques",
"europe": "Europe",
"middleEast": "Moyen-Orient",
"asiaPacific": "Asie-Pacifique",
"africa": "Afrique"
},
"badges": {
"radiation": "RADIATION",
"live": "EN DIRECT",
"delayed": "DIFFÉRÉ",
"items": "ÉLÉMENTS",
"urgent": "URGENT",
"worldview": "VUE GLOBALE",
"hotMetrics": "MÉTRIQUES CLÉS",
"stress": "STRESS",
"sweeping": "SCAN EN COURS...",
"europe": "EUROPE",
"orbital": "ORBITAL"
},
"delta": {
"baseline": "RÉFÉRENCE",
"escalation": "ESCALADE",
"deescalation": "DÉSESCALADE",
"stable": "STABLE",
"newSignals": "Nouveaux Signaux",
"resolved": "Résolus",
"noChanges": "Aucun changement depuis le dernier scan",
"changes": "Changements",
"critical": "Critiques",
"new": "NOUVEAU"
},
"metrics": {
"wtiCrude": "Pétrole WTI",
"brent": "Brent",
"natGas": "Gaz Naturel",
"vix": "VIX",
"fedFunds": "Taux Fed",
"gscpi": "GSCPI",
"cpiMom": "IPC MoM",
"unemployment": "Chômage",
"hySpread": "Spread HY",
"usdIndex": "Indice USD",
"joblessClaims": "Inscriptions Chômage",
"mortgage30y": "Hypothèque 30A",
"m2Supply": "Masse M2",
"natDebt": "Dette Nat.",
"wti5day": "WTI 5 JOURS",
"indexes": "INDICES",
"crypto": "CRYPTO",
"energyMacro": "ÉNERGIE + MACRO",
"vsPrior": "vs précédent",
"ecbRate": "Taux BCE",
"eurusd": "EUR/USD",
"euM3": "M3 UE",
"euHicp": "IPCH UE",
"earthquakes7d": "Séismes (7j)",
"criticalCves": "CVE Critiques",
"spaceObjects": "Objets Spatiaux",
"starlink": "Starlink",
"europeAlerts": "Alertes UE"
},
"signalMetrics": {
"incidentTempo": "Tempo Incidents",
"airTheaters": "Théâtres Aériens",
"thermalSpikes": "Pics Thermiques",
"sdrNodes": "Nœuds SDR",
"chokepoints": "Points Strat.",
"whoAlerts": "Alertes OMS"
},
"nuclear": {
"allSitesNormal": "TOUS LES SITES NORMAUX",
"anomalyDetected": "ANOMALIE DÉTECTÉE",
"noData": "Pas de données"
},
"seismic": {
"noRecentQuakes": "PAS DE SÉISME SIGNIFICATIF",
"majorQuake": "SÉISME MAJEUR DÉTECTÉ",
"tsunamiRisk": "RISQUE TSUNAMI",
"mag": "M"
},
"cyber": {
"noAlerts": "PAS DE CVE CRITIQUE",
"criticalAlert": "VULNÉRABILITÉS CRITIQUES",
"cvss": "CVSS",
"critical": "CRITIQUE"
},
"space": {
"noActivity": "ACTIVITÉ NORMALE",
"launchDetected": "ACTIVITÉ DE LANCEMENT",
"objectsTracked": "objets suivis",
"newLast30d": "nouv. (30j)",
"satellites": "sats",
"recentLaunches": "Lancements Récents",
"totalTracked": "Total Suivi",
"byCountry": "PAR PAYS",
"constellations": "CONSTELLATIONS",
"launches": "lancements"
},
"europe": {
"noAlerts": "PAS D'ALERTES ACTIVES",
"activeAlerts": "ALERTES ACTIVES",
"fires": "Incendies",
"floods": "Inondations",
"types": "types"
},
"time": {
"justNow": "à l'instant",
"hoursAgo": "il y a {hours}h",
"daysAgo": "il y a {days}j"
},
"bot": {
"commands": {
"status": "État du système, dernier scan, statut des sources",
"sweep": "Déclencher un scan manuel",
"brief": "Résumé compact des derniers renseignements",
"portfolio": "Positions et P&L (si Alpaca connecté)",
"alerts": "Historique des alertes récentes",
"mute": "Couper les alertes 1h (ou /mute 2h, /mute 4h)",
"unmute": "Reprendre les alertes",
"help": "Afficher les commandes disponibles"
},
"messages": {
"alertsMuted": "🔇 Alertes coupées pour {hours}h — jusqu'à {time} UTC",
"useUnmute": "Utilisez /unmute pour reprendre.",
"alertsResumed": "🔔 Alertes reprises. Vous recevrez la prochaine évaluation de signal.",
"sweepTriggered": "🚀 Scan manuel déclenché. Vous recevrez des alertes si quelque chose de significatif est détecté.",
"sweepInProgress": "🔄 Scan déjà en cours. Veuillez patienter.",
"noDataYet": "⏳ Pas encore de données — en attente du premier scan.",
"noRecentAlerts": "Pas d'alertes récentes.",
"recentAlerts": "📋 Alertes Récentes (les {count} dernières)",
"commandsTip": "Astuce : Les commandes ne sont pas sensibles à la casse",
"commandFailed": "❌ Commande échouée : {error}",
"portfolioNotAvailable": "📊 L'intégration portfolio nécessite la connexion Alpaca MCP.\nUtilisez le dashboard Crucix ou l'agent Claude pour les requêtes portfolio."
},
"status": {
"title": "🖥️ STATUT CRUCIX",
"uptime": "Disponibilité",
"lastSweep": "Dernier scan",
"nextSweep": "Prochain scan",
"sweepInProgress": "Scan en cours",
"yes": "🔄 Oui",
"no": "⏸️ Non",
"sources": "Sources",
"failed": "échouées",
"llm": "LLM",
"enabled": "✅",
"disabled": "❌ Désactivé",
"sseClients": "Clients SSE",
"dashboard": "Dashboard",
"pending": "en attente",
"never": "jamais"
},
"brief": {
"title": "📋 BRIEF CRUCIX",
"direction": "Direction",
"changes": "changements",
"criticalChanges": "critiques",
"osint": "📡 OSINT",
"urgentSignals": "signaux urgents",
"totalPosts": "posts totaux",
"topIdeas": "💡 Meilleures Idées"
},
"alertTiers": {
"flash": "FLASH",
"priority": "PRIORITÉ",
"routine": "ROUTINE"
}
},
"alerts": {
"tiers": {
"flash": {
"label": "FLASH",
"description": "Action immédiate requise — impact marché, temps critique"
},
"priority": {
"label": "PRIORITÉ",
"description": "Cluster de signaux important — agir dans les heures"
},
"routine": {
"label": "ROUTINE",
"description": "Changement notable — informatif, pas d'urgence"
}
},
"confidence": {
"high": "HAUTE",
"medium": "MOYENNE",
"low": "BASSE"
},
"fields": {
"direction": "Direction",
"confidence": "Confiance",
"crossCorrelation": "Corrélation Croisée",
"action": "Action",
"signals": "Signaux",
"monitor": "Surveiller"
},
"messages": {
"alertsMuted": "Alertes Coupées",
"mutedUntil": "Alertes suspendues pour {hours}h — jusqu'à {time} UTC.",
"useUnmuteToResume": "Utilisez /unmute pour reprendre.",
"alertsResumed": "Alertes Reprises",
"willReceiveNext": "Vous recevrez la prochaine évaluation de signal."
},
"ruleBasedHeadlines": {
"nuclearAnomaly": "Anomalie Nucléaire Détectée",
"crossDomainSignals": "{count} Signaux Critiques Multi-Domaines",
"escalatingSignals": "{count} Signaux en Escalade",
"osintSurge": "Surge OSINT : {count} Nouveaux Posts Urgents",
"signalChangeDetected": "Changement de Signal Détecté"
}
},
"discord": {
"commands": {
"status": "Santé système, dernier scan, statut sources",
"sweep": "Déclencher un scan manuel",
"brief": "Résumé compact de renseignement",
"portfolio": "Statut portfolio (si Alpaca connecté)",
"alerts": "Historique des alertes récentes",
"mute": "Couper les alertes (par défaut 1h)",
"unmute": "Reprendre les alertes",
"muteHoursOption": "Heures de coupure (par défaut : 1)"
}
},
"llm": {
"systemPrompt": "Tu es un analyste quantitatif dans une firme de renseignement macro. Tu reçois des données OSINT + économiques structurées de 25 sources et tu produis 5-8 idées de trade actionnables.\n\nRègles:\n- Chaque idée doit citer des données spécifiques de l'input\n- Inclure le rationnel d'entrée, les facteurs de risque et l'horizon temporel\n- Croiser les signaux géopolitiques, économiques et de marché\n- Être spécifique: nommer les instruments (tickers, futures, ETFs), pas des secteurs vagues\n- Si le delta montre des changements significatifs, commencer par ceux-là\n- NE PAS répéter les idées de la liste \"previous ideas\" sauf si les conditions ont matériellement changé\n- Évaluer la confiance: HIGH (signaux multiples confirmants), MEDIUM (thèse supportée), LOW (spéculatif)\n\nOutput UNIQUEMENT un tableau JSON valide. Chaque objet:\n{\n \"title\": \"Titre court en français (max 10 mots)\",\n \"type\": \"LONG|SHORT|HEDGE|WATCH|AVOID\",\n \"ticker\": \"Instrument principal\",\n \"confidence\": \"HIGH|MEDIUM|LOW\",\n \"rationale\": \"Explication 2-3 phrases en français citant les données spécifiques\",\n \"risk\": \"Facteur de risque principal en français\",\n \"horizon\": \"Intraday|Days|Weeks|Months\",\n \"signals\": [\"signal1\", \"signal2\"]\n}"
},
"api": {
"errors": {
"noDataYet": "Pas encore de données — premier scan en cours"
}
}
}

View File

@@ -8,6 +8,7 @@ import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { exec } from 'child_process'; import { exec } from 'child_process';
import config from './crucix.config.mjs'; import config from './crucix.config.mjs';
import { getLocale, currentLanguage, getSupportedLocales } from './lib/i18n.mjs';
import { fullBriefing } from './apis/briefing.mjs'; import { fullBriefing } from './apis/briefing.mjs';
import { synthesize, generateIdeas } from './dashboard/inject.mjs'; import { synthesize, generateIdeas } from './dashboard/inject.mjs';
import { MemoryManager } from './lib/delta/index.mjs'; import { MemoryManager } from './lib/delta/index.mjs';
@@ -231,12 +232,20 @@ if (discordAlerter.isConfigured) {
const app = express(); const app = express();
app.use(express.static(join(ROOT, 'dashboard/public'))); app.use(express.static(join(ROOT, 'dashboard/public')));
// Serve loading page until first sweep completes, then the dashboard // Serve loading page until first sweep completes, then the dashboard with injected locale
app.get('/', (req, res) => { app.get('/', (req, res) => {
if (!currentData) { if (!currentData) {
res.sendFile(join(ROOT, 'dashboard/public/loading.html')); res.sendFile(join(ROOT, 'dashboard/public/loading.html'));
} else { } else {
res.sendFile(join(ROOT, 'dashboard/public/jarvis.html')); const htmlPath = join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf-8');
// Inject locale data into the HTML
const locale = getLocale();
const localeScript = `<script>window.__CRUCIX_LOCALE__ = ${JSON.stringify(locale).replace(/<\/script>/gi, '<\\/script>')};</script>`;
html = html.replace('</head>', `${localeScript}\n</head>`);
res.type('html').send(html);
} }
}); });
@@ -263,6 +272,15 @@ app.get('/api/health', (req, res) => {
llmProvider: config.llm.provider, llmProvider: config.llm.provider,
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId), telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
refreshIntervalMinutes: config.refreshIntervalMinutes, refreshIntervalMinutes: config.refreshIntervalMinutes,
language: currentLanguage,
});
});
// API: available locales
app.get('/api/locales', (req, res) => {
res.json({
current: currentLanguage,
supported: getSupportedLocales(),
}); });
}); });
@@ -408,7 +426,7 @@ async function start() {
process.exit(1); process.exit(1);
}); });
server.on('listening', () => { server.on('listening', async () => {
console.log(`[Crucix] Server running on http://localhost:${port}`); console.log(`[Crucix] Server running on http://localhost:${port}`);
// Auto-open browser // Auto-open browser
@@ -420,17 +438,18 @@ async function start() {
if (err) console.log('[Crucix] Could not auto-open browser:', err.message); if (err) console.log('[Crucix] Could not auto-open browser:', err.message);
}); });
// Try to load existing data first for instant display // Try to load existing data first for instant display (await so dashboard shows immediately)
try { try {
const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8')); const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8'));
synthesize(existing).then(data => { const data = await synthesize(existing);
currentData = data; currentData = data;
console.log('[Crucix] Loaded existing data from runs/latest.json'); console.log('[Crucix] Loaded existing data from runs/latest.json — dashboard ready instantly');
broadcast({ type: 'update', data: currentData }); broadcast({ type: 'update', data: currentData });
}).catch(() => {}); } catch {
} catch { /* no existing data */ } console.log('[Crucix] No existing data found — first sweep required');
}
// Run first sweep // Run first sweep (refreshes data in background)
console.log('[Crucix] Running initial sweep...'); console.log('[Crucix] Running initial sweep...');
runSweepCycle().catch(err => { runSweepCycle().catch(err => {
console.error('[Crucix] Initial sweep failed:', err.message || err); console.error('[Crucix] Initial sweep failed:', err.message || err);

View File

@@ -0,0 +1,30 @@
// Mistral provider — integration test (calls real API)
// Requires MISTRAL_API_KEY environment variable
// Run: MISTRAL_API_KEY=sk-... node --test test/llm-minimax-integration.test.mjs
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MistralProvider } from '../lib/llm/mistral.mjs';
const API_KEY = process.env.MISTRAL_API_KEY;
describe('Mistral integration', { skip: !API_KEY && 'MISTRAL_API_KEY not set' }, () => {
it('should complete a prompt with mistral large latest', async () => {
const provider = new MistralProvider({ apiKey: API_KEY, model: 'mistral-large-latest' });
assert.equal(provider.isConfigured, true);
const result = await provider.complete(
'You are a helpful assistant. Respond in exactly one sentence.',
'What is 2+2?',
{ maxTokens: 128, timeout: 30000 }
);
assert.ok(result.text.length > 0, 'Response text should not be empty');
assert.ok(result.usage.inputTokens > 0, 'Should report input tokens');
assert.ok(result.usage.outputTokens > 0, 'Should report output tokens');
assert.ok(result.model, 'Should report model name');
console.log(` Response: ${result.text}`);
console.log(` Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out`);
console.log(` Model: ${result.model}`);
});
});

144
test/llm-mistral.test.mjs Normal file
View File

@@ -0,0 +1,144 @@
// Mistral provider — unit tests
// Uses Node.js built-in test runner (node:test) — no extra dependencies
import { describe, it, mock, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { MistralProvider } from '../lib/llm/mistral.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
// ─── Unit Tests ───
describe('MistralProvider', () => {
it('should set defaults correctly', () => {
const provider = new MistralProvider({ apiKey: 'sk-test' });
assert.equal(provider.name, 'mistral');
assert.equal(provider.model, 'mistral-large-latest');
assert.equal(provider.isConfigured, true);
});
it('should accept custom model', () => {
const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-small-2603' });
assert.equal(provider.model, 'mistral-small-2603');
});
it('should report not configured without API key', () => {
const provider = new MistralProvider({});
assert.equal(provider.isConfigured, false);
});
it('should throw on API error', async () => {
const provider = new MistralProvider({ apiKey: 'sk-test' });
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(() =>
Promise.resolve({ ok: false, status: 401, text: () => Promise.resolve('Unauthorized') })
);
try {
await assert.rejects(
() => provider.complete('system', 'user'),
(err) => {
assert.match(err.message, /Mistral API 401/);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should parse successful response', async () => {
const provider = new MistralProvider({ apiKey: 'sk-test' });
const mockResponse = {
choices: [{ message: { content: 'Hello from Mistral' } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
model: 'mistral-large-latest',
};
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(mockResponse) })
);
try {
const result = await provider.complete('You are helpful.', 'Say hello');
assert.equal(result.text, 'Hello from Mistral');
assert.equal(result.usage.inputTokens, 10);
assert.equal(result.usage.outputTokens, 5);
assert.equal(result.model, 'mistral-large-latest');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should send correct request format', async () => {
const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-large-latest' });
let capturedUrl, capturedOpts;
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn((url, opts) => {
capturedUrl = url;
capturedOpts = opts;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
choices: [{ message: { content: 'ok' } }],
usage: { prompt_tokens: 1, completion_tokens: 1 },
model: 'mistral-large-latest',
}),
});
});
try {
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
assert.equal(capturedUrl, 'https://api.mistral.ai/v1/chat/completions');
assert.equal(capturedOpts.method, 'POST');
const headers = capturedOpts.headers;
assert.equal(headers['Content-Type'], 'application/json');
assert.equal(headers['Authorization'], 'Bearer sk-test-key');
const body = JSON.parse(capturedOpts.body);
assert.equal(body.model, 'mistral-large-latest');
assert.equal(body.max_tokens, 2048);
assert.equal(body.messages[0].role, 'system');
assert.equal(body.messages[0].content, 'system prompt');
assert.equal(body.messages[1].role, 'user');
assert.equal(body.messages[1].content, 'user message');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should handle empty response gracefully', async () => {
const provider = new MistralProvider({ apiKey: 'sk-test' });
const originalFetch = globalThis.fetch;
globalThis.fetch = mock.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [], usage: {} }),
})
);
try {
const result = await provider.complete('sys', 'user');
assert.equal(result.text, '');
assert.equal(result.usage.inputTokens, 0);
assert.equal(result.usage.outputTokens, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
});
// ─── Factory Tests ───
describe('createLLMProvider — mistral', () => {
it('should create MistralProvider for provider=mistral', () => {
const provider = createLLMProvider({ provider: 'mistral', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MistralProvider);
assert.equal(provider.name, 'mistral');
assert.equal(provider.isConfigured, true);
});
it('should be case-insensitive', () => {
const provider = createLLMProvider({ provider: 'Mistral', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MistralProvider);
});
it('should return null for empty provider', () => {
const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null });
assert.equal(provider, null);
});
});