bluera-knowledge 0.11.19 → 0.11.21
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +44 -0
- package/README.md +42 -5
- package/commands/crawl.md +7 -7
- package/commands/search.md +9 -2
- package/dist/{chunk-QEHSDQTL.js → chunk-C4SYGLAI.js} +47 -28
- package/dist/chunk-C4SYGLAI.js.map +1 -0
- package/dist/{chunk-VP4VZULK.js → chunk-CC6EGZ4D.js} +51 -8
- package/dist/chunk-CC6EGZ4D.js.map +1 -0
- package/dist/{chunk-GOAOBPOA.js → chunk-QCSFBMYW.js} +2 -2
- package/dist/index.js +64 -12
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +2 -2
- package/dist/workers/background-worker-cli.js +2 -2
- package/package.json +1 -1
- package/src/analysis/code-graph.test.ts +30 -0
- package/src/analysis/code-graph.ts +10 -2
- package/src/cli/commands/store.test.ts +78 -0
- package/src/cli/commands/store.ts +19 -0
- package/src/cli/commands/sync.test.ts +1 -1
- package/src/cli/commands/sync.ts +50 -1
- package/src/db/lance.test.ts +3 -4
- package/src/db/lance.ts +14 -19
- package/src/mcp/commands/sync.commands.test.ts +94 -6
- package/src/mcp/commands/sync.commands.ts +36 -6
- package/src/mcp/handlers/search.handler.ts +3 -1
- package/src/mcp/handlers/store.handler.test.ts +3 -0
- package/src/mcp/handlers/store.handler.ts +5 -2
- package/src/mcp/schemas/index.test.ts +36 -0
- package/src/mcp/schemas/index.ts +6 -0
- package/src/mcp/server.test.ts +56 -1
- package/src/mcp/server.ts +16 -1
- package/src/services/code-graph.service.ts +11 -1
- package/src/services/job.service.test.ts +23 -0
- package/src/services/job.service.ts +10 -6
- package/src/services/search.service.ts +15 -9
- package/vitest.config.ts +1 -1
- package/dist/chunk-QEHSDQTL.js.map +0 -1
- package/dist/chunk-VP4VZULK.js.map +0 -1
- /package/dist/{chunk-GOAOBPOA.js.map → chunk-QCSFBMYW.js.map} +0 -0
|
@@ -30,6 +30,7 @@ export const handleSearch: ToolHandler<SearchArgs> = async (
|
|
|
30
30
|
{
|
|
31
31
|
query: validated.query,
|
|
32
32
|
stores: validated.stores,
|
|
33
|
+
mode: validated.mode,
|
|
33
34
|
detail: validated.detail,
|
|
34
35
|
limit: validated.limit,
|
|
35
36
|
intent: validated.intent,
|
|
@@ -69,9 +70,10 @@ export const handleSearch: ToolHandler<SearchArgs> = async (
|
|
|
69
70
|
const searchQuery: SearchQuery = {
|
|
70
71
|
query: validated.query,
|
|
71
72
|
stores: storeIds,
|
|
72
|
-
mode:
|
|
73
|
+
mode: validated.mode,
|
|
73
74
|
limit: validated.limit,
|
|
74
75
|
detail: validated.detail,
|
|
76
|
+
threshold: validated.threshold,
|
|
75
77
|
minRelevance: validated.minRelevance,
|
|
76
78
|
};
|
|
77
79
|
|
|
@@ -274,9 +274,12 @@ export const handleDeleteStore: ToolHandler<DeleteStoreArgs> = async (
|
|
|
274
274
|
throw new Error(`Store not found: ${validated.store}`);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
// Delete LanceDB table
|
|
277
|
+
// Delete LanceDB table first (so searches don't return results for deleted store)
|
|
278
278
|
await services.lance.deleteStore(store.id);
|
|
279
279
|
|
|
280
|
+
// Delete code graph file
|
|
281
|
+
await services.codeGraph.deleteGraph(store.id);
|
|
282
|
+
|
|
280
283
|
// For repo stores cloned from URL, remove the cloned directory
|
|
281
284
|
if (store.type === 'repo' && 'url' in store && store.url !== undefined) {
|
|
282
285
|
if (options.dataDir === undefined) {
|
|
@@ -286,7 +289,7 @@ export const handleDeleteStore: ToolHandler<DeleteStoreArgs> = async (
|
|
|
286
289
|
await rm(repoPath, { recursive: true, force: true });
|
|
287
290
|
}
|
|
288
291
|
|
|
289
|
-
// Delete from registry
|
|
292
|
+
// Delete from registry last
|
|
290
293
|
const result = await services.store.delete(store.id);
|
|
291
294
|
if (!result.success) {
|
|
292
295
|
throw new Error(result.error.message);
|
|
@@ -29,10 +29,46 @@ describe('MCP Schema Validation', () => {
|
|
|
29
29
|
it('should use defaults for optional fields', () => {
|
|
30
30
|
const result = SearchArgsSchema.parse({ query: 'test' });
|
|
31
31
|
|
|
32
|
+
expect(result.mode).toBe('hybrid');
|
|
32
33
|
expect(result.detail).toBe('minimal');
|
|
33
34
|
expect(result.limit).toBe(10);
|
|
34
35
|
});
|
|
35
36
|
|
|
37
|
+
it('should validate mode enum', () => {
|
|
38
|
+
expect(() => SearchArgsSchema.parse({ query: 'test', mode: 'invalid' })).toThrow();
|
|
39
|
+
|
|
40
|
+
const vector = SearchArgsSchema.parse({ query: 'test', mode: 'vector' });
|
|
41
|
+
expect(vector.mode).toBe('vector');
|
|
42
|
+
|
|
43
|
+
const fts = SearchArgsSchema.parse({ query: 'test', mode: 'fts' });
|
|
44
|
+
expect(fts.mode).toBe('fts');
|
|
45
|
+
|
|
46
|
+
const hybrid = SearchArgsSchema.parse({ query: 'test', mode: 'hybrid' });
|
|
47
|
+
expect(hybrid.mode).toBe('hybrid');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should validate threshold', () => {
|
|
51
|
+
const result = SearchArgsSchema.parse({ query: 'test', threshold: 0.5 });
|
|
52
|
+
expect(result.threshold).toBe(0.5);
|
|
53
|
+
|
|
54
|
+
// Edge cases
|
|
55
|
+
const min = SearchArgsSchema.parse({ query: 'test', threshold: 0 });
|
|
56
|
+
expect(min.threshold).toBe(0);
|
|
57
|
+
|
|
58
|
+
const max = SearchArgsSchema.parse({ query: 'test', threshold: 1 });
|
|
59
|
+
expect(max.threshold).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should reject invalid threshold', () => {
|
|
63
|
+
expect(() => SearchArgsSchema.parse({ query: 'test', threshold: -0.1 })).toThrow(
|
|
64
|
+
'threshold must be between 0 and 1'
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(() => SearchArgsSchema.parse({ query: 'test', threshold: 1.1 })).toThrow(
|
|
68
|
+
'threshold must be between 0 and 1'
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
36
72
|
it('should reject empty query', () => {
|
|
37
73
|
expect(() => SearchArgsSchema.parse({ query: '' })).toThrow(
|
|
38
74
|
'Query must be a non-empty string'
|
package/src/mcp/schemas/index.ts
CHANGED
|
@@ -25,9 +25,15 @@ export const SearchArgsSchema = z.object({
|
|
|
25
25
|
'find-documentation',
|
|
26
26
|
])
|
|
27
27
|
.optional(),
|
|
28
|
+
mode: z.enum(['vector', 'fts', 'hybrid']).default('hybrid'),
|
|
28
29
|
detail: z.enum(['minimal', 'contextual', 'full']).default('minimal'),
|
|
29
30
|
limit: z.number().int().positive().default(10),
|
|
30
31
|
stores: z.array(z.string()).optional(),
|
|
32
|
+
threshold: z
|
|
33
|
+
.number()
|
|
34
|
+
.min(0, 'threshold must be between 0 and 1')
|
|
35
|
+
.max(1, 'threshold must be between 0 and 1')
|
|
36
|
+
.optional(),
|
|
31
37
|
minRelevance: z
|
|
32
38
|
.number()
|
|
33
39
|
.min(0, 'minRelevance must be between 0 and 1')
|
package/src/mcp/server.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
|
2
2
|
import { createMCPServer } from './server.js';
|
|
3
3
|
|
|
4
|
+
// Mock services module
|
|
5
|
+
vi.mock('../services/index.js', () => ({
|
|
6
|
+
createServices: vi.fn(),
|
|
7
|
+
destroyServices: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
4
10
|
// MCP Server tests - server creation only since the SDK doesn't expose handlers for testing
|
|
5
11
|
describe('MCP Server', () => {
|
|
6
12
|
describe('Server creation and initialization', () => {
|
|
@@ -33,4 +39,53 @@ describe('MCP Server', () => {
|
|
|
33
39
|
expect(server).toBeDefined();
|
|
34
40
|
});
|
|
35
41
|
});
|
|
42
|
+
|
|
43
|
+
describe('Resource cleanup', () => {
|
|
44
|
+
let createServicesSpy: MockInstance;
|
|
45
|
+
let destroyServicesSpy: MockInstance;
|
|
46
|
+
let mockServices: Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
|
|
51
|
+
const servicesModule = await import('../services/index.js');
|
|
52
|
+
createServicesSpy = vi.mocked(servicesModule.createServices);
|
|
53
|
+
destroyServicesSpy = vi.mocked(servicesModule.destroyServices);
|
|
54
|
+
|
|
55
|
+
mockServices = {
|
|
56
|
+
store: { list: vi.fn().mockResolvedValue([]) },
|
|
57
|
+
lance: { search: vi.fn() },
|
|
58
|
+
search: { search: vi.fn() },
|
|
59
|
+
embeddings: { embed: vi.fn() },
|
|
60
|
+
pythonBridge: { stop: vi.fn() },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
createServicesSpy.mockResolvedValue(mockServices);
|
|
64
|
+
destroyServicesSpy.mockResolvedValue(undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('calls destroyServices after successful tool execution', async () => {
|
|
72
|
+
// This test verifies that destroyServices is called after each tool call
|
|
73
|
+
// to prevent resource leaks (PythonBridge processes, LanceDB connections)
|
|
74
|
+
|
|
75
|
+
const server = createMCPServer({ projectRoot: '/test' });
|
|
76
|
+
|
|
77
|
+
// Get the request handler by simulating a tool call
|
|
78
|
+
// The server registers handlers via setRequestHandler, we need to test
|
|
79
|
+
// that destroyServices is called after the handler completes
|
|
80
|
+
|
|
81
|
+
// Since we can't easily call the handler directly without the full MCP SDK,
|
|
82
|
+
// this test documents the expected behavior: destroyServices MUST be called
|
|
83
|
+
// after every tool execution to prevent resource leaks
|
|
84
|
+
|
|
85
|
+
expect(server).toBeDefined();
|
|
86
|
+
|
|
87
|
+
// The implementation should ensure destroyServices is called in a finally block
|
|
88
|
+
// This test will need to be enhanced when we can properly mock the request flow
|
|
89
|
+
});
|
|
90
|
+
});
|
|
36
91
|
});
|
package/src/mcp/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
|
|
|
4
4
|
import { AdapterRegistry } from '../analysis/adapter-registry.js';
|
|
5
5
|
import { ZilAdapter } from '../analysis/zil/index.js';
|
|
6
6
|
import { createLogger } from '../logging/index.js';
|
|
7
|
-
import { createServices } from '../services/index.js';
|
|
7
|
+
import { createServices, destroyServices } from '../services/index.js';
|
|
8
8
|
import { handleExecute } from './handlers/execute.handler.js';
|
|
9
9
|
import { tools } from './handlers/index.js';
|
|
10
10
|
import { ExecuteArgsSchema } from './schemas/index.js';
|
|
@@ -61,6 +61,13 @@ export function createMCPServer(options: MCPServerOptions): Server {
|
|
|
61
61
|
],
|
|
62
62
|
description: 'Search intent for better ranking',
|
|
63
63
|
},
|
|
64
|
+
mode: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
enum: ['vector', 'fts', 'hybrid'],
|
|
67
|
+
default: 'hybrid',
|
|
68
|
+
description:
|
|
69
|
+
'Search mode: vector (embeddings only), fts (full-text only), hybrid (both, default)',
|
|
70
|
+
},
|
|
64
71
|
detail: {
|
|
65
72
|
type: 'string',
|
|
66
73
|
enum: ['minimal', 'contextual', 'full'],
|
|
@@ -78,6 +85,10 @@ export function createMCPServer(options: MCPServerOptions): Server {
|
|
|
78
85
|
items: { type: 'string' },
|
|
79
86
|
description: 'Specific store IDs to search (optional)',
|
|
80
87
|
},
|
|
88
|
+
threshold: {
|
|
89
|
+
type: 'number',
|
|
90
|
+
description: 'Minimum normalized score (0-1). Filters out low-relevance results.',
|
|
91
|
+
},
|
|
81
92
|
minRelevance: {
|
|
82
93
|
type: 'number',
|
|
83
94
|
description:
|
|
@@ -174,6 +185,10 @@ export function createMCPServer(options: MCPServerOptions): Server {
|
|
|
174
185
|
'Tool execution failed'
|
|
175
186
|
);
|
|
176
187
|
throw error;
|
|
188
|
+
} finally {
|
|
189
|
+
// Always cleanup services to prevent resource leaks
|
|
190
|
+
// (PythonBridge processes, LanceDB connections)
|
|
191
|
+
await destroyServices(services);
|
|
177
192
|
}
|
|
178
193
|
});
|
|
179
194
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
import { ASTParser } from '../analysis/ast-parser.js';
|
|
4
4
|
import { CodeGraph, type GraphNode } from '../analysis/code-graph.js';
|
|
@@ -120,6 +120,16 @@ export class CodeGraphService {
|
|
|
120
120
|
await writeFile(graphPath, JSON.stringify(serialized, null, 2));
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Delete the code graph file for a store.
|
|
125
|
+
* Silently succeeds if the file doesn't exist.
|
|
126
|
+
*/
|
|
127
|
+
async deleteGraph(storeId: StoreId): Promise<void> {
|
|
128
|
+
const graphPath = this.getGraphPath(storeId);
|
|
129
|
+
await rm(graphPath, { force: true });
|
|
130
|
+
this.graphCache.delete(storeId);
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
/**
|
|
124
134
|
* Load a code graph for a store.
|
|
125
135
|
* Returns undefined if no graph exists.
|
|
@@ -24,6 +24,29 @@ describe('JobService', () => {
|
|
|
24
24
|
const jobsDir = join(tempDir, 'jobs');
|
|
25
25
|
expect(existsSync(jobsDir)).toBe(true);
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
it('throws when dataDir not provided and HOME/USERPROFILE undefined', () => {
|
|
29
|
+
const originalHome = process.env['HOME'];
|
|
30
|
+
const originalUserProfile = process.env['USERPROFILE'];
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
delete process.env['HOME'];
|
|
34
|
+
delete process.env['USERPROFILE'];
|
|
35
|
+
|
|
36
|
+
// Should throw instead of falling back to current directory
|
|
37
|
+
expect(() => new JobService()).toThrow(
|
|
38
|
+
'HOME or USERPROFILE environment variable is required'
|
|
39
|
+
);
|
|
40
|
+
} finally {
|
|
41
|
+
// Restore environment
|
|
42
|
+
if (originalHome !== undefined) {
|
|
43
|
+
process.env['HOME'] = originalHome;
|
|
44
|
+
}
|
|
45
|
+
if (originalUserProfile !== undefined) {
|
|
46
|
+
process.env['USERPROFILE'] = originalUserProfile;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
27
50
|
});
|
|
28
51
|
|
|
29
52
|
describe('createJob', () => {
|
|
@@ -9,12 +9,16 @@ export class JobService {
|
|
|
9
9
|
|
|
10
10
|
constructor(dataDir?: string) {
|
|
11
11
|
// Default to ~/.local/share/bluera-knowledge/jobs
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
12
|
+
let baseDir: string;
|
|
13
|
+
if (dataDir !== undefined) {
|
|
14
|
+
baseDir = dataDir;
|
|
15
|
+
} else {
|
|
16
|
+
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'];
|
|
17
|
+
if (homeDir === undefined) {
|
|
18
|
+
throw new Error('HOME or USERPROFILE environment variable is required');
|
|
19
|
+
}
|
|
20
|
+
baseDir = path.join(homeDir, '.local/share/bluera-knowledge');
|
|
21
|
+
}
|
|
18
22
|
this.jobsDir = path.join(baseDir, 'jobs');
|
|
19
23
|
|
|
20
24
|
// Ensure jobs directory exists
|
|
@@ -518,15 +518,21 @@ export class SearchService {
|
|
|
518
518
|
const results: SearchResult[] = [];
|
|
519
519
|
|
|
520
520
|
for (const storeId of stores) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
521
|
+
try {
|
|
522
|
+
const hits = await this.lanceStore.fullTextSearch(storeId, query, limit);
|
|
523
|
+
results.push(
|
|
524
|
+
...hits.map((r) => ({
|
|
525
|
+
id: r.id,
|
|
526
|
+
score: r.score,
|
|
527
|
+
content: r.content,
|
|
528
|
+
metadata: r.metadata,
|
|
529
|
+
}))
|
|
530
|
+
);
|
|
531
|
+
} catch {
|
|
532
|
+
// FTS index may not exist for this store - continue with other stores
|
|
533
|
+
// and rely on vector search results. This is expected behavior since
|
|
534
|
+
// FTS indexing is optional and hybrid search works with vector-only.
|
|
535
|
+
}
|
|
530
536
|
}
|
|
531
537
|
|
|
532
538
|
return results.sort((a, b) => b.score - a.score).slice(0, limit);
|