diff options
Diffstat (limited to 'modules/core/module-memory-graph.js')
| -rw-r--r-- | modules/core/module-memory-graph.js | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/modules/core/module-memory-graph.js b/modules/core/module-memory-graph.js new file mode 100644 index 0000000..2382a4a --- /dev/null +++ b/modules/core/module-memory-graph.js @@ -0,0 +1,400 @@ +// 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 <query> — 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 <query>', '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; +}
\ No newline at end of file |