@dreb/semantic-search 1.18.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.
Files changed (90) hide show
  1. package/.claude-plugin/plugin.json +17 -0
  2. package/.mcp.json +8 -0
  3. package/README.md +97 -0
  4. package/bin/server.js +14 -0
  5. package/dist/chunker.d.ts +21 -0
  6. package/dist/chunker.d.ts.map +1 -0
  7. package/dist/chunker.js +51 -0
  8. package/dist/chunker.js.map +1 -0
  9. package/dist/db.d.ts +89 -0
  10. package/dist/db.d.ts.map +1 -0
  11. package/dist/db.js +406 -0
  12. package/dist/db.js.map +1 -0
  13. package/dist/embedder.d.ts +52 -0
  14. package/dist/embedder.d.ts.map +1 -0
  15. package/dist/embedder.js +158 -0
  16. package/dist/embedder.js.map +1 -0
  17. package/dist/format.d.ts +4 -0
  18. package/dist/format.d.ts.map +1 -0
  19. package/dist/format.js +37 -0
  20. package/dist/format.js.map +1 -0
  21. package/dist/index-manager.d.ts +55 -0
  22. package/dist/index-manager.d.ts.map +1 -0
  23. package/dist/index-manager.js +311 -0
  24. package/dist/index-manager.js.map +1 -0
  25. package/dist/index.d.ts +18 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +21 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/mcp-server.d.ts +25 -0
  30. package/dist/mcp-server.d.ts.map +1 -0
  31. package/dist/mcp-server.js +149 -0
  32. package/dist/mcp-server.js.map +1 -0
  33. package/dist/metrics/bm25.d.ts +10 -0
  34. package/dist/metrics/bm25.d.ts.map +1 -0
  35. package/dist/metrics/bm25.js +32 -0
  36. package/dist/metrics/bm25.js.map +1 -0
  37. package/dist/metrics/git-recency.d.ts +14 -0
  38. package/dist/metrics/git-recency.d.ts.map +1 -0
  39. package/dist/metrics/git-recency.js +123 -0
  40. package/dist/metrics/git-recency.js.map +1 -0
  41. package/dist/metrics/import-graph.d.ts +15 -0
  42. package/dist/metrics/import-graph.d.ts.map +1 -0
  43. package/dist/metrics/import-graph.js +115 -0
  44. package/dist/metrics/import-graph.js.map +1 -0
  45. package/dist/metrics/path-match.d.ts +13 -0
  46. package/dist/metrics/path-match.d.ts.map +1 -0
  47. package/dist/metrics/path-match.js +54 -0
  48. package/dist/metrics/path-match.js.map +1 -0
  49. package/dist/metrics/symbol-match.d.ts +12 -0
  50. package/dist/metrics/symbol-match.d.ts.map +1 -0
  51. package/dist/metrics/symbol-match.js +62 -0
  52. package/dist/metrics/symbol-match.js.map +1 -0
  53. package/dist/metrics/tokenize.d.ts +12 -0
  54. package/dist/metrics/tokenize.d.ts.map +1 -0
  55. package/dist/metrics/tokenize.js +29 -0
  56. package/dist/metrics/tokenize.js.map +1 -0
  57. package/dist/poem.d.ts +38 -0
  58. package/dist/poem.d.ts.map +1 -0
  59. package/dist/poem.js +214 -0
  60. package/dist/poem.js.map +1 -0
  61. package/dist/query-classifier.d.ts +17 -0
  62. package/dist/query-classifier.d.ts.map +1 -0
  63. package/dist/query-classifier.js +54 -0
  64. package/dist/query-classifier.js.map +1 -0
  65. package/dist/scanner.d.ts +30 -0
  66. package/dist/scanner.d.ts.map +1 -0
  67. package/dist/scanner.js +343 -0
  68. package/dist/scanner.js.map +1 -0
  69. package/dist/search.d.ts +63 -0
  70. package/dist/search.d.ts.map +1 -0
  71. package/dist/search.js +400 -0
  72. package/dist/search.js.map +1 -0
  73. package/dist/text-chunker.d.ts +15 -0
  74. package/dist/text-chunker.d.ts.map +1 -0
  75. package/dist/text-chunker.js +580 -0
  76. package/dist/text-chunker.js.map +1 -0
  77. package/dist/tree-sitter-chunker.d.ts +25 -0
  78. package/dist/tree-sitter-chunker.d.ts.map +1 -0
  79. package/dist/tree-sitter-chunker.js +357 -0
  80. package/dist/tree-sitter-chunker.js.map +1 -0
  81. package/dist/types.d.ts +98 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +6 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/vector-store.d.ts +43 -0
  86. package/dist/vector-store.d.ts.map +1 -0
  87. package/dist/vector-store.js +73 -0
  88. package/dist/vector-store.js.map +1 -0
  89. package/package.json +71 -0
  90. package/skills/search/SKILL.md +56 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "semantic-search",
3
+ "description": "Semantic codebase search — natural language queries over code and docs using embeddings, tree-sitter parsing, and POEM multi-signal ranking",
4
+ "version": "1.17.1",
5
+ "author": {
6
+ "name": "Drew Brereton"
7
+ },
8
+ "repository": "https://github.com/aebrer/dreb",
9
+ "license": "MIT",
10
+ "keywords": [
11
+ "semantic-search",
12
+ "codebase",
13
+ "embeddings",
14
+ "mcp",
15
+ "poem"
16
+ ]
17
+ }
package/.mcp.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "semantic-search": {
4
+ "command": "node",
5
+ "args": ["${CLAUDE_PLUGIN_ROOT}/bin/server.js"]
6
+ }
7
+ }
8
+ }
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @dreb/semantic-search
2
+
3
+ Semantic codebase search engine with embedding-based ranking and an MCP server. Extracts and indexes code using tree-sitter for AST-aware chunking and a transformer embedding model ([all-MiniLM-L6-v2](https://huggingface.co/Xenova/all-MiniLM-L6-v2)), then ranks results using 6-signal fusion via POEM.
4
+
5
+ ## Requirements
6
+
7
+ - **Node.js 22+** — uses the built-in `node:sqlite` module
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @dreb/semantic-search
13
+ ```
14
+
15
+ ## Claude Code Plugin
16
+
17
+ The package ships as a Claude Code plugin. Install via a marketplace that includes it as an npm source, or register the MCP server directly:
18
+
19
+ ```bash
20
+ claude mcp add --transport stdio semantic-search -- npx @dreb/semantic-search semantic-search-mcp
21
+ ```
22
+
23
+ For local development:
24
+
25
+ ```bash
26
+ claude --plugin-dir /path/to/packages/semantic-search
27
+ ```
28
+
29
+ ## MCP Server
30
+
31
+ The package exposes a `search` tool over the Model Context Protocol (stdio transport). The tool accepts:
32
+
33
+ | Parameter | Required | Description |
34
+ | ------------ | -------- | ------------------------------------------------ |
35
+ | `query` | yes | Natural language, identifier, or path query |
36
+ | `projectDir` | yes | Absolute path to the project directory to search |
37
+ | `path` | no | Restrict search to files under this path |
38
+ | `limit` | no | Maximum results to return (default: 20) |
39
+ | `rebuild` | no | Force a clean index rebuild (default: false) |
40
+
41
+ Start the server standalone:
42
+
43
+ ```bash
44
+ npx @dreb/semantic-search semantic-search-mcp
45
+ ```
46
+
47
+ ## How Ranking Works
48
+
49
+ Results are ranked by fusing 6 independent signals using **POEM** (Pareto-Optimal Embedded Modeling) weights that vary per query type:
50
+
51
+ | Signal | Description |
52
+ | --------------------- | -------------------------------------------------------------- |
53
+ | **BM25** | Keyword matching via FTS5 full-text search |
54
+ | **Cosine similarity** | Embedding-based semantic similarity using all-MiniLM-L6-v2 |
55
+ | **Path match** | Query terms appearing in the file path |
56
+ | **Symbol match** | Query terms matching function, class, or type names |
57
+ | **Import graph** | Proximity to high-scoring files in the import/dependency graph |
58
+ | **Git recency** | Recently modified files ranked higher |
59
+
60
+ Queries are automatically classified as _identifier_, _natural language_, or _path_ queries, and each type applies different POEM column weights. POEM constructs a Pareto front over all signal dimensions and assigns ranks based on dominance depth — no manual weight tuning required. See [Pareto-Optimal Embedded Modeling](https://iopscience.iop.org/article/10.1088/2632-2153/ab891b) for the theoretical foundation.
61
+
62
+ ## Library API
63
+
64
+ ```typescript
65
+ import { SearchEngine } from "@dreb/semantic-search";
66
+
67
+ const engine = new SearchEngine("/path/to/project", {
68
+ indexDir: "/custom/index/path", // default: <projectRoot>/.search-index
69
+ globalMemoryDir: "~/.dreb/memory", // additional directory to index
70
+ modelCacheDir: "~/.cache/models", // default: ~/.cache/semantic-search/models
71
+ visibleDirs: (root) => [`${root}/.special`], // extra dirs (bypasses .gitignore)
72
+ });
73
+
74
+ // First call builds the index (10-60s); subsequent calls are fast
75
+ const results = await engine.search("where is auth handled", {
76
+ limit: 20,
77
+ pathFilter: "src/",
78
+ onProgress: (phase, current, total) => console.log(`${phase}: ${current}/${total}`),
79
+ });
80
+
81
+ const stats = engine.getStats(); // { files, chunks } | null
82
+ await engine.resetIndex(); // delete index, next search rebuilds
83
+ await engine.close(); // dispose resources
84
+ SearchEngine.isAvailable(); // check for node:sqlite
85
+ ```
86
+
87
+ ## What Gets Indexed
88
+
89
+ - **Code** — tree-sitter AST chunks (functions, classes, methods, interfaces, etc.). TypeScript, JavaScript, Python, Go, Rust, Java, C, C++.
90
+ - **Text** — Markdown (by heading), YAML/TOML (by key), JSON, plaintext (by paragraph).
91
+ - **Extra directories** — via `globalMemoryDir` or `visibleDirs`, scanned even if gitignored.
92
+
93
+ The index is stored in `.search-index/search.db` at the project root (add `.search-index/` to `.gitignore`).
94
+
95
+ ## License
96
+
97
+ MIT
package/bin/server.js ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from "node:path";
4
+ import { startServer } from "../dist/mcp-server.js";
5
+
6
+ // Project directory: CLI arg if provided, otherwise CWD.
7
+ // Claude Code launches MCP servers with CWD set to the project root,
8
+ // so no argument is needed for typical usage.
9
+ const projectDir = resolve(process.argv[2] || ".");
10
+
11
+ startServer(projectDir).catch((err) => {
12
+ console.error("Failed to start MCP server:", err);
13
+ process.exit(1);
14
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Chunking coordinator for the semantic search subsystem.
3
+ *
4
+ * Dispatches to the tree-sitter AST chunker for code files and the
5
+ * text chunker for non-code files (markdown, YAML, JSON, etc.).
6
+ */
7
+ import type { Chunk, FileType } from "./types.js";
8
+ /**
9
+ * Chunk a file's content into semantically meaningful pieces.
10
+ *
11
+ * For code files, uses tree-sitter to parse the AST and extract functions,
12
+ * classes, methods, etc. For text files, uses format-specific splitting rules.
13
+ *
14
+ * If tree-sitter parsing fails for a code file, falls back to plaintext chunking.
15
+ *
16
+ * @param content - Raw file content
17
+ * @param filePath - Relative file path (stored in chunk metadata)
18
+ * @param fileType - Detected file type
19
+ */
20
+ export declare function chunkFile(content: string, filePath: string, fileType: FileType): Promise<Chunk[]>;
21
+ //# sourceMappingURL=chunker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../src/chunker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAoC,MAAM,YAAY,CAAC;AAsBpF;;;;;;;;;;;GAWG;AACH,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC,CAYvG","sourcesContent":["/**\n * Chunking coordinator for the semantic search subsystem.\n *\n * Dispatches to the tree-sitter AST chunker for code files and the\n * text chunker for non-code files (markdown, YAML, JSON, etc.).\n */\n\nimport { chunkTextFile } from \"./text-chunker.js\";\nimport { chunkWithTreeSitter, initTreeSitter } from \"./tree-sitter-chunker.js\";\nimport type { Chunk, FileType, TextFileType, TreeSitterLanguage } from \"./types.js\";\n\n// ============================================================================\n// Language Sets\n// ============================================================================\n\nconst TREE_SITTER_LANGUAGES: Set<string> = new Set([\n\t\"typescript\",\n\t\"tsx\",\n\t\"javascript\",\n\t\"python\",\n\t\"go\",\n\t\"rust\",\n\t\"java\",\n\t\"c\",\n\t\"cpp\",\n]);\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Chunk a file's content into semantically meaningful pieces.\n *\n * For code files, uses tree-sitter to parse the AST and extract functions,\n * classes, methods, etc. For text files, uses format-specific splitting rules.\n *\n * If tree-sitter parsing fails for a code file, falls back to plaintext chunking.\n *\n * @param content - Raw file content\n * @param filePath - Relative file path (stored in chunk metadata)\n * @param fileType - Detected file type\n */\nexport async function chunkFile(content: string, filePath: string, fileType: FileType): Promise<Chunk[]> {\n\tif (TREE_SITTER_LANGUAGES.has(fileType)) {\n\t\ttry {\n\t\t\tawait initTreeSitter();\n\t\t\treturn await chunkWithTreeSitter(content, filePath, fileType as TreeSitterLanguage);\n\t\t} catch {\n\t\t\t// Tree-sitter failed — fall back to plaintext chunking\n\t\t\treturn chunkTextFile(content, filePath, \"plaintext\");\n\t\t}\n\t}\n\n\treturn chunkTextFile(content, filePath, fileType as TextFileType);\n}\n"]}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Chunking coordinator for the semantic search subsystem.
3
+ *
4
+ * Dispatches to the tree-sitter AST chunker for code files and the
5
+ * text chunker for non-code files (markdown, YAML, JSON, etc.).
6
+ */
7
+ import { chunkTextFile } from "./text-chunker.js";
8
+ import { chunkWithTreeSitter, initTreeSitter } from "./tree-sitter-chunker.js";
9
+ // ============================================================================
10
+ // Language Sets
11
+ // ============================================================================
12
+ const TREE_SITTER_LANGUAGES = new Set([
13
+ "typescript",
14
+ "tsx",
15
+ "javascript",
16
+ "python",
17
+ "go",
18
+ "rust",
19
+ "java",
20
+ "c",
21
+ "cpp",
22
+ ]);
23
+ // ============================================================================
24
+ // Public API
25
+ // ============================================================================
26
+ /**
27
+ * Chunk a file's content into semantically meaningful pieces.
28
+ *
29
+ * For code files, uses tree-sitter to parse the AST and extract functions,
30
+ * classes, methods, etc. For text files, uses format-specific splitting rules.
31
+ *
32
+ * If tree-sitter parsing fails for a code file, falls back to plaintext chunking.
33
+ *
34
+ * @param content - Raw file content
35
+ * @param filePath - Relative file path (stored in chunk metadata)
36
+ * @param fileType - Detected file type
37
+ */
38
+ export async function chunkFile(content, filePath, fileType) {
39
+ if (TREE_SITTER_LANGUAGES.has(fileType)) {
40
+ try {
41
+ await initTreeSitter();
42
+ return await chunkWithTreeSitter(content, filePath, fileType);
43
+ }
44
+ catch {
45
+ // Tree-sitter failed — fall back to plaintext chunking
46
+ return chunkTextFile(content, filePath, "plaintext");
47
+ }
48
+ }
49
+ return chunkTextFile(content, filePath, fileType);
50
+ }
51
+ //# sourceMappingURL=chunker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chunker.js","sourceRoot":"","sources":["../src/chunker.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAG/E,+EAA+E;AAC/E,gBAAgB;AAChB,+EAA+E;AAE/E,MAAM,qBAAqB,GAAgB,IAAI,GAAG,CAAC;IAClD,YAAY;IACZ,KAAK;IACL,YAAY;IACZ,QAAQ;IACR,IAAI;IACJ,MAAM;IACN,MAAM;IACN,GAAG;IACH,KAAK;CACL,CAAC,CAAC;AAEH,+EAA+E;AAC/E,aAAa;AACb,+EAA+E;AAE/E;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,OAAe,EAAE,QAAgB,EAAE,QAAkB,EAAoB;IACxG,IAAI,qBAAqB,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;QACzC,IAAI,CAAC;YACJ,MAAM,cAAc,EAAE,CAAC;YACvB,OAAO,MAAM,mBAAmB,CAAC,OAAO,EAAE,QAAQ,EAAE,QAA8B,CAAC,CAAC;QACrF,CAAC;QAAC,MAAM,CAAC;YACR,yDAAuD;YACvD,OAAO,aAAa,CAAC,OAAO,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC;QACtD,CAAC;IACF,CAAC;IAED,OAAO,aAAa,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAwB,CAAC,CAAC;AAAA,CAClE","sourcesContent":["/**\n * Chunking coordinator for the semantic search subsystem.\n *\n * Dispatches to the tree-sitter AST chunker for code files and the\n * text chunker for non-code files (markdown, YAML, JSON, etc.).\n */\n\nimport { chunkTextFile } from \"./text-chunker.js\";\nimport { chunkWithTreeSitter, initTreeSitter } from \"./tree-sitter-chunker.js\";\nimport type { Chunk, FileType, TextFileType, TreeSitterLanguage } from \"./types.js\";\n\n// ============================================================================\n// Language Sets\n// ============================================================================\n\nconst TREE_SITTER_LANGUAGES: Set<string> = new Set([\n\t\"typescript\",\n\t\"tsx\",\n\t\"javascript\",\n\t\"python\",\n\t\"go\",\n\t\"rust\",\n\t\"java\",\n\t\"c\",\n\t\"cpp\",\n]);\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Chunk a file's content into semantically meaningful pieces.\n *\n * For code files, uses tree-sitter to parse the AST and extract functions,\n * classes, methods, etc. For text files, uses format-specific splitting rules.\n *\n * If tree-sitter parsing fails for a code file, falls back to plaintext chunking.\n *\n * @param content - Raw file content\n * @param filePath - Relative file path (stored in chunk metadata)\n * @param fileType - Detected file type\n */\nexport async function chunkFile(content: string, filePath: string, fileType: FileType): Promise<Chunk[]> {\n\tif (TREE_SITTER_LANGUAGES.has(fileType)) {\n\t\ttry {\n\t\t\tawait initTreeSitter();\n\t\t\treturn await chunkWithTreeSitter(content, filePath, fileType as TreeSitterLanguage);\n\t\t} catch {\n\t\t\t// Tree-sitter failed — fall back to plaintext chunking\n\t\t\treturn chunkTextFile(content, filePath, \"plaintext\");\n\t\t}\n\t}\n\n\treturn chunkTextFile(content, filePath, fileType as TextFileType);\n}\n"]}
package/dist/db.d.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * SQLite database abstraction for the search index.
3
+ *
4
+ * Uses `node:sqlite` (built-in Node 22+). Feature-gated — callers must check
5
+ * availability via `isSqliteAvailable()` before constructing a SearchDatabase.
6
+ */
7
+ import type { ChunkKind, FileType, IndexedFile, StoredChunk } from "./types.js";
8
+ /** Check whether `node:sqlite` is available in this Node.js runtime. */
9
+ export declare function isSqliteAvailable(): boolean;
10
+ /** Wrapper around `node:sqlite` DatabaseSync for the search index. */
11
+ export declare class SearchDatabase {
12
+ private db;
13
+ constructor(dbPath: string);
14
+ /** Create or migrate the database schema. */
15
+ private initSchema;
16
+ /** Insert or update a file record. Returns the file ID. */
17
+ upsertFile(filePath: string, mtime: number, fileType: FileType): number;
18
+ /** Get a file by path. */
19
+ getFile(filePath: string): IndexedFile | null;
20
+ /** Get all indexed files. */
21
+ getAllFiles(): IndexedFile[];
22
+ /** Delete a file and all its chunks/embeddings/symbols (cascading). */
23
+ deleteFile(fileId: number): void;
24
+ /** Insert a chunk. Returns the chunk ID. */
25
+ insertChunk(fileId: number, filePath: string, startLine: number, endLine: number, kind: ChunkKind, name: string | null, content: string, fileType: FileType): number;
26
+ /** Delete all chunks for a file. */
27
+ deleteChunksForFile(fileId: number): void;
28
+ /** Get all chunks. */
29
+ getAllChunks(): StoredChunk[];
30
+ /** Get chunks by file ID. */
31
+ getChunksByFileId(fileId: number): StoredChunk[];
32
+ /** Get a chunk by ID. */
33
+ getChunk(chunkId: number): StoredChunk | null;
34
+ /** Get multiple chunks by IDs. Batches queries to avoid exceeding SQLite's bind variable limit. */
35
+ getChunksById(chunkIds: number[]): StoredChunk[];
36
+ private rowToChunk;
37
+ /** Store an embedding vector for a chunk. */
38
+ upsertEmbedding(chunkId: number, modelName: string, vector: Float32Array): void;
39
+ /** Batch insert embeddings. Uses a transaction for performance. */
40
+ batchUpsertEmbeddings(items: Array<{
41
+ chunkId: number;
42
+ modelName: string;
43
+ vector: Float32Array;
44
+ }>): void;
45
+ /** Get the embedding for a chunk. */
46
+ getEmbedding(chunkId: number, modelName: string): Float32Array | null;
47
+ /** Get all embeddings for a model. Returns map of chunkId → vector. */
48
+ getAllEmbeddings(modelName: string): Map<number, Float32Array>;
49
+ /** Get chunk IDs that have no embedding for a given model. */
50
+ getChunkIdsWithoutEmbedding(modelName: string): number[];
51
+ /** Rebuild the FTS5 index (use after bulk operations). */
52
+ rebuildFts(): void;
53
+ /**
54
+ * Search via FTS5 with BM25 ranking.
55
+ * Returns chunk IDs with their BM25 scores (negated so higher = better).
56
+ */
57
+ ftsSearch(query: string, limit: number): Array<{
58
+ chunkId: number;
59
+ score: number;
60
+ }>;
61
+ /** Record an import edge. */
62
+ insertImport(sourceFileId: number, targetFilePath: string): void;
63
+ /** Delete all imports for a source file. */
64
+ deleteImportsForFile(sourceFileId: number): void;
65
+ /** Get files imported by a given source file. */
66
+ getImportsFrom(sourceFileId: number): string[];
67
+ /** Get file IDs that import a given target path. */
68
+ getImportersOf(targetFilePath: string): number[];
69
+ /** Get all import edges. */
70
+ getAllImports(): Array<{
71
+ sourceFileId: number;
72
+ targetFilePath: string;
73
+ }>;
74
+ /** Insert a symbol. */
75
+ insertSymbol(chunkId: number, name: string, kind: string): void;
76
+ /** Delete symbols for a chunk. */
77
+ deleteSymbolsForChunk(chunkId: number): void;
78
+ /** Get all symbols. Returns map of chunkId → symbol names. */
79
+ getAllSymbols(): Map<number, string[]>;
80
+ /** Run a function inside a transaction. */
81
+ transaction<T>(fn: () => T): T;
82
+ /** Get the total number of chunks. */
83
+ getChunkCount(): number;
84
+ /** Get the total number of files. */
85
+ getFileCount(): number;
86
+ /** Close the database connection. */
87
+ close(): void;
88
+ }
89
+ //# sourceMappingURL=db.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SQLite database abstraction for the search index.\n *\n * Uses `node:sqlite` (built-in Node 22+). Feature-gated — callers must check\n * availability via `isSqliteAvailable()` before constructing a SearchDatabase.\n */\n\nimport { createRequire } from \"node:module\";\nimport type { ChunkKind, FileType, IndexedFile, StoredChunk } from \"./types.js\";\nimport { unpackVector } from \"./vector-store.js\";\n\n// ============================================================================\n// Availability Check\n// ============================================================================\n\nconst require = createRequire(import.meta.url);\nlet _sqliteAvailable: boolean | null = null;\n\n/** Check whether `node:sqlite` is available in this Node.js runtime. */\nexport function isSqliteAvailable(): boolean {\n\tif (_sqliteAvailable !== null) return _sqliteAvailable;\n\ttry {\n\t\trequire(\"node:sqlite\");\n\t\t_sqliteAvailable = true;\n\t} catch {\n\t\t_sqliteAvailable = false;\n\t}\n\treturn _sqliteAvailable;\n}\n\n// ============================================================================\n// Schema Version\n// ============================================================================\n\nconst SCHEMA_VERSION = 1;\n\n// ============================================================================\n// Database\n// ============================================================================\n\n/** Wrapper around `node:sqlite` DatabaseSync for the search index. */\nexport class SearchDatabase {\n\tprivate db: any; // DatabaseSync from node:sqlite\n\n\tconstructor(dbPath: string) {\n\t\t// Import synchronously — caller must have verified availability\n\t\tconst { DatabaseSync } = require(\"node:sqlite\");\n\t\tthis.db = new DatabaseSync(dbPath);\n\t\tthis.db.exec(\"PRAGMA journal_mode=WAL\");\n\t\tthis.db.exec(\"PRAGMA synchronous=NORMAL\");\n\t\tthis.db.exec(\"PRAGMA foreign_keys=ON\");\n\t\tthis.initSchema();\n\t}\n\n\t/** Create or migrate the database schema. */\n\tprivate initSchema(): void {\n\t\t// Check schema version\n\t\tthis.db.exec(\"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)\");\n\t\tconst versionRow = this.db.prepare(\"SELECT value FROM meta WHERE key = 'schema_version'\").get();\n\t\tconst currentVersion = versionRow ? Number.parseInt(versionRow.value, 10) : 0;\n\n\t\tif (currentVersion >= SCHEMA_VERSION) return;\n\n\t\t// Files table\n\t\tthis.db.exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS files (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tfile_path TEXT NOT NULL UNIQUE,\n\t\t\t\tmtime REAL NOT NULL,\n\t\t\t\tfile_type TEXT NOT NULL\n\t\t\t)\n\t\t`);\n\n\t\t// Chunks table\n\t\tthis.db.exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS chunks (\n\t\t\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\t\t\tfile_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,\n\t\t\t\tfile_path TEXT NOT NULL,\n\t\t\t\tstart_line INTEGER NOT NULL,\n\t\t\t\tend_line INTEGER NOT NULL,\n\t\t\t\tkind TEXT NOT NULL,\n\t\t\t\tname TEXT,\n\t\t\t\tcontent TEXT NOT NULL,\n\t\t\t\tfile_type TEXT NOT NULL\n\t\t\t)\n\t\t`);\n\t\tthis.db.exec(\"CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id)\");\n\t\tthis.db.exec(\"CREATE INDEX IF NOT EXISTS idx_chunks_file_path ON chunks(file_path)\");\n\n\t\t// FTS5 virtual table (content-synced with chunks)\n\t\tthis.db.exec(`\n\t\t\tCREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(\n\t\t\t\tcontent,\n\t\t\t\tname,\n\t\t\t\tfile_path,\n\t\t\t\tcontent='chunks',\n\t\t\t\tcontent_rowid='id',\n\t\t\t\ttokenize='porter unicode61'\n\t\t\t)\n\t\t`);\n\n\t\t// FTS triggers for incremental updates\n\t\tthis.db.exec(`\n\t\t\tCREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN\n\t\t\t\tINSERT INTO chunks_fts(rowid, content, name, file_path)\n\t\t\t\tVALUES (new.id, new.content, new.name, new.file_path);\n\t\t\tEND\n\t\t`);\n\t\tthis.db.exec(`\n\t\t\tCREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN\n\t\t\t\tINSERT INTO chunks_fts(chunks_fts, rowid, content, name, file_path)\n\t\t\t\tVALUES ('delete', old.id, old.content, old.name, old.file_path);\n\t\t\tEND\n\t\t`);\n\t\tthis.db.exec(`\n\t\t\tCREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN\n\t\t\t\tINSERT INTO chunks_fts(chunks_fts, rowid, content, name, file_path)\n\t\t\t\tVALUES ('delete', old.id, old.content, old.name, old.file_path);\n\t\t\t\tINSERT INTO chunks_fts(rowid, content, name, file_path)\n\t\t\t\tVALUES (new.id, new.content, new.name, new.file_path);\n\t\t\tEND\n\t\t`);\n\n\t\t// Embeddings table (keyed by model name for multi-model support)\n\t\tthis.db.exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS embeddings (\n\t\t\t\tchunk_id INTEGER NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,\n\t\t\t\tmodel_name TEXT NOT NULL,\n\t\t\t\tvector BLOB NOT NULL,\n\t\t\t\tPRIMARY KEY (chunk_id, model_name)\n\t\t\t)\n\t\t`);\n\n\t\t// Imports table (import graph edges between files)\n\t\tthis.db.exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS imports (\n\t\t\t\tsource_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,\n\t\t\t\ttarget_file_path TEXT NOT NULL,\n\t\t\t\tPRIMARY KEY (source_file_id, target_file_path)\n\t\t\t)\n\t\t`);\n\t\tthis.db.exec(\"CREATE INDEX IF NOT EXISTS idx_imports_target ON imports(target_file_path)\");\n\n\t\t// Symbols table (symbol names extracted from chunks)\n\t\tthis.db.exec(`\n\t\t\tCREATE TABLE IF NOT EXISTS symbols (\n\t\t\t\tchunk_id INTEGER NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,\n\t\t\t\tname TEXT NOT NULL,\n\t\t\t\tkind TEXT NOT NULL\n\t\t\t)\n\t\t`);\n\t\tthis.db.exec(\"CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)\");\n\n\t\t// Set schema version\n\t\tthis.db\n\t\t\t.prepare(\"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)\")\n\t\t\t.run(String(SCHEMA_VERSION));\n\t}\n\n\t// ========================================================================\n\t// Files\n\t// ========================================================================\n\n\t/** Insert or update a file record. Returns the file ID. */\n\tupsertFile(filePath: string, mtime: number, fileType: FileType): number {\n\t\tconst existing = this.db.prepare(\"SELECT id FROM files WHERE file_path = ?\").get(filePath);\n\t\tif (existing) {\n\t\t\tthis.db.prepare(\"UPDATE files SET mtime = ?, file_type = ? WHERE id = ?\").run(mtime, fileType, existing.id);\n\t\t\treturn existing.id;\n\t\t}\n\t\tconst result = this.db\n\t\t\t.prepare(\"INSERT INTO files (file_path, mtime, file_type) VALUES (?, ?, ?)\")\n\t\t\t.run(filePath, mtime, fileType);\n\t\treturn Number(result.lastInsertRowid);\n\t}\n\n\t/** Get a file by path. */\n\tgetFile(filePath: string): IndexedFile | null {\n\t\tconst row = this.db\n\t\t\t.prepare(\"SELECT id, file_path, mtime, file_type FROM files WHERE file_path = ?\")\n\t\t\t.get(filePath);\n\t\tif (!row) return null;\n\t\treturn { id: row.id, filePath: row.file_path, mtime: row.mtime, fileType: row.file_type as FileType };\n\t}\n\n\t/** Get all indexed files. */\n\tgetAllFiles(): IndexedFile[] {\n\t\tconst rows = this.db.prepare(\"SELECT id, file_path, mtime, file_type FROM files\").all();\n\t\treturn rows.map((r: any) => ({\n\t\t\tid: r.id,\n\t\t\tfilePath: r.file_path,\n\t\t\tmtime: r.mtime,\n\t\t\tfileType: r.file_type as FileType,\n\t\t}));\n\t}\n\n\t/** Delete a file and all its chunks/embeddings/symbols (cascading). */\n\tdeleteFile(fileId: number): void {\n\t\tthis.db.prepare(\"DELETE FROM files WHERE id = ?\").run(fileId);\n\t}\n\n\t// ========================================================================\n\t// Chunks\n\t// ========================================================================\n\n\t/** Insert a chunk. Returns the chunk ID. */\n\tinsertChunk(\n\t\tfileId: number,\n\t\tfilePath: string,\n\t\tstartLine: number,\n\t\tendLine: number,\n\t\tkind: ChunkKind,\n\t\tname: string | null,\n\t\tcontent: string,\n\t\tfileType: FileType,\n\t): number {\n\t\tconst result = this.db\n\t\t\t.prepare(\n\t\t\t\t\"INSERT INTO chunks (file_id, file_path, start_line, end_line, kind, name, content, file_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\",\n\t\t\t)\n\t\t\t.run(fileId, filePath, startLine, endLine, kind, name, content, fileType);\n\t\treturn Number(result.lastInsertRowid);\n\t}\n\n\t/** Delete all chunks for a file. */\n\tdeleteChunksForFile(fileId: number): void {\n\t\tthis.db.prepare(\"DELETE FROM chunks WHERE file_id = ?\").run(fileId);\n\t}\n\n\t/** Get all chunks. */\n\tgetAllChunks(): StoredChunk[] {\n\t\tconst rows = this.db\n\t\t\t.prepare(\"SELECT id, file_id, file_path, start_line, end_line, kind, name, content, file_type FROM chunks\")\n\t\t\t.all();\n\t\treturn rows.map((r: any) => this.rowToChunk(r));\n\t}\n\n\t/** Get chunks by file ID. */\n\tgetChunksByFileId(fileId: number): StoredChunk[] {\n\t\tconst rows = this.db\n\t\t\t.prepare(\n\t\t\t\t\"SELECT id, file_id, file_path, start_line, end_line, kind, name, content, file_type FROM chunks WHERE file_id = ?\",\n\t\t\t)\n\t\t\t.all(fileId);\n\t\treturn rows.map((r: any) => this.rowToChunk(r));\n\t}\n\n\t/** Get a chunk by ID. */\n\tgetChunk(chunkId: number): StoredChunk | null {\n\t\tconst row = this.db\n\t\t\t.prepare(\n\t\t\t\t\"SELECT id, file_id, file_path, start_line, end_line, kind, name, content, file_type FROM chunks WHERE id = ?\",\n\t\t\t)\n\t\t\t.get(chunkId);\n\t\tif (!row) return null;\n\t\treturn this.rowToChunk(row);\n\t}\n\n\t/** Get multiple chunks by IDs. Batches queries to avoid exceeding SQLite's bind variable limit. */\n\tgetChunksById(chunkIds: number[]): StoredChunk[] {\n\t\tif (chunkIds.length === 0) return [];\n\n\t\tconst BATCH_SIZE = 500;\n\t\tconst results: StoredChunk[] = [];\n\n\t\tfor (let i = 0; i < chunkIds.length; i += BATCH_SIZE) {\n\t\t\tconst batch = chunkIds.slice(i, i + BATCH_SIZE);\n\t\t\tconst placeholders = batch.map(() => \"?\").join(\",\");\n\t\t\tconst rows = this.db\n\t\t\t\t.prepare(\n\t\t\t\t\t`SELECT id, file_id, file_path, start_line, end_line, kind, name, content, file_type FROM chunks WHERE id IN (${placeholders})`,\n\t\t\t\t)\n\t\t\t\t.all(...batch);\n\t\t\tfor (const r of rows) {\n\t\t\t\tresults.push(this.rowToChunk(r));\n\t\t\t}\n\t\t}\n\n\t\treturn results;\n\t}\n\n\tprivate rowToChunk(r: any): StoredChunk {\n\t\treturn {\n\t\t\tid: r.id,\n\t\t\tfileId: r.file_id,\n\t\t\tfilePath: r.file_path,\n\t\t\tstartLine: r.start_line,\n\t\t\tendLine: r.end_line,\n\t\t\tkind: r.kind as ChunkKind,\n\t\t\tname: r.name,\n\t\t\tcontent: r.content,\n\t\t\tfileType: r.file_type as FileType,\n\t\t};\n\t}\n\n\t// ========================================================================\n\t// Embeddings\n\t// ========================================================================\n\n\t/** Store an embedding vector for a chunk. */\n\tupsertEmbedding(chunkId: number, modelName: string, vector: Float32Array): void {\n\t\tconst blob = Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);\n\t\tthis.db\n\t\t\t.prepare(\"INSERT OR REPLACE INTO embeddings (chunk_id, model_name, vector) VALUES (?, ?, ?)\")\n\t\t\t.run(chunkId, modelName, blob);\n\t}\n\n\t/** Batch insert embeddings. Uses a transaction for performance. */\n\tbatchUpsertEmbeddings(items: Array<{ chunkId: number; modelName: string; vector: Float32Array }>): void {\n\t\tconst stmt = this.db.prepare(\"INSERT OR REPLACE INTO embeddings (chunk_id, model_name, vector) VALUES (?, ?, ?)\");\n\t\tthis.transaction(() => {\n\t\t\tfor (const item of items) {\n\t\t\t\tconst blob = Buffer.from(item.vector.buffer, item.vector.byteOffset, item.vector.byteLength);\n\t\t\t\tstmt.run(item.chunkId, item.modelName, blob);\n\t\t\t}\n\t\t});\n\t}\n\n\t/** Get the embedding for a chunk. */\n\tgetEmbedding(chunkId: number, modelName: string): Float32Array | null {\n\t\tconst row = this.db\n\t\t\t.prepare(\"SELECT vector FROM embeddings WHERE chunk_id = ? AND model_name = ?\")\n\t\t\t.get(chunkId, modelName);\n\t\tif (!row) return null;\n\t\treturn unpackVector(row.vector);\n\t}\n\n\t/** Get all embeddings for a model. Returns map of chunkId → vector. */\n\tgetAllEmbeddings(modelName: string): Map<number, Float32Array> {\n\t\tconst rows = this.db.prepare(\"SELECT chunk_id, vector FROM embeddings WHERE model_name = ?\").all(modelName);\n\t\tconst map = new Map<number, Float32Array>();\n\t\tfor (const row of rows) {\n\t\t\tmap.set(row.chunk_id, unpackVector(row.vector));\n\t\t}\n\t\treturn map;\n\t}\n\n\t/** Get chunk IDs that have no embedding for a given model. */\n\tgetChunkIdsWithoutEmbedding(modelName: string): number[] {\n\t\tconst rows = this.db\n\t\t\t.prepare(\n\t\t\t\t`SELECT c.id FROM chunks c\n\t\t\t\t LEFT JOIN embeddings e ON c.id = e.chunk_id AND e.model_name = ?\n\t\t\t\t WHERE e.chunk_id IS NULL`,\n\t\t\t)\n\t\t\t.all(modelName);\n\t\treturn rows.map((r: any) => r.id);\n\t}\n\n\t// ========================================================================\n\t// FTS5\n\t// ========================================================================\n\n\t/** Rebuild the FTS5 index (use after bulk operations). */\n\trebuildFts(): void {\n\t\tthis.db.exec(\"INSERT INTO chunks_fts(chunks_fts) VALUES ('rebuild')\");\n\t}\n\n\t/**\n\t * Search via FTS5 with BM25 ranking.\n\t * Returns chunk IDs with their BM25 scores (negated so higher = better).\n\t */\n\tftsSearch(query: string, limit: number): Array<{ chunkId: number; score: number }> {\n\t\ttry {\n\t\t\tconst rows = this.db\n\t\t\t\t.prepare(\n\t\t\t\t\t`SELECT chunks_fts.rowid as chunk_id, -bm25(chunks_fts, 1.0, 10.0, 5.0) as score\n\t\t\t\t\t FROM chunks_fts\n\t\t\t\t\t WHERE chunks_fts MATCH ?\n\t\t\t\t\t ORDER BY score DESC\n\t\t\t\t\t LIMIT ?`,\n\t\t\t\t)\n\t\t\t\t.all(query, limit);\n\t\t\treturn rows.map((r: any) => ({ chunkId: r.chunk_id, score: r.score }));\n\t\t} catch {\n\t\t\t// FTS5 MATCH can fail on malformed queries\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// ========================================================================\n\t// Imports\n\t// ========================================================================\n\n\t/** Record an import edge. */\n\tinsertImport(sourceFileId: number, targetFilePath: string): void {\n\t\tthis.db\n\t\t\t.prepare(\"INSERT OR IGNORE INTO imports (source_file_id, target_file_path) VALUES (?, ?)\")\n\t\t\t.run(sourceFileId, targetFilePath);\n\t}\n\n\t/** Delete all imports for a source file. */\n\tdeleteImportsForFile(sourceFileId: number): void {\n\t\tthis.db.prepare(\"DELETE FROM imports WHERE source_file_id = ?\").run(sourceFileId);\n\t}\n\n\t/** Get files imported by a given source file. */\n\tgetImportsFrom(sourceFileId: number): string[] {\n\t\tconst rows = this.db.prepare(\"SELECT target_file_path FROM imports WHERE source_file_id = ?\").all(sourceFileId);\n\t\treturn rows.map((r: any) => r.target_file_path);\n\t}\n\n\t/** Get file IDs that import a given target path. */\n\tgetImportersOf(targetFilePath: string): number[] {\n\t\tconst rows = this.db.prepare(\"SELECT source_file_id FROM imports WHERE target_file_path = ?\").all(targetFilePath);\n\t\treturn rows.map((r: any) => r.source_file_id);\n\t}\n\n\t/** Get all import edges. */\n\tgetAllImports(): Array<{ sourceFileId: number; targetFilePath: string }> {\n\t\tconst rows = this.db.prepare(\"SELECT source_file_id, target_file_path FROM imports\").all();\n\t\treturn rows.map((r: any) => ({ sourceFileId: r.source_file_id, targetFilePath: r.target_file_path }));\n\t}\n\n\t// ========================================================================\n\t// Symbols\n\t// ========================================================================\n\n\t/** Insert a symbol. */\n\tinsertSymbol(chunkId: number, name: string, kind: string): void {\n\t\tthis.db.prepare(\"INSERT INTO symbols (chunk_id, name, kind) VALUES (?, ?, ?)\").run(chunkId, name, kind);\n\t}\n\n\t/** Delete symbols for a chunk. */\n\tdeleteSymbolsForChunk(chunkId: number): void {\n\t\tthis.db.prepare(\"DELETE FROM symbols WHERE chunk_id = ?\").run(chunkId);\n\t}\n\n\t/** Get all symbols. Returns map of chunkId → symbol names. */\n\tgetAllSymbols(): Map<number, string[]> {\n\t\tconst rows = this.db.prepare(\"SELECT chunk_id, name FROM symbols\").all();\n\t\tconst map = new Map<number, string[]>();\n\t\tfor (const row of rows) {\n\t\t\tconst existing = map.get(row.chunk_id);\n\t\t\tif (existing) existing.push(row.name);\n\t\t\telse map.set(row.chunk_id, [row.name]);\n\t\t}\n\t\treturn map;\n\t}\n\n\t// ========================================================================\n\t// Transaction Helpers\n\t// ========================================================================\n\n\t/** Run a function inside a transaction. */\n\ttransaction<T>(fn: () => T): T {\n\t\tthis.db.exec(\"BEGIN\");\n\t\ttry {\n\t\t\tconst result = fn();\n\t\t\tthis.db.exec(\"COMMIT\");\n\t\t\treturn result;\n\t\t} catch (err) {\n\t\t\tthis.db.exec(\"ROLLBACK\");\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\t/** Get the total number of chunks. */\n\tgetChunkCount(): number {\n\t\tconst row = this.db.prepare(\"SELECT COUNT(*) as count FROM chunks\").get();\n\t\treturn row.count;\n\t}\n\n\t/** Get the total number of files. */\n\tgetFileCount(): number {\n\t\tconst row = this.db.prepare(\"SELECT COUNT(*) as count FROM files\").get();\n\t\treturn row.count;\n\t}\n\n\t/** Close the database connection. */\n\tclose(): void {\n\t\tthis.db.close();\n\t}\n}\n"]}