@nanhara/hara 0.121.0 → 0.122.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 +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/tools/search.js
CHANGED
|
@@ -1,39 +1,442 @@
|
|
|
1
1
|
// Search/listing tools — grep (regex across files), glob (path patterns), ls (one directory).
|
|
2
2
|
// All read-only (kind: "read"), so they never hit the approval gate and run in parallel.
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve, join, relative, sep } from "node:path";
|
|
5
6
|
import { registerTool } from "./registry.js";
|
|
6
|
-
import {
|
|
7
|
+
import { IGNORE_DIRS, walkFiles } from "../fs-walk.js";
|
|
7
8
|
const MAX_OUT = 60_000;
|
|
8
9
|
const MAX_MATCHES = 300;
|
|
9
10
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const GREP_TIMEOUT_MS = 5_000;
|
|
12
|
+
const MAX_PATTERN_CHARS = 4_096;
|
|
13
|
+
const MAX_GLOB_CHARS = 256;
|
|
14
|
+
const GLOB_MATCH_BUDGET_MS = 250;
|
|
10
15
|
const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
11
16
|
const absOf = (p, cwd) => (p ? (isAbsolute(p) ? p : resolve(cwd, p)) : cwd);
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
/** Match one path segment with *, ? using the classic greedy wildcard algorithm. No RegExp is built
|
|
18
|
+
* from model input, so repeated wildcard constructs cannot trigger regex backtracking. */
|
|
19
|
+
function matchGlobSegment(pattern, value) {
|
|
20
|
+
let patternAt = 0;
|
|
21
|
+
let valueAt = 0;
|
|
22
|
+
let starAt = -1;
|
|
23
|
+
let starValueAt = -1;
|
|
24
|
+
while (valueAt < value.length) {
|
|
25
|
+
if (patternAt < pattern.length && (pattern[patternAt] === "?" || pattern[patternAt] === value[valueAt])) {
|
|
26
|
+
patternAt++;
|
|
27
|
+
valueAt++;
|
|
28
|
+
}
|
|
29
|
+
else if (patternAt < pattern.length && pattern[patternAt] === "*") {
|
|
30
|
+
while (pattern[patternAt] === "*")
|
|
31
|
+
patternAt++;
|
|
32
|
+
starAt = patternAt;
|
|
33
|
+
starValueAt = valueAt;
|
|
34
|
+
}
|
|
35
|
+
else if (starAt >= 0) {
|
|
36
|
+
patternAt = starAt;
|
|
37
|
+
valueAt = ++starValueAt;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
while (pattern[patternAt] === "*")
|
|
44
|
+
patternAt++;
|
|
45
|
+
return patternAt === pattern.length;
|
|
46
|
+
}
|
|
47
|
+
/** Standard path glob semantics: ** as a whole segment crosses directories, while * and ? stay within
|
|
48
|
+
* one segment. The greedy ** checkpoint consumes path segments monotonically and remains bounded. */
|
|
49
|
+
function matchesGlob(pattern, value) {
|
|
50
|
+
const patternParts = pattern.split("/");
|
|
51
|
+
const valueParts = value.split("/");
|
|
52
|
+
let patternAt = 0;
|
|
53
|
+
let valueAt = 0;
|
|
54
|
+
let globstarAt = -1;
|
|
55
|
+
let globstarValueAt = -1;
|
|
56
|
+
while (valueAt < valueParts.length) {
|
|
57
|
+
if (patternAt < patternParts.length && patternParts[patternAt] !== "**" && matchGlobSegment(patternParts[patternAt], valueParts[valueAt])) {
|
|
58
|
+
patternAt++;
|
|
59
|
+
valueAt++;
|
|
60
|
+
}
|
|
61
|
+
else if (patternParts[patternAt] === "**") {
|
|
62
|
+
while (patternParts[patternAt] === "**")
|
|
63
|
+
patternAt++;
|
|
64
|
+
globstarAt = patternAt;
|
|
65
|
+
globstarValueAt = valueAt;
|
|
66
|
+
}
|
|
67
|
+
else if (globstarAt >= 0) {
|
|
68
|
+
patternAt = globstarAt;
|
|
69
|
+
valueAt = ++globstarValueAt;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
while (patternParts[patternAt] === "**")
|
|
76
|
+
patternAt++;
|
|
77
|
+
return patternAt === patternParts.length;
|
|
78
|
+
}
|
|
79
|
+
/** Run regex matching outside the agent process in ripgrep's linear-time regex engine. The subprocess is
|
|
80
|
+
* killed at a hard deadline and once enough output has arrived, so neither a pathological pattern nor a
|
|
81
|
+
* huge repository can wedge the CLI/Serve event loop. */
|
|
82
|
+
function runRipgrep(pattern, root, isFile, cwd, glob, ignoreCase) {
|
|
83
|
+
return new Promise((resolveResult) => {
|
|
84
|
+
const runCwd = isFile ? dirname(root) : root;
|
|
85
|
+
const target = isFile ? basename(root) : ".";
|
|
86
|
+
const args = [
|
|
87
|
+
"--json",
|
|
88
|
+
"--line-number",
|
|
89
|
+
"--color", "never",
|
|
90
|
+
"--no-ignore",
|
|
91
|
+
"--hidden",
|
|
92
|
+
"--threads", "1",
|
|
93
|
+
"--max-filesize", String(MAX_FILE_BYTES),
|
|
94
|
+
// Never stream a multi-megabyte minified line through JSON merely to slice it to 300 chars below.
|
|
95
|
+
"--max-columns", "1000",
|
|
96
|
+
"--max-columns-preview",
|
|
97
|
+
];
|
|
98
|
+
for (const ignored of IGNORE_DIRS)
|
|
99
|
+
args.push("--glob", `!**/${ignored}/**`);
|
|
100
|
+
if (glob)
|
|
101
|
+
args.push("--glob", glob);
|
|
102
|
+
if (ignoreCase)
|
|
103
|
+
args.push("--ignore-case");
|
|
104
|
+
args.push("--", pattern, target);
|
|
105
|
+
const child = spawn("rg", args, { cwd: runCwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
106
|
+
const lines = [];
|
|
107
|
+
let matches = 0;
|
|
108
|
+
let scanned = 0;
|
|
109
|
+
let outputChars = 0;
|
|
110
|
+
let pending = "";
|
|
111
|
+
let stderr = "";
|
|
112
|
+
let settled = false;
|
|
113
|
+
let timedOut = false;
|
|
114
|
+
let truncated = false;
|
|
115
|
+
let timer;
|
|
116
|
+
const finish = (result) => {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
if (timer)
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolveResult(result);
|
|
123
|
+
};
|
|
124
|
+
const stopForLimit = () => {
|
|
125
|
+
if (truncated)
|
|
126
|
+
return;
|
|
127
|
+
truncated = true;
|
|
128
|
+
child.kill("SIGKILL");
|
|
129
|
+
};
|
|
130
|
+
const consume = (line) => {
|
|
131
|
+
if (!line)
|
|
132
|
+
return;
|
|
133
|
+
let event;
|
|
134
|
+
try {
|
|
135
|
+
event = JSON.parse(line);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
24
139
|
}
|
|
25
|
-
|
|
26
|
-
|
|
140
|
+
if (event?.type === "summary") {
|
|
141
|
+
const searches = Number(event?.data?.stats?.searches);
|
|
142
|
+
if (Number.isFinite(searches))
|
|
143
|
+
scanned = searches;
|
|
144
|
+
return;
|
|
27
145
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
146
|
+
if (event?.type !== "match" || matches >= MAX_MATCHES)
|
|
147
|
+
return;
|
|
148
|
+
const pathText = event?.data?.path?.text;
|
|
149
|
+
const lineNo = Number(event?.data?.line_number);
|
|
150
|
+
const content = event?.data?.lines?.text;
|
|
151
|
+
if (typeof pathText !== "string" || !Number.isFinite(lineNo) || typeof content !== "string")
|
|
152
|
+
return;
|
|
153
|
+
const absolute = resolve(runCwd, pathText);
|
|
154
|
+
const shown = toPosix(relative(cwd, absolute)) || toPosix(relative(root, absolute)) || basename(absolute);
|
|
155
|
+
const rendered = `${shown}:${lineNo}: ${content.replace(/\r?\n$/, "").trim().slice(0, 300)}`;
|
|
156
|
+
lines.push(rendered);
|
|
157
|
+
matches++;
|
|
158
|
+
outputChars += rendered.length + 1;
|
|
159
|
+
if (matches >= MAX_MATCHES || outputChars >= MAX_OUT)
|
|
160
|
+
stopForLimit();
|
|
161
|
+
};
|
|
162
|
+
child.stdout.setEncoding("utf8");
|
|
163
|
+
child.stdout.on("data", (chunk) => {
|
|
164
|
+
pending += chunk;
|
|
165
|
+
// JSON events are newline-delimited. max-columns keeps normal match events small; fail boundedly if
|
|
166
|
+
// a future rg version emits an unexpectedly giant unterminated event.
|
|
167
|
+
if (pending.length > 2 * 1024 * 1024 && !pending.includes("\n"))
|
|
168
|
+
return stopForLimit();
|
|
169
|
+
let newline;
|
|
170
|
+
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
171
|
+
consume(pending.slice(0, newline));
|
|
172
|
+
pending = pending.slice(newline + 1);
|
|
173
|
+
if (truncated)
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
child.stderr.setEncoding("utf8");
|
|
178
|
+
child.stderr.on("data", (chunk) => {
|
|
179
|
+
stderr = (stderr + chunk).slice(-4_000);
|
|
180
|
+
});
|
|
181
|
+
child.once("error", (error) => {
|
|
182
|
+
finish({
|
|
183
|
+
kind: error.code === "ENOENT" ? "missing" : "error",
|
|
184
|
+
lines,
|
|
185
|
+
matches,
|
|
186
|
+
scanned,
|
|
187
|
+
truncated,
|
|
188
|
+
error: error.message,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
child.once("close", (code) => {
|
|
192
|
+
if (timedOut)
|
|
193
|
+
return finish({ kind: "timeout", lines: [], matches: 0, scanned, truncated: false });
|
|
194
|
+
if (truncated)
|
|
195
|
+
return finish({ kind: "ok", lines, matches, scanned, truncated: true });
|
|
196
|
+
if (code === 0 || code === 1)
|
|
197
|
+
return finish({ kind: "ok", lines, matches, scanned, truncated: false });
|
|
198
|
+
finish({ kind: "error", lines: [], matches: 0, scanned, truncated: false, error: stderr.trim() || `ripgrep exited ${code}` });
|
|
199
|
+
});
|
|
200
|
+
timer = setTimeout(() => {
|
|
201
|
+
timedOut = true;
|
|
202
|
+
child.kill("SIGKILL");
|
|
203
|
+
}, GREP_TIMEOUT_MS);
|
|
204
|
+
timer.unref();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// Dependency-free grep worker for npm/Windows installations that do not have rg. Untrusted JavaScript
|
|
208
|
+
// RegExp evaluation happens only in this disposable process; the parent applies a hard wall-clock kill.
|
|
209
|
+
// The worker opens each candidate O_NONBLOCK, fstats that SAME fd, and performs a positional bounded read,
|
|
210
|
+
// so a path exchanged for a FIFO/device cannot wedge either process.
|
|
211
|
+
const NODE_GREP_SOURCE = String.raw `
|
|
212
|
+
"use strict";
|
|
213
|
+
const fs = require("node:fs");
|
|
214
|
+
const path = require("node:path");
|
|
215
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
216
|
+
let input = "";
|
|
217
|
+
let inputRejected = false;
|
|
218
|
+
function execute() {
|
|
219
|
+
function emit(value) { process.stdout.write(JSON.stringify(value)); }
|
|
220
|
+
if (inputRejected) return emit({ kind: "error", error: "grep worker input exceeded its safety limit" });
|
|
221
|
+
let cfg;
|
|
222
|
+
try { cfg = JSON.parse(input); }
|
|
223
|
+
catch (error) { return emit({ kind: "error", error: "invalid grep worker input" }); }
|
|
224
|
+
|
|
225
|
+
let expression;
|
|
226
|
+
try { expression = new RegExp(cfg.pattern, cfg.ignoreCase ? "i" : ""); }
|
|
227
|
+
catch (error) { return emit({ kind: "invalid", error: String(error && error.message || error) }); }
|
|
228
|
+
|
|
229
|
+
function matchGlobSegment(pattern, value) {
|
|
230
|
+
let patternAt = 0;
|
|
231
|
+
let valueAt = 0;
|
|
232
|
+
let starAt = -1;
|
|
233
|
+
let starValueAt = -1;
|
|
234
|
+
while (valueAt < value.length) {
|
|
235
|
+
if (patternAt < pattern.length && (pattern[patternAt] === "?" || pattern[patternAt] === value[valueAt])) {
|
|
236
|
+
patternAt++;
|
|
237
|
+
valueAt++;
|
|
238
|
+
} else if (patternAt < pattern.length && pattern[patternAt] === "*") {
|
|
239
|
+
while (pattern[patternAt] === "*") patternAt++;
|
|
240
|
+
starAt = patternAt;
|
|
241
|
+
starValueAt = valueAt;
|
|
242
|
+
} else if (starAt >= 0) {
|
|
243
|
+
patternAt = starAt;
|
|
244
|
+
valueAt = ++starValueAt;
|
|
245
|
+
} else return false;
|
|
246
|
+
}
|
|
247
|
+
while (pattern[patternAt] === "*") patternAt++;
|
|
248
|
+
return patternAt === pattern.length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function matchesGlob(pattern, value) {
|
|
252
|
+
const patternParts = pattern.split("/");
|
|
253
|
+
const valueParts = value.split("/");
|
|
254
|
+
let patternAt = 0;
|
|
255
|
+
let valueAt = 0;
|
|
256
|
+
let globstarAt = -1;
|
|
257
|
+
let globstarValueAt = -1;
|
|
258
|
+
while (valueAt < valueParts.length) {
|
|
259
|
+
if (patternAt < patternParts.length && patternParts[patternAt] !== "**" && matchGlobSegment(patternParts[patternAt], valueParts[valueAt])) {
|
|
260
|
+
patternAt++;
|
|
261
|
+
valueAt++;
|
|
262
|
+
} else if (patternParts[patternAt] === "**") {
|
|
263
|
+
while (patternParts[patternAt] === "**") patternAt++;
|
|
264
|
+
globstarAt = patternAt;
|
|
265
|
+
globstarValueAt = valueAt;
|
|
266
|
+
} else if (globstarAt >= 0) {
|
|
267
|
+
patternAt = globstarAt;
|
|
268
|
+
valueAt = ++globstarValueAt;
|
|
269
|
+
} else return false;
|
|
270
|
+
}
|
|
271
|
+
while (patternParts[patternAt] === "**") patternAt++;
|
|
272
|
+
return patternAt === patternParts.length;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const ignored = new Set(cfg.ignoreDirs);
|
|
276
|
+
const files = [];
|
|
277
|
+
if (cfg.isFile) files.push(cfg.root);
|
|
278
|
+
else {
|
|
279
|
+
const stack = [cfg.root];
|
|
280
|
+
while (stack.length && files.length < cfg.maxFiles) {
|
|
281
|
+
const dir = stack.pop();
|
|
282
|
+
let entries;
|
|
283
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
284
|
+
catch (error) { continue; }
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (files.length >= cfg.maxFiles) break;
|
|
287
|
+
const absolute = path.join(dir, entry.name);
|
|
288
|
+
if (entry.isDirectory()) {
|
|
289
|
+
if (!ignored.has(entry.name)) stack.push(absolute);
|
|
290
|
+
} else if (entry.isFile()) files.push(absolute);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const readBuffer = Buffer.allocUnsafe(cfg.maxFileBytes + 1);
|
|
296
|
+
function safeReadText(absolute) {
|
|
297
|
+
let fd;
|
|
298
|
+
try {
|
|
299
|
+
fd = fs.openSync(absolute, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
300
|
+
const info = fs.fstatSync(fd);
|
|
301
|
+
if (!info.isFile() || info.size > cfg.maxFileBytes) return null;
|
|
302
|
+
let total = 0;
|
|
303
|
+
while (total <= cfg.maxFileBytes) {
|
|
304
|
+
const count = fs.readSync(fd, readBuffer, total, cfg.maxFileBytes + 1 - total, total);
|
|
305
|
+
if (count === 0) break;
|
|
306
|
+
total += count;
|
|
307
|
+
}
|
|
308
|
+
if (total > cfg.maxFileBytes) return null;
|
|
309
|
+
const bytes = readBuffer.subarray(0, total);
|
|
310
|
+
if (bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0)) return null;
|
|
311
|
+
return bytes.toString("utf8");
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return null;
|
|
314
|
+
} finally {
|
|
315
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch (error) {}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const lines = [];
|
|
320
|
+
let matches = 0;
|
|
321
|
+
let scanned = 0;
|
|
322
|
+
let outputChars = 0;
|
|
323
|
+
let truncated = files.length >= cfg.maxFiles;
|
|
324
|
+
for (const absolute of files) {
|
|
325
|
+
if (matches >= cfg.maxMatches || outputChars >= cfg.maxOutputChars) { truncated = true; break; }
|
|
326
|
+
const relativeForGlob = path.relative(cfg.isFile ? cfg.cwd : cfg.root, absolute).split(path.sep).join("/");
|
|
327
|
+
if (cfg.glob && !matchesGlob(cfg.glob, relativeForGlob)) continue;
|
|
328
|
+
const text = safeReadText(absolute);
|
|
329
|
+
if (text === null) continue;
|
|
330
|
+
scanned++;
|
|
331
|
+
const fileLines = text.split("\n");
|
|
332
|
+
for (let lineNumber = 0; lineNumber < fileLines.length; lineNumber++) {
|
|
333
|
+
if (!expression.test(fileLines[lineNumber])) continue;
|
|
334
|
+
let shown = path.relative(cfg.cwd, absolute).split(path.sep).join("/") || path.basename(absolute);
|
|
335
|
+
if (shown.length > 1000) shown = "…/" + shown.slice(-996);
|
|
336
|
+
const rendered = shown + ":" + (lineNumber + 1) + ": " + fileLines[lineNumber].trim().slice(0, 300);
|
|
337
|
+
if (outputChars + rendered.length + 1 > cfg.maxOutputChars) { truncated = true; break; }
|
|
338
|
+
lines.push(rendered);
|
|
339
|
+
matches++;
|
|
340
|
+
outputChars += rendered.length + 1;
|
|
341
|
+
if (matches >= cfg.maxMatches) { truncated = true; break; }
|
|
35
342
|
}
|
|
36
|
-
|
|
343
|
+
}
|
|
344
|
+
emit({ kind: "ok", lines: lines, matches: matches, scanned: scanned, truncated: truncated });
|
|
345
|
+
}
|
|
346
|
+
process.stdin.setEncoding("utf8");
|
|
347
|
+
process.stdin.on("data", function (chunk) {
|
|
348
|
+
if (inputRejected) return;
|
|
349
|
+
input += chunk;
|
|
350
|
+
if (Buffer.byteLength(input, "utf8") > MAX_INPUT_BYTES) inputRejected = true;
|
|
351
|
+
});
|
|
352
|
+
process.stdin.on("end", execute);
|
|
353
|
+
`;
|
|
354
|
+
function normalizeGrepWorkerResult(parsed) {
|
|
355
|
+
if (!parsed || !["ok", "invalid", "error"].includes(String(parsed.kind)))
|
|
356
|
+
throw new Error("invalid worker response");
|
|
357
|
+
return {
|
|
358
|
+
kind: parsed.kind,
|
|
359
|
+
lines: Array.isArray(parsed.lines) ? parsed.lines.slice(0, MAX_MATCHES).map(String) : [],
|
|
360
|
+
matches: Number.isFinite(parsed.matches) ? Number(parsed.matches) : 0,
|
|
361
|
+
scanned: Number.isFinite(parsed.scanned) ? Number(parsed.scanned) : 0,
|
|
362
|
+
truncated: parsed.truncated === true,
|
|
363
|
+
error: typeof parsed.error === "string" ? parsed.error : undefined,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function runNodeGrep(pattern, root, isFile, cwd, glob, ignoreCase) {
|
|
367
|
+
const payload = JSON.stringify({
|
|
368
|
+
pattern,
|
|
369
|
+
root,
|
|
370
|
+
isFile,
|
|
371
|
+
cwd,
|
|
372
|
+
glob,
|
|
373
|
+
ignoreCase,
|
|
374
|
+
ignoreDirs: [...IGNORE_DIRS],
|
|
375
|
+
maxFiles: 8_000,
|
|
376
|
+
maxFileBytes: MAX_FILE_BYTES,
|
|
377
|
+
maxMatches: MAX_MATCHES,
|
|
378
|
+
maxOutputChars: MAX_OUT,
|
|
379
|
+
});
|
|
380
|
+
if (Buffer.byteLength(payload, "utf8") > 64 * 1024) {
|
|
381
|
+
return Promise.resolve({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: "grep input exceeds its safety limit" });
|
|
382
|
+
}
|
|
383
|
+
return new Promise((resolveResult) => {
|
|
384
|
+
const isBun = typeof process.versions.bun === "string";
|
|
385
|
+
const args = isBun ? ["-e", NODE_GREP_SOURCE] : ["--max-old-space-size=128", "-e", NODE_GREP_SOURCE];
|
|
386
|
+
// In a Bun --compile build process.execPath is the hara executable. BUN_BE_BUN makes that embedded
|
|
387
|
+
// runtime act as the Bun CLI for this isolated eval subprocess instead of recursively launching hara.
|
|
388
|
+
const env = isBun ? { ...process.env, BUN_BE_BUN: "1" } : process.env;
|
|
389
|
+
const child = spawn(process.execPath, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
|
390
|
+
let stdout = "";
|
|
391
|
+
let stderr = "";
|
|
392
|
+
let settled = false;
|
|
393
|
+
let timedOut = false;
|
|
394
|
+
let timer;
|
|
395
|
+
const finish = (result) => {
|
|
396
|
+
if (settled)
|
|
397
|
+
return;
|
|
398
|
+
settled = true;
|
|
399
|
+
if (timer)
|
|
400
|
+
clearTimeout(timer);
|
|
401
|
+
resolveResult(result);
|
|
402
|
+
};
|
|
403
|
+
child.stdout.setEncoding("utf8");
|
|
404
|
+
child.stdout.on("data", (chunk) => {
|
|
405
|
+
stdout += chunk;
|
|
406
|
+
if (stdout.length > 256 * 1024) {
|
|
407
|
+
child.kill("SIGKILL");
|
|
408
|
+
finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: "grep worker output exceeded its safety limit" });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
child.stderr.setEncoding("utf8");
|
|
412
|
+
child.stderr.on("data", (chunk) => {
|
|
413
|
+
stderr = (stderr + chunk).slice(-4_000);
|
|
414
|
+
});
|
|
415
|
+
child.stdin.on("error", () => { });
|
|
416
|
+
child.once("error", (error) => {
|
|
417
|
+
finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: error.message });
|
|
418
|
+
});
|
|
419
|
+
child.once("close", (code) => {
|
|
420
|
+
if (timedOut)
|
|
421
|
+
return finish({ kind: "timeout", lines: [], matches: 0, scanned: 0, truncated: false });
|
|
422
|
+
if (settled)
|
|
423
|
+
return;
|
|
424
|
+
if (code !== 0)
|
|
425
|
+
return finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: stderr.trim() || `grep worker exited ${code}` });
|
|
426
|
+
try {
|
|
427
|
+
finish(normalizeGrepWorkerResult(JSON.parse(stdout)));
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
finish({ kind: "error", lines: [], matches: 0, scanned: 0, truncated: false, error: error?.message ?? "invalid grep worker response" });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
timer = setTimeout(() => {
|
|
434
|
+
timedOut = true;
|
|
435
|
+
child.kill("SIGKILL");
|
|
436
|
+
}, GREP_TIMEOUT_MS);
|
|
437
|
+
timer.unref();
|
|
438
|
+
child.stdin.end(payload);
|
|
439
|
+
});
|
|
37
440
|
}
|
|
38
441
|
registerTool({
|
|
39
442
|
name: "grep",
|
|
@@ -42,7 +445,7 @@ registerTool({
|
|
|
42
445
|
input_schema: {
|
|
43
446
|
type: "object",
|
|
44
447
|
properties: {
|
|
45
|
-
pattern: { type: "string", description: "
|
|
448
|
+
pattern: { type: "string", description: "regular expression (executed in a bounded search subprocess)" },
|
|
46
449
|
path: { type: "string", description: "directory or file to search (default: cwd)" },
|
|
47
450
|
glob: { type: "string", description: "only search files whose path matches this glob (e.g. **/*.ts)" },
|
|
48
451
|
ignore_case: { type: "boolean" },
|
|
@@ -51,61 +454,48 @@ registerTool({
|
|
|
51
454
|
},
|
|
52
455
|
kind: "read",
|
|
53
456
|
async run(input, ctx) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return `Error: invalid regex: ${e.message}`;
|
|
60
|
-
}
|
|
457
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
458
|
+
if (pattern.length > MAX_PATTERN_CHARS)
|
|
459
|
+
return `Error: grep pattern exceeds ${MAX_PATTERN_CHARS} characters.`;
|
|
460
|
+
if (typeof input.glob === "string" && input.glob.length > MAX_GLOB_CHARS)
|
|
461
|
+
return `Error: grep glob exceeds ${MAX_GLOB_CHARS} characters.`;
|
|
61
462
|
const root = absOf(input.path, ctx.cwd);
|
|
62
463
|
let isFile = false;
|
|
63
464
|
try {
|
|
64
|
-
|
|
465
|
+
const info = statSync(root);
|
|
466
|
+
if (!info.isFile() && !info.isDirectory())
|
|
467
|
+
return `Error: grep path is not a regular file or directory: ${input.path ?? "."}`;
|
|
468
|
+
isFile = info.isFile();
|
|
65
469
|
}
|
|
66
470
|
catch {
|
|
67
471
|
return `Error: no such path: ${input.path ?? "."}`;
|
|
68
472
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (statSync(abs).size > MAX_FILE_BYTES)
|
|
84
|
-
continue;
|
|
85
|
-
buf = readFileSync(abs);
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (isProbablyBinary(buf))
|
|
91
|
-
continue;
|
|
92
|
-
scanned++;
|
|
93
|
-
const text = buf.toString("utf8");
|
|
94
|
-
const fileLines = text.split("\n");
|
|
95
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
96
|
-
if (re.test(fileLines[i])) {
|
|
97
|
-
lines.push(`${r}:${i + 1}: ${fileLines[i].trim().slice(0, 300)}`);
|
|
98
|
-
if (++matches >= MAX_MATCHES)
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
473
|
+
const rg = await runRipgrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true);
|
|
474
|
+
if (rg.kind === "timeout")
|
|
475
|
+
return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
|
|
476
|
+
let { lines, matches, scanned } = rg;
|
|
477
|
+
let truncated = rg.truncated;
|
|
478
|
+
if (rg.kind === "missing" || rg.kind === "error") {
|
|
479
|
+
const fallback = await runNodeGrep(pattern, root, isFile, ctx.cwd, typeof input.glob === "string" ? input.glob : undefined, input.ignore_case === true);
|
|
480
|
+
if (fallback.kind === "timeout")
|
|
481
|
+
return `Error: grep exceeded its ${GREP_TIMEOUT_MS}ms safety timeout and was stopped.`;
|
|
482
|
+
if (fallback.kind === "invalid")
|
|
483
|
+
return `Error: invalid regex: ${fallback.error ?? "invalid pattern"}`;
|
|
484
|
+
if (fallback.kind !== "ok") {
|
|
485
|
+
const rgDetail = rg.kind === "error" && rg.error ? `; ripgrep: ${rg.error}` : "";
|
|
486
|
+
return `Error: grep regex failed safely: ${fallback.error ?? "unknown worker error"}${rgDetail}`;
|
|
101
487
|
}
|
|
488
|
+
lines = fallback.lines;
|
|
489
|
+
matches = fallback.matches;
|
|
490
|
+
scanned = fallback.scanned;
|
|
491
|
+
truncated = fallback.truncated;
|
|
102
492
|
}
|
|
103
493
|
if (!lines.length)
|
|
104
494
|
return `No matches for /${input.pattern}/ (scanned ${scanned} files).`;
|
|
105
495
|
let body = lines.join("\n");
|
|
106
496
|
if (body.length > MAX_OUT)
|
|
107
497
|
body = body.slice(0, MAX_OUT) + "\n…[truncated]";
|
|
108
|
-
const head = matches >= MAX_MATCHES ? `(showing first ${MAX_MATCHES} matches)\n` : "";
|
|
498
|
+
const head = truncated || matches >= MAX_MATCHES ? `(showing first ${Math.min(matches, MAX_MATCHES)} matches)\n` : "";
|
|
109
499
|
return head + body;
|
|
110
500
|
},
|
|
111
501
|
});
|
|
@@ -123,12 +513,32 @@ registerTool({
|
|
|
123
513
|
kind: "read",
|
|
124
514
|
async run(input, ctx) {
|
|
125
515
|
const root = absOf(input.path, ctx.cwd);
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
516
|
+
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
517
|
+
if (pattern.length > MAX_GLOB_CHARS)
|
|
518
|
+
return `Error: glob pattern exceeds ${MAX_GLOB_CHARS} characters.`;
|
|
519
|
+
const files = walkFiles(root);
|
|
520
|
+
const hits = [];
|
|
521
|
+
const started = Date.now();
|
|
522
|
+
let examined = 0;
|
|
523
|
+
let budgetReached = false;
|
|
524
|
+
for (const file of files) {
|
|
525
|
+
if ((examined & 31) === 0 && Date.now() - started >= GLOB_MATCH_BUDGET_MS) {
|
|
526
|
+
budgetReached = true;
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
examined++;
|
|
530
|
+
if (matchesGlob(pattern, file))
|
|
531
|
+
hits.push(file);
|
|
532
|
+
}
|
|
533
|
+
if (!hits.length) {
|
|
534
|
+
return budgetReached
|
|
535
|
+
? `No files matched ${pattern} before the ${GLOB_MATCH_BUDGET_MS}ms safety budget; narrow \`path\` or simplify the glob.`
|
|
536
|
+
: `No files match ${pattern}.`;
|
|
537
|
+
}
|
|
130
538
|
const shown = hits.slice(0, 400);
|
|
131
|
-
const head =
|
|
539
|
+
const head = budgetReached
|
|
540
|
+
? `(showing ${shown.length} matches found before the ${GLOB_MATCH_BUDGET_MS}ms safety budget; examined ${examined}/${files.length} files)\n`
|
|
541
|
+
: hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
|
|
132
542
|
return head + shown.join("\n");
|
|
133
543
|
},
|
|
134
544
|
});
|