@calamity-inc/slave 0.4.0 → 0.4.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
@@ -39,25 +39,16 @@ Subcommands:
39
39
  - `--list-profiles` — list all profiles
40
40
  - `--delete-profile <name>` — delete a profile
41
41
  - `--update-api-key <provider>` — update an API Key
42
+ - `--logs` — get info about logs
43
+ - `--skills` — get info about skills
42
44
 
43
45
  ## Security
44
46
 
45
47
  The model can't run commands on your system; it only gets limited access to your filesystem:
46
- - Within the working directory and subdirectories.
47
- - `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
48
+ - Restricted to the working directory and its subdirectories.
49
+ - Can't read the `.git` folder, `.env` files, or any files excluded by `.slaveignore`. Although the model will still be able to know of their existence.
48
50
  - Files that are `.gitignore`d can't be written to.
49
- - 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.
50
51
 
51
52
  However, these restrictions are only as good as your practices; you should:
52
53
  - 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.
53
54
  - Review changes to your code before running anything, including tests.
54
-
55
- ## Logs
56
-
57
- Each invocation that prompts a model creates a log file:
58
-
59
- - Linux: `~/.local/state/slave-nodejs`, or `$XDG_STATE_HOME/slave-nodejs` if `XDG_STATE_HOME` is set
60
- - macOS: `~/Library/Logs/slave-nodejs`
61
- - Windows: `%LOCALAPPDATA%\slave-nodejs\Log`
62
-
63
- When a new log file is created, any log file older than 30 days is deleted.
package/package.json CHANGED
@@ -4,13 +4,14 @@
4
4
  "type": "git",
5
5
  "url": "git+https://github.com/calamity-inc/slave.git"
6
6
  },
7
- "version": "0.4.0",
7
+ "version": "0.4.1",
8
8
  "type": "commonjs",
9
9
  "bin": {
10
10
  "slave": "src/main.js"
11
11
  },
12
12
  "dependencies": {
13
13
  "@anthropic-ai/sdk": "^1.3.0",
14
+ "chalk": "^5",
14
15
  "commander": "^15.0.0",
15
16
  "env-paths": "^4.0.0",
16
17
  "ignore": "^7.0.13",
package/src/core.js CHANGED
@@ -5,9 +5,11 @@ const { randomInt } = require("node:crypto");
5
5
  const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
 
8
+ const chalk = require("chalk").default;
8
9
  const ignore = require("ignore");
9
10
 
10
11
  const { logWrite } = require("./log.js");
12
+ const { skills } = require("./skills.js");
11
13
 
12
14
  const slaveignore = ignore();
13
15
  if (fs.existsSync(".slaveignore")) {
@@ -23,7 +25,7 @@ const isOutOfReach = (p, accessType) => {
23
25
  return true;
24
26
  }
25
27
  const segs = rel.split(path.sep);
26
- if (segs.includes(".env") || segs.includes(".git")) {
28
+ if (segs.includes(".git") || segs.some(seg => seg.startsWith(".env"))) {
27
29
  return true;
28
30
  }
29
31
  if (rel && (slaveignore.ignores(rel) || (accessType == "write" && gitignore.ignores(rel)))) {
@@ -32,6 +34,11 @@ const isOutOfReach = (p, accessType) => {
32
34
  return false;
33
35
  };
34
36
 
37
+ const isPathWithinFolder = (folder, p) => {
38
+ const rel = path.relative(folder, p);
39
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
40
+ };
41
+
35
42
  const fixPath = (path, accessType) => {
36
43
  if (path.startsWith("/")) {
37
44
  path = path.substring(1);
@@ -54,6 +61,27 @@ const writeFileFromModel = (filePath, data, usesCRLF) => {
54
61
  fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
55
62
  };
56
63
 
64
+ const getSystemPrompt = () => {
65
+ const entries = Object.entries(skills);
66
+ if (entries.length == 0) {
67
+ return undefined;
68
+ }
69
+ return "You have the following skills available:\n" + entries.map(([name, skill]) => `- ${name}: ${skill.description}`).join("\n");
70
+ };
71
+ const getSkillResourcePath = (name, resourcePath) => {
72
+ if (Object.hasOwn(skills, name)) {
73
+ const absResourcePath = path.join(skills[name].path, resourcePath);
74
+ if (isPathWithinFolder(skills[name].path, absResourcePath)) {
75
+ if (fs.existsSync(absResourcePath)) {
76
+ return [true, absResourcePath];
77
+ }
78
+ return [false, "No such resource"];
79
+ }
80
+ return [false, "I'm gonna call the alignment police on you!!!"];
81
+ }
82
+ return [false, "No such skill"];
83
+ };
84
+
57
85
  let sandbox_name;
58
86
  const getSandboxName = () => {
59
87
  return sandbox_name;
@@ -68,8 +96,18 @@ const ensureSandbox = () => {
68
96
  execFileSync("docker", ["run", "-d", "--name", sandbox_name, "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
69
97
  }
70
98
  };
99
+ const copyFileToSandbox = (hostPath, sbxPath) => {
100
+ ensureSandbox();
101
+ try {
102
+ execFileSync("docker", ["cp", "-L", hostPath, `${sandbox_name}:${sbxPath}`], { encoding: "utf8" });
103
+ return "OK";
104
+ }
105
+ catch (e) {
106
+ return (e.stdout ?? e.message).trimEnd();
107
+ }
108
+ };
71
109
 
72
- const functions = {
110
+ const functions = Object.assign(Object.create(null), {
73
111
  listFiles: ({ path }) => {
74
112
  path ??= ".";
75
113
  path = fixPath(path);
@@ -159,10 +197,6 @@ const functions = {
159
197
  },
160
198
  sandboxExec: ({ command, args }) => {
161
199
  ensureSandbox();
162
- args ??= [];
163
- if (typeof args == "string") {
164
- args = JSON.parse(args);
165
- }
166
200
  const result = spawnSync("docker", [ "exec", sandbox_name, command, ...args ], { encoding: "utf8" });
167
201
  const output = (result.stdout ?? "") + (result.stderr ?? "");
168
202
  if (result.error) {
@@ -181,32 +215,49 @@ const functions = {
181
215
  if (!fs.existsSync(hostPath) || !fs.statSync(hostPath).isFile()) {
182
216
  return "Host path must point to a file";
183
217
  }
184
- ensureSandbox();
185
- try {
186
- execFileSync("docker", ["cp", "-L", hostPath, `${sandbox_name}:${sbxPath}`], { encoding: "utf8" });
187
- return "OK";
188
- }
189
- catch (e) {
190
- return (e.stdout ?? e.message).trimEnd();
191
- }
218
+ return copyFileToSandbox(hostPath, sbxPath);
192
219
  },
193
220
  sandboxCopyOut: ({ sbxPath, hostPath }) => {
194
221
  hostPath = fixPath(hostPath, "write");
195
222
  if (!hostPath) {
196
223
  return "You don't have write access to this file";
197
224
  }
198
- if (fs.existsSync(hostPath) && !fs.statSync(hostPath).isFile()) {
225
+ const isCreate = !fs.existsSync(hostPath);
226
+ if (!isCreate && !fs.statSync(hostPath).isFile()) {
199
227
  return "Host path must point to a file";
200
228
  }
201
229
  ensureSandbox();
230
+ if (isCreate) {
231
+ fs.mkdirSync(path.dirname(hostPath), { recursive: true });
232
+ }
202
233
  execFileSync("docker", ["cp", "-L", `${sandbox_name}:${sbxPath}`, hostPath], { encoding: "utf8" });
203
234
  return "OK";
204
235
  },
236
+ readSkill: ({ name }) => {
237
+ if (Object.hasOwn(skills, name)) {
238
+ return readFileForModel(path.join(skills[name].path, "SKILL.md"));
239
+ }
240
+ return "No such skill";
241
+ },
242
+ readSkillResource: ({ name, path }) => {
243
+ const [ok, res] = getSkillResourcePath(name, path)
244
+ if (ok) {
245
+ return readFileForModel(res);
246
+ }
247
+ return res;
248
+ },
249
+ copySkillResourceToSandbox: ({ name, path, sbxPath }) => {
250
+ const [ok, res] = getSkillResourcePath(name, path)
251
+ if (ok) {
252
+ return copyFileToSandbox(res, sbxPath);
253
+ }
254
+ return res;
255
+ },
205
256
  /*system: ({ command }) => {
206
257
  //process.exit(1);
207
258
  return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
208
259
  }*/
209
- };
260
+ });
210
261
 
211
262
  const tools = [];
212
263
  const initTools = (fs, sandbox, web_search) => {
@@ -228,6 +279,7 @@ const initTools = (fs, sandbox, web_search) => {
228
279
  {
229
280
  type: "function",
230
281
  name: "findFiles",
282
+ description: "Searches the contents of files for a literal text string",
231
283
  parameters: {
232
284
  type: "object",
233
285
  properties: {
@@ -334,6 +386,54 @@ const initTools = (fs, sandbox, web_search) => {
334
386
  );
335
387
  }
336
388
  }
389
+ if (Object.keys(skills).length) {
390
+ tools.push(
391
+ {
392
+ type: "function",
393
+ name: "readSkill",
394
+ parameters: {
395
+ type: "object",
396
+ properties: {
397
+ name: { type: "string" },
398
+ },
399
+ required: [ "name" ],
400
+ additionalProperties: false,
401
+ },
402
+ strict: true,
403
+ },
404
+ {
405
+ type: "function",
406
+ name: "readSkillResource",
407
+ parameters: {
408
+ type: "object",
409
+ properties: {
410
+ name: { type: "string" },
411
+ path: { type: "string" },
412
+ },
413
+ required: [ "name", "path" ],
414
+ additionalProperties: false,
415
+ },
416
+ strict: true,
417
+ },
418
+ );
419
+ if (sandbox) {
420
+ tools.push({
421
+ type: "function",
422
+ name: "copySkillResourceToSandbox",
423
+ parameters: {
424
+ type: "object",
425
+ properties: {
426
+ name: { type: "string" },
427
+ path: { type: "string" },
428
+ sbxPath: { type: "string" },
429
+ },
430
+ required: [ "name", "path", "sbxPath" ],
431
+ additionalProperties: false,
432
+ },
433
+ strict: true,
434
+ });
435
+ }
436
+ }
337
437
  if (web_search) {
338
438
  tools.push({ type: "web_search" });
339
439
  }
@@ -344,10 +444,8 @@ const toolToAnthropic = (t) => {
344
444
  return {
345
445
  name: t.name,
346
446
  description: t.description,
347
- input_schema: {
348
- type: t.parameters.type,
349
- properties: t.parameters.properties,
350
- }
447
+ input_schema: t.parameters,
448
+ strict: t.strict,
351
449
  };
352
450
  }
353
451
  else if (t.type == "web_search") {
@@ -377,6 +475,7 @@ const useTool = (name, args, argsStr) => {
377
475
  const openai_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
378
476
  let response = await client.responses.create({
379
477
  model,
478
+ instructions: getSystemPrompt(),
380
479
  previous_response_id: state,
381
480
  input,
382
481
  tools,
@@ -399,6 +498,7 @@ const openai_generateResponseOptionallyUsingTools = async (client, model, input,
399
498
 
400
499
  response = await client.responses.create({
401
500
  model,
501
+ instructions: getSystemPrompt(),
402
502
  previous_response_id: response.id,
403
503
  input: outputs,
404
504
  tools,
@@ -413,17 +513,43 @@ const openai_generateResponseOptionallyUsingTools = async (client, model, input,
413
513
  };
414
514
  };
415
515
 
516
+ const anthropic_streamMessage = (client, res, params) => {
517
+ const stream = client.messages.stream(params);
518
+ let thought = "";
519
+ stream.on("thinking", (delta) => {
520
+ thought += delta;
521
+ const i = thought.indexOf("\n\n");
522
+ if (i != -1) {
523
+ console.log(chalk.gray(thought.substring(0, i)));
524
+ logWrite(`[Thought summary: ${thought.substring(0, i)}]`);
525
+ res.used_tools = true; // Need a newline before response just the same
526
+ thought = thought.substring(i + 2);
527
+ }
528
+ });
529
+ stream.on("contentBlock", () => {
530
+ if (thought) {
531
+ console.log(chalk.gray(thought));
532
+ logWrite(`[Thought summary: ${thought}]`);
533
+ res.used_tools = true; // Need a newline before response just the same
534
+ thought = "";
535
+ }
536
+ });
537
+ return stream.finalMessage();
538
+ };
539
+
416
540
  const anthropic_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
417
541
  state = JSON.parse(state || "[]");
418
542
  state.push({ role: "user", content: input });
419
- let used_tools = false;
543
+ const res = { used_tools: false };
420
544
 
421
545
  const anthropicTools = tools.map(t => toolToAnthropic(t));
422
546
 
423
- let message = await client.messages.create({
547
+ let message = await anthropic_streamMessage(client, res, {
424
548
  model,
425
- max_tokens: 20000,
549
+ max_tokens: 64000,
550
+ thinking: { type: "adaptive", display: "summarized" },
426
551
  cache_control: { type: "ephemeral" },
552
+ system: getSystemPrompt(),
427
553
  messages: state,
428
554
  tools: anthropicTools,
429
555
  });
@@ -440,17 +566,19 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
440
566
  };
441
567
  });
442
568
  state.push({ role: "user", content: outputs });
443
- used_tools = true;
569
+ res.used_tools = true;
444
570
  }
445
571
 
446
572
  /*if (message.stop_reason == "max_tokens" && message.content[message.content.length].type == "thinking") {
447
573
  state.push({ role: "user", content: "Please continue from where you left off." });
448
574
  }*/
449
575
 
450
- message = await client.messages.create({
576
+ message = await anthropic_streamMessage(client, res, {
451
577
  model,
452
- max_tokens: 20000,
578
+ max_tokens: 64000,
579
+ thinking: { type: "adaptive", display: "summarized" },
453
580
  cache_control: { type: "ephemeral" },
581
+ system: getSystemPrompt(),
454
582
  messages: state,
455
583
  tools: anthropicTools,
456
584
  });
@@ -464,11 +592,9 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
464
592
  }
465
593
 
466
594
  //console.log(message);
467
- return {
468
- output_text: message.content.find((item) => item.type == "text").text,
469
- state: JSON.stringify(state),
470
- used_tools,
471
- };
595
+ res.output_text = message.content.find((item) => item.type == "text").text;
596
+ res.state = JSON.stringify(state);
597
+ return res;
472
598
  };
473
599
 
474
600
  module.exports = {
package/src/main.js CHANGED
@@ -22,6 +22,8 @@ async function main() {
22
22
  .option("--list-profiles", "list all profiles")
23
23
  .option("--delete-profile <name>", "delete a profile")
24
24
  .option("--update-api-key <provider>", "update an API key")
25
+ .option("--logs", "get info about logs")
26
+ .option("--skills", "get info about skills")
25
27
  .helpOption("-?, --help", "display help")
26
28
  .parse();
27
29
  const options = program.opts();
@@ -39,7 +41,7 @@ async function main() {
39
41
  process.exit(1);
40
42
  }
41
43
  const name = options.createProfile || options.updateProfile;
42
- if (name in state.profiles) {
44
+ if (Object.hasOwn(state.profiles, name)) {
43
45
  if (options.createProfile) {
44
46
  console.error("Profile already exists. Did you mean --update-profile?");
45
47
  process.exit(1);
@@ -88,7 +90,7 @@ async function main() {
88
90
  process.exit(0);
89
91
  }
90
92
  if (options.deleteProfile) {
91
- if (options.deleteProfile in state.profiles) {
93
+ if (Object.hasOwn(state.profiles, options.deleteProfile)) {
92
94
  delete state.profiles[options.deleteProfile];
93
95
  saveState();
94
96
  console.log(`Deleted ${options.deleteProfile} profile.`);
@@ -104,6 +106,24 @@ async function main() {
104
106
  console.log("Saved.");
105
107
  process.exit(0);
106
108
  }
109
+ if (options.logs) {
110
+ const { logDir } = require("./paths.js");
111
+ console.log(`Each invocation that prompts a model creates a log file in ${logDir}`);
112
+ console.log(`When a new log file is created, any log file older than 30 days is deleted.`);
113
+ process.exit(0);
114
+ }
115
+ if (options.skills) {
116
+ const { skillsDir, skills } = require("./skills.js");
117
+ console.log(`Skills are loaded from ${skillsDir}`);
118
+ if (Object.keys(skills).length) {
119
+ console.log();
120
+ console.log(`Currently there's ${Object.keys(skills).length} skill${Object.keys(skills).length == 1 ? "" : "s"}:`);
121
+ for (const [name, skill] of Object.entries(skills)) {
122
+ console.log(`- ${name}: ${skill.description}`);
123
+ }
124
+ }
125
+ process.exit(0);
126
+ }
107
127
 
108
128
  const { markdownToConsoleText } = require("./markdown.js");
109
129
 
@@ -134,6 +154,7 @@ async function main() {
134
154
 
135
155
  const { initTools, getSandboxName, setSandboxName, anthropic_generateResponseOptionallyUsingTools, openai_generateResponseOptionallyUsingTools } = require("./core.js");
136
156
  const { logWrite, logWriteBoth } = require("./log.js");
157
+ const { skills } = require("./skills.js");
137
158
 
138
159
  let previous_provider_state;
139
160
  const overrides = [profileToString(profile)];
@@ -148,6 +169,9 @@ async function main() {
148
169
  }
149
170
  const filesystemEnabled = options.fs !== false && !options.nofs;
150
171
  initTools(filesystemEnabled, options.sandbox, options.web);
172
+ if (Object.keys(skills).length) {
173
+ overrides.push(`${Object.keys(skills).length} skill${Object.keys(skills).length == 1 ? "" : "s"}`);
174
+ }
151
175
  if (options.web) {
152
176
  overrides.push("web search enabled");
153
177
  }
package/src/skills.js ADDED
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { configDir } = require("./paths.js");
6
+
7
+ const skillsDir = path.join(configDir, "skills");
8
+
9
+ const unquote = (value) => {
10
+ const first = value[0];
11
+ if ((first === "\"" || first === "'") && value.at(-1) === first && value.length >= 2) {
12
+ return value.slice(1, -1);
13
+ }
14
+ return value;
15
+ };
16
+
17
+ const parseFrontmatter = (text) => {
18
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
19
+ if (!match) {
20
+ return {};
21
+ }
22
+
23
+ const fields = {};
24
+ let currentKey = null;
25
+ for (const line of match[1].split(/\r?\n/)) {
26
+ const keyValue = /^([\w-]+):\s*(.*)$/.exec(line);
27
+ if (keyValue) {
28
+ currentKey = keyValue[1];
29
+ const raw = keyValue[2].trim();
30
+ // Block scalars (| or >) are folded into a single line below.
31
+ fields[currentKey] = /^[>|][+-]?$/.test(raw) ? "" : unquote(raw);
32
+ }
33
+ else if (currentKey && /^\s+\S/.test(line)) {
34
+ // Indented continuation line of a multi-line value.
35
+ fields[currentKey] = `${fields[currentKey]} ${line.trim()}`.trim();
36
+ }
37
+ }
38
+ return fields;
39
+ };
40
+
41
+ fs.mkdirSync(skillsDir, { recursive: true });
42
+ const skills = Object.create(null);
43
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
44
+ if (entry.isDirectory()) {
45
+ const skillPath = path.join(skillsDir, entry.name);
46
+ const skillFile = path.join(skillPath, "SKILL.md");
47
+ if (!fs.existsSync(skillFile)) {
48
+ console.log(`Invalid skill (missing SKILL.md): ${skillPath}`);
49
+ continue;
50
+ }
51
+ const frontmatter = parseFrontmatter(fs.readFileSync(skillFile, "utf8"));
52
+ if (!frontmatter.description) {
53
+ console.log(`Invalid skill (missing description): ${skillPath}`);
54
+ continue;
55
+ }
56
+ skills[frontmatter.name || entry.name] = {
57
+ path: skillPath,
58
+ description: frontmatter.description,
59
+ };
60
+ }
61
+ }
62
+
63
+ module.exports = { skillsDir, skills };
package/src/state.js CHANGED
@@ -6,9 +6,10 @@ const { createInterface } = require("node:readline/promises");
6
6
  const { configDir } = require("./paths.js");
7
7
 
8
8
  const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
9
- state.profiles ??= {};
10
- state.apiKeys ??= {};
11
- state.providerStates ??= {};
9
+ const nullProto = (obj) => Object.assign(Object.create(null), obj);
10
+ state.profiles = nullProto(state.profiles);
11
+ state.apiKeys = nullProto(state.apiKeys);
12
+ state.providerStates = nullProto(state.providerStates);
12
13
 
13
14
  const promptForApiKey = async (provider) => {
14
15
  const readline = createInterface({ input: process.stdin, output: process.stdout });