aboutsummaryrefslogtreecommitdiff
path: root/modules/core/module-memory-graph.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/module-memory-graph.js')
-rw-r--r--modules/core/module-memory-graph.js106
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 {