@oxecli/oxe 1.0.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/.env +2 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/oxe.js +6 -0
- package/dist/api.js +64 -0
- package/dist/cli.js +284 -0
- package/dist/config.js +280 -0
- package/dist/engine.js +534 -0
- package/dist/oxe.js +6 -0
- package/dist/sessions.js +146 -0
- package/dist/skills.js +141 -0
- package/dist/system.js +19 -0
- package/dist/tools.js +856 -0
- package/dist/ui.js +569 -0
- package/package.json +49 -0
- package/skills/apple-design/SKILL.md +282 -0
- package/skills/react-native/SKILL.md +14 -0
- package/skills/react-native/references/structure.md +9 -0
- package/skills/react-native/scripts/scaffold.sh +3 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { structuredPatch } from "diff";
|
|
5
|
+
import { max_diff_source_chars, max_diff_lines, max_diff_context_lines, max_diff_line_chars, max_read_line_chars, max_read_file_bytes, max_read_lines, max_output_chars, max_bash_timeout_seconds, max_grep_file_bytes, strict_max_properties, ignoredDirs, } from "./config.js";
|
|
6
|
+
import { toolLoadSkill } from "./skills.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Natural sort + diff helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export function naturalSortKey(s) {
|
|
11
|
+
return s.replace(/\d+/g, (m) => m.padStart(12, "0")).toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
function isGlob(s) {
|
|
14
|
+
return /[*?[]/.test(s);
|
|
15
|
+
}
|
|
16
|
+
function truncateDiffLine(line) {
|
|
17
|
+
const ending = line.endsWith("\n") ? "\n" : "";
|
|
18
|
+
const body = ending ? line.slice(0, -1) : line;
|
|
19
|
+
if (body.length > max_diff_line_chars) {
|
|
20
|
+
return body.slice(0, max_diff_line_chars) + " …(line truncated)" + ending;
|
|
21
|
+
}
|
|
22
|
+
return line;
|
|
23
|
+
}
|
|
24
|
+
function displayDiff(pathName, oldContent, newContent) {
|
|
25
|
+
if (oldContent.length + newContent.length > max_diff_source_chars) {
|
|
26
|
+
console.log(`\x1b[2m${pathName}: ${oldContent.length.toLocaleString()} chars -> ${newContent.length.toLocaleString()} chars ` +
|
|
27
|
+
`(diff hidden: exceeds ${max_diff_source_chars.toLocaleString()} char limit)\x1b[0m`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const patch = structuredPatch(pathName, pathName, oldContent, newContent, "", "", { context: max_diff_context_lines });
|
|
31
|
+
const contentLines = [];
|
|
32
|
+
for (const hunk of patch.hunks) {
|
|
33
|
+
for (const l of hunk.lines) {
|
|
34
|
+
if (l.startsWith("+"))
|
|
35
|
+
contentLines.push({ line: l, kind: "+" });
|
|
36
|
+
else if (l.startsWith("-"))
|
|
37
|
+
contentLines.push({ line: l, kind: "-" });
|
|
38
|
+
else
|
|
39
|
+
contentLines.push({ line: l, kind: " " });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const added = contentLines.filter((c) => c.kind === "+").length;
|
|
43
|
+
const removed = contentLines.filter((c) => c.kind === "-").length;
|
|
44
|
+
if (!added && !removed)
|
|
45
|
+
return;
|
|
46
|
+
console.log(`\x1b[2m${pathName}\x1b[0m`);
|
|
47
|
+
console.log(`\x1b[31m-${removed}\x1b[0m \x1b[32m+${added}\x1b[0m`);
|
|
48
|
+
const shown = [];
|
|
49
|
+
let run = [];
|
|
50
|
+
let lastKind = null;
|
|
51
|
+
const flush = () => {
|
|
52
|
+
if (!run.length)
|
|
53
|
+
return;
|
|
54
|
+
const kind = run[0].kind;
|
|
55
|
+
const limit = kind === " " ? max_diff_context_lines : max_diff_lines;
|
|
56
|
+
const hidden = run.length - limit;
|
|
57
|
+
for (const r of run.slice(0, limit))
|
|
58
|
+
shown.push(truncateDiffLine(r.line));
|
|
59
|
+
if (hidden > 0) {
|
|
60
|
+
shown.push(`… (${hidden} ${hidden === 1 ? "line" : "lines"} of diff hidden) …`);
|
|
61
|
+
}
|
|
62
|
+
run = [];
|
|
63
|
+
};
|
|
64
|
+
for (const c of contentLines) {
|
|
65
|
+
if (c.kind !== lastKind) {
|
|
66
|
+
flush();
|
|
67
|
+
lastKind = c.kind;
|
|
68
|
+
}
|
|
69
|
+
run.push(c);
|
|
70
|
+
}
|
|
71
|
+
flush();
|
|
72
|
+
const diffText = shown.join("\n");
|
|
73
|
+
const colored = diffText
|
|
74
|
+
.split("\n")
|
|
75
|
+
.map((l) => l.startsWith("+") ? `\x1b[32m${l}\x1b[0m` : l.startsWith("-") ? `\x1b[31m${l}\x1b[0m` : l)
|
|
76
|
+
.join("\n");
|
|
77
|
+
process.stdout.write(colored + "\n");
|
|
78
|
+
}
|
|
79
|
+
export function truncateToolOutput(output, maxChars = max_output_chars) {
|
|
80
|
+
if (output.length > maxChars) {
|
|
81
|
+
return (output.slice(0, maxChars) +
|
|
82
|
+
`\n\n[... Output truncated! Total characters: ${output.length} ...]`);
|
|
83
|
+
}
|
|
84
|
+
return output;
|
|
85
|
+
}
|
|
86
|
+
function capLine(line) {
|
|
87
|
+
line = line.replace(/[\r\n]+$/, "");
|
|
88
|
+
if (line.length > max_read_line_chars) {
|
|
89
|
+
return line.slice(0, max_read_line_chars) + " …(line truncated)";
|
|
90
|
+
}
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// read_file
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
const lineIndexCache = new Map();
|
|
97
|
+
const lineIndexCacheMaxFiles = 32;
|
|
98
|
+
const lineIndexCacheMaxTotalOffsets = 1_000_000;
|
|
99
|
+
const indexScanChunk = 1 << 20;
|
|
100
|
+
function buildLineIndex(p) {
|
|
101
|
+
const offsets = [0];
|
|
102
|
+
let nNewlines = 0;
|
|
103
|
+
let base = 0;
|
|
104
|
+
const buf = Buffer.alloc(indexScanChunk);
|
|
105
|
+
let fd = null;
|
|
106
|
+
try {
|
|
107
|
+
fd = fs.openSync(p, "r");
|
|
108
|
+
while (true) {
|
|
109
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, base);
|
|
110
|
+
if (bytesRead <= 0)
|
|
111
|
+
break;
|
|
112
|
+
const chunk = buf.subarray(0, bytesRead);
|
|
113
|
+
for (let i = 0; i < chunk.length; i++)
|
|
114
|
+
if (chunk[i] === 0x0a)
|
|
115
|
+
nNewlines++;
|
|
116
|
+
let start = 0;
|
|
117
|
+
while (true) {
|
|
118
|
+
const idx = chunk.indexOf(0x0a, start);
|
|
119
|
+
if (idx === -1)
|
|
120
|
+
break;
|
|
121
|
+
offsets.push(base + idx + 1);
|
|
122
|
+
start = idx + 1;
|
|
123
|
+
}
|
|
124
|
+
base += bytesRead;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (fd !== null)
|
|
132
|
+
try {
|
|
133
|
+
fs.closeSync(fd);
|
|
134
|
+
}
|
|
135
|
+
catch { /* ignore */ }
|
|
136
|
+
}
|
|
137
|
+
if (base === 0)
|
|
138
|
+
return [[0], 0];
|
|
139
|
+
const endsWithNl = offsets[offsets.length - 1] === base;
|
|
140
|
+
const total = endsWithNl ? nNewlines : nNewlines + 1;
|
|
141
|
+
return [offsets, total];
|
|
142
|
+
}
|
|
143
|
+
function lineIndex(p) {
|
|
144
|
+
let st;
|
|
145
|
+
try {
|
|
146
|
+
st = fs.statSync(p);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const key = `${path.resolve(p).toLowerCase()}:${st.size}:${st.mtimeMs}`;
|
|
152
|
+
const cached = lineIndexCache.get(p);
|
|
153
|
+
if (cached && cached.key === key)
|
|
154
|
+
return [cached.offsets, cached.total];
|
|
155
|
+
const built = buildLineIndex(p);
|
|
156
|
+
if (!built)
|
|
157
|
+
return null;
|
|
158
|
+
const [offsets, total] = built;
|
|
159
|
+
if (offsets.length <= lineIndexCacheMaxTotalOffsets) {
|
|
160
|
+
while (lineIndexCache.size >= lineIndexCacheMaxFiles ||
|
|
161
|
+
[...lineIndexCache.values()].reduce((s, e) => s + e.offsets.length, 0) >
|
|
162
|
+
lineIndexCacheMaxTotalOffsets) {
|
|
163
|
+
lineIndexCache.delete(lineIndexCache.keys().next().value);
|
|
164
|
+
}
|
|
165
|
+
lineIndexCache.set(p, { key, offsets, total });
|
|
166
|
+
}
|
|
167
|
+
return [offsets, total];
|
|
168
|
+
}
|
|
169
|
+
function readSpan(p, offsets, startIdx, endIdx) {
|
|
170
|
+
const startOff = offsets[startIdx];
|
|
171
|
+
const endOff = endIdx + 1 < offsets.length ? offsets[endIdx + 1] : undefined;
|
|
172
|
+
let data;
|
|
173
|
+
try {
|
|
174
|
+
if (endOff === undefined) {
|
|
175
|
+
data = fs.readFileSync(p);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const fd = fs.openSync(p, "r");
|
|
179
|
+
const buf = Buffer.alloc(endOff - startOff);
|
|
180
|
+
fs.readSync(fd, buf, 0, buf.length, startOff);
|
|
181
|
+
fs.closeSync(fd);
|
|
182
|
+
data = buf;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const text = data.toString("utf-8");
|
|
189
|
+
let pieces = text.split("\n");
|
|
190
|
+
if (pieces.length && pieces[pieces.length - 1] === "")
|
|
191
|
+
pieces.pop();
|
|
192
|
+
return pieces.map(capLine);
|
|
193
|
+
}
|
|
194
|
+
function streamReadRange(p, start, end) {
|
|
195
|
+
const numbered = [];
|
|
196
|
+
let i = 0;
|
|
197
|
+
try {
|
|
198
|
+
const lines = fs.readFileSync(p, "utf-8").split("\n");
|
|
199
|
+
for (let n = 0; n < lines.length; n++) {
|
|
200
|
+
i = n + 1;
|
|
201
|
+
if (start <= i && i <= end) {
|
|
202
|
+
numbered.push(`${String(i).padStart(6, " ")}\t${capLine(lines[n])}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* ignore */
|
|
208
|
+
}
|
|
209
|
+
return [i, numbered];
|
|
210
|
+
}
|
|
211
|
+
export function toolReadFile(pathName, startLine = 1, endLine) {
|
|
212
|
+
const p = pathName;
|
|
213
|
+
if (!fs.existsSync(p))
|
|
214
|
+
return `Error: file not found: ${pathName}`;
|
|
215
|
+
if (!fs.statSync(p).isFile())
|
|
216
|
+
return `Error: not a file: ${pathName}`;
|
|
217
|
+
let size;
|
|
218
|
+
try {
|
|
219
|
+
size = fs.statSync(p).size;
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
return `Error: ${err}`;
|
|
223
|
+
}
|
|
224
|
+
if (size > max_read_file_bytes) {
|
|
225
|
+
return (`Error: file too large (${size.toLocaleString()} bytes > ${max_read_file_bytes.toLocaleString()} cap). ` +
|
|
226
|
+
`Use grep or bash to inspect it instead of reading it whole.`);
|
|
227
|
+
}
|
|
228
|
+
if (size === 0)
|
|
229
|
+
return "(empty file)";
|
|
230
|
+
const start = Math.max(1, startLine);
|
|
231
|
+
if (endLine != null && endLine < start) {
|
|
232
|
+
return `Error: end_line ${endLine} is before start_line ${start}`;
|
|
233
|
+
}
|
|
234
|
+
let end = null;
|
|
235
|
+
let total = 0;
|
|
236
|
+
let numbered = [];
|
|
237
|
+
let capped = false;
|
|
238
|
+
const idx = lineIndex(p);
|
|
239
|
+
if (idx) {
|
|
240
|
+
const [offsets, t] = idx;
|
|
241
|
+
total = t;
|
|
242
|
+
if (start > total)
|
|
243
|
+
return `Error: start_line ${start} exceeds file length (${total} lines)`;
|
|
244
|
+
end = endLine == null ? total : Math.min(total, endLine);
|
|
245
|
+
capped = end - start + 1 > max_read_lines;
|
|
246
|
+
if (capped)
|
|
247
|
+
end = start + max_read_lines - 1;
|
|
248
|
+
numbered = [];
|
|
249
|
+
const span = readSpan(p, offsets, start - 1, end - 1);
|
|
250
|
+
for (let i = 0; i < span.length; i++) {
|
|
251
|
+
numbered.push(`${String(start + i).padStart(6, " ")}\t${span[i]}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const collectEnd = Math.min(Math.max(1, endLine != null ? endLine : start + max_read_lines - 1), start + max_read_lines - 1);
|
|
256
|
+
[total, numbered] = streamReadRange(p, start, collectEnd);
|
|
257
|
+
if (start > total)
|
|
258
|
+
return `Error: start_line ${start} exceeds file length (${total} lines)`;
|
|
259
|
+
end = endLine == null ? total : Math.min(total, endLine);
|
|
260
|
+
capped = end - start + 1 > max_read_lines;
|
|
261
|
+
if (capped) {
|
|
262
|
+
end = start + max_read_lines - 1;
|
|
263
|
+
numbered = numbered.slice(0, max_read_lines);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (end === null)
|
|
267
|
+
end = total;
|
|
268
|
+
const header = `${pathName}: lines ${start}-${end} of ${total} total`;
|
|
269
|
+
const notes = [];
|
|
270
|
+
if (capped) {
|
|
271
|
+
notes.push(`capped at ${max_read_lines} lines; pass start_line=${end + 1} to continue`);
|
|
272
|
+
}
|
|
273
|
+
const footer = notes.length ? `\n[${notes.join("; ")}]` : "";
|
|
274
|
+
return `${header}\n${numbered.join("\n")}${footer}`;
|
|
275
|
+
}
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// write_file
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
export function toolWriteFile(pathName, content) {
|
|
280
|
+
const p = pathName;
|
|
281
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
|
|
282
|
+
return `Error: ${pathName} is a directory`;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
286
|
+
if (fs.existsSync(p)) {
|
|
287
|
+
const head = fs.readFileSync(p).subarray(0, 8192);
|
|
288
|
+
if (head.includes(0))
|
|
289
|
+
return `Error: refusing to overwrite binary file: ${pathName}`;
|
|
290
|
+
const oldContent = fs.readFileSync(p, "utf-8");
|
|
291
|
+
displayDiff(pathName, oldContent, content);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
displayDiff(pathName, "", content);
|
|
295
|
+
}
|
|
296
|
+
fs.writeFileSync(p, content, "utf-8");
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
return `Error: ${err}`;
|
|
300
|
+
}
|
|
301
|
+
return `Wrote ${content.length} characters to ${pathName}`;
|
|
302
|
+
}
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// edit_file
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
function detectNewline(text) {
|
|
307
|
+
const crlf = (text.match(/\r\n/g) || []).length;
|
|
308
|
+
const lf = (text.match(/\n/g) || []).length - crlf;
|
|
309
|
+
const loneCr = (text.match(/\r/g) || []).length - crlf;
|
|
310
|
+
if (crlf && crlf >= lf && crlf >= loneCr)
|
|
311
|
+
return "\r\n";
|
|
312
|
+
if (loneCr && loneCr >= lf)
|
|
313
|
+
return "\r";
|
|
314
|
+
return "\n";
|
|
315
|
+
}
|
|
316
|
+
function withNewlines(text, nl) {
|
|
317
|
+
if (nl === "\r")
|
|
318
|
+
return text.replace(/\r\n/g, "\r").replace(/\n/g, "\r");
|
|
319
|
+
if (nl === "\n")
|
|
320
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
321
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r\n");
|
|
322
|
+
}
|
|
323
|
+
function findWsBlocks(text, needle) {
|
|
324
|
+
const needleLines = withNewlines(needle, "\n").split("\n").map((l) => l.trim());
|
|
325
|
+
while (needleLines.length && needleLines[needleLines.length - 1] === "")
|
|
326
|
+
needleLines.pop();
|
|
327
|
+
if (!needleLines.length)
|
|
328
|
+
return [];
|
|
329
|
+
const textLines = text.split("\n");
|
|
330
|
+
const n = needleLines.length;
|
|
331
|
+
const blocks = [];
|
|
332
|
+
let i = 0;
|
|
333
|
+
while (i <= textLines.length - n) {
|
|
334
|
+
let match = true;
|
|
335
|
+
for (let j = 0; j < n; j++) {
|
|
336
|
+
if (textLines[i + j].trim() !== needleLines[j]) {
|
|
337
|
+
match = false;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (match) {
|
|
342
|
+
blocks.push(textLines.slice(i, i + n).join("\n"));
|
|
343
|
+
i += n;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
i += 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return blocks;
|
|
350
|
+
}
|
|
351
|
+
export function toolEditFile(pathName, oldString, newString, replaceAll = false) {
|
|
352
|
+
const p = pathName;
|
|
353
|
+
if (!fs.existsSync(p))
|
|
354
|
+
return `Error: file not found: ${pathName}`;
|
|
355
|
+
if (!fs.statSync(p).isFile())
|
|
356
|
+
return `Error: not a file: ${pathName}`;
|
|
357
|
+
if (oldString === newString)
|
|
358
|
+
return "Error: old_string and new_string are identical";
|
|
359
|
+
let data;
|
|
360
|
+
try {
|
|
361
|
+
data = fs.readFileSync(p);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
return `Error: ${err}`;
|
|
365
|
+
}
|
|
366
|
+
if (data.includes(0))
|
|
367
|
+
return `Error: refusing to edit binary file: ${pathName}`;
|
|
368
|
+
const text = data.toString("utf-8");
|
|
369
|
+
const newline = detectNewline(text);
|
|
370
|
+
let match;
|
|
371
|
+
let normalized = false;
|
|
372
|
+
if (text.includes(oldString)) {
|
|
373
|
+
match = oldString;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
const norm = withNewlines(oldString, newline);
|
|
377
|
+
if (text.includes(norm)) {
|
|
378
|
+
match = norm;
|
|
379
|
+
normalized = true;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const blocks = findWsBlocks(text, oldString);
|
|
383
|
+
if (!blocks.length) {
|
|
384
|
+
return ("Error: old_string not found in file " +
|
|
385
|
+
"(line endings and per-line leading/trailing whitespace are normalized for matching)");
|
|
386
|
+
}
|
|
387
|
+
match = blocks[0];
|
|
388
|
+
normalized = true;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const count = text.split(match).length - 1;
|
|
392
|
+
if (count > 1 && !replaceAll) {
|
|
393
|
+
return (`Error: old_string matches ${count} locations; pass replace_all=true or make old_string more specific`);
|
|
394
|
+
}
|
|
395
|
+
const insert = withNewlines(newString, newline);
|
|
396
|
+
const newContent = replaceAll
|
|
397
|
+
? text.split(match).join(insert)
|
|
398
|
+
: text.replace(match, insert);
|
|
399
|
+
displayDiff(pathName, text, newContent);
|
|
400
|
+
try {
|
|
401
|
+
fs.writeFileSync(p, newContent, "utf-8");
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
return `Error: ${err}`;
|
|
405
|
+
}
|
|
406
|
+
return (`Edited ${pathName} (${count} occurrence${count !== 1 ? "s" : ""} replaced` +
|
|
407
|
+
`${normalized ? "; line endings and per-line whitespace normalized" : ""})`);
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// bash
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
function terminateProcessTree(proc) {
|
|
413
|
+
try {
|
|
414
|
+
if (isWindowsTool()) {
|
|
415
|
+
execFile("taskkill", ["/F", "/T", "/PID", String(proc.pid)], { timeout: 10000 });
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
try {
|
|
419
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
proc.kill();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
try {
|
|
428
|
+
proc.kill();
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
/* ignore */
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function isWindowsTool() {
|
|
436
|
+
return process.platform === "win32";
|
|
437
|
+
}
|
|
438
|
+
export function toolBash(command, timeout = 60, cwd) {
|
|
439
|
+
try {
|
|
440
|
+
let t = typeof timeout === "number" ? timeout : parseInt(String(timeout), 10);
|
|
441
|
+
if (Number.isNaN(t))
|
|
442
|
+
t = 60;
|
|
443
|
+
t = t > 0 ? Math.min(t, max_bash_timeout_seconds) : 60;
|
|
444
|
+
return new Promise((resolve) => {
|
|
445
|
+
let child;
|
|
446
|
+
try {
|
|
447
|
+
if (isWindowsTool()) {
|
|
448
|
+
child = spawn(command, {
|
|
449
|
+
shell: true,
|
|
450
|
+
cwd: cwd || undefined,
|
|
451
|
+
windowsHide: false,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
child = spawn(command, {
|
|
456
|
+
shell: true,
|
|
457
|
+
cwd: cwd || undefined,
|
|
458
|
+
detached: true,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
resolve(`Error: ${err}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
let stdout = "";
|
|
467
|
+
let stderr = "";
|
|
468
|
+
let finished = false;
|
|
469
|
+
const timer = setTimeout(() => {
|
|
470
|
+
if (finished)
|
|
471
|
+
return;
|
|
472
|
+
terminateProcessTree({ pid: child.pid, kill: () => child.kill("SIGKILL") });
|
|
473
|
+
const output = (stdout + stderr).trim();
|
|
474
|
+
let msg = `Error: command timed out after ${t}s`;
|
|
475
|
+
if (output)
|
|
476
|
+
msg += `\n${output}`;
|
|
477
|
+
finished = true;
|
|
478
|
+
resolve(msg);
|
|
479
|
+
}, t * 1000);
|
|
480
|
+
child.stdout?.on("data", (d) => (stdout += d.toString("utf-8")));
|
|
481
|
+
child.stderr?.on("data", (d) => (stderr += d.toString("utf-8")));
|
|
482
|
+
child.on("error", (err) => {
|
|
483
|
+
if (finished)
|
|
484
|
+
return;
|
|
485
|
+
finished = true;
|
|
486
|
+
clearTimeout(timer);
|
|
487
|
+
resolve(`Error: ${err}`);
|
|
488
|
+
});
|
|
489
|
+
child.on("close", (code) => {
|
|
490
|
+
if (finished)
|
|
491
|
+
return;
|
|
492
|
+
finished = true;
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
const output = (stdout + stderr).trim() || "(no output)";
|
|
495
|
+
resolve(`exit code: ${code}\n${output}`);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
return Promise.resolve(`Error: ${err}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// glob / grep
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
function pathUnderIgnoredDir(base, f) {
|
|
507
|
+
const rel = path.relative(base, f);
|
|
508
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
509
|
+
return false;
|
|
510
|
+
const parts = rel.split(/[\\/]/);
|
|
511
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
512
|
+
if (ignoredDirs.has(parts[i]))
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
function matchParts(parts, relParts) {
|
|
518
|
+
if (!parts.length)
|
|
519
|
+
return relParts.length === 0;
|
|
520
|
+
if (parts[0] === "**") {
|
|
521
|
+
for (let k = 0; k <= relParts.length; k++) {
|
|
522
|
+
if (matchParts(parts.slice(1), relParts.slice(k)))
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
return (relParts.length > 0 &&
|
|
528
|
+
globMatch(relParts[0], parts[0]) &&
|
|
529
|
+
matchParts(parts.slice(1), relParts.slice(1)));
|
|
530
|
+
}
|
|
531
|
+
// minimal glob matcher (supports * ? [..])
|
|
532
|
+
function globMatch(name, pattern) {
|
|
533
|
+
const regex = pattern
|
|
534
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
535
|
+
.replace(/\*/g, ".*")
|
|
536
|
+
.replace(/\?/g, ".");
|
|
537
|
+
return new RegExp(`^${regex}$`).test(name);
|
|
538
|
+
}
|
|
539
|
+
function walkFiles(root, current, rel, parts, out) {
|
|
540
|
+
let entries;
|
|
541
|
+
try {
|
|
542
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
const name = entry.name;
|
|
549
|
+
try {
|
|
550
|
+
if (entry.isDirectory()) {
|
|
551
|
+
if (ignoredDirs.has(name))
|
|
552
|
+
continue;
|
|
553
|
+
rel.push(name);
|
|
554
|
+
walkFiles(root, path.join(current, name), rel, parts, out);
|
|
555
|
+
rel.pop();
|
|
556
|
+
}
|
|
557
|
+
else if (entry.isFile()) {
|
|
558
|
+
if (matchParts(parts, rel.concat(name))) {
|
|
559
|
+
out.push([path.join(root, ...rel, name), fs.statSync(path.join(root, ...rel, name)).size]);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function rglobPruned(base, pattern) {
|
|
569
|
+
const norm = pattern.replace(/\\/g, "/");
|
|
570
|
+
const parts = norm.split("/").filter((p) => p !== "" && p !== ".");
|
|
571
|
+
const hasDotDot = parts.includes("..");
|
|
572
|
+
if (hasDotDot) {
|
|
573
|
+
const out = [];
|
|
574
|
+
const walk = (dir) => {
|
|
575
|
+
let entries;
|
|
576
|
+
try {
|
|
577
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
for (const e of entries) {
|
|
583
|
+
const full = path.join(dir, e.name);
|
|
584
|
+
if (e.isDirectory()) {
|
|
585
|
+
if (!ignoredDirs.has(e.name))
|
|
586
|
+
walk(full);
|
|
587
|
+
}
|
|
588
|
+
else if (e.isFile()) {
|
|
589
|
+
out.push([full, fs.statSync(full).size]);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
if (fs.statSync(base).isFile()) {
|
|
594
|
+
out.push([base, fs.statSync(base).size]);
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
walk(base);
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
if (fs.statSync(base).isFile()) {
|
|
602
|
+
return globMatch(path.basename(base), parts[parts.length - 1] ?? "*")
|
|
603
|
+
? [[base, fs.statSync(base).size]]
|
|
604
|
+
: [];
|
|
605
|
+
}
|
|
606
|
+
if (!parts.length)
|
|
607
|
+
return [];
|
|
608
|
+
const out = [];
|
|
609
|
+
walkFiles(base, base, [], ["**", ...parts], out);
|
|
610
|
+
return out;
|
|
611
|
+
}
|
|
612
|
+
export function toolGlob(pattern, pathName = ".", limit = 200) {
|
|
613
|
+
const base = pathName;
|
|
614
|
+
if (!fs.existsSync(base))
|
|
615
|
+
return `Error: path not found: ${pathName}`;
|
|
616
|
+
limit = Math.max(1, limit);
|
|
617
|
+
let matches;
|
|
618
|
+
try {
|
|
619
|
+
matches = rglobPruned(base, pattern);
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return `Error: path not found: ${pathName}`;
|
|
623
|
+
}
|
|
624
|
+
matches.sort((a, b) => naturalSortKey(a[0]).localeCompare(naturalSortKey(b[0])));
|
|
625
|
+
if (!matches.length)
|
|
626
|
+
return "No files matched";
|
|
627
|
+
const total = matches.length;
|
|
628
|
+
let result = matches.slice(0, limit).map(([p]) => p).join("\n");
|
|
629
|
+
if (total > limit) {
|
|
630
|
+
result += `\n\n[... ${total - limit} more matches not shown ...]`;
|
|
631
|
+
}
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
export function toolGrep(pattern, pathName = ".", glob = "*", limit = 200) {
|
|
635
|
+
const base = pathName;
|
|
636
|
+
if (!fs.existsSync(base))
|
|
637
|
+
return `Error: path not found: ${pathName}`;
|
|
638
|
+
let regex;
|
|
639
|
+
try {
|
|
640
|
+
regex = new RegExp(pattern);
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
return `Error: invalid regex pattern: ${err}`;
|
|
644
|
+
}
|
|
645
|
+
glob = glob || "*";
|
|
646
|
+
let targets;
|
|
647
|
+
if (fs.statSync(base).isFile()) {
|
|
648
|
+
targets =
|
|
649
|
+
glob === "*" || glob === "**" || globMatch(path.basename(base), glob)
|
|
650
|
+
? [[base, fs.statSync(base).size]]
|
|
651
|
+
: [];
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
targets = rglobPruned(base, glob);
|
|
655
|
+
}
|
|
656
|
+
const hits = [];
|
|
657
|
+
let skippedLarge = 0;
|
|
658
|
+
for (const [fpath, fsize] of targets) {
|
|
659
|
+
try {
|
|
660
|
+
if (fsize > max_grep_file_bytes) {
|
|
661
|
+
skippedLarge++;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
const head = fs.readFileSync(fpath).subarray(0, 8192);
|
|
665
|
+
if (head.includes(0))
|
|
666
|
+
continue;
|
|
667
|
+
const lines = fs.readFileSync(fpath, "utf-8").split("\n");
|
|
668
|
+
for (let i = 0; i < lines.length; i++) {
|
|
669
|
+
const line = capLine(lines[i]);
|
|
670
|
+
if (regex.test(line)) {
|
|
671
|
+
hits.push(`${fpath}:${i + 1}:${line}`);
|
|
672
|
+
if (hits.length >= limit)
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (hits.length >= limit)
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
let result = hits.length ? hits.join("\n") : "No matches found";
|
|
684
|
+
if (skippedLarge) {
|
|
685
|
+
result += `\n[... ${skippedLarge} file(s) skipped (> ${Math.floor(max_grep_file_bytes / (1024 * 1024))} MB) ...]`;
|
|
686
|
+
}
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// Tool registry
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
export const TOOL_IMPLEMENTATIONS = {
|
|
693
|
+
read_file: toolReadFile,
|
|
694
|
+
write_file: toolWriteFile,
|
|
695
|
+
edit_file: toolEditFile,
|
|
696
|
+
bash: toolBash,
|
|
697
|
+
glob: toolGlob,
|
|
698
|
+
grep: toolGrep,
|
|
699
|
+
load_skill: toolLoadSkill,
|
|
700
|
+
};
|
|
701
|
+
export const RESPONSES_TOOLS = [
|
|
702
|
+
{
|
|
703
|
+
type: "function",
|
|
704
|
+
name: "read_file",
|
|
705
|
+
description: "Read a text file from disk, returned with 1-indexed line numbers. " +
|
|
706
|
+
"Use start_line/end_line to page through large files; the response " +
|
|
707
|
+
"header reports the file's total line count.",
|
|
708
|
+
parameters: {
|
|
709
|
+
type: "object",
|
|
710
|
+
properties: {
|
|
711
|
+
path: { type: "string", description: "Path to the file" },
|
|
712
|
+
start_line: { type: "integer", description: "First line to read, 1-indexed (default 1)" },
|
|
713
|
+
end_line: {
|
|
714
|
+
type: "integer",
|
|
715
|
+
description: `Last line to read, inclusive (default: end of file, capped at ${max_read_lines} lines from start_line)`,
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
required: ["path"],
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
type: "function",
|
|
723
|
+
name: "write_file",
|
|
724
|
+
description: "Create a new file or overwrite an existing one with the given content. " +
|
|
725
|
+
"Always read a file first if you need to preserve parts of it.",
|
|
726
|
+
parameters: {
|
|
727
|
+
type: "object",
|
|
728
|
+
properties: {
|
|
729
|
+
path: { type: "string", description: "Path to the file" },
|
|
730
|
+
content: { type: "string", description: "Full contents to write" },
|
|
731
|
+
},
|
|
732
|
+
required: ["path", "content"],
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
type: "function",
|
|
737
|
+
name: "edit_file",
|
|
738
|
+
description: "Replace an exact occurrence of old_string with new_string in a file. " +
|
|
739
|
+
"old_string must match the file's current text verbatim, including whitespace. " +
|
|
740
|
+
"By default it must be unique in the file; set replace_all to replace every " +
|
|
741
|
+
"occurrence. Prefer this over write_file for small changes.",
|
|
742
|
+
parameters: {
|
|
743
|
+
type: "object",
|
|
744
|
+
properties: {
|
|
745
|
+
path: { type: "string", description: "Path to the file" },
|
|
746
|
+
old_string: { type: "string", description: "Exact text to find" },
|
|
747
|
+
new_string: { type: "string", description: "Text to replace it with" },
|
|
748
|
+
replace_all: {
|
|
749
|
+
type: "boolean",
|
|
750
|
+
description: "Replace every occurrence instead of requiring a unique match (default false)",
|
|
751
|
+
},
|
|
752
|
+
},
|
|
753
|
+
required: ["path", "old_string", "new_string"],
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
type: "function",
|
|
758
|
+
name: "bash",
|
|
759
|
+
description: "Run a shell command and return its exit code, stdout, and stderr. " +
|
|
760
|
+
"Use for running tests, installing packages, git, and anything not covered " +
|
|
761
|
+
"by the other tools. Avoid interactive commands.",
|
|
762
|
+
parameters: {
|
|
763
|
+
type: "object",
|
|
764
|
+
properties: {
|
|
765
|
+
command: { type: "string", description: "The command to run" },
|
|
766
|
+
timeout: {
|
|
767
|
+
type: "integer",
|
|
768
|
+
description: `Timeout in seconds (default 60, max ${max_bash_timeout_seconds})`,
|
|
769
|
+
},
|
|
770
|
+
cwd: {
|
|
771
|
+
type: "string",
|
|
772
|
+
description: "Working directory to run the command in (default: current directory)",
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
required: ["command"],
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
type: "function",
|
|
780
|
+
name: "glob",
|
|
781
|
+
description: "Find files by name pattern (e.g. '*.py') under a directory, recursively.",
|
|
782
|
+
parameters: {
|
|
783
|
+
type: "object",
|
|
784
|
+
properties: {
|
|
785
|
+
pattern: { type: "string", description: "Glob pattern, e.g. '*.py'" },
|
|
786
|
+
path: { type: "string", description: "Directory to search from (default '.')" },
|
|
787
|
+
limit: { type: "integer", description: "Max number of matches to return (default 200)" },
|
|
788
|
+
},
|
|
789
|
+
required: ["pattern"],
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
type: "function",
|
|
794
|
+
name: "grep",
|
|
795
|
+
description: "Search file contents for a regex pattern, recursively. Returns matching " +
|
|
796
|
+
"'path:line:text' entries.",
|
|
797
|
+
parameters: {
|
|
798
|
+
type: "object",
|
|
799
|
+
properties: {
|
|
800
|
+
pattern: { type: "string", description: "Regular expression to search for" },
|
|
801
|
+
path: { type: "string", description: "Directory to search from (default '.')" },
|
|
802
|
+
glob: {
|
|
803
|
+
type: "string",
|
|
804
|
+
description: "Only search files matching this glob (default '*')",
|
|
805
|
+
},
|
|
806
|
+
limit: { type: "integer", description: "Max number of matches to return (default 200)" },
|
|
807
|
+
},
|
|
808
|
+
required: ["pattern"],
|
|
809
|
+
},
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
type: "function",
|
|
813
|
+
name: "load_skill",
|
|
814
|
+
description: "Load the full contents of an available skill's SKILL.md into context. " +
|
|
815
|
+
"Call this when a user task needs specialized domain knowledge matching " +
|
|
816
|
+
"an available skill.",
|
|
817
|
+
parameters: {
|
|
818
|
+
type: "object",
|
|
819
|
+
properties: {
|
|
820
|
+
skill_name: {
|
|
821
|
+
type: "string",
|
|
822
|
+
description: "Name of the skill to load (e.g. 'react-native').",
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
required: ["skill_name"],
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
];
|
|
829
|
+
export function buildTools() {
|
|
830
|
+
const strictTools = [];
|
|
831
|
+
for (const tool of RESPONSES_TOOLS) {
|
|
832
|
+
const params = tool["parameters"];
|
|
833
|
+
if (!params || typeof params !== "object" || params["type"] !== "object") {
|
|
834
|
+
strictTools.push(tool);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const properties = params["properties"] || {};
|
|
838
|
+
if (Object.keys(properties).length <= strict_max_properties) {
|
|
839
|
+
const required = Object.keys(properties).sort();
|
|
840
|
+
strictTools.push({
|
|
841
|
+
...tool,
|
|
842
|
+
parameters: {
|
|
843
|
+
type: "object",
|
|
844
|
+
properties,
|
|
845
|
+
required,
|
|
846
|
+
additionalProperties: false,
|
|
847
|
+
},
|
|
848
|
+
strict: true,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
strictTools.push(tool);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return strictTools;
|
|
856
|
+
}
|