class VirtualFileSystem { constructor() { this.root = { type: 'dir', content: {} }; this.currentPath = []; this._ready = this.loadFromStorage(); this._virtualMounts = null; // lazy-init on first /sys/ access // Register /sys/ in emit-change exclusion (also added below) } // ── Auto-purge trash older than 24 hours ── _purgeTrash() { try { const trash = this.root.content && this.root.content['.trash']; if (!trash || !trash.content) return; const now = Date.now(); const maxAge = 86400000; // 24 hours let purged = 0; for (const name of Object.keys(trash.content)) { const tsMatch = name.match(/^(\d+)/); if (tsMatch) { const ts = parseInt(tsMatch[1], 10); if (now - ts > maxAge) { delete trash.content[name]; purged++; } } } if (purged > 0) { this.saveToStorage(); } } catch (e) { /* silent */ } } // ── Virtual /sys/ filesystem (live runtime state, never persisted) ── _initVirtualMounts() { if (this._virtualMounts) return; const vm = (name, factory) => ({ _factory: factory, _name: name }); const dir = () => ({ _dir: true, content: {} }); const vmCache = {}; // factories can cache costly results within same tick const mm = () => typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; const ai = () => mm()?.WebX11AIAssistantModule || null; const ds = () => mm()?.DebugSupervisorModule || null; const heap = () => typeof performance !== 'undefined' && performance.memory ? performance.memory : null; const fmt = (bytes) => { if (!bytes && bytes !== 0) return 'N/A'; const kb = bytes / 1024; if (kb < 1024) return kb.toFixed(1) + ' KB'; return (kb / 1024).toFixed(1) + ' MB'; }; const fmtBytes = (bytes) => { if (!bytes && bytes !== 0) return 'N/A'; if (bytes < 1024) return bytes + ' B'; const kb = bytes / 1024; if (kb < 1024) return kb.toFixed(1) + ' KB'; return (kb / 1024).toFixed(1) + ' MB'; }; // Walk VFS root to count files/dirs and find largest const vfsWalk = (dir, prefix) => { let files = 0, dirs = 0, total = 0, largest = []; const walk = (node, p) => { if (!node) return; if (node._factory || node._dir) return; // skip virtual if (node.type === 'file') { files++; const size = node.content ? node.content.length : 0; total += size; largest.push({ path: p, size }); } else if (node.type === 'dir') { dirs++; for (const [name, child] of Object.entries(node.content || {})) { walk(child, p + '/' + name); } } }; walk(dir, prefix); largest.sort((a, b) => b.size - a.size); return { files, dirs, total, largest: largest.slice(0, 20) }; }; this._virtualMounts = { // /sys/info info: vm('info', () => [ '=== newOS4 Runtime Debug (/sys/) ===', `Version: ${window.NEWOS4_VERSION || '?'}`, `JS heap: ${fmt(heap()?.usedJSHeapSize)} / ${fmt(heap()?.jsHeapSizeLimit)}`, `DOM nodes: ${typeof document !== 'undefined' ? document.querySelectorAll('*').length : 'N/A'}`, `Loaded modules: ${mm() ? Object.keys(mm()).length : 'N/A'}`, '', ' cat /sys/heap/stats — Memory overview', ' cat /sys/heap/breakdown — What consumes what', ' cat /sys/logs/tool-calls — AI tool call log', ' cat /sys/logs/errors — AI error log', ' cat /sys/vfs/stats — VFS file/dir counts', ' cat /sys/vfs/largest — Top 20 largest files', ' ls /sys/modules/ — Loaded modules', ' cat /sys/config/ai — AI config parameters', ' cat /sys/dom/node-count — DOM element breakdown', ].join('\n')), // /sys/heap/ heap: { _dir: true, content: { stats: vm('stats', () => { const h = heap(); const lsSize = (k) => { try { return (localStorage.getItem(k) || '').length / 1024; } catch(e) { return 0; } }; return [ '=== Heap Stats ===', ` JS heap used: ${fmt(h?.usedJSHeapSize)}`, ` JS heap limit: ${fmt(h?.jsHeapSizeLimit)}`, ` JS heap % used: ${h ? ((h.usedJSHeapSize / h.jsHeapSizeLimit) * 100).toFixed(1) + '%' : 'N/A'}`, '', ` VFS localStorage: ${lsSize('webx11_filesystem_v2').toFixed(1)} KB`, ` AI localStorage: ${lsSize('webx11_ai_context').toFixed(1)} KB`, ` Config localStorage: ${lsSize('webx11_ai_config').toFixed(1)} KB`, ].join('\n'); }), breakdown: vm('breakdown', () => { const a = ai(); const cCount = a?.context?.length || 0; const lCount = a?._toolCallLog?.length || 0; const eCount = ds() ? Object.keys(ds()._errorDB?.errors || {}).length : 0; const modules = mm() ? Object.keys(mm()).length : 0; const domNodes = typeof document !== 'undefined' ? document.querySelectorAll('*').length : 0; return [ '=== Heap Breakdown (estimates) ===', ` Module code + state: ~${(modules * 1.2).toFixed(1)} MB (${modules} modules × ~1.2MB)`, ` VFS tree (in-memory): varies with file count`, ` AI context messages: ${cCount} (max: ${a?.maxContext || '?'})`, ` AI tool call log: ${lCount} (cap: 50)`, ` AI error log: ${eCount} (cap: 20)`, ` DOM nodes: ${domNodes}`, ].join('\n'); }) }}, // /sys/modules/ modules: { _dir: true, content: { list: vm('list', () => { const m = mm(); if (!m) return '(moduleManager not available)'; return Object.keys(m).sort().join('\n'); }) // module subdirectories are dynamic — handled in _virtualLs }}, // /sys/vfs/ vfs: { _dir: true, content: { stats: vm('stats', () => { const w = vfsWalk(this.root, ''); return [ '=== VFS Stats ===', ` Files: ${w.files}`, ` Directories: ${w.dirs}`, ` Total content: ${fmtBytes(w.total)}`, ` localStorage: ${((localStorage.getItem('webx11_filesystem_v2') || '').length / 1024).toFixed(1)} KB (compressed)`, ].join('\n'); }), largest: vm('largest', () => { const w = vfsWalk(this.root, ''); if (w.largest.length === 0) return '(empty VFS)'; const maxW = Math.max(...w.largest.map(f => f.path.length), 8); let out = '=== Top 20 Largest Files ===\n'; w.largest.forEach((f, i) => { out += ` ${(i + 1).toString().padStart(2)}. ${f.path.padEnd(maxW)} ${fmtBytes(f.size)}\n`; }); return out; }) }}, // /sys/logs/ logs: { _dir: true, content: { 'tool-calls': vm('tool-calls', () => { const a = ai(); if (!a || !a._toolCallLog || a._toolCallLog.length === 0) return '(empty)'; return a._toolCallLog.map((e, i) => `[${i}] ${JSON.stringify(e)}`).join('\n'); }), 'errors': vm('errors', () => { const d = ds(); if (!d || !d._errorDB || Object.keys(d._errorDB.errors).length === 0) return '(empty)'; return Object.entries(d._errorDB.errors) .sort((a, b) => b[1].lastSeen - a[1].lastSeen) .map(([hash, e]) => `[${hash}] ${e.type}/${e.severity} ×${e.count}: ${e.message.substring(0, 100)}`) .join('\n'); }), 'commands': vm('commands', () => { const a = ai(); if (!a || !a._commandFreq || Object.keys(a._commandFreq).length === 0) return '(empty)'; const sorted = Object.entries(a._commandFreq).sort((a,b) => b[1] - a[1]); return sorted.map(([cmd, n]) => `${cmd.padEnd(20)} ${n}x`).join('\n'); }) }}, // /sys/dom/ dom: { _dir: true, content: { 'node-count': vm('node-count', () => { if (typeof document === 'undefined') return 'N/A'; const all = document.querySelectorAll('*'); const byTag = {}; all.forEach(el => { const t = el.tagName.toLowerCase(); byTag[t] = (byTag[t] || 0) + 1; }); const sorted = Object.entries(byTag).sort((a,b) => b[1] - a[1]); let out = `Total DOM nodes: ${all.length}\n\n=== By Tag ===\n`; sorted.forEach(([tag, count]) => { out += ` ${tag.padEnd(15)} ${count}\n`; }); return out; }) }}, // /sys/config/ config: { _dir: true, content: { version: vm('version', () => { return `NEWOS4_VERSION: ${window.NEWOS4_VERSION || '?'}\n`; }), ai: vm('ai', () => { const a = ai(); if (!a) return '(AI module not loaded)'; return [ '=== AI Config ===', ` maxContext: ${a.maxContext}`, ` MAX_TOOL_TURNS: ${a.MAX_TOOL_TURNS}`, ` maxContextTokens: ${a.maxContextTokens}`, ` confirmTimeoutMs: ${a._confirmTimeoutMs}`, ` skillDebounceMs: ${a._skillDebounceMs}`, ` context messages: ${a.context?.length || 0}`, ` tool call log cap: 50 (auto-capped)`, ` error log cap: 20 (auto-capped)`, ].join('\n'); }) }} }; } // Check if a path is under /sys/ _isVirtualPath(path) { if (!path) return false; const normalized = path.startsWith('/') ? path : '/' + path; return normalized === '/sys' || normalized.startsWith('/sys/'); } // Resolve a virtual path: returns {dir: virtualNode, name: lastSegment} or null _resolveVirtualPath(path) { this._initVirtualMounts(); const normalized = path.startsWith('/') ? path : '/' + path; let segments = normalized.split('/').filter(s => s); // Remove leading 'sys' if (segments[0] === 'sys') segments = segments.slice(1); if (segments.length === 0) { // /sys itself — return the root virtual mounts node as a dir const allContent = {}; for (const [k, v] of Object.entries(this._virtualMounts)) { if (v._dir) allContent[k] = { type: 'dir', content: v.content, _virtual: true }; else allContent[k] = { type: 'file', content: null, _virtual: true, _factory: v._factory }; } // Add dynamic module subdirs to modules listing if (this._virtualMounts.modules?._dir) { const mm = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (mm) { for (const modName of Object.keys(mm)) { if (!allContent[modName]) { allContent[modName] = { type: 'dir', content: {}, _virtual: true }; } } } } return { dir: { content: allContent }, name: null }; } // Walk the tree let node = this._virtualMounts; for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i]; if (!node[seg]) { // Check dynamic module subdir under /sys/modules/ const mods = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (node === this._virtualMounts?.modules?.content && mods && mods[seg]) { // Dynamic module subdir — create virtual node node = { _dir: true, content: { info: { type: 'file', content: null, _virtual: true, _factory: () => { const mod = mods[seg]; const cmdKeys = mod.commands ? Object.keys(mod.commands) : []; return [ `Module: ${seg}`, ` Type: ${typeof mod}`, ` Commands: ${cmdKeys.join(', ') || '(none)'}`, ` Has help: ${mod.getHelpInfo ? 'yes' : 'no'}`, ` Constructor: ${mod.constructor?.name || 'anonymous'}`, ].join('\n'); }} } }; continue; } return null; } node = node[seg]; if (node && node._dir) node = node.content || {}; } const lastName = segments[segments.length - 1]; if (node && node[lastName]) { const entry = node[lastName]; if (entry._dir) { // Static subdir with known content const allContent = {}; for (const [k, v] of Object.entries(entry.content || {})) { if (v._dir) allContent[k] = { type: 'dir', content: v.content, _virtual: true }; else allContent[k] = { type: 'file', content: null, _virtual: true, _factory: v._factory }; } // Add dynamic module subdirs if (segments[0] === 'modules' || (segments.length >= 2 && segments[segments.length - 2] === 'modules')) { const mm = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (mm) { for (const modName of Object.keys(mm)) { if (!allContent[modName]) { allContent[modName] = { type: 'dir', content: {}, _virtual: true }; } } } } return { dir: { content: allContent }, name: null }; } else { return { dir: { content: node }, name: lastName }; } } // Check if it's a module subdirectory itself (e.g. /sys/modules/calc) const mods = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (node && mods && mods[lastName] && segments.length >= 1) { // It's a module name — return its info dir return { dir: { content: { info: { type: 'file', content: null, _virtual: true, _factory: () => { const mod = mods[lastName]; const cmdKeys = mod.commands ? Object.keys(mod.commands) : []; return [ `Module: ${lastName}`, ` Type: ${typeof mod}`, ` Commands: ${cmdKeys.join(', ') || '(none)'}`, ` Has help: ${mod.getHelpInfo ? 'yes' : 'no'}`, ` Constructor: ${mod.constructor?.name || 'anonymous'}`, ].join('\n'); }} }}, name: null }; } return null; } _u8ToBase64(u8) { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } _base64ToU8(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; } async _compress(text) { if (typeof CompressionStream === 'undefined') return null; const blob = new Blob([text]); const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); const resp = new Response(stream); const buf = await resp.arrayBuffer(); return this._u8ToBase64(new Uint8Array(buf)); } async _decompress(b64) { if (typeof DecompressionStream === 'undefined') throw new Error('DecompressionStream not available'); const u8 = this._base64ToU8(b64); const blob = new Blob([u8]); const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); const resp = new Response(stream); return await resp.text(); } async loadFromStorage() { try { const v2 = localStorage.getItem('webx11_filesystem_v2'); if (v2) { try { const text = await this._decompress(v2); const data = JSON.parse(text); this.root = data.root || this.root; this.currentPath = data.currentPath || []; this._purgeTrash(); return true; } catch (e) { console.error('FS v2 decompress error:', e); return this._loadLegacy(); } } // P0: Check IndexedDB fallback if localStorage is empty try { const idbData = await this._loadFromIndexedDB(); if (idbData) { const text = await this._decompress(idbData); const data = JSON.parse(text); this.root = data.root || this.root; this.currentPath = data.currentPath || []; this._usingIndexedDB = true; this._purgeTrash(); console.info('FS loaded from IndexedDB fallback'); return true; } } catch (e) { /* IndexedDB not available or empty */ } return this._loadLegacy(); } catch (e) { console.error('FS load error:', e); return false; } } _loadLegacy() { const saved = localStorage.getItem('webx11_filesystem'); if (saved) { if (saved.length > 524288) { console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); localStorage.removeItem('webx11_filesystem'); return false; } const data = JSON.parse(saved); this.root = data.root || this.root; this.currentPath = data.currentPath || []; console.info('Loaded legacy FS; will migrate to compressed on next save.'); return true; } return false; } _emitChange(path) { // Notify NeuralVFS via window event — zero coupling // Skip internal index/system files to prevent re-trigger loops if (!path) return; const normalized = path.startsWith('/') ? path : '/' + path; const sysPaths = ['/.semantic_index', '/.memory_graph', '/.trash', '/_snapshots', '/_sessions', '/logs', '/memory', '/system', '/_mods', '/backups', '/sys']; if (sysPaths.some(p => normalized.startsWith(p))) return; if (typeof window !== 'undefined' && window.__nvfsMarkDirty) { window.__nvfsMarkDirty(path); } } saveToStorage() { if (this._storageSavePending) return; this._storageSavePending = true; setTimeout(async () => { this._storageSavePending = false; try { const json = JSON.stringify({ root: this.root, currentPath: this.currentPath }); const compressed = await this._compress(json); if (compressed) { try { localStorage.setItem('webx11_filesystem_v2', compressed); localStorage.removeItem('webx11_filesystem'); } catch (quotaErr) { // P0: IndexedDB fallback when localStorage quota exceeded console.warn('localStorage quota exceeded, falling back to IndexedDB:', quotaErr.message); await this._saveToIndexedDB(compressed); this._usingIndexedDB = true; } } else { localStorage.setItem('webx11_filesystem', json); } } catch (e) { console.error('FS save error:', e); } }, 2000); } // P0: IndexedDB fallback storage for large VFS data _openIDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('webx11_vfs', 1); req.onupgradeneeded = () => { req.result.createObjectStore('vfs'); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async _saveToIndexedDB(data) { const db = await this._openIDB(); return new Promise((resolve, reject) => { const tx = db.transaction('vfs', 'readwrite'); tx.objectStore('vfs').put(data, 'filesystem_v2'); tx.oncomplete = () => { db.close(); resolve(true); }; tx.onerror = () => { db.close(); reject(tx.error); }; }); } async _loadFromIndexedDB() { const db = await this._openIDB(); return new Promise((resolve, reject) => { const tx = db.transaction('vfs', 'readonly'); const req = tx.objectStore('vfs').get('filesystem_v2'); req.onsuccess = () => { db.close(); resolve(req.result || null); }; req.onerror = () => { db.close(); reject(req.error); }; }); } getCurrentDir() { let dir = this.root; for (let part of this.currentPath) { if (dir.content[part] && dir.content[part].type === 'dir') { dir = dir.content[part]; } else { return null; } } return dir; } _toAbsoluteSegments(path) { if (!path || path === '.' || path === '') return [...this.currentPath]; if (path === '/') return []; const parts = path.split('/').filter(p => p && p !== '.'); let base = path.startsWith('/') ? [] : [...this.currentPath]; const result = [...base]; for (const p of parts) { if (p === '..') { if (result.length > 0) result.pop(); else return null; } else { result.push(p); } } return result; } _resolvePath(path) { if (this._isVirtualPath(path)) return this._resolveVirtualPath(path); const segs = this._toAbsoluteSegments(path); if (segs === null) return null; if (segs.length === 0) return { dir: this.root, name: null }; let dir = this.root; for (let i = 0; i < segs.length - 1; i++) { if (!dir.content[segs[i]] || dir.content[segs[i]].type !== 'dir') return null; dir = dir.content[segs[i]]; } return { dir, name: segs[segs.length - 1] }; } _resolveDir(path) { if (this._isVirtualPath(path)) { const r = this._resolveVirtualPath(path); return r ? r.dir : null; } const segs = this._toAbsoluteSegments(path); if (segs === null) return null; if (segs.length === 0) return this.root; let dir = this.root; for (const seg of segs) { if (!dir.content[seg] || dir.content[seg].type !== 'dir') return null; dir = dir.content[seg]; } return dir; } ls(path) { let dir; if (path) { const resolved = this._resolvePath(path); if (!resolved) return []; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') dir = node; else return []; } else { dir = resolved.dir; } } else { dir = this.getCurrentDir(); } if (!dir) return []; const entries = Object.keys(dir.content).map(name => ({ name, type: dir.content[name].type, size: dir.content[name]._factory ? 0 : (dir.content[name].content ? dir.content[name].content.length : 0), _virtual: !!dir.content[name]._virtual })); // Inject virtual /sys/ at root level if ((!path || path === '/' || path === '') && !entries.some(e => e.name === 'sys' && e._virtual)) { entries.push({ name: 'sys', type: 'dir', size: 0, _virtual: true }); } return entries; } writeFile(path, content) { if (this._isVirtualPath(path)) return false; const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const name = resolved.name; if (name === '.' || name === '..') return false; if (resolved.dir.content[name] && resolved.dir.content[name].type === 'dir') return false; resolved.dir.content[name] = { type: 'file', content: content, modified: new Date() }; this.saveToStorage(); this._emitChange(path); return true; } readFile(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return null; const node = resolved.dir.content[resolved.name]; if (node && node._factory) { return node._factory(); } if (node && node.type === 'file') return node.content; return null; } deleteFile(path) { if (this._isVirtualPath(path)) return false; const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'file') { this.trashFile(path, node); return true; } return false; } trashFile(path, node) { try { const name = path.split('/').pop() || 'unknown'; const ts = Date.now(); if (!this.root.content['.trash']) { this.root.content['.trash'] = { type: 'dir', content: {} }; } const trash = this.root.content['.trash']; const trashName = ts + '-' + name; trash.content[trashName] = JSON.parse(JSON.stringify(node)); const resolved = this._resolvePath(path); if (resolved) delete resolved.dir.content[resolved.name]; this.saveToStorage(); this._emitChange(path); } catch (e) { console.error('Trash error:', e); } } trashDir(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') { try { const name = resolved.name; const ts = Date.now(); if (!this.root.content['.trash']) { this.root.content['.trash'] = { type: 'dir', content: {} }; } const trash = this.root.content['.trash']; // Keep the subtree intact so it can be restored later; the old flatten loop // turned every descendant into a sibling file and lost empty dirs entirely. const baseName = ts + '-dir-' + name; let unique = baseName; let ctr = 0; while (trash.content[unique]) { unique = baseName + '-' + (++ctr); } trash.content[unique] = JSON.parse(JSON.stringify(node)); delete resolved.dir.content[resolved.name]; this.saveToStorage(); this._emitChange(path); return true; } catch (e) { console.error('Trash dir error:', e); } } return false; } deleteDirRecursive(path) { return this.trashDir(path); } mkdirRecursive(path) { if (this._isVirtualPath(path)) return false; const segs = this._toAbsoluteSegments(path); if (!segs) return false; let dir = this.root; for (const seg of segs) { if (!dir.content[seg]) { dir.content[seg] = { type: 'dir', content: {} }; } else if (dir.content[seg].type !== 'dir') { return false; } dir = dir.content[seg]; } this.saveToStorage(); this._emitChange(path); return true; } rename(oldPath, newPath) { if (this._isVirtualPath(oldPath) || this._isVirtualPath(newPath)) return false; const src = this._resolvePath(oldPath); if (!src || !src.name) return false; const dst = this._resolvePath(newPath); if (!dst || !dst.name) return false; const node = src.dir.content[src.name]; if (!node) return false; dst.dir.content[dst.name] = node; delete src.dir.content[src.name]; this.saveToStorage(); this._emitChange(oldPath); this._emitChange(newPath); return true; } copy(srcPath, dstPath) { if (this._isVirtualPath(srcPath) || this._isVirtualPath(dstPath)) return false; const src = this._resolvePath(srcPath); if (!src || !src.name) return false; const dst = this._resolvePath(dstPath); if (!dst || !dst.name) return false; const node = src.dir.content[src.name]; if (!node) return false; dst.dir.content[dst.name] = JSON.parse(JSON.stringify(node)); this.saveToStorage(); this._emitChange(dstPath); return true; } move(srcPath, dstPath) { return this.rename(srcPath, dstPath); } cp(srcPath, dstPath) { return this.copy(srcPath, dstPath); } mv(srcPath, dstPath) { return this.rename(srcPath, dstPath); } rm(path) { if (this._isVirtualPath(path)) return false; const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (!node) return false; if (node.type === 'file') { this.trashFile(path, node); return true; } if (node.type === 'dir') { this.trashDir(path); return true; } return false; } rmdir(path) { if (this._isVirtualPath(path)) return false; const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') { if (Object.keys(node.content).length > 0) return false; delete resolved.dir.content[resolved.name]; this.saveToStorage(); this._emitChange(path); return true; } return false; } touch(path) { if (this._isVirtualPath(path)) return false; return this.writeFile(path, ''); } readDir(path) { const resolved = this._resolvePath(path); if (!resolved) return null; let dir; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && (node.type === 'dir' || node._virtual)) dir = node; else return null; } else { dir = resolved.dir; } const entries = Object.keys(dir.content).map(name => ({ name, type: dir.content[name].type || (dir.content[name]._virtual ? 'file' : '?'), size: dir.content[name]._factory ? 0 : (dir.content[name].content ? dir.content[name].content.length : 0), _virtual: !!dir.content[name]._virtual })); // Inject virtual /sys/ at root level if ((!path || path === '/' || path === '') && !entries.some(e => e.name === 'sys' && e._virtual)) { entries.push({ name: 'sys', type: 'dir', size: 0, _virtual: true }); } return entries; } fsWriteJSON(path, obj) { return this.writeFile(path, JSON.stringify(obj)); } fsReadJSON(path) { const content = this.readFile(path); if (!content) return null; try { return JSON.parse(content); } catch (e) { return null; } } tree(path = '', prefix = '') { const resolved = this._resolvePath(path || '/'); if (!resolved) return ''; let dir; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') dir = node; else return ''; } else { dir = resolved.dir; } let result = ''; const entries = Object.keys(dir.content).sort(); // Inject /sys/ at root level in tree view const atRoot = !path || path === '' || path === '/'; if (atRoot && !entries.includes('sys')) entries.push('sys'); for (let i = 0; i < entries.length; i++) { const name = entries[i]; const isLast = i === entries.length - 1; const connector = isLast ? '└── ' : '├── '; result += prefix + connector + name + '\n'; if (dir.content[name].type === 'dir' || dir.content[name]._virtual) { const ext = isLast ? ' ' : '│ '; result += this.tree(path + '/' + name, prefix + ext); } } return result; } find(pattern, path = '') { if (this._isVirtualPath(path || '/')) return []; const resolved = this._resolvePath(path); if (!resolved) return []; let dir; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') dir = node; else return []; } else { dir = resolved.dir; } const results = []; const regex = new RegExp(pattern, 'i'); for (const name of Object.keys(dir.content)) { const fullPath = path ? path + '/' + name : name; if (regex.test(name)) { results.push({ path: fullPath, type: dir.content[name].type }); } if (dir.content[name].type === 'dir') { results.push(...this.find(pattern, fullPath)); } } return results; } grep(pattern, path = '') { const results = []; const regex = new RegExp(pattern, 'i'); const files = this.find('.', path).filter(e => e.type === 'file'); for (const file of files) { const content = this.readFile(file.path); if (content && regex.test(content)) { results.push(file.path); } } return results; } chmod(path, mode) { if (this._isVirtualPath(path)) return false; const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (!node) return false; node.mode = mode; this.saveToStorage(); return true; } du(path = '') { if (this._isVirtualPath(path || '/')) return 0; const resolved = this._resolvePath(path); if (!resolved) return 0; let dir; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') dir = node; else return node.content ? node.content.length : 0; } else { dir = resolved.dir; } let size = 0; for (const name of Object.keys(dir.content)) { const node = dir.content[name]; if (node.type === 'file') { size += node.content ? node.content.length : 0; } else if (node.type === 'dir') { size += this.du(path ? path + '/' + name : name); } } return size; } getSize(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return 0; const node = resolved.dir.content[resolved.name]; if (!node) return 0; if (node.type === 'file') return node.content ? node.content.length : 0; return this.du(path); } exportToJSON(path = '') { const resolved = this._resolvePath(path); if (!resolved) return null; let dir; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') dir = node; else return JSON.stringify(node); } else { dir = resolved.dir; } return JSON.stringify(dir, null, 2); } // Restore VFS from an exported dump file (replaces entire root) restoreFromExport(content) { if (typeof content === 'string') { try { content = JSON.parse(content); } catch (e) { return false; } } if (!content || !content.type) return false; // Handle v2 structured export: { type, version, root, currentPath, ... } if (content.version === 2 || content.root) { this.root = content.root; this.currentPath = content.currentPath || []; } else { // Legacy format: content is just the root object this.root = content; this.currentPath = []; } this.saveToStorage(); return true; } _snapshotDirExists() { return !!this.readDir('/_snapshots'); } checkpoint(name) { if (!name) name = new Date().toISOString(); const createdAt = new Date().toISOString(); // Strip system dirs to prevent fractal bloat const rootClone = JSON.parse(JSON.stringify(this.root)); if (rootClone && rootClone.content) { delete rootClone.content['_sessions']; delete rootClone.content['_snapshots']; delete rootClone.content['.trash']; } const data = { version: 1, createdAt, root: rootClone, currentPath: [...this.currentPath] }; if (!this.readDir('/_snapshots')) { this.mkdirRecursive('/_snapshots'); } this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data); this.saveToStorage(); // Auto-manage: keep max 3 checkpoints, delete oldest const all = this.listCheckpoints(); if (all.length > 3) { const sorted = all.slice().sort(); const toDelete = sorted.slice(0, all.length - 3); for (const old of toDelete) this.deleteCheckpoint(old); } return true; } rollback(name) { const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); if (!raw || !raw.root) { console.error('Rollback failed: checkpoint not found or invalid'); return false; } this.root = raw.root; this.currentPath = raw.currentPath || []; this.saveToStorage(); return true; } listCheckpoints() { const dir = this.readDir('/_snapshots'); if (!dir) return []; return dir .filter(e => e.type === 'file' && e.name.startsWith('checkpoint-') && e.name.endsWith('.json')) .map(e => e.name.replace('checkpoint-', '').replace('.json', '')); } deleteCheckpoint(name) { const path = '/_snapshots/checkpoint-' + name + '.json'; const resolved = this._resolvePath(path); if (resolved && resolved.name) { delete resolved.dir.content[resolved.name]; this.saveToStorage(); return true; } return false; } // Old-compat aliases for agent tools and SkillsModule read(path) { return this.readFile(path); } write(path, content) { return this.writeFile(path, content); } delete(path) { return this.deleteFile(path); } mkdir(path) { return this.mkdirRecursive(path); } list(path) { return this.ls(path); } dir(path) { return this.ls(path); } reset() { this.root = { type: 'dir', content: {} }; this.currentPath = []; // Wipe all localStorage keys ['webx11_filesystem', 'webx11_filesystem_v2'].forEach(k => localStorage.removeItem(k)); // P0: Also clear IndexedDB fallback try { indexedDB.deleteDatabase('webx11_vfs'); } catch (e) {} this._usingIndexedDB = false; return true; } } // Also expose for module system window.VirtualFileSystem = VirtualFileSystem;