@calamity-inc/slave 0.0.0-stage → 0.1.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 +38 -2
- package/index.js +358 -0
- package/package.json +17 -5
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# slave
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Vibe coding without the kaboom factor.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm i -g @calamity-inc/slave
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
In any directory:
|
|
14
|
+
```
|
|
15
|
+
slave [flags] [--] How do I use the 'slave' command?
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
You can also pipe text in:
|
|
19
|
+
```
|
|
20
|
+
cat yap.txt | slave Summarize this text
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Flags:
|
|
24
|
+
- `-c`, `--cont`, `--continue` — add your previous invocation to the context of this one
|
|
25
|
+
- `-w`, `--web` — allow the model to search the web
|
|
26
|
+
- `-n`, `--no-fs`, `--nofs` — disables filesystem access so you can just have a non-binding chat with the model
|
|
27
|
+
|
|
28
|
+
Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
|
|
29
|
+
|
|
30
|
+
## Sandboxing / Security
|
|
31
|
+
|
|
32
|
+
The model gets no command prompt access and limited access to your filesystem:
|
|
33
|
+
- Only within the working directory and subdirectories.
|
|
34
|
+
- `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
|
|
35
|
+
- Files that are `.gitignore`d can't be written to.
|
|
36
|
+
|
|
37
|
+
However, these restrictions are only as good as your practices; you should:
|
|
38
|
+
- 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.
|
|
39
|
+
- Review changes to your code before running anything, including tests.
|
package/index.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseEnv } from "node:util";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import ignore from "ignore";
|
|
8
|
+
import { marked } from "marked";
|
|
9
|
+
import { markedTerminal } from "marked-terminal";
|
|
10
|
+
import OpenAI from "openai";
|
|
11
|
+
|
|
12
|
+
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const env = fs.existsSync(path.join(configDir, ".env")) ? parseEnv(fs.readFileSync(path.join(configDir, ".env"), "utf8")) : {};
|
|
14
|
+
if (!env.OPENAI_API_KEY) {
|
|
15
|
+
if (!process.stdin.isTTY) {
|
|
16
|
+
console.error(`No OPENAI_API_KEY found. Run this command in a terminal to enter your key, or add it to ${path.join(configDir, ".env")}.`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
try {
|
|
22
|
+
env.OPENAI_API_KEY = (await readline.question("Enter your OpenAI API key: ")).trim();
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
readline.close();
|
|
26
|
+
}
|
|
27
|
+
if (!env.OPENAI_API_KEY) {
|
|
28
|
+
console.error("An API key is required.");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
console.log();
|
|
32
|
+
|
|
33
|
+
fs.writeFileSync(path.join(configDir, ".env"), `OPENAI_API_KEY=${env.OPENAI_API_KEY}`, { encoding: "utf8", mode: 0o600 });
|
|
34
|
+
fs.chmodSync(path.join(configDir, ".env"), 0o600);
|
|
35
|
+
}
|
|
36
|
+
const client = new OpenAI({
|
|
37
|
+
apiKey: env.OPENAI_API_KEY,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const gitignore = ignore();
|
|
41
|
+
if (fs.existsSync(".gitignore")) {
|
|
42
|
+
gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isOutOfReach = (p, type) => {
|
|
46
|
+
const rel = path.relative(".", p);
|
|
47
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const segs = rel.split(path.sep);
|
|
51
|
+
if (segs.includes(".env") || segs.includes(".git")) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (type == "write" && rel && gitignore.ignores(rel)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
|
|
61
|
+
const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
62
|
+
if (usesCRLF === undefined) {
|
|
63
|
+
usesCRLF = fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8").includes("\r\n");
|
|
64
|
+
}
|
|
65
|
+
const normalized = data.replaceAll("\r\n", "\n");
|
|
66
|
+
fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const functions = {
|
|
70
|
+
ls: ({ path }) => {
|
|
71
|
+
path ??= ".";
|
|
72
|
+
if (isOutOfReach(path)) {
|
|
73
|
+
return "Out of your reach";
|
|
74
|
+
}
|
|
75
|
+
let files;
|
|
76
|
+
try {
|
|
77
|
+
files = fs.readdirSync(path, { withFileTypes: true });
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return e.code;
|
|
81
|
+
}
|
|
82
|
+
return JSON.stringify(files.map(dirent => dirent.isDirectory() ? `${dirent.name}/` : dirent.name));
|
|
83
|
+
},
|
|
84
|
+
findFiles: ({ path, substring, recursive }) => {
|
|
85
|
+
path ??= ".";
|
|
86
|
+
if (isOutOfReach(path)) {
|
|
87
|
+
return "Out of your reach";
|
|
88
|
+
}
|
|
89
|
+
let files;
|
|
90
|
+
try {
|
|
91
|
+
files = fs.readdirSync(path, { withFileTypes: true, recursive });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return e.code;
|
|
95
|
+
}
|
|
96
|
+
files = files.filter(dirent => dirent.isFile())
|
|
97
|
+
.map(dirent => {
|
|
98
|
+
if (dirent.parentPath != ".") {
|
|
99
|
+
return `${dirent.parentPath.replaceAll("\\", "/")}/${dirent.name}`
|
|
100
|
+
}
|
|
101
|
+
return dirent.name;
|
|
102
|
+
})
|
|
103
|
+
.filter(file => !isOutOfReach(file));
|
|
104
|
+
const res = [];
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
const matches = readFileForModel(file).split(substring).length - 1;
|
|
107
|
+
if (matches) {
|
|
108
|
+
res.push(`${file} has ${matches} match${matches == 1 ? "" : "es"}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return res.join("\n");
|
|
112
|
+
},
|
|
113
|
+
readFile: ({ path }) => {
|
|
114
|
+
if (isOutOfReach(path)) {
|
|
115
|
+
return "Out of your reach";
|
|
116
|
+
}
|
|
117
|
+
return readFileForModel(path);
|
|
118
|
+
},
|
|
119
|
+
writeFile: ({ path, data }) => {
|
|
120
|
+
if (isOutOfReach(path, "write")) {
|
|
121
|
+
return "Out of your reach";
|
|
122
|
+
}
|
|
123
|
+
writeFileFromModel(path, data);
|
|
124
|
+
return "OK";
|
|
125
|
+
},
|
|
126
|
+
fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
|
|
127
|
+
if (isOutOfReach(path, "write")) {
|
|
128
|
+
return "Out of your reach";
|
|
129
|
+
}
|
|
130
|
+
const fileContent = fs.readFileSync(path, "utf8");
|
|
131
|
+
const usesCRLF = fileContent.includes("\r\n");
|
|
132
|
+
const arr = fileContent.replaceAll("\r\n", "\n").split(substring);
|
|
133
|
+
const matches = arr.length - 1;
|
|
134
|
+
if (matches == 0 || (expectedMatches && matches != expectedMatches)) {
|
|
135
|
+
return `There were ${matches} match${matches == 1 ? "" : "es"}`;
|
|
136
|
+
}
|
|
137
|
+
writeFileFromModel(path, arr.join(replacement), usesCRLF);
|
|
138
|
+
return "OK";
|
|
139
|
+
},
|
|
140
|
+
system: ({ command }) => {
|
|
141
|
+
//process.exit(1);
|
|
142
|
+
return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let tools = [
|
|
147
|
+
{
|
|
148
|
+
type: "function",
|
|
149
|
+
name: "ls",
|
|
150
|
+
description: "Lists files",
|
|
151
|
+
parameters: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
path: { type: ["string", "null"] },
|
|
155
|
+
},
|
|
156
|
+
required: [ "path" ],
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
strict: true,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "function",
|
|
163
|
+
name: "findFiles",
|
|
164
|
+
parameters: {
|
|
165
|
+
type: "object",
|
|
166
|
+
properties: {
|
|
167
|
+
path: { type: ["string", "null"] },
|
|
168
|
+
substring: { type: "string" },
|
|
169
|
+
recursive: { type: ["boolean", "null"] },
|
|
170
|
+
},
|
|
171
|
+
required: [ "path", "substring", "recursive" ],
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
},
|
|
174
|
+
strict: true,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
type: "function",
|
|
178
|
+
name: "readFile",
|
|
179
|
+
parameters: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
path: { type: "string" },
|
|
183
|
+
},
|
|
184
|
+
required: [ "path" ],
|
|
185
|
+
additionalProperties: false,
|
|
186
|
+
},
|
|
187
|
+
strict: true,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
type: "function",
|
|
191
|
+
name: "writeFile",
|
|
192
|
+
parameters: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {
|
|
195
|
+
path: { type: "string" },
|
|
196
|
+
data: { type: "string" },
|
|
197
|
+
},
|
|
198
|
+
required: [ "path", "data" ],
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
},
|
|
201
|
+
strict: true,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
type: "function",
|
|
205
|
+
name: "fileFindAndReplace",
|
|
206
|
+
parameters: {
|
|
207
|
+
type: "object",
|
|
208
|
+
properties: {
|
|
209
|
+
path: { type: "string" },
|
|
210
|
+
substring: { type: "string" },
|
|
211
|
+
replacement: { type: "string" },
|
|
212
|
+
expectedMatches: { type: ["number", "null"] },
|
|
213
|
+
},
|
|
214
|
+
required: [ "path", "substring", "replacement", "expectedMatches" ],
|
|
215
|
+
additionalProperties: false,
|
|
216
|
+
},
|
|
217
|
+
strict: true,
|
|
218
|
+
},
|
|
219
|
+
// This is useful to see where the model maybe expects a better tool:
|
|
220
|
+
/*{
|
|
221
|
+
type: "function",
|
|
222
|
+
name: "system",
|
|
223
|
+
parameters: {
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: {
|
|
226
|
+
command: { type: "string" },
|
|
227
|
+
},
|
|
228
|
+
required: [ "command" ],
|
|
229
|
+
additionalProperties: false,
|
|
230
|
+
},
|
|
231
|
+
strict: true,
|
|
232
|
+
},*/
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const generateResponseOptionallyUsingTools = async (input, previous_response_id) => {
|
|
236
|
+
const model = "gpt-6-luna";
|
|
237
|
+
let response = await client.responses.create({
|
|
238
|
+
model,
|
|
239
|
+
previous_response_id,
|
|
240
|
+
input,
|
|
241
|
+
tools,
|
|
242
|
+
});
|
|
243
|
+
let used_tools = false;
|
|
244
|
+
|
|
245
|
+
while (true) {
|
|
246
|
+
const toolCalls = response.output.filter((item) => item.type === "function_call");
|
|
247
|
+
if (toolCalls.length == 0) {
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const outputs = toolCalls.map((call) => {
|
|
252
|
+
console.log("$ " + call.name + call.arguments);
|
|
253
|
+
const output = String(functions[call.name](JSON.parse(call.arguments)));
|
|
254
|
+
let consoleOutput = output.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>");
|
|
255
|
+
if (call.name == "readFile" && consoleOutput.length > 80) {
|
|
256
|
+
consoleOutput = consoleOutput.substring(0, 77) + "...";
|
|
257
|
+
}
|
|
258
|
+
console.log(consoleOutput);
|
|
259
|
+
return {
|
|
260
|
+
type: "function_call_output",
|
|
261
|
+
call_id: call.call_id,
|
|
262
|
+
output,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
response = await client.responses.create({
|
|
267
|
+
model,
|
|
268
|
+
previous_response_id: response.id,
|
|
269
|
+
input: outputs,
|
|
270
|
+
tools,
|
|
271
|
+
});
|
|
272
|
+
used_tools = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
response.used_tools = used_tools;
|
|
276
|
+
return response;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
marked.use(markedTerminal());
|
|
280
|
+
const markdownToConsoleText = (md) => {
|
|
281
|
+
const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
|
|
282
|
+
console.error = undefined;
|
|
283
|
+
const text = marked.parse(md);
|
|
284
|
+
console.error = _console_error;
|
|
285
|
+
return text;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
let i = 2;
|
|
289
|
+
let previous_response_id;
|
|
290
|
+
const overrides = [];
|
|
291
|
+
const processFlag = (flag) => {
|
|
292
|
+
if (flag == "-c" || flag == "--cont" || flag == "--continue") {
|
|
293
|
+
if (fs.existsSync(path.join(configDir, ".state.json"))) {
|
|
294
|
+
previous_response_id = JSON.parse(fs.readFileSync(path.join(configDir, ".state.json"), "utf8")).previous_response_id;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (flag == "-w" || flag == "--web") {
|
|
298
|
+
if (!overrides.includes("web search enabled")) {
|
|
299
|
+
tools.push({ type: "web_search" });
|
|
300
|
+
overrides.push("web search enabled");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (flag == "-n" || flag == "--no-fs" || flag == "--nofs") {
|
|
304
|
+
if (!overrides.includes("no filesystem access")) {
|
|
305
|
+
tools = tools.filter(tool => tool.type != "function");
|
|
306
|
+
overrides.push("no filesystem access");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
console.log(`Unknown flag: ${flag}`);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
for (; i != process.argv.length; ++i) {
|
|
314
|
+
const argument = process.argv[i];
|
|
315
|
+
if (!argument.startsWith("-")) {
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (argument == "--") {
|
|
319
|
+
++i;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
// Short flags can be combined (for example, -cw is equivalent to -c -w).
|
|
323
|
+
const flags = argument.startsWith("--") || argument.length <= 2
|
|
324
|
+
? [argument]
|
|
325
|
+
: [...argument.slice(1)].map((flag) => `-${flag}`);
|
|
326
|
+
for (const flag of flags) {
|
|
327
|
+
processFlag(flag);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
let prompt = process.argv.slice(i).join(" ").trim();
|
|
331
|
+
let stdinText = "";
|
|
332
|
+
if (!process.stdin.isTTY) {
|
|
333
|
+
const chunks = [];
|
|
334
|
+
for await (const chunk of process.stdin) {
|
|
335
|
+
chunks.push(chunk);
|
|
336
|
+
}
|
|
337
|
+
stdinText = Buffer.concat(chunks).toString("utf8");
|
|
338
|
+
}
|
|
339
|
+
if (!prompt) {
|
|
340
|
+
console.log("Syntax: slave [flags] [--] <prompt>");
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log((previous_response_id ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
|
|
345
|
+
if (stdinText) {
|
|
346
|
+
overrides.push("stdin provided");
|
|
347
|
+
prompt = `${stdinText}\n\n${prompt}`;
|
|
348
|
+
}
|
|
349
|
+
if (overrides.length) {
|
|
350
|
+
console.log(`(${overrides.join(", ")})`);
|
|
351
|
+
}
|
|
352
|
+
console.log();
|
|
353
|
+
const response = await generateResponseOptionallyUsingTools(prompt, previous_response_id);
|
|
354
|
+
fs.writeFileSync(path.join(configDir, ".state.json"), JSON.stringify({ previous_response_id: response.id }), "utf8");
|
|
355
|
+
if (response.used_tools) {
|
|
356
|
+
console.log();
|
|
357
|
+
}
|
|
358
|
+
console.log(markdownToConsoleText(response.output_text).trimEnd() + "\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
2
|
+
"name": "@calamity-inc/slave",
|
|
3
|
+
"repository": {
|
|
4
|
+
"type": "git",
|
|
5
|
+
"url": "git+https://github.com/calamity-inc/slave.git"
|
|
6
|
+
},
|
|
7
|
+
"version": "0.1.1",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"slave": "./index.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"ignore": "^7.0.13",
|
|
14
|
+
"marked": "^12",
|
|
15
|
+
"marked-terminal": "7.0.0",
|
|
16
|
+
"openai": "^7.42.0"
|
|
17
|
+
}
|
|
18
|
+
}
|