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; 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/lib/alerts/discord.mjs b/lib/alerts/discord.mjs index ba0ce3fb..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' }, @@ -59,23 +67,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'); @@ -421,6 +429,142 @@ 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; + + 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(); + 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; + 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( + 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}`, EMBED_LIMITS.description), + idea.type === 'SHORT' ? 0xE74C3C : idea.type === 'HEDGE' ? 0x95A5A6 : 0x2ECC71 + ); + + const fields = [ + { 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) { + 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._truncate(this._kalshiAngle(idea), EMBED_LIMITS.fieldValue), 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` }; + } + + 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; + } + + /** + * 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`; + } + + _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) { 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 { diff --git a/server.mjs b/server.mjs index 3c094b4e..b45d869a 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'); }); @@ -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();