@calamity-inc/slave 0.3.0 → 0.3.2

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.
Files changed (3) hide show
  1. package/README.md +14 -3
  2. package/index.js +115 -47
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,10 +30,11 @@ Options:
30
30
  Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
31
31
 
32
32
  Subcommands:
33
- - `--create-profile <name> <provider> <model>` — creates a profile
33
+ - `--create-profile <name> <provider> <model>` — create a profile
34
34
  - `provider` can be `openai`, `anthropic`, or an OpenAI-compatible base URL like `http://127.0.0.1:1234/v1` for [LM Studio](https://lmstudio.ai/developers).
35
- - `--delete-profile <name>` — deletes a profile
36
- - `--update-api-key <provider>` — updates an API Key
35
+ - `--list-profiles` — list all profiles
36
+ - `--delete-profile <name>` — delete a profile
37
+ - `--update-api-key <provider>` — update an API Key
37
38
 
38
39
  ## Security
39
40
 
@@ -46,3 +47,13 @@ The model can't run commands on your system; it only gets limited access to your
46
47
  However, these restrictions are only as good as your practices; you should:
47
48
  - Only run `slave` in Git-tracked folders and be sure that anything you don't want to lose is committed or at least staged so you can always revert any changes made by the model.
48
49
  - Review changes to your code before running anything, including tests.
50
+
51
+ ## Logs
52
+
53
+ Each invocation that prompts a model creates a log file:
54
+
55
+ - Linux: `~/.local/state/slave-nodejs`, or `$XDG_STATE_HOME/slave-nodejs` if `XDG_STATE_HOME` is set
56
+ - macOS: `~/Library/Logs/slave-nodejs`
57
+ - Windows: `%LOCALAPPDATA%\slave-nodejs\Log`
58
+
59
+ When a new log file is created, any log file older than 30 days is deleted.
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync, spawnSync } from "node:child_process";
4
+ import { randomInt } from "node:crypto";
4
5
  import fs from "node:fs";
5
6
  import path from "node:path";
6
7
  import { createInterface } from "node:readline/promises";
@@ -13,8 +14,8 @@ import { marked } from "marked";
13
14
  import { markedTerminal } from "marked-terminal";
14
15
  import OpenAI from "openai";
15
16
 
17
+ const { data: configDir, log: logDir } = envPaths("slave");
16
18
 
17
- const configDir = envPaths("slave").data;
18
19
  const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
19
20
  state.profiles ??= {};
20
21
  state.apiKeys ??= {};
@@ -31,7 +32,37 @@ const promptForApiKey = async (provider) => {
31
32
  const saveState = () => {
32
33
  fs.mkdirSync(configDir, { recursive: true });
33
34
  fs.writeFileSync(path.join(configDir, "state.json"), JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
34
- fs.chmodSync(path.join(configDir, "state.json"), 0o600);
35
+ };
36
+
37
+ const rotateLogs = () => {
38
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
39
+ for (const name of fs.readdirSync(logDir)) {
40
+ try {
41
+ if (fs.statSync(path.join(logDir, name)).mtimeMs < cutoff) {
42
+ fs.unlinkSync(path.join(logDir, name));
43
+ }
44
+ }
45
+ catch (e) {
46
+ // another process may have removed it already; ignore.
47
+ }
48
+ }
49
+ };
50
+
51
+ let logFh;
52
+ const logWrite = (line) => {
53
+ if (!logFh) {
54
+ const now = new Date();
55
+ const pad = (n) => String(n).padStart(2, '0');
56
+ const logfileName = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}Z.txt`;
57
+ fs.mkdirSync(logDir, { recursive: true });
58
+ logFh = fs.openSync(path.join(logDir, logfileName), "a", 0o600);
59
+ rotateLogs();
60
+ }
61
+ fs.writeSync(logFh, line + "\n");
62
+ };
63
+ const logWriteBoth = (line) => {
64
+ console.log(line);
65
+ logWrite(line);
35
66
  };
36
67
 
37
68
  const slaveignore = ignore();
@@ -79,17 +110,12 @@ const writeFileFromModel = (filePath, data, usesCRLF) => {
79
110
  fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
80
111
  };
81
112
 
82
- let used_docker;
113
+ let sandbox_name;
83
114
  const ensureSandbox = () => {
84
- if (!used_docker) {
85
- try {
86
- // `docker pull ghcr.io/calamity-inc/slave-sandbox` is not needed to get the image, but would be needed to update it.
87
- execFileSync("docker", ["run", "-d", "--name", "slave-sandbox", "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
88
- }
89
- catch (e) {
90
- // container may already exist if it was not cleaned up previously. this is okay-ish.
91
- }
92
- used_docker = true;
115
+ if (!sandbox_name) {
116
+ sandbox_name = "slave-sandbox-" + Array.from({ length: 10 }, () => "abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("");
117
+ // `docker pull ghcr.io/calamity-inc/slave-sandbox` is not needed to get the image, but would be needed to update it.
118
+ execFileSync("docker", ["run", "-d", "--name", sandbox_name, "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
93
119
  }
94
120
  };
95
121
 
@@ -132,9 +158,14 @@ const functions = {
132
158
  .filter(file => !isOutOfReach(file));
133
159
  const res = [];
134
160
  for (const file of files) {
135
- const matches = readFileForModel(file).split(substring).length - 1;
136
- if (matches) {
137
- res.push(`${file} has ${matches} match${matches == 1 ? "" : "es"}`);
161
+ try {
162
+ const matches = readFileForModel(file).split(substring).length - 1;
163
+ if (matches) {
164
+ res.push(`${file} has ${matches} match${matches == 1 ? "" : "es"}`);
165
+ }
166
+ }
167
+ catch (e) {
168
+ // file might be busy, skip it
138
169
  }
139
170
  }
140
171
  return res.join("\n");
@@ -147,7 +178,11 @@ const functions = {
147
178
  if (!fs.existsSync(path) || !fs.statSync(path).isFile()) {
148
179
  return "No such file";
149
180
  }
150
- return readFileForModel(path);
181
+ const cont = readFileForModel(path);
182
+ if (cont.length > 10485760) {
183
+ return "File is over 10485760 characters long";
184
+ }
185
+ return cont;
151
186
  },
152
187
  writeFile: ({ path, data }) => {
153
188
  path = fixPath(path, "write");
@@ -174,7 +209,11 @@ const functions = {
174
209
  },
175
210
  sandboxExec: ({ command, args }) => {
176
211
  ensureSandbox();
177
- const result = spawnSync("docker", [ "exec", "slave-sandbox", command, ...args ], { encoding: "utf8" });
212
+ args ??= [];
213
+ if (typeof args == "string") {
214
+ args = JSON.parse(args);
215
+ }
216
+ const result = spawnSync("docker", [ "exec", sandbox_name, command, ...args ], { encoding: "utf8" });
178
217
  const output = (result.stdout ?? "") + (result.stderr ?? "");
179
218
  if (result.error) {
180
219
  return (output || result.error.message).trimEnd();
@@ -194,7 +233,7 @@ const functions = {
194
233
  }
195
234
  ensureSandbox();
196
235
  try {
197
- execFileSync("docker", ["cp", "-L", hostPath, `slave-sandbox:${sbxPath}`], { encoding: "utf8" });
236
+ execFileSync("docker", ["cp", "-L", hostPath, `${sandbox_name}:${sbxPath}`], { encoding: "utf8" });
198
237
  return "OK";
199
238
  }
200
239
  catch (e) {
@@ -210,7 +249,7 @@ const functions = {
210
249
  return "Host path must point to a file";
211
250
  }
212
251
  ensureSandbox();
213
- execFileSync("docker", ["cp", "-L", `slave-sandbox:${sbxPath}`, hostPath], { encoding: "utf8" });
252
+ execFileSync("docker", ["cp", "-L", `${sandbox_name}:${sbxPath}`, hostPath], { encoding: "utf8" });
214
253
  return "OK";
215
254
  },
216
255
  /*system: ({ command }) => {
@@ -370,14 +409,19 @@ const toolToAnthropic = (t) => {
370
409
  }
371
410
  };
372
411
 
412
+ const printJunk = (line) => {
413
+ logWrite(line);
414
+ line = line.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>").replaceAll("\t", " ");
415
+ if (line.length > 80) {
416
+ line = line.substring(0, 77) + "...";
417
+ }
418
+ console.log(line);
419
+ }
420
+
373
421
  const useTool = (name, args, argsStr) => {
374
- console.log("$ " + name + argsStr);
422
+ printJunk("$ " + name + argsStr);
375
423
  const output = String(functions[name](args));
376
- let consoleOutput = output.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>");
377
- if (name == "readFile" && consoleOutput.length > 80) {
378
- consoleOutput = consoleOutput.substring(0, 77) + "...";
379
- }
380
- console.log(consoleOutput);
424
+ printJunk(output);
381
425
  return output;
382
426
  };
383
427
 
@@ -429,14 +473,14 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
429
473
 
430
474
  let message = await client.messages.create({
431
475
  model,
432
- max_tokens: 4096,
476
+ max_tokens: 20000,
433
477
  cache_control: { type: "ephemeral" },
434
478
  messages: state,
435
479
  tools: anthropicTools,
436
480
  });
437
481
  state.push({ role: message.role, content: message.content });
438
482
 
439
- while (message.stop_reason == "tool_use" || message.stop_reason == "max_tokens" || message.stop_reason == "pause_turn") {
483
+ while (message.stop_reason == "tool_use" || message.stop_reason == "pause_turn") {
440
484
  const toolCalls = message.content.filter((item) => item.type == "tool_use");
441
485
  if (toolCalls.length) {
442
486
  const outputs = toolCalls.map((call) => {
@@ -450,9 +494,13 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
450
494
  used_tools = true;
451
495
  }
452
496
 
497
+ /*if (message.stop_reason == "max_tokens" && message.content[message.content.length].type == "thinking") {
498
+ state.push({ role: "user", content: "Please continue from where you left off." });
499
+ }*/
500
+
453
501
  message = await client.messages.create({
454
502
  model,
455
- max_tokens: 4096,
503
+ max_tokens: 20000,
456
504
  cache_control: { type: "ephemeral" },
457
505
  messages: state,
458
506
  tools: anthropicTools,
@@ -460,6 +508,12 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
460
508
  state.push({ role: message.role, content: message.content });
461
509
  }
462
510
 
511
+ if (message.stop_reason != "end_turn") {
512
+ logWrite(`unexpected stop_reason: ${message.stop_reason}\n${JSON.stringify(message, null, 2)}`);
513
+ console.log(message);
514
+ throw new Error(`unexpected stop_reason: ${message.stop_reason}`);
515
+ }
516
+
463
517
  //console.log(message);
464
518
  return {
465
519
  output_text: message.content.find((item) => item.type == "text").text,
@@ -477,6 +531,9 @@ const markdownToConsoleText = (md) => {
477
531
  return text;
478
532
  };
479
533
 
534
+ const profileToString = (profile) => {
535
+ return profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`;
536
+ };
480
537
 
481
538
  const program = new Command();
482
539
  program
@@ -491,8 +548,9 @@ program
491
548
  .option("-s, --sandbox", "allow the model to use a docker container")
492
549
  .option("-p, --profile <name>", "profile name", "default")
493
550
  .option("--create-profile <name> <provider> <model>", "create a profile")
494
- .option("--delete-profile <name>", "delete a profile") // TODO
495
- .option("--update-api-key <provider>", "update an API key") // TODO
551
+ .option("--list-profiles", "list all profiles")
552
+ .option("--delete-profile <name>", "delete a profile")
553
+ .option("--update-api-key <provider>", "update an API key")
496
554
  .helpOption("-?, --help", "display help")
497
555
  .parse();
498
556
  const options = program.opts();
@@ -516,6 +574,16 @@ if (options.createProfile) {
516
574
  console.log(`${isCreate ? "Created" : "Updated"} ${options.createProfile} profile.`);
517
575
  process.exit(0);
518
576
  }
577
+ if (options.listProfiles) {
578
+ const names = Object.keys(state.profiles);
579
+ if (names.length == 0) {
580
+ console.log("No profiles.");
581
+ }
582
+ for (const name of names) {
583
+ console.log(`${name}: ${profileToString(state.profiles[name])}`);
584
+ }
585
+ process.exit(0);
586
+ }
519
587
  if (options.deleteProfile) {
520
588
  delete state.profiles[options.deleteProfile];
521
589
  saveState();
@@ -542,13 +610,15 @@ if (!profile) {
542
610
 
543
611
  let prompt = program.args.join(" ").trim();
544
612
  let previous_provider_state;
545
- const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
613
+ const overrides = [profileToString(profile)];
546
614
  state.providerStates[profile.provider] ??= {};
547
615
  if (options.continue || options.cont) {
548
616
  previous_provider_state = state.providerStates[profile.provider].previous;
617
+ sandbox_name = state.providerStates[profile.provider].sandbox_name;
549
618
  }
550
619
  else if (options.amend) {
551
620
  previous_provider_state = state.providerStates[profile.provider].previous_previous;
621
+ sandbox_name = state.providerStates[profile.provider].sandbox_name;
552
622
  }
553
623
  const filesystemEnabled = options.fs !== false && !options.nofs;
554
624
  initTools(filesystemEnabled, options.sandbox, options.web);
@@ -561,25 +631,21 @@ if (!filesystemEnabled) {
561
631
  if (options.sandbox) {
562
632
  overrides.push("sandbox enabled");
563
633
  }
564
- let stdinText = "";
565
- if (!process.stdin.isTTY) {
566
- const chunks = [];
567
- for await (const chunk of process.stdin) {
568
- chunks.push(chunk);
569
- }
570
- stdinText = Buffer.concat(chunks).toString("utf8");
571
- }
572
634
  if (!prompt) {
573
635
  console.log("No prompt given.");
574
636
  process.exit(1);
575
637
  }
576
638
 
577
- console.log((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
578
- if (stdinText) {
639
+ logWriteBoth((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
640
+ if (!process.stdin.isTTY) {
641
+ const chunks = [];
642
+ for await (const chunk of process.stdin) {
643
+ chunks.push(chunk);
644
+ }
645
+ prompt = `${Buffer.concat(chunks).toString("utf8")}\n\n${prompt}`;
579
646
  overrides.push("stdin provided");
580
- prompt = `${stdinText}\n\n${prompt}`;
581
647
  }
582
- console.log(`(${overrides.join(", ")})\n`);
648
+ logWriteBoth(`(${overrides.join(", ")})\n`);
583
649
 
584
650
  //console.log({ model: profile.model, prompt, previous_provider_state });
585
651
 
@@ -601,11 +667,13 @@ else {
601
667
  }
602
668
  state.providerStates[profile.provider].previous_previous = previous_provider_state;
603
669
  state.providerStates[profile.provider].previous = response.state;
670
+ if (state.providerStates[profile.provider].sandbox_name && state.providerStates[profile.provider].sandbox_name != sandbox_name) {
671
+ execFileSync("docker", ["rm", "-f", state.providerStates[profile.provider].sandbox_name], { encoding: "utf8" });
672
+ }
673
+ state.providerStates[profile.provider].sandbox_name = sandbox_name;
604
674
  saveState();
605
675
  if (response.used_tools) {
606
- if (used_docker) {
607
- execFileSync("docker", ["rm", "-f", "slave-sandbox"], { encoding: "utf8" });
608
- }
609
- console.log();
676
+ logWriteBoth("");
610
677
  }
611
678
  console.log(markdownToConsoleText(response.output_text) + "\n");
679
+ logWrite(response.output_text);
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "git+https://github.com/calamity-inc/slave.git"
6
6
  },
7
- "version": "0.3.0",
7
+ "version": "0.3.2",
8
8
  "type": "module",
9
9
  "bin": {
10
10
  "slave": "index.js"