From c2f2ea62503f81c07f4d36d7c3a798b91146153e Mon Sep 17 00:00:00 2001 From: Agando Date: Mon, 2 Nov 2026 21:05:58 +0100 Subject: fix(newos4): WebDAV listing sizes + pull, and five MemoryGraph defects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes STEER-20261102-205416-facd and STEER-20261102-204031-f82a. WebDAV (modules/ext/webdav.js): - listings showed "21 B" for a 21 KB file: nginx emits humanised sizes (autoindex_exact_size off, vhost :412) and _parseAutoindex did parseInt("21K"). _parseAutoindexSize() handles K/M/G, decimals, lowercase and "-"; PROPFIND's exact getcontentlength path is untouched. - webdav-pull into a missing local directory printed "Pulled: ... (N bytes)" and wrote nothing: it ignored fs.writeFile's false return (virtual-fs.js:561). It now creates the parent chain, checks the write, and only reports success after a confirmed write. The byte count uses TextEncoder, not .length (a Cyrillic file under-reported ~2x). - _formatSize gained the GB branch the parser already accepted (3 GB printed "3072.0 MB") and no longer prints "NaN MB" for a missing size. MemoryGraph (modules/core/module-memory-graph.js): - addEdge averages weights, so every repeat pulled a strong edge toward the call site's constant (0.9 -> 0.7 -> 0.6). Now Math.max, with decay left to applyDecay(); prune() untouched. - task ids were `task-${Date.now()}`, so re-submitting a task never deduped: now a hash of agent + normalised text. - recordFileAccess no longer records the graph's own /.memory_graph/ store. - failed tool calls were weighted 0.8 vs success 0.5, so showStats ranked failures as strongest: now 0.2. Legacy 0.8 failures are capped on load. - concept ids keep punctuation ("concept:(v2!),"): slugged, and the slug uses \p{L}\p{N} so Cyrillic/CJK/accented text still yields nodes -- an a-z0-9 slug silently produced none. Review findings folded in from the regression pass: the non-Latin regression (a-z0-9 slug), the GB formatter asymmetry, and the pull byte count were all found by review, not by the patch authors, and each now has a test. Tests: test-webdav-sizes.js 27 (was RED 15/24 before the fix), test-memory-graph.js 37 (RED 17/32), both auto-discovered by newos4-maintain -- 26/26 node tests. Live on v4.6.140: _parseAutoindexSize('21K') 21504, _formatSize -> "21.0 KB", _formatSize(2GiB) "2.0 GB", slug Cyrillic "исправить-парсер-ошибок", 0 console errors. --- modules/core/core-bundle.js | 106 ++++++++++++++++++++++++++++++------ modules/core/module-loader.js | 2 +- modules/core/module-memory-graph.js | 106 ++++++++++++++++++++++++++++++------ 3 files changed, 179 insertions(+), 35 deletions(-) (limited to 'modules/core') diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index cf805a9..379c297 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -18205,6 +18205,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', @@ -18300,7 +18327,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])]; @@ -18323,6 +18359,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)}`; @@ -18339,11 +18383,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); @@ -18351,8 +18401,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 @@ -18360,11 +18415,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) @@ -18708,9 +18766,13 @@ class MemoryGraphModule { if (!args || args.length < 2) return this.terminal.print('Usage: memgraph-learn ', '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)) @@ -18800,9 +18862,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; } @@ -18822,9 +18882,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`); @@ -18838,6 +18896,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 { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 7206155..4301d0a 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.139'; + const LATEST_VER = 'v4.6.140'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). 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 ', '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 { -- cgit v1.2.3