UNPKG

@every-env/sparkle-mcp-server

Version:

MCP server for secure Sparkle folder file access with Claude AI, including clipboard history support

240 lines 9.99 kB
import { spawn } from "child_process"; import * as path from "path"; import * as os from "os"; export class FileSearchEngine { // No default locations - must be explicitly provided defaultLocations = []; async search(options) { const { query, locations = this.defaultLocations, fileTypes = [], limit = 50, } = options; // Expand paths const searchPaths = locations.map(loc => this.expandPath(loc)); // Parse query to understand intent const searchTerms = this.parseQuery(query); const results = []; for (const searchPath of searchPaths) { try { const pathResults = await this.searchInPath(searchPath, searchTerms, fileTypes, limit - results.length); results.push(...pathResults); if (results.length >= limit) break; } catch (error) { console.error(`Error searching ${searchPath}:`, error); } } return results.slice(0, limit); } expandPath(folderPath) { if (folderPath.startsWith("~/")) { return path.join(os.homedir(), folderPath.slice(2)); } return folderPath; } parseQuery(query) { const queryLower = query.toLowerCase(); const keywords = []; const fileTypes = []; // Extract file type hints if (queryLower.includes("pdf")) fileTypes.push(".pdf"); if (queryLower.includes("document")) fileTypes.push(".doc", ".docx", ".pdf"); if (queryLower.includes("image") || queryLower.includes("photo")) { fileTypes.push(".jpg", ".jpeg", ".png", ".gif"); } if (queryLower.includes("video")) fileTypes.push(".mp4", ".mov", ".avi"); if (queryLower.includes("audio") || queryLower.includes("podcast")) { fileTypes.push(".mp3", ".wav", ".m4a"); } // Extract keywords (simple tokenization) const words = query.split(/\\s+/); for (const word of words) { if (word.length > 2 && !this.isStopWord(word)) { keywords.push(word.toLowerCase()); } } return { keywords, fileTypes }; } isStopWord(word) { const stopWords = ["the", "is", "at", "which", "on", "a", "an", "and", "or", "but", "in", "with", "to", "for", "of", "as", "by", "that", "this", "from", "up", "out", "if", "about", "into", "through", "during", "how", "when", "where", "why", "what", "who", "whose", "whom", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "should", "could", "may", "might", "must", "shall", "can", "need", "ought", "dare", "used"]; return stopWords.includes(word.toLowerCase()); } async searchInPath(searchPath, searchTerms, requestedFileTypes, limit) { const results = []; // Combine file types from query parsing and explicit request const fileTypes = [ ...new Set([...(searchTerms.fileTypes || []), ...requestedFileTypes]) ]; try { // First, try to use find command for file name search const findResults = await this.findByName(searchPath, searchTerms.keywords, fileTypes, limit); results.push(...findResults); // If we need more results and have text file types, search content if (results.length < limit && this.shouldSearchContent(fileTypes)) { const contentResults = await this.searchContent(searchPath, searchTerms.keywords, fileTypes, limit - results.length); results.push(...contentResults); } } catch (error) { console.error("Search error:", error); } return results; } async findByName(searchPath, keywords, fileTypes, limit) { return new Promise((resolve) => { const results = []; // Build find command let findCmd = `find "${searchPath}" -type f`; // Add file type filters if (fileTypes.length > 0) { const nameFilters = fileTypes.map(ext => `-name "*${ext}"`).join(" -o "); findCmd += ` \\( ${nameFilters} \\)`; } // Limit depth to avoid too deep recursion findCmd += " -maxdepth 5"; const find = spawn("sh", ["-c", findCmd]); let stdout = ""; find.stdout.on("data", (data) => { stdout += data.toString(); }); find.on("close", () => { const files = stdout.split("\\n").filter(f => f.trim()); for (const file of files) { if (results.length >= limit) break; const relevance = this.calculateNameRelevance(file, keywords); if (relevance > 0) { results.push({ path: file, relevance, }); } } resolve(results .sort((a, b) => b.relevance - a.relevance) .slice(0, limit)); }); find.on("error", () => { resolve([]); }); }); } calculateNameRelevance(filePath, keywords) { const fileName = path.basename(filePath).toLowerCase(); let relevance = 0; for (const keyword of keywords) { if (fileName.includes(keyword)) { relevance += 0.6; } } // Boost for exact matches for (const keyword of keywords) { if (fileName === keyword || fileName === `${keyword}.pdf` || fileName === `${keyword}.doc`) { relevance += 0.4; } } return Math.min(relevance, 1.0); } shouldSearchContent(fileTypes) { const textTypes = [".txt", ".md", ".json", ".csv", ".log", ".js", ".ts", ".py"]; return fileTypes.length === 0 || fileTypes.some(ft => textTypes.includes(ft)); } async searchContent(searchPath, keywords, fileTypes, limit) { // Simple grep-based content search const results = []; for (const keyword of keywords) { if (results.length >= limit) break; const grepResults = await this.grepSearch(searchPath, keyword, fileTypes); results.push(...grepResults); } return results .sort((a, b) => b.relevance - a.relevance) .slice(0, limit); } async grepSearch(searchPath, keyword, fileTypes) { return new Promise((resolve) => { const results = []; // Try to use ripgrep first, fallback to grep let rgPath = null; try { rgPath = require('@vscode/ripgrep').rgPath; } catch (e) { // Ripgrep not available, use regular grep } let cmd; let args; if (rgPath) { // Use ripgrep - much faster and more features args = [ '-i', // case insensitive '--files-with-matches', // only show filenames '--max-count', '1', // stop at first match per file '--max-filesize', '50M', // skip large files '--type-add', 'custom:*{.txt,.md,.json,.csv,.log,.js,.ts,.py}', ]; if (fileTypes.length > 0) { // Add glob patterns for file types fileTypes.forEach(ext => { args.push('-g', `*${ext}`); }); } else { // Search common text files by default args.push('--type', 'custom'); } args.push(keyword, searchPath); cmd = rgPath; } else { // Fallback to grep cmd = 'sh'; let grepCmd = `grep -r -i -l "${keyword}" "${searchPath}"`; if (fileTypes.length > 0) { const includes = fileTypes.map(ext => `--include="*${ext}"`).join(" "); grepCmd += ` ${includes}`; } grepCmd += " 2>/dev/null | head -20"; args = ['-c', grepCmd]; } const proc = spawn(cmd, args); let stdout = ""; let lineCount = 0; const maxResults = 20; proc.stdout.on("data", (data) => { stdout += data.toString(); // Process results as they come for better performance const lines = stdout.split("\\n"); stdout = lines.pop() || ""; // Keep incomplete line for (const line of lines) { if (line.trim() && lineCount < maxResults) { results.push({ path: line.trim(), relevance: 0.7, matchedContent: `Contains "${keyword}"`, }); lineCount++; } } }); proc.on("close", () => { // Process any remaining output if (stdout.trim() && lineCount < maxResults) { results.push({ path: stdout.trim(), relevance: 0.7, matchedContent: `Contains "${keyword}"`, }); } resolve(results); }); proc.on("error", (error) => { console.error("Search process error:", error); resolve([]); }); }); } } //# sourceMappingURL=search-engine.js.map