aboutsummaryrefslogtreecommitdiff
path: root/modules/core/module-memory-graph.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-06-05 18:01:01 +0200
committerAgando <roduccomm@gmail.com>2026-06-05 18:01:01 +0200
commitb538577cd0a4292e4cd4ca1c9f9829fc7e133caa (patch)
tree04d76bf4f0d680cdb26b1a205481bc8628cd77b4 /modules/core/module-memory-graph.js
parent5092f9a0ce02691dc476d7b64fa731a1518dd834 (diff)
auto: 2026-06-05_18:01 changes
Diffstat (limited to 'modules/core/module-memory-graph.js')
-rw-r--r--modules/core/module-memory-graph.js400
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