// module-memory-graph.js — Cross-session persistent memory graph // Nodes: files, concepts, tasks, patterns, users // Edges: relationships with weight, last_used // Commands: memgraph-status, memgraph-search, memgraph-stats, memgraph-export, memgraph-prune class MemoryGraphModule { constructor() { this.commands = {}; this.terminal = null; this._nodes = {}; this._edges = []; this._indexPath = '/.memory_graph/'; this._debounceTimer = null; this._initialized = false; this._totalNodeAccesses = 0; } getHelpInfo() { return { 'memgraph-status': 'memgraph-status — Show node/edge counts, last access', 'memgraph-search': 'memgraph-search — Find nodes by label or type', 'memgraph-stats': 'memgraph-stats — Most connected nodes, strongest edges, cluster info', 'memgraph-export': 'memgraph-export — Export graph as JSON', 'memgraph-prune': 'memgraph-prune — Remove stale low-weight edges (>30d, <3 accesses)', }; } registerCommands() { this.commands['memgraph-status'] = () => this.showStatus(); this.commands['memgraph-search'] = (args) => this.search(args.join(' ')); this.commands['memgraph-stats'] = () => this.showStats(); this.commands['memgraph-export'] = () => this.exportGraph(); this.commands['memgraph-prune'] = () => this.prune(); } init(terminal) { this.terminal = terminal; this._loadFromVFS(); this._initialized = true; this.terminal.print('🧠 Memory Graph loaded — cross-session context active', 'info'); // Log startup for graph this.addNode('session', `session-${Date.now()}`, `Session ${new Date().toLocaleDateString()}`); } // ─── Node API ─── addNode(type, id, label, metadata = {}) { const key = `${type}:${id}`; if (!this._nodes[key]) { this._nodes[key] = { id: key, type, label: label || id, metadata: metadata || {}, created: new Date().toISOString(), updated: new Date().toISOString(), access_count: 1 }; } else { this._nodes[key].updated = new Date().toISOString(); this._nodes[key].access_count++; if (metadata && Object.keys(metadata).length > 0) { Object.assign(this._nodes[key].metadata, metadata); } } this._totalNodeAccesses++; this._debounceSave(); return key; } getNode(type, id) { return this._nodes[`${type}:${id}`] || null; } findNodes(query) { const q = query.toLowerCase(); return Object.values(this._nodes).filter(n => n.label.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) || (n.id && n.id.toLowerCase().includes(q)) ); } // ─── Edge API ─── addEdge(sourceKey, targetKey, type, weight = 1) { // Check if edge already exists let edge = this._edges.find(e => e.source === sourceKey && e.target === targetKey && e.type === type ); if (edge) { edge.weight = (edge.weight + weight) / 2; edge.last_used = new Date().toISOString(); edge.use_count++; } else { this._edges.push({ source: sourceKey, target: targetKey, type, weight, created: new Date().toISOString(), last_used: new Date().toISOString(), use_count: 1 }); } this._debounceSave(); } // ─── Event-based Learning ─── recordFileAccess(path) { const name = path.split('/').pop() || path; const nodeKey = this.addNode('file', path, name); const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; this.addEdge(sessionKey, nodeKey, 'accessed', 0.5); // Extract file extension edge const ext = path.split('.').pop(); if (ext && ext.length < 10) { this.addNode('type', ext, `.${ext} files`); this.addEdge(nodeKey, `type:${ext}`, 'has_type', 0.3); } } recordToolCall(toolName, success) { const nodeKey = this.addNode('tool', toolName, toolName); const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; this.addEdge(sessionKey, nodeKey, success ? 'used' : 'failed', success ? 0.5 : 0.8); } recordConcept(concept, filePath) { const conceptKey = this.addNode('concept', concept, concept); const fileKey = this.addNode('file', filePath, filePath.split('/').pop()); this.addEdge(conceptKey, fileKey, 'maps_to', 0.7); this.addEdge(fileKey, conceptKey, 'tagged_as', 0.7); } recordTask(task, result, agentId) { const taskId = `task-${Date.now()}`; const taskKey = this.addNode('task', taskId, task.substring(0, 60), { full: task, result: result?.substring(0, 100), agent: agentId }); const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; this.addEdge(sessionKey, taskKey, 'executed', 0.6); // Extract keywords as concepts const words = task.toLowerCase().split(/\s+/).filter(w => w.length > 3); for (const word of [...new Set(words)].slice(0, 5)) { this.addNode('concept', word, word); this.addEdge(taskKey, `concept:${word}`, 'involves', 0.4); } // Link to tools used (if any found in task) const knownTools = ['list_files', 'read_file', 'write_file', 'web_search', 'web_fetch', 'edit_module', 'view_module', 'run_command', 'orchestrate']; for (const tool of knownTools) { if (task.toLowerCase().includes(tool.replace('_', ' '))) { this.recordToolCall(tool, true); } } } // ─── Context Injection ─── getContextSummary() { // Get top active nodes from recent session const now = Date.now(); const today = `session:session-${Math.floor(now / 86400000)}`; const yesterday = `session:session-${Math.floor(now / 86400000) - 1}`; const lines = []; const totalNodes = Object.keys(this._nodes).length; const totalEdges = this._edges.length; lines.push(`Memory Graph: ${totalNodes} nodes, ${totalEdges} edges`); // Recent task context const recentTasks = Object.values(this._nodes) .filter(n => n.type === 'task') .sort((a, b) => new Date(b.updated) - new Date(a.updated)) .slice(0, 3); if (recentTasks.length > 0) { lines.push('Recent tasks:'); for (const t of recentTasks) { lines.push(` - ${t.label.substring(0, 50)}`); } } // Most accessed files const topFiles = Object.values(this._nodes) .filter(n => n.type === 'file') .sort((a, b) => b.access_count - a.access_count) .slice(0, 3); if (topFiles.length > 0) { lines.push('Most accessed files:'); for (const f of topFiles) { lines.push(` - ${f.label} (${f.access_count}x)`); } } return lines.join('\\n'); } getContextForPrompt() { const summary = this.getContextSummary(); if (!summary) return ''; return `[Memory Graph Context]\\n${summary}\\n`; } // ─── Commands ─── showStatus() { const types = {}; for (const n of Object.values(this._nodes)) { types[n.type] = (types[n.type] || 0) + 1; } const typeStr = Object.entries(types) .sort((a, b) => b[1] - a[1]) .map(([t, c]) => `${t}: ${c}`) .join(', '); this.terminal.print('🧠 Memory Graph', 'header'); this.terminal.print(` Nodes: ${Object.keys(this._nodes).length} (${typeStr})`, 'info'); this.terminal.print(` Edges: ${this._edges.length}`, 'info'); this.terminal.print(` Total accesses: ${this._totalNodeAccesses}`, 'info'); this.terminal.print(` Path: ${this._indexPath}`, 'info'); } search(query) { if (!query) return this.terminal.print('Usage: memgraph-search ', 'error'); const results = this.findNodes(query); if (results.length === 0) return this.terminal.print(`No nodes matching "${query}"`, 'info'); this.terminal.print(`🔍 Found ${results.length} nodes:`, 'header'); for (const n of results.slice(0, 20)) { const edges = this._edges.filter(e => e.source === n.id || e.target === n.id); this.terminal.print(` ${n.type}:${n.label} (${edges.length} edges, ${n.access_count}x)`, 'info'); for (const e of edges.slice(0, 3)) { const other = e.source === n.id ? e.target : e.source; const otherLabel = (this._nodes[other] || {}).label || other; this.terminal.print(` └─ ${e.type} → ${otherLabel} (w:${e.weight.toFixed(1)})`, 'text-muted'); } } if (results.length > 20) this.terminal.print(` ... and ${results.length - 20} more`, 'info'); } showStats() { if (Object.keys(this._nodes).length === 0) { return this.terminal.print('No graph data yet. Use commands normally to build it.', 'info'); } // Most connected nodes const connected = {}; for (const e of this._edges) { connected[e.source] = (connected[e.source] || 0) + 1; connected[e.target] = (connected[e.target] || 0) + 1; } const top = Object.entries(connected) .sort((a, b) => b[1] - a[1]) .slice(0, 5); this.terminal.print('📊 Memory Graph Stats', 'header'); this.terminal.print(' Most connected:', 'info'); for (const [key, count] of top) { const label = (this._nodes[key] || {}).label || key; this.terminal.print(` ${label} — ${count} connections`, 'info'); } // Strongest edges const strong = [...this._edges].sort((a, b) => b.weight - a.weight).slice(0, 3); if (strong.length > 0) { this.terminal.print(' Strongest relationships:', 'info'); for (const e of strong) { const sLabel = (this._nodes[e.source] || {}).label || e.source; const tLabel = (this._nodes[e.target] || {}).label || e.target; this.terminal.print(` ${sLabel} → ${tLabel} (${e.type}, w:${e.weight.toFixed(2)}, used ${e.use_count}x)`, 'info'); } } // Cluster density const density = this._edges.length / Math.max(Object.keys(this._nodes).length, 1); this.terminal.print(` Density: ${density.toFixed(2)} edges/node`, 'info'); } exportGraph() { const exportData = { version: 1, exported: new Date().toISOString(), nodes: Object.values(this._nodes), edges: this._edges, stats: { totalNodes: Object.keys(this._nodes).length, totalEdges: this._edges.length, totalAccesses: this._totalNodeAccesses } }; const json = JSON.stringify(exportData, null, 2); // Save to VFS try { if (!this.terminal.fs.readDir(this._indexPath)) { this.terminal.fs.mkdirRecursive(this._indexPath); } this.terminal.fs.writeFile(`${this._indexPath}export.json`, json); this.terminal.print(`✅ Graph exported (${json.length} bytes) to ${this._indexPath}export.json`, 'success'); } catch (e) { this.terminal.print(`❌ Export failed: ${e.message}`, 'error'); } } prune() { const now = Date.now(); const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; const before = this._edges.length; this._edges = this._edges.filter(e => { const age = now - new Date(e.last_used).getTime(); // Keep if: used recently (<30d) OR used 3+ times regardless of age if (age < THIRTY_DAYS) return true; if (e.use_count >= 3) return true; // Or if weight is high if (e.weight > 0.8) return true; return false; }); const pruned = before - this._edges.length; // Also prune orphan nodes (no edges) const connectedNodes = new Set(); for (const e of this._edges) { connectedNodes.add(e.source); connectedNodes.add(e.target); } const nodeBefore = Object.keys(this._nodes).length; for (const key of Object.keys(this._nodes)) { if (!connectedNodes.has(key)) { // Keep session and high-access nodes if (this._nodes[key].access_count >= 5) continue; delete this._nodes[key]; } } const nodesPruned = nodeBefore - Object.keys(this._nodes).length; this._saveNow(); this.terminal.print(`🧹 Pruned ${pruned} edges and ${nodesPruned} orphan nodes`, nodesPruned > 0 ? 'warning' : 'info'); } // ─── Persistence ─── _loadFromVFS() { try { const fs = this.terminal.fs; if (!fs) return; // Try loading from export first const exportData = fs.fsReadJSON(`${this._indexPath}export.json`); if (exportData && exportData.nodes) { for (const n of exportData.nodes) { this._nodes[n.id] = n; } this._edges = exportData.edges || []; this._totalNodeAccesses = (exportData.stats?.totalAccesses) || 0; return; } // Fallback: load nodes and edges separately const nodes = fs.fsReadJSON(`${this._indexPath}nodes.json`); if (nodes) this._nodes = nodes; const edges = fs.fsReadJSON(`${this._indexPath}edges.json`); if (edges) this._edges = edges; } catch (e) { console.warn('[MemoryGraph] Load error (fresh start):', e.message); } } _saveNow() { try { const fs = this.terminal.fs; if (!fs) return; if (!fs.readDir(this._indexPath)) { fs.mkdirRecursive(this._indexPath); } fs.fsWriteJSON(`${this._indexPath}nodes.json`, this._nodes); fs.fsWriteJSON(`${this._indexPath}edges.json`, this._edges); } catch (e) { console.error('[MemoryGraph] Save error:', e.message); } } _debounceSave() { if (this._debounceTimer) clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(() => this._saveNow(), 2000); } } // Required for module system if (typeof window !== 'undefined') { window.MemoryGraphModule = MemoryGraphModule; }