edhindex 1.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.
@@ -0,0 +1,64 @@
1
+ import { TransformersReranker } from '../embeddings/transformers.js';
2
+ import { MetadataStore, ChunkMetadata } from '../storage/metadata.js';
3
+ import { logger } from '../logging.js';
4
+
5
+ export interface RerankedResult {
6
+ id: string;
7
+ score: number;
8
+ metadata: ChunkMetadata | null;
9
+ }
10
+
11
+ export async function rerankResults(
12
+ query: string,
13
+ candidates: Array<{ id: string }>,
14
+ metadataStore: MetadataStore,
15
+ reranker: TransformersReranker | null,
16
+ topK: number,
17
+ useRerank: boolean,
18
+ ): Promise<RerankedResult[]> {
19
+ if (candidates.length === 0) return [];
20
+
21
+ if (!useRerank || !reranker) {
22
+ return candidates.slice(0, topK).map(c => ({
23
+ id: c.id,
24
+ score: 1,
25
+ metadata: metadataStore.getChunk(c.id) || null,
26
+ }));
27
+ }
28
+
29
+ const texts: string[] = [];
30
+ const validCandidates: Array<{ id: string }> = [];
31
+
32
+ for (const c of candidates) {
33
+ const meta = metadataStore.getChunk(c.id);
34
+ if (meta) {
35
+ texts.push(meta.imports + ' ' + meta.exports + ' ' + (meta.symbol || ''));
36
+ validCandidates.push(c);
37
+ }
38
+ }
39
+
40
+ if (texts.length === 0) {
41
+ return candidates.slice(0, topK).map(c => ({
42
+ id: c.id,
43
+ score: 1,
44
+ metadata: metadataStore.getChunk(c.id) || null,
45
+ }));
46
+ }
47
+
48
+ try {
49
+ const scores = await reranker.rerank(query, texts);
50
+ const results: RerankedResult[] = scores.slice(0, topK).map(s => ({
51
+ id: validCandidates[s.index].id,
52
+ score: s.score,
53
+ metadata: metadataStore.getChunk(validCandidates[s.index].id) || null,
54
+ }));
55
+ return results;
56
+ } catch (e) {
57
+ logger.debug('Reranking failed, falling back to unranked:', e);
58
+ return validCandidates.slice(0, topK).map(c => ({
59
+ id: c.id,
60
+ score: 1,
61
+ metadata: metadataStore.getChunk(c.id) || null,
62
+ }));
63
+ }
64
+ }
@@ -0,0 +1,144 @@
1
+ import { FTSStore } from '../storage/fts.js';
2
+ import { MetadataStore, ChunkMetadata } from '../storage/metadata.js';
3
+ import { VectorStore } from '../vector/store.js';
4
+ import { EmbeddingProvider } from '../embeddings/provider.js';
5
+ import { TransformersReranker } from '../embeddings/transformers.js';
6
+ import { searchBM25 } from './bm25.js';
7
+ import { rerankResults, RerankedResult } from './reranker.js';
8
+ import { logger } from '../logging.js';
9
+
10
+ export interface SearchOptions {
11
+ query: string;
12
+ maxResults?: number;
13
+ rerank?: boolean;
14
+ fileFilter?: string;
15
+ languageFilter?: string;
16
+ }
17
+
18
+ export interface SearchResult {
19
+ id: string;
20
+ file: string;
21
+ language: string;
22
+ symbol: string;
23
+ kind: string | null;
24
+ startLine: number;
25
+ endLine: number;
26
+ content: string;
27
+ score: number;
28
+ matchType: 'keyword' | 'vector' | 'hybrid';
29
+ }
30
+
31
+ const BM25_TOP_K = 30;
32
+ const VECTOR_TOP_K = 30;
33
+
34
+ export class SearchEngine {
35
+ private fts: FTSStore;
36
+ private metadata: MetadataStore;
37
+ private vectors: VectorStore;
38
+ private embedder: EmbeddingProvider;
39
+ private reranker: TransformersReranker | null;
40
+ private useRerank: boolean;
41
+
42
+ constructor(
43
+ fts: FTSStore,
44
+ metadata: MetadataStore,
45
+ vectors: VectorStore,
46
+ embedder: EmbeddingProvider,
47
+ reranker: TransformersReranker | null,
48
+ useRerank: boolean,
49
+ ) {
50
+ this.fts = fts;
51
+ this.metadata = metadata;
52
+ this.vectors = vectors;
53
+ this.embedder = embedder;
54
+ this.reranker = reranker;
55
+ this.useRerank = useRerank;
56
+ }
57
+
58
+ async search(opts: SearchOptions): Promise<SearchResult[]> {
59
+ const maxResults = opts.maxResults || 10;
60
+ const query = opts.query.trim();
61
+ if (!query) return [];
62
+
63
+ // Normalize query
64
+ const normalized = query.replace(/\s+/g, ' ').trim();
65
+
66
+ // BM25
67
+ const bm25Results = searchBM25(this.fts, normalized, BM25_TOP_K);
68
+ logger.debug(`BM25 returned ${bm25Results.length} results`);
69
+
70
+ // Vector search
71
+ let vectorResults: Array<{ id: string; score: number }> = [];
72
+ try {
73
+ const embedding = await this.embedder.embed([normalized]);
74
+ if (embedding.length > 0 && embedding[0].length > 0) {
75
+ vectorResults = await this.vectors.search(embedding[0], VECTOR_TOP_K);
76
+ }
77
+ } catch (e) {
78
+ logger.debug('Vector search failed:', e);
79
+ }
80
+ logger.debug(`Vector search returned ${vectorResults.length} results`);
81
+
82
+ // Merge and deduplicate
83
+ const seen = new Set<string>();
84
+ const merged: Array<{ id: string; score: number }> = [];
85
+
86
+ for (const r of bm25Results) {
87
+ if (!seen.has(r.id)) {
88
+ seen.add(r.id);
89
+ merged.push(r);
90
+ }
91
+ }
92
+
93
+ for (const r of vectorResults) {
94
+ if (!seen.has(r.id)) {
95
+ seen.add(r.id);
96
+ merged.push({ id: r.id, score: r.score * 0.8 });
97
+ } else {
98
+ const existing = merged.find(m => m.id === r.id);
99
+ if (existing) {
100
+ existing.score = Math.max(existing.score, r.score);
101
+ }
102
+ }
103
+ }
104
+
105
+ logger.debug(`Merged ${merged.length} unique results`);
106
+
107
+ // Rerank
108
+ const reranked = await rerankResults(
109
+ normalized,
110
+ merged,
111
+ this.metadata,
112
+ this.reranker,
113
+ maxResults,
114
+ this.useRerank,
115
+ );
116
+
117
+ // Build final results
118
+ const results: SearchResult[] = [];
119
+ for (const r of reranked) {
120
+ if (!r.metadata) continue;
121
+
122
+ let matchType: SearchResult['matchType'] = 'hybrid';
123
+ const inBM25 = bm25Results.some(b => b.id === r.id);
124
+ const inVector = vectorResults.some(v => v.id === r.id);
125
+ if (inBM25 && !inVector) matchType = 'keyword';
126
+ else if (!inBM25 && inVector) matchType = 'vector';
127
+
128
+ results.push({
129
+ id: r.id,
130
+ file: r.metadata.file,
131
+ language: r.metadata.language,
132
+ symbol: r.metadata.symbol,
133
+ kind: r.metadata.kind,
134
+ startLine: r.metadata.start_line,
135
+ endLine: r.metadata.end_line,
136
+ content: r.metadata.imports + '\n' + r.metadata.exports,
137
+ score: r.score,
138
+ matchType,
139
+ });
140
+ }
141
+
142
+ return results.slice(0, maxResults);
143
+ }
144
+ }
@@ -0,0 +1,46 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve, relative, normalize, sep } from 'node:path';
3
+
4
+ export function isSubPath(parent: string, child: string): boolean {
5
+ const rel = relative(parent, child);
6
+ return !rel.startsWith('..') && !isAbsolutePath(rel);
7
+ }
8
+
9
+ function isAbsolutePath(p: string): boolean {
10
+ return sep === '/' ? p.startsWith('/') : /^[a-zA-Z]:[/\\]/.test(p);
11
+ }
12
+
13
+ export function sanitizePath(root: string, requestedPath: string): string | null {
14
+ const resolved = resolve(root, requestedPath);
15
+ const normalized = normalize(resolved);
16
+
17
+ if (!isSubPath(root, normalized)) {
18
+ return null;
19
+ }
20
+
21
+ return normalized;
22
+ }
23
+
24
+ export function resolveSafePath(root: string, requestedPath: string): string | null {
25
+ const sanitized = sanitizePath(root, requestedPath);
26
+ if (!sanitized) return null;
27
+
28
+ try {
29
+ const real = realpathSync(sanitized);
30
+ if (!isSubPath(root, real)) {
31
+ return null;
32
+ }
33
+ return real;
34
+ } catch {
35
+ return sanitized;
36
+ }
37
+ }
38
+
39
+ export function checkSymlinkSafety(root: string, targetPath: string): boolean {
40
+ try {
41
+ const real = realpathSync(targetPath);
42
+ return isSubPath(root, real);
43
+ } catch {
44
+ return true;
45
+ }
46
+ }
@@ -0,0 +1,130 @@
1
+ import Database from 'better-sqlite3';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { logger } from '../logging.js';
5
+
6
+ export interface FTSResult {
7
+ id: string;
8
+ rank: number;
9
+ content: string;
10
+ file: string;
11
+ symbol: string;
12
+ kind: string | null;
13
+ start_line: number;
14
+ end_line: number;
15
+ language: string;
16
+ }
17
+
18
+ export class FTSStore {
19
+ private db: Database.Database;
20
+ private path: string;
21
+
22
+ constructor(indexDir: string) {
23
+ this.path = join(indexDir, 'fts.db');
24
+ this.db = new Database(this.path);
25
+ this.createSchema();
26
+ }
27
+
28
+ private createSchema() {
29
+ this.db.exec(`
30
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
31
+ content,
32
+ file UNINDEXED,
33
+ symbol,
34
+ language UNINDEXED,
35
+ content_id UNINDEXED,
36
+ tokenize='porter unicode61'
37
+ );
38
+
39
+ PRAGMA journal_mode = WAL;
40
+ PRAGMA synchronous = NORMAL;
41
+ `);
42
+ }
43
+
44
+ beginTransaction() {
45
+ this.db.exec('BEGIN TRANSACTION');
46
+ }
47
+
48
+ commit() {
49
+ this.db.exec('COMMIT');
50
+ }
51
+
52
+ rollback() {
53
+ this.db.exec('ROLLBACK');
54
+ }
55
+
56
+ insertChunk(id: string, content: string, file: string, symbol: string, language: string) {
57
+ try {
58
+ this.db.prepare(`
59
+ INSERT INTO chunks_fts (content, file, symbol, language, content_id)
60
+ VALUES (?, ?, ?, ?, ?)
61
+ `).run(content, file, symbol, language, id);
62
+ } catch (e: any) {
63
+ if (e?.message?.includes('already exists')) {
64
+ this.db.prepare(`
65
+ UPDATE chunks_fts SET content = ?, file = ?, symbol = ?, language = ?
66
+ WHERE content_id = ?
67
+ `).run(content, file, symbol, language, id);
68
+ }
69
+ }
70
+ }
71
+
72
+ deleteChunk(id: string) {
73
+ this.db.prepare('DELETE FROM chunks_fts WHERE content_id = ?').run(id);
74
+ }
75
+
76
+ deleteChunksForFile(filePath: string) {
77
+ this.db.prepare('DELETE FROM chunks_fts WHERE file = ?').run(filePath);
78
+ }
79
+
80
+ search(query: string, limit: number): FTSResult[] {
81
+ const cleaned = query.replace(/[^\w\s]/g, ' ').trim();
82
+ if (!cleaned) return [];
83
+
84
+ try {
85
+ const results = this.db.prepare(`
86
+ SELECT
87
+ content_id as id,
88
+ rank,
89
+ content,
90
+ file,
91
+ symbol,
92
+ language
93
+ FROM chunks_fts
94
+ WHERE chunks_fts MATCH ?
95
+ ORDER BY rank
96
+ LIMIT ?
97
+ `).all(cleaned, limit) as FTSResult[];
98
+
99
+ return results.map(r => ({
100
+ ...r,
101
+ kind: null,
102
+ start_line: 0,
103
+ end_line: 0,
104
+ }));
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ searchWithRank(query: string, limit: number): Array<{ id: string; rank: number }> {
111
+ const cleaned = query.replace(/[^\w\s]/g, ' ').trim();
112
+ if (!cleaned) return [];
113
+
114
+ try {
115
+ return this.db.prepare(`
116
+ SELECT content_id as id, rank
117
+ FROM chunks_fts
118
+ WHERE chunks_fts MATCH ?
119
+ ORDER BY rank
120
+ LIMIT ?
121
+ `).all(cleaned, limit) as Array<{ id: string; rank: number }>;
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ close() {
128
+ this.db.close();
129
+ }
130
+ }
@@ -0,0 +1,162 @@
1
+ import Database from 'better-sqlite3';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { logger } from '../logging.js';
5
+
6
+ export interface ChunkMetadata {
7
+ id: string;
8
+ file: string;
9
+ language: string;
10
+ symbol: string;
11
+ kind: string | null;
12
+ parent: string | null;
13
+ hash: string;
14
+ git_commit: string | null;
15
+ start_line: number;
16
+ end_line: number;
17
+ imports: string;
18
+ exports: string;
19
+ embedding_model: string;
20
+ last_indexed: string;
21
+ checksum: string;
22
+ }
23
+
24
+ export class MetadataStore {
25
+ private db: Database.Database;
26
+ private path: string;
27
+
28
+ constructor(indexDir: string) {
29
+ this.path = join(indexDir, 'metadata.db');
30
+ const exists = existsSync(this.path);
31
+ this.db = new Database(this.path);
32
+
33
+ if (!exists) {
34
+ this.createSchema();
35
+ }
36
+ }
37
+
38
+ private createSchema() {
39
+ this.db.exec(`
40
+ CREATE TABLE IF NOT EXISTS chunks (
41
+ id TEXT PRIMARY KEY,
42
+ file TEXT NOT NULL,
43
+ language TEXT NOT NULL,
44
+ symbol TEXT NOT NULL DEFAULT '',
45
+ kind TEXT,
46
+ parent TEXT,
47
+ hash TEXT NOT NULL,
48
+ git_commit TEXT,
49
+ start_line INTEGER NOT NULL,
50
+ end_line INTEGER NOT NULL,
51
+ imports TEXT DEFAULT '',
52
+ exports TEXT DEFAULT '',
53
+ embedding_model TEXT NOT NULL DEFAULT '',
54
+ last_indexed TEXT NOT NULL,
55
+ checksum TEXT NOT NULL
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file);
59
+ CREATE INDEX IF NOT EXISTS idx_chunks_language ON chunks(language);
60
+ CREATE INDEX IF NOT EXISTS idx_chunks_symbol ON chunks(symbol);
61
+ CREATE INDEX IF NOT EXISTS idx_chunks_kind ON chunks(kind);
62
+ CREATE INDEX IF NOT EXISTS idx_chunks_hash ON chunks(hash);
63
+
64
+ CREATE TABLE IF NOT EXISTS file_hashes (
65
+ path TEXT PRIMARY KEY,
66
+ hash TEXT NOT NULL,
67
+ last_indexed TEXT NOT NULL
68
+ );
69
+
70
+ PRAGMA journal_mode = WAL;
71
+ PRAGMA synchronous = NORMAL;
72
+ `);
73
+ }
74
+
75
+ beginTransaction() {
76
+ this.db.exec('BEGIN TRANSACTION');
77
+ }
78
+
79
+ commit() {
80
+ this.db.exec('COMMIT');
81
+ }
82
+
83
+ rollback() {
84
+ this.db.exec('ROLLBACK');
85
+ }
86
+
87
+ upsertChunk(chunk: ChunkMetadata) {
88
+ const stmt = this.db.prepare(`
89
+ INSERT OR REPLACE INTO chunks
90
+ (id, file, language, symbol, kind, parent, hash, git_commit,
91
+ start_line, end_line, imports, exports, embedding_model, last_indexed, checksum)
92
+ VALUES
93
+ (@id, @file, @language, @symbol, @kind, @parent, @hash, @git_commit,
94
+ @start_line, @end_line, @imports, @exports, @embedding_model, @last_indexed, @checksum)
95
+ `);
96
+ stmt.run(chunk);
97
+ }
98
+
99
+ deleteChunks(chunkIds: string[]) {
100
+ if (chunkIds.length === 0) return;
101
+ const stmt = this.db.prepare('DELETE FROM chunks WHERE id = ?');
102
+ for (const id of chunkIds) {
103
+ stmt.run(id);
104
+ }
105
+ }
106
+
107
+ deleteChunksForFile(filePath: string) {
108
+ this.db.prepare('DELETE FROM chunks WHERE file = ?').run(filePath);
109
+ }
110
+
111
+ getChunk(id: string): ChunkMetadata | undefined {
112
+ return this.db.prepare('SELECT * FROM chunks WHERE id = ?').get(id) as ChunkMetadata | undefined;
113
+ }
114
+
115
+ getAllChunkHashes(): Map<string, string> {
116
+ const rows = this.db.prepare('SELECT id, hash FROM chunks').all() as { id: string; hash: string }[];
117
+ const map = new Map<string, string>();
118
+ for (const row of rows) {
119
+ map.set(row.id, row.hash);
120
+ }
121
+ return map;
122
+ }
123
+
124
+ getAllFileHashes(): Map<string, string> {
125
+ const rows = this.db.prepare('SELECT path, hash FROM file_hashes').all() as { path: string; hash: string }[];
126
+ const map = new Map<string, string>();
127
+ for (const row of rows) {
128
+ map.set(row.path, row.hash);
129
+ }
130
+ return map;
131
+ }
132
+
133
+ upsertFileHash(path: string, hash: string) {
134
+ const stmt = this.db.prepare(`
135
+ INSERT OR REPLACE INTO file_hashes (path, hash, last_indexed)
136
+ VALUES (?, ?, ?)
137
+ `);
138
+ stmt.run(path, hash, new Date().toISOString());
139
+ }
140
+
141
+ deleteFileHash(path: string) {
142
+ this.db.prepare('DELETE FROM file_hashes WHERE path = ?').run(path);
143
+ }
144
+
145
+ getChunksForFile(file: string): ChunkMetadata[] {
146
+ return this.db.prepare('SELECT * FROM chunks WHERE file = ?').all() as ChunkMetadata[];
147
+ }
148
+
149
+ getChunkCount(): number {
150
+ const row = this.db.prepare('SELECT COUNT(*) as count FROM chunks').get() as { count: number };
151
+ return row.count;
152
+ }
153
+
154
+ getFileCount(): number {
155
+ const row = this.db.prepare('SELECT COUNT(DISTINCT file) as count FROM chunks').get() as { count: number };
156
+ return row.count;
157
+ }
158
+
159
+ close() {
160
+ this.db.close();
161
+ }
162
+ }
@@ -0,0 +1,95 @@
1
+ import { VectorStore } from './store.js';
2
+ import { logger } from '../logging.js';
3
+
4
+ export class LanceDBStore implements VectorStore {
5
+ private db: any = null;
6
+ private table: any = null;
7
+ private uri: string;
8
+ private dims: number;
9
+ private tableName = 'chunks';
10
+ private initialized = false;
11
+
12
+ constructor(indexDir: string, dimensions: number) {
13
+ this.uri = indexDir;
14
+ this.dims = dimensions;
15
+ }
16
+
17
+ async init(): Promise<void> {
18
+ if (this.initialized) return;
19
+ const mod = await import('@lancedb/lancedb');
20
+ this.db = await mod.connect(this.uri);
21
+ const tableNames = await this.db.tableNames();
22
+
23
+ if (tableNames.includes(this.tableName)) {
24
+ this.table = await this.db.openTable(this.tableName);
25
+ } else {
26
+ this.table = await this.db.createTable(this.tableName, [
27
+ { id: '', vector: new Array(this.dims).fill(0), metadata: {} },
28
+ ]);
29
+ }
30
+ this.initialized = true;
31
+ }
32
+
33
+ async insert(vectors: Array<{ id: string; values: number[]; metadata: Record<string, any> }>): Promise<void> {
34
+ await this.init();
35
+ const data = vectors.map(v => ({
36
+ id: v.id,
37
+ vector: v.values,
38
+ metadata: { ...v.metadata, text: v.metadata.text || '' },
39
+ }));
40
+ await this.table.add(data);
41
+ }
42
+
43
+ async delete(ids: string[]): Promise<void> {
44
+ await this.init();
45
+ for (const id of ids) {
46
+ try {
47
+ await this.table.delete(`id = '${id.replace(/'/g, "''")}'`);
48
+ } catch (e) {
49
+ logger.debug(`Failed to delete vector ${id}:`, e);
50
+ }
51
+ }
52
+ }
53
+
54
+ async search(query: number[], topK: number): Promise<Array<{ id: string; score: number }>> {
55
+ await this.init();
56
+ try {
57
+ const results = await this.table
58
+ .vectorSearch(query)
59
+ .limit(topK)
60
+ .toArray();
61
+
62
+ return results.map((r: any) => ({
63
+ id: r.id as string,
64
+ score: r._distance !== undefined ? 1 / (1 + r._distance) : 1,
65
+ }));
66
+ } catch (e) {
67
+ logger.debug('Vector search failed:', e);
68
+ return [];
69
+ }
70
+ }
71
+
72
+ async compact(): Promise<void> {
73
+ await this.init();
74
+ try {
75
+ await this.table.compact();
76
+ } catch (e) {
77
+ logger.debug('Compact failed:', e);
78
+ }
79
+ }
80
+
81
+ async count(): Promise<number> {
82
+ await this.init();
83
+ try {
84
+ return await this.table.countRows();
85
+ } catch {
86
+ return 0;
87
+ }
88
+ }
89
+
90
+ async close(): Promise<void> {
91
+ if (this.db) {
92
+ try { await this.db.close(); } catch { /* ignore */ }
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,8 @@
1
+ export interface VectorStore {
2
+ insert(vectors: Array<{ id: string; values: number[]; metadata: Record<string, any> }>): Promise<void>;
3
+ delete(ids: string[]): Promise<void>;
4
+ search(query: number[], topK: number): Promise<Array<{ id: string; score: number }>>;
5
+ compact(): Promise<void>;
6
+ count(): Promise<number>;
7
+ close(): Promise<void>;
8
+ }
package/src/version.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { logger } from './logging.js';
4
+
5
+ export interface IndexVersion {
6
+ schema: number;
7
+ embeddingModel: string;
8
+ chunkStrategy: number;
9
+ createdAt: string;
10
+ lastIndexed: string;
11
+ }
12
+
13
+ const CURRENT_SCHEMA = 1;
14
+ const CURRENT_CHUNK_STRATEGY = 1;
15
+
16
+ export function getExpectedVersion(embeddingModelName: string): IndexVersion {
17
+ return {
18
+ schema: CURRENT_SCHEMA,
19
+ embeddingModel: embeddingModelName,
20
+ chunkStrategy: CURRENT_CHUNK_STRATEGY,
21
+ createdAt: new Date().toISOString(),
22
+ lastIndexed: new Date().toISOString(),
23
+ };
24
+ }
25
+
26
+ export function loadVersion(indexDir: string): IndexVersion | null {
27
+ const path = join(indexDir, 'version.json');
28
+ if (!existsSync(path)) return null;
29
+ try {
30
+ const raw = readFileSync(path, 'utf-8');
31
+ return JSON.parse(raw) as IndexVersion;
32
+ } catch (e) {
33
+ logger.debug('Failed to load version.json:', e);
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function saveVersion(indexDir: string, version: IndexVersion) {
39
+ const path = join(indexDir, 'version.json');
40
+ if (!existsSync(indexDir)) {
41
+ mkdirSync(indexDir, { recursive: true });
42
+ }
43
+ writeFileSync(path, JSON.stringify(version, null, 2), 'utf-8');
44
+ }
45
+
46
+ export function needsRebuild(
47
+ existing: IndexVersion | null,
48
+ embeddingModel: string,
49
+ ): boolean {
50
+ if (!existing) return true;
51
+ if (existing.schema !== CURRENT_SCHEMA) return true;
52
+ if (existing.embeddingModel !== embeddingModel) return true;
53
+ if (existing.chunkStrategy !== CURRENT_CHUNK_STRATEGY) return true;
54
+ return false;
55
+ }