@yeaft/webchat-agent 1.0.299 → 1.0.301

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.
@@ -7,10 +7,20 @@
7
7
 
8
8
  import { defineTool } from './types.js';
9
9
  import { spawn } from 'child_process';
10
- import { readdir, readFile, stat } from 'fs/promises';
10
+ import { lstat, readdir, readFile } from 'fs/promises';
11
11
  import { StringDecoder } from 'string_decoder';
12
12
  import { existsSync } from 'fs';
13
- import { resolve, join, relative, extname } from 'path';
13
+ import { basename, dirname, resolve, join, relative, extname } from 'path';
14
+ import { managedCliToolReady, resolveManagedCliCommand } from '../managed-cli.js';
15
+ import { runProcess } from './process-runner.js';
16
+ import {
17
+ createSearchPathMatcher,
18
+ SEARCH_SKIP_GLOBS,
19
+ isAbortError,
20
+ isSkippedSearchDirectory,
21
+ throwIfAborted,
22
+ waitForAbortable,
23
+ } from './search-paths.js';
14
24
 
15
25
  /** Max output lines. */
16
26
  const MAX_LINES = 250;
@@ -23,9 +33,9 @@ const MAX_CAPTURE_BYTES = MAX_OUTPUT_BYTES - Buffer.byteLength(OUTPUT_TRUNCATED_
23
33
 
24
34
  /** Keep one pathological source line from consuming the whole output budget. */
25
35
  const MAX_LINE_BYTES = 16 * 1024;
26
- const FALLBACK_CONCURRENCY = 8;
27
- const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.next', 'dist', 'build', '.cache']);
28
- let ripgrepAvailability;
36
+ const MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024;
37
+ const MAX_CANDIDATE_BYTES = 16 * 1024 * 1024;
38
+ const FALLBACK_CONCURRENCY = 4;
29
39
 
30
40
  /** Binary extensions to skip. */
31
41
  const BINARY_EXTS = new Set([
@@ -59,6 +69,169 @@ function boundToolOutput(text) {
59
69
  return truncateUtf8(text, MAX_CAPTURE_BYTES) + OUTPUT_TRUNCATED_MARKER;
60
70
  }
61
71
 
72
+ function normalizeRipgrepPath(searchPath, path) {
73
+ const normalized = String(path || '').replace(/\\/g, '/').replace(/^\.\//, '');
74
+ const normalizedSearchPath = String(searchPath || '').replace(/\\/g, '/');
75
+ const isAbsolute = normalized.startsWith('/')
76
+ || (/^[A-Za-z]:\//.test(normalized) && /^[A-Za-z]:\//.test(normalizedSearchPath));
77
+ if (!isAbsolute) return normalized;
78
+ return relative(searchPath, normalized).replace(/\\/g, '/');
79
+ }
80
+
81
+ function createGrepRecord(path, suffix = '', kind = 'match', metadata = {}) {
82
+ return { path, suffix, kind, ...metadata };
83
+ }
84
+
85
+ function createRipgrepEnv() {
86
+ const env = { ...process.env };
87
+ delete env.RIPGREP_CONFIG_PATH;
88
+ return env;
89
+ }
90
+
91
+ function renderGrepRecord(record, options) {
92
+ if (options.filesOnly) return record.path;
93
+ const separatorAfterPath = record.kind === 'context' ? '-' : ':';
94
+ return record.path + separatorAfterPath + record.suffix;
95
+ }
96
+
97
+ function prepareOutputValue(value) {
98
+ const normalized = String(value).replace(/\r/g, '');
99
+ const text = truncateUtf8(normalized, MAX_LINE_BYTES);
100
+ return {
101
+ text,
102
+ truncated: Buffer.byteLength(text, 'utf8') < Buffer.byteLength(normalized, 'utf8'),
103
+ bytes: Buffer.byteLength(text, 'utf8'),
104
+ };
105
+ }
106
+
107
+ function measureOutputValues(values) {
108
+ return values.reduce((total, value, index) => total + value.bytes + (index > 0 ? 1 : 0), 0);
109
+ }
110
+
111
+ function renderOutputValues(values, truncated) {
112
+ if (!truncated) return values.map(value => value.text).join('\n');
113
+ const markerBytes = Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
114
+ let remaining = Math.max(0, SEARCH_RESULT_BYTES - markerBytes);
115
+ const parts = [];
116
+ for (const value of values) {
117
+ const separator = parts.length > 0 ? '\n' : '';
118
+ const separatorBytes = Buffer.byteLength(separator, 'utf8');
119
+ if (remaining <= separatorBytes) break;
120
+ const text = truncateUtf8(value.text, remaining - separatorBytes);
121
+ if (!text && value.text) break;
122
+ parts.push(separator + text);
123
+ remaining -= separatorBytes + Buffer.byteLength(text, 'utf8');
124
+ if (text !== value.text) break;
125
+ }
126
+ return parts.join('') + truncateUtf8(OUTPUT_TRUNCATED_MARKER, SEARCH_RESULT_BYTES);
127
+ }
128
+
129
+ function renderSelectedRecords(records, visibleMatches, options, moreResults) {
130
+ const visibleMatchRecords = new Set(visibleMatches);
131
+ const visibleMatchKeys = new Set(visibleMatches.map(record => record.matchKey).filter(Boolean));
132
+ const eligible = records.filter(record => (
133
+ record.kind !== 'context'
134
+ ? visibleMatchRecords.has(record)
135
+ : record.matchKeys?.some(matchKey => visibleMatchKeys.has(matchKey))
136
+ ));
137
+ const prepared = new Map(eligible.map(record => [
138
+ record,
139
+ prepareOutputValue(renderGrepRecord(record, options)),
140
+ ]));
141
+ const notice = moreResults ? prepareOutputValue('\n... (more results omitted)') : null;
142
+ const completeValues = eligible.map(record => prepared.get(record));
143
+ if (notice) completeValues.push(notice);
144
+ if (completeValues.every(value => !value.truncated)
145
+ && measureOutputValues(completeValues) <= SEARCH_RESULT_BYTES) {
146
+ return renderOutputValues(completeValues, false);
147
+ }
148
+
149
+ const markerBytes = Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8');
150
+ const noticeBytes = notice ? notice.bytes + 1 : 0;
151
+ const selectedMatches = new Set(visibleMatches);
152
+ const selectedMatchKeys = new Set(
153
+ visibleMatches.map(record => record.matchKey).filter(Boolean),
154
+ );
155
+ const matchValues = visibleMatches.map(record => prepared.get(record));
156
+ const selectedContexts = new Set();
157
+ let usedBytes = measureOutputValues(matchValues) + markerBytes + noticeBytes;
158
+ if (usedBytes <= SEARCH_RESULT_BYTES) {
159
+ for (const record of eligible) {
160
+ if (record.kind !== 'context'
161
+ || !record.matchKeys?.some(matchKey => selectedMatchKeys.has(matchKey))) continue;
162
+ const value = prepared.get(record);
163
+ if (value.truncated || usedBytes + value.bytes + 1 > SEARCH_RESULT_BYTES) continue;
164
+ selectedContexts.add(record);
165
+ usedBytes += value.bytes + 1;
166
+ }
167
+ }
168
+
169
+ const selectedValues = eligible
170
+ .filter(record => selectedMatches.has(record) || selectedContexts.has(record))
171
+ .map(record => prepared.get(record));
172
+ if (notice) selectedValues.push(notice);
173
+ return renderOutputValues(selectedValues, true);
174
+ }
175
+
176
+ function compareSearchPaths(left, right) {
177
+ return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
178
+ }
179
+
180
+ function compareGrepRecords(left, right) {
181
+ if (left.path !== right.path) return compareSearchPaths(left.path, right.path);
182
+ const leftLine = Number.parseInt(left.suffix, 10);
183
+ const rightLine = Number.parseInt(right.suffix, 10);
184
+ if (Number.isFinite(leftLine) && Number.isFinite(rightLine) && leftLine !== rightLine) {
185
+ return leftLine - rightLine;
186
+ }
187
+ if (left.suffix === right.suffix) return 0;
188
+ return left.suffix < right.suffix ? -1 : 1;
189
+ }
190
+
191
+ function createNodeRegexPlan(pattern, options) {
192
+ let source = options.fixedStrings
193
+ ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
194
+ : pattern;
195
+ const flags = new Set(['g']);
196
+ if (options.caseInsensitive) flags.add('i');
197
+ if (options.multiline) {
198
+ flags.add('m');
199
+ flags.add('s');
200
+ }
201
+ if (!options.fixedStrings) {
202
+ while (true) {
203
+ const inlineFlags = source.match(/^\(\?([ims]*)(?:-([ims]*))?\)/);
204
+ if (!inlineFlags || (!inlineFlags[1] && !inlineFlags[2])) break;
205
+ for (const flag of inlineFlags[1]) flags.add(flag);
206
+ for (const flag of inlineFlags[2] || '') flags.delete(flag);
207
+ source = source.slice(inlineFlags[0].length);
208
+ }
209
+ }
210
+ const flagString = [...flags].join('');
211
+ new RegExp(source, flagString);
212
+ return { source, flags: flagString };
213
+ }
214
+
215
+ function canUseRipgrep(pattern, options) {
216
+ const utf8RoundTrip = Buffer.from(pattern, 'utf8').toString('utf8') === pattern;
217
+ if (options.caseInsensitive || options.multiline || !utf8RoundTrip) return false;
218
+ if (/[\0\r\n\u2028\u2029]/.test(pattern)) return false;
219
+ if (options.fixedStrings) return true;
220
+
221
+ // JavaScript RegExp is the public contract. Rust regex only finds candidate
222
+ // files for plain printable ASCII text; Node.js validates every result.
223
+ return /^[\x20-\x7e]+$/.test(pattern)
224
+ && !/[\\^$.*+?()[\]{}|]/.test(pattern);
225
+ }
226
+
227
+ function prepareGrepPattern(pattern, options) {
228
+ const regexPlan = createNodeRegexPlan(pattern, options);
229
+ return {
230
+ ...regexPlan,
231
+ useRipgrep: canUseRipgrep(pattern, options),
232
+ };
233
+ }
234
+
62
235
  function formatGrepError(message) {
63
236
  const errorMessage = `Grep failed: ${message}`;
64
237
  const serialized = JSON.stringify({ error: errorMessage });
@@ -85,81 +258,122 @@ function formatGrepError(message) {
85
258
  return result;
86
259
  }
87
260
 
88
- function createOutputCollector(maxBytes = MAX_OUTPUT_BYTES) {
261
+ export function createOutputCollector(maxBytes = MAX_OUTPUT_BYTES) {
89
262
  const parts = [];
90
- const contentBytes = Math.max(0, maxBytes - Buffer.byteLength(OUTPUT_TRUNCATED_MARKER, 'utf8'));
263
+ const marker = truncateUtf8(OUTPUT_TRUNCATED_MARKER, maxBytes);
264
+ const markerBytes = Buffer.byteLength(marker, 'utf8');
91
265
  let bytes = 0;
92
266
  let truncated = false;
267
+
268
+ function truncateExistingParts(contentBytes) {
269
+ let remaining = contentBytes;
270
+ const bounded = [];
271
+ for (const part of parts) {
272
+ if (remaining <= 0) break;
273
+ const value = truncateUtf8(part, remaining);
274
+ if (value) bounded.push(value);
275
+ remaining -= Buffer.byteLength(value, 'utf8');
276
+ }
277
+ parts.length = 0;
278
+ parts.push(...bounded);
279
+ bytes = contentBytes - remaining;
280
+ }
281
+
282
+ function markTruncated(part = '') {
283
+ if (truncated) return;
284
+ truncated = true;
285
+ const contentBytes = Math.max(0, maxBytes - markerBytes);
286
+ truncateExistingParts(contentBytes);
287
+ const remaining = contentBytes - bytes;
288
+ if (remaining > 0 && part) {
289
+ const bounded = truncateUtf8(part, remaining);
290
+ if (bounded) {
291
+ parts.push(bounded);
292
+ bytes += Buffer.byteLength(bounded, 'utf8');
293
+ }
294
+ }
295
+ }
296
+
93
297
  return {
94
298
  add(value) {
95
299
  if (truncated) return false;
96
300
  const normalized = String(value).replace(/\r/g, '');
97
301
  const line = truncateUtf8(normalized, MAX_LINE_BYTES);
98
302
  const lineWasTruncated = Buffer.byteLength(normalized, 'utf8') > Buffer.byteLength(line, 'utf8');
99
- const separator = parts.length > 0 ? '\n' : '';
100
- const remaining = contentBytes - bytes - Buffer.byteLength(separator, 'utf8');
101
- if (remaining <= 0) { truncated = true; return false; }
102
- const bounded = truncateUtf8(line, remaining);
103
- parts.push(separator + bounded);
104
- bytes += Buffer.byteLength(separator + bounded, 'utf8');
105
- if (lineWasTruncated || bounded !== line) truncated = true;
106
- return !truncated;
303
+ const part = (parts.length > 0 ? '\n' : '') + line;
304
+ const partBytes = Buffer.byteLength(part, 'utf8');
305
+ if (lineWasTruncated || bytes + partBytes > maxBytes) {
306
+ markTruncated(part);
307
+ return false;
308
+ }
309
+ parts.push(part);
310
+ bytes += partBytes;
311
+ return true;
107
312
  },
108
- toString() { return parts.join('') + (truncated ? OUTPUT_TRUNCATED_MARKER : ''); },
313
+ get truncated() { return truncated; },
314
+ toString() { return parts.join('') + (truncated ? marker : ''); },
109
315
  };
110
316
  }
111
317
 
112
318
  /**
113
319
  * Check if ripgrep is available.
114
320
  */
115
- export function setRipgrepAvailabilityForTests(value) {
116
- ripgrepAvailability = value;
117
- }
118
-
119
- function hasRipgrep() {
120
- if (typeof ripgrepAvailability === 'boolean') return Promise.resolve(ripgrepAvailability);
121
- if (ripgrepAvailability) return ripgrepAvailability;
122
- ripgrepAvailability = new Promise((resolve) => {
123
- const proc = spawn('rg', ['--version'], { stdio: 'pipe', windowsHide: true });
124
- proc.on('close', (code) => resolve(code === 0));
125
- proc.on('error', () => resolve(false));
126
- }).then((available) => {
127
- ripgrepAvailability = available;
128
- return available;
129
- });
130
- return ripgrepAvailability;
131
- }
132
-
133
321
  /**
134
322
  * Run ripgrep and return results.
135
323
  */
136
- export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
324
+ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn, command = 'rg') {
325
+ throwIfAborted(options.signal);
137
326
  return new Promise((resolve, reject) => {
138
- const args = [pattern, searchPath, '--no-heading', '--line-number', '--color', 'never'];
327
+ const relativeTarget = options.cwd ? relative(options.cwd, searchPath) : null;
328
+ const searchTarget = relativeTarget === '' ? null : (relativeTarget ?? searchPath);
329
+ const args = [
330
+ '--no-config',
331
+ '--no-heading',
332
+ '--line-number',
333
+ '--color', 'never',
334
+ '--hidden',
335
+ '--no-ignore',
336
+ '--null',
337
+ ];
139
338
  if (options.caseInsensitive) args.push('-i');
140
339
  if (options.fixedStrings) args.push('-F');
141
- if (options.glob) args.push('--glob', options.glob);
142
- if (options.type) args.push('--type', options.type);
340
+ // User glob and type semantics are defined by the shared matcher below.
341
+ // Do not pass either positive filter to rg: rg's glob dialect and type
342
+ // registry are broader, so either filter could discard a fallback match.
343
+ for (const skipGlob of SEARCH_SKIP_GLOBS) args.push('--glob', skipGlob);
143
344
  if (options.filesOnly) args.push('-l');
144
345
  if (options.count) args.push('-c');
145
346
  if (options.context) args.push('-C', String(options.context));
146
347
  if (options.before) args.push('-B', String(options.before));
147
348
  if (options.after) args.push('-A', String(options.after));
349
+ if (options.context || options.before || options.after) args.push('--no-context-separator');
148
350
  if (options.multiline) args.push('-U', '--multiline-dotall');
149
-
150
- const proc = spawnProcess('rg', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
351
+ args.push('--sort', 'path');
352
+ args.push('--', pattern);
353
+ if (searchTarget) args.push(searchTarget);
354
+
355
+ const proc = spawnProcess(command, args, {
356
+ cwd: options.cwd,
357
+ env: createRipgrepEnv(),
358
+ stdio: ['ignore', 'pipe', 'pipe'],
359
+ windowsHide: true,
360
+ });
151
361
  const requestedBudget = Number(options.byteBudget);
152
362
  const stdoutBudget = Number.isFinite(requestedBudget) && requestedBudget >= 0
153
363
  ? Math.min(requestedBudget, MAX_OUTPUT_BYTES)
154
364
  : MAX_OUTPUT_BYTES;
155
- const stdoutMarker = truncateUtf8(OUTPUT_TRUNCATED_MARKER, stdoutBudget);
156
- const stdoutChunks = [];
365
+ const stdout = createOutputCollector(stdoutBudget);
366
+ const records = [];
367
+ const matchesPath = createSearchPathMatcher(options);
368
+ const stdoutDecoder = new StringDecoder('utf8');
157
369
  const stderrChunks = [];
158
- let stdoutBytes = 0;
370
+ let pendingStdout = '';
159
371
  let stderrBytes = 0;
160
372
  let stdoutTruncated = false;
161
373
  let stderrTruncated = false;
162
- let stdoutLines = 0;
374
+ let resultCount = 0;
375
+ let pendingPath = null;
376
+ let discardSuffix = false;
163
377
  let stoppedForLimit = false;
164
378
  let stopRequested = false;
165
379
  let settled = false;
@@ -170,32 +384,75 @@ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
170
384
  try { proc.kill(); } catch {}
171
385
  }
172
386
 
173
- function captureStdout(chunk) {
174
- if (stdoutTruncated || stoppedForLimit) return;
175
- let buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
176
- const maxResults = Math.max(1, options.maxResults || 500);
177
- let cursor = 0;
178
- while (stdoutLines < maxResults) {
179
- const newline = buffer.indexOf(0x0a, cursor);
180
- if (newline === -1) break;
181
- stdoutLines += 1;
182
- cursor = newline + 1;
387
+ function captureRecord(record) {
388
+ if (stoppedForLimit || stdoutTruncated || !matchesPath(record.path)) return;
389
+ resultCount += 1;
390
+ records.push(record);
391
+ if (!stdout.add(renderGrepRecord(record, options))) stdoutTruncated = true;
392
+ if (resultCount >= Math.max(1, options.maxResults || 500)) stoppedForLimit = true;
393
+ if (stdoutTruncated || stoppedForLimit) stop();
394
+ }
395
+
396
+ function captureSuffix(suffix) {
397
+ if (pendingPath == null) return;
398
+ let kind = 'match';
399
+ if (!options.count && suffix.match(/^\d+-/)) kind = 'context';
400
+ captureRecord(createGrepRecord(pendingPath, suffix, kind));
401
+ pendingPath = null;
402
+ }
403
+
404
+ function drainStdout(final = false) {
405
+ while (!stdoutTruncated && !stoppedForLimit) {
406
+ if (discardSuffix) {
407
+ const boundary = pendingStdout.indexOf('\n');
408
+ if (boundary < 0) break;
409
+ pendingStdout = pendingStdout.slice(boundary + 1);
410
+ pendingPath = null;
411
+ discardSuffix = false;
412
+ continue;
413
+ }
414
+ if (pendingPath == null) {
415
+ const boundary = pendingStdout.indexOf('\0');
416
+ if (boundary < 0) break;
417
+ pendingPath = normalizeRipgrepPath(searchPath, pendingStdout.slice(0, boundary));
418
+ pendingStdout = pendingStdout.slice(boundary + 1);
419
+ if (options.filesOnly) {
420
+ captureRecord(createGrepRecord(pendingPath));
421
+ pendingPath = null;
422
+ }
423
+ continue;
424
+ }
425
+ const boundary = pendingStdout.indexOf('\n');
426
+ if (boundary < 0) break;
427
+ const suffix = pendingStdout.slice(0, boundary).replace(/\r$/, '');
428
+ pendingStdout = pendingStdout.slice(boundary + 1);
429
+ captureSuffix(suffix);
183
430
  }
184
- if (stdoutLines >= maxResults) {
185
- buffer = buffer.subarray(0, cursor);
186
- stoppedForLimit = true;
431
+ if (final && !stdoutTruncated && !stoppedForLimit) {
432
+ if (pendingPath != null && pendingStdout && !discardSuffix) {
433
+ captureSuffix(pendingStdout.replace(/\r$/, ''));
434
+ }
435
+ pendingStdout = '';
436
+ pendingPath = null;
437
+ discardSuffix = false;
187
438
  }
439
+ }
188
440
 
189
- const remaining = stdoutBudget - stdoutBytes;
190
- if (buffer.length > remaining) {
191
- if (remaining > 0) stdoutChunks.push(buffer.subarray(0, remaining));
192
- stdoutBytes = stdoutBudget;
193
- stdoutTruncated = true;
194
- } else {
195
- stdoutChunks.push(buffer);
196
- stdoutBytes += buffer.length;
441
+ function captureStdout(chunk) {
442
+ if (stdoutTruncated || stoppedForLimit) return;
443
+ pendingStdout += stdoutDecoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
444
+ drainStdout();
445
+ if (pendingPath != null && !discardSuffix
446
+ && Buffer.byteLength(pendingStdout, 'utf8') > MAX_LINE_BYTES) {
447
+ if (matchesPath(pendingPath)) {
448
+ captureSuffix(truncateUtf8(pendingStdout, MAX_LINE_BYTES + 1));
449
+ pendingStdout = '';
450
+ } else {
451
+ discardSuffix = true;
452
+ drainStdout();
453
+ if (discardSuffix) pendingStdout = '';
454
+ }
197
455
  }
198
- if (stdoutTruncated || stoppedForLimit) stop();
199
456
  }
200
457
 
201
458
  function captureStderr(chunk) {
@@ -220,117 +477,296 @@ export function runRipgrep(pattern, searchPath, options, spawnProcess = spawn) {
220
477
  return truncateUtf8(decoded, maxTextBytes) + boundedMarker;
221
478
  }
222
479
 
480
+ const cleanup = () => options.signal?.removeEventListener('abort', onAbort);
481
+ const onAbort = () => {
482
+ if (settled) return;
483
+ settled = true;
484
+ cleanup();
485
+ let abort;
486
+ try { throwIfAborted(options.signal); } catch (error) { abort = error; }
487
+ stop();
488
+ reject(abort);
489
+ };
490
+
223
491
  proc.stdout.on('data', captureStdout);
224
492
  proc.stderr.on('data', captureStderr);
225
493
  proc.on('close', (code) => {
226
494
  if (settled) return;
227
495
  settled = true;
228
- const stdout = decodeCaptured(stdoutChunks, stdoutBudget, stdoutTruncated, stdoutMarker);
496
+ cleanup();
497
+ if (!stdoutTruncated && !stoppedForLimit) {
498
+ pendingStdout += stdoutDecoder.end();
499
+ drainStdout(true);
500
+ }
501
+ const output = stdout.toString();
229
502
  const stderr = decodeCaptured(stderrChunks, MAX_OUTPUT_BYTES, stderrTruncated);
230
- if (code === 0 || code === 1 || stoppedForLimit || stdoutTruncated) resolve(stdout);
231
- else reject(new Error(stderr || `rg exited with code ${code}`));
503
+ if (code === 0 || code === 1 || stoppedForLimit || stdoutTruncated) {
504
+ resolve(options.structured
505
+ ? { output, records, resultCount, truncated: stdout.truncated }
506
+ : output);
507
+ } else reject(new Error(stderr || `rg exited with code ${code}`));
232
508
  });
233
509
  proc.on('error', (err) => {
234
510
  if (settled || stopRequested) return;
235
511
  settled = true;
512
+ cleanup();
236
513
  reject(err);
237
514
  });
515
+ options.signal?.addEventListener('abort', onAbort, { once: true });
516
+ if (options.signal?.aborted) onAbort();
238
517
  });
239
518
  }
240
519
 
520
+ async function listRipgrepCandidatePaths(command, searchPath, options) {
521
+ throwIfAborted(options.signal);
522
+ const searchStat = await lstat(searchPath);
523
+ const baseDir = searchStat.isDirectory() ? searchPath : dirname(searchPath);
524
+ const target = searchStat.isDirectory() ? '.' : basename(searchPath);
525
+ const args = [
526
+ '--no-config',
527
+ '--files-with-matches',
528
+ '--color', 'never',
529
+ '--hidden',
530
+ '--no-ignore',
531
+ '--null',
532
+ ];
533
+ if (options.fixedStrings) args.push('-F');
534
+ for (const skipGlob of SEARCH_SKIP_GLOBS) args.push('--glob', skipGlob);
535
+ args.push('--sort', 'path', '--', options.pattern, target);
536
+ const result = await runProcess(command, args, {
537
+ cwd: baseDir,
538
+ env: createRipgrepEnv(),
539
+ signal: options.signal,
540
+ timeoutMs: 120_000,
541
+ maxBytes: MAX_CANDIDATE_BYTES,
542
+ preserveCarriageReturns: true,
543
+ });
544
+ if (result.timedOut) throw new Error('rg timed out');
545
+ if (result.truncated) throw new Error('rg candidate output exceeded the tool limit');
546
+ if (result.code !== 0 && result.code !== 1) {
547
+ throw new Error(result.stderr.trim() || `rg exited with code ${result.code}`);
548
+ }
549
+ return result.stdout
550
+ .split('\0')
551
+ .filter(Boolean)
552
+ .map(path => resolve(baseDir, path));
553
+ }
554
+
555
+ function splitTextLines(content) {
556
+ const lines = [];
557
+ let start = 0;
558
+ for (let index = 0; index < content.length; index += 1) {
559
+ const character = content[index];
560
+ if (character !== '\n' && character !== '\r'
561
+ && character !== '\u2028' && character !== '\u2029') continue;
562
+ const lineEnd = index;
563
+ if (character === '\r' && content[index + 1] === '\n') index += 1;
564
+ lines.push({ text: content.slice(start, lineEnd), start, end: lineEnd });
565
+ start = index + 1;
566
+ }
567
+ lines.push({ text: content.slice(start), start, end: content.length });
568
+ return lines;
569
+ }
570
+
571
+ function advanceEmptyMatch(regex, content) {
572
+ if (regex.lastIndex >= content.length) return false;
573
+ regex.lastIndex += regex.unicode && content.codePointAt(regex.lastIndex) > 0xffff ? 2 : 1;
574
+ return true;
575
+ }
576
+
241
577
  /**
242
- * Fallback: Node.js grep implementation.
578
+ * Fallback and final verifier: JavaScript RegExp defines matching semantics.
243
579
  */
244
580
  export async function nodeGrep(pattern, searchPath, options) {
245
- const regexSource = options.fixedStrings
246
- ? pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
247
- : pattern;
248
- const regex = new RegExp(regexSource, options.caseInsensitive ? 'gi' : 'g');
581
+ throwIfAborted(options.signal);
582
+ const regexPlan = options.regexPlan || createNodeRegexPlan(pattern, options);
583
+ const regex = new RegExp(regexPlan.source, regexPlan.flags);
249
584
  const output = createOutputCollector(options.byteBudget || SEARCH_RESULT_BYTES);
585
+ const records = [];
250
586
  const maxResults = Math.max(1, options.maxResults || 500);
587
+ const matchesPath = createSearchPathMatcher(options);
588
+ const rootStat = await lstat(searchPath);
589
+ throwIfAborted(options.signal);
590
+ const searchBase = rootStat.isDirectory() ? searchPath : dirname(searchPath);
251
591
  let resultCount = 0;
252
592
  let stopped = false;
253
593
 
254
- function compileGlob(glob) {
255
- const escaped = glob.replace(/\\/g, '/')
256
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
257
- .replace(/\*\*/g, '\0').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')
258
- .replace(/\0/g, '.*');
259
- return new RegExp(`^${escaped}$`);
260
- }
261
- const globMatcher = options.glob ? compileGlob(options.glob) : null;
262
- const typeExtensions = {
263
- js: ['.js', '.jsx', '.mjs', '.cjs'], ts: ['.ts', '.tsx', '.mts', '.cts'],
264
- py: ['.py'], rust: ['.rs'], go: ['.go'], java: ['.java'],
265
- json: ['.json'], yaml: ['.yaml', '.yml'], markdown: ['.md', '.markdown'],
266
- html: ['.html', '.htm'], css: ['.css'], shell: ['.sh', '.bash', '.zsh'],
267
- };
594
+ const candidatePaths = options.candidatePaths
595
+ ? new Set(options.candidatePaths.map(path => resolve(path)))
596
+ : null;
268
597
 
269
598
  function matchesFilters(fullPath) {
270
- const relPath = relative(searchPath, fullPath).replace(/\\/g, '/');
271
- if (globMatcher && !globMatcher.test(relPath) && !globMatcher.test(relPath.split('/').pop())) return false;
272
- if (!options.type) return true;
273
- const extensions = typeExtensions[options.type];
274
- return Boolean(extensions?.includes(extname(fullPath).toLowerCase()));
599
+ return (!candidatePaths || candidatePaths.has(resolve(fullPath)))
600
+ && matchesPath(relative(searchBase, fullPath).replace(/\\/g, '/'));
275
601
  }
276
602
 
277
- function addResult(value) {
278
- resultCount += 1;
279
- if (!output.add(value) || resultCount >= maxResults) stopped = true;
603
+ function addFileRecords(fileRecords) {
604
+ if (stopped) return;
605
+ for (const record of fileRecords) {
606
+ records.push(record);
607
+ if (!options.structured && !output.add(renderGrepRecord(record, options))) {
608
+ stopped = true;
609
+ return;
610
+ }
611
+ if (record.kind !== 'context') resultCount += 1;
612
+ }
613
+ if (resultCount >= maxResults) stopped = true;
280
614
  }
281
615
 
282
- async function searchFile(fullPath) {
283
- if (stopped || !matchesFilters(fullPath) || BINARY_EXTS.has(extname(fullPath).toLowerCase())) return;
616
+ async function collectFileRecords(fullPath) {
617
+ throwIfAborted(options.signal);
618
+ if (!matchesFilters(fullPath) || BINARY_EXTS.has(extname(fullPath).toLowerCase())) return [];
284
619
  try {
285
- const fileStat = await stat(fullPath);
286
- if (fileStat.size > 1024 * 1024 || stopped) return;
287
- const content = decodeTextFile(await readFile(fullPath));
288
- if (content == null) return;
289
- const relPath = relative(searchPath, fullPath);
290
- regex.lastIndex = 0;
291
- if (options.filesOnly) {
292
- if (regex.test(content)) addResult(relPath);
293
- } else if (options.count) {
294
- const matches = content.match(regex);
295
- if (matches) addResult(`${relPath}:${matches.length}`);
620
+ const fileStat = await lstat(fullPath);
621
+ throwIfAborted(options.signal);
622
+ if (!fileStat.isFile() || fileStat.size > MAX_TEXT_FILE_BYTES) return [];
623
+ const decoded = decodeTextFile(await readFile(fullPath));
624
+ throwIfAborted(options.signal);
625
+ if (decoded == null) return [];
626
+ const content = decoded;
627
+ const relPath = relative(searchBase, fullPath).replace(/\\/g, '/');
628
+ const lines = splitTextLines(content);
629
+ const matchedLines = new Set();
630
+ const textMatchedLines = new Set();
631
+ let matchCount = 0;
632
+ const lineAtOffset = (value, zeroWidth = false) => {
633
+ let low = 0;
634
+ let high = lines.length - 1;
635
+ while (low < high) {
636
+ const middle = Math.ceil((low + high) / 2);
637
+ if (lines[middle].start <= value) low = middle;
638
+ else high = middle - 1;
639
+ }
640
+ if (zeroWidth && value > lines[low].end && low + 1 < lines.length) return low + 1;
641
+ return low;
642
+ };
643
+
644
+ if (options.multiline) {
645
+ regex.lastIndex = 0;
646
+ let match;
647
+ while ((match = regex.exec(content)) !== null) {
648
+ matchCount += 1;
649
+ const zeroWidth = match[0].length === 0;
650
+ const startLine = lineAtOffset(match.index, zeroWidth);
651
+ const endOffset = zeroWidth ? match.index : match.index + match[0].length - 1;
652
+ const endLine = lineAtOffset(endOffset, zeroWidth);
653
+ for (let line = startLine; line <= endLine; line += 1) {
654
+ matchedLines.add(line);
655
+ if (!zeroWidth) textMatchedLines.add(line);
656
+ }
657
+ if (zeroWidth && match.index >= lines[startLine].start) textMatchedLines.add(startLine);
658
+ if (match[0].length === 0 && !advanceEmptyMatch(regex, content)) break;
659
+ }
296
660
  } else {
297
- const lines = content.split('\n');
298
- for (let i = 0; i < lines.length && !stopped; i += 1) {
661
+ for (let line = 0; line < lines.length; line += 1) {
299
662
  regex.lastIndex = 0;
300
- if (regex.test(lines[i])) addResult(`${relPath}:${i + 1}:${lines[i]}`);
663
+ let match;
664
+ while ((match = regex.exec(lines[line].text)) !== null) {
665
+ matchCount += 1;
666
+ matchedLines.add(line);
667
+ textMatchedLines.add(line);
668
+ if (match[0].length === 0 && !advanceEmptyMatch(regex, lines[line].text)) break;
669
+ }
301
670
  }
302
671
  }
303
- } catch {
304
- // Skip unreadable files.
672
+
673
+ if (options.filesOnly) {
674
+ return matchCount > 0 ? [createGrepRecord(relPath)] : [];
675
+ }
676
+ if (options.count) {
677
+ return matchCount > 0
678
+ ? [createGrepRecord(relPath, String(matchCount))]
679
+ : [];
680
+ }
681
+
682
+ const beforeLines = Math.max(0, Number(options.before ?? options.context ?? 0));
683
+ const afterLines = Math.max(0, Number(options.after ?? options.context ?? 0));
684
+ const selected = new Map();
685
+ const selectedMatches = [...matchedLines].sort((a, b) => a - b).slice(0, maxResults);
686
+ const selectedMatchSet = new Set(selectedMatches);
687
+ for (const matchIndex of selectedMatches) {
688
+ const matchKey = `${relPath}\0${matchIndex}`;
689
+ const start = Math.max(0, matchIndex - beforeLines);
690
+ const end = Math.min(lines.length - 1, matchIndex + afterLines);
691
+ for (let i = start; i <= end; i += 1) {
692
+ const kind = selectedMatchSet.has(i) ? 'match' : 'context';
693
+ const entry = selected.get(i) || { kind, matchKeys: new Set() };
694
+ if (kind === 'match') entry.kind = 'match';
695
+ entry.matchKeys.add(matchKey);
696
+ selected.set(i, entry);
697
+ }
698
+ }
699
+ return [...selected.entries()]
700
+ .sort((a, b) => a[0] - b[0])
701
+ .map(([lineIndex, entry]) => {
702
+ const lineSeparator = entry.kind === 'context' ? '-' : ':';
703
+ const lineText = entry.kind === 'match' && !textMatchedLines.has(lineIndex)
704
+ ? ''
705
+ : lines[lineIndex].text;
706
+ return createGrepRecord(
707
+ relPath,
708
+ `${lineIndex + 1}${lineSeparator}${lineText}`,
709
+ entry.kind,
710
+ entry.kind === 'match'
711
+ ? { matchKey: `${relPath}\0${lineIndex}` }
712
+ : { matchKeys: [...entry.matchKeys] },
713
+ );
714
+ });
715
+ } catch (error) {
716
+ if (isAbortError(error)) throw error;
717
+ return [];
305
718
  }
306
719
  }
307
720
 
308
721
  async function searchDir(dir) {
722
+ throwIfAborted(options.signal);
309
723
  if (stopped) return;
310
724
  let entries;
311
- try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
312
- const files = [];
313
- const directories = [];
314
- for (const entry of entries) {
315
- const fullPath = join(dir, entry.name);
316
- if (entry.isDirectory()) {
317
- const relPath = relative(searchPath, fullPath).replace(/\\/g, '/');
318
- if (!SKIP_DIRS.has(entry.name) && relPath !== '.yeaft/worktrees' && !relPath.startsWith('.yeaft/worktrees/')) directories.push(fullPath);
319
- } else files.push(fullPath);
725
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) {
726
+ if (isAbortError(error)) throw error;
727
+ return;
320
728
  }
321
- for (let i = 0; i < files.length && !stopped; i += FALLBACK_CONCURRENCY) {
322
- await Promise.all(files.slice(i, i + FALLBACK_CONCURRENCY).map(searchFile));
729
+ throwIfAborted(options.signal);
730
+ entries.sort((left, right) => compareSearchPaths(left.name, right.name));
731
+ let pendingFiles = [];
732
+
733
+ async function flushFiles() {
734
+ for (let i = 0; i < pendingFiles.length && !stopped; i += FALLBACK_CONCURRENCY) {
735
+ throwIfAborted(options.signal);
736
+ const batches = await Promise.all(
737
+ pendingFiles.slice(i, i + FALLBACK_CONCURRENCY).map(collectFileRecords),
738
+ );
739
+ for (const batch of batches) {
740
+ addFileRecords(batch);
741
+ if (stopped) break;
742
+ }
743
+ }
744
+ pendingFiles = [];
323
745
  }
324
- for (const child of directories) {
746
+
747
+ for (const entry of entries) {
748
+ throwIfAborted(options.signal);
325
749
  if (stopped) break;
326
- await searchDir(child);
750
+ const fullPath = join(dir, entry.name);
751
+ if (!entry.isDirectory()) {
752
+ pendingFiles.push(fullPath);
753
+ continue;
754
+ }
755
+ const relPath = relative(searchPath, fullPath).replace(/\\/g, '/');
756
+ if (isSkippedSearchDirectory(relPath, entry.name)) continue;
757
+ await flushFiles();
758
+ if (!stopped) await searchDir(fullPath);
327
759
  }
760
+ await flushFiles();
328
761
  }
329
762
 
330
- const rootStat = await stat(searchPath);
763
+ throwIfAborted(options.signal);
331
764
  if (rootStat.isDirectory()) await searchDir(searchPath);
332
- else await searchFile(searchPath);
333
- return output.toString();
765
+ else addFileRecords(await collectFileRecords(searchPath));
766
+ const result = output.toString();
767
+ return options.structured
768
+ ? { output: result, records, resultCount, truncated: output.truncated }
769
+ : result;
334
770
  }
335
771
 
336
772
  export default defineTool({
@@ -346,9 +782,11 @@ Output modes:
346
782
  - "count" — show match count per file
347
783
 
348
784
  Guidelines:
349
- - Uses regex syntax (escape special chars: \\., \\{, etc.)
785
+ - Uses the JavaScript RegExp syntax supported by the running Node.js version
786
+ - Escape special characters such as \\. and \\{
787
+ - Skips symlinks, binary files, invalid UTF-8, and text files larger than 16 MiB
350
788
  - Use glob or type filters to narrow the search
351
- - Skips binary files and common large directories (node_modules, .git)
789
+ - Skips common large directories such as node_modules and .git
352
790
  - Results are limited to 500 matches by default`,
353
791
  zh: `用正则表达式搜索文件内容。
354
792
 
@@ -360,9 +798,11 @@ Guidelines:
360
798
  - "count" — 显示每个文件的匹配数量
361
799
 
362
800
  使用指南:
363
- - 使用正则语法(特殊字符需转义:\\.、\\{ 等)
801
+ - 使用当前 Node.js 版本支持的 JavaScript RegExp 语法
802
+ - 特殊字符需转义,如 \\.、\\{
803
+ - 跳过符号链接、二进制、无效 UTF-8 和超过 16 MiB 的文本文件
364
804
  - 用 glob 或 type 过滤缩小搜索范围
365
- - 跳过二进制文件和常见大目录(node_modules、.git
805
+ - 跳过 node_modules、.git 等常见大目录
366
806
  - 默认结果限制 500 条`
367
807
  },
368
808
  parameters: {
@@ -491,35 +931,59 @@ Guidelines:
491
931
  before,
492
932
  after,
493
933
  multiline,
494
- maxResults: headLimit,
934
+ maxResults: headLimit + 1,
495
935
  byteBudget: SEARCH_RESULT_BYTES,
936
+ cwd: absPath,
937
+ signal: ctx?.signal,
938
+ structured: true,
496
939
  };
497
940
 
498
941
  try {
942
+ throwIfAborted(ctx?.signal);
943
+ const regexPlan = prepareGrepPattern(pattern, options);
944
+ options.regexPlan = regexPlan;
499
945
  let result;
500
- const rgAvailable = await hasRipgrep();
946
+ let rgCommand = null;
947
+ if (regexPlan.useRipgrep) {
948
+ rgCommand = resolveManagedCliCommand('rg', { yeaftDir: ctx?.yeaftDir });
949
+ if (!rgCommand) {
950
+ await waitForAbortable(managedCliToolReady(ctx?.managedCliReady, 'rg'), ctx?.signal);
951
+ rgCommand = resolveManagedCliCommand('rg', { yeaftDir: ctx?.yeaftDir });
952
+ }
953
+ }
501
954
 
502
- if (rgAvailable) {
503
- result = await runRipgrep(pattern, absPath, options);
955
+ if (rgCommand) {
956
+ try {
957
+ const candidatePaths = await listRipgrepCandidatePaths(rgCommand, absPath, {
958
+ ...options,
959
+ pattern,
960
+ });
961
+ result = await nodeGrep(pattern, absPath, { ...options, candidatePaths });
962
+ } catch (error) {
963
+ if (isAbortError(error)) throw error;
964
+ result = await nodeGrep(pattern, absPath, options);
965
+ }
504
966
  } else {
505
967
  result = await nodeGrep(pattern, absPath, options);
506
968
  }
507
969
 
508
- if (!result || !result.trim()) {
509
- return '(no matches)';
510
- }
511
-
512
- // Limit output lines, then enforce the byte budget at the actual tool
513
- // boundary so prefixes, JSON escaping, and result markers are included.
514
- const lines = result.trim().split('\n');
515
- if (lines.length > headLimit) {
516
- return boundToolOutput(
517
- lines.slice(0, headLimit).join('\n') + `\n\n... (${lines.length - headLimit} more results)`,
518
- );
519
- }
520
-
521
- return boundToolOutput(result.trim());
970
+ const {
971
+ output = '', records = [], resultCount = 0, truncated = false,
972
+ } = result || {};
973
+ if (records.length === 0 && !output.trim()) return '(no matches)';
974
+ if (truncated) return output.trim();
975
+ const sortedRecords = [...records].sort(compareGrepRecords);
976
+ const visibleMatches = sortedRecords
977
+ .filter(record => record.kind !== 'context')
978
+ .slice(0, headLimit);
979
+ return renderSelectedRecords(
980
+ sortedRecords,
981
+ visibleMatches,
982
+ options,
983
+ resultCount > headLimit,
984
+ );
522
985
  } catch (err) {
986
+ if (isAbortError(err)) throw err;
523
987
  return formatGrepError(err.message);
524
988
  }
525
989
  },