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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nano-brain",
3
- "version": "2026.3.5",
3
+ "version": "2026.3.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "nano-brain": "./bin/cli.js"
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 ?? 540;
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 = 5 * 1024 * 1024
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
- const bulkDeactivateExceptStmt = db.prepare(`
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 result = bulkDeactivateExceptStmt.run(collection, JSON.stringify(activePaths));
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
- if (vecAvailable) {
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 embeddingInterval: NodeJS.Timeout | null = null;
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
- embeddingInterval = setInterval(async () => {
300
- if (stopped || isEmbedding) return;
301
- isEmbedding = true;
302
- try {
303
- const count = await embedPendingCodebase(store, embedder, 50, projectHash);
304
- if (count > 0) {
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
- } catch (err) {
309
- console.warn('[embed] Embedding cycle failed:', err);
310
- } finally {
311
- isEmbedding = false;
312
- }
313
- }, embedIntervalMs);
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 (embeddingInterval) {
359
- clearInterval(embeddingInterval);
360
- embeddingInterval = null;
382
+ if (embeddingTimeout) {
383
+ clearTimeout(embeddingTimeout);
384
+ embeddingTimeout = null;
361
385
  }
362
386
 
363
387
  if (watcher) {
@@ -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
  });
@@ -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 getNextHashNeedingEmbedding in a loop
623
- vi.mocked(mockStore.getNextHashNeedingEmbedding)
624
- .mockReturnValueOnce({ hash: 'abc123', body: 'Content to embed', path: testFile })
625
- .mockReturnValue(null);
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
  });