@mhalder/qdrant-mcp-server 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecov.yml +16 -0
- package/CHANGELOG.md +25 -0
- package/README.md +304 -9
- package/build/code/chunker/base.d.ts +19 -0
- package/build/code/chunker/base.d.ts.map +1 -0
- package/build/code/chunker/base.js +5 -0
- package/build/code/chunker/base.js.map +1 -0
- package/build/code/chunker/character-chunker.d.ts +22 -0
- package/build/code/chunker/character-chunker.d.ts.map +1 -0
- package/build/code/chunker/character-chunker.js +111 -0
- package/build/code/chunker/character-chunker.js.map +1 -0
- package/build/code/chunker/tree-sitter-chunker.d.ts +29 -0
- package/build/code/chunker/tree-sitter-chunker.d.ts.map +1 -0
- package/build/code/chunker/tree-sitter-chunker.js +213 -0
- package/build/code/chunker/tree-sitter-chunker.js.map +1 -0
- package/build/code/config.d.ts +11 -0
- package/build/code/config.d.ts.map +1 -0
- package/build/code/config.js +145 -0
- package/build/code/config.js.map +1 -0
- package/build/code/indexer.d.ts +42 -0
- package/build/code/indexer.d.ts.map +1 -0
- package/build/code/indexer.js +508 -0
- package/build/code/indexer.js.map +1 -0
- package/build/code/metadata.d.ts +32 -0
- package/build/code/metadata.d.ts.map +1 -0
- package/build/code/metadata.js +128 -0
- package/build/code/metadata.js.map +1 -0
- package/build/code/scanner.d.ts +35 -0
- package/build/code/scanner.d.ts.map +1 -0
- package/build/code/scanner.js +108 -0
- package/build/code/scanner.js.map +1 -0
- package/build/code/sync/merkle.d.ts +45 -0
- package/build/code/sync/merkle.d.ts.map +1 -0
- package/build/code/sync/merkle.js +116 -0
- package/build/code/sync/merkle.js.map +1 -0
- package/build/code/sync/snapshot.d.ts +41 -0
- package/build/code/sync/snapshot.d.ts.map +1 -0
- package/build/code/sync/snapshot.js +91 -0
- package/build/code/sync/snapshot.js.map +1 -0
- package/build/code/sync/synchronizer.d.ts +53 -0
- package/build/code/sync/synchronizer.d.ts.map +1 -0
- package/build/code/sync/synchronizer.js +132 -0
- package/build/code/sync/synchronizer.js.map +1 -0
- package/build/code/types.d.ts +98 -0
- package/build/code/types.d.ts.map +1 -0
- package/build/code/types.js +5 -0
- package/build/code/types.js.map +1 -0
- package/build/index.js +321 -6
- package/build/index.js.map +1 -1
- package/build/prompts/index.d.ts +7 -0
- package/build/prompts/index.d.ts.map +1 -0
- package/build/prompts/index.js +7 -0
- package/build/prompts/index.js.map +1 -0
- package/build/prompts/index.test.d.ts +2 -0
- package/build/prompts/index.test.d.ts.map +1 -0
- package/build/prompts/index.test.js +25 -0
- package/build/prompts/index.test.js.map +1 -0
- package/build/prompts/loader.d.ts +25 -0
- package/build/prompts/loader.d.ts.map +1 -0
- package/build/prompts/loader.js +81 -0
- package/build/prompts/loader.js.map +1 -0
- package/build/prompts/loader.test.d.ts +2 -0
- package/build/prompts/loader.test.d.ts.map +1 -0
- package/build/prompts/loader.test.js +417 -0
- package/build/prompts/loader.test.js.map +1 -0
- package/build/prompts/template.d.ts +20 -0
- package/build/prompts/template.d.ts.map +1 -0
- package/build/prompts/template.js +52 -0
- package/build/prompts/template.js.map +1 -0
- package/build/prompts/template.test.d.ts +2 -0
- package/build/prompts/template.test.d.ts.map +1 -0
- package/build/prompts/template.test.js +163 -0
- package/build/prompts/template.test.js.map +1 -0
- package/build/prompts/types.d.ts +34 -0
- package/build/prompts/types.d.ts.map +1 -0
- package/build/prompts/types.js +5 -0
- package/build/prompts/types.js.map +1 -0
- package/examples/code-search/README.md +271 -0
- package/package.json +13 -1
- package/prompts.example.json +96 -0
- package/src/code/chunker/base.ts +22 -0
- package/src/code/chunker/character-chunker.ts +131 -0
- package/src/code/chunker/tree-sitter-chunker.ts +250 -0
- package/src/code/config.ts +156 -0
- package/src/code/indexer.ts +613 -0
- package/src/code/metadata.ts +153 -0
- package/src/code/scanner.ts +124 -0
- package/src/code/sync/merkle.ts +136 -0
- package/src/code/sync/snapshot.ts +110 -0
- package/src/code/sync/synchronizer.ts +154 -0
- package/src/code/types.ts +117 -0
- package/src/index.ts +382 -5
- package/src/prompts/index.test.ts +29 -0
- package/src/prompts/index.ts +7 -0
- package/src/prompts/loader.test.ts +494 -0
- package/src/prompts/loader.ts +90 -0
- package/src/prompts/template.test.ts +212 -0
- package/src/prompts/template.ts +69 -0
- package/src/prompts/types.ts +37 -0
- package/tests/code/chunker/character-chunker.test.ts +141 -0
- package/tests/code/chunker/tree-sitter-chunker.test.ts +275 -0
- package/tests/code/fixtures/sample-py/calculator.py +32 -0
- package/tests/code/fixtures/sample-ts/async-operations.ts +120 -0
- package/tests/code/fixtures/sample-ts/auth.ts +31 -0
- package/tests/code/fixtures/sample-ts/config.ts +52 -0
- package/tests/code/fixtures/sample-ts/database.ts +50 -0
- package/tests/code/fixtures/sample-ts/index.ts +39 -0
- package/tests/code/fixtures/sample-ts/types-advanced.ts +132 -0
- package/tests/code/fixtures/sample-ts/utils.ts +105 -0
- package/tests/code/fixtures/sample-ts/validator.ts +169 -0
- package/tests/code/indexer.test.ts +828 -0
- package/tests/code/integration.test.ts +708 -0
- package/tests/code/metadata.test.ts +457 -0
- package/tests/code/scanner.test.ts +131 -0
- package/tests/code/sync/merkle.test.ts +406 -0
- package/tests/code/sync/snapshot.test.ts +360 -0
- package/tests/code/sync/synchronizer.test.ts +501 -0
- package/vitest.config.ts +1 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetadataExtractor - Extracts metadata from code chunks
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { extname } from "node:path";
|
|
7
|
+
import { LANGUAGE_MAP } from "./config.js";
|
|
8
|
+
import type { CodeChunk } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export class MetadataExtractor {
|
|
11
|
+
/**
|
|
12
|
+
* Extract programming language from file path
|
|
13
|
+
*/
|
|
14
|
+
extractLanguage(filePath: string): string {
|
|
15
|
+
const ext = extname(filePath);
|
|
16
|
+
return LANGUAGE_MAP[ext] || "unknown";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate deterministic chunk ID based on content and location
|
|
21
|
+
* Format: chunk_{sha256(path:start:end:content)[:16]}
|
|
22
|
+
*/
|
|
23
|
+
generateChunkId(chunk: CodeChunk): string {
|
|
24
|
+
const { metadata, startLine, endLine, content } = chunk;
|
|
25
|
+
const combined = `${metadata.filePath}:${startLine}:${endLine}:${content}`;
|
|
26
|
+
const hash = createHash("sha256").update(combined).digest("hex");
|
|
27
|
+
return `chunk_${hash.substring(0, 16)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate simple code complexity score (optional)
|
|
32
|
+
* Based on: cyclomatic complexity indicators
|
|
33
|
+
*/
|
|
34
|
+
calculateComplexity(code: string): number {
|
|
35
|
+
if (!code || code.trim().length === 0) {
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let complexity = 0;
|
|
40
|
+
|
|
41
|
+
// Count control flow statements
|
|
42
|
+
const controlFlowPatterns = [
|
|
43
|
+
/\bif\b/g,
|
|
44
|
+
/\belse\b/g,
|
|
45
|
+
/\bfor\b/g,
|
|
46
|
+
/\bwhile\b/g,
|
|
47
|
+
/\bswitch\b/g,
|
|
48
|
+
/\bcase\b/g,
|
|
49
|
+
/\bcatch\b/g,
|
|
50
|
+
/&&/g,
|
|
51
|
+
/\|\|/g,
|
|
52
|
+
/\?[^?]/g, // Ternary operator
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const pattern of controlFlowPatterns) {
|
|
56
|
+
const matches = code.match(pattern);
|
|
57
|
+
if (matches) {
|
|
58
|
+
complexity += matches.length;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If code contains function/method/class, add base complexity of 1
|
|
63
|
+
if (complexity > 0 || /\b(function|class|def|fn)\b/.test(code)) {
|
|
64
|
+
complexity = Math.max(1, complexity);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return complexity;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Detect potential secrets in code (basic pattern matching)
|
|
72
|
+
*/
|
|
73
|
+
containsSecrets(code: string): boolean {
|
|
74
|
+
const secretPatterns = [
|
|
75
|
+
/(?:api[-_]?key|apikey)\s*=\s*['"][^'"]{20,}['"]/i,
|
|
76
|
+
/(?:secret|SECRET)\s*=\s*['"][^'"]{20,}['"]/i,
|
|
77
|
+
/(?:password|PASSWORD)\s*=\s*['"][^'"]{8,}['"]/i,
|
|
78
|
+
/(?:token|TOKEN)\s*=\s*['"][^'"]{20,}['"]/i,
|
|
79
|
+
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/,
|
|
80
|
+
/sk_live_[a-zA-Z0-9]{24,}/, // Stripe secret key
|
|
81
|
+
/ghp_[a-zA-Z0-9]{36,}/, // GitHub personal access token
|
|
82
|
+
/AIza[0-9A-Za-z\\-_]{35}/, // Google API key
|
|
83
|
+
/AKIA[0-9A-Z]{16}/, // AWS access key
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const pattern of secretPatterns) {
|
|
87
|
+
if (pattern.test(code)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Extract imports/exports from code (basic regex-based)
|
|
97
|
+
*/
|
|
98
|
+
extractImportsExports(
|
|
99
|
+
code: string,
|
|
100
|
+
language: string
|
|
101
|
+
): {
|
|
102
|
+
imports: string[];
|
|
103
|
+
exports: string[];
|
|
104
|
+
} {
|
|
105
|
+
const imports: string[] = [];
|
|
106
|
+
const exports: string[] = [];
|
|
107
|
+
|
|
108
|
+
if (language === "typescript" || language === "javascript") {
|
|
109
|
+
// Extract imports
|
|
110
|
+
const importMatches = code.matchAll(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g);
|
|
111
|
+
for (const match of importMatches) {
|
|
112
|
+
imports.push(match[1]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract require statements
|
|
116
|
+
const requireMatches = code.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
|
|
117
|
+
for (const match of requireMatches) {
|
|
118
|
+
imports.push(match[1]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Extract exports - regular declarations
|
|
122
|
+
const exportMatches = code.matchAll(/export\s+(?:class|function|const|let|var)\s+(\w+)/g);
|
|
123
|
+
for (const match of exportMatches) {
|
|
124
|
+
exports.push(match[1]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Extract export default
|
|
128
|
+
if (/export\s+default\b/.test(code)) {
|
|
129
|
+
exports.push("default");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Extract named exports from other modules: export { name } from 'module'
|
|
133
|
+
const reExportMatches = code.matchAll(/export\s+\{\s*(\w+)\s*\}/g);
|
|
134
|
+
for (const match of reExportMatches) {
|
|
135
|
+
exports.push(match[1]);
|
|
136
|
+
}
|
|
137
|
+
} else if (language === "python") {
|
|
138
|
+
// Extract imports
|
|
139
|
+
const importMatches = code.matchAll(/(?:from\s+(\S+)\s+)?import\s+([^;\n]+)/g);
|
|
140
|
+
for (const match of importMatches) {
|
|
141
|
+
imports.push(match[1] || match[2]);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Extract functions/classes (rough)
|
|
145
|
+
const defMatches = code.matchAll(/^(?:def|class)\s+(\w+)/gm);
|
|
146
|
+
for (const match of defMatches) {
|
|
147
|
+
exports.push(match[1]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { imports, exports };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileScanner - Discovers code files in a directory while respecting ignore patterns
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { extname, join, relative, resolve } from "node:path";
|
|
7
|
+
import ignore, { type Ignore } from "ignore";
|
|
8
|
+
import type { ScannerConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export class FileScanner {
|
|
11
|
+
private ig: Ignore = ignore();
|
|
12
|
+
private supportedExts: Set<string>;
|
|
13
|
+
|
|
14
|
+
constructor(private config: ScannerConfig) {
|
|
15
|
+
this.supportedExts = new Set(config.supportedExtensions);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load ignore patterns from .gitignore, .dockerignore, .npmignore, and .contextignore
|
|
20
|
+
*/
|
|
21
|
+
async loadIgnorePatterns(rootPath: string): Promise<void> {
|
|
22
|
+
const ignoreFiles = [".gitignore", ".dockerignore", ".npmignore", ".contextignore"];
|
|
23
|
+
|
|
24
|
+
for (const ignoreFile of ignoreFiles) {
|
|
25
|
+
const ignorePath = join(rootPath, ignoreFile);
|
|
26
|
+
if (await this.fileExists(ignorePath)) {
|
|
27
|
+
try {
|
|
28
|
+
const content = await fs.readFile(ignorePath, "utf-8");
|
|
29
|
+
this.ig.add(content);
|
|
30
|
+
} catch (_error) {
|
|
31
|
+
// Silently skip if file can't be read
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Add default patterns from config
|
|
37
|
+
if (this.config.ignorePatterns && this.config.ignorePatterns.length > 0) {
|
|
38
|
+
this.ig.add(this.config.ignorePatterns);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Add custom patterns
|
|
42
|
+
if (this.config.customIgnorePatterns && this.config.customIgnorePatterns.length > 0) {
|
|
43
|
+
this.ig.add(this.config.customIgnorePatterns);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scan directory recursively and return all code files
|
|
49
|
+
*/
|
|
50
|
+
async scanDirectory(rootPath: string): Promise<string[]> {
|
|
51
|
+
const absoluteRoot = resolve(rootPath);
|
|
52
|
+
const files: string[] = [];
|
|
53
|
+
|
|
54
|
+
await this.walkDirectory(absoluteRoot, absoluteRoot, files);
|
|
55
|
+
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a file should be ignored based on patterns
|
|
61
|
+
*/
|
|
62
|
+
shouldIgnore(filePath: string, rootPath: string): boolean {
|
|
63
|
+
const relativePath = relative(rootPath, filePath);
|
|
64
|
+
return this.ig.ignores(relativePath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get list of supported file extensions
|
|
69
|
+
*/
|
|
70
|
+
getSupportedExtensions(): string[] {
|
|
71
|
+
return Array.from(this.supportedExts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Recursively walk directory tree
|
|
76
|
+
*/
|
|
77
|
+
private async walkDirectory(
|
|
78
|
+
currentPath: string,
|
|
79
|
+
rootPath: string,
|
|
80
|
+
files: string[]
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
84
|
+
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const fullPath = join(currentPath, entry.name);
|
|
87
|
+
const relativePath = relative(rootPath, fullPath);
|
|
88
|
+
|
|
89
|
+
// Skip ignored paths
|
|
90
|
+
if (this.ig.ignores(relativePath)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle symbolic links safely to avoid infinite loops
|
|
95
|
+
if (entry.isSymbolicLink()) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
await this.walkDirectory(fullPath, rootPath, files);
|
|
101
|
+
} else if (entry.isFile()) {
|
|
102
|
+
const ext = extname(entry.name);
|
|
103
|
+
if (this.supportedExts.has(ext)) {
|
|
104
|
+
files.push(fullPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch (_error) {
|
|
109
|
+
// Skip directories that can't be read (permission errors, etc.)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a file exists
|
|
115
|
+
*/
|
|
116
|
+
private async fileExists(path: string): Promise<boolean> {
|
|
117
|
+
try {
|
|
118
|
+
await fs.access(path);
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MerkleTree - Efficient change detection using Merkle trees
|
|
3
|
+
* Enables incremental updates by comparing file hashes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
export class MerkleNode {
|
|
9
|
+
constructor(
|
|
10
|
+
public hash: string,
|
|
11
|
+
public left?: MerkleNode,
|
|
12
|
+
public right?: MerkleNode
|
|
13
|
+
) {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MerkleTree {
|
|
17
|
+
root: MerkleNode | undefined = undefined;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build Merkle tree from file hashes
|
|
21
|
+
* @param fileHashes Map of file path to content hash
|
|
22
|
+
*/
|
|
23
|
+
build(fileHashes: Map<string, string>): void {
|
|
24
|
+
if (fileHashes.size === 0) {
|
|
25
|
+
this.root = undefined;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Sort files for deterministic tree structure
|
|
30
|
+
const leaves = Array.from(fileHashes.entries())
|
|
31
|
+
.sort(([pathA], [pathB]) => pathA.localeCompare(pathB))
|
|
32
|
+
.map(([path, hash]) => {
|
|
33
|
+
const combined = `${path}:${hash}`;
|
|
34
|
+
const leafHash = createHash("sha256").update(combined).digest("hex");
|
|
35
|
+
return new MerkleNode(leafHash);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.root = this.buildRecursive(leaves);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Recursively build tree from leaf nodes
|
|
43
|
+
*/
|
|
44
|
+
private buildRecursive(nodes: MerkleNode[]): MerkleNode {
|
|
45
|
+
if (nodes.length === 1) {
|
|
46
|
+
return nodes[0];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parents: MerkleNode[] = [];
|
|
50
|
+
for (let i = 0; i < nodes.length; i += 2) {
|
|
51
|
+
const left = nodes[i];
|
|
52
|
+
const right = nodes[i + 1] || left; // Duplicate if odd number
|
|
53
|
+
|
|
54
|
+
const combined = left.hash + right.hash;
|
|
55
|
+
const parentHash = createHash("sha256").update(combined).digest("hex");
|
|
56
|
+
|
|
57
|
+
parents.push(new MerkleNode(parentHash, left, right));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return this.buildRecursive(parents);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compare two trees and return file differences
|
|
65
|
+
*/
|
|
66
|
+
static compare(
|
|
67
|
+
oldHashes: Map<string, string>,
|
|
68
|
+
newHashes: Map<string, string>
|
|
69
|
+
): { added: string[]; modified: string[]; deleted: string[] } {
|
|
70
|
+
const added: string[] = [];
|
|
71
|
+
const modified: string[] = [];
|
|
72
|
+
const deleted: string[] = [];
|
|
73
|
+
|
|
74
|
+
// Find added and modified files
|
|
75
|
+
for (const [path, hash] of newHashes) {
|
|
76
|
+
if (!oldHashes.has(path)) {
|
|
77
|
+
added.push(path);
|
|
78
|
+
} else if (oldHashes.get(path) !== hash) {
|
|
79
|
+
modified.push(path);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Find deleted files
|
|
84
|
+
for (const [path] of oldHashes) {
|
|
85
|
+
if (!newHashes.has(path)) {
|
|
86
|
+
deleted.push(path);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { added, modified, deleted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get root hash (quick comparison)
|
|
95
|
+
*/
|
|
96
|
+
getRootHash(): string | undefined {
|
|
97
|
+
return this.root?.hash;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Serialize tree for storage
|
|
102
|
+
*/
|
|
103
|
+
serialize(): string {
|
|
104
|
+
return JSON.stringify({
|
|
105
|
+
root: this.serializeNode(this.root),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private serializeNode(node: MerkleNode | undefined): any | null {
|
|
110
|
+
if (!node) return null;
|
|
111
|
+
return {
|
|
112
|
+
hash: node.hash,
|
|
113
|
+
left: this.serializeNode(node.left),
|
|
114
|
+
right: this.serializeNode(node.right),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deserialize tree from storage
|
|
120
|
+
*/
|
|
121
|
+
static deserialize(data: string): MerkleTree {
|
|
122
|
+
const tree = new MerkleTree();
|
|
123
|
+
const obj = JSON.parse(data);
|
|
124
|
+
tree.root = tree.deserializeNode(obj.root);
|
|
125
|
+
return tree;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private deserializeNode(obj: any): MerkleNode | undefined {
|
|
129
|
+
if (!obj) return undefined;
|
|
130
|
+
return new MerkleNode(
|
|
131
|
+
obj.hash,
|
|
132
|
+
this.deserializeNode(obj.left),
|
|
133
|
+
this.deserializeNode(obj.right)
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot - Persistence layer for Merkle tree snapshots
|
|
3
|
+
* Stores file hashes and tree structure for incremental updates
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { MerkleTree } from "./merkle.js";
|
|
9
|
+
|
|
10
|
+
export interface Snapshot {
|
|
11
|
+
codebasePath: string;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
fileHashes: Record<string, string>;
|
|
14
|
+
merkleTree: string; // Serialized tree
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SnapshotManager {
|
|
18
|
+
constructor(private snapshotPath: string) {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Save snapshot to disk
|
|
22
|
+
*/
|
|
23
|
+
async save(
|
|
24
|
+
codebasePath: string,
|
|
25
|
+
fileHashes: Map<string, string>,
|
|
26
|
+
tree: MerkleTree
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const snapshot: Snapshot = {
|
|
29
|
+
codebasePath,
|
|
30
|
+
timestamp: Date.now(),
|
|
31
|
+
fileHashes: Object.fromEntries(fileHashes),
|
|
32
|
+
merkleTree: tree.serialize(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Ensure directory exists
|
|
36
|
+
await fs.mkdir(dirname(this.snapshotPath), { recursive: true });
|
|
37
|
+
|
|
38
|
+
// Write snapshot atomically (write to temp file, then rename)
|
|
39
|
+
// Use unique temp file name to avoid race conditions
|
|
40
|
+
const tempPath = `${this.snapshotPath}.tmp.${Date.now()}.${Math.random().toString(36).substring(2, 9)}`;
|
|
41
|
+
await fs.writeFile(tempPath, JSON.stringify(snapshot, null, 2), "utf-8");
|
|
42
|
+
await fs.rename(tempPath, this.snapshotPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load snapshot from disk
|
|
47
|
+
*/
|
|
48
|
+
async load(): Promise<{
|
|
49
|
+
codebasePath: string;
|
|
50
|
+
fileHashes: Map<string, string>;
|
|
51
|
+
merkleTree: MerkleTree;
|
|
52
|
+
timestamp: number;
|
|
53
|
+
} | null> {
|
|
54
|
+
try {
|
|
55
|
+
const data = await fs.readFile(this.snapshotPath, "utf-8");
|
|
56
|
+
const snapshot: Snapshot = JSON.parse(data);
|
|
57
|
+
|
|
58
|
+
const fileHashes = new Map(Object.entries(snapshot.fileHashes));
|
|
59
|
+
const tree = MerkleTree.deserialize(snapshot.merkleTree);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
codebasePath: snapshot.codebasePath,
|
|
63
|
+
fileHashes,
|
|
64
|
+
merkleTree: tree,
|
|
65
|
+
timestamp: snapshot.timestamp,
|
|
66
|
+
};
|
|
67
|
+
} catch (_error) {
|
|
68
|
+
// Snapshot doesn't exist or is corrupted
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if snapshot exists
|
|
75
|
+
*/
|
|
76
|
+
async exists(): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
await fs.access(this.snapshotPath);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Delete snapshot
|
|
87
|
+
*/
|
|
88
|
+
async delete(): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
await fs.unlink(this.snapshotPath);
|
|
91
|
+
} catch {
|
|
92
|
+
// Ignore if doesn't exist
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate snapshot (check for corruption)
|
|
98
|
+
*/
|
|
99
|
+
async validate(): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
const snapshot = await this.load();
|
|
102
|
+
if (!snapshot) return false;
|
|
103
|
+
|
|
104
|
+
// Basic validation: check if tree can be deserialized
|
|
105
|
+
return snapshot.merkleTree.getRootHash() !== undefined || snapshot.fileHashes.size === 0;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileSynchronizer - Manages incremental updates using Merkle trees
|
|
3
|
+
* Detects file changes and updates snapshots
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, relative } from "node:path";
|
|
10
|
+
import type { FileChanges } from "../types.js";
|
|
11
|
+
import { MerkleTree } from "./merkle.js";
|
|
12
|
+
import { SnapshotManager } from "./snapshot.js";
|
|
13
|
+
|
|
14
|
+
export class FileSynchronizer {
|
|
15
|
+
private snapshotManager: SnapshotManager;
|
|
16
|
+
private previousHashes: Map<string, string> = new Map();
|
|
17
|
+
private previousTree: MerkleTree | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private codebasePath: string,
|
|
21
|
+
collectionName: string
|
|
22
|
+
) {
|
|
23
|
+
// Store snapshots in ~/.qdrant-mcp/snapshots/
|
|
24
|
+
const snapshotDir = join(homedir(), ".qdrant-mcp", "snapshots");
|
|
25
|
+
const snapshotPath = join(snapshotDir, `${collectionName}.json`);
|
|
26
|
+
this.snapshotManager = new SnapshotManager(snapshotPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize synchronizer by loading previous snapshot
|
|
31
|
+
*/
|
|
32
|
+
async initialize(): Promise<boolean> {
|
|
33
|
+
const snapshot = await this.snapshotManager.load();
|
|
34
|
+
|
|
35
|
+
if (snapshot) {
|
|
36
|
+
this.previousHashes = snapshot.fileHashes;
|
|
37
|
+
this.previousTree = snapshot.merkleTree;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute hash for a file's content
|
|
46
|
+
*/
|
|
47
|
+
private async hashFile(filePath: string): Promise<string> {
|
|
48
|
+
try {
|
|
49
|
+
// Resolve path relative to codebase if not absolute
|
|
50
|
+
const absolutePath = filePath.startsWith(this.codebasePath)
|
|
51
|
+
? filePath
|
|
52
|
+
: join(this.codebasePath, filePath);
|
|
53
|
+
const content = await fs.readFile(absolutePath, "utf-8");
|
|
54
|
+
return createHash("sha256").update(content).digest("hex");
|
|
55
|
+
} catch (_error) {
|
|
56
|
+
// If file can't be read, return empty hash
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Compute hashes for all files
|
|
63
|
+
*/
|
|
64
|
+
async computeFileHashes(filePaths: string[]): Promise<Map<string, string>> {
|
|
65
|
+
const fileHashes = new Map<string, string>();
|
|
66
|
+
|
|
67
|
+
for (const filePath of filePaths) {
|
|
68
|
+
const hash = await this.hashFile(filePath);
|
|
69
|
+
if (hash) {
|
|
70
|
+
// Normalize to relative path
|
|
71
|
+
const relativePath = filePath.startsWith(this.codebasePath)
|
|
72
|
+
? relative(this.codebasePath, filePath)
|
|
73
|
+
: filePath;
|
|
74
|
+
fileHashes.set(relativePath, hash);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return fileHashes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Detect changes since last snapshot
|
|
83
|
+
*/
|
|
84
|
+
async detectChanges(currentFiles: string[]): Promise<FileChanges> {
|
|
85
|
+
// Compute current hashes
|
|
86
|
+
const currentHashes = await this.computeFileHashes(currentFiles);
|
|
87
|
+
|
|
88
|
+
// Compare with previous snapshot
|
|
89
|
+
const changes = MerkleTree.compare(this.previousHashes, currentHashes);
|
|
90
|
+
|
|
91
|
+
return changes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Update snapshot with current state
|
|
96
|
+
*/
|
|
97
|
+
async updateSnapshot(files: string[]): Promise<void> {
|
|
98
|
+
const fileHashes = await this.computeFileHashes(files);
|
|
99
|
+
const tree = new MerkleTree();
|
|
100
|
+
tree.build(fileHashes);
|
|
101
|
+
|
|
102
|
+
await this.snapshotManager.save(this.codebasePath, fileHashes, tree);
|
|
103
|
+
|
|
104
|
+
// Update internal state
|
|
105
|
+
this.previousHashes = fileHashes;
|
|
106
|
+
this.previousTree = tree;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Delete snapshot
|
|
111
|
+
*/
|
|
112
|
+
async deleteSnapshot(): Promise<void> {
|
|
113
|
+
await this.snapshotManager.delete();
|
|
114
|
+
this.previousHashes.clear();
|
|
115
|
+
this.previousTree = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if snapshot exists
|
|
120
|
+
*/
|
|
121
|
+
async hasSnapshot(): Promise<boolean> {
|
|
122
|
+
return this.snapshotManager.exists();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate snapshot integrity
|
|
127
|
+
*/
|
|
128
|
+
async validateSnapshot(): Promise<boolean> {
|
|
129
|
+
return this.snapshotManager.validate();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get snapshot age in milliseconds
|
|
134
|
+
*/
|
|
135
|
+
async getSnapshotAge(): Promise<number | null> {
|
|
136
|
+
const snapshot = await this.snapshotManager.load();
|
|
137
|
+
if (!snapshot) return null;
|
|
138
|
+
|
|
139
|
+
return Date.now() - snapshot.timestamp;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Quick check if re-indexing is needed (compare root hashes)
|
|
144
|
+
*/
|
|
145
|
+
async needsReindex(currentFiles: string[]): Promise<boolean> {
|
|
146
|
+
if (!this.previousTree) return true;
|
|
147
|
+
|
|
148
|
+
const currentHashes = await this.computeFileHashes(currentFiles);
|
|
149
|
+
const currentTree = new MerkleTree();
|
|
150
|
+
currentTree.build(currentHashes);
|
|
151
|
+
|
|
152
|
+
return this.previousTree.getRootHash() !== currentTree.getRootHash();
|
|
153
|
+
}
|
|
154
|
+
}
|