Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ coverage/
server.log

# Local planning/documentation directories
plans/
plans/

# Test output files
test/test_output/
7 changes: 5 additions & 2 deletions src/handlers/search-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function handleStartSearch(args: unknown): Promise<ServerResult> {
includeHidden: parsed.data.includeHidden,
contextLines: parsed.data.contextLines,
timeout: parsed.data.timeout_ms,
earlyTermination: parsed.data.earlyTermination,
});

const searchTypeText = parsed.data.searchType === 'content' ? 'content search' : 'file search';
Expand Down Expand Up @@ -96,11 +97,13 @@ export async function handleGetMoreSearchResults(args: unknown): Promise<ServerR
parsed.data.length
);

if (results.isError) {
// Only return error if we have no results AND there's an actual error
// Permission errors should not block returning found results
if (results.isError && results.totalResults === 0 && results.error?.trim()) {
return {
content: [{
type: "text",
text: `Search session ${parsed.data.sessionId} encountered an error: ${results.error || 'Unknown error'}`
text: `Search session ${parsed.data.sessionId} encountered an error: ${results.error}`
}],
isError: true,
};
Expand Down
94 changes: 72 additions & 22 deletions src/search-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface SearchSessionOptions {
includeHidden?: boolean;
contextLines?: number;
timeout?: number;
earlyTermination?: boolean; // Stop search early when exact filename match is found
}

/**
Expand Down Expand Up @@ -127,14 +128,16 @@ export interface SearchSessionOptions {
sessionId,
searchType: options.searchType,
hasTimeout: !!timeoutMs,
timeoutMs
timeoutMs,
requestedPath: options.rootPath,
validatedPath: validPath
});

// Wait for first chunk of data or early completion instead of fixed delay
const firstChunk = new Promise<void>(resolve => {
const onData = () => {
session.process.stdout?.off('data', onData);
resolve();
const onData = () => {
session.process.stdout?.off('data', onData);
resolve();
};
session.process.stdout?.once('data', onData);
setTimeout(resolve, 40); // cap at 40ms instead of 50-100ms
Expand Down Expand Up @@ -189,27 +192,27 @@ export interface SearchSessionOptions {
totalResults: session.totalMatches + session.totalContextLines,
totalMatches: session.totalMatches, // Actual matches only
isComplete: session.isComplete,
isError: session.isError,
error: session.error,
isError: session.isError && !!session.error?.trim(), // Only error if we have actual errors
error: session.error?.trim() || undefined,
hasMoreResults: false, // Tail always returns what's available
runtime: Date.now() - session.startTime
};
}

// Handle positive offsets (range behavior) - like file reading
const slicedResults = allResults.slice(offset, offset + length);
const hasMoreResults = offset + length < allResults.length || !session.isComplete;

session.lastReadTime = Date.now();

return {
results: slicedResults,
returnedCount: slicedResults.length,
totalResults: session.totalMatches + session.totalContextLines,
totalMatches: session.totalMatches, // Actual matches only
isComplete: session.isComplete,
isError: session.isError,
error: session.error,
isError: session.isError && !!session.error?.trim(), // Only error if we have actual errors
error: session.error?.trim() || undefined,
hasMoreResults,
runtime: Date.now() - session.startTime
};
Expand Down Expand Up @@ -387,25 +390,58 @@ export interface SearchSessionOptions {

process.stderr?.on('data', (data: Buffer) => {
const errorText = data.toString();
session.error = (session.error || '') + errorText;
capture('search_session_error', {
sessionId: session.id,
error: errorText.substring(0, 200) // Limit error length for telemetry
});

// Filter meaningful errors
const filteredErrors = errorText
.split('\n')
.filter(line => {
const trimmed = line.trim();

// Skip empty lines and lines with just symbols/numbers/colons
if (!trimmed || trimmed.match(/^[\)\(\s\d:]*$/)) return false;

// Skip all ripgrep system errors that start with "rg:"
if (trimmed.startsWith('rg:')) return false;

return true;
});

// Only add to session.error if there are actual meaningful errors after filtering
if (filteredErrors.length > 0) {
const meaningfulErrors = filteredErrors.join('\n').trim();
if (meaningfulErrors) {
session.error = (session.error || '') + meaningfulErrors + '\n';
capture('search_session_error', {
sessionId: session.id,
error: meaningfulErrors.substring(0, 200)
});
}
}
});

process.on('close', (code: number) => {
// Process any remaining buffer content
if (session.buffer.trim()) {
this.processBufferedOutput(session, true);
}

session.isComplete = true;

if (code !== 0 && code !== 1) {
// ripgrep returns 1 when no matches found, which is not an error
session.isError = true;
session.error = session.error || `ripgrep exited with code ${code}`;

// Only treat as error if:
// 1. Unexpected exit code (not 0, 1, or 2) AND
// 2. We have meaningful errors after filtering AND
// 3. We found no results at all
if (code !== 0 && code !== 1 && code !== 2) {
// Codes 0=success, 1=no matches, 2=some files couldn't be searched
if (session.error?.trim() && session.totalMatches === 0) {
session.isError = true;
session.error = session.error || `ripgrep exited with code ${code}`;
}
}

// If we have results, don't mark as error even if there were permission issues
if (session.totalMatches > 0) {
session.isError = false;
}

capture('search_session_completed', {
Expand Down Expand Up @@ -455,6 +491,20 @@ export interface SearchSessionOptions {
} else {
session.totalMatches++;
}

// Early termination for exact filename matches (if enabled)
if (session.options.earlyTermination !== false && // Default to true
session.options.searchType === 'files' &&
this.isExactFilename(session.options.pattern) &&
result.file.endsWith(session.options.pattern)) {
// Found exact match, terminate search early
setTimeout(() => {
if (!session.process.killed) {
session.process.kill('SIGTERM');
}
}, 100); // Small delay to allow any remaining results
break;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,13 +313,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
- pattern: What to search for (file names OR content text)
- filePattern: Optional filter to limit search to specific file types (e.g., "*.js", "package.json")
- ignoreCase: Case-insensitive search (default: true). Works for both file names and content.
- earlyTermination: Stop search early when exact filename match is found (optional: defaults to true for file searches, false for content searches)

EXAMPLES:
- Find package.json files: searchType="files", pattern="package.json", filePattern="package.json"
- Find all JS files: searchType="files", pattern="*.js" (or use filePattern="*.js")
- Search for "TODO" in code: searchType="content", pattern="TODO", filePattern="*.js|*.ts"
- Case-sensitive file search: searchType="files", pattern="README", ignoreCase=false
- Case-insensitive file search: searchType="files", pattern="readme", ignoreCase=true
- Find exact file, stop after first match: searchType="files", pattern="config.json", earlyTermination=true
- Find all matching files: searchType="files", pattern="test.js", earlyTermination=false

Unlike regular search tools, this starts a background search process and returns
immediately with a session ID. Use get_more_search_results to get results as they
Expand Down
1 change: 1 addition & 0 deletions src/tools/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ export async function searchFiles(rootPath: string, pattern: string): Promise<st
searchType: 'files',
ignoreCase: true,
maxResults: 5000, // Higher limit for compatibility
earlyTermination: true, // Use early termination for better performance
});

const sessionId = result.sessionId;
Expand Down
1 change: 1 addition & 0 deletions src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const StartSearchArgsSchema = z.object({
includeHidden: z.boolean().optional().default(false),
contextLines: z.number().optional().default(5),
timeout_ms: z.number().optional(), // Match process naming convention
earlyTermination: z.boolean().optional(), // Stop search early when exact filename match is found (default: true for files, false for content)
});

export const GetMoreSearchResultsArgsSchema = z.object({
Expand Down
3 changes: 2 additions & 1 deletion src/utils/system-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,8 @@ MACOS-SPECIFIC NOTES:
- Python 3 might be 'python3' command, not 'python'
- Some GNU tools have different names (e.g., gsed instead of sed)
- System Integrity Protection (SIP) may block certain operations
- Use 'open' command to open files/applications from terminal`;
- Use 'open' command to open files/applications from terminal
- For file search: Use mdfind (Spotlight) for fastest exact filename searches`;
} else {
guidance += `

Expand Down