aboutsummaryrefslogtreecommitdiff
path: root/.bkp-fix-20261023_143429/module-memory-graph.js
diff options
context:
space:
mode:
Diffstat (limited to '.bkp-fix-20261023_143429/module-memory-graph.js')
-rw-r--r--.bkp-fix-20261023_143429/module-memory-graph.js663
1 files changed, 663 insertions, 0 deletions
diff --git a/.bkp-fix-20261023_143429/module-memory-graph.js b/.bkp-fix-20261023_143429/module-memory-graph.js
new file mode 100644
index 0000000..ffc79e0
--- /dev/null
+++ b/.bkp-fix-20261023_143429/module-memory-graph.js
@@ -0,0 +1,663 @@
+// 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;
+ // v4.5.89: Decay configuration
+ this._decayDays = 30;
+ this._decayMinUses = 3;
+ this._decayRate = 0.01; // Daily decay rate
+ this._lastDecayRun = 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)',
+ 'memgraph-learn': 'memgraph-learn <entity> <fact> — Add a fact to the memory graph',
+ 'memgraph-session': 'memgraph-session [date] — Show graph nodes for a session (default: today)',
+ 'memgraph-agent': 'memgraph-agent <agentId> — Show namespaced nodes for an agent',
+ 'memgraph-decay-config': 'memgraph-decay-config <days> <min_uses> — Configure decay policy (default: 30d, 3 uses)',
+ };
+ }
+
+ 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();
+ this.commands['memgraph-learn'] = (args) => this.learn(args);
+ this.commands['memgraph-session'] = (args) => this.showSession(args.join(' '));
+ this.commands['memgraph-agent'] = (args) => this.showAgent(args.join(' '));
+ // v4.5.89: Decay configuration
+ this.commands['memgraph-decay-config'] = (args) => this.decayConfig(args);
+ }
+
+ 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
+ // v4.5.89 fix: Use day-floored key to match edge source keys (P0-1 from review)
+ this.addNode('session', `session-${Math.floor(Date.now() / 86400000)}`, `Session ${new Date().toLocaleDateString()}`);
+ }
+
+ // ─── Node API ───
+
+ addNode(type, id, label, metadata = {}, agentId = null) {
+ // v4.5.88: Support agent namespacing with dual-lookup
+ const ns = agentId ? `agent:${agentId}` : 'global';
+ const key = agentId ? `${ns}:${type}:${id}` : `${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,
+ namespace: ns,
+ source_session: `session-${Math.floor(Date.now() / 86400000)}`
+ };
+ } 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;
+ }
+
+ // v4.5.88: Dual-lookup — try namespaced key first, fall back to global
+ getNode(type, id, agentId = null) {
+ if (agentId) {
+ const nsKey = `agent:${agentId}:${type}:${id}`;
+ if (this._nodes[nsKey]) return this._nodes[nsKey];
+ }
+ const globalKey = `${type}:${id}`;
+ return this._nodes[globalKey] || 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 ───
+
+ // v4.5.88: addEdge tracks source_agents for multi-agent attribution
+ addEdge(sourceKey, targetKey, type, weight = 1, agentId = null) {
+ // 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++;
+ if (agentId) edge.source_agents = [...new Set([...(edge.source_agents || []), agentId])];
+ } else {
+ this._edges.push({
+ source: sourceKey,
+ target: targetKey,
+ type,
+ weight,
+ created: new Date().toISOString(),
+ last_used: new Date().toISOString(),
+ use_count: 1,
+ source_agents: agentId ? [agentId] : []
+ });
+ }
+ this._debounceSave();
+ }
+
+ // ─── Event-based Learning ───
+
+ // v4.5.89 fix: Thread agentId through record* methods (P1-3 from review)
+ recordFileAccess(path, agentId = null) {
+ const name = path.split('/').pop() || path;
+ const nodeKey = this.addNode('file', path, name, {}, agentId);
+ const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`;
+ this.addEdge(sessionKey, nodeKey, 'accessed', 0.5, agentId);
+
+ // 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, agentId = null) {
+ const nodeKey = this.addNode('tool', toolName, toolName, {}, agentId);
+ const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`;
+ this.addEdge(sessionKey, nodeKey, success ? 'used' : 'failed', success ? 0.5 : 0.8, agentId);
+ }
+
+ 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);
+ }
+
+ // v4.5.88: recordTask now passes agentId for namespacing + adds source_session link
+ 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
+ }, agentId);
+ const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`;
+ this.addEdge(sessionKey, taskKey, 'executed', 0.6, agentId);
+
+ // 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 '';
+ // Suppress noise: don't inject when graph is nearly empty
+ const totalNodes = Object.keys(this._nodes).length;
+ const totalEdges = this._edges.length;
+ if (totalNodes <= 2 && totalEdges === 0) return '';
+ return `[Memory Graph Context]\n${summary}\n`;
+ }
+
+ // Alias for orchestrator compatibility (orchestrator calls getStats, not showStats)
+ getStats() {
+ const types = {};
+ for (const n of Object.values(this._nodes)) {
+ types[n.type] = (types[n.type] || 0) + 1;
+ }
+ return {
+ nodes: Object.keys(this._nodes).length,
+ edges: this._edges.length,
+ types: types,
+ totalAccesses: this._totalNodeAccesses
+ };
+ }
+
+ // ─── Commands ───
+
+ showStatus() {
+ // v4.5.89: Lazy decay — run applyDecay() on status check
+ this.applyDecay();
+ 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');
+ // v4.5.89: Show decay config
+ this.terminal.print(` Decay: ${this._decayDays}d threshold, ${this._decayMinUses} min uses, rate ${this._decayRate}/day`, 'text-muted');
+ }
+
+ 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');
+ }
+
+ // v4.5.89: Apply decay scores to nodes based on age and access patterns
+ applyDecay() {
+ const now = Date.now();
+ if (now - this._lastDecayRun < 3600000) return;
+ this._lastDecayRun = now;
+
+ // v4.5.89b fix: Compute decay rate from _decayDays (P0 from review)
+ const effectiveRate = 0.9 / this._decayDays;
+ let decayed = 0;
+ let removed = 0;
+
+ for (const key of Object.keys(this._nodes)) {
+ const node = this._nodes[key];
+ const ageDays = (now - new Date(node.updated || node.created).getTime()) / 86400000;
+ node.decay_score = Math.max(0, 1.0 - (ageDays * effectiveRate));
+ if (node.decay_score < 0.1 && node.access_count < this._decayMinUses) {
+ if (node.type === 'session') continue;
+ if (node.namespace && node.namespace.startsWith('agent:')) continue;
+ delete this._nodes[key];
+ removed++;
+ continue;
+ }
+ decayed++;
+ }
+
+ this._edges = this._edges.filter(e => {
+ const ageDays = (now - new Date(e.last_used).getTime()) / 86400000;
+ e.decay_score = Math.max(0, 1.0 - (ageDays * effectiveRate));
+ return !(e.decay_score < 0.1 && (e.use_count || 1) < this._decayMinUses);
+ });
+
+ if (removed > 0) {
+ this._debounceSave();
+ console.log('[MemoryGraph] Decay: ' + decayed + ' scored, ' + removed + ' auto-removed');
+ }
+
+ // v4.5.90: Check for stale skills
+ var staleSkills = this.getStaleSkills();
+ if (staleSkills.length > 0) {
+ this.terminal.print('🧠 Stale skills: ' + staleSkills.length + ' (' + staleSkills.map(function(s) { return s.name; }).join(', ') + ')', 'warning');
+ }
+ }
+
+ // v4.5.90: Record skill usage for decay tracking
+ recordSkillUse(name, success) {
+ try {
+ const fs = this.terminal.fs;
+ if (!fs) return;
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ if (!index[name]) index[name] = { useCount: 0, successCount: 0 };
+ index[name].lastUsed = new Date().toISOString();
+ index[name].useCount = (index[name].useCount || 0) + 1;
+ if (success !== false) {
+ index[name].successCount = (index[name].successCount || 0) + 1;
+ }
+ fs.fsWriteJSON('/skills/index.json', index);
+ } catch (e) {
+ console.warn('[MemoryGraph] Skill use tracking error:', e.message);
+ }
+ }
+
+ // v4.5.90: Get stale skills (unused >_decayDays, useCount == 0, not immune)
+ getStaleSkills() {
+ try {
+ const fs = this.terminal.fs;
+ if (!fs) return [];
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ const now = Date.now();
+ // v4.5.90b fix: Use configurable _decayDays (P1 from review)
+ const staleThreshold = this._decayDays * 24 * 60 * 60 * 1000;
+ var stale = [];
+ for (var name of Object.keys(index)) {
+ var stats = index[name] || {};
+ if (stats.immune) continue;
+ var useCount = stats.useCount || 0;
+ var lastUsed = stats.lastUsed ? new Date(stats.lastUsed).getTime() : 0;
+ if (useCount === 0 && (now - lastUsed > staleThreshold || lastUsed === 0)) {
+ var daysUnused = lastUsed > 0 ? Math.floor((now - lastUsed) / 86400000) : 999;
+ stale.push({ name: name, daysUnused: daysUnused });
+ }
+ }
+ return stale;
+ } catch (e) {
+ return [];
+ }
+ }
+
+ // v4.5.89: Configure decay policy
+ decayConfig(args) {
+ if (args.length < 2) {
+ this.terminal.print('Usage: memgraph-decay-config <days> <min_uses>', 'error');
+ this.terminal.print(' Current: ' + this._decayDays + 'd threshold, ' + this._decayMinUses + ' min uses', 'info');
+ this.terminal.print(' Decay rate: ' + this._decayRate + '/day (0.01 = 1% per day)', 'info');
+ return;
+ }
+ const days = parseInt(args[0]);
+ const minUses = parseInt(args[1]);
+ if (isNaN(days) || days < 1) return this.terminal.print('Days must be >= 1', 'error');
+ if (isNaN(minUses) || minUses < 0) return this.terminal.print('Min uses must be >= 0', 'error');
+ this._decayDays = days;
+ this._decayMinUses = minUses;
+ this._debounceSave();
+ this.terminal.print('✅ Decay config: ' + days + 'd threshold, ' + minUses + ' min uses', 'success');
+ }
+
+ learn(args) {
+ if (!args || args.length < 2) return this.terminal.print('Usage: memgraph-learn <entity> <fact>', 'error');
+ const entity = args[0];
+ const fact = args.slice(1).join(' ');
+ this.addNode('concept', entity, entity);
+ const factKey = this.addNode('fact', entity + '-' + Date.now(), fact);
+ const conceptKey = `concept:${entity}`;
+ this.addEdge(conceptKey, factKey, 'has_fact', 0.9);
+ this.terminal.print(`✅ Learned: ${entity} → ${fact}`, 'success');
+ // v4.5.89b fix: Index only the new fact, not all facts (P2 from review — O(n) → O(1))
+ const nvfs = this.terminal.moduleManager.modules['NeuralVFSModule'];
+ if (nvfs && typeof nvfs.indexGraphFacts === 'function') {
+ nvfs.indexSingleFact(factKey, entity, fact);
+ }
+ }
+
+ // v4.5.88: Show graph nodes for a specific session
+ showSession(dateStr) {
+ let sessionKey;
+ if (dateStr) {
+ const d = new Date(dateStr);
+ if (isNaN(d.getTime())) return this.terminal.print('Usage: memgraph-session [YYYY-MM-DD]', 'error');
+ sessionKey = `session-${Math.floor(d.getTime() / 86400000)}`;
+ } else {
+ sessionKey = `session-${Math.floor(Date.now() / 86400000)}`;
+ }
+
+ const sessionNodes = Object.values(this._nodes).filter(function(n) {
+ return n.source_session === sessionKey || n.type === 'session' && n.id.includes(sessionKey);
+ });
+
+ this.terminal.print('📅 Session Graph: ' + sessionKey, 'header');
+ if (sessionNodes.length === 0) {
+ this.terminal.print(' No nodes found for this session', 'info');
+ return;
+ }
+ this.terminal.print(' ' + sessionNodes.length + ' nodes:', 'info');
+ for (var n of sessionNodes.slice(0, 15)) {
+ var ns = n.namespace || 'global';
+ this.terminal.print(' ' + n.type + ':' + n.label + ' (' + ns + ', ' + n.access_count + 'x)', 'info');
+ }
+ var sessionEdges = this._edges.filter(function(e) {
+ return sessionNodes.some(function(n) { return n.id === e.source || n.id === e.target; });
+ });
+ this.terminal.print(' ' + sessionEdges.length + ' edges', 'info');
+ }
+
+ // v4.5.88: Show namespaced nodes for a specific agent
+ showAgent(agentId) {
+ if (!agentId) return this.terminal.print('Usage: memgraph-agent <agentId>', 'error');
+ var ns = 'agent:' + agentId;
+ var agentNodes = Object.values(this._nodes).filter(function(n) {
+ return n.namespace === ns;
+ });
+ var agentEdges = this._edges.filter(function(e) {
+ return (e.source_agents || []).includes(agentId);
+ });
+
+ this.terminal.print('🤖 Agent Memory: ' + agentId, 'header');
+ if (agentNodes.length === 0) {
+ this.terminal.print(' No namespaced nodes for this agent', 'info');
+ this.terminal.print(' (Agent may have used global namespace)', 'text-muted');
+ return;
+ }
+ this.terminal.print(' ' + agentNodes.length + ' nodes, ' + agentEdges.length + ' edges:', 'info');
+ for (var n of agentNodes.slice(0, 15)) {
+ this.terminal.print(' ' + n.type + ':' + n.label + ' (' + n.access_count + 'x)', '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) {
+ // v4.5.89 fix: Migrate old-format nodes (P1-1 from review)
+ if (!n.namespace) n.namespace = 'global';
+ if (!n.source_session) {
+ const createdMs = new Date(n.created).getTime();
+ n.source_session = isNaN(createdMs) ? null : `session-${Math.floor(createdMs / 86400000)}`;
+ }
+ this._nodes[n.id] = n;
+ }
+ this._edges = exportData.edges || [];
+ for (const e of this._edges) {
+ if (!e.source_agents) e.source_agents = [];
+ }
+ 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;
+ for (const n of Object.values(this._nodes)) {
+ if (!n.namespace) n.namespace = 'global';
+ if (!n.source_session) {
+ const createdMs = new Date(n.created).getTime();
+ n.source_session = isNaN(createdMs) ? null : `session-${Math.floor(createdMs / 86400000)}`;
+ }
+ }
+ }
+ const edges = fs.fsReadJSON(`${this._indexPath}edges.json`);
+ if (edges) {
+ this._edges = edges;
+ for (const e of this._edges) {
+ if (!e.source_agents) e.source_agents = [];
+ }
+ }
+ // v4.5.89b fix: Load persisted decay config (P1 from review)
+ const config = fs.fsReadJSON(`${this._indexPath}config.json`);
+ if (config) {
+ if (typeof config.decayDays === 'number') this._decayDays = config.decayDays;
+ if (typeof config.decayMinUses === 'number') this._decayMinUses = config.decayMinUses;
+ if (typeof config.lastDecayRun === 'number') this._lastDecayRun = config.lastDecayRun;
+ }
+ } 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);
+ // v4.5.89b fix: Persist decay config (P1 from review)
+ fs.fsWriteJSON(`${this._indexPath}config.json`, {
+ decayDays: this._decayDays,
+ decayMinUses: this._decayMinUses,
+ lastDecayRun: this._lastDecayRun
+ });
+ } 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