aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 21:15:28 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 21:15:28 +0100
commit08a77451f503f531eee44c69c14bed3a2961cc5c (patch)
treed64f2c0a5a62f5b3edcd750068559edb9ca1ed0b
parentc2f2ea62503f81c07f4d36d7c3a798b91146153e (diff)
fix(newos4): memory-graph loads stop rewriting user data; unknown sizes say unknown
Three follow-ups from the merge review, each with the assertion that pins it. module-memory-graph.js - _normalizeEdges no longer mutates stored weights. It capped legacy 'failed' edges from 0.8 to 0.2 in memory, and the next debounced save made that permanent - an irreversible migration of the user's graph with no schema version. The cap moved to _effectiveWeight(), which ranking (showStats "Strongest relationships"), the session view and prune() now use; the stored record is left exactly as written. - config.json is stamped version: 2 so a future format change can migrate deliberately. ext/webdav.js - the two fallback parsers emit size: null instead of 0. They cannot see the size column, and 0 rendered as "0 B" - the same "every file looks empty" symptom as the parseInt bug fixed in e639c2d, one regex miss away. - _formatSize(null) renders '—' (unknown) rather than '0 B'; 0 still renders '0 B'. module-aiassist.js - the lock timeout now says what to do: "AI is still busy with another turn (waited Ns). Wait for it to finish, or run 'ai-stop' and retry - the running turn's lock is left alone." It used to be a dead-end string. Tests: test-memory-graph 39 (T6.4 rewritten to the read-time contract: stored weight stays 0.8, effective 0.2, success unaffected), test-webdav-sizes 28 (A14d null -> '—'). maintain 26/26, live v4.6.141: _effectiveWeight 0.2 with the edge object unchanged, _formatSize(null) '—', '0 B' still '0 B'.
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js36
-rw-r--r--modules/core/module-aiassist.js5
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-memory-graph.js31
-rw-r--r--modules/ext/webdav.js11
-rw-r--r--modules/test/test-memory-graph.js11
-rw-r--r--modules/test/test-webdav-sizes.js1
8 files changed, 75 insertions, 28 deletions
diff --git a/main.html b/main.html
index e67a7fb..f3c4ac4 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.140"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.141"></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.140"></script>
+<script src="modules/core/module-loader.js?v=v4.6.141"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.140"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.141"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index 379c297..71f3ada 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -9708,8 +9708,9 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const lockStart = Date.now();
while (!_agentTag && this._chatLock) {
if (Date.now() - lockStart > this._chatLockTimeoutMs) {
- throw new Error("chat lock busy after " + Math.round(this._chatLockTimeoutMs / 1000)
- + "s — refusing to release another turn's lock");
+ throw new Error("AI is still busy with another turn (waited "
+ + Math.round(this._chatLockTimeoutMs / 1000)
+ + "s). Wait for it to finish, or run 'ai-stop' and retry — the running turn's lock is left alone.");
}
await new Promise(r => setTimeout(r, 50));
}
@@ -18537,7 +18538,7 @@ class MemoryGraphModule {
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:${e.weight.toFixed(1)})`, 'text-muted');
+ 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');
@@ -18566,13 +18567,13 @@ class MemoryGraphModule {
}
// Strongest edges
- const strong = [...this._edges].sort((a, b) => b.weight - a.weight).slice(0, 3);
+ 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:${e.weight.toFixed(2)}, used ${e.use_count}x)`, 'info');
+ this.terminal.print(` ${sLabel} → ${tLabel} (${e.type}, w:${this._effectiveWeight(e).toFixed(2)}, used ${e.use_count}x)`, 'info');
}
}
@@ -18617,7 +18618,7 @@ class MemoryGraphModule {
if (age < THIRTY_DAYS) return true;
if (e.use_count >= 3) return true;
// Or if weight is high
- if (e.weight > 0.8) return true;
+ if (this._effectiveWeight(e) > 0.8) return true;
return false;
});
const pruned = before - this._edges.length;
@@ -18901,15 +18902,29 @@ class MemoryGraphModule {
// 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 (!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;
+ 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 {
@@ -18940,6 +18955,7 @@ class MemoryGraphModule {
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
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
index db4e691..09551c2 100644
--- a/modules/core/module-aiassist.js
+++ b/modules/core/module-aiassist.js
@@ -833,8 +833,9 @@ max_chars: { type: 'integer', description: 'Max characters of page content to re
const lockStart = Date.now();
while (!_agentTag && this._chatLock) {
if (Date.now() - lockStart > this._chatLockTimeoutMs) {
- throw new Error("chat lock busy after " + Math.round(this._chatLockTimeoutMs / 1000)
- + "s — refusing to release another turn's lock");
+ throw new Error("AI is still busy with another turn (waited "
+ + Math.round(this._chatLockTimeoutMs / 1000)
+ + "s). Wait for it to finish, or run 'ai-stop' and retry — the running turn's lock is left alone.");
}
await new Promise(r => setTimeout(r, 50));
}
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 4301d0a..367ad60 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.140';
+ const LATEST_VER = 'v4.6.141';
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 e0d426e..3e52353 100644
--- a/modules/core/module-memory-graph.js
+++ b/modules/core/module-memory-graph.js
@@ -351,7 +351,7 @@ class MemoryGraphModule {
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:${e.weight.toFixed(1)})`, 'text-muted');
+ 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');
@@ -380,13 +380,13 @@ class MemoryGraphModule {
}
// Strongest edges
- const strong = [...this._edges].sort((a, b) => b.weight - a.weight).slice(0, 3);
+ 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:${e.weight.toFixed(2)}, used ${e.use_count}x)`, 'info');
+ this.terminal.print(` ${sLabel} → ${tLabel} (${e.type}, w:${this._effectiveWeight(e).toFixed(2)}, used ${e.use_count}x)`, 'info');
}
}
@@ -431,7 +431,7 @@ class MemoryGraphModule {
if (age < THIRTY_DAYS) return true;
if (e.use_count >= 3) return true;
// Or if weight is high
- if (e.weight > 0.8) return true;
+ if (this._effectiveWeight(e) > 0.8) return true;
return false;
});
const pruned = before - this._edges.length;
@@ -715,15 +715,29 @@ class MemoryGraphModule {
// 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 (!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;
+ 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 {
@@ -754,6 +768,7 @@ class MemoryGraphModule {
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
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
index e78e876..c7efbf9 100644
--- a/modules/ext/webdav.js
+++ b/modules/ext/webdav.js
@@ -480,7 +480,10 @@ class WebDAVModule {
entries.push({
name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
type: isDir ? 'dir' : 'file',
- size: 0,
+ // null, not 0: these are the fallback parsers, which cannot see the
+ // size column. 0 rendered as "0 B", i.e. a listing that looked like
+ // every file was empty (the bug _parseAutoindexSize fixes upstream).
+ size: null,
mtime: null,
href: href
});
@@ -496,7 +499,10 @@ class WebDAVModule {
entries.push({
name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
type: isDir ? 'dir' : 'file',
- size: 0,
+ // null, not 0: these are the fallback parsers, which cannot see the
+ // size column. 0 rendered as "0 B", i.e. a listing that looked like
+ // every file was empty (the bug _parseAutoindexSize fixes upstream).
+ size: null,
mtime: null,
href: href
});
@@ -2479,6 +2485,7 @@ class WebDAVModule {
}
_formatSize(bytes) {
+ if (bytes === null || bytes === undefined) return '—'; // listing could not determine it
const n = Number(bytes);
if (!Number.isFinite(n) || n < 0) return '—';
if (n < 1024) return n + ' B';
diff --git a/modules/test/test-memory-graph.js b/modules/test/test-memory-graph.js
index d4fe37a..8c3bb6e 100644
--- a/modules/test/test-memory-graph.js
+++ b/modules/test/test-memory-graph.js
@@ -220,8 +220,15 @@ const sessionOf = d => 'session-session-' + Math.floor(new Date(d).getTime() / 8
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);
+ // The cap is applied at READ time, not by rewriting the store: a load must not mutate
+ // user data (the next debounced save would have made it permanent, silently).
+ ok('T6.4a a legacy 0.8 failure is NOT rewritten on load',
+ !!legFailed && legFailed.weight === 0.8, 'stored weight ' + (legFailed || {}).weight);
+ ok('T6.4b it ranks at 0.2 through _effectiveWeight',
+ !!legFailed && mg2._effectiveWeight(legFailed) === 0.2, 'effective ' + (legFailed && mg2._effectiveWeight(legFailed)));
+ ok('T6.4c a success edge keeps its own weight',
+ mg2._effectiveWeight({ type: 'used', weight: 0.5 }) === 0.5,
+ String(mg2._effectiveWeight({ type: 'used', weight: 0.5 })));
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));
diff --git a/modules/test/test-webdav-sizes.js b/modules/test/test-webdav-sizes.js
index 68cb90b..9085f23 100644
--- a/modules/test/test-webdav-sizes.js
+++ b/modules/test/test-webdav-sizes.js
@@ -229,6 +229,7 @@ const said = (out, re) => out.some(o => re.test(o.msg));
// 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('A14d null renders as unknown, not 0 B', WebDAVModule.prototype._formatSize.call(null, null) === '—', WebDAVModule.prototype._formatSize.call(null, null));
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');