glancey 2.0.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/LICENSE +21 -0
- package/README.md +435 -0
- package/dist/__tests__/ast-chunker.test.d.ts +2 -0
- package/dist/__tests__/ast-chunker.test.d.ts.map +1 -0
- package/dist/__tests__/ast-chunker.test.js +307 -0
- package/dist/__tests__/ast-chunker.test.js.map +1 -0
- package/dist/__tests__/config.test.d.ts +2 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +242 -0
- package/dist/__tests__/config.test.js.map +1 -0
- package/dist/__tests__/dashboard/beads.test.d.ts +2 -0
- package/dist/__tests__/dashboard/beads.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard/beads.test.js +151 -0
- package/dist/__tests__/dashboard/beads.test.js.map +1 -0
- package/dist/__tests__/dashboard/index.test.d.ts +2 -0
- package/dist/__tests__/dashboard/index.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard/index.test.js +116 -0
- package/dist/__tests__/dashboard/index.test.js.map +1 -0
- package/dist/__tests__/dashboard/routes.test.d.ts +2 -0
- package/dist/__tests__/dashboard/routes.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard/routes.test.js +125 -0
- package/dist/__tests__/dashboard/routes.test.js.map +1 -0
- package/dist/__tests__/dashboard/server.test.d.ts +2 -0
- package/dist/__tests__/dashboard/server.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard/server.test.js +75 -0
- package/dist/__tests__/dashboard/server.test.js.map +1 -0
- package/dist/__tests__/dashboard/state.test.d.ts +2 -0
- package/dist/__tests__/dashboard/state.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard/state.test.js +124 -0
- package/dist/__tests__/dashboard/state.test.js.map +1 -0
- package/dist/__tests__/dashboard/token-tracking.test.d.ts +2 -0
- package/dist/__tests__/dashboard/token-tracking.test.d.ts.map +1 -0
- package/dist/__tests__/dashboard/token-tracking.test.js +315 -0
- package/dist/__tests__/dashboard/token-tracking.test.js.map +1 -0
- package/dist/__tests__/embeddings/factory.test.d.ts +2 -0
- package/dist/__tests__/embeddings/factory.test.d.ts.map +1 -0
- package/dist/__tests__/embeddings/factory.test.js +157 -0
- package/dist/__tests__/embeddings/factory.test.js.map +1 -0
- package/dist/__tests__/embeddings/gemini.test.d.ts +2 -0
- package/dist/__tests__/embeddings/gemini.test.d.ts.map +1 -0
- package/dist/__tests__/embeddings/gemini.test.js +178 -0
- package/dist/__tests__/embeddings/gemini.test.js.map +1 -0
- package/dist/__tests__/embeddings/ollama.test.d.ts +2 -0
- package/dist/__tests__/embeddings/ollama.test.d.ts.map +1 -0
- package/dist/__tests__/embeddings/ollama.test.js +273 -0
- package/dist/__tests__/embeddings/ollama.test.js.map +1 -0
- package/dist/__tests__/embeddings/rate-limiter.test.d.ts +2 -0
- package/dist/__tests__/embeddings/rate-limiter.test.d.ts.map +1 -0
- package/dist/__tests__/embeddings/rate-limiter.test.js +163 -0
- package/dist/__tests__/embeddings/rate-limiter.test.js.map +1 -0
- package/dist/__tests__/embeddings/retry.test.d.ts +2 -0
- package/dist/__tests__/embeddings/retry.test.d.ts.map +1 -0
- package/dist/__tests__/embeddings/retry.test.js +334 -0
- package/dist/__tests__/embeddings/retry.test.js.map +1 -0
- package/dist/__tests__/embeddings/types.test.d.ts +2 -0
- package/dist/__tests__/embeddings/types.test.d.ts.map +1 -0
- package/dist/__tests__/embeddings/types.test.js +31 -0
- package/dist/__tests__/embeddings/types.test.js.map +1 -0
- package/dist/__tests__/mocks/embedding-backend.mock.d.ts +10 -0
- package/dist/__tests__/mocks/embedding-backend.mock.d.ts.map +1 -0
- package/dist/__tests__/mocks/embedding-backend.mock.js +39 -0
- package/dist/__tests__/mocks/embedding-backend.mock.js.map +1 -0
- package/dist/__tests__/mocks/fetch.mock.d.ts +49 -0
- package/dist/__tests__/mocks/fetch.mock.d.ts.map +1 -0
- package/dist/__tests__/mocks/fetch.mock.js +102 -0
- package/dist/__tests__/mocks/fetch.mock.js.map +1 -0
- package/dist/__tests__/mocks/lancedb.mock.d.ts +38 -0
- package/dist/__tests__/mocks/lancedb.mock.d.ts.map +1 -0
- package/dist/__tests__/mocks/lancedb.mock.js +63 -0
- package/dist/__tests__/mocks/lancedb.mock.js.map +1 -0
- package/dist/__tests__/search/clustering.test.d.ts +2 -0
- package/dist/__tests__/search/clustering.test.d.ts.map +1 -0
- package/dist/__tests__/search/clustering.test.js +230 -0
- package/dist/__tests__/search/clustering.test.js.map +1 -0
- package/dist/__tests__/search/hybrid-search.test.d.ts +2 -0
- package/dist/__tests__/search/hybrid-search.test.d.ts.map +1 -0
- package/dist/__tests__/search/hybrid-search.test.js +279 -0
- package/dist/__tests__/search/hybrid-search.test.js.map +1 -0
- package/dist/__tests__/search/indexer.test.d.ts +2 -0
- package/dist/__tests__/search/indexer.test.d.ts.map +1 -0
- package/dist/__tests__/search/indexer.test.js +1305 -0
- package/dist/__tests__/search/indexer.test.js.map +1 -0
- package/dist/__tests__/search/tree-sitter-chunker.test.d.ts +2 -0
- package/dist/__tests__/search/tree-sitter-chunker.test.d.ts.map +1 -0
- package/dist/__tests__/search/tree-sitter-chunker.test.js +228 -0
- package/dist/__tests__/search/tree-sitter-chunker.test.js.map +1 -0
- package/dist/__tests__/setup.d.ts +2 -0
- package/dist/__tests__/setup.d.ts.map +1 -0
- package/dist/__tests__/setup.js +11 -0
- package/dist/__tests__/setup.js.map +1 -0
- package/dist/__tests__/tools/clustering-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/clustering-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/clustering-handlers.test.js +286 -0
- package/dist/__tests__/tools/clustering-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/commit-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/commit-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/commit-handlers.test.js +153 -0
- package/dist/__tests__/tools/commit-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/index-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/index-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/index-handlers.test.js +184 -0
- package/dist/__tests__/tools/index-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/instructions-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/instructions-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/instructions-handlers.test.js +53 -0
- package/dist/__tests__/tools/instructions-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/memory-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/memory-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/memory-handlers.test.js +175 -0
- package/dist/__tests__/tools/memory-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/search-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/search-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/search-handlers.test.js +229 -0
- package/dist/__tests__/tools/search-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/symbol-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/symbol-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/symbol-handlers.test.js +348 -0
- package/dist/__tests__/tools/symbol-handlers.test.js.map +1 -0
- package/dist/__tests__/tools/worktree-handlers.test.d.ts +2 -0
- package/dist/__tests__/tools/worktree-handlers.test.d.ts.map +1 -0
- package/dist/__tests__/tools/worktree-handlers.test.js +297 -0
- package/dist/__tests__/tools/worktree-handlers.test.js.map +1 -0
- package/dist/__tests__/utils/cache.test.d.ts +2 -0
- package/dist/__tests__/utils/cache.test.d.ts.map +1 -0
- package/dist/__tests__/utils/cache.test.js +147 -0
- package/dist/__tests__/utils/cache.test.js.map +1 -0
- package/dist/__tests__/utils/concurrency.test.d.ts +2 -0
- package/dist/__tests__/utils/concurrency.test.d.ts.map +1 -0
- package/dist/__tests__/utils/concurrency.test.js +83 -0
- package/dist/__tests__/utils/concurrency.test.js.map +1 -0
- package/dist/__tests__/utils/errors.test.d.ts +2 -0
- package/dist/__tests__/utils/errors.test.d.ts.map +1 -0
- package/dist/__tests__/utils/errors.test.js +136 -0
- package/dist/__tests__/utils/errors.test.js.map +1 -0
- package/dist/__tests__/utils/logger.test.d.ts +2 -0
- package/dist/__tests__/utils/logger.test.d.ts.map +1 -0
- package/dist/__tests__/utils/logger.test.js +131 -0
- package/dist/__tests__/utils/logger.test.js.map +1 -0
- package/dist/__tests__/utils/type-guards.test.d.ts +2 -0
- package/dist/__tests__/utils/type-guards.test.d.ts.map +1 -0
- package/dist/__tests__/utils/type-guards.test.js +80 -0
- package/dist/__tests__/utils/type-guards.test.js.map +1 -0
- package/dist/__tests__/worktree/worktree-manager.test.d.ts +2 -0
- package/dist/__tests__/worktree/worktree-manager.test.d.ts.map +1 -0
- package/dist/__tests__/worktree/worktree-manager.test.js +403 -0
- package/dist/__tests__/worktree/worktree-manager.test.js.map +1 -0
- package/dist/config.d.ts +191 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +788 -0
- package/dist/config.js.map +1 -0
- package/dist/dashboard/beads.d.ts +35 -0
- package/dist/dashboard/beads.d.ts.map +1 -0
- package/dist/dashboard/beads.js +102 -0
- package/dist/dashboard/beads.js.map +1 -0
- package/dist/dashboard/events.d.ts +64 -0
- package/dist/dashboard/events.d.ts.map +1 -0
- package/dist/dashboard/events.js +211 -0
- package/dist/dashboard/events.js.map +1 -0
- package/dist/dashboard/index.d.ts +69 -0
- package/dist/dashboard/index.d.ts.map +1 -0
- package/dist/dashboard/index.js +93 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/dashboard/routes.d.ts +6 -0
- package/dist/dashboard/routes.d.ts.map +1 -0
- package/dist/dashboard/routes.js +505 -0
- package/dist/dashboard/routes.js.map +1 -0
- package/dist/dashboard/server.d.ts +27 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +72 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/dashboard/state.d.ts +172 -0
- package/dist/dashboard/state.d.ts.map +1 -0
- package/dist/dashboard/state.js +362 -0
- package/dist/dashboard/state.js.map +1 -0
- package/dist/dashboard/token-tracking.d.ts +143 -0
- package/dist/dashboard/token-tracking.d.ts.map +1 -0
- package/dist/dashboard/token-tracking.js +363 -0
- package/dist/dashboard/token-tracking.js.map +1 -0
- package/dist/dashboard/ui.d.ts +6 -0
- package/dist/dashboard/ui.d.ts.map +1 -0
- package/dist/dashboard/ui.js +2710 -0
- package/dist/dashboard/ui.js.map +1 -0
- package/dist/embeddings/gemini.d.ts +28 -0
- package/dist/embeddings/gemini.d.ts.map +1 -0
- package/dist/embeddings/gemini.js +165 -0
- package/dist/embeddings/gemini.js.map +1 -0
- package/dist/embeddings/index.d.ts +34 -0
- package/dist/embeddings/index.d.ts.map +1 -0
- package/dist/embeddings/index.js +120 -0
- package/dist/embeddings/index.js.map +1 -0
- package/dist/embeddings/ollama.d.ts +22 -0
- package/dist/embeddings/ollama.d.ts.map +1 -0
- package/dist/embeddings/ollama.js +179 -0
- package/dist/embeddings/ollama.js.map +1 -0
- package/dist/embeddings/rate-limiter.d.ts +75 -0
- package/dist/embeddings/rate-limiter.d.ts.map +1 -0
- package/dist/embeddings/rate-limiter.js +145 -0
- package/dist/embeddings/rate-limiter.js.map +1 -0
- package/dist/embeddings/retry.d.ts +20 -0
- package/dist/embeddings/retry.d.ts.map +1 -0
- package/dist/embeddings/retry.js +227 -0
- package/dist/embeddings/retry.js.map +1 -0
- package/dist/embeddings/types.d.ts +103 -0
- package/dist/embeddings/types.d.ts.map +1 -0
- package/dist/embeddings/types.js +23 -0
- package/dist/embeddings/types.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2028 -0
- package/dist/index.js.map +1 -0
- package/dist/memory/index.d.ts +63 -0
- package/dist/memory/index.d.ts.map +1 -0
- package/dist/memory/index.js +168 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/search/ast-chunker.d.ts +29 -0
- package/dist/search/ast-chunker.d.ts.map +1 -0
- package/dist/search/ast-chunker.js +236 -0
- package/dist/search/ast-chunker.js.map +1 -0
- package/dist/search/chunk-utils.d.ts +24 -0
- package/dist/search/chunk-utils.d.ts.map +1 -0
- package/dist/search/chunk-utils.js +41 -0
- package/dist/search/chunk-utils.js.map +1 -0
- package/dist/search/clustering.d.ts +77 -0
- package/dist/search/clustering.d.ts.map +1 -0
- package/dist/search/clustering.js +455 -0
- package/dist/search/clustering.js.map +1 -0
- package/dist/search/indexer.d.ts +377 -0
- package/dist/search/indexer.d.ts.map +1 -0
- package/dist/search/indexer.js +1557 -0
- package/dist/search/indexer.js.map +1 -0
- package/dist/search/tree-sitter-chunker.d.ts +64 -0
- package/dist/search/tree-sitter-chunker.d.ts.map +1 -0
- package/dist/search/tree-sitter-chunker.js +412 -0
- package/dist/search/tree-sitter-chunker.js.map +1 -0
- package/dist/symbols/index.d.ts +14 -0
- package/dist/symbols/index.d.ts.map +1 -0
- package/dist/symbols/index.js +19 -0
- package/dist/symbols/index.js.map +1 -0
- package/dist/symbols/name-path.d.ts +113 -0
- package/dist/symbols/name-path.d.ts.map +1 -0
- package/dist/symbols/name-path.js +194 -0
- package/dist/symbols/name-path.js.map +1 -0
- package/dist/symbols/pattern-search.d.ts +14 -0
- package/dist/symbols/pattern-search.d.ts.map +1 -0
- package/dist/symbols/pattern-search.js +224 -0
- package/dist/symbols/pattern-search.js.map +1 -0
- package/dist/symbols/reference-finder.d.ts +38 -0
- package/dist/symbols/reference-finder.d.ts.map +1 -0
- package/dist/symbols/reference-finder.js +376 -0
- package/dist/symbols/reference-finder.js.map +1 -0
- package/dist/symbols/symbol-editor.d.ts +81 -0
- package/dist/symbols/symbol-editor.d.ts.map +1 -0
- package/dist/symbols/symbol-editor.js +257 -0
- package/dist/symbols/symbol-editor.js.map +1 -0
- package/dist/symbols/symbol-extractor.d.ts +49 -0
- package/dist/symbols/symbol-extractor.d.ts.map +1 -0
- package/dist/symbols/symbol-extractor.js +593 -0
- package/dist/symbols/symbol-extractor.js.map +1 -0
- package/dist/symbols/symbol-renamer.d.ts +81 -0
- package/dist/symbols/symbol-renamer.d.ts.map +1 -0
- package/dist/symbols/symbol-renamer.js +204 -0
- package/dist/symbols/symbol-renamer.js.map +1 -0
- package/dist/symbols/types.d.ts +234 -0
- package/dist/symbols/types.d.ts.map +1 -0
- package/dist/symbols/types.js +106 -0
- package/dist/symbols/types.js.map +1 -0
- package/dist/tools/clustering-handlers.d.ts +70 -0
- package/dist/tools/clustering-handlers.d.ts.map +1 -0
- package/dist/tools/clustering-handlers.js +150 -0
- package/dist/tools/clustering-handlers.js.map +1 -0
- package/dist/tools/commit-handlers.d.ts +77 -0
- package/dist/tools/commit-handlers.d.ts.map +1 -0
- package/dist/tools/commit-handlers.js +251 -0
- package/dist/tools/commit-handlers.js.map +1 -0
- package/dist/tools/index-handlers.d.ts +31 -0
- package/dist/tools/index-handlers.d.ts.map +1 -0
- package/dist/tools/index-handlers.js +53 -0
- package/dist/tools/index-handlers.js.map +1 -0
- package/dist/tools/instructions-handlers.d.ts +25 -0
- package/dist/tools/instructions-handlers.d.ts.map +1 -0
- package/dist/tools/instructions-handlers.js +36 -0
- package/dist/tools/instructions-handlers.js.map +1 -0
- package/dist/tools/memory-handlers.d.ts +95 -0
- package/dist/tools/memory-handlers.d.ts.map +1 -0
- package/dist/tools/memory-handlers.js +126 -0
- package/dist/tools/memory-handlers.js.map +1 -0
- package/dist/tools/search-handlers.d.ts +51 -0
- package/dist/tools/search-handlers.d.ts.map +1 -0
- package/dist/tools/search-handlers.js +128 -0
- package/dist/tools/search-handlers.js.map +1 -0
- package/dist/tools/symbol-handlers.d.ts +171 -0
- package/dist/tools/symbol-handlers.d.ts.map +1 -0
- package/dist/tools/symbol-handlers.js +456 -0
- package/dist/tools/symbol-handlers.js.map +1 -0
- package/dist/tools/types.d.ts +32 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +17 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/worktree-handlers.d.ts +96 -0
- package/dist/tools/worktree-handlers.d.ts.map +1 -0
- package/dist/tools/worktree-handlers.js +186 -0
- package/dist/tools/worktree-handlers.js.map +1 -0
- package/dist/utils/cache.d.ts +77 -0
- package/dist/utils/cache.d.ts.map +1 -0
- package/dist/utils/cache.js +134 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/concurrency.d.ts +32 -0
- package/dist/utils/concurrency.d.ts.map +1 -0
- package/dist/utils/concurrency.js +57 -0
- package/dist/utils/concurrency.js.map +1 -0
- package/dist/utils/errors.d.ts +36 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +91 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +37 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +114 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/type-guards.d.ts +17 -0
- package/dist/utils/type-guards.d.ts.map +1 -0
- package/dist/utils/type-guards.js +25 -0
- package/dist/utils/type-guards.js.map +1 -0
- package/dist/worktree/index.d.ts +6 -0
- package/dist/worktree/index.d.ts.map +1 -0
- package/dist/worktree/index.js +6 -0
- package/dist/worktree/index.js.map +1 -0
- package/dist/worktree/types.d.ts +101 -0
- package/dist/worktree/types.d.ts.map +1 -0
- package/dist/worktree/types.js +6 -0
- package/dist/worktree/types.js.map +1 -0
- package/dist/worktree/worktree-manager.d.ts +80 -0
- package/dist/worktree/worktree-manager.d.ts.map +1 -0
- package/dist/worktree/worktree-manager.js +407 -0
- package/dist/worktree/worktree-manager.js.map +1 -0
- package/package.json +87 -0
- package/scripts/postinstall.js +48 -0
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
import * as lancedb from '@lancedb/lancedb';
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { broadcastLog } from '../dashboard/events.js';
|
|
6
|
+
import { ASTChunker } from './ast-chunker.js';
|
|
7
|
+
import { TreeSitterChunker } from './tree-sitter-chunker.js';
|
|
8
|
+
import { loadConfig, getDefaultPatterns, getDefaultExcludePatterns, getChunkingConfig, getSearchConfig, getIndexingConfig, } from '../config.js';
|
|
9
|
+
import { TTLCache } from '../utils/cache.js';
|
|
10
|
+
import { minimatch } from 'minimatch';
|
|
11
|
+
import { mapInBatches } from '../utils/concurrency.js';
|
|
12
|
+
import { kMeansClustering, calculateSilhouetteScore, } from './clustering.js';
|
|
13
|
+
/** Default concurrency for parallel file processing */
|
|
14
|
+
const FILE_PROCESSING_CONCURRENCY = 10;
|
|
15
|
+
/**
|
|
16
|
+
* Compute a checksum for index integrity validation.
|
|
17
|
+
* Based on sorted file list and chunk count.
|
|
18
|
+
*/
|
|
19
|
+
function computeIndexChecksum(files, chunkCount) {
|
|
20
|
+
const sortedFiles = [...files].sort();
|
|
21
|
+
const data = JSON.stringify({ files: sortedFiles, chunkCount });
|
|
22
|
+
return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Sanitize a string for use in LanceDB filter expressions.
|
|
26
|
+
* Prevents SQL injection by escaping quotes and backslashes.
|
|
27
|
+
* Used for filepaths and chunk IDs in WHERE clauses.
|
|
28
|
+
*/
|
|
29
|
+
function sanitizeForFilter(value) {
|
|
30
|
+
// Safe characters: alphanumeric, /, ., -, _, space, : (for chunk IDs like "path:1-10")
|
|
31
|
+
// This is more restrictive than escaping and prevents injection attacks
|
|
32
|
+
if (!/^[\w\s./:_-]+$/.test(value)) {
|
|
33
|
+
// If value contains unusual characters, escape single quotes and backslashes
|
|
34
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
|
35
|
+
}
|
|
36
|
+
return value.replace(/'/g, "''");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Code indexer that uses LanceDB for vector storage and semantic search.
|
|
40
|
+
*
|
|
41
|
+
* Provides functionality to:
|
|
42
|
+
* - Index a codebase by chunking files and generating embeddings
|
|
43
|
+
* - Perform hybrid semantic + keyword search
|
|
44
|
+
* - Support incremental indexing (only re-index changed files)
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* const backend = await createEmbeddingBackend();
|
|
49
|
+
* const indexer = new CodeIndexer('/path/to/project', backend);
|
|
50
|
+
* await indexer.initialize();
|
|
51
|
+
*
|
|
52
|
+
* // Index the codebase
|
|
53
|
+
* await indexer.indexCodebase();
|
|
54
|
+
*
|
|
55
|
+
* // Search for code
|
|
56
|
+
* const results = await indexer.search('authentication middleware');
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export class CodeIndexer {
|
|
60
|
+
db = null;
|
|
61
|
+
table = null;
|
|
62
|
+
metadataTable = null;
|
|
63
|
+
embeddingBackend;
|
|
64
|
+
indexPath;
|
|
65
|
+
projectPath;
|
|
66
|
+
config = null;
|
|
67
|
+
/** LRU cache for query embeddings with TTL to avoid recomputing identical queries */
|
|
68
|
+
queryEmbeddingCache = new TTLCache({ maxSize: 100, ttlMs: 60 * 60 * 1000 });
|
|
69
|
+
/** Tracks chunking method usage during current indexing operation */
|
|
70
|
+
currentChunkingStats = this.createEmptyChunkingStats();
|
|
71
|
+
/** Create empty chunking stats */
|
|
72
|
+
createEmptyChunkingStats() {
|
|
73
|
+
return {
|
|
74
|
+
astChunked: 0,
|
|
75
|
+
treeSitterChunked: 0,
|
|
76
|
+
lineBasedChunked: 0,
|
|
77
|
+
astFallbacks: [],
|
|
78
|
+
treeSitterFallbacks: [],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
constructor(projectPath, embeddingBackend) {
|
|
82
|
+
this.projectPath = projectPath;
|
|
83
|
+
this.embeddingBackend = embeddingBackend;
|
|
84
|
+
this.indexPath = path.join(projectPath, '.glancey');
|
|
85
|
+
}
|
|
86
|
+
async initialize() {
|
|
87
|
+
this.db = await lancedb.connect(this.indexPath);
|
|
88
|
+
this.config = await loadConfig(this.projectPath);
|
|
89
|
+
console.error(`[glancey] Loaded config with ${this.config.patterns?.length} patterns`);
|
|
90
|
+
}
|
|
91
|
+
get metadataPath() {
|
|
92
|
+
return path.join(this.indexPath, 'index-metadata.json');
|
|
93
|
+
}
|
|
94
|
+
get checkpointPath() {
|
|
95
|
+
return path.join(this.indexPath, 'checkpoint.json');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Save indexing checkpoint to disk for crash recovery.
|
|
99
|
+
* Strips chunk content to reduce checkpoint size - content is re-read on resume.
|
|
100
|
+
*/
|
|
101
|
+
async saveCheckpoint(checkpoint, options = {}) {
|
|
102
|
+
const { blocking = false } = options;
|
|
103
|
+
const doSave = async () => {
|
|
104
|
+
await fs.mkdir(this.indexPath, { recursive: true });
|
|
105
|
+
// Strip content from chunks to reduce checkpoint size (can be 50MB+ for large codebases)
|
|
106
|
+
// Content will be re-read from files on checkpoint resume
|
|
107
|
+
const lightCheckpoint = {
|
|
108
|
+
...checkpoint,
|
|
109
|
+
pendingChunks: checkpoint.pendingChunks?.map((c) => ({ ...c, content: '' })),
|
|
110
|
+
embeddedChunks: checkpoint.embeddedChunks?.map((c) => ({ ...c, content: '' })),
|
|
111
|
+
};
|
|
112
|
+
await fs.writeFile(this.checkpointPath, JSON.stringify(lightCheckpoint));
|
|
113
|
+
};
|
|
114
|
+
if (blocking) {
|
|
115
|
+
await doSave();
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Non-blocking: start write in background, don't wait
|
|
119
|
+
// If crash happens before write completes, checkpoint is lost but that's acceptable
|
|
120
|
+
doSave().catch((err) => console.error('[glancey] Checkpoint save failed:', err));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Re-read chunk content from source files.
|
|
125
|
+
* Used when resuming from a checkpoint that has stripped content.
|
|
126
|
+
*/
|
|
127
|
+
async rehydrateChunkContent(chunks) {
|
|
128
|
+
// Group chunks by file for efficient reading
|
|
129
|
+
const chunksByFile = new Map();
|
|
130
|
+
for (const chunk of chunks) {
|
|
131
|
+
const existing = chunksByFile.get(chunk.filepath) || [];
|
|
132
|
+
existing.push(chunk);
|
|
133
|
+
chunksByFile.set(chunk.filepath, existing);
|
|
134
|
+
}
|
|
135
|
+
// Read each file once and populate all its chunks
|
|
136
|
+
await Promise.all(Array.from(chunksByFile.entries()).map(async ([filepath, fileChunks]) => {
|
|
137
|
+
try {
|
|
138
|
+
const fullPath = path.join(this.projectPath, filepath);
|
|
139
|
+
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
|
140
|
+
const lines = fileContent.split('\n');
|
|
141
|
+
for (const chunk of fileChunks) {
|
|
142
|
+
chunk.content = lines.slice(chunk.startLine - 1, chunk.endLine).join('\n');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// File may have been deleted - mark chunk for removal
|
|
147
|
+
for (const chunk of fileChunks) {
|
|
148
|
+
chunk.content = '';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Load indexing checkpoint from disk.
|
|
155
|
+
* Returns null if no checkpoint exists or if it's invalid.
|
|
156
|
+
*/
|
|
157
|
+
async loadCheckpoint() {
|
|
158
|
+
try {
|
|
159
|
+
const content = await fs.readFile(this.checkpointPath, 'utf-8');
|
|
160
|
+
const checkpoint = JSON.parse(content);
|
|
161
|
+
// Validate checkpoint has required fields
|
|
162
|
+
if (!checkpoint.phase ||
|
|
163
|
+
!checkpoint.startedAt ||
|
|
164
|
+
!Array.isArray(checkpoint.files) ||
|
|
165
|
+
!Array.isArray(checkpoint.processedFiles)) {
|
|
166
|
+
console.error('[glancey] Invalid checkpoint file, ignoring');
|
|
167
|
+
await this.clearCheckpoint();
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return checkpoint;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Clear the indexing checkpoint file.
|
|
178
|
+
*/
|
|
179
|
+
async clearCheckpoint() {
|
|
180
|
+
try {
|
|
181
|
+
await fs.unlink(this.checkpointPath);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Ignore errors if file doesn't exist
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Save index metadata to disk
|
|
189
|
+
*/
|
|
190
|
+
async saveIndexMetadata(fileCount, chunkCount, indexedFiles) {
|
|
191
|
+
// Convert to relative paths for checksum
|
|
192
|
+
const relativePaths = indexedFiles.map((f) => path.isAbsolute(f) ? path.relative(this.projectPath, f) : f);
|
|
193
|
+
const metadata = {
|
|
194
|
+
lastUpdated: new Date().toISOString(),
|
|
195
|
+
fileCount,
|
|
196
|
+
chunkCount,
|
|
197
|
+
embeddingBackend: this.embeddingBackend.name,
|
|
198
|
+
embeddingModel: this.embeddingBackend.getModel(),
|
|
199
|
+
embeddingDimensions: this.embeddingBackend.getDimensions(),
|
|
200
|
+
version: '1.0.0',
|
|
201
|
+
checksum: computeIndexChecksum(relativePaths, chunkCount),
|
|
202
|
+
chunkingStats: this.currentChunkingStats,
|
|
203
|
+
};
|
|
204
|
+
await fs.writeFile(this.metadataPath, JSON.stringify(metadata, null, 2));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Load index metadata from disk
|
|
208
|
+
*/
|
|
209
|
+
async loadIndexMetadata() {
|
|
210
|
+
try {
|
|
211
|
+
const content = await fs.readFile(this.metadataPath, 'utf-8');
|
|
212
|
+
return JSON.parse(content);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Get the modification time of a file
|
|
220
|
+
*/
|
|
221
|
+
async getFileMtime(filepath) {
|
|
222
|
+
const stats = await fs.stat(filepath);
|
|
223
|
+
return stats.mtimeMs;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Collect modification times for multiple files.
|
|
227
|
+
* Used for checkpoint freshness validation.
|
|
228
|
+
*/
|
|
229
|
+
async collectFileMtimes(files) {
|
|
230
|
+
const mtimes = {};
|
|
231
|
+
// Process in batches to avoid overwhelming the system with file descriptors
|
|
232
|
+
const batchSize = 500;
|
|
233
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
234
|
+
const batch = files.slice(i, i + batchSize);
|
|
235
|
+
const results = await Promise.all(batch.map(async (filepath) => {
|
|
236
|
+
try {
|
|
237
|
+
const relativePath = path.relative(this.projectPath, filepath);
|
|
238
|
+
const mtime = await this.getFileMtime(filepath);
|
|
239
|
+
return { relativePath, mtime };
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null; // Skip files that can't be stat'd
|
|
243
|
+
}
|
|
244
|
+
}));
|
|
245
|
+
for (const result of results) {
|
|
246
|
+
if (result) {
|
|
247
|
+
mtimes[result.relativePath] = result.mtime;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return mtimes;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Validate that checkpoint files haven't been modified since checkpoint creation.
|
|
255
|
+
* Returns true if checkpoint is fresh, false if files have changed.
|
|
256
|
+
*/
|
|
257
|
+
async validateCheckpointFreshness(checkpoint) {
|
|
258
|
+
if (!checkpoint.fileMtimes) {
|
|
259
|
+
// Legacy checkpoint without mtimes - can't validate, assume stale for safety
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
for (const [relativePath, savedMtime] of Object.entries(checkpoint.fileMtimes)) {
|
|
263
|
+
try {
|
|
264
|
+
const filepath = path.join(this.projectPath, relativePath);
|
|
265
|
+
const currentMtime = await this.getFileMtime(filepath);
|
|
266
|
+
if (currentMtime > savedMtime) {
|
|
267
|
+
console.error(`[glancey] File ${relativePath} modified since checkpoint, invalidating`);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// File was deleted or can't be accessed - checkpoint is stale
|
|
273
|
+
console.error(`[glancey] File ${relativePath} no longer accessible, invalidating checkpoint`);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Get stored metadata for all indexed files
|
|
281
|
+
*/
|
|
282
|
+
async getStoredMetadata() {
|
|
283
|
+
const tableNames = await this.db.tableNames();
|
|
284
|
+
if (!tableNames.includes('file_metadata')) {
|
|
285
|
+
return new Map();
|
|
286
|
+
}
|
|
287
|
+
this.metadataTable = await this.db.openTable('file_metadata');
|
|
288
|
+
const rows = await this.metadataTable.query().toArray();
|
|
289
|
+
const metadata = new Map();
|
|
290
|
+
for (const row of rows) {
|
|
291
|
+
metadata.set(row.filepath, row.mtime);
|
|
292
|
+
}
|
|
293
|
+
return metadata;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Detect which files have been added, modified, or deleted
|
|
297
|
+
*/
|
|
298
|
+
async detectFileChanges(currentFiles) {
|
|
299
|
+
const storedMetadata = await this.getStoredMetadata();
|
|
300
|
+
const changes = {
|
|
301
|
+
added: [],
|
|
302
|
+
modified: [],
|
|
303
|
+
deleted: [],
|
|
304
|
+
unchanged: [],
|
|
305
|
+
};
|
|
306
|
+
const currentFilesSet = new Set();
|
|
307
|
+
for (const filepath of currentFiles) {
|
|
308
|
+
const relativePath = path.relative(this.projectPath, filepath);
|
|
309
|
+
currentFilesSet.add(relativePath);
|
|
310
|
+
const currentMtime = await this.getFileMtime(filepath);
|
|
311
|
+
const storedMtime = storedMetadata.get(relativePath);
|
|
312
|
+
if (storedMtime === undefined) {
|
|
313
|
+
changes.added.push(filepath);
|
|
314
|
+
}
|
|
315
|
+
else if (currentMtime > storedMtime) {
|
|
316
|
+
changes.modified.push(filepath);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
changes.unchanged.push(filepath);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Find deleted files
|
|
323
|
+
for (const [relativePath] of storedMetadata) {
|
|
324
|
+
if (!currentFilesSet.has(relativePath)) {
|
|
325
|
+
changes.deleted.push(relativePath);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return changes;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Get all project files matching the configured patterns.
|
|
332
|
+
* Used for change detection and staleness checking.
|
|
333
|
+
*/
|
|
334
|
+
async getProjectFiles() {
|
|
335
|
+
const { glob } = await import('glob');
|
|
336
|
+
// Ensure config is loaded
|
|
337
|
+
if (!this.config) {
|
|
338
|
+
this.config = await loadConfig(this.projectPath);
|
|
339
|
+
}
|
|
340
|
+
const patterns = this.config.patterns || getDefaultPatterns();
|
|
341
|
+
const excludePatterns = this.config.excludePatterns || getDefaultExcludePatterns();
|
|
342
|
+
const files = [];
|
|
343
|
+
for (const pattern of patterns) {
|
|
344
|
+
const matches = await glob(pattern, {
|
|
345
|
+
cwd: this.projectPath,
|
|
346
|
+
ignore: excludePatterns,
|
|
347
|
+
absolute: true,
|
|
348
|
+
});
|
|
349
|
+
files.push(...matches);
|
|
350
|
+
}
|
|
351
|
+
return files;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Check if the index is stale (files have been modified since last index).
|
|
355
|
+
* Returns true if any files have been added, modified, or deleted.
|
|
356
|
+
* This is a lightweight check that only compares file modification times.
|
|
357
|
+
*/
|
|
358
|
+
async checkIfStale() {
|
|
359
|
+
// Ensure database is initialized
|
|
360
|
+
if (!this.db) {
|
|
361
|
+
await this.initialize();
|
|
362
|
+
}
|
|
363
|
+
// Check if index exists
|
|
364
|
+
const tableNames = await this.db.tableNames();
|
|
365
|
+
if (!tableNames.includes('code_chunks')) {
|
|
366
|
+
return { stale: true, reason: 'Index does not exist' };
|
|
367
|
+
}
|
|
368
|
+
// Check if metadata table exists
|
|
369
|
+
if (!tableNames.includes('file_metadata')) {
|
|
370
|
+
return { stale: true, reason: 'No file metadata stored' };
|
|
371
|
+
}
|
|
372
|
+
// Get current files
|
|
373
|
+
const currentFiles = await this.getProjectFiles();
|
|
374
|
+
// Detect changes
|
|
375
|
+
const changes = await this.detectFileChanges(currentFiles);
|
|
376
|
+
const hasChanges = changes.added.length > 0 || changes.modified.length > 0 || changes.deleted.length > 0;
|
|
377
|
+
if (!hasChanges) {
|
|
378
|
+
return { stale: false };
|
|
379
|
+
}
|
|
380
|
+
// Build reason string
|
|
381
|
+
const reasons = [];
|
|
382
|
+
if (changes.added.length > 0) {
|
|
383
|
+
reasons.push(`${changes.added.length} new file(s)`);
|
|
384
|
+
}
|
|
385
|
+
if (changes.modified.length > 0) {
|
|
386
|
+
reasons.push(`${changes.modified.length} modified file(s)`);
|
|
387
|
+
}
|
|
388
|
+
if (changes.deleted.length > 0) {
|
|
389
|
+
reasons.push(`${changes.deleted.length} deleted file(s)`);
|
|
390
|
+
}
|
|
391
|
+
return { stale: true, reason: reasons.join(', ') };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Save metadata for indexed files
|
|
395
|
+
*/
|
|
396
|
+
async saveFileMetadata(files) {
|
|
397
|
+
const metadata = [];
|
|
398
|
+
for (const filepath of files) {
|
|
399
|
+
const relativePath = path.relative(this.projectPath, filepath);
|
|
400
|
+
const mtime = await this.getFileMtime(filepath);
|
|
401
|
+
metadata.push({ filepath: relativePath, mtime });
|
|
402
|
+
}
|
|
403
|
+
// Drop and recreate metadata table
|
|
404
|
+
const tableNames = await this.db.tableNames();
|
|
405
|
+
if (tableNames.includes('file_metadata')) {
|
|
406
|
+
await this.db.dropTable('file_metadata');
|
|
407
|
+
}
|
|
408
|
+
if (metadata.length > 0) {
|
|
409
|
+
this.metadataTable = await this.db.createTable('file_metadata', metadata);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async getStatus() {
|
|
413
|
+
const tableNames = await this.db?.tableNames();
|
|
414
|
+
const hasTable = tableNames?.includes('code_chunks') ?? false;
|
|
415
|
+
if (!hasTable) {
|
|
416
|
+
return {
|
|
417
|
+
indexed: false,
|
|
418
|
+
fileCount: 0,
|
|
419
|
+
chunkCount: 0,
|
|
420
|
+
lastUpdated: null,
|
|
421
|
+
indexPath: this.indexPath,
|
|
422
|
+
embeddingBackend: this.embeddingBackend.name,
|
|
423
|
+
embeddingModel: this.embeddingBackend.getModel(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
this.table = await this.db.openTable('code_chunks');
|
|
427
|
+
const count = await this.table.countRows();
|
|
428
|
+
// Load persisted metadata
|
|
429
|
+
const metadata = await this.loadIndexMetadata();
|
|
430
|
+
// Validate index integrity
|
|
431
|
+
const corruptionCheck = await this.validateIndexIntegrity(metadata, count);
|
|
432
|
+
// Check for backend mismatch
|
|
433
|
+
const backendMismatch = this.checkBackendMismatch(metadata);
|
|
434
|
+
return {
|
|
435
|
+
indexed: true,
|
|
436
|
+
fileCount: metadata?.fileCount ?? 0,
|
|
437
|
+
chunkCount: count,
|
|
438
|
+
lastUpdated: metadata?.lastUpdated ?? null,
|
|
439
|
+
indexPath: this.indexPath,
|
|
440
|
+
embeddingBackend: metadata?.embeddingBackend ?? this.embeddingBackend.name,
|
|
441
|
+
embeddingModel: metadata?.embeddingModel ?? this.embeddingBackend.getModel(),
|
|
442
|
+
corrupted: corruptionCheck.corrupted,
|
|
443
|
+
corruptionReason: corruptionCheck.reason,
|
|
444
|
+
backendMismatch: backendMismatch.mismatch,
|
|
445
|
+
backendMismatchReason: backendMismatch.reason,
|
|
446
|
+
chunkingStats: metadata?.chunkingStats,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Check if the current embedding backend differs from the one used to create the index.
|
|
451
|
+
* Returns mismatch status and reason if mismatched.
|
|
452
|
+
*/
|
|
453
|
+
checkBackendMismatch(metadata) {
|
|
454
|
+
if (!metadata) {
|
|
455
|
+
return { mismatch: false };
|
|
456
|
+
}
|
|
457
|
+
const currentBackend = this.embeddingBackend.name;
|
|
458
|
+
const currentModel = this.embeddingBackend.getModel();
|
|
459
|
+
const currentDimensions = this.embeddingBackend.getDimensions();
|
|
460
|
+
// Check dimension mismatch (critical - will cause search failures)
|
|
461
|
+
if (metadata.embeddingDimensions && metadata.embeddingDimensions !== currentDimensions) {
|
|
462
|
+
return {
|
|
463
|
+
mismatch: true,
|
|
464
|
+
reason: `Embedding dimension mismatch: index has ${metadata.embeddingDimensions}-dim vectors, ` +
|
|
465
|
+
`current backend (${currentBackend}) produces ${currentDimensions}-dim vectors. Reindex required.`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
// Check model mismatch (different models produce incompatible embeddings)
|
|
469
|
+
if (metadata.embeddingModel && metadata.embeddingModel !== currentModel) {
|
|
470
|
+
return {
|
|
471
|
+
mismatch: true,
|
|
472
|
+
reason: `Embedding model mismatch: index uses '${metadata.embeddingModel}', ` +
|
|
473
|
+
`current backend uses '${currentModel}'. Reindex required.`,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// Check backend mismatch (even same dimensions may have different embedding spaces)
|
|
477
|
+
if (metadata.embeddingBackend && metadata.embeddingBackend !== currentBackend) {
|
|
478
|
+
return {
|
|
479
|
+
mismatch: true,
|
|
480
|
+
reason: `Embedding backend changed from '${metadata.embeddingBackend}' to '${currentBackend}'. Reindex required.`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
return { mismatch: false };
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Validate index integrity by checking metadata consistency.
|
|
487
|
+
* Returns corruption status and reason if corrupted.
|
|
488
|
+
*/
|
|
489
|
+
async validateIndexIntegrity(metadata, actualChunkCount) {
|
|
490
|
+
// No metadata file - possible incomplete indexing
|
|
491
|
+
if (!metadata) {
|
|
492
|
+
return {
|
|
493
|
+
corrupted: true,
|
|
494
|
+
reason: 'Missing index metadata file. Index may be incomplete. Run clear_index followed by index_codebase to rebuild.',
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// Check if chunk count matches
|
|
498
|
+
if (metadata.chunkCount !== actualChunkCount) {
|
|
499
|
+
return {
|
|
500
|
+
corrupted: true,
|
|
501
|
+
reason: `Chunk count mismatch: metadata says ${metadata.chunkCount}, index has ${actualChunkCount}. Run clear_index followed by index_codebase to rebuild.`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
// Validate checksum if present
|
|
505
|
+
if (metadata.checksum) {
|
|
506
|
+
const storedFiles = await this.getStoredMetadata();
|
|
507
|
+
const fileList = Array.from(storedFiles.keys());
|
|
508
|
+
const computedChecksum = computeIndexChecksum(fileList, actualChunkCount);
|
|
509
|
+
if (computedChecksum !== metadata.checksum) {
|
|
510
|
+
return {
|
|
511
|
+
corrupted: true,
|
|
512
|
+
reason: `Checksum mismatch: file metadata does not match index. Run clear_index followed by index_codebase to rebuild.`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return { corrupted: false };
|
|
517
|
+
}
|
|
518
|
+
async indexCodebase(patterns, excludePatterns, forceReindex = false, onProgress, autoRepair = false) {
|
|
519
|
+
const { glob } = await import('glob');
|
|
520
|
+
const report = (progress) => {
|
|
521
|
+
console.error(`[glancey] ${progress.message}`);
|
|
522
|
+
onProgress?.(progress);
|
|
523
|
+
};
|
|
524
|
+
// Reset chunking stats for this indexing run
|
|
525
|
+
this.currentChunkingStats = this.createEmptyChunkingStats();
|
|
526
|
+
// Check for corruption if autoRepair is enabled
|
|
527
|
+
if (autoRepair) {
|
|
528
|
+
const status = await this.getStatus();
|
|
529
|
+
if (status.corrupted) {
|
|
530
|
+
console.error(`[glancey] Index corruption detected: ${status.corruptionReason}`);
|
|
531
|
+
console.error('[glancey] Auto-repair enabled, clearing and rebuilding index...');
|
|
532
|
+
await this.clearIndex();
|
|
533
|
+
// Recursively call with forceReindex but without autoRepair to avoid loops
|
|
534
|
+
const result = await this.indexCodebase(patterns, excludePatterns, true, onProgress, false);
|
|
535
|
+
return { ...result, repaired: true };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// Check for incomplete checkpoint (resume interrupted indexing)
|
|
539
|
+
if (!forceReindex) {
|
|
540
|
+
const checkpoint = await this.loadCheckpoint();
|
|
541
|
+
if (checkpoint && checkpoint.phase !== 'complete') {
|
|
542
|
+
// Validate checkpoint is compatible with current backend
|
|
543
|
+
const currentBackend = this.embeddingBackend.name;
|
|
544
|
+
const currentModel = this.embeddingBackend.getModel();
|
|
545
|
+
if (checkpoint.embeddingBackend !== currentBackend ||
|
|
546
|
+
checkpoint.embeddingModel !== currentModel) {
|
|
547
|
+
console.error(`[glancey] Checkpoint uses different embedding backend/model ` +
|
|
548
|
+
`(${checkpoint.embeddingBackend}/${checkpoint.embeddingModel} vs ${currentBackend}/${currentModel}), ` +
|
|
549
|
+
`discarding checkpoint`);
|
|
550
|
+
await this.clearCheckpoint();
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
// Validate checkpoint freshness - ensure files haven't changed
|
|
554
|
+
const isFresh = await this.validateCheckpointFreshness(checkpoint);
|
|
555
|
+
if (!isFresh) {
|
|
556
|
+
console.error(`[glancey] Checkpoint is stale (files modified since ${checkpoint.startedAt}), discarding`);
|
|
557
|
+
await this.clearCheckpoint();
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
console.error(`[glancey] Found incomplete checkpoint from ${checkpoint.startedAt}, resuming...`);
|
|
561
|
+
return this.resumeFromCheckpoint(checkpoint, onProgress);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Use provided patterns or fall back to config/defaults
|
|
567
|
+
const effectivePatterns = patterns || this.config?.patterns || getDefaultPatterns();
|
|
568
|
+
const effectiveExcludePatterns = excludePatterns || this.config?.excludePatterns || getDefaultExcludePatterns();
|
|
569
|
+
report({ phase: 'scanning', current: 0, total: 0, message: 'Scanning for files...' });
|
|
570
|
+
// Find all matching files
|
|
571
|
+
const files = [];
|
|
572
|
+
for (const pattern of effectivePatterns) {
|
|
573
|
+
const matches = await glob(pattern, {
|
|
574
|
+
cwd: this.projectPath,
|
|
575
|
+
ignore: effectiveExcludePatterns,
|
|
576
|
+
absolute: true,
|
|
577
|
+
});
|
|
578
|
+
files.push(...matches);
|
|
579
|
+
}
|
|
580
|
+
report({
|
|
581
|
+
phase: 'scanning',
|
|
582
|
+
current: files.length,
|
|
583
|
+
total: files.length,
|
|
584
|
+
message: `Found ${files.length} files to index`,
|
|
585
|
+
});
|
|
586
|
+
// Check if we can do incremental indexing
|
|
587
|
+
const tableNames = await this.db.tableNames();
|
|
588
|
+
const hasExistingIndex = tableNames.includes('code_chunks');
|
|
589
|
+
// Check for embedding dimension or model mismatch
|
|
590
|
+
let embeddingMismatch = false;
|
|
591
|
+
if (hasExistingIndex && !forceReindex) {
|
|
592
|
+
const metadata = await this.loadIndexMetadata();
|
|
593
|
+
const currentDimensions = this.embeddingBackend.getDimensions();
|
|
594
|
+
const currentModel = this.embeddingBackend.getModel();
|
|
595
|
+
// Check dimension mismatch
|
|
596
|
+
if (metadata?.embeddingDimensions && metadata.embeddingDimensions !== currentDimensions) {
|
|
597
|
+
console.error(`[glancey] Embedding dimension mismatch: index has ${metadata.embeddingDimensions}, ` +
|
|
598
|
+
`current backend (${this.embeddingBackend.name}) uses ${currentDimensions}. Forcing full reindex.`);
|
|
599
|
+
embeddingMismatch = true;
|
|
600
|
+
}
|
|
601
|
+
// Check model mismatch (even if dimensions match, different models produce incompatible embeddings)
|
|
602
|
+
if (metadata?.embeddingModel && metadata.embeddingModel !== currentModel) {
|
|
603
|
+
console.error(`[glancey] Embedding model mismatch: index uses '${metadata.embeddingModel}', ` +
|
|
604
|
+
`current backend uses '${currentModel}'. Forcing full reindex.`);
|
|
605
|
+
embeddingMismatch = true;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const canDoIncremental = hasExistingIndex && !forceReindex && !embeddingMismatch;
|
|
609
|
+
if (canDoIncremental) {
|
|
610
|
+
return this.indexIncremental(files, onProgress);
|
|
611
|
+
}
|
|
612
|
+
// Full reindex
|
|
613
|
+
return this.indexFull(files, onProgress);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Perform a full reindex of all files
|
|
617
|
+
*/
|
|
618
|
+
async indexFull(files, onProgress) {
|
|
619
|
+
const report = (progress) => {
|
|
620
|
+
console.error(`[glancey] ${progress.message}`);
|
|
621
|
+
onProgress?.(progress);
|
|
622
|
+
};
|
|
623
|
+
const startedAt = new Date().toISOString();
|
|
624
|
+
// Process files into chunks (parallelized for I/O efficiency)
|
|
625
|
+
report({ phase: 'chunking', current: 0, total: files.length, message: 'Chunking files...' });
|
|
626
|
+
const chunkResults = await mapInBatches(files, async (filepath) => this.chunkFile(filepath), FILE_PROCESSING_CONCURRENCY, (completed, total) => {
|
|
627
|
+
report({
|
|
628
|
+
phase: 'chunking',
|
|
629
|
+
current: completed,
|
|
630
|
+
total,
|
|
631
|
+
message: `Chunked ${completed}/${total} files`,
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
const allChunks = chunkResults.flat();
|
|
635
|
+
report({
|
|
636
|
+
phase: 'chunking',
|
|
637
|
+
current: files.length,
|
|
638
|
+
total: files.length,
|
|
639
|
+
message: `Created ${allChunks.length} chunks`,
|
|
640
|
+
});
|
|
641
|
+
// Save checkpoint after chunking (non-blocking - embedding starts immediately)
|
|
642
|
+
// Skip mtime collection - it's slow for large codebases and only used for checkpoint validation
|
|
643
|
+
this.saveCheckpoint({
|
|
644
|
+
phase: 'chunking',
|
|
645
|
+
startedAt,
|
|
646
|
+
files,
|
|
647
|
+
processedFiles: [],
|
|
648
|
+
pendingChunks: allChunks,
|
|
649
|
+
embeddingBackend: this.embeddingBackend.name,
|
|
650
|
+
embeddingModel: this.embeddingBackend.getModel(),
|
|
651
|
+
});
|
|
652
|
+
// Generate embeddings in batches
|
|
653
|
+
await this.embedChunks(allChunks, onProgress);
|
|
654
|
+
// Save checkpoint after embedding (before storage) - blocking to ensure embeddings are saved
|
|
655
|
+
await this.saveCheckpoint({
|
|
656
|
+
phase: 'embedding',
|
|
657
|
+
startedAt,
|
|
658
|
+
files,
|
|
659
|
+
processedFiles: [],
|
|
660
|
+
embeddedChunks: allChunks,
|
|
661
|
+
embeddingBackend: this.embeddingBackend.name,
|
|
662
|
+
embeddingModel: this.embeddingBackend.getModel(),
|
|
663
|
+
}, { blocking: true });
|
|
664
|
+
// Store in LanceDB
|
|
665
|
+
const data = allChunks.map((chunk) => ({
|
|
666
|
+
id: chunk.id,
|
|
667
|
+
filepath: chunk.filepath,
|
|
668
|
+
content: chunk.content,
|
|
669
|
+
startLine: chunk.startLine,
|
|
670
|
+
endLine: chunk.endLine,
|
|
671
|
+
language: chunk.language,
|
|
672
|
+
vector: chunk.embedding,
|
|
673
|
+
}));
|
|
674
|
+
// Drop existing table if exists
|
|
675
|
+
const tableNames = await this.db.tableNames();
|
|
676
|
+
if (tableNames.includes('code_chunks')) {
|
|
677
|
+
await this.db.dropTable('code_chunks');
|
|
678
|
+
}
|
|
679
|
+
this.table = await this.db.createTable('code_chunks', data);
|
|
680
|
+
// Save file metadata for future incremental indexing
|
|
681
|
+
await this.saveFileMetadata(files);
|
|
682
|
+
// Save index metadata with checksum
|
|
683
|
+
await this.saveIndexMetadata(files.length, allChunks.length, files);
|
|
684
|
+
// Log chunking statistics
|
|
685
|
+
this.logChunkingStats();
|
|
686
|
+
// Clear checkpoint on successful completion
|
|
687
|
+
await this.clearCheckpoint();
|
|
688
|
+
return {
|
|
689
|
+
filesIndexed: files.length,
|
|
690
|
+
chunksCreated: allChunks.length,
|
|
691
|
+
incremental: false,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Log chunking statistics summary, with warnings for fallbacks
|
|
696
|
+
*/
|
|
697
|
+
logChunkingStats() {
|
|
698
|
+
const stats = this.currentChunkingStats;
|
|
699
|
+
const totalFallbacks = stats.astFallbacks.length + stats.treeSitterFallbacks.length;
|
|
700
|
+
const totalFiles = stats.astChunked + stats.treeSitterChunked + stats.lineBasedChunked;
|
|
701
|
+
console.error(`[glancey] Chunking: ${stats.astChunked} AST, ${stats.treeSitterChunked} tree-sitter, ${stats.lineBasedChunked} line-based`);
|
|
702
|
+
if (totalFallbacks > 0) {
|
|
703
|
+
const fallbackPct = ((totalFallbacks / totalFiles) * 100).toFixed(1);
|
|
704
|
+
console.error(`[glancey] Warning: ${totalFallbacks} files (${fallbackPct}%) fell back to line-based chunking`);
|
|
705
|
+
if (stats.astFallbacks.length > 0 && stats.astFallbacks.length <= 5) {
|
|
706
|
+
console.error(`[glancey] AST fallbacks: ${stats.astFallbacks.join(', ')}`);
|
|
707
|
+
}
|
|
708
|
+
else if (stats.astFallbacks.length > 5) {
|
|
709
|
+
console.error(`[glancey] AST fallbacks: ${stats.astFallbacks.slice(0, 5).join(', ')} (+${stats.astFallbacks.length - 5} more)`);
|
|
710
|
+
}
|
|
711
|
+
if (stats.treeSitterFallbacks.length > 0 && stats.treeSitterFallbacks.length <= 5) {
|
|
712
|
+
console.error(`[glancey] Tree-sitter fallbacks: ${stats.treeSitterFallbacks.join(', ')}`);
|
|
713
|
+
}
|
|
714
|
+
else if (stats.treeSitterFallbacks.length > 5) {
|
|
715
|
+
console.error(`[glancey] Tree-sitter fallbacks: ${stats.treeSitterFallbacks.slice(0, 5).join(', ')} (+${stats.treeSitterFallbacks.length - 5} more)`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Perform incremental indexing - only process changed files
|
|
721
|
+
*/
|
|
722
|
+
async indexIncremental(files, onProgress) {
|
|
723
|
+
const report = (progress) => {
|
|
724
|
+
console.error(`[glancey] ${progress.message}`);
|
|
725
|
+
onProgress?.(progress);
|
|
726
|
+
};
|
|
727
|
+
const changes = await this.detectFileChanges(files);
|
|
728
|
+
const filesToProcess = [...changes.added, ...changes.modified];
|
|
729
|
+
const hasChanges = filesToProcess.length > 0 || changes.deleted.length > 0;
|
|
730
|
+
if (!hasChanges) {
|
|
731
|
+
report({
|
|
732
|
+
phase: 'complete',
|
|
733
|
+
current: 0,
|
|
734
|
+
total: 0,
|
|
735
|
+
message: 'No changes detected, index is up to date',
|
|
736
|
+
});
|
|
737
|
+
this.table = await this.db.openTable('code_chunks');
|
|
738
|
+
const count = await this.table.countRows();
|
|
739
|
+
return {
|
|
740
|
+
filesIndexed: 0,
|
|
741
|
+
chunksCreated: count,
|
|
742
|
+
incremental: true,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
report({
|
|
746
|
+
phase: 'scanning',
|
|
747
|
+
current: 0,
|
|
748
|
+
total: filesToProcess.length,
|
|
749
|
+
message: `Incremental update: ${changes.added.length} added, ${changes.modified.length} modified, ${changes.deleted.length} deleted`,
|
|
750
|
+
});
|
|
751
|
+
// Open the existing table
|
|
752
|
+
this.table = await this.db.openTable('code_chunks');
|
|
753
|
+
// Delete chunks from modified and deleted files
|
|
754
|
+
const filesToRemove = [
|
|
755
|
+
...changes.modified.map((f) => path.relative(this.projectPath, f)),
|
|
756
|
+
...changes.deleted,
|
|
757
|
+
];
|
|
758
|
+
if (filesToRemove.length > 0) {
|
|
759
|
+
for (const relativePath of filesToRemove) {
|
|
760
|
+
const sanitizedPath = sanitizeForFilter(relativePath);
|
|
761
|
+
await this.table.delete(`filepath = '${sanitizedPath}'`);
|
|
762
|
+
}
|
|
763
|
+
report({
|
|
764
|
+
phase: 'chunking',
|
|
765
|
+
current: 0,
|
|
766
|
+
total: filesToProcess.length,
|
|
767
|
+
message: `Removed chunks from ${filesToRemove.length} files`,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
// Process new and modified files (parallelized for I/O efficiency)
|
|
771
|
+
if (filesToProcess.length > 0) {
|
|
772
|
+
const chunkResults = await mapInBatches(filesToProcess, async (filepath) => this.chunkFile(filepath), FILE_PROCESSING_CONCURRENCY, (completed, total) => {
|
|
773
|
+
report({
|
|
774
|
+
phase: 'chunking',
|
|
775
|
+
current: completed,
|
|
776
|
+
total,
|
|
777
|
+
message: `Chunked ${completed}/${total} files`,
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
const newChunks = chunkResults.flat();
|
|
781
|
+
report({
|
|
782
|
+
phase: 'chunking',
|
|
783
|
+
current: filesToProcess.length,
|
|
784
|
+
total: filesToProcess.length,
|
|
785
|
+
message: `Created ${newChunks.length} new chunks`,
|
|
786
|
+
});
|
|
787
|
+
// Generate embeddings
|
|
788
|
+
await this.embedChunks(newChunks, onProgress);
|
|
789
|
+
// Add new chunks to the table
|
|
790
|
+
const data = newChunks.map((chunk) => ({
|
|
791
|
+
id: chunk.id,
|
|
792
|
+
filepath: chunk.filepath,
|
|
793
|
+
content: chunk.content,
|
|
794
|
+
startLine: chunk.startLine,
|
|
795
|
+
endLine: chunk.endLine,
|
|
796
|
+
language: chunk.language,
|
|
797
|
+
vector: chunk.embedding,
|
|
798
|
+
}));
|
|
799
|
+
if (data.length > 0) {
|
|
800
|
+
await this.table.add(data);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Update file metadata
|
|
804
|
+
const allCurrentFiles = [...changes.unchanged, ...changes.added, ...changes.modified];
|
|
805
|
+
await this.saveFileMetadata(allCurrentFiles);
|
|
806
|
+
const totalChunks = await this.table.countRows();
|
|
807
|
+
// Save index metadata with checksum
|
|
808
|
+
await this.saveIndexMetadata(allCurrentFiles.length, totalChunks, allCurrentFiles);
|
|
809
|
+
// Log chunking statistics
|
|
810
|
+
this.logChunkingStats();
|
|
811
|
+
return {
|
|
812
|
+
filesIndexed: filesToProcess.length,
|
|
813
|
+
chunksCreated: totalChunks,
|
|
814
|
+
incremental: true,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Resume indexing from a saved checkpoint.
|
|
819
|
+
* Handles each checkpoint phase appropriately.
|
|
820
|
+
*/
|
|
821
|
+
async resumeFromCheckpoint(checkpoint, onProgress) {
|
|
822
|
+
const report = (progress) => {
|
|
823
|
+
console.error(`[glancey] ${progress.message}`);
|
|
824
|
+
onProgress?.(progress);
|
|
825
|
+
};
|
|
826
|
+
report({
|
|
827
|
+
phase: checkpoint.phase,
|
|
828
|
+
current: 0,
|
|
829
|
+
total: checkpoint.files.length,
|
|
830
|
+
message: `Resuming from ${checkpoint.phase} phase (started ${checkpoint.startedAt})`,
|
|
831
|
+
});
|
|
832
|
+
let allChunks;
|
|
833
|
+
switch (checkpoint.phase) {
|
|
834
|
+
case 'chunking': {
|
|
835
|
+
// Resume from chunking phase - chunks are ready but not embedded
|
|
836
|
+
if (!checkpoint.pendingChunks || checkpoint.pendingChunks.length === 0) {
|
|
837
|
+
console.error('[glancey] Checkpoint has no pending chunks, restarting full index');
|
|
838
|
+
await this.clearCheckpoint();
|
|
839
|
+
return this.indexFull(checkpoint.files, onProgress);
|
|
840
|
+
}
|
|
841
|
+
allChunks = checkpoint.pendingChunks;
|
|
842
|
+
// Re-read chunk content from files (stripped during checkpoint save to reduce size)
|
|
843
|
+
report({
|
|
844
|
+
phase: 'chunking',
|
|
845
|
+
current: 0,
|
|
846
|
+
total: allChunks.length,
|
|
847
|
+
message: `Re-reading content for ${allChunks.length} chunks...`,
|
|
848
|
+
});
|
|
849
|
+
await this.rehydrateChunkContent(allChunks);
|
|
850
|
+
report({
|
|
851
|
+
phase: 'chunking',
|
|
852
|
+
current: checkpoint.files.length,
|
|
853
|
+
total: checkpoint.files.length,
|
|
854
|
+
message: `Resumed with ${allChunks.length} chunks ready for embedding`,
|
|
855
|
+
});
|
|
856
|
+
// Continue with embedding
|
|
857
|
+
await this.embedChunks(allChunks, onProgress);
|
|
858
|
+
// Save checkpoint after embedding (preserve fileMtimes from original checkpoint) - blocking
|
|
859
|
+
await this.saveCheckpoint({
|
|
860
|
+
phase: 'embedding',
|
|
861
|
+
startedAt: checkpoint.startedAt,
|
|
862
|
+
files: checkpoint.files,
|
|
863
|
+
processedFiles: [],
|
|
864
|
+
embeddedChunks: allChunks,
|
|
865
|
+
embeddingBackend: this.embeddingBackend.name,
|
|
866
|
+
embeddingModel: this.embeddingBackend.getModel(),
|
|
867
|
+
fileMtimes: checkpoint.fileMtimes,
|
|
868
|
+
}, { blocking: true });
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
case 'embedding': {
|
|
872
|
+
// Resume from embedding phase - chunks are embedded but not stored
|
|
873
|
+
if (!checkpoint.embeddedChunks || checkpoint.embeddedChunks.length === 0) {
|
|
874
|
+
console.error('[glancey] Checkpoint has no embedded chunks, restarting full index');
|
|
875
|
+
await this.clearCheckpoint();
|
|
876
|
+
return this.indexFull(checkpoint.files, onProgress);
|
|
877
|
+
}
|
|
878
|
+
allChunks = checkpoint.embeddedChunks;
|
|
879
|
+
// Re-read chunk content from files (stripped during checkpoint save to reduce size)
|
|
880
|
+
report({
|
|
881
|
+
phase: 'embedding',
|
|
882
|
+
current: 0,
|
|
883
|
+
total: allChunks.length,
|
|
884
|
+
message: `Re-reading content for ${allChunks.length} chunks...`,
|
|
885
|
+
});
|
|
886
|
+
await this.rehydrateChunkContent(allChunks);
|
|
887
|
+
report({
|
|
888
|
+
phase: 'embedding',
|
|
889
|
+
current: allChunks.length,
|
|
890
|
+
total: allChunks.length,
|
|
891
|
+
message: `Resumed with ${allChunks.length} embedded chunks ready for storage`,
|
|
892
|
+
});
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
default:
|
|
896
|
+
// Unknown phase, restart full index
|
|
897
|
+
console.error(`[glancey] Unknown checkpoint phase: ${checkpoint.phase}, restarting`);
|
|
898
|
+
await this.clearCheckpoint();
|
|
899
|
+
return this.indexFull(checkpoint.files, onProgress);
|
|
900
|
+
}
|
|
901
|
+
// Store in LanceDB
|
|
902
|
+
report({
|
|
903
|
+
phase: 'storing',
|
|
904
|
+
current: 0,
|
|
905
|
+
total: allChunks.length,
|
|
906
|
+
message: 'Storing chunks in database...',
|
|
907
|
+
});
|
|
908
|
+
const data = allChunks.map((chunk) => ({
|
|
909
|
+
id: chunk.id,
|
|
910
|
+
filepath: chunk.filepath,
|
|
911
|
+
content: chunk.content,
|
|
912
|
+
startLine: chunk.startLine,
|
|
913
|
+
endLine: chunk.endLine,
|
|
914
|
+
language: chunk.language,
|
|
915
|
+
vector: chunk.embedding,
|
|
916
|
+
}));
|
|
917
|
+
// Drop existing table if exists
|
|
918
|
+
const tableNames = await this.db.tableNames();
|
|
919
|
+
if (tableNames.includes('code_chunks')) {
|
|
920
|
+
await this.db.dropTable('code_chunks');
|
|
921
|
+
}
|
|
922
|
+
this.table = await this.db.createTable('code_chunks', data);
|
|
923
|
+
// Save file metadata for future incremental indexing
|
|
924
|
+
await this.saveFileMetadata(checkpoint.files);
|
|
925
|
+
// Save index metadata with checksum
|
|
926
|
+
await this.saveIndexMetadata(checkpoint.files.length, allChunks.length, checkpoint.files);
|
|
927
|
+
// Clear checkpoint on successful completion
|
|
928
|
+
await this.clearCheckpoint();
|
|
929
|
+
report({
|
|
930
|
+
phase: 'complete',
|
|
931
|
+
current: allChunks.length,
|
|
932
|
+
total: allChunks.length,
|
|
933
|
+
message: `Resumed indexing complete: ${checkpoint.files.length} files, ${allChunks.length} chunks`,
|
|
934
|
+
});
|
|
935
|
+
return {
|
|
936
|
+
filesIndexed: checkpoint.files.length,
|
|
937
|
+
chunksCreated: allChunks.length,
|
|
938
|
+
incremental: false,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Generate embeddings for chunks in batches.
|
|
943
|
+
* Supports configurable batch size and delay between batches for rate limiting.
|
|
944
|
+
*/
|
|
945
|
+
async embedChunks(chunks, onProgress) {
|
|
946
|
+
const report = (progress) => {
|
|
947
|
+
console.error(`[glancey] ${progress.message}`);
|
|
948
|
+
onProgress?.(progress);
|
|
949
|
+
};
|
|
950
|
+
// Get indexing config for rate limiting
|
|
951
|
+
const indexingConfig = this.config
|
|
952
|
+
? getIndexingConfig(this.config)
|
|
953
|
+
: { batchSize: 200, batchDelayMs: 0 };
|
|
954
|
+
const { batchDelayMs } = indexingConfig;
|
|
955
|
+
// Use batch size of 2000 to balance parallelization with progress visibility
|
|
956
|
+
// The embedding backend will split into smaller batches and process in parallel
|
|
957
|
+
// With Ollama defaults (batchSize=100, concurrency=100), 2000 = 20 batches = 1 parallel round
|
|
958
|
+
const embeddingBatchSize = 2000;
|
|
959
|
+
const startTime = Date.now();
|
|
960
|
+
let processedChunks = 0;
|
|
961
|
+
// Report initial progress so user knows embedding has started
|
|
962
|
+
report({
|
|
963
|
+
phase: 'embedding',
|
|
964
|
+
current: 0,
|
|
965
|
+
total: chunks.length,
|
|
966
|
+
message: `Starting embedding of ${chunks.length} chunks...`,
|
|
967
|
+
});
|
|
968
|
+
for (let i = 0; i < chunks.length; i += embeddingBatchSize) {
|
|
969
|
+
const batch = chunks.slice(i, i + embeddingBatchSize);
|
|
970
|
+
const texts = batch.map((c) => c.content);
|
|
971
|
+
const batchMsg = `Sending ${texts.length} texts to embedding backend (batch ${Math.floor(i / embeddingBatchSize) + 1}/${Math.ceil(chunks.length / embeddingBatchSize)})...`;
|
|
972
|
+
console.error(`[glancey] ${batchMsg}`);
|
|
973
|
+
broadcastLog('info', batchMsg);
|
|
974
|
+
const embeddings = await this.embeddingBackend.embedBatch(texts);
|
|
975
|
+
batch.forEach((chunk, idx) => {
|
|
976
|
+
chunk.embedding = embeddings[idx];
|
|
977
|
+
});
|
|
978
|
+
processedChunks = i + batch.length;
|
|
979
|
+
const elapsedMs = Date.now() - startTime;
|
|
980
|
+
const chunksPerMs = processedChunks / elapsedMs;
|
|
981
|
+
const remainingChunks = chunks.length - processedChunks;
|
|
982
|
+
const etaSeconds = chunksPerMs > 0 ? Math.round(remainingChunks / chunksPerMs / 1000) : undefined;
|
|
983
|
+
report({
|
|
984
|
+
phase: 'embedding',
|
|
985
|
+
current: processedChunks,
|
|
986
|
+
total: chunks.length,
|
|
987
|
+
message: `Embedded ${processedChunks}/${chunks.length} chunks`,
|
|
988
|
+
etaSeconds,
|
|
989
|
+
});
|
|
990
|
+
// Apply rate limiting delay between batches (if configured and not the last batch)
|
|
991
|
+
if (batchDelayMs > 0 && i + embeddingBatchSize < chunks.length) {
|
|
992
|
+
await new Promise((resolve) => setTimeout(resolve, batchDelayMs));
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async chunkFile(filepath) {
|
|
997
|
+
const ext = path.extname(filepath).slice(1);
|
|
998
|
+
const language = this.getLanguage(ext);
|
|
999
|
+
const relativePath = path.relative(this.projectPath, filepath);
|
|
1000
|
+
// Try AST-aware chunking for TypeScript/JavaScript
|
|
1001
|
+
if (ASTChunker.canParse(filepath)) {
|
|
1002
|
+
try {
|
|
1003
|
+
const chunks = await this.chunkFileWithAST(filepath, relativePath, language);
|
|
1004
|
+
this.currentChunkingStats.astChunked++;
|
|
1005
|
+
return chunks;
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
// Fall back to line-based chunking if AST parsing fails
|
|
1009
|
+
console.error(`[glancey] AST parsing failed for ${relativePath}, falling back to line-based chunking`);
|
|
1010
|
+
this.currentChunkingStats.astFallbacks.push(relativePath);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// Try tree-sitter chunking for other languages (Python, Go, Rust, Java, Kotlin)
|
|
1014
|
+
if (TreeSitterChunker.canParse(filepath)) {
|
|
1015
|
+
try {
|
|
1016
|
+
const chunks = await this.chunkFileWithTreeSitter(filepath, relativePath, language);
|
|
1017
|
+
this.currentChunkingStats.treeSitterChunked++;
|
|
1018
|
+
return chunks;
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
// Fall back to line-based chunking if tree-sitter parsing fails
|
|
1022
|
+
console.error(`[glancey] Tree-sitter parsing failed for ${relativePath}, falling back to line-based chunking:`, error);
|
|
1023
|
+
this.currentChunkingStats.treeSitterFallbacks.push(relativePath);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Line-based chunking for unsupported languages or as fallback
|
|
1027
|
+
this.currentChunkingStats.lineBasedChunked++;
|
|
1028
|
+
return this.chunkFileByLines(filepath, relativePath, language);
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Chunk a file using AST-aware parsing
|
|
1032
|
+
*/
|
|
1033
|
+
async chunkFileWithAST(filepath, relativePath, language) {
|
|
1034
|
+
const astChunker = new ASTChunker();
|
|
1035
|
+
const astChunks = await astChunker.chunkFile(filepath);
|
|
1036
|
+
return astChunks.map((chunk) => ({
|
|
1037
|
+
id: `${relativePath}:${chunk.startLine}-${chunk.endLine}${chunk.name ? `:${chunk.name}` : ''}`,
|
|
1038
|
+
filepath: relativePath,
|
|
1039
|
+
content: chunk.content,
|
|
1040
|
+
startLine: chunk.startLine,
|
|
1041
|
+
endLine: chunk.endLine,
|
|
1042
|
+
language,
|
|
1043
|
+
symbolType: chunk.type,
|
|
1044
|
+
symbolName: chunk.name,
|
|
1045
|
+
}));
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Chunk a file using tree-sitter AST parsing (Python, Go, Rust, Java, Kotlin)
|
|
1049
|
+
*/
|
|
1050
|
+
async chunkFileWithTreeSitter(filepath, relativePath, language) {
|
|
1051
|
+
const treeSitterChunker = new TreeSitterChunker();
|
|
1052
|
+
const treeSitterChunks = await treeSitterChunker.chunkFile(filepath);
|
|
1053
|
+
return treeSitterChunks.map((chunk) => ({
|
|
1054
|
+
id: `${relativePath}:${chunk.startLine}-${chunk.endLine}${chunk.name ? `:${chunk.name}` : ''}`,
|
|
1055
|
+
filepath: relativePath,
|
|
1056
|
+
content: chunk.content,
|
|
1057
|
+
startLine: chunk.startLine,
|
|
1058
|
+
endLine: chunk.endLine,
|
|
1059
|
+
language,
|
|
1060
|
+
symbolType: chunk.type,
|
|
1061
|
+
symbolName: chunk.name,
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Chunk a file using line-based splitting (fallback)
|
|
1066
|
+
*/
|
|
1067
|
+
async chunkFileByLines(filepath, relativePath, language) {
|
|
1068
|
+
const content = await fs.readFile(filepath, 'utf-8');
|
|
1069
|
+
const lines = content.split('\n');
|
|
1070
|
+
const chunkingConfig = getChunkingConfig(this.config);
|
|
1071
|
+
const chunkSize = chunkingConfig.maxLines;
|
|
1072
|
+
const chunkOverlap = chunkingConfig.overlap;
|
|
1073
|
+
const chunks = [];
|
|
1074
|
+
for (let i = 0; i < lines.length; i += chunkSize - chunkOverlap) {
|
|
1075
|
+
const chunkLines = lines.slice(i, i + chunkSize);
|
|
1076
|
+
const chunkContent = chunkLines.join('\n');
|
|
1077
|
+
if (chunkContent.trim().length === 0)
|
|
1078
|
+
continue;
|
|
1079
|
+
chunks.push({
|
|
1080
|
+
id: `${relativePath}:${i + 1}-${i + chunkLines.length}`,
|
|
1081
|
+
filepath: relativePath,
|
|
1082
|
+
content: chunkContent,
|
|
1083
|
+
startLine: i + 1,
|
|
1084
|
+
endLine: i + chunkLines.length,
|
|
1085
|
+
language,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return chunks;
|
|
1089
|
+
}
|
|
1090
|
+
getLanguage(ext) {
|
|
1091
|
+
const langMap = {
|
|
1092
|
+
ts: 'typescript',
|
|
1093
|
+
tsx: 'typescript',
|
|
1094
|
+
js: 'javascript',
|
|
1095
|
+
jsx: 'javascript',
|
|
1096
|
+
py: 'python',
|
|
1097
|
+
go: 'go',
|
|
1098
|
+
rs: 'rust',
|
|
1099
|
+
java: 'java',
|
|
1100
|
+
rb: 'ruby',
|
|
1101
|
+
php: 'php',
|
|
1102
|
+
c: 'c',
|
|
1103
|
+
cpp: 'cpp',
|
|
1104
|
+
h: 'c',
|
|
1105
|
+
hpp: 'cpp',
|
|
1106
|
+
cs: 'csharp',
|
|
1107
|
+
swift: 'swift',
|
|
1108
|
+
kt: 'kotlin',
|
|
1109
|
+
md: 'markdown',
|
|
1110
|
+
json: 'json',
|
|
1111
|
+
yaml: 'yaml',
|
|
1112
|
+
yml: 'yaml',
|
|
1113
|
+
};
|
|
1114
|
+
return langMap[ext] || ext;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Get query embedding from cache or compute it.
|
|
1118
|
+
* Uses TTLCache for LRU eviction and TTL-based expiration.
|
|
1119
|
+
*/
|
|
1120
|
+
async getQueryEmbedding(query) {
|
|
1121
|
+
// Check cache first
|
|
1122
|
+
const cached = this.queryEmbeddingCache.get(query);
|
|
1123
|
+
if (cached) {
|
|
1124
|
+
return cached;
|
|
1125
|
+
}
|
|
1126
|
+
// Compute embedding and cache it
|
|
1127
|
+
const embedding = await this.embeddingBackend.embed(query);
|
|
1128
|
+
this.queryEmbeddingCache.set(query, embedding);
|
|
1129
|
+
return embedding;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Check if a filepath matches a glob pattern.
|
|
1133
|
+
* Supports negation patterns starting with '!'.
|
|
1134
|
+
*/
|
|
1135
|
+
matchesPathPattern(filepath, pattern) {
|
|
1136
|
+
// Handle negation pattern
|
|
1137
|
+
if (pattern.startsWith('!')) {
|
|
1138
|
+
return !minimatch(filepath, pattern.slice(1));
|
|
1139
|
+
}
|
|
1140
|
+
return minimatch(filepath, pattern);
|
|
1141
|
+
}
|
|
1142
|
+
async search(queryOrOptions, limit) {
|
|
1143
|
+
// Normalize arguments
|
|
1144
|
+
const options = typeof queryOrOptions === 'string'
|
|
1145
|
+
? { query: queryOrOptions, limit: limit ?? 10 }
|
|
1146
|
+
: queryOrOptions;
|
|
1147
|
+
const { query, limit: resultLimit = 10, pathPattern, languages } = options;
|
|
1148
|
+
if (!this.table) {
|
|
1149
|
+
const status = await this.getStatus();
|
|
1150
|
+
if (!status.indexed) {
|
|
1151
|
+
throw new Error('Codebase not indexed. Run index_codebase first.');
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Check for stale index and auto-reindex if configured
|
|
1155
|
+
const searchConfig = getSearchConfig(this.config);
|
|
1156
|
+
if (searchConfig.autoReindex) {
|
|
1157
|
+
const staleCheck = await this.checkIfStale();
|
|
1158
|
+
if (staleCheck.stale) {
|
|
1159
|
+
// Perform incremental reindex (indexCodebase auto-detects changes)
|
|
1160
|
+
await this.indexCodebase();
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
const queryEmbedding = await this.getQueryEmbedding(query);
|
|
1164
|
+
// Fetch more results than needed for re-ranking and filtering
|
|
1165
|
+
// If we have filters, fetch even more to account for filtered-out results
|
|
1166
|
+
const hasFilters = pathPattern !== undefined || (languages && languages.length > 0);
|
|
1167
|
+
const fetchMultiplier = hasFilters ? 5 : 3;
|
|
1168
|
+
const fetchLimit = Math.min(resultLimit * fetchMultiplier, hasFilters ? 100 : 50);
|
|
1169
|
+
const results = await this.table.search(queryEmbedding).limit(fetchLimit).toArray();
|
|
1170
|
+
// Apply filters
|
|
1171
|
+
let filteredResults = results;
|
|
1172
|
+
if (pathPattern) {
|
|
1173
|
+
filteredResults = filteredResults.filter((r) => this.matchesPathPattern(r.filepath, pathPattern));
|
|
1174
|
+
}
|
|
1175
|
+
if (languages && languages.length > 0) {
|
|
1176
|
+
const normalizedLanguages = languages.map((l) => l.toLowerCase());
|
|
1177
|
+
filteredResults = filteredResults.filter((r) => normalizedLanguages.includes(r.language.toLowerCase()));
|
|
1178
|
+
}
|
|
1179
|
+
// Hybrid scoring: combine semantic similarity with keyword matching
|
|
1180
|
+
const scoredResults = filteredResults.map((r, index) => {
|
|
1181
|
+
// Semantic score: inverse of rank (higher is better)
|
|
1182
|
+
const semanticScore = 1 - index / fetchLimit;
|
|
1183
|
+
// Keyword score: based on query term matches
|
|
1184
|
+
const keywordScore = this.calculateKeywordScore(query, r.content, r.filepath);
|
|
1185
|
+
// Combined score using configurable weights
|
|
1186
|
+
const combinedScore = searchConfig.semanticWeight * semanticScore + searchConfig.keywordWeight * keywordScore;
|
|
1187
|
+
return { result: r, score: combinedScore };
|
|
1188
|
+
});
|
|
1189
|
+
// Sort by combined score and take top results
|
|
1190
|
+
scoredResults.sort((a, b) => b.score - a.score);
|
|
1191
|
+
return scoredResults.slice(0, resultLimit).map((sr) => ({
|
|
1192
|
+
id: sr.result.id,
|
|
1193
|
+
filepath: sr.result.filepath,
|
|
1194
|
+
content: sr.result.content,
|
|
1195
|
+
startLine: sr.result.startLine,
|
|
1196
|
+
endLine: sr.result.endLine,
|
|
1197
|
+
language: sr.result.language,
|
|
1198
|
+
symbolType: sr.result.symbolType,
|
|
1199
|
+
symbolName: sr.result.symbolName,
|
|
1200
|
+
}));
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Calculate keyword match score for hybrid search
|
|
1204
|
+
*/
|
|
1205
|
+
calculateKeywordScore(query, content, filepath) {
|
|
1206
|
+
const queryTerms = query
|
|
1207
|
+
.toLowerCase()
|
|
1208
|
+
.split(/\s+/)
|
|
1209
|
+
.filter((t) => t.length > 2);
|
|
1210
|
+
if (queryTerms.length === 0)
|
|
1211
|
+
return 0;
|
|
1212
|
+
const contentLower = content.toLowerCase();
|
|
1213
|
+
const filepathLower = filepath.toLowerCase();
|
|
1214
|
+
let matchCount = 0;
|
|
1215
|
+
let exactMatchBonus = 0;
|
|
1216
|
+
for (const term of queryTerms) {
|
|
1217
|
+
// Check content matches
|
|
1218
|
+
if (contentLower.includes(term)) {
|
|
1219
|
+
matchCount++;
|
|
1220
|
+
// Bonus for exact word match (not just substring)
|
|
1221
|
+
const wordBoundaryRegex = new RegExp(`\\b${term}\\b`, 'i');
|
|
1222
|
+
if (wordBoundaryRegex.test(content)) {
|
|
1223
|
+
exactMatchBonus += 0.5;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
// Bonus for filename/path match
|
|
1227
|
+
if (filepathLower.includes(term)) {
|
|
1228
|
+
matchCount += 0.5;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
// Normalize score to 0-1 range
|
|
1232
|
+
const baseScore = matchCount / queryTerms.length;
|
|
1233
|
+
const bonusScore = Math.min(exactMatchBonus / queryTerms.length, 0.5);
|
|
1234
|
+
return Math.min(baseScore + bonusScore, 1);
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Find code chunks semantically similar to a given code snippet or file location.
|
|
1238
|
+
* This is useful for finding duplicate logic, similar implementations, or related code.
|
|
1239
|
+
*/
|
|
1240
|
+
async searchSimilar(options) {
|
|
1241
|
+
const { filepath, startLine, endLine, code, limit = 10, threshold = 0, excludeSelf = true, } = options;
|
|
1242
|
+
// Validate input first - need either code or filepath
|
|
1243
|
+
if (!code && !filepath) {
|
|
1244
|
+
throw new Error('Either code or filepath must be provided');
|
|
1245
|
+
}
|
|
1246
|
+
if (!this.table) {
|
|
1247
|
+
const status = await this.getStatus();
|
|
1248
|
+
if (!status.indexed) {
|
|
1249
|
+
throw new Error('Codebase not indexed. Run index_codebase first.');
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
// Get the source code to find similar chunks for
|
|
1253
|
+
let sourceCode;
|
|
1254
|
+
let sourceId = null;
|
|
1255
|
+
if (code) {
|
|
1256
|
+
sourceCode = code;
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
// Read from file
|
|
1260
|
+
const fullPath = path.join(this.projectPath, filepath);
|
|
1261
|
+
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
|
1262
|
+
const lines = fileContent.split('\n');
|
|
1263
|
+
const start = startLine ? startLine - 1 : 0;
|
|
1264
|
+
const end = endLine ? endLine : lines.length;
|
|
1265
|
+
sourceCode = lines.slice(start, end).join('\n');
|
|
1266
|
+
// Build source ID for exclusion
|
|
1267
|
+
if (startLine && endLine) {
|
|
1268
|
+
sourceId = `${filepath}:${startLine}-${endLine}`;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (!sourceCode.trim()) {
|
|
1272
|
+
throw new Error('Source code is empty');
|
|
1273
|
+
}
|
|
1274
|
+
// Embed the source code
|
|
1275
|
+
const sourceEmbedding = await this.embeddingBackend.embed(sourceCode);
|
|
1276
|
+
// Search for similar chunks - fetch extra to account for filtering
|
|
1277
|
+
const fetchLimit = Math.min((limit + 5) * 2, 100);
|
|
1278
|
+
const results = await this.table.search(sourceEmbedding).limit(fetchLimit).toArray();
|
|
1279
|
+
// LanceDB returns results sorted by distance (ascending)
|
|
1280
|
+
// Convert distance to similarity score (1 - normalized_distance)
|
|
1281
|
+
const maxDistance = results.length > 0 ? Math.max(...results.map((r) => r._distance || 0)) : 1;
|
|
1282
|
+
const scoredResults = [];
|
|
1283
|
+
for (const r of results) {
|
|
1284
|
+
// Skip self if requested
|
|
1285
|
+
if (excludeSelf && sourceId && r.id === sourceId) {
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
// Also skip if content is identical (for code-based search)
|
|
1289
|
+
if (excludeSelf && code && r.content.trim() === code.trim()) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
// Convert distance to similarity (0 = far, 1 = identical)
|
|
1293
|
+
const distance = r._distance || 0;
|
|
1294
|
+
const similarity = maxDistance > 0 ? 1 - distance / maxDistance : 1;
|
|
1295
|
+
// Apply threshold filter
|
|
1296
|
+
if (similarity < threshold) {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
scoredResults.push({
|
|
1300
|
+
id: r.id,
|
|
1301
|
+
filepath: r.filepath,
|
|
1302
|
+
content: r.content,
|
|
1303
|
+
startLine: r.startLine,
|
|
1304
|
+
endLine: r.endLine,
|
|
1305
|
+
language: r.language,
|
|
1306
|
+
similarity,
|
|
1307
|
+
symbolType: r.symbolType,
|
|
1308
|
+
symbolName: r.symbolName,
|
|
1309
|
+
});
|
|
1310
|
+
if (scoredResults.length >= limit) {
|
|
1311
|
+
break;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
return scoredResults;
|
|
1315
|
+
}
|
|
1316
|
+
async clearIndex() {
|
|
1317
|
+
const tableNames = await this.db.tableNames();
|
|
1318
|
+
if (tableNames.includes('code_chunks')) {
|
|
1319
|
+
await this.db.dropTable('code_chunks');
|
|
1320
|
+
}
|
|
1321
|
+
this.table = null;
|
|
1322
|
+
// Clear query embedding cache to prevent stale embeddings
|
|
1323
|
+
this.queryEmbeddingCache.clear();
|
|
1324
|
+
// Clear clustering metadata
|
|
1325
|
+
await this.clearClusteringMetadata();
|
|
1326
|
+
// Clear any incomplete checkpoint
|
|
1327
|
+
await this.clearCheckpoint();
|
|
1328
|
+
}
|
|
1329
|
+
get clusteringMetadataPath() {
|
|
1330
|
+
return path.join(this.indexPath, 'clustering-metadata.json');
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Clear clustering metadata file
|
|
1334
|
+
*/
|
|
1335
|
+
async clearClusteringMetadata() {
|
|
1336
|
+
try {
|
|
1337
|
+
await fs.unlink(this.clusteringMetadataPath);
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
// Ignore errors if file doesn't exist
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Save clustering result to metadata file.
|
|
1345
|
+
* Includes index checksum for cache invalidation.
|
|
1346
|
+
*/
|
|
1347
|
+
async saveClusteringMetadata(result) {
|
|
1348
|
+
await fs.mkdir(this.indexPath, { recursive: true });
|
|
1349
|
+
// Get current index checksum for cache validation
|
|
1350
|
+
const indexMetadata = await this.loadIndexMetadata();
|
|
1351
|
+
const data = {
|
|
1352
|
+
clusterCount: result.clusterCount,
|
|
1353
|
+
clusters: result.clusters,
|
|
1354
|
+
// Convert Map to object for JSON serialization
|
|
1355
|
+
assignments: Object.fromEntries(result.assignments),
|
|
1356
|
+
generatedAt: new Date().toISOString(),
|
|
1357
|
+
// Store index checksum for cache invalidation
|
|
1358
|
+
indexChecksum: indexMetadata?.checksum ?? null,
|
|
1359
|
+
};
|
|
1360
|
+
await fs.writeFile(this.clusteringMetadataPath, JSON.stringify(data, null, 2));
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Load clustering result from metadata file.
|
|
1364
|
+
* Validates that the cached clustering matches the current index checksum.
|
|
1365
|
+
* Returns null if cache is stale or missing.
|
|
1366
|
+
*/
|
|
1367
|
+
async loadClusteringMetadata() {
|
|
1368
|
+
try {
|
|
1369
|
+
const content = await fs.readFile(this.clusteringMetadataPath, 'utf-8');
|
|
1370
|
+
const data = JSON.parse(content);
|
|
1371
|
+
// Validate cache against current index checksum
|
|
1372
|
+
if (data.indexChecksum) {
|
|
1373
|
+
const indexMetadata = await this.loadIndexMetadata();
|
|
1374
|
+
if (indexMetadata?.checksum && indexMetadata.checksum !== data.indexChecksum) {
|
|
1375
|
+
// Cache is stale - index has changed since clustering was generated
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
clusterCount: data.clusterCount,
|
|
1381
|
+
clusters: data.clusters,
|
|
1382
|
+
// Convert object back to Map
|
|
1383
|
+
assignments: new Map(Object.entries(data.assignments).map(([k, v]) => [k, v])),
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Cluster the indexed codebase into semantic concept areas.
|
|
1392
|
+
* Uses k-means clustering on embeddings to discover related code groups.
|
|
1393
|
+
*/
|
|
1394
|
+
async clusterConcepts(options = {}) {
|
|
1395
|
+
if (!this.table) {
|
|
1396
|
+
const status = await this.getStatus();
|
|
1397
|
+
if (!status.indexed) {
|
|
1398
|
+
throw new Error('Codebase not indexed. Run index_codebase first.');
|
|
1399
|
+
}
|
|
1400
|
+
this.table = await this.db.openTable('code_chunks');
|
|
1401
|
+
}
|
|
1402
|
+
// Fetch all chunks with embeddings
|
|
1403
|
+
const rows = await this.table.query().toArray();
|
|
1404
|
+
const chunks = rows.map((row) => ({
|
|
1405
|
+
id: row.id,
|
|
1406
|
+
content: row.content,
|
|
1407
|
+
filepath: row.filepath,
|
|
1408
|
+
embedding: row.vector,
|
|
1409
|
+
symbolName: row.symbolName,
|
|
1410
|
+
symbolType: row.symbolType,
|
|
1411
|
+
}));
|
|
1412
|
+
// Perform clustering
|
|
1413
|
+
const result = kMeansClustering(chunks, options);
|
|
1414
|
+
// Save to metadata file
|
|
1415
|
+
await this.saveClusteringMetadata(result);
|
|
1416
|
+
return result;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* List all discovered concept clusters.
|
|
1420
|
+
* Returns cached clustering result if available, otherwise clusters first.
|
|
1421
|
+
*/
|
|
1422
|
+
async listConcepts(forceRecluster = false) {
|
|
1423
|
+
if (!forceRecluster) {
|
|
1424
|
+
const cached = await this.loadClusteringMetadata();
|
|
1425
|
+
if (cached) {
|
|
1426
|
+
return cached.clusters;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
const result = await this.clusterConcepts();
|
|
1430
|
+
return result.clusters;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Search for code within a specific concept cluster.
|
|
1434
|
+
* Returns chunks that belong to the specified cluster, optionally filtered by query.
|
|
1435
|
+
*/
|
|
1436
|
+
async searchByConcept(conceptId, query, limit = 10) {
|
|
1437
|
+
const clustering = await this.loadClusteringMetadata();
|
|
1438
|
+
if (!clustering) {
|
|
1439
|
+
throw new Error('No clustering data available. Run clusterConcepts first.');
|
|
1440
|
+
}
|
|
1441
|
+
// Get chunk IDs in this cluster
|
|
1442
|
+
const chunkIds = new Set();
|
|
1443
|
+
for (const [chunkId, clusterId] of clustering.assignments) {
|
|
1444
|
+
if (clusterId === conceptId) {
|
|
1445
|
+
chunkIds.add(chunkId);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if (chunkIds.size === 0) {
|
|
1449
|
+
return [];
|
|
1450
|
+
}
|
|
1451
|
+
if (!this.table) {
|
|
1452
|
+
this.table = await this.db.openTable('code_chunks');
|
|
1453
|
+
}
|
|
1454
|
+
// If query provided, use semantic search and filter to cluster
|
|
1455
|
+
if (query) {
|
|
1456
|
+
const queryEmbedding = await this.getQueryEmbedding(query);
|
|
1457
|
+
const results = await this.table
|
|
1458
|
+
.search(queryEmbedding)
|
|
1459
|
+
.limit(limit * 3)
|
|
1460
|
+
.toArray();
|
|
1461
|
+
return results
|
|
1462
|
+
.filter((r) => chunkIds.has(r.id))
|
|
1463
|
+
.slice(0, limit)
|
|
1464
|
+
.map((r) => ({
|
|
1465
|
+
id: r.id,
|
|
1466
|
+
filepath: r.filepath,
|
|
1467
|
+
content: r.content,
|
|
1468
|
+
startLine: r.startLine,
|
|
1469
|
+
endLine: r.endLine,
|
|
1470
|
+
language: r.language,
|
|
1471
|
+
symbolType: r.symbolType,
|
|
1472
|
+
symbolName: r.symbolName,
|
|
1473
|
+
}));
|
|
1474
|
+
}
|
|
1475
|
+
// Without query, return representative chunks from the cluster
|
|
1476
|
+
const cluster = clustering.clusters.find((c) => c.id === conceptId);
|
|
1477
|
+
if (!cluster) {
|
|
1478
|
+
return [];
|
|
1479
|
+
}
|
|
1480
|
+
const results = [];
|
|
1481
|
+
for (const chunkId of cluster.representativeChunks.slice(0, limit)) {
|
|
1482
|
+
// Fetch chunk by ID - LanceDB doesn't have direct ID lookup, so we filter
|
|
1483
|
+
const rows = await this.table
|
|
1484
|
+
.query()
|
|
1485
|
+
.where(`id = '${sanitizeForFilter(chunkId)}'`)
|
|
1486
|
+
.limit(1)
|
|
1487
|
+
.toArray();
|
|
1488
|
+
if (rows.length > 0) {
|
|
1489
|
+
const r = rows[0];
|
|
1490
|
+
results.push({
|
|
1491
|
+
id: r.id,
|
|
1492
|
+
filepath: r.filepath,
|
|
1493
|
+
content: r.content,
|
|
1494
|
+
startLine: r.startLine,
|
|
1495
|
+
endLine: r.endLine,
|
|
1496
|
+
language: r.language,
|
|
1497
|
+
symbolType: r.symbolType,
|
|
1498
|
+
symbolName: r.symbolName,
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
return results;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Generate a comprehensive summary of the codebase including concept areas.
|
|
1506
|
+
*/
|
|
1507
|
+
async summarizeCodebase(clusteringOptions) {
|
|
1508
|
+
const status = await this.getStatus();
|
|
1509
|
+
if (!status.indexed) {
|
|
1510
|
+
throw new Error('Codebase not indexed. Run index_codebase first.');
|
|
1511
|
+
}
|
|
1512
|
+
if (!this.table) {
|
|
1513
|
+
this.table = await this.db.openTable('code_chunks');
|
|
1514
|
+
}
|
|
1515
|
+
// Gather language statistics
|
|
1516
|
+
const rows = await this.table.query().toArray();
|
|
1517
|
+
const languageStats = new Map();
|
|
1518
|
+
for (const row of rows) {
|
|
1519
|
+
const lang = row.language;
|
|
1520
|
+
if (!languageStats.has(lang)) {
|
|
1521
|
+
languageStats.set(lang, { fileCount: new Set(), chunkCount: 0 });
|
|
1522
|
+
}
|
|
1523
|
+
const stats = languageStats.get(lang);
|
|
1524
|
+
stats.fileCount.add(row.filepath);
|
|
1525
|
+
stats.chunkCount++;
|
|
1526
|
+
}
|
|
1527
|
+
const languages = Array.from(languageStats.entries())
|
|
1528
|
+
.map(([language, stats]) => ({
|
|
1529
|
+
language,
|
|
1530
|
+
fileCount: stats.fileCount.size,
|
|
1531
|
+
chunkCount: stats.chunkCount,
|
|
1532
|
+
}))
|
|
1533
|
+
.sort((a, b) => b.chunkCount - a.chunkCount);
|
|
1534
|
+
// Perform clustering
|
|
1535
|
+
const chunks = rows.map((row) => ({
|
|
1536
|
+
id: row.id,
|
|
1537
|
+
content: row.content,
|
|
1538
|
+
filepath: row.filepath,
|
|
1539
|
+
embedding: row.vector,
|
|
1540
|
+
symbolName: row.symbolName,
|
|
1541
|
+
symbolType: row.symbolType,
|
|
1542
|
+
}));
|
|
1543
|
+
const clusteringResult = kMeansClustering(chunks, clusteringOptions);
|
|
1544
|
+
await this.saveClusteringMetadata(clusteringResult);
|
|
1545
|
+
// Calculate clustering quality
|
|
1546
|
+
const silhouetteScore = calculateSilhouetteScore(chunks, clusteringResult.assignments, clusteringResult.clusters);
|
|
1547
|
+
return {
|
|
1548
|
+
totalFiles: status.fileCount,
|
|
1549
|
+
totalChunks: status.chunkCount,
|
|
1550
|
+
languages,
|
|
1551
|
+
concepts: clusteringResult.clusters,
|
|
1552
|
+
clusteringQuality: silhouetteScore,
|
|
1553
|
+
generatedAt: new Date().toISOString(),
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
//# sourceMappingURL=indexer.js.map
|