@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 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
- - `-p, --profile <name>` — use the specified profile
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>` — creates a profile
33
- - `provider` can be `openai` or an OpenAI-compatible base URL, e.g. `http://127.0.0.1:1234/v1` for [LM Studio](https://lmstudio.ai/developers).
34
- - `--delete-profile <name>` — deletes a profile
35
- - `--update-api-key <provider>` — updates an API Key
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
- ## Sandboxing / Security
39
+ ## Security
38
40
 
39
- The model gets no command prompt access and limited access to your filesystem:
40
- - Only within the working directory and subdirectories.
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
- import ignore from "ignore";
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 = path.dirname(fileURLToPath(import.meta.url));
14
- const state = fs.existsSync(path.join(configDir, ".state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, ".state.json"), "utf8")) : {};
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
- if (!state.apiKeys.openai && fs.existsSync(path.join(configDir, ".env"))) {
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.writeFileSync(path.join(configDir, ".state.json"), JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
34
- fs.chmodSync(path.join(configDir, ".state.json"), 0o600);
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, type) => {
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) || (type == "write" && gitignore.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
- if (isOutOfReach(path)) {
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
- if (isOutOfReach(path)) {
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
- if (isOutOfReach(path)) {
120
- return "Out of your reach";
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
- if (isOutOfReach(path, "write")) {
126
- return "Out of your reach";
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
- if (isOutOfReach(path, "write")) {
133
- return "Out of your reach";
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
- system: ({ command }) => {
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
- let tools = [
152
- {
153
- type: "function",
154
- name: "ls",
155
- description: "Lists files",
156
- parameters: {
157
- type: "object",
158
- properties: {
159
- path: { type: ["string", "null"] },
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
- required: [ "path" ],
162
- additionalProperties: false,
163
- },
164
- strict: true,
165
- },
166
- {
167
- type: "function",
168
- name: "findFiles",
169
- parameters: {
170
- type: "object",
171
- properties: {
172
- path: { type: ["string", "null"] },
173
- substring: { type: "string" },
174
- recursive: { type: ["boolean", "null"] },
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
- required: [ "path", "substring", "recursive" ],
177
- additionalProperties: false,
178
- },
179
- strict: true,
180
- },
181
- {
182
- type: "function",
183
- name: "readFile",
184
- parameters: {
185
- type: "object",
186
- properties: {
187
- path: { type: "string" },
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
- required: [ "path" ],
190
- additionalProperties: false,
191
- },
192
- strict: true,
193
- },
194
- {
195
- type: "function",
196
- name: "writeFile",
197
- parameters: {
198
- type: "object",
199
- properties: {
200
- path: { type: "string" },
201
- data: { type: "string" },
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
- required: [ "path", "data" ],
204
- additionalProperties: false,
205
- },
206
- strict: true,
207
- },
208
- {
209
- type: "function",
210
- name: "fileFindAndReplace",
211
- parameters: {
212
- type: "object",
213
- properties: {
214
- path: { type: "string" },
215
- substring: { type: "string" },
216
- replacement: { type: "string" },
217
- expectedMatches: { type: ["number", "null"] },
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
- required: [ "path", "substring", "replacement", "expectedMatches" ],
220
- additionalProperties: false,
221
- },
222
- strict: true,
223
- },
224
- // This is useful to see where the model maybe expects a better tool:
225
- /*{
226
- type: "function",
227
- name: "system",
228
- parameters: {
229
- type: "object",
230
- properties: {
231
- command: { type: "string" },
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
- required: [ "command" ],
234
- additionalProperties: false,
235
- },
236
- strict: true,
237
- },*/
238
- ];
239
-
240
- const generateResponseOptionallyUsingTools = async (client, model, input, previous_response_id) => {
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 === "function_call");
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
- response.used_tools = used_tools;
280
- return response;
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("--delete-profile <name>", "delete a profile") // TODO
306
- .option("--update-api-key <provider>", "update an API key") // TODO
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 previous_response_id;
356
- const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
603
+ let previous_provider_state;
604
+ const overrides = [profileToString(profile)];
605
+ state.providerStates[profile.provider] ??= {};
357
606
  if (options.continue || options.cont) {
358
- previous_response_id = state.previous_response_id;
607
+ previous_provider_state = state.providerStates[profile.provider].previous;
359
608
  }
360
609
  else if (options.amend) {
361
- previous_response_id = state.previous_previous_response_id;
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 (options.fs == false || options.nofs) {
368
- tools = tools.filter(tool => tool.type != "function");
617
+ if (!filesystemEnabled) {
369
618
  overrides.push("no filesystem access");
370
619
  }
371
- let stdinText = "";
372
- if (!process.stdin.isTTY) {
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
- console.log((previous_response_id ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
385
- if (stdinText) {
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
- console.log(`(${overrides.join(", ")})\n`);
637
+ logWriteBoth(`(${overrides.join(", ")})\n`);
390
638
 
391
- //console.log({ model: profile.model, prompt, previous_response_id });
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
- if (profile.provider != "openai") {
397
- clientOpts.baseURL = profile.provider;
398
- clientOpts.apiKey ||= "dummy";
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
- const client = new OpenAI(clientOpts);
401
- const response = await generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_response_id);
402
- state.previous_previous_response_id = previous_response_id;
403
- state.previous_response_id = response.id;
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
- console.log();
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.2.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