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