/** * BrainBank — Main Orchestrator * * Thin facade that composes services: * - **PluginRegistry** — registration + lookup * - **SearchAPI** — all search + context logic * - **runIndex** — code / git / docs indexing orchestration * * Initialization is inline — no indirection layers. * All heavy logic lives in those modules; BrainBank owns state, * guards (`_requireInit` / `initialize`), and public API shape. * * Multi-process coordination: * - `ensureFresh()` detects stale HNSW indices via `index_state` table * - Hot-reloads from disk when another process updated the index * - Called implicitly before every search operation */ import type { ReembedResult, ReembedOptions } from './engine/reembed.ts'; import type { IndexDeps } from './engine/index-api.ts'; import type { Plugin, PluginContext } from './plugin.ts'; import type { SearchOptions } from './search/types.ts'; import type { WatchOptions } from './services/watch.ts'; import type { BrainBankConfig, ResolvedConfig, EmbeddingProvider, SearchResult, ICollection, ContextOptions, StageProgressCallback, } from './types.ts'; import { EventEmitter } from 'node:events'; import * as path from 'node:path'; import { resolveConfig } from './config.ts'; import { HNSW } from './constants.ts'; import type { DatabaseAdapter } from './db/adapter.ts'; import { SQLiteAdapter } from './db/sqlite-adapter.ts'; import { createTracker } from './db/tracker.ts'; import { setEmbeddingMeta, getEmbeddingMeta, detectProviderMismatch, getVersions } from './db/metadata.ts'; import { runIndex } from './engine/index-api.ts'; import { reembedAll } from './engine/reembed.ts'; import { SearchAPI, createSearchAPI } from './engine/search-api.ts'; import { isReembeddable, isFileResolvable } from './plugin.ts'; import { resolveEmbedding } from './providers/embeddings/resolve.ts'; import { HNSWIndex } from './providers/vector/hnsw-index.ts'; import { hnswPath, countRows, saveAllHnsw, loadVectors, loadVecCache, reloadHnsw } from './providers/vector/hnsw-loader.ts'; import { KVService } from './services/kv-service.ts'; import { PluginRegistry } from './services/plugin-registry.ts'; import { Watcher } from './services/watch.ts'; import { WebhookServer } from './services/webhook-server.ts'; export class BrainBank extends EventEmitter { private _config: ResolvedConfig; private _db!: DatabaseAdapter; private _embedding!: EmbeddingProvider; private _registry = new PluginRegistry(); private _searchAPI?: SearchAPI; private _indexDeps?: IndexDeps; private _kvService?: KVService; private _initialized = false; private _initPromise: Promise | null = null; private _watcher?: Watcher; private _webhookServer?: WebhookServer; private _sharedHnsw = new Map }>(); private _repoDBs = new Map(); private _loadedVersions = new Map(); constructor(config: BrainBankConfig = {}) { super(); this._config = resolveConfig(config); } /** Whether the brainbank has been initialized. */ get isInitialized(): boolean { return this._initialized; } /** The resolved configuration. */ get config(): Readonly { return this._config; } /** All registered plugin names (insertion order). */ get plugins(): string[] { return this._registry.names; } /** * Register a plugin. Chainable. * * @example * brain.use(code({ repoPath: '.' })).use(docs()); * * @throws If called after `initialize()`. */ use(plugin: Plugin): this { if (this._initialized) { throw new Error( `BrainBank: Cannot add plugin '${plugin.name}' after initialization. ` + `Call .use() before any operations.`, ); } this._registry.register(plugin); return this; } /** * Check if a plugin is loaded. * Also matches type prefix (e.g. `'code'` matches `'code:frontend'`). */ has(name: string): boolean { return this._registry.has(name); } /** Get a plugin instance by name. Returns `undefined` if not loaded. */ plugin(name: string): T | undefined { return this._registry.has(name) ? this._registry.get(name) : undefined; } /** * Initialize database, HNSW indices, and load existing vectors. * Automatically called by `index` / `search` methods if not yet initialized. * Concurrent calls are deduped via `_initPromise`. * * @param options.force - If `true`, skip vector load on dimension mismatch. */ async initialize(options: { force?: boolean } = {}): Promise { if (this._initialized) return; if (this._initPromise) return this._initPromise; this._initPromise = this._runInitialize(options) .then(() => { this._initPromise = null; }) .catch(err => { this._cleanupAfterFailedInit(); throw err; }); return this._initPromise; } /** * Estimated memory footprint of loaded HNSW indices (bytes). * Counts only vector data: `vectorCount × dims × 4`. * Returns 0 if not initialized. */ memoryHint(): number { if (!this._initialized) return 0; const dims = this._config.embeddingDims; const bytesPerVector = dims * 4; let total = 0; if (this._kvService) total += this._kvService.hnsw.size * bytesPerVector; for (const { hnsw } of this._sharedHnsw.values()) { total += hnsw.size * bytesPerVector; } return total; } /** Close database and release all resources. Synchronous. */ close(): void { void this._watcher?.close(); this._webhookServer?.close(); for (const plugin of this._registry.all) plugin.close?.(); const pruner = this._config.pruner as { close?: () => void } | undefined; pruner?.close?.(); this._embedding?.close().catch(() => { }); for (const db of this._repoDBs.values()) db.close(); this._repoDBs.clear(); this._db?.close(); this._initialized = false; this._kvService?.clear(); this._sharedHnsw.clear(); this._loadedVersions.clear(); this._kvService = undefined; this._searchAPI = undefined; this._indexDeps = undefined; this._webhookServer = undefined; this._registry.clear(); } /** * Get or create a dynamic collection (universal KV primitive). * * @example * const errors = brain.collection('debug_errors'); * await errors.add('Fixed null check', { file: 'api.ts' }); * const hits = await errors.search('null pointer'); * * @throws If not initialized. */ collection(name: string): ICollection { if (!this._kvService) { throw new Error('BrainBank: Collections not ready. Call await brain.initialize() first.'); } return this._kvService.collection(name); } /** List all collection names that have data. */ listCollectionNames(): string[] { this._requireInit('listCollectionNames'); return this._kvService!.listNames(); } /** Delete a collection's data and evict from cache. */ deleteCollection(name: string): void { this._requireInit('deleteCollection'); this._kvService!.delete(name); } /** Run indexing across selected modules. Auto-initializes. */ async index(options: { modules?: string[]; forceReindex?: boolean; onProgress?: StageProgressCallback; pluginOptions?: Record; } = {}): Promise> { await this.initialize(); return runIndex(this._indexDeps!, options); } /** * Detect stale HNSW indices and hot-reload from disk. * Called implicitly before every search operation. * Cost: one SQLite SELECT (~5μs on WAL mode). */ async ensureFresh(): Promise { if (!this._initialized) return; const dbVersions = getVersions(this._db); for (const [name, dbVersion] of dbVersions) { const loaded = this._loadedVersions.get(name) ?? 0; if (dbVersion <= loaded) continue; this.emit('progress', `Hot-reload: ${name} version ${loaded} → ${dbVersion}`); this._reloadIndex(name); this._loadedVersions.set(name, dbVersion); } } /** * Semantic search across all loaded modules. * Scope via `sources: { code: 10, git: 0 }`. */ async search(query: string, options?: SearchOptions): Promise { await this.initialize(); await this.ensureFresh(); return this._searchAPI?.search(query, options) ?? []; } /** * Hybrid search: vector + BM25 fused with Reciprocal Rank Fusion. * Scope via `sources: { code: 10, git: 5, docs: 3, myNotes: 5 }`. */ async hybridSearch(query: string, options?: SearchOptions): Promise { await this.initialize(); await this.ensureFresh(); return this._searchAPI?.hybridSearch(query, options) ?? []; } /** BM25 keyword search only (no embeddings needed). */ async searchBM25(query: string, options?: SearchOptions): Promise { await this.initialize(); await this.ensureFresh(); return this._searchAPI?.searchBM25(query, options) ?? []; } /** Build formatted context block for LLM system prompt injection. Auto-initializes. */ async getContext(task: string, options: ContextOptions = {}): Promise { await this.initialize(); await this.ensureFresh(); return this._searchAPI?.getContext(task, options) ?? ''; } /** * Resolve file paths, directories, and glob patterns to full SearchResults. * Bypasses search entirely — reads directly from plugin indexes. * * @example * const files = brain.resolveFiles(['src/auth/login.ts', 'src/graph/']); */ resolveFiles(patterns: string[]): SearchResult[] { this._requireInit('resolveFiles'); const results: SearchResult[] = []; for (const mod of this._registry.all) { if (!isFileResolvable(mod)) continue; results.push(...mod.resolveFiles(patterns)); } return results; } /** Rebuild FTS5 indices. */ rebuildFTS(): void { this._requireInit('rebuildFTS'); this._searchAPI?.rebuildFTS(); } /** Get statistics for all loaded plugins. */ stats(): Record | undefined> { this._requireInit('stats'); const result: Record | undefined> = {}; for (const mod of this._registry.all) { if (mod.stats) { const baseType = mod.name.split(':')[0]; result[baseType] = mod.stats(); } } return result; } /** Start watching for changes and auto-re-index. */ watch(options: WatchOptions = {}): Watcher { this._requireInit('watch'); void this._watcher?.close(); this._watcher = new Watcher( async () => { await this.index(); }, this._registry.all, options, this._config.repoPath, ); return this._watcher; } /** * Re-embed all existing text with the current embedding provider. * Use after switching providers (e.g. Local → OpenAI). */ async reembed(options: ReembedOptions = {}): Promise { await this.initialize(); const hnswMap = new Map }>(); if (this._kvService) { hnswMap.set(HNSW.KV, { hnsw: this._kvService.hnsw, vecs: this._kvService.vecs }); } for (const [type, shared] of this._sharedHnsw) { hnswMap.set(type, { hnsw: shared.hnsw, vecs: shared.vecCache }); } const result = await reembedAll(this._db, this._embedding, hnswMap, this._registry.all, options, { dbPath: this._config.dbPath, kvHnsw: this._kvService!.hnsw, sharedHnsw: this._sharedHnsw, }); this.emit('reembedded', result); return result; } /** * Linear 8-step initialization: * 1. Open database * 2. Resolve embedding provider * 3. Check dimension mismatch * 4. Create KV HNSW + KVService * 5. Load KV vectors * 6. Initialize plugins * 7. Persist HNSW indices * 8. Build SearchAPI + index deps */ private async _runInitialize(options: { force?: boolean } = {}): Promise { if (this._initialized) return; this._db = new SQLiteAdapter(this._config.dbPath); this._embedding = await this._resolveEmbedding(); const mismatch = detectProviderMismatch(this._db, this._embedding); if (mismatch?.mismatch && !options.force) { this._db.close(); throw new Error( `BrainBank: Embedding dimension mismatch (stored: ${mismatch.stored}, current: ${mismatch.current}). ` + `Run brain.reembed() to re-index with the new provider, or switch back to the original provider.`, ); } setEmbeddingMeta(this._db, this._embedding); const skipVectorLoad = !!(options.force && mismatch?.mismatch); const dims = this._embedding.dims ?? this._config.embeddingDims; const kvHnsw = new HNSWIndex( dims, this._config.maxElements ?? 500_000, this._config.hnswM, this._config.hnswEfConstruction, this._config.hnswEfSearch, ); await kvHnsw.init(); this._kvService = new KVService(this._db, this._embedding, kvHnsw, new Map()); if (!skipVectorLoad) { const kvIndexPath = hnswPath(this._config.dbPath, 'kv'); const kvCount = countRows(this._db, 'kv_vectors'); if (kvHnsw.tryLoad(kvIndexPath, kvCount)) { loadVecCache(this._db, 'kv_vectors', 'data_id', this._kvService.vecs); } else { loadVectors(this._db, 'kv_vectors', 'data_id', kvHnsw, this._kvService.vecs); } } const privateHnsw = new Map(); for (const mod of this._registry.all) { const pluginDb = this._getOrCreatePluginDb(mod.name); // Propagate embedding meta to per-repo DBs if (pluginDb !== this._db) { setEmbeddingMeta(pluginDb, this._embedding); } const ctx = this._buildPluginContext(skipVectorLoad, privateHnsw, pluginDb, mod.name); await mod.initialize(ctx); } // Start webhook server if configured (after plugins so they can register routes) if (this._config.webhookPort) { this._webhookServer = new WebhookServer(); this._webhookServer.listen(this._config.webhookPort); } await saveAllHnsw(this._config.dbPath, kvHnsw, this._sharedHnsw, privateHnsw); this._searchAPI = createSearchAPI( this._db, this._embedding, this._config, this._registry, this._kvService, this._sharedHnsw, ); this._indexDeps = { db: this._db, dbPath: this._config.dbPath, sharedHnsw: this._sharedHnsw, kvHnsw, registry: this._registry, emit: (e, d) => this.emit(e, d), }; // Snapshot current versions for staleness detection this._loadedVersions = getVersions(this._db); this._initialized = true; this.emit('initialized', { plugins: this.plugins }); } /** Reset shared state after a failed `_runInitialize`. */ private _cleanupAfterFailedInit(): void { for (const { hnsw } of this._sharedHnsw.values()) { try { hnsw.reinit(); } catch (e) { this.emit('warn', `HNSW reinit failed during cleanup: ${e}`); } } this._kvService?.clear(); if (this._kvService) { try { this._kvService.hnsw.reinit(); } catch (e) { this.emit('warn', `KV HNSW reinit failed during cleanup: ${e}`); } } try { this._db?.close(); } catch { /* DB already closed — safe to ignore */ } this._db = undefined!; this._kvService = undefined; this._searchAPI = undefined; this._indexDeps = undefined; this._initPromise = null; } /** Resolve embedding: explicit config > stored DB key > local default. */ private async _resolveEmbedding(): Promise { if (this._config.embeddingProvider) return this._config.embeddingProvider; const meta = getEmbeddingMeta(this._db); if (meta?.providerKey && meta.providerKey !== 'local') { this.emit('progress', `Embedding: auto-resolved '${meta.providerKey}' from DB`); return resolveEmbedding(meta.providerKey); } return resolveEmbedding('local'); } /** * Get or create a per-repo SQLiteAdapter for namespaced plugins. * Non-namespaced plugins use the root DB. * DB path: `.brainbank/.db` (e.g., `servicehub-backend.db`). */ private _getOrCreatePluginDb(pluginName: string): DatabaseAdapter { if (!pluginName.includes(':')) return this._db; const repoName = pluginName.split(':').slice(1).join(':'); const existing = this._repoDBs.get(repoName); if (existing) return existing; const dir = path.dirname(this._config.dbPath); const repoDbPath = path.join(dir, `${repoName}.db`); const db = new SQLiteAdapter(repoDbPath); this._repoDBs.set(repoName, db); return db; } /** Build a per-plugin `PluginContext` with appropriate DB and HNSW scoping. */ private _buildPluginContext( skipVectorLoad: boolean, privateHnsw: Map, pluginDb: DatabaseAdapter, pluginName: string, ): PluginContext { let autoId = 0; const dbPath = this._config.dbPath; return { db: pluginDb, embedding: this._embedding, config: this._config, createHnsw: async (maxElements?: number, dims?: number, name?: string) => { const hnsw = await new HNSWIndex( dims ?? this._config.embeddingDims, maxElements ?? this._config.maxElements, this._config.hnswM, this._config.hnswEfConstruction, this._config.hnswEfSearch, ).init(); privateHnsw.set(name ?? `private-${autoId++}`, hnsw); return hnsw; }, loadVectors: (table, idCol, hnsw, cache) => { if (skipVectorLoad) return; const indexName = table.replace('_vectors', '').replace('_chunks', ''); const indexPath = hnswPath(dbPath, `${indexName}-${pluginDb === this._db ? 'root' : 'repo'}`); const rowCount = countRows(pluginDb, table); if (hnsw.tryLoad(indexPath, rowCount)) { loadVecCache(pluginDb, table, idCol, cache); } else { loadVectors(pluginDb, table, idCol, hnsw, cache); } }, getOrCreateSharedHnsw: async (type, maxElements, dims) => { const existing = this._sharedHnsw.get(type); if (existing) return { ...existing, isNew: false }; const hnsw = await new HNSWIndex( dims ?? this._config.embeddingDims, maxElements ?? this._config.maxElements, this._config.hnswM, this._config.hnswEfConstruction, this._config.hnswEfSearch, ).init(); const vecCache = new Map(); this._sharedHnsw.set(type, { hnsw, vecCache }); return { hnsw, vecCache, isNew: true }; }, collection: (name) => this._kvService!.collection(name), createTracker: () => createTracker(pluginDb, pluginName), webhookServer: this._webhookServer, }; } /** * Reload a single HNSW index by name. * Discovers the vector table via ReembeddablePlugin capability. * KV is handled directly since it's core-owned. * * The `name` comes from `index_state` and equals the plugin's `mod.name` * (e.g. `code:backend`, `git`, `docs`). This matches the key used in * `getOrCreateSharedHnsw()` during initialization. */ private _reloadIndex(name: string): void { // KV HNSW — core-owned, known table if (name === HNSW.KV && this._kvService) { reloadHnsw({ dbPath: this._config.dbPath, db: this._db, name, hnsw: this._kvService.hnsw, vecCache: this._kvService.vecs, vectorTable: 'kv_vectors', idCol: 'data_id', }); return; } // Shared HNSW — exact key match against the map const shared = this._sharedHnsw.get(name); if (!shared) return; // Discover vector table from the plugin that owns this HNSW key. // Match by plugin name (e.g. 'code:backend') or reembed name (e.g. 'code'). for (const mod of this._registry.all) { if (!isReembeddable(mod)) continue; if (mod.name !== name) continue; const cfg = mod.reembedConfig(); reloadHnsw({ dbPath: this._config.dbPath, db: this._db, name, hnsw: shared.hnsw, vecCache: shared.vecCache, vectorTable: cfg.vectorTable, idCol: cfg.fkColumn, }); return; } } /** Guard: throw descriptive error if not initialized. */ private _requireInit(method: string): void { if (!this._initialized) { throw new Error(`BrainBank: Not initialized. Call await brain.initialize() before ${method}().`); } } }