UNPKG

@every-env/sparkle-mcp-server

Version:

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

269 lines 9.35 kB
import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; import chokidar from "chokidar"; export class SparkleFolder { folderPath; fileIndex = new Map(); watcher; indexReady = false; constructor(folderPath) { this.folderPath = this.expandPath(folderPath); this.initialize(); } expandPath(folderPath) { if (folderPath.startsWith("~/")) { return path.join(os.homedir(), folderPath.slice(2)); } return folderPath; } async initialize() { // Create folder if it doesn't exist await fs.mkdir(this.folderPath, { recursive: true }); // Initial indexing await this.indexAllFiles(); // Set up file watcher this.setupWatcher(); this.indexReady = true; } setupWatcher() { this.watcher = chokidar.watch(this.folderPath, { persistent: true, ignoreInitial: true, depth: 5, }); this.watcher .on("add", (filePath) => this.onFileAdded(filePath)) .on("change", (filePath) => this.onFileChanged(filePath)) .on("unlink", (filePath) => this.onFileRemoved(filePath)); } async onFileAdded(filePath) { console.error(`New file in Sparkle folder: ${filePath}`); const metadata = await this.indexFile(filePath); // Auto-enhance file name if needed if (this.needsBetterName(metadata)) { await this.enhanceFileName(filePath, metadata); } } async onFileChanged(filePath) { console.error(`File changed: ${filePath}`); await this.indexFile(filePath); } onFileRemoved(filePath) { console.error(`File removed: ${filePath}`); this.fileIndex.delete(filePath); } async indexAllFiles() { try { const files = await this.walkDirectory(this.folderPath); for (const file of files) { await this.indexFile(file); } console.error(`Indexed ${this.fileIndex.size} files in Sparkle folder`); } catch (error) { console.error("Error indexing files:", error); } } async walkDirectory(dir) { const files = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory() && !entry.name.startsWith(".")) { files.push(...await this.walkDirectory(fullPath)); } else if (entry.isFile()) { files.push(fullPath); } } return files; } async indexFile(filePath) { const stats = await fs.stat(filePath); const ext = path.extname(filePath).toLowerCase(); const metadata = { path: filePath, name: path.basename(filePath), size: stats.size, modified: stats.mtime, type: this.getFileType(ext), }; // Extract content based on file type if (this.isTextFile(ext)) { try { const content = await fs.readFile(filePath, "utf-8"); metadata.content = content.slice(0, 50000); // First 50KB for indexing metadata.summary = this.generateSummary(content); } catch (error) { console.error(`Error reading ${filePath}:`, error); } } // Generate embedding for semantic search metadata.embedding = await this.generateEmbedding(metadata); this.fileIndex.set(filePath, metadata); return metadata; } getFileType(ext) { const typeMap = { ".pdf": "document", ".doc": "document", ".docx": "document", ".txt": "text", ".md": "text", ".jpg": "image", ".png": "image", ".mp3": "audio", ".wav": "audio", ".mp4": "video", ".mov": "video", ".csv": "data", ".json": "data", ".xlsx": "spreadsheet", }; return typeMap[ext] || "other"; } isTextFile(ext) { return [".txt", ".md", ".json", ".csv", ".log"].includes(ext); } generateSummary(content) { // Simple summary: first few lines const lines = content.split("\\n").filter(l => l.trim()); return lines.slice(0, 3).join(" ").slice(0, 200); } async generateEmbedding(metadata) { // Placeholder: In real implementation, use embeddings API // For now, create fake embedding based on content const text = metadata.content || metadata.name; const hash = this.simpleHash(text); // Generate pseudo-embedding const embedding = []; for (let i = 0; i < 128; i++) { embedding.push(Math.sin(hash * i) * Math.cos(hash / (i + 1))); } return embedding; } simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return Math.abs(hash); } needsBetterName(metadata) { // Check if file has generic name const genericPatterns = [ /^IMG_\\d+/, /^DSC\\d+/, /^Screenshot/, /^audio_recording/, /^REC\\d+/, /^untitled/i, ]; return genericPatterns.some(pattern => pattern.test(metadata.name)); } async enhanceFileName(filePath, metadata) { // Placeholder: In real implementation, use AI to generate name // For now, add timestamp const timestamp = new Date().toISOString().split("T")[0]; const ext = path.extname(filePath); const newName = `enhanced_${timestamp}_${metadata.name}`; const newPath = path.join(path.dirname(filePath), newName); try { await fs.rename(filePath, newPath); console.error(`Renamed ${filePath} to ${newPath}`); } catch (error) { console.error("Error renaming file:", error); } } async findRelevant(query, limit) { if (!this.indexReady) { await this.waitForIndex(); } console.error(`Finding relevant files for query: "${query}", fileIndex size: ${this.fileIndex.size}`); const queryEmbedding = await this.generateEmbedding({ name: query, content: query, }); const results = []; for (const [filePath, metadata] of this.fileIndex.entries()) { // Calculate relevance score const relevance = this.calculateRelevance(query, metadata, queryEmbedding, metadata.embedding || []); results.push({ path: filePath, relevance, summary: metadata.summary, metadata, }); } console.error(`Found ${results.length} results before sorting`); // Sort by relevance and return top results return results .sort((a, b) => b.relevance - a.relevance) .slice(0, limit); } calculateRelevance(query, metadata, queryEmbedding, fileEmbedding) { let score = 0; // 1. Semantic similarity (if embeddings available) if (fileEmbedding.length > 0) { score += this.cosineSimilarity(queryEmbedding, fileEmbedding) * 0.5; } // 2. Keyword matching in filename const queryWords = query.toLowerCase().split(/\\s+/); const nameWords = metadata.name.toLowerCase(); for (const word of queryWords) { if (nameWords.includes(word)) { score += 0.3; } } // 3. Content matching (if available) if (metadata.content) { const contentLower = metadata.content.toLowerCase(); for (const word of queryWords) { if (contentLower.includes(word)) { score += 0.2; } } } // 4. Recency bonus const daysSinceModified = (Date.now() - metadata.modified.getTime()) / (1000 * 60 * 60 * 24); if (daysSinceModified < 7) { score += 0.1; } return Math.min(score, 1.0); } cosineSimilarity(a, b) { if (a.length !== b.length) return 0; let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dotProduct += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } if (normA === 0 || normB === 0) return 0; return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); } async waitForIndex() { // Wait for initial indexing to complete while (!this.indexReady) { await new Promise(resolve => setTimeout(resolve, 100)); } } async cleanup() { if (this.watcher) { await this.watcher.close(); } } getFileCount() { return this.fileIndex.size; } } //# sourceMappingURL=sparkle-folder.js.map