diff options
Diffstat (limited to 'modules/core/module-memory-graph.js')
| -rw-r--r-- | modules/core/module-memory-graph.js | 106 |
1 files changed, 89 insertions, 17 deletions
diff --git a/modules/core/module-memory-graph.js b/modules/core/module-memory-graph.js index e802387..e0d426e 100644 --- a/modules/core/module-memory-graph.js +++ b/modules/core/module-memory-graph.js @@ -19,6 +19,33 @@ class MemoryGraphModule { 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', @@ -114,7 +141,16 @@ class MemoryGraphModule { e.source === sourceKey && e.target === targetKey && e.type === type ); if (edge) { - edge.weight = (edge.weight + weight) / 2; + // 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])]; @@ -137,6 +173,14 @@ class MemoryGraphModule { // 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)}`; @@ -153,11 +197,17 @@ class MemoryGraphModule { 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); + // 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 conceptKey = this.addNode('concept', concept, concept); + 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); @@ -165,8 +215,13 @@ class MemoryGraphModule { // 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), { + // 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 @@ -174,11 +229,14 @@ class MemoryGraphModule { 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); + // 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)) { - this.addNode('concept', word, word); - this.addEdge(taskKey, `concept:${word}`, 'involves', 0.4); + 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) @@ -522,9 +580,13 @@ class MemoryGraphModule { 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); + // 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:${entity}`; + 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)) @@ -614,9 +676,7 @@ class MemoryGraphModule { this._nodes[n.id] = n; } this._edges = exportData.edges || []; - for (const e of this._edges) { - if (!e.source_agents) e.source_agents = []; - } + this._normalizeEdges(); this._totalNodeAccesses = (exportData.stats?.totalAccesses) || 0; return; } @@ -636,9 +696,7 @@ class MemoryGraphModule { 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 = []; - } + this._normalizeEdges(); } // v4.5.89b fix: Load persisted decay config (P1 from review) const config = fs.fsReadJSON(`${this._indexPath}config.json`); @@ -652,6 +710,20 @@ class MemoryGraphModule { } } + // 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). + _normalizeEdges() { + for (const e of this._edges) { + if (!e.source_agents) e.source_agents = []; + const w = Number(e.weight); + e.weight = Number.isFinite(w) ? w : 0.5; + if (e.type === 'failed' && e.weight > 0.2) e.weight = 0.2; + } + } + // MAINT-003: keep an unreadable store aside instead of starting fresh over it. _quarantineCorrupt(name) { try { |