@calamity-inc/slave 0.2.0 → 0.3.1
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 +22 -10
- package/index.js +411 -152
- 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,25 @@ 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
|
-
- `--create-profile <name> <provider> <model>` —
|
|
33
|
-
- `provider` can be `openai` or an OpenAI-compatible base URL
|
|
34
|
-
- `--
|
|
35
|
-
- `--
|
|
33
|
+
- `--create-profile <name> <provider> <model>` — create 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
|
+
- `--list-profiles` — list all profiles
|
|
36
|
+
- `--delete-profile <name>` — delete a profile
|
|
37
|
+
- `--update-api-key <provider>` — update an API Key
|
|
36
38
|
|
|
37
|
-
##
|
|
39
|
+
## Security
|
|
38
40
|
|
|
39
|
-
The model
|
|
40
|
-
-
|
|
41
|
+
The model can't run commands on your system; it only gets limited access to your filesystem:
|
|
42
|
+
- Within the working directory and subdirectories.
|
|
41
43
|
- `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
|
|
42
44
|
- Files that are `.gitignore`d can't be written to.
|
|
43
45
|
- 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.
|
|
@@ -45,3 +47,13 @@ The model gets no command prompt access and limited access to your filesystem:
|
|
|
45
47
|
However, these restrictions are only as good as your practices; you should:
|
|
46
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.
|
|
47
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,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
|
-
const configDir =
|
|
14
|
-
|
|
16
|
+
const { data: configDir, log: logDir } = envPaths("slave");
|
|
17
|
+
|
|
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,39 @@ 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
|
+
};
|
|
35
|
+
|
|
36
|
+
const rotateLogs = () => {
|
|
37
|
+
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
38
|
+
for (const name of fs.readdirSync(logDir)) {
|
|
39
|
+
try {
|
|
40
|
+
if (fs.statSync(path.join(logDir, name)).mtimeMs < cutoff) {
|
|
41
|
+
fs.unlinkSync(path.join(logDir, name));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
// another process may have removed it already; ignore.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let logFh;
|
|
51
|
+
const logWrite = (line) => {
|
|
52
|
+
if (!logFh) {
|
|
53
|
+
const now = new Date();
|
|
54
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
55
|
+
const logfileName = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}Z.txt`;
|
|
56
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
57
|
+
logFh = fs.openSync(path.join(logDir, logfileName), "a", 0o600);
|
|
58
|
+
rotateLogs();
|
|
59
|
+
}
|
|
60
|
+
fs.writeSync(logFh, line + "\n");
|
|
61
|
+
};
|
|
62
|
+
const logWriteBoth = (line) => {
|
|
63
|
+
console.log(line);
|
|
64
|
+
logWrite(line);
|
|
35
65
|
};
|
|
36
66
|
|
|
37
67
|
const slaveignore = ignore();
|
|
@@ -42,7 +72,7 @@ const gitignore = ignore();
|
|
|
42
72
|
if (fs.existsSync(".gitignore")) {
|
|
43
73
|
gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
|
|
44
74
|
}
|
|
45
|
-
const isOutOfReach = (p,
|
|
75
|
+
const isOutOfReach = (p, accessType) => {
|
|
46
76
|
const rel = path.relative(".", p);
|
|
47
77
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
48
78
|
return true;
|
|
@@ -51,12 +81,20 @@ const isOutOfReach = (p, type) => {
|
|
|
51
81
|
if (segs.includes(".env") || segs.includes(".git")) {
|
|
52
82
|
return true;
|
|
53
83
|
}
|
|
54
|
-
if (rel && (slaveignore.ignores(rel) || (
|
|
84
|
+
if (rel && (slaveignore.ignores(rel) || (accessType == "write" && gitignore.ignores(rel)))) {
|
|
55
85
|
return true;
|
|
56
86
|
}
|
|
57
87
|
return false;
|
|
58
88
|
};
|
|
59
89
|
|
|
90
|
+
const fixPath = (path, accessType) => {
|
|
91
|
+
if (path.startsWith("/")) {
|
|
92
|
+
path = path.substring(1);
|
|
93
|
+
path ||= ".";
|
|
94
|
+
}
|
|
95
|
+
return isOutOfReach(path, accessType) ? "" : path;
|
|
96
|
+
};
|
|
97
|
+
|
|
60
98
|
const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
|
|
61
99
|
const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
62
100
|
if (fs.existsSync(filePath)) {
|
|
@@ -71,10 +109,25 @@ const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
|
71
109
|
fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
|
|
72
110
|
};
|
|
73
111
|
|
|
112
|
+
let used_docker;
|
|
113
|
+
const ensureSandbox = () => {
|
|
114
|
+
if (!used_docker) {
|
|
115
|
+
try {
|
|
116
|
+
// `docker pull ghcr.io/calamity-inc/slave-sandbox` is not needed to get the image, but would be needed to update it.
|
|
117
|
+
execFileSync("docker", ["run", "-d", "--name", "slave-sandbox", "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
// container may already exist if it was not cleaned up previously. this is okay-ish.
|
|
121
|
+
}
|
|
122
|
+
used_docker = true;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
74
126
|
const functions = {
|
|
75
127
|
ls: ({ path }) => {
|
|
76
128
|
path ??= ".";
|
|
77
|
-
|
|
129
|
+
path = fixPath(path);
|
|
130
|
+
if (!path) {
|
|
78
131
|
return "Out of your reach";
|
|
79
132
|
}
|
|
80
133
|
let files;
|
|
@@ -88,7 +141,8 @@ const functions = {
|
|
|
88
141
|
},
|
|
89
142
|
findFiles: ({ path, substring, recursive }) => {
|
|
90
143
|
path ??= ".";
|
|
91
|
-
|
|
144
|
+
path = fixPath(path);
|
|
145
|
+
if (!path) {
|
|
92
146
|
return "Out of your reach";
|
|
93
147
|
}
|
|
94
148
|
let files;
|
|
@@ -116,21 +170,27 @@ const functions = {
|
|
|
116
170
|
return res.join("\n");
|
|
117
171
|
},
|
|
118
172
|
readFile: ({ path }) => {
|
|
119
|
-
|
|
120
|
-
|
|
173
|
+
path = fixPath(path);
|
|
174
|
+
if (!path) {
|
|
175
|
+
return "You don't have read access to this file";
|
|
176
|
+
}
|
|
177
|
+
if (!fs.existsSync(path) || !fs.statSync(path).isFile()) {
|
|
178
|
+
return "No such file";
|
|
121
179
|
}
|
|
122
180
|
return readFileForModel(path);
|
|
123
181
|
},
|
|
124
182
|
writeFile: ({ path, data }) => {
|
|
125
|
-
|
|
126
|
-
|
|
183
|
+
path = fixPath(path, "write");
|
|
184
|
+
if (!path) {
|
|
185
|
+
return "You don't have write access to this file";
|
|
127
186
|
}
|
|
128
187
|
writeFileFromModel(path, data);
|
|
129
188
|
return "OK";
|
|
130
189
|
},
|
|
131
190
|
fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
|
|
132
|
-
|
|
133
|
-
|
|
191
|
+
path = fixPath(path, "write");
|
|
192
|
+
if (!path) {
|
|
193
|
+
return "You don't have write access to this file";
|
|
134
194
|
}
|
|
135
195
|
const fileContent = fs.readFileSync(path, "utf8");
|
|
136
196
|
const usesCRLF = fileContent.includes("\r\n");
|
|
@@ -142,128 +202,240 @@ const functions = {
|
|
|
142
202
|
writeFileFromModel(path, arr.join(replacement), usesCRLF);
|
|
143
203
|
return "OK";
|
|
144
204
|
},
|
|
145
|
-
|
|
205
|
+
sandboxExec: ({ command, args }) => {
|
|
206
|
+
ensureSandbox();
|
|
207
|
+
const result = spawnSync("docker", [ "exec", "slave-sandbox", command, ...args ], { encoding: "utf8" });
|
|
208
|
+
const output = (result.stdout ?? "") + (result.stderr ?? "");
|
|
209
|
+
if (result.error) {
|
|
210
|
+
return (output || result.error.message).trimEnd();
|
|
211
|
+
}
|
|
212
|
+
if (result.status !== 0 && !output) {
|
|
213
|
+
return `Command exited with status ${result.status}`;
|
|
214
|
+
}
|
|
215
|
+
return output;
|
|
216
|
+
},
|
|
217
|
+
sandboxCopyIn: ({ hostPath, sbxPath }) => {
|
|
218
|
+
hostPath = fixPath(hostPath);
|
|
219
|
+
if (!hostPath) {
|
|
220
|
+
return "You don't have read access to this file";
|
|
221
|
+
}
|
|
222
|
+
if (!fs.existsSync(hostPath) || !fs.statSync(hostPath).isFile()) {
|
|
223
|
+
return "Host path must point to a file";
|
|
224
|
+
}
|
|
225
|
+
ensureSandbox();
|
|
226
|
+
try {
|
|
227
|
+
execFileSync("docker", ["cp", "-L", hostPath, `slave-sandbox:${sbxPath}`], { encoding: "utf8" });
|
|
228
|
+
return "OK";
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return (e.stdout ?? e.message).trimEnd();
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
sandboxCopyOut: ({ sbxPath, hostPath }) => {
|
|
235
|
+
hostPath = fixPath(hostPath, "write");
|
|
236
|
+
if (!hostPath) {
|
|
237
|
+
return "You don't have write access to this file";
|
|
238
|
+
}
|
|
239
|
+
if (fs.existsSync(hostPath) && !fs.statSync(hostPath).isFile()) {
|
|
240
|
+
return "Host path must point to a file";
|
|
241
|
+
}
|
|
242
|
+
ensureSandbox();
|
|
243
|
+
execFileSync("docker", ["cp", "-L", `slave-sandbox:${sbxPath}`, hostPath], { encoding: "utf8" });
|
|
244
|
+
return "OK";
|
|
245
|
+
},
|
|
246
|
+
/*system: ({ command }) => {
|
|
146
247
|
//process.exit(1);
|
|
147
248
|
return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
|
|
148
|
-
}
|
|
249
|
+
}*/
|
|
149
250
|
};
|
|
150
251
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
252
|
+
const tools = [];
|
|
253
|
+
const initTools = (fs, sandbox, web_search) => {
|
|
254
|
+
if (fs) {
|
|
255
|
+
tools.push(
|
|
256
|
+
{
|
|
257
|
+
type: "function",
|
|
258
|
+
name: "ls",
|
|
259
|
+
description: "Lists files",
|
|
260
|
+
parameters: {
|
|
261
|
+
type: "object",
|
|
262
|
+
properties: {
|
|
263
|
+
path: { type: ["string", "null"] },
|
|
264
|
+
},
|
|
265
|
+
required: [ "path" ],
|
|
266
|
+
additionalProperties: false,
|
|
267
|
+
},
|
|
268
|
+
strict: true,
|
|
160
269
|
},
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
270
|
+
{
|
|
271
|
+
type: "function",
|
|
272
|
+
name: "findFiles",
|
|
273
|
+
parameters: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
path: { type: ["string", "null"] },
|
|
277
|
+
substring: { type: "string" },
|
|
278
|
+
recursive: { type: ["boolean", "null"] },
|
|
279
|
+
},
|
|
280
|
+
required: [ "path", "substring", "recursive" ],
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
},
|
|
283
|
+
strict: true,
|
|
175
284
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
285
|
+
{
|
|
286
|
+
type: "function",
|
|
287
|
+
name: "readFile",
|
|
288
|
+
parameters: {
|
|
289
|
+
type: "object",
|
|
290
|
+
properties: {
|
|
291
|
+
path: { type: "string" },
|
|
292
|
+
},
|
|
293
|
+
required: [ "path" ],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
strict: true,
|
|
188
297
|
},
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
298
|
+
{
|
|
299
|
+
type: "function",
|
|
300
|
+
name: "writeFile",
|
|
301
|
+
parameters: {
|
|
302
|
+
type: "object",
|
|
303
|
+
properties: {
|
|
304
|
+
path: { type: "string" },
|
|
305
|
+
data: { type: "string" },
|
|
306
|
+
},
|
|
307
|
+
required: [ "path", "data" ],
|
|
308
|
+
additionalProperties: false,
|
|
309
|
+
},
|
|
310
|
+
strict: true,
|
|
202
311
|
},
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
312
|
+
{
|
|
313
|
+
type: "function",
|
|
314
|
+
name: "fileFindAndReplace",
|
|
315
|
+
parameters: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: {
|
|
318
|
+
path: { type: "string" },
|
|
319
|
+
substring: { type: "string" },
|
|
320
|
+
replacement: { type: "string" },
|
|
321
|
+
expectedMatches: { type: ["number", "null"] },
|
|
322
|
+
},
|
|
323
|
+
required: [ "path", "substring", "replacement", "expectedMatches" ],
|
|
324
|
+
additionalProperties: false,
|
|
325
|
+
},
|
|
326
|
+
strict: true,
|
|
218
327
|
},
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (sandbox) {
|
|
331
|
+
tools.push({
|
|
332
|
+
type: "function",
|
|
333
|
+
name: "sandboxExec",
|
|
334
|
+
description: "Runs a command without Bash in an isolated sandbox.",
|
|
335
|
+
parameters: {
|
|
336
|
+
type: "object",
|
|
337
|
+
properties: {
|
|
338
|
+
command: { type: "string" },
|
|
339
|
+
args: { type: "array", items: { type: "string" } },
|
|
340
|
+
},
|
|
341
|
+
required: [ "command", "args" ],
|
|
342
|
+
additionalProperties: false,
|
|
232
343
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
344
|
+
strict: true,
|
|
345
|
+
});
|
|
346
|
+
if (fs) {
|
|
347
|
+
tools.push(
|
|
348
|
+
{
|
|
349
|
+
type: "function",
|
|
350
|
+
name: "sandboxCopyIn",
|
|
351
|
+
parameters: {
|
|
352
|
+
type: "object",
|
|
353
|
+
properties: {
|
|
354
|
+
hostPath: { type: "string" },
|
|
355
|
+
sbxPath: { type: "string" },
|
|
356
|
+
},
|
|
357
|
+
required: [ "hostPath", "sbxPath" ],
|
|
358
|
+
additionalProperties: false,
|
|
359
|
+
},
|
|
360
|
+
strict: true,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
type: "function",
|
|
364
|
+
name: "sandboxCopyOut",
|
|
365
|
+
parameters: {
|
|
366
|
+
type: "object",
|
|
367
|
+
properties: {
|
|
368
|
+
sbxPath: { type: "string" },
|
|
369
|
+
hostPath: { type: "string" },
|
|
370
|
+
},
|
|
371
|
+
required: [ "sbxPath", "hostPath" ],
|
|
372
|
+
additionalProperties: false,
|
|
373
|
+
},
|
|
374
|
+
strict: true,
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (web_search) {
|
|
380
|
+
tools.push({ type: "web_search" });
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const toolToAnthropic = (t) => {
|
|
385
|
+
if (t.type == "function") {
|
|
386
|
+
return {
|
|
387
|
+
name: t.name,
|
|
388
|
+
description: t.description,
|
|
389
|
+
input_schema: {
|
|
390
|
+
type: t.parameters.type,
|
|
391
|
+
properties: t.parameters.properties,
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
else if (t.type == "web_search") {
|
|
396
|
+
return {
|
|
397
|
+
type: "web_search_20260318",
|
|
398
|
+
name: "web_search"
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const printJunk = (line) => {
|
|
404
|
+
logWrite(line);
|
|
405
|
+
line = line.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>").replaceAll("\t", " ");
|
|
406
|
+
if (line.length > 80) {
|
|
407
|
+
line = line.substring(0, 77) + "...";
|
|
408
|
+
}
|
|
409
|
+
console.log(line);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const useTool = (name, args, argsStr) => {
|
|
413
|
+
printJunk("$ " + name + argsStr);
|
|
414
|
+
const output = String(functions[name](args));
|
|
415
|
+
printJunk(output);
|
|
416
|
+
return output;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const openai_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
|
|
241
420
|
let response = await client.responses.create({
|
|
242
421
|
model,
|
|
243
|
-
previous_response_id,
|
|
422
|
+
previous_response_id: state,
|
|
244
423
|
input,
|
|
245
424
|
tools,
|
|
246
425
|
});
|
|
247
426
|
let used_tools = false;
|
|
248
427
|
|
|
249
428
|
while (true) {
|
|
250
|
-
const toolCalls = response.output.filter((item) => item.type
|
|
429
|
+
const toolCalls = response.output.filter((item) => item.type == "function_call");
|
|
251
430
|
if (toolCalls.length == 0) {
|
|
252
431
|
break;
|
|
253
432
|
}
|
|
254
433
|
|
|
255
434
|
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
435
|
return {
|
|
264
436
|
type: "function_call_output",
|
|
265
437
|
call_id: call.call_id,
|
|
266
|
-
output,
|
|
438
|
+
output: useTool(call.name, JSON.parse(call.arguments), call.arguments)
|
|
267
439
|
};
|
|
268
440
|
});
|
|
269
441
|
|
|
@@ -276,8 +448,69 @@ const generateResponseOptionallyUsingTools = async (client, model, input, previo
|
|
|
276
448
|
used_tools = true;
|
|
277
449
|
}
|
|
278
450
|
|
|
279
|
-
|
|
280
|
-
|
|
451
|
+
return {
|
|
452
|
+
output_text: response.output_text,
|
|
453
|
+
state: response.id,
|
|
454
|
+
used_tools,
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const anthropic_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
|
|
459
|
+
state = JSON.parse(state || "[]");
|
|
460
|
+
state.push({ role: "user", content: input });
|
|
461
|
+
let used_tools = false;
|
|
462
|
+
|
|
463
|
+
const anthropicTools = tools.map(t => toolToAnthropic(t));
|
|
464
|
+
|
|
465
|
+
let message = await client.messages.create({
|
|
466
|
+
model,
|
|
467
|
+
max_tokens: 20000,
|
|
468
|
+
cache_control: { type: "ephemeral" },
|
|
469
|
+
messages: state,
|
|
470
|
+
tools: anthropicTools,
|
|
471
|
+
});
|
|
472
|
+
state.push({ role: message.role, content: message.content });
|
|
473
|
+
|
|
474
|
+
while (message.stop_reason == "tool_use" || message.stop_reason == "pause_turn") {
|
|
475
|
+
const toolCalls = message.content.filter((item) => item.type == "tool_use");
|
|
476
|
+
if (toolCalls.length) {
|
|
477
|
+
const outputs = toolCalls.map((call) => {
|
|
478
|
+
return {
|
|
479
|
+
type: "tool_result",
|
|
480
|
+
tool_use_id: call.id,
|
|
481
|
+
content: useTool(call.name, call.input, JSON.stringify(call.input))
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
state.push({ role: "user", content: outputs });
|
|
485
|
+
used_tools = true;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/*if (message.stop_reason == "max_tokens" && message.content[message.content.length].type == "thinking") {
|
|
489
|
+
state.push({ role: "user", content: "Please continue from where you left off." });
|
|
490
|
+
}*/
|
|
491
|
+
|
|
492
|
+
message = await client.messages.create({
|
|
493
|
+
model,
|
|
494
|
+
max_tokens: 20000,
|
|
495
|
+
cache_control: { type: "ephemeral" },
|
|
496
|
+
messages: state,
|
|
497
|
+
tools: anthropicTools,
|
|
498
|
+
});
|
|
499
|
+
state.push({ role: message.role, content: message.content });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (message.stop_reason != "end_turn") {
|
|
503
|
+
logWrite(`unexpected stop_reason: ${message.stop_reason}\n${JSON.stringify(message, null, 2)}`);
|
|
504
|
+
console.log(message);
|
|
505
|
+
throw new Error(`unexpected stop_reason: ${message.stop_reason}`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
//console.log(message);
|
|
509
|
+
return {
|
|
510
|
+
output_text: message.content.find((item) => item.type == "text").text,
|
|
511
|
+
state: JSON.stringify(state),
|
|
512
|
+
used_tools,
|
|
513
|
+
};
|
|
281
514
|
};
|
|
282
515
|
|
|
283
516
|
marked.use(markedTerminal());
|
|
@@ -289,6 +522,9 @@ const markdownToConsoleText = (md) => {
|
|
|
289
522
|
return text;
|
|
290
523
|
};
|
|
291
524
|
|
|
525
|
+
const profileToString = (profile) => {
|
|
526
|
+
return profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`;
|
|
527
|
+
};
|
|
292
528
|
|
|
293
529
|
const program = new Command();
|
|
294
530
|
program
|
|
@@ -297,13 +533,15 @@ program
|
|
|
297
533
|
.description("Vibe coding without the kaboom factor.")
|
|
298
534
|
.argument("[prompt...]")
|
|
299
535
|
.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")
|
|
536
|
+
.option("-a, --amend", "retry the previous invocation, using the context from before it")
|
|
301
537
|
.option("-w, --web", "allow the model to search the web")
|
|
302
538
|
.option("-n, --no-fs", "disable filesystem access").option("--nofs")
|
|
539
|
+
.option("-s, --sandbox", "allow the model to use a docker container")
|
|
303
540
|
.option("-p, --profile <name>", "profile name", "default")
|
|
304
541
|
.option("--create-profile <name> <provider> <model>", "create a profile")
|
|
305
|
-
.option("--
|
|
306
|
-
.option("--
|
|
542
|
+
.option("--list-profiles", "list all profiles")
|
|
543
|
+
.option("--delete-profile <name>", "delete a profile")
|
|
544
|
+
.option("--update-api-key <provider>", "update an API key")
|
|
307
545
|
.helpOption("-?, --help", "display help")
|
|
308
546
|
.parse();
|
|
309
547
|
const options = program.opts();
|
|
@@ -314,7 +552,7 @@ if (options.createProfile) {
|
|
|
314
552
|
process.exit(1);
|
|
315
553
|
}
|
|
316
554
|
const provider = program.args[0];
|
|
317
|
-
if (provider != "openai" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
|
|
555
|
+
if (provider != "openai" && provider != "anthropic" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
|
|
318
556
|
console.log(`Invalid provider: ${provider}`);
|
|
319
557
|
process.exit(1);
|
|
320
558
|
}
|
|
@@ -327,6 +565,16 @@ if (options.createProfile) {
|
|
|
327
565
|
console.log(`${isCreate ? "Created" : "Updated"} ${options.createProfile} profile.`);
|
|
328
566
|
process.exit(0);
|
|
329
567
|
}
|
|
568
|
+
if (options.listProfiles) {
|
|
569
|
+
const names = Object.keys(state.profiles);
|
|
570
|
+
if (names.length == 0) {
|
|
571
|
+
console.log("No profiles.");
|
|
572
|
+
}
|
|
573
|
+
for (const name of names) {
|
|
574
|
+
console.log(`${name}: ${profileToString(state.profiles[name])}`);
|
|
575
|
+
}
|
|
576
|
+
process.exit(0);
|
|
577
|
+
}
|
|
330
578
|
if (options.deleteProfile) {
|
|
331
579
|
delete state.profiles[options.deleteProfile];
|
|
332
580
|
saveState();
|
|
@@ -352,57 +600,68 @@ if (!profile) {
|
|
|
352
600
|
}
|
|
353
601
|
|
|
354
602
|
let prompt = program.args.join(" ").trim();
|
|
355
|
-
let
|
|
356
|
-
const overrides = [profile
|
|
603
|
+
let previous_provider_state;
|
|
604
|
+
const overrides = [profileToString(profile)];
|
|
605
|
+
state.providerStates[profile.provider] ??= {};
|
|
357
606
|
if (options.continue || options.cont) {
|
|
358
|
-
|
|
607
|
+
previous_provider_state = state.providerStates[profile.provider].previous;
|
|
359
608
|
}
|
|
360
609
|
else if (options.amend) {
|
|
361
|
-
|
|
610
|
+
previous_provider_state = state.providerStates[profile.provider].previous_previous;
|
|
362
611
|
}
|
|
612
|
+
const filesystemEnabled = options.fs !== false && !options.nofs;
|
|
613
|
+
initTools(filesystemEnabled, options.sandbox, options.web);
|
|
363
614
|
if (options.web) {
|
|
364
|
-
tools.push({ type: "web_search" });
|
|
365
615
|
overrides.push("web search enabled");
|
|
366
616
|
}
|
|
367
|
-
if (
|
|
368
|
-
tools = tools.filter(tool => tool.type != "function");
|
|
617
|
+
if (!filesystemEnabled) {
|
|
369
618
|
overrides.push("no filesystem access");
|
|
370
619
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const chunks = [];
|
|
374
|
-
for await (const chunk of process.stdin) {
|
|
375
|
-
chunks.push(chunk);
|
|
376
|
-
}
|
|
377
|
-
stdinText = Buffer.concat(chunks).toString("utf8");
|
|
620
|
+
if (options.sandbox) {
|
|
621
|
+
overrides.push("sandbox enabled");
|
|
378
622
|
}
|
|
379
623
|
if (!prompt) {
|
|
380
624
|
console.log("No prompt given.");
|
|
381
625
|
process.exit(1);
|
|
382
626
|
}
|
|
383
627
|
|
|
384
|
-
|
|
385
|
-
if (
|
|
628
|
+
logWriteBoth((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
|
|
629
|
+
if (!process.stdin.isTTY) {
|
|
630
|
+
const chunks = [];
|
|
631
|
+
for await (const chunk of process.stdin) {
|
|
632
|
+
chunks.push(chunk);
|
|
633
|
+
}
|
|
634
|
+
prompt = `${Buffer.concat(chunks).toString("utf8")}\n\n${prompt}`;
|
|
386
635
|
overrides.push("stdin provided");
|
|
387
|
-
prompt = `${stdinText}\n\n${prompt}`;
|
|
388
636
|
}
|
|
389
|
-
|
|
637
|
+
logWriteBoth(`(${overrides.join(", ")})\n`);
|
|
390
638
|
|
|
391
|
-
//console.log({ model: profile.model, prompt,
|
|
639
|
+
//console.log({ model: profile.model, prompt, previous_provider_state });
|
|
392
640
|
|
|
393
641
|
const clientOpts = {
|
|
394
642
|
apiKey: state.apiKeys[profile.provider],
|
|
395
643
|
};
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
644
|
+
let response;
|
|
645
|
+
if (profile.provider == "anthropic") {
|
|
646
|
+
const client = new Anthropic(clientOpts);
|
|
647
|
+
response = await anthropic_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
|
|
399
648
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
649
|
+
else {
|
|
650
|
+
if (profile.provider != "openai") {
|
|
651
|
+
clientOpts.baseURL = profile.provider;
|
|
652
|
+
clientOpts.apiKey ||= "dummy";
|
|
653
|
+
}
|
|
654
|
+
const client = new OpenAI(clientOpts);
|
|
655
|
+
response = await openai_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
|
|
656
|
+
}
|
|
657
|
+
state.providerStates[profile.provider].previous_previous = previous_provider_state;
|
|
658
|
+
state.providerStates[profile.provider].previous = response.state;
|
|
404
659
|
saveState();
|
|
405
660
|
if (response.used_tools) {
|
|
406
|
-
|
|
661
|
+
if (used_docker) {
|
|
662
|
+
execFileSync("docker", ["rm", "-f", "slave-sandbox"], { encoding: "utf8" });
|
|
663
|
+
}
|
|
664
|
+
logWriteBoth("");
|
|
407
665
|
}
|
|
408
666
|
console.log(markdownToConsoleText(response.output_text) + "\n");
|
|
667
|
+
logWrite(response.output_text);
|
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.1",
|
|
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
|