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

238 lines • 10.3 kB
/** * ╔══════════════════════════════════════════════════════════════════════╗ * ║ OVERMIND BRIDGE — SessionStore (Multi-Tenant Session Map) ║ * ║ ║ * ║ Gère une map clé_externe → session_id avec TTL et persistence. ║ * ║ Inspiré du pattern bt-sms (phone → sessionId avec TTL 4h). ║ * ║ ║ * ║ USAGE ║ * ║ ───── ║ * ║ Clé externe = n'importe quoi : téléphone, user_id, channel_id, ║ * ║ conversation_id, etc. Le bridge isole les sessions par clé. ║ * ║ ║ * ║ { ║ * ║ "agent.run": { agentName, runner, prompt, externalKey: "+1418..." } ║ * ║ } ║ * ║ → le bridge retrouve ou crée une session pour cette clé, ║ * ║ la passe à l'agent, et la sauvegarde dans le store. ║ * ╚══════════════════════════════════════════════════════════════════════╝ */ import fs from 'node:fs/promises'; import path from 'node:path'; import { createBridgeLogger } from './utils.js'; // ─── SessionStore ────────────────────────────────────────────────────────── export class SessionStore { map = new Map(); log; config; cleanupTimer; writeQueue = Promise.resolve(); constructor(config = {}, logger) { this.config = { ttlMs: config.ttlMs ?? 4 * 60 * 60 * 1000, // 4h cleanupIntervalMs: config.cleanupIntervalMs ?? 5 * 60 * 1000, // 5min persistPath: config.persistPath, }; this.log = logger ?? createBridgeLogger('session-store'); } // ─── Lifecycle ─────────────────────────────────────────────────────────── /** * Initialise le store : charge le fichier de persistence si présent, * démarre le cleanup périodique. */ async init() { if (this.config.persistPath) { try { const raw = await fs.readFile(this.config.persistPath, 'utf-8'); const parsed = JSON.parse(raw); const now = Date.now(); let loaded = 0; for (const entry of parsed.sessions ?? []) { // Skip les sessions expirées au load if (now - entry.lastActivityAt > this.config.ttlMs) continue; // Déobfusquer le sessionId si le fichier est marqué _obfuscated if (parsed._obfuscated && entry.sessionId) { try { entry.sessionId = Buffer.from(entry.sessionId, 'base64').toString('utf-8'); } catch { /* skip — valeur non-base64, utiliser tel quel */ } } this.map.set(this.makeKey(entry.externalKey, entry.agentName), entry); loaded++; } this.log.info(`📂 Loaded ${loaded} session(s) from ${this.config.persistPath}`); } catch (err) { if (err.code !== 'ENOENT') { this.log.warn(`⚠️ Failed to load sessions: ${err.message}`); } } } if (this.cleanupTimer) clearInterval(this.cleanupTimer); this.cleanupTimer = setInterval(() => this.purgeExpired(), this.config.cleanupIntervalMs); } /** * Arrête le cleanup et persiste l'état final. */ async close() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = undefined; } await this.writeQueue; // attend les writes en cours await this.persist(); this.log.info('🛑 SessionStore closed'); } // ─── CRUD ──────────────────────────────────────────────────────────────── /** * Récupère une session par (externalKey, agentName). * Retourne undefined si absente ou expirée. */ get(externalKey, agentName) { const key = this.makeKey(externalKey, agentName); const entry = this.map.get(key); if (!entry) return undefined; if (this.isExpired(entry)) { this.map.delete(key); return undefined; } return { ...entry }; } /** * Sauvegarde ou met à jour une session. Retourne l'entrée. */ set(entry) { const full = { ...entry, lastActivityAt: entry.lastActivityAt ?? Date.now() }; const key = this.makeKey(full.externalKey, full.agentName); this.map.set(key, full); this.schedulePersist(); return full; } /** * Met à jour uniquement le sessionId (cas typique : après un run, on a le vrai ID). */ updateSessionId(externalKey, agentName, sessionId, runner) { const key = this.makeKey(externalKey, agentName); const existing = this.map.get(key); if (!existing) { return this.set({ externalKey, agentName, runner, sessionId }); } existing.sessionId = sessionId; existing.runner = runner; existing.lastActivityAt = Date.now(); this.schedulePersist(); return { ...existing }; } /** * Met à jour le context (pattern CONTEXT_UPDATE de bt-sms). */ updateContext(externalKey, agentName, patch) { const entry = this.get(externalKey, agentName); if (!entry) return undefined; entry.context = { ...(entry.context ?? {}), ...patch }; entry.lastActivityAt = Date.now(); this.map.set(this.makeKey(externalKey, agentName), entry); this.schedulePersist(); return { ...entry }; } /** * Supprime une session. */ delete(externalKey, agentName) { const deleted = this.map.delete(this.makeKey(externalKey, agentName)); if (deleted) this.schedulePersist(); return deleted; } /** * Purge toutes les sessions (reset complet). */ clear() { this.map.clear(); this.schedulePersist(); } // ─── Queries ──────────────────────────────────────────────────────────── /** * Liste toutes les sessions actives (non expirées). */ list() { this.purgeExpired(); return Array.from(this.map.values()).map((e) => ({ ...e })); } /** * Stats par agent. */ stats() { const byAgent = {}; for (const entry of this.map.values()) { byAgent[entry.agentName] = (byAgent[entry.agentName] ?? 0) + 1; } return { total: this.map.size, byAgent }; } // ─── Helpers ──────────────────────────────────────────────────────────── makeKey(externalKey, agentName) { return `${agentName}::${externalKey}`; } isExpired(entry) { return Date.now() - entry.lastActivityAt > this.config.ttlMs; } purgeExpired() { const now = Date.now(); let purged = 0; for (const [key, entry] of this.map) { if (now - entry.lastActivityAt > this.config.ttlMs) { this.map.delete(key); purged++; } } if (purged > 0) { this.log.info(`🧹 Purged ${purged} expired sessions`); this.schedulePersist(); } return purged; } // ─── Persistence (queued, atomic) ─────────────────────────────────────── schedulePersist() { if (!this.config.persistPath) return; this.writeQueue = this.writeQueue .then(() => this.persist()) .catch((err) => { this.log.error(`Persist error: ${err.message}`); }); } async persist() { if (!this.config.persistPath) return; // Obfuscation basique: base64-encode les sessionIds pour qu'un cat du fichier // ne révèle pas les IDs en clair. C'est pas du chiffrement — c'est pour éviter // le leak accidentel (logs, screenshots, etc.). const sessions = Array.from(this.map.values()).map((s) => ({ ...s, sessionId: Buffer.from(s.sessionId).toString('base64'), })); const data = { sessions, _obfuscated: true }; const tmp = this.config.persistPath + '.tmp'; try { await fs.mkdir(path.dirname(this.config.persistPath), { recursive: true }); await fs.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8'); await fs.rename(tmp, this.config.persistPath); } catch (err) { this.log.error(`Failed to persist sessions: ${err.message}`); try { await fs.unlink(tmp); } catch { // ignore } } } } //# sourceMappingURL=SessionStore.js.map