// DEPRECATED: Orama DB operations are superseded by v3 MemoryIndexManager JSONL index. Keep only for // backward compatibility where needed; new features should not depend on this module. import EmbeddingsManager from "@/LLMProviders/embeddingManager"; import { CustomError } from "@/error"; import { logError, logInfo } from "@/logger"; import { getSettings, subscribeToSettingsChange } from "@/settings/model"; import { areEmbeddingModelsSame } from "@/utils"; import { Embeddings } from "@langchain/core/embeddings"; import { create, insert, Orama, remove, removeMultiple, search } from "@orama/orama"; import { Mutex } from "async-mutex"; import { MD5 } from "crypto-js"; import { App, Notice, Platform } from "obsidian"; import { ChunkedStorage } from "./chunkedStorage"; import { getMatchingPatterns, getVectorLength, shouldIndexFile } from "./searchUtils"; const INTEGRITY_CHECK_YIELD_INTERVAL = 25; export interface OramaDocument { id: string; title: string; content: string; embedding: number[]; path: string; embeddingModel: string; created_at: number; ctime: number; mtime: number; tags: string[]; extension: string; nchars: number; metadata: Record; } export class DBOperations { private oramaDb: Orama | undefined; private chunkedStorage: ChunkedStorage | undefined; private isInitialized = false; private dbPath: string; private initializationPromise: Promise; private isIndexLoaded = false; private hasUnsavedChanges = false; private filesWithoutEmbeddings: Set = new Set(); private upsertMutex = new Mutex(); constructor(private app: App) { // Subscribe to settings changes subscribeToSettingsChange(() => { void (async () => { try { const settings = getSettings(); // Handle mobile index loading setting change if (Platform.isMobile && settings.disableIndexOnMobile) { this.isIndexLoaded = false; this.oramaDb = undefined; } else if (Platform.isMobile && !settings.disableIndexOnMobile && !this.oramaDb) { // Re-initialize DB if mobile setting is enabled await this.initializeDB(await EmbeddingsManager.getInstance().getEmbeddingsAPI()); } // Handle index sync setting change const newPath = await this.getDbPath(); if (this.dbPath && newPath !== this.dbPath) { logInfo("Path change detected, reinitializing database..."); this.dbPath = newPath; await this.initializeChunkedStorage(); await this.initializeDB(await EmbeddingsManager.getInstance().getEmbeddingsAPI()); logInfo("Database reinitialized with new path:", newPath); } } catch (error) { logError("DBOperations settings change handler failed", error); } })(); }); } private async initializeChunkedStorage() { if (!this.app.vault.adapter) { throw new CustomError("Vault adapter not available. Please try again later."); } const baseDir = await this.getDbPath(); this.chunkedStorage = new ChunkedStorage(this.app, baseDir, this.getVaultIdentifier()); this.isInitialized = true; } async initializeDB(embeddingInstance: Embeddings | undefined): Promise | undefined> { try { if (!this.isInitialized) { this.dbPath = await this.getDbPath(); await this.initializeChunkedStorage(); } if (Platform.isMobile && getSettings().disableIndexOnMobile) { this.isIndexLoaded = false; this.oramaDb = undefined; return; } if (!this.chunkedStorage) { throw new CustomError("Storage not initialized properly"); } try { if (await this.chunkedStorage.exists()) { this.oramaDb = await this.chunkedStorage.loadDatabase(); logInfo("Loaded existing chunked semantic index database from disk."); return this.oramaDb; } } catch (error) { // If loading fails, we'll create a new database logError("Failed to load existing semantic index database, creating new one:", error); } // Create new database if none exists or loading failed const newDb = await this.createNewDb(embeddingInstance); this.oramaDb = newDb; return newDb; } catch (error) { logError(`Error initializing semantic index database:`, error); new Notice("Failed to initialize Copilot database. Some features may be limited."); return undefined; } } async saveDB() { if (Platform.isMobile && getSettings().disableIndexOnMobile) { return; } if (!this.oramaDb || !this.chunkedStorage) { // Instead of throwing immediately, try to initialize. // Crucial for new user onboarding. try { await this.initializeDB(await EmbeddingsManager.getInstance().getEmbeddingsAPI()); // If still not initialized after attempt, then throw if (!this.oramaDb || !this.chunkedStorage) { throw new CustomError("Semantic index database not found."); } } catch (error) { logError("Failed to initialize database during save:", error); throw new CustomError("Failed to initialize and save database."); } } try { await this.chunkedStorage.saveDatabase(this.oramaDb); this.hasUnsavedChanges = false; if (getSettings().debug) { logInfo("Semantic index database saved successfully at:", this.dbPath); } } catch (error) { logError(`Error saving semantic index database:`, error); throw error; } } async clearIndex(embeddingInstance: Embeddings | undefined): Promise { try { // Ensure database is initialized first if (!this.oramaDb) { await this.initializeDB(embeddingInstance); } // Clear existing storage first await this.chunkedStorage?.clearStorage(); // Wait a moment to ensure file system operations complete await new Promise((resolve) => window.setTimeout(resolve, 100)); // Create new database instance this.oramaDb = await this.createNewDb(embeddingInstance); // Save the empty database await this.saveDB(); new Notice("Local Copilot index cleared successfully."); logInfo("Local Copilot index cleared successfully, new instance created."); } catch (err) { logError("Error clearing the local Copilot index:", err); new Notice("An error occurred while clearing the local Copilot index."); throw err; } } public async removeDocs(filePath: string) { if (!this.oramaDb) { throw new CustomError("Semantic index database not found."); } try { const searchResult = await search(this.oramaDb, { term: filePath, properties: ["path"], }); if (searchResult.hits.length > 0) { await removeMultiple( this.oramaDb, searchResult.hits.map((hit) => hit.id), 500 ); if (getSettings().debug) { logInfo(`Deleted document from local Copilot index: ${filePath}`); } } this.markUnsavedChanges(); } catch (err) { logError("Error deleting document from local Copilotindex:", err); } } public getDb(): Orama | undefined { if (!this.oramaDb) { console.warn("Database not initialized. Some features may be limited."); } return this.oramaDb; } public async getIsIndexLoaded(): Promise { return this.isIndexLoaded; } public async waitForInitialization() { await this.initializationPromise; } public onunload() { if (this.hasUnsavedChanges) { void this.saveDB().catch((err) => logError("saveDB on unload failed", err)); } } public getCurrentDbPath(): string { // This is the old path before any setting changes, used for comparison return this.dbPath; } // This is the path according to the setting's enableIndexSync public async getDbPath(): Promise { const vaultRoot = this.app.vault.getRoot().path; let baseDir: string; if (getSettings().enableIndexSync) { baseDir = this.app.vault.configDir; } else { // If vaultRoot is just "/", treat it as empty const effectiveRoot = vaultRoot === "/" ? "" : vaultRoot; const prefix = effectiveRoot === "" || effectiveRoot.startsWith("/") ? "" : "/"; baseDir = `${prefix}${effectiveRoot}/.copilot-index`; // Ensure the directory exists if (!(await this.app.vault.adapter.exists(baseDir))) { await this.app.vault.adapter.mkdir(baseDir); logInfo("Created directory:", baseDir); } } return baseDir; } private getVaultIdentifier(): string { const vaultName = this.app.vault.getName(); return MD5(vaultName).toString(); } public markUnsavedChanges() { this.hasUnsavedChanges = true; } private async createNewDb(embeddingInstance: Embeddings | undefined): Promise> { if (!embeddingInstance) { throw new CustomError("Embedding instance not found."); } const vectorLength = await getVectorLength(embeddingInstance); if (!vectorLength || vectorLength === 0) { throw new CustomError( "Invalid vector length detected. Please check if your embedding model is working." ); } const schema = this.createDynamicSchema(vectorLength); const db = create({ schema, components: { tokenizer: { stemmer: undefined, stopWords: undefined, }, }, }); logInfo( `Created new semantic index database for ${this.dbPath}. ` + `Embedding model: ${EmbeddingsManager.getModelName(embeddingInstance)} with vector length ${vectorLength}.` ); this.isIndexLoaded = true; return db; } public static async getDocsByPath(db: Orama, path: string) { if (!db) throw new Error("DB not initialized"); if (!path) return; // Use getAllDocuments + JS filter for reliable path matching (handles Unicode/Chinese correctly) const allDocs = await DBOperations.getAllDocuments(db); const filtered = allDocs.filter((doc: any) => doc.path === path); // Return in same format as before (hits with document and score) return filtered.map((doc: any) => ({ document: doc, score: 1 })); } public static async getDocsByEmbedding( db: Orama, embedding: number[], options: { limit: number; similarity: number; } ) { const result = await search(db, { mode: "vector", vector: { value: embedding, property: "embedding" }, limit: options.limit, similarity: options.similarity, includeVectors: true, }); return result.hits; } public static async getLatestFileMtime(db: Orama | undefined): Promise { if (!db) throw new Error("DB not initialized"); try { const result = await search(db, { term: "", limit: 1, sortBy: { property: "mtime", order: "DESC", }, }); if (result.hits.length > 0) { const latestDoc = result.hits[0].document as any; return latestDoc.mtime; } return 0; // Return 0 if no documents found } catch (err) { logError("Error getting latest file mtime from VectorDB:", err); return 0; } } createDynamicSchema(vectorLength: number) { return { id: "string", title: "string", // basename of the TFile path: "string", // path of the TFile content: "string", embedding: `vector[${vectorLength}]`, embeddingModel: "string", created_at: "number", ctime: "number", mtime: "number", tags: "string[]", extension: "string", nchars: "number", }; } async upsert(docToSave: any): Promise { if (!this.oramaDb) throw new Error("DB not initialized"); const db = this.oramaDb; // Use mutex to make the operation atomic return await this.upsertMutex.runExclusive(async () => { try { // Calculate partition first const partition = this.chunkedStorage?.assignDocumentToPartition( String(docToSave.id), getSettings().numPartitions ); // Check if document exists const existingDoc = await search(db, { term: String(docToSave.id), properties: ["id"], limit: 1, }); if (existingDoc.hits.length > 0) { await remove(db, existingDoc.hits[0].id); } // Insert into the assigned partition try { await insert(db, docToSave as Parameters[1]); logInfo( `${existingDoc.hits.length > 0 ? "Updated" : "Inserted"} document ${docToSave.id} in partition ${partition}` ); this.markUnsavedChanges(); return docToSave; } catch (insertErr) { logError( `Failed to ${existingDoc.hits.length > 0 ? "update" : "insert"} document ${docToSave.id}:`, insertErr ); // If we removed an existing document but failed to insert the new one, // we should try to restore the old document if (existingDoc.hits.length > 0) { try { await insert(db, existingDoc.hits[0].document); } catch (restoreErr) { logError("Failed to restore previous document version:", restoreErr); } } return undefined; } } catch (err) { logError(`Error upserting document ${docToSave.id}:`, err); return undefined; } }); } async getLatestFileMtime(): Promise { if (!this.oramaDb) throw new Error("DB not initialized"); try { const result = await search(this.oramaDb, { term: "", limit: 1, sortBy: { property: "mtime", order: "DESC", }, }); if (result.hits.length > 0) { const latestDoc = result.hits[0].document as any; return latestDoc.mtime; } return 0; // Return 0 if no documents found } catch (err) { logError("Error getting latest file mtime from VectorDB:", err); return 0; } } /** * Checks if the embedding model has changed and handles the transition. * * @param embeddingInstance - The current embedding instance to check against * @returns {boolean} - Returns true if model changed (triggers full rebuild with overwrite=true), * false if model unchanged or no index exists (allows normal indexing flow) * * Behavior: * - true = Model changed → Forces full rebuild (clears existing index and reindexes all files) * - false = Model unchanged OR no index exists → Proceeds with normal indexing: * - If index exists: incremental update (only new/modified files) * - If no index: first-time build (indexes all files without "rebuilding") * * Note: When no index exists, returning false is correct because: * - It's not a "rebuild" (nothing to rebuild), it's a first-time "build" * - The indexing flow will still create the index, just without the "overwrite" flag * - This prevents unnecessary "rebuilding" when toggling semantic search on/off */ async checkAndHandleEmbeddingModelChange(embeddingInstance: Embeddings): Promise { // Load DB from disk if not in memory if (!this.oramaDb) { logInfo("Semantic index database not loaded in memory. Checking for existing index..."); try { await this.initializeDB(embeddingInstance); } catch (error) { logError("Failed to initialize database:", error); throw new CustomError( "Failed to initialize semantic index database. Please check your embedding model settings." ); } // If still no DB after init, no existing index to compare against if (!this.oramaDb) { logInfo("No existing index found. Will create new index."); return false; // Not a rebuild, just a first-time build } } // Check if the embedding model in the existing index matches the current model const singleDoc = await search(this.oramaDb, { term: "", limit: 1, }); let prevEmbeddingModel: string | undefined; if (singleDoc.hits.length > 0) { const oramaDocSample = singleDoc.hits[0]; if ( typeof oramaDocSample === "object" && oramaDocSample !== null && "document" in oramaDocSample ) { const doc = oramaDocSample.document as { embeddingModel?: string }; prevEmbeddingModel = doc.embeddingModel; } } if (prevEmbeddingModel) { const currEmbeddingModel = EmbeddingsManager.getModelName(embeddingInstance); if (!areEmbeddingModelsSame(prevEmbeddingModel, currEmbeddingModel)) { // Model has changed, notify user and rebuild DB new Notice("New embedding model detected. Rebuilding Copilot index from scratch."); logInfo( `Detected change in embedding model from "${prevEmbeddingModel}" to "${currEmbeddingModel}". Rebuilding Copilot index from scratch.` ); // Create new DB with new model this.oramaDb = await this.createNewDb(embeddingInstance); await this.saveDB(); return true; } } else { logInfo("No previous embedding model found in the database."); } return false; } public static async getAllDocuments(db: Orama): Promise { const result = await search(db, { term: "", limit: 100000, includeVectors: true, }); return result.hits.map((hit) => hit.document); } public async garbageCollect(): Promise { if (!this.oramaDb) { logInfo( "Semantic index database not found during garbage collection. Attempting to initialize..." ); try { const embeddingInstance = await EmbeddingsManager.getInstance().getEmbeddingsAPI(); if (!embeddingInstance) { throw new CustomError("No embedding model available."); } await this.initializeDB(embeddingInstance); if (!this.oramaDb) { throw new CustomError("Failed to initialize database after attempt."); } } catch (error) { logError("Failed to initialize database during garbage collection:", error); throw new CustomError( "Failed to initialize database. Please check your embedding model settings." ); } } try { const files = this.app.vault.getMarkdownFiles(); const filePaths = new Set(files.map((file) => file.path)); // Determine which files are currently eligible for indexing based on settings const { inclusions, exclusions } = getMatchingPatterns(); const allowedPaths = new Set( files .filter((file) => shouldIndexFile(file, inclusions, exclusions)) .map((file) => file.path) ); // Get all documents in the database const docs = await DBOperations.getAllDocuments(this.oramaDb); // Identify docs to remove: // 1) Files that no longer exist // 2) Files that exist but are excluded by current settings (or internal exclusions) const docsToRemove = docs.filter( (doc: { path: string }) => !filePaths.has(doc.path) || !allowedPaths.has(doc.path) ) as { path: string; id: string }[]; if (docsToRemove.length === 0) { return 0; } logInfo( "Copilot index: Docs to remove during garbage collection:", Array.from(new Set(docsToRemove.map((doc) => doc.path))).join(", ") ); if (docsToRemove.length === 1) { await remove(this.oramaDb, docsToRemove[0].id); } else { await removeMultiple( this.oramaDb, docsToRemove.map((hit) => hit.id), 500 ); } await this.saveDB(); return docsToRemove.length; } catch (err) { logError("Error garbage collecting the Copilot index:", err); throw new CustomError("Failed to garbage collect the Copilot index."); } } public async getIndexedFiles(): Promise { if (!this.oramaDb) { throw new CustomError("Semantic index database not found."); } try { // Search all documents and get unique file paths const docs = await DBOperations.getAllDocuments(this.oramaDb); // Use a Set to get unique file paths since multiple chunks can belong to the same file const uniquePaths = new Set(); docs.forEach((doc: { path: string }) => { uniquePaths.add(doc.path); }); // Convert Set to sorted array return Array.from(uniquePaths).sort(); } catch (err) { logError("Error getting indexed files:", err); throw new CustomError("Failed to retrieve indexed files."); } } public async isIndexEmpty(): Promise { if (!this.oramaDb) { return true; } try { const result = await search(this.oramaDb, { term: "", limit: 1, }); return result.hits.length === 0; } catch (err) { logError("Error checking if database is empty:", err); throw new CustomError("Failed to check if database is empty."); } } public async hasIndex(notePath: string): Promise { if (!this.oramaDb) { return false; } const docs = await DBOperations.getDocsByPath(this.oramaDb, notePath); return docs !== undefined && docs.length > 0; } async hasEmbeddings(notePath: string): Promise { if (!this.oramaDb) { return false; } const docs = await DBOperations.getDocsByPath(this.oramaDb, notePath); if (!docs || docs.length === 0) { return false; } // Check if ALL documents for this path have embeddings return docs.every((doc) => { return ( doc?.document?.embedding && Array.isArray(doc.document.embedding) && doc.document.embedding.length > 0 ); }); } async getDocsJsonByPaths(paths: string[]): Promise> { if (!this.oramaDb) { throw new CustomError("Semantic index database not found."); } const result: Record = {}; for (const path of paths) { const docs = await DBOperations.getDocsByPath(this.oramaDb, path); if (docs && docs.length > 0) { result[path] = docs.map((hit) => ({ id: hit.document.id, title: hit.document.title, path: hit.document.path, content: hit.document.content, metadata: hit.document.metadata, embedding: hit.document.embedding, embeddingModel: hit.document.embeddingModel, tags: hit.document.tags, extension: hit.document.extension, nchars: hit.document.nchars, })); } } return result; } /** * Mark a file as missing embeddings */ public markFileMissingEmbeddings(filePath: string): void { this.filesWithoutEmbeddings.add(filePath); } /** * Clear the list of files missing embeddings */ public clearFilesMissingEmbeddings(): void { this.filesWithoutEmbeddings.clear(); } /** * Get the list of files missing embeddings */ public getFilesMissingEmbeddings(): string[] { return Array.from(this.filesWithoutEmbeddings); } /** * Check if a file is missing embeddings */ public isFileMissingEmbeddings(filePath: string): boolean { return this.filesWithoutEmbeddings.has(filePath); } /** * Check the integrity of the index by verifying all documents have proper embeddings. * Files found missing embeddings are marked for retry so the next indexing run picks them up. * (The phantom re-indexing issue is prevented by clearing the set at the start of each * indexing run in indexOperations.ts, not by skipping the mark here.) */ public async checkIndexIntegrity(): Promise { if (!this.oramaDb) { throw new CustomError("Orama database not found."); } try { const indexedFiles = await this.getIndexedFiles(); const filesMissing: string[] = []; for (let index = 0; index < indexedFiles.length; index += 1) { const filePath = indexedFiles[index]; const hasEmb = await this.hasEmbeddings(filePath); if (!hasEmb) { filesMissing.push(filePath); this.filesWithoutEmbeddings.add(filePath); } if ((index + 1) % INTEGRITY_CHECK_YIELD_INTERVAL === 0) { await this.yieldToEventLoop(); } } if (indexedFiles.length % INTEGRITY_CHECK_YIELD_INTERVAL !== 0) { await this.yieldToEventLoop(); } if (filesMissing.length > 0) { logInfo( `Integrity check: ${filesMissing.length} file(s) missing embeddings, marked for re-indexing:`, filesMissing.join(", ") ); } else { logInfo("Index integrity check completed. All documents have embeddings."); } } catch (err) { logError("Error checking index integrity:", err); throw new CustomError("Failed to check index integrity."); } } /** * Yield control to the browser event loop so long-running integrity checks do not freeze the UI. */ private async yieldToEventLoop(): Promise { await new Promise((resolve) => { if (typeof window !== "undefined") { const idleWindow = window as Window & { requestIdleCallback?: (callback: IdleRequestCallback) => number; }; if (idleWindow.requestIdleCallback) { idleWindow.requestIdleCallback(() => resolve()); return; } } window.setTimeout(resolve, 0); }); } }