@nanhara/hara 0.121.1 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +40 -6
  3. package/dist/agent/loop.js +136 -21
  4. package/dist/agent/reminders.js +22 -7
  5. package/dist/agent/repeat-guard.js +26 -7
  6. package/dist/agent/structured.js +231 -0
  7. package/dist/agent/touched.js +20 -6
  8. package/dist/config.js +33 -23
  9. package/dist/cron/deliver.js +37 -3
  10. package/dist/feedback.js +3 -2
  11. package/dist/fs-read.js +106 -12
  12. package/dist/fs-write.js +242 -16
  13. package/dist/gateway/dingtalk.js +4 -1
  14. package/dist/gateway/discord.js +53 -18
  15. package/dist/gateway/feishu.js +158 -57
  16. package/dist/gateway/flows-pending.js +720 -0
  17. package/dist/gateway/flows.js +391 -16
  18. package/dist/gateway/matrix.js +80 -15
  19. package/dist/gateway/mattermost.js +44 -32
  20. package/dist/gateway/media.js +659 -0
  21. package/dist/gateway/serve.js +657 -162
  22. package/dist/gateway/sessions.js +475 -78
  23. package/dist/gateway/signal.js +27 -22
  24. package/dist/gateway/slack.js +26 -17
  25. package/dist/gateway/telegram.js +32 -18
  26. package/dist/gateway/wecom.js +32 -24
  27. package/dist/gateway/weixin.js +127 -49
  28. package/dist/hooks.js +32 -20
  29. package/dist/index.js +631 -222
  30. package/dist/org/projects.js +347 -0
  31. package/dist/org/roles.js +38 -11
  32. package/dist/security/secrets.js +84 -9
  33. package/dist/serve/server.js +772 -317
  34. package/dist/serve/sessions.js +113 -33
  35. package/dist/session/store.js +298 -47
  36. package/dist/tools/all.js +1 -0
  37. package/dist/tools/builtin.js +30 -28
  38. package/dist/tools/codebase.js +3 -1
  39. package/dist/tools/computer.js +98 -92
  40. package/dist/tools/edit.js +11 -8
  41. package/dist/tools/patch.js +230 -31
  42. package/dist/tools/search.js +482 -72
  43. package/dist/tools/task.js +453 -0
  44. package/dist/tools/todo.js +67 -16
  45. package/dist/tools/web.js +168 -54
  46. package/dist/undo.js +83 -7
  47. package/package.json +1 -1
@@ -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 { readFileSync, readdirSync, statSync } from "node:fs";
4
- import { isAbsolute, resolve, join, relative, sep } from "node:path";
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 { walkFiles, isProbablyBinary } from "../fs-walk.js";
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
- /** Convert a glob (supports **, *, ?) to an anchored RegExp over POSIX paths. */
13
- function globToRegExp(glob) {
14
- let re = "";
15
- for (let i = 0; i < glob.length; i++) {
16
- const ch = glob[i];
17
- if (ch === "*") {
18
- if (glob[i + 1] === "*") {
19
- // ** matches across path separators (optionally swallow a trailing slash)
20
- i++;
21
- if (glob[i + 1] === "/")
22
- i++;
23
- re += "(?:.*/)?";
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
- else {
26
- re += "[^/]*";
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
- else if (ch === "?")
30
- re += "[^/]";
31
- else if (".+^${}()|[]\\".includes(ch))
32
- re += "\\" + ch;
33
- else
34
- re += ch;
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
- return new RegExp("^" + re + "$");
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: "JavaScript regular expression" },
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
- let re;
55
- try {
56
- re = new RegExp(input.pattern, input.ignore_case ? "i" : "");
57
- }
58
- catch (e) {
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
- isFile = statSync(root).isFile();
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 rel = (abs) => toPosix(relative(ctx.cwd, abs)) || toPosix(relative(root, abs));
70
- const files = isFile ? [root] : walkFiles(root).map((f) => join(root, f));
71
- const globRe = input.glob ? globToRegExp(input.glob) : null;
72
- const lines = [];
73
- let matches = 0;
74
- let scanned = 0;
75
- for (const abs of files) {
76
- if (matches >= MAX_MATCHES)
77
- break;
78
- const r = toPosix(relative(ctx.cwd, abs));
79
- if (globRe && !globRe.test(toPosix(relative(isFile ? ctx.cwd : root, abs))))
80
- continue;
81
- let buf;
82
- try {
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 re = globToRegExp(input.pattern);
127
- const hits = walkFiles(root).filter((f) => re.test(f));
128
- if (!hits.length)
129
- return `No files match ${input.pattern}.`;
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 = hits.length > shown.length ? `(${hits.length} matches, showing 400)\n` : "";
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
  });