@calamity-inc/slave 0.2.0 → 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 +8 -7
- package/index.js +341 -138
- package/package.json +3 -1
- package/.slaveignore +0 -1
- package/calamity-inc-slave-0.2.0.tgz +0 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Vibe coding without the kaboom factor.
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
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`
|
|
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`
|
|
9
9
|
|
|
10
10
|
## Usage
|
|
11
11
|
|
|
@@ -21,23 +21,24 @@ cat yap.txt | slave Summarize this text
|
|
|
21
21
|
|
|
22
22
|
Options:
|
|
23
23
|
- `-c`, `--cont`, `--continue` — add your previous invocation to the context of this one
|
|
24
|
-
- `--amend` — retry the previous invocation, using the context from before it
|
|
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
26
|
- `-n`, `--no-fs`, `--nofs` — disable filesystem access
|
|
27
|
-
- `-
|
|
27
|
+
- `-s`, `--sandbox` — allow the model to use a docker container
|
|
28
|
+
- `-p`, `--profile` `<name>` — use the specified profile
|
|
28
29
|
|
|
29
30
|
Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
|
|
30
31
|
|
|
31
32
|
Subcommands:
|
|
32
33
|
- `--create-profile <name> <provider> <model>` — creates a profile
|
|
33
|
-
- `provider` can be `openai` or an OpenAI-compatible base URL
|
|
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).
|
|
34
35
|
- `--delete-profile <name>` — deletes a profile
|
|
35
36
|
- `--update-api-key <provider>` — updates an API Key
|
|
36
37
|
|
|
37
|
-
##
|
|
38
|
+
## Security
|
|
38
39
|
|
|
39
|
-
The model
|
|
40
|
-
-
|
|
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.
|
|
41
42
|
- `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
|
|
42
43
|
- Files that are `.gitignore`d can't be written to.
|
|
43
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.
|
package/index.js
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
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
|
-
|
|
7
|
+
|
|
8
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
8
9
|
import { Command } from "commander";
|
|
10
|
+
import envPaths from "env-paths";
|
|
11
|
+
import ignore from "ignore";
|
|
9
12
|
import { marked } from "marked";
|
|
10
13
|
import { markedTerminal } from "marked-terminal";
|
|
11
14
|
import OpenAI from "openai";
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
const
|
|
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")) : {};
|
|
15
19
|
state.profiles ??= {};
|
|
16
20
|
state.apiKeys ??= {};
|
|
17
|
-
|
|
18
|
-
const env = parseEnv(fs.readFileSync(path.join(configDir, ".env"), "utf8"));
|
|
19
|
-
if (env.OPENAI_API_KEY) {
|
|
20
|
-
state.apiKeys.openai = env.OPENAI_API_KEY;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
21
|
+
state.providerStates ??= {};
|
|
23
22
|
const promptForApiKey = async (provider) => {
|
|
24
23
|
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
25
24
|
try {
|
|
@@ -30,8 +29,9 @@ const promptForApiKey = async (provider) => {
|
|
|
30
29
|
}
|
|
31
30
|
};
|
|
32
31
|
const saveState = () => {
|
|
33
|
-
fs.
|
|
34
|
-
fs.
|
|
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
35
|
};
|
|
36
36
|
|
|
37
37
|
const slaveignore = ignore();
|
|
@@ -42,7 +42,7 @@ const gitignore = ignore();
|
|
|
42
42
|
if (fs.existsSync(".gitignore")) {
|
|
43
43
|
gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
|
|
44
44
|
}
|
|
45
|
-
const isOutOfReach = (p,
|
|
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,12 +51,20 @@ const isOutOfReach = (p, type) => {
|
|
|
51
51
|
if (segs.includes(".env") || segs.includes(".git")) {
|
|
52
52
|
return true;
|
|
53
53
|
}
|
|
54
|
-
if (rel && (slaveignore.ignores(rel) || (
|
|
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
70
|
if (fs.existsSync(filePath)) {
|
|
@@ -71,10 +79,25 @@ const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
|
71
79
|
fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
|
|
72
80
|
};
|
|
73
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
|
+
|
|
74
96
|
const functions = {
|
|
75
97
|
ls: ({ path }) => {
|
|
76
98
|
path ??= ".";
|
|
77
|
-
|
|
99
|
+
path = fixPath(path);
|
|
100
|
+
if (!path) {
|
|
78
101
|
return "Out of your reach";
|
|
79
102
|
}
|
|
80
103
|
let files;
|
|
@@ -88,7 +111,8 @@ const functions = {
|
|
|
88
111
|
},
|
|
89
112
|
findFiles: ({ path, substring, recursive }) => {
|
|
90
113
|
path ??= ".";
|
|
91
|
-
|
|
114
|
+
path = fixPath(path);
|
|
115
|
+
if (!path) {
|
|
92
116
|
return "Out of your reach";
|
|
93
117
|
}
|
|
94
118
|
let files;
|
|
@@ -116,21 +140,27 @@ const functions = {
|
|
|
116
140
|
return res.join("\n");
|
|
117
141
|
},
|
|
118
142
|
readFile: ({ path }) => {
|
|
119
|
-
|
|
120
|
-
|
|
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";
|
|
121
149
|
}
|
|
122
150
|
return readFileForModel(path);
|
|
123
151
|
},
|
|
124
152
|
writeFile: ({ path, data }) => {
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
path = fixPath(path, "write");
|
|
154
|
+
if (!path) {
|
|
155
|
+
return "You don't have write access to this file";
|
|
127
156
|
}
|
|
128
157
|
writeFileFromModel(path, data);
|
|
129
158
|
return "OK";
|
|
130
159
|
},
|
|
131
160
|
fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
|
|
132
|
-
|
|
133
|
-
|
|
161
|
+
path = fixPath(path, "write");
|
|
162
|
+
if (!path) {
|
|
163
|
+
return "You don't have write access to this file";
|
|
134
164
|
}
|
|
135
165
|
const fileContent = fs.readFileSync(path, "utf8");
|
|
136
166
|
const usesCRLF = fileContent.includes("\r\n");
|
|
@@ -142,128 +172,235 @@ const functions = {
|
|
|
142
172
|
writeFileFromModel(path, arr.join(replacement), usesCRLF);
|
|
143
173
|
return "OK";
|
|
144
174
|
},
|
|
145
|
-
|
|
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 }) => {
|
|
146
217
|
//process.exit(1);
|
|
147
218
|
return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
|
|
148
|
-
}
|
|
219
|
+
}*/
|
|
149
220
|
};
|
|
150
221
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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,
|
|
160
239
|
},
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,
|
|
175
254
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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,
|
|
188
267
|
},
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
|
202
281
|
},
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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,
|
|
218
297
|
},
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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,
|
|
232
313
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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) => {
|
|
241
385
|
let response = await client.responses.create({
|
|
242
386
|
model,
|
|
243
|
-
previous_response_id,
|
|
387
|
+
previous_response_id: state,
|
|
244
388
|
input,
|
|
245
389
|
tools,
|
|
246
390
|
});
|
|
247
391
|
let used_tools = false;
|
|
248
392
|
|
|
249
393
|
while (true) {
|
|
250
|
-
const toolCalls = response.output.filter((item) => item.type
|
|
394
|
+
const toolCalls = response.output.filter((item) => item.type == "function_call");
|
|
251
395
|
if (toolCalls.length == 0) {
|
|
252
396
|
break;
|
|
253
397
|
}
|
|
254
398
|
|
|
255
399
|
const outputs = toolCalls.map((call) => {
|
|
256
|
-
console.log("$ " + call.name + call.arguments);
|
|
257
|
-
const output = String(functions[call.name](JSON.parse(call.arguments)));
|
|
258
|
-
let consoleOutput = output.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>");
|
|
259
|
-
if (call.name == "readFile" && consoleOutput.length > 80) {
|
|
260
|
-
consoleOutput = consoleOutput.substring(0, 77) + "...";
|
|
261
|
-
}
|
|
262
|
-
console.log(consoleOutput);
|
|
263
400
|
return {
|
|
264
401
|
type: "function_call_output",
|
|
265
402
|
call_id: call.call_id,
|
|
266
|
-
output,
|
|
403
|
+
output: useTool(call.name, JSON.parse(call.arguments), call.arguments)
|
|
267
404
|
};
|
|
268
405
|
});
|
|
269
406
|
|
|
@@ -276,8 +413,59 @@ const generateResponseOptionallyUsingTools = async (client, model, input, previo
|
|
|
276
413
|
used_tools = true;
|
|
277
414
|
}
|
|
278
415
|
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
};
|
|
281
469
|
};
|
|
282
470
|
|
|
283
471
|
marked.use(markedTerminal());
|
|
@@ -297,9 +485,10 @@ program
|
|
|
297
485
|
.description("Vibe coding without the kaboom factor.")
|
|
298
486
|
.argument("[prompt...]")
|
|
299
487
|
.option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
|
|
300
|
-
.option("--amend", "retry the previous invocation, using the context from before it")
|
|
488
|
+
.option("-a, --amend", "retry the previous invocation, using the context from before it")
|
|
301
489
|
.option("-w, --web", "allow the model to search the web")
|
|
302
490
|
.option("-n, --no-fs", "disable filesystem access").option("--nofs")
|
|
491
|
+
.option("-s, --sandbox", "allow the model to use a docker container")
|
|
303
492
|
.option("-p, --profile <name>", "profile name", "default")
|
|
304
493
|
.option("--create-profile <name> <provider> <model>", "create a profile")
|
|
305
494
|
.option("--delete-profile <name>", "delete a profile") // TODO
|
|
@@ -314,7 +503,7 @@ if (options.createProfile) {
|
|
|
314
503
|
process.exit(1);
|
|
315
504
|
}
|
|
316
505
|
const provider = program.args[0];
|
|
317
|
-
if (provider != "openai" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
|
|
506
|
+
if (provider != "openai" && provider != "anthropic" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
|
|
318
507
|
console.log(`Invalid provider: ${provider}`);
|
|
319
508
|
process.exit(1);
|
|
320
509
|
}
|
|
@@ -352,22 +541,26 @@ if (!profile) {
|
|
|
352
541
|
}
|
|
353
542
|
|
|
354
543
|
let prompt = program.args.join(" ").trim();
|
|
355
|
-
let
|
|
544
|
+
let previous_provider_state;
|
|
356
545
|
const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
|
|
546
|
+
state.providerStates[profile.provider] ??= {};
|
|
357
547
|
if (options.continue || options.cont) {
|
|
358
|
-
|
|
548
|
+
previous_provider_state = state.providerStates[profile.provider].previous;
|
|
359
549
|
}
|
|
360
550
|
else if (options.amend) {
|
|
361
|
-
|
|
551
|
+
previous_provider_state = state.providerStates[profile.provider].previous_previous;
|
|
362
552
|
}
|
|
553
|
+
const filesystemEnabled = options.fs !== false && !options.nofs;
|
|
554
|
+
initTools(filesystemEnabled, options.sandbox, options.web);
|
|
363
555
|
if (options.web) {
|
|
364
|
-
tools.push({ type: "web_search" });
|
|
365
556
|
overrides.push("web search enabled");
|
|
366
557
|
}
|
|
367
|
-
if (
|
|
368
|
-
tools = tools.filter(tool => tool.type != "function");
|
|
558
|
+
if (!filesystemEnabled) {
|
|
369
559
|
overrides.push("no filesystem access");
|
|
370
560
|
}
|
|
561
|
+
if (options.sandbox) {
|
|
562
|
+
overrides.push("sandbox enabled");
|
|
563
|
+
}
|
|
371
564
|
let stdinText = "";
|
|
372
565
|
if (!process.stdin.isTTY) {
|
|
373
566
|
const chunks = [];
|
|
@@ -381,28 +574,38 @@ if (!prompt) {
|
|
|
381
574
|
process.exit(1);
|
|
382
575
|
}
|
|
383
576
|
|
|
384
|
-
console.log((
|
|
577
|
+
console.log((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
|
|
385
578
|
if (stdinText) {
|
|
386
579
|
overrides.push("stdin provided");
|
|
387
580
|
prompt = `${stdinText}\n\n${prompt}`;
|
|
388
581
|
}
|
|
389
582
|
console.log(`(${overrides.join(", ")})\n`);
|
|
390
583
|
|
|
391
|
-
//console.log({ model: profile.model, prompt,
|
|
584
|
+
//console.log({ model: profile.model, prompt, previous_provider_state });
|
|
392
585
|
|
|
393
586
|
const clientOpts = {
|
|
394
587
|
apiKey: state.apiKeys[profile.provider],
|
|
395
588
|
};
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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);
|
|
593
|
+
}
|
|
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);
|
|
399
601
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
state.previous_previous_response_id = previous_response_id;
|
|
403
|
-
state.previous_response_id = response.id;
|
|
602
|
+
state.providerStates[profile.provider].previous_previous = previous_provider_state;
|
|
603
|
+
state.providerStates[profile.provider].previous = response.state;
|
|
404
604
|
saveState();
|
|
405
605
|
if (response.used_tools) {
|
|
606
|
+
if (used_docker) {
|
|
607
|
+
execFileSync("docker", ["rm", "-f", "slave-sandbox"], { encoding: "utf8" });
|
|
608
|
+
}
|
|
406
609
|
console.log();
|
|
407
610
|
}
|
|
408
611
|
console.log(markdownToConsoleText(response.output_text) + "\n");
|
package/package.json
CHANGED
|
@@ -4,13 +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
10
|
"slave": "index.js"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
+
"@anthropic-ai/sdk": "^1.3.0",
|
|
13
14
|
"commander": "^15.0.0",
|
|
15
|
+
"env-paths": "^4.0.0",
|
|
14
16
|
"ignore": "^7.0.13",
|
|
15
17
|
"marked": "^12",
|
|
16
18
|
"marked-terminal": "7.0.0",
|
package/.slaveignore
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
.state.json
|
|
Binary file
|