@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
345 lines (344 loc) • 14.1 kB
JavaScript
/**
* Conversation Memory Manager for NeuroLink
* Handles in-memory conversation storage, session management, and context injection
*/
import { randomUUID } from "crypto";
import { DEFAULT_MAX_SESSIONS, MEMORY_THRESHOLD_PERCENTAGE, MESSAGES_PER_TURN, } from "../config/conversationMemory.js";
import { TokenUtils } from "../constants/tokens.js";
import { SummarizationEngine } from "../context/summarizationEngine.js";
import { runWithCurrentLangfuseContext } from "../services/server/ai/observability/instrumentation.js";
import { tracers, withSpan } from "../telemetry/index.js";
import { ConversationMemoryError } from "../types/index.js";
import { buildContextFromPointer, getEffectiveTokenThreshold, } from "../utils/conversationMemory.js";
import { logger } from "../utils/logger.js";
export class ConversationMemoryManager {
sessions = new Map();
config;
isInitialized = false;
summarizationEngine = new SummarizationEngine();
/**
* Track sessions currently being summarized to prevent race conditions
*/
summarizationInProgress = new Set();
constructor(config) {
this.config = config;
}
/**
* Initialize the memory manager
*/
async initialize() {
if (this.isInitialized) {
return;
}
try {
this.isInitialized = true;
logger.info("ConversationMemoryManager initialized", {
storage: "in-memory",
maxSessions: this.config.maxSessions,
maxTurnsPerSession: this.config.maxTurnsPerSession,
});
}
catch (error) {
throw new ConversationMemoryError("Failed to initialize conversation memory", "CONFIG_ERROR", {
error: error instanceof Error ? error.message : String(error),
});
}
}
/** Whether this memory manager can persist data (always true for in-memory within process) */
get canPersist() {
return true;
}
/** Whether Redis client is configured (always false for in-memory) */
get isRedisConfigured() {
return false;
}
/** Get health status for monitoring */
getHealthStatus() {
return {
initialized: this.isInitialized,
connected: false,
};
}
/**
* Store a conversation turn for a session
* TOKEN-BASED: Validates message size and triggers summarization based on tokens
*/
async storeConversationTurn(options) {
return withSpan({
name: "neurolink.memory.storeTurn",
tracer: tracers.memory,
attributes: {
"memory.type": "in-memory",
"session.id": options.sessionId,
"memory.operation": "store_turn",
},
}, async (span) => {
await this.ensureInitialized();
try {
// Get or create session
let session = this.sessions.get(options.sessionId);
if (!session) {
session = this.createNewSession(options.sessionId, options.userId);
this.sessions.set(options.sessionId, session);
}
const tokenThreshold = options.providerDetails
? getEffectiveTokenThreshold(options.providerDetails.provider, options.providerDetails.model, this.config.tokenThreshold, session.tokenThreshold)
: this.config.tokenThreshold || 50000;
const userMsg = await this.validateAndPrepareMessage(options.userMessage, "user", tokenThreshold);
const assistantMsg = await this.validateAndPrepareMessage(options.aiResponse, "assistant", tokenThreshold);
if (options.events && options.events.length > 0) {
assistantMsg.events = options.events;
}
session.messages.push(userMsg, assistantMsg);
session.lastActivity = Date.now();
// Store API-reported token counts if available
if (options.tokenUsage) {
session.lastApiTokenCount = options.tokenUsage;
}
span.setAttribute("memory.message_count", session.messages.length);
const shouldSummarize = options.enableSummarization !== undefined
? options.enableSummarization
: this.config.enableSummarization;
if (shouldSummarize) {
// Only trigger summarization if not already in progress for this session
if (!this.summarizationInProgress.has(options.sessionId)) {
// Capture the current Langfuse ALS context before setImmediate,
// which breaks automatic AsyncLocalStorage propagation and would
// otherwise cause orphaned traces in Langfuse.
const summarizeWithContext = runWithCurrentLangfuseContext(async () => {
try {
await this.checkAndSummarize(session, tokenThreshold, options.requestId);
}
catch (error) {
logger.error("Background summarization failed", {
sessionId: session.sessionId,
requestId: options.requestId,
error: error instanceof Error ? error.message : String(error),
});
}
});
setImmediate(summarizeWithContext);
}
else {
logger.debug("[ConversationMemoryManager] Summarization already in progress, skipping", {
sessionId: options.sessionId,
});
}
}
this.enforceSessionLimit();
}
catch (error) {
throw new ConversationMemoryError(`Failed to store conversation turn for session ${options.sessionId}`, "STORAGE_ERROR", {
sessionId: options.sessionId,
error: error instanceof Error ? error.message : String(error),
});
}
});
}
/**
* Validate and prepare a message before adding to session
* Truncates if message exceeds token limit
*/
async validateAndPrepareMessage(content, role, threshold) {
const id = randomUUID();
const tokenCount = TokenUtils.estimateTokenCount(content);
const maxMessageSize = Math.floor(threshold * MEMORY_THRESHOLD_PERCENTAGE);
if (tokenCount > maxMessageSize) {
const truncated = TokenUtils.truncateToTokenLimit(content, maxMessageSize);
logger.warn("Message truncated due to token limit", {
id,
role,
originalTokens: tokenCount,
threshold,
truncatedTo: maxMessageSize,
});
return {
id,
role,
content: truncated,
timestamp: new Date().toISOString(),
metadata: {
truncated: true,
},
};
}
return {
id,
role,
content,
timestamp: new Date().toISOString(),
};
}
/**
* Check if summarization is needed based on token count
*/
async checkAndSummarize(session, threshold, requestId) {
// Acquire lock - if already in progress, skip
if (this.summarizationInProgress.has(session.sessionId)) {
logger.debug("[ConversationMemoryManager] Summarization already in progress, skipping", {
sessionId: session.sessionId,
});
return;
}
this.summarizationInProgress.add(session.sessionId);
try {
await this.summarizationEngine.checkAndSummarize(session, threshold, this.config, "[ConversationMemory]", requestId);
}
catch (error) {
logger.error("Token counting or summarization failed", {
sessionId: session.sessionId,
error: error instanceof Error ? error.message : String(error),
});
}
finally {
// Release lock when done
this.summarizationInProgress.delete(session.sessionId);
}
}
/**
* Estimate total tokens for a list of messages
*/
estimateTokens(messages) {
return messages.reduce((total, msg) => {
let msgTokens = TokenUtils.estimateTokenCount(msg.content);
if (msg.events && Array.isArray(msg.events) && msg.events.length > 0) {
const eventsJson = JSON.stringify(msg.events);
msgTokens += TokenUtils.estimateTokenCount(eventsJson);
}
return total + msgTokens;
}, 0);
}
/**
* Build context messages for AI prompt injection (TOKEN-BASED)
* Returns messages from pointer onwards (or all if no pointer)
* Now consistently async to match Redis implementation
*/
async buildContextMessages(sessionId, _userId, _enableSummarization, requestId) {
return withSpan({
name: "neurolink.memory.buildContext",
tracer: tracers.memory,
attributes: {
"memory.type": "in-memory",
"session.id": sessionId,
"memory.operation": "build_context",
},
}, async (span) => {
const session = this.sessions.get(sessionId);
const messages = session
? buildContextFromPointer(session, requestId)
: [];
span.setAttribute("memory.message_count", messages.length);
return messages;
});
}
getSession(sessionId, _userId) {
return this.sessions.get(sessionId);
}
createSummarySystemMessage(content, summarizesFrom, summarizesTo) {
return {
id: `summary-${randomUUID()}`,
role: "system",
content: `Summary of previous conversation turns:\n\n${content}`,
timestamp: new Date().toISOString(),
metadata: {
isSummary: true,
summarizesFrom,
summarizesTo,
},
};
}
async ensureInitialized() {
if (!this.isInitialized) {
await this.initialize();
}
}
createNewSession(sessionId, userId) {
return {
sessionId,
userId,
messages: [],
createdAt: Date.now(),
lastActivity: Date.now(),
};
}
enforceSessionLimit() {
const maxSessions = this.config.maxSessions || DEFAULT_MAX_SESSIONS;
if (this.sessions.size <= maxSessions) {
return;
}
const sessions = Array.from(this.sessions.entries()).sort(([, a], [, b]) => a.lastActivity - b.lastActivity);
const sessionsToRemove = sessions.slice(0, this.sessions.size - maxSessions);
for (const [sessionId] of sessionsToRemove) {
this.sessions.delete(sessionId);
}
}
async getStats() {
await this.ensureInitialized();
const sessions = Array.from(this.sessions.values());
const totalTurns = sessions.reduce((sum, session) => sum + session.messages.length / MESSAGES_PER_TURN, 0);
return {
totalSessions: sessions.length,
totalTurns,
};
}
async clearSession(sessionId) {
return withSpan({
name: "neurolink.memory.clear",
tracer: tracers.memory,
attributes: {
"memory.type": "in-memory",
"session.id": sessionId,
"memory.operation": "clear_session",
},
}, async (span) => {
const session = this.sessions.get(sessionId);
if (!session) {
span.setAttribute("memory.session_found", false);
return false;
}
this.sessions.delete(sessionId);
span.setAttribute("memory.session_found", true);
logger.info("Session cleared", { sessionId });
return true;
});
}
async clearAllSessions() {
const sessionIds = Array.from(this.sessions.keys());
this.sessions.clear();
logger.info("All sessions cleared", { clearedCount: sessionIds.length });
}
/**
* Get the raw messages array for a session.
* Returns the full messages list without context filtering or summarization.
* Returns a deep copy to prevent external mutation of internal state.
*/
async getSessionMessages(sessionId, _userId) {
await this.ensureInitialized();
const session = this.sessions.get(sessionId);
return session ? session.messages.map((msg) => ({ ...msg })) : [];
}
/**
* Replace the entire messages array for a session.
* Creates the session if it does not exist.
* Resets summary pointers since old pointers may reference messages that no longer exist.
*/
async setSessionMessages(sessionId, messages, userId) {
await this.ensureInitialized();
let session = this.sessions.get(sessionId);
if (!session) {
session = this.createNewSession(sessionId, userId);
this.sessions.set(sessionId, session);
this.enforceSessionLimit();
}
session.messages = [...messages];
session.summarizedUpToMessageId = undefined;
session.summarizedMessage = undefined;
session.lastTokenCount = undefined;
session.lastCountedAt = undefined;
session.lastActivity = Date.now();
}
/** Close/shutdown — no-op for in-memory manager (no external connections to release) */
async close() {
// In-memory manager has nothing to close
}
}