@wonderwhy-er/desktop-commander 0.2.42 → 0.2.44

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 (53) hide show
  1. package/dist/bootstrap.d.ts +1 -0
  2. package/dist/bootstrap.js +22 -0
  3. package/dist/config-manager.d.ts +30 -1
  4. package/dist/config-manager.js +55 -8
  5. package/dist/handlers/filesystem-handlers.js +86 -52
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +3 -0
  8. package/dist/remote-device/desktop-commander-integration.js +8 -2
  9. package/dist/remote-device/remote-channel.d.ts +15 -0
  10. package/dist/remote-device/remote-channel.js +136 -27
  11. package/dist/server.d.ts +5 -1
  12. package/dist/server.js +118 -27
  13. package/dist/terminal-manager.d.ts +18 -0
  14. package/dist/terminal-manager.js +84 -18
  15. package/dist/tools/edit.d.ts +1 -1
  16. package/dist/tools/edit.js +34 -26
  17. package/dist/tools/filesystem.d.ts +1 -0
  18. package/dist/tools/filesystem.js +23 -13
  19. package/dist/tools/fuzzySearch.d.ts +10 -20
  20. package/dist/tools/fuzzySearch.js +66 -105
  21. package/dist/tools/fuzzySearchCore.d.ts +52 -0
  22. package/dist/tools/fuzzySearchCore.js +125 -0
  23. package/dist/tools/improved-process-tools.js +8 -1
  24. package/dist/tools/schemas.d.ts +33 -1
  25. package/dist/tools/schemas.js +61 -3
  26. package/dist/types.d.ts +3 -1
  27. package/dist/ui/config-editor/config-editor-runtime.js +49 -49
  28. package/dist/ui/config-editor/src/app.js +2 -11
  29. package/dist/ui/file-preview/preview-runtime.js +147 -146
  30. package/dist/ui/file-preview/src/app.js +172 -53
  31. package/dist/ui/file-preview/src/directory-controller.js +4 -4
  32. package/dist/ui/file-preview/src/file-type-handlers.js +2 -2
  33. package/dist/ui/file-preview/src/markdown/controller.js +4 -1
  34. package/dist/ui/file-preview/src/panel-actions.js +4 -4
  35. package/dist/ui/file-preview/src/payload-utils.js +23 -7
  36. package/dist/utils/capture.d.ts +2 -0
  37. package/dist/utils/capture.js +32 -7
  38. package/dist/utils/files/base.d.ts +2 -0
  39. package/dist/utils/files/image.js +1 -1
  40. package/dist/utils/files/text.js +24 -19
  41. package/dist/utils/open-browser.d.ts +1 -1
  42. package/dist/utils/open-browser.js +5 -2
  43. package/dist/utils/unsupportedParams.d.ts +24 -0
  44. package/dist/utils/unsupportedParams.js +66 -0
  45. package/dist/utils/usageTracker.d.ts +5 -1
  46. package/dist/utils/usageTracker.js +14 -3
  47. package/dist/utils/welcome-onboarding.d.ts +1 -1
  48. package/dist/utils/welcome-onboarding.js +2 -2
  49. package/dist/utils/withTimeout.d.ts +17 -0
  50. package/dist/utils/withTimeout.js +33 -0
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +5 -1
@@ -32,6 +32,17 @@ function getRepairedPathExt() {
32
32
  }
33
33
  return current;
34
34
  }
35
+ /**
36
+ * Output buffering caps. Without a cap, a process emitting enough output makes
37
+ * string concatenation throw "RangeError: Invalid string length" at V8's max
38
+ * string size (~536M chars) inside a stdout 'data' handler — an uncaught
39
+ * exception that kills the whole server (index.ts exits on uncaughtException).
40
+ * The cap also bounds the join() cost in snapshot reads and the periodic
41
+ * process-state scan, both of which are O(total output).
42
+ */
43
+ export const MAX_BUFFERED_OUTPUT_CHARS = 50 * 1024 * 1024; // per session; oldest lines evicted first
44
+ const MAX_LINE_CHARS = 1024 * 1024; // force-split longer lines so eviction can work
45
+ const MAX_WAIT_OUTPUT_CHARS = 2 * 1024 * 1024; // start_process wait buffer (prompt/state detection)
35
46
  /**
36
47
  * Get the appropriate spawn configuration for a given shell
37
48
  * This handles login shell flags for different shell types
@@ -207,7 +218,10 @@ export class TerminalManager {
207
218
  outputLines: [], // Line-based buffer
208
219
  lastReadIndex: 0, // Track where "new" output starts
209
220
  isBlocked: false,
210
- startTime: new Date()
221
+ startTime: new Date(),
222
+ bufferedChars: 0,
223
+ evictedLines: 0,
224
+ evictedChars: 0
211
225
  };
212
226
  this.sessions.set(childProcess.pid, session);
213
227
  // Timing telemetry
@@ -249,7 +263,14 @@ export class TerminalManager {
249
263
  if (!firstOutputTime)
250
264
  firstOutputTime = now;
251
265
  lastOutputTime = now;
252
- output += text;
266
+ // `output` only feeds the wait-phase result and prompt/state detection,
267
+ // so stop growing it once resolved and keep only a bounded tail.
268
+ if (!resolved) {
269
+ output += text;
270
+ if (output.length > MAX_WAIT_OUTPUT_CHARS) {
271
+ output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
272
+ }
273
+ }
253
274
  // Append to line-based buffer
254
275
  this.appendToLineBuffer(session, text);
255
276
  // Record output event if collecting timing
@@ -282,7 +303,12 @@ export class TerminalManager {
282
303
  if (!firstOutputTime)
283
304
  firstOutputTime = now;
284
305
  lastOutputTime = now;
285
- output += text;
306
+ if (!resolved) {
307
+ output += text;
308
+ if (output.length > MAX_WAIT_OUTPUT_CHARS) {
309
+ output = output.slice(-Math.floor(MAX_WAIT_OUTPUT_CHARS / 2));
310
+ }
311
+ }
286
312
  // Append to line-based buffer
287
313
  this.appendToLineBuffer(session, text);
288
314
  // Record output event if collecting timing
@@ -329,7 +355,9 @@ export class TerminalManager {
329
355
  outputLines: [...session.outputLines], // Copy line buffer
330
356
  exitCode: code,
331
357
  startTime: session.startTime,
332
- endTime: new Date()
358
+ endTime: new Date(),
359
+ evictedLines: session.evictedLines,
360
+ evictedChars: session.evictedChars
333
361
  });
334
362
  // Keep only last 100 completed sessions
335
363
  if (this.completedSessions.size > 100) {
@@ -373,6 +401,32 @@ export class TerminalManager {
373
401
  session.outputLines.push(line);
374
402
  }
375
403
  }
404
+ // Appended text contributes exactly its length to the joined buffer
405
+ // (its newlines become the join separators).
406
+ session.bufferedChars += text.length;
407
+ // A process printing without newlines grows a single line forever, which
408
+ // eviction can't bound — force-split so no line exceeds MAX_LINE_CHARS.
409
+ // Each inserted break adds one separator to the joined length.
410
+ let lastIndex = session.outputLines.length - 1;
411
+ while (session.outputLines[lastIndex].length > MAX_LINE_CHARS) {
412
+ const overlong = session.outputLines[lastIndex];
413
+ session.outputLines[lastIndex] = overlong.slice(0, MAX_LINE_CHARS);
414
+ session.outputLines.push(overlong.slice(MAX_LINE_CHARS));
415
+ session.bufferedChars += 1;
416
+ lastIndex++;
417
+ }
418
+ // Enforce the per-session cap by evicting the oldest lines. Keeps the
419
+ // buffer far below V8's max string length so concatenation and join()
420
+ // can never throw "Invalid string length" and kill the server.
421
+ while (session.bufferedChars > MAX_BUFFERED_OUTPUT_CHARS && session.outputLines.length > 1) {
422
+ const dropped = session.outputLines.shift();
423
+ const droppedJoinedChars = dropped.length + 1; // +1 for its join separator
424
+ session.bufferedChars -= droppedJoinedChars;
425
+ session.evictedChars += droppedJoinedChars;
426
+ session.evictedLines++;
427
+ if (session.lastReadIndex > 0)
428
+ session.lastReadIndex--;
429
+ }
376
430
  }
377
431
  /**
378
432
  * Read process output with pagination (like file reading)
@@ -385,15 +439,19 @@ export class TerminalManager {
385
439
  // First check active sessions
386
440
  const session = this.sessions.get(pid);
387
441
  if (session) {
388
- return this.readFromLineBuffer(session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined);
442
+ const result = this.readFromLineBuffer(session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined);
443
+ result.evictedLines = session.evictedLines;
444
+ return result;
389
445
  }
390
446
  // Then check completed sessions
391
447
  const completedSession = this.completedSessions.get(pid);
392
448
  if (completedSession) {
393
449
  const runtimeMs = completedSession.endTime.getTime() - completedSession.startTime.getTime();
394
- return this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, // Completed sessions don't track read position
450
+ const result = this.readFromLineBuffer(completedSession.outputLines, offset, length, 0, // Completed sessions don't track read position
395
451
  () => { }, // No-op for completed sessions
396
452
  true, completedSession.exitCode, runtimeMs);
453
+ result.evictedLines = completedSession.evictedLines;
454
+ return result;
397
455
  }
398
456
  return null;
399
457
  }
@@ -491,8 +549,11 @@ export class TerminalManager {
491
549
  if (session) {
492
550
  const fullOutput = session.outputLines.join('\n');
493
551
  return {
494
- totalChars: fullOutput.length,
495
- lineCount: session.outputLines.length
552
+ // Absolute since process start (includes evicted output), so the
553
+ // offset stays valid even if the cap evicts lines between
554
+ // snapshot and read.
555
+ totalChars: session.evictedChars + fullOutput.length,
556
+ lineCount: session.evictedLines + session.outputLines.length
496
557
  };
497
558
  }
498
559
  return null;
@@ -506,23 +567,28 @@ export class TerminalManager {
506
567
  // Check active session first
507
568
  const session = this.sessions.get(pid);
508
569
  if (session) {
509
- const fullOutput = session.outputLines.join('\n');
510
- if (fullOutput.length <= snapshot.totalChars) {
511
- return ''; // No new output
512
- }
513
- return fullOutput.substring(snapshot.totalChars);
570
+ return TerminalManager.outputSinceSnapshot(session.outputLines, session.evictedChars, snapshot.totalChars);
514
571
  }
515
572
  // Fallback to completed sessions - process may have finished between snapshot and poll
516
573
  const completedSession = this.completedSessions.get(pid);
517
574
  if (completedSession) {
518
- const fullOutput = completedSession.outputLines.join('\n');
519
- if (fullOutput.length <= snapshot.totalChars) {
520
- return ''; // No new output
521
- }
522
- return fullOutput.substring(snapshot.totalChars);
575
+ return TerminalManager.outputSinceSnapshot(completedSession.outputLines, completedSession.evictedChars, snapshot.totalChars);
523
576
  }
524
577
  return null;
525
578
  }
579
+ /**
580
+ * New output since a snapshot, in absolute (since process start) offsets.
581
+ * If eviction dropped part of the unseen output, returns what the buffer
582
+ * still holds — the oldest unseen chars are lost to the cap.
583
+ */
584
+ static outputSinceSnapshot(outputLines, evictedChars, snapshotTotalChars) {
585
+ const fullOutput = outputLines.join('\n');
586
+ const newChars = evictedChars + fullOutput.length - snapshotTotalChars;
587
+ if (newChars <= 0) {
588
+ return ''; // No new output
589
+ }
590
+ return fullOutput.substring(Math.max(0, fullOutput.length - newChars));
591
+ }
526
592
  /**
527
593
  * Get a session by PID
528
594
  * @param pid Process ID
@@ -19,7 +19,7 @@ interface SearchReplace {
19
19
  search: string;
20
20
  replace: string;
21
21
  }
22
- export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number): Promise<ServerResult>;
22
+ export declare function performSearchReplace(filePath: string, block: SearchReplace, expectedReplacements?: number, origin?: 'ui' | 'llm'): Promise<ServerResult>;
23
23
  /**
24
24
  * Handle edit_block command
25
25
  *
@@ -15,7 +15,7 @@
15
15
  * 3. Unify the editRange() signature to handle both text search/replace and structured edits
16
16
  */
17
17
  import { getDefaultEditorMetadata, writeFile, readFileInternal, validatePath } from './filesystem.js';
18
- import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js';
18
+ import { runFuzzySearchInWorker, getSimilarityRatio } from './fuzzySearch.js';
19
19
  import { capture } from '../utils/capture.js';
20
20
  import { createErrorResponse } from '../error-handlers.js';
21
21
  import { EditBlockArgsSchema } from "./schemas.js";
@@ -82,7 +82,7 @@ function getCharacterCodeData(expected, actual) {
82
82
  diffLength: fullDiff.length
83
83
  };
84
84
  }
85
- export async function performSearchReplace(filePath, block, expectedReplacements = 1) {
85
+ export async function performSearchReplace(filePath, block, expectedReplacements = 1, origin) {
86
86
  // Get file extension for telemetry using path module
87
87
  const fileExtension = path.extname(filePath).toLowerCase();
88
88
  // Capture file extension and string sizes in telemetry without capturing the file path
@@ -176,13 +176,18 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
176
176
  type: "text",
177
177
  text: `${statusLine}${previewContent}`
178
178
  }],
179
- structuredContent: {
180
- fileName: path.basename(resolvedEditPath),
181
- filePath: resolvedEditPath,
182
- fileType: resolvePreviewFileType(resolvedEditPath),
183
- sourceTool: 'edit_block',
184
- ...await getDefaultEditorMetadata(resolvedEditPath),
185
- },
179
+ // structuredContent only travels on widget-initiated calls: it is the
180
+ // widget's save-success signal (assertSuccessfulEditBlockResult) and
181
+ // metadata source. LLM-facing responses don't need it — nothing there
182
+ // consumes it, and the widget re-reads by path instead.
183
+ ...(origin === 'ui' ? {
184
+ structuredContent: {
185
+ fileName: path.basename(resolvedEditPath),
186
+ filePath: resolvedEditPath,
187
+ fileType: resolvePreviewFileType(resolvedEditPath),
188
+ ...await getDefaultEditorMetadata(resolvedEditPath),
189
+ },
190
+ } : {}),
186
191
  };
187
192
  }
188
193
  // If exact match found but count doesn't match expected, inform the user
@@ -202,8 +207,9 @@ RECOMMENDATION: For large search/replace operations, consider breaking them into
202
207
  if (count === 0) {
203
208
  // Track fuzzy search time
204
209
  const startTime = performance.now();
205
- // Perform fuzzy search
206
- const fuzzyResult = recursiveFuzzyIndexOf(content, block.search);
210
+ // Perform fuzzy search in a worker thread so the main event loop stays
211
+ // responsive to pings and parallel tool calls during the scan
212
+ const fuzzyResult = await runFuzzySearchInWorker(content, block.search);
207
213
  const similarity = getSimilarityRatio(block.search, fuzzyResult.value);
208
214
  // Calculate execution time in milliseconds
209
215
  const executionTime = performance.now() - startTime;
@@ -366,13 +372,14 @@ export async function handleEditBlock(args) {
366
372
  type: "text",
367
373
  text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
368
374
  }],
369
- structuredContent: {
370
- fileName: path.basename(resolvedRangePath),
371
- filePath: resolvedRangePath,
372
- fileType: resolvePreviewFileType(resolvedRangePath),
373
- sourceTool: 'edit_block',
374
- ...await getDefaultEditorMetadata(resolvedRangePath),
375
- },
375
+ ...(parsed.origin === 'ui' ? {
376
+ structuredContent: {
377
+ fileName: path.basename(resolvedRangePath),
378
+ filePath: resolvedRangePath,
379
+ fileType: resolvePreviewFileType(resolvedRangePath),
380
+ ...await getDefaultEditorMetadata(resolvedRangePath),
381
+ },
382
+ } : {}),
376
383
  };
377
384
  }
378
385
  catch (error) {
@@ -403,13 +410,14 @@ export async function handleEditBlock(args) {
403
410
  type: "text",
404
411
  text: `Successfully applied ${result.editsApplied} edit(s) to ${parsed.file_path}`
405
412
  }],
406
- structuredContent: {
407
- fileName: path.basename(resolvedEditRangePath),
408
- filePath: resolvedEditRangePath,
409
- fileType: resolvePreviewFileType(resolvedEditRangePath),
410
- sourceTool: 'edit_block',
411
- ...await getDefaultEditorMetadata(resolvedEditRangePath),
412
- },
413
+ ...(parsed.origin === 'ui' ? {
414
+ structuredContent: {
415
+ fileName: path.basename(resolvedEditRangePath),
416
+ filePath: resolvedEditRangePath,
417
+ fileType: resolvePreviewFileType(resolvedEditRangePath),
418
+ ...await getDefaultEditorMetadata(resolvedEditRangePath),
419
+ },
420
+ } : {}),
413
421
  };
414
422
  }
415
423
  const errorMsg = result.errors?.map(e => e.error).join('; ') || 'Unknown error';
@@ -423,5 +431,5 @@ export async function handleEditBlock(args) {
423
431
  return performSearchReplace(parsed.file_path, {
424
432
  search: parsed.old_string,
425
433
  replace: parsed.new_string
426
- }, parsed.expected_replacements);
434
+ }, parsed.expected_replacements, parsed.origin);
427
435
  }
@@ -1,5 +1,6 @@
1
1
  import type { ReadOptions, FileResult, PdfPageItem } from '../utils/files/base.js';
2
2
  import { PdfOperations, PdfMetadata } from './pdf/index.js';
3
+ export declare const READ_OPERATION_TIMEOUT_MS: number;
3
4
  /**
4
5
  * Validates a path to ensure it can be accessed or created.
5
6
  * For existing paths, returns the real path (resolving symlinks).
@@ -5,7 +5,7 @@ import fetch from 'cross-fetch';
5
5
  import { execFile } from 'child_process';
6
6
  import { promisify } from 'util';
7
7
  import { capture } from '../utils/capture.js';
8
- import { withTimeout } from '../utils/withTimeout.js';
8
+ import { withTimeout, runWithAbortableTimeout } from '../utils/withTimeout.js';
9
9
  import { configManager } from '../config-manager.js';
10
10
  import { getFileHandler, TextFileHandler } from '../utils/files/index.js';
11
11
  import { isPdfFile } from "./mime-types.js";
@@ -16,6 +16,13 @@ const FILE_OPERATION_TIMEOUTS = {
16
16
  URL_FETCH: 30000, // 30 seconds
17
17
  FILE_READ: 30000, // 30 seconds
18
18
  };
19
+ // Cap file read operations at 3 minutes. The MCP client's hard per-call limit
20
+ // is ~4 minutes; timing out at 3m lets us abort the underlying fs op and return
21
+ // a useful error (e.g. the cloud-storage guidance in buildPermissionError)
22
+ // BEFORE the client gives up with an opaque "No result received after 4
23
+ // minutes". Paired with runWithAbortableTimeout so the read is actually
24
+ // cancelled (fd/thread released), not just abandoned.
25
+ export const READ_OPERATION_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
19
26
  const FILE_SIZE_LIMITS = {
20
27
  LINE_COUNT_LIMIT: 10 * 1024 * 1024, // 10MB for line counting
21
28
  };
@@ -426,8 +433,9 @@ export async function readFileFromDisk(filePath, options) {
426
433
  capture('server_read_file_error', { error: errorMessage, fileExtension: fileExtension });
427
434
  // If we can't stat the file, continue anyway and let the read operation handle errors
428
435
  }
429
- // Use withTimeout to handle potential hangs
430
- const readOperation = async () => {
436
+ // Read under an abortable timeout so a hung/stalled read is cancelled
437
+ // (fd/thread freed) rather than leaked until the OS call returns.
438
+ const readOperation = async (signal) => {
431
439
  // Get appropriate handler for this file type (async - includes binary detection)
432
440
  const handler = await getFileHandler(validPath);
433
441
  // Use handler to read the file
@@ -436,7 +444,8 @@ export async function readFileFromDisk(filePath, options) {
436
444
  length,
437
445
  sheet,
438
446
  range,
439
- includeStatusMessage: true
447
+ includeStatusMessage: true,
448
+ signal
440
449
  });
441
450
  // Return with content as string
442
451
  // For images: content is already base64-encoded string from handler
@@ -458,18 +467,17 @@ export async function readFileFromDisk(filePath, options) {
458
467
  metadata: result.metadata
459
468
  };
460
469
  };
461
- // Execute with timeout
470
+ // Execute with a 3-minute, cancellable timeout
462
471
  let result;
463
472
  try {
464
- result = await withTimeout(readOperation(), FILE_OPERATION_TIMEOUTS.FILE_READ, `Read file operation for ${filePath}`, null);
473
+ result = await runWithAbortableTimeout((signal) => readOperation(signal), READ_OPERATION_TIMEOUT_MS, `Read file operation for ${filePath}`);
465
474
  }
466
475
  catch (error) {
467
476
  const err = error;
468
- // withTimeout rejects with a plain string "__ERROR__: ... timed out after N seconds"
469
- // when defaultValue is null it has no .code property, so check for that too.
470
- const isWithTimeoutString = typeof error === 'string' && error.startsWith('__ERROR__:');
471
- if (isWithTimeoutString || err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
472
- throw buildPermissionError(filePath, isWithTimeoutString ? 'ETIMEDOUT' : err.code);
477
+ // runWithAbortableTimeout rejects with an Error whose .code is 'ETIMEDOUT'
478
+ // on timeout; fs rejects with EPERM/EACCES. Map all to the guidance error.
479
+ if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'ETIMEDOUT') {
480
+ throw buildPermissionError(filePath, err.code);
473
481
  }
474
482
  throw error;
475
483
  }
@@ -515,8 +523,10 @@ export async function readFileInternal(filePath, offset = 0, length) {
515
523
  // IMPORTANT: For internal operations (especially edit operations), we must
516
524
  // preserve exact file content including original line endings.
517
525
  // We cannot use readline-based reading as it strips line endings.
518
- // Read entire file content preserving line endings
519
- const content = await fs.readFile(validPath, 'utf8');
526
+ // Read entire file content preserving line endings, under a 3-minute,
527
+ // cancellable timeout so an edit on a stalled/cloud path can't hang forever
528
+ // (previously this read had no timeout at all).
529
+ const content = await runWithAbortableTimeout((signal) => fs.readFile(validPath, { encoding: 'utf8', signal }), READ_OPERATION_TIMEOUT_MS, `Internal read for ${filePath}`);
520
530
  // If we need to apply offset/length, do it while preserving line endings
521
531
  if (offset === 0 && length >= Number.MAX_SAFE_INTEGER) {
522
532
  // Most common case for edit operations: read entire file
@@ -1,22 +1,12 @@
1
+ import type { FuzzyMatch } from './fuzzySearchCore.js';
2
+ export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js';
3
+ /** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */
4
+ export declare const FUZZY_SEARCH_TIMEOUT_MS = 30000;
1
5
  /**
2
- * Recursively finds the closest match to a query string within text using fuzzy matching
3
- * @param text The text to search within
4
- * @param query The query string to find
5
- * @param start Start index in the text (default: 0)
6
- * @param end End index in the text (default: text.length)
7
- * @param parentDistance Best distance found so far (default: Infinity)
8
- * @returns Object with start and end indices, matched value, and Levenshtein distance
6
+ * Runs the fuzzy search in a Worker thread so the main MCP event loop stays
7
+ * responsive to pings and other tool calls during heavy scans. Rejects if the
8
+ * scan exceeds timeoutMs, terminating the worker so it doesn't linger in the
9
+ * background. Search metrics come back with the result and are captured here,
10
+ * on the main thread, where the client identity is initialized.
9
11
  */
10
- export declare function recursiveFuzzyIndexOf(text: string, query: string, start?: number, end?: number | null, parentDistance?: number, depth?: number): {
11
- start: number;
12
- end: number;
13
- value: string;
14
- distance: number;
15
- };
16
- /**
17
- * Calculates the similarity ratio between two strings
18
- * @param a First string
19
- * @param b Second string
20
- * @returns Similarity ratio (0-1)
21
- */
22
- export declare function getSimilarityRatio(a: string, b: string): number;
12
+ export declare function runFuzzySearchInWorker(text: string, query: string, timeoutMs?: number): Promise<FuzzyMatch>;
@@ -1,113 +1,74 @@
1
- import { distance } from 'fastest-levenshtein';
2
1
  import { capture } from '../utils/capture.js';
2
+ import { Worker } from 'worker_threads';
3
+ // Re-export so existing callers keep importing from this module.
4
+ export { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearchCore.js';
5
+ /** Abort fuzzy search in the worker after this many ms to avoid unbounded CPU burn. */
6
+ export const FUZZY_SEARCH_TIMEOUT_MS = 30000;
3
7
  /**
4
- * Recursively finds the closest match to a query string within text using fuzzy matching
5
- * @param text The text to search within
6
- * @param query The query string to find
7
- * @param start Start index in the text (default: 0)
8
- * @param end End index in the text (default: text.length)
9
- * @param parentDistance Best distance found so far (default: Infinity)
10
- * @returns Object with start and end indices, matched value, and Levenshtein distance
8
+ * Inline worker entry: imports the dependency-free core module (passed as
9
+ * moduleUrl) and runs the search off the main thread. The core module is
10
+ * deliberately a leaf importing this module (or anything app-level) from the
11
+ * worker would boot the whole server per search. Kept as an eval'd snippet so
12
+ * the worker needs no separate file to ship alongside the compiled output.
11
13
  */
12
- export function recursiveFuzzyIndexOf(text, query, start = 0, end = null, parentDistance = Infinity, depth = 0) {
13
- // For debugging and performance tracking purposes
14
- if (depth === 0) {
15
- const startTime = performance.now();
16
- const result = recursiveFuzzyIndexOf(text, query, start, end, parentDistance, depth + 1);
17
- const executionTime = performance.now() - startTime;
18
- // Capture detailed metrics for the recursive search for in-depth analysis
19
- capture('fuzzy_search_recursive_metrics', {
20
- execution_time_ms: executionTime,
21
- text_length: text.length,
22
- query_length: query.length,
23
- result_distance: result.distance
24
- });
25
- return result;
26
- }
27
- if (end === null)
28
- end = text.length;
29
- // For small text segments, use iterative approach
30
- if (end - start <= 2 * query.length) {
31
- return iterativeReduction(text, query, start, end, parentDistance);
32
- }
33
- let midPoint = start + Math.floor((end - start) / 2);
34
- let leftEnd = Math.min(end, midPoint + query.length); // Include query length to cover overlaps
35
- let rightStart = Math.max(start, midPoint - query.length); // Include query length to cover overlaps
36
- // Calculate distance for current segments
37
- let leftDistance = distance(text.substring(start, leftEnd), query);
38
- let rightDistance = distance(text.substring(rightStart, end), query);
39
- let bestDistance = Math.min(leftDistance, parentDistance, rightDistance);
40
- // If parent distance is already the best, use iterative approach
41
- if (parentDistance === bestDistance) {
42
- return iterativeReduction(text, query, start, end, parentDistance);
43
- }
44
- // Recursively search the better half
45
- if (leftDistance < rightDistance) {
46
- return recursiveFuzzyIndexOf(text, query, start, leftEnd, bestDistance, depth + 1);
47
- }
48
- else {
49
- return recursiveFuzzyIndexOf(text, query, rightStart, end, bestDistance, depth + 1);
50
- }
51
- }
14
+ const WORKER_CODE = `
15
+ const { workerData, parentPort } = require('worker_threads');
16
+ import(workerData.moduleUrl)
17
+ .then((m) => {
18
+ parentPort.postMessage({ ok: true, ...m.runFuzzySearch(workerData.text, workerData.query) });
19
+ })
20
+ .catch((err) => {
21
+ parentPort.postMessage({ ok: false, error: String(err && err.stack || err) });
22
+ });
23
+ `;
24
+ const CORE_MODULE_URL = new URL('./fuzzySearchCore.js', import.meta.url).href;
52
25
  /**
53
- * Iteratively refines the best match by reducing the search area
54
- * @param text The text to search within
55
- * @param query The query string to find
56
- * @param start Start index in the text
57
- * @param end End index in the text
58
- * @param parentDistance Best distance found so far
59
- * @returns Object with start and end indices, matched value, and Levenshtein distance
26
+ * Runs the fuzzy search in a Worker thread so the main MCP event loop stays
27
+ * responsive to pings and other tool calls during heavy scans. Rejects if the
28
+ * scan exceeds timeoutMs, terminating the worker so it doesn't linger in the
29
+ * background. Search metrics come back with the result and are captured here,
30
+ * on the main thread, where the client identity is initialized.
60
31
  */
61
- function iterativeReduction(text, query, start, end, parentDistance) {
62
- const startTime = performance.now();
63
- let iterations = 0;
64
- let bestDistance = parentDistance;
65
- let bestStart = start;
66
- let bestEnd = end;
67
- // Improve start position
68
- let nextDistance = distance(text.substring(bestStart + 1, bestEnd), query);
69
- while (nextDistance < bestDistance) {
70
- bestDistance = nextDistance;
71
- bestStart++;
72
- const smallerString = text.substring(bestStart + 1, bestEnd);
73
- nextDistance = distance(smallerString, query);
74
- iterations++;
75
- }
76
- // Improve end position
77
- nextDistance = distance(text.substring(bestStart, bestEnd - 1), query);
78
- while (nextDistance < bestDistance) {
79
- bestDistance = nextDistance;
80
- bestEnd--;
81
- const smallerString = text.substring(bestStart, bestEnd - 1);
82
- nextDistance = distance(smallerString, query);
83
- iterations++;
84
- }
85
- const executionTime = performance.now() - startTime;
86
- // Capture metrics for the iterative refinement phase
87
- capture('fuzzy_search_iterative_metrics', {
88
- execution_time_ms: executionTime,
89
- iterations: iterations,
90
- segment_length: end - start,
91
- query_length: query.length,
92
- final_distance: bestDistance
32
+ export function runFuzzySearchInWorker(text, query, timeoutMs = FUZZY_SEARCH_TIMEOUT_MS) {
33
+ return new Promise((resolve, reject) => {
34
+ const worker = new Worker(WORKER_CODE, { eval: true, workerData: { moduleUrl: CORE_MODULE_URL, text, query } });
35
+ // Never let a scan keep the server process alive during shutdown.
36
+ worker.unref();
37
+ const timer = setTimeout(() => {
38
+ worker.terminate();
39
+ reject(new Error(`Fuzzy search timed out after ${timeoutMs}ms`));
40
+ }, timeoutMs);
41
+ timer.unref();
42
+ worker.on('message', (msg) => {
43
+ clearTimeout(timer);
44
+ if (msg.ok) {
45
+ captureFuzzySearchMetrics(msg.metrics);
46
+ resolve(msg.result);
47
+ }
48
+ else {
49
+ reject(new Error(`Fuzzy search worker failed: ${msg.error}`));
50
+ }
51
+ // Don't let the worker wind down on its own; the answer is already
52
+ // here, and a lingering worker holds its copy of the file text.
53
+ // The promise is settled, so the exit-code rejection below is a no-op.
54
+ worker.terminate();
55
+ });
56
+ worker.on('error', (err) => {
57
+ clearTimeout(timer);
58
+ reject(err);
59
+ });
60
+ worker.on('exit', (code) => {
61
+ clearTimeout(timer);
62
+ if (code !== 0) {
63
+ reject(new Error(`Fuzzy search worker exited with code ${code}`));
64
+ }
65
+ });
93
66
  });
94
- return {
95
- start: bestStart,
96
- end: bestEnd,
97
- value: text.substring(bestStart, bestEnd),
98
- distance: bestDistance
99
- };
100
67
  }
101
- /**
102
- * Calculates the similarity ratio between two strings
103
- * @param a First string
104
- * @param b Second string
105
- * @returns Similarity ratio (0-1)
106
- */
107
- export function getSimilarityRatio(a, b) {
108
- const maxLength = Math.max(a.length, b.length);
109
- if (maxLength === 0)
110
- return 1; // Both strings are empty
111
- const levenshteinDistance = distance(a, b);
112
- return 1 - (levenshteinDistance / maxLength);
68
+ /** Same telemetry events the search used to emit inline, now sent from the main thread. */
69
+ function captureFuzzySearchMetrics(metrics) {
70
+ capture('fuzzy_search_recursive_metrics', metrics.recursive);
71
+ if (metrics.iterative) {
72
+ capture('fuzzy_search_iterative_metrics', metrics.iterative);
73
+ }
113
74
  }