summaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
authorHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 16:39:12 +0100
committerHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 16:39:12 +0100
commit228ad621e2631487e74acdf9bf58e797f60ab745 (patch)
tree6372c40b0ec6ec1470999b0e8e39cd267f24e979 /modules/core
parentcbaa678ae624860ce4f918428a7b09d5f0e0ae2f (diff)
fix(newos4): honest size/network measurements + checkpoint and phantom-delete fixes (v4.6.160)
Applies the three 2026-11-09 patches after review, and fixes two more navigator.onLine lies found while verifying them. Each patch was checked against the live code first; one of its claims did not survive. 1. virtual-fs — phantom deletes and checkpoint bloat - deleteCheckpoint('nope') returned true and printed "Deleted checkpoint" for a name that did not exist: _resolvePath() validates intermediate segments only, so the leaf was never checked. New _requireLeaf() refuses a resolved path whose leaf is absent, and deleteCheckpoint uses it. - CHECKPOINT SIZE: checkpoint() cloned the whole root minus _snapshots/_sessions/.trash, so _mods (module originals) and .semantic_index were duplicated into EVERY checkpoint — measured 2,101,686 bytes for a 2 MiB _mods. Both are now excluded on write and preserved across rollback (without the keep-list change a rollback would delete the live module originals). Measured after: 185 bytes. - Claim corrected: the patch also claimed deleteFile() reported success on a phantom path. It did not — live code already required `node && node.type === 'file'` and returned false. The guard is kept as defence-in-depth, and the test asserts the real behaviour rather than the claimed one. 2. module-fs — vfs-size reported ~2x the real size - It measured JSON.stringify(root).length: UTF-16 code units PLUS every structural character of the serialisation. A 1046-byte tree printed as 1961; anything under ~5 KB printed as "0.00 MB". - Every number is now stored bytes (UTF-8 of file content): one measure() walk for the total, the per-file table, the top-directory table and the system dirs, so the rows add up to the total. The patch's arithmetic was still off in seven places, all fixed here: a UTF-16 fallback that reintroduced the bug when Blob was missing, a top-5 table that could never sum to the total (now top 10 + an aggregated remainder row), the top-dirs table dropping files stored at root (now a "/(root files)" row), a 2-decimal MB total that printed 0.00 for small VFS, three duplicate hand-rolled walks, readDir() used for dir sizes, and "1 files". 3. module-system — status/doctor no longer trust navigator.onLine - status printed "Network: Offline" from navigator.onLine, which an Android WebView reports false while fully connected. - doctor now awaits a real HEAD probe (AbortController, 4 s): only 'reachable' is healthy, a throw is HIGH "Network: unreachable", a non-ok answer is MEDIUM and names the status (405 to HEAD is not "offline"). - THE PATCH'S THRESHOLDS COULD NEVER FIRE: it compared localStorage bytes against navigator.storage.estimate().quota, which is the whole-origin quota (GBs, mostly Cache Storage), while localStorage has its own ~5 MB cap — a 4.5 MB localStorage reported "All systems healthy" against a 2 GB quota. Now: localStorage measured as (key+value)*2 UTF-16 bytes with HIGH > 4 MiB / MEDIUM > 3 MiB, VFS measured in UTF-8 bytes with HIGH > 3 MiB and > 80% of the measured localStorage bytes. The origin quota appears only in one informational line and decides no severity. - Also removed navigator.onLine from version, bug-report and bug (a grep for it is now 0 in the module), and added a "Measured:" line so a clean report still shows what was measured. 4. terminal.js / module-servermon.js — the same lie, where the user looks - The status bar printed "❌ Offline" in red from navigator.onLine; on a WebView that is false while online. It now shows "📶 Network?" with a title pointing at `doctor` unless the browser claims online. - `net` test line now reads "Browser reports: offline (unreliable)" as a warning instead of a red failure — the real verdict comes from the probes that follow. Verified: 40/40 node tests (3 new files: 36 + 53 + 34 assertions, each proven red against the pre-patch code; the doctor test runs against the PATCH's threshold logic and shows the 4.5 MB case staying silent). An independent orchestrator probe re-derived all of it: phantom delete false, 2 MB _mods absent from the checkpoint and preserved through rollback, vfs-size exact bytes for ASCII/é/☃ nested trees, 4.5 MB localStorage HIGH despite a 2 GB quota, failing probe unreachable, status never claiming Online/Offline. Served bytes identical for core-bundle.js / main.html / module-system.js / virtual-fs.js. Live Chromium on v4.6.160: vfs-size "85.8 KB (87880 bytes) across 40 files", doctor "Measured: VFS 0.08 MB UTF-8 · localStorage 0.07 MB UTF-16 in 14 keys", a stubbed failing probe reports HIGH unreachable, the status bar reads "📶 Network?" when the browser lies, deleteCheckpoint on a missing name returns false, and the console has 0 warnings and 0 JS errors.
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/core-bundle.js336
-rw-r--r--modules/core/module-fs.js100
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-servermon.js4
-rw-r--r--modules/core/module-system.js202
-rw-r--r--modules/core/terminal.js9
-rw-r--r--modules/core/virtual-fs.js23
7 files changed, 570 insertions, 106 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index b365fa0..ddcac45 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -557,6 +557,19 @@ class VirtualFileSystem {
}
return { dir, name: segs[segs.length - 1] };
}
+ // newOS4 patch: _resolvePath() validates intermediate segments only, so it returns a
+ // result for a leaf that does not exist. Mutating callers then acted on a phantom
+ // path and reported success (deleteCheckpoint printed 'Deleted checkpoint' and
+ // deleted nothing). _requireLeaf() refuses any resolved path whose leaf is absent.
+ _requireLeaf(resolved) {
+ if (!resolved) return null;
+ if (resolved.name) {
+ const node = (resolved.dir && resolved.dir.content) ? resolved.dir.content[resolved.name] : null;
+ if (!node) return null;
+ }
+ return resolved;
+ }
+
_resolveDir(path) {
if (this._isVirtualPath(path)) {
const r = this._resolveVirtualPath(path);
@@ -626,7 +639,7 @@ class VirtualFileSystem {
}
deleteFile(path) {
if (this._isVirtualPath(path)) return false;
- const resolved = this._resolvePath(path);
+ const resolved = this._requireLeaf(this._resolvePath(path));
if (!resolved || !resolved.name) return false;
const node = resolved.dir.content[resolved.name];
if (node && node.type === 'file') {
@@ -1013,6 +1026,10 @@ class VirtualFileSystem {
delete rootClone.content['_sessions'];
delete rootClone.content['_snapshots'];
delete rootClone.content['.trash'];
+ // newOS4 patch: module originals and the semantic index dominated every
+ // snapshot (one checkpoint reached 4.13MB) and grew on each new checkpoint.
+ delete rootClone.content['_mods'];
+ delete rootClone.content['.semantic_index'];
}
const data = {
version: 1,
@@ -1047,7 +1064,7 @@ class VirtualFileSystem {
// clone verbatim deletes every checkpoint, every agent session and the trash, so keep the
// live copies and re-attach them after the root swap.
const keep = {};
- for (const dir of ['_snapshots', '_sessions', '.trash']) {
+ for (const dir of ['_snapshots', '_sessions', '.trash', '_mods', '.semantic_index']) {
const node = this.root && this.root.content ? this.root.content[dir] : null;
if (node) keep[dir] = node;
}
@@ -1067,7 +1084,7 @@ class VirtualFileSystem {
}
deleteCheckpoint(name) {
const path = '/_snapshots/checkpoint-' + name + '.json';
- const resolved = this._resolvePath(path);
+ const resolved = this._requireLeaf(this._resolvePath(path));
if (resolved && resolved.name) {
delete resolved.dir.content[resolved.name];
this.saveToStorage();
@@ -3253,9 +3270,14 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
}
}
updateNetworkStatus() {
+ // navigator.onLine is a hint, not a fact: an Android WebView reports false while
+ // fully connected (the same lie `status` used to print). Never turn that hint into
+ // "❌ Offline" — claim Online only when the browser says so, otherwise say unknown
+ // and point at `doctor`, which probes for real.
const isOnline = navigator.onLine;
- this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline';
- this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)';
+ this.networkStatus.textContent = isOnline ? '📶 Online' : '📶 Network?';
+ this.networkStatus.title = isOnline ? '' : 'Browser reports offline — run doctor for a live probe';
+ this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--text-muted)';
}
updatePrompt() {
const path = '/' + this.fs.currentPath.join('/');
@@ -5886,6 +5908,84 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint',
};
}
+
+ // ── doctor measurement helpers ──────────────────────────────────────
+ // UTF-8 byte length of a string. Blob is the reference implementation
+ // (Node 17+/every target browser), TextEncoder the browser fallback, and a
+ // code-point walk the last resort so a stripped-down WebView still gets a
+ // number instead of a thrown command.
+ _utf8Bytes(str) {
+ if (typeof str !== 'string' || str.length === 0) return 0;
+ try {
+ if (typeof Blob === 'function') return new Blob([str]).size;
+ } catch (e) {}
+ try {
+ if (typeof TextEncoder === 'function') return new TextEncoder().encode(str).length;
+ } catch (e) {}
+ let n = 0;
+ for (let i = 0; i < str.length; i++) {
+ const cp = str.codePointAt(i);
+ if (cp > 0xffff) i++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ }
+
+ // Sum of UTF-8 bytes of every file's content under `root`. This is the unit
+ // `vfs-size` reports, without JSON.stringify's braces/quoting overhead and
+ // without the old `chars * 2` fudge. Depth- and node-capped so a malformed
+ // or cyclic tree cannot hang the command.
+ _vfsContentBytes(root) {
+ let bytes = 0, nodes = 0;
+ const walk = (node, depth) => {
+ if (!node || typeof node !== 'object' || depth > 64) return;
+ if (node.type === 'file') {
+ if (typeof node.content === 'string') bytes += this._utf8Bytes(node.content);
+ return;
+ }
+ const kids = node.content;
+ if (!kids || typeof kids !== 'object') return;
+ for (const name of Object.keys(kids)) {
+ if (++nodes > 20000) return;
+ walk(kids[name], depth + 1);
+ }
+ };
+ walk(root, 0);
+ return bytes;
+ }
+
+ // Live reachability probe. The legacy `onLine` boolean is not consulted anywhere:
+ // in Android WebView it reports false while the page is fully connected, which is
+ // exactly how `status` used to tell working users they were offline.
+ async _probeNetwork() {
+ try {
+ if (typeof fetch !== 'function') return { state: 'unprobed', detail: 'fetch unavailable' };
+ } catch (e) {
+ return { state: 'unprobed', detail: 'fetch unavailable' };
+ }
+ let ctl = null, timer = null;
+ try { if (typeof AbortController === 'function') ctl = new AbortController(); } catch (e) {}
+ try {
+ if (ctl) timer = setTimeout(() => { try { ctl.abort(); } catch (e) {} }, 4000);
+ const resp = await fetch('./', {
+ method: 'HEAD',
+ cache: 'no-store',
+ signal: ctl ? ctl.signal : undefined,
+ });
+ if (!resp) return { state: 'unreachable', detail: 'empty response' };
+ if (resp.ok) return { state: 'reachable', detail: 'HTTP ' + resp.status };
+ return { state: 'http-' + resp.status, detail: 'HTTP ' + resp.status };
+ } catch (e) {
+ const aborted = e && (e.name === 'AbortError' || e.name === 'TimeoutError');
+ return {
+ state: 'unreachable',
+ detail: aborted ? 'timed out after 4s' : ((e && e.message) || 'request failed'),
+ };
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ }
+
registerCommands() {
this.commands['help'] = (args) => this.handleHelp(args);
this.commands['echo'] = (args) => this.terminal.print(args.join(' '));
@@ -5934,7 +6034,10 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
}, 500);
this.commands['status'] = () => {
this.terminal.print(`System: WebX11 ${window.NEWOS4_VERSION || 'v4.0'}`);
- this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`);
+ // The legacy `onLine` boolean is NOT evidence of reachability: Android
+ // WebView reports false on a fully connected page, and true on a captive
+ // portal. Say so, and point at the command that actually probes.
+ this.terminal.print('Network: unknown — run doctor for a live probe');
this.terminal.print(`Files: ${this.terminal.fs.ls().length} items`);
this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`);
this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`);
@@ -5961,7 +6064,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
this.terminal.print(` Build: ${buildDate}`);
this.terminal.print(` Cache key: ${cacheKey}`);
this.terminal.print(` Browser: ${ua}`);
- this.terminal.print(` Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ this.terminal.print(` Network: unknown — run doctor for a live probe`);
};
// v4.5.43: uptime — time since page load + last save time
@@ -6017,40 +6120,102 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
};
// v4.5.43: doctor — run all health checks, rank issues, show top 3
- this.commands['doctor'] = () => {
+ // v4.6.x: the legacy `onLine` boolean is GONE from this file. It reports false
+ // on a fully connected Android WebView, which is how `status`/`doctor` used to
+ // announce an outage on a working page. Reachability now comes from a real HEAD
+ // probe, and every size is measured in the unit the store actually charges for.
+ this.commands['doctor'] = async () => {
const issues = [];
const fs = this.terminal.fs;
+ const MB = 1024 * 1024;
- // Check 1: Network
- if (!navigator.onLine) issues.push({ severity: 'high', msg: 'Network: offline' });
+ // Check 1: Network — live probe, 4s budget
+ const net = await this._probeNetwork();
+ if (net.state === 'unreachable') {
+ issues.push({ severity: 'high', msg: `Network: unreachable (HEAD ./ — ${net.detail})` });
+ } else if (net.state.indexOf('http-') === 0) {
+ // A non-ok HEAD is not proof the page is broken (some servers answer
+ // 405 to HEAD); report the status and let the user judge.
+ issues.push({ severity: 'medium', msg: `Network: HTTP probe to ./ returned ${net.state} (page may still work)` });
+ }
// Check 2: Ollama
if (!this.terminal.ollamaManager.connected) {
issues.push({ severity: 'medium', msg: 'Ollama: not connected (AI features limited)' });
}
- // Check 3: VFS size
+ // Origin quota — context only. It is the whole-origin quota (Cache Storage
+ // included, typically hundreds of MB to GBs). localStorage has its own
+ // ~5 MB per-origin cap, so this number must NEVER set a severity: a 4.5 MB
+ // localStorage is 96% of its cap and 0.2% of a 2 GB origin quota.
+ let originUsage = 0, originQuota = 0;
try {
- const vfsBytes = JSON.stringify(fs.root).length;
- if (vfsBytes > 524288) issues.push({ severity: 'high', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 512KB limit` });
- else if (vfsBytes > 262144) issues.push({ severity: 'medium', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 256KB (consider reset-vfs)` });
+ if (typeof navigator !== 'undefined' && navigator.storage &&
+ typeof navigator.storage.estimate === 'function') {
+ const est = await navigator.storage.estimate();
+ if (est && typeof est.quota === 'number' && est.quota > 0) {
+ originQuota = est.quota;
+ originUsage = typeof est.usage === 'number' ? est.usage : 0;
+ }
+ }
+ } catch (e) {}
+
+ // Check 3: VFS — UTF-8 bytes of file content (the unit vfs-size reports),
+ // never JSON.stringify(root).length and never "chars × 2".
+ let vfsBytes = null;
+ try { vfsBytes = this._vfsContentBytes(fs.root); } catch (e) { vfsBytes = null; }
+
+ // Check 4: localStorage — JS strings are UTF-16, so each key and value is
+ // charged 2 bytes per code unit. Σ (key.length + value.length) × 2 is the
+ // real storage cost, not a fudge factor.
+ let lsBytes = 0, lsKeys = 0;
+ try {
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k) continue;
+ lsKeys++;
+ lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
+ }
} catch (e) {}
- // Check 4: Heap
+ // VFS thresholds: a VFS only threatens the browser budget when it is BOTH
+ // large on its own and the bulk of what is actually stored.
+ if (vfsBytes !== null) {
+ const vfsMB = vfsBytes / MB;
+ const share = lsBytes > 0 ? vfsBytes / lsBytes : 0;
+ const budget = lsBytes > 0
+ ? `, ${(share * 100).toFixed(0)}% of the ${(lsBytes / MB).toFixed(1)} MB localStorage budget`
+ : ', no localStorage copy measured';
+ if (vfsBytes > 3 * MB && (lsBytes === 0 || share > 0.8)) {
+ issues.push({ severity: 'high', msg: `VFS: ${vfsMB.toFixed(1)} MB of file content${budget} — close to the ~5 MB per-origin cap` });
+ } else if (vfsBytes > 2 * MB) {
+ issues.push({ severity: 'medium', msg: `VFS: ${vfsMB.toFixed(1)} MB of file content${budget} (consider deleting stale checkpoints)` });
+ }
+ }
+
+ // localStorage thresholds: the cap is ~5 MB per origin, whatever
+ // navigator.storage.estimate() says the whole origin may use.
+ if (lsBytes > 4 * MB) {
+ issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes / MB).toFixed(1)} MB of UTF-16 data in ${lsKeys} keys — approaching the ~5 MB per-origin cap` });
+ } else if (lsBytes > 3 * MB) {
+ issues.push({ severity: 'medium', msg: `localStorage: ${(lsBytes / MB).toFixed(1)} MB of UTF-16 data in ${lsKeys} keys — past half of the ~5 MB per-origin cap` });
+ }
+
+ // Check 5: Heap
if (performance.memory) {
const heapMB = performance.memory.usedJSHeapSize / 1048576;
if (heapMB > 200) issues.push({ severity: 'high', msg: `Heap: ${heapMB.toFixed(0)}MB > 200MB` });
else if (heapMB > 100) issues.push({ severity: 'medium', msg: `Heap: ${heapMB.toFixed(0)}MB > 100MB` });
}
- // Check 5: Logs
+ // Check 6: Logs
try {
const logContent = fs.readFile('/logs/current.log') || '';
const logLines = logContent.split('\n').filter(l => l).length;
if (logLines > 500) issues.push({ severity: 'low', msg: `Logs: ${logLines} lines (consider logrotate)` });
} catch (e) {}
- // Check 6: Errors in last 100 log lines
+ // Check 7: Errors in last 100 log lines
try {
const logContent = fs.readFile('/logs/current.log') || '';
const recent = logContent.split('\n').filter(l => l).slice(-100);
@@ -6059,16 +6224,6 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
else if (errorCount > 0) issues.push({ severity: 'medium', msg: `Errors: ${errorCount} errors in last 100 log lines` });
} catch (e) {}
- // Check 7: localStorage quota
- let lsBytes = 0;
- try {
- for (let i = 0; i < localStorage.length; i++) {
- const k = localStorage.key(i);
- if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
- }
- } catch (e) {}
- if (lsBytes > 4 * 1024 * 1024) issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes/1024/1024).toFixed(1)}MB approaching 5MB quota` });
-
// Check 8: Disabled modules
let disabled = [];
try { disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {}
@@ -6077,6 +6232,11 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
}
this.terminal.print('🩺 Doctor Report', 'header');
+ this.terminal.print(` Measured: VFS ${vfsBytes === null ? 'unavailable' : (vfsBytes / MB).toFixed(2) + ' MB UTF-8'} · localStorage ${(lsBytes / MB).toFixed(2)} MB UTF-16 in ${lsKeys} keys`, 'info');
+ this.terminal.print(` Network: ${net.state === 'reachable' ? 'reachable (' + net.detail + ')' : net.state + (net.detail ? ' — ' + net.detail : '')}`, 'info');
+ if (originQuota > 0) {
+ this.terminal.print(` Origin usage ${(originUsage / MB).toFixed(1)} MB of ~${(originQuota / MB).toFixed(0)} MB quota (whole origin, mostly Cache Storage — localStorage has its own ~5 MB cap)`, 'info');
+ }
if (issues.length === 0) {
this.terminal.print(' ✅ All systems healthy', 'success');
return;
@@ -6085,20 +6245,21 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
const sevOrder = { high: 0, medium: 1, low: 2 };
issues.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
this.terminal.print(` Found ${issues.length} issue(s):`, 'warning');
- for (const i of issues.slice(0, 5)) {
+ for (const i of issues.slice(0, 3)) {
const icon = i.severity === 'high' ? '🔴' : i.severity === 'medium' ? '🟡' : '🟢';
this.terminal.print(` ${icon} [${i.severity.toUpperCase()}] ${i.msg}`, i.severity === 'high' ? 'error' : 'warning');
}
- if (issues.length > 5) this.terminal.print(` ... and ${issues.length - 5} more`, 'info');
+ if (issues.length > 3) this.terminal.print(` ... and ${issues.length - 3} more`, 'info');
this.terminal.print('', '');
this.terminal.print(' Recommended actions:', 'info');
- if (issues.some(i => i.msg.startsWith('Network'))) this.terminal.print(' → Check internet connection');
+ if (issues.some(i => i.msg.startsWith('Network: unreachable'))) this.terminal.print(' → Check connectivity; reload before trusting this probe');
+ if (issues.some(i => i.msg.startsWith('Network: HTTP probe'))) this.terminal.print(' → Server answered the probe with an error status — check the server, not the network');
if (issues.some(i => i.msg.startsWith('Ollama'))) this.terminal.print(' → Check Ollama is running (ollama serve)');
- if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Run reset-vfs --confirm to clear or checkpoint/rollback');
+ if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Delete stale snapshots (checkpoint-list, checkpoint-del <name>) or rollback; do NOT reset-vfs');
if (issues.some(i => i.msg.startsWith('Heap'))) this.terminal.print(' → Refresh the page to clear memory');
if (issues.some(i => i.msg.startsWith('Logs'))) this.terminal.print(' → Run logrotate');
if (issues.some(i => i.msg.startsWith('Errors'))) this.terminal.print(' → Run errors to see details, or bug-report to save state');
- if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Run factory-reset --confirm (last resort)');
+ if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Free space by deleting stale VFS entries or checkpoints; do NOT factory-reset');
};
// v4.5.43: bug-report — generate markdown report to /tmp/
@@ -6112,7 +6273,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
lines.push('## System');
lines.push(`- Version: ${window.NEWOS4_VERSION || 'unknown'}`);
lines.push(`- Cache key: ${localStorage.getItem('webx11_cache_ver') || 'unknown'}`);
- lines.push(`- Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ lines.push(`- Online: unknown (not probed; run doctor)`);
lines.push(`- Browser: ${navigator.userAgent}`);
lines.push(`- Screen: ${screen.width}x${screen.height}`);
lines.push(`- Heap: ${performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A'}`);
@@ -6176,7 +6337,10 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
version: window.NEWOS4_VERSION || 'unknown',
url: location.href,
ua: navigator.userAgent,
- online: navigator.onLine,
+ // Legacy key, kept so stored reports keep their shape. The old onLine
+ // boolean lied in Android WebView, so null = "not asserted".
+ online: null,
+ network: 'unknown',
modules: Object.keys(this.terminal.moduleManager.modules).length,
errors: [],
};
@@ -7416,17 +7580,52 @@ class FileSystemModule {
};
this.commands['vfs-size'] = () => {
try {
- const json = JSON.stringify(this.terminal.fs.root);
- const totalSize = (json.length / 1024 / 1024).toFixed(2);
+ // Every number this command prints is STORED BYTES: the UTF-8 length of each
+ // file's content. It used to report JSON.stringify(root).length, which is UTF-16
+ // string units plus every structural character of the serialisation — roughly 2x
+ // the real size of a plain-text tree, and wrong the other way for non-ASCII.
+ const utf8Len = (s) => {
+ if (typeof s !== 'string' || s.length === 0) return 0;
+ if (typeof Blob !== 'undefined') {
+ try { return new Blob([s]).size; } catch (e) { /* fall through */ }
+ }
+ if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(s).length;
+ return s.length; // last resort only: no Blob and no TextEncoder
+ };
+ const fmtSize = (bytes) => {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
+ };
+ // Recursively total one subtree. Each file is visited exactly once, so a nested
+ // tree is never double counted and no directory node adds bytes of its own.
+ const measure = (node) => {
+ const out = { bytes: 0, files: 0 };
+ if (!node || !node.content) return out;
+ for (const child of Object.values(node.content)) {
+ if (!child) continue;
+ if (child.type === 'file') {
+ out.bytes += utf8Len(child.content);
+ out.files += 1;
+ } else if (child.type === 'dir') {
+ const sub = measure(child);
+ out.bytes += sub.bytes;
+ out.files += sub.files;
+ }
+ }
+ return out;
+ };
const root = this.terminal.fs.root;
+ const total = measure(root);
+ // Per-file table, same byte metric.
const sizeMap = [];
function walk(node, path) {
if (!node || !node.content) return;
for (const [name, child] of Object.entries(node.content)) {
+ if (!child) continue;
const fullPath = path + '/' + name;
if (child.type === 'file') {
- const size = (child.content ? child.content.length : 0);
- sizeMap.push({ path: fullPath, size, sizeKB: (size / 1024).toFixed(1) });
+ sizeMap.push({ path: fullPath, size: utf8Len(child.content) });
} else if (child.type === 'dir') {
walk(child, fullPath);
}
@@ -7434,23 +7633,54 @@ class FileSystemModule {
}
walk(root, '');
sizeMap.sort((a, b) => b.size - a.size);
+ // Top-level groups, so the table accounts for the whole tree: one row per
+ // top-level directory plus a row for files stored directly at root.
+ const groups = [];
+ const rootFiles = { bytes: 0, files: 0 };
+ for (const [name, child] of Object.entries((root && root.content) || {})) {
+ if (!child) continue;
+ if (child.type === 'dir') {
+ const m = measure(child);
+ groups.push({ name: '/' + name, bytes: m.bytes, files: m.files });
+ } else if (child.type === 'file') {
+ rootFiles.bytes += utf8Len(child.content);
+ rootFiles.files += 1;
+ }
+ }
+ if (rootFiles.files > 0) groups.push({ name: '/(root files)', bytes: rootFiles.bytes, files: rootFiles.files });
+ groups.sort((a, b) => b.bytes - a.bytes);
const totalFiles = sizeMap.length;
- const top5 = sizeMap.slice(0, 5);
this.terminal.print('📊 VFS Size Report', 'text-muted');
- this.terminal.print(' Total: ' + totalSize + ' MB across ' + totalFiles + ' files');
- this.terminal.print(' Largest:');
- top5.forEach((f, i) => {
- this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + f.sizeKB + ' KB');
- });
- // Show system dir sizes
- const sysDirs = ['/_snapshots', '/_sessions', '/.trash'];
+ this.terminal.print(' Total: ' + fmtSize(total.bytes) + ' (' + total.bytes + ' bytes) across ' + totalFiles + (totalFiles === 1 ? ' file' : ' files'));
+ if (totalFiles > 0) {
+ this.terminal.print(' Largest:');
+ const shownFiles = sizeMap.slice(0, 10);
+ shownFiles.forEach((f, i) => {
+ this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + fmtSize(f.size));
+ });
+ // The rows must add up to the total above, so the tail is folded into one
+ // row instead of dropped: this table used to be top-5 only, and the rest of
+ // the tree was missing from the report entirely.
+ if (totalFiles > shownFiles.length) {
+ let restBytes = 0;
+ for (const f of sizeMap.slice(shownFiles.length)) restBytes += f.size;
+ this.terminal.print(' (+' + (totalFiles - shownFiles.length) + ' more files — ' + fmtSize(restBytes) + ')');
+ }
+ }
+ // Every top-level group is listed (root breadth, not depth), so these rows
+ // always sum to the total above.
+ this.terminal.print(' Top directories by stored bytes:', 'info');
+ for (const g of groups) {
+ this.terminal.print(' ' + g.name + ' — ' + fmtSize(g.bytes) + ' (' + g.files + (g.files === 1 ? ' file)' : ' files)'));
+ }
+ // Show system dir sizes — real bytes, not JSON.stringify(list of entries).length
+ const sysDirs = ['/_snapshots', '/_sessions', '/.trash', '/_mods', '/.semantic_index'];
for (const dir of sysDirs) {
- const dirNode = this.terminal.fs.readDir(dir);
+ const name = dir.replace(/^\//, '');
+ const dirNode = (root && root.content && root.content[name]) || null;
if (dirNode) {
- const cnt = dirNode.length;
- // estimate dir size
- const dirSize = JSON.stringify(dirNode).length;
- this.terminal.print(' ' + dir + ': ' + cnt + ' files, ~' + (dirSize / 1024).toFixed(1) + ' KB', dirSize > 102400 ? 'warning' : 'text-muted');
+ const m = measure(dirNode);
+ this.terminal.print(' ' + dir + ': ' + m.files + (m.files === 1 ? ' file, ' : ' files, ') + fmtSize(m.bytes), m.bytes > 102400 ? 'warning' : 'text-muted');
}
}
} catch (e) {
@@ -8553,7 +8783,9 @@ class ServerMonitorModule {
this.terminal.print('🌐 Network Test', 'header');
// 1. Browser online
const online = navigator.onLine;
- this.terminal.print(` ${online ? '✅' : '🔴'} Browser: ${online ? 'online' : 'offline'}`, online ? 'success' : 'error');
+ // Reported, not measured: WebViews answer false while connected. The real verdict comes
+ // from the proxy probe below, so this line must not read as a failure.
+ this.terminal.print(` ${online ? '✅' : '❔'} Browser reports: ${online ? 'online' : 'offline (unreliable)'}`, online ? 'success' : 'warning');
// 2. Proxy API
try {
const t0 = Date.now();
diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js
index 0dea0d5..b6cf272 100644
--- a/modules/core/module-fs.js
+++ b/modules/core/module-fs.js
@@ -730,17 +730,52 @@ class FileSystemModule {
};
this.commands['vfs-size'] = () => {
try {
- const json = JSON.stringify(this.terminal.fs.root);
- const totalSize = (json.length / 1024 / 1024).toFixed(2);
+ // Every number this command prints is STORED BYTES: the UTF-8 length of each
+ // file's content. It used to report JSON.stringify(root).length, which is UTF-16
+ // string units plus every structural character of the serialisation — roughly 2x
+ // the real size of a plain-text tree, and wrong the other way for non-ASCII.
+ const utf8Len = (s) => {
+ if (typeof s !== 'string' || s.length === 0) return 0;
+ if (typeof Blob !== 'undefined') {
+ try { return new Blob([s]).size; } catch (e) { /* fall through */ }
+ }
+ if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(s).length;
+ return s.length; // last resort only: no Blob and no TextEncoder
+ };
+ const fmtSize = (bytes) => {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
+ };
+ // Recursively total one subtree. Each file is visited exactly once, so a nested
+ // tree is never double counted and no directory node adds bytes of its own.
+ const measure = (node) => {
+ const out = { bytes: 0, files: 0 };
+ if (!node || !node.content) return out;
+ for (const child of Object.values(node.content)) {
+ if (!child) continue;
+ if (child.type === 'file') {
+ out.bytes += utf8Len(child.content);
+ out.files += 1;
+ } else if (child.type === 'dir') {
+ const sub = measure(child);
+ out.bytes += sub.bytes;
+ out.files += sub.files;
+ }
+ }
+ return out;
+ };
const root = this.terminal.fs.root;
+ const total = measure(root);
+ // Per-file table, same byte metric.
const sizeMap = [];
function walk(node, path) {
if (!node || !node.content) return;
for (const [name, child] of Object.entries(node.content)) {
+ if (!child) continue;
const fullPath = path + '/' + name;
if (child.type === 'file') {
- const size = (child.content ? child.content.length : 0);
- sizeMap.push({ path: fullPath, size, sizeKB: (size / 1024).toFixed(1) });
+ sizeMap.push({ path: fullPath, size: utf8Len(child.content) });
} else if (child.type === 'dir') {
walk(child, fullPath);
}
@@ -748,23 +783,54 @@ class FileSystemModule {
}
walk(root, '');
sizeMap.sort((a, b) => b.size - a.size);
+ // Top-level groups, so the table accounts for the whole tree: one row per
+ // top-level directory plus a row for files stored directly at root.
+ const groups = [];
+ const rootFiles = { bytes: 0, files: 0 };
+ for (const [name, child] of Object.entries((root && root.content) || {})) {
+ if (!child) continue;
+ if (child.type === 'dir') {
+ const m = measure(child);
+ groups.push({ name: '/' + name, bytes: m.bytes, files: m.files });
+ } else if (child.type === 'file') {
+ rootFiles.bytes += utf8Len(child.content);
+ rootFiles.files += 1;
+ }
+ }
+ if (rootFiles.files > 0) groups.push({ name: '/(root files)', bytes: rootFiles.bytes, files: rootFiles.files });
+ groups.sort((a, b) => b.bytes - a.bytes);
const totalFiles = sizeMap.length;
- const top5 = sizeMap.slice(0, 5);
this.terminal.print('📊 VFS Size Report', 'text-muted');
- this.terminal.print(' Total: ' + totalSize + ' MB across ' + totalFiles + ' files');
- this.terminal.print(' Largest:');
- top5.forEach((f, i) => {
- this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + f.sizeKB + ' KB');
- });
- // Show system dir sizes
- const sysDirs = ['/_snapshots', '/_sessions', '/.trash'];
+ this.terminal.print(' Total: ' + fmtSize(total.bytes) + ' (' + total.bytes + ' bytes) across ' + totalFiles + (totalFiles === 1 ? ' file' : ' files'));
+ if (totalFiles > 0) {
+ this.terminal.print(' Largest:');
+ const shownFiles = sizeMap.slice(0, 10);
+ shownFiles.forEach((f, i) => {
+ this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + fmtSize(f.size));
+ });
+ // The rows must add up to the total above, so the tail is folded into one
+ // row instead of dropped: this table used to be top-5 only, and the rest of
+ // the tree was missing from the report entirely.
+ if (totalFiles > shownFiles.length) {
+ let restBytes = 0;
+ for (const f of sizeMap.slice(shownFiles.length)) restBytes += f.size;
+ this.terminal.print(' (+' + (totalFiles - shownFiles.length) + ' more files — ' + fmtSize(restBytes) + ')');
+ }
+ }
+ // Every top-level group is listed (root breadth, not depth), so these rows
+ // always sum to the total above.
+ this.terminal.print(' Top directories by stored bytes:', 'info');
+ for (const g of groups) {
+ this.terminal.print(' ' + g.name + ' — ' + fmtSize(g.bytes) + ' (' + g.files + (g.files === 1 ? ' file)' : ' files)'));
+ }
+ // Show system dir sizes — real bytes, not JSON.stringify(list of entries).length
+ const sysDirs = ['/_snapshots', '/_sessions', '/.trash', '/_mods', '/.semantic_index'];
for (const dir of sysDirs) {
- const dirNode = this.terminal.fs.readDir(dir);
+ const name = dir.replace(/^\//, '');
+ const dirNode = (root && root.content && root.content[name]) || null;
if (dirNode) {
- const cnt = dirNode.length;
- // estimate dir size
- const dirSize = JSON.stringify(dirNode).length;
- this.terminal.print(' ' + dir + ': ' + cnt + ' files, ~' + (dirSize / 1024).toFixed(1) + ' KB', dirSize > 102400 ? 'warning' : 'text-muted');
+ const m = measure(dirNode);
+ this.terminal.print(' ' + dir + ': ' + m.files + (m.files === 1 ? ' file, ' : ' files, ') + fmtSize(m.bytes), m.bytes > 102400 ? 'warning' : 'text-muted');
}
}
} catch (e) {
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index b926c4a..801bdb7 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.159';
+ const LATEST_VER = 'v4.6.160';
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-servermon.js b/modules/core/module-servermon.js
index 600618a..2d320f4 100644
--- a/modules/core/module-servermon.js
+++ b/modules/core/module-servermon.js
@@ -32,7 +32,9 @@ class ServerMonitorModule {
this.terminal.print('🌐 Network Test', 'header');
// 1. Browser online
const online = navigator.onLine;
- this.terminal.print(` ${online ? '✅' : '🔴'} Browser: ${online ? 'online' : 'offline'}`, online ? 'success' : 'error');
+ // Reported, not measured: WebViews answer false while connected. The real verdict comes
+ // from the proxy probe below, so this line must not read as a failure.
+ this.terminal.print(` ${online ? '✅' : '❔'} Browser reports: ${online ? 'online' : 'offline (unreliable)'}`, online ? 'success' : 'warning');
// 2. Proxy API
try {
const t0 = Date.now();
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
index 43bb0b1..ef37b11 100644
--- a/modules/core/module-system.js
+++ b/modules/core/module-system.js
@@ -35,6 +35,84 @@
'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint',
};
}
+
+ // ── doctor measurement helpers ──────────────────────────────────────
+ // UTF-8 byte length of a string. Blob is the reference implementation
+ // (Node 17+/every target browser), TextEncoder the browser fallback, and a
+ // code-point walk the last resort so a stripped-down WebView still gets a
+ // number instead of a thrown command.
+ _utf8Bytes(str) {
+ if (typeof str !== 'string' || str.length === 0) return 0;
+ try {
+ if (typeof Blob === 'function') return new Blob([str]).size;
+ } catch (e) {}
+ try {
+ if (typeof TextEncoder === 'function') return new TextEncoder().encode(str).length;
+ } catch (e) {}
+ let n = 0;
+ for (let i = 0; i < str.length; i++) {
+ const cp = str.codePointAt(i);
+ if (cp > 0xffff) i++; // surrogate pair consumed
+ n += cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;
+ }
+ return n;
+ }
+
+ // Sum of UTF-8 bytes of every file's content under `root`. This is the unit
+ // `vfs-size` reports, without JSON.stringify's braces/quoting overhead and
+ // without the old `chars * 2` fudge. Depth- and node-capped so a malformed
+ // or cyclic tree cannot hang the command.
+ _vfsContentBytes(root) {
+ let bytes = 0, nodes = 0;
+ const walk = (node, depth) => {
+ if (!node || typeof node !== 'object' || depth > 64) return;
+ if (node.type === 'file') {
+ if (typeof node.content === 'string') bytes += this._utf8Bytes(node.content);
+ return;
+ }
+ const kids = node.content;
+ if (!kids || typeof kids !== 'object') return;
+ for (const name of Object.keys(kids)) {
+ if (++nodes > 20000) return;
+ walk(kids[name], depth + 1);
+ }
+ };
+ walk(root, 0);
+ return bytes;
+ }
+
+ // Live reachability probe. The legacy `onLine` boolean is not consulted anywhere:
+ // in Android WebView it reports false while the page is fully connected, which is
+ // exactly how `status` used to tell working users they were offline.
+ async _probeNetwork() {
+ try {
+ if (typeof fetch !== 'function') return { state: 'unprobed', detail: 'fetch unavailable' };
+ } catch (e) {
+ return { state: 'unprobed', detail: 'fetch unavailable' };
+ }
+ let ctl = null, timer = null;
+ try { if (typeof AbortController === 'function') ctl = new AbortController(); } catch (e) {}
+ try {
+ if (ctl) timer = setTimeout(() => { try { ctl.abort(); } catch (e) {} }, 4000);
+ const resp = await fetch('./', {
+ method: 'HEAD',
+ cache: 'no-store',
+ signal: ctl ? ctl.signal : undefined,
+ });
+ if (!resp) return { state: 'unreachable', detail: 'empty response' };
+ if (resp.ok) return { state: 'reachable', detail: 'HTTP ' + resp.status };
+ return { state: 'http-' + resp.status, detail: 'HTTP ' + resp.status };
+ } catch (e) {
+ const aborted = e && (e.name === 'AbortError' || e.name === 'TimeoutError');
+ return {
+ state: 'unreachable',
+ detail: aborted ? 'timed out after 4s' : ((e && e.message) || 'request failed'),
+ };
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+ }
+
registerCommands() {
this.commands['help'] = (args) => this.handleHelp(args);
this.commands['echo'] = (args) => this.terminal.print(args.join(' '));
@@ -83,7 +161,10 @@
}, 500);
this.commands['status'] = () => {
this.terminal.print(`System: WebX11 ${window.NEWOS4_VERSION || 'v4.0'}`);
- this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`);
+ // The legacy `onLine` boolean is NOT evidence of reachability: Android
+ // WebView reports false on a fully connected page, and true on a captive
+ // portal. Say so, and point at the command that actually probes.
+ this.terminal.print('Network: unknown — run doctor for a live probe');
this.terminal.print(`Files: ${this.terminal.fs.ls().length} items`);
this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`);
this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`);
@@ -110,7 +191,7 @@
this.terminal.print(` Build: ${buildDate}`);
this.terminal.print(` Cache key: ${cacheKey}`);
this.terminal.print(` Browser: ${ua}`);
- this.terminal.print(` Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ this.terminal.print(` Network: unknown — run doctor for a live probe`);
};
// v4.5.43: uptime — time since page load + last save time
@@ -166,40 +247,102 @@
};
// v4.5.43: doctor — run all health checks, rank issues, show top 3
- this.commands['doctor'] = () => {
+ // v4.6.x: the legacy `onLine` boolean is GONE from this file. It reports false
+ // on a fully connected Android WebView, which is how `status`/`doctor` used to
+ // announce an outage on a working page. Reachability now comes from a real HEAD
+ // probe, and every size is measured in the unit the store actually charges for.
+ this.commands['doctor'] = async () => {
const issues = [];
const fs = this.terminal.fs;
-
- // Check 1: Network
- if (!navigator.onLine) issues.push({ severity: 'high', msg: 'Network: offline' });
+ const MB = 1024 * 1024;
+
+ // Check 1: Network — live probe, 4s budget
+ const net = await this._probeNetwork();
+ if (net.state === 'unreachable') {
+ issues.push({ severity: 'high', msg: `Network: unreachable (HEAD ./ — ${net.detail})` });
+ } else if (net.state.indexOf('http-') === 0) {
+ // A non-ok HEAD is not proof the page is broken (some servers answer
+ // 405 to HEAD); report the status and let the user judge.
+ issues.push({ severity: 'medium', msg: `Network: HTTP probe to ./ returned ${net.state} (page may still work)` });
+ }
// Check 2: Ollama
if (!this.terminal.ollamaManager.connected) {
issues.push({ severity: 'medium', msg: 'Ollama: not connected (AI features limited)' });
}
- // Check 3: VFS size
+ // Origin quota — context only. It is the whole-origin quota (Cache Storage
+ // included, typically hundreds of MB to GBs). localStorage has its own
+ // ~5 MB per-origin cap, so this number must NEVER set a severity: a 4.5 MB
+ // localStorage is 96% of its cap and 0.2% of a 2 GB origin quota.
+ let originUsage = 0, originQuota = 0;
+ try {
+ if (typeof navigator !== 'undefined' && navigator.storage &&
+ typeof navigator.storage.estimate === 'function') {
+ const est = await navigator.storage.estimate();
+ if (est && typeof est.quota === 'number' && est.quota > 0) {
+ originQuota = est.quota;
+ originUsage = typeof est.usage === 'number' ? est.usage : 0;
+ }
+ }
+ } catch (e) {}
+
+ // Check 3: VFS — UTF-8 bytes of file content (the unit vfs-size reports),
+ // never JSON.stringify(root).length and never "chars × 2".
+ let vfsBytes = null;
+ try { vfsBytes = this._vfsContentBytes(fs.root); } catch (e) { vfsBytes = null; }
+
+ // Check 4: localStorage — JS strings are UTF-16, so each key and value is
+ // charged 2 bytes per code unit. Σ (key.length + value.length) × 2 is the
+ // real storage cost, not a fudge factor.
+ let lsBytes = 0, lsKeys = 0;
try {
- const vfsBytes = JSON.stringify(fs.root).length;
- if (vfsBytes > 524288) issues.push({ severity: 'high', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 512KB limit` });
- else if (vfsBytes > 262144) issues.push({ severity: 'medium', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 256KB (consider reset-vfs)` });
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (!k) continue;
+ lsKeys++;
+ lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
+ }
} catch (e) {}
- // Check 4: Heap
+ // VFS thresholds: a VFS only threatens the browser budget when it is BOTH
+ // large on its own and the bulk of what is actually stored.
+ if (vfsBytes !== null) {
+ const vfsMB = vfsBytes / MB;
+ const share = lsBytes > 0 ? vfsBytes / lsBytes : 0;
+ const budget = lsBytes > 0
+ ? `, ${(share * 100).toFixed(0)}% of the ${(lsBytes / MB).toFixed(1)} MB localStorage budget`
+ : ', no localStorage copy measured';
+ if (vfsBytes > 3 * MB && (lsBytes === 0 || share > 0.8)) {
+ issues.push({ severity: 'high', msg: `VFS: ${vfsMB.toFixed(1)} MB of file content${budget} — close to the ~5 MB per-origin cap` });
+ } else if (vfsBytes > 2 * MB) {
+ issues.push({ severity: 'medium', msg: `VFS: ${vfsMB.toFixed(1)} MB of file content${budget} (consider deleting stale checkpoints)` });
+ }
+ }
+
+ // localStorage thresholds: the cap is ~5 MB per origin, whatever
+ // navigator.storage.estimate() says the whole origin may use.
+ if (lsBytes > 4 * MB) {
+ issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes / MB).toFixed(1)} MB of UTF-16 data in ${lsKeys} keys — approaching the ~5 MB per-origin cap` });
+ } else if (lsBytes > 3 * MB) {
+ issues.push({ severity: 'medium', msg: `localStorage: ${(lsBytes / MB).toFixed(1)} MB of UTF-16 data in ${lsKeys} keys — past half of the ~5 MB per-origin cap` });
+ }
+
+ // Check 5: Heap
if (performance.memory) {
const heapMB = performance.memory.usedJSHeapSize / 1048576;
if (heapMB > 200) issues.push({ severity: 'high', msg: `Heap: ${heapMB.toFixed(0)}MB > 200MB` });
else if (heapMB > 100) issues.push({ severity: 'medium', msg: `Heap: ${heapMB.toFixed(0)}MB > 100MB` });
}
- // Check 5: Logs
+ // Check 6: Logs
try {
const logContent = fs.readFile('/logs/current.log') || '';
const logLines = logContent.split('\n').filter(l => l).length;
if (logLines > 500) issues.push({ severity: 'low', msg: `Logs: ${logLines} lines (consider logrotate)` });
} catch (e) {}
- // Check 6: Errors in last 100 log lines
+ // Check 7: Errors in last 100 log lines
try {
const logContent = fs.readFile('/logs/current.log') || '';
const recent = logContent.split('\n').filter(l => l).slice(-100);
@@ -208,16 +351,6 @@
else if (errorCount > 0) issues.push({ severity: 'medium', msg: `Errors: ${errorCount} errors in last 100 log lines` });
} catch (e) {}
- // Check 7: localStorage quota
- let lsBytes = 0;
- try {
- for (let i = 0; i < localStorage.length; i++) {
- const k = localStorage.key(i);
- if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2;
- }
- } catch (e) {}
- if (lsBytes > 4 * 1024 * 1024) issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes/1024/1024).toFixed(1)}MB approaching 5MB quota` });
-
// Check 8: Disabled modules
let disabled = [];
try { disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {}
@@ -226,6 +359,11 @@
}
this.terminal.print('🩺 Doctor Report', 'header');
+ this.terminal.print(` Measured: VFS ${vfsBytes === null ? 'unavailable' : (vfsBytes / MB).toFixed(2) + ' MB UTF-8'} · localStorage ${(lsBytes / MB).toFixed(2)} MB UTF-16 in ${lsKeys} keys`, 'info');
+ this.terminal.print(` Network: ${net.state === 'reachable' ? 'reachable (' + net.detail + ')' : net.state + (net.detail ? ' — ' + net.detail : '')}`, 'info');
+ if (originQuota > 0) {
+ this.terminal.print(` Origin usage ${(originUsage / MB).toFixed(1)} MB of ~${(originQuota / MB).toFixed(0)} MB quota (whole origin, mostly Cache Storage — localStorage has its own ~5 MB cap)`, 'info');
+ }
if (issues.length === 0) {
this.terminal.print(' ✅ All systems healthy', 'success');
return;
@@ -234,20 +372,21 @@
const sevOrder = { high: 0, medium: 1, low: 2 };
issues.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]);
this.terminal.print(` Found ${issues.length} issue(s):`, 'warning');
- for (const i of issues.slice(0, 5)) {
+ for (const i of issues.slice(0, 3)) {
const icon = i.severity === 'high' ? '🔴' : i.severity === 'medium' ? '🟡' : '🟢';
this.terminal.print(` ${icon} [${i.severity.toUpperCase()}] ${i.msg}`, i.severity === 'high' ? 'error' : 'warning');
}
- if (issues.length > 5) this.terminal.print(` ... and ${issues.length - 5} more`, 'info');
+ if (issues.length > 3) this.terminal.print(` ... and ${issues.length - 3} more`, 'info');
this.terminal.print('', '');
this.terminal.print(' Recommended actions:', 'info');
- if (issues.some(i => i.msg.startsWith('Network'))) this.terminal.print(' → Check internet connection');
+ if (issues.some(i => i.msg.startsWith('Network: unreachable'))) this.terminal.print(' → Check connectivity; reload before trusting this probe');
+ if (issues.some(i => i.msg.startsWith('Network: HTTP probe'))) this.terminal.print(' → Server answered the probe with an error status — check the server, not the network');
if (issues.some(i => i.msg.startsWith('Ollama'))) this.terminal.print(' → Check Ollama is running (ollama serve)');
- if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Run reset-vfs --confirm to clear or checkpoint/rollback');
+ if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Delete stale snapshots (checkpoint-list, checkpoint-del <name>) or rollback; do NOT reset-vfs');
if (issues.some(i => i.msg.startsWith('Heap'))) this.terminal.print(' → Refresh the page to clear memory');
if (issues.some(i => i.msg.startsWith('Logs'))) this.terminal.print(' → Run logrotate');
if (issues.some(i => i.msg.startsWith('Errors'))) this.terminal.print(' → Run errors to see details, or bug-report to save state');
- if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Run factory-reset --confirm (last resort)');
+ if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Free space by deleting stale VFS entries or checkpoints; do NOT factory-reset');
};
// v4.5.43: bug-report — generate markdown report to /tmp/
@@ -261,7 +400,7 @@
lines.push('## System');
lines.push(`- Version: ${window.NEWOS4_VERSION || 'unknown'}`);
lines.push(`- Cache key: ${localStorage.getItem('webx11_cache_ver') || 'unknown'}`);
- lines.push(`- Online: ${navigator.onLine ? 'yes' : 'no'}`);
+ lines.push(`- Online: unknown (not probed; run doctor)`);
lines.push(`- Browser: ${navigator.userAgent}`);
lines.push(`- Screen: ${screen.width}x${screen.height}`);
lines.push(`- Heap: ${performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A'}`);
@@ -325,7 +464,10 @@
version: window.NEWOS4_VERSION || 'unknown',
url: location.href,
ua: navigator.userAgent,
- online: navigator.onLine,
+ // Legacy key, kept so stored reports keep their shape. The old onLine
+ // boolean lied in Android WebView, so null = "not asserted".
+ online: null,
+ network: 'unknown',
modules: Object.keys(this.terminal.moduleManager.modules).length,
errors: [],
};
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index d0f6cff..9750c24 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -564,9 +564,14 @@
}
}
updateNetworkStatus() {
+ // navigator.onLine is a hint, not a fact: an Android WebView reports false while
+ // fully connected (the same lie `status` used to print). Never turn that hint into
+ // "❌ Offline" — claim Online only when the browser says so, otherwise say unknown
+ // and point at `doctor`, which probes for real.
const isOnline = navigator.onLine;
- this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline';
- this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)';
+ this.networkStatus.textContent = isOnline ? '📶 Online' : '📶 Network?';
+ this.networkStatus.title = isOnline ? '' : 'Browser reports offline — run doctor for a live probe';
+ this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--text-muted)';
}
updatePrompt() {
const path = '/' + this.fs.currentPath.join('/');
diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js
index 3a3e119..57c6578 100644
--- a/modules/core/virtual-fs.js
+++ b/modules/core/virtual-fs.js
@@ -510,6 +510,19 @@ class VirtualFileSystem {
}
return { dir, name: segs[segs.length - 1] };
}
+ // newOS4 patch: _resolvePath() validates intermediate segments only, so it returns a
+ // result for a leaf that does not exist. Mutating callers then acted on a phantom
+ // path and reported success (deleteCheckpoint printed 'Deleted checkpoint' and
+ // deleted nothing). _requireLeaf() refuses any resolved path whose leaf is absent.
+ _requireLeaf(resolved) {
+ if (!resolved) return null;
+ if (resolved.name) {
+ const node = (resolved.dir && resolved.dir.content) ? resolved.dir.content[resolved.name] : null;
+ if (!node) return null;
+ }
+ return resolved;
+ }
+
_resolveDir(path) {
if (this._isVirtualPath(path)) {
const r = this._resolveVirtualPath(path);
@@ -579,7 +592,7 @@ class VirtualFileSystem {
}
deleteFile(path) {
if (this._isVirtualPath(path)) return false;
- const resolved = this._resolvePath(path);
+ const resolved = this._requireLeaf(this._resolvePath(path));
if (!resolved || !resolved.name) return false;
const node = resolved.dir.content[resolved.name];
if (node && node.type === 'file') {
@@ -966,6 +979,10 @@ class VirtualFileSystem {
delete rootClone.content['_sessions'];
delete rootClone.content['_snapshots'];
delete rootClone.content['.trash'];
+ // newOS4 patch: module originals and the semantic index dominated every
+ // snapshot (one checkpoint reached 4.13MB) and grew on each new checkpoint.
+ delete rootClone.content['_mods'];
+ delete rootClone.content['.semantic_index'];
}
const data = {
version: 1,
@@ -1000,7 +1017,7 @@ class VirtualFileSystem {
// clone verbatim deletes every checkpoint, every agent session and the trash, so keep the
// live copies and re-attach them after the root swap.
const keep = {};
- for (const dir of ['_snapshots', '_sessions', '.trash']) {
+ for (const dir of ['_snapshots', '_sessions', '.trash', '_mods', '.semantic_index']) {
const node = this.root && this.root.content ? this.root.content[dir] : null;
if (node) keep[dir] = node;
}
@@ -1020,7 +1037,7 @@ class VirtualFileSystem {
}
deleteCheckpoint(name) {
const path = '/_snapshots/checkpoint-' + name + '.json';
- const resolved = this._resolvePath(path);
+ const resolved = this._requireLeaf(this._resolvePath(path));
if (resolved && resolved.name) {
delete resolved.dir.content[resolved.name];
this.saveToStorage();