@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.
- package/README.md +14 -3
- package/index.js +115 -47
- 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>` —
|
|
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
|
-
- `--
|
|
36
|
-
- `--
|
|
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
|
-
|
|
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
|
|
113
|
+
let sandbox_name;
|
|
83
114
|
const ensureSandbox = () => {
|
|
84
|
-
if (!
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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",
|
|
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
|
-
|
|
422
|
+
printJunk("$ " + name + argsStr);
|
|
375
423
|
const output = String(functions[name](args));
|
|
376
|
-
|
|
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:
|
|
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 == "
|
|
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:
|
|
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("--
|
|
495
|
-
.option("--
|
|
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
|
|
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
|
-
|
|
578
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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);
|