Skip to main content
Glama

Memory MCP Server

by inchan
search-engine.ts6.99 kB
/** * 통합 인덱스 & 검색 엔진 */ import { logger, MarkdownNote, normalizePath, IndexStats } from '@memory-mcp/common'; import type { LinkGraphNode } from '@memory-mcp/common'; import { createHash } from 'crypto'; import { DatabaseManager, type SqliteDatabase } from './database'; import { FtsSearchEngine } from './fts-index'; import { LinkGraphEngine } from './link-graph'; import type { IndexConfig, BatchIndexResult, SearchOptions, EnhancedSearchResult, BacklinkOptions, ConnectedNotesOptions, LinkRelation, OrphanNote, } from './types'; import { IndexingError } from './types'; const EMPTY_INDEXED_AT = new Date(0).toISOString(); type Statement = ReturnType<SqliteDatabase['prepare']>; type NoteStatsRow = { totalNotes: number; lastIndexedAt: string | null; }; type CountRow = { totalLinks: number; }; /** * 검색 엔진 최상위 퍼사드 */ export class IndexSearchEngine { private readonly manager: DatabaseManager; private readonly db: SqliteDatabase; private readonly fts: FtsSearchEngine; private readonly graph: LinkGraphEngine; private readonly upsertNoteStmt: Statement; private readonly deleteNoteStmt: Statement; private readonly noteStatsStmt: Statement; private readonly linkCountStmt: Statement; constructor(config: IndexConfig) { this.manager = new DatabaseManager(config); this.db = this.manager.getDatabase(); this.fts = new FtsSearchEngine(this.db); this.graph = new LinkGraphEngine(this.db); this.upsertNoteStmt = this.db.prepare(` INSERT INTO notes ( uid, title, category, file_path, project, tags, content_hash, created_at, updated_at, indexed_at ) VALUES ( @uid, @title, @category, @filePath, @project, @tags, @contentHash, @createdAt, @updatedAt, @indexedAt ) ON CONFLICT(uid) DO UPDATE SET title = excluded.title, category = excluded.category, file_path = excluded.file_path, project = excluded.project, tags = excluded.tags, content_hash = excluded.content_hash, created_at = excluded.created_at, updated_at = excluded.updated_at, indexed_at = excluded.indexed_at `); this.deleteNoteStmt = this.db.prepare(` DELETE FROM notes WHERE uid = ? `); this.noteStatsStmt = this.db.prepare(` SELECT COUNT(*) as totalNotes, MAX(indexed_at) as lastIndexedAt FROM notes `); this.linkCountStmt = this.db.prepare(` SELECT COUNT(*) as totalLinks FROM links `); } /** * 노트를 인덱싱합니다. */ indexNote(note: MarkdownNote): void { try { const tx = this.db.transaction(() => { this.persistNoteMetadata(note); this.fts.updateNote(note); this.graph.updateLinksForNote(note); }); tx(); logger.debug('노트 인덱싱 완료', { uid: note.frontMatter.id }); } catch (error) { logger.error('노트 인덱싱 실패', { uid: note.frontMatter.id, error }); throw new IndexingError(`노트 인덱싱 실패: ${note.frontMatter.id}`, error); } } /** * 노트 삭제를 처리합니다. */ removeNote(noteUid: string): void { try { const tx = this.db.transaction(() => { this.deleteNoteStmt.run(noteUid); this.fts.removeNote(noteUid); this.graph.removeLinksForSource(noteUid); this.graph.removeLinksToTarget(noteUid); }); tx(); logger.debug('노트 삭제 인덱스 반영 완료', { uid: noteUid }); } catch (error) { logger.error('노트 삭제 처리 실패', { uid: noteUid, error }); throw new IndexingError(`노트 삭제 실패: ${noteUid}`, error); } } /** * 여러 노트를 배치 인덱싱합니다. */ batchIndexNotes(notes: MarkdownNote[]): BatchIndexResult { const failures: BatchIndexResult['failures'] = []; let successful = 0; const start = Date.now(); for (const note of notes) { try { this.indexNote(note); successful += 1; } catch (error) { const message = error instanceof Error ? error.message : String(error); failures.push({ noteUid: note.frontMatter.id, error: message, }); } } return { successful, failed: failures.length, totalTimeMs: Date.now() - start, failures, }; } /** * 전문 검색을 수행합니다. */ async search(query: string, options: SearchOptions = {}): Promise<EnhancedSearchResult> { return this.fts.searchNotes(query, options); } /** * 백링크를 조회합니다. */ getBacklinks(targetUid: string, options: BacklinkOptions = {}): LinkRelation[] { return this.graph.getBacklinks(targetUid, options); } /** * 아웃바운드 링크를 조회합니다. */ getOutgoingLinks(sourceUid: string): LinkRelation[] { return this.graph.getOutgoingLinks(sourceUid); } /** * 연결된 노트들을 탐색합니다. */ getConnectedNodes(startUid: string, options: ConnectedNotesOptions = {}): LinkGraphNode[] { return this.graph.getConnectedNodes(startUid, options); } /** * 고아 노트 목록을 반환합니다. */ getOrphanNotes(): OrphanNote[] { return this.graph.getOrphanNotes(); } /** * 인덱스 통계를 조회합니다. */ getIndexStats(): IndexStats { const dbStats = this.manager.getStats(); const noteStats = (this.noteStatsStmt as any).get() as NoteStatsRow; const linkStats = (this.linkCountStmt as any).get() as CountRow; return { totalNotes: noteStats.totalNotes, totalLinks: linkStats.totalLinks, lastIndexedAt: noteStats.lastIndexedAt ?? EMPTY_INDEXED_AT, indexSizeBytes: dbStats.dbSizeBytes, }; } /** * 인덱스를 최적화합니다. */ optimize(): void { this.fts.optimize(); this.manager.optimize(); } /** * 리소스를 정리합니다. */ close(): void { this.fts.cleanup(); this.manager.close(); } private persistNoteMetadata(note: MarkdownNote): void { const tagsJson = JSON.stringify(note.frontMatter.tags ?? []); const project = note.frontMatter.project ?? null; this.upsertNoteStmt.run({ uid: note.frontMatter.id, title: note.frontMatter.title, category: note.frontMatter.category, filePath: normalizePath(note.filePath), project, tags: tagsJson, contentHash: this.computeContentHash(note), createdAt: note.frontMatter.created, updatedAt: note.frontMatter.updated, indexedAt: new Date().toISOString(), }); } private computeContentHash(note: MarkdownNote): string { return createHash('sha256') .update(note.frontMatter.title) .update('\n') .update(note.content) .digest('hex'); } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/inchan/memory-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server