feat: harden intelligence runtime and llm providers
This commit is contained in:
@@ -59,9 +59,6 @@ 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;
|
||||
@@ -71,9 +68,10 @@ export class DiscordAlerter {
|
||||
// Connect
|
||||
await this._client.login(this.botToken);
|
||||
|
||||
this._client.once('ready', () => {
|
||||
this._client.once('clientReady', async () => {
|
||||
this._ready = true;
|
||||
console.log(`[Discord] Bot online as ${this._client.user.tag}`);
|
||||
await this._registerCommands(REST, Routes, SlashCommandBuilder);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
@@ -123,11 +121,13 @@ export class DiscordAlerter {
|
||||
try {
|
||||
if (this.guildId) {
|
||||
// Guild commands (instant, for development)
|
||||
await rest.put(Routes.applicationGuildCommands(this._client?.user?.id || 'me', this.guildId), { body: commands });
|
||||
const appId = this._client?.application?.id || this._client?.user?.id;
|
||||
if (!appId) throw new Error('Discord application id unavailable after login');
|
||||
await rest.put(Routes.applicationGuildCommands(appId, this.guildId), { body: commands });
|
||||
console.log(`[Discord] Registered ${commands.length} guild slash commands`);
|
||||
} else {
|
||||
// Global commands (can take up to 1h to propagate)
|
||||
const appId = this._client?.application?.id;
|
||||
const appId = this._client?.application?.id || this._client?.user?.id;
|
||||
if (appId) {
|
||||
await rest.put(Routes.applicationCommands(appId), { body: commands });
|
||||
console.log(`[Discord] Registered ${commands.length} global slash commands`);
|
||||
|
||||
@@ -41,6 +41,8 @@ export class TelegramAlerter {
|
||||
this._commandHandlers = {}; // Registered command callbacks
|
||||
this._pollingInterval = null;
|
||||
this._botUsername = null;
|
||||
this._pollFailureCount = 0;
|
||||
this._lastPollErrorLogAt = 0;
|
||||
}
|
||||
|
||||
get isConfigured() {
|
||||
@@ -353,6 +355,7 @@ export class TelegramAlerter {
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.ok || !Array.isArray(data.result)) return;
|
||||
this._pollFailureCount = 0;
|
||||
|
||||
for (const update of data.result) {
|
||||
this._lastUpdateId = Math.max(this._lastUpdateId, update.update_id);
|
||||
@@ -366,9 +369,14 @@ export class TelegramAlerter {
|
||||
await this._handleMessage(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
// Silent — polling failures are non-fatal
|
||||
this._pollFailureCount++;
|
||||
if (!err.message?.includes('aborted')) {
|
||||
console.error('[Telegram] Poll error:', err.message);
|
||||
const now = Date.now();
|
||||
const quietMs = Math.min(300000, 30000 * this._pollFailureCount);
|
||||
if (now - this._lastPollErrorLogAt > quietMs) {
|
||||
this._lastPollErrorLogAt = now;
|
||||
console.error(`[Telegram] Poll degraded (${this._pollFailureCount} consecutive failures):`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
112
lib/intelligence-store.mjs
Normal file
112
lib/intelligence-store.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
// Phase-1 intelligence memory. Uses node:sqlite when available and degrades to no-op.
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
export class IntelligenceStore {
|
||||
constructor(dbPath) {
|
||||
this.dbPath = dbPath;
|
||||
this.db = null;
|
||||
this.available = false;
|
||||
this.reason = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const dir = dirname(this.dbPath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
try {
|
||||
const sqlite = await import('node:sqlite');
|
||||
const DatabaseSync = sqlite.DatabaseSync;
|
||||
this.db = new DatabaseSync(this.dbPath);
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
sources_ok INTEGER DEFAULT 0,
|
||||
sources_degraded INTEGER DEFAULT 0,
|
||||
sources_failed INTEGER DEFAULT 0,
|
||||
direction TEXT,
|
||||
summary_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS predictions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT,
|
||||
confidence TEXT,
|
||||
source TEXT,
|
||||
payload_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
UNIQUE(name, kind)
|
||||
);
|
||||
`);
|
||||
this.available = true;
|
||||
} catch (err) {
|
||||
this.available = false;
|
||||
this.reason = err.message;
|
||||
if (!existsSync(this.dbPath)) {
|
||||
writeFileSync(this.dbPath, '');
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
recordRun(data, delta) {
|
||||
if (!this.available || !this.db) return;
|
||||
const meta = data.meta || {};
|
||||
const timestamp = meta.timestamp || new Date().toISOString();
|
||||
this.db.prepare(`INSERT INTO runs (timestamp, sources_ok, sources_degraded, sources_failed, direction, summary_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(
|
||||
timestamp,
|
||||
meta.sourcesOk || 0,
|
||||
meta.sourcesDegraded || 0,
|
||||
meta.sourcesFailed || 0,
|
||||
delta?.summary?.direction || null,
|
||||
JSON.stringify({ meta, delta: delta?.summary || null }),
|
||||
);
|
||||
for (const idea of data.ideas || []) {
|
||||
this.db.prepare(`INSERT INTO predictions (created_at, title, type, confidence, source, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run(
|
||||
timestamp,
|
||||
idea.title || 'Untitled idea',
|
||||
idea.type || null,
|
||||
idea.confidence || null,
|
||||
idea.source || data.ideasSource || null,
|
||||
JSON.stringify(idea),
|
||||
);
|
||||
}
|
||||
this._recordEntities(data, timestamp);
|
||||
}
|
||||
|
||||
status() {
|
||||
return { available: this.available, path: this.dbPath, reason: this.reason };
|
||||
}
|
||||
|
||||
_recordEntities(data, timestamp) {
|
||||
const names = [];
|
||||
for (const item of data.acled?.deadliestEvents || []) {
|
||||
if (item.country) names.push([item.country, 'country']);
|
||||
if (item.location) names.push([item.location, 'location']);
|
||||
}
|
||||
for (const item of data.news || []) {
|
||||
if (item.region) names.push([item.region, 'region']);
|
||||
}
|
||||
for (const [name, kind] of names.slice(0, 200)) {
|
||||
this.db.prepare(`INSERT INTO entities (first_seen, last_seen, name, kind, count)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
ON CONFLICT(name, kind) DO UPDATE SET last_seen=excluded.last_seen, count=count+1`).run(
|
||||
timestamp,
|
||||
timestamp,
|
||||
String(name).slice(0, 160),
|
||||
kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { MiniMaxProvider } from "./minimax.mjs";
|
||||
import { MistralProvider } from "./mistral.mjs";
|
||||
import { OllamaProvider } from "./ollama.mjs";
|
||||
import { GrokProvider } from "./grok.mjs";
|
||||
import { OpenAICompatibleProvider } from "./openai-compatible.mjs";
|
||||
|
||||
export { LLMProvider } from "./provider.mjs";
|
||||
export { AnthropicProvider } from "./anthropic.mjs";
|
||||
@@ -20,6 +21,7 @@ export { MiniMaxProvider } from "./minimax.mjs";
|
||||
export { MistralProvider } from "./mistral.mjs";
|
||||
export { OllamaProvider } from "./ollama.mjs";
|
||||
export { GrokProvider } from "./grok.mjs";
|
||||
export { OpenAICompatibleProvider } from "./openai-compatible.mjs";
|
||||
|
||||
/**
|
||||
* Create an LLM provider based on config.
|
||||
@@ -30,14 +32,42 @@ export function createLLMProvider(llmConfig) {
|
||||
if (!llmConfig?.provider) return null;
|
||||
|
||||
const { provider, apiKey, model } = llmConfig;
|
||||
const common = {
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl: llmConfig.baseUrl,
|
||||
temperature: llmConfig.temperature,
|
||||
maxTokens: llmConfig.maxTokens,
|
||||
timeoutMs: llmConfig.timeoutMs,
|
||||
openRouterSiteUrl: llmConfig.openRouterSiteUrl,
|
||||
openRouterAppName: llmConfig.openRouterAppName,
|
||||
};
|
||||
|
||||
switch (provider.toLowerCase()) {
|
||||
case "anthropic":
|
||||
return new AnthropicProvider({ apiKey, model });
|
||||
case "openai":
|
||||
return new OpenAIProvider({ apiKey, model });
|
||||
return new OpenAIProvider(common);
|
||||
case "openai-compatible":
|
||||
case "compatible":
|
||||
case "local-openai":
|
||||
return new OpenAICompatibleProvider({
|
||||
...common,
|
||||
name: provider.toLowerCase(),
|
||||
model: model || 'local-model',
|
||||
requiresApiKey: Boolean(apiKey),
|
||||
});
|
||||
case "lmstudio":
|
||||
case "lm-studio":
|
||||
return new OpenAICompatibleProvider({
|
||||
...common,
|
||||
name: 'lmstudio',
|
||||
baseUrl: llmConfig.baseUrl || 'http://localhost:1234/v1',
|
||||
model: model || 'local-model',
|
||||
requiresApiKey: false,
|
||||
});
|
||||
case "openrouter":
|
||||
return new OpenRouterProvider({ apiKey, model });
|
||||
return new OpenRouterProvider(common);
|
||||
case "gemini":
|
||||
return new GeminiProvider({ apiKey, model });
|
||||
case "codex":
|
||||
@@ -47,7 +77,7 @@ export function createLLMProvider(llmConfig) {
|
||||
case "mistral":
|
||||
return new MistralProvider({ apiKey, model });
|
||||
case "ollama":
|
||||
return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl });
|
||||
return new OllamaProvider(common);
|
||||
case 'grok':
|
||||
return new GrokProvider({ apiKey, model });
|
||||
default:
|
||||
|
||||
@@ -1,49 +1,20 @@
|
||||
// Ollama Provider — raw fetch, no SDK
|
||||
// Uses Ollama's OpenAI-compatible Chat Completions API
|
||||
// No API key required — fully local inference
|
||||
// Ollama Provider — OpenAI-compatible Chat Completions API
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
import { OpenAICompatibleProvider } from './openai-compatible.mjs';
|
||||
|
||||
export class OllamaProvider extends LLMProvider {
|
||||
export class OllamaProvider extends OpenAICompatibleProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'ollama';
|
||||
this.baseUrl = (config.baseUrl || 'http://localhost:11434').replace(/\/+$/, '');
|
||||
this.model = config.model || 'llama3.1:8b';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.model; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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 || 120000),
|
||||
const rawBaseUrl = config.baseUrl || 'http://localhost:11434';
|
||||
const baseUrl = rawBaseUrl.replace(/\/+$/, '').endsWith('/v1')
|
||||
? rawBaseUrl
|
||||
: `${rawBaseUrl.replace(/\/+$/, '')}/v1`;
|
||||
super({
|
||||
...config,
|
||||
name: 'ollama',
|
||||
baseUrl,
|
||||
model: config.model || 'llama3.1:8b',
|
||||
requiresApiKey: false,
|
||||
timeoutMs: config.timeoutMs || 120000,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`Ollama 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
77
lib/llm/openai-compatible.mjs
Normal file
77
lib/llm/openai-compatible.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
// OpenAI-compatible provider for OpenRouter, LM Studio, Ollama, Groq, xAI, and local endpoints.
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
||||
|
||||
export class OpenAICompatibleProvider extends LLMProvider {
|
||||
constructor(config = {}) {
|
||||
super(config);
|
||||
this.name = config.name || 'openai-compatible';
|
||||
this.apiKey = config.apiKey || null;
|
||||
this.baseUrl = (config.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.model = config.model || 'gpt-4o-mini';
|
||||
this.temperature = config.temperature ?? 0.2;
|
||||
this.maxTokens = config.maxTokens || 2000;
|
||||
this.timeoutMs = config.timeoutMs || 90000;
|
||||
this.extraHeaders = config.extraHeaders || {};
|
||||
this.requiresApiKey = config.requiresApiKey ?? true;
|
||||
this.useMaxCompletionTokens = Boolean(config.useMaxCompletionTokens);
|
||||
}
|
||||
|
||||
get isConfigured() {
|
||||
return Boolean(this.model && (!this.requiresApiKey || this.apiKey));
|
||||
}
|
||||
|
||||
get status() {
|
||||
if (!this.model) return { state: 'misconfigured', reason: 'LLM_MODEL is required' };
|
||||
if (this.requiresApiKey && !this.apiKey) return { state: 'misconfigured', reason: 'LLM_API_KEY is required' };
|
||||
return { state: 'configured', provider: this.name, model: this.model, baseUrl: this.baseUrl };
|
||||
}
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const maxTokens = opts.maxTokens || this.maxTokens;
|
||||
const timeout = opts.timeout || this.timeoutMs;
|
||||
const body = {
|
||||
model: this.model,
|
||||
temperature: opts.temperature ?? this.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
};
|
||||
body[this.useMaxCompletionTokens ? 'max_completion_tokens' : 'max_tokens'] = maxTokens;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...this.extraHeaders,
|
||||
};
|
||||
if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
const detail = err.substring(0, 300);
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`${this.name} auth failed (${res.status}): ${detail}`);
|
||||
if (res.status === 408 || res.status === 429) throw new Error(`${this.name} rate limited or timed out (${res.status}): ${detail}`);
|
||||
throw new Error(`${this.name} API ${res.status}: ${detail}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data.choices?.[0]?.message?.content || data.choices?.[0]?.text || '';
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: data.usage?.prompt_tokens || data.usage?.input_tokens || 0,
|
||||
outputTokens: data.usage?.completion_tokens || data.usage?.output_tokens || 0,
|
||||
},
|
||||
model: data.model || this.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,15 @@
|
||||
// OpenAI Provider — raw fetch, no SDK
|
||||
// OpenAI Provider — OpenAI-compatible chat completions
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
import { OpenAICompatibleProvider } from './openai-compatible.mjs';
|
||||
|
||||
export class OpenAIProvider extends LLMProvider {
|
||||
export class OpenAIProvider extends OpenAICompatibleProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'openai';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'gpt-5.4';
|
||||
}
|
||||
|
||||
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_completion_tokens: opts.maxTokens || 4096,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(opts.timeout || 60000),
|
||||
super({
|
||||
...config,
|
||||
name: 'openai',
|
||||
baseUrl: config.baseUrl || 'https://api.openai.com/v1',
|
||||
model: config.model || 'gpt-4o-mini',
|
||||
useMaxCompletionTokens: true,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,20 @@
|
||||
// OpenRouter Provider — raw fetch, no SDK
|
||||
// OpenRouter Provider — OpenAI-compatible chat completions
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
import { OpenAICompatibleProvider } from './openai-compatible.mjs';
|
||||
|
||||
export class OpenRouterProvider extends LLMProvider {
|
||||
export class OpenRouterProvider extends OpenAICompatibleProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'openrouter';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'openrouter/auto';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
super({
|
||||
...config,
|
||||
name: 'openrouter',
|
||||
baseUrl: config.baseUrl || 'https://openrouter.ai/api/v1',
|
||||
model: config.model || 'openrouter/free',
|
||||
extraHeaders: {
|
||||
'HTTP-Referer': 'https://github.com/calesthio/Crucix',
|
||||
'X-Title': 'Crucix',
|
||||
...(config.openRouterSiteUrl ? { 'HTTP-Referer': config.openRouterSiteUrl } : {}),
|
||||
...(config.openRouterAppName ? { 'X-Title': config.openRouterAppName } : {}),
|
||||
},
|
||||
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(`OpenRouter 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user