@calamity-inc/slave 0.1.1 → 0.3.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 +19 -10
- package/index.js +432 -179
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Vibe coding without the kaboom factor.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```
|
|
7
|
+
1. Install slave: `npm i -g @calamity-inc/slave`
|
|
8
|
+
2. Create a default profile, e.g. `slave --create-profile default openai gpt-6-luna` or `slave --create-profile default anthropic claude-haiku-5-5`
|
|
10
9
|
|
|
11
10
|
## Usage
|
|
12
11
|
|
|
13
12
|
In any directory:
|
|
14
13
|
```
|
|
15
|
-
slave [
|
|
14
|
+
slave [options] [--] How do I use the 'slave' command?
|
|
16
15
|
```
|
|
17
16
|
|
|
18
17
|
You can also pipe text in:
|
|
@@ -20,19 +19,29 @@ You can also pipe text in:
|
|
|
20
19
|
cat yap.txt | slave Summarize this text
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
Options:
|
|
24
23
|
- `-c`, `--cont`, `--continue` — add your previous invocation to the context of this one
|
|
24
|
+
- `-a`, `--amend` — retry the previous invocation, using the context from before it
|
|
25
25
|
- `-w`, `--web` — allow the model to search the web
|
|
26
|
-
- `-n`, `--no-fs`, `--nofs` —
|
|
26
|
+
- `-n`, `--no-fs`, `--nofs` — disable filesystem access
|
|
27
|
+
- `-s`, `--sandbox` — allow the model to use a docker container
|
|
28
|
+
- `-p`, `--profile` `<name>` — use the specified profile
|
|
27
29
|
|
|
28
30
|
Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
Subcommands:
|
|
33
|
+
- `--create-profile <name> <provider> <model>` — creates a profile
|
|
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
|
|
37
|
+
|
|
38
|
+
## Security
|
|
31
39
|
|
|
32
|
-
The model
|
|
33
|
-
-
|
|
40
|
+
The model can't run commands on your system; it only gets limited access to your filesystem:
|
|
41
|
+
- Within the working directory and subdirectories.
|
|
34
42
|
- `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
|
|
35
43
|
- Files that are `.gitignore`d can't be written to.
|
|
44
|
+
- You can provide a `.slaveignore` for files that the model should not be allowed to read or write. It will still be able to know of their existence.
|
|
36
45
|
|
|
37
46
|
However, these restrictions are only as good as your practices; you should:
|
|
38
47
|
- 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.
|
package/index.js
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
4
|
import fs from "node:fs";
|
|
3
5
|
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { parseEnv } from "node:util";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
|
+
|
|
8
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import envPaths from "env-paths";
|
|
7
11
|
import ignore from "ignore";
|
|
8
12
|
import { marked } from "marked";
|
|
9
13
|
import { markedTerminal } from "marked-terminal";
|
|
10
14
|
import OpenAI from "openai";
|
|
11
15
|
|
|
12
|
-
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const env = fs.existsSync(path.join(configDir, ".env")) ? parseEnv(fs.readFileSync(path.join(configDir, ".env"), "utf8")) : {};
|
|
14
|
-
if (!env.OPENAI_API_KEY) {
|
|
15
|
-
if (!process.stdin.isTTY) {
|
|
16
|
-
console.error(`No OPENAI_API_KEY found. Run this command in a terminal to enter your key, or add it to ${path.join(configDir, ".env")}.`);
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
|
19
16
|
|
|
17
|
+
const configDir = envPaths("slave").data;
|
|
18
|
+
const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
|
|
19
|
+
state.profiles ??= {};
|
|
20
|
+
state.apiKeys ??= {};
|
|
21
|
+
state.providerStates ??= {};
|
|
22
|
+
const promptForApiKey = async (provider) => {
|
|
20
23
|
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
24
|
try {
|
|
22
|
-
|
|
25
|
+
state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
|
|
23
26
|
}
|
|
24
27
|
finally {
|
|
25
28
|
readline.close();
|
|
26
29
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
30
|
+
};
|
|
31
|
+
const saveState = () => {
|
|
32
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
33
|
+
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
|
+
};
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
const slaveignore = ignore();
|
|
38
|
+
if (fs.existsSync(".slaveignore")) {
|
|
39
|
+
slaveignore.add(fs.readFileSync(".slaveignore", "utf8").replaceAll("/\n", "\n"));
|
|
35
40
|
}
|
|
36
|
-
const client = new OpenAI({
|
|
37
|
-
apiKey: env.OPENAI_API_KEY,
|
|
38
|
-
});
|
|
39
|
-
|
|
40
41
|
const gitignore = ignore();
|
|
41
42
|
if (fs.existsSync(".gitignore")) {
|
|
42
43
|
gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
const isOutOfReach = (p, type) => {
|
|
45
|
+
const isOutOfReach = (p, accessType) => {
|
|
46
46
|
const rel = path.relative(".", p);
|
|
47
47
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
48
48
|
return true;
|
|
@@ -51,25 +51,53 @@ const isOutOfReach = (p, type) => {
|
|
|
51
51
|
if (segs.includes(".env") || segs.includes(".git")) {
|
|
52
52
|
return true;
|
|
53
53
|
}
|
|
54
|
-
if (
|
|
54
|
+
if (rel && (slaveignore.ignores(rel) || (accessType == "write" && gitignore.ignores(rel)))) {
|
|
55
55
|
return true;
|
|
56
56
|
}
|
|
57
57
|
return false;
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
const fixPath = (path, accessType) => {
|
|
61
|
+
if (path.startsWith("/")) {
|
|
62
|
+
path = path.substring(1);
|
|
63
|
+
path ||= ".";
|
|
64
|
+
}
|
|
65
|
+
return isOutOfReach(path, accessType) ? "" : path;
|
|
66
|
+
};
|
|
67
|
+
|
|
60
68
|
const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
|
|
61
69
|
const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
62
|
-
if (
|
|
63
|
-
usesCRLF
|
|
70
|
+
if (fs.existsSync(filePath)) {
|
|
71
|
+
if (usesCRLF === undefined) {
|
|
72
|
+
usesCRLF = fs.readFileSync(filePath, "utf8").includes("\r\n");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
64
77
|
}
|
|
65
78
|
const normalized = data.replaceAll("\r\n", "\n");
|
|
66
79
|
fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
|
|
67
80
|
};
|
|
68
81
|
|
|
82
|
+
let used_docker;
|
|
83
|
+
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;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
69
96
|
const functions = {
|
|
70
97
|
ls: ({ path }) => {
|
|
71
98
|
path ??= ".";
|
|
72
|
-
|
|
99
|
+
path = fixPath(path);
|
|
100
|
+
if (!path) {
|
|
73
101
|
return "Out of your reach";
|
|
74
102
|
}
|
|
75
103
|
let files;
|
|
@@ -83,7 +111,8 @@ const functions = {
|
|
|
83
111
|
},
|
|
84
112
|
findFiles: ({ path, substring, recursive }) => {
|
|
85
113
|
path ??= ".";
|
|
86
|
-
|
|
114
|
+
path = fixPath(path);
|
|
115
|
+
if (!path) {
|
|
87
116
|
return "Out of your reach";
|
|
88
117
|
}
|
|
89
118
|
let files;
|
|
@@ -111,21 +140,27 @@ const functions = {
|
|
|
111
140
|
return res.join("\n");
|
|
112
141
|
},
|
|
113
142
|
readFile: ({ path }) => {
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
path = fixPath(path);
|
|
144
|
+
if (!path) {
|
|
145
|
+
return "You don't have read access to this file";
|
|
146
|
+
}
|
|
147
|
+
if (!fs.existsSync(path) || !fs.statSync(path).isFile()) {
|
|
148
|
+
return "No such file";
|
|
116
149
|
}
|
|
117
150
|
return readFileForModel(path);
|
|
118
151
|
},
|
|
119
152
|
writeFile: ({ path, data }) => {
|
|
120
|
-
|
|
121
|
-
|
|
153
|
+
path = fixPath(path, "write");
|
|
154
|
+
if (!path) {
|
|
155
|
+
return "You don't have write access to this file";
|
|
122
156
|
}
|
|
123
157
|
writeFileFromModel(path, data);
|
|
124
158
|
return "OK";
|
|
125
159
|
},
|
|
126
160
|
fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
|
|
127
|
-
|
|
128
|
-
|
|
161
|
+
path = fixPath(path, "write");
|
|
162
|
+
if (!path) {
|
|
163
|
+
return "You don't have write access to this file";
|
|
129
164
|
}
|
|
130
165
|
const fileContent = fs.readFileSync(path, "utf8");
|
|
131
166
|
const usesCRLF = fileContent.includes("\r\n");
|
|
@@ -137,129 +172,235 @@ const functions = {
|
|
|
137
172
|
writeFileFromModel(path, arr.join(replacement), usesCRLF);
|
|
138
173
|
return "OK";
|
|
139
174
|
},
|
|
140
|
-
|
|
175
|
+
sandboxExec: ({ command, args }) => {
|
|
176
|
+
ensureSandbox();
|
|
177
|
+
const result = spawnSync("docker", [ "exec", "slave-sandbox", command, ...args ], { encoding: "utf8" });
|
|
178
|
+
const output = (result.stdout ?? "") + (result.stderr ?? "");
|
|
179
|
+
if (result.error) {
|
|
180
|
+
return (output || result.error.message).trimEnd();
|
|
181
|
+
}
|
|
182
|
+
if (result.status !== 0 && !output) {
|
|
183
|
+
return `Command exited with status ${result.status}`;
|
|
184
|
+
}
|
|
185
|
+
return output;
|
|
186
|
+
},
|
|
187
|
+
sandboxCopyIn: ({ hostPath, sbxPath }) => {
|
|
188
|
+
hostPath = fixPath(hostPath);
|
|
189
|
+
if (!hostPath) {
|
|
190
|
+
return "You don't have read access to this file";
|
|
191
|
+
}
|
|
192
|
+
if (!fs.existsSync(hostPath) || !fs.statSync(hostPath).isFile()) {
|
|
193
|
+
return "Host path must point to a file";
|
|
194
|
+
}
|
|
195
|
+
ensureSandbox();
|
|
196
|
+
try {
|
|
197
|
+
execFileSync("docker", ["cp", "-L", hostPath, `slave-sandbox:${sbxPath}`], { encoding: "utf8" });
|
|
198
|
+
return "OK";
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
return (e.stdout ?? e.message).trimEnd();
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
sandboxCopyOut: ({ sbxPath, hostPath }) => {
|
|
205
|
+
hostPath = fixPath(hostPath, "write");
|
|
206
|
+
if (!hostPath) {
|
|
207
|
+
return "You don't have write access to this file";
|
|
208
|
+
}
|
|
209
|
+
if (fs.existsSync(hostPath) && !fs.statSync(hostPath).isFile()) {
|
|
210
|
+
return "Host path must point to a file";
|
|
211
|
+
}
|
|
212
|
+
ensureSandbox();
|
|
213
|
+
execFileSync("docker", ["cp", "-L", `slave-sandbox:${sbxPath}`, hostPath], { encoding: "utf8" });
|
|
214
|
+
return "OK";
|
|
215
|
+
},
|
|
216
|
+
/*system: ({ command }) => {
|
|
141
217
|
//process.exit(1);
|
|
142
218
|
return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
|
|
143
|
-
}
|
|
219
|
+
}*/
|
|
144
220
|
};
|
|
145
221
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
222
|
+
const tools = [];
|
|
223
|
+
const initTools = (fs, sandbox, web_search) => {
|
|
224
|
+
if (fs) {
|
|
225
|
+
tools.push(
|
|
226
|
+
{
|
|
227
|
+
type: "function",
|
|
228
|
+
name: "ls",
|
|
229
|
+
description: "Lists files",
|
|
230
|
+
parameters: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
path: { type: ["string", "null"] },
|
|
234
|
+
},
|
|
235
|
+
required: [ "path" ],
|
|
236
|
+
additionalProperties: false,
|
|
237
|
+
},
|
|
238
|
+
strict: true,
|
|
155
239
|
},
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
240
|
+
{
|
|
241
|
+
type: "function",
|
|
242
|
+
name: "findFiles",
|
|
243
|
+
parameters: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
path: { type: ["string", "null"] },
|
|
247
|
+
substring: { type: "string" },
|
|
248
|
+
recursive: { type: ["boolean", "null"] },
|
|
249
|
+
},
|
|
250
|
+
required: [ "path", "substring", "recursive" ],
|
|
251
|
+
additionalProperties: false,
|
|
252
|
+
},
|
|
253
|
+
strict: true,
|
|
170
254
|
},
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
255
|
+
{
|
|
256
|
+
type: "function",
|
|
257
|
+
name: "readFile",
|
|
258
|
+
parameters: {
|
|
259
|
+
type: "object",
|
|
260
|
+
properties: {
|
|
261
|
+
path: { type: "string" },
|
|
262
|
+
},
|
|
263
|
+
required: [ "path" ],
|
|
264
|
+
additionalProperties: false,
|
|
265
|
+
},
|
|
266
|
+
strict: true,
|
|
183
267
|
},
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
268
|
+
{
|
|
269
|
+
type: "function",
|
|
270
|
+
name: "writeFile",
|
|
271
|
+
parameters: {
|
|
272
|
+
type: "object",
|
|
273
|
+
properties: {
|
|
274
|
+
path: { type: "string" },
|
|
275
|
+
data: { type: "string" },
|
|
276
|
+
},
|
|
277
|
+
required: [ "path", "data" ],
|
|
278
|
+
additionalProperties: false,
|
|
279
|
+
},
|
|
280
|
+
strict: true,
|
|
197
281
|
},
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
282
|
+
{
|
|
283
|
+
type: "function",
|
|
284
|
+
name: "fileFindAndReplace",
|
|
285
|
+
parameters: {
|
|
286
|
+
type: "object",
|
|
287
|
+
properties: {
|
|
288
|
+
path: { type: "string" },
|
|
289
|
+
substring: { type: "string" },
|
|
290
|
+
replacement: { type: "string" },
|
|
291
|
+
expectedMatches: { type: ["number", "null"] },
|
|
292
|
+
},
|
|
293
|
+
required: [ "path", "substring", "replacement", "expectedMatches" ],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
strict: true,
|
|
213
297
|
},
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (sandbox) {
|
|
301
|
+
tools.push({
|
|
302
|
+
type: "function",
|
|
303
|
+
name: "sandboxExec",
|
|
304
|
+
description: "Runs a command without Bash in an isolated sandbox.",
|
|
305
|
+
parameters: {
|
|
306
|
+
type: "object",
|
|
307
|
+
properties: {
|
|
308
|
+
command: { type: "string" },
|
|
309
|
+
args: { type: "array", items: { type: "string" } },
|
|
310
|
+
},
|
|
311
|
+
required: [ "command", "args" ],
|
|
312
|
+
additionalProperties: false,
|
|
227
313
|
},
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
314
|
+
strict: true,
|
|
315
|
+
});
|
|
316
|
+
if (fs) {
|
|
317
|
+
tools.push(
|
|
318
|
+
{
|
|
319
|
+
type: "function",
|
|
320
|
+
name: "sandboxCopyIn",
|
|
321
|
+
parameters: {
|
|
322
|
+
type: "object",
|
|
323
|
+
properties: {
|
|
324
|
+
hostPath: { type: "string" },
|
|
325
|
+
sbxPath: { type: "string" },
|
|
326
|
+
},
|
|
327
|
+
required: [ "hostPath", "sbxPath" ],
|
|
328
|
+
additionalProperties: false,
|
|
329
|
+
},
|
|
330
|
+
strict: true,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
type: "function",
|
|
334
|
+
name: "sandboxCopyOut",
|
|
335
|
+
parameters: {
|
|
336
|
+
type: "object",
|
|
337
|
+
properties: {
|
|
338
|
+
sbxPath: { type: "string" },
|
|
339
|
+
hostPath: { type: "string" },
|
|
340
|
+
},
|
|
341
|
+
required: [ "sbxPath", "hostPath" ],
|
|
342
|
+
additionalProperties: false,
|
|
343
|
+
},
|
|
344
|
+
strict: true,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (web_search) {
|
|
350
|
+
tools.push({ type: "web_search" });
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const toolToAnthropic = (t) => {
|
|
355
|
+
if (t.type == "function") {
|
|
356
|
+
return {
|
|
357
|
+
name: t.name,
|
|
358
|
+
description: t.description,
|
|
359
|
+
input_schema: {
|
|
360
|
+
type: t.parameters.type,
|
|
361
|
+
properties: t.parameters.properties,
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
else if (t.type == "web_search") {
|
|
366
|
+
return {
|
|
367
|
+
type: "web_search_20260318",
|
|
368
|
+
name: "web_search"
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const useTool = (name, args, argsStr) => {
|
|
374
|
+
console.log("$ " + name + argsStr);
|
|
375
|
+
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);
|
|
381
|
+
return output;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const openai_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
|
|
237
385
|
let response = await client.responses.create({
|
|
238
386
|
model,
|
|
239
|
-
previous_response_id,
|
|
387
|
+
previous_response_id: state,
|
|
240
388
|
input,
|
|
241
389
|
tools,
|
|
242
390
|
});
|
|
243
391
|
let used_tools = false;
|
|
244
392
|
|
|
245
393
|
while (true) {
|
|
246
|
-
const toolCalls = response.output.filter((item) => item.type
|
|
394
|
+
const toolCalls = response.output.filter((item) => item.type == "function_call");
|
|
247
395
|
if (toolCalls.length == 0) {
|
|
248
396
|
break;
|
|
249
397
|
}
|
|
250
398
|
|
|
251
399
|
const outputs = toolCalls.map((call) => {
|
|
252
|
-
console.log("$ " + call.name + call.arguments);
|
|
253
|
-
const output = String(functions[call.name](JSON.parse(call.arguments)));
|
|
254
|
-
let consoleOutput = output.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>");
|
|
255
|
-
if (call.name == "readFile" && consoleOutput.length > 80) {
|
|
256
|
-
consoleOutput = consoleOutput.substring(0, 77) + "...";
|
|
257
|
-
}
|
|
258
|
-
console.log(consoleOutput);
|
|
259
400
|
return {
|
|
260
401
|
type: "function_call_output",
|
|
261
402
|
call_id: call.call_id,
|
|
262
|
-
output,
|
|
403
|
+
output: useTool(call.name, JSON.parse(call.arguments), call.arguments)
|
|
263
404
|
};
|
|
264
405
|
});
|
|
265
406
|
|
|
@@ -272,62 +413,154 @@ const generateResponseOptionallyUsingTools = async (input, previous_response_id)
|
|
|
272
413
|
used_tools = true;
|
|
273
414
|
}
|
|
274
415
|
|
|
275
|
-
|
|
276
|
-
|
|
416
|
+
return {
|
|
417
|
+
output_text: response.output_text,
|
|
418
|
+
state: response.id,
|
|
419
|
+
used_tools,
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const anthropic_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
|
|
424
|
+
state = JSON.parse(state || "[]");
|
|
425
|
+
state.push({ role: "user", content: input });
|
|
426
|
+
let used_tools = false;
|
|
427
|
+
|
|
428
|
+
const anthropicTools = tools.map(t => toolToAnthropic(t));
|
|
429
|
+
|
|
430
|
+
let message = await client.messages.create({
|
|
431
|
+
model,
|
|
432
|
+
max_tokens: 4096,
|
|
433
|
+
cache_control: { type: "ephemeral" },
|
|
434
|
+
messages: state,
|
|
435
|
+
tools: anthropicTools,
|
|
436
|
+
});
|
|
437
|
+
state.push({ role: message.role, content: message.content });
|
|
438
|
+
|
|
439
|
+
while (message.stop_reason == "tool_use" || message.stop_reason == "max_tokens" || message.stop_reason == "pause_turn") {
|
|
440
|
+
const toolCalls = message.content.filter((item) => item.type == "tool_use");
|
|
441
|
+
if (toolCalls.length) {
|
|
442
|
+
const outputs = toolCalls.map((call) => {
|
|
443
|
+
return {
|
|
444
|
+
type: "tool_result",
|
|
445
|
+
tool_use_id: call.id,
|
|
446
|
+
content: useTool(call.name, call.input, JSON.stringify(call.input))
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
state.push({ role: "user", content: outputs });
|
|
450
|
+
used_tools = true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
message = await client.messages.create({
|
|
454
|
+
model,
|
|
455
|
+
max_tokens: 4096,
|
|
456
|
+
cache_control: { type: "ephemeral" },
|
|
457
|
+
messages: state,
|
|
458
|
+
tools: anthropicTools,
|
|
459
|
+
});
|
|
460
|
+
state.push({ role: message.role, content: message.content });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
//console.log(message);
|
|
464
|
+
return {
|
|
465
|
+
output_text: message.content.find((item) => item.type == "text").text,
|
|
466
|
+
state: JSON.stringify(state),
|
|
467
|
+
used_tools,
|
|
468
|
+
};
|
|
277
469
|
};
|
|
278
470
|
|
|
279
471
|
marked.use(markedTerminal());
|
|
280
472
|
const markdownToConsoleText = (md) => {
|
|
281
473
|
const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
|
|
282
474
|
console.error = undefined;
|
|
283
|
-
const text = marked.parse(md);
|
|
475
|
+
const text = marked.parse(md).trimEnd() + "\n";
|
|
284
476
|
console.error = _console_error;
|
|
285
477
|
return text;
|
|
286
478
|
};
|
|
287
479
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
480
|
+
|
|
481
|
+
const program = new Command();
|
|
482
|
+
program
|
|
483
|
+
.name("slave")
|
|
484
|
+
.usage("[options] [--] [prompt...]")
|
|
485
|
+
.description("Vibe coding without the kaboom factor.")
|
|
486
|
+
.argument("[prompt...]")
|
|
487
|
+
.option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
|
|
488
|
+
.option("-a, --amend", "retry the previous invocation, using the context from before it")
|
|
489
|
+
.option("-w, --web", "allow the model to search the web")
|
|
490
|
+
.option("-n, --no-fs", "disable filesystem access").option("--nofs")
|
|
491
|
+
.option("-s, --sandbox", "allow the model to use a docker container")
|
|
492
|
+
.option("-p, --profile <name>", "profile name", "default")
|
|
493
|
+
.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
|
|
496
|
+
.helpOption("-?, --help", "display help")
|
|
497
|
+
.parse();
|
|
498
|
+
const options = program.opts();
|
|
499
|
+
|
|
500
|
+
if (options.createProfile) {
|
|
501
|
+
if (!program.args[0] || !program.args[1]) {
|
|
502
|
+
console.log("Syntax: slave --create-profile <name> <provider> <model>");
|
|
503
|
+
process.exit(1);
|
|
302
504
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
505
|
+
const provider = program.args[0];
|
|
506
|
+
if (provider != "openai" && provider != "anthropic" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
|
|
507
|
+
console.log(`Invalid provider: ${provider}`);
|
|
508
|
+
process.exit(1);
|
|
308
509
|
}
|
|
309
|
-
|
|
310
|
-
|
|
510
|
+
const isCreate = !(options.createProfile in state.profiles);
|
|
511
|
+
state.profiles[options.createProfile] = { provider, model: program.args[1] };
|
|
512
|
+
if (!state.apiKeys[provider]) {
|
|
513
|
+
await promptForApiKey(provider);
|
|
311
514
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
515
|
+
saveState();
|
|
516
|
+
console.log(`${isCreate ? "Created" : "Updated"} ${options.createProfile} profile.`);
|
|
517
|
+
process.exit(0);
|
|
518
|
+
}
|
|
519
|
+
if (options.deleteProfile) {
|
|
520
|
+
delete state.profiles[options.deleteProfile];
|
|
521
|
+
saveState();
|
|
522
|
+
console.log(`Deleted ${options.deleteProfile} profile.`);
|
|
523
|
+
process.exit(0);
|
|
524
|
+
}
|
|
525
|
+
if (options.updateApiKey) {
|
|
526
|
+
await promptForApiKey(options.updateApiKey);
|
|
527
|
+
saveState();
|
|
528
|
+
console.log("Saved.");
|
|
529
|
+
process.exit(0);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const profile = state.profiles[options.profile];
|
|
533
|
+
if (!profile) {
|
|
534
|
+
if (options.profile == "default") {
|
|
535
|
+
console.log(markdownToConsoleText("You did not specify a profile, but a default profile does not exist. Use e.g. `slave --create-profile default openai gpt-6-luna` to create one."));
|
|
321
536
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
? [argument]
|
|
325
|
-
: [...argument.slice(1)].map((flag) => `-${flag}`);
|
|
326
|
-
for (const flag of flags) {
|
|
327
|
-
processFlag(flag);
|
|
537
|
+
else {
|
|
538
|
+
console.log(markdownToConsoleText(`No such profile. Use e.g. \`slave --create-profile ${options.profile} openai gpt-6-luna\` to create it.`));
|
|
328
539
|
}
|
|
540
|
+
process.exit(1);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let prompt = program.args.join(" ").trim();
|
|
544
|
+
let previous_provider_state;
|
|
545
|
+
const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
|
|
546
|
+
state.providerStates[profile.provider] ??= {};
|
|
547
|
+
if (options.continue || options.cont) {
|
|
548
|
+
previous_provider_state = state.providerStates[profile.provider].previous;
|
|
549
|
+
}
|
|
550
|
+
else if (options.amend) {
|
|
551
|
+
previous_provider_state = state.providerStates[profile.provider].previous_previous;
|
|
552
|
+
}
|
|
553
|
+
const filesystemEnabled = options.fs !== false && !options.nofs;
|
|
554
|
+
initTools(filesystemEnabled, options.sandbox, options.web);
|
|
555
|
+
if (options.web) {
|
|
556
|
+
overrides.push("web search enabled");
|
|
557
|
+
}
|
|
558
|
+
if (!filesystemEnabled) {
|
|
559
|
+
overrides.push("no filesystem access");
|
|
560
|
+
}
|
|
561
|
+
if (options.sandbox) {
|
|
562
|
+
overrides.push("sandbox enabled");
|
|
329
563
|
}
|
|
330
|
-
let prompt = process.argv.slice(i).join(" ").trim();
|
|
331
564
|
let stdinText = "";
|
|
332
565
|
if (!process.stdin.isTTY) {
|
|
333
566
|
const chunks = [];
|
|
@@ -337,22 +570,42 @@ if (!process.stdin.isTTY) {
|
|
|
337
570
|
stdinText = Buffer.concat(chunks).toString("utf8");
|
|
338
571
|
}
|
|
339
572
|
if (!prompt) {
|
|
340
|
-
console.log("
|
|
573
|
+
console.log("No prompt given.");
|
|
341
574
|
process.exit(1);
|
|
342
575
|
}
|
|
343
576
|
|
|
344
|
-
console.log((
|
|
577
|
+
console.log((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
|
|
345
578
|
if (stdinText) {
|
|
346
579
|
overrides.push("stdin provided");
|
|
347
580
|
prompt = `${stdinText}\n\n${prompt}`;
|
|
348
581
|
}
|
|
349
|
-
|
|
350
|
-
|
|
582
|
+
console.log(`(${overrides.join(", ")})\n`);
|
|
583
|
+
|
|
584
|
+
//console.log({ model: profile.model, prompt, previous_provider_state });
|
|
585
|
+
|
|
586
|
+
const clientOpts = {
|
|
587
|
+
apiKey: state.apiKeys[profile.provider],
|
|
588
|
+
};
|
|
589
|
+
let response;
|
|
590
|
+
if (profile.provider == "anthropic") {
|
|
591
|
+
const client = new Anthropic(clientOpts);
|
|
592
|
+
response = await anthropic_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
|
|
351
593
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
594
|
+
else {
|
|
595
|
+
if (profile.provider != "openai") {
|
|
596
|
+
clientOpts.baseURL = profile.provider;
|
|
597
|
+
clientOpts.apiKey ||= "dummy";
|
|
598
|
+
}
|
|
599
|
+
const client = new OpenAI(clientOpts);
|
|
600
|
+
response = await openai_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
|
|
601
|
+
}
|
|
602
|
+
state.providerStates[profile.provider].previous_previous = previous_provider_state;
|
|
603
|
+
state.providerStates[profile.provider].previous = response.state;
|
|
604
|
+
saveState();
|
|
355
605
|
if (response.used_tools) {
|
|
606
|
+
if (used_docker) {
|
|
607
|
+
execFileSync("docker", ["rm", "-f", "slave-sandbox"], { encoding: "utf8" });
|
|
608
|
+
}
|
|
356
609
|
console.log();
|
|
357
610
|
}
|
|
358
|
-
console.log(markdownToConsoleText(response.output_text)
|
|
611
|
+
console.log(markdownToConsoleText(response.output_text) + "\n");
|
package/package.json
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "git+https://github.com/calamity-inc/slave.git"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.3.0",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
|
-
"slave": "
|
|
10
|
+
"slave": "index.js"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
+
"@anthropic-ai/sdk": "^1.3.0",
|
|
14
|
+
"commander": "^15.0.0",
|
|
15
|
+
"env-paths": "^4.0.0",
|
|
13
16
|
"ignore": "^7.0.13",
|
|
14
17
|
"marked": "^12",
|
|
15
18
|
"marked-terminal": "7.0.0",
|