oakode 0.0.0-stage → 0.5.0
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/CHANGELOG.md +86 -0
- package/LICENSE +9 -0
- package/README.md +243 -2
- package/SECURITY.md +13 -0
- package/dist/account.js +34 -0
- package/dist/agent.js +570 -0
- package/dist/agents/approval.js +116 -0
- package/dist/agents/budget.js +293 -0
- package/dist/agents/child.js +102 -0
- package/dist/agents/host.js +289 -0
- package/dist/agents/orchestrator.js +286 -0
- package/dist/agents/plan.js +143 -0
- package/dist/agents/types.js +20 -0
- package/dist/agents/verify.js +94 -0
- package/dist/app.js +619 -0
- package/dist/auth.js +456 -0
- package/dist/brief.js +97 -0
- package/dist/commands.js +587 -0
- package/dist/compact.js +231 -0
- package/dist/config.js +82 -0
- package/dist/diff.js +98 -0
- package/dist/doctor.js +106 -0
- package/dist/errors.js +15 -0
- package/dist/fileio.js +688 -0
- package/dist/input.js +244 -0
- package/dist/jobs.js +262 -0
- package/dist/main.js +560 -0
- package/dist/mask.js +63 -0
- package/dist/mcp.js +721 -0
- package/dist/models.js +106 -0
- package/dist/modes.js +114 -0
- package/dist/oicki.js +475 -0
- package/dist/output.js +101 -0
- package/dist/permissions.js +389 -0
- package/dist/permissions_ui.js +134 -0
- package/dist/print.js +76 -0
- package/dist/proc.js +63 -0
- package/dist/runners.js +264 -0
- package/dist/serial.js +9 -0
- package/dist/session.js +452 -0
- package/dist/shell.js +40 -0
- package/dist/skills.js +322 -0
- package/dist/syntax.js +80 -0
- package/dist/tools.js +480 -0
- package/dist/tui/editor.js +279 -0
- package/dist/tui/highlight.js +78 -0
- package/dist/tui/markdown.js +254 -0
- package/dist/tui/screen.js +146 -0
- package/dist/tui/tui.js +505 -0
- package/dist/tui/view.js +337 -0
- package/dist/ui.js +105 -0
- package/dist/update.js +64 -0
- package/package.json +57 -4
package/dist/tools.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/** read / write / edit / grep / ls / bash. All file tools are confined to the working directory. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { HeadTail, condenseOutput, keepEnds, stripEscapes } from "./output.js";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { MAX_BYTES, MAX_IMAGE_BYTES, MAX_LINES, ToolError, applyEdits, atomicWrite, encodeLike, globWorkspace, grepWorkspace, imageMime, listDir, optInt, readTextFile, realRel, rel, requireFile, safePath, truncate } from "./fileio.js";
|
|
9
|
+
import { syntaxProblem } from "./syntax.js";
|
|
10
|
+
import { killTreeOf, spawnShell, untrackChild } from "./proc.js";
|
|
11
|
+
import { MAX_JOBS, allJobs, jobById, outputFile, report, saveOutput, startJob, stateOf } from "./jobs.js";
|
|
12
|
+
export { MAX_BYTES, MAX_LINES, ToolError, safePath, truncate };
|
|
13
|
+
/** Appended to a write/edit result when the file no longer parses. */
|
|
14
|
+
async function syntaxNote(file, text, ctx) {
|
|
15
|
+
const problem = await syntaxProblem(file, text, ctx.cwd, ctx.signal);
|
|
16
|
+
return problem ? `\n[Syntax check: the file does not parse. Fix this before moving on:\n${problem}]` : "";
|
|
17
|
+
}
|
|
18
|
+
const MAX_READ_PATHS = 10;
|
|
19
|
+
/** Several files in one call share this much output, so a batch costs about as much as two single reads. */
|
|
20
|
+
const MULTI_READ_BYTES = 100 * 1024;
|
|
21
|
+
/** macOS only (its built-in `sips`): a JPEG of at most 2048 px on the long side (1024 px if that is still too big), or undefined. */
|
|
22
|
+
function shrinkImage(p) {
|
|
23
|
+
if (process.platform !== "darwin" || !existsSync("/usr/bin/sips"))
|
|
24
|
+
return undefined;
|
|
25
|
+
const out = join(tmpdir(), `oakode-img-${randomBytes(6).toString("hex")}.jpg`);
|
|
26
|
+
try {
|
|
27
|
+
let data;
|
|
28
|
+
for (const px of ["2048", "1024"]) {
|
|
29
|
+
const r = spawnSync("/usr/bin/sips", ["-Z", px, "-s", "format", "jpeg", "-s", "formatOptions", "75", p, "--out", out], { stdio: "ignore", timeout: 20_000 });
|
|
30
|
+
data = r.status === 0 && existsSync(out) ? readFileSync(out) : undefined;
|
|
31
|
+
if (!data || data.length <= MAX_IMAGE_BYTES)
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
rmSync(out, { force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function readOne(a, ctx, maxBytes = MAX_BYTES) {
|
|
41
|
+
const p = outputFile(a.path) ?? safePath(ctx.cwd, a.path); // the session's saved command output is readable too
|
|
42
|
+
if (!existsSync(p))
|
|
43
|
+
throw new ToolError(`No such file: ${a.path}`);
|
|
44
|
+
requireFile(p, a.path);
|
|
45
|
+
const mime = imageMime(p);
|
|
46
|
+
if (mime) {
|
|
47
|
+
const size = statSync(p).size;
|
|
48
|
+
const kb = `${Math.max(1, Math.round(size / 1024))} KB`;
|
|
49
|
+
if (!ctx.vision?.())
|
|
50
|
+
throw new ToolError(`${a.path} is an image (${mime}, ${kb}), but the current model cannot receive images, so it cannot be shown. Tell the user; they can choose a vision-capable model with /model. Do not try to approximate the image with scripts, conversions or character dumps.`);
|
|
51
|
+
if (!ctx.attach)
|
|
52
|
+
throw new ToolError("This session cannot deliver images to the model.");
|
|
53
|
+
const data = size <= MAX_IMAGE_BYTES ? readFileSync(p) : shrinkImage(p);
|
|
54
|
+
if (!data || data.length > MAX_IMAGE_BYTES)
|
|
55
|
+
throw new ToolError(`${a.path} is too large to send (${kb}; limit ${MAX_IMAGE_BYTES / 1024 / 1024} MB${process.platform === "darwin" ? ", even after scaling down" : ""}). Ask the user for a smaller image.`);
|
|
56
|
+
const shrunk = data.length !== size;
|
|
57
|
+
ctx.attach({ mime: shrunk ? "image/jpeg" : mime, data: data.toString("base64"), path: rel(ctx.cwd, p) });
|
|
58
|
+
return `Image ${rel(ctx.cwd, p)} (${mime}, ${kb}${shrunk ? `, scaled down to a ${Math.max(1, Math.round(data.length / 1024))} KB JPEG` : ""}) is attached to the next message.`;
|
|
59
|
+
}
|
|
60
|
+
const offset = optInt(a.offset, "offset");
|
|
61
|
+
const limit = optInt(a.limit, "limit");
|
|
62
|
+
const f = readTextFile(p, a.path);
|
|
63
|
+
const all = f.text.split("\n");
|
|
64
|
+
const start = offset ?? 1;
|
|
65
|
+
if (start > all.length)
|
|
66
|
+
throw new ToolError(`offset ${start} is past the end of the file (${all.length} lines)`);
|
|
67
|
+
const slice = all.slice(start - 1, limit ? start - 1 + limit : undefined);
|
|
68
|
+
const numbered = slice.map((l, i) => `${String(start + i).padStart(5)}\t${l}`).join("\n");
|
|
69
|
+
const { text, truncated } = truncate(numbered, "head", maxBytes);
|
|
70
|
+
const shown = text.split("\n").length;
|
|
71
|
+
const more = start - 1 + shown < all.length;
|
|
72
|
+
const meta = [f.eol === "\r\n" ? "CRLF line endings" : "", f.bom ? "UTF-8 BOM" : "", f.mixed ? "mixed line endings" : ""].filter(Boolean);
|
|
73
|
+
return (text +
|
|
74
|
+
(truncated || more ? `\n[showing lines ${start}-${start + shown - 1} of ${all.length}; use offset=${start + shown} to continue]` : "") +
|
|
75
|
+
(meta.length ? `\n[file has ${meta.join(" and ")}; write and edit preserve them]` : ""));
|
|
76
|
+
}
|
|
77
|
+
const read = {
|
|
78
|
+
readOnly: true,
|
|
79
|
+
def: {
|
|
80
|
+
type: "function",
|
|
81
|
+
function: {
|
|
82
|
+
name: "read",
|
|
83
|
+
description: `Read a text file (relative to the working directory), or several at once with \`paths\` (up to ${MAX_READ_PATHS}; read all the files you need in one call). Output is line-numbered and limited to ${MAX_LINES} lines / ${MAX_BYTES / 1024}KB per file (${MULTI_READ_BYTES / 1024}KB shared across \`paths\`); use offset/limit to page through large files. Also shows png/jpeg/gif/webp images (up to 1.5 MB; larger ones are scaled down on macOS) when the model supports images; otherwise it says so.`,
|
|
84
|
+
parameters: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
path: { type: "string" },
|
|
88
|
+
paths: { type: "array", items: { type: "string" }, description: "Several files to read in one call (instead of path)" },
|
|
89
|
+
offset: { type: "integer", description: "1-based first line (default 1); single path only" },
|
|
90
|
+
limit: { type: "integer", description: "Max lines to return; single path only" },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
summarize: (a) => (Array.isArray(a?.paths) ? `read ${a.paths.length > 3 ? `${a.paths.slice(0, 3).join(", ")} +${a.paths.length - 3}` : a.paths.join(", ")}` : `read ${a?.path ?? "?"}${a?.offset ? `:${a.offset}` : ""}`),
|
|
96
|
+
async run(a, ctx) {
|
|
97
|
+
if (a.paths === undefined) {
|
|
98
|
+
if (typeof a.path !== "string")
|
|
99
|
+
throw new ToolError("Pass `path` (one file) or `paths` (several).");
|
|
100
|
+
return readOne(a, ctx);
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(a.paths) || !a.paths.length || a.paths.some((x) => typeof x !== "string"))
|
|
103
|
+
throw new ToolError("`paths` must be a non-empty array of strings");
|
|
104
|
+
const paths = [...new Set(a.paths)];
|
|
105
|
+
if (typeof a.path === "string" && !paths.includes(a.path))
|
|
106
|
+
paths.unshift(a.path);
|
|
107
|
+
if (paths.length > MAX_READ_PATHS)
|
|
108
|
+
throw new ToolError(`At most ${MAX_READ_PATHS} paths per call (got ${paths.length}); split them.`);
|
|
109
|
+
if (paths.length === 1)
|
|
110
|
+
return readOne({ path: paths[0] }, ctx);
|
|
111
|
+
const each = Math.max(8 * 1024, Math.floor(MULTI_READ_BYTES / paths.length));
|
|
112
|
+
return paths
|
|
113
|
+
.map((p) => {
|
|
114
|
+
let body;
|
|
115
|
+
try {
|
|
116
|
+
body = readOne({ path: p }, ctx, each);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
body = `Error: ${e instanceof ToolError ? e.message : e.message}`;
|
|
120
|
+
}
|
|
121
|
+
return `==> ${p} <==\n${body}`;
|
|
122
|
+
})
|
|
123
|
+
.join("\n\n");
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const glob = {
|
|
127
|
+
readOnly: true,
|
|
128
|
+
def: {
|
|
129
|
+
type: "function",
|
|
130
|
+
function: {
|
|
131
|
+
name: "glob",
|
|
132
|
+
description: "Find files by name pattern, e.g. **/*.test.ts, src/**/*.{js,jsx}, *config*. A pattern without / matches file names anywhere. In a git repository, files ignored by .gitignore are left out. Returns workspace-relative paths.",
|
|
133
|
+
parameters: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: { pattern: { type: "string" }, path: { type: "string", description: "Directory to search (default: workspace root)" } },
|
|
136
|
+
required: ["pattern"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
summarize: (a) => `glob ${a?.pattern ?? "?"}${a?.path ? ` in ${a.path}` : ""}`,
|
|
141
|
+
run: (a, ctx) => globWorkspace(ctx.cwd, a, ctx.signal),
|
|
142
|
+
};
|
|
143
|
+
const write = {
|
|
144
|
+
def: {
|
|
145
|
+
type: "function",
|
|
146
|
+
function: {
|
|
147
|
+
name: "write",
|
|
148
|
+
description: "Create or overwrite a file with the given content (atomically). Parent directories are created. Prefer `edit` for changing existing files. Line endings and BOM of an existing file are preserved. " +
|
|
149
|
+
"For a file longer than ~200 lines, write the first part, then add each further part with `append: true` (one part per call). Always create and change files with write/edit, not bash.",
|
|
150
|
+
parameters: {
|
|
151
|
+
type: "object",
|
|
152
|
+
properties: { path: { type: "string" }, content: { type: "string" }, append: { type: "boolean", description: "Add `content` to the end of the file (creating it if missing) instead of replacing it" } },
|
|
153
|
+
required: ["path", "content"],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
summarize: (a) => `write ${a?.path ?? "?"}${a?.append ? " (append)" : ""}`,
|
|
158
|
+
async run(a, ctx) {
|
|
159
|
+
const p = safePath(ctx.cwd, a.path);
|
|
160
|
+
if (typeof a.content !== "string")
|
|
161
|
+
throw new ToolError("`content` must be a string");
|
|
162
|
+
if (a.append !== undefined && typeof a.append !== "boolean")
|
|
163
|
+
throw new ToolError("`append` must be true or false");
|
|
164
|
+
const append = a.append === true;
|
|
165
|
+
const exists = existsSync(p);
|
|
166
|
+
if (exists)
|
|
167
|
+
requireFile(p, a.path);
|
|
168
|
+
const preview = a.content.split("\n").slice(0, 6).join("\n");
|
|
169
|
+
let orig;
|
|
170
|
+
if (exists) {
|
|
171
|
+
try {
|
|
172
|
+
orig = readTextFile(p, a.path);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
if (append)
|
|
176
|
+
throw e;
|
|
177
|
+
} // appending to something unreadable would corrupt it
|
|
178
|
+
}
|
|
179
|
+
const relPath = rel(ctx.cwd, p);
|
|
180
|
+
const added = a.content.replace(/\r\n/g, "\n");
|
|
181
|
+
const proposed = append && orig ? orig.text + added : added;
|
|
182
|
+
const lines = a.content.split("\n").length;
|
|
183
|
+
const ok = await ctx.confirm({
|
|
184
|
+
title: append && exists ? `Append ${lines} lines to ${relPath}` : `${exists ? "Overwrite" : "Create"} ${relPath} (${lines} lines)`,
|
|
185
|
+
detail: preview,
|
|
186
|
+
note: exists && !append ? "replaces existing contents" : undefined,
|
|
187
|
+
action: { tool: "write", path: realRel(ctx.cwd, p) }, // decided on the real path: a symlink cannot launder a protected file
|
|
188
|
+
diff: { path: relPath, old: orig ? orig.text : exists ? "" : null, new: proposed },
|
|
189
|
+
});
|
|
190
|
+
if (!ok)
|
|
191
|
+
throw new ToolError("The user declined this write.");
|
|
192
|
+
safePath(ctx.cwd, a.path); // re-check: the tree may have changed while waiting for approval
|
|
193
|
+
const data = append && orig ? encodeLike(orig, proposed) : /\r/.test(a.content) ? a.content : encodeLike(orig, a.content);
|
|
194
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
195
|
+
atomicWrite(p, data);
|
|
196
|
+
ctx.changed?.({ tool: "write", path: relPath, old: orig ? orig.text : exists ? "" : null, new: proposed });
|
|
197
|
+
return `${append && exists ? "Appended to" : exists ? "Overwrote" : "Created"} ${relPath} (${Buffer.byteLength(data)} bytes${append && exists ? `, ${lines} lines added` : ""})` + (await syntaxNote(p, proposed, ctx));
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const edit = {
|
|
201
|
+
def: {
|
|
202
|
+
type: "function",
|
|
203
|
+
function: {
|
|
204
|
+
name: "edit",
|
|
205
|
+
description: "Replace exact text in an existing file. Pass one or more edits in `edits`; each `old_text` is located in the ORIGINAL file (not after other edits), must be unique unless replace_all is true, and edits must not overlap. " +
|
|
206
|
+
"Matching ignores trailing whitespace, line-ending style and smart quotes/dashes if there is no exact match. CRLF and BOM are preserved. Read the file first. Use this (not sed/python/heredocs in bash) to change existing files.",
|
|
207
|
+
parameters: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
path: { type: "string" },
|
|
211
|
+
edits: {
|
|
212
|
+
type: "array",
|
|
213
|
+
items: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: { old_text: { type: "string" }, new_text: { type: "string" }, replace_all: { type: "boolean" } },
|
|
216
|
+
required: ["old_text", "new_text"],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
required: ["path", "edits"],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
summarize: (a) => `edit ${a?.path ?? "?"}${Array.isArray(a?.edits) && a.edits.length > 1 ? ` (${a.edits.length} edits)` : ""}`,
|
|
225
|
+
async run(a, ctx) {
|
|
226
|
+
const p = safePath(ctx.cwd, a.path);
|
|
227
|
+
if (!existsSync(p))
|
|
228
|
+
throw new ToolError(`No such file: ${a.path} (use \`write\` to create it)`);
|
|
229
|
+
requireFile(p, a.path);
|
|
230
|
+
const edits = Array.isArray(a.edits) ? a.edits : typeof a.old_text === "string" ? [{ old_text: a.old_text, new_text: a.new_text, replace_all: a.replace_all }] : undefined;
|
|
231
|
+
if (!edits)
|
|
232
|
+
throw new ToolError("`edits` must be an array of {old_text, new_text}");
|
|
233
|
+
const file = readTextFile(p, a.path, { strict: true });
|
|
234
|
+
const r = applyEdits(file.text, edits); // throws a precise error before anything is shown or written
|
|
235
|
+
const shown = edits.slice(0, 6).flatMap((e, i) => [`${edits.length > 1 ? `#${i + 1} ` : ""}- ${String(e.old_text).split("\n")[0].slice(0, 100)}`, `${edits.length > 1 ? " " : ""}+ ${String(e.new_text).split("\n")[0].slice(0, 100)}`]);
|
|
236
|
+
const ok = await ctx.confirm({
|
|
237
|
+
title: `Edit ${rel(ctx.cwd, p)} (${r.replacements} replacement${r.replacements > 1 ? "s" : ""})`,
|
|
238
|
+
detail: shown.join("\n") + (edits.length > 6 ? `\n… and ${edits.length - 6} more edits` : ""),
|
|
239
|
+
note: r.notes.length ? r.notes.join("; ") : undefined,
|
|
240
|
+
action: { tool: "edit", path: realRel(ctx.cwd, p) },
|
|
241
|
+
diff: { path: rel(ctx.cwd, p), old: file.text, new: r.text },
|
|
242
|
+
});
|
|
243
|
+
if (!ok)
|
|
244
|
+
throw new ToolError("The user declined this edit.");
|
|
245
|
+
safePath(ctx.cwd, a.path);
|
|
246
|
+
const again = readTextFile(p, a.path, { strict: true }); // the file may have changed while the prompt was open
|
|
247
|
+
if (again.text !== file.text)
|
|
248
|
+
throw new ToolError(`${a.path} changed while waiting for approval; nothing was written. Re-read it and retry.`);
|
|
249
|
+
atomicWrite(p, encodeLike(file, r.text));
|
|
250
|
+
ctx.changed?.({ tool: "edit", path: rel(ctx.cwd, p), old: file.text, new: r.text });
|
|
251
|
+
const note = await syntaxNote(p, r.text, ctx);
|
|
252
|
+
// where the new text now is, and for a small change the lines themselves: a next nearby edit needs no re-read
|
|
253
|
+
const where = r.spans.slice(0, 20).map((x) => (x.to > x.from ? `${x.from}-${x.to}` : `${x.from}`)).join(", ") + (r.spans.length > 20 ? `, and ${r.spans.length - 20} more places` : "");
|
|
254
|
+
const total = r.spans.reduce((n, x) => n + x.to - x.from + 1, 0);
|
|
255
|
+
const after = r.text.split("\n");
|
|
256
|
+
const size = r.spans.reduce((n, x) => n + after.slice(x.from - 1, x.to).join("\n").length, 0);
|
|
257
|
+
const lines = total <= 15 && size <= 3000 ? "\n" + r.spans.map((x) => after.slice(x.from - 1, x.to).map((l, k) => `${String(x.from + k).padStart(5)}\t${l}`).join("\n")).join("\n …\n") : "";
|
|
258
|
+
return `Edited ${rel(ctx.cwd, p)}: ${r.replacements} replacement${r.replacements > 1 ? "s" : ""}, now at line${where.includes(",") || where.includes("-") ? "s" : ""} ${where}` + (r.notes.length ? ` (${r.notes.join("; ")})` : "") + (file.mixed ? " (line endings were mixed; normalized to " + (file.eol === "\r\n" ? "CRLF" : "LF") + ")" : "") + note + lines;
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
const grep = {
|
|
262
|
+
readOnly: true,
|
|
263
|
+
def: {
|
|
264
|
+
type: "function",
|
|
265
|
+
function: {
|
|
266
|
+
name: "grep",
|
|
267
|
+
description: "Search file contents in the workspace with a regular expression (or literal text). In a git repository, files ignored by .gitignore are skipped; also skips node_modules, binary and very large files; never follows symlinks. Returns path:line: text. Use `context` to see surrounding lines instead of reading whole files, and `files_only` to just find which files match.",
|
|
268
|
+
parameters: {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: {
|
|
271
|
+
pattern: { type: "string", description: "JavaScript regular expression (or literal text if `literal` is true)" },
|
|
272
|
+
path: { type: "string", description: "File or directory to search (default: workspace root)" },
|
|
273
|
+
glob: { type: "string", description: "Only files matching this glob, e.g. **/*.ts or *.{js,ts}" },
|
|
274
|
+
ignore_case: { type: "boolean" },
|
|
275
|
+
literal: { type: "boolean" },
|
|
276
|
+
context: { type: "integer", description: "Lines of context before and after each match (0-10, default 0)" },
|
|
277
|
+
files_only: { type: "boolean", description: "List matching files with match counts instead of lines" },
|
|
278
|
+
max_results: { type: "integer", description: "Default 100, max 500" },
|
|
279
|
+
},
|
|
280
|
+
required: ["pattern"],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
summarize: (a) => `grep ${JSON.stringify(a?.pattern ?? "?")}${a?.path ? ` in ${a.path}` : ""}${a?.glob ? ` (${a.glob})` : ""}`,
|
|
285
|
+
run: (a, ctx) => {
|
|
286
|
+
const log = outputFile(a?.path); // a saved command or job log (from a shortened result)
|
|
287
|
+
return log ? grepWorkspace(dirname(log), { ...a, path: basename(log) }, ctx.signal) : grepWorkspace(ctx.cwd, a, ctx.signal);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
const ls = {
|
|
291
|
+
readOnly: true,
|
|
292
|
+
def: {
|
|
293
|
+
type: "function",
|
|
294
|
+
function: {
|
|
295
|
+
name: "ls",
|
|
296
|
+
description: "List a directory in the workspace (directories first, file sizes, symlinks marked with @). depth 1-3 lists subdirectories too; .git and node_modules are not expanded.",
|
|
297
|
+
parameters: {
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: { path: { type: "string", description: "Default: workspace root" }, depth: { type: "integer", description: "1-3, default 1" } },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
summarize: (a) => `ls ${a?.path ?? "."}`,
|
|
304
|
+
async run(a, ctx) {
|
|
305
|
+
return listDir(ctx.cwd, a);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
export { childEnv, killAllChildren, killTreeOf, trackChild, untrackChild } from "./proc.js";
|
|
309
|
+
const bash = {
|
|
310
|
+
def: {
|
|
311
|
+
type: "function",
|
|
312
|
+
function: {
|
|
313
|
+
name: "bash",
|
|
314
|
+
description: `Run a shell command in the working directory and return combined stdout/stderr (escapes removed, repeated lines collapsed, long output shortened to its start and end; the full text is saved to a file that read and grep can open) and the exit code. Default timeout 120s. For servers, watchers and anything slower than the timeout, set background: true: it returns once \`until\` matches, the command ends, or after \`wait\` seconds, and you check it later with the job tool. Never run interactive commands. Not for creating or editing files: use write and edit, which show a diff and respect the permission mode. Keep output small (quiet flags, tail, grep).`,
|
|
315
|
+
parameters: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: {
|
|
318
|
+
command: { type: "string" },
|
|
319
|
+
timeout: { type: "integer", description: "Seconds (1-600), default 120. Not for background commands" },
|
|
320
|
+
background: { type: "boolean", description: `Run as a background job (at most ${MAX_JOBS}); stopped when OAKODE exits` },
|
|
321
|
+
wait: { type: "integer", description: "Background: seconds to wait before returning (0-240, default 10)" },
|
|
322
|
+
until: { type: "string", description: "Background: a regular expression; return as soon as the output matches, e.g. \"listening|ready|error\"" },
|
|
323
|
+
},
|
|
324
|
+
required: ["command"],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
summarize: (a) => `$ ${String(a?.command ?? "?").split("\n")[0].slice(0, 120)}${a?.background === true ? " (background)" : ""}`,
|
|
329
|
+
async run(a, ctx) {
|
|
330
|
+
const cmd = a.command;
|
|
331
|
+
if (typeof cmd !== "string" || !cmd.trim())
|
|
332
|
+
throw new ToolError("`command` must be a non-empty string");
|
|
333
|
+
// models often pass milliseconds (120000): taken as such rather than refused, which costs a round trip
|
|
334
|
+
const t = a.timeout === undefined ? 120 : typeof a.timeout === "number" && a.timeout >= 1000 && a.timeout <= 600_000 ? Math.ceil(a.timeout / 1000) : a.timeout;
|
|
335
|
+
if (typeof t !== "number" || !Number.isFinite(t) || t < 1 || t > 600)
|
|
336
|
+
throw new ToolError("`timeout` must be 1-600 seconds");
|
|
337
|
+
if (a.background !== undefined && typeof a.background !== "boolean")
|
|
338
|
+
throw new ToolError("`background` must be true or false");
|
|
339
|
+
const wait = a.background ? waitArg(a.wait, 10) : 0;
|
|
340
|
+
const until = a.background ? untilArg(a.until) : undefined;
|
|
341
|
+
// Every command needs explicit approval. There is deliberately no command classifier.
|
|
342
|
+
const ok = await ctx.confirm({
|
|
343
|
+
title: "Run command",
|
|
344
|
+
detail: cmd,
|
|
345
|
+
note: "Approval only, not a sandbox: the command runs with your user's full permissions.",
|
|
346
|
+
action: { tool: "bash", command: cmd },
|
|
347
|
+
});
|
|
348
|
+
if (!ok)
|
|
349
|
+
throw new ToolError("The user declined to run this command.");
|
|
350
|
+
if (a.background) {
|
|
351
|
+
const j = startJob(cmd, ctx.cwd);
|
|
352
|
+
return report(j, await j.wait(wait * 1000, until, ctx.signal, ctx.onOutput));
|
|
353
|
+
}
|
|
354
|
+
return runShell(cmd, ctx, t * 1000);
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
/** A command the person typed themselves (`!npm test`): no approval, the same capture and shortening as bash. */
|
|
358
|
+
export const runUserCommand = (cmd, cwd, signal, onOutput) => runShell(cmd, { cwd, signal, onOutput, confirm: async () => true }, 600_000);
|
|
359
|
+
function runShell(cmd, ctx, timeoutMs) {
|
|
360
|
+
return new Promise((resolveP) => {
|
|
361
|
+
const child = spawnShell(cmd, ctx.cwd);
|
|
362
|
+
const captured = new HeadTail(1024 * 1024, 7 * 1024 * 1024); // bounded memory, and the start is never lost
|
|
363
|
+
let status = "";
|
|
364
|
+
let done = false;
|
|
365
|
+
const take = (b) => {
|
|
366
|
+
if (done)
|
|
367
|
+
return; // a background process still writing after the command returned: drained, not kept
|
|
368
|
+
captured.push(b);
|
|
369
|
+
ctx.onOutput?.(stripEscapes(b.toString()));
|
|
370
|
+
};
|
|
371
|
+
child.stdout.on("data", take);
|
|
372
|
+
child.stderr.on("data", take);
|
|
373
|
+
const kill = (why) => {
|
|
374
|
+
status = why;
|
|
375
|
+
killTreeOf(child, "SIGTERM");
|
|
376
|
+
setTimeout(() => killTreeOf(child, "SIGKILL"), 2000).unref();
|
|
377
|
+
};
|
|
378
|
+
const timer = setTimeout(() => kill(`timed out after ${timeoutMs / 1000}s`), timeoutMs);
|
|
379
|
+
const onAbort = () => kill("interrupted by user");
|
|
380
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
381
|
+
if (ctx.signal.aborted)
|
|
382
|
+
onAbort();
|
|
383
|
+
const finish = (suffix, background = false) => {
|
|
384
|
+
if (done)
|
|
385
|
+
return;
|
|
386
|
+
done = true;
|
|
387
|
+
// with processes left running in the background, the child stays tracked so OAKODE stops them when it exits
|
|
388
|
+
if (!background)
|
|
389
|
+
untrackChild(child);
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
clearTimeout(exitWait);
|
|
392
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
393
|
+
const full = captured.text();
|
|
394
|
+
const c = condenseOutput(full);
|
|
395
|
+
const { text, truncated } = keepEnds(c.text, MAX_BYTES);
|
|
396
|
+
let note = "";
|
|
397
|
+
if (truncated || c.changed || captured.omitted) {
|
|
398
|
+
let f = "";
|
|
399
|
+
try {
|
|
400
|
+
f = saveOutput(`bash-${randomBytes(4).toString("hex")}.log`, full);
|
|
401
|
+
}
|
|
402
|
+
catch { } // could not be saved: the result says only that it was shortened
|
|
403
|
+
note = `[output shortened (${truncated || captured.omitted ? "the middle was cut; the start and the end are kept" : "repeats collapsed / middle omitted"})${f ? `; ${captured.omitted ? "the start and end of the" : "full"} output saved to ${f}` : ""}]\n`;
|
|
404
|
+
}
|
|
405
|
+
const bg = background ? " [the command finished, but processes it started in the background are still running and holding its output; they are stopped when OAKODE exits]" : "";
|
|
406
|
+
resolveP(`${note}${text}${text && !text.endsWith("\n") ? "\n" : ""}${status ? `[${status}] ` : ""}${suffix}${bg}`);
|
|
407
|
+
};
|
|
408
|
+
// Return when the shell exits, not when its output pipes close: `server &` leaves a background process holding
|
|
409
|
+
// them, and waiting for that would hang until the timeout (and then kill the server). Normal commands close their
|
|
410
|
+
// pipes right after exiting, so the short grace only matters for those.
|
|
411
|
+
let exitWait;
|
|
412
|
+
child.on("exit", (code, sig) => {
|
|
413
|
+
exitWait = setTimeout(() => finish(`[exit code ${code ?? `signal ${sig}`}]`, true), 250);
|
|
414
|
+
});
|
|
415
|
+
child.on("error", (e) => finish(`[failed to start: ${e.message}]`));
|
|
416
|
+
child.on("close", (code, sig) => finish(`[exit code ${code ?? `signal ${sig}`}]`));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const waitArg = (v, dflt) => {
|
|
420
|
+
if (v === undefined)
|
|
421
|
+
return dflt;
|
|
422
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > 240)
|
|
423
|
+
throw new ToolError("`wait` must be 0-240 seconds");
|
|
424
|
+
return v;
|
|
425
|
+
};
|
|
426
|
+
const untilArg = (v) => {
|
|
427
|
+
if (v === undefined || v === "")
|
|
428
|
+
return undefined;
|
|
429
|
+
if (typeof v !== "string" || v.length > 500)
|
|
430
|
+
throw new ToolError("`until` must be a regular expression (at most 500 characters)");
|
|
431
|
+
// patterns that can backtrack for minutes on one line (nested quantifiers, backreferences) would stall OAKODE
|
|
432
|
+
if (/\([^()]*[+*}|][^()]*\)\s*[+*{]/.test(v) || /\\[1-9]|\\k</.test(v))
|
|
433
|
+
throw new ToolError("`until` must be a simple pattern: no repeated group that itself repeats or has alternatives, and no backreferences. Plain alternatives like \"ready|listening|error\" are fine.");
|
|
434
|
+
try {
|
|
435
|
+
return new RegExp(v, "m");
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
throw new ToolError(`\`until\` is not a valid regular expression: ${e.message}`);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
/** Checks on or stops background jobs. It starts nothing, so it asks nothing. */
|
|
442
|
+
const job = {
|
|
443
|
+
def: {
|
|
444
|
+
type: "function",
|
|
445
|
+
function: {
|
|
446
|
+
name: "job",
|
|
447
|
+
description: `Check on or stop a command started with bash {background: true}. poll returns the job's output since the last poll; it waits up to \`wait\` seconds (default 30), returning early when the job ends or its output matches \`until\`. Waiting costs nothing, but each poll is a round trip: prefer one long wait with \`until\` over many short polls. interrupt sends Ctrl+C (SIGINT; on Windows it stops the job); kill stops the job and everything it started; list shows all jobs. Full logs are saved to files read and grep can open.`,
|
|
448
|
+
parameters: {
|
|
449
|
+
type: "object",
|
|
450
|
+
properties: {
|
|
451
|
+
action: { type: "string", enum: ["poll", "interrupt", "kill", "list"] },
|
|
452
|
+
id: { type: "integer", description: "The job id (not for list)" },
|
|
453
|
+
wait: { type: "integer", description: "poll: seconds to wait (0-240, default 30)" },
|
|
454
|
+
until: { type: "string", description: "poll: a regular expression to return on as soon as the output matches" },
|
|
455
|
+
},
|
|
456
|
+
required: ["action"],
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
summarize: (a) => (a?.action === "list" ? "job list" : `job ${a?.action ?? "?"} ${a?.id ?? "?"}${a?.until ? ` until /${String(a.until).slice(0, 40)}/` : ""}`),
|
|
461
|
+
async run(a, ctx) {
|
|
462
|
+
if (a?.action === "list") {
|
|
463
|
+
const all = allJobs();
|
|
464
|
+
return all.length ? all.map((j) => `${j.id}: ${stateOf(j)} · ${j.command.split("\n")[0].slice(0, 100)} · log ${j.log}`).join("\n") : "No background jobs.";
|
|
465
|
+
}
|
|
466
|
+
const j = jobById(a?.id);
|
|
467
|
+
if (!j)
|
|
468
|
+
throw new ToolError(`No job ${JSON.stringify(a?.id)}. ${allJobs().length ? `Jobs: ${allJobs().map((x) => x.id).join(", ")}.` : "There are no background jobs."}`);
|
|
469
|
+
if (a.action === "poll")
|
|
470
|
+
return report(j, await j.wait(waitArg(a.wait, 30) * 1000, untilArg(a.until), ctx.signal, ctx.onOutput));
|
|
471
|
+
if (a.action === "interrupt" || a.action === "kill") {
|
|
472
|
+
const was = j.alive;
|
|
473
|
+
await j.stop(a.action);
|
|
474
|
+
return report(j) + (was && j.alive ? `\n[it did not stop after ${a.action === "interrupt" ? "Ctrl+C; try kill" : "SIGKILL"}]` : "");
|
|
475
|
+
}
|
|
476
|
+
throw new ToolError("`action` must be poll, interrupt, kill or list");
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
export const TOOLS = [read, write, edit, grep, glob, ls, bash, job];
|
|
480
|
+
export const toolByName = (name) => TOOLS.find((t) => t.def.function.name === name);
|