UNPKG

overmind-mcp

Version:

Orchestrateur universel agents IA multi-modeles via MCP. Inclut le protocole 'Custom-Nickname' pour identifier vos agents avec des surnoms originaux (The Chaos Prophet, Shadow Sniper, etc.), l'isolation mémoire (Private Memory Context) et le support pour

313 lines (311 loc) • 13.7 kB
/** * ╔══════════════════════════════════════════════════════════════════════╗ * ║ OVERMIND BRIDGE — MessageLog (Postgres Persistence) ║ * ║ ║ * ║ Persiste tous les messages agent↔agent et client→agent dans ║ * ║ Postgres pour audit, replay après crash, et statistiques. ║ * ║ ║ * ║ TABLE ║ * ║ ────── ║ * ║ bridge_messages ║ * ║ id UUID PK ║ * ║ from_agent TEXT NULL (NULL = client externe) ║ * ║ to_agent TEXT NOT NULL ║ * ║ runner TEXT NOT NULL ║ * ║ prompt TEXT NOT NULL ║ * ║ response TEXT NULL ║ * ║ status TEXT NOT NULL (pending|running|done|failed) ║ * ║ session_id TEXT NULL ║ * ║ metadata JSONB NULL (path, model, mode, discord ctx) ║ * ║ error TEXT NULL ║ * ║ created_at TIMESTAMPTZ ║ * ║ started_at TIMESTAMPTZ NULL ║ * ║ completed_at TIMESTAMPTZ NULL ║ * ║ duration_ms INTEGER NULL ║ * ╚══════════════════════════════════════════════════════════════════════╝ */ import pg from 'pg'; import { createBridgeLogger } from './utils.js'; // ─── Schema SQL ─────────────────────────────────────────────────────────── const SCHEMA_SQL = ` CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE TABLE IF NOT EXISTS bridge_messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), from_agent TEXT NULL, to_agent TEXT NOT NULL, runner TEXT NOT NULL, prompt TEXT NOT NULL, response TEXT NULL, status TEXT NOT NULL DEFAULT 'pending', session_id TEXT NULL, metadata JSONB NULL, error TEXT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ NULL, completed_at TIMESTAMPTZ NULL, duration_ms INTEGER NULL ); CREATE INDEX IF NOT EXISTS idx_bridge_messages_status ON bridge_messages(status); CREATE INDEX IF NOT EXISTS idx_bridge_messages_to_agent ON bridge_messages(to_agent, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bridge_messages_from_agent ON bridge_messages(from_agent, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bridge_messages_session ON bridge_messages(session_id); CREATE INDEX IF NOT EXISTS idx_bridge_messages_created ON bridge_messages(created_at DESC); `; // ─── MessageLog ─────────────────────────────────────────────────────────── export class MessageLog { config; pool; log; initialized = false; constructor(config, logger) { this.config = config; this.log = logger ?? createBridgeLogger('message-log'); } // ─── Lifecycle ─────────────────────────────────────────────────────────── /** * Initialise le pool Postgres et crée le schéma si nécessaire. * Idempotent : peut être appelé plusieurs fois sans danger. */ async init() { if (this.initialized) return; this.log.info(`🐘 Connecting to Postgres ${this.config.host}:${this.config.port}/${this.config.database}...`); this.pool = new pg.Pool({ host: this.config.host, port: this.config.port, user: this.config.user, password: this.config.password, database: this.config.database, ssl: this.config.ssl ? { rejectUnauthorized: false } : false, min: this.config.poolMin ?? 2, max: this.config.poolMax ?? 10, idleTimeoutMillis: 30_000, }); // Test connexion const client = await this.pool.connect(); try { await client.query('SELECT 1'); this.log.info('🐘 Postgres connection OK'); } finally { client.release(); } // Crée le schéma await this.pool.query(SCHEMA_SQL); this.log.info('🐘 Schema bridge_messages ready'); this.initialized = true; } /** * Ferme proprement le pool. */ async close() { if (this.pool) { await this.pool.end(); this.pool = undefined; this.initialized = false; this.log.info('🐘 Postgres pool closed'); } } // ─── CRUD ──────────────────────────────────────────────────────────────── /** * Crée un message en status 'pending' et retourne son ID. */ async create(input) { this.assertReady(); const result = await this.pool.query(`INSERT INTO bridge_messages (from_agent, to_agent, runner, prompt, session_id, metadata, status) VALUES ($1, $2, $3, $4, $5, $6, 'pending') RETURNING id`, [ input.fromAgent ?? null, input.toAgent, input.runner, input.prompt, input.sessionId ?? null, input.metadata ? JSON.stringify(input.metadata) : null, ]); return result.rows[0].id; } /** * Marque un message comme 'running' (appel MCP en cours). */ async markRunning(id, sessionId) { this.assertReady(); await this.pool.query(`UPDATE bridge_messages SET status = 'running', started_at = now(), session_id = COALESCE($2, session_id) WHERE id = $1`, [id, sessionId ?? null]); } /** * Marque un message comme 'done' avec sa réponse. */ async markDone(id, response, sessionId) { this.assertReady(); await this.pool.query(`UPDATE bridge_messages SET status = 'done', response = $2, session_id = COALESCE($3, session_id), completed_at = now(), duration_ms = EXTRACT(MILLISECOND FROM (now() - started_at))::int + EXTRACT(SECOND FROM (now() - started_at))::int * 1000 + EXTRACT(MINUTE FROM (now() - started_at))::int * 60_000 WHERE id = $1`, [id, response, sessionId ?? null]); } /** * Marque un message comme 'failed' avec erreur. */ async markFailed(id, error) { this.assertReady(); await this.pool.query(`UPDATE bridge_messages SET status = 'failed', error = $2, completed_at = now() WHERE id = $1`, [id, error]); } /** * Marque un message comme 'timeout'. */ async markTimeout(id) { this.assertReady(); await this.pool.query(`UPDATE bridge_messages SET status = 'timeout', completed_at = now() WHERE id = $1`, [id]); } /** * Récupère un message par ID. */ async getById(id) { this.assertReady(); const result = await this.pool.query('SELECT * FROM bridge_messages WHERE id = $1', [ id, ]); return result.rows[0] ? rowToMessage(result.rows[0]) : null; } /** * Liste les messages avec filtres et pagination. */ async list(filter = {}) { this.assertReady(); const limit = filter.limit ?? 50; const offset = filter.offset ?? 0; const conditions = []; const params = []; if (filter.toAgent) { params.push(filter.toAgent); conditions.push(`to_agent = $${params.length}`); } if (filter.fromAgent !== undefined) { if (filter.fromAgent === null) { conditions.push(`from_agent IS NULL`); } else { params.push(filter.fromAgent); conditions.push(`from_agent = $${params.length}`); } } if (filter.status) { params.push(filter.status); conditions.push(`status = $${params.length}`); } if (filter.sinceHours) { params.push(filter.sinceHours); conditions.push(`created_at > now() - ($${params.length} || ' hours')::interval`); } const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; params.push(limit, offset); const result = await this.pool.query(`SELECT * FROM bridge_messages ${where} ORDER BY created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`, params); return result.rows.map(rowToMessage); } /** * Trouve les messages 'pending' ou 'running' depuis plus de X minutes (stuck). * Utile pour le replay automatique au redémarrage. */ async findStuck(stuckAfterMinutes = 5) { this.assertReady(); const result = await this.pool.query(`SELECT * FROM bridge_messages WHERE status IN ('pending', 'running') AND created_at < now() - ($1 || ' minutes')::interval ORDER BY created_at ASC`, [stuckAfterMinutes]); return result.rows.map(rowToMessage); } /** * Statistiques globales du log. */ async stats(sinceHours = 24) { this.assertReady(); const result = await this.pool.query(`SELECT COUNT(*)::text AS total, AVG(duration_ms)::text AS avg_duration FROM bridge_messages WHERE created_at > now() - ($1 || ' hours')::interval`, [sinceHours]); const byStatusResult = await this.pool.query(`SELECT status, COUNT(*)::text AS count FROM bridge_messages WHERE created_at > now() - ($1 || ' hours')::interval GROUP BY status`, [sinceHours]); const byRunnerResult = await this.pool.query(`SELECT runner, COUNT(*)::text AS count FROM bridge_messages WHERE created_at > now() - ($1 || ' hours')::interval GROUP BY runner`, [sinceHours]); const total = Number(result.rows[0]?.total ?? 0); const byStatus = { pending: 0, running: 0, done: 0, failed: 0, timeout: 0, }; for (const row of byStatusResult.rows) { byStatus[row.status] = Number(row.count); } const byRunner = {}; for (const row of byRunnerResult.rows) { byRunner[row.runner] = Number(row.count); } const avgDurationMs = result.rows[0]?.avg_duration ? Math.round(Number(result.rows[0].avg_duration)) : null; return { total, byStatus, byRunner, avgDurationMs }; } // ─── Internal ─────────────────────────────────────────────────────────── assertReady() { if (!this.initialized || !this.pool) { throw new Error('MessageLog not initialized. Call init() first.'); } } } function rowToMessage(row) { return { id: row.id, fromAgent: row.from_agent, toAgent: row.to_agent, runner: row.runner, prompt: row.prompt, response: row.response, status: row.status, sessionId: row.session_id, metadata: row.metadata, error: row.error, createdAt: row.created_at, startedAt: row.started_at, completedAt: row.completed_at, durationMs: row.duration_ms, }; } // ─── Env Loader ─────────────────────────────────────────────────────────── /** * Charge la config Postgres depuis process.env (POSTGRES_*). * Utilise les variables déjà dans .env. */ export function loadMessageLogConfigFromEnv() { const num = (v, def) => { const n = v ? Number(v) : NaN; return Number.isFinite(n) ? n : def; }; return { host: process.env.POSTGRES_HOST ?? 'localhost', port: num(process.env.POSTGRES_PORT, 5432), user: process.env.POSTGRES_USER ?? 'postgres', password: process.env.POSTGRES_PASSWORD ?? '', database: process.env.POSTGRES_DATABASE ?? process.env.POSTGRES_DB ?? 'overmind_memory', ssl: process.env.POSTGRES_SSL === 'true', poolMin: num(process.env.POSTGRES_POOL_MIN, 2), poolMax: num(process.env.POSTGRES_POOL_MAX, 10), }; } //# sourceMappingURL=MessageLog.js.map