UNPKG

@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

588 lines 27.4 kB
import chalk from "chalk"; import readline from "readline"; import { checkContextBudget } from "../../lib/context/budgetChecker.js"; import { NeuroLink } from "../../lib/neurolink.js"; import { globalSession } from "../../lib/session/globalSessionState.js"; import { logger } from "../../lib/utils/logger.js"; import { displayConversationPreview, displaySessionMessage, getConversationPreview, loadCommandHistory, parseValue, restoreSessionVariables, saveCommandToHistory, verifyConversationContext, } from "../../lib/utils/loopUtils.js"; import { SpanStatusCode } from "@opentelemetry/api"; import { tracers } from "../../lib/telemetry/tracers.js"; import { handleError } from "../errorHandler.js"; import { ConversationSelector } from "./conversationSelector.js"; import { textGenerationOptionsSchema } from "./optionsSchema.js"; // Banner Art const NEUROLINK_BANNER = ` ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▖▗▄▄▖ ▗▄▖ ▗▖ ▗▄▄▄▖▗▖ ▗▖▗▖ ▗▖ ▐▛▚▖▐▌▐▌ ▐▌ ▐▌▐▌ ▐▌▐▌ ▐▌▐▌ █ ▐▛▚▖▐▌▐▌▗▞▘ ▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▌▐▛▀▚▖▐▌ ▐▌▐▌ █ ▐▌ ▝▜▌▐▛▚▖ ▐▌ ▐▌▐▙▄▄▖▝▚▄▞▘▐▌ ▐▌▝▚▄▞▘▐▙▄▄▖▗▄█▄▖▐▌ ▐▌▐▌ ▐▌ `; export class LoopSession { conversationMemoryConfig; options; initializeCliParser; isRunning = false; sessionId; commandHistory = []; sessionVariablesSchema = textGenerationOptionsSchema; constructor(initializeCliParser, conversationMemoryConfig, options) { this.conversationMemoryConfig = conversationMemoryConfig; this.options = options; this.initializeCliParser = initializeCliParser; } async start() { // Initialize global session state this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); // Load command history from global file, reverse once for most recent first this.commandHistory = (await loadCommandHistory()).reverse(); this.isRunning = true; logger.always(chalk.bold.green(NEUROLINK_BANNER)); logger.always(chalk.bold.green("Welcome to NeuroLink Loop Mode!")); // Check for direct CLI options const directResumeSessionId = this.options?.directResumeSessionId; const forceNewSession = this.options?.forceNewSession; // Handle conversation discovery and selection if memory is enabled if (this.conversationMemoryConfig?.enabled) { logger.always(chalk.gray("Conversation memory enabled")); // Handle direct resume option if (directResumeSessionId) { await this.handleDirectSessionResume(directResumeSessionId); } // Handle force new session option else if (forceNewSession) { logger.always(chalk.blue("Force starting new conversation...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } // Default behavior: check for existing conversations else { await this.handleConversationSelection(); } // Display session information logger.always(chalk.gray(`Session ID: ${this.sessionId}`)); logger.always(chalk.gray(`Max sessions: ${this.conversationMemoryConfig.maxSessions}`)); logger.always(chalk.gray(`Max turns per session: ${this.conversationMemoryConfig.maxTurnsPerSession}\n`)); } else { // No conversation memory - just create a new session this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); logger.always(chalk.gray(`Session ID: ${this.sessionId}`)); } // Load command history from global file this.commandHistory = (await loadCommandHistory()).reverse(); logger.always(chalk.gray('Type "help" for a list of commands.')); logger.always(chalk.gray('Type "exit", "quit", or ":q" to leave the loop.')); while (this.isRunning) { try { // Use readline with history support instead of inquirer const command = await this.getCommandWithHistory(); if (command.toLowerCase() === "exit" || command.toLowerCase() === "quit" || command.toLowerCase() === ":q") { this.isRunning = false; continue; } if (!command) { continue; } // Save command to history if (command && command.trim()) { this.commandHistory.unshift(command); await saveCommandToHistory(command); } let processedCommand; if (command.startsWith("//")) { // Escape sequence - treat as stream with single / processedCommand = ["stream", command.slice(1)]; } else if (command.startsWith("/")) { // Explicit CLI command: remove "/" prefix processedCommand = command.slice(1).trim(); if (!processedCommand) { logger.always(chalk.red("Type 'help' for available commands.")); continue; } // Handle session variable commands and skip further processing if (await this.handleSessionCommands(processedCommand)) { continue; } } else { // Default: treat as stream command with array format processedCommand = ["stream", command]; } // Execute the command within an OTel span for per-turn visibility. // The .fail() handler in cli.ts is now session-aware and will // handle all parsing and validation errors without exiting the loop. // We create a fresh instance for each command to prevent state pollution. await tracers.sdk.startActiveSpan("neurolink.cli.turn", async (turnSpan) => { try { turnSpan.setAttribute("cli.command", typeof processedCommand === "string" ? processedCommand.slice(0, 100) : (processedCommand[0] ?? "unknown")); turnSpan.setAttribute("cli.session_id", this.sessionId ?? "none"); const yargsInstance = this.initializeCliParser(); await yargsInstance .scriptName("") .fail((msg, err) => { throw err || new Error(msg); }) .exitProcess(false) .parse(processedCommand); } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); turnSpan.recordException(err); turnSpan.setStatus({ code: SpanStatusCode.ERROR, message: err.message, }); throw e; } finally { turnSpan.end(); } }); // Check context budget after each generation command await this.checkContextBudgetWarning(); } catch (error) { // Handle command execution errors gracefully handleError(error, "Command execution failed"); } } // Cleanup on exit this.cleanup(); } /** * Handle direct session resume from CLI option */ async handleDirectSessionResume(directResumeSessionId) { logger.always(chalk.blue(`Attempting to resume session: ${directResumeSessionId.slice(0, 12)}...`)); try { const restoreResult = await this.restoreSession(directResumeSessionId); if (restoreResult.success) { displaySessionMessage(restoreResult); this.sessionId = directResumeSessionId; // Display conversation preview const preview = await getConversationPreview(directResumeSessionId, 2); displayConversationPreview(preview, 2); } else { displaySessionMessage(restoreResult); logger.always(chalk.yellow("Starting new conversation instead...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } } catch (error) { logger.error(`Failed to resume session ${directResumeSessionId}:`, error); logger.always(chalk.yellow("Starting new conversation instead...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } } /** * Handle conversation selection logic when no direct resume is specified */ async handleConversationSelection() { logger.always(chalk.gray("Checking for existing conversations...\n")); try { const conversationSelector = new ConversationSelector(); // Check if there are any stored conversations const hasStoredConversations = await conversationSelector.hasStoredConversations(); if (hasStoredConversations) { // Show conversation selection menu const selectedSessionId = await conversationSelector.displayConversationMenu(); if (selectedSessionId !== "NEW_CONVERSATION") { // Restore the selected conversation logger.always(chalk.blue("Restoring conversation...")); const restoreResult = await this.restoreSession(selectedSessionId); if (restoreResult.success) { displaySessionMessage(restoreResult); this.sessionId = selectedSessionId; // Display conversation preview const preview = await getConversationPreview(selectedSessionId, 2); displayConversationPreview(preview, 2); } else { displaySessionMessage(restoreResult); logger.always(chalk.yellow("Starting new conversation instead...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } } else { // User chose to start new conversation logger.always(chalk.blue("Starting new conversation...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } } else { // No existing conversations found logger.always(chalk.gray("No existing conversations found.")); logger.always(chalk.blue("Starting new conversation...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } // Close the conversation selector await conversationSelector.close(); } catch (error) { logger.warn("Failed to check for existing conversations:", error); logger.always(chalk.yellow("Starting new conversation...")); this.sessionId = globalSession.setLoopSession(this.conversationMemoryConfig); } } /** * Check context budget and warn if approaching limits. */ async checkContextBudgetWarning() { const compactionConfig = this.conversationMemoryConfig?.contextCompaction; if (!compactionConfig?.enabled) { return; } try { const provider = globalSession.getSessionVariable("provider") || "openai"; const model = globalSession.getSessionVariable("model"); const neurolinkInstance = globalSession.getOrCreateNeuroLink(); if (!neurolinkInstance?.conversationMemory || !this.sessionId) { return; } const messages = await neurolinkInstance.conversationMemory.buildContextMessages(this.sessionId); if (!messages || messages.length === 0) { return; } const budgetResult = checkContextBudget({ provider, model, conversationMessages: messages.map((m) => ({ role: m.role, content: m.content, })), }); const usagePercent = budgetResult.usageRatio * 100; if (budgetResult.shouldCompact) { logger.always(chalk.yellow(`\n Context usage: ${usagePercent.toFixed(0)}% of window (${budgetResult.estimatedInputTokens.toLocaleString()} / ${budgetResult.availableInputTokens.toLocaleString()} tokens)`)); logger.always(chalk.yellow(` Auto-compaction will trigger to preserve conversation quality.\n`)); } else if (usagePercent > 60) { logger.always(chalk.gray(` Context: ${usagePercent.toFixed(0)}% used`)); } } catch (error) { logger.debug("Context budget check failed", { error: error instanceof Error ? error.message : String(error), }); } } /** * Clean up session resources and connections */ cleanup() { try { globalSession.clearLoopSession(); logger.always(chalk.yellow("Loop session ended.")); } catch (error) { // Silently handle cleanup errors to avoid hanging logger.error("Error during cleanup:", error); } } async handleSessionCommands(command) { const parts = command.split(" "); const cmd = parts[0].toLowerCase(); switch (cmd) { case "help": this.showHelp(); return true; case "set": if (parts.length === 2 && parts[1].toLowerCase() === "help") { this.showSetHelp(); } else if (parts.length >= 3) { const key = parts[1]; const schema = this.sessionVariablesSchema[key]; if (!schema) { logger.always(chalk.red(`Error: Unknown session variable "${key}".`)); logger.always(chalk.gray('Use "set help" to see available variables.')); return true; } const valueStr = parts.slice(2).join(" "); let value = parseValue(valueStr); // Validate type if (schema.type === "boolean" && typeof value !== "boolean") { logger.always(chalk.red(`Error: Invalid value for "${key}". Expected a boolean (true/false).`)); return true; } if (schema.type === "string") { if (typeof value === "number" || typeof value === "boolean") { value = String(value); } else if (typeof value !== "string") { logger.always(chalk.red(`Error: Invalid value for "${key}". Expected a string.`)); return true; } } if (schema.type === "number" && typeof value !== "number") { logger.always(chalk.red(`Error: Invalid value for "${key}". Expected a number.`)); return true; } // Validate allowedValues if (schema.allowedValues && !schema.allowedValues.includes(String(value))) { logger.always(chalk.red(`Error: Invalid value for "${key}".`)); logger.always(chalk.gray(`Allowed values are: ${schema.allowedValues.join(", ")}`)); return true; } globalSession.setSessionVariable(key, value); logger.always(chalk.green(`✓ ${key} set to ${value}`)); } else { logger.always(chalk.red("Usage: set <key> <value> or set help")); } return true; case "get": if (parts.length >= 2) { const key = parts[1]; const value = globalSession.getSessionVariable(key); if (value !== undefined) { logger.always(chalk.cyan(`${key}: ${value}`)); } else { logger.always(chalk.yellow(`${key} is not set`)); } } else { logger.always(chalk.red("Usage: get <key>")); } return true; case "unset": if (parts.length >= 2) { const key = parts[1]; if (globalSession.unsetSessionVariable(key)) { logger.always(chalk.green(`✓ ${key} unset`)); } else { logger.always(chalk.yellow(`${key} was not set`)); } } else { logger.always(chalk.red("Usage: unset <key>")); } return true; case "show": { const variables = globalSession.getSessionVariables(); if (Object.keys(variables).length > 0) { logger.always(chalk.cyan("Session Variables:")); for (const [key, value] of Object.entries(variables)) { logger.always(chalk.gray(` ${key}: ${value}`)); } } else { logger.always(chalk.yellow("No session variables set")); } return true; } case "clear": globalSession.clearSessionVariables(); logger.always(chalk.green("✓ All session variables cleared")); return true; default: return false; } } showHelp() { logger.always(chalk.cyan("Available Loop Mode Commands:")); const commands = [ { cmd: "help", desc: "Show this help message.", }, { cmd: "set <key> <value>", desc: "Set a session variable. Use 'set help' for details.", }, { cmd: "get <key>", desc: "Get a session variable." }, { cmd: "unset <key>", desc: "Unset a session variable." }, { cmd: "show", desc: "Show all currently set session variables.", }, { cmd: "clear", desc: "Clear all session variables." }, { cmd: "exit / quit / :q", desc: "Exit the loop mode.", }, ]; commands.forEach((c) => { logger.always(chalk.yellow(` ${c.cmd.padEnd(20)}`) + `${c.desc}`); }); logger.always("\nAny other command will be executed as a standard neurolink CLI command."); // Also show the standard help output this.initializeCliParser().showHelp("log"); } showSetHelp() { logger.always(chalk.cyan("Available Session Variables to Set:")); for (const [key, schema] of Object.entries(this.sessionVariablesSchema)) { logger.always(chalk.yellow(` ${key}`)); logger.always(` ${schema.description}`); if (schema.allowedValues) { logger.always(chalk.gray(` Allowed: ${schema.allowedValues.join(", ")}`)); } else { logger.always(chalk.gray(` Type: ${schema.type}`)); } } } /** * Get command input with history support using readline */ async getCommandWithHistory() { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, history: [...this.commandHistory], // most recent first prompt: `${chalk.blue.green("⎔")} ${chalk.blue.bold("neurolink")} ${chalk.blue.green("»")} `, }); rl.prompt(); rl.on("line", (input) => { rl.close(); resolve(input.trim()); }); rl.on("SIGINT", () => { rl.close(); this.isRunning = false; resolve("exit"); }); }); } // === SESSION RESTORATION METHODS === /** * Restore a conversation session and set up the global session state */ async restoreSession(sessionId, userId) { // Local helper for creating failure results with bound sessionId const createFailure = (error) => ({ success: false, sessionId, messageCount: 0, error, }); try { logger.debug(`Attempting to restore session: ${sessionId}`); // 1. Create NeuroLink instance and validate conversation in one step const { neurolinkInstance, conversationData } = await this.createAndValidateNeurolinkInstance(sessionId, userId); if (!conversationData) { return createFailure(`Conversation ${sessionId} not found or inaccessible`); } // 2. Set up tool execution context await this.configureToolContext(neurolinkInstance, sessionId, userId); // 3. Restore global session state this.restoreGlobalSessionState(sessionId, neurolinkInstance, conversationData); // 4. Verify conversation context accessibility await verifyConversationContext(sessionId); const result = { success: true, sessionId, messageCount: conversationData.messages?.length || 0, lastActivity: conversationData.updatedAt, }; logger.info(`Session restored successfully: ${sessionId}`, { messageCount: result.messageCount, lastActivity: result.lastActivity, }); return result; } catch (error) { logger.error(`Failed to restore session ${sessionId}:`, error); return createFailure(error instanceof Error ? error.message : String(error)); } } /** * Create NeuroLink instance and validate conversation in one step * Eliminates redundant instance creation and initialization */ async createAndValidateNeurolinkInstance(sessionId, userId) { // Create NeuroLink instance with proper configuration const neurolinkOptions = {}; if (this.conversationMemoryConfig?.enabled) { neurolinkOptions.conversationMemory = { enabled: true, maxSessions: this.conversationMemoryConfig.maxSessions, maxTurnsPerSession: this.conversationMemoryConfig.maxTurnsPerSession, }; neurolinkOptions.sessionId = sessionId; } const neurolinkInstance = new NeuroLink(neurolinkOptions); await neurolinkInstance.ensureConversationMemoryInitialized(); // Use the same instance to validate conversation exists try { const messages = await neurolinkInstance.getConversationHistory(sessionId); if (!messages || messages.length === 0) { logger.debug(`No conversation messages found for session ${sessionId}`); return { neurolinkInstance, conversationData: null }; } // Create conversation object with available data const conversationData = { id: sessionId, sessionId, userId: userId || "unknown", messages, createdAt: new Date().toISOString(), // Fallback updatedAt: new Date().toISOString(), // Fallback title: messages[0]?.content?.slice(0, 50) + "..." || "Untitled Conversation", }; return { neurolinkInstance, conversationData }; } catch (error) { logger.debug(`Error accessing conversation for session ${sessionId}:`, error); return { neurolinkInstance, conversationData: null }; } } /** * Configure tool execution context for the restored session */ async configureToolContext(neurolinkInstance, sessionId, userId) { const toolContext = { sessionId, userId: userId || "loop-user", source: "loop-mode", restored: true, timestamp: new Date().toISOString(), }; neurolinkInstance.setToolContext(toolContext); logger.debug("Tool execution context configured for restored session", { sessionId, userId, hasToolContext: true, }); await this.verifyToolAvailability(neurolinkInstance); } /** * Verify that tools are available and working in the restored session */ async verifyToolAvailability(neurolinkInstance) { try { const availableTools = await neurolinkInstance.getAllAvailableTools(); logger.debug(`Tools available in restored session: ${availableTools.length} tools`, { toolNames: availableTools.slice(0, 5).map((t) => t.name), hasFileTools: availableTools.some((t) => t.name.includes("file") || t.name.includes("File")), hasDirectoryTools: availableTools.some((t) => t.name.includes("directory") || t.name.includes("Directory")), }); if (availableTools.length === 0) { logger.warn("No tools available in restored session - this may affect AI capabilities"); } } catch (error) { logger.warn("Could not verify tool availability in restored session:", error); } } /** * Restore global session state and session variables */ restoreGlobalSessionState(sessionId, neurolinkInstance, conversationData) { globalSession.clearLoopSession(); globalSession.restoreLoopSession(sessionId, neurolinkInstance, this.conversationMemoryConfig, {}); if (conversationData) { restoreSessionVariables(conversationData); } } } //# sourceMappingURL=session.js.map