Merge master into feature/openrouter-support

This commit is contained in:
calesthio
2026-03-17 14:27:06 -07:00
13 changed files with 639 additions and 23 deletions

View File

@@ -31,12 +31,12 @@ REFRESH_INTERVAL_MINUTES=15
# === LLM Layer (optional) ===
# Enables AI-enhanced trade ideas and breaking news Telegram alerts.
# Provider options: anthropic | openai | gemini | codex | openrouter
# Provider options: anthropic | openai | gemini | codex | openrouter | minimax
LLM_PROVIDER=
# Not needed for codex (uses ~/.codex/auth.json)
LLM_API_KEY=
# Optional override. Each provider has a sensible default:
# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto
# anthropic: claude-sonnet-4-6 | openai: gpt-5.4 | gemini: gemini-3.1-pro | codex: gpt-5.3-codex | openrouter: openrouter/auto | minimax: MiniMax-M2.5
LLM_MODEL=
# === Telegram Alerts (optional, requires LLM) ===

3
.gitignore vendored
View File

@@ -45,3 +45,6 @@ npm-debug.log*
# Local maintainer notes
MAINTAINER_DECISIONS.local.md
# Local deploy config
dashboard/public/vercel.json

View File

@@ -4,6 +4,11 @@
**Your own intelligence terminal. 27 sources. One command. Zero cloud.**
## [Visit The Live Site: crucix.live](https://www.crucix.live/)
[![Live Website](https://img.shields.io/badge/live-crucix.live-00d4ff?style=for-the-badge)](https://www.crucix.live/)
[![Open Demo](https://img.shields.io/badge/open-live%20dashboard-0b1220?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.crucix.live/)
[![Node.js 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](#quick-start)
[![License: AGPL v3](https://img.shields.io/badge/license-AGPLv3-blue.svg)](LICENSE)
[![Dependencies](https://img.shields.io/badge/dependencies-1%20(express)-orange)](#architecture)
@@ -27,10 +32,15 @@
</div>
> **Live website:** [https://www.crucix.live/](https://www.crucix.live/)
> Explore the public demo first, then clone the repo to run Crucix locally.
Crucix pulls satellite fire detection, flight tracking, radiation monitoring, satellite constellation tracking, economic indicators, live market prices, conflict data, sanctions lists, and social sentiment from 27 open-source intelligence feeds — in parallel, every 15 minutes — and renders everything on a single self-contained Jarvis-style dashboard.
Hook it up to an LLM and it becomes a **two-way intelligence assistant** — pushing multi-tier alerts to Telegram and Discord when something meaningful changes, responding to commands like `/brief` and `/sweep` from your phone, and generating actionable trade ideas grounded in real cross-domain data. Your own analyst that watches the world while you sleep.
Try the live demo first at [https://www.crucix.live/](https://www.crucix.live/), then clone the repo when you want the full local stack.
No cloud. No telemetry. No subscriptions. Just `node server.mjs` and you're running.
---
@@ -50,7 +60,7 @@ It was built for anyone who wants to understand what's actually happening in the
```bash
# 1. Clone the repo
git clone https://github.com/calesthio/Crucix.git
cd crucix
cd Crucix
# 2. Install dependencies (just Express)
npm install
@@ -76,7 +86,7 @@ The dashboard opens automatically at `http://localhost:3117` and immediately beg
```bash
git clone https://github.com/calesthio/Crucix.git
cd crucix
cd Crucix
cp .env.example .env # add your API keys
docker compose up -d
```
@@ -148,10 +158,10 @@ Alerts are delivered as rich embeds with color-coded sidebars: red for FLASH, ye
**Optional dependency:** The full bot requires `discord.js`. Install it with `npm install discord.js`. If it's not installed, Crucix automatically falls back to webhook-only mode.
### Optional LLM Layer
Connect any of 5 LLM providers for enhanced analysis:
Connect any of 6 LLM providers for enhanced analysis:
- **AI trade ideas** — quantitative analyst producing 5-8 actionable ideas citing specific data
- **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)
- Providers: Anthropic Claude, OpenAI, Google Gemini, OpenRouter (Unified API), OpenAI Codex (ChatGPT subscription), MiniMax
- Graceful fallback — when LLM is unavailable, a rule-based engine takes over alert evaluation. LLM failures never crash the sweep cycle.
---
@@ -184,7 +194,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`
Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, `minimax`
| Provider | Key Required | Default Model |
|----------|-------------|---------------|
@@ -193,6 +203,7 @@ Set `LLM_PROVIDER` to one of: `anthropic`, `openai`, `gemini`, `codex`
| `gemini` | `LLM_API_KEY` | gemini-3.1-pro |
| `openrouter` | `LLM_API_KEY` | openrouter/auto |
| `codex` | None (uses `~/.codex/auth.json`) | gpt-5.3-codex |
| `minimax` | `LLM_API_KEY` | MiniMax-M2.5 |
For Codex, run `npx @openai/codex login` to authenticate via your ChatGPT subscription.
@@ -269,6 +280,7 @@ crucix/
│ │ ├── gemini.mjs # Gemini
│ │ ├── openrouter.mjs # OpenRouter (Unified API)
│ │ ├── codex.mjs # Codex (ChatGPT subscription)
│ │ ├── minimax.mjs # MiniMax (M2.5, 204K context)
│ │ ├── ideas.mjs # LLM-powered trade idea generation
│ │ └── index.mjs # Factory: createLLMProvider()
│ ├── delta/ # Change tracking between sweeps
@@ -370,7 +382,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`, or `openrouter` |
| `LLM_PROVIDER` | disabled | `anthropic`, `openai`, `gemini`, `codex`, `openrouter`, or `minimax` |
| `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 |

View File

@@ -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
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax
apiKey: process.env.LLM_API_KEY || null,
model: process.env.LLM_MODEL || null,
},

View File

@@ -9,6 +9,9 @@ import { readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import config from '../crucix.config.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
import { generateLLMIdeas } from '../lib/llm/ideas.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
@@ -511,11 +514,42 @@ function buildNewsFeed(rssNews, gdeltData, tgUrgent, tgTop) {
}
// === CLI Mode: inject into HTML file ===
function getCliArg(flag) {
const idx = process.argv.indexOf(flag);
return idx >= 0 ? process.argv[idx + 1] : null;
}
async function cliInject() {
const data = JSON.parse(readFileSync(join(ROOT, 'runs/latest.json'), 'utf8'));
const htmlOverride = getCliArg('--html');
const shouldOpen = !process.argv.includes('--no-open');
console.log('Fetching RSS news feeds...');
const V2 = await synthesize(data);
const llmProvider = createLLMProvider(config.llm);
if (llmProvider?.isConfigured) {
try {
console.log(`[LLM] Generating ideas via ${llmProvider.name}...`);
const llmIdeas = await generateLLMIdeas(llmProvider, V2, null, []);
if (llmIdeas?.length) {
V2.ideas = llmIdeas;
V2.ideasSource = 'llm';
console.log(`[LLM] Generated ${llmIdeas.length} ideas`);
} else {
V2.ideas = [];
V2.ideasSource = 'llm-failed';
console.log('[LLM] No ideas returned');
}
} catch (err) {
V2.ideas = [];
V2.ideasSource = 'llm-failed';
console.log('[LLM] Idea generation failed:', err.message);
}
} else {
V2.ideas = [];
V2.ideasSource = 'disabled';
}
console.log(`Generated ${V2.ideas.length} leverageable ideas`);
const json = JSON.stringify(V2);
@@ -523,12 +557,15 @@ async function cliInject() {
console.log('Size:', json.length, 'bytes | Air:', V2.air.length, '| Thermal:', V2.thermal.length,
'| News:', V2.news.length, '| Ideas:', V2.ideas.length, '| Sources:', V2.health.length);
const htmlPath = join(ROOT, 'dashboard/public/jarvis.html');
const htmlPath = htmlOverride || join(ROOT, 'dashboard/public/jarvis.html');
let html = readFileSync(htmlPath, 'utf8');
html = html.replace(/^(let|const) D = .*;\s*$/m, 'let D = ' + json + ';');
// Use a replacer function so JSON is inserted literally even if it contains `$`.
html = html.replace(/^(let|const) D = .*;\s*$/m, () => 'let D = ' + json + ';');
writeFileSync(htmlPath, html);
console.log('Data injected into jarvis.html!');
if (!shouldOpen) return;
// Auto-open dashboard in default browser
// NOTE: On Windows, `start` in PowerShell is an alias for Start-Service, not cmd's start.
// We must use `cmd /c start ""` to ensure it works in both cmd.exe and PowerShell.
@@ -542,7 +579,8 @@ async function cliInject() {
}
// Run CLI if invoked directly
const isMain = process.argv[1] && fileURLToPath(import.meta.url).includes(process.argv[1].replace(/\\/g, '/'));
const isMain = process.argv[1]
&& fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/');
if (isMain) {
cliInject();
await cliInject();
}

View File

@@ -1202,9 +1202,10 @@ function init(){
}
document.addEventListener('DOMContentLoaded', () => {
const isServer = location.protocol !== 'file:';
const hasInlineData = !!(D && D.meta);
const canProbeApi = location.protocol !== 'file:';
if (isServer) {
if (canProbeApi && !hasInlineData) {
// Server mode: always fetch live data from API (ignore any stale inline D)
fetch('/api/data')
.then(r => r.json())
@@ -1213,7 +1214,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Should not reach here — server routes to loading.html when no data
if (D && D.meta) { init(); connectSSE(); }
});
} else if (D && D.meta) {
} else if (hasInlineData) {
// File mode: use inline data
init();
}

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
crucix:
build: .

View File

@@ -5,6 +5,7 @@ import { OpenAIProvider } from './openai.mjs';
import { OpenRouterProvider } from './openrouter.mjs';
import { GeminiProvider } from './gemini.mjs';
import { CodexProvider } from './codex.mjs';
import { MiniMaxProvider } from './minimax.mjs';
export { LLMProvider } from './provider.mjs';
export { AnthropicProvider } from './anthropic.mjs';
@@ -12,6 +13,7 @@ export { OpenAIProvider } from './openai.mjs';
export { OpenRouterProvider } from './openrouter.mjs';
export { GeminiProvider } from './gemini.mjs';
export { CodexProvider } from './codex.mjs';
export { MiniMaxProvider } from './minimax.mjs';
/**
* Create an LLM provider based on config.
@@ -34,6 +36,8 @@ export function createLLMProvider(llmConfig) {
return new GeminiProvider({ apiKey, model });
case 'codex':
return new CodexProvider({ model });
case 'minimax':
return new MiniMaxProvider({ apiKey, model });
default:
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
return null;

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

@@ -0,0 +1,51 @@
// MiniMax Provider — raw fetch, no SDK
// Uses MiniMax's OpenAI-compatible Chat Completions API
import { LLMProvider } from './provider.mjs';
export class MiniMaxProvider extends LLMProvider {
constructor(config) {
super(config);
this.name = 'minimax';
this.apiKey = config.apiKey;
this.model = config.model || 'MiniMax-M2.5';
}
get isConfigured() { return !!this.apiKey; }
async complete(systemPrompt, userMessage, opts = {}) {
const res = await fetch('https://api.minimax.io/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(`MiniMax 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,
};
}
}

332
package-lock.json generated
View File

@@ -7,12 +7,221 @@
"": {
"name": "crucix",
"version": "2.0.0",
"license": "ISC",
"license": "AGPL-3.0-only",
"dependencies": {
"express": "^5.1.0"
},
"engines": {
"node": ">=22"
"node": ">=22",
"npm": ">=10"
},
"optionalDependencies": {
"discord.js": "^14.25.1"
}
},
"node_modules/@discordjs/builders": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz",
"integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz",
"integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"optional": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@types/node": {
"version": "25.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
"license": "MIT",
"optional": true,
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/accepts": {
@@ -156,6 +365,44 @@
"node": ">= 0.8"
}
},
"node_modules/discord-api-types": {
"version": "0.38.42",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.42.tgz",
"integrity": "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"scripts/actions/documentation"
]
},
"node_modules/discord.js": {
"version": "14.25.1",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz",
"integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@discordjs/builders": "^1.13.0",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.0",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.38.33",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.21.3"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -273,6 +520,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"optional": true
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -451,6 +705,27 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT",
"optional": true
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT",
"optional": true
},
"node_modules/magic-bytes.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
"license": "MIT",
"optional": true
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -788,6 +1063,20 @@
"node": ">=0.6"
}
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT",
"optional": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@@ -802,6 +1091,23 @@
"node": ">= 0.6"
}
},
"node_modules/undici": {
"version": "7.24.4",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz",
"integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT",
"optional": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -825,6 +1131,28 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "crucix",
"version": "2.0.0",
"description": "Local intelligence engine 26 OSINT sources, live dashboard, auto-refresh, optional LLM layer.",
"description": "Local intelligence engine - 27 OSINT sources, live dashboard, public demo at crucix.live, auto-refresh, optional LLM layer.",
"type": "module",
"scripts": {
"start": "node server.mjs",
@@ -14,7 +14,12 @@
"clean": "node scripts/clean.mjs",
"fresh-start": "npm run clean && npm start"
},
"keywords": ["osint", "intelligence", "dashboard", "geopolitical"],
"keywords": [
"osint",
"intelligence",
"dashboard",
"geopolitical"
],
"author": "Crucix",
"license": "AGPL-3.0-only",
"engines": {
@@ -25,6 +30,7 @@
"express": "^5.1.0"
},
"optionalDependencies": {
"discord.js": "^14.25.0"
}
"discord.js": "^14.25.1" },
"overrides": {
"undici": "^7.24.4" }
}

View File

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

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

@@ -0,0 +1,144 @@
// MiniMax 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 { MiniMaxProvider } from '../lib/llm/minimax.mjs';
import { createLLMProvider } from '../lib/llm/index.mjs';
// ─── Unit Tests ───
describe('MiniMaxProvider', () => {
it('should set defaults correctly', () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
assert.equal(provider.name, 'minimax');
assert.equal(provider.model, 'MiniMax-M2.5');
assert.equal(provider.isConfigured, true);
});
it('should accept custom model', () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test', model: 'MiniMax-M2.5-highspeed' });
assert.equal(provider.model, 'MiniMax-M2.5-highspeed');
});
it('should report not configured without API key', () => {
const provider = new MiniMaxProvider({});
assert.equal(provider.isConfigured, false);
});
it('should throw on API error', async () => {
const provider = new MiniMaxProvider({ 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, /MiniMax API 401/);
return true;
}
);
} finally {
globalThis.fetch = originalFetch;
}
});
it('should parse successful response', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test' });
const mockResponse = {
choices: [{ message: { content: 'Hello from MiniMax' } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
model: 'MiniMax-M2.5',
};
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 MiniMax');
assert.equal(result.usage.inputTokens, 10);
assert.equal(result.usage.outputTokens, 5);
assert.equal(result.model, 'MiniMax-M2.5');
} finally {
globalThis.fetch = originalFetch;
}
});
it('should send correct request format', async () => {
const provider = new MiniMaxProvider({ apiKey: 'sk-test-key', model: 'MiniMax-M2.5' });
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: 'MiniMax-M2.5',
}),
});
});
try {
await provider.complete('system prompt', 'user message', { maxTokens: 2048 });
assert.equal(capturedUrl, 'https://api.minimax.io/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, 'MiniMax-M2.5');
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 MiniMaxProvider({ 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 — minimax', () => {
it('should create MiniMaxProvider for provider=minimax', () => {
const provider = createLLMProvider({ provider: 'minimax', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MiniMaxProvider);
assert.equal(provider.name, 'minimax');
assert.equal(provider.isConfigured, true);
});
it('should be case-insensitive', () => {
const provider = createLLMProvider({ provider: 'MiniMax', apiKey: 'sk-test', model: null });
assert.ok(provider instanceof MiniMaxProvider);
});
it('should return null for empty provider', () => {
const provider = createLLMProvider({ provider: null, apiKey: 'sk-test', model: null });
assert.equal(provider, null);
});
});