@ocis/myagent-cli 0.2.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/README.md +357 -0
- package/dist/agent/context.d.ts +33 -0
- package/dist/agent/context.js +169 -0
- package/dist/agent/modes.d.ts +21 -0
- package/dist/agent/modes.js +84 -0
- package/dist/agent/prompt-builder.d.ts +9 -0
- package/dist/agent/prompt-builder.js +31 -0
- package/dist/agent/sessions.d.ts +38 -0
- package/dist/agent/sessions.js +130 -0
- package/dist/agent/todo.d.ts +18 -0
- package/dist/agent/todo.js +61 -0
- package/dist/agent/turn.d.ts +309 -0
- package/dist/agent/turn.js +1253 -0
- package/dist/approval/policy.d.ts +81 -0
- package/dist/approval/policy.js +157 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +156 -0
- package/dist/git/status.d.ts +89 -0
- package/dist/git/status.js +226 -0
- package/dist/headless.d.ts +72 -0
- package/dist/headless.js +330 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +511 -0
- package/dist/protocol/client.d.ts +123 -0
- package/dist/protocol/client.js +250 -0
- package/dist/protocol/sse-frames.d.ts +6 -0
- package/dist/protocol/sse-frames.js +75 -0
- package/dist/protocol/types.d.ts +200 -0
- package/dist/protocol/types.js +8 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +166 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +21 -0
- package/dist/skills/discovery.d.ts +24 -0
- package/dist/skills/discovery.js +109 -0
- package/dist/tools/binary.d.ts +2 -0
- package/dist/tools/binary.js +22 -0
- package/dist/tools/diff.d.ts +1 -0
- package/dist/tools/diff.js +49 -0
- package/dist/tools/find.d.ts +2 -0
- package/dist/tools/find.js +61 -0
- package/dist/tools/fs.d.ts +2 -0
- package/dist/tools/fs.js +276 -0
- package/dist/tools/glob.d.ts +6 -0
- package/dist/tools/glob.js +131 -0
- package/dist/tools/grep.d.ts +3 -0
- package/dist/tools/grep.js +228 -0
- package/dist/tools/paths.d.ts +27 -0
- package/dist/tools/paths.js +124 -0
- package/dist/tools/registry.d.ts +13 -0
- package/dist/tools/registry.js +38 -0
- package/dist/tools/shell.d.ts +2 -0
- package/dist/tools/shell.js +136 -0
- package/dist/tools/skills.d.ts +2 -0
- package/dist/tools/skills.js +36 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +43 -0
- package/dist/tools/transfer.d.ts +2 -0
- package/dist/tools/transfer.js +145 -0
- package/dist/tools/truncate.d.ts +12 -0
- package/dist/tools/truncate.js +46 -0
- package/dist/tools/types.d.ts +85 -0
- package/dist/tools/types.js +63 -0
- package/dist/ui/app.d.ts +39 -0
- package/dist/ui/app.js +1061 -0
- package/dist/ui/colors.d.ts +100 -0
- package/dist/ui/colors.js +169 -0
- package/dist/ui/components.d.ts +267 -0
- package/dist/ui/components.js +811 -0
- package/dist/ui/diff.d.ts +37 -0
- package/dist/ui/diff.js +143 -0
- package/dist/ui/format.d.ts +28 -0
- package/dist/ui/format.js +76 -0
- package/dist/ui/help.d.ts +6 -0
- package/dist/ui/help.js +45 -0
- package/dist/ui/highlight.d.ts +20 -0
- package/dist/ui/highlight.js +210 -0
- package/dist/ui/logo.d.ts +24 -0
- package/dist/ui/logo.js +106 -0
- package/dist/ui/model-list.d.ts +10 -0
- package/dist/ui/model-list.js +33 -0
- package/dist/ui/quit-confirm.d.ts +8 -0
- package/dist/ui/quit-confirm.js +40 -0
- package/dist/ui/select-popup.d.ts +30 -0
- package/dist/ui/select-popup.js +54 -0
- package/dist/ui/theme.d.ts +4 -0
- package/dist/ui/theme.js +41 -0
- package/dist/ui/tool-view.d.ts +20 -0
- package/dist/ui/tool-view.js +326 -0
- package/package.json +44 -0
- package/skills/git-commit/SKILL.md +27 -0
package/dist/tools/fs.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Filesystem tools: local_read, local_write, local_edit, local_ls.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join, relative } from "node:path";
|
|
6
|
+
import { defineTool, optionalBoolean, optionalNumber, optionalString, requireString } from "./types.js";
|
|
7
|
+
import { displayPath, resolveToolPath } from "./paths.js";
|
|
8
|
+
import { isBinaryBuffer } from "./binary.js";
|
|
9
|
+
import { matchGlob } from "./glob.js";
|
|
10
|
+
import { generateDiff } from "./diff.js";
|
|
11
|
+
const MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
const DEFAULT_READ_LINES = 2000;
|
|
13
|
+
function splitLines(text) {
|
|
14
|
+
const lines = text.split("\n");
|
|
15
|
+
// A trailing newline yields a final empty element; drop it for line counts.
|
|
16
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
17
|
+
lines.pop();
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
const readTool = defineTool({
|
|
21
|
+
name: "local_read",
|
|
22
|
+
description: "Read a text file with line numbers. Use offset/limit for large files. Paths are relative to the workspace root.",
|
|
23
|
+
parameters: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
path: { type: "string", description: "File path (relative to the workspace)." },
|
|
27
|
+
offset: { type: "number", description: "1-indexed first line to return (default 1)." },
|
|
28
|
+
limit: { type: "number", description: `Maximum lines to return (default ${DEFAULT_READ_LINES}).` },
|
|
29
|
+
},
|
|
30
|
+
required: ["path"],
|
|
31
|
+
},
|
|
32
|
+
summarize: (args) => `read ${String(args.path ?? "")}`,
|
|
33
|
+
run: async (args, ctx) => {
|
|
34
|
+
const path = requireString(args, "path");
|
|
35
|
+
const offset = Math.max(1, Math.floor(optionalNumber(args, "offset") ?? 1));
|
|
36
|
+
const limit = Math.max(1, Math.floor(optionalNumber(args, "limit") ?? DEFAULT_READ_LINES));
|
|
37
|
+
const absolute = await resolveToolPath(path, ctx);
|
|
38
|
+
const info = await stat(absolute).catch(() => null);
|
|
39
|
+
if (!info)
|
|
40
|
+
throw new Error(`File not found: ${path}`);
|
|
41
|
+
if (info.isDirectory())
|
|
42
|
+
throw new Error(`"${path}" is a directory — use local_ls.`);
|
|
43
|
+
if (info.size > MAX_READ_BYTES) {
|
|
44
|
+
throw new Error(`File is too large (${info.size} bytes, limit ${MAX_READ_BYTES}). Use local_grep/local_shell to inspect it.`);
|
|
45
|
+
}
|
|
46
|
+
const buffer = await readFile(absolute);
|
|
47
|
+
if (isBinaryBuffer(buffer))
|
|
48
|
+
throw new Error(`"${path}" appears to be a binary file.`);
|
|
49
|
+
const lines = splitLines(buffer.toString("utf-8"));
|
|
50
|
+
const start = Math.min(offset - 1, lines.length);
|
|
51
|
+
const selected = lines.slice(start, start + limit);
|
|
52
|
+
const numbered = selected.map((line, i) => `${start + i + 1}: ${line}`).join("\n");
|
|
53
|
+
const end = start + selected.length;
|
|
54
|
+
const footer = end < lines.length ? `\n… (file has ${lines.length} lines; showed ${start + 1}–${end}; use offset/limit to see more)` : "";
|
|
55
|
+
// An out-of-range offset on a non-empty file is not an empty file — the
|
|
56
|
+
// model would conclude the file has no content.
|
|
57
|
+
const emptyNote = lines.length === 0 ? "(empty file)" : "(no lines in that range)";
|
|
58
|
+
return (numbered || emptyNote) + footer;
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const writeTool = defineTool({
|
|
62
|
+
name: "local_write",
|
|
63
|
+
description: "Create or overwrite a file with the given content, creating parent directories as needed. Set append:true to append instead.",
|
|
64
|
+
parameters: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
path: { type: "string", description: "File path (relative to the workspace)." },
|
|
68
|
+
content: { type: "string", description: "Full file content to write." },
|
|
69
|
+
append: { type: "boolean", description: "Append instead of overwriting (default false)." },
|
|
70
|
+
},
|
|
71
|
+
required: ["path", "content"],
|
|
72
|
+
},
|
|
73
|
+
mutating: true,
|
|
74
|
+
summarize: (args) => `write ${String(args.path ?? "")}`,
|
|
75
|
+
run: async (args, ctx) => {
|
|
76
|
+
const path = requireString(args, "path");
|
|
77
|
+
if (typeof args.content !== "string") {
|
|
78
|
+
throw new Error('Invalid arguments: "content" is required and must be a string.');
|
|
79
|
+
}
|
|
80
|
+
const content = args.content;
|
|
81
|
+
const append = optionalBoolean(args, "append") ?? false;
|
|
82
|
+
const absolute = await resolveToolPath(path, ctx);
|
|
83
|
+
const existed = await stat(absolute).then((s) => s.isFile()).catch(() => false);
|
|
84
|
+
if (append && !existed)
|
|
85
|
+
throw new Error(`Cannot append: ${path} does not exist.`);
|
|
86
|
+
if (append) {
|
|
87
|
+
// Same cap as local_read/local_edit: appending to a huge existing file
|
|
88
|
+
// would otherwise read it all into memory before the write.
|
|
89
|
+
const size = await stat(absolute).then((s) => s.size).catch(() => 0);
|
|
90
|
+
if (size > MAX_READ_BYTES) {
|
|
91
|
+
throw new Error(`Cannot append: ${path} is too large (${size} bytes, limit ${MAX_READ_BYTES}).`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
95
|
+
if (append) {
|
|
96
|
+
const previous = await readFile(absolute, "utf-8").catch(() => "");
|
|
97
|
+
await writeFile(absolute, previous + content, "utf-8");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
await writeFile(absolute, content, "utf-8");
|
|
101
|
+
}
|
|
102
|
+
const bytes = Buffer.byteLength(content, "utf-8");
|
|
103
|
+
const verb = append ? "Appended" : existed ? "Updated" : "Created";
|
|
104
|
+
return `${verb} ${displayPath(ctx.workspace, absolute)} (${bytes} bytes).`;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
function parseEditSpecs(args) {
|
|
108
|
+
const raw = args.edits;
|
|
109
|
+
if (Array.isArray(raw)) {
|
|
110
|
+
if (raw.length === 0)
|
|
111
|
+
throw new Error("`edits` must not be empty.");
|
|
112
|
+
return raw.map((entry, index) => {
|
|
113
|
+
if (typeof entry !== "object" || entry === null)
|
|
114
|
+
throw new Error(`edits[${index}] must be an object.`);
|
|
115
|
+
const e = entry;
|
|
116
|
+
if (typeof e.old_string !== "string" || e.old_string.length === 0) {
|
|
117
|
+
throw new Error(`edits[${index}].old_string must be a non-empty string.`);
|
|
118
|
+
}
|
|
119
|
+
if (typeof e.new_string !== "string")
|
|
120
|
+
throw new Error(`edits[${index}].new_string must be a string.`);
|
|
121
|
+
return { old_string: e.old_string, new_string: e.new_string, replace_all: e.replace_all === true };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const oldString = requireString(args, "old_string");
|
|
125
|
+
const newString = requireString(args, "new_string");
|
|
126
|
+
if (oldString.length === 0)
|
|
127
|
+
throw new Error("`old_string` must not be empty.");
|
|
128
|
+
return [{ old_string: oldString, new_string: newString, replace_all: optionalBoolean(args, "replace_all") === true }];
|
|
129
|
+
}
|
|
130
|
+
/** Apply one literal edit; returns replacement count or throws a precise error. */
|
|
131
|
+
function applyEdit(content, edit) {
|
|
132
|
+
const occurrences = content.split(edit.old_string).length - 1;
|
|
133
|
+
if (occurrences === 0) {
|
|
134
|
+
throw new Error("old_string not found. Make sure it matches exactly, including whitespace and indentation.");
|
|
135
|
+
}
|
|
136
|
+
if (occurrences > 1 && !edit.replace_all) {
|
|
137
|
+
throw new Error(`old_string appears ${occurrences} times. Add more surrounding context or set replace_all: true.`);
|
|
138
|
+
}
|
|
139
|
+
// split/join is a literal replacement — String.replace would interpret $&/$1
|
|
140
|
+
// sequences in new_string and silently corrupt the file.
|
|
141
|
+
return { content: content.split(edit.old_string).join(edit.new_string), replacements: edit.replace_all ? occurrences : 1 };
|
|
142
|
+
}
|
|
143
|
+
const editTool = defineTool({
|
|
144
|
+
name: "local_edit",
|
|
145
|
+
description: "Edit a file by exact string replacement. old_string must be unique unless replace_all is true. Multiple edits can be sent in `edits`, applied in order. Returns a unified diff.",
|
|
146
|
+
parameters: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
path: { type: "string", description: "File path (relative to the workspace)." },
|
|
150
|
+
old_string: { type: "string", description: "Exact text to replace." },
|
|
151
|
+
new_string: { type: "string", description: "Replacement text." },
|
|
152
|
+
replace_all: { type: "boolean", description: "Replace every occurrence." },
|
|
153
|
+
edits: {
|
|
154
|
+
type: "array",
|
|
155
|
+
description: "Batch form: [{old_string, new_string, replace_all?}] applied in order.",
|
|
156
|
+
items: { type: "object" },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
required: ["path"],
|
|
160
|
+
},
|
|
161
|
+
mutating: true,
|
|
162
|
+
summarize: (args) => `edit ${String(args.path ?? "")}`,
|
|
163
|
+
run: async (args, ctx) => {
|
|
164
|
+
const path = requireString(args, "path");
|
|
165
|
+
const specs = parseEditSpecs(args);
|
|
166
|
+
const absolute = await resolveToolPath(path, ctx);
|
|
167
|
+
const info = await stat(absolute).catch(() => null);
|
|
168
|
+
if (!info)
|
|
169
|
+
throw new Error(`File not found: ${path}`);
|
|
170
|
+
// Same guard as local_read: without it an edit pointed at a huge file (a
|
|
171
|
+
// log, a data dump) would read it all into memory and then amplify it
|
|
172
|
+
// through applyEdit's split/join.
|
|
173
|
+
if (info.size > MAX_READ_BYTES) {
|
|
174
|
+
throw new Error(`File is too large (${info.size} bytes, limit ${MAX_READ_BYTES}). Use local_grep/local_shell to edit it.`);
|
|
175
|
+
}
|
|
176
|
+
// Same guard as local_read: a string edit against a binary file would
|
|
177
|
+
// decode it lossily and write the re-encoded (corrupted) bytes back.
|
|
178
|
+
const buffer = await readFile(absolute).catch(() => {
|
|
179
|
+
throw new Error(`File not found: ${path}`);
|
|
180
|
+
});
|
|
181
|
+
if (isBinaryBuffer(buffer))
|
|
182
|
+
throw new Error(`"${path}" appears to be a binary file.`);
|
|
183
|
+
const original = buffer.toString("utf-8");
|
|
184
|
+
let content = original;
|
|
185
|
+
let totalReplacements = 0;
|
|
186
|
+
const errors = [];
|
|
187
|
+
for (let i = 0; i < specs.length; i++) {
|
|
188
|
+
try {
|
|
189
|
+
const result = applyEdit(content, specs[i]);
|
|
190
|
+
content = result.content;
|
|
191
|
+
totalReplacements += result.replacements;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
errors.push(`edit ${i + 1}: ${err instanceof Error ? err.message : String(err)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (totalReplacements === 0) {
|
|
198
|
+
throw new Error(errors.length ? `All ${specs.length} edits failed:\n${errors.join("\n")}` : "No edits applied.");
|
|
199
|
+
}
|
|
200
|
+
await writeFile(absolute, content, "utf-8");
|
|
201
|
+
const diff = generateDiff(displayPath(ctx.workspace, absolute), original, content);
|
|
202
|
+
const errorBlock = errors.length ? `\nERROR:\n${errors.join("\n")}` : "";
|
|
203
|
+
return `Applied ${totalReplacements} replacement(s) in ${displayPath(ctx.workspace, absolute)}.${errorBlock}\n${diff}`;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
const lsTool = defineTool({
|
|
207
|
+
name: "local_ls",
|
|
208
|
+
description: "List a directory (non-recursive by default). Directories are suffixed with /.",
|
|
209
|
+
parameters: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
path: { type: "string", description: "Directory path (default: workspace root)." },
|
|
213
|
+
recursive: { type: "boolean", description: "Recurse into subdirectories (max depth 5, 1000 entries)." },
|
|
214
|
+
pattern: { type: "string", description: "Optional glob filter (e.g. *.ts)." },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
summarize: (args) => `ls ${String(args.path ?? ".")}`,
|
|
218
|
+
run: async (args, ctx) => {
|
|
219
|
+
const path = optionalString(args, "path") ?? ".";
|
|
220
|
+
const recursive = optionalBoolean(args, "recursive") ?? false;
|
|
221
|
+
const pattern = optionalString(args, "pattern");
|
|
222
|
+
const absolute = await resolveToolPath(path, ctx);
|
|
223
|
+
const info = await stat(absolute).catch(() => null);
|
|
224
|
+
if (!info)
|
|
225
|
+
throw new Error(`Directory not found: ${path}`);
|
|
226
|
+
if (!info.isDirectory())
|
|
227
|
+
throw new Error(`"${path}" is not a directory — use local_read.`);
|
|
228
|
+
const maxEntries = 1000;
|
|
229
|
+
const lines = [];
|
|
230
|
+
let truncated = false;
|
|
231
|
+
async function walk(dir, depth) {
|
|
232
|
+
if (truncated)
|
|
233
|
+
return;
|
|
234
|
+
// One unreadable subdirectory (permissions, corruption) skips that
|
|
235
|
+
// subtree — same tolerance as local_find/local_grep — instead of
|
|
236
|
+
// failing the whole listing.
|
|
237
|
+
let entries;
|
|
238
|
+
try {
|
|
239
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
entries.sort((a, b) => {
|
|
245
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
246
|
+
return a.isDirectory() ? -1 : 1;
|
|
247
|
+
return a.name.localeCompare(b.name);
|
|
248
|
+
});
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
if (lines.length >= maxEntries) {
|
|
251
|
+
truncated = true;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const child = join(dir, entry.name);
|
|
255
|
+
const rel = relative(absolute, child) || entry.name;
|
|
256
|
+
if (entry.isDirectory()) {
|
|
257
|
+
// Directories are structural: always shown and always traversed when
|
|
258
|
+
// recursive, regardless of the file pattern.
|
|
259
|
+
lines.push(`${rel}/`);
|
|
260
|
+
if (recursive && depth < 5)
|
|
261
|
+
await walk(child, depth + 1);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (pattern && !matchGlob(pattern, rel) && !matchGlob(pattern, entry.name))
|
|
265
|
+
continue;
|
|
266
|
+
const size = await stat(child).then((s) => s.size).catch(() => 0);
|
|
267
|
+
lines.push(recursive ? `${rel} (${size} bytes)` : `${entry.name} (${size} bytes)`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await walk(absolute, 0);
|
|
271
|
+
if (lines.length === 0)
|
|
272
|
+
return "(empty directory)";
|
|
273
|
+
return lines.join("\n") + (truncated ? `\n… (truncated at ${maxEntries} entries)` : "");
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
export const fsTools = [readTool, writeTool, editTool, lsTool];
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Minimal glob matching (*, ?, **, {a,b}) for find/ls/grep --include.
|
|
3
|
+
// No dependency — pi-tui pulls in `marked` only, and a full glob library is
|
|
4
|
+
// overkill for workspace-relative matching.
|
|
5
|
+
//
|
|
6
|
+
// Matching is a memoized walk over (pattern, path) — NOT a compiled RegExp.
|
|
7
|
+
// The regex translation this replaced was ambiguous by construction: `**/`
|
|
8
|
+
// became `(?:.*/)?`, so a run of them backtracked exponentially and a
|
|
9
|
+
// model-supplied pattern like "**/".repeat(14) + "x" froze the single-threaded
|
|
10
|
+
// CLI for minutes (local_find/local_ls need no approval). The walk is
|
|
11
|
+
// polynomial with no backtracking blowup.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Index of the `}` closing the group opened at `open`, tracking depth — a
|
|
15
|
+
* plain indexOf would stop at a NESTED close (`{a,{b,c}}`) and leave the outer
|
|
16
|
+
* brace to be escaped into a literal, silently matching the wrong strings.
|
|
17
|
+
* Returns -1 when the group is never closed.
|
|
18
|
+
*/
|
|
19
|
+
function matchingBrace(pattern, open) {
|
|
20
|
+
let depth = 0;
|
|
21
|
+
for (let i = open; i < pattern.length; i++) {
|
|
22
|
+
if (pattern[i] === "{")
|
|
23
|
+
depth++;
|
|
24
|
+
else if (pattern[i] === "}" && --depth === 0)
|
|
25
|
+
return i;
|
|
26
|
+
}
|
|
27
|
+
return -1;
|
|
28
|
+
}
|
|
29
|
+
/** Split on top-level commas only — a nested group's commas belong to it. */
|
|
30
|
+
function splitAlternatives(body) {
|
|
31
|
+
const parts = [];
|
|
32
|
+
let depth = 0;
|
|
33
|
+
let start = 0;
|
|
34
|
+
for (let i = 0; i < body.length; i++) {
|
|
35
|
+
const ch = body[i];
|
|
36
|
+
if (ch === "{")
|
|
37
|
+
depth++;
|
|
38
|
+
else if (ch === "}")
|
|
39
|
+
depth = Math.max(0, depth - 1);
|
|
40
|
+
else if (ch === "," && depth === 0) {
|
|
41
|
+
parts.push(body.slice(start, i));
|
|
42
|
+
start = i + 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
parts.push(body.slice(start));
|
|
46
|
+
return parts;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Match one pattern against one path with a memoized walk. Brace groups are
|
|
50
|
+
* tried in place (each alternative is matched as `alternative + suffix`), so
|
|
51
|
+
* nested/combined braces never expand into a pattern list.
|
|
52
|
+
*/
|
|
53
|
+
function matchPattern(pattern, path) {
|
|
54
|
+
const n = path.length;
|
|
55
|
+
const memos = new Map();
|
|
56
|
+
function match(pat, pi, si) {
|
|
57
|
+
let memo = memos.get(pat);
|
|
58
|
+
if (!memo) {
|
|
59
|
+
memo = new Map();
|
|
60
|
+
memos.set(pat, memo);
|
|
61
|
+
}
|
|
62
|
+
const key = pi * (n + 1) + si;
|
|
63
|
+
const hit = memo.get(key);
|
|
64
|
+
if (hit !== undefined)
|
|
65
|
+
return hit;
|
|
66
|
+
let result;
|
|
67
|
+
if (pi >= pat.length) {
|
|
68
|
+
result = si >= n;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const ch = pat[pi];
|
|
72
|
+
if (ch === "*") {
|
|
73
|
+
if (pat[pi + 1] === "*") {
|
|
74
|
+
if (pat[pi + 2] === "/") {
|
|
75
|
+
// `**/`: zero or more directories — stop now, or consume one
|
|
76
|
+
// segment (up to and including its `/`) and continue. Consuming a
|
|
77
|
+
// whole segment at a time is what keeps the token from stopping
|
|
78
|
+
// mid-name (`**` + `/a` must not match "ba").
|
|
79
|
+
result = match(pat, pi + 3, si);
|
|
80
|
+
if (!result) {
|
|
81
|
+
let k = si;
|
|
82
|
+
while (k < n && path[k] !== "/")
|
|
83
|
+
k++;
|
|
84
|
+
result = k < n && match(pat, pi, k + 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// `**`: anything, slashes included.
|
|
89
|
+
result = match(pat, pi + 2, si) || (si < n && match(pat, pi, si + 1));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// `*`: anything but a slash.
|
|
94
|
+
result = match(pat, pi + 1, si) || (si < n && path[si] !== "/" && match(pat, pi, si + 1));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (ch === "?") {
|
|
98
|
+
result = si < n && path[si] !== "/" && match(pat, pi + 1, si + 1);
|
|
99
|
+
}
|
|
100
|
+
else if (ch === "{") {
|
|
101
|
+
const close = matchingBrace(pat, pi);
|
|
102
|
+
if (close < 0) {
|
|
103
|
+
result = si < n && path[si] === "{" && match(pat, pi + 1, si + 1);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const suffix = pat.slice(close + 1);
|
|
107
|
+
result = splitAlternatives(pat.slice(pi + 1, close)).some((alt) => match(alt + suffix, 0, si));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
result = si < n && path[si] === ch && match(pat, pi + 1, si + 1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
memo.set(key, result);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
return match(pattern, 0, 0);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Match a glob against a path. Patterns without "/" match the basename;
|
|
121
|
+
* patterns with "/" match the full path (use a double-star segment to match
|
|
122
|
+
* any depth).
|
|
123
|
+
*/
|
|
124
|
+
export function matchGlob(pattern, path) {
|
|
125
|
+
const normalized = path.replace(/\\/g, "/");
|
|
126
|
+
if (!pattern.includes("/")) {
|
|
127
|
+
const base = normalized.slice(normalized.lastIndexOf("/") + 1);
|
|
128
|
+
return matchPattern(pattern, base);
|
|
129
|
+
}
|
|
130
|
+
return matchPattern(pattern, normalized);
|
|
131
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// local_grep — ripgrep-first with a pure-JS fallback.
|
|
3
|
+
//
|
|
4
|
+
// rg's --json output is parsed; SKIP_DIRS become negative --glob patterns so
|
|
5
|
+
// the same trees are excluded in both modes. Capped at 100 matches either way.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { defineTool, optionalString, requireString } from "./types.js";
|
|
10
|
+
import { resolveToolPath } from "./paths.js";
|
|
11
|
+
import { spawnProcess, which } from "../runtime.js";
|
|
12
|
+
const MAX_MATCHES = 100;
|
|
13
|
+
/**
|
|
14
|
+
* Wall-clock budget for the JS fallback walk. The pattern is LLM-authored and
|
|
15
|
+
* compiled with `new RegExp` — a catastrophic-backtracking pattern would
|
|
16
|
+
* otherwise pin the single-threaded process inside `pattern.test()` with
|
|
17
|
+
* no abort able to interrupt it. The budget bounds the aggregate; a single
|
|
18
|
+
* pathological match on one huge line can still overshoot, but the window
|
|
19
|
+
* shrinks from unbounded to one line.
|
|
20
|
+
*/
|
|
21
|
+
const JS_SEARCH_BUDGET_MS = 10_000;
|
|
22
|
+
export const SKIP_DIRS = new Set([
|
|
23
|
+
"node_modules", ".git", ".svn", ".hg", "dist", "build", ".next", ".cache",
|
|
24
|
+
".turbo", ".vercel", "coverage", "__pycache__", ".venv", "venv", ".bun", ".deno",
|
|
25
|
+
]);
|
|
26
|
+
function formatMatches(matches, workspace) {
|
|
27
|
+
if (matches.length === 0)
|
|
28
|
+
return "No matches found.";
|
|
29
|
+
const lines = matches.map((m) => `${relative(workspace, m.path) || m.path}:${m.line}: ${m.content}`);
|
|
30
|
+
const truncated = matches.length >= MAX_MATCHES;
|
|
31
|
+
return lines.join("\n") + (truncated ? `\n… (results capped at ${MAX_MATCHES} matches)` : "");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Search with ripgrep when available. Returns null when rg is unavailable.
|
|
35
|
+
*
|
|
36
|
+
* The cap is enforced HERE, on the parse: rg's `--max-count` is per FILE, so
|
|
37
|
+
* on a large tree it can still produce a huge JSON stream. The output is read
|
|
38
|
+
* incrementally and rg is killed as soon as MAX_MATCHES lands, so neither the
|
|
39
|
+
* buffer nor the child outlives the cap.
|
|
40
|
+
*/
|
|
41
|
+
async function searchWithRipgrep(pattern, searchPath, include, signal) {
|
|
42
|
+
const rg = which("rg");
|
|
43
|
+
if (!rg)
|
|
44
|
+
return null;
|
|
45
|
+
// --max-count bounds the per-file work rg does; the global cap is ours.
|
|
46
|
+
const args = ["--json", "--max-count", String(MAX_MATCHES), "--no-ignore", "-e", pattern];
|
|
47
|
+
if (include)
|
|
48
|
+
args.push("--glob", include);
|
|
49
|
+
for (const dir of SKIP_DIRS)
|
|
50
|
+
args.push("--glob", `!**/${dir}/**`);
|
|
51
|
+
args.push(searchPath);
|
|
52
|
+
try {
|
|
53
|
+
const proc = spawnProcess([rg, ...args], { stdout: "pipe", stderr: "ignore" });
|
|
54
|
+
const onAbort = () => { try {
|
|
55
|
+
proc.kill();
|
|
56
|
+
}
|
|
57
|
+
catch { /* gone */ } };
|
|
58
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
59
|
+
try {
|
|
60
|
+
const matches = [];
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let buffer = "";
|
|
63
|
+
let capped = false;
|
|
64
|
+
const consume = (line) => {
|
|
65
|
+
if (!line)
|
|
66
|
+
return;
|
|
67
|
+
let frame;
|
|
68
|
+
try {
|
|
69
|
+
frame = JSON.parse(line);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (frame.type !== "match" || !frame.data?.path?.text)
|
|
75
|
+
return;
|
|
76
|
+
matches.push({
|
|
77
|
+
path: frame.data.path.text,
|
|
78
|
+
line: frame.data.line_number ?? 0,
|
|
79
|
+
content: (frame.data.lines?.text ?? "").replace(/\n$/, ""),
|
|
80
|
+
});
|
|
81
|
+
if (matches.length >= MAX_MATCHES)
|
|
82
|
+
capped = true;
|
|
83
|
+
};
|
|
84
|
+
for await (const chunk of proc.stdout) {
|
|
85
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
86
|
+
let idx;
|
|
87
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
88
|
+
consume(buffer.slice(0, idx));
|
|
89
|
+
buffer = buffer.slice(idx + 1);
|
|
90
|
+
if (capped)
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (capped)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (capped) {
|
|
97
|
+
// Enough results: stop rg rather than draining (and buffering) the
|
|
98
|
+
// rest of a large tree's output.
|
|
99
|
+
try {
|
|
100
|
+
proc.kill();
|
|
101
|
+
}
|
|
102
|
+
catch { /* gone */ }
|
|
103
|
+
await proc.exited;
|
|
104
|
+
return matches;
|
|
105
|
+
}
|
|
106
|
+
if (buffer)
|
|
107
|
+
consume(buffer);
|
|
108
|
+
const code = await proc.exited;
|
|
109
|
+
// rg exits 1 for "no matches" (an answer); any other non-zero exit is a
|
|
110
|
+
// failure — e.g. a JS-valid pattern (backreference, lookahead) its engine
|
|
111
|
+
// rejects — and must fall back to the JS walk rather than read as "none".
|
|
112
|
+
if (code > 1)
|
|
113
|
+
return null;
|
|
114
|
+
return matches;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
signal?.removeEventListener("abort", onAbort);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null; // fall back to the JS walk
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function searchJs(pattern, root, signal) {
|
|
125
|
+
const matches = [];
|
|
126
|
+
const deadline = Date.now() + JS_SEARCH_BUDGET_MS;
|
|
127
|
+
let timedOut = false;
|
|
128
|
+
const overBudget = () => {
|
|
129
|
+
if (Date.now() > deadline)
|
|
130
|
+
timedOut = true;
|
|
131
|
+
return timedOut;
|
|
132
|
+
};
|
|
133
|
+
async function walk(dir, depth) {
|
|
134
|
+
if (matches.length >= MAX_MATCHES || depth > 12 || signal?.aborted || overBudget())
|
|
135
|
+
return;
|
|
136
|
+
let entries;
|
|
137
|
+
try {
|
|
138
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const entry of entries) {
|
|
144
|
+
if (matches.length >= MAX_MATCHES || signal?.aborted || overBudget())
|
|
145
|
+
return;
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
|
|
148
|
+
continue;
|
|
149
|
+
await walk(join(dir, entry.name), depth + 1);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!entry.isFile())
|
|
153
|
+
continue;
|
|
154
|
+
const full = join(dir, entry.name);
|
|
155
|
+
const info = await stat(full).catch(() => null);
|
|
156
|
+
if (!info || info.size > 2 * 1024 * 1024)
|
|
157
|
+
continue;
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
text = await readFile(full, "utf-8");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (text.includes("\0"))
|
|
166
|
+
continue;
|
|
167
|
+
const lines = text.split("\n");
|
|
168
|
+
for (let i = 0; i < lines.length && matches.length < MAX_MATCHES; i++) {
|
|
169
|
+
// Abort and the budget must hold inside the per-line loop too — a
|
|
170
|
+
// large file must not run to its last line after the user pressed
|
|
171
|
+
// stop or the budget expired.
|
|
172
|
+
if ((i & 31) === 0 && (signal?.aborted || overBudget()))
|
|
173
|
+
return;
|
|
174
|
+
if (pattern.test(lines[i])) {
|
|
175
|
+
matches.push({ path: full, line: i + 1, content: lines[i] });
|
|
176
|
+
}
|
|
177
|
+
pattern.lastIndex = 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await walk(root, 0);
|
|
182
|
+
return { matches, timedOut };
|
|
183
|
+
}
|
|
184
|
+
const grepTool = defineTool({
|
|
185
|
+
name: "local_grep",
|
|
186
|
+
description: "Search file contents with a regular expression (ripgrep when available). Returns file:line: content, capped at 100 matches. Common build/VCS directories are skipped.",
|
|
187
|
+
parameters: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
pattern: { type: "string", description: "JavaScript regular expression." },
|
|
191
|
+
path: { type: "string", description: "Directory or file to search (default: workspace root)." },
|
|
192
|
+
include: { type: "string", description: "Glob filter for file names (e.g. \"*.ts\")." },
|
|
193
|
+
},
|
|
194
|
+
required: ["pattern"],
|
|
195
|
+
},
|
|
196
|
+
summarize: (args) => `grep ${String(args.pattern ?? "")}`,
|
|
197
|
+
run: async (args, ctx) => {
|
|
198
|
+
const patternText = requireString(args, "pattern");
|
|
199
|
+
let pattern;
|
|
200
|
+
try {
|
|
201
|
+
pattern = new RegExp(patternText);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
throw new Error(`Invalid regular expression: ${err instanceof Error ? err.message : String(err)}`);
|
|
205
|
+
}
|
|
206
|
+
const path = optionalString(args, "path") ?? ".";
|
|
207
|
+
const include = optionalString(args, "include");
|
|
208
|
+
const absolute = await resolveToolPath(path, ctx);
|
|
209
|
+
// Already stopped before the search began — no work, and no misleading
|
|
210
|
+
// "No matches found." for a search that never ran.
|
|
211
|
+
if (ctx.signal?.aborted)
|
|
212
|
+
return "[search aborted]";
|
|
213
|
+
const rgMatches = await searchWithRipgrep(patternText, absolute, include, ctx.signal);
|
|
214
|
+
if (rgMatches) {
|
|
215
|
+
// rg honors --glob; the JS fallback does not implement include — acceptable parity trade-off.
|
|
216
|
+
return formatMatches(rgMatches, ctx.workspace);
|
|
217
|
+
}
|
|
218
|
+
// A killed rg (the user pressed stop) also returns null. Falling through
|
|
219
|
+
// to the JS walk would report "No matches found." for an interrupted
|
|
220
|
+
// search — say it was aborted instead (same rule as the shell tool).
|
|
221
|
+
if (ctx.signal?.aborted)
|
|
222
|
+
return "[search aborted]";
|
|
223
|
+
const { matches, timedOut } = await searchJs(pattern, absolute, ctx.signal);
|
|
224
|
+
const body = formatMatches(matches, ctx.workspace);
|
|
225
|
+
return timedOut ? `${body}\n[search stopped: exceeded ${JS_SEARCH_BUDGET_MS}ms time budget — refine the pattern or narrow the path]` : body;
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
export const grepToolInstance = grepTool;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare class PathOutsideWorkspaceError extends Error {
|
|
2
|
+
constructor(input: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function isInside(root: string, candidate: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* True when the path (or a symlink along it) escapes the workspace. Lexical
|
|
7
|
+
* containment alone is not enough: a symlink inside the workspace can point
|
|
8
|
+
* outside it, so every existing prefix is realpath-checked too.
|
|
9
|
+
*/
|
|
10
|
+
export declare function pathEscapesWorkspace(input: string, workspace: string): Promise<boolean>;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a tool-supplied path to an absolute path inside `workspace`.
|
|
13
|
+
* Throws PathOutsideWorkspaceError when the path (or a symlink along it)
|
|
14
|
+
* escapes the workspace and `allowOutside` is false.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveToolPath(input: string, opts: {
|
|
17
|
+
workspace: string;
|
|
18
|
+
allowOutside: boolean;
|
|
19
|
+
}): Promise<string>;
|
|
20
|
+
/** Workspace-relative display path (falls back to the absolute path). */
|
|
21
|
+
export declare function displayPath(workspace: string, absolute: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a tool request should be treated as reaching outside the workspace
|
|
24
|
+
* (and therefore asked about even in AUTO mode). Path-less tools like shell
|
|
25
|
+
* cannot be judged statically and are left to the approval mode.
|
|
26
|
+
*/
|
|
27
|
+
export declare function toolRequestOutsideWorkspace(name: string, args: Record<string, unknown>, workspace: string): Promise<boolean>;
|