@calamity-inc/slave 0.3.2 → 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/src/log.js ADDED
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { logDir } = require("./paths.js");
6
+
7
+ const rotateLogs = () => {
8
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30 days
9
+ for (const name of fs.readdirSync(logDir)) {
10
+ try {
11
+ if (fs.statSync(path.join(logDir, name)).mtimeMs < cutoff) {
12
+ fs.unlinkSync(path.join(logDir, name));
13
+ }
14
+ }
15
+ catch (e) {
16
+ // another process may have removed it already; ignore.
17
+ }
18
+ }
19
+ };
20
+
21
+ let logFh;
22
+ const logWrite = (line) => {
23
+ if (!logFh) {
24
+ const now = new Date();
25
+ const pad = (n) => String(n).padStart(2, '0');
26
+ const logfileName = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}T${pad(now.getUTCHours())}-${pad(now.getUTCMinutes())}-${pad(now.getUTCSeconds())}Z.txt`;
27
+ fs.mkdirSync(logDir, { recursive: true });
28
+ logFh = fs.openSync(path.join(logDir, logfileName), "a", 0o600);
29
+ rotateLogs();
30
+ }
31
+ fs.writeSync(logFh, line + "\n");
32
+ };
33
+ const logWriteBoth = (line) => {
34
+ console.log(line);
35
+ logWrite(line);
36
+ };
37
+
38
+ module.exports = { logWrite, logWriteBoth };
package/src/main.js ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ async function main() {
5
+ const { Command } = require("commander");
6
+
7
+ const program = new Command();
8
+ program
9
+ .name("slave")
10
+ .usage("[options] [--] [prompt...]")
11
+ .description("Vibe coding without the kaboom factor.")
12
+ .argument("[prompt...]")
13
+ .option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
14
+ .option("-a, --amend", "retry the previous invocation, using the context from before it")
15
+ .option("-w, --web", "allow the model to search the web")
16
+ .option("-n, --no-fs", "disable filesystem access").option("--nofs")
17
+ .option("-s, --sandbox", "allow the model to use a docker container")
18
+ .option("-p, --profile <name>", "profile name")
19
+ .option("--create-profile <name> <provider> <model>", "create a profile")
20
+ .option("--update-profile <name> <provider> <model>", "update a profile")
21
+ .option("--set-default", "set default profile")
22
+ .option("--list-profiles", "list all profiles")
23
+ .option("--delete-profile <name>", "delete a profile")
24
+ .option("--update-api-key <provider>", "update an API key")
25
+ .option("--logs", "get info about logs")
26
+ .option("--skills", "get info about skills")
27
+ .helpOption("-?, --help", "display help")
28
+ .parse();
29
+ const options = program.opts();
30
+
31
+ const { state, promptForApiKey, saveState, profileToString } = require("./state.js");
32
+
33
+ if (options.createProfile || options.updateProfile) {
34
+ if (!program.args[0] || !program.args[1]) {
35
+ console.error("Syntax: slave --create-profile <name> <provider> <model>");
36
+ process.exit(1);
37
+ }
38
+ const provider = program.args[0];
39
+ if (provider != "openai" && provider != "anthropic" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
40
+ console.error(`Invalid provider: ${provider}`);
41
+ process.exit(1);
42
+ }
43
+ const name = options.createProfile || options.updateProfile;
44
+ if (Object.hasOwn(state.profiles, name)) {
45
+ if (options.createProfile) {
46
+ console.error("Profile already exists. Did you mean --update-profile?");
47
+ process.exit(1);
48
+ }
49
+ }
50
+ else {
51
+ if (options.updateProfile) {
52
+ console.error("No such profile. Did you mean --create-profile?");
53
+ process.exit(1);
54
+ }
55
+ }
56
+ state.profiles[name] = { provider, model: program.args[1] };
57
+ if (options.setDefault) {
58
+ state.defaultProfile = name;
59
+ }
60
+ if (!state.apiKeys[provider]) {
61
+ await promptForApiKey(provider);
62
+ }
63
+ saveState();
64
+ console.log(`${options.createProfile ? "Created" : "Updated"} ${name} profile${options.setDefault ? " and set as your default" : ""}.`);
65
+ process.exit(0);
66
+ }
67
+ if (options.setDefault) {
68
+ if (!program.args[0]) {
69
+ console.error("No profile name given.");
70
+ process.exit(1);
71
+ }
72
+ state.defaultProfile = program.args[0];
73
+ saveState();
74
+ console.log(`Set ${program.args[0]} as your default profile.`);
75
+ process.exit(0);
76
+ }
77
+ if (options.listProfiles) {
78
+ const names = Object.keys(state.profiles);
79
+ if (names.length == 0) {
80
+ console.log("No profiles.");
81
+ }
82
+ for (const name of names) {
83
+ if (state.defaultProfile == name) {
84
+ console.log(`${name} (default): ${profileToString(state.profiles[name])}`);
85
+ }
86
+ else {
87
+ console.log(`${name}: ${profileToString(state.profiles[name])}`);
88
+ }
89
+ }
90
+ process.exit(0);
91
+ }
92
+ if (options.deleteProfile) {
93
+ if (Object.hasOwn(state.profiles, options.deleteProfile)) {
94
+ delete state.profiles[options.deleteProfile];
95
+ saveState();
96
+ console.log(`Deleted ${options.deleteProfile} profile.`);
97
+ }
98
+ else {
99
+ console.log("No such profile.");
100
+ }
101
+ process.exit(0);
102
+ }
103
+ if (options.updateApiKey) {
104
+ await promptForApiKey(options.updateApiKey);
105
+ saveState();
106
+ console.log("Saved.");
107
+ process.exit(0);
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
+ }
127
+
128
+ const { markdownToConsoleText } = require("./markdown.js");
129
+
130
+ const profile = state.profiles[options.profile ?? state.defaultProfile];
131
+ if (!profile) {
132
+ if (Object.keys(state.profiles).length == 0) {
133
+ console.error(markdownToConsoleText("You don't have any profiles. Use e.g. `slave --create-profile luna openai gpt-6-luna --set-default` to create one."));
134
+ }
135
+ else if (options.profile) {
136
+ console.error(markdownToConsoleText(`No such profile. Use e.g. \`slave --create-profile ${options.profile} openai gpt-6-luna\` to create it.`));
137
+ }
138
+ else {
139
+ console.error(markdownToConsoleText(`You did not specify a profile and no profile is set as your default. Use e.g. \`slave --set-default ${Object.keys(state.profiles)[0]}\` to set **${Object.keys(state.profiles)[0]}** as your default profile.`));
140
+ }
141
+ process.exit(1);
142
+ }
143
+
144
+ let prompt = program.args.join(" ").trim();
145
+ if (!prompt) {
146
+ console.error("No prompt given.");
147
+ process.exit(1);
148
+ }
149
+
150
+ const { execFileSync } = require("node:child_process");
151
+
152
+ const { default: Anthropic } = require("@anthropic-ai/sdk");
153
+ const { default: OpenAI } = require("openai");
154
+
155
+ const { initTools, getSandboxName, setSandboxName, anthropic_generateResponseOptionallyUsingTools, openai_generateResponseOptionallyUsingTools } = require("./core.js");
156
+ const { logWrite, logWriteBoth } = require("./log.js");
157
+ const { skills } = require("./skills.js");
158
+
159
+ let previous_provider_state;
160
+ const overrides = [profileToString(profile)];
161
+ state.providerStates[profile.provider] ??= {};
162
+ if (options.continue || options.cont) {
163
+ previous_provider_state = state.providerStates[profile.provider].previous;
164
+ setSandboxName(state.providerStates[profile.provider].sandbox_name);
165
+ }
166
+ else if (options.amend) {
167
+ previous_provider_state = state.providerStates[profile.provider].previous_previous;
168
+ setSandboxName(state.providerStates[profile.provider].sandbox_name);
169
+ }
170
+ const filesystemEnabled = options.fs !== false && !options.nofs;
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
+ }
175
+ if (options.web) {
176
+ overrides.push("web search enabled");
177
+ }
178
+ if (!filesystemEnabled) {
179
+ overrides.push("no filesystem access");
180
+ }
181
+ if (options.sandbox) {
182
+ overrides.push("sandbox enabled");
183
+ }
184
+
185
+ logWriteBoth((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
186
+ if (!process.stdin.isTTY) {
187
+ const chunks = [];
188
+ for await (const chunk of process.stdin) {
189
+ chunks.push(chunk);
190
+ }
191
+ prompt = `${Buffer.concat(chunks).toString("utf8")}\n\n${prompt}`;
192
+ overrides.push("stdin provided");
193
+ }
194
+ logWriteBoth(`(${overrides.join(", ")})\n`);
195
+
196
+ //console.log({ model: profile.model, prompt, previous_provider_state });
197
+
198
+ const clientOpts = {
199
+ apiKey: state.apiKeys[profile.provider],
200
+ };
201
+ let response;
202
+ if (profile.provider == "anthropic") {
203
+ const client = new Anthropic(clientOpts);
204
+ response = await anthropic_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
205
+ }
206
+ else {
207
+ if (profile.provider != "openai") {
208
+ clientOpts.baseURL = profile.provider;
209
+ clientOpts.apiKey ||= "dummy";
210
+ }
211
+ const client = new OpenAI(clientOpts);
212
+ response = await openai_generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_provider_state);
213
+ }
214
+ state.providerStates[profile.provider].previous_previous = previous_provider_state;
215
+ state.providerStates[profile.provider].previous = response.state;
216
+ if (state.providerStates[profile.provider].sandbox_name && state.providerStates[profile.provider].sandbox_name != getSandboxName()) {
217
+ execFileSync("docker", ["rm", "-f", state.providerStates[profile.provider].sandbox_name], { encoding: "utf8" });
218
+ }
219
+ state.providerStates[profile.provider].sandbox_name = getSandboxName();
220
+ saveState();
221
+ if (response.used_tools) {
222
+ logWriteBoth("");
223
+ }
224
+ console.log(markdownToConsoleText(response.output_text) + "\n");
225
+ logWrite(response.output_text);
226
+ }
227
+
228
+ main().catch((err) => {
229
+ console.error(err);
230
+ process.exit(1);
231
+ });
@@ -0,0 +1,13 @@
1
+ const { marked } = require("marked");
2
+ const { markedTerminal } = require("marked-terminal");
3
+
4
+ marked.use(markedTerminal());
5
+ const markdownToConsoleText = (md) => {
6
+ const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
7
+ console.error = undefined;
8
+ const text = marked.parse(md).trimEnd() + "\n";
9
+ console.error = _console_error;
10
+ return text;
11
+ };
12
+
13
+ module.exports = { markdownToConsoleText };
package/src/paths.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+
3
+ const envPaths = require("env-paths").default;
4
+
5
+ const { data, log } = envPaths("slave");
6
+ module.exports = {
7
+ configDir: data,
8
+ logDir: log,
9
+ };
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 ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { createInterface } = require("node:readline/promises");
6
+ const { configDir } = require("./paths.js");
7
+
8
+ const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
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);
13
+
14
+ const promptForApiKey = async (provider) => {
15
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
16
+ try {
17
+ state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
18
+ }
19
+ finally {
20
+ readline.close();
21
+ }
22
+ };
23
+
24
+ const saveState = () => {
25
+ fs.mkdirSync(configDir, { recursive: true });
26
+ fs.writeFileSync(path.join(configDir, "state.json"), JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
27
+ };
28
+
29
+ const profileToString = (profile) => {
30
+ return profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`;
31
+ };
32
+
33
+ module.exports = { state, promptForApiKey, saveState, profileToString };