sigma-memory 0.2.1 → 0.2.2

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.
@@ -1,17 +1,14 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
- import { dirname } from 'path';
3
- import initSqlJs, { type Database } from 'sql.js';
4
- import type { VectorSearchResult } from './types.js';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { dirname } from "path";
3
+ import initSqlJs, { type Database } from "sql.js";
4
+ import type { VectorSearchResult } from "./types.js";
5
5
 
6
6
  /**
7
7
  * Helper for loading ESM-only modules from a CJS context.
8
8
  * Uses native import() via Function constructor to bypass
9
9
  * TypeScript's CJS transformation of dynamic imports.
10
10
  */
11
- const esmImport = new Function(
12
- 'specifier',
13
- 'return import(specifier)'
14
- ) as (specifier: string) => Promise<any>;
11
+ const esmImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<any>;
15
12
 
16
13
  /**
17
14
  * Self-contained vector store using sql.js (SQLite via WebAssembly) and
@@ -21,50 +18,50 @@ const esmImport = new Function(
21
18
  * No native compilation required.
22
19
  */
23
20
  export class VectorStore {
24
- private db: Database | null = null;
25
- private pipeline: any = null;
26
- private dbPath: string;
27
- private initialized = false;
28
- private initPromise: Promise<void> | null = null;
29
- private modelPromise: Promise<void> | null = null;
30
-
31
- constructor(dbPath: string) {
32
- this.dbPath = dbPath;
33
- }
34
-
35
- /**
36
- * Initialize the vector store: sets up the SQLite database.
37
- * The embedding model is loaded lazily on first embed operation.
38
- * Safe to call multiple times — only initializes once.
39
- */
40
- async init(): Promise<void> {
41
- if (this.initialized) return;
42
- if (this.initPromise) return this.initPromise;
43
-
44
- this.initPromise = this._init();
45
- await this.initPromise;
46
- }
47
-
48
- private async _init(): Promise<void> {
49
- // Ensure directory exists
50
- const dir = dirname(this.dbPath);
51
- if (!existsSync(dir)) {
52
- mkdirSync(dir, { recursive: true });
53
- }
54
-
55
- // Initialize sql.js (loads WASM automatically)
56
- const SQL = await initSqlJs();
57
-
58
- // Load existing DB or create a new one
59
- if (existsSync(this.dbPath)) {
60
- const fileBuffer = readFileSync(this.dbPath);
61
- this.db = new SQL.Database(fileBuffer);
62
- } else {
63
- this.db = new SQL.Database();
64
- }
65
-
66
- // Create schema
67
- this.db.run(`
21
+ private db: Database | null = null;
22
+ private pipeline: any = null;
23
+ private dbPath: string;
24
+ private initialized = false;
25
+ private initPromise: Promise<void> | null = null;
26
+ private modelPromise: Promise<void> | null = null;
27
+
28
+ constructor(dbPath: string) {
29
+ this.dbPath = dbPath;
30
+ }
31
+
32
+ /**
33
+ * Initialize the vector store: sets up the SQLite database.
34
+ * The embedding model is loaded lazily on first embed operation.
35
+ * Safe to call multiple times — only initializes once.
36
+ */
37
+ async init(): Promise<void> {
38
+ if (this.initialized) return;
39
+ if (this.initPromise) return this.initPromise;
40
+
41
+ this.initPromise = this._init();
42
+ await this.initPromise;
43
+ }
44
+
45
+ private async _init(): Promise<void> {
46
+ // Ensure directory exists
47
+ const dir = dirname(this.dbPath);
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+
52
+ // Initialize sql.js (loads WASM automatically)
53
+ const SQL = await initSqlJs();
54
+
55
+ // Load existing DB or create a new one
56
+ if (existsSync(this.dbPath)) {
57
+ const fileBuffer = readFileSync(this.dbPath);
58
+ this.db = new SQL.Database(fileBuffer);
59
+ } else {
60
+ this.db = new SQL.Database();
61
+ }
62
+
63
+ // Create schema
64
+ this.db.run(`
68
65
  CREATE TABLE IF NOT EXISTS documents (
69
66
  id INTEGER PRIMARY KEY AUTOINCREMENT,
70
67
  file TEXT NOT NULL,
@@ -76,309 +73,311 @@ export class VectorStore {
76
73
  )
77
74
  `);
78
75
 
79
- // Index for fast file lookups
80
- this.db.run('CREATE INDEX IF NOT EXISTS idx_documents_file ON documents(file)');
81
-
82
- this.persist();
83
- this.initialized = true;
84
- }
85
-
86
- /**
87
- * Load the embedding model. Called lazily on first embed operation.
88
- * Downloads the model on first use (~23MB for all-MiniLM-L6-v2).
89
- */
90
- private async loadEmbeddingModel(): Promise<void> {
91
- if (this.pipeline) return;
92
- if (this.modelPromise) return this.modelPromise;
93
-
94
- this.modelPromise = (async () => {
95
- console.log('[VectorStore] Loading embedding model (Xenova/all-MiniLM-L6-v2)...');
96
- console.log('[VectorStore] First run may download the model (~23MB).');
97
-
98
- // Dynamic ESM import for @huggingface/transformers (ESM-only package)
99
- const { pipeline: createPipeline } = await esmImport('@huggingface/transformers');
100
-
101
- this.pipeline = await createPipeline(
102
- 'feature-extraction',
103
- 'Xenova/all-MiniLM-L6-v2'
104
- );
105
-
106
- console.log('[VectorStore] Embedding model loaded.');
107
- })();
108
-
109
- await this.modelPromise;
110
- }
111
-
112
- /**
113
- * Ensure the embedding model is loaded before use.
114
- */
115
- private async ensurePipeline(): Promise<void> {
116
- if (!this.pipeline) {
117
- await this.loadEmbeddingModel();
118
- }
119
- }
120
-
121
- /**
122
- * Persist the in-memory SQLite database to disk.
123
- */
124
- private persist(): void {
125
- if (!this.db) return;
126
- const data = this.db.export();
127
- const buffer = Buffer.from(data);
128
- writeFileSync(this.dbPath, buffer);
129
- }
130
-
131
- /**
132
- * Embed text into a Float32Array vector (384 dimensions).
133
- */
134
- private async embed(text: string): Promise<Float32Array> {
135
- await this.ensurePipeline();
136
-
137
- const output = await this.pipeline(text, { pooling: 'mean', normalize: true });
138
- return new Float32Array(output.data);
139
- }
140
-
141
- /**
142
- * Chunk text into overlapping segments.
143
- *
144
- * Strategy:
145
- * 1. Split by paragraphs (double newline)
146
- * 2. If a paragraph exceeds 500 chars, split by sentences
147
- * 3. Each chunk gets a 100-char overlap with the previous chunk
148
- */
149
- private chunkText(text: string): string[] {
150
- const MAX_CHUNK_SIZE = 500;
151
- const OVERLAP = 100;
152
-
153
- // Split by paragraphs (double newline)
154
- const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0);
155
-
156
- const rawChunks: string[] = [];
157
-
158
- for (const paragraph of paragraphs) {
159
- const trimmed = paragraph.trim();
160
-
161
- if (trimmed.length <= MAX_CHUNK_SIZE) {
162
- rawChunks.push(trimmed);
163
- } else {
164
- // Split long paragraphs by sentences
165
- const sentences = trimmed.split(/(?<=[.!?])\s+/);
166
- let current = '';
167
-
168
- for (const sentence of sentences) {
169
- if (current.length + sentence.length + 1 > MAX_CHUNK_SIZE && current.length > 0) {
170
- rawChunks.push(current.trim());
171
- current = sentence;
172
- } else {
173
- current = current ? current + ' ' + sentence : sentence;
174
- }
175
- }
176
-
177
- if (current.trim().length > 0) {
178
- rawChunks.push(current.trim());
179
- }
180
- }
181
- }
182
-
183
- if (rawChunks.length === 0) return [];
184
-
185
- // Apply overlap: each chunk (except the first) gets the last 100 chars
186
- // of the previous chunk prepended
187
- const chunks: string[] = [rawChunks[0]];
188
-
189
- for (let i = 1; i < rawChunks.length; i++) {
190
- const prevChunk = rawChunks[i - 1];
191
- const overlap = prevChunk.slice(-OVERLAP);
192
- chunks.push(overlap + ' ' + rawChunks[i]);
193
- }
194
-
195
- return chunks;
196
- }
197
-
198
- /**
199
- * Serialize a Float32Array to a Buffer for BLOB storage.
200
- */
201
- private serializeEmbedding(embedding: Float32Array): Uint8Array {
202
- return new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
203
- }
204
-
205
- /**
206
- * Deserialize a BLOB (Uint8Array) back to Float32Array.
207
- */
208
- private deserializeEmbedding(blob: Uint8Array): Float32Array {
209
- // Create a proper copy to ensure alignment
210
- const buffer = new ArrayBuffer(blob.byteLength);
211
- new Uint8Array(buffer).set(blob);
212
- return new Float32Array(buffer);
213
- }
214
-
215
- /**
216
- * Compute cosine similarity between two vectors.
217
- * Both vectors should be normalized (which they are from the model),
218
- * so this is equivalent to the dot product.
219
- */
220
- private cosineSimilarity(a: Float32Array, b: Float32Array): number {
221
- let dotProduct = 0;
222
- let normA = 0;
223
- let normB = 0;
224
-
225
- for (let i = 0; i < a.length; i++) {
226
- dotProduct += a[i] * b[i];
227
- normA += a[i] * a[i];
228
- normB += b[i] * b[i];
229
- }
230
-
231
- const denominator = Math.sqrt(normA) * Math.sqrt(normB);
232
- if (denominator === 0) return 0;
233
-
234
- return dotProduct / denominator;
235
- }
236
-
237
- /**
238
- * Add a document to the vector store.
239
- * Chunks the content, embeds each chunk, and stores in the DB.
240
- * Replaces any existing chunks for this file.
241
- */
242
- async addDocument(file: string, content: string): Promise<void> {
243
- if (!this.db) throw new Error('VectorStore not initialized. Call init() first.');
244
-
245
- const chunks = this.chunkText(content);
246
- if (chunks.length === 0) return;
247
-
248
- // Remove existing chunks for this file
249
- this.db.run('DELETE FROM documents WHERE file = ?', [file]);
250
-
251
- // Embed and insert each chunk
252
- for (let i = 0; i < chunks.length; i++) {
253
- const embedding = await this.embed(chunks[i]);
254
- const embeddingBlob = this.serializeEmbedding(embedding);
255
- const now = new Date().toISOString();
256
-
257
- this.db.run(
258
- 'INSERT INTO documents (file, chunk_index, content, embedding, updated_at) VALUES (?, ?, ?, ?, ?)',
259
- [file, i, chunks[i], embeddingBlob as any, now]
260
- );
261
- }
262
-
263
- this.persist();
264
- }
265
-
266
- /**
267
- * Search the vector store for content similar to the query.
268
- * Returns top-k results sorted by cosine similarity (descending).
269
- *
270
- * For performance with large DBs (>10K chunks), embeddings are loaded
271
- * as Float32Array and compared using optimized JS computation.
272
- */
273
- async search(query: string, limit: number = 10): Promise<VectorSearchResult[]> {
274
- if (!this.db) throw new Error('VectorStore not initialized. Call init() first.');
275
-
276
- // Embed the query
277
- const queryEmbedding = await this.embed(query);
278
-
279
- // Load all documents with embeddings
280
- const results = this.db.exec(
281
- 'SELECT file, chunk_index, content, embedding FROM documents'
282
- );
283
-
284
- if (results.length === 0 || results[0].values.length === 0) {
285
- return [];
286
- }
287
-
288
- // Compute cosine similarity for each document
289
- const scored: VectorSearchResult[] = [];
290
-
291
- for (const row of results[0].values) {
292
- const [file, chunkIndex, content, embeddingBlob] = row as [string, number, string, Uint8Array];
293
-
294
- const docEmbedding = this.deserializeEmbedding(
295
- embeddingBlob instanceof Uint8Array ? embeddingBlob : new Uint8Array(embeddingBlob as any)
296
- );
297
-
298
- const score = this.cosineSimilarity(queryEmbedding, docEmbedding);
299
-
300
- scored.push({
301
- file: file as string,
302
- chunkIndex: chunkIndex as number,
303
- content: content as string,
304
- score
305
- });
306
- }
307
-
308
- // Sort by score descending and return top-k
309
- scored.sort((a, b) => b.score - a.score);
310
- return scored.slice(0, limit);
311
- }
312
-
313
- /**
314
- * Remove all chunks for a given file.
315
- */
316
- async removeDocument(file: string): Promise<void> {
317
- if (!this.db) throw new Error('VectorStore not initialized. Call init() first.');
318
-
319
- this.db.run('DELETE FROM documents WHERE file = ?', [file]);
320
- this.persist();
321
- }
322
-
323
- /**
324
- * Full reindex from a file map (filename → content).
325
- * Clears all existing data and re-indexes everything.
326
- */
327
- async reindex(files: Map<string, string>): Promise<void> {
328
- if (!this.db) throw new Error('VectorStore not initialized. Call init() first.');
329
-
330
- // Clear all existing documents
331
- this.db.run('DELETE FROM documents');
332
-
333
- // Re-add all files
334
- for (const [file, content] of files) {
335
- await this.addDocument(file, content);
336
- }
337
-
338
- this.persist();
339
- }
340
-
341
- /**
342
- * Get statistics about the vector store.
343
- */
344
- getStats(): { documentCount: number; chunkCount: number; lastUpdate: string } {
345
- if (!this.db) {
346
- return { documentCount: 0, chunkCount: 0, lastUpdate: '' };
347
- }
348
-
349
- const countResult = this.db.exec(
350
- 'SELECT COUNT(DISTINCT file) as docs, COUNT(*) as chunks FROM documents'
351
- );
352
- const updateResult = this.db.exec(
353
- 'SELECT MAX(updated_at) as last_update FROM documents'
354
- );
355
-
356
- const docs = countResult.length > 0 && countResult[0].values.length > 0
357
- ? (countResult[0].values[0][0] as number)
358
- : 0;
359
-
360
- const chunks = countResult.length > 0 && countResult[0].values.length > 0
361
- ? (countResult[0].values[0][1] as number)
362
- : 0;
363
-
364
- const lastUpdate = updateResult.length > 0 && updateResult[0].values.length > 0
365
- ? ((updateResult[0].values[0][0] as string) || '')
366
- : '';
367
-
368
- return { documentCount: docs, chunkCount: chunks, lastUpdate };
369
- }
370
-
371
- /**
372
- * Close the database connection and persist to disk.
373
- */
374
- close(): void {
375
- if (this.db) {
376
- this.persist();
377
- this.db.close();
378
- this.db = null;
379
- }
380
- this.initialized = false;
381
- this.initPromise = null;
382
- this.modelPromise = null;
383
- }
76
+ // Index for fast file lookups
77
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_documents_file ON documents(file)");
78
+
79
+ this.persist();
80
+ this.initialized = true;
81
+ }
82
+
83
+ /**
84
+ * Load the embedding model. Called lazily on first embed operation.
85
+ * Downloads the model on first use (~23MB for all-MiniLM-L6-v2).
86
+ */
87
+ private async loadEmbeddingModel(): Promise<void> {
88
+ if (this.pipeline) return;
89
+ if (this.modelPromise) return this.modelPromise;
90
+
91
+ this.modelPromise = (async () => {
92
+ // Choose the smallest quantised variant by default. The model
93
+ // (Xenova/all-MiniLM-L6-v2) ships in three flavours on the HF
94
+ // hub: fp32 (~90 MB), fp16 (~45 MB), q8 (~22 MB). On CPU — which
95
+ // is where phi-code's memory subsystem runs — q8 is 2- faster
96
+ // than fp32 with negligible quality loss for 384-dim sentence
97
+ // embeddings. Override with PHI_EMBEDDING_DTYPE=fp32|fp16|q8.
98
+ const requestedDtype = process.env.PHI_EMBEDDING_DTYPE;
99
+ const dtype: "fp32" | "fp16" | "q8" =
100
+ requestedDtype === "fp32" || requestedDtype === "fp16" || requestedDtype === "q8"
101
+ ? requestedDtype
102
+ : "q8";
103
+
104
+ console.log(`[VectorStore] Loading embedding model (Xenova/all-MiniLM-L6-v2, dtype=${dtype})...`);
105
+ console.log(`[VectorStore] First run may download the model (~${dtype === "q8" ? "22" : dtype === "fp16" ? "45" : "90"}MB).`);
106
+
107
+ // Dynamic ESM import for @huggingface/transformers (ESM-only package)
108
+ const { pipeline: createPipeline } = await esmImport("@huggingface/transformers");
109
+
110
+ this.pipeline = await createPipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", { dtype });
111
+
112
+ console.log("[VectorStore] Embedding model loaded.");
113
+ })();
114
+
115
+ await this.modelPromise;
116
+ }
117
+
118
+ /**
119
+ * Ensure the embedding model is loaded before use.
120
+ */
121
+ private async ensurePipeline(): Promise<void> {
122
+ if (!this.pipeline) {
123
+ await this.loadEmbeddingModel();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Persist the in-memory SQLite database to disk.
129
+ */
130
+ private persist(): void {
131
+ if (!this.db) return;
132
+ const data = this.db.export();
133
+ const buffer = Buffer.from(data);
134
+ writeFileSync(this.dbPath, buffer);
135
+ }
136
+
137
+ /**
138
+ * Embed text into a Float32Array vector (384 dimensions).
139
+ */
140
+ private async embed(text: string): Promise<Float32Array> {
141
+ await this.ensurePipeline();
142
+
143
+ const output = await this.pipeline(text, { pooling: "mean", normalize: true });
144
+ return new Float32Array(output.data);
145
+ }
146
+
147
+ /**
148
+ * Chunk text into overlapping segments.
149
+ *
150
+ * Strategy:
151
+ * 1. Split by paragraphs (double newline)
152
+ * 2. If a paragraph exceeds 500 chars, split by sentences
153
+ * 3. Each chunk gets a 100-char overlap with the previous chunk
154
+ */
155
+ private chunkText(text: string): string[] {
156
+ const MAX_CHUNK_SIZE = 500;
157
+ const OVERLAP = 100;
158
+
159
+ // Split by paragraphs (double newline)
160
+ const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
161
+
162
+ const rawChunks: string[] = [];
163
+
164
+ for (const paragraph of paragraphs) {
165
+ const trimmed = paragraph.trim();
166
+
167
+ if (trimmed.length <= MAX_CHUNK_SIZE) {
168
+ rawChunks.push(trimmed);
169
+ } else {
170
+ // Split long paragraphs by sentences
171
+ const sentences = trimmed.split(/(?<=[.!?])\s+/);
172
+ let current = "";
173
+
174
+ for (const sentence of sentences) {
175
+ if (current.length + sentence.length + 1 > MAX_CHUNK_SIZE && current.length > 0) {
176
+ rawChunks.push(current.trim());
177
+ current = sentence;
178
+ } else {
179
+ current = current ? current + " " + sentence : sentence;
180
+ }
181
+ }
182
+
183
+ if (current.trim().length > 0) {
184
+ rawChunks.push(current.trim());
185
+ }
186
+ }
187
+ }
188
+
189
+ if (rawChunks.length === 0) return [];
190
+
191
+ // Apply overlap: each chunk (except the first) gets the last 100 chars
192
+ // of the previous chunk prepended
193
+ const chunks: string[] = [rawChunks[0]];
194
+
195
+ for (let i = 1; i < rawChunks.length; i++) {
196
+ const prevChunk = rawChunks[i - 1];
197
+ const overlap = prevChunk.slice(-OVERLAP);
198
+ chunks.push(overlap + " " + rawChunks[i]);
199
+ }
200
+
201
+ return chunks;
202
+ }
203
+
204
+ /**
205
+ * Serialize a Float32Array to a Buffer for BLOB storage.
206
+ */
207
+ private serializeEmbedding(embedding: Float32Array): Uint8Array {
208
+ return new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
209
+ }
210
+
211
+ /**
212
+ * Deserialize a BLOB (Uint8Array) back to Float32Array.
213
+ */
214
+ private deserializeEmbedding(blob: Uint8Array): Float32Array {
215
+ // Create a proper copy to ensure alignment
216
+ const buffer = new ArrayBuffer(blob.byteLength);
217
+ new Uint8Array(buffer).set(blob);
218
+ return new Float32Array(buffer);
219
+ }
220
+
221
+ /**
222
+ * Compute cosine similarity between two vectors.
223
+ * Both vectors should be normalized (which they are from the model),
224
+ * so this is equivalent to the dot product.
225
+ */
226
+ private cosineSimilarity(a: Float32Array, b: Float32Array): number {
227
+ let dotProduct = 0;
228
+ let normA = 0;
229
+ let normB = 0;
230
+
231
+ for (let i = 0; i < a.length; i++) {
232
+ dotProduct += a[i] * b[i];
233
+ normA += a[i] * a[i];
234
+ normB += b[i] * b[i];
235
+ }
236
+
237
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
238
+ if (denominator === 0) return 0;
239
+
240
+ return dotProduct / denominator;
241
+ }
242
+
243
+ /**
244
+ * Add a document to the vector store.
245
+ * Chunks the content, embeds each chunk, and stores in the DB.
246
+ * Replaces any existing chunks for this file.
247
+ */
248
+ async addDocument(file: string, content: string): Promise<void> {
249
+ if (!this.db) throw new Error("VectorStore not initialized. Call init() first.");
250
+
251
+ const chunks = this.chunkText(content);
252
+ if (chunks.length === 0) return;
253
+
254
+ // Remove existing chunks for this file
255
+ this.db.run("DELETE FROM documents WHERE file = ?", [file]);
256
+
257
+ // Embed and insert each chunk
258
+ for (let i = 0; i < chunks.length; i++) {
259
+ const embedding = await this.embed(chunks[i]);
260
+ const embeddingBlob = this.serializeEmbedding(embedding);
261
+ const now = new Date().toISOString();
262
+
263
+ this.db.run(
264
+ "INSERT INTO documents (file, chunk_index, content, embedding, updated_at) VALUES (?, ?, ?, ?, ?)",
265
+ [file, i, chunks[i], embeddingBlob as any, now],
266
+ );
267
+ }
268
+
269
+ this.persist();
270
+ }
271
+
272
+ /**
273
+ * Search the vector store for content similar to the query.
274
+ * Returns top-k results sorted by cosine similarity (descending).
275
+ *
276
+ * For performance with large DBs (>10K chunks), embeddings are loaded
277
+ * as Float32Array and compared using optimized JS computation.
278
+ */
279
+ async search(query: string, limit: number = 10): Promise<VectorSearchResult[]> {
280
+ if (!this.db) throw new Error("VectorStore not initialized. Call init() first.");
281
+
282
+ // Embed the query
283
+ const queryEmbedding = await this.embed(query);
284
+
285
+ // Load all documents with embeddings
286
+ const results = this.db.exec("SELECT file, chunk_index, content, embedding FROM documents");
287
+
288
+ if (results.length === 0 || results[0].values.length === 0) {
289
+ return [];
290
+ }
291
+
292
+ // Compute cosine similarity for each document
293
+ const scored: VectorSearchResult[] = [];
294
+
295
+ for (const row of results[0].values) {
296
+ const [file, chunkIndex, content, embeddingBlob] = row as [string, number, string, Uint8Array];
297
+
298
+ const docEmbedding = this.deserializeEmbedding(
299
+ embeddingBlob instanceof Uint8Array ? embeddingBlob : new Uint8Array(embeddingBlob as any),
300
+ );
301
+
302
+ const score = this.cosineSimilarity(queryEmbedding, docEmbedding);
303
+
304
+ scored.push({
305
+ file: file as string,
306
+ chunkIndex: chunkIndex as number,
307
+ content: content as string,
308
+ score,
309
+ });
310
+ }
311
+
312
+ // Sort by score descending and return top-k
313
+ scored.sort((a, b) => b.score - a.score);
314
+ return scored.slice(0, limit);
315
+ }
316
+
317
+ /**
318
+ * Remove all chunks for a given file.
319
+ */
320
+ async removeDocument(file: string): Promise<void> {
321
+ if (!this.db) throw new Error("VectorStore not initialized. Call init() first.");
322
+
323
+ this.db.run("DELETE FROM documents WHERE file = ?", [file]);
324
+ this.persist();
325
+ }
326
+
327
+ /**
328
+ * Full reindex from a file map (filename content).
329
+ * Clears all existing data and re-indexes everything.
330
+ */
331
+ async reindex(files: Map<string, string>): Promise<void> {
332
+ if (!this.db) throw new Error("VectorStore not initialized. Call init() first.");
333
+
334
+ // Clear all existing documents
335
+ this.db.run("DELETE FROM documents");
336
+
337
+ // Re-add all files
338
+ for (const [file, content] of files) {
339
+ await this.addDocument(file, content);
340
+ }
341
+
342
+ this.persist();
343
+ }
344
+
345
+ /**
346
+ * Get statistics about the vector store.
347
+ */
348
+ getStats(): { documentCount: number; chunkCount: number; lastUpdate: string } {
349
+ if (!this.db) {
350
+ return { documentCount: 0, chunkCount: 0, lastUpdate: "" };
351
+ }
352
+
353
+ const countResult = this.db.exec("SELECT COUNT(DISTINCT file) as docs, COUNT(*) as chunks FROM documents");
354
+ const updateResult = this.db.exec("SELECT MAX(updated_at) as last_update FROM documents");
355
+
356
+ const docs =
357
+ countResult.length > 0 && countResult[0].values.length > 0 ? (countResult[0].values[0][0] as number) : 0;
358
+
359
+ const chunks =
360
+ countResult.length > 0 && countResult[0].values.length > 0 ? (countResult[0].values[0][1] as number) : 0;
361
+
362
+ const lastUpdate =
363
+ updateResult.length > 0 && updateResult[0].values.length > 0
364
+ ? (updateResult[0].values[0][0] as string) || ""
365
+ : "";
366
+
367
+ return { documentCount: docs, chunkCount: chunks, lastUpdate };
368
+ }
369
+
370
+ /**
371
+ * Close the database connection and persist to disk.
372
+ */
373
+ close(): void {
374
+ if (this.db) {
375
+ this.persist();
376
+ this.db.close();
377
+ this.db = null;
378
+ }
379
+ this.initialized = false;
380
+ this.initPromise = null;
381
+ this.modelPromise = null;
382
+ }
384
383
  }