naisys 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,18 @@
1
1
  ## NAISYS (Node.js Autonomous Intelligence System)
2
2
 
3
- NAISYS acts as a proxy shell between LLM(s) and a real shell. The goal is to see how far a LLM can
4
- get into writing a website from scratch as well as work with other LLM agents on the same project. Trying to figure
5
- out what works and what doesn't when it comes to 'cognitive architectures' for autonomy. NAISYS isn't
6
- limited to websites, but it seemed like a good place to start.
3
+ NAISYS allows any LLM you want to operate a standard linux shell given your instructions. You can control how much
4
+ to spend, the maximum number of tokens to use per session, how long to wait between commands, etc.. Between each command
5
+ NAISYS will wait a few seconds to accept any input you want to put in yourself in case you want to colllaborate with the
6
+ LLM, give it hints, and/or diagnose the session. Once the LLM reaches the token max you specified for the sesssion it
7
+ will wrap things up, and start a fresh shell for the LLM to continue on its work.
7
8
 
8
- Since the LLM has a limited context, NAISYS takes this into account and helps the LLM
9
- perform 'context friendly' operations. For example reading/writing a file can't use a typical editor like
10
- vim or nano so point the LLM to use cat to read/write files in a single operation.
9
+ NAISYS tries to be a minimal wrapper, just helping the LLM operate in the shell 'better'. Making commands 'context friendly'. For instace if a command is long running, NAISYS will interrupt it, show the LLM the current output, and ask the LLM what it wants to
10
+ do next - wait, kill, or send input. The custom command prompt helps the LLM keep track of its token usage during the session. The 'comment' command helps the LLM think outloud without putting invalid commands into the shell.
11
+
12
+ Some use cases are building websites, diagnosing a system for security concerns, mapping out the topology of the local
13
+ network, learning and performing arbitrary tasks, or just plain exploring the limits of autonomy. NAISYS has a built-in
14
+ system for inter-agent communiation. You can manually startup mulitple instances of NAISYS with different roles, or
15
+ you can allow agents to start their own sub-agents on demand with instructions defined by the LLM itself!
11
16
 
12
17
  [NPM](https://www.npmjs.com/package/naisys) | [Website](https://naisys.org) | [Discord](https://discord.gg/JBUPWSbaEt) | [Demo Video](https://www.youtube.com/watch?v=Ttya3ixjumo)
13
18
 
@@ -194,9 +199,16 @@ initialCommands:
194
199
  - To use NAISYS on Windows you need to run it locally from source (or from within WSL)
195
200
  - Use the above instructions to install locally, and then continue with the instructions below
196
201
  - Install WSL (Windows Subsystem for Linux)
202
+ - Install a Linux distribution, Ubuntu can easily be installed from the Microsoft Store
197
203
  - The `NAISYS_FOLDER` and `WEBSITE_FOLDER` should be set to the WSL path
198
204
  - So `C:\var\naisys` should be `/mnt/c/var/naisys` in the `.env` file
199
205
 
206
+ #### Notes for MacOS users
207
+
208
+ - The browser llmynx requires `timeout` and `lynx`. Run these commands to install them:
209
+ - `brew install coreutils`
210
+ - `brew install lynx`
211
+
200
212
  #### Using NAISYS for a website
201
213
 
202
214
  - Many frameworks come with their own dev server
@@ -205,6 +217,7 @@ initialCommands:
205
217
 
206
218
  ## Changelog
207
219
 
220
+ - 1.6: Support for long running shell commands and full screen terminal output
208
221
  - 1.5: Allow agents to start their own parallel `subagents`
209
222
  - 1.4: `genimg` command for generating images
210
223
  - 1.3: Post-session 'dreaming' as well as a mail 'blackout' period
@@ -78,6 +78,10 @@ export async function processCommand(prompt, consoleInput) {
78
78
  if (!config.endSessionEnabled) {
79
79
  throw 'The "trimsession" command is not enabled in this environment.';
80
80
  }
81
+ if (shellCommand.isShellSuspended()) {
82
+ await contextManager.append("Session cannot be ended while a shell command is active.");
83
+ break;
84
+ }
81
85
  // Don't need to check end line as this is the last command in the context, just read to the end
82
86
  const endSessionNotes = utilities.trimChars(cmdArgs, '"');
83
87
  if (!endSessionNotes) {
@@ -158,8 +162,8 @@ export async function processCommand(prompt, consoleInput) {
158
162
  ? NextCommandAction.ExitApplication
159
163
  : NextCommandAction.Continue;
160
164
  }
161
- }
162
- }
165
+ } // End switch
166
+ } // End loop processing LLM response
163
167
  // display unprocessed lines to aid in debugging
164
168
  if (consoleInput.trim()) {
165
169
  await output.errorAndLog(`Unprocessed LLM response:\n${consoleInput}`);
@@ -227,6 +231,13 @@ async function splitMultipleInputCommands(nextInput) {
227
231
  input = nextInput.slice(0, newLinePos);
228
232
  nextInput = nextInput.slice(newLinePos).trim();
229
233
  }
234
+ // If shell is suspended, the process can kill/wait the shell, and may run some commands after
235
+ else if (newLinePos > 0 &&
236
+ shellCommand.isShellSuspended() &&
237
+ (nextInput.startsWith("kill") || nextInput.startsWith("wait"))) {
238
+ input = nextInput.slice(0, newLinePos);
239
+ nextInput = nextInput.slice(newLinePos).trim();
240
+ }
230
241
  // Else process the entire input now
231
242
  else {
232
243
  input = nextInput;
@@ -19,6 +19,7 @@ import * as utilities from "../utils/utilities.js";
19
19
  import * as commandHandler from "./commandHandler.js";
20
20
  import { NextCommandAction } from "./commandHandler.js";
21
21
  import * as promptBuilder from "./promptBuilder.js";
22
+ import * as shellCommand from "./shellCommand.js";
22
23
  const maxErrorCount = 5;
23
24
  export async function run() {
24
25
  // Show Agent Config exept the agent prompt
@@ -52,6 +53,9 @@ export async function run() {
52
53
  let pauseSeconds = config.agent.debugPauseSeconds;
53
54
  let wakeOnMessage = config.agent.wakeOnMessage;
54
55
  while (nextCommandAction == NextCommandAction.Continue) {
56
+ if (shellCommand.isShellSuspended()) {
57
+ await contextManager.append(`Command still running. Enter 'wait' to continue waiting. 'kill' to terminate. Other input will be sent to the process.`, ContentSource.Console);
58
+ }
55
59
  let prompt = await promptBuilder.getPrompt(pauseSeconds, wakeOnMessage);
56
60
  let consoleInput = "";
57
61
  // Debug command prompt
@@ -8,28 +8,35 @@ import * as inputMode from "../utils/inputMode.js";
8
8
  import { InputMode } from "../utils/inputMode.js";
9
9
  import * as output from "../utils/output.js";
10
10
  import * as shellWrapper from "./shellWrapper.js";
11
- // When actual output is entered by the user we want to cancel any auto-continue timers and/or wake on message
12
- // We don't want to cancel if the user is entering a chords like ctrl+b then down arrow, when using tmux
13
- // This is why we can't put the event listener on the standard process.stdin/keypress event.
14
- // There is no 'data entered' output event so this monkey patch does that
11
+ /**
12
+ * When actual output is entered by the user we want to cancel any auto-continue timers and/or wake on message
13
+ * We don't want to cancel if the user is entering a chords like ctrl+b then down arrow, when using tmux
14
+ * This is why we can't put the event listener on the standard process.stdin/keypress event.
15
+ * There is no 'data entered' output event so this monkey patch does that
16
+ */
17
+ const _writeEventEmitter = new events.EventEmitter();
15
18
  const _writeEventName = "write";
16
- const _outputEmitter = new events.EventEmitter();
17
19
  const _originalWrite = process.stdout.write.bind(process.stdout);
18
20
  process.stdout.write = (...args) => {
19
- _outputEmitter.emit(_writeEventName, false, ...args);
21
+ _writeEventEmitter.emit(_writeEventName, false, ...args);
20
22
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
23
  return _originalWrite.apply(process.stdout, args);
22
24
  };
23
- const _readlineInterface = readline.createInterface({
25
+ /**
26
+ * Tried to make this local and have it cleaned up with close() after using it, but
27
+ * due to the terminal settings below there are bugs with both terminal true and false
28
+ * pause() actually is nice in that it queues up the input, and doesn't allow the user
29
+ * to enter anything while the LLM is working
30
+ */
31
+ const readlineInterface = readline.createInterface({
24
32
  input: process.stdin,
25
33
  output: process.stdout,
34
+ // With this set to ture, after an abort the second input will not be processed, see:
35
+ // https://gist.github.com/swax/964a2488494048c8e03d05493d9370f8
36
+ // With this set to false, the stdout.write event above will not be triggered
37
+ terminal: true,
26
38
  });
27
- // Happens when ctrl+c is pressed
28
- let readlineInterfaceClosed = false;
29
- _readlineInterface.on("close", () => {
30
- readlineInterfaceClosed = true;
31
- output.error("Readline interface closed");
32
- });
39
+ readlineInterface.pause();
33
40
  export async function getPrompt(pauseSeconds, wakeOnMessage) {
34
41
  const promptSuffix = inputMode.current == InputMode.Debug ? "#" : "$";
35
42
  const tokenMax = config.agent.tokenMax;
@@ -60,27 +67,24 @@ export function getInput(commandPrompt, pauseSeconds, wakeOnMessage) {
60
67
  let timeout;
61
68
  let interval;
62
69
  let timeoutCancelled = false;
63
- if (readlineInterfaceClosed) {
64
- output.error("Hanging because readline interface is closed.");
65
- return;
70
+ function clearTimers() {
71
+ timeoutCancelled = true;
72
+ _writeEventEmitter.off(_writeEventName, cancelWaitingForUserInput);
73
+ clearTimeout(timeout);
74
+ clearInterval(interval);
66
75
  }
67
76
  /** Cancels waiting for user input */
68
- function onStdinWrite_cancelTimers(questionAborted, buffer) {
77
+ const cancelWaitingForUserInput = (questionAborted, buffer) => {
69
78
  // Don't allow console escape commands like \x1B[1G to cancel the timeout
70
79
  if (timeoutCancelled || (buffer && !/^[a-zA-Z0-9 ]+$/.test(buffer))) {
71
80
  return;
72
81
  }
73
- timeoutCancelled = true;
74
- _outputEmitter.off(_writeEventName, onStdinWrite_cancelTimers);
75
- clearTimeout(timeout);
76
- clearInterval(interval);
77
- timeout = undefined;
78
- interval = undefined;
82
+ clearTimers();
79
83
  if (questionAborted) {
80
84
  return;
81
85
  }
82
- // Else timeout interrupted by user input, clear out the timeout information from the prompt
83
- // to prevent the user from thinking the timeout still applies
86
+ // Else timeout interrupted by user input
87
+ // Clear out the timeout information from the prompt to prevent the user from thinking the timeout still applies
84
88
  let pausePos = commandPrompt.indexOf("[Paused:");
85
89
  pausePos =
86
90
  pausePos == -1 ? commandPrompt.indexOf("[WakeOnMsg]") : pausePos;
@@ -92,21 +96,22 @@ export function getInput(commandPrompt, pauseSeconds, wakeOnMessage) {
92
96
  process.stdout.write("-".repeat(charsBack - 3));
93
97
  readline.moveCursor(process.stdout, 3, 0);
94
98
  }
95
- }
96
- _readlineInterface.question(chalk.greenBright(commandPrompt), { signal: questionController.signal }, (answer) => {
99
+ };
100
+ readlineInterface.question(chalk.greenBright(commandPrompt), { signal: questionController.signal }, (answer) => {
101
+ clearTimers();
102
+ readlineInterface.pause();
97
103
  resolve(answer);
98
104
  });
99
105
  // If user starts typing in prompt, cancel any auto timeouts or wake on msg
100
- _outputEmitter.on(_writeEventName, onStdinWrite_cancelTimers);
101
- const abortQuestion = () => {
102
- onStdinWrite_cancelTimers(true);
106
+ _writeEventEmitter.on(_writeEventName, cancelWaitingForUserInput);
107
+ function abortQuestion() {
108
+ cancelWaitingForUserInput(true);
103
109
  questionController.abort();
110
+ readlineInterface.pause();
104
111
  resolve("");
105
- };
112
+ }
106
113
  if (pauseSeconds) {
107
- timeout = setTimeout(() => {
108
- abortQuestion();
109
- }, pauseSeconds * 1000);
114
+ timeout = setTimeout(abortQuestion, pauseSeconds * 1000);
110
115
  }
111
116
  if (wakeOnMessage) {
112
117
  // Break timeout if new message is received
@@ -133,7 +138,8 @@ export function getInput(commandPrompt, pauseSeconds, wakeOnMessage) {
133
138
  }
134
139
  export function getCommandConfirmation() {
135
140
  return new Promise((resolve) => {
136
- _readlineInterface.question(chalk.greenBright("Allow command to run? [y/n] "), (answer) => {
141
+ readlineInterface.question(chalk.greenBright("Allow command to run? [y/n] "), (answer) => {
142
+ readlineInterface.pause();
137
143
  resolve(answer);
138
144
  });
139
145
  });
@@ -4,27 +4,35 @@ import * as inputMode from "../utils/inputMode.js";
4
4
  import { InputMode } from "../utils/inputMode.js";
5
5
  import * as utilities from "../utils/utilities.js";
6
6
  import * as shellWrapper from "./shellWrapper.js";
7
+ export const isShellSuspended = () => shellWrapper.isShellSuspended();
7
8
  export async function handleCommand(input) {
8
9
  const cmdParams = input.split(" ");
9
- // Route user to context friendly edit commands that can read/write the entire file in one go
10
- // Having EOF in quotes is important as it prevents the shell from replacing $variables with bash values
11
- if (["nano", "vi", "vim"].includes(cmdParams[0])) {
12
- throw `${cmdParams[0]} not supported. Use \`cat\` to read a file and \`cat > filename << 'EOF'\` to write a file`;
13
- }
14
- if (cmdParams[0] == "lynx" && cmdParams[1] != "--dump") {
15
- throw `Interactive mode with lynx is not supported. Use --dump with lynx to view a website`;
16
- }
17
- if (cmdParams[0] == "exit") {
18
- if (inputMode.current == InputMode.LLM) {
19
- throw "Use 'endsession' to end the session and clear the console log.";
10
+ let response;
11
+ if (!isShellSuspended()) {
12
+ if (["nano", "vi", "vim"].includes(cmdParams[0])) {
13
+ // Route user to context friendly edit commands that can read/write the entire file in one go
14
+ // Having EOF in quotes is important as it prevents the shell from replacing $variables with bash values
15
+ throw `${cmdParams[0]} not supported. Use \`cat\` to read a file and \`cat > filename << 'EOF'\` to write a file`;
20
16
  }
21
- // Only the debug user is allowed to exit the shell
22
- else if (inputMode.current == InputMode.Debug) {
23
- await shellWrapper.terminate();
24
- return true;
17
+ if (cmdParams[0] == "lynx" && cmdParams[1] != "--dump") {
18
+ throw `Interactive mode with lynx is not supported. Use --dump with lynx to view a website`;
25
19
  }
20
+ if (cmdParams[0] == "exit") {
21
+ if (inputMode.current == InputMode.LLM) {
22
+ throw "Use 'endsession' to end the session and clear the console log.";
23
+ }
24
+ // Only the debug user is allowed to exit the shell
25
+ else if (inputMode.current == InputMode.Debug) {
26
+ await shellWrapper.terminate();
27
+ return true;
28
+ }
29
+ }
30
+ response = await shellWrapper.executeCommand(input);
31
+ }
32
+ // Else shell is suspended, continue
33
+ else {
34
+ response = await shellWrapper.continueCommand(input);
26
35
  }
27
- let response = await shellWrapper.executeCommand(input);
28
36
  let outputLimitExceeded = false;
29
37
  const tokenCount = utilities.getTokenCount(response);
30
38
  // Prevent too much output from blowing up the context
@@ -41,9 +49,9 @@ export async function handleCommand(input) {
41
49
  }
42
50
  if (response.endsWith(": command not found")) {
43
51
  response +=
44
- "Please enter a valid Linux or NAISYS command after the prompt. Use the 'comment' command for thoughts.";
52
+ "\nPlease enter a valid Linux or NAISYS command after the prompt. Use the 'comment' command for thoughts.";
45
53
  }
46
- // todo move this into the command handler to remove the context manager dependency
54
+ // TODO: move this into the command handler to remove the context manager dependency
47
55
  await contextManager.append(response);
48
56
  return false;
49
57
  }
@@ -1,8 +1,12 @@
1
+ import xterm from "@xterm/headless";
1
2
  import { spawn } from "child_process";
2
3
  import * as fs from "fs";
3
4
  import * as os from "os";
5
+ import stripAnsi from "strip-ansi";
6
+ import treeKill from "tree-kill";
4
7
  import * as config from "../config.js";
5
8
  import * as output from "../utils/output.js";
9
+ import * as pathService from "../utils/pathService.js";
6
10
  import { NaisysPath } from "../utils/pathService.js";
7
11
  var ShellEvent;
8
12
  (function (ShellEvent) {
@@ -14,31 +18,35 @@ let _process;
14
18
  let _currentProcessId;
15
19
  let _commandOutput = "";
16
20
  let _currentPath;
21
+ let _terminal;
22
+ let _bufferChangeEvent;
23
+ let _currentBufferType = "normal";
17
24
  let _resolveCurrentCommand;
18
25
  let _currentCommandTimeout;
19
- let _startTime;
20
26
  /** How we know the command has completed when running the command inside a shell like bash or wsl */
21
27
  const _commandDelimiter = "__COMMAND_END_X7YUTT__";
28
+ let _wrapperSuspended = false;
29
+ const _queuedOutput = [];
22
30
  async function ensureOpen() {
23
31
  if (_process) {
24
32
  return;
25
33
  }
26
34
  resetCommand();
27
- const spawnProcess = os.platform() === "win32" ? "wsl" : "bash";
28
- _process = spawn(spawnProcess, [], { stdio: "pipe" });
35
+ const spawnCmd = os.platform() === "win32" ? "wsl" : "bash";
36
+ _process = spawn(spawnCmd, [], { stdio: "pipe" });
29
37
  const pid = _process.pid;
30
38
  if (!pid) {
31
39
  throw "Shell process failed to start";
32
40
  }
33
41
  _currentProcessId = pid;
34
42
  _process.stdout.on("data", (data) => {
35
- processOutput(data.toString(), ShellEvent.Ouptput, pid);
43
+ processOutput(data, ShellEvent.Ouptput, pid);
36
44
  });
37
45
  _process.stderr.on("data", (data) => {
38
- processOutput(data.toString(), ShellEvent.Error, pid);
46
+ processOutput(data, ShellEvent.Error, pid);
39
47
  });
40
48
  _process.on("close", (code) => {
41
- processOutput(`${code}`, ShellEvent.Exit, pid);
49
+ processOutput(Buffer.from(`${code}`), ShellEvent.Exit, pid);
42
50
  });
43
51
  // Init users home dir on first run, on shell crash/rerun go back to the current path
44
52
  if (!_currentPath) {
@@ -61,7 +69,12 @@ function errorIfNotEmpty(response) {
61
69
  output.error(response);
62
70
  }
63
71
  }
64
- function processOutput(dataStr, eventType, pid) {
72
+ function processOutput(rawDataStr, eventType, pid) {
73
+ if (_wrapperSuspended) {
74
+ _queuedOutput.push({ rawDataStr, eventType, pid });
75
+ return;
76
+ }
77
+ let dataStr = stripAnsi(rawDataStr.toString());
65
78
  if (pid != _currentProcessId) {
66
79
  output.comment(`Ignoring '${eventType}' from old shell process ${pid}: ` + dataStr);
67
80
  return;
@@ -72,75 +85,170 @@ function processOutput(dataStr, eventType, pid) {
72
85
  return;
73
86
  }
74
87
  if (eventType === ShellEvent.Exit) {
75
- output.error("SHELL EXITED. PID: " + _process?.pid + " CODE: " + dataStr);
76
- const elapsedSeconds = _startTime
77
- ? Math.round((new Date().getTime() - _startTime.getTime()) / 1000)
78
- : -1;
79
- const outputWithError = _commandOutput.trim() +
80
- `\nNAISYS: Command hit time out limit after ${elapsedSeconds} seconds. If possible figure out how to run the command faster or break it up into smaller parts.`;
88
+ output.error(`SHELL EXIT. PID: ${_process?.pid}, CODE: ${rawDataStr}`);
89
+ let finalOutput = _currentBufferType == "alternate"
90
+ ? _getTerminalActiveBuffer()
91
+ : _commandOutput.trim();
92
+ finalOutput += `\nNAISYS: Command killed.`;
81
93
  resetProcess();
82
- _resolveCurrentCommand(outputWithError);
94
+ _completeCommand(finalOutput);
83
95
  return;
84
96
  }
85
- else {
86
- // Extend the timeout of the current command
87
- setOrExtendShellTimeout();
97
+ // Should only happen back in normal mode, so we don't need to modify the rawDataStr
98
+ let endDelimiterHit = false;
99
+ const endDelimiterPos = dataStr.indexOf(_commandDelimiter);
100
+ if (endDelimiterPos != -1 &&
101
+ // Quotes will only precede the delimiter if the echo command got in the output, so don't count it
102
+ // For example running nano or vi will cause this
103
+ dataStr[endDelimiterPos - 1] != '"') {
104
+ endDelimiterHit = true;
105
+ dataStr = dataStr.slice(0, endDelimiterPos);
106
+ // If it does happen somehow, log it so I can figure out why/how and what to do about it
107
+ if (_currentBufferType == "alternate") {
108
+ output.error("UNEXPECTED END DELIMITER IN ALTERNATE BUFFER: " + dataStr);
109
+ }
110
+ }
111
+ // If we're in alternate mode, just write the data to the terminal
112
+ // When the buffer changes back to normal, the output will be copied back to the command output
113
+ if (_currentBufferType == "normal") {
88
114
  _commandOutput += dataStr;
89
115
  }
90
- const delimiterIndex = _commandOutput.indexOf(_commandDelimiter);
91
- if (delimiterIndex != -1) {
92
- // trim everything after delimiter
93
- _commandOutput = _commandOutput.slice(0, delimiterIndex);
94
- const response = _commandOutput.trim();
116
+ // TODO: get token size of buffer, if too big, switch it front/middle/back
117
+ _terminal?.write(rawDataStr); // Not synchronous, second param takes a call back, don't need to handle it AFAIK
118
+ if (endDelimiterHit) {
119
+ const finalOutput = _commandOutput.trim();
95
120
  resetCommand();
96
- _resolveCurrentCommand(response);
121
+ _completeCommand(finalOutput);
97
122
  }
98
123
  }
99
124
  export async function executeCommand(command) {
125
+ if (_wrapperSuspended) {
126
+ throw "Use continueCommand to send input to a shell command in process";
127
+ }
128
+ command = command.trim();
129
+ _lastCommand = command; // Set here before it gets reset by the multi line script below
100
130
  await ensureOpen();
101
- if (_currentPath && command.trim().split("\n").length > 1) {
131
+ if (_currentPath && command.split("\n").length > 1) {
102
132
  command = await putMultilineCommandInAScript(command);
103
133
  }
104
134
  return new Promise((resolve, reject) => {
105
135
  _resolveCurrentCommand = resolve;
106
- const commandWithDelimiter = `${command.trim()}\necho "${_commandDelimiter} LINE:\${LINENO}"\n`;
107
136
  if (!_process) {
108
137
  reject("Shell process is not open");
109
138
  return;
110
139
  }
140
+ const commandWithDelimiter = `${command}\necho "${_commandDelimiter}"\n`;
111
141
  _process.stdin.write(commandWithDelimiter);
112
- _startTime = new Date();
113
- // If no response, kill and reset the shell, often hanging on some unescaped input
114
- setOrExtendShellTimeout();
142
+ // Set timeout to wait for response from command
143
+ setCommandTimeout();
115
144
  });
116
145
  }
117
- function setOrExtendShellTimeout() {
118
- // Don't extend if we've been waiting longer than the max timeout seconds
119
- const timeWaiting = new Date().getTime() - (_startTime?.getTime() || 0);
120
- if (!_process?.pid ||
121
- timeWaiting > config.shellCommand.maxTimeoutSeconds * 1000) {
122
- return;
146
+ /** The LLM made its decision on how it wants to continue with the shell that previously timed out */
147
+ export function continueCommand(command) {
148
+ if (!_wrapperSuspended) {
149
+ throw "Shell is not suspended, use execute command";
150
+ }
151
+ command = command.trim();
152
+ _wrapperSuspended = false;
153
+ let choice;
154
+ if (command != "wait" && command != "kill") {
155
+ choice = "input";
156
+ }
157
+ else {
158
+ choice = command;
159
+ }
160
+ return new Promise((resolve, reject) => {
161
+ _resolveCurrentCommand = resolve;
162
+ // If new output from the shell was queued while waiting for the LLM to decide what to do
163
+ if (_queuedOutput.length > 0) {
164
+ for (const output of _queuedOutput) {
165
+ processOutput(output.rawDataStr, output.eventType, output.pid);
166
+ }
167
+ _queuedOutput.length = 0;
168
+ // If processing queue resolved the command, then we're done
169
+ if (!_resolveCurrentCommand) {
170
+ return;
171
+ }
172
+ // Used to return here if LLM was sending if output was generated while waiting for the LLM
173
+ // In normal mode this would make the log confusing and out of order
174
+ // But since we only use the terminal in alternate mode, this is fine and works
175
+ // with commands like `mtr` changing the display type
176
+ }
177
+ // LLM wants to wait for more output
178
+ if (choice == "wait") {
179
+ setCommandTimeout();
180
+ return;
181
+ }
182
+ // Else LLM wants to kill the process
183
+ else if (choice == "kill") {
184
+ if (!_currentProcessId) {
185
+ reject("No process to kill");
186
+ }
187
+ else if (resetShell(_currentProcessId)) {
188
+ return; // Wait for exit event
189
+ }
190
+ else {
191
+ reject("Unable to kill. Process not found");
192
+ }
193
+ return;
194
+ }
195
+ // Else LLM wants to send input to the process
196
+ else {
197
+ if (!_process) {
198
+ reject("Shell process is not open");
199
+ return;
200
+ }
201
+ _process.stdin.write(command + "\n");
202
+ _lastCommand = command;
203
+ setCommandTimeout();
204
+ }
205
+ });
206
+ }
207
+ let _startCommandTime;
208
+ /** Pulled out because for commands like 'wait' we want to vary the run time based on the 'last command' */
209
+ let _lastCommand;
210
+ function setCommandTimeout() {
211
+ _startCommandTime = new Date();
212
+ let timeoutSeconds = config.shellCommand.timeoutSeconds;
213
+ if (config.shellCommand.longRunningCommands.some((cmd) => _lastCommand?.startsWith(cmd))) {
214
+ timeoutSeconds = config.shellCommand.longRunningTimeoutSeconds;
123
215
  }
124
- // Define the pid for use in the timeout closure, as _process.pid may change
125
- const pid = _process.pid;
126
- clearTimeout(_currentCommandTimeout);
127
216
  _currentCommandTimeout = setTimeout(() => {
128
- resetShell(pid);
129
- }, config.shellCommand.timeoutSeconds * 1000);
217
+ returnControlToNaisys();
218
+ }, timeoutSeconds * 1000);
219
+ }
220
+ function returnControlToNaisys() {
221
+ _wrapperSuspended = true;
222
+ _queuedOutput.length = 0;
223
+ // Flush the output to the consol, and give the LLM instructions of how it might continue
224
+ let outputWithInstruction = _currentBufferType == "alternate"
225
+ ? _getTerminalActiveBuffer()
226
+ : _commandOutput.trim();
227
+ _commandOutput = "";
228
+ // Don't clear the alternate buffer, it's a special terminal full screen mode that the
229
+ // LLM might want to see updates too
230
+ if (_currentBufferType != "alternate") {
231
+ resetTerminal();
232
+ }
233
+ const waitSeconds = Math.round((new Date().getTime() - _startCommandTime.getTime()) / 1000);
234
+ outputWithInstruction += `\nNAISYS: Command interrupted after waiting ${waitSeconds} seconds.`;
235
+ _completeCommand(outputWithInstruction);
130
236
  }
131
237
  function resetShell(pid) {
132
238
  if (!_process || _process.pid != pid) {
133
239
  output.comment("Ignoring timeout for old shell process " + pid);
134
- return;
240
+ return false;
135
241
  }
136
- // There is still an issue here when running on linux where if a command like 'ping' is running
137
- // then kill() won't actually kill the 'bash' process hosting the ping, it will just hang here indefinitely
138
- // A not fail proof workaround is to tell the LLM to prefix long running commands with 'timeout 10s' or similar
139
- const killResponse = _process.kill();
140
- output.error(`KILL SIGNAL SENT TO PID: ${_process.pid}, RESPONSE: ${killResponse ? "SUCCESS" : "FAILED"}`);
242
+ output.error(`KILL-TREE SIGNAL SENT TO PID: ${_process.pid}`);
243
+ treeKill(pid, "SIGKILL");
141
244
  // Should trigger the process close event from here
245
+ return true;
142
246
  }
143
247
  export async function getCurrentPath() {
248
+ // If wrapper suspended just give the last known path
249
+ if (_wrapperSuspended) {
250
+ return _currentPath;
251
+ }
144
252
  await ensureOpen();
145
253
  _currentPath = await executeCommand("pwd");
146
254
  return _currentPath;
@@ -152,18 +260,40 @@ export async function terminate() {
152
260
  }
153
261
  function resetCommand() {
154
262
  _commandOutput = "";
155
- _startTime = undefined;
263
+ resetTerminal();
156
264
  clearTimeout(_currentCommandTimeout);
157
265
  }
266
+ function resetTerminal() {
267
+ _bufferChangeEvent?.dispose();
268
+ _terminal?.dispose();
269
+ _terminal = new xterm.Terminal({
270
+ allowProposedApi: true,
271
+ rows: process.stdout.rows,
272
+ cols: process.stdout.columns,
273
+ });
274
+ _currentBufferType = "normal";
275
+ _bufferChangeEvent = _terminal.buffer.onBufferChange((buffer) => {
276
+ // If changing back to normal, copy the alternate buffer back to the output
277
+ // so it shows up when the command is resolved
278
+ if (_currentBufferType == "alternate" && buffer.type == "normal") {
279
+ output.comment("NAISYS: BUFFER CHANGE BACK TO NORMAL");
280
+ _commandOutput += "\n" + _getTerminalActiveBuffer() + "\n";
281
+ }
282
+ _currentBufferType = buffer.type;
283
+ });
284
+ }
158
285
  function resetProcess() {
159
286
  resetCommand();
160
287
  _process?.removeAllListeners();
161
288
  _process = undefined;
289
+ _terminal?.dispose();
290
+ _terminal = undefined;
162
291
  }
163
292
  /** Wraps multi line commands in a script to make it easier to diagnose the source of errors based on line number
164
293
  * May also help with common escaping errors */
165
294
  function putMultilineCommandInAScript(command) {
166
- const scriptPath = new NaisysPath(`${config.naisysFolder}/home/${config.agent.username}/.command.tmp.sh`);
295
+ const scriptPath = new NaisysPath(`${config.naisysFolder}/agent-data/${config.agent.username}/multiline-command.sh`);
296
+ pathService.ensureFileDirExists(scriptPath);
167
297
  // set -e causes the script to exit on the first error
168
298
  const scriptContent = `#!/bin/bash
169
299
  set -e
@@ -176,4 +306,32 @@ ${command.trim()}`;
176
306
  // `source` will run the script in the current shell, so any change directories in the script will persist in the current shell
177
307
  return `PATH=${config.binPath}:$PATH source ${scriptPath.getNaisysPath()}`;
178
308
  }
309
+ function _completeCommand(output) {
310
+ if (!_resolveCurrentCommand) {
311
+ throw "No command to resolve";
312
+ }
313
+ _resolveCurrentCommand(output);
314
+ _resolveCurrentCommand = undefined;
315
+ }
316
+ export function isShellSuspended() {
317
+ return _wrapperSuspended;
318
+ }
319
+ /**
320
+ * The alternate/active buffer is a special terminal mode that runs full screen
321
+ * independent of the 'normal' buffer that is more like a log
322
+ */
323
+ function _getTerminalActiveBuffer() {
324
+ let output = "";
325
+ const bufferLineCount = _terminal?.buffer.normal?.length || 0;
326
+ for (let i = 0; i < bufferLineCount; i++) {
327
+ const line = _terminal?.buffer.alternate
328
+ ?.getLine(i)
329
+ ?.translateToString()
330
+ .trim();
331
+ if (line) {
332
+ output += line + "\n";
333
+ }
334
+ }
335
+ return output.trim();
336
+ }
179
337
  //# sourceMappingURL=shellWrapper.js.map
package/dist/config.js CHANGED
@@ -16,13 +16,15 @@ export const shellCommand = {
16
16
  outputTokenMax: 3000,
17
17
  /** The time NAISYS will wait for new shell output before giving up */
18
18
  timeoutSeconds: 15,
19
- /** The max time NAISYS will wait for a shell command to complete */
20
- maxTimeoutSeconds: 60,
19
+ /** These commands have their own timeout so the LLM doesn't have to continually waste tokens on wait commands */
20
+ longRunningCommands: ["nmap", "traceroute", "tracepath", "mtr"],
21
+ longRunningTimeoutSeconds: 120,
21
22
  };
22
23
  /** Web pages loaded with llmynx will be reduced down to around this number of tokens */
23
24
  export const webTokenMax = 2500;
24
25
  export const endSessionEnabled = true;
25
26
  export const mailEnabled = true;
27
+ export const webEnabled = true;
26
28
  /** Experimental, live updating spot in the context for the LLM to put files, to avoid having to continually cat */
27
29
  export const workspacesEnabled = false;
28
30
  /** Experimental, allow LLM to trim prompts from it's own session context */
@@ -19,6 +19,8 @@ async function init() {
19
19
  const newDbCreated = await dbUtils.initDatabase(_dbFilePath);
20
20
  await usingDatabase(async (db) => {
21
21
  if (newDbCreated) {
22
+ // For llmail to work, the usernames need to be unique
23
+ // The agentPaths also need to be unique so we know what configuration each agent should use when we restart/reload naisys
22
24
  const createTables = [
23
25
  `CREATE TABLE Users (
24
26
  id INTEGER PRIMARY KEY,
@@ -65,16 +67,22 @@ async function init() {
65
67
  ]);
66
68
  // If user not in database, add them
67
69
  if (!user) {
68
- const insertedUser = await db.run("INSERT INTO Users (username, title, agentPath, leadUsername) VALUES (?, ?, ?, ?)", [
69
- config.agent.username,
70
- config.agent.title,
71
- config.agent.hostpath,
72
- config.agent.leadAgent,
73
- ]);
74
- if (!insertedUser.lastID) {
75
- throw "Error adding local user to llmail database";
70
+ try {
71
+ const insertedUser = await db.run("INSERT INTO Users (username, title, agentPath, leadUsername) VALUES (?, ?, ?, ?)", [
72
+ config.agent.username,
73
+ config.agent.title,
74
+ config.agent.hostpath,
75
+ config.agent.leadAgent,
76
+ ]);
77
+ if (!insertedUser.lastID) {
78
+ throw "Error adding local user to llmail database";
79
+ }
80
+ _myUserId = insertedUser.lastID;
81
+ }
82
+ catch (e) {
83
+ throw (`A user already exists in the database with the agent path (${config.agent.hostpath})\n` +
84
+ `Either create a new agent config file, or delete the ${config.naisysFolder} folder to reset the database.`);
76
85
  }
77
- _myUserId = insertedUser.lastID;
78
86
  }
79
87
  // Else already exists, validate it's config path is correct
80
88
  else {
@@ -46,6 +46,7 @@ export async function handleCommand(args) {
46
46
  if (!argParams[0]) {
47
47
  argParams[0] = "help";
48
48
  }
49
+ let errorText = "";
49
50
  switch (argParams[0]) {
50
51
  case "help": {
51
52
  let helpOutput = `subagent <command>
@@ -75,6 +76,11 @@ export async function handleCommand(args) {
75
76
  const newParams = argParams.slice(1).join(" ").split('"');
76
77
  const title = newParams[1];
77
78
  const task = newParams[3];
79
+ // Validate title and task set
80
+ if (!title || !task) {
81
+ errorText = "See valid 'create' syntax below:\n";
82
+ break;
83
+ }
78
84
  return await _createAgent(title, task);
79
85
  }
80
86
  case "start": {
@@ -90,10 +96,11 @@ export async function handleCommand(args) {
90
96
  _debugFlushContext(subagentId);
91
97
  return "";
92
98
  }
93
- default:
94
- return ("Error, unknown command. See valid commands below:\n" +
95
- (await handleCommand("help")));
99
+ default: {
100
+ errorText = "Error, unknown command. See valid commands below:\n";
101
+ }
96
102
  }
103
+ return errorText + (await handleCommand("help"));
97
104
  }
98
105
  export function getRunningSubagentNames() {
99
106
  return _subagents
@@ -112,10 +119,6 @@ export function unreadContextSummary() {
112
119
  .join(" | "));
113
120
  }
114
121
  async function _createAgent(title, taskDescription) {
115
- // Validate title and task set
116
- if (!title || !taskDescription) {
117
- throw "Title and task description must be set";
118
- }
119
122
  // Get available username
120
123
  const usernames = await llmail.getAllUserNames();
121
124
  let agentName = "";
@@ -218,6 +221,6 @@ function _debugFlushContext(subagentId) {
218
221
  subagent.log = "";
219
222
  }
220
223
  function _getSubagentDir() {
221
- return new NaisysPath(`${config.naisysFolder}/home/${config.agent.username}/.subagents`);
224
+ return new NaisysPath(`${config.naisysFolder}/agent-data/${config.agent.username}/subagents`);
222
225
  }
223
226
  //# sourceMappingURL=subagent.js.map
@@ -8,7 +8,7 @@ export var LlmApiType;
8
8
  const llmModels = [
9
9
  {
10
10
  key: "gpt4turbo",
11
- name: "gpt-4-0125-preview",
11
+ name: "gpt-4-turbo",
12
12
  apiType: LlmApiType.OpenAI,
13
13
  maxTokens: 128000,
14
14
  // Prices are per 1M tokens
@@ -17,7 +17,7 @@ const llmModels = [
17
17
  },
18
18
  {
19
19
  key: "gpt3turbo",
20
- name: "gpt-3.5-turbo-0125",
20
+ name: "gpt-3.5-turbo",
21
21
  apiType: LlmApiType.OpenAI,
22
22
  maxTokens: 16000,
23
23
  // Prices are per 1M tokens
@@ -49,8 +49,8 @@ const llmModels = [
49
49
  apiType: LlmApiType.Google,
50
50
  maxTokens: 30720,
51
51
  // 60 queries per minute free then the prices below are per 1000 characters
52
- inputCost: 0.50,
53
- outputCost: 1.50,
52
+ inputCost: 0.5,
53
+ outputCost: 1.5,
54
54
  },
55
55
  {
56
56
  key: "claude3opus",
@@ -57,8 +57,11 @@ async function sendWithOpenAiCompatible(modelKey, systemMessage, context, source
57
57
  })),
58
58
  ],
59
59
  });
60
+ if (!model.inputCost && !model.outputCost) {
61
+ // Don't cost models with no costs
62
+ }
60
63
  // Total up costs, prices are per 1M tokens
61
- if (chatResponse.usage) {
64
+ else if (chatResponse.usage) {
62
65
  const cost = chatResponse.usage.prompt_tokens * model.inputCost +
63
66
  chatResponse.usage.completion_tokens * model.outputCost;
64
67
  await costTracker.recordCost(cost / 1000000, source, model.name);
@@ -118,7 +121,7 @@ async function sendWithGoogle(modelKey, systemMessage, context, source) {
118
121
  throw `Google API Request Blocked, ${result.response.promptFeedback.blockReason}`;
119
122
  }
120
123
  const responseText = result.response.text();
121
- // todo: take into account google allows 60 queries per minute for free for 1.0, 2 queries/min for 1.5
124
+ // TODO: take into account google allows 60 queries per minute for free for 1.0, 2 queries/min for 1.5
122
125
  // AFAIK Google API doesn't provide usage data, so we have to estimate it ourselves
123
126
  const inputTokenCount = getTokenCount(systemMessage) +
124
127
  context
@@ -18,6 +18,10 @@ let llmailCmd = "";
18
18
  if (config.mailEnabled) {
19
19
  llmailCmd = `\n llmail: A local mail system for communicating with your team`;
20
20
  }
21
+ let llmynxCmd = "";
22
+ if (config.webEnabled) {
23
+ llmynxCmd = `\n llmynx: A context optimized web browser. Enter 'llmynx help' to learn how to use it`;
24
+ }
21
25
  let workspaces = "";
22
26
  if (config.workspacesEnabled) {
23
27
  workspaces = `\nWorkspaces:`;
@@ -71,8 +75,7 @@ LINUX Commands:
71
75
  vi and nano are not supported
72
76
  Read files with cat. Write files with \`cat > filename << 'EOF'\`
73
77
  Do not input notes after the prompt. Only valid commands.
74
- NAISYS Commands: (cannot be used with other commands on the same prompt)${llmailCmd}${subagentNote}
75
- llmynx: A context optimized web browser. Enter 'llmynx help' to learn how to use it${genImgCmd}
78
+ NAISYS Commands: (cannot be used with other commands on the same prompt)${llmailCmd}${subagentNote}${llmynxCmd}${genImgCmd}
76
79
  comment "<thought>": Any non-command output like thinking out loud, prefix with the 'comment' command
77
80
  pause <seconds>: Pause for <seconds>${trimSession}${endsession}
78
81
  Tokens:
@@ -91,7 +91,7 @@ export function roleToSource(role) {
91
91
  }
92
92
  /** Write entire context to a file in the users home directory */
93
93
  export function recordContext(contextLog) {
94
- const filePath = new NaisysPath(`${config.naisysFolder}/home/${config.agent.username}/.current-context.txt`);
94
+ const filePath = new NaisysPath(`${config.naisysFolder}/agent-data/${config.agent.username}/current-context.txt`);
95
95
  pathService.ensureFileDirExists(filePath);
96
96
  fs.writeFileSync(filePath.toHostPath(), contextLog);
97
97
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "naisys",
3
3
  "description": "Node.js Autonomous Intelligence System",
4
- "version": "1.5.0",
4
+ "version": "1.6.0",
5
5
  "type": "module",
6
6
  "main": "dist/naisys.js",
7
7
  "preferGlobal": true,
@@ -9,7 +9,7 @@
9
9
  "naisys": "bin/naisys"
10
10
  },
11
11
  "scripts": {
12
- "compile/run/attachable": "tsc && node --inspect dist/naisys.js ./agents/assistant.yaml",
12
+ "compile/run/attachable": "tsc && node --inspect dist/naisys.js ./agents/netmap.yaml",
13
13
  "agent:assistant": "node dist/naisys.js ./agents/assistant.yaml",
14
14
  "agent:nightwatch": "node dist/naisys.js ./agents/nightwatch.yaml",
15
15
  "clean": "rm -rf dist",
@@ -42,29 +42,32 @@
42
42
  "devDependencies": {
43
43
  "@types/escape-html": "1.0.4",
44
44
  "@types/js-yaml": "4.0.9",
45
- "@types/node": "20.12.4",
45
+ "@types/node": "20.12.7",
46
46
  "@types/text-table": "0.2.5",
47
- "@typescript-eslint/eslint-plugin": "7.5.0",
48
- "@typescript-eslint/parser": "7.5.0",
49
- "eslint": "8.57.0",
47
+ "@typescript-eslint/eslint-plugin": "7.7.0",
48
+ "@typescript-eslint/parser": "7.7.0",
49
+ "eslint": "8.56.0",
50
50
  "jest": "29.7.0",
51
51
  "prettier": "3.2.5",
52
52
  "ts-node": "10.9.2",
53
- "typescript": "5.4.4"
53
+ "typescript": "5.4.5"
54
54
  },
55
55
  "dependencies": {
56
- "@anthropic-ai/sdk": "0.20.1",
57
- "@google/generative-ai": "0.5.0",
56
+ "@anthropic-ai/sdk": "0.20.5",
57
+ "@google/generative-ai": "0.7.1",
58
+ "@xterm/headless": "5.5.0",
58
59
  "chalk": "5.3.0",
59
60
  "commander": "12.0.0",
60
61
  "dotenv": "16.4.5",
61
62
  "escape-html": "1.0.3",
62
63
  "js-yaml": "4.1.0",
63
- "openai": "4.33.0",
64
+ "openai": "4.36.0",
64
65
  "sharp": "0.33.3",
65
66
  "sqlite": "5.1.1",
66
67
  "sqlite3": "5.1.7",
68
+ "strip-ansi": "7.1.0",
67
69
  "text-table": "0.2.0",
68
- "tiktoken": "1.0.13"
70
+ "tiktoken": "1.0.14",
71
+ "tree-kill": "1.2.2"
69
72
  }
70
73
  }