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
JavaScript
/**
* ╔══════════════════════════════════════════════════════════════════════╗
* ║ 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