UNPKG

naisys

Version:

NAISYS - Autonomous AI agent runner with built-in context management and cost tracking

318 lines 15.4 kB
import chalk from "chalk"; import stringArgv from "string-argv"; import { ContentSource } from "../llm/llmDtos.js"; import { OutputColor } from "../utils/output/output.js"; import { NextCommandAction } from "./commandRegistry.js"; export function createCommandHandler(_globalConfig, { agentConfig }, commandProtection, promptBuilder, shellCommand, shellWrapper, commandRegistry, contextManager, output, inputMode, commandLoopState, sessionService) { async function processCommand(prompt, commandList) { // We process the lines one at a time so we can support multiple commands with line breaks let firstLine = true; let firstCommand = true; let processNextLLMpromptBlock = true; let nextCommandAction = NextCommandAction.Continue; // ns-cmd / ! interception flips us to LLM mode for one iteration; the // outer try/finally restores debug mode on every exit path so triggerLlm // and other downstream mode logic see the correct starting state. let interceptedToLlm = false; try { while (processNextLLMpromptBlock && commandList.length) { const popped = await popFirstCommand(commandList); const { splitResult } = popped; let input = popped.input; if (splitResult == "inputInPrompt") { continue; } else if (splitResult == "inputPromptMismatch" || !input.trim()) { break; } const prefixed = applyDebugPrefixes(input); input = prefixed.input; if (prefixed.interceptedToLlm) { interceptedToLlm = true; } // First line is special because we want to append the output to the context without a line break if (inputMode.isLLM()) { const displayInput = redactIfSecure(input); if (firstLine) { firstLine = false; contextManager.append(displayInput, ContentSource.LlmPromptResponse); output.write(prompt + chalk[OutputColor.llm](displayInput)); } else { // Check if multiple commands are disabled if (!firstCommand && !agentConfig().multipleCommandsEnabled) { output.errorAndLog(`Multiple commands disabled. Blocked command: ${displayInput}`); break; } output.commentAndLog(`Continuing with next command from same LLM response...`); contextManager.append(displayInput, ContentSource.LLM); } // Skip write protection for internal NAISYS commands const commandName = stringArgv(input)[0]; if (!commandRegistry.get(commandName)) { const { commandAllowed, rejectReason } = await commandProtection.validateCommand(input); if (!commandAllowed) { output.errorAndLog(`Write Protection Triggered`); contextManager.append(rejectReason || "Unknown"); break; } } } const argv = stringArgv(input); const command = argv[0]; // cmdArgs is everything after the command name const cmdArgs = input.slice(command.length).trim(); // Restore Executing after commandProtection may have shifted state // to Confirming/LlmQuerying; the actual command is the next hold. commandLoopState.setState("Executing"); // Check command registry first const registeredCommand = commandRegistry.get(command); if (registeredCommand) { const expandedArgs = await expandShellArgs(cmdArgs); const response = await registeredCommand.handleCommand(expandedArgs); sessionService.updateCanComplete(command, expandedArgs); // Handle string or CommandResponse if (typeof response === "string") { contextManager.append(response); } else { contextManager.append(response.content); // If command provides a next command response, return it directly if (response.nextCommandResponse) { return response.nextCommandResponse; } } } else if (command.startsWith("ns-") && !shellCommand.isShellSuspended()) { // Unknown NAISYS command. NAISYS owns the `ns-` namespace, so don't // hand a typo to the shell — its command-not-found handler would // tell the LLM to run it on its own line, which it already did. contextManager.append(`'${command}' is not a recognized NAISYS command. Run 'ns-help' to see available commands.`); } else { const { response, exitApp } = await shellCommand.handleCommand(input); if (response !== undefined) { contextManager.append(response); } sessionService.updateCanComplete(command, ""); nextCommandAction = exitApp ? NextCommandAction.ExitApplication : NextCommandAction.Continue; } if (command != "ns-comment" && firstCommand) { firstCommand = false; } // After the first real command, check if we've exceeded the token limit. // Break early so the LLM can re-evaluate and decide to compact. if (!firstCommand && commandList.length > 0 && contextManager.getTokenCount() > agentConfig().tokenMax) { output.errorAndLog(`Token limit exceeded mid-response, breaking to allow session compaction`); break; } } // End loop processing LLM response // display unprocessed lines to aid in debugging if (commandList.length) { output.errorAndLog(`Unprocessed LLM commands:\n${commandList.map((c, i) => `${i + 1}: ${c}`).join("\n")}`); } return { nextCommandAction, }; } finally { if (interceptedToLlm) { inputMode.setDebug(); } } } /** * Apply debug-mode prefix conventions to the raw input line: * - `@msg` → `@ msg` (single-char alias of ns-talk needs a space) * - `ns-cmd <x>` → `<x>` in LLM mode (run as if the LLM had typed it) * - `! <x>` / `!<x>` → `<x>` in LLM mode (shorthand for ns-cmd) * * Side effect: switches inputMode to LLM when an ns-cmd/! prefix is * stripped. The caller owns restoring debug mode (see processCommand's * try/finally) so any triggerLlm/mode logic downstream stays consistent. * * No-op outside debug mode — these prefixes are admin-facing and shouldn't * rewrite stray LLM output that happens to start with `@` or `!`. */ function applyDebugPrefixes(input) { if (!inputMode.isDebug()) { return { input, interceptedToLlm: false }; } if (input.length > 1 && input.startsWith("@") && input[1] !== " ") { input = "@ " + input.slice(1); } let stripped; if (input.startsWith("ns-cmd ")) { stripped = input.slice("ns-cmd ".length).trim(); } else if (input.startsWith("! ")) { stripped = input.slice("! ".length).trim(); } else if (input.length > 1 && input.startsWith("!")) { stripped = input.slice(1).trim(); } if (stripped) { inputMode.setLLM(); return { input: stripped, interceptedToLlm: true }; } return { input, interceptedToLlm: false }; } /** * Pops the first command, but it some cases splits the first command, and pushes the rest back to the command list * If the command starts with the command prompt itself, slice that off * If the command starts with a NAISYS command, slice that off as welll as it needs to be processed internally by NAISYS and the the shell */ async function popFirstCommand(commandList) { let nextInput = commandList.shift() || ""; nextInput = nextInput.trim(); let input = ""; let splitResult; // If the prompt exists in the input, save if for the next run const userHostPrompt = promptBuilder.getUserHostPrompt(); const nextPromptPos = nextInput.indexOf(userHostPrompt); const newLinePos = nextInput.indexOf("\n"); if (nextPromptPos == 0) { const pathPrompt = await promptBuilder.getUserHostPathPrompt(); // Check working directory is the same if (nextInput.startsWith(pathPrompt)) { // Slice nextInput after $ const endPrompt = nextInput.indexOf("$", pathPrompt.length); nextInput = nextInput.slice(endPrompt + 1).trim(); splitResult = "inputInPrompt"; } // Else prompt did not match, stop processing input else { splitResult = "inputPromptMismatch"; } } // Most custom NAISYS commands are single line, but comment in quotes can span multiple lines so we need to handle that // because often the LLM puts shell commands after the comment else if (nextInput.startsWith(`ns-comment "`)) { // Find next double quote in nextInput that isn't escaped let endQuote = nextInput.indexOf(`"`, 12); while (endQuote > 0 && nextInput[endQuote - 1] === "\\") { endQuote = nextInput.indexOf(`"`, endQuote + 1); } if (endQuote > 0) { input = nextInput.slice(0, endQuote + 1); nextInput = nextInput.slice(endQuote + 1).trim(); } else { input = nextInput; nextInput = ""; } } // If the LLM forgets the quote on the comment, treat it as a single line comment // Not something we want to use for multi-line commands like llmail and subagent else if (newLinePos > 0 && (nextInput.startsWith("ns-comment ") || nextInput.startsWith("ns-genimg ") || nextInput.startsWith("ns-look ") || nextInput.startsWith("ns-session "))) { input = nextInput.slice(0, newLinePos); nextInput = nextInput.slice(newLinePos).trim(); } // If shell is suspended, the process can ns-kill/ns-wait the shell, and may run some commands after else if (newLinePos > 0 && shellCommand.isShellSuspended() && (nextInput.startsWith("ns-kill") || nextInput.startsWith("ns-wait"))) { input = nextInput.slice(0, newLinePos); nextInput = nextInput.slice(newLinePos).trim(); } // We can't validate that the working directory in the prompt is good until the commands are processed else if (nextPromptPos > 0) { input = nextInput.slice(0, nextPromptPos); nextInput = nextInput.slice(nextPromptPos).trim(); } // Else process the entire input now else { input = nextInput; nextInput = ""; } if (nextInput) { commandList.unshift(nextInput); } if (!splitResult) { splitResult = nextInput ? "sliced" : "popped"; } return { input, splitResult }; } /** * Expand env vars, command substitutions ($(...)), and ~ in args via the * shell. Parses args first so each one is expanded individually, * preserving argument boundaries (plain `echo $args` flattens all quoting * and breaks multi-word values). */ async function expandShellArgs(cmdArgs) { if (!/[$~]/.test(cmdArgs)) { return cmdArgs; } // Expansion shells out to `printf` in the live shell. If a previous // command left the shell suspended, that call would throw the generic // executeCommand-busy error from a step the agent didn't initiate — // surface a message that names the blocked operation explicitly. if (shellWrapper.isShellSuspended()) { const running = shellWrapper.getCurrentCommandName() || "shell command"; throw `Cannot expand $vars in args while '${running}' is still running. Use 'ns-wait <seconds>' to keep waiting, 'ns-kill' to terminate it, then re-issue the command.`; } const parsedArgs = stringArgv(cmdArgs); const expandedParts = []; let anyExpanded = false; for (let arg of parsedArgs) { // Convert ~ to $HOME so it expands inside double quotes if (arg.startsWith("~")) { arg = "$HOME" + arg.slice(1); } if (/\$/.test(arg)) { // Escape \, ", and ` but keep $ for shell expansion const safe = arg .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/`/g, "\\`"); const expanded = (await shellWrapper.executeCommand(`printf '%s' "${safe}"`)).trimEnd(); expandedParts.push(expanded); anyExpanded = true; } else { expandedParts.push(arg); } } if (!anyExpanded) { return cmdArgs; } // Re-encode as quoted args so downstream stringArgv preserves boundaries return expandedParts .map((part) => { const escaped = part.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `"${escaped}"`; }) .join(" "); } /** Mask input lines while a secure-flagged command (e.g. ns-pty) is mid-flight, * so passwords typed into the suspended shell don't leak into the LLM context, * the persisted log, or the supervisor UI mirror. ns-wait/ns-kill stay visible * since they're control verbs, not credentials. */ function redactIfSecure(input) { if (!shellWrapper.isShellSuspended() || !shellWrapper.isSecureContinuation()) { return input; } const baseCmd = input.trim().split(/\s+/)[0]; if (baseCmd === "ns-wait" || baseCmd === "ns-kill") { return input; } return "******"; } return { processCommand, exportedForTesting: { popFirstCommand, }, }; } //# sourceMappingURL=commandHandler.js.map