class VirtualFileSystem { constructor() { this.root = { type: 'dir', content: {} }; this.currentPath = []; this._ready = this.loadFromStorage(); this._conceptCache = 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 || []; return true; } catch (e) { console.error('FS v2 decompress error:', e); return this._loadLegacy(); } } 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; } 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) { localStorage.setItem('webx11_filesystem_v2', compressed); localStorage.removeItem('webx11_filesystem'); } else { localStorage.setItem('webx11_filesystem', json); } } catch (e) { console.error('FS save error:', e); } }, 2000); } getCurrentPath() { return '/' + this.currentPath.join('/'); } setCurrentPath(path) { const segs = this._toAbsoluteSegments(path); if (segs === null) return false; this.currentPath = segs; return true; } _loadConceptIndex() { if (this._conceptCache) return this._conceptCache; try { const raw = this.readFile('/.semantic_index/map.json'); if (raw) { this._conceptCache = JSON.parse(raw); return this._conceptCache; } } catch (e) {} return null; } _clearConceptCache() { this._conceptCache = null; } 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) { 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) { 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) { // concept:// listing if (path && path.startsWith('concept://')) { const concept = path.replace('concept://', '').replace(/\/.*$/, '').toLowerCase(); const index = this._loadConceptIndex(); if (!index) return []; let matches = index[concept] || []; if (matches.length === 0) { const fuzzy = Object.keys(index).filter(k => k.includes(concept) || concept.includes(k)); if (fuzzy.length > 0) matches = index[fuzzy[0]]; } return (matches || []).map(m => ({ name: m.path.split('/').pop(), type: 'file', path: m.path, size: 0, concept: true })); } 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 []; return Object.keys(dir.content).map(name => ({ name, type: dir.content[name].type, size: dir.content[name].content ? dir.content[name].content.length : 0 })); } writeFile(path, content) { 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(); return true; } readFile(path) { // concept:// URI resolution if (path && path.startsWith('concept://')) { const concept = path.replace('concept://', '').replace(/\/.*$/, '').toLowerCase(); try { const index = this._loadConceptIndex(); if (!index) return 'No semantic index found. Run nvfs-index first.'; let matches = index[concept]; if (!matches) { const fuzzy = Object.keys(index).filter(k => k.includes(concept) || concept.includes(k)); if (fuzzy.length > 0) matches = index[fuzzy[0]]; } if (!matches || matches.length === 0) return `No files found for concept "${concept}"`; const first = matches[0]; const content = this.readFile(first.path); if (content) return `--- ${first.path} (concept://${concept}) ---\n${content}`; return `Concept "${concept}" → ${matches.length} file(s):\n${matches.map(m => ` ${m.path} — ${m.summary || ''}`).join('\n')}`; } catch (e) { return `concept:// error: ${e.message}`; } } const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return null; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'file') return node.content; return null; } deleteFile(path) { 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(); } 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']; const flatten = (n, prefix) => { if (n.type === 'file') { const flatName = prefix + '-' + name; let ctr = 0; let unique = flatName; while (trash.content[unique]) { unique = flatName + '-' + (++ctr); } trash.content[unique] = JSON.parse(JSON.stringify(n)); } else if (n.type === 'dir') { for (const [childName, child] of Object.entries(n.content || {})) { flatten(child, prefix + '-' + childName); } } }; flatten(node, ts + '-dir-' + name); delete resolved.dir.content[resolved.name]; this.saveToStorage(); return true; } catch (e) { console.error('Trash dir error:', e); } } return false; } mkdirRecursive(path) { 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(); return true; } rename(oldPath, newPath) { 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(); return true; } copy(srcPath, dstPath) { 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(); 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) { 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) { 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(); return true; } return false; } touch(path) { 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') dir = node; else return null; } else { dir = resolved.dir; } return Object.keys(dir.content).map(name => ({ name, type: dir.content[name].type, size: dir.content[name].content ? dir.content[name].content.length : 0 })); } 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(); 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') { const ext = isLast ? ' ' : '│ '; result += this.tree(path + '/' + name, prefix + ext); } } return result; } find(pattern, path = '') { 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) { 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 = '') { 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 avoid fractal bloat — snapshots/sessions/trash // don't belong inside a checkpoint of user data const root = JSON.parse(JSON.stringify(this.root)); if (root && root.content) { delete root.content['_snapshots']; delete root.content['_sessions']; delete root.content['.trash']; } const data = { version: 1, createdAt, root, currentPath: [...this.currentPath] }; if (!this.readDir('/_snapshots')) { this.mkdirRecursive('/_snapshots'); } // Strip _snapshots from the serialized data too (redundant safety) data.root = root; this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data); this.saveToStorage(); // Auto-manage: keep max 10 checkpoints, delete oldest const all = this.listCheckpoints(); if (all.length > 10) { const sorted = all.slice().sort(); const toDelete = sorted.slice(0, all.length - 10); 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; } // Reset VFS — wipes everything, removes localStorage keys reset() { this.root = { type: 'dir', content: {} }; this.currentPath = []; this._conceptCache = null; try { localStorage.removeItem('webx11_filesystem_v2'); localStorage.removeItem('webx11_filesystem'); } catch (e) {} return true; } // Empty trash — permanently delete all trashed files emptyTrash() { if (this.root.content['.trash']) { delete this.root.content['.trash']; 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); } } // Also expose for module system window.VirtualFileSystem = VirtualFileSystem;