// 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; } // ─── Id helpers ─── // Concept ids must be stable, punctuation-free slugs. The old split was whitespace-only, so // 'Neural VFS, v2!' became 'concept:Neural VFS, v2!' — an id that never matched the same // word written anywhere else (and put commas/brackets into stored node keys). _slug(text) { return String(text == null ? '' : text) .toLowerCase() // \p{L}\p{N} keeps Cyrillic, CJK, accents: a-z0-9 slugged // 'Исправить парсер' to '' and German 'Ärger' to 'rger'. .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, ''); } // FNV-1a (32-bit) as base36. Used for content-derived task ids: Date.now() ids meant an // identical task re-submitted a second later created a brand new node (and a new 'executed' // edge) instead of reinforcing the existing one. _hash(text) { const s = String(text == null ? '' : text); let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return h.toString(36); } 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)', 'memgraph-learn': 'memgraph-learn — Add a fact to the memory graph', 'memgraph-session': 'memgraph-session [date] — Show graph nodes for a session (default: today)', 'memgraph-agent': 'memgraph-agent — Show namespaced nodes for an agent', 'memgraph-decay-config': 'memgraph-decay-config — 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) { // v4.6.140: max, not mean. Every one of the 9 call sites passes a constant that // describes the *kind* of relationship ('used' 0.5, 'accessed' 0.5, 'maps_to' 0.7). // Averaging therefore decayed a strong edge toward that constant on every repeat: // using a relationship weakened it, and a 0.9 edge became 0.6 after three accesses. // Weight is now monotonic — an edge is as strong as the strongest observation of it, // and weak observations can never erode strong evidence. Fading over time is still // applyDecay()'s job (decay_score); prune() is untouched. const prev = Number.isFinite(Number(edge.weight)) ? Number(edge.weight) : 0; const next = Number.isFinite(Number(weight)) ? Number(weight) : prev; edge.weight = Math.max(prev, next); 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) { // v4.6.140: never record the graph's own store. Saving (and exportGraph) goes through // the same VFS as AI file access, so /.memory_graph/* was recorded as user files: the // graph observed itself, minting a file node plus 'accessed'/'has_type' edges per save. if (typeof path !== 'string' || !path) return null; const storeDir = this._indexPath.replace(/\/+$/, ''); // '/.memory_graph' const abs = path.charAt(0) === '/' ? path : '/' + path; if (abs === storeDir || abs.indexOf(storeDir + '/') === 0) return 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)}`; // v4.6.140: failure used to persist at 0.8 while success persisted at 0.5, so showStats' // 'Strongest relationships' ranked failures above successes. A failure is weaker evidence // than a success: keep it below the success weight (and below prune()'s 0.8 keep-bar) so // stale failures age out like any other weak edge instead of dominating the graph. this.addEdge(sessionKey, nodeKey, success ? 'used' : 'failed', success ? 0.5 : 0.2, agentId); } recordConcept(concept, filePath) { const slug = this._slug(concept); if (!slug) return; const conceptKey = this.addNode('concept', slug, 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) { // v4.6.140: id derived from the task text + agent namespace, not Date.now(). Re-submitting // the same task now merges into one node (access_count/use_count grow) instead of creating // a fresh node every millisecond. Normalisation ignores case and runs of whitespace. const text = typeof task === 'string' ? task : String(task == null ? '' : task); const normalized = text.trim().replace(/\s+/g, ' ').toLowerCase(); const taskId = `task-${this._hash((agentId || 'global') + '|' + normalized)}`; const taskKey = this.addNode('task', taskId, text.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. Split on anything that is not alphanumeric and slug the // result, so the id can never carry punctuation ('(v2!),' used to become a key). const words = normalized.split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 3); for (const word of [...new Set(words)].slice(0, 5)) { const slug = this._slug(word); if (!slug) continue; this.addNode('concept', slug, word); this.addEdge(taskKey, `concept:${slug}`, '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 ', '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:${this._effectiveWeight(e).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) => this._effectiveWeight(b) - this._effectiveWeight(a)).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:${this._effectiveWeight(e).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 (this._effectiveWeight(e) > 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 = []; var untracked = []; for (var name of Object.keys(index)) { var stats = index[name] || {}; if (stats.immune) continue; // A skill with no usage telemetry has never been measured. Calling it stale is how // the audit came to recommend archiving the system's entire procedural memory. if (stats.lastUsed === undefined && stats.useCount === undefined) { untracked.push(name); continue; } var useCount = stats.useCount || 0; var lastUsed = stats.lastUsed ? new Date(stats.lastUsed).getTime() : 0; if (useCount === 0 && (now - lastUsed > staleThreshold || lastUsed === 0)) { // v4.6.85: never fabricate a measurement. A skill with useCount 0 and no // lastUsed has NEVER been used — reporting it as "999d unused" invented a // number that then appeared in the audit as if it had been observed. var daysUnused = lastUsed > 0 ? Math.floor((now - lastUsed) / 86400000) : null; stale.push({ name: name, daysUnused: daysUnused, neverUsed: lastUsed === 0 }); } } this._untrackedSkills = untracked; return stale; } catch (e) { return []; } } getUntrackedSkills() { return Array.isArray(this._untrackedSkills) ? this._untrackedSkills : []; } // v4.5.89: Configure decay policy decayConfig(args) { if (args.length < 2) { this.terminal.print('Usage: memgraph-decay-config ', '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 ', 'error'); const entity = args[0]; const fact = args.slice(1).join(' '); // v4.6.140: same slug rule as recordConcept/recordTask, so 'memgraph-learn "Neural VFS"' // maps to the concept:neural-vfs node the other writers already created. const entitySlug = this._slug(entity); if (!entitySlug) return this.terminal.print('Entity needs at least one letter or digit', 'error'); this.addNode('concept', entitySlug, entity); const factKey = this.addNode('fact', entity + '-' + Date.now(), fact); const conceptKey = `concept:${entitySlug}`; 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 ', '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; // MAINT-003: a corrupt store used to look exactly like a fresh one — fsReadJSON // returns null on a parse failure, so the graph started empty with no warning and no // copy of what was there. Quarantine anything unreadable first, then load what's left. this._quarantineCorrupt('export.json'); this._quarantineCorrupt('nodes.json'); this._quarantineCorrupt('edges.json'); // 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 || []; this._normalizeEdges(); 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; this._normalizeEdges(); } // 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); } } // v4.6.140: normalise edges read from an older store. Two things differ: pre-v4.5.88 records // have no source_agents array, and 'failed' edges were written at 0.8 — higher than the 0.5 // successes — so a stored graph would keep ranking failures on top until they were re-seen. // Nothing else is rewritten: weights, ids and timestamps are otherwise left exactly as stored, // so old Date.now() task ids stay addressable (they simply coexist with new hashed ids). // Compatibility shim for stores written before the weight rules changed. It is // deliberately NON-DESTRUCTIVE: a load must not rewrite what the next save persists. // Anything that needs the corrected value (ranking, pruning, display) asks // _effectiveWeight(), so a legacy 0.8 'failed' edge stops outranking successes without // the stored record being altered. _normalizeEdges() { for (const e of this._edges) { if (!Array.isArray(e.source_agents)) e.source_agents = []; if (!Number.isFinite(Number(e.weight))) e.weight = 0.5; } } // The weight an edge is ranked/pruned by. Legacy stores wrote failures at 0.8 (above the // 0.5 success weight, which put them at the top of "Strongest relationships"); the cap // lives here instead of in the stored data. _effectiveWeight(e) { if (!e) return 0; const w = Number(e.weight); if (!Number.isFinite(w)) return 0; if (e.type === 'failed' && w > 0.2) return 0.2; return w; } // MAINT-003: keep an unreadable store aside instead of starting fresh over it. _quarantineCorrupt(name) { try { const fs = this.terminal.fs; const p = `${this._indexPath}${name}`; const raw = fs.readFile(p); if (typeof raw !== 'string' || !raw.length) return false; try { JSON.parse(raw); return false; } catch (e) { const kept = p + '.corrupt'; fs.writeFile(kept, raw); this.terminal.print(`⚠️ Memory graph store ${name} is unreadable — kept a copy at ${kept} and starting from what remains`, 'warning'); if (typeof fs.deleteFile === 'function') fs.deleteFile(p); return true; } } catch (e) { return false; } } _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`, { version: 2, // format stamp: weight semantics changed in v4.6.140 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; }