From ba775565ec3fe2c54d9d2b35cb9b2a1a164dab28 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 20 Apr 2026 04:15:31 +0000 Subject: [PATCH 1/6] fix(env): strip surrounding quotes from .env values The custom .env parser did not handle quoted values, causing passwords and API keys containing special characters (|, ^, ", >, [, etc.) to include the quote characters as part of the value or parse incorrectly. This adds quote stripping for both single and double-quoted values, matching the behavior of dotenv and other standard .env parsers. Co-authored-by: Shelley --- apis/utils/env.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apis/utils/env.mjs b/apis/utils/env.mjs index 0b60ee8e..757f3a0f 100644 --- a/apis/utils/env.mjs +++ b/apis/utils/env.mjs @@ -20,7 +20,11 @@ function loadEnv(filePath) { const eq = trimmed.indexOf('='); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); - const val = trimmed.slice(eq + 1).trim(); + let val = trimmed.slice(eq + 1).trim(); + // Strip surrounding quotes (single or double) to support special characters + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } if (!process.env[key]) { process.env[key] = val; loaded++; } } return loaded; From bea8fc655dcd4c3c1f283054935eb65536c7fe75 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 20 Apr 2026 04:34:59 +0000 Subject: [PATCH 2/6] fix(discord): register slash commands after client is ready Slash command registration was called before client.login(), so client.user.id was undefined and fell back to the string "me", causing a Discord API error: Invalid Form Body application_id[NUMBER_TYPE_COERCE]: Value "me" is not snowflake. This moves command registration into the ready event handler and attaches that handler before login() to avoid a race condition where the ready event fires before the listener is attached. Co-authored-by: Shelley --- lib/alerts/discord.mjs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/alerts/discord.mjs b/lib/alerts/discord.mjs index ba0ce3fb..673a4a57 100644 --- a/lib/alerts/discord.mjs +++ b/lib/alerts/discord.mjs @@ -59,23 +59,23 @@ export class DiscordAlerter { intents: [GatewayIntentBits.Guilds], }); - // Register slash commands - await this._registerCommands(REST, Routes, SlashCommandBuilder); - // Handle slash command interactions this._client.on('interactionCreate', async (interaction) => { if (!interaction.isChatInputCommand()) return; await this._handleCommand(interaction); }); - // Connect - await this._client.login(this.botToken); - - this._client.once('ready', () => { + // Register ready handler before login so we don't miss the event + this._client.once('ready', async () => { this._ready = true; console.log(`[Discord] Bot online as ${this._client.user.tag}`); + // Register slash commands after login so client.user.id is available + await this._registerCommands(REST, Routes, SlashCommandBuilder); }); + // Connect + await this._client.login(this.botToken); + } catch (err) { if (err.code === 'MODULE_NOT_FOUND' || err.message?.includes('Cannot find')) { console.warn('[Discord] discord.js not installed. Run: npm install discord.js'); From 290fce9d414dc024eaddaa2695aeb9f58e371f33 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 20 Apr 2026 04:42:14 +0000 Subject: [PATCH 3/6] feat(config): add PUBLIC_URL for bot status dashboard link When running behind a reverse proxy or on a remote host, the /status command in Telegram and Discord shows http://localhost:PORT which is not reachable by users. Adds a PUBLIC_URL env var that, when set, replaces the hardcoded localhost URL in bot status responses. Falls back to localhost when unset, so existing setups are unaffected. Example: PUBLIC_URL=https://my-crucix.example.com Co-authored-by: Shelley --- crucix.config.mjs | 1 + server.mjs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crucix.config.mjs b/crucix.config.mjs index 887d7608..8a6a63f6 100644 --- a/crucix.config.mjs +++ b/crucix.config.mjs @@ -4,6 +4,7 @@ import "./apis/utils/env.mjs"; // Load .env first export default { port: parseInt(process.env.PORT) || 3117, + publicUrl: process.env.PUBLIC_URL || null, refreshIntervalMinutes: parseInt(process.env.REFRESH_INTERVAL_MINUTES) || 15, llm: { diff --git a/server.mjs b/server.mjs index 3c094b4e..330d77ef 100644 --- a/server.mjs +++ b/server.mjs @@ -71,7 +71,7 @@ if (telegramAlerter.isConfigured) { `Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`, `LLM: ${llmStatus}`, `SSE clients: ${sseClients.size}`, - `Dashboard: http://localhost:${config.port}`, + `Dashboard: ${config.publicUrl || `http://localhost:${config.port}`}`, ].join('\n'); }); @@ -169,7 +169,7 @@ if (discordAlerter.isConfigured) { `Sources: ${sourcesOk}/${sourcesTotal} OK${sourcesFailed > 0 ? ` (${sourcesFailed} failed)` : ''}`, `LLM: ${llmStatus}`, `SSE clients: ${sseClients.size}`, - `Dashboard: http://localhost:${config.port}`, + `Dashboard: ${config.publicUrl || `http://localhost:${config.port}`}`, ].join('\n'); }); From 96863bff53c2e552d33082f33f081b114cdab3f9 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 20 Apr 2026 05:20:47 +0000 Subject: [PATCH 4/6] =?UTF-8?q?fix(llm):=20Gemini=202.5=20compatibility=20?= =?UTF-8?q?=E2=80=94=20thinking=20parts=20and=20response=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues when using Gemini 2.5 Flash/Pro models: 1. Gemini 2.5 models return multi-part responses where the first part is a "thinking" part and the second is the actual content. The provider only read parts[0], getting thinking text instead of the response. Fixed by filtering out thought parts. 2. Thinking tokens consumed the maxOutputTokens budget, causing truncated JSON responses (cut mid-object). Added thinkingConfig with a 1024-token budget to keep reasoning concise, and bumped idea generation to 8192 output tokens. 3. The ideas response parser failed on Gemini output because it only handled code blocks at string boundaries. Rewrote to extract code blocks from anywhere in the response and fall back to finding the JSON array if no code block is present. Co-authored-by: Shelley --- lib/llm/gemini.mjs | 12 +++++++++++- lib/llm/ideas.mjs | 19 ++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/llm/gemini.mjs b/lib/llm/gemini.mjs index 04cf65d2..736ead4c 100644 --- a/lib/llm/gemini.mjs +++ b/lib/llm/gemini.mjs @@ -23,6 +23,9 @@ export class GeminiProvider extends LLMProvider { contents: [{ parts: [{ text: userMessage }] }], generationConfig: { maxOutputTokens: opts.maxTokens || 4096, + // Gemini 2.5 models use thinking tokens from a separate budget; + // set thinkingConfig to keep reasoning concise + thinkingConfig: { thinkingBudget: 1024 }, }, }), signal: AbortSignal.timeout(opts.timeout || 60000), @@ -34,7 +37,14 @@ export class GeminiProvider extends LLMProvider { } const data = await res.json(); - const text = data.candidates?.[0]?.content?.parts?.[0]?.text || ''; + // Gemini 2.5+ models may return multiple parts (thinking + response) + // Filter out thinking parts and concatenate the rest + const parts = data.candidates?.[0]?.content?.parts || []; + const text = parts + .filter(p => !p.thought) // Skip thinking/reasoning parts + .map(p => p.text || '') + .join('\n') + .trim() || ''; return { text, diff --git a/lib/llm/ideas.mjs b/lib/llm/ideas.mjs index f3d6020a..63b44477 100644 --- a/lib/llm/ideas.mjs +++ b/lib/llm/ideas.mjs @@ -43,12 +43,12 @@ Output ONLY valid JSON array. Each object: }`; try { - const result = await provider.complete(systemPrompt, context, { maxTokens: 4096, timeout: 90000 }); + const result = await provider.complete(systemPrompt, context, { maxTokens: 8192, timeout: 90000 }); const ideas = parseIdeasResponse(result.text); if (ideas && ideas.length > 0) { return ideas; } - console.warn('[LLM Ideas] No valid ideas parsed from response'); + console.warn('[LLM Ideas] No valid ideas parsed from response. Raw length:', result.text?.length, 'First 1000 chars:', JSON.stringify(result.text?.slice(0, 1000))); return null; } catch (err) { console.error('[LLM Ideas] Generation failed:', err.message); @@ -167,10 +167,19 @@ function compactSweepForLLM(data, delta, previousIdeas) { function parseIdeasResponse(text) { if (!text) return null; - // Strip markdown code block wrappers + // Strip markdown code block wrappers (handles trailing whitespace, thinking tags, etc.) let cleaned = text.trim(); - if (cleaned.startsWith('```')) { - cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, ''); + // Extract content from code blocks anywhere in the response + const codeBlockMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); + if (codeBlockMatch) { + cleaned = codeBlockMatch[1].trim(); + } else if (cleaned.startsWith('```')) { + cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```\s*$/, ''); + } + // Strip any leading/trailing non-JSON text (find the array) + const arrayMatch = cleaned.match(/(\[[\s\S]*\])/); + if (arrayMatch) { + cleaned = arrayMatch[1]; } try { From fe188ea97dc3c5aa09eb9acfe717ee84d0a080f2 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 20 Apr 2026 05:21:22 +0000 Subject: [PATCH 5/6] feat(discord): actionable trade idea alerts for prediction markets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds automatic Discord alerts for HIGH confidence, short-horizon (Intraday/Days/Weeks) trade ideas after each sweep. Designed for users acting on prediction markets like Kalshi. Each alert includes: - Ticker, direction (LONG/SHORT/HEDGE), confidence, and horizon - Full rationale and risk from the LLM analysis - Supporting OSINT signals that back the thesis - A prediction market angle suggesting relevant contract types (oil/gold/rates/indexes/defense/crypto with contextual guidance) Features: - Filters: only HIGH confidence + actionable types (LONG/SHORT/HEDGE) - Dedup: same idea title suppressed for 6 hours - Color-coded embeds: green=LONG, red=SHORT, gray=HEDGE - Works with any LLM provider Wired into the sweep cycle in server.mjs — fires after idea generation, independent of the delta-based alert pipeline. Co-authored-by: Shelley --- lib/alerts/discord.mjs | 113 +++++++++++++++++++++++++++++++++++++++++ server.mjs | 7 +++ 2 files changed, 120 insertions(+) diff --git a/lib/alerts/discord.mjs b/lib/alerts/discord.mjs index 673a4a57..f0d9564f 100644 --- a/lib/alerts/discord.mjs +++ b/lib/alerts/discord.mjs @@ -421,6 +421,119 @@ export class DiscordAlerter { }; } + // ─── Kalshi-Style Actionable Idea Alerts ──────────────────────────────── + + /** + * Post HIGH confidence, short-horizon ideas as actionable Kalshi-style alerts. + * @param {Array} ideas — LLM-generated ideas from the sweep + */ + async sendActionableIdeas(ideas) { + if (!this.isConfigured || !ideas?.length) return; + if (this._isMuted()) return; + + // Filter: HIGH confidence + short horizon (Intraday or Days) + const actionable = ideas.filter(i => + i.confidence === 'HIGH' && + ['Intraday', 'Days', 'Weeks'].includes(i.horizon) && + ['LONG', 'SHORT', 'HEDGE'].includes(i.type) + ); + + if (actionable.length === 0) return; + + // Dedup: don't re-alert the same idea title within 6 hours + const now = Date.now(); + if (!this._alertedIdeas) this._alertedIdeas = new Map(); + const fresh = actionable.filter(idea => { + const key = idea.title.toLowerCase().replace(/\s+/g, '-'); + const last = this._alertedIdeas.get(key); + if (last && (now - last) < 6 * 60 * 60 * 1000) return false; + this._alertedIdeas.set(key, now); + return true; + }); + + if (fresh.length === 0) return; + + for (const idea of fresh) { + const typeEmoji = { LONG: '📈', SHORT: '📉', HEDGE: '🛡️' }[idea.type] || '👁️'; + const horizonEmoji = { Intraday: '⚡', Days: '📅', Weeks: '🗓️' }[idea.horizon] || '⏳'; + + const embed = this._embed( + `${typeEmoji} ACTIONABLE: ${idea.title}`, + `**${idea.type}** ${idea.ticker} · Confidence: 🟢 HIGH · Horizon: ${horizonEmoji} ${idea.horizon}\n\n` + + `${idea.rationale}\n\n` + + `⚠️ **Risk:** ${idea.risk}`, + idea.type === 'SHORT' ? 0xE74C3C : idea.type === 'HEDGE' ? 0x95A5A6 : 0x2ECC71 + ); + + const fields = [ + { name: 'Ticker', value: idea.ticker || '—', inline: true }, + { name: 'Direction', value: idea.type, inline: true }, + { name: 'Horizon', value: idea.horizon, inline: true }, + ]; + + if (idea.signals?.length) { + fields.push({ name: 'Supporting Signals', value: idea.signals.join('\n'), inline: false }); + } + + fields.push({ name: '🎯 Prediction Market Angle', value: this._kalshiAngle(idea), inline: false }); + + if (embed.setFields) { + embed.setFields(fields); + embed.setFooter({ text: `Crucix Actionable Ideas · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` }); + } else { + embed.fields = fields; + embed.footer = { text: `Crucix Actionable Ideas · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` }; + } + + await this.sendMessage(null, [embed]); + console.log(`[Discord] Actionable idea sent: ${idea.type} ${idea.ticker} (${idea.horizon})`); + } + } + + /** + * Generate a Kalshi-relevant angle for a given idea. + */ + _kalshiAngle(idea) { + const ticker = idea.ticker || ''; + const type = idea.type; + const signals = (idea.signals || []).join(' '); + + // Oil / Energy + if (/BNO|USO|WTI|Brent|CL=F/i.test(ticker + signals)) { + return type === 'LONG' + ? '🛢️ Look for YES on "Oil above $X" contracts or NO on "Oil below $X" contracts' + : '🛢️ Look for YES on "Oil below $X" contracts'; + } + // Gold / Metals + if (/GLD|Gold|Silver|SLV/i.test(ticker + signals)) { + return type === 'LONG' + ? '🥇 Look for YES on "Gold above $X" contracts' + : '🥇 Look for YES on "Gold below $X" contracts'; + } + // Rates / Treasury + if (/ZT|ZN|ZB|DGS|Treasury|Fed.*Fund|MORTGAGE/i.test(ticker + signals)) { + return '📊 Look for Fed rate decision contracts or Treasury yield range contracts'; + } + // Indexes + if (/SPY|QQQ|SPX|NDX|IWM/i.test(ticker)) { + return type === 'LONG' + ? '📈 Look for YES on "S&P above X" or daily range contracts' + : '📉 Look for YES on "S&P below X" or daily range contracts'; + } + // Defense / Geopolitical + if (/ITA|LMT|RTX|defense|conflict/i.test(ticker + idea.rationale)) { + return '🌍 Look for geopolitical event contracts (conflict escalation, sanctions, etc.)'; + } + // Crypto + if (/BTC|ETH|crypto/i.test(ticker + signals)) { + return type === 'LONG' + ? '₿ Look for YES on "Bitcoin above $X" contracts' + : '₿ Look for YES on "Bitcoin below $X" contracts'; + } + // Generic + return `Look for event contracts related to ${ticker || 'this sector'} directional moves`; + } + // ─── Rule-Based Fallback (same logic as Telegram) ─────────────────────── _ruleBasedEvaluation(signals, delta) { diff --git a/server.mjs b/server.mjs index 330d77ef..b45d869a 100644 --- a/server.mjs +++ b/server.mjs @@ -376,6 +376,13 @@ async function runSweepCycle() { } } + // 7. Post actionable ideas to Discord (HIGH confidence, short horizon, Kalshi-style) + if (discordAlerter.isConfigured && synthesized.ideas?.length > 0) { + discordAlerter.sendActionableIdeas(synthesized.ideas).catch(err => { + console.error('[Crucix] Discord idea alert error:', err.message); + }); + } + // Prune old alerted signals memory.pruneAlertedSignals(); From e2dc87285f51adf72e2fcfd84685e5ae5787a79a Mon Sep 17 00:00:00 2001 From: calesthio Date: Tue, 19 May 2026 17:42:27 -0700 Subject: [PATCH 6/6] fix(discord): bound actionable idea alerts --- lib/alerts/discord.mjs | 55 +++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/lib/alerts/discord.mjs b/lib/alerts/discord.mjs index f0d9564f..8e3ec1b2 100644 --- a/lib/alerts/discord.mjs +++ b/lib/alerts/discord.mjs @@ -11,6 +11,14 @@ const TIER_CONFIG = { ROUTINE: { color: 0x3498DB, label: 'ROUTINE', cooldownMs: 60 * 60 * 1000, maxPerHour: 2 }, }; +const ACTIONABLE_IDEA_TIER = 'PRIORITY'; +const ACTIONABLE_IDEA_MAX_PER_SWEEP = 2; +const EMBED_LIMITS = { + title: 256, + description: 3600, + fieldValue: 1024, +}; + // Slash command definitions for Discord's API const SLASH_COMMANDS = [ { name: 'status', description: 'System health, last sweep time, source status' }, @@ -440,6 +448,11 @@ export class DiscordAlerter { if (actionable.length === 0) return; + if (!this._checkRateLimit(ACTIONABLE_IDEA_TIER)) { + console.log(`[Discord] Rate limited for actionable ideas (${ACTIONABLE_IDEA_TIER})`); + return false; + } + // Dedup: don't re-alert the same idea title within 6 hours const now = Date.now(); if (!this._alertedIdeas) this._alertedIdeas = new Map(); @@ -447,35 +460,37 @@ export class DiscordAlerter { const key = idea.title.toLowerCase().replace(/\s+/g, '-'); const last = this._alertedIdeas.get(key); if (last && (now - last) < 6 * 60 * 60 * 1000) return false; - this._alertedIdeas.set(key, now); + idea._alertKey = key; return true; - }); + }).slice(0, ACTIONABLE_IDEA_MAX_PER_SWEEP); if (fresh.length === 0) return; + let sentAny = false; for (const idea of fresh) { const typeEmoji = { LONG: '📈', SHORT: '📉', HEDGE: '🛡️' }[idea.type] || '👁️'; const horizonEmoji = { Intraday: '⚡', Days: '📅', Weeks: '🗓️' }[idea.horizon] || '⏳'; const embed = this._embed( - `${typeEmoji} ACTIONABLE: ${idea.title}`, - `**${idea.type}** ${idea.ticker} · Confidence: 🟢 HIGH · Horizon: ${horizonEmoji} ${idea.horizon}\n\n` + + this._truncate(`${typeEmoji} ACTIONABLE: ${idea.title}`, EMBED_LIMITS.title), + this._truncate(`**${idea.type}** ${idea.ticker} · Confidence: 🟢 HIGH · Horizon: ${horizonEmoji} ${idea.horizon}\n\n` + `${idea.rationale}\n\n` + - `⚠️ **Risk:** ${idea.risk}`, + `⚠️ **Risk:** ${idea.risk}`, EMBED_LIMITS.description), idea.type === 'SHORT' ? 0xE74C3C : idea.type === 'HEDGE' ? 0x95A5A6 : 0x2ECC71 ); const fields = [ - { name: 'Ticker', value: idea.ticker || '—', inline: true }, - { name: 'Direction', value: idea.type, inline: true }, - { name: 'Horizon', value: idea.horizon, inline: true }, + { name: 'Ticker', value: this._truncate(idea.ticker || '—', EMBED_LIMITS.fieldValue), inline: true }, + { name: 'Direction', value: this._truncate(idea.type, EMBED_LIMITS.fieldValue), inline: true }, + { name: 'Horizon', value: this._truncate(idea.horizon, EMBED_LIMITS.fieldValue), inline: true }, ]; if (idea.signals?.length) { - fields.push({ name: 'Supporting Signals', value: idea.signals.join('\n'), inline: false }); + const signals = idea.signals.map(s => String(s)).filter(Boolean).slice(0, 8).join('\n'); + fields.push({ name: 'Supporting Signals', value: this._truncate(signals, EMBED_LIMITS.fieldValue), inline: false }); } - fields.push({ name: '🎯 Prediction Market Angle', value: this._kalshiAngle(idea), inline: false }); + fields.push({ name: '🎯 Prediction Market Angle', value: this._truncate(this._kalshiAngle(idea), EMBED_LIMITS.fieldValue), inline: false }); if (embed.setFields) { embed.setFields(fields); @@ -485,9 +500,18 @@ export class DiscordAlerter { embed.footer = { text: `Crucix Actionable Ideas · ${new Date().toISOString().replace('T', ' ').substring(0, 19)} UTC` }; } - await this.sendMessage(null, [embed]); - console.log(`[Discord] Actionable idea sent: ${idea.type} ${idea.ticker} (${idea.horizon})`); + const sent = await this.sendMessage(null, [embed]); + if (sent) { + this._alertedIdeas.set(idea._alertKey, now); + sentAny = true; + console.log(`[Discord] Actionable idea sent: ${idea.type} ${idea.ticker} (${idea.horizon})`); + } else { + console.warn(`[Discord] Actionable idea send failed: ${idea.type} ${idea.ticker} (${idea.horizon})`); + } } + + if (sentAny) this._recordAlert(ACTIONABLE_IDEA_TIER); + return sentAny; } /** @@ -534,6 +558,13 @@ export class DiscordAlerter { return `Look for event contracts related to ${ticker || 'this sector'} directional moves`; } + _truncate(value, max, fallback = '—') { + const text = String(value ?? '').trim(); + if (!text) return fallback; + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1))}…`; + } + // ─── Rule-Based Fallback (same logic as Telegram) ─────────────────────── _ruleBasedEvaluation(signals, delta) {