diff --git a/.env.example b/.env.example index 4830306..44eb01b 100644 --- a/.env.example +++ b/.env.example @@ -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) === diff --git a/.gitignore b/.gitignore index 4dd3c66..e855d3f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ npm-debug.log* # Local maintainer notes MAINTAINER_DECISIONS.local.md + +# Local deploy config +dashboard/public/vercel.json diff --git a/README.md b/README.md index 6651453..6bb7538 100644 --- a/README.md +++ b/README.md @@ -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 @@ +> **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 | diff --git a/crucix.config.mjs b/crucix.config.mjs index e76e55c..da25ca1 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 + 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, }, diff --git a/dashboard/inject.mjs b/dashboard/inject.mjs index bdad625..d011f52 100644 --- a/dashboard/inject.mjs +++ b/dashboard/inject.mjs @@ -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(); } diff --git a/dashboard/public/jarvis.html b/dashboard/public/jarvis.html index 7acfca3..77adcd2 100644 --- a/dashboard/public/jarvis.html +++ b/dashboard/public/jarvis.html @@ -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(); } diff --git a/docker-compose.yml b/docker-compose.yml index 2724ce4..cdad7b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: crucix: build: . diff --git a/lib/llm/index.mjs b/lib/llm/index.mjs index e590b0a..7320a66 100644 --- a/lib/llm/index.mjs +++ b/lib/llm/index.mjs @@ -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; diff --git a/lib/llm/minimax.mjs b/lib/llm/minimax.mjs new file mode 100644 index 0000000..7c9160b --- /dev/null +++ b/lib/llm/minimax.mjs @@ -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, + }; + } +} diff --git a/package-lock.json b/package-lock.json index 42c3545..2dba6a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index 92c3c20..5b90bf2 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/test/llm-minimax-integration.test.mjs b/test/llm-minimax-integration.test.mjs new file mode 100644 index 0000000..08b0c7c --- /dev/null +++ b/test/llm-minimax-integration.test.mjs @@ -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}`); + }); +}); diff --git a/test/llm-minimax.test.mjs b/test/llm-minimax.test.mjs new file mode 100644 index 0000000..f2d3b7d --- /dev/null +++ b/test/llm-minimax.test.mjs @@ -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); + }); +});