From 2a0b73e5a661bce9373e553a506d91df517d2514 Mon Sep 17 00:00:00 2001 From: satoshipanic Date: Tue, 17 Mar 2026 13:43:55 +0100 Subject: [PATCH 01/25] fix(telegram): register slash commands and support DM/group two-way bot - Call setMyCommands on startup for private and group chat scopes - Parse /cmd@BotName in groups; reply to originating chat - Allow sendMessage chatId override for command replies Made-with: Cursor --- lib/alerts/telegram.mjs | 91 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index be93968..97a9d3f 100644 --- a/lib/alerts/telegram.mjs +++ b/lib/alerts/telegram.mjs @@ -38,6 +38,7 @@ export class TelegramAlerter { this._lastUpdateId = 0; // For polling bot commands this._commandHandlers = {}; // Registered command callbacks this._pollingInterval = null; + this._botUsername = null; } get isConfigured() { @@ -60,7 +61,7 @@ export class TelegramAlerter { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - chat_id: this.chatId, + chat_id: opts.chatId ?? this.chatId, text: message, parse_mode: opts.parseMode || 'Markdown', disable_web_page_preview: opts.disablePreview !== false, @@ -286,6 +287,9 @@ export class TelegramAlerter { if (this._pollingInterval) return; // Already polling 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); // Initial poll this._pollUpdates(); @@ -325,9 +329,10 @@ export class TelegramAlerter { const msg = update.message; if (!msg?.text) continue; - // Only process messages from the configured chat + const chatType = msg.chat?.type; const chatId = String(msg.chat?.id); - if (chatId !== String(this.chatId)) continue; + // Commands can come from private chats or the configured chat/group. + if (chatType !== 'private' && chatId !== String(this.chatId)) continue; await this._handleMessage(msg); } @@ -342,8 +347,11 @@ export class TelegramAlerter { async _handleMessage(msg) { const text = msg.text.trim(); 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 replyChatId = msg.chat?.id; // Built-in commands if (command === '/help') { @@ -352,7 +360,7 @@ export class TelegramAlerter { .join('\n'); await this.sendMessage( `๐Ÿค– *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -362,7 +370,7 @@ export class TelegramAlerter { this._muteUntil = Date.now() + hours * 60 * 60 * 1000; await this.sendMessage( `๐Ÿ”‡ Alerts muted for ${hours}h โ€” until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -371,7 +379,7 @@ export class TelegramAlerter { this._muteUntil = null; await this.sendMessage( `๐Ÿ”” Alerts resumed. You'll receive the next signal evaluation.`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -379,7 +387,7 @@ export class TelegramAlerter { if (command === '/alerts') { const recent = this._alertHistory.slice(-10); 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; } const lines = recent.map(a => @@ -387,7 +395,7 @@ export class TelegramAlerter { ); await this.sendMessage( `๐Ÿ“‹ *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); return; } @@ -398,19 +406,80 @@ export class TelegramAlerter { try { const response = await handler(args, msg.message_id); if (response) { - await this.sendMessage(response, { replyToMessageId: msg.message_id }); + await this.sendMessage(response, { chatId: replyChatId, replyToMessageId: msg.message_id }); } } catch (err) { console.error(`[Telegram] Command ${command} error:`, err.message); await this.sendMessage( `โŒ Command failed: ${err.message}`, - { replyToMessageId: msg.message_id } + { chatId: replyChatId, replyToMessageId: msg.message_id } ); } } // 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 globally, then force explicit private/group scopes. + await this._setMyCommands(botCommands); + await this._setMyCommands(botCommands, { type: 'all_private_chats' }); + await this._setMyCommands(botCommands, { type: 'all_group_chats' }); + } + + 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)}`); + } + } + + _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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** From 28121298cfd262863bcaff3ec0b91c451692b0fc Mon Sep 17 00:00:00 2001 From: satoshipanic Date: Tue, 17 Mar 2026 14:04:32 +0100 Subject: [PATCH 02/25] fix(telegram): split long messages at 4096 chars to avoid truncation - Add TELEGRAM_MAX_TEXT and _chunkText(); send multiple messages when over limit - Prefer newline boundaries to avoid breaking Markdown Made-with: Cursor --- lib/alerts/telegram.mjs | 72 +++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index 97a9d3f..45e902f 100644 --- a/lib/alerts/telegram.mjs +++ b/lib/alerts/telegram.mjs @@ -4,6 +4,8 @@ import { createHash } from 'crypto'; const TELEGRAM_API = 'https://api.telegram.org'; +/** Telegram Bot API limit for sendMessage text (bytes/characters). */ +const TELEGRAM_MAX_TEXT = 4096; // โ”€โ”€โ”€ Alert Tiers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // FLASH: Immediate action required โ€” market-moving, time-critical (e.g. war escalation, flash crash) @@ -48,42 +50,70 @@ export class TelegramAlerter { // โ”€โ”€โ”€ 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 {object} opts - optional: { parseMode, disablePreview, replyToMessageId } + * @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId, chatId } * @returns {Promise<{ok: boolean, messageId?: number}>} */ async sendMessage(message, opts = {}) { 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 { - const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chat_id: opts.chatId ?? this.chatId, - text: message, - parse_mode: opts.parseMode || 'Markdown', - disable_web_page_preview: opts.disablePreview !== false, - ...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}), - }), - signal: AbortSignal.timeout(15000), - }); + let lastResult = { ok: false, messageId: undefined }; + for (let i = 0; i < chunks.length; i++) { + const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text: chunks[i], + parse_mode: parseMode, + disable_web_page_preview: opts.disablePreview !== false, + ...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}), + }), + signal: AbortSignal.timeout(15000), + }); - if (!res.ok) { - const err = await res.text().catch(() => ''); - console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`); - return { ok: false }; + if (!res.ok) { + const err = await res.text().catch(() => ''); + console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`); + return lastResult; + } + + const data = await res.json(); + lastResult = { ok: true, messageId: data.result?.message_id }; } - - const data = await res.json(); - return { ok: true, messageId: data.result?.message_id }; + return lastResult; } catch (err) { console.error('[Telegram] Send error:', err.message); 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 async sendAlert(message) { const result = await this.sendMessage(message); From 4513d5d7ed1b5aa1f5652bfcc38bb37fea7c8fb9 Mon Sep 17 00:00:00 2001 From: R4V3N Date: Tue, 17 Mar 2026 17:19:33 +0100 Subject: [PATCH 03/25] Add GitHub Actions workflow for Docker image publishing Builds multi-platform images (amd64/arm64) and pushes to GHCR on master pushes and version tags. Optional Docker Hub support via repository secrets. --- .dockerignore | 14 ++++++ .github/workflows/docker-publish.yml | 67 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-publish.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7d4b7ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log* +.git +.gitignore +.github +.omc +.env +.env.* +!.env.example +runs/ +docs/ +*.md +!README.md +LICENSE diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..7b09bcc --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,67 @@ +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' && secrets.DOCKERHUB_USERNAME != '' + 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 }} + 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 From d22f36e158ed1c5ce6d3356c80d11858d153b4f4 Mon Sep 17 00:00:00 2001 From: Firdavs Date: Tue, 17 Mar 2026 21:56:08 +0300 Subject: [PATCH 04/25] fix: prevent infinite loading screen by adding sweep timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard would hang indefinitely on the loading screen because: 1. `bls.mjs` used a raw `fetch()` without any timeout/AbortSignal โ€” if the BLS API was slow or unresponsive, it would block forever. 2. `runSource()` in `briefing.mjs` had no per-source timeout, so a single hanging API could stall the entire sweep indefinitely. 3. `server.mjs` loaded cached `latest.json` via a fire-and-forget promise (`.then()`) instead of `await`, meaning the dashboard never received the cached data before the sweep started. 4. `loading.html` relied solely on SSE for redirect โ€” if the SSE connection missed the update event, the page would never redirect. Changes: - Add 15s AbortController timeout to BLS `getSeries()` fetch call - Add 30s per-source timeout via `Promise.race()` in `runSource()` - Await `synthesize()` when loading cached data so the dashboard serves instantly on restart when `runs/latest.json` exists - Add 5s fallback polling to loading page alongside SSE Co-Authored-By: Claude Opus 4.6 (1M context) --- apis/briefing.mjs | 16 +++++++++++++--- apis/sources/bls.mjs | 4 ++++ dashboard/public/loading.html | 27 +++++++++++++++++++++------ server.mjs | 19 ++++++++++--------- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/apis/briefing.mjs b/apis/briefing.mjs index 4b916ba..5e3e8e4 100644 --- a/apis/briefing.mjs +++ b/apis/briefing.mjs @@ -43,10 +43,16 @@ import { briefing as space } from './sources/space.mjs'; // === Tier 5: Live Market Data === 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) { const start = Date.now(); try { - const data = await fn(...args); + const dataPromise = fn(...args); + const timeoutPromise = new Promise((_, reject) => + 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 }; } catch (e) { return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; @@ -57,7 +63,7 @@ export async function fullBriefing() { console.error('[Crucix] Starting intelligence sweep โ€” 27 sources...'); const start = Date.now(); - const results = await Promise.allSettled([ + const allPromises = [ // Tier 1: Core OSINT & Geopolitical runSource('GDELT', gdelt), runSource('OpenSky', opensky), @@ -94,7 +100,11 @@ export async function fullBriefing() { // Tier 5: Live Market Data 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 totalMs = Date.now() - start; diff --git a/apis/sources/bls.mjs b/apis/sources/bls.mjs index 85195fb..86bfdbd 100644 --- a/apis/sources/bls.mjs +++ b/apis/sources/bls.mjs @@ -37,11 +37,15 @@ export async function getSeries(seriesIds, opts = {}) { if (apiKey) payload.registrationkey = apiKey; try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); const res = await fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal: controller.signal, }); + clearTimeout(timer); return await res.json(); } catch (e) { return { error: e.message }; diff --git a/dashboard/public/loading.html b/dashboard/public/loading.html index 196f6ab..fe9f998 100644 --- a/dashboard/public/loading.html +++ b/dashboard/public/loading.html @@ -130,18 +130,26 @@ fetch('/api/health') .catch(() => startCountdown(0)); // === 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'); es.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.type === 'update') { es.close(); - clearInterval(countdownInterval); - barFill.style.transition = 'width 0.4s ease'; - barFill.style.width = '100%'; - etaText.textContent = ''; - statusText.textContent = 'TERMINAL READY โ€” LOADING DASHBOARD'; - setTimeout(() => location.replace('/'), 800); + goToDashboard(); } } catch {} }; @@ -149,6 +157,13 @@ es.onerror = () => { es.close(); 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); diff --git a/server.mjs b/server.mjs index 86e26b5..b64995b 100644 --- a/server.mjs +++ b/server.mjs @@ -408,7 +408,7 @@ async function start() { process.exit(1); }); - server.on('listening', () => { + server.on('listening', async () => { console.log(`[Crucix] Server running on http://localhost:${port}`); // Auto-open browser @@ -420,17 +420,18 @@ async function start() { 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 { const existing = JSON.parse(readFileSync(join(RUNS_DIR, 'latest.json'), 'utf8')); - synthesize(existing).then(data => { - currentData = data; - console.log('[Crucix] Loaded existing data from runs/latest.json'); - broadcast({ type: 'update', data: currentData }); - }).catch(() => {}); - } catch { /* no existing data */ } + const data = await synthesize(existing); + currentData = data; + console.log('[Crucix] Loaded existing data from runs/latest.json โ€” dashboard ready instantly'); + broadcast({ type: 'update', data: currentData }); + } catch { + 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...'); runSweepCycle().catch(err => { console.error('[Crucix] Initial sweep failed:', err.message || err); From 3294b18d1c12797a46e4a7854e450b82fd67e3a4 Mon Sep 17 00:00:00 2001 From: R4V3N Date: Wed, 18 Mar 2026 06:13:50 +0100 Subject: [PATCH 05/25] Remove .dockerignore from .gitignore The .dockerignore needs to be tracked so the CI workflow and contributors can use it during Docker builds. --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 4dd3c66..451e831 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,6 @@ AGENTS.md *.log npm-debug.log* -# Docker -.dockerignore # Package lock (optional โ€” remove this line if you want deterministic installs) # package-lock.json From 9510865dd82e70b6bd526f7904db8b946e76c31a Mon Sep 17 00:00:00 2001 From: R4V3N Date: Wed, 18 Mar 2026 06:28:19 +0100 Subject: [PATCH 06/25] Fix Docker Hub login condition in CI workflow Replace invalid secrets check in if-condition with a repository variable (vars.DOCKERHUB_ENABLED) to avoid workflow file parsing errors. --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7b09bcc..e0b3560 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -37,7 +37,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub - if: github.event_name != 'pull_request' && secrets.DOCKERHUB_USERNAME != '' + if: github.event_name != 'pull_request' && vars.DOCKERHUB_ENABLED == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} From 801293356c06fa2411695f531e3444fe49a807be Mon Sep 17 00:00:00 2001 From: satoshipanic Date: Wed, 18 Mar 2026 08:20:32 +0100 Subject: [PATCH 07/25] fix(telegram): restore command auth boundary and scope command registration Restrict command handling to TELEGRAM_CHAT_ID again to prevent arbitrary private chats from executing privileged bot commands. Keep reply routing, @BotName parsing, and long-message chunking while scoping setMyCommands to the configured chat only. Made-with: Cursor --- lib/alerts/telegram.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/alerts/telegram.mjs b/lib/alerts/telegram.mjs index 45e902f..4c3ac3a 100644 --- a/lib/alerts/telegram.mjs +++ b/lib/alerts/telegram.mjs @@ -359,10 +359,9 @@ export class TelegramAlerter { const msg = update.message; if (!msg?.text) continue; - const chatType = msg.chat?.type; const chatId = String(msg.chat?.id); - // Commands can come from private chats or the configured chat/group. - if (chatType !== 'private' && chatId !== String(this.chatId)) continue; + // Restrict command execution to the configured chat/group only. + if (chatId !== String(this.chatId)) continue; await this._handleMessage(msg); } @@ -457,10 +456,8 @@ export class TelegramAlerter { description: description.substring(0, 256), })); - // Register globally, then force explicit private/group scopes. - await this._setMyCommands(botCommands); - await this._setMyCommands(botCommands, { type: 'all_private_chats' }); - await this._setMyCommands(botCommands, { type: 'all_group_chats' }); + // Register commands only for the configured chat to avoid global discovery. + await this._setMyCommands(botCommands, this._buildConfiguredChatScope()); } async _loadBotIdentity() { @@ -498,6 +495,14 @@ export class TelegramAlerter { } } + _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; From 6b614d559d56107484ffd7d16ee749be1bc558e0 Mon Sep 17 00:00:00 2001 From: Firdavs <102187486+Firdavs9512@users.noreply.github.com> Date: Wed, 18 Mar 2026 07:40:27 +0000 Subject: [PATCH 08/25] fix: clear timeout timer in runSource to prevent event loop hang The Promise.race timeout was never cleared on success/failure, keeping the Node event loop alive for ~30s after fast sweeps. Co-Authored-By: Claude Opus 4.6 (1M context) --- apis/briefing.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apis/briefing.mjs b/apis/briefing.mjs index 5e3e8e4..94e4173 100644 --- a/apis/briefing.mjs +++ b/apis/briefing.mjs @@ -47,15 +47,18 @@ const SOURCE_TIMEOUT_MS = 30_000; // 30s max per individual source export async function runSource(name, fn, ...args) { const start = Date.now(); + let timer; try { const dataPromise = fn(...args); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error(`Source ${name} timed out after ${SOURCE_TIMEOUT_MS / 1000}s`)), SOURCE_TIMEOUT_MS) - ); + 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 }; } catch (e) { return { name, status: 'error', durationMs: Date.now() - start, error: e.message }; + } finally { + clearTimeout(timer); } } From 06e0140268e376c4296e0ff26c51f2d134b614bc Mon Sep 17 00:00:00 2001 From: R4V3N Date: Wed, 18 Mar 2026 16:13:49 +0100 Subject: [PATCH 09/25] Add Docker Hub image tags to metadata step The login step was present but no Docker Hub image name was configured in the metadata action, so nothing would be pushed. Now generates Docker Hub tags when DOCKERHUB_ENABLED is set. --- .github/workflows/docker-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e0b3560..b76a0fc 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -49,6 +49,7 @@ jobs: 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}} From d570ca6887023ddcc278a01bdd46712cd2f5bb51 Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 18 Mar 2026 10:52:04 -0700 Subject: [PATCH 10/25] Improve mobile dashboard layout and map defaults --- dashboard/public/jarvis.html | 170 +++++++++++++++++++++++++---------- 1 file changed, 124 insertions(+), 46 deletions(-) diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 45b5b8e..9349549 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -226,7 +226,25 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s /* 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: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} + .top-right{gap:6px} + .region-btn,.meta-pill,.alert-badge{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)} +} /* CONFLICT LAYER */ @keyframes pulse-conflict{0%,100%{opacity:0.5;stroke-width:1.5}50%{opacity:0.9;stroke-width:2.5}} @@ -295,7 +313,7 @@ let D = null; // === GLOBALS === let globe = null; let flightsVisible = true; -let isFlat = true; +let isFlat = !isMobileLayout(); let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; const regionPOV = { world: { lat: 20, lng: 20, altitude: 1.8 }, @@ -486,18 +504,27 @@ function initMap(){ }); // Resize handler - window.addEventListener('resize', () => { - const c = document.getElementById('mapContainer'); - globe.width(c.clientWidth).height(c.clientHeight || 560); + 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)); // Plot globe markers (preloaded but hidden) plotMarkers(); // Start in flat mode โ€” hide globe, show flat map - document.getElementById('globeViz').style.display = 'none'; - document.getElementById('flatMapSvg').style.display = 'block'; - initFlatMap(); + if(isFlat){ + document.getElementById('globeViz').style.display = 'none'; + document.getElementById('flatMapSvg').style.display = 'block'; + 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 document.getElementById('mapLegend').innerHTML= @@ -976,6 +1003,7 @@ function mkSparkSvg(values, isGood){ // === LOWER GRID === function renderLower(){ + const mobile = isMobileLayout(); const spread=D.fred.find(f=>f.id==='T10Y2Y'); const ff=D.fred.find(f=>f.id==='DFF'); const ue=D.bls.find(b=>b.id==='LNS14000000'); @@ -1092,23 +1120,14 @@ function renderLower(){ } const deltaHtml = hasDelta ? deltaRows.join('') : '
No changes since last sweep
'; - document.getElementById('lowerGrid').innerHTML=` -
+ const tickerPanel = `

Live News Ticker

${feed.length} ITEMS
${tickerCards}${tickerCards}
-
-
-

Sweep Delta

${dirEmoji} ${ds.direction?ds.direction.toUpperCase():'BASELINE'}
- ${hasDelta?`
- Changes: ${ds.totalChanges} - Critical: ${ds.criticalChanges||0} - ${ds.signalBreakdown?`New: ${ds.signalBreakdown.new} ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}`:''} -
`:''} -
${deltaHtml}
-
-
+
`; + const osintPanel = mobile ? buildOsintPanel('lp-osint', 240) : ''; + const macroPanel = `

Macro + Markets

${mkt.timestamp?'LIVE':'DELAYED'}
${hasMarkets?`
INDEXES
@@ -1129,33 +1148,32 @@ function renderLower(){
WTI 5-DAY
${sparkHtml}
-
-
+
`; + const ideasPanel = `

Leverageable Ideas

${D.ideasSource==='llm'?'AI ENHANCED':D.ideasSource==='disabled'?'LLM OFF':'PENDING'}
${ideasHtml}
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.
`; + const deltaPanel = `
+

Sweep Delta

${dirEmoji} ${ds.direction?ds.direction.toUpperCase():'BASELINE'}
+ ${hasDelta?`
+ Changes: ${ds.totalChanges} + Critical: ${ds.criticalChanges||0} + ${ds.signalBreakdown?`New: ${ds.signalBreakdown.new} ↑${ds.signalBreakdown.escalated} ↓${ds.signalBreakdown.deescalated}`:''} +
`:''} +
${deltaHtml}
+
`; + + document.getElementById('lowerGrid').innerHTML=`${tickerPanel}${osintPanel}${macroPanel}${ideasPanel}${deltaPanel}`; } // === RIGHT RAIL === function renderRight(){ + const mobile = isMobileLayout(); // CROSS-SOURCE SIGNALS โ€” moved from lower grid to right rail const signals=D.tSignals.slice(0,6).map((s,i)=>`
Signal ${i+1}

${s}

`).join(''); // 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=>`${f}`).join(''); - const srcCls=p.isWho?'style="color:#69f0ae;border-color:rgba(105,240,174,0.4)"':'class="tk-src tg"'; - return `
${(p.channel||'OSINT').toUpperCase().substring(0,14)}${views?`${views}`:''}${age}${flags}
${cleanText((p.text||'').substring(0,160))}
`; - }).join(''); - const osintDuration=Math.max(25,osintItems.length*3); - const signalMetrics=[ {l:'Incident Tempo',v:D.tg.urgent.length,p:70}, {l:'Air Theaters',v:D.air.length,p:60}, @@ -1166,17 +1184,12 @@ function renderRight(){ ]; document.getElementById('rightRail').innerHTML=` -
+

Cross-Source Signals

WORLDVIEW
${signals}
-
-

OSINT Stream

${D.tg.urgent.length} URGENT
-
-
${osintCards}${osintCards}
-
-
-
+ ${mobile ? '' : buildOsintPanel('right-osint', 260)} +

Signal Core

HOT METRICS
${signalMetrics.map(s=>`
${s.l}
${s.v}
`).join('')}
`; @@ -1210,8 +1223,8 @@ function runBoot(){ tl.call(()=>{ const div=document.createElement('div');div.innerHTML=line.text;div.style.opacity='0'; 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('#boot',{opacity:0,duration:0.5,ease:'power2.in'},3.7); tl.set('#boot',{display:'none'},4.2); @@ -1232,6 +1245,70 @@ function runBoot(){ },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=>`${f}`).join(''); + const srcCls=p.isWho?'style="color:#69f0ae;border-color:rgba(105,240,174,0.4)"':'class="tk-src tg"'; + return `
${(p.channel||'OSINT').toUpperCase().substring(0,14)}${views?`${views}`:''}${age}${flags}
${cleanText((p.text||'').substring(0,160))}
`; + }).join(''); + const osintDuration=Math.max(25,osintItems.length*3); + return `
+

OSINT Stream

${D.tg.urgent.length} URGENT
+
+
${osintCards}${osintCards}
+
+
`; +} + +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; + renderLeftRail(); + renderLower(); + renderRight(); + } + refreshMapViewport(force && !isFlat); +} + // === REINIT (for live updates without boot sequence) === function reinit(){ renderTopbar();renderLeftRail();renderLower();renderRight(); @@ -1288,6 +1365,7 @@ function init(){ if(url) window.open(url,'_blank','noopener'); } }); + syncResponsiveLayout(true); } document.addEventListener('DOMContentLoaded', () => { From 7c10c6d0cdce9a929ee7a44df836ad7ef816e0b7 Mon Sep 17 00:00:00 2001 From: Guardian Date: Wed, 18 Mar 2026 13:12:57 -0500 Subject: [PATCH 11/25] Added the Strait of Gibraltar into CHOKEPOINTS --- apis/sources/ships.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apis/sources/ships.mjs b/apis/sources/ships.mjs index 97ac2ba..9fe36fa 100644 --- a/apis/sources/ships.mjs +++ b/apis/sources/ships.mjs @@ -14,6 +14,7 @@ import { safeFetch } from '../utils/fetch.mjs'; const CHOKEPOINTS = { 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' }, + 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' }, 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' }, From 26a64712699df83cdda5a57537500116dab64ab9 Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 18 Mar 2026 11:49:17 -0700 Subject: [PATCH 12/25] Improve mobile globe loading and perf mode (#44) --- dashboard/public/jarvis.html | 178 ++++++++++++++++++++++++++++++----- 1 file changed, 157 insertions(+), 21 deletions(-) diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 9349549..59f527f 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -59,6 +59,8 @@ 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 .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))} +.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{display:grid;grid-template-columns:240px 1fr 340px;gap:10px;margin-top:10px;min-height:calc(100vh - 100px)} @@ -122,6 +124,11 @@ 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-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-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-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)} @@ -224,6 +231,17 @@ 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.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 */ @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){ @@ -292,6 +310,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
+
Initializing 3D Globe
SCROLL TO ZOOM ยท DRAG TO PAN
@@ -312,8 +331,10 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s let D = null; // === GLOBALS === let globe = null; +let globeInitialized = false; let flightsVisible = true; -let isFlat = !isMobileLayout(); +let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true'; +let isFlat = shouldStartFlat(); let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; const regionPOV = { world: { lat: 20, lng: 20, altitude: 1.8 }, @@ -324,6 +345,46 @@ const regionPOV = { 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 === function renderTopbar(){ const ts = new Date(D.meta.timestamp); @@ -340,6 +401,7 @@ function renderTopbar(){ ).join('')}
+ SWEEP ${(D.meta.totalDurationMs/1000).toFixed(1)}s ${d} ${t} SOURCES ${D.meta.sourcesOk}/${D.meta.sourcesQueried} @@ -412,7 +474,67 @@ function renderLeftRail(){ } // === 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:'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=>`
${x.l}
`).join(''); +} + 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 w = container.clientWidth; const h = container.clientHeight || 560; @@ -487,7 +609,7 @@ function initMap(){ globe.pointOfView(regionPOV.world, 0); // Auto-rotate slowly - globe.controls().autoRotate = true; + globe.controls().autoRotate = !lowPerfMode; globe.controls().autoRotateSpeed = 0.3; globe.controls().enableDamping = true; globe.controls().dampingFactor = 0.1; @@ -500,17 +622,9 @@ function initMap(){ clearTimeout(rotateTimeout); }); 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', () => 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)); - // Plot globe markers (preloaded but hidden) plotMarkers(); @@ -526,20 +640,17 @@ function initMap(){ document.getElementById('mapHint').textContent = 'DRAG TO ROTATE ยท SCROLL TO ZOOM'; } - // Legend - 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=>`
${x.l}
`).join(''); + globeInitialized = true; } function plotMarkers(){ + if(!globe) return; const points = []; const labels = []; // === 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}]; - D.air.forEach((a,i)=>{ + if(flightsVisible) D.air.forEach((a,i)=>{ const c=airCoords[i]; if(!c) return; points.push({ lat:c.lat, lng:c.lon, size:0.25+a.total/200, alt:0.015, @@ -690,6 +801,8 @@ function plotMarkers(){ globe.ringsData(conflictRings); // === FLIGHT CORRIDORS (3D arcs) === + const arcs = []; + if(flightsVisible){ const airCoordsFlight = [ {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}, @@ -701,7 +814,6 @@ function plotMarkers(){ {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 arcs = []; // Inter-hotspot corridors for(let i=0; i showLabels ? (d.size || 0.4) : 0); // Scale arc strokes with zoom 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 if(alt > 2.0){ globe.pointsData(points.filter(p => (p.priority||3) <= 1)); @@ -794,6 +908,9 @@ function toggleFlights() { flightsVisible = !flightsVisible; const btn = document.getElementById('flightToggle'); btn.classList.toggle('off', !flightsVisible); + if(!globe){ + return; + } if(flightsVisible) { plotMarkers(); // re-render with arcs } else { @@ -821,13 +938,32 @@ function toggleMapMode(){ const globeEl = document.getElementById('globeViz'); const flatEl = document.getElementById('flatMapSvg'); if(isFlat){ + if(globe && typeof globe.pauseAnimation === 'function') globe.pauseAnimation(); globeEl.style.display = 'none'; flatEl.style.display = 'block'; + setMapLoading(false); if(!flatSvg) initFlatMap(); else { flatG.selectAll('*').remove(); drawFlatMap(); } } else { - globeEl.style.display = 'block'; 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); + } + }); } } @@ -1123,7 +1259,7 @@ function renderLower(){ const tickerPanel = `

Live News Ticker

${feed.length} ITEMS
-
${tickerCards}${tickerCards}
+
${tickerCards}${lowPerfMode ? '' : tickerCards}
`; const osintPanel = mobile ? buildOsintPanel('lp-osint', 240) : ''; @@ -1263,7 +1399,7 @@ function buildOsintPanel(panelClass='', maxHeight=260){ return `

OSINT Stream

${D.tg.urgent.length} URGENT
-
${osintCards}${osintCards}
+
${osintCards}${lowPerfMode ? '' : osintCards}
`; } From c29ec93350ff3e93a324587e55dd55bd4ed6eb0b Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 18 Mar 2026 12:01:04 -0700 Subject: [PATCH 13/25] Add dashboard signal guide glossary (#42) --- dashboard/public/jarvis.html | 186 ++++++++++++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 59f527f..d83955a 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -59,6 +59,8 @@ 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 .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))} +.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)} @@ -124,6 +126,23 @@ 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-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} +.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)} @@ -250,7 +269,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200, .top-left,.top-center,.top-right{width:100%} .top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} .top-right{gap:6px} - .region-btn,.meta-pill,.alert-badge{font-size:10px} + .region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px} .grid{display:flex;flex-direction:column} #centerCol{order:1} #rightRail{order:2} @@ -262,6 +281,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200, .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 */ @@ -326,6 +346,20 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
+
+
+
+
+
Signal Guide
+
What the signals actually mean
+
Plain-English interpretation, why it matters, and what you should not infer from it.
+
+ +
+
+
Treat these as interpretation guides, not conclusions. Stronger judgments should come from corroboration across multiple layers, not from a single signal viewed in isolation.
+
+
`; + html = html.replace('', `${localeScript}\n`); + + res.type('html').send(html); } }); @@ -263,6 +272,15 @@ app.get('/api/health', (req, res) => { llmProvider: config.llm.provider, telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId), refreshIntervalMinutes: config.refreshIntervalMinutes, + language: currentLanguage, + }); +}); + +// API: available locales +app.get('/api/locales', (req, res) => { + res.json({ + current: currentLanguage, + supported: getSupportedLocales(), }); }); From b1f1a537679a38c6c7ad626963922d1592565543 Mon Sep 17 00:00:00 2001 From: calesthio Date: Wed, 18 Mar 2026 23:41:55 -0700 Subject: [PATCH 15/25] Refine README community callouts --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6bb7538..3dc7b19 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,11 @@ [![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) +**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)
From ba9e93679fa7ddb04231ec798d6dabfd696233c6 Mon Sep 17 00:00:00 2001 From: Ketchalegend Date: Mon, 16 Mar 2026 16:21:17 +0100 Subject: [PATCH 16/25] feat: add news sources, 30-day filter, and fix ticker performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Al Jazeera RSS URL (feeds.aljazeera.com is dead, moved to www.aljazeera.com) - Add 8 new RSS sources: DW, France 24, Euronews, DW Africa, RFI, Africa News, NYT Africa, NPR โ€” covering Germany, Europe, Africa, Cameroon region, and USA - Filter WHO outbreak news and ticker feed to last 30 days (drops stale alerts like 733-day-old WHO DONs) - Fix ticker causing Chrome to crash: add will-change:transform and contain:layout style for GPU compositing, reduce DOM nodes from 80 to 40 - Add badge styles for new source categories (DW, EU, Africa) --- apis/sources/who.mjs | 6 +++++- dashboard/inject.mjs | 31 ++++++++++++++++++++++++------- dashboard/public/jarvis.html | 10 ++++++++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apis/sources/who.mjs b/apis/sources/who.mjs index f3adde1..ac0dd96 100644 --- a/apis/sources/who.mjs +++ b/apis/sources/who.mjs @@ -51,7 +51,11 @@ export async function getOutbreakNews() { 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, date: item.PublicationDate, donId: item.DonId || null, diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index 9dfd39b..3f294ac 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -126,13 +126,26 @@ async function fetchRSS(url, source) { export async function fetchAllNews() { const feeds = [ + // Global ['http://feeds.bbci.co.uk/news/world/rss.xml', 'BBC'], ['https://rss.nytimes.com/services/xml/rss/nyt/World.xml', 'NYT'], ['https://www.aljazeera.com/xml/rss/all.xml', 'Al Jazeera'], - ['https://rss.nytimes.com/services/xml/rss/nyt/Americas.xml', 'NYT Americas'], - ['https://rss.nytimes.com/services/xml/rss/nyt/AsiaPacific.xml', 'NYT Asia'], + // USA + ['https://feeds.npr.org/1001/rss.xml', 'NPR'], ['https://feeds.bbci.co.uk/news/technology/rss.xml', 'BBC Tech'], ['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'], ]; const results = await Promise.allSettled( @@ -164,8 +177,10 @@ export async function fetchAllNews() { } } - geoNews.sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0)); - return geoNews.slice(0, 50); + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + 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)); + return filtered.slice(0, 50); } // === Leverageable Ideas from Signals === @@ -549,9 +564,11 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) { }); } - // Sort by timestamp descending, limit to 50 - feed.sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0)); - return feed.slice(0, 50); + // Filter to last 30 days, sort by timestamp descending, limit to 50 + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + 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)); + return recent.slice(0, 50); } // === CLI Mode: inject into HTML file === diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index aab4025..c130f9c 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -213,7 +213,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{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-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} @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} @@ -229,6 +229,9 @@ 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.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.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.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-time{font-family:var(--mono);font-size:8px;color:var(--dim);margin-top:2px} @@ -1361,7 +1364,7 @@ function renderLower(){ const srcHtml=D.health.map(s=>`
${s.n}
`).join(''); // 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 => { if (!s) return 'other'; const sl = s.toLowerCase(); @@ -1370,6 +1373,9 @@ function renderLower(){ if (sl.includes('jazeera') || sl.includes('alj')) return 'alj'; if (sl.includes('gdelt')) return 'gdelt'; if (sl.includes('telegram')) return 'tg'; + if (sl.includes('dw') || sl.includes('deutsche')) return 'dw'; + if (sl.includes('france') || sl.includes('rfi') || sl.includes('euronews')) return 'eu'; + if (sl.includes('africa') || sl.includes('npr')) return 'af'; return 'other'; }; const tickerCards = feed.map(n => { From a8ff83783486f1e21c19c3a2482d2c8ff77c3302 Mon Sep 17 00:00:00 2001 From: Ketchalegend Date: Wed, 18 Mar 2026 14:38:15 +0100 Subject: [PATCH 17/25] fix: tighten source badge mapping per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NYT Africa, DW Africa โ†’ Africa badge (check before generic nyt/dw) - NPR โ†’ USA badge (was incorrectly grouped with Africa) - RFI โ†’ Africa (Africa/Cameroon region per PR) - Add .tk-src.us style for USA sources --- dashboard/public/jarvis.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index c130f9c..9ca9c06 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -232,6 +232,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s .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.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-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} @@ -1368,14 +1369,16 @@ function renderLower(){ const srcClass = s => { if (!s) return 'other'; 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('bbc')) return 'bbc'; - if (sl.includes('nyt') || sl.includes('times')) return 'nyt'; if (sl.includes('jazeera') || sl.includes('alj')) return 'alj'; if (sl.includes('gdelt')) return 'gdelt'; if (sl.includes('telegram')) return 'tg'; + if (sl.includes('npr')) return 'us'; if (sl.includes('dw') || sl.includes('deutsche')) return 'dw'; - if (sl.includes('france') || sl.includes('rfi') || sl.includes('euronews')) return 'eu'; - if (sl.includes('africa') || sl.includes('npr')) return 'af'; + if (sl.includes('france') || sl.includes('euronews')) return 'eu'; + if (sl.includes('nyt') || sl.includes('times')) return 'nyt'; return 'other'; }; const tickerCards = feed.map(n => { From 4af53ab25a7397455b83a856f06d3d3c38837066 Mon Sep 17 00:00:00 2001 From: nirae Date: Mon, 16 Mar 2026 21:32:47 +0100 Subject: [PATCH 18/25] Add Mistral AI as LLM provider --- .env.example | 2 +- crucix.config.mjs | 2 +- lib/llm/index.mjs | 4 + lib/llm/mistral.mjs | 51 +++++++++ test/llm-mistral-integration.test.mjs | 144 ++++++++++++++++++++++++++ test/llm-mistral.test.mjs | 144 ++++++++++++++++++++++++++ 6 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 lib/llm/mistral.mjs create mode 100644 test/llm-mistral-integration.test.mjs create mode 100644 test/llm-mistral.test.mjs diff --git a/.env.example b/.env.example index 44eb01b..b44266f 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,7 @@ REFRESH_INTERVAL_MINUTES=15 # === LLM Layer (optional) === # Enables AI-enhanced trade ideas and breaking news Telegram alerts. -# Provider options: anthropic | openai | gemini | codex | openrouter | minimax +# Provider options: anthropic | openai | gemini | codex | openrouter | minimax | mistral LLM_PROVIDER= # Not needed for codex (uses ~/.codex/auth.json) LLM_API_KEY= diff --git a/crucix.config.mjs b/crucix.config.mjs index da25ca1..c9e7235 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -7,7 +7,7 @@ export default { refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, llm: { - provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax + provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral apiKey: process.env.LLM_API_KEY || null, model: process.env.LLM_MODEL || null, }, diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index 7320a66..b2d16ee 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -6,6 +6,7 @@ import { OpenRouterProvider } from './openrouter.mjs'; import { GeminiProvider } from './gemini.mjs'; import { CodexProvider } from './codex.mjs'; import { MiniMaxProvider } from './minimax.mjs'; +import { MistralProvider } from './mistral.mjs'; export { LLMProvider } from './provider.mjs'; export { AnthropicProvider } from './anthropic.mjs'; @@ -14,6 +15,7 @@ export { OpenRouterProvider } from './openrouter.mjs'; export { GeminiProvider } from './gemini.mjs'; export { CodexProvider } from './codex.mjs'; export { MiniMaxProvider } from './minimax.mjs'; +export { MistralProvider } from './mistral.mjs'; /** * Create an LLM provider based on config. @@ -38,6 +40,8 @@ export function createLLMProvider(llmConfig) { return new CodexProvider({ model }); case 'minimax': return new MiniMaxProvider({ apiKey, model }); + case 'mistral': + return new MistralProvider({ apiKey, model }); default: console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`); return null; diff --git a/lib/llm/mistral.mjs b/lib/llm/mistral.mjs new file mode 100644 index 0000000..fa3e525 --- /dev/null +++ b/lib/llm/mistral.mjs @@ -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-medium'; + } + + 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, + }; + } +} diff --git a/test/llm-mistral-integration.test.mjs b/test/llm-mistral-integration.test.mjs new file mode 100644 index 0000000..c0f42c6 --- /dev/null +++ b/test/llm-mistral-integration.test.mjs @@ -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-medium'); + assert.equal(provider.isConfigured, true); + }); + + it('should accept custom model', () => { + const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-large-latest' }); + assert.equal(provider.model, 'mistral-large-latest'); + }); + + 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-medium', + }; + 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-medium'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should send correct request format', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-medium' }); + 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-medium', + }), + }); + }); + 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-medium'); + 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); + }); +}); diff --git a/test/llm-mistral.test.mjs b/test/llm-mistral.test.mjs new file mode 100644 index 0000000..2dc1d8d --- /dev/null +++ b/test/llm-mistral.test.mjs @@ -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-medium'); + assert.equal(provider.isConfigured, true); + }); + + it('should accept custom model', () => { + const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-medium-highspeed' }); + assert.equal(provider.model, 'mistral-medium-highspeed'); + }); + + 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-medium', + }; + 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-medium'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('should send correct request format', async () => { + const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-medium' }); + 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-medium', + }), + }); + }); + 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-medium'); + 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); + }); +}); From 7a9c957909805333175cc34120c40863981bfa6d Mon Sep 17 00:00:00 2001 From: nirae Date: Mon, 16 Mar 2026 21:46:49 +0100 Subject: [PATCH 19/25] add Mistral in README --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3dc7b19..f9adcb5 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye Connect any of 6 LLM providers for enhanced analysis: - **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 -- 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. --- @@ -199,7 +199,7 @@ These three unlock the most valuable economic and satellite data. Each takes abo ### 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 | |----------|-------------|---------------| @@ -209,6 +209,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrou | `openrouter` | `LLM_API_KEY` | openrouter/auto | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | | `minimax` | `LLM_API_KEY` | MiniMax-M2.5 | +| `mistral` | `LLM_API_KEY` | mistral-medium | For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription. @@ -286,6 +287,7 @@ crucix/ โ”‚ โ”‚ โ”œโ”€โ”€ openrouter.mjs # OpenRouter (Unified API) โ”‚ โ”‚ โ”œโ”€โ”€ codex.mjs # Codex (ChatGPT subscription) โ”‚ โ”‚ โ”œโ”€โ”€ minimax.mjs # MiniMax (M2.5, 204K context) +โ”‚ โ”‚ โ”œโ”€โ”€ mistral.mjs # Mistral AI โ”‚ โ”‚ โ”œโ”€โ”€ ideas.mjs # LLM-powered trade idea generation โ”‚ โ”‚ โ””โ”€โ”€ index.mjs # Factory: createLLMProvider() โ”‚ โ”œโ”€โ”€ delta/ # Change tracking between sweeps @@ -387,7 +389,7 @@ All settings are in `.env` with sensible defaults: |----------|---------|-------------| | `PORT` | `3117` | Dashboard server port | | `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_MODEL` | per-provider default | Override model selection | | `TELEGRAM_BOT_TOKEN` | disabled | For Telegram alerts + bot commands | From 05b63a68afa5afe92a3c6b4179e36a3c4578d6ed Mon Sep 17 00:00:00 2001 From: nirae Date: Wed, 18 Mar 2026 18:04:35 +0100 Subject: [PATCH 20/25] use mistral-large-latest instead of mistral-medium --- README.md | 2 +- lib/llm/mistral.mjs | 2 +- test/llm-mistral.test.mjs | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f9adcb5..3739f19 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrou | `openrouter` | `LLM_API_KEY` | openrouter/auto | | `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex | | `minimax` | `LLM_API_KEY` | MiniMax-M2.5 | -| `mistral` | `LLM_API_KEY` | mistral-medium | +| `mistral` | `LLM_API_KEY` | mistral-large-latest | For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription. diff --git a/lib/llm/mistral.mjs b/lib/llm/mistral.mjs index fa3e525..dde09db 100644 --- a/lib/llm/mistral.mjs +++ b/lib/llm/mistral.mjs @@ -8,7 +8,7 @@ export class MistralProvider extends LLMProvider { super(config); this.name = 'mistral'; this.apiKey = config.apiKey; - this.model = config.model || 'mistral-medium'; + this.model = config.model || 'mistral-large-latest'; } get isConfigured() { return !!this.apiKey; } diff --git a/test/llm-mistral.test.mjs b/test/llm-mistral.test.mjs index 2dc1d8d..24fe17d 100644 --- a/test/llm-mistral.test.mjs +++ b/test/llm-mistral.test.mjs @@ -50,7 +50,7 @@ describe('MistralProvider', () => { const mockResponse = { choices: [{ message: { content: 'Hello from Mistral' } }], usage: { prompt_tokens: 10, completion_tokens: 5 }, - model: 'mistral-medium', + model: 'mistral-large-latest', }; const originalFetch = globalThis.fetch; globalThis.fetch = mock.fn(() => @@ -61,14 +61,14 @@ describe('MistralProvider', () => { assert.equal(result.text, 'Hello from Mistral'); assert.equal(result.usage.inputTokens, 10); assert.equal(result.usage.outputTokens, 5); - assert.equal(result.model, 'mistral-medium'); + 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-medium' }); + 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) => { @@ -79,7 +79,7 @@ describe('MistralProvider', () => { json: () => Promise.resolve({ choices: [{ message: { content: 'ok' } }], usage: { prompt_tokens: 1, completion_tokens: 1 }, - model: 'mistral-medium', + model: 'mistral-large-latest', }), }); }); @@ -91,7 +91,7 @@ describe('MistralProvider', () => { 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-medium'); + 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'); From 4d526ca11b0837b15784d49d63590e7cb29070ab Mon Sep 17 00:00:00 2001 From: nirae Date: Wed, 18 Mar 2026 18:38:50 +0100 Subject: [PATCH 21/25] update default model to mistral-large-latest and real integration test --- test/llm-mistral-integration.test.mjs | 152 ++++---------------------- test/llm-mistral.test.mjs | 6 +- 2 files changed, 22 insertions(+), 136 deletions(-) diff --git a/test/llm-mistral-integration.test.mjs b/test/llm-mistral-integration.test.mjs index c0f42c6..d4b5f29 100644 --- a/test/llm-mistral-integration.test.mjs +++ b/test/llm-mistral-integration.test.mjs @@ -1,144 +1,30 @@ -// Mistral provider โ€” unit tests -// Uses Node.js built-in test runner (node:test) โ€” no extra dependencies +// 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, mock, beforeEach } from 'node:test'; +import { describe, it } 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 โ”€โ”€โ”€ +const API_KEY = process.env.MISTRAL_API_KEY; -describe('MistralProvider', () => { - it('should set defaults correctly', () => { - const provider = new MistralProvider({ apiKey: 'sk-test' }); - assert.equal(provider.name, 'mistral'); - assert.equal(provider.model, 'mistral-medium'); +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); - }); - it('should accept custom model', () => { - const provider = new MistralProvider({ apiKey: 'sk-test', model: 'mistral-large-latest' }); - assert.equal(provider.model, 'mistral-large-latest'); - }); - - 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') }) + const result = await provider.complete( + 'You are a helpful assistant. Respond in exactly one sentence.', + 'What is 2+2?', + { maxTokens: 128, timeout: 30000 } ); - 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-medium', - }; - 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-medium'); - } finally { - globalThis.fetch = originalFetch; - } - }); - - it('should send correct request format', async () => { - const provider = new MistralProvider({ apiKey: 'sk-test-key', model: 'mistral-medium' }); - 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-medium', - }), - }); - }); - 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-medium'); - 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); + 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}`); }); }); diff --git a/test/llm-mistral.test.mjs b/test/llm-mistral.test.mjs index 24fe17d..3ae30fb 100644 --- a/test/llm-mistral.test.mjs +++ b/test/llm-mistral.test.mjs @@ -12,13 +12,13 @@ describe('MistralProvider', () => { it('should set defaults correctly', () => { const provider = new MistralProvider({ apiKey: 'sk-test' }); assert.equal(provider.name, 'mistral'); - assert.equal(provider.model, 'mistral-medium'); + 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-medium-highspeed' }); - assert.equal(provider.model, 'mistral-medium-highspeed'); + 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', () => { From 7e3ead0e9652f157d96181f02493e9e28171a92e Mon Sep 17 00:00:00 2001 From: calesthio Date: Thu, 19 Mar 2026 11:57:22 -0700 Subject: [PATCH 22/25] Fix dashboard map regressions and OpenSky fallback --- README.md | 2 + apis/sources/opensky.mjs | 12 +++++ dashboard/inject.mjs | 64 +++++++++++++++++++--- dashboard/public/jarvis.html | 102 ++++++++++++++++++++++------------- 4 files changed, 136 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 3739f19..89b1477 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,8 @@ This is normal โ€” the first sweep takes 30โ€“60 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`. +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 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/getMe`. diff --git a/apis/sources/opensky.mjs b/apis/sources/opensky.mjs index 70d595b..655a513 100644 --- a/apis/sources/opensky.mjs +++ b/apis/sources/opensky.mjs @@ -69,6 +69,7 @@ export async function briefing() { const results = await Promise.all( hotspotEntries.map(async ([key, box]) => { const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax); + const error = data?.error || null; const states = data?.states || []; return { region: box.label, @@ -83,14 +84,25 @@ export async function briefing() { // Flag potentially interesting (military often have no callsign or specific patterns) noCallsign: states.filter(s => !s[1]?.trim()).length, 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 { source: 'OpenSky', timestamp: new Date().toISOString(), 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, + } : {}), }; } diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index 3f294ac..bb6b32f 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -5,7 +5,7 @@ // // 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 { fileURLToPath } from 'url'; 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 === async function fetchRSS(url, source) { try { @@ -326,11 +369,12 @@ export function generateIdeas(V2) { // === Synthesize raw sweep data into dashboard format === export async function synthesize(data) { - const air = (data.sources.OpenSky?.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) - })); + const liveAirHotspots = data.sources.OpenSky?.hotspots || []; + const airFallback = sumAirHotspots(liveAirHotspots) > 0 + ? null + : 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 => ({ region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0, hc: h.highConfidence || 0, @@ -511,6 +555,14 @@ export async function synthesize(data) { const V2 = { 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 }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news, diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 9ca9c06..46a52b0 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -97,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} /* 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} #globeViz{width:100%;height:100%;cursor:grab} #globeViz:active{cursor:grabbing} @@ -272,6 +273,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200, .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} @@ -331,6 +333,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
+
@@ -389,6 +392,7 @@ let globeInitialized = false; let flightsVisible = true; let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true'; let isFlat = shouldStartFlat(); +let currentRegion = 'world'; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; const signalGuideItems = [ { @@ -554,7 +558,26 @@ function togglePerfMode(){ } // === TOPBAR === +function getRegionControlsMarkup(){ + return ['world','americas','europe','middleEast','asiaPacific','africa'].map(r=> + `` + ).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(){ + const mobile = isMobileLayout(); const ts = new Date(D.meta.timestamp); const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase(); const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); @@ -563,11 +586,7 @@ function renderTopbar(){ CRUCIX MONITOR WARTIME STAGFLATION RISK
-
- ${['world','americas','europe','middleEast','asiaPacific','africa'].map(r=> - `` - ).join('')} -
+ ${mobile ? `
${getRegionControlsMarkup()}
` : ''}
${t('dashboard.sweep','SWEEP')} ${(D.meta.totalDurationMs/1000).toFixed(1)}s @@ -577,6 +596,7 @@ function renderTopbar(){ ${t('dashboard.highAlert','HIGH ALERT')}
`; + renderRegionControls(); } // === LEFT RAIL === @@ -1077,6 +1097,13 @@ function toggleFlights() { flightsVisible = !flightsVisible; const btn = document.getElementById('flightToggle'); btn.classList.toggle('off', !flightsVisible); + if(isFlat){ + if(flatG){ + flatG.selectAll('*').remove(); + drawFlatMap(); + } + return; + } if(!globe){ return; } @@ -1149,13 +1176,6 @@ function initFlatMap(){ 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') .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); drawFlatMap(); @@ -1184,12 +1204,14 @@ function plotFlatMarkers(){ }; // 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}]; - D.air.forEach((a,i)=>{ - 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)', - ev=>showPopup(ev,a.region,`${a.total} aircraft
No callsign: ${a.noCallsign}
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(flightsVisible){ + D.air.forEach((a,i)=>{ + 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)', + ev=>showPopup(ev,a.region,`${a.total} aircraft
No callsign: ${a.noCallsign}
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); + }); + } // Thermal 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)', @@ -1237,25 +1259,27 @@ function plotFlatMarkers(){ g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); }); // Flight corridors - 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 cG=flatG.append('g').attr('class','corridors-layer'); - for(let i=0;i0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)'; - const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]); - const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); - const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}}; - cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80))); - }} - D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{ - if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return; - const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]); - 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); - })}); + 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 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'); + for(let i=0;i0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)'; + const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]); + const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); + const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}}; + cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80))); + }} + D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{ + if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return; + const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]); + 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); + })}); + } } // Update setRegion for flat mode @@ -1265,6 +1289,7 @@ const _origSetRegion = setRegion; const _origMapZoom = mapZoom; function setRegion(r){ + currentRegion = r; document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r)); closePopup(); if(isFlat && flatSvg && flatZoom){ @@ -1553,7 +1578,7 @@ 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('.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); - },4.0); + },[],4.0); } function isMobileLayout(){ return window.innerWidth <= 1100; } @@ -1644,6 +1669,7 @@ function syncResponsiveLayout(force=false){ const mobileNow = isMobileLayout(); if(force || lastResponsiveMobile === null || mobileNow !== lastResponsiveMobile){ lastResponsiveMobile = mobileNow; + renderTopbar(); renderLeftRail(); renderLower(); renderRight(); From 05ce4680f57e8a8efaa12b495c116058f45e2799 Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 20 Mar 2026 05:49:34 -0700 Subject: [PATCH 23/25] Add token impersonation warning to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 89b1477..0a2bbef 100644 --- a/README.md +++ b/README.md @@ -48,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. +## 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 From 4ce2e7f1e3558b96718be6798c75b826ae35c407 Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 20 Mar 2026 06:32:42 -0700 Subject: [PATCH 24/25] Add regional RSS sources to dashboard --- dashboard/inject.mjs | 51 +++++++++++++++++++++++++++++++++--- dashboard/public/jarvis.html | 6 +++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index bb6b32f..962da19 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -167,6 +167,14 @@ 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() { const feeds = [ // Global @@ -189,6 +197,12 @@ export async function fetchAllNews() { ['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( @@ -206,7 +220,7 @@ export async function fetchAllNews() { const key = item.title.substring(0, 40).toLowerCase(); if (seen.has(key)) continue; seen.add(key); - const geo = geoTagText(item.title); + const geo = geoTagText(item.title) || RSS_SOURCE_FALLBACKS[item.source]; if (geo) { geoNews.push({ title: item.title.substring(0, 100), @@ -223,7 +237,23 @@ export async function fetchAllNews() { const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); 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)); - return filtered.slice(0, 50); + + 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 === @@ -620,7 +650,22 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) { const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); 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)); - return recent.slice(0, 50); + + 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 === diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 46a52b0..d7214c8 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -233,6 +233,9 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s .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-head{font-size:11px;line-height:1.35;color:#c8d8d2;margin-top:3px} @@ -1396,6 +1399,9 @@ function renderLower(){ 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('jazeera') || sl.includes('alj')) return 'alj'; if (sl.includes('gdelt')) return 'gdelt'; From ed384528efe526d52cbda21617b55ea91e96f2c6 Mon Sep 17 00:00:00 2001 From: calesthio Date: Fri, 20 Mar 2026 11:03:49 -0700 Subject: [PATCH 25/25] Document dashboard performance modes --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 0a2bbef..a79a49f 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,22 @@ A self-contained Jarvis-style HUD with: - **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) +### 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 The server runs a sweep cycle every 15 minutes (configurable). Each cycle: 1. Queries all 27 sources in parallel (~30s)