Initial release — Crucix Intelligence Engine v2.0.0
26-source OSINT intelligence engine with live Jarvis dashboard, auto-refresh via SSE, optional LLM layer (4 providers), delta/memory system, and Telegram breaking news alerts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
162
lib/alerts/telegram.mjs
Normal file
162
lib/alerts/telegram.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
// Telegram Alerter — sends breaking news alerts via Telegram Bot API (LLM-gated)
|
||||
|
||||
const TELEGRAM_API = 'https://api.telegram.org';
|
||||
|
||||
export class TelegramAlerter {
|
||||
constructor({ botToken, chatId }) {
|
||||
this.botToken = botToken;
|
||||
this.chatId = chatId;
|
||||
}
|
||||
|
||||
get isConfigured() {
|
||||
return !!(this.botToken && this.chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message via Telegram Bot API.
|
||||
* @param {string} message - markdown-formatted message
|
||||
* @returns {Promise<boolean>} - true if sent successfully
|
||||
*/
|
||||
async sendAlert(message) {
|
||||
if (!this.isConfigured) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text: message,
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 100)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Telegram] Send error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate delta signals with LLM and send alert if warranted.
|
||||
* @param {LLMProvider} llmProvider - configured LLM provider
|
||||
* @param {object} delta - delta from current sweep
|
||||
* @param {MemoryManager} memory - memory manager for dedup
|
||||
* @returns {Promise<boolean>} - true if alert was sent
|
||||
*/
|
||||
async evaluateAndAlert(llmProvider, delta, memory) {
|
||||
if (!this.isConfigured || !llmProvider?.isConfigured) return false;
|
||||
if (!delta?.summary?.criticalChanges) return false;
|
||||
|
||||
// Filter out already-alerted signals
|
||||
const alerted = memory.getAlertedSignals();
|
||||
const newSignals = [
|
||||
...(delta.signals?.new || []),
|
||||
...(delta.signals?.escalated || []),
|
||||
].filter(s => {
|
||||
const key = s.key || s.label || s.text?.substring(0, 40);
|
||||
return !alerted[key];
|
||||
});
|
||||
|
||||
if (newSignals.length === 0) return false;
|
||||
|
||||
// Ask LLM if these signals warrant an immediate alert
|
||||
const systemPrompt = `You are an intelligence alert evaluator. You receive new/escalated signals from an OSINT monitoring system. Your job is to determine if any warrant an IMMEDIATE alert to the user.
|
||||
|
||||
Alert criteria (ALL must be true):
|
||||
1. Material market impact likely (>1% move in major index, or >5% move in sector/commodity)
|
||||
2. Time-sensitive — acting in the next few hours matters
|
||||
3. Not routine data (scheduled economic releases don't count unless they're a major surprise)
|
||||
|
||||
Respond with ONLY valid JSON:
|
||||
{
|
||||
"shouldAlert": true/false,
|
||||
"reason": "1-2 sentence explanation",
|
||||
"headline": "Alert headline if shouldAlert is true",
|
||||
"signals": ["key signals that triggered alert"]
|
||||
}`;
|
||||
|
||||
const userMessage = `New/escalated signals since last sweep:\n${newSignals.map(s => {
|
||||
if (s.changePct !== undefined) return `- ${s.label}: ${s.previous} → ${s.current} (${s.changePct > 0 ? '+' : ''}${s.changePct.toFixed(1)}%)`;
|
||||
if (s.text) return `- NEW OSINT: ${s.text.substring(0, 120)}`;
|
||||
return `- ${s.label || JSON.stringify(s)}`;
|
||||
}).join('\n')}
|
||||
|
||||
Delta summary: direction=${delta.summary.direction}, total changes=${delta.summary.totalChanges}, critical=${delta.summary.criticalChanges}`;
|
||||
|
||||
try {
|
||||
const result = await llmProvider.complete(systemPrompt, userMessage, { maxTokens: 512, timeout: 30000 });
|
||||
const evaluation = parseEvaluation(result.text);
|
||||
|
||||
if (!evaluation?.shouldAlert) {
|
||||
console.log('[Telegram] LLM says no alert needed:', evaluation?.reason || 'unknown');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build and send alert message
|
||||
const message = formatAlertMessage(evaluation, delta);
|
||||
const sent = await this.sendAlert(message);
|
||||
|
||||
if (sent) {
|
||||
// Mark signals as alerted
|
||||
for (const s of newSignals) {
|
||||
const key = s.key || s.label || s.text?.substring(0, 40);
|
||||
memory.markAsAlerted(key, new Date().toISOString());
|
||||
}
|
||||
console.log('[Telegram] Alert sent:', evaluation.headline);
|
||||
}
|
||||
|
||||
return sent;
|
||||
} catch (err) {
|
||||
console.error('[Telegram] LLM evaluation failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseEvaluation(text) {
|
||||
if (!text) return null;
|
||||
let cleaned = text.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
const match = cleaned.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try { return JSON.parse(match[0]); } catch { /* give up */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAlertMessage(evaluation, delta) {
|
||||
const lines = [
|
||||
`🚨 *CRUCIX ALERT*`,
|
||||
``,
|
||||
`*${evaluation.headline}*`,
|
||||
``,
|
||||
evaluation.reason,
|
||||
``,
|
||||
`Direction: ${delta.summary.direction.toUpperCase()}`,
|
||||
`Critical changes: ${delta.summary.criticalChanges}`,
|
||||
];
|
||||
|
||||
if (evaluation.signals?.length) {
|
||||
lines.push('', `Key signals: ${evaluation.signals.join(', ')}`);
|
||||
}
|
||||
|
||||
lines.push('', `_${new Date().toLocaleTimeString()} UTC_`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
117
lib/delta/engine.mjs
Normal file
117
lib/delta/engine.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
// Delta Engine — compares two synthesized sweep results and produces structured changes
|
||||
|
||||
// Metrics we track for delta computation
|
||||
const NUMERIC_METRICS = [
|
||||
{ key: 'vix', extract: d => d.fred?.find(f => f.id === 'VIXCLS')?.value, label: 'VIX', threshold: 5 },
|
||||
{ key: 'hy_spread', extract: d => d.fred?.find(f => f.id === 'BAMLH0A0HYM2')?.value, label: 'HY Spread', threshold: 5 },
|
||||
{ key: '10y2y', extract: d => d.fred?.find(f => f.id === 'T10Y2Y')?.value, label: '10Y-2Y Spread', threshold: 10 },
|
||||
{ key: 'wti', extract: d => d.energy?.wti, label: 'WTI Crude', threshold: 3 },
|
||||
{ key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude', threshold: 3 },
|
||||
{ key: 'natgas', extract: d => d.energy?.natgas, label: 'Natural Gas', threshold: 5 },
|
||||
{ key: 'unemployment', extract: d => d.bls?.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE')?.value, label: 'Unemployment', threshold: 2 },
|
||||
{ key: 'fed_funds', extract: d => d.fred?.find(f => f.id === 'DFF')?.value, label: 'Fed Funds Rate', threshold: 1 },
|
||||
{ key: '10y_yield', extract: d => d.fred?.find(f => f.id === 'DGS10')?.value, label: '10Y Yield', threshold: 3 },
|
||||
{ key: 'usd_index', extract: d => d.fred?.find(f => f.id === 'DTWEXBGS')?.value, label: 'USD Index', threshold: 1 },
|
||||
{ key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage', threshold: 2 },
|
||||
];
|
||||
|
||||
const COUNT_METRICS = [
|
||||
{ key: 'urgent_posts', extract: d => d.tg?.urgent?.length || 0, label: 'Urgent OSINT Posts' },
|
||||
{ key: 'thermal_total', extract: d => d.thermal?.reduce((s, t) => s + t.det, 0) || 0, label: 'Thermal Detections' },
|
||||
{ key: 'air_total', extract: d => d.air?.reduce((s, a) => s + a.total, 0) || 0, label: 'Air Activity' },
|
||||
{ key: 'who_alerts', extract: d => d.who?.length || 0, label: 'WHO Alerts' },
|
||||
{ key: 'conflict_events', extract: d => d.acled?.totalEvents || 0, label: 'Conflict Events' },
|
||||
{ key: 'conflict_fatalities', extract: d => d.acled?.totalFatalities || 0, label: 'Conflict Fatalities' },
|
||||
{ key: 'sdr_online', extract: d => d.sdr?.online || 0, label: 'SDR Receivers' },
|
||||
{ key: 'news_count', extract: d => d.news?.length || 0, label: 'News Items' },
|
||||
{ key: 'sources_ok', extract: d => d.meta?.sourcesOk || 0, label: 'Sources OK' },
|
||||
];
|
||||
|
||||
export function computeDelta(current, previous) {
|
||||
if (!previous) return null;
|
||||
|
||||
const signals = { new: [], escalated: [], deescalated: [], unchanged: [] };
|
||||
let criticalChanges = 0;
|
||||
|
||||
// Numeric metrics: track % change
|
||||
for (const m of NUMERIC_METRICS) {
|
||||
const curr = m.extract(current);
|
||||
const prev = m.extract(previous);
|
||||
if (curr == null || prev == null) continue;
|
||||
|
||||
const pctChange = prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : 0;
|
||||
|
||||
if (Math.abs(pctChange) > m.threshold) {
|
||||
const entry = {
|
||||
key: m.key, label: m.label, from: prev, to: curr,
|
||||
pctChange: parseFloat(pctChange.toFixed(2)),
|
||||
direction: pctChange > 0 ? 'up' : 'down',
|
||||
};
|
||||
if (pctChange > 0) signals.escalated.push(entry);
|
||||
else signals.deescalated.push(entry);
|
||||
if (Math.abs(pctChange) > 10) criticalChanges++;
|
||||
} else {
|
||||
signals.unchanged.push(m.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Count metrics: track absolute change
|
||||
for (const m of COUNT_METRICS) {
|
||||
const curr = m.extract(current);
|
||||
const prev = m.extract(previous);
|
||||
const diff = curr - prev;
|
||||
|
||||
if (Math.abs(diff) > 0) {
|
||||
const entry = {
|
||||
key: m.key, label: m.label, from: prev, to: curr,
|
||||
change: diff, direction: diff > 0 ? 'up' : 'down',
|
||||
};
|
||||
if (diff > 0) signals.escalated.push(entry);
|
||||
else signals.deescalated.push(entry);
|
||||
} else {
|
||||
signals.unchanged.push(m.key);
|
||||
}
|
||||
}
|
||||
|
||||
// New urgent posts (check by text content)
|
||||
const prevUrgentTexts = new Set((previous.tg?.urgent || []).map(p => p.text?.substring(0, 60)));
|
||||
for (const post of (current.tg?.urgent || [])) {
|
||||
const key = post.text?.substring(0, 60);
|
||||
if (key && !prevUrgentTexts.has(key)) {
|
||||
signals.new.push({ key: 'tg_urgent', item: post, reason: 'New urgent OSINT post' });
|
||||
criticalChanges++;
|
||||
}
|
||||
}
|
||||
|
||||
// Nuclear anomaly change
|
||||
const currAnom = current.nuke?.some(n => n.anom) || false;
|
||||
const prevAnom = previous.nuke?.some(n => n.anom) || false;
|
||||
if (currAnom && !prevAnom) {
|
||||
signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected' });
|
||||
criticalChanges += 5; // Critical
|
||||
} else if (!currAnom && prevAnom) {
|
||||
signals.deescalated.push({ key: 'nuke_anomaly', label: 'Nuclear Anomaly', direction: 'resolved' });
|
||||
}
|
||||
|
||||
// Determine overall direction
|
||||
let direction = 'mixed';
|
||||
const riskUp = signals.escalated.filter(s =>
|
||||
['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'].includes(s.key)
|
||||
).length;
|
||||
const riskDown = signals.deescalated.filter(s =>
|
||||
['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'].includes(s.key)
|
||||
).length;
|
||||
if (riskUp > riskDown + 1) direction = 'risk-off';
|
||||
else if (riskDown > riskUp + 1) direction = 'risk-on';
|
||||
|
||||
return {
|
||||
timestamp: current.meta?.timestamp || new Date().toISOString(),
|
||||
previous: previous.meta?.timestamp || null,
|
||||
signals,
|
||||
summary: {
|
||||
totalChanges: signals.new.length + signals.escalated.length + signals.deescalated.length,
|
||||
criticalChanges,
|
||||
direction,
|
||||
},
|
||||
};
|
||||
}
|
||||
2
lib/delta/index.mjs
Normal file
2
lib/delta/index.mjs
Normal file
@@ -0,0 +1,2 @@
|
||||
export { computeDelta } from './engine.mjs';
|
||||
export { MemoryManager } from './memory.mjs';
|
||||
139
lib/delta/memory.mjs
Normal file
139
lib/delta/memory.mjs
Normal file
@@ -0,0 +1,139 @@
|
||||
// Memory Manager — hot/cold storage for sweep history and alert tracking
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { computeDelta } from './engine.mjs';
|
||||
|
||||
const MAX_HOT_RUNS = 3;
|
||||
|
||||
export class MemoryManager {
|
||||
constructor(runsDir) {
|
||||
this.runsDir = runsDir;
|
||||
this.memoryDir = join(runsDir, 'memory');
|
||||
this.hotPath = join(this.memoryDir, 'hot.json');
|
||||
this.coldDir = join(this.memoryDir, 'cold');
|
||||
|
||||
// Ensure dirs exist
|
||||
for (const dir of [this.memoryDir, this.coldDir]) {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Load hot memory from disk
|
||||
this.hot = this._loadHot();
|
||||
}
|
||||
|
||||
_loadHot() {
|
||||
try {
|
||||
return JSON.parse(readFileSync(this.hotPath, 'utf8'));
|
||||
} catch {
|
||||
return { runs: [], alertedSignals: {} };
|
||||
}
|
||||
}
|
||||
|
||||
_saveHot() {
|
||||
try {
|
||||
writeFileSync(this.hotPath, JSON.stringify(this.hot, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Memory] Failed to save hot memory:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new run to hot memory
|
||||
addRun(synthesizedData) {
|
||||
const previous = this.getLastRun();
|
||||
const delta = computeDelta(synthesizedData, previous);
|
||||
|
||||
// Compact the data for storage (strip large arrays)
|
||||
const compact = this._compactForStorage(synthesizedData);
|
||||
|
||||
this.hot.runs.unshift({
|
||||
timestamp: synthesizedData.meta?.timestamp || new Date().toISOString(),
|
||||
data: compact,
|
||||
delta,
|
||||
});
|
||||
|
||||
// Keep only MAX_HOT_RUNS
|
||||
if (this.hot.runs.length > MAX_HOT_RUNS) {
|
||||
const archived = this.hot.runs.splice(MAX_HOT_RUNS);
|
||||
this._archiveToCold(archived);
|
||||
}
|
||||
|
||||
this._saveHot();
|
||||
return delta;
|
||||
}
|
||||
|
||||
// Get last run's synthesized data
|
||||
getLastRun() {
|
||||
if (this.hot.runs.length === 0) return null;
|
||||
return this.hot.runs[0].data;
|
||||
}
|
||||
|
||||
// Get last N runs
|
||||
getRunHistory(n = 3) {
|
||||
return this.hot.runs.slice(0, n);
|
||||
}
|
||||
|
||||
// Get the delta from the most recent run
|
||||
getLastDelta() {
|
||||
if (this.hot.runs.length === 0) return null;
|
||||
return this.hot.runs[0].delta;
|
||||
}
|
||||
|
||||
// Track what signals have been alerted on
|
||||
getAlertedSignals() {
|
||||
return this.hot.alertedSignals || {};
|
||||
}
|
||||
|
||||
markAsAlerted(signalKey, timestamp) {
|
||||
this.hot.alertedSignals[signalKey] = timestamp || new Date().toISOString();
|
||||
this._saveHot();
|
||||
}
|
||||
|
||||
// Clean up old alerted signals (older than 24h)
|
||||
pruneAlertedSignals() {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
for (const [key, ts] of Object.entries(this.hot.alertedSignals)) {
|
||||
if (new Date(ts).getTime() < cutoff) {
|
||||
delete this.hot.alertedSignals[key];
|
||||
}
|
||||
}
|
||||
this._saveHot();
|
||||
}
|
||||
|
||||
// Compact data for storage — strip heavy arrays
|
||||
_compactForStorage(data) {
|
||||
return {
|
||||
meta: data.meta,
|
||||
fred: data.fred,
|
||||
energy: data.energy,
|
||||
bls: data.bls,
|
||||
treasury: data.treasury,
|
||||
gscpi: data.gscpi,
|
||||
tg: { posts: data.tg?.posts, urgent: (data.tg?.urgent || []).map(p => ({ text: p.text?.substring(0, 80), date: p.date })) },
|
||||
thermal: (data.thermal || []).map(t => ({ region: t.region, det: t.det, night: t.night, hc: t.hc })),
|
||||
air: (data.air || []).map(a => ({ region: a.region, total: a.total })),
|
||||
nuke: (data.nuke || []).map(n => ({ site: n.site, anom: n.anom, cpm: n.cpm })),
|
||||
who: (data.who || []).map(w => ({ title: w.title })),
|
||||
acled: { totalEvents: data.acled?.totalEvents, totalFatalities: data.acled?.totalFatalities },
|
||||
sdr: { total: data.sdr?.total, online: data.sdr?.online },
|
||||
ideas: (data.ideas || []).map(i => ({ title: i.title, type: i.type, confidence: i.confidence })),
|
||||
};
|
||||
}
|
||||
|
||||
// Archive old runs to cold storage
|
||||
_archiveToCold(runs) {
|
||||
if (runs.length === 0) return;
|
||||
const dateKey = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const coldPath = join(this.coldDir, `${dateKey}.json`);
|
||||
|
||||
let existing = [];
|
||||
try { existing = JSON.parse(readFileSync(coldPath, 'utf8')); } catch { }
|
||||
|
||||
existing.push(...runs);
|
||||
try {
|
||||
writeFileSync(coldPath, JSON.stringify(existing, null, 2));
|
||||
} catch (err) {
|
||||
console.error('[Memory] Failed to archive to cold storage:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
lib/llm/anthropic.mjs
Normal file
49
lib/llm/anthropic.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
// Anthropic Claude Provider — raw fetch, no SDK
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
export class AnthropicProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'anthropic';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'claude-sonnet-4-20250514';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: opts.maxTokens || 4096,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(opts.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`Anthropic API ${res.status}: ${err.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data.content?.[0]?.text || '';
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: data.usage?.input_tokens || 0,
|
||||
outputTokens: data.usage?.output_tokens || 0,
|
||||
},
|
||||
model: data.model || this.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
147
lib/llm/codex.mjs
Normal file
147
lib/llm/codex.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
// OpenAI Codex Provider — uses ChatGPT subscription via chatgpt.com/backend-api/codex/responses
|
||||
// Auth: reads ~/.codex/auth.json (created by `npx @openai/codex login`)
|
||||
// SSE streaming, codex-specific models only (gpt-5.2-codex, gpt-5.3-codex)
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
const CODEX_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses';
|
||||
const AUTH_PATH = join(homedir(), '.codex', 'auth.json');
|
||||
|
||||
export class CodexProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'codex';
|
||||
this.model = config.model || 'gpt-5.2-codex';
|
||||
this._creds = null;
|
||||
}
|
||||
|
||||
get isConfigured() {
|
||||
return !!this._getCredentials();
|
||||
}
|
||||
|
||||
_getCredentials() {
|
||||
if (this._creds) return this._creds;
|
||||
|
||||
// Try env vars first
|
||||
const token = process.env.CODEX_ACCESS_TOKEN || process.env.OPENAI_OAUTH_TOKEN;
|
||||
const accountId = process.env.CODEX_ACCOUNT_ID;
|
||||
if (token && accountId) {
|
||||
this._creds = { accessToken: token, accountId };
|
||||
return this._creds;
|
||||
}
|
||||
|
||||
// Try ~/.codex/auth.json
|
||||
try {
|
||||
const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8'));
|
||||
// Tokens may be nested under auth.tokens (newer format) or top-level
|
||||
const tokens = auth.tokens || auth;
|
||||
const accessToken = tokens.access_token || tokens.token || auth.access_token || auth.token;
|
||||
if (accessToken) {
|
||||
this._creds = {
|
||||
accessToken,
|
||||
accountId: tokens.account_id || auth.account_id || accountId || '',
|
||||
};
|
||||
return this._creds;
|
||||
}
|
||||
} catch { /* no auth file */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_clearCredentials() {
|
||||
this._creds = null;
|
||||
}
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const creds = this._getCredentials();
|
||||
if (!creds) throw new Error('Codex: No credentials found. Run `npx @openai/codex login`');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${creds.accessToken}`,
|
||||
};
|
||||
if (creds.accountId) headers['ChatGPT-Account-Id'] = creds.accountId;
|
||||
|
||||
const body = {
|
||||
model: this.model,
|
||||
instructions: systemPrompt || '',
|
||||
input: [{ type: 'message', role: 'user', content: userMessage }],
|
||||
stream: true,
|
||||
store: false,
|
||||
};
|
||||
|
||||
const res = await fetch(CODEX_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(opts.timeout || 90000),
|
||||
});
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
this._clearCredentials();
|
||||
throw new Error(`Codex auth failed (${res.status}). Run \`npx @openai/codex login\` to refresh.`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`Codex API ${res.status}: ${err.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
// Parse SSE stream
|
||||
const text = await this._parseSSE(res);
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: { inputTokens: 0, outputTokens: 0 }, // Codex doesn't always return usage
|
||||
model: this.model,
|
||||
};
|
||||
}
|
||||
|
||||
async _parseSSE(res) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === '[DONE]') return text;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload);
|
||||
// Handle text deltas
|
||||
if (event.type === 'response.output_text.delta') {
|
||||
text += event.delta || '';
|
||||
}
|
||||
// Handle completed response
|
||||
if (event.type === 'response.completed') {
|
||||
const output = event.response?.output;
|
||||
if (output && Array.isArray(output)) {
|
||||
for (const item of output) {
|
||||
if (item.type === 'message' && item.content) {
|
||||
for (const part of item.content) {
|
||||
if (part.type === 'output_text') text = part.text || text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* skip malformed events */ }
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
48
lib/llm/gemini.mjs
Normal file
48
lib/llm/gemini.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
// Google Gemini Provider — raw fetch, no SDK
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
export class GeminiProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'gemini';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'gemini-2.0-flash';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent?key=${this.apiKey}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents: [{ parts: [{ text: userMessage }] }],
|
||||
generationConfig: {
|
||||
maxOutputTokens: opts.maxTokens || 4096,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(opts.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`Gemini API ${res.status}: ${err.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: data.usageMetadata?.promptTokenCount || 0,
|
||||
outputTokens: data.usageMetadata?.candidatesTokenCount || 0,
|
||||
},
|
||||
model: this.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
lib/llm/ideas.mjs
Normal file
189
lib/llm/ideas.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
// LLM-Powered Trade Ideas — generates actionable ideas from sweep data + delta context
|
||||
|
||||
/**
|
||||
* Generate LLM-enhanced trade ideas from sweep data.
|
||||
* @param {LLMProvider} provider - configured LLM provider
|
||||
* @param {object} sweepData - synthesized dashboard data
|
||||
* @param {object|null} delta - delta from last sweep
|
||||
* @param {Array} previousIdeas - ideas from previous runs (for dedup)
|
||||
* @returns {Promise<Array>} - array of idea objects
|
||||
*/
|
||||
export async function generateLLMIdeas(provider, sweepData, delta, previousIdeas = []) {
|
||||
if (!provider?.isConfigured) return null;
|
||||
|
||||
let context;
|
||||
try {
|
||||
context = compactSweepForLLM(sweepData, delta, previousIdeas);
|
||||
} catch (err) {
|
||||
console.error('[LLM Ideas] Failed to compact sweep data:', err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
const systemPrompt = `You are a quantitative analyst at a macro intelligence firm. You receive structured OSINT + economic data from 25 sources and produce 5-8 actionable trade ideas.
|
||||
|
||||
Rules:
|
||||
- Each idea must cite specific data points from the input
|
||||
- Include entry rationale, risk factors, and time horizon
|
||||
- Blend geopolitical, economic, and market signals — cross-correlate across domains
|
||||
- Be specific: name instruments (tickers, futures, ETFs), not vague sectors
|
||||
- If delta shows significant changes, lead with those
|
||||
- Do NOT repeat ideas from the "previous ideas" list unless conditions have materially changed
|
||||
- Rate confidence: HIGH (multiple confirming signals), MEDIUM (thesis supported), LOW (speculative)
|
||||
|
||||
Output ONLY valid JSON array. Each object:
|
||||
{
|
||||
"title": "Short title (max 10 words)",
|
||||
"type": "LONG|SHORT|HEDGE|WATCH|AVOID",
|
||||
"ticker": "Primary instrument",
|
||||
"confidence": "HIGH|MEDIUM|LOW",
|
||||
"rationale": "2-3 sentence explanation citing specific data",
|
||||
"risk": "Key risk factor",
|
||||
"horizon": "Intraday|Days|Weeks|Months",
|
||||
"signals": ["signal1", "signal2"]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const result = await provider.complete(systemPrompt, context, { maxTokens: 4096, timeout: 90000 });
|
||||
const ideas = parseIdeasResponse(result.text);
|
||||
if (ideas && ideas.length > 0) {
|
||||
return ideas;
|
||||
}
|
||||
console.warn('[LLM Ideas] No valid ideas parsed from response');
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[LLM Ideas] Generation failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sweep data to ~8KB for token efficiency.
|
||||
*/
|
||||
function compactSweepForLLM(data, delta, previousIdeas) {
|
||||
const sections = [];
|
||||
|
||||
// Economic indicators
|
||||
if (data.fred?.length) {
|
||||
const key = data.fred.filter(f => ['VIXCLS', 'DFF', 'DGS10', 'DGS2', 'T10Y2Y', 'BAMLH0A0HYM2', 'DTWEXBGS', 'MORTGAGE30US'].includes(f.id));
|
||||
sections.push(`ECONOMIC: ${key.map(f => `${f.id}=${f.value}${f.momChange ? ` (${f.momChange > 0 ? '+' : ''}${f.momChange})` : ''}`).join(', ')}`);
|
||||
}
|
||||
|
||||
// Energy
|
||||
if (data.energy) {
|
||||
sections.push(`ENERGY: WTI=$${data.energy.wti}, Brent=$${data.energy.brent}, NatGas=$${data.energy.natgas}, CrudeStocks=${data.energy.crudeStocks}bbl`);
|
||||
}
|
||||
|
||||
// BLS
|
||||
if (data.bls?.length) {
|
||||
sections.push(`LABOR: ${data.bls.map(b => `${b.id}=${b.value}`).join(', ')}`);
|
||||
}
|
||||
|
||||
// Treasury
|
||||
if (data.treasury) {
|
||||
sections.push(`TREASURY: totalDebt=$${data.treasury}T`);
|
||||
}
|
||||
|
||||
// Supply chain
|
||||
if (data.gscpi) {
|
||||
sections.push(`SUPPLY_CHAIN: GSCPI=${data.gscpi.value} (${data.gscpi.interpretation})`);
|
||||
}
|
||||
|
||||
// Geopolitical signals
|
||||
const urgentPosts = (data.tg?.urgent || []).slice(0, 5);
|
||||
if (urgentPosts.length) {
|
||||
sections.push(`URGENT_OSINT:\n${urgentPosts.map(p => `- ${(p.text || '').substring(0, 120)}`).join('\n')}`);
|
||||
}
|
||||
|
||||
// Thermal / fire detections
|
||||
if (data.thermal?.length) {
|
||||
const hotRegions = data.thermal.filter(t => t.det > 10).map(t => `${t.region}: ${t.det} detections (${t.hc} high-conf)`);
|
||||
if (hotRegions.length) sections.push(`THERMAL: ${hotRegions.join(', ')}`);
|
||||
}
|
||||
|
||||
// Air activity
|
||||
if (data.air?.length) {
|
||||
const airSum = data.air.map(a => `${a.region}: ${a.total} aircraft`);
|
||||
sections.push(`AIR_ACTIVITY: ${airSum.join(', ')}`);
|
||||
}
|
||||
|
||||
// Nuclear
|
||||
if (data.nuke?.length) {
|
||||
const anomalies = data.nuke.filter(n => n.anom);
|
||||
if (anomalies.length) sections.push(`NUCLEAR_ANOMALY: ${anomalies.map(n => `${n.site}: ${n.cpm}cpm`).join(', ')}`);
|
||||
}
|
||||
|
||||
// WHO alerts
|
||||
if (data.who?.length) {
|
||||
sections.push(`WHO_ALERTS: ${data.who.slice(0, 3).map(w => w.title).join('; ')}`);
|
||||
}
|
||||
|
||||
// Defense spending
|
||||
if (data.defense?.length) {
|
||||
const topContracts = data.defense.slice(0, 3).map(d => `$${((d.amount || 0) / 1e6).toFixed(0)}M to ${d.recipient}`);
|
||||
sections.push(`DEFENSE_CONTRACTS: ${topContracts.join(', ')}`);
|
||||
}
|
||||
|
||||
// Delta context
|
||||
if (delta?.summary) {
|
||||
sections.push(`\nDELTA_SINCE_LAST_SWEEP: direction=${delta.summary.direction}, changes=${delta.summary.totalChanges}, critical=${delta.summary.criticalChanges}`);
|
||||
if (delta.signals?.escalated?.length) {
|
||||
sections.push(`ESCALATED: ${delta.signals.escalated.map(s => `${s.label}: ${s.previous}→${s.current} (${(s.changePct||0) > 0 ? '+' : ''}${(s.changePct||0).toFixed(1)}%)`).join(', ')}`);
|
||||
}
|
||||
if (delta.signals?.new?.length) {
|
||||
sections.push(`NEW_SIGNALS: ${delta.signals.new.map(s => s.label || s.text?.substring(0, 60)).join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Previous ideas (for dedup)
|
||||
if (previousIdeas.length) {
|
||||
sections.push(`\nPREVIOUS_IDEAS (avoid repeating):\n${previousIdeas.map(i => `- ${i.title} [${i.type}]`).join('\n')}`);
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM response into ideas array. Handles markdown code blocks.
|
||||
*/
|
||||
function parseIdeasResponse(text) {
|
||||
if (!text) return null;
|
||||
|
||||
// Strip markdown code block wrappers
|
||||
let cleaned = text.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
|
||||
// Validate each idea has required fields
|
||||
return parsed.filter(idea =>
|
||||
idea.title && idea.type && idea.confidence
|
||||
).map(idea => ({
|
||||
title: idea.title,
|
||||
type: idea.type,
|
||||
ticker: idea.ticker || '',
|
||||
confidence: idea.confidence,
|
||||
rationale: idea.rationale || '',
|
||||
risk: idea.risk || '',
|
||||
horizon: idea.horizon || '',
|
||||
signals: idea.signals || [],
|
||||
source: 'llm',
|
||||
}));
|
||||
} catch {
|
||||
// Try to extract JSON array from mixed text
|
||||
const match = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (match) {
|
||||
try {
|
||||
const arr = JSON.parse(match[0]);
|
||||
return arr.filter(i => i.title && i.type).map(idea => ({
|
||||
...idea,
|
||||
source: 'llm',
|
||||
}));
|
||||
} catch { /* give up */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
37
lib/llm/index.mjs
Normal file
37
lib/llm/index.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
// LLM Factory — creates the configured provider or returns null
|
||||
|
||||
import { AnthropicProvider } from './anthropic.mjs';
|
||||
import { OpenAIProvider } from './openai.mjs';
|
||||
import { GeminiProvider } from './gemini.mjs';
|
||||
import { CodexProvider } from './codex.mjs';
|
||||
|
||||
export { LLMProvider } from './provider.mjs';
|
||||
export { AnthropicProvider } from './anthropic.mjs';
|
||||
export { OpenAIProvider } from './openai.mjs';
|
||||
export { GeminiProvider } from './gemini.mjs';
|
||||
export { CodexProvider } from './codex.mjs';
|
||||
|
||||
/**
|
||||
* Create an LLM provider based on config.
|
||||
* @param {{ provider: string|null, apiKey: string|null, model: string|null }} llmConfig
|
||||
* @returns {LLMProvider|null}
|
||||
*/
|
||||
export function createLLMProvider(llmConfig) {
|
||||
if (!llmConfig?.provider) return null;
|
||||
|
||||
const { provider, apiKey, model } = llmConfig;
|
||||
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'anthropic':
|
||||
return new AnthropicProvider({ apiKey, model });
|
||||
case 'openai':
|
||||
return new OpenAIProvider({ apiKey, model });
|
||||
case 'gemini':
|
||||
return new GeminiProvider({ apiKey, model });
|
||||
case 'codex':
|
||||
return new CodexProvider({ model });
|
||||
default:
|
||||
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
50
lib/llm/openai.mjs
Normal file
50
lib/llm/openai.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// OpenAI Provider — raw fetch, no SDK
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
export class OpenAIProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'openai';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'gpt-4o';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch('https://api.openai.com/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(`OpenAI 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
18
lib/llm/provider.mjs
Normal file
18
lib/llm/provider.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
// Base LLM Provider — all providers implement this interface
|
||||
|
||||
export class LLMProvider {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.name = 'base';
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a prompt with system + user messages
|
||||
* @returns {{ text: string, usage: { inputTokens: number, outputTokens: number }, model: string }}
|
||||
*/
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
throw new Error(`${this.name}: complete() not implemented`);
|
||||
}
|
||||
|
||||
get isConfigured() { return false; }
|
||||
}
|
||||
Reference in New Issue
Block a user