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