nano-brain 2026.3.5 → 2026.3.7
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/package.json +1 -1
- package/src/chunker.ts +1 -1
- package/src/codebase.ts +29 -4
- package/src/server.ts +23 -3
- package/src/store.ts +90 -16
- package/src/types.ts +10 -2
- package/src/watcher.ts +42 -18
- package/test/store.test.ts +306 -1
- package/test/watcher.test.ts +299 -4
package/package.json
CHANGED
package/src/chunker.ts
CHANGED
|
@@ -141,7 +141,7 @@ export function chunkMarkdown(
|
|
|
141
141
|
): MemoryChunk[] {
|
|
142
142
|
const maxChunkSize = options?.maxChunkSize ?? 3600;
|
|
143
143
|
const minChunkSize = options?.minChunkSize ?? 200;
|
|
144
|
-
const overlap = options?.overlap ??
|
|
144
|
+
const overlap = options?.overlap ?? 200;
|
|
145
145
|
const windowSize = 800;
|
|
146
146
|
|
|
147
147
|
if (content.trim().length === 0) {
|
package/src/codebase.ts
CHANGED
|
@@ -22,7 +22,7 @@ import * as fs from 'fs'
|
|
|
22
22
|
import * as path from 'path'
|
|
23
23
|
import fg from 'fast-glob'
|
|
24
24
|
|
|
25
|
-
const DEFAULT_MAX_FILE_SIZE =
|
|
25
|
+
const DEFAULT_MAX_FILE_SIZE = 300 * 1024
|
|
26
26
|
const DEFAULT_CODEBASE_MAX_SIZE = 2 * 1024 * 1024 * 1024
|
|
27
27
|
|
|
28
28
|
const BUILTIN_EXCLUDE_PATTERNS = [
|
|
@@ -133,6 +133,27 @@ const BUILTIN_EXCLUDE_PATTERNS = [
|
|
|
133
133
|
'**/*.sum',
|
|
134
134
|
'**/*.snap',
|
|
135
135
|
'**/docker-data/**',
|
|
136
|
+
|
|
137
|
+
// Lock files (zero search value, massive token waste)
|
|
138
|
+
'**/package-lock.json',
|
|
139
|
+
'**/yarn.lock',
|
|
140
|
+
'**/pnpm-lock.yaml',
|
|
141
|
+
'**/Pipfile.lock',
|
|
142
|
+
'**/poetry.lock',
|
|
143
|
+
'**/composer.lock',
|
|
144
|
+
'**/Gemfile.lock',
|
|
145
|
+
'**/Cargo.lock',
|
|
146
|
+
'**/go.sum',
|
|
147
|
+
|
|
148
|
+
// Bundled/minified assets (already built, not source)
|
|
149
|
+
'**/public/vs/**',
|
|
150
|
+
'**/assets/index-*.js',
|
|
151
|
+
'**/*.bundle.js',
|
|
152
|
+
'**/*.chunk.js',
|
|
153
|
+
|
|
154
|
+
// i18n/locale data (large, repetitive, low search value)
|
|
155
|
+
'**/nls.messages.*.js',
|
|
156
|
+
'**/locales/*.json',
|
|
136
157
|
]
|
|
137
158
|
|
|
138
159
|
const PROJECT_TYPE_MARKERS: Record<string, string[]> = {
|
|
@@ -310,6 +331,9 @@ export async function indexCodebase(
|
|
|
310
331
|
}
|
|
311
332
|
continue
|
|
312
333
|
}
|
|
334
|
+
if (existingDoc) {
|
|
335
|
+
store.cleanupVectorsForHash(existingDoc.hash)
|
|
336
|
+
}
|
|
313
337
|
store.insertContent(hash, content)
|
|
314
338
|
const chunks = chunkSourceCode(content, hash, filePath, workspaceRoot)
|
|
315
339
|
chunksCreated += chunks.length
|
|
@@ -427,6 +451,7 @@ export async function embedPendingCodebase(
|
|
|
427
451
|
projectHash?: string
|
|
428
452
|
): Promise<number> {
|
|
429
453
|
const maxChars = embedder.getMaxChars?.() ?? 6000
|
|
454
|
+
const vectorStore = store.getVectorStore?.() ?? null
|
|
430
455
|
let embedded = 0
|
|
431
456
|
const failedHashes = new Set<string>()
|
|
432
457
|
while (true) {
|
|
@@ -463,12 +488,12 @@ export async function embedPendingCodebase(
|
|
|
463
488
|
if (embedder.embedBatch && texts.length > 1) {
|
|
464
489
|
const results = await embedder.embedBatch(texts)
|
|
465
490
|
for (let i = 0; i < allChunks.length; i++) {
|
|
466
|
-
store.insertEmbedding(allChunks[i].hash, allChunks[i].seq, allChunks[i].pos, results[i].embedding, modelName)
|
|
491
|
+
store.insertEmbedding(allChunks[i].hash, allChunks[i].seq, allChunks[i].pos, results[i].embedding, modelName, vectorStore ?? undefined)
|
|
467
492
|
}
|
|
468
493
|
} else {
|
|
469
494
|
for (let i = 0; i < allChunks.length; i++) {
|
|
470
495
|
const result = await embedder.embed(texts[i])
|
|
471
|
-
store.insertEmbedding(allChunks[i].hash, allChunks[i].seq, allChunks[i].pos, result.embedding, result.model || modelName)
|
|
496
|
+
store.insertEmbedding(allChunks[i].hash, allChunks[i].seq, allChunks[i].pos, result.embedding, result.model || modelName, vectorStore ?? undefined)
|
|
472
497
|
}
|
|
473
498
|
}
|
|
474
499
|
embedded += batch.length
|
|
@@ -479,7 +504,7 @@ export async function embedPendingCodebase(
|
|
|
479
504
|
for (let i = 0; i < allChunks.length; i++) {
|
|
480
505
|
try {
|
|
481
506
|
const result = await embedder.embed(texts[i])
|
|
482
|
-
store.insertEmbedding(allChunks[i].hash, allChunks[i].seq, allChunks[i].pos, result.embedding, result.model || modelName)
|
|
507
|
+
store.insertEmbedding(allChunks[i].hash, allChunks[i].seq, allChunks[i].pos, result.embedding, result.model || modelName, vectorStore ?? undefined)
|
|
483
508
|
succeededHashes.add(allChunks[i].hash)
|
|
484
509
|
} catch {
|
|
485
510
|
console.warn(`[embed] Skipping chunk ${allChunks[i].hash}:${allChunks[i].seq}`)
|
package/src/server.ts
CHANGED
|
@@ -1147,13 +1147,33 @@ export async function startServer(options: ServerOptions): Promise<void> {
|
|
|
1147
1147
|
const store = createStore(effectiveDbPath);
|
|
1148
1148
|
const symbolGraphDb = new Database(effectiveDbPath);
|
|
1149
1149
|
|
|
1150
|
+
const validateInterval = (value: number | undefined, name: string, defaultVal: number): number => {
|
|
1151
|
+
if (value === undefined) return defaultVal;
|
|
1152
|
+
if (value <= 0 || value > 3600) {
|
|
1153
|
+
console.error(`[memory] Warning: intervals.${name}=${value} invalid (must be 1-3600), using default ${defaultVal}`);
|
|
1154
|
+
return defaultVal;
|
|
1155
|
+
}
|
|
1156
|
+
return value;
|
|
1157
|
+
};
|
|
1158
|
+
const validatedIntervals = config?.intervals ? {
|
|
1159
|
+
embed: validateInterval(config.intervals.embed, 'embed', 60),
|
|
1160
|
+
sessionPoll: validateInterval(config.intervals.sessionPoll, 'sessionPoll', 120),
|
|
1161
|
+
reindexPoll: validateInterval(config.intervals.reindexPoll, 'reindexPoll', 120),
|
|
1162
|
+
} : undefined;
|
|
1163
|
+
|
|
1150
1164
|
let vectorStore: VectorStore | null = null;
|
|
1165
|
+
const configuredDimensions = config?.vector?.dimensions;
|
|
1151
1166
|
if (config?.vector?.provider === 'qdrant') {
|
|
1152
1167
|
try {
|
|
1153
1168
|
vectorStore = createVectorStore(config.vector);
|
|
1154
1169
|
vectorStore.health().then((health) => {
|
|
1155
1170
|
log('server', 'vector provider=' + health.provider + ' ok=' + health.ok + ' vectors=' + health.vectorCount);
|
|
1156
1171
|
console.error(`[memory] Vector store: ${health.provider} (ok=${health.ok}, vectors=${health.vectorCount})`);
|
|
1172
|
+
if (health.dimensions && configuredDimensions && health.dimensions !== configuredDimensions) {
|
|
1173
|
+
console.error(`[memory] FATAL: Vector dimension mismatch! Configured=${configuredDimensions}, Qdrant collection=${health.dimensions}`);
|
|
1174
|
+
console.error(`[memory] Either update config.yml vector.dimensions or recreate the Qdrant collection.`);
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
}
|
|
1157
1177
|
}).catch((err) => {
|
|
1158
1178
|
log('server', 'vector health check failed error=' + (err instanceof Error ? err.message : String(err)));
|
|
1159
1179
|
console.error(`[memory] Vector store health check failed:`, err);
|
|
@@ -1215,9 +1235,9 @@ export async function startServer(options: ServerOptions): Promise<void> {
|
|
|
1215
1235
|
collections,
|
|
1216
1236
|
embedder: providers.embedder,
|
|
1217
1237
|
debounceMs: watcherConfig?.debounceMs ?? 2000,
|
|
1218
|
-
pollIntervalMs: watcherConfig?.pollIntervalMs ?? 120000,
|
|
1219
|
-
sessionPollMs: watcherConfig?.sessionPollMs ?? 120000,
|
|
1220
|
-
embedIntervalMs: watcherConfig?.embedIntervalMs ?? 60000,
|
|
1238
|
+
pollIntervalMs: validatedIntervals?.reindexPoll ? validatedIntervals.reindexPoll * 1000 : (watcherConfig?.pollIntervalMs ?? 120000),
|
|
1239
|
+
sessionPollMs: validatedIntervals?.sessionPoll ? validatedIntervals.sessionPoll * 1000 : (watcherConfig?.sessionPollMs ?? 120000),
|
|
1240
|
+
embedIntervalMs: validatedIntervals?.embed ? validatedIntervals.embed * 1000 : (watcherConfig?.embedIntervalMs ?? 60000),
|
|
1221
1241
|
sessionStorageDir: path.join(homeDir, '.local/share/opencode/storage'),
|
|
1222
1242
|
outputDir: path.join(outputDir, 'sessions'),
|
|
1223
1243
|
storageConfig,
|
package/src/store.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import * as sqliteVec from 'sqlite-vec';
|
|
3
3
|
import type { Store, Document, SearchResult, IndexHealth, StoreSearchOptions } from './types.js';
|
|
4
|
-
import type { VectorStore } from './vector-store.js';
|
|
4
|
+
import type { VectorStore, VectorPoint } from './vector-store.js';
|
|
5
|
+
import { SqliteVecStore } from './providers/sqlite-vec.js';
|
|
5
6
|
import * as fs from 'fs';
|
|
6
7
|
import * as path from 'path';
|
|
7
8
|
import * as crypto from 'crypto';
|
|
@@ -135,6 +136,8 @@ export function createStore(dbPath: string): Store {
|
|
|
135
136
|
);
|
|
136
137
|
CREATE INDEX IF NOT EXISTS idx_symbols_type_pattern ON symbols(type, pattern);
|
|
137
138
|
CREATE INDEX IF NOT EXISTS idx_symbols_repo ON symbols(repo);
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_file_project ON symbols(file_path, project_hash);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_documents_modified ON documents(modified_at) WHERE active = 1;
|
|
138
141
|
|
|
139
142
|
CREATE TABLE IF NOT EXISTS code_symbols (
|
|
140
143
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -286,10 +289,7 @@ export function createStore(dbPath: string): Store {
|
|
|
286
289
|
UPDATE documents SET active = 0 WHERE collection = ? AND path = ?
|
|
287
290
|
`);
|
|
288
291
|
|
|
289
|
-
|
|
290
|
-
UPDATE documents SET active = 0
|
|
291
|
-
WHERE collection = ? AND path NOT IN (SELECT value FROM json_each(?))
|
|
292
|
-
`);
|
|
292
|
+
|
|
293
293
|
|
|
294
294
|
const searchFTSStmt = db.prepare(`
|
|
295
295
|
SELECT
|
|
@@ -378,6 +378,7 @@ export function createStore(dbPath: string): Store {
|
|
|
378
378
|
JOIN documents d ON d.hash = c.hash AND d.active = 1
|
|
379
379
|
LEFT JOIN content_vectors cv ON cv.hash = c.hash
|
|
380
380
|
WHERE cv.hash IS NULL
|
|
381
|
+
LIMIT ?
|
|
381
382
|
`);
|
|
382
383
|
|
|
383
384
|
const getHashesNeedingEmbeddingByWorkspaceStmt = db.prepare(`
|
|
@@ -386,6 +387,7 @@ export function createStore(dbPath: string): Store {
|
|
|
386
387
|
JOIN documents d ON d.hash = c.hash AND d.active = 1
|
|
387
388
|
LEFT JOIN content_vectors cv ON cv.hash = c.hash
|
|
388
389
|
WHERE cv.hash IS NULL AND d.project_hash IN (?, 'global')
|
|
390
|
+
LIMIT ?
|
|
389
391
|
`);
|
|
390
392
|
const getNextHashNeedingEmbeddingStmt = db.prepare(`
|
|
391
393
|
SELECT c.hash, c.body, d.path
|
|
@@ -515,30 +517,67 @@ export function createStore(dbPath: string): Store {
|
|
|
515
517
|
},
|
|
516
518
|
|
|
517
519
|
bulkDeactivateExcept(collection: string, activePaths: string[]): number {
|
|
518
|
-
const
|
|
520
|
+
const beforeHashes = new Set<string>();
|
|
521
|
+
if (vectorStore) {
|
|
522
|
+
const rows = db.prepare('SELECT DISTINCT hash FROM documents WHERE collection = ? AND active = 1').all(collection) as Array<{ hash: string }>;
|
|
523
|
+
for (const r of rows) beforeHashes.add(r.hash);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
db.exec('CREATE TEMP TABLE IF NOT EXISTS _active_paths(path TEXT PRIMARY KEY)');
|
|
527
|
+
db.exec('DELETE FROM _active_paths');
|
|
528
|
+
const insertPath = db.prepare('INSERT OR IGNORE INTO _active_paths(path) VALUES(?)');
|
|
529
|
+
const insertMany = db.transaction((paths: string[]) => {
|
|
530
|
+
for (const p of paths) insertPath.run(p);
|
|
531
|
+
});
|
|
532
|
+
insertMany(activePaths);
|
|
533
|
+
const updateStmt = db.prepare('UPDATE documents SET active = 0 WHERE collection = ? AND path NOT IN (SELECT path FROM _active_paths)');
|
|
534
|
+
const result = updateStmt.run(collection);
|
|
535
|
+
db.exec('DROP TABLE IF EXISTS _active_paths');
|
|
536
|
+
|
|
537
|
+
if (vectorStore && beforeHashes.size > 0) {
|
|
538
|
+
const afterRows = db.prepare('SELECT DISTINCT hash FROM documents WHERE collection = ? AND active = 1').all(collection) as Array<{ hash: string }>;
|
|
539
|
+
const afterHashes = new Set(afterRows.map(r => r.hash));
|
|
540
|
+
for (const hash of beforeHashes) {
|
|
541
|
+
if (!afterHashes.has(hash)) {
|
|
542
|
+
vectorStore.deleteByHash(hash).catch(err => {
|
|
543
|
+
log('store', 'bulkDeactivateExcept vector cleanup failed hash=' + hash.substring(0, 8));
|
|
544
|
+
console.warn('[store] Failed to cleanup vector:', err);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
519
550
|
return result.changes;
|
|
520
551
|
},
|
|
521
552
|
|
|
522
|
-
insertEmbedding(hash: string, seq: number, pos: number, embedding: number[], model: string) {
|
|
553
|
+
insertEmbedding(hash: string, seq: number, pos: number, embedding: number[], model: string, externalVectorStore?: VectorStore) {
|
|
523
554
|
log('store', 'insertEmbedding hash=' + hash.substring(0, 8) + ' seq=' + seq);
|
|
524
555
|
insertEmbeddingStmt.run(hash, seq, pos, model);
|
|
525
556
|
|
|
526
|
-
|
|
557
|
+
const useExternalStore = externalVectorStore && !(externalVectorStore instanceof SqliteVecStore);
|
|
558
|
+
|
|
559
|
+
if (useExternalStore) {
|
|
560
|
+
const point: VectorPoint = {
|
|
561
|
+
id: `${hash}:${seq}`,
|
|
562
|
+
embedding,
|
|
563
|
+
metadata: { hash, seq, pos, model },
|
|
564
|
+
};
|
|
565
|
+
externalVectorStore.upsert(point).catch((err) => {
|
|
566
|
+
log('store', 'insertEmbedding external vector store upsert failed hash=' + hash.substring(0, 8));
|
|
567
|
+
console.warn(`[store] External vector store upsert failed for ${hash.substring(0, 8)}:${seq}, will retry on next embedding cycle:`, err);
|
|
568
|
+
});
|
|
569
|
+
} else if (vecAvailable) {
|
|
527
570
|
try {
|
|
528
571
|
const hashSeq = `${hash}:${seq}`;
|
|
529
|
-
// sqlite-vec virtual tables don't support INSERT OR REPLACE,
|
|
530
|
-
// so delete first then insert to handle re-embedding
|
|
531
572
|
try {
|
|
532
573
|
db.prepare(`DELETE FROM vectors_vec WHERE hash_seq = ?`).run(hashSeq);
|
|
533
574
|
} catch {
|
|
534
|
-
// Ignore if row doesn't exist
|
|
535
575
|
}
|
|
536
576
|
const insertVecStmt = db.prepare(`
|
|
537
577
|
INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)
|
|
538
578
|
`);
|
|
539
579
|
insertVecStmt.run(hashSeq, new Float32Array(embedding));
|
|
540
580
|
} catch (err) {
|
|
541
|
-
// Silently ignore duplicate key errors (vector already exists)
|
|
542
581
|
const msg = err instanceof Error ? err.message : String(err);
|
|
543
582
|
if (!msg.includes('UNIQUE constraint')) {
|
|
544
583
|
log('store', 'insertEmbedding vector insert failed hash=' + hash.substring(0, 8));
|
|
@@ -733,6 +772,19 @@ export function createStore(dbPath: string): Store {
|
|
|
733
772
|
vectorStore = vs;
|
|
734
773
|
},
|
|
735
774
|
|
|
775
|
+
getVectorStore(): VectorStore | null {
|
|
776
|
+
return vectorStore;
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
cleanupVectorsForHash(hash: string): void {
|
|
780
|
+
if (vectorStore) {
|
|
781
|
+
vectorStore.deleteByHash(hash).catch(err => {
|
|
782
|
+
log('store', 'cleanupVectorsForHash failed hash=' + hash.substring(0, 8));
|
|
783
|
+
console.warn('[store] Failed to cleanup vectors for hash:', err);
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
|
|
736
788
|
async searchVecAsync(query: string, embedding: number[], options: StoreSearchOptions = {}): Promise<SearchResult[]> {
|
|
737
789
|
const { limit = 10, collection, projectHash, tags, since, until } = options;
|
|
738
790
|
|
|
@@ -828,7 +880,7 @@ export function createStore(dbPath: string): Store {
|
|
|
828
880
|
const docCount = (getDocumentCountStmt.get() as { count: number }).count;
|
|
829
881
|
const embeddedCount = (getEmbeddedCountStmt.get() as { count: number }).count;
|
|
830
882
|
const collections = getCollectionStatsStmt.all() as Array<{ name: string; documentCount: number; path: string }>;
|
|
831
|
-
const pending = (getHashesNeedingEmbeddingStmt.all() as unknown[]).length;
|
|
883
|
+
const pending = (getHashesNeedingEmbeddingStmt.all(1000000) as unknown[]).length;
|
|
832
884
|
const workspaceStats = this.getWorkspaceStats();
|
|
833
885
|
|
|
834
886
|
let dbSize = 0;
|
|
@@ -850,11 +902,12 @@ export function createStore(dbPath: string): Store {
|
|
|
850
902
|
};
|
|
851
903
|
},
|
|
852
904
|
|
|
853
|
-
getHashesNeedingEmbedding(projectHash?: string): Array<{ hash: string; body: string; path: string }> {
|
|
905
|
+
getHashesNeedingEmbedding(projectHash?: string, limit?: number): Array<{ hash: string; body: string; path: string }> {
|
|
906
|
+
const effectiveLimit = limit ?? 1000000;
|
|
854
907
|
if (projectHash && projectHash !== 'all') {
|
|
855
|
-
return getHashesNeedingEmbeddingByWorkspaceStmt.all(projectHash) as Array<{ hash: string; body: string; path: string }>;
|
|
908
|
+
return getHashesNeedingEmbeddingByWorkspaceStmt.all(projectHash, effectiveLimit) as Array<{ hash: string; body: string; path: string }>;
|
|
856
909
|
}
|
|
857
|
-
return getHashesNeedingEmbeddingStmt.all() as Array<{ hash: string; body: string; path: string }>;
|
|
910
|
+
return getHashesNeedingEmbeddingStmt.all(effectiveLimit) as Array<{ hash: string; body: string; path: string }>;
|
|
858
911
|
},
|
|
859
912
|
|
|
860
913
|
getNextHashNeedingEmbedding(projectHash?: string): { hash: string; body: string; path: string } | null {
|
|
@@ -928,6 +981,13 @@ export function createStore(dbPath: string): Store {
|
|
|
928
981
|
cleanOrphanedEmbeddings(): number {
|
|
929
982
|
let totalDeleted = 0;
|
|
930
983
|
|
|
984
|
+
let orphanedHashes: string[] = [];
|
|
985
|
+
if (vectorStore) {
|
|
986
|
+
orphanedHashes = (db.prepare(`
|
|
987
|
+
SELECT DISTINCT hash FROM content_vectors WHERE hash NOT IN (SELECT DISTINCT hash FROM documents WHERE active = 1)
|
|
988
|
+
`).all() as Array<{ hash: string }>).map(r => r.hash);
|
|
989
|
+
}
|
|
990
|
+
|
|
931
991
|
const deleteContentVectorsStmt = db.prepare(`
|
|
932
992
|
DELETE FROM content_vectors WHERE hash NOT IN (SELECT DISTINCT hash FROM documents WHERE active = 1)
|
|
933
993
|
`);
|
|
@@ -945,6 +1005,16 @@ export function createStore(dbPath: string): Store {
|
|
|
945
1005
|
}
|
|
946
1006
|
}
|
|
947
1007
|
|
|
1008
|
+
if (vectorStore && orphanedHashes.length > 0) {
|
|
1009
|
+
for (const hash of orphanedHashes) {
|
|
1010
|
+
vectorStore.deleteByHash(hash).catch(err => {
|
|
1011
|
+
log('store', 'cleanOrphanedEmbeddings vector cleanup failed hash=' + hash.substring(0, 8));
|
|
1012
|
+
console.warn('[store] Failed to cleanup orphaned vector:', err);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
log('store', 'cleanOrphanedEmbeddings queued ' + orphanedHashes.length + ' vector store deletes');
|
|
1016
|
+
}
|
|
1017
|
+
|
|
948
1018
|
log('store', 'cleanOrphanedEmbeddings deleted=' + totalDeleted);
|
|
949
1019
|
return totalDeleted;
|
|
950
1020
|
},
|
|
@@ -1269,6 +1339,10 @@ export function indexDocument(
|
|
|
1269
1339
|
return { hash, chunks: 0, skipped: true };
|
|
1270
1340
|
}
|
|
1271
1341
|
|
|
1342
|
+
if (existingDoc && existingDoc.hash !== hash) {
|
|
1343
|
+
store.cleanupVectorsForHash(existingDoc.hash);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1272
1346
|
store.insertContent(hash, content);
|
|
1273
1347
|
|
|
1274
1348
|
const chunks = chunkMarkdown(content, hash);
|
package/src/types.ts
CHANGED
|
@@ -85,6 +85,11 @@ export interface CollectionConfig {
|
|
|
85
85
|
collection?: string
|
|
86
86
|
dimensions?: number
|
|
87
87
|
}
|
|
88
|
+
intervals?: {
|
|
89
|
+
embed?: number
|
|
90
|
+
sessionPoll?: number
|
|
91
|
+
reindexPoll?: number
|
|
92
|
+
}
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
export interface CodebaseConfig {
|
|
@@ -255,13 +260,14 @@ export interface Store {
|
|
|
255
260
|
|
|
256
261
|
insertContent(hash: string, body: string): void;
|
|
257
262
|
|
|
258
|
-
insertEmbedding(hash: string, seq: number, pos: number, embedding: number[], model: string): void;
|
|
263
|
+
insertEmbedding(hash: string, seq: number, pos: number, embedding: number[], model: string, vectorStore?: import('./vector-store.js').VectorStore): void;
|
|
259
264
|
ensureVecTable(dimensions: number): void;
|
|
260
265
|
|
|
261
266
|
searchFTS(query: string, options?: StoreSearchOptions): SearchResult[];
|
|
262
267
|
searchVec(query: string, embedding: number[], options?: StoreSearchOptions): SearchResult[];
|
|
263
268
|
searchVecAsync(query: string, embedding: number[], options?: StoreSearchOptions): Promise<SearchResult[]>;
|
|
264
269
|
setVectorStore(vs: import('./vector-store.js').VectorStore): void;
|
|
270
|
+
getVectorStore(): import('./vector-store.js').VectorStore | null;
|
|
265
271
|
|
|
266
272
|
getCachedResult(hash: string, projectHash?: string): string | null;
|
|
267
273
|
setCachedResult(hash: string, result: string, projectHash?: string, type?: string): void;
|
|
@@ -273,7 +279,7 @@ export interface Store {
|
|
|
273
279
|
clearQueryEmbeddingCache(): void;
|
|
274
280
|
|
|
275
281
|
getIndexHealth(): IndexHealth;
|
|
276
|
-
getHashesNeedingEmbedding(projectHash?: string): Array<{ hash: string; body: string; path: string }>;
|
|
282
|
+
getHashesNeedingEmbedding(projectHash?: string, limit?: number): Array<{ hash: string; body: string; path: string }>;
|
|
277
283
|
getNextHashNeedingEmbedding(projectHash?: string): { hash: string; body: string; path: string } | null;
|
|
278
284
|
getWorkspaceStats(): Array<{ projectHash: string; count: number }>;
|
|
279
285
|
|
|
@@ -347,4 +353,6 @@ export interface Store {
|
|
|
347
353
|
filePath: string;
|
|
348
354
|
lineNumber: number;
|
|
349
355
|
}>;
|
|
356
|
+
|
|
357
|
+
cleanupVectorsForHash(hash: string): void;
|
|
350
358
|
}
|
package/src/watcher.ts
CHANGED
|
@@ -76,8 +76,11 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
76
76
|
let watcher: FSWatcher | null = null;
|
|
77
77
|
let harvestCycleCount = 0;
|
|
78
78
|
const watchedPaths = new Set<string>();
|
|
79
|
-
let
|
|
79
|
+
let embeddingTimeout: NodeJS.Timeout | null = null;
|
|
80
80
|
let isEmbedding = false;
|
|
81
|
+
let consecutiveEmptyCycles = 0;
|
|
82
|
+
let consecutiveFailures = 0;
|
|
83
|
+
let currentEmbedInterval = embedIntervalMs;
|
|
81
84
|
|
|
82
85
|
const handleFileChange = (filePath: string) => {
|
|
83
86
|
if (stopped) return
|
|
@@ -296,21 +299,42 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
296
299
|
}, sessionPollMs);
|
|
297
300
|
|
|
298
301
|
if (embedder) {
|
|
299
|
-
|
|
300
|
-
if (stopped
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
log('watcher', 'Embedding cycle: ' + count + ' document(s) embedded')
|
|
306
|
-
console.log(`[embed] Embedded ${count} document(s)`);
|
|
302
|
+
const scheduleNextEmbedCycle = () => {
|
|
303
|
+
if (stopped) return;
|
|
304
|
+
embeddingTimeout = setTimeout(async () => {
|
|
305
|
+
if (stopped || isEmbedding) {
|
|
306
|
+
scheduleNextEmbedCycle();
|
|
307
|
+
return;
|
|
307
308
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
309
|
+
isEmbedding = true;
|
|
310
|
+
try {
|
|
311
|
+
const count = await embedPendingCodebase(store, embedder, 50, projectHash);
|
|
312
|
+
if (count > 0) {
|
|
313
|
+
log('watcher', 'Embedding cycle: ' + count + ' document(s) embedded')
|
|
314
|
+
console.log(`[embed] Embedded ${count} document(s)`);
|
|
315
|
+
consecutiveEmptyCycles = 0;
|
|
316
|
+
currentEmbedInterval = embedIntervalMs;
|
|
317
|
+
} else {
|
|
318
|
+
consecutiveEmptyCycles++;
|
|
319
|
+
if (consecutiveEmptyCycles >= 3) {
|
|
320
|
+
currentEmbedInterval = Math.min(currentEmbedInterval * 1.5, 300000);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
consecutiveFailures = 0;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
consecutiveFailures++;
|
|
326
|
+
if (consecutiveFailures >= 5) {
|
|
327
|
+
console.warn(`[embed] WARNING: ${consecutiveFailures} consecutive embedding failures. Check embedding provider configuration. Last error:`, err);
|
|
328
|
+
} else {
|
|
329
|
+
console.warn('[embed] Embedding cycle failed:', err);
|
|
330
|
+
}
|
|
331
|
+
} finally {
|
|
332
|
+
isEmbedding = false;
|
|
333
|
+
scheduleNextEmbedCycle();
|
|
334
|
+
}
|
|
335
|
+
}, currentEmbedInterval);
|
|
336
|
+
};
|
|
337
|
+
scheduleNextEmbedCycle();
|
|
314
338
|
}
|
|
315
339
|
};
|
|
316
340
|
|
|
@@ -355,9 +379,9 @@ export function startWatcher(options: WatcherOptions): Watcher {
|
|
|
355
379
|
sessionPollInterval = null;
|
|
356
380
|
}
|
|
357
381
|
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
382
|
+
if (embeddingTimeout) {
|
|
383
|
+
clearTimeout(embeddingTimeout);
|
|
384
|
+
embeddingTimeout = null;
|
|
361
385
|
}
|
|
362
386
|
|
|
363
387
|
if (watcher) {
|
package/test/store.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { createStore, computeHash, indexDocument, extractProjectHashFromPath } from '../src/store.js';
|
|
3
3
|
import type { Store } from '../src/types.js';
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
@@ -867,4 +867,309 @@ describe('Store', () => {
|
|
|
867
867
|
});
|
|
868
868
|
});
|
|
869
869
|
|
|
870
|
+
describe('Qdrant migration features', () => {
|
|
871
|
+
describe('getVectorStore/setVectorStore', () => {
|
|
872
|
+
it('should return null when no vector store is set', () => {
|
|
873
|
+
expect(store.getVectorStore()).toBeNull();
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('should set and get vector store correctly', () => {
|
|
877
|
+
const mockVectorStore = {
|
|
878
|
+
upsert: async () => {},
|
|
879
|
+
batchUpsert: async () => {},
|
|
880
|
+
search: async () => [],
|
|
881
|
+
delete: async () => {},
|
|
882
|
+
deleteByHash: async () => {},
|
|
883
|
+
health: async () => ({ ok: true, provider: 'mock', vectorCount: 0 }),
|
|
884
|
+
close: async () => {},
|
|
885
|
+
};
|
|
886
|
+
store.setVectorStore(mockVectorStore as unknown as import('../src/vector-store.js').VectorStore);
|
|
887
|
+
expect(store.getVectorStore()).toBe(mockVectorStore);
|
|
888
|
+
});
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
describe('insertEmbedding with external vector store', () => {
|
|
892
|
+
it('should route to external vector store when non-SqliteVecStore is provided', async () => {
|
|
893
|
+
const body = '# Test Content';
|
|
894
|
+
const hash = computeHash(body);
|
|
895
|
+
store.insertContent(hash, body);
|
|
896
|
+
store.insertDocument({
|
|
897
|
+
collection: 'test',
|
|
898
|
+
path: 'test/doc.md',
|
|
899
|
+
title: 'Test',
|
|
900
|
+
hash,
|
|
901
|
+
createdAt: new Date().toISOString(),
|
|
902
|
+
modifiedAt: new Date().toISOString(),
|
|
903
|
+
active: true,
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
const mockVectorStore = {
|
|
907
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
908
|
+
batchUpsert: vi.fn().mockResolvedValue(undefined),
|
|
909
|
+
search: vi.fn().mockResolvedValue([]),
|
|
910
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
911
|
+
deleteByHash: vi.fn().mockResolvedValue(undefined),
|
|
912
|
+
health: vi.fn().mockResolvedValue({ ok: true, provider: 'mock', vectorCount: 0 }),
|
|
913
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const embedding = new Array(768).fill(0.1);
|
|
917
|
+
store.insertEmbedding(hash, 0, 0, embedding, 'test-model', mockVectorStore as unknown as import('../src/vector-store.js').VectorStore);
|
|
918
|
+
|
|
919
|
+
await vi.waitFor(() => expect(mockVectorStore.upsert).toHaveBeenCalled());
|
|
920
|
+
expect(mockVectorStore.upsert).toHaveBeenCalledWith({
|
|
921
|
+
id: `${hash}:0`,
|
|
922
|
+
embedding,
|
|
923
|
+
metadata: { hash, seq: 0, pos: 0, model: 'test-model' },
|
|
924
|
+
});
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('should fall back to sqlite-vec when no externalVectorStore is provided', () => {
|
|
928
|
+
const body = '# Fallback Content';
|
|
929
|
+
const hash = computeHash(body);
|
|
930
|
+
store.insertContent(hash, body);
|
|
931
|
+
store.insertDocument({
|
|
932
|
+
collection: 'test',
|
|
933
|
+
path: 'test/fallback.md',
|
|
934
|
+
title: 'Fallback',
|
|
935
|
+
hash,
|
|
936
|
+
createdAt: new Date().toISOString(),
|
|
937
|
+
modifiedAt: new Date().toISOString(),
|
|
938
|
+
active: true,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
const embedding = new Array(768).fill(0.2);
|
|
942
|
+
store.insertEmbedding(hash, 0, 0, embedding, 'test-model');
|
|
943
|
+
|
|
944
|
+
const db = new Database(dbPath);
|
|
945
|
+
const row = db.prepare('SELECT * FROM content_vectors WHERE hash = ?').get(hash);
|
|
946
|
+
expect(row).toBeDefined();
|
|
947
|
+
db.close();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('should always write content_vectors tracking row regardless of vector store', async () => {
|
|
951
|
+
const body = '# Tracking Test';
|
|
952
|
+
const hash = computeHash(body);
|
|
953
|
+
store.insertContent(hash, body);
|
|
954
|
+
store.insertDocument({
|
|
955
|
+
collection: 'test',
|
|
956
|
+
path: 'test/tracking.md',
|
|
957
|
+
title: 'Tracking',
|
|
958
|
+
hash,
|
|
959
|
+
createdAt: new Date().toISOString(),
|
|
960
|
+
modifiedAt: new Date().toISOString(),
|
|
961
|
+
active: true,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
const mockVectorStore = {
|
|
965
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
966
|
+
batchUpsert: vi.fn().mockResolvedValue(undefined),
|
|
967
|
+
search: vi.fn().mockResolvedValue([]),
|
|
968
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
969
|
+
deleteByHash: vi.fn().mockResolvedValue(undefined),
|
|
970
|
+
health: vi.fn().mockResolvedValue({ ok: true, provider: 'mock', vectorCount: 0 }),
|
|
971
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const embedding = new Array(768).fill(0.3);
|
|
975
|
+
store.insertEmbedding(hash, 0, 0, embedding, 'test-model', mockVectorStore as unknown as import('../src/vector-store.js').VectorStore);
|
|
976
|
+
|
|
977
|
+
const db = new Database(dbPath);
|
|
978
|
+
const row = db.prepare('SELECT * FROM content_vectors WHERE hash = ?').get(hash);
|
|
979
|
+
expect(row).toBeDefined();
|
|
980
|
+
db.close();
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
describe('cleanupVectorsForHash', () => {
|
|
985
|
+
it('should call vectorStore.deleteByHash fire-and-forget when vectorStore is set', async () => {
|
|
986
|
+
const mockVectorStore = {
|
|
987
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
988
|
+
batchUpsert: vi.fn().mockResolvedValue(undefined),
|
|
989
|
+
search: vi.fn().mockResolvedValue([]),
|
|
990
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
991
|
+
deleteByHash: vi.fn().mockResolvedValue(undefined),
|
|
992
|
+
health: vi.fn().mockResolvedValue({ ok: true, provider: 'mock', vectorCount: 0 }),
|
|
993
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
store.setVectorStore(mockVectorStore as unknown as import('../src/vector-store.js').VectorStore);
|
|
997
|
+
store.cleanupVectorsForHash('abc123');
|
|
998
|
+
|
|
999
|
+
await vi.waitFor(() => expect(mockVectorStore.deleteByHash).toHaveBeenCalled());
|
|
1000
|
+
expect(mockVectorStore.deleteByHash).toHaveBeenCalledWith('abc123');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('should not throw when no vectorStore is set', () => {
|
|
1004
|
+
expect(() => store.cleanupVectorsForHash('abc123')).not.toThrow();
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe('cleanOrphanedEmbeddings with vectorStore', () => {
|
|
1009
|
+
it('should collect orphan hashes before delete and call vectorStore.deleteByHash', async () => {
|
|
1010
|
+
const body = '# Orphan Content';
|
|
1011
|
+
const hash = computeHash(body);
|
|
1012
|
+
store.insertContent(hash, body);
|
|
1013
|
+
const docId = store.insertDocument({
|
|
1014
|
+
collection: 'test',
|
|
1015
|
+
path: 'test/orphan.md',
|
|
1016
|
+
title: 'Orphan',
|
|
1017
|
+
hash,
|
|
1018
|
+
createdAt: new Date().toISOString(),
|
|
1019
|
+
modifiedAt: new Date().toISOString(),
|
|
1020
|
+
active: true,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
const embedding = new Array(768).fill(0.4);
|
|
1024
|
+
store.insertEmbedding(hash, 0, 0, embedding, 'test-model');
|
|
1025
|
+
|
|
1026
|
+
store.deactivateDocument('test', 'test/orphan.md');
|
|
1027
|
+
|
|
1028
|
+
const mockVectorStore = {
|
|
1029
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
1030
|
+
batchUpsert: vi.fn().mockResolvedValue(undefined),
|
|
1031
|
+
search: vi.fn().mockResolvedValue([]),
|
|
1032
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
1033
|
+
deleteByHash: vi.fn().mockResolvedValue(undefined),
|
|
1034
|
+
health: vi.fn().mockResolvedValue({ ok: true, provider: 'mock', vectorCount: 0 }),
|
|
1035
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
store.setVectorStore(mockVectorStore as unknown as import('../src/vector-store.js').VectorStore);
|
|
1039
|
+
const deleted = store.cleanOrphanedEmbeddings();
|
|
1040
|
+
|
|
1041
|
+
expect(deleted).toBeGreaterThan(0);
|
|
1042
|
+
await vi.waitFor(() => expect(mockVectorStore.deleteByHash).toHaveBeenCalled());
|
|
1043
|
+
expect(mockVectorStore.deleteByHash).toHaveBeenCalledWith(hash);
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
describe('bulkDeactivateExcept with vectorStore', () => {
|
|
1048
|
+
it('should compute before/after hash diff and delete orphaned vectors', async () => {
|
|
1049
|
+
const body1 = '# Keep Content';
|
|
1050
|
+
const hash1 = computeHash(body1);
|
|
1051
|
+
store.insertContent(hash1, body1);
|
|
1052
|
+
store.insertDocument({
|
|
1053
|
+
collection: 'bulk-test',
|
|
1054
|
+
path: 'keep.md',
|
|
1055
|
+
title: 'Keep',
|
|
1056
|
+
hash: hash1,
|
|
1057
|
+
createdAt: new Date().toISOString(),
|
|
1058
|
+
modifiedAt: new Date().toISOString(),
|
|
1059
|
+
active: true,
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const body2 = '# Remove Content';
|
|
1063
|
+
const hash2 = computeHash(body2);
|
|
1064
|
+
store.insertContent(hash2, body2);
|
|
1065
|
+
store.insertDocument({
|
|
1066
|
+
collection: 'bulk-test',
|
|
1067
|
+
path: 'remove.md',
|
|
1068
|
+
title: 'Remove',
|
|
1069
|
+
hash: hash2,
|
|
1070
|
+
createdAt: new Date().toISOString(),
|
|
1071
|
+
modifiedAt: new Date().toISOString(),
|
|
1072
|
+
active: true,
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
const mockVectorStore = {
|
|
1076
|
+
upsert: vi.fn().mockResolvedValue(undefined),
|
|
1077
|
+
batchUpsert: vi.fn().mockResolvedValue(undefined),
|
|
1078
|
+
search: vi.fn().mockResolvedValue([]),
|
|
1079
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
1080
|
+
deleteByHash: vi.fn().mockResolvedValue(undefined),
|
|
1081
|
+
health: vi.fn().mockResolvedValue({ ok: true, provider: 'mock', vectorCount: 0 }),
|
|
1082
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
store.setVectorStore(mockVectorStore as unknown as import('../src/vector-store.js').VectorStore);
|
|
1086
|
+
const deactivated = store.bulkDeactivateExcept('bulk-test', ['keep.md']);
|
|
1087
|
+
|
|
1088
|
+
expect(deactivated).toBe(1);
|
|
1089
|
+
await vi.waitFor(() => expect(mockVectorStore.deleteByHash).toHaveBeenCalled());
|
|
1090
|
+
expect(mockVectorStore.deleteByHash).toHaveBeenCalledWith(hash2);
|
|
1091
|
+
expect(mockVectorStore.deleteByHash).not.toHaveBeenCalledWith(hash1);
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
describe('new indexes', () => {
|
|
1096
|
+
it('should create idx_symbols_file_project index after createStore()', () => {
|
|
1097
|
+
const db = new Database(dbPath);
|
|
1098
|
+
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_symbols_file_project'").all();
|
|
1099
|
+
expect(indexes.length).toBe(1);
|
|
1100
|
+
db.close();
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it('should create idx_documents_modified index after createStore()', () => {
|
|
1104
|
+
const db = new Database(dbPath);
|
|
1105
|
+
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_documents_modified'").all();
|
|
1106
|
+
expect(indexes.length).toBe(1);
|
|
1107
|
+
db.close();
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
describe('getHashesNeedingEmbedding', () => {
|
|
1112
|
+
it('should respect LIMIT param', () => {
|
|
1113
|
+
for (let i = 0; i < 5; i++) {
|
|
1114
|
+
const body = `# Content ${i}`;
|
|
1115
|
+
const hash = computeHash(body);
|
|
1116
|
+
store.insertContent(hash, body);
|
|
1117
|
+
store.insertDocument({
|
|
1118
|
+
collection: 'limit-test',
|
|
1119
|
+
path: `doc${i}.md`,
|
|
1120
|
+
title: `Doc ${i}`,
|
|
1121
|
+
hash,
|
|
1122
|
+
createdAt: new Date().toISOString(),
|
|
1123
|
+
modifiedAt: new Date().toISOString(),
|
|
1124
|
+
active: true,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const limited = store.getHashesNeedingEmbedding(undefined, 2);
|
|
1129
|
+
expect(limited.length).toBe(2);
|
|
1130
|
+
|
|
1131
|
+
const all = store.getHashesNeedingEmbedding(undefined, 100);
|
|
1132
|
+
expect(all.length).toBe(5);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('should filter by projectHash when provided', () => {
|
|
1136
|
+
const body1 = '# Project A Content';
|
|
1137
|
+
const hash1 = computeHash(body1);
|
|
1138
|
+
store.insertContent(hash1, body1);
|
|
1139
|
+
store.insertDocument({
|
|
1140
|
+
collection: 'project-test',
|
|
1141
|
+
path: 'a.md',
|
|
1142
|
+
title: 'A',
|
|
1143
|
+
hash: hash1,
|
|
1144
|
+
createdAt: new Date().toISOString(),
|
|
1145
|
+
modifiedAt: new Date().toISOString(),
|
|
1146
|
+
active: true,
|
|
1147
|
+
projectHash: 'project_a',
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const body2 = '# Project B Content';
|
|
1151
|
+
const hash2 = computeHash(body2);
|
|
1152
|
+
store.insertContent(hash2, body2);
|
|
1153
|
+
store.insertDocument({
|
|
1154
|
+
collection: 'project-test',
|
|
1155
|
+
path: 'b.md',
|
|
1156
|
+
title: 'B',
|
|
1157
|
+
hash: hash2,
|
|
1158
|
+
createdAt: new Date().toISOString(),
|
|
1159
|
+
modifiedAt: new Date().toISOString(),
|
|
1160
|
+
active: true,
|
|
1161
|
+
projectHash: 'project_b',
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
const projectA = store.getHashesNeedingEmbedding('project_a');
|
|
1165
|
+
expect(projectA.length).toBe(1);
|
|
1166
|
+
expect(projectA[0].hash).toBe(hash1);
|
|
1167
|
+
|
|
1168
|
+
const projectB = store.getHashesNeedingEmbedding('project_b');
|
|
1169
|
+
expect(projectB.length).toBe(1);
|
|
1170
|
+
expect(projectB[0].hash).toBe(hash2);
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
});
|
|
1174
|
+
|
|
870
1175
|
});
|
package/test/watcher.test.ts
CHANGED
|
@@ -66,6 +66,15 @@ describe('Watcher', () => {
|
|
|
66
66
|
insertTags: vi.fn(),
|
|
67
67
|
getDocumentTags: vi.fn().mockReturnValue([]),
|
|
68
68
|
listAllTags: vi.fn().mockReturnValue([]),
|
|
69
|
+
getVectorStore: vi.fn().mockReturnValue(null),
|
|
70
|
+
setVectorStore: vi.fn(),
|
|
71
|
+
cleanupVectorsForHash: vi.fn(),
|
|
72
|
+
searchVecAsync: vi.fn().mockResolvedValue([]),
|
|
73
|
+
getFileDependencies: vi.fn().mockReturnValue([]),
|
|
74
|
+
getFileDependents: vi.fn().mockReturnValue([]),
|
|
75
|
+
getDocumentCentrality: vi.fn().mockReturnValue(null),
|
|
76
|
+
getClusterMembers: vi.fn().mockReturnValue([]),
|
|
77
|
+
getGraphStats: vi.fn().mockReturnValue({ nodeCount: 0, edgeCount: 0, clusterCount: 0, topCentrality: [] }),
|
|
69
78
|
} as unknown as Store;
|
|
70
79
|
|
|
71
80
|
collections = [
|
|
@@ -619,10 +628,10 @@ describe('Watcher', () => {
|
|
|
619
628
|
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
620
629
|
};
|
|
621
630
|
|
|
622
|
-
// embedPendingCodebase calls
|
|
623
|
-
vi.mocked(mockStore.
|
|
624
|
-
.mockReturnValueOnce({ hash: 'abc123', body: 'Content to embed', path: testFile })
|
|
625
|
-
.mockReturnValue(
|
|
631
|
+
// embedPendingCodebase calls getHashesNeedingEmbedding in a loop
|
|
632
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding)
|
|
633
|
+
.mockReturnValueOnce([{ hash: 'abc123', body: 'Content to embed', path: testFile }])
|
|
634
|
+
.mockReturnValue([]);
|
|
626
635
|
|
|
627
636
|
const watcher = startWatcher({
|
|
628
637
|
store: mockStore,
|
|
@@ -738,4 +747,290 @@ describe('Watcher', () => {
|
|
|
738
747
|
watcher.stop();
|
|
739
748
|
});
|
|
740
749
|
});
|
|
750
|
+
|
|
751
|
+
describe('adaptive embedding backoff', () => {
|
|
752
|
+
beforeEach(() => {
|
|
753
|
+
vi.useFakeTimers();
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
afterEach(() => {
|
|
757
|
+
vi.useRealTimers();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('should increase embedding interval after 3 consecutive empty cycles (×1.5 multiplier)', async () => {
|
|
761
|
+
const mockEmbedder = {
|
|
762
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([]);
|
|
766
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue(null);
|
|
767
|
+
|
|
768
|
+
const watcher = startWatcher({
|
|
769
|
+
store: mockStore,
|
|
770
|
+
collections,
|
|
771
|
+
embedder: mockEmbedder,
|
|
772
|
+
embedIntervalMs: 1000,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
776
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
777
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
778
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
779
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
780
|
+
|
|
781
|
+
watcher.stop();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('should cap interval at 300000ms (5 minutes)', async () => {
|
|
785
|
+
const mockEmbedder = {
|
|
786
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([]);
|
|
790
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue(null);
|
|
791
|
+
|
|
792
|
+
const watcher = startWatcher({
|
|
793
|
+
store: mockStore,
|
|
794
|
+
collections,
|
|
795
|
+
embedder: mockEmbedder,
|
|
796
|
+
embedIntervalMs: 200000,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
800
|
+
for (let i = 0; i < 10; i++) {
|
|
801
|
+
await vi.advanceTimersByTimeAsync(300000);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
watcher.stop();
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should snap back to base interval when work is detected (count > 0)', async () => {
|
|
808
|
+
const mockEmbedder = {
|
|
809
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding)
|
|
813
|
+
.mockReturnValueOnce([])
|
|
814
|
+
.mockReturnValueOnce([])
|
|
815
|
+
.mockReturnValueOnce([])
|
|
816
|
+
.mockReturnValueOnce([{ hash: 'abc123', body: 'Content', path: '/test.md' }])
|
|
817
|
+
.mockReturnValue([]);
|
|
818
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding)
|
|
819
|
+
.mockReturnValueOnce(null)
|
|
820
|
+
.mockReturnValueOnce(null)
|
|
821
|
+
.mockReturnValueOnce(null)
|
|
822
|
+
.mockReturnValueOnce({ hash: 'abc123', body: 'Content', path: '/test.md' })
|
|
823
|
+
.mockReturnValue(null);
|
|
824
|
+
|
|
825
|
+
const watcher = startWatcher({
|
|
826
|
+
store: mockStore,
|
|
827
|
+
collections,
|
|
828
|
+
embedder: mockEmbedder,
|
|
829
|
+
embedIntervalMs: 1000,
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
833
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
834
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
835
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
836
|
+
await vi.advanceTimersByTimeAsync(1500);
|
|
837
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
838
|
+
|
|
839
|
+
watcher.stop();
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('should reset consecutiveEmptyCycles when work is detected', async () => {
|
|
843
|
+
const mockEmbedder = {
|
|
844
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding)
|
|
848
|
+
.mockReturnValueOnce([])
|
|
849
|
+
.mockReturnValueOnce([])
|
|
850
|
+
.mockReturnValueOnce([{ hash: 'abc123', body: 'Content', path: '/test.md' }])
|
|
851
|
+
.mockReturnValue([]);
|
|
852
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding)
|
|
853
|
+
.mockReturnValueOnce(null)
|
|
854
|
+
.mockReturnValueOnce(null)
|
|
855
|
+
.mockReturnValueOnce({ hash: 'abc123', body: 'Content', path: '/test.md' })
|
|
856
|
+
.mockReturnValue(null);
|
|
857
|
+
|
|
858
|
+
const watcher = startWatcher({
|
|
859
|
+
store: mockStore,
|
|
860
|
+
collections,
|
|
861
|
+
embedder: mockEmbedder,
|
|
862
|
+
embedIntervalMs: 1000,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
866
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
867
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
868
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
869
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
870
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
871
|
+
|
|
872
|
+
watcher.stop();
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it('should increment consecutiveFailures on error', async () => {
|
|
876
|
+
const mockEmbedder = {
|
|
877
|
+
embed: vi.fn().mockRejectedValue(new Error('Embedding failed')),
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([
|
|
881
|
+
{ hash: 'abc123', body: 'Content', path: '/test.md' },
|
|
882
|
+
]);
|
|
883
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue({
|
|
884
|
+
hash: 'abc123',
|
|
885
|
+
body: 'Content',
|
|
886
|
+
path: '/test.md',
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
890
|
+
|
|
891
|
+
const watcher = startWatcher({
|
|
892
|
+
store: mockStore,
|
|
893
|
+
collections,
|
|
894
|
+
embedder: mockEmbedder,
|
|
895
|
+
embedIntervalMs: 1000,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
899
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
900
|
+
|
|
901
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
902
|
+
expect.stringContaining('[embed]'),
|
|
903
|
+
expect.any(Error)
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
watcher.stop();
|
|
907
|
+
consoleWarnSpy.mockRestore();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('should log warning after 5 consecutive failures', async () => {
|
|
911
|
+
const mockEmbedder = {
|
|
912
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockImplementation(() => {
|
|
916
|
+
throw new Error('Database connection failed');
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
920
|
+
|
|
921
|
+
const watcher = startWatcher({
|
|
922
|
+
store: mockStore,
|
|
923
|
+
collections,
|
|
924
|
+
embedder: mockEmbedder,
|
|
925
|
+
embedIntervalMs: 10,
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
for (let i = 0; i < 60; i++) {
|
|
929
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const warningCalls = consoleWarnSpy.mock.calls.filter(
|
|
933
|
+
call => typeof call[0] === 'string' && call[0].includes('WARNING') && call[0].includes('consecutive embedding failures')
|
|
934
|
+
);
|
|
935
|
+
expect(warningCalls.length).toBeGreaterThan(0);
|
|
936
|
+
|
|
937
|
+
watcher.stop();
|
|
938
|
+
consoleWarnSpy.mockRestore();
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
describe('embedIntervalMs validation behavior', () => {
|
|
943
|
+
beforeEach(() => {
|
|
944
|
+
vi.useFakeTimers();
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
afterEach(() => {
|
|
948
|
+
vi.useRealTimers();
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('should use provided embedIntervalMs when valid', async () => {
|
|
952
|
+
const mockEmbedder = {
|
|
953
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([]);
|
|
957
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue(null);
|
|
958
|
+
|
|
959
|
+
const watcher = startWatcher({
|
|
960
|
+
store: mockStore,
|
|
961
|
+
collections,
|
|
962
|
+
embedder: mockEmbedder,
|
|
963
|
+
embedIntervalMs: 2000,
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
967
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
968
|
+
|
|
969
|
+
watcher.stop();
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it('should use default embedIntervalMs (60000) when not provided', async () => {
|
|
973
|
+
const mockEmbedder = {
|
|
974
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([]);
|
|
978
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue(null);
|
|
979
|
+
|
|
980
|
+
const watcher = startWatcher({
|
|
981
|
+
store: mockStore,
|
|
982
|
+
collections,
|
|
983
|
+
embedder: mockEmbedder,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
987
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
988
|
+
|
|
989
|
+
watcher.stop();
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('should handle very small embedIntervalMs values', async () => {
|
|
993
|
+
const mockEmbedder = {
|
|
994
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([]);
|
|
998
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue(null);
|
|
999
|
+
|
|
1000
|
+
const watcher = startWatcher({
|
|
1001
|
+
store: mockStore,
|
|
1002
|
+
collections,
|
|
1003
|
+
embedder: mockEmbedder,
|
|
1004
|
+
embedIntervalMs: 100,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
1008
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1009
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1010
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
1011
|
+
|
|
1012
|
+
watcher.stop();
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it('should handle large embedIntervalMs values', async () => {
|
|
1016
|
+
const mockEmbedder = {
|
|
1017
|
+
embed: vi.fn().mockResolvedValue({ embedding: new Array(768).fill(0.1), model: 'test-model' }),
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
vi.mocked(mockStore.getHashesNeedingEmbedding).mockReturnValue([]);
|
|
1021
|
+
vi.mocked(mockStore.getNextHashNeedingEmbedding).mockReturnValue(null);
|
|
1022
|
+
|
|
1023
|
+
const watcher = startWatcher({
|
|
1024
|
+
store: mockStore,
|
|
1025
|
+
collections,
|
|
1026
|
+
embedder: mockEmbedder,
|
|
1027
|
+
embedIntervalMs: 300000,
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
1031
|
+
await vi.advanceTimersByTimeAsync(300000);
|
|
1032
|
+
|
|
1033
|
+
watcher.stop();
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
741
1036
|
});
|