Skip to main content
Glama
MemoryManager.ts23.8 kB
// SQLite-based memory management system with Knowledge Graph support (v2.0) // Replaces JSON file storage with proper database import Database from 'better-sqlite3'; import path from 'path'; import { promises as fs } from 'fs'; import { MemoryRelation, MemoryGraph, MemoryGraphNode } from '../types/tool.js'; export interface MemoryItem { key: string; value: string; category: string; timestamp: string; lastAccessed: string; priority?: number; } export class MemoryManager { private db: Database.Database; private static instance: MemoryManager | null = null; private readonly dbPath: string; private recallStmt: Database.Statement | null = null; private saveStmt: Database.Statement | null = null; private recallSelectStmt: Database.Statement | null = null; private recallUpdateStmt: Database.Statement | null = null; private constructor(customDbPath?: string) { if (customDbPath) { this.dbPath = customDbPath; } else { const memoryDir = path.join(process.cwd(), 'memories'); this.dbPath = path.join(memoryDir, 'memories.db'); // Ensure directory exists synchronously (needed for DB init) try { require('fs').mkdirSync(memoryDir, { recursive: true }); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code !== 'EEXIST') { throw new Error(`Failed to create memory directory: ${nodeError.message}`); } } } this.db = new Database(this.dbPath); this.initializeDatabase(); // Only migrate if using default path (not for tests) if (!customDbPath) { this.migrateFromJSON(); } } private static cleanupRegistered = false; public static getInstance(customDbPath?: string): MemoryManager { if (!MemoryManager.instance) { MemoryManager.instance = new MemoryManager(customDbPath); // Register cleanup handlers only once if (!MemoryManager.cleanupRegistered) { MemoryManager.cleanupRegistered = true; // Increase max listeners to avoid warnings in test environments process.setMaxListeners(Math.max(process.getMaxListeners(), 15)); // Register cleanup on process exit to prevent memory leaks const cleanup = () => { if (MemoryManager.instance) { MemoryManager.instance.close(); } }; process.on('exit', cleanup); process.on('SIGINT', () => { cleanup(); process.exit(0); }); process.on('SIGTERM', () => { cleanup(); process.exit(0); }); } } return MemoryManager.instance; } private initializeDatabase(): void { // Create memories table this.db.exec(` CREATE TABLE IF NOT EXISTS memories ( key TEXT PRIMARY KEY, value TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'general', timestamp TEXT NOT NULL, lastAccessed TEXT NOT NULL, priority INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_category ON memories(category); CREATE INDEX IF NOT EXISTS idx_timestamp ON memories(timestamp); CREATE INDEX IF NOT EXISTS idx_priority ON memories(priority); CREATE INDEX IF NOT EXISTS idx_lastAccessed ON memories(lastAccessed); `); // v2.0: Create memory_relations table for Knowledge Graph this.db.exec(` CREATE TABLE IF NOT EXISTS memory_relations ( id INTEGER PRIMARY KEY AUTOINCREMENT, sourceKey TEXT NOT NULL, targetKey TEXT NOT NULL, relationType TEXT NOT NULL, strength REAL DEFAULT 1.0, metadata TEXT, timestamp TEXT NOT NULL, UNIQUE(sourceKey, targetKey, relationType) ); CREATE INDEX IF NOT EXISTS idx_rel_source ON memory_relations(sourceKey); CREATE INDEX IF NOT EXISTS idx_rel_target ON memory_relations(targetKey); CREATE INDEX IF NOT EXISTS idx_rel_type ON memory_relations(relationType); `); // Enable WAL mode for better concurrency this.db.pragma('journal_mode = WAL'); // Pre-compile frequently used statements for performance this.initializePreparedStatements(); } private initializePreparedStatements(): void { // Pre-compile recall statement try { this.recallStmt = this.db.prepare(` UPDATE memories SET lastAccessed = ? WHERE key = ? RETURNING * `); } catch (error) { // RETURNING not supported, pre-compile fallback statements this.recallStmt = null; this.recallSelectStmt = this.db.prepare(`SELECT * FROM memories WHERE key = ?`); this.recallUpdateStmt = this.db.prepare(`UPDATE memories SET lastAccessed = ? WHERE key = ?`); } // Pre-compile save statement this.saveStmt = this.db.prepare(` INSERT OR REPLACE INTO memories (key, value, category, timestamp, lastAccessed, priority) VALUES (?, ?, ?, ?, ?, ?) `); } /** * Auto-migrate from JSON to SQLite database */ private migrateFromJSON(): void { const jsonPath = this.getJSONPath(); const memories = this.loadJSONMemories(jsonPath); if (memories.length === 0) return; this.importMemories(memories); this.backupAndCleanup(jsonPath, memories.length); } /** * Get JSON file path */ private getJSONPath(): string { return path.join(path.dirname(this.dbPath), 'memories.json'); } /** * Load memories from JSON file */ private loadJSONMemories(jsonPath: string): MemoryItem[] { try { const jsonData = require('fs').readFileSync(jsonPath, 'utf-8'); return JSON.parse(jsonData); } catch (error) { return []; } } /** * Import memories into SQLite database */ private importMemories(memories: MemoryItem[]): void { const insert = this.db.prepare(` INSERT OR REPLACE INTO memories (key, value, category, timestamp, lastAccessed, priority) VALUES (?, ?, ?, ?, ?, ?) `); const insertMany = this.db.transaction((items: MemoryItem[]) => { for (const item of items) { insert.run( item.key, item.value, item.category || 'general', item.timestamp, item.lastAccessed, item.priority || 0 ); } }); insertMany(memories); } /** * Backup JSON file and log migration */ private backupAndCleanup(jsonPath: string, count: number): void { try { require('fs').renameSync(jsonPath, `${jsonPath}.backup`); // Migration successful - could add logger here } catch (error) { // Backup failed but migration completed } } /** * Save or update a memory item * @param key - Unique identifier for the memory * @param value - Content to store * @param category - Category for organization (default: 'general') * @param priority - Priority level (default: 0) */ public save(key: string, value: string, category: string = 'general', priority: number = 0): void { const timestamp = new Date().toISOString(); if (this.saveStmt) { this.saveStmt.run(key, value, category, timestamp, timestamp, priority); } else { // Fallback if prepared statement not available const stmt = this.db.prepare(` INSERT OR REPLACE INTO memories (key, value, category, timestamp, lastAccessed, priority) VALUES (?, ?, ?, ?, ?, ?) `); stmt.run(key, value, category, timestamp, timestamp, priority); } } /** * Recall a memory item by key * @param key - Memory key to recall * @returns Memory item or null if not found */ public recall(key: string): MemoryItem | null { const timestamp = new Date().toISOString(); // Use pre-compiled statement if available if (this.recallStmt) { const result = this.recallStmt.get(timestamp, key) as MemoryItem | undefined; return result || null; } // Fallback for older SQLite versions (using pre-compiled statements) if (!this.recallSelectStmt || !this.recallUpdateStmt) { throw new Error('Fallback recall statements not initialized'); } const result = this.recallSelectStmt.get(key) as MemoryItem | undefined; if (result) { this.recallUpdateStmt.run(timestamp, key); } return result || null; } /** * Delete a memory item * @param key - Memory key to delete * @returns True if deleted successfully */ public delete(key: string): boolean { // Also delete related relations this.db.prepare(`DELETE FROM memory_relations WHERE sourceKey = ? OR targetKey = ?`).run(key, key); const stmt = this.db.prepare(` DELETE FROM memories WHERE key = ? `); const result = stmt.run(key); return result.changes > 0; } /** * Update a memory item's value * @param key - Memory key to update * @param value - New value * @returns True if updated successfully */ public update(key: string, value: string): boolean { const timestamp = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE memories SET value = ?, timestamp = ?, lastAccessed = ? WHERE key = ? `); const result = stmt.run(value, timestamp, timestamp, key); return result.changes > 0; } /** * List all memories or filter by category * @param category - Optional category filter * @returns Array of memory items */ public list(category?: string): MemoryItem[] { let stmt; if (category) { stmt = this.db.prepare(` SELECT * FROM memories WHERE category = ? ORDER BY priority DESC, timestamp DESC `); return stmt.all(category) as MemoryItem[]; } else { stmt = this.db.prepare(` SELECT * FROM memories ORDER BY priority DESC, timestamp DESC `); return stmt.all() as MemoryItem[]; } } /** * Search memories by keyword * @param query - Search query string * @returns Array of matching memory items */ public search(query: string): MemoryItem[] { const stmt = this.db.prepare(` SELECT * FROM memories WHERE key LIKE ? OR value LIKE ? ORDER BY priority DESC, timestamp DESC `); const pattern = `%${query}%`; return stmt.all(pattern, pattern) as MemoryItem[]; } /** * Get memories by priority level * @param priority - Priority level to filter * @returns Array of memory items with specified priority */ public getByPriority(priority: number): MemoryItem[] { const stmt = this.db.prepare(` SELECT * FROM memories WHERE priority = ? ORDER BY timestamp DESC `); return stmt.all(priority) as MemoryItem[]; } /** * Update priority of a memory item * @param key - Memory key * @param priority - New priority level * @returns True if updated successfully */ public setPriority(key: string, priority: number): boolean { const stmt = this.db.prepare(` UPDATE memories SET priority = ? WHERE key = ? `); const result = stmt.run(priority, key); return result.changes > 0; } /** * Get memory statistics (optimized to single query) * @returns Total count and count by category */ public getStats(): { total: number; byCategory: Record<string, number> } { // Single query with ROLLUP or combined approach const categories = this.db.prepare(` SELECT category, COUNT(*) as count FROM memories GROUP BY category `).all() as Array<{ category: string; count: number }>; const byCategory: Record<string, number> = {}; let total = 0; categories.forEach(cat => { byCategory[cat.category] = cat.count; total += cat.count; }); return { total, byCategory }; } // ============================================================================ // v2.0 - Knowledge Graph Methods // ============================================================================ /** * Link two memories with a relationship * @param sourceKey - Source memory key * @param targetKey - Target memory key * @param relationType - Type of relationship (e.g., 'related_to', 'depends_on', 'implements') * @param strength - Relationship strength (0.0 to 1.0) * @param metadata - Optional additional data */ public linkMemories( sourceKey: string, targetKey: string, relationType: string, strength: number = 1.0, metadata?: Record<string, any> ): boolean { const timestamp = new Date().toISOString(); const metadataJson = metadata ? JSON.stringify(metadata) : null; try { const stmt = this.db.prepare(` INSERT OR REPLACE INTO memory_relations (sourceKey, targetKey, relationType, strength, metadata, timestamp) VALUES (?, ?, ?, ?, ?, ?) `); stmt.run(sourceKey, targetKey, relationType, strength, metadataJson, timestamp); return true; } catch (error) { return false; } } /** * Get all relations for a memory * @param key - Memory key * @param direction - 'outgoing', 'incoming', or 'both' */ public getRelations(key: string, direction: 'outgoing' | 'incoming' | 'both' = 'both'): MemoryRelation[] { let sql = ''; if (direction === 'outgoing') { sql = `SELECT * FROM memory_relations WHERE sourceKey = ?`; } else if (direction === 'incoming') { sql = `SELECT * FROM memory_relations WHERE targetKey = ?`; } else { sql = `SELECT * FROM memory_relations WHERE sourceKey = ? OR targetKey = ?`; } const stmt = this.db.prepare(sql); const rows = direction === 'both' ? stmt.all(key, key) as any[] : stmt.all(key) as any[]; return rows.map(row => ({ sourceKey: row.sourceKey, targetKey: row.targetKey, relationType: row.relationType, strength: row.strength, metadata: row.metadata ? JSON.parse(row.metadata) : undefined, timestamp: row.timestamp })); } /** * Get related memories using graph traversal * @param key - Starting memory key * @param depth - How many levels to traverse * @param relationType - Optional filter by relation type */ public getRelatedMemories( key: string, depth: number = 1, relationType?: string ): MemoryItem[] { const visited = new Set<string>([key]); const result: MemoryItem[] = []; let currentLevel = [key]; for (let d = 0; d < depth; d++) { const nextLevel: string[] = []; for (const currentKey of currentLevel) { const relations = this.getRelations(currentKey, 'both'); for (const rel of relations) { if (relationType && rel.relationType !== relationType) continue; const neighborKey = rel.sourceKey === currentKey ? rel.targetKey : rel.sourceKey; if (!visited.has(neighborKey)) { visited.add(neighborKey); nextLevel.push(neighborKey); const memory = this.recall(neighborKey); if (memory) { result.push(memory); } } } } currentLevel = nextLevel; if (currentLevel.length === 0) break; } return result; } /** * Get memory graph structure * @param key - Starting memory key (optional, if not provided returns entire graph) * @param depth - Traversal depth */ public getMemoryGraph(key?: string, depth: number = 2): MemoryGraph { const nodes: MemoryGraphNode[] = []; const edges: MemoryRelation[] = []; const visited = new Set<string>(); if (key) { // BFS from starting key this.buildGraphFromKey(key, depth, visited, nodes, edges); } else { // Get all memories and relations const allMemories = this.list(); for (const memory of allMemories) { const relations = this.getRelations(memory.key, 'outgoing'); nodes.push({ key: memory.key, value: memory.value, category: memory.category, relations }); edges.push(...relations); } } // Detect clusters using simple connected components const clusters = this.detectClusters(nodes, edges); return { nodes, edges, clusters }; } private buildGraphFromKey( startKey: string, depth: number, visited: Set<string>, nodes: MemoryGraphNode[], edges: MemoryRelation[] ): void { const queue: Array<{ key: string; level: number }> = [{ key: startKey, level: 0 }]; while (queue.length > 0) { const { key, level } = queue.shift()!; if (visited.has(key) || level > depth) continue; visited.add(key); const memory = this.recall(key); if (!memory) continue; const relations = this.getRelations(key, 'both'); nodes.push({ key: memory.key, value: memory.value, category: memory.category, relations }); for (const rel of relations) { if (!edges.some(e => e.sourceKey === rel.sourceKey && e.targetKey === rel.targetKey && e.relationType === rel.relationType )) { edges.push(rel); } const neighborKey = rel.sourceKey === key ? rel.targetKey : rel.sourceKey; if (!visited.has(neighborKey) && level < depth) { queue.push({ key: neighborKey, level: level + 1 }); } } } } private detectClusters(nodes: MemoryGraphNode[], edges: MemoryRelation[]): string[][] { const parent: Record<string, string> = {}; // Initialize each node as its own parent for (const node of nodes) { parent[node.key] = node.key; } // Find with path compression const find = (x: string): string => { if (parent[x] !== x) { parent[x] = find(parent[x]); } return parent[x]; }; // Union const union = (x: string, y: string) => { const px = find(x); const py = find(y); if (px !== py) { parent[px] = py; } }; // Union all connected nodes for (const edge of edges) { if (parent[edge.sourceKey] !== undefined && parent[edge.targetKey] !== undefined) { union(edge.sourceKey, edge.targetKey); } } // Group by root const clusters: Record<string, string[]> = {}; for (const node of nodes) { const root = find(node.key); if (!clusters[root]) { clusters[root] = []; } clusters[root].push(node.key); } return Object.values(clusters).filter(c => c.length > 1); } /** * Find shortest path between two memories * @param sourceKey - Starting memory key * @param targetKey - Target memory key */ public findPath(sourceKey: string, targetKey: string): string[] | null { const visited = new Set<string>(); const queue: Array<{ key: string; path: string[] }> = [ { key: sourceKey, path: [sourceKey] } ]; while (queue.length > 0) { const { key, path } = queue.shift()!; if (key === targetKey) { return path; } if (visited.has(key)) continue; visited.add(key); const relations = this.getRelations(key, 'both'); for (const rel of relations) { const neighborKey = rel.sourceKey === key ? rel.targetKey : rel.sourceKey; if (!visited.has(neighborKey)) { queue.push({ key: neighborKey, path: [...path, neighborKey] }); } } } return null; } /** * Remove a relationship between memories */ public unlinkMemories(sourceKey: string, targetKey: string, relationType?: string): boolean { let sql = `DELETE FROM memory_relations WHERE sourceKey = ? AND targetKey = ?`; const params: any[] = [sourceKey, targetKey]; if (relationType) { sql += ` AND relationType = ?`; params.push(relationType); } const result = this.db.prepare(sql).run(...params); return result.changes > 0; } /** * Get memories sorted by time * @param startDate - Optional start date filter * @param endDate - Optional end date filter * @param limit - Maximum number of results */ public getTimeline(startDate?: string, endDate?: string, limit: number = 50): MemoryItem[] { let sql = `SELECT * FROM memories WHERE 1=1`; const params: any[] = []; if (startDate) { sql += ` AND timestamp >= ?`; params.push(startDate); } if (endDate) { sql += ` AND timestamp <= ?`; params.push(endDate); } sql += ` ORDER BY timestamp DESC LIMIT ?`; params.push(limit); return this.db.prepare(sql).all(...params) as MemoryItem[]; } /** * Advanced search with multiple strategies */ public searchAdvanced( query: string, strategy: 'keyword' | 'graph_traversal' | 'temporal' | 'priority' | 'context_aware', options: { limit?: number; category?: string; includeRelations?: boolean; startKey?: string; depth?: number; } = {} ): MemoryItem[] { const { limit = 20, category, includeRelations = false, startKey, depth = 2 } = options; switch (strategy) { case 'keyword': return this.searchKeyword(query, limit, category); case 'graph_traversal': if (!startKey) return this.searchKeyword(query, limit, category); return this.getRelatedMemories(startKey, depth); case 'temporal': return this.searchTemporal(query, limit); case 'priority': return this.searchByPriority(query, limit); case 'context_aware': return this.searchContextAware(query, limit, category); default: return this.search(query); } } private searchKeyword(query: string, limit: number, category?: string): MemoryItem[] { let sql = ` SELECT * FROM memories WHERE (key LIKE ? OR value LIKE ?) `; const params: any[] = [`%${query}%`, `%${query}%`]; if (category) { sql += ` AND category = ?`; params.push(category); } sql += ` ORDER BY priority DESC, timestamp DESC LIMIT ?`; params.push(limit); return this.db.prepare(sql).all(...params) as MemoryItem[]; } private searchTemporal(query: string, limit: number): MemoryItem[] { const sql = ` SELECT * FROM memories WHERE key LIKE ? OR value LIKE ? ORDER BY timestamp DESC LIMIT ? `; return this.db.prepare(sql).all(`%${query}%`, `%${query}%`, limit) as MemoryItem[]; } private searchByPriority(query: string, limit: number): MemoryItem[] { const sql = ` SELECT * FROM memories WHERE key LIKE ? OR value LIKE ? ORDER BY priority DESC, lastAccessed DESC LIMIT ? `; return this.db.prepare(sql).all(`%${query}%`, `%${query}%`, limit) as MemoryItem[]; } private searchContextAware(query: string, limit: number, category?: string): MemoryItem[] { // Combined strategy: keyword + priority + recency let sql = ` SELECT *, (CASE WHEN key LIKE ? THEN 3 ELSE 0 END + CASE WHEN value LIKE ? THEN 2 ELSE 0 END + priority * 0.5) as relevance_score FROM memories WHERE key LIKE ? OR value LIKE ? `; const params: any[] = [`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`]; if (category) { sql += ` AND category = ?`; params.push(category); } sql += ` ORDER BY relevance_score DESC, lastAccessed DESC LIMIT ?`; params.push(limit); return this.db.prepare(sql).all(...params) as MemoryItem[]; } /** * Close database connection */ public close(): void { if (this.db) { this.db.close(); MemoryManager.instance = null; } } /** * Reset singleton instance (useful for testing and cleanup) */ public static resetInstance(): void { if (MemoryManager.instance) { MemoryManager.instance.close(); } } }

Latest Blog Posts

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/su-record/hi-ai'

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