naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
318 lines • 15.4 kB
JavaScript
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