aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 21:05:58 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 21:05:58 +0100
commitc2f2ea62503f81c07f4d36d7c3a798b91146153e (patch)
tree6f20a4437c75a7f27d4b01f4dd2fe413e4bcfcd1
parentb339bc13199eb9326307905dd635c56974b55f53 (diff)
fix(newos4): WebDAV listing sizes + pull, and five MemoryGraph defects
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.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js106
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-memory-graph.js106
-rw-r--r--modules/ext/webdav.js52
-rw-r--r--modules/test/test-memory-graph.js253
-rw-r--r--modules/test/test-webdav-sizes.js239
7 files changed, 720 insertions, 44 deletions
diff --git a/main.html b/main.html
index 743d261..e67a7fb 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.139"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.140"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.139"></script>
+<script src="modules/core/module-loader.js?v=v4.6.140"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.139"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.140"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
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 <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))
@@ -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 <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 {
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
index 7f3e7a7..e78e876 100644
--- a/modules/ext/webdav.js
+++ b/modules/ext/webdav.js
@@ -453,7 +453,7 @@ class WebDAVModule {
const isDir = href.endsWith('/');
let size = 0;
if (!isDir && sizeStr !== '-') {
- size = parseInt(sizeStr) || 0;
+ size = this._parseAutoindexSize(sizeStr);
}
entries.push({
name: name,
@@ -506,6 +506,25 @@ class WebDAVModule {
return entries;
}
+ // nginx autoindex with `autoindex_exact_size off` (our template) emits
+ // HUMANISED sizes: '512', '21K', '45K', '1.2M'. parseInt() read the leading
+ // digits and dropped the suffix, so a 21 397-byte file was reported as 21 B.
+ // Parse the suffix here, 1024-based, to match _formatSize() below.
+ _parseAutoindexSize(sizeStr) {
+ if (sizeStr === null || sizeStr === undefined) return 0;
+ const s = String(sizeStr).trim();
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)i?B?$/i);
+ if (!m) return 0;
+ const num = parseFloat(m[1]);
+ if (!isFinite(num) || num < 0) return 0;
+ const unit = (m[2] || '').toUpperCase();
+ const mult = unit === 'K' ? 1024
+ : unit === 'M' ? 1024 * 1024
+ : unit === 'G' ? 1024 * 1024 * 1024
+ : 1;
+ return Math.round(num * mult);
+ }
+
_parseNginxDate(dateStr) {
if (!dateStr) return null;
// nginx default: 02-Aug-2026 14:30
@@ -1095,8 +1114,26 @@ class WebDAVModule {
const resp = await self._webdavRequest('GET', remotePath);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const content = await resp.text();
- self.terminal.fs.writeFile(vfsPath, content);
- self.terminal.print('Pulled: ' + remotePath + ' → ' + vfsPath + ' (' + content.length + ' bytes)', 'success');
+ // Create the local parent chain FIRST: fs.writeFile() returns false
+ // when the parent directory is missing (_resolvePath bails), and the
+ // old code ignored that return and printed a success line for a
+ // write that never happened.
+ const slash = vfsPath.lastIndexOf('/');
+ const parentDir = slash > 0 ? vfsPath.slice(0, slash) : '';
+ if (parentDir && typeof self.terminal.fs.mkdirRecursive === 'function') {
+ if (self.terminal.fs.mkdirRecursive(parentDir) === false) {
+ self.terminal.print('Pull failed: cannot create local directory ' + parentDir, 'error');
+ return;
+ }
+ }
+ const wrote = self.terminal.fs.writeFile(vfsPath, content);
+ if (!wrote) {
+ self.terminal.print('Pull failed: cannot write local file ' + vfsPath +
+ ' (missing directory or path is a directory) — nothing saved', 'error');
+ return;
+ }
+ const _bytes = (typeof TextEncoder !== 'undefined') ? new TextEncoder().encode(content).length : content.length;
+ self.terminal.print('Pulled: ' + remotePath + ' → ' + vfsPath + ' (' + _bytes + ' bytes)', 'success');
} catch (e) {
self.terminal.print('Pull error: ' + e.message, 'error');
}
@@ -2442,8 +2479,11 @@ class WebDAVModule {
}
_formatSize(bytes) {
- if (bytes < 1024) return bytes + ' B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ const n = Number(bytes);
+ if (!Number.isFinite(n) || n < 0) return '—';
+ if (n < 1024) return n + ' B';
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
+ return (n / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
}
}
diff --git a/modules/test/test-memory-graph.js b/modules/test/test-memory-graph.js
new file mode 100644
index 0000000..d4fe37a
--- /dev/null
+++ b/modules/test/test-memory-graph.js
@@ -0,0 +1,253 @@
+// node modules/test/test-memory-graph.js
+// test-memory-graph: five confirmed defects in module-memory-graph.js.
+//
+// T1 addEdge() averaged weights, so every repeat of a strong edge pulled it down toward the
+// constant its caller passed. A relation that was used once at 0.9 and then observed at
+// 0.5 decayed to 0.5 for being used. Evidence must be monotonic: stronger wins.
+// T2 recordTask() keyed task nodes on Date.now(), so re-submitting the same task created a
+// brand new node every millisecond instead of reinforcing the existing one.
+// T3 recordFileAccess() recorded the graph's own store (/.memory_graph/*) as user files,
+// so the graph observed itself and grew every time it saved.
+// T4 failed tool calls were persisted at weight 0.8 and successes at 0.5, which made
+// showStats() rank failures as the strongest relationships in the graph.
+// T5 concept ids were built from a whitespace-only split, so ids carried punctuation
+// ("concept:parser," ) and never matched an id built from the same word elsewhere.
+//
+// Set MEMORY_GRAPH_PATH to test a copy of the module (used to prove the test is RED on the
+// pre-fix file). prune() is deliberately untouched and T6.5 pins that.
+const fs = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const MG_FILE = process.env.MEMORY_GRAPH_PATH || path.join(ROOT, 'module-memory-graph.js');
+
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; }
+};
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.window = global;
+
+const load = (file, cls) => new Function(fs.readFileSync(file, 'utf8') + '\nreturn ' + cls + ';')();
+
+let VFS, MG;
+try {
+ VFS = load(path.join(ROOT, 'virtual-fs.js'), 'VirtualFileSystem');
+ MG = load(MG_FILE, 'MemoryGraphModule');
+} catch (e) {
+ console.log('FAIL could not load module — ' + e.message);
+ process.exit(1);
+}
+if (typeof MG !== 'function') {
+ console.log('FAIL module-memory-graph.js did not expose MemoryGraphModule (got ' + typeof MG + ')');
+ process.exit(1);
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+function mk() {
+ const out = [];
+ const vfs = new VFS();
+ vfs.saveToStorage = () => true;
+ const term = { print: (m, t) => out.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs };
+ const mg = new MG();
+ mg.init(term);
+ return { mg, vfs, out, term };
+}
+const text = arr => arr.map(o => o.text).join('\n');
+// Legacy task ids are Date.now()-based: make sure two calls land in different milliseconds so
+// the RED run cannot pass by the two calls sharing one tick.
+const tick = ms => { const t = Date.now(); while (Date.now() - t < ms) { /* spin */ } };
+const iso = new Date('2024-01-05T10:00:00.000Z').toISOString();
+const sessionOf = d => 'session-session-' + Math.floor(new Date(d).getTime() / 86400000);
+
+// ─── T1: addEdge must not erode a strong edge ───
+{
+ const { mg } = mk();
+ mg.addEdge('a', 'b', 'used', 0.9);
+ mg.addEdge('a', 'b', 'used', 0.5);
+ mg.addEdge('a', 'b', 'used', 0.5);
+ const e = mg._edges[0];
+ ok('T1.1 a strong edge is not eroded by repeated weak observations',
+ !!e && e.weight === 0.9, 'weight ' + (e && e.weight));
+ ok('T1.2 repeats still count as use', !!e && e.use_count === 3, 'use_count ' + (e && e.use_count));
+ ok('T1.3 repeats do not duplicate the edge', mg._edges.length === 1, 'edges ' + mg._edges.length);
+
+ mg.addEdge('a', 'b', 'used', 1);
+ ok('T1.4 stronger evidence still raises the weight', e.weight === 1, 'weight ' + e.weight);
+}
+
+// ─── T2: task ids are content-derived, so identical tasks merge ───
+{
+ const { mg } = mk();
+ const TASK = 'Read the parser and fix the lexer bug';
+ mg.recordTask(TASK, 'ok', 'agent-a');
+ tick(3);
+ mg.recordTask(TASK, 'ok', 'agent-a');
+ const tasks = () => Object.values(mg._nodes).filter(n => n.type === 'task');
+ ok('T2.1 identical re-submits collapse to one task node',
+ tasks().length === 1, 'task nodes: ' + tasks().map(t => t.id).join(','));
+ ok('T2.2 the merged task records both uses',
+ tasks().length === 1 && tasks()[0].access_count === 2, 'access_count ' + (tasks()[0] || {}).access_count);
+ const ex = mg._edges.filter(e => e.type === 'executed');
+ ok('T2.3 one executed edge, reinforced',
+ ex.length === 1 && ex[0].use_count === 2, 'edges ' + ex.length + ' use_count ' + (ex[0] || {}).use_count);
+
+ tick(3);
+ mg.recordTask(TASK, 'ok', 'agent-b');
+ ok('T2.4 a different agent keeps its own task node', tasks().length === 2,
+ 'task nodes: ' + tasks().map(t => t.id).join(','));
+
+ tick(3);
+ mg.recordTask('Deploy the release notes', 'ok', 'agent-a');
+ ok('T2.5 different text gives a different task id', tasks().length === 3,
+ 'task nodes: ' + tasks().length);
+
+ tick(3);
+ mg.recordTask(' read the PARSER and fix the lexer bug ', 'ok', 'agent-a');
+ ok('T2.6 whitespace and case variants still dedupe', tasks().length === 3,
+ 'task nodes: ' + tasks().map(t => t.label).join(' | '));
+}
+
+// ─── T3: the graph must not record its own store ───
+{
+ const { mg } = mk();
+ const nodesBefore = Object.keys(mg._nodes).length;
+ const edgesBefore = mg._edges.length;
+ mg.recordFileAccess('/.memory_graph/nodes.json');
+ mg.recordFileAccess('/.memory_graph/edges.json', 'agent-a');
+ mg.recordFileAccess('/.memory_graph/');
+ mg.recordFileAccess('.memory_graph/export.json');
+
+ const selfFiles = Object.keys(mg._nodes).filter(k => k.indexOf('.memory_graph') !== -1);
+ ok('T3.1 store paths are not recorded as file nodes', selfFiles.length === 0, selfFiles.join(','));
+ ok('T3.2 no extra nodes from the store', Object.keys(mg._nodes).length === nodesBefore,
+ 'before ' + nodesBefore + ' after ' + Object.keys(mg._nodes).length);
+ ok('T3.3 no extra edges from the store', mg._edges.length === edgesBefore,
+ 'before ' + edgesBefore + ' after ' + mg._edges.length);
+
+ mg.recordFileAccess('/src/app.js');
+ ok('T3.4 ordinary paths are still recorded', !!mg.getNode('file', '/src/app.js'),
+ Object.keys(mg._nodes).join(','));
+ ok('T3.5 an ordinary path still gets its accessed edge',
+ mg._edges.some(e => e.type === 'accessed' && e.target === 'file:/src/app.js'));
+}
+
+// ─── T4: a failure must not outrank a success ───
+{
+ const { mg, out } = mk();
+ mg.recordToolCall('read_file', true);
+ mg.recordToolCall('write_file', false);
+ const used = mg._edges.find(e => e.type === 'used');
+ const failed = mg._edges.find(e => e.type === 'failed');
+ ok('T4.1 both outcomes are recorded', !!used && !!failed,
+ mg._edges.map(e => e.type + ':' + e.weight).join(','));
+ ok('T4.2 a failed tool call is weaker than a successful one',
+ !!used && !!failed && failed.weight < used.weight,
+ 'used ' + (used || {}).weight + ' failed ' + (failed || {}).weight);
+ ok('T4.3 success keeps its weight', !!used && used.weight === 0.5, String((used || {}).weight));
+
+ const mark = out.length;
+ mg.showStats();
+ const stats = out.slice(mark).map(o => o.text).join('\n');
+ const lines = stats.split('\n');
+ const at = lines.findIndex(l => l.indexOf('Strongest relationships') !== -1);
+ const strong = at === -1 ? [] : lines.slice(at + 1, at + 4).filter(Boolean);
+ ok('T4.4 showStats still lists strongest relationships', strong.length >= 2, stats.slice(0, 300));
+ ok('T4.5 the failure is not ranked above the success',
+ strong.length >= 2 && strong[0].indexOf('used') !== -1 && strong[1].indexOf('failed') !== -1,
+ strong.join(' | '));
+}
+
+// ─── T5: concept ids are slugged ───
+{
+ const { mg } = mk();
+ mg.recordTask('Refactor: the Parser (v2!), then ship it.', 'done', 'agent-a');
+ const concepts = Object.values(mg._nodes).filter(n => n.type === 'concept');
+ ok('T5.1 task keywords become concept nodes', concepts.length > 0, 'none');
+ ok('T5.2 concept ids carry no punctuation',
+ concepts.every(c => /^concept:[a-z0-9]+(-[a-z0-9]+)*$/.test(c.id)),
+ concepts.map(c => c.id).join(','));
+ const involves = mg._edges.filter(e => e.type === 'involves');
+ ok('T5.3 every involves edge points at a real concept node',
+ involves.length > 0 && involves.every(e => !!mg._nodes[e.target]),
+ involves.map(e => e.target).join(','));
+
+ mg.recordConcept('Neural VFS, v2!', '/src/nvfs.js');
+ ok('T5.4 recordConcept slugifies its id', !!mg.getNode('concept', 'neural-vfs-v2'),
+ Object.keys(mg._nodes).filter(k => k.indexOf('concept:') === 0).join(','));
+ ok('T5.5 the concept→file edge uses the slugged id',
+ mg._edges.some(e => e.type === 'maps_to' && e.source === 'concept:neural-vfs-v2'));
+}
+
+// ─── T6: graphs already stored keep loading, and prune() is untouched ───
+{
+ const { vfs } = mk();
+ const recent = new Date().toISOString();
+ const legacyNodes = {
+ 'session:session-legacy': { id: 'session:session-legacy', type: 'session', label: 'Legacy session',
+ metadata: {}, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' },
+ 'task:task-1700000000000': { id: 'task:task-1700000000000', type: 'task', label: 'legacy task',
+ metadata: { full: 'legacy task' }, created: iso, updated: iso, access_count: 1,
+ namespace: 'global', source_session: 'session-legacy' },
+ 'concept:orphan': { id: 'concept:orphan', type: 'concept', label: 'orphan',
+ metadata: {}, created: iso, updated: iso, access_count: 1, namespace: 'global', source_session: 'session-legacy' }
+ };
+ const legacyEdges = [
+ { source: 'session:session-legacy', target: 'task:task-1700000000000', type: 'executed',
+ weight: 0.6, created: iso, last_used: recent, use_count: 1, source_agents: [] },
+ { source: 'session:session-legacy', target: 'tool:write_file', type: 'failed',
+ weight: 0.8, created: iso, last_used: recent, use_count: 1, source_agents: ['agent-a'] }
+ ];
+ vfs.mkdirRecursive('/.memory_graph');
+ vfs.fsWriteJSON('/.memory_graph/nodes.json', legacyNodes);
+ vfs.fsWriteJSON('/.memory_graph/edges.json', legacyEdges);
+
+ const out2 = [];
+ const mg2 = new MG();
+ mg2.init({ print: (m, t) => out2.push({ text: String(m), type: t || '' }), log: () => {}, fs: vfs });
+
+ ok('T6.1 a legacy graph still loads', Object.keys(mg2._nodes).length >= 4,
+ 'nodes ' + Object.keys(mg2._nodes).length);
+ ok('T6.2 legacy Date.now() task ids are still addressable',
+ !!mg2.getNode('task', 'task-1700000000000'));
+ ok('T6.3 legacy edges keep their shape',
+ mg2._edges.every(e => Array.isArray(e.source_agents) && typeof e.weight === 'number'),
+ JSON.stringify(mg2._edges));
+ const legFailed = mg2._edges.find(e => e.type === 'failed');
+ ok('T6.4 a legacy 0.8 failure is capped below success on load',
+ !!legFailed && legFailed.weight < 0.5, 'weight ' + (legFailed || {}).weight);
+ ok('T6.5 unrelated legacy weights are untouched',
+ (mg2._edges.find(e => e.type === 'executed') || {}).weight === 0.6,
+ String((mg2._edges.find(e => e.type === 'executed') || {}).weight));
+
+ // prune() is correct and must stay correct: it drops zero-degree nodes and keeps the rest.
+ mg2.addNode('concept', 'second-orphan', 'second orphan');
+ const orphanPresent = !!mg2._nodes['concept:second-orphan'];
+ mg2.prune();
+ ok('T6.6 prune still drops zero-degree nodes (prune untouched)',
+ orphanPresent && !mg2._nodes['concept:second-orphan'], Object.keys(mg2._nodes).join(','));
+ ok('T6.7 prune keeps edges that are still in use', mg2._edges.length === 2,
+ 'edges ' + mg2._edges.length);
+}
+
+// T7. slugging must not erase non-Latin scripts: an a-z0-9 slug turned a Cyrillic task
+// into '' (no concept nodes at all) and German 'Ärger' into 'rger'.
+{
+ const { mg } = mk();
+ ok('T7.1 cyrillic slug is not empty', mg._slug('Исправить парсер ошибок') === 'исправить-парсер-ошибок', mg._slug('Исправить парсер ошибок'));
+ ok('T7.2 cjk slug is not empty', mg._slug('修复解析器的错误') === '修复解析器的错误', mg._slug('修复解析器的错误'));
+ ok('T7.3 accents survive', mg._slug('Ärger mit Größe') === 'ärger-mit-größe', mg._slug('Ärger mit Größe'));
+ ok('T7.4 ascii is unchanged', mg._slug('Neural VFS, v2!') === 'neural-vfs-v2', mg._slug('Neural VFS, v2!'));
+ mg.recordTask('Исправить парсер ошибок', 'done', 'agent-ru');
+ const cyr = Object.keys(mg._nodes).filter(k => k.indexOf('concept:') === 0);
+ ok('T7.5 a cyrillic task still yields concept nodes', cyr.length > 0, JSON.stringify(cyr.slice(0, 3)));
+}
+
+console.log('test-memory-graph: ' + passes + ' passed, ' + fails + ' failed');
+process.exit(fails ? 1 : 0);
diff --git a/modules/test/test-webdav-sizes.js b/modules/test/test-webdav-sizes.js
new file mode 100644
index 0000000..68cb90b
--- /dev/null
+++ b/modules/test/test-webdav-sizes.js
@@ -0,0 +1,239 @@
+// node modules/test/test-webdav-sizes.js
+// test-webdav-sizes: two WebDAV defects that both lied to the user.
+//
+// (a) webdav-ls sizes. nginx renders __list__ with `autoindex_exact_size off`,
+// so the HTML carries humanised sizes ('21K', '45K', '1.2M'). _parseAutoindex
+// did `parseInt(sizeStr) || 0`, which drops the suffix: '21K' -> 21 bytes, and
+// _formatSize() then printed '21 B' for a 21 397-byte file. The PROPFIND path
+// already gets exact bytes from getcontentlength and must stay exact.
+// (b) webdav-pull into a missing local directory. It called fs.writeFile() and
+// ignored the return value — virtual-fs returns false when the parent chain is
+// absent — then printed 'Pulled: ... (N bytes)' for a write that never happened.
+//
+// Run: node modules/test/test-webdav-sizes.js
+// Set WEBDAV_SRC=<path> to point the harness at another copy of webdav.js
+// (used to prove the test is RED against the pre-fix file).
+const fs = require('fs'), path = require('path');
+const SRC = process.env.WEBDAV_SRC || path.join(__dirname, '..', 'ext', 'webdav.js');
+
+// --- browser stubs -------------------------------------------------------
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; }
+};
+global.window = global;
+global.location = { pathname: '/spaces/chatstudio/newos4/' };
+global.document = {
+ cookie: '',
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ getElementById: () => null,
+ querySelector: () => null,
+ createElement: () => ({ style: {}, appendChild: () => {}, setAttribute: () => {},
+ addEventListener: () => {}, remove: () => {} }),
+ body: { appendChild: () => {} }
+};
+global.fetch = async () => ({ ok: false, status: 401, statusText: 'Unauthorized',
+ text: async () => '', json: async () => ({}) });
+
+// The PROPFIND path builds a DOM. Feed it pre-cooked response objects as JSON:
+// parseFromString(JSON) -> { querySelectorAll() } is all _parsePropfind uses.
+function fakeDoc(xml) {
+ const rows = JSON.parse(xml);
+ return {
+ querySelectorAll: () => rows.map(r => ({
+ querySelector: (sel) => ({
+ textContent: sel.indexOf('href') >= 0 ? (r.href || '')
+ : sel.indexOf('collection') >= 0 ? (r.collection || '')
+ : (r.size || '')
+ })
+ }))
+ };
+}
+global.DOMParser = function () { this.parseFromString = fakeDoc; };
+
+let WebDAVModule;
+try {
+ WebDAVModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebDAVModule;')();
+} catch (e) {
+ console.log('FAIL could not load ' + SRC + ' — ' + e.message);
+ process.exit(1);
+}
+if (typeof WebDAVModule !== 'function') {
+ console.log('FAIL webdav.js did not expose WebDAVModule (got ' + typeof WebDAVModule + ')');
+ process.exit(1);
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail !== undefined ? ' — ' + detail : ''));
+}
+
+// A VFS stand-in with virtual-fs's real semantics: writeFile() refuses to create
+// a file whose parent directory does not exist (core/virtual-fs.js _resolvePath).
+function makeVfs(opts) {
+ opts = opts || {};
+ const dirs = new Set(['/']);
+ const files = {};
+ const calls = [];
+ const norm = p => '/' + String(p).split('/').filter(Boolean).join('/');
+ const parentOf = p => norm(p).replace(/\/[^/]*$/, '') || '/';
+ return {
+ files, calls,
+ mkdirRecursive(p) {
+ calls.push('mkdir:' + p);
+ if (opts.mkdirFails) return false;
+ let cur = '';
+ for (const seg of String(p).split('/').filter(Boolean)) { cur += '/' + seg; dirs.add(cur); }
+ return true;
+ },
+ writeFile(p, c) {
+ calls.push('write:' + p);
+ if (opts.writeAlwaysFails) return false;
+ if (!dirs.has(parentOf(p))) return false; // missing parent -> false
+ files[norm(p)] = c;
+ return true;
+ },
+ readFile(p) { return (norm(p) in files) ? files[norm(p)] : null; }
+ };
+}
+
+function makeModule(vfs) {
+ const m = new WebDAVModule();
+ const out = [];
+ m.terminal = {
+ print: (msg, cls) => out.push({ msg: String(msg), cls: cls || '' }),
+ fs: vfs
+ };
+ m.config = { url: 'https://webx11.duckdns.org/spaces/chatstudio/', username: 'chatstudio', password: 'tok' };
+ m.registerCommands();
+ return { m, out };
+}
+const said = (out, re) => out.some(o => re.test(o.msg));
+
+(async () => {
+ // ---------------- (a) humanised autoindex sizes ----------------
+ const dir = makeModule(makeVfs()).m;
+
+ // Exactly the shape nginx emits with autoindex_exact_size off: link, date, size.
+ const html =
+ '<html><body><h1>Index of /</h1><hr><pre>'
+ + '<a href="../">../</a> 02-Nov-2026 09:13 -\n'
+ + '<a href="CHANGELOG.md">CHANGELOG.md</a> 02-Nov-2026 09:14 21K\n'
+ + '<a href="downloads/">downloads/</a> 02-Nov-2026 09:14 -\n'
+ + '<a href="small.txt">small.txt</a> 02-Nov-2026 09:15 512\n'
+ + '<a href="big.bin">big.bin</a> 02-Nov-2026 09:16 1.2M\n'
+ + '<a href="huge.bin">huge.bin</a> 02-Nov-2026 09:17 2G\n'
+ + '<a href="memgraph.html">memgraph.html</a> 02-Nov-2026 09:18 45K\n'
+ + '</pre><hr></body></html>';
+ const entries = dir._parseAutoindex(html);
+ const byName = {};
+ for (const e of entries) byName[e.name] = e;
+
+ ok('A1 autoindex parsed 6 entries (not the 0-byte fallback)', entries.length === 6,
+ entries.length + ': ' + entries.map(e => e.name + '=' + e.size).join(','));
+ ok('A0 no entry fell back to a 0 size', entries.every(e => e.type === 'dir' || e.size > 0),
+ entries.map(e => e.name + '=' + e.size).join(','));
+ ok('A1b 21K is 21504 bytes, not 21', byName['CHANGELOG.md'] && byName['CHANGELOG.md'].size === 21 * 1024,
+ byName['CHANGELOG.md'] && byName['CHANGELOG.md'].size);
+ ok('A2 45K is 46080 bytes, not 45', byName['memgraph.html'] && byName['memgraph.html'].size === 45 * 1024,
+ byName['memgraph.html'] && byName['memgraph.html'].size);
+ ok('A3 1.2M is 1.2*1024*1024', byName['big.bin'] && byName['big.bin'].size === Math.round(1.2 * 1024 * 1024),
+ byName['big.bin'] && byName['big.bin'].size);
+ ok('A4 2G is 2*1024^3', byName['huge.bin'] && byName['huge.bin'].size === 2 * 1024 * 1024 * 1024,
+ byName['huge.bin'] && byName['huge.bin'].size);
+ ok('A5 plain numeric size stays exact', byName['small.txt'] && byName['small.txt'].size === 512,
+ byName['small.txt'] && byName['small.txt'].size);
+ ok('A6 directories keep size 0', byName['downloads'] && byName['downloads'].size === 0,
+ byName['downloads'] && byName['downloads'].size);
+
+ // The user-visible symptom: webdav-ls printed '21 B' for a 21 KB file.
+ ok('A7 _formatSize(21K) no longer renders bytes', dir._formatSize(byName['CHANGELOG.md'].size) === '21.0 KB',
+ dir._formatSize(byName['CHANGELOG.md'].size));
+ ok('A8 webdav-ls line shows KB not B', dir._formatSize(45 * 1024) === '45.0 KB', dir._formatSize(45 * 1024));
+
+ // Suffix parser edge cases (case-insensitive, optional i/B, junk, missing).
+ const psize = s => { try { return dir._parseAutoindexSize(s); } catch (e) { return NaN; } };
+ ok('A9 lowercase k', psize('21k') === 21 * 1024, psize('21k'));
+ ok('A10 "1KiB" style', psize('1KiB') === 1024, psize('1KiB'));
+ ok('A11 integer KB', psize('1KB') === 1024, psize('1KB'));
+ ok('A12 \'-\' / junk -> 0', psize('-') === 0 && psize('garbage') === 0,
+ psize('-') + '/' + psize('garbage'));
+
+ // PROPFIND must NOT regress: exact getcontentlength bytes, untouched.
+ const pf = dir._parsePropfind(JSON.stringify([
+ { href: '/spaces/chatstudio/newos4/', collection: 'collection', size: '' },
+ { href: '/spaces/chatstudio/newos4/CHANGELOG.md', collection: '', size: '21397' }
+ ]));
+ const pfe = pf.filter(e => e.name === 'CHANGELOG.md')[0];
+ ok('A13 PROPFIND keeps exact 21397 bytes', !!pfe && pfe.size === 21397, pfe && pfe.size);
+
+ // ---------------- (b) webdav-pull into a missing dir ----------------
+ // B1: the reported bug — pull to notes/deep/a.txt with no such local dirs.
+ {
+ const vfs = makeVfs();
+ const { m, out } = makeModule(vfs);
+ m._webdavRequest = async () => ({ ok: true, text: async () => 'HELLO-WEBDAV' });
+ await m.commands['webdav-pull'](['notes/deep/a.txt', 'notes/deep/a.txt']);
+ ok('B1 pull into missing dir succeeds and saves the file',
+ said(out, /^Pulled: /) && vfs.readFile('/notes/deep/a.txt') === 'HELLO-WEBDAV',
+ out.map(o => o.msg).join(' | ') + ' | read=' + JSON.stringify(vfs.readFile('/notes/deep/a.txt')));
+ ok('B2 the file really exists locally', vfs.readFile('/notes/deep/a.txt') === 'HELLO-WEBDAV',
+ JSON.stringify(vfs.readFile('/notes/deep/a.txt')));
+ ok('B3 parent chain was created before the write',
+ vfs.calls.indexOf('mkdir:notes/deep') >= 0 &&
+ vfs.calls.indexOf('mkdir:notes/deep') < vfs.calls.indexOf('write:notes/deep/a.txt'),
+ vfs.calls.join(' > '));
+ ok('B4 byte count is the fetched length', said(out, /\(12 bytes\)$/), out.map(o => o.msg).join(' | '));
+ }
+
+ // B5: write still fails (e.g. path is a directory) -> error, NO success line.
+ {
+ const vfs = makeVfs({ writeAlwaysFails: true });
+ const { m, out } = makeModule(vfs);
+ m._webdavRequest = async () => ({ ok: true, text: async () => 'DATA' });
+ await m.commands['webdav-pull'](['x/y.txt', 'x/y.txt']);
+ ok('B5 refused write reports failure, not success',
+ !said(out, /^Pulled: /) && said(out, /^Pull failed: /), out.map(o => o.msg).join(' | '));
+ }
+
+ // B6: cannot even create the directory -> error, NO success line.
+ {
+ const vfs = makeVfs({ mkdirFails: true });
+ const { m, out } = makeModule(vfs);
+ m._webdavRequest = async () => ({ ok: true, text: async () => 'DATA' });
+ await m.commands['webdav-pull'](['x/y.txt', 'x/y.txt']);
+ ok('B6 mkdir failure reports failure, not success',
+ !said(out, /^Pulled: /) && said(out, /^Pull failed: /), out.map(o => o.msg).join(' | '));
+ ok('B7 nothing was written when mkdir failed', vfs.calls.indexOf('write:x/y.txt') === -1,
+ vfs.calls.join(' > '));
+ }
+
+ // B8: plain filename in an existing root dir still works (no spurious mkdir).
+ {
+ const vfs = makeVfs();
+ const { m, out } = makeModule(vfs);
+ m._webdavRequest = async () => ({ ok: true, text: async () => 'ROOTFILE' });
+ await m.commands['webdav-pull'](['file.txt']);
+ ok('B8 root-level pull still works', said(out, /^Pulled: /) && vfs.readFile('/file.txt') === 'ROOTFILE',
+ out.map(o => o.msg).join(' | '));
+ ok('B9 no mkdir for a root-level file', vfs.calls.filter(c => c.startsWith('mkdir:')).length === 0,
+ vfs.calls.join(' > '));
+ }
+
+ // A14. the parser accepts G, so the formatter must render GB (it printed '3072.0 MB'
+ // for a 3 GB file) and must not print 'NaN MB' for a missing size.
+ ok('A14a 2 GiB renders as GB', WebDAVModule.prototype._formatSize.call(null, 2 * 1024 * 1024 * 1024) === '2.0 GB', WebDAVModule.prototype._formatSize.call(null, 2 * 1024 * 1024 * 1024));
+ ok('A14b undefined does not render NaN', WebDAVModule.prototype._formatSize.call(null, undefined) === '—', WebDAVModule.prototype._formatSize.call(null, undefined));
+ ok('A14c 0 bytes is still B', WebDAVModule.prototype._formatSize.call(null, 0) === '0 B', WebDAVModule.prototype._formatSize.call(null, 0));
+
+ console.log('test-webdav-sizes: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})().catch(e => {
+ console.log('FAIL harness threw — ' + (e && e.stack || e));
+ process.exit(1);
+});