@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
JavaScript
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