@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
JavaScript
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