From de713efd619360e29b9bfc4b99d30d2a86b96caf Mon Sep 17 00:00:00 2001 From: Agando Date: Sat, 13 Jun 2026 18:00:58 +0200 Subject: auto: 2026-06-13_18:00 changes --- modules/core/core-bundle.js | 15347 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 15347 insertions(+) create mode 100644 modules/core/core-bundle.js (limited to 'modules/core/core-bundle.js') diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js new file mode 100644 index 0000000..e242230 --- /dev/null +++ b/modules/core/core-bundle.js @@ -0,0 +1,15347 @@ +// Core module bundle — generated for v4.5.49// Loaded synchronously in main.html before module-loader.js. +/* === virtual-fs.js === */ +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) + } + // ── 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 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 = a?._errorLog?.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 a = ai(); + if (!a || !a._errorLog || a._errorLog.length === 0) return '(empty)'; + return a._errorLog.map((e, i) => `[${i}] ${JSON.stringify(e)}`).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 || []; + 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; + } + _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) { + 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); + } + 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']; + 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(); + 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(); + const data = { + version: 1, + createdAt, + root: JSON.parse(JSON.stringify(this.root)), + currentPath: [...this.currentPath] + }; + if (!this.readDir('/_snapshots')) { + this.mkdirRecursive('/_snapshots'); + } + 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; + } + // 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)); + return true; + } +} + +// Also expose for module system +window.VirtualFileSystem = VirtualFileSystem; + +/* === module-manager.js === */ +// Extracted from newOS4 index.html — ModuleManager + class ModuleManager { + constructor(terminal) { + this.terminal = terminal; + this.modules = {}; + this.moduleRegistry = []; + this.loadFromStorage(); + } + loadFromStorage() { + try { + const saved = localStorage.getItem('webx11_modules'); + if (saved) { + this.moduleRegistry = JSON.parse(saved); + } + } catch (e) { console.error('Module load error:', e); } + } + saveToStorage() { + try { + localStorage.setItem('webx11_modules', JSON.stringify(this.moduleRegistry)); + } catch (e) { console.error('Module save error:', e); } + } + async loadModuleFromURL(url) { + try { + this.terminal.print(`Loading module from ${url}...`, 'info'); + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const code = await response.text(); + const ModuleClass = this.createModuleClass(code, url); + if (ModuleClass) { + const instance = new ModuleClass(); + this.registerModule(instance, url); + if (instance.init) { + await instance.init(this.terminal); + } + this.terminal.print(`Module loaded successfully from ${url}`, 'success'); + return true; + } + } catch (error) { + this.terminal.print(`Failed to load module: ${error.message}`, 'error'); + return false; + } + } + createModuleClass(code, url) { + try { + // Strip leading comments to avoid ASI: `return\n/**` → `return; /**` + const cleanCode = code.replace(/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*\s*/, ''); + const classMatch = cleanCode.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); + if (classMatch) { + const className = classMatch[1]; + const mod = new Function('return ' + cleanCode + '\nreturn ' + className)(); + return mod; + } + throw new Error('No valid module class found'); + } catch (e) { + this.terminal.print(`Module parse error: ${e.message}`, 'error'); + return null; + } + } + registerModule(instance, url = null) { + const name = instance.constructor.name; + this.modules[name] = instance; + instance.terminal = this.terminal; + if (instance.registerCommands) { instance.registerCommands(); } + // Only persist modules loaded from absolute URLs (not boot-time ext modules) + if (url && url.startsWith('http') && !this.moduleRegistry.find(m => m.name === name)) { + this.moduleRegistry.push({ + name: name, url: url, + loaded: new Date().toISOString(), enabled: true + }); + this.saveToStorage(); + } + console.log(`Module registered: ${name}`); + } + unregisterModule(name) { + if (this.modules[name]) { + delete this.modules[name]; + this.moduleRegistry = this.moduleRegistry.filter(m => m.name !== name); + this.saveToStorage(); + return true; + } + return false; + } + loadModuleFromCode(code, url) { + try { + this.terminal.print('Loading module from editor...', 'info'); + const ModuleClass = this.createModuleClass(code, url); + if (ModuleClass) { + const instance = new ModuleClass(); + this.registerModule(instance, url); + if (instance.init) { + instance.init(this.terminal); + } + this.terminal.print(`Module loaded from editor: ${instance.constructor.name}`, 'success'); + return true; + } + } catch (error) { + this.terminal.print(`Failed to load module from code: ${error.message}`, 'error'); + return false; + } + } + getModuleList() { + const allNames = new Set([...this.moduleRegistry.map(m => m.name), ...Object.keys(this.modules)]); + return Array.from(allNames).map(name => ({ + name, + url: (this.moduleRegistry.find(m => m.name === name) || {}).url || null, + loaded: this.modules[name] !== undefined + })); + } + clearAll() { + this.modules = {}; + this.moduleRegistry = []; + this.saveToStorage(); + } + getModuleRequirements(name) { + const mod = this.modules[name]; + if (mod && typeof mod.requirements === 'function') { + return mod.requirements(); + } + return null; + } + checkAllRequirements() { + const results = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (typeof mod.requirements === 'function') { + results[modName] = mod.requirements(); + } + } + return results; + } + getAllCommands() { + const commands = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (mod.commands) { + for (const cmd in mod.commands) { + commands[cmd] = modName; + } + } + } + return commands; + } + getAllHelp() { + const help = {}; + for (const modName in this.modules) { + const mod = this.modules[modName]; + if (typeof mod.getHelpInfo === 'function') { + const modHelp = mod.getHelpInfo(); + if (modHelp) Object.assign(help, modHelp); + } else if (mod.commands) { + for (const cmd in mod.commands) { + if (!help[cmd]) help[cmd] = cmd + ' — (no description)'; + } + } + } + return help; + } + } + +/* === ollama-manager.js === */ +// Extracted from newOS4 index.html — OllamaManager / LLMRouter +// Refactored to support multiple LLM providers with auto-fallback +// Class name kept as OllamaManager for backward compatibility +class OllamaManager { + constructor(terminal) { + this.terminal = terminal; + // --- Backward-compat properties (reflected from active provider) --- + this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + this.connected = false; + this.models = []; + this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; + this.responseHooks = []; + this._abortController = null; + // --- AI Agent Loop Optimization Toggle --- + this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') !== 'false'; + // --- Streaming Toggle --- + this.streamEnabled = localStorage.getItem('webx11_stream_enabled') === 'true'; + // --- Validator Agent Config --- + this.validatorEnabled = localStorage.getItem('webx11_validator_enabled') === 'true'; + this.validatorModel = localStorage.getItem('webx11_validator_model') || 'ministral-3:3b-cloud'; + this.validatorEndpoint = localStorage.getItem('webx11_validator_endpoint') || ''; + // Build fallback endpoints + this.fallbackEndpoints = []; + const origin = typeof window !== 'undefined' ? window.location.origin : ''; + if (origin) { + this.fallbackEndpoints.push(origin + '/ollama/'); + } + + // --- Multi-provider architecture --- + this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled } + this.currentProvider = null; // currently active provider object + this.fallbackCount = 0; + this._loadProviders(); + + // If no active provider set, pick the highest-priority enabled one + if (!this.currentProvider) { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + } + + // ─── Provider management ───────────────────────────────────────────────── + + _loadProviders() { + const stored = this._getSecret('webx11_llm_providers'); + if (stored) { + try { + this.providers = JSON.parse(stored); + // Ensure all entries have the 'enabled' field + this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; }); + } catch (e) { + this.providers = []; + } + } + if (this.providers.length === 0) { + // Default single provider: Ollama + this.providers = [{ + name: 'Ollama', + type: 'ollama', + endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434', + apiKey: '', + model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: 0, + enabled: true + }]; + } + } + + _saveProviders() { + this._setSecret('webx11_llm_providers', JSON.stringify(this.providers)); + } + + _getSecret(key) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') { + return window.webx11_secretVault.decrypt(key); + } + return localStorage.getItem(key); + } + + _setSecret(key, value) { + if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') { + window.webx11_secretVault.encrypt(key, value); + } else { + localStorage.setItem(key, value); + } + } + + _getSortedEnabledProviders() { + return this.providers + .filter(p => p.enabled) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); + } + + _syncCompatProperties(provider) { + if (!provider) return; + this.endpoint = provider.endpoint; + this.currentModel = provider.model || this.currentModel; + // Don't override backward-compat localStorage unless explicitly set + } + + _getProviderByType(type) { + return this.providers.find(p => p.type === type && p.enabled); + } + + addProvider(name, type, endpoint, apiKey, model, priority) { + const existing = this.providers.findIndex(p => p.name === name); + const entry = { + name: name, + type: type || 'ollama', + endpoint: endpoint || 'http://localhost:11434', + apiKey: apiKey || '', + model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud', + priority: priority !== undefined ? priority : this.providers.length, + enabled: true + }; + if (existing >= 0) { + this.providers[existing] = entry; + } else { + this.providers.push(entry); + } + this._saveProviders(); + // If this is the new highest-priority enabled provider, activate it + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0 && sorted[0].name === entry.name) { + this.currentProvider = entry; + this._syncCompatProperties(entry); + } + return true; + } + + removeProvider(name) { + const idx = this.providers.findIndex(p => p.name === name); + if (idx < 0) return false; + this.providers.splice(idx, 1); + if (this.currentProvider && this.currentProvider.name === name) { + this.currentProvider = null; + const sorted = this._getSortedEnabledProviders(); + if (sorted.length > 0) { + this.currentProvider = sorted[0]; + this._syncCompatProperties(this.currentProvider); + } + } + this._saveProviders(); + return true; + } + + listProviders() { + return this.providers; + } + + setActiveProvider(name) { + const provider = this.providers.find(p => p.name === name); + if (!provider) { + this.terminal.print(`Provider "${name}" not found`, 'error'); + return false; + } + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success'); + return true; + } + + getActiveProvider() { + return this.currentProvider; + } + + getProviderStats() { + const sorted = this._getSortedEnabledProviders(); + const connectedProvider = this.connected + ? (this.currentProvider ? this.currentProvider.name : 'unknown') + : null; + return { + currentProvider: this.currentProvider ? this.currentProvider.name : null, + totalProviders: this.providers.length, + connectedProvider: connectedProvider, + fallbackCount: this.fallbackCount + }; + } + + // ─── Connection ────────────────────────────────────────────────────────── + + async connect() { + const sortedProviders = this._getSortedEnabledProviders(); + if (sortedProviders.length === 0) { + this.terminal.print('No enabled providers configured', 'error'); + this.connected = false; + return false; + } + + // Also build endpoint-based fallback list for backward compat + const allEndpoints = []; + // First try the original endpoint + fallbackEndpoints for plain Ollama connect + if (this._getProviderByType('ollama')) { + allEndpoints.push(this.endpoint); + for (const fb of this.fallbackEndpoints) { + if (!allEndpoints.includes(fb)) allEndpoints.push(fb); + } + } + + // Try each provider in priority order + for (const provider of sortedProviders) { + let success = false; + if (provider.type === 'ollama') { + // Try provider endpoint first, then fallback endpoints + const eps = [provider.endpoint]; + for (const fb of this.fallbackEndpoints) { + if (!eps.includes(fb)) eps.push(fb); + } + for (const ep of eps) { + try { + this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + this.currentProvider = provider; + provider.endpoint = ep; // may have been updated via fallback + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + break; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message); + this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + } else { + // OpenAI-compatible provider (OpenRouter, etc.) + try { + this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/models`, { + method: 'GET', headers + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = (data.data || data.models || []).map(m => ({ + name: m.id || m.name, + size: m.size || 0 + })); + this.connected = true; + this.currentProvider = provider; + this._syncCompatProperties(provider); + this.saveSettings(); + this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + provider.model = this.currentModel; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this._saveProviders(); + success = true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message); + this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning'); + } + } + + if (success) return true; + } + + // If provider-based connect failed, try bare endpoint fallback (legacy) + for (const ep of allEndpoints) { + try { + this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info'); + const response = await fetch(`${ep}/api/tags`, { + method: 'GET', headers: { 'Content-Type': 'application/json' } + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.models = data.models || []; + this.connected = true; + if (ep !== this.endpoint) { + this.endpoint = ep; + this.saveSettings(); + this.terminal.print(`Endpoint updated to: ${ep}`, 'info'); + } + this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success'); + this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)'); + if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { + if (this.models.length > 0) { + this.currentModel = this.models[0].name; + localStorage.setItem('webx11_ollama_model', this.currentModel); + this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); + } + } + this.saveSettings(); + return true; + } catch (error) { + this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message); + this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning'); + } + } + + this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers'); + this.connected = false; + return false; + } + + // ─── Chat (non-streaming) ──────────────────────────────────────────────── + + async chat(prompt, model = null) { + if (!this.connected) { + this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error'); + return null; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected. Use ollamamodels to select.', 'error'); + return null; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithProvider(provider, prompt, useModel); + if (result !== null && result !== undefined) { + // Success — if we fell back, increment fallbackCount + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + lastError = result; + } + + if (lastError === null) { + this.terminal.print(`Ollama error: No provider responded`, 'error'); + } + return null; + } + + async _chatWithProvider(provider, prompt, model) { + const useModel = model || provider.model || this.currentModel; + let fullText = ''; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + try { + if (provider.type === 'ollama') { + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + if (parsed.response) { + fullText += parsed.response; + this.terminal.printStreamingResponse(parsed.response, !isFirstToken); + isFirstToken = false; + } + } catch (e) { + // Skip malformed partial lines + } + } + } + } else { + const data = await response.json(); + fullText = data.response || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } + } + return fullText || null; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: this.streamEnabled + }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let isFirstToken = true; + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullText += content; + this.terminal.printStreamingResponse(content, !isFirstToken); + isFirstToken = false; + } + } catch (e) { + // Skip malformed SSE lines + } + } + } + } + } else { + const data = await response.json(); + fullText = data.choices?.[0]?.message?.content || ''; + if (fullText) { + this.terminal.printStreamingResponse(fullText, false); + } + } + return fullText || null; + } + } catch (error) { + if (fullText && fullText.length > 0) { + this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true); + return fullText; + } + return null; // signal failure so caller can try next provider + } finally { + this._abortController = null; + } + } + + // ─── Chat Stream ───────────────────────────────────────────────────────── + + async chatStream(prompt, onToken, onComplete, model = null) { + if (!this.connected) { this.terminal.print('Ollama not connected.', 'error'); onComplete && onComplete(null); return; } + const useModel = model || this.currentModel; + if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; } + + const providersToTry = this._getFallbackChain(); + let triedAny = false; + + for (const provider of providersToTry) { + triedAny = true; + const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel); + if (result !== null) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + } + + if (!triedAny) { + this.terminal.print('No model selected.', 'error'); + onComplete && onComplete(null); + } else { + this.terminal.print(`Stream error: No provider responded`, 'error'); + onComplete && onComplete(null); + } + return null; + } + + async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + try { + if (provider.type === 'ollama') { + const response = await fetch(`${provider.endpoint}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + let fullResponse = ''; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const obj = JSON.parse(line); + if (obj.response) { + fullResponse += obj.response; + onToken && onToken(obj.response, fullResponse); + } + } catch (e) { continue; } + } + } + if (buffer.trim()) { + try { + const obj = JSON.parse(buffer); + if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } + } catch (e) {} + } + } else { + const data = await response.json(); + fullResponse = data.response || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } else { + // OpenAI-compatible provider + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify({ + model: useModel, + messages: [{ role: 'user', content: prompt }], + stream: this.streamEnabled + }), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + let fullResponse = ''; + if (this.streamEnabled) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullResponse += content; + onToken && onToken(content, fullResponse); + } + } catch (e) { continue; } + } + } + } + } else { + const data = await response.json(); + fullResponse = data.choices?.[0]?.message?.content || ''; + if (fullResponse) { + onToken && onToken(fullResponse, fullResponse); + } + } + this.fireResponseHooks(fullResponse); + onComplete && onComplete(fullResponse); + return fullResponse; + } + } catch (error) { + this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error'); + onComplete && onComplete(null); + return null; + } finally { + this._abortController = null; + } + } + + // ─── Chat With Tools ───────────────────────────────────────────────────── + + _isModelType(modelName, type) { + if (!modelName) return false; + const lower = modelName.toLowerCase(); + return lower.includes(type); + } + + async chatWithTools(messages, tools, model = null) { + if (!this.connected) { + this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error'); + return { message: null, tool_calls: null, error: 'Not connected' }; + } + const useModel = model || this.currentModel; + if (!useModel) { + this.terminal.print('No model selected.', 'error'); + return { message: null, tool_calls: null, error: 'No model' }; + } + + const providersToTry = this._getFallbackChain(); + let lastError = null; + + for (const provider of providersToTry) { + const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel); + if (result && !result.error) { + if (provider !== this.currentProvider) { + this.fallbackCount++; + this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning'); + this.currentProvider = provider; + this._syncCompatProperties(provider); + } + return result; + } + // Only keep non-cancellation errors for fallback + if (result && result.error && result.error !== 'Cancelled') { + lastError = result; + } + } + + return lastError || { message: null, tool_calls: null, error: 'All providers failed' }; + } + + async _chatWithToolsWithProvider(provider, messages, tools, model) { + const useModel = model || provider.model || this.currentModel; + this._abortController = new AbortController(); + const signal = this._abortController.signal; + + try { + if (provider.type === 'ollama') { + const body = { model: useModel, messages, stream: false }; + if (tools && tools.length > 0) body.tools = tools; + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + if (response.status === 400) { + return await this.chatWithToolsFallback(messages, tools, useModel, signal); + } + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + return { + message: data.message || null, + tool_calls: data.message?.tool_calls || null, + error: null + }; + } else { + // OpenAI-compatible provider (OpenRouter, DeepSeek, etc.) + const isDeepSeek = this._isModelType(useModel, 'deepseek'); + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + if (isDeepSeek) { + body.temperature = 0.3; + body.max_tokens = 4096; + body.stop = ['<|end|>']; + } + if (tools && tools.length > 0) { + body.tools = tools; + } + const t0 = Date.now(); + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal + }); + const networkTime = Math.round((Date.now() - t0) / 1000); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); + const choice = data.choices?.[0]; + const message = choice?.message || null; + const toolCalls = message?.tool_calls || null; + const content = message?.content || ''; + + // DeepSeek: handle null content with tool calls — send empty string + if (isDeepSeek && content === '' && toolCalls && toolCalls.length > 0) { + return { + message: { role: 'assistant', content: '' }, + tool_calls: toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })), + error: null + }; + } + + // DeepSeek: empty response with no tools — retry once with stream forcing + if (isDeepSeek && content === '' && (!toolCalls || toolCalls.length === 0)) { + this.terminal.print('⚠️ DeepSeek returned empty — retrying with stream...', 'warning'); + body.stream = true; + delete body.stop; + this._abortController = new AbortController(); + const retrySignal = this._abortController.signal; + const retryResponse = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: retrySignal + }); + if (retryResponse.ok) { + const reader = retryResponse.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + let retryBuffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + retryBuffer += decoder.decode(value, { stream: true }); + const lines = retryBuffer.split('\n'); + retryBuffer = lines.pop() || ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'data: [DONE]') continue; + if (trimmed.startsWith('data: ')) { + try { + const parsed = JSON.parse(trimmed.substring(6)); + const chunk = parsed.choices?.[0]?.delta?.content || ''; + if (chunk) fullText += chunk; + } catch (e) {} + } + } + } + if (fullText) { + return { + message: { role: 'assistant', content: fullText }, + tool_calls: null, + error: null + }; + } + } + return { + message: { role: 'assistant', content: '' }, + tool_calls: null, + error: null + }; + } + + return { + message: message ? { role: message.role || 'assistant', content: content } : null, + tool_calls: toolCalls ? toolCalls.map(tc => ({ + id: tc.id, + type: tc.type || 'function', + function: { + name: tc.function?.name || '', + arguments: tc.function?.arguments || '{}' + } + })) : null, + error: null + }; + } + } catch (error) { + if (error.name === 'AbortError') { + this.terminal.print('⏹ AI request cancelled by user', 'warning'); + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + if (error.message === 'Failed to fetch' && provider.type === 'ollama') { + return await this.chatWithToolsFallback(messages, tools, useModel); + } + this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error'); + return { message: null, tool_calls: null, error: error.message }; + } finally { + this._abortController = null; + } + } + + async chatWithToolsFallback(messages, tools, model, signal) { + const provider = this.currentProvider; + const useModel = model || (provider ? provider.model : this.currentModel); + if (provider && provider.type !== 'ollama') { + // For OpenAI-compatible providers, try without tools + this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning'); + try { + const headers = { 'Content-Type': 'application/json' }; + if (provider.apiKey) { + headers['Authorization'] = 'Bearer ' + provider.apiKey; + } + const body = { + model: useModel, + messages: messages, + stream: false + }; + const response = await fetch(`${provider.endpoint}/v1/chat/completions`, { + method: 'POST', headers, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.choices?.[0]?.message?.content || ''; + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // MiniCPM5 XML tool calling mode — sends tools in message context instead of API parameter + // Model natively outputs val XML + if (useModel && useModel.includes('minicpm5') && tools && tools.length > 0) { + this.terminal.print(`🧩 Using XML tool mode for ${useModel}...`, 'info'); + try { + // Inject tool defs into a system message (the orchestrator model knows XML format) + const toolDesc = tools.map(t => JSON.stringify({type:'function',function:t.function})).join('\n'); + const toolContext = { role: 'system', content: +`You are a tool orchestrator. When you need to call a tool, output XML like: +param-value + +Available tools: + +${toolDesc} + + +Only output XML function calls for tool tasks. If no tool is needed, answer normally.` }; + const msgsWithTools = [toolContext, ...messages]; + const body = { model: useModel, messages: msgsWithTools, stream: false, options: { temperature: 0.1 } }; + + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = (data.message?.content || '').trim(); + + // Parse XML function calls from the response + const toolCalls = []; + const funcRegex = //gs; + let match; + while ((match = funcRegex.exec(text)) !== null) { + const funcName = match[1]; + const args = {}; + const paramRegex = /(.*?)<\/param>/gs; + let pmatch; + while ((pmatch = paramRegex.exec(match[0])) !== null) { + args[pmatch[1]] = pmatch[3] + .replace('', '') + .replace(/^\s+|\s+$/g, ''); + } + if (funcName && Object.keys(args).length > 0) { + toolCalls.push({ + id: 'call_' + Math.random().toString(36).slice(2, 10), + type: 'function', + function: { name: funcName, arguments: JSON.stringify(args) } + }); + } + } + if (toolCalls.length > 0) { + this.terminal.print(`✅ XML tool call(s) parsed from ${useModel}`, 'success'); + return { message: { role: 'assistant', content: '' }, tool_calls: toolCalls, error: null }; + } + + return { message: { role: 'assistant', content: text || '(no response)' }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // Original Ollama fallback + this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning'); + try { + const body = { model: useModel, messages, stream: false }; + const response = await fetch(`${this.endpoint}/api/chat`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const text = data.message?.content || data.response || ''; + return { message: { role: 'assistant', content: text }, tool_calls: null, error: null }; + } catch (e) { + if (e.name === 'AbortError') { + return { message: null, tool_calls: null, error: 'Cancelled' }; + } + return { message: null, tool_calls: null, error: e.message }; + } + } + + // ─── Internal helpers ──────────────────────────────────────────────────── + + _getFallbackChain() { + const sorted = this._getSortedEnabledProviders(); + if (sorted.length === 0) return []; + + // Start with the current provider (if any), then all others sorted by priority + const chain = []; + const seen = new Set(); + + if (this.currentProvider && this.currentProvider.enabled) { + chain.push(this.currentProvider); + seen.add(this.currentProvider.name); + } + + for (const p of sorted) { + if (!seen.has(p.name)) { + chain.push(p); + seen.add(p.name); + } + } + + return chain; + } + + // ─── Backward-compat methods ───────────────────────────────────────────── + + setEndpoint(url) { + this.endpoint = url; + // Also update the current provider's endpoint if it exists + if (this.currentProvider) { + this.currentProvider.endpoint = url; + this._saveProviders(); + } + this.saveSettings(); + } + + setModel(name) { + this.currentModel = name; + // Also update the current provider's model + if (this.currentProvider) { + this.currentProvider.model = name; + this._saveProviders(); + } + this.saveSettings(); + this.terminal.print(`Model set to: ${name}`, 'success'); + } + + saveSettings() { + localStorage.setItem('webx11_ollama_endpoint', this.endpoint); + localStorage.setItem('webx11_ollama_model', this.currentModel); + localStorage.setItem('webx11_optimization_enabled', this.optimizationEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_stream_enabled', this.streamEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_validator_enabled', this.validatorEnabled ? 'true' : 'false'); + localStorage.setItem('webx11_validator_model', this.validatorModel); + localStorage.setItem('webx11_validator_endpoint', this.validatorEndpoint || ''); + } + + setOptimizationEnabled(val) { + this.optimizationEnabled = !!val; + this.terminal?.print(`Agent optimization ${this.optimizationEnabled ? 'enabled' : 'disabled'}. Reload to apply.`, 'info'); + this.saveSettings(); + } + + setStreamEnabled(val) { + this.streamEnabled = !!val; + this.terminal?.print(`Response streaming ${this.streamEnabled ? 'enabled' : 'disabled'}.`, 'info'); + this.terminal?.updateStreamIndicator(); + this.saveSettings(); + } + + setValidatorConfig(enabled, model) { + this.validatorEnabled = !!enabled; + if (model !== undefined) { + this.validatorModel = model; + localStorage.setItem('webx11_validator_model', model); + } + this.saveSettings(); + } + + async validateToolResult(toolName, args, result, aiAnswer) { + if (!this.validatorEnabled || !this.validatorModel) return true; + const endpoint = this.validatorEndpoint || this.endpoint; + const prompt = 'Tool called: ' + toolName + '(' + JSON.stringify(args) + ')\n' + + 'Tool returned: ' + ((result || '').substring(0, 500)) + '\n' + + 'AI answered: ' + ((aiAnswer || '').substring(0, 500)) + '\n' + + 'QUESTION: Is the AI answer consistent with the tool result?\nAnswer only YES or NO.'; + try { + const res = await fetch(endpoint + '/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.validatorModel, messages: [{ role: 'user', content: prompt }], stream: false }) + }); + const data = await res.json(); + const answer = (data.message?.content || '').trim().toUpperCase(); + return answer.startsWith('YES'); + } catch (e) { + if (this.terminal) this.terminal.print('⚠️ Validator error: ' + e.message, 'warning'); + return true; + } + } + + getModels() { return this.models; } + + addResponseHook(fn) { this.responseHooks.push(fn); } + + removeResponseHook(fn) { this.responseHooks = this.responseHooks.filter(h => h !== fn); } + + fireResponseHooks(text) { if (text) this.responseHooks.forEach(fn => { try { fn(text); } catch (e) {} }); } + + abort() { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + return true; + } + return false; + } +} + +/* === terminal.js === */ +// Extracted from newOS4 index.html — WebX11Terminal + class WebX11SecurityManager { + constructor(terminal, level = 1) { + this.terminal = terminal; + this.level = level; + this.commandMeta = new Map(); + this.lastRouteTime = 0; + this.lastRouteCmd = ''; + this.blockedCount = 0; + this.destructiveCmds = new Set(['delete','del','write','save','overwrite','format','kill','stop','shutdown','reboot']); + this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']); + this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']); + this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history','find','grep','tree','head','tail','wc','sort','du','diff','which','hostname','cal','basename','dirname','yes','seq','cut','uniq','expand','printf','base64','sleep','ll','h','mem','status','health-check','system-health','alias','unalias','modules','listmods','modules-list','nvfs-index','nvfs-status','nvfs-resolve','nvfs-tree','nvfs-config','nvfs-alias','nvfs-sync','floating-add','floating-remove','floating-list','floating-pos','floating-clear','floating-toggle','mem-debug','reset-mem']); + // Load persisted overrides from localStorage + try { const d = localStorage.getItem('webx11_security_destructive'); if(d){JSON.parse(d).forEach(c=>this.destructiveCmds.add(c));} }catch(e){} + try { const s = localStorage.getItem('webx11_security_system'); if(s){JSON.parse(s).forEach(c=>this.systemCmds.add(c));} }catch(e){} + try { const w = localStorage.getItem('webx11_security_whitelist'); if(w){JSON.parse(w).forEach(c=>this.whitelist.add(c));} }catch(e){} + try { const f = localStorage.getItem('webx11_security_flags'); if(f){JSON.parse(f).forEach(c=>this.suspiciousFlags.add(c));} }catch(e){} + this._registerBuiltIn(); + } + _registerBuiltIn() { + const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','cat','run','font-size','load-local','loadlocal','mem','security','security-level','security-destructive','security-system','security-whitelist','security-flag','config','ai-config','startup-modules','smart-prompt','cmd-allow','cmd-deny','system-health','server','servers','server-check','monitor','vfs','vfs-clean','vfs-size','empty-trash']; + internals.forEach(c => this.registerCommand(c, { category: 'builtin' })); + } + setLevel(n) { + if (n >= 0 && n <= 3) { + this.level = n; + this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success'); + if (n === 0) { + this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning'); + } + } + else { this.terminal.print('Level must be 0-3', 'error'); } + } + getLevel() { return this.level; } + getLevelName() { + return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown'; + } + registerCommand(name, meta = {}) { + this.commandMeta.set(name, { destructive: false, category: 'user', ...meta }); + } + _collectAllCommands() { + const cmds = new Set([...this.commandMeta.keys()]); + for (const modName in this.terminal.moduleManager.modules) { + const mod = this.terminal.moduleManager.modules[modName]; + if (mod.commands) { + for (const c in mod.commands) cmds.add(c); + } + } + const builtins = ['clear','help','ls','df','security','security-level']; + builtins.forEach(c => cmds.add(c)); + return [...cmds]; + } + _levenshtein(a, b) { + a = a.toLowerCase(); b = b.toLowerCase(); + const m = a.length, n = b.length; + if (!m) return n; if (!n) return m; + let prev = new Uint16Array(n + 1); + let curr = new Uint16Array(n + 1); + for (let j = 0; j <= n; j++) prev[j] = j; + for (let i = 1; i <= m; i++) { + curr[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, prev]; + } + return prev[n]; + } + suggest(input, maxDist = 2) { + const all = this._collectAllCommands(); + const lower = input.toLowerCase(); + let best = '', bestDist = Infinity; + for (const c of all) { + const d = this._levenshtein(lower, c); + if (d < bestDist && d <= maxDist) { bestDist = d; best = c; } + } + return best ? { cmd: best, dist: bestDist } : null; + } + _parseArgs(input) { + const args = []; + let current = ''; + let inDouble = false; + let inSingle = false; + for (const ch of input) { + if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } + if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } + if (ch === ' ' && !inDouble && !inSingle) { + if (current) { args.push(current); current = ''; } + } else { + current += ch; + } + } + if (current) args.push(current); + return args; + } + _classify(raw) { + const parts = this._parseArgs(raw.trim()); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Internal commands always allowed at terminal level, but security still classifies + const all = this._collectAllCommands(); + const known = all.includes(cmd); + const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase())); + const destructive = this.destructiveCmds.has(cmd) || hasSuspicious; + const system = this.systemCmds.has(cmd); + const unknown = !known; + return { cmd, args, known, destructive, system, unknown, hasSuspicious }; + } + async checkAndRun(rawInput) { + const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); + // Level 3 Audit: just log and allow + if (this.level === 3) { + this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info'); + return this._dispatch(cmd, args); + } + // Always allow builtins that are safe, regardless of level + if (['help','security','security-level'].includes(cmd)) { + return this._dispatch(cmd, args); + } + // Unknown command handling — skip at level 0 (permissive) + if (unknown && !this.whitelist.has(cmd)) { + if (this.level === 0) { + return this._dispatch(cmd, args); + } + this.blockedCount++; + const sug = this.suggest(cmd); + if (sug) { + this.terminal.print(`Security: Unknown command "${cmd}". Did you mean: "${sug.cmd}"?`, 'warning'); + } else { + this.terminal.print(`Security: Unknown command "${cmd}" — blocked. Use 'security-level 0' to disable.`, 'warning'); + } + // At level 1: suggest/fix but still route to AI (terminal is also a chat interface) + // At level 2+: block entirely (paranoid) + if (this.level >= 2) { + this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).`, 'error'); + return; + } + if (this.level >= 1) { + if (sug) { + this.terminal.print(`Security: Did you mean "${sug.cmd}"? Routing to AI...`, 'warning'); + } else { + this.terminal.print(`Security: Routing to AI...`, 'info'); + } + } + return this._tryAIRoute(rawInput); + } + // Suspicious flags (e.g. --execute) at any level > 0 + if (hasSuspicious && this.level > 0) { + this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error'); + this.blockedCount++; + return; + } + // Destructive commands + if (destructive) { + if (this.level === 0) { + this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning'); + return this._dispatch(cmd, args); + } + if (this.level >= 1) { + // Reuse your existing confirm UI if present, otherwise print confirm message + if (this.terminal.showConfirmationBar) { + this.terminal._pendingCommand = () => this._dispatch(cmd, args); + this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`); + return; + } else { + this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning'); + return; + } + } + } + // System commands at level >=1 + if (system && this.level >= 1) { + this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error'); + this.blockedCount++; + return; + } + // Standard dispatch for everything else + return this._dispatch(cmd, args); + } + async _dispatch(cmd, args) { + return this.terminal.dispatchCommand(cmd, args); + } + async _tryAIRoute(rawInput) { + // AI routing cooldown guard + const now = Date.now(); + if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) { + this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning'); + return; + } + this.lastRouteCmd = rawInput; + this.lastRouteTime = now; + try { + const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.terminal.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([rawInput.trim()]); + } + } catch (e) { + this.terminal.print(`Security: AI error: ${e.message}`, 'error'); + } + } + } + + class WebX11Terminal { + constructor() { + this.outputContainer = document.getElementById('terminal-container'); + this.inputField = document.getElementById('command-input'); + this.dropdown = document.getElementById('autocomplete-dropdown'); + this.statusBar = document.getElementById('status-text'); + this.networkStatus = document.getElementById('network-status'); + this.fs = new VirtualFileSystem(); + this.secretVault = null; + try { + if (typeof SecretVault !== 'undefined') { + this.secretVault = new SecretVault(this); + window.webx11_secretVault = this.secretVault; + } + } catch (e) { console.warn('SecretVault init:', e.message); } + this.moduleManager = new ModuleManager(this); + this.security = new WebX11SecurityManager(this, parseInt(localStorage.getItem('webx11_security_level')) || 1); + this.ollamaManager = new OllamaManager(this); + // Config registry — modules can register keys for the unified `config` command + this._configRegistry = {}; + this._configDefaults = {}; + this._configSetters = {}; + this._loadTerminalConfig(); + // Register built-in terminal config keys + this.registerConfigItem('terminal-max-lines', 200, (v) => { this.MAX_OUTPUT_LINES = v; }); + this.registerConfigItem('terminal-hist-output', 50, (v) => { this._historyMaxLines = v; }); + this.registerConfigItem('terminal-cmd-history', 200, (v) => { /* applied on next save */ }); + this.registerConfigItem('terminal-pipe-buffer', 5000, (v) => { this._pipeBufferSize = v; }); + this._pipeInput = null; + this._pipeBufferSize = 5000; + this._historyBuffer = []; + this._historyMaxLines = 50; + this._historySavePending = false; + this.MAX_OUTPUT_LINES = 200; + this._historyCap = 200; + // Apply history cap from localStorage on startup (was applied "on next save" before — broken) + try { + const cfgCap = parseInt(localStorage.getItem('webx11_terminal_cmd_history')); + if (cfgCap && cfgCap > 0) this._historyCap = cfgCap; + } catch (e) {} + this._aiBusy = false; + this._dirty = false; + this.history = JSON.parse(localStorage.getItem('webx11_command_history') || '[]'); + this.historyIndex = this.history.length; + this.selectedSuggestion = -1; + this.suggestions = []; + this._isCdAutocomplete = false; + this._autocompletePrefix = ''; + this._welcomeShown = false; + this.initEventListeners(); + this.updateNetworkStatus(); + this.loadBuiltInModules(); + this._loadHistory(); + // Auto-restore session + this._autoRestoreSession(); + this._restoreFontSize(); + // Restore saved theme on boot + this._restoreTheme(); + // Init auto-save timer — 60s after boot, then every 60s + setTimeout(() => this._initAutoSave(), 30000); + // Init sound toggle button text + const soundBtn = document.getElementById('sound-toggle-btn'); + if (soundBtn) { + soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '🔇 Muted' : '🔊 Sound'; + } + setTimeout(() => this.inputField.focus(), 100); + // First-run welcome check — fires after ext modules are loaded + setTimeout(() => this._checkFirstRun(), 2000); + // Init all registered modules (safe now — constructor is done) + this._initRegisteredModules(); + // Apply config overrides after everything is initialized + this._applyTerminalConfig(); + } + initEventListeners() { + this.inputField.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; + } else if (!this._isCdAutocomplete) { + this.inputField.value = this.suggestions[this.selectedSuggestion]; + } + } + const cmd = this.inputField.value; + // Dedup: don't push if same as last entry + if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) { + this.history.push(cmd); + } + // Trim to max 200 entries + if (this.history.length > this._historyCap) { + this.history = this.history.slice(-this._historyCap); + } + this.historyIndex = this.history.length; + this._saveCommandHistory(); + this.executeCommand(cmd); + this.inputField.value = ''; + this.inputField.style.height = 'auto'; + this.hideAutocomplete(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (this.historyIndex > 0) { + this.historyIndex--; + this.inputField.value = this.history[this.historyIndex]; + } + this.hideAutocomplete(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (this.historyIndex < this.history.length - 1) { + this.historyIndex++; + this.inputField.value = this.history[this.historyIndex]; + } else { + this.historyIndex = this.history.length; + this.inputField.value = ''; + } + this.hideAutocomplete(); + } else if (e.key === 'Tab') { + e.preventDefault(); + if (this.suggestions.length > 0) { + if (e.shiftKey) { + this.selectedSuggestion = this.selectedSuggestion <= 0 + ? this.suggestions.length - 1 + : this.selectedSuggestion - 1; + } else { + this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length; + } + this.updateAutocompleteDisplay(); + } + } else if (e.ctrlKey && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + this.toggleCmdCards(); + } else if (e.ctrlKey && (e.key === 'l' || e.key === 'L')) { + // Ctrl+L — clear output (standard terminal) + e.preventDefault(); + // Preserve welcome/loading indicators — only clear actual output lines + if (this.outputContainer) { + for (const child of [...this.outputContainer.children]) { + if (!child.id || (!child.id.includes('welcome') && !child.id.includes('loading'))) { + child.remove(); + } + } + } + } else if (e.ctrlKey && (e.key === 'c' || e.key === 'C') && this.inputField.value) { + // Ctrl+C — cancel current input line (standard terminal) + e.preventDefault(); + this.print('^C', 'warning'); + this.inputField.value = ''; + this.inputField.style.height = 'auto'; + this.hideAutocomplete(); + return; + } else if (e.ctrlKey && (e.key === 'u' || e.key === 'U')) { + // Ctrl+U — clear line from cursor to start (standard terminal) + e.preventDefault(); + this.inputField.value = ''; + this.inputField.style.height = 'auto'; + } else if (e.key === 'Escape') { + if (this._aiBusy) { + this.executeCommand('ai-stop'); + } else { + this.hideAutocomplete(); + } + } + }); + this.inputField.addEventListener('input', () => { + this.inputField.style.height = 'auto'; + this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px'; + this.showAutocomplete(); + }); + this.inputField.addEventListener('focus', () => { + this.showAutocomplete(); + }); + document.addEventListener('click', (e) => { + if (!e.target.closest('#input-area')) { + this.hideAutocomplete(); + } + }); + window.addEventListener('online', () => this.updateNetworkStatus()); + window.addEventListener('offline', () => this.updateNetworkStatus()); + document.getElementById('file-upload-input').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (evt) => { + this.fs.writeFile(file.name, evt.target.result); + this.markDirty(); + this.print(`File '${file.name}' uploaded successfully.`, 'success'); + }; + reader.readAsText(file); + } + e.target.value = ''; + }); + // Voice mic button + // Sound toggle button + const soundBtn = document.getElementById('sound-toggle-btn'); + if (soundBtn) { + soundBtn.addEventListener('click', () => { + const current = localStorage.getItem('webx11_config_ai_sound'); + if (current === 'off') { + localStorage.setItem('webx11_config_ai_sound', 'on'); + soundBtn.textContent = '🔊 Sound'; + } else { + localStorage.setItem('webx11_config_ai_sound', 'off'); + soundBtn.textContent = '🔇 Muted'; + } + }); + } + // Mic toggle button + const micToggleBtn = document.getElementById('voice-toggle-btn'); + if (micToggleBtn) { + micToggleBtn.addEventListener('click', () => this.toggleMicVisibility()); + } + } + updateNetworkStatus() { + const isOnline = navigator.onLine; + this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline'; + this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)'; + } + updatePrompt() { + const path = '/' + this.fs.currentPath.join('/'); + const label = document.querySelector('.prompt-label'); + if (!label) return; + if (localStorage.getItem('webx11_smart_prompt') === 'off') { + label.textContent = path === '/' ? '$' : path + ' $'; + return; + } + const indicators = []; + try { + const p = localStorage.getItem('webx11_user_profile'); + if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); } + } catch (e) {} + try { + const idx = this.fs.fsReadJSON('/skills/index.json'); + if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); } + } catch (e) {} + try { + const memIdx = this.fs.fsReadJSON('/memory/index.json'); + if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); } + } catch (e) {} + try { + if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { + indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB'); + } + } catch (e) {} + try { + const md = this.fs.readDir('_mods'); + if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); } + } catch (e) {} + const prefix = indicators.length ? indicators.join(' ') + ' ' : ''; + label.textContent = prefix + (path === '/' ? '$' : path + ' $'); + } + print(text, type = '') { + const line = document.createElement('div'); + // Use card layout for typed messages (error/warning/info/success/text-muted), plain for bare text + const isTyped = ['error','warning','success'].includes(type); + if (isTyped) { + line.className = 'output-line msg-card system ' + type; + const safeText = String(text).replace(//g, ">"); + line.innerHTML = '
⚙ System
' + safeText + '
'; + } else { + line.className = 'output-line'; + line.textContent = text; + } + this.outputContainer.appendChild(line); + this._historyBuffer.push({ l: text, t: type }); + if (this._historyBuffer.length > this._historyMaxLines) { + this._historyBuffer.shift(); + } + this._saveHistory(); + this.scrollToBottom(); + this._trimOutput(); + } + _renderHTMLPreview(content) { + // AI returns raw HTML. We must HTML-escape for the srcdoc attribute + // so the parser treats it as attribute content, not tags. + // srcdoc decodes entities back to actual HTML inside the iframe. + const hasOwnStyle = /body{background:#000;color:#0f0;margin:16px;font-family:monospace;}${content}`; + const safeSrcdoc = themedContent + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/`/g, '`'); + return `
📄 Rendered HTML
`; + } + _renderHTMLBlock(text) { + const renderRegex = /:::render-html\r?\n([\s\S]*?)\r?\n:::/g; + let match; + let lastIndex = 0; + const fragments = []; + while ((match = renderRegex.exec(text)) !== null) { + // Text before this block + const before = text.slice(lastIndex, match.index); + if (before.trim()) { + const safeBefore = before.replace(//g, ">"); + fragments.push({ type: 'text', html: '
' + safeBefore + '
' }); + } + fragments.push({ type: 'render', html: this._renderHTMLPreview(match[1].trim()) }); + lastIndex = match.index + match[0].length; + } + const after = text.slice(lastIndex); + if (after.trim()) { + const safeAfter = after.replace(//g, ">"); + fragments.push({ type: 'text', html: '
' + safeAfter + '
' }); + } + return fragments.length > 0 ? fragments : null; + } + printAIResponse(text) { + this._flushStreamChunks(); + const fragments = this._renderHTMLBlock(text); + if (fragments) { + const wrapper = document.createElement('div'); + wrapper.className = 'output-line msg-card ai'; + wrapper.innerHTML = '
🤖 AI
'; + const body = wrapper.querySelector('.msg-card-body'); + for (const frag of fragments) { + const div = document.createElement('div'); + div.innerHTML = frag.html; + body.appendChild(div); + } + this.outputContainer.appendChild(wrapper); + this.scrollToBottom(); + this._trimOutput(); + return; + } + const wrapper = document.createElement('div'); + wrapper.className = 'output-line msg-card ai'; + const safeText = text.replace(//g, ">"); + wrapper.innerHTML = '
🤖 AI
' + safeText + '
'; + this.outputContainer.appendChild(wrapper); + this.scrollToBottom(); + this._trimOutput(); + } + printStreamingResponse(text, append) { + if (!this._streamChunks) this._streamChunks = []; + if (!append) { + const safeText = text.replace(//g, ">"); + const wrapper = document.createElement('div'); + wrapper.className = 'output-line msg-card ai'; + wrapper.innerHTML = '
🤖 AI
' + safeText + '
'; + this.outputContainer.appendChild(wrapper); + this._streamChunks = []; + } else { + const contentDiv = this.outputContainer.querySelector('.msg-card.ai:last-child .msg-card-body'); + if (contentDiv) { + this._streamChunks.push(text); + if (this._streamChunks.length >= 5) { + contentDiv.textContent += this._streamChunks.join(''); + this._streamChunks = []; + if (contentDiv.textContent.length > 50000) { + contentDiv.textContent = contentDiv.textContent.slice(-30000); + } + } + } + } + this.scrollToBottom(); + this._trimOutput(); + } + _flushStreamChunks() { + const contentDiv = this.outputContainer.querySelector('.msg-card.ai:last-child .msg-card-body'); + if (contentDiv && this._streamChunks && this._streamChunks.length > 0) { + contentDiv.textContent += this._streamChunks.join(''); + this._streamChunks = []; + } + } + printUserPrompt(rawInput) { + const wrapper = document.createElement('div'); + wrapper.className = 'output-line msg-card user'; + const safe = rawInput.replace(//g, '>'); + const hasHtml = /<[a-z][\s\S]*>/i.test(rawInput.trim()); + let buttons = ''; + if (hasHtml) buttons += ''; + buttons += ''; + wrapper.innerHTML = '
👤 User' + buttons + '
' + safe + '
'; + this.outputContainer.appendChild(wrapper); + this._historyBuffer.push({ l: rawInput, t: 'command-prompt' }); + if (this._historyBuffer.length > this._historyMaxLines) this._historyBuffer.shift(); + this.scrollToBottom(); + this._trimOutput(); + } + editPrompt(text) { + this.inputField.value = text; + this.inputField.focus(); + this.inputField.setSelectionRange(text.length, text.length); + } + copyToClipboard(btn) { + let text = ''; + const card = btn.closest('.msg-card'); + if (card) { + const cardBody = card.querySelector('.msg-card-body'); + if (cardBody) text = cardBody.textContent; + } else { + const nd = btn.nextElementSibling; + if (nd) text = nd.innerText || nd.textContent; + } + if (!text) return; + btn.textContent = 'Copied!'; + setTimeout(function(){ btn.textContent = 'Copy'; }, 2000); + // Use navigator.clipboard on secure contexts (iOS 14+, modern browsers). + // This is the preferred API — NOTHING blocks it on HTTPS pages. + if (navigator.clipboard) { + navigator.clipboard.writeText(text).catch(function(){}); + } + // Also try textarea+execCommand synchronously for older browsers. + // DO NOT wrap in setTimeout — iOS Safari requires execCommand to run + // in the same microtask as the user's click event. + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:10px;top:10px;width:200px;height:40px;font-size:16px;padding:4px;z-index:-1;opacity:0.01'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + ta.setSelectionRange(0, text.length); + document.execCommand('copy'); + document.body.removeChild(ta); + } catch(e) {} + } + printClickable(items) { + const line = document.createElement('div'); + line.className = 'output-line'; + items.forEach(f => { + const span = document.createElement('span'); + const safeName = f.name.replace(//g, '>'); + if (f.type === 'dir') { + span.className = 'clickable-dir'; + span.textContent = '[dir]' + f.name + '/'; + span.onclick = () => { + this.executeCommand('cd ' + f.name); + setTimeout(() => this.executeCommand('ls'), 50); + }; + } else { + span.className = 'clickable-file'; + span.textContent = ' ' + f.name; + span.onclick = () => this.executeCommand('cat ' + f.name); + } + line.appendChild(span); + }); + this.outputContainer.appendChild(line); + this.scrollToBottom(); + } + printHTML(html) { + const line = document.createElement('div'); + line.className = 'output-line'; + line.innerHTML = html; + this.outputContainer.appendChild(line); + this.scrollToBottom(); + } + showConfirmationBar(desc) { + const area = document.getElementById('ai-confirm-area'); + if (!area) { + this.print('Confirmation UI unavailable — command blocked.', 'error'); + return; + } + this._pendingCommandDesc = desc; + area.innerHTML = ` + ⚠️ ${desc} + + + Type: cmd-allow or cmd-deny + `; + area.classList.add('visible'); + const allowBtn = document.getElementById('cmd-confirm-allow'); + const denyBtn = document.getElementById('cmd-confirm-deny'); + const onAllow = () => { + this._resolveCmdConfirm(true); + }; + const onDeny = () => { + this._resolveCmdConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + } + _resolveCmdConfirm(approved) { + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + if (approved) { + this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success'); + if (this._pendingCommand) { + const fn = this._pendingCommand; + this._pendingCommand = null; + this._pendingCommandDesc = null; + fn(); + } + } else { + this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning'); + this._pendingCommand = null; + this._pendingCommandDesc = null; + } + } + _loadHistory() { + try { + const saved = localStorage.getItem('webx11_terminal_output'); + if (!saved) return; + const entries = JSON.parse(saved); + if (!Array.isArray(entries)) return; + this._historyBuffer = entries.slice(-this._historyMaxLines); + // Check localStorage quota warning + let quotaWarned = false; + try { + const testKey = '_quota_test_' + Date.now(); + localStorage.setItem(testKey, 'x'.repeat(1024)); + localStorage.removeItem(testKey); + } catch (e) { + quotaWarned = true; + } + // Lazy-load history: render first 20 immediately, batch the rest + const batchSize = 20; + const toRender = entries.slice(-this._historyMaxLines); + const initial = toRender.slice(0, batchSize); + for (const entry of initial) { + const line = document.createElement('div'); + line.className = `output-line ${entry.t || ''}`; + line.textContent = entry.l || ''; + this.outputContainer.appendChild(line); + } + if (toRender.length > batchSize) { + const remaining = toRender.slice(batchSize); + const MAX_BATCHES = 50; + const doBatch = (index, batchCount) => { + if (index >= remaining.length || batchCount >= MAX_BATCHES) { + this.scrollToBottom(); + return; + } + requestAnimationFrame(() => { + const end = Math.min(index + batchSize, remaining.length); + for (let i = index; i < end; i++) { + const line = document.createElement('div'); + line.className = `output-line ${remaining[i].t || ''}`; + line.textContent = remaining[i].l || ''; + this.outputContainer.appendChild(line); + } + doBatch(end, (batchCount || 0) + 1); + }); + }; + doBatch(0, 0); + } else { + this.scrollToBottom(); + } + if (quotaWarned) { + const warnLine = document.createElement('div'); + warnLine.className = 'output-line warning'; + warnLine.textContent = '⚠ localStorage is near quota — history may be incomplete'; + this.outputContainer.appendChild(warnLine); + } + } catch (e) { + // Silently ignore corrupt history data + } + } + _saveHistory() { + if (this._historySavePending) return; + this._historySavePending = true; + setTimeout(() => { + this._historySavePending = false; + try { + const slice = this._historyBuffer.slice(-this._historyMaxLines); + localStorage.setItem('webx11_terminal_output', JSON.stringify(slice)); + } catch (e) { + // Silently ignore storage errors (e.g. quota exceeded) + } + }, 3000); + } + _saveCommandHistory() { + try { + localStorage.setItem('webx11_command_history', JSON.stringify(this.history)); + } catch (e) { + // Silently ignore storage errors + } + } + _trimOutput() { + const container = this.outputContainer; + while (container.children.length > this.MAX_OUTPUT_LINES) { + container.removeChild(container.firstChild); + } + } + markDirty() { + this._dirty = true; + } + _initAutoSave() { + if (this._autoSaveTimer) return; + this._autoSaveTimer = setInterval(() => { + // Auto-trim on memory pressure + try { + if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { + const oldMax = this.MAX_OUTPUT_LINES; + this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100; + this._trimOutput(); + this.MAX_OUTPUT_LINES = oldMax; + this.print('🧹 Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted'); + } + } catch (e) {} + if (!this._dirty) return; + this._autoSaveSession(); + this._dirty = false; + }, 180000); + window.addEventListener('beforeunload', () => { + // Flush session before tab closes — avoids losing up to 3min of output on crash/close + try { this._autoSaveSession(); } catch (e) {} + if (this._autoSaveTimer) { + clearInterval(this._autoSaveTimer); + this._autoSaveTimer = null; + } + }); + } + _autoSaveSession() { + try { + this.fs.mkdirRecursive('_sessions'); + var rootClone; + try { + const json = JSON.stringify(this.fs.root); + if (json.length > 52428800) { // 50MB cap + rootClone = { type: 'directory', name: '/', content: {}, _truncated: true }; + this.print('⚠ Auto-save skipped (VFS too large)', 'warning'); + return; + } + rootClone = JSON.parse(json); + } catch (e) { + rootClone = { type: 'directory', name: '/', content: {}, _error: true }; + return; + } + if (rootClone && rootClone.content && rootClone.content['_sessions']) { + delete rootClone.content['_sessions']; + } + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: rootClone, + currentPath: this.fs.currentPath, + version: 1, + terminalLines: this._historyBuffer.slice(-50) + }; + const snapshotJson = JSON.stringify(snapshot); + if (snapshotJson.length > 52428800) { + this.print('⚠ Auto-save snapshot too large, skipping', 'warning'); + return; + } + this.fs.writeFile('_sessions/_auto.json', snapshotJson); + try { + const ctx = localStorage.getItem('webx11_ai_context'); + if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx); + } catch (e) {} + } catch (e) { + // Silently handle auto-save failures + } + } + _autoRestoreSession() { + try { + const lastReset = parseInt(localStorage.getItem('webx11_last_reset') || '0', 10); + this.fs.mkdirRecursive('_sessions'); + const autoSession = this.fs.readFile('_sessions/_auto.json'); + if (autoSession) { + const data = JSON.parse(autoSession); + const savedAt = data.savedAt || 0; + // v4.5.47: skip restoring backups created before the last reset + if (lastReset && savedAt && savedAt < lastReset) { + this.fs.deleteFile('_sessions/_auto.json'); + this.fs.deleteFile('_sessions/_auto-context.json'); + this.print('↻ Skipped stale auto-session backup (older than last reset)', 'info'); + return; + } + if (data && data.fsRoot) { + this.fs.root = data.fsRoot; + this.fs.currentPath = data.currentPath || []; + this.fs.saveToStorage(); + this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success'); + // Defer AI context restoration (AI module loads after terminal init) + try { + const aiCtx = this.fs.readFile('_sessions/_auto-context.json'); + if (aiCtx) { + localStorage.setItem('webx11_ai_context', aiCtx); + } + } catch (e) {} + // v4.5.47: delete backup after successful restore so reset-mem doesn't get resurrected + this.fs.deleteFile('_sessions/_auto.json'); + this.fs.deleteFile('_sessions/_auto-context.json'); + } + } + } catch (e) { + // Silent on first boot — no auto-session yet + } + } + scrollToBottom() { + this.outputContainer.scrollTop = this.outputContainer.scrollHeight; + } + _getPathSuggestions(partial, type) { + // type: 'dir' = dirs only, 'file' = files + dirs (dirs get trailing /), 'session' = _sessions/*.json + if (type === 'session') { + this.fs.mkdirRecursive('_sessions'); + const all = this.fs.ls('_sessions'); + const names = all + .filter(e => e.type === 'file' && e.name.endsWith('.json')) + .map(e => e.name.replace('.json', '')); + return partial + ? names.filter(n => n.startsWith(partial)).map(n => n) + : names; + } + // Resolve the partial path to determine which directory to list + let dirPath = ''; + let filterPrefix = ''; + let entries = []; + let prefix = ''; + + if (partial.includes('/')) { + // "dir1/dir2" or "dir1/dir2/" or "dir1/" + const lastSlash = partial.lastIndexOf('/'); + const beforeSlash = partial.slice(0, lastSlash); // dir1 + filterPrefix = partial.slice(lastSlash + 1); // dir2 (or '' if ends with /) + // Resolve "dir1" relative to current path + const resolvedDir = this.fs._resolveDir(beforeSlash || '.'); + if (!resolvedDir) return []; + entries = Object.keys(resolvedDir.content).map(name => ({ + name, + type: resolvedDir.content[name].type, + size: resolvedDir.content[name].content ? resolvedDir.content[name].content.length : 0 + })); + prefix = beforeSlash + '/'; + } else { + // No slash — list current dir + entries = this.fs.ls(); + filterPrefix = partial; + prefix = ''; + } + + if (type === 'dir') { + return entries + .filter(e => e.type === 'dir' && e.name.startsWith(filterPrefix)) + .map(e => prefix + e.name + '/'); + } + // 'file' — show files + dirs (dirs with trailing /) + return entries + .filter(e => e.name.startsWith(filterPrefix)) + .map(e => e.type === 'dir' ? prefix + e.name + '/' : prefix + e.name); + } + + // ── Enum autocomplete from commandsMeta ── + + _tryEnumAutocomplete(cmdName) { + this.suggestions = []; + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + const meta = mod.commandsMeta && mod.commandsMeta[cmdName]; + if (meta && meta.argType === 'enum' && meta.enumFn && typeof mod[meta.enumFn] === 'function') { + const values = mod[meta.enumFn](); + if (Array.isArray(values) && values.length > 0) { + this.suggestions.push(...values); + return true; + } + } + } + return false; + } + + showAutocomplete() { + if (!this.moduleManager || !this.security) return; + const input = this.inputField.value; + // Always clear dropdown innerHTML at the start — prevents stale state from prior input + this.dropdown.innerHTML = ''; + this.suggestions = []; + this.selectedSuggestion = -1; + this._isCdAutocomplete = false; + const trimmed = input.trim(); + const commands = this.moduleManager.getAllCommands() || {}; + // Merge built-in internals into autocomplete (they're handled via if-chain, not module commands) + for (const c of this.security.commandMeta.keys()) { + if (!(c in commands)) commands[c] = 'builtin'; + } + if (input.trim().length === 0) { + this.hideAutocomplete(); + return; + } + + const parts = input.split(' '); + const cmdPart = parts[0].toLowerCase(); + const lowerInput = input.toLowerCase(); + + // Expanded command lists for autocomplete (fallback when commandsMeta lacks argType) + // NOTE: Commands with argType:'enum' in their commandsMeta are handled by _tryEnumAutocomplete + // and should NOT be in these hardcoded lists (the enum check is called BEFORE these). + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'trash', 'ls-trash', 'upload', 'vfs-export', 'vfs-import', 'files', 'preview', 'skill-view', 'skill-create', 'skill-patch', 'skill-delete', 'skill-sync', 'webdav-ls', 'webdav-push', 'webdav-pull', 'webdav-rm', 'webdav-mkdir', 'webdav-rename', 'webdav-move', 'webdav-config', 'nvfs-index', 'nvfs-resolve', 'nvfs-tree']; + const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir', 'upload', 'webdav-ls', 'webdav-mkdir', 'vfs-export', 'vfs-import', 'skill-create']; + const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import']; + const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview']; + + // Collect commands that take arguments from ext module metadata + const extFileCmds = new Set(); + const extDirCmds = new Set(); + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + if (mod.commandsMeta) { + for (const [c, meta] of Object.entries(mod.commandsMeta)) { + if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c); + if (meta.argType === 'dir') extDirCmds.add(c); + } + } + } + + // If we have more than one word, we're past the command name — autocomplete file/dir/session names + if (parts.length > 1 && parts[1].length > 0) { + const partial = parts.slice(1).join(' '); + const partialTrimmed = partial.trim(); + + if (partialTrimmed === '..') { + this.hideAutocomplete(); + return; + } + + if (this._tryEnumAutocomplete(cmdPart)) { + this.suggestions = this.suggestions.filter(s => s.startsWith(partialTrimmed)); + } else if (sessionArgCommands.includes(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'session'); + } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir'); + this._isCdAutocomplete = true; + } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions(partialTrimmed, 'file'); + } else { + // Fallback: show files anyway + this.suggestions = this._getPathSuggestions(partialTrimmed, 'file'); + } + + this._autocompletePrefix = cmdPart + ' '; + if (this.suggestions.length > 0) { + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Just typed a space after command — show all matching entries + if (parts.length > 1) { + if (this._tryEnumAutocomplete(cmdPart)) { + // suggestions already populated + } else if (sessionArgCommands.includes(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'session'); + } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'dir'); + this._isCdAutocomplete = true; + } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { + this.suggestions = this._getPathSuggestions('', 'file'); + } else { + // Fallback: show files + this.suggestions = this._getPathSuggestions('', 'file'); + } + this._autocompletePrefix = cmdPart + ' '; + if (this.suggestions.length > 0) { + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Directory autocomplete for bare 'cd ' (no partial yet) + if (lowerInput.startsWith('cd ')) { + this.suggestions = this._getPathSuggestions('', 'dir'); + if (this.suggestions.length > 0) { + this._isCdAutocomplete = true; + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + return; + } + + // Command name autocomplete (first word only) — also clears any stale prefix + this._autocompletePrefix = ''; + for (const cmd in commands) { + if (cmd.startsWith(cmdPart) && cmd !== cmdPart) { + this.suggestions.push(cmd); + } + } + + if (this.suggestions.length > 0) { + this.renderAutocomplete(); + } else { + this.hideAutocomplete(); + } + } + renderAutocomplete() { + this.dropdown.innerHTML = ''; + this.suggestions.forEach((item, index) => { + const div = document.createElement('div'); + div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`; + div.innerHTML = this._isCdAutocomplete + ? `${item}` + : `${item}`; + div.onclick = () => { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + item; + } else if (this._isCdAutocomplete) { + this.inputField.value = 'cd ' + item; + } else { + this.inputField.value = item + ' '; + } + this.hideAutocomplete(); + this.inputField.focus(); + }; + this.dropdown.appendChild(div); + }); + this.dropdown.style.display = 'block'; + } + updateAutocompleteDisplay() { + const items = this.dropdown.querySelectorAll('.autocomplete-item'); + items.forEach((item, index) => { + item.classList.toggle('selected', index === this.selectedSuggestion); + }); + if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { + if (this._autocompletePrefix) { + this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; + } else if (this._isCdAutocomplete) { + this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion]; + } else { + this.inputField.value = this.suggestions[this.selectedSuggestion] + ' '; + } + } + } + hideAutocomplete() { + this.dropdown.style.display = 'none'; + this.suggestions = []; + this.selectedSuggestion = -1; + this._isCdAutocomplete = false; + this._autocompletePrefix = ''; + } + loadBuiltInModules() { + this._safeRegister('SystemModule', 'system'); + this._safeRegister('FileSystemModule', 'filesystem'); + this._safeRegister('ModuleManagerModule', 'module manager'); + this._safeRegister('OllamaModule', 'ollama'); + this._safeRegister('SearXNGModule', 'searxng search'); + this._safeRegister('ConfigManagerModule', 'config'); + this._safeRegister('WebX11AIAssistantModule', 'AI assistant'); + this._safeRegister('WebFetcherModule', 'web fetcher'); + this._safeRegister('WebXAIAliasModule', 'AI alias'); + this._safeRegister('SecretVault', 'secret vault'); + this.reloadPersistedModules(); + this._safeRegister('ChatStudioModule', 'chat studio'); + this._safeRegister('AgentRegistryModule', 'agent registry'); + this._safeRegister('LeaseModule', 'lease system'); + this._safeRegister('OrchestratorModule', 'orchestrator'); + this._safeRegister('NeuralVFSModule', 'neural VFS'); + this._safeRegister('SandboxModule', 'sandbox'); + this._safeRegister('MemoryGraphModule', 'memory graph'); + this._safeRegister('PredictorModule', 'predictor'); + this._safeRegister('WorldModelModule', 'world model'); + this._safeRegister('ServerMonitorModule', 'server monitor'); + this._safeRegister('SimpleGUIModule', 'simple GUI builder'); + // v4.5.47: Critical ext modules promoted to boot path + this._safeRegister('EditorModule', 'editor'); + this._safeRegister('ShellModule', 'shell'); + this._safeRegister('AliasModule', 'aliases'); + this._safeRegister('FinderModule', 'finder'); + this._safeRegister('FileManagerModule', 'file manager'); + this._safeRegister('DiffModule', 'diff'); + this._safeRegister('BusyBoxModule', 'busybox'); + } + _initRegisteredModules() { + for (const [name, mod] of Object.entries(this.moduleManager.modules)) { + if (typeof mod.init === 'function') { + try { mod.init(this); } catch (e) { + console.warn(`[Init] ${name} failed: ${e.message}`); + } + } + } + } + _safeRegister(className, label) { + let cls; + try { + cls = eval(className); + } catch (e) { + console.log(`[SafeRegister] Skipped ${label}: class not loaded (skipped in boot selection)`); + return; + } + if (typeof cls === 'undefined') { + console.log(`[SafeRegister] Skipped ${label}: class not defined`); + return; + } + try { this.moduleManager.registerModule(new cls()); } + catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } + } + // ── Config Registry ── + registerConfigItem(key, defaultValue, setterFn) { + this._configRegistry[key] = { key, defaultValue }; + this._configDefaults[key] = defaultValue; + this._configSetters[key] = setterFn; + // Apply persisted override if present + const saved = localStorage.getItem('webx11_terminal_config'); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed[key] !== undefined && parsed[key] !== defaultValue) { + setterFn(parsed[key]); + } + } catch (e) {} + } + } + _loadTerminalConfig() { + try { + const saved = localStorage.getItem('webx11_terminal_config'); + if (!saved) return; + const parsed = JSON.parse(saved); + // Apply registered config items from persistence + for (const [key, val] of Object.entries(parsed)) { + if (this._configSetters[key] && val !== this._configDefaults[key]) { + this._configSetters[key](val); + } + } + } catch (e) {} + } + _saveTerminalConfig() { + const values = {}; + for (const key of Object.keys(this._configRegistry)) { + const actual = this[key] !== undefined ? this[key] : this._configDefaults[key]; + if (actual !== this._configDefaults[key]) { + values[key] = actual; + } + } + localStorage.setItem('webx11_terminal_config', JSON.stringify(values)); + } + _applyTerminalConfig() { + try { + const saved = localStorage.getItem('webx11_terminal_config'); + if (!saved) return; + const parsed = JSON.parse(saved); + if (parsed['terminal-max-lines'] !== undefined) this.MAX_OUTPUT_LINES = parsed['terminal-max-lines']; + if (parsed['terminal-hist-output'] !== undefined) this._historyMaxLines = parsed['terminal-hist-output']; + if (parsed['terminal-cmd-history'] !== undefined) this._historyCap = parsed['terminal-cmd-history']; + if (parsed['terminal-pipe-buffer'] !== undefined) this._pipeBufferSize = parsed['terminal-pipe-buffer']; + } catch (e) {} + } + async _loadExtModules(filterKeys) { + const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'cryptogzip', 'tutorial', 'floating', 'console']; + const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'; + let index = null; + let lastErr = null; + // v4.5.47: retry packages.json fetch up to 3 times + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const res = await fetch('packages.json?v=' + cacheVer); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + index = await res.json(); + lastErr = null; + break; + } catch (err) { + lastErr = err; + if (attempt < 3) await new Promise(r => setTimeout(r, 300 * attempt)); + } + } + + if (!index) { + this.print(`Failed to auto-load ext modules: ${lastErr ? lastErr.message : 'unknown error'}`, 'error'); + return; + } + + for (const key of autoLoadKeys) { + const pkg = index.packages[key]; + if (pkg) { + const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer; + await this.moduleManager.loadModuleFromURL(url); + } + } + } + async reloadPersistedModules() { + const registry = this.moduleManager.moduleRegistry; + const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell', 'tutorial', 'busybox', 'floating', 'console']; + for (const mod of registry) { + if (mod.enabled && mod.url) { + // Skip ext modules — they load via _loadExtModules + const name = (mod.url.match(/(\w+)\.js/) || [])[1]; + if (name && skipList.includes(name)) continue; + await this.moduleManager.loadModuleFromURL(mod.url); + } + } + } + async executeCommand(rawInput) { + if (!rawInput.trim()) return; + this.markDirty(); + // Push to command history (dedup + trim) + const cmd = rawInput.trim(); + if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) { + this.history.push(cmd); + } + if (this.history.length > this._historyCap) { + this.history = this.history.slice(-this._historyCap); + } + this.historyIndex = this.history.length; + this._saveCommandHistory(); + this.printUserPrompt(rawInput); + const pipeParts = this.splitPipe(rawInput.trim()); + if (pipeParts.length > 1) { + await this.executePipeChain(pipeParts); + return; + } + // Security gate for all single commands and internal builtins + await this.security.checkAndRun(rawInput); + } + splitPipe(input) { + const parts = []; + let current = ''; + let inQuote = false; + for (const ch of input) { + if (ch === '"' || ch === "'") { inQuote = !inQuote; current += ch; } + else if (ch === '|' && !inQuote) { parts.push(current.trim()); current = ''; } + else { current += ch; } + } + parts.push(current.trim()); + return parts; + } + async executePipeChain(pipeParts) { + let pipeInput = null; + for (let i = 0; i < pipeParts.length; i++) { + const cmdText = pipeParts[i]; + const parts = this.security._parseArgs(cmdText); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + this._pipeInput = pipeInput; + if (i < pipeParts.length - 1) { + pipeInput = await this.capturePipeOutput(cmd, args); + } else { + await this.dispatchCommand(cmd, args); + } + } + this._pipeInput = null; + } + async capturePipeOutput(cmd, args) { + const originalPrint = this.print.bind(this); + let output = ''; + this.print = (text, type) => { output += text + '\n'; }; + await this.dispatchCommand(cmd, args); + this.print = originalPrint; + const trimmed = output.trim(); + if (trimmed.length > this._pipeBufferSize) { + return trimmed.slice(0, this._pipeBufferSize); + } + return trimmed; + } + async dispatchCommand(cmd, args) { + if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } + if (cmd === 'startup-modules') { this.openStartupModules(); return; } + if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; } + if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; } + if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; } + if (cmd === 'mem') { this.printMemoryStats(); return; } + if (cmd === 'config') { + if (args.length === 0) { + this.print('=== Terminal Config ===', 'info'); + this.print(`terminal-max-lines: ${this.MAX_OUTPUT_LINES} (default: 200)`, ''); + this.print(`terminal-hist-output: ${this._historyMaxLines} (default: 50)`, ''); + this.print(`terminal-cmd-history: ${this._historyCap} (default: 200)`, ''); + this.print(`terminal-pipe-buffer: ${this._pipeBufferSize} (default: 5000)`, ''); + this.print('Usage: config ', 'info'); + this.print('Keys: terminal-max-lines, terminal-hist-output, terminal-cmd-history, terminal-pipe-buffer', 'info'); + return; + } + const key = args[0]; + const val = args[1]; + if (val === undefined) { this.print('Usage: config ', 'warning'); return; } + const n = parseInt(val); + if (isNaN(n) || n < 1) { this.print('Value must be a positive number', 'warning'); return; } + if (key === 'terminal-max-lines') { this.MAX_OUTPUT_LINES = n; } + else if (key === 'terminal-hist-output') { this._historyMaxLines = n; } + else if (key === 'terminal-cmd-history') { this._historyCap = n; } + else if (key === 'terminal-pipe-buffer') { this._pipeBufferSize = n; } + else { this.print(`Unknown key: ${key}. Valid: terminal-max-lines, terminal-hist-output, terminal-cmd-history, terminal-pipe-buffer`, 'warning'); return; } + this._saveTerminalConfig(); + this.print(`config ${key} set to ${val}`, 'success'); + return; + } + if (cmd === 'security') { + this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info'); + this.print(`Blocked commands: ${this.security.blockedCount}`, 'info'); + this.print(`Destructive set: ${[...this.security.destructiveCmds].sort().join(', ')}`, ''); + this.print(`System set: ${[...this.security.systemCmds].sort().join(', ')}`, ''); + this.print(`Suspicious flags: ${[...this.security.suspiciousFlags].sort().join(', ')}`, ''); + this.print(`Whitelist: ${[...this.security.whitelist].sort().join(', ')}`, ''); + const all = this.security._collectAllCommands(); + this.print(`Registered commands: ${all.length}`, ''); + return; + } + if (cmd === 'security-level') { + const lv = parseInt(args[0]); + if (isNaN(lv)) { this.print('Usage: security-level 0|1|2|3', 'warning'); return; } + this.security.setLevel(lv); + localStorage.setItem('webx11_security_level', lv); + return; + } + if (cmd === 'security-destructive') { + const action = args[0]; + if (!action) { this.print('Usage: security-destructive add|remove|list [cmd]', 'warning'); return; } + if (action === 'list') { + this.print('Destructive commands:', 'info'); + for (const c of this.security.destructiveCmds) this.print(` ${c}`, ''); + return; + } + if (!args[1]) { this.print('Usage: security-destructive add|remove ', 'warning'); return; } + if (action === 'add') { this.security.destructiveCmds.add(args[1]); } + else if (action === 'remove') { this.security.destructiveCmds.delete(args[1]); } + else { this.print('Action must be: add, remove, or list', 'warning'); return; } + localStorage.setItem('webx11_security_destructive', JSON.stringify([...this.security.destructiveCmds])); + this.print(`Updated destructive set (${this.security.destructiveCmds.size} commands)`, 'success'); + return; + } + if (cmd === 'security-system') { + const action = args[0]; + if (!action) { this.print('Usage: security-system add|remove|list [cmd]', 'warning'); return; } + if (action === 'list') { + this.print('System commands:', 'info'); + for (const c of this.security.systemCmds) this.print(` ${c}`, ''); + return; + } + if (!args[1]) { this.print('Usage: security-system add|remove ', 'warning'); return; } + if (action === 'add') { this.security.systemCmds.add(args[1]); } + else if (action === 'remove') { this.security.systemCmds.delete(args[1]); } + else { this.print('Action must be: add, remove, or list', 'warning'); return; } + localStorage.setItem('webx11_security_system', JSON.stringify([...this.security.systemCmds])); + this.print(`Updated system set (${this.security.systemCmds.size} commands)`, 'success'); + return; + } + if (cmd === 'security-whitelist') { + const action = args[0]; + if (!action) { this.print('Usage: security-whitelist add|remove|list [cmd]', 'warning'); return; } + if (action === 'list') { + this.print('Whitelist commands:', 'info'); + for (const c of this.security.whitelist) this.print(` ${c}`, ''); + return; + } + if (!args[1]) { this.print('Usage: security-whitelist add|remove ', 'warning'); return; } + if (action === 'add') { this.security.whitelist.add(args[1]); } + else if (action === 'remove') { this.security.whitelist.delete(args[1]); } + else { this.print('Action must be: add, remove, or list', 'warning'); return; } + localStorage.setItem('webx11_security_whitelist', JSON.stringify([...this.security.whitelist])); + this.print(`Updated whitelist (${this.security.whitelist.size} commands)`, 'success'); + return; + } + if (cmd === 'security-flag') { + const action = args[0]; + if (!action) { this.print('Usage: security-flag add|remove|list [flag]', 'warning'); return; } + if (action === 'list') { + this.print('Suspicious flags:', 'info'); + for (const f of this.security.suspiciousFlags) this.print(` ${f}`, ''); + return; + } + if (!args[1]) { this.print('Usage: security-flag add|remove ', 'warning'); return; } + const flag = args[1].startsWith('--') ? args[1] : '--' + args[1]; + if (action === 'add') { this.security.suspiciousFlags.add(flag); } + else if (action === 'remove') { this.security.suspiciousFlags.delete(flag); } + else { this.print('Action must be: add, remove, or list', 'warning'); return; } + localStorage.setItem('webx11_security_flags', JSON.stringify([...this.security.suspiciousFlags])); + this.print(`Updated suspicious flags (${this.security.suspiciousFlags.size} flags)`, 'success'); + return; + } + if (cmd === 'cmd-allow') { this._resolveCmdConfirm(true); return; } + if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; } + if (cmd === 'vfs' && args[0] === 'reset') { + if (args[1] !== '--force') { + this.print('⚠ This will permanently delete all files! Use vfs reset --force to confirm.', 'warning'); + return; + } + this.fs.reset(); + this.print('VFS reset — all files deleted.', 'success'); + return; + } + if (cmd === 'vfs' && args[0] === 'clean') { + let count = 0; + const sysDirs = ['_snapshots', '_sessions', '.trash']; + for (const dir of sysDirs) { + if (this.fs.root.content && this.fs.root.content[dir]) { + delete this.fs.root.content[dir]; + count++; + } + } + if (count > 0) { + this.fs.saveToStorage(); + this.print('🧹 Cleaned ' + count + ' system dirs: ' + sysDirs.join(', '), 'success'); + } else { + this.print('Nothing to clean — system dirs already empty.', 'info'); + } + return; + } + if (cmd === 'vfs-clean') { + let count = 0; + const sysDirs = ['_snapshots', '_sessions', '.trash']; + for (const dir of sysDirs) { + if (this.fs.root.content && this.fs.root.content[dir]) { + delete this.fs.root.content[dir]; + count++; + } + } + if (count > 0) { + this.fs.saveToStorage(); + this.print('🧹 Cleaned ' + count + ' system dirs: ' + sysDirs.join(', '), 'success'); + } else { + this.print('Nothing to clean — system dirs already empty.', 'info'); + } + return; + } + if (cmd === 'empty-trash') { + if (args[0] !== '--force') { + this.print('⚠ This will permanently delete all trashed files! Use empty-trash --force to confirm.', 'warning'); + return; + } + if (this.fs.emptyTrash()) { + this.print('Trash emptied.', 'success'); + } else { + this.print('Trash is already empty.', 'info'); + } + return; + } + if (cmd === 'history') { + const search = args.join(' ').toLowerCase(); + const entries = this.history; + if (!entries.length) { this.print('No command history.', 'info'); return; } + if (search) { + const filtered = entries.map((c, i) => ({ i, c })).filter(e => e.c.toLowerCase().includes(search)); + if (!filtered.length) { this.print(`No history entries matching: ${search}`, 'info'); return; } + this.print(`=== History (matching: ${search}) ===`, 'info'); + filtered.forEach(e => this.print(` ${e.i + 1} ${e.c}`)); + } else { + this.print(`=== Command History (${entries.length} entries) ===`, 'info'); + const start = Math.max(0, entries.length - 50); + for (let i = start; i < entries.length; i++) { + this.print(` ${i + 1} ${entries[i]}`); + } + if (entries.length > 50) this.print(`... (${entries.length - 50} older entries hidden, use history to find)`); + } + return; + } + for (const modName in this.moduleManager.modules) { + const mod = this.moduleManager.modules[modName]; + if (mod.commands && mod.commands[cmd]) { + try { await mod.commands[cmd](args); + // Notify tutorial module for quest tracking + if (this.tutorialModule && cmd !== 'tutorial') this.tutorialModule.onCommandExecuted(cmd, args); + // Memory graph: record file access for read/file commands + if (cmd === 'cat' || cmd === 'view_module' || cmd === 'edit_module') { + try { + const _mg = this.moduleManager.modules['MemoryGraphModule']; + if (_mg && args[0]) _mg.recordFileAccess(args[0]); + } catch (_e) {} + } + return; } + catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } + } + } + // Enhanced: check fuzzy suggestions before showing "not found" + const suggestion = this.security.suggest(cmd); + if (suggestion && suggestion.dist <= 2 && suggestion.dist > 0) { + this.print(`Command not found: ${cmd}`, 'error'); + this.print(`Did you mean: ${suggestion.cmd}? (yes) (no)`, 'info'); + // Render clickable yes/no + const out = this.outputContainer; + if (out) { + const yesSpan = document.createElement('span'); + yesSpan.textContent = ' [yes] '; + yesSpan.style.cssText = 'color:var(--success-color);cursor:pointer;text-decoration:underline;margin-right:12px'; + yesSpan.onclick = () => this.executeCommand(suggestion.cmd + (args.length ? ' ' + args.join(' ') : '')); + const noSpan = document.createElement('span'); + noSpan.textContent = ' [no] '; + noSpan.style.cssText = 'color:var(--error-color);cursor:pointer;text-decoration:underline'; + noSpan.onclick = () => { this.print('Dismissed.', 'text-muted'); }; + const line = out.lastElementChild; + if (line) { line.appendChild(yesSpan); line.appendChild(noSpan); } + } + return; + } + this.print(`Command not found: ${cmd}`, 'error'); + } + openFileBrowser() { + const modal = document.getElementById('file-browser'); + const content = document.getElementById('fb-content'); + content.innerHTML = ''; + const files = this.fs.ls(); + if (files.length === 0) { + content.innerHTML = '
Directory Empty
'; + } else { + files.forEach(f => { + const div = document.createElement('div'); + div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`; + div.innerHTML = `${f.type === 'dir' ? '📁' : '📄'} ${f.name} ${f.size}b`; + div.onclick = () => { + if (f.type === 'file') { + const content = this.fs.readFile(f.name); + this.print(`--- ${f.name} ---`); + this.print(content); + this.print('--- End ---'); + this.closeFileBrowser(); + } + }; + content.appendChild(div); + }); + } + modal.style.display = 'flex'; + } + closeFileBrowser() { + document.getElementById('file-browser').style.display = 'none'; + this.inputField.focus(); + } + openModuleManager() { + const modal = document.getElementById('module-manager'); + this.renderModuleList(); + modal.style.display = 'flex'; + } + closeModuleManager() { + document.getElementById('module-manager').style.display = 'none'; + this.inputField.focus(); + } + renderModuleList() { + const list = document.getElementById('module-list'); + list.innerHTML = ''; + const modules = this.moduleManager.getModuleList(); + if (modules.length === 0) { + list.innerHTML = '
No modules loaded
'; + return; + } + modules.forEach(mod => { + const div = document.createElement('div'); + div.className = `module-item ${mod.loaded ? '' : 'disabled'}`; + const badge = mod.url ? '' : 'Built-in'; + div.innerHTML = ` +
+ ${mod.name}${badge} +
${mod.url || 'Built-in'}
+
+ ${mod.url ? `` : ''} + `; + list.appendChild(div); + }); + } + async loadModuleFromURL() { + const url = document.getElementById('module-url-input').value.trim(); + if (!url) { alert('Please enter a URL'); return; } + const success = await this.moduleManager.loadModuleFromURL(url); + if (success) { + document.getElementById('module-url-input').value = ''; + this.renderModuleList(); + } + } + removeModule(name) { + if (this.moduleManager.unregisterModule(name)) { + this.print(`Module '${name}' removed`, 'success'); + this.renderModuleList(); + } + } + clearAllModules() { + if (confirm('Remove all custom modules? Built-in modules will remain.')) { + this.moduleManager.clearAll(); + this.renderModuleList(); + this.print('All custom modules cleared', 'success'); + } + } + openOllamaManager() { + const modal = document.getElementById('ollama-manager'); + document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint; + this.updateOllamaStatus(); + this.renderOllamaModels(); + this.initOllamaUI(); + modal.style.display = 'flex'; + } + closeOllamaManager() { + document.getElementById('ollama-manager').style.display = 'none'; + this.inputField.focus(); + } + openPreview(args) { + let path = ''; + if (typeof args === 'string') path = args.trim(); + else if (Array.isArray(args)) path = args.join(' ').trim(); + if (!path) { + this.print('Usage: preview ', 'warning'); + return; + } + // Remove leading slash for VFS paths + const vfsPath = path.replace(/^\/*/, ''); + let content = this.fs.readFile(vfsPath); + if (content === null) content = this.fs.readFile('/' + vfsPath); + if (content === null) { + this.print(`File not found: ${path}`, 'error'); + return; + } + // Use srcdoc instead of blob URL — works reliably on mobile browsers + const iframe = document.getElementById('preview-iframe'); + iframe.srcdoc = content; + document.getElementById('preview-title').textContent = `📄 ${path}`; + document.getElementById('preview-modal').style.display = 'flex'; + } + closePreview() { + document.getElementById('preview-modal').style.display = 'none'; + const iframe = document.getElementById('preview-iframe'); + iframe.srcdoc = ''; + iframe.src = 'about:blank'; + this.inputField.focus(); + } + openRenderPreview(btn) { + const content = btn.getAttribute('data-content'); + if (!content) return; + const iframe = document.getElementById('preview-iframe'); + // The srcdoc content is already HTML-escaped from _renderHTMLPreview + const decoded = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/`/g, '`'); + iframe.srcdoc = decoded; + document.getElementById('preview-title').textContent = '📄 AI Rendered Preview (Fullscreen)'; + document.getElementById('preview-modal').style.display = 'flex'; + } + previewHtmlFromPrompt(btn) { + const content = btn.getAttribute('data-content'); + if (!content) return; + const iframe = document.getElementById('preview-iframe'); + // Content is HTML-escaped, decode it back + const decoded = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/`/g, '`'); + // Wrap in a basic HTML template if it doesn't have html/head/body tags + const hasOwnDoc = /${decoded}`; + iframe.srcdoc = fullDoc; + document.getElementById('preview-title').textContent = '📄 User HTML Preview'; + document.getElementById('preview-modal').style.display = 'flex'; + } + openSkillsManager() { + const modal = document.getElementById('skills-manager'); + // Ensure /skills/ directory exists + this.fs.mkdirRecursive('/skills'); + this.refreshSkillsList(); + modal.style.display = 'flex'; + } + closeSkillsManager() { + document.getElementById('skills-manager').style.display = 'none'; + this.inputField.focus(); + } + openConfigManager() { + document.getElementById('config-manager').style.display = 'flex'; + this.renderConfigList(); + } + closeConfigManager() { + document.getElementById('config-manager').style.display = 'none'; + this.inputField.focus(); + } + renderConfigList() { + const list = document.getElementById('config-list'); + if (!list) return; + const prefix = 'webx11_config_'; + const entries = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) }); + } + } + if (entries.length === 0) { + list.innerHTML = '
No configuration saved.
'; + return; + } + list.innerHTML = entries.map(e => { + const displayVal = e.key.toLowerCase().includes('password') || e.key.toLowerCase().includes('token') || e.key.toLowerCase().includes('secret') + ? '****' : e.value; + return `
+
${e.key} ${displayVal}
+ +
`; + }).join(''); + } + addConfigFromUI() { + const key = document.getElementById('config-key-input').value.trim(); + const value = document.getElementById('config-value-input').value.trim(); + if (!key || !value) { + this.print('Enter both key and value', 'warning'); + return; + } + localStorage.setItem('webx11_config_' + key, value); + document.getElementById('config-key-input').value = ''; + document.getElementById('config-value-input').value = ''; + this.print(`Config saved: ${key} = ${value}`, 'success'); + this.renderConfigList(); + } + deleteConfigFromUI(key) { + localStorage.removeItem('webx11_config_' + key); + this.print(`Config '${key}' deleted`, 'success'); + this.renderConfigList(); + } + exportConfigFromUI() { + const mod = this.moduleManager.modules['ConfigManagerModule']; + if (mod && mod.commands && mod.commands['config-export']) { + mod.commands['config-export'](); + } + } + resetConfigFromUI() { + const mod = this.moduleManager.modules['ConfigManagerModule']; + if (mod && mod.commands && mod.commands['config-reset']) { + mod.commands['config-reset'](); + this.renderConfigList(); + } + } + refreshSkillsList() { + const list = document.getElementById('skills-list'); + list.innerHTML = ''; + const fs = this.fs; + fs.mkdirRecursive('/skills'); + const index = fs.fsReadJSON('/skills/index.json') || {}; + const names = Object.keys(index); + if (names.length === 0) { + list.innerHTML = '
No skills yet. Create one below!
'; + return; + } + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + const div = document.createElement('div'); + div.className = 'module-item'; + div.innerHTML = ` +
+ ${name} +
${s.description || ''}
+
${s.category || 'general'}${tags ? ' · ' + tags : ''}
+
+
+ + +
+ `; + list.appendChild(div); + }); + } + viewSkillUI(name) { + const fs = this.fs; + const content = fs.readFile(`/skills/${name}.md`); + if (!content) { this.print(`Skill '${name}' not found`, 'error'); return; } + document.getElementById('skill-preview-title').textContent = name; + document.getElementById('skill-preview-content').textContent = content; + document.getElementById('skill-preview').style.display = 'flex'; + } + closeSkillPreview() { + document.getElementById('skill-preview').style.display = 'none'; + } + deleteSkillUI(name) { + if (!confirm(`Delete skill "${name}"?`)) return; + const fs = this.fs; + fs.mkdirRecursive('/skills'); + // Actually remove from VFS tree + if (fs.root && fs.root.content && fs.root.content['skills'] && fs.root.content['skills'].content) { + delete fs.root.content['skills'].content[`${name}.md`]; + } + const index = fs.fsReadJSON('/skills/index.json') || {}; + delete index[name]; + fs.fsWriteJSON('/skills/index.json', index); + this.print(`Skill '${name}' deleted`, 'success'); + this.refreshSkillsList(); + fs.saveToStorage(); + } + createSkillFromUI() { + const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase(); + const description = document.getElementById('skill-desc-input').value.trim() || 'No description'; + const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean); + const contentInput = document.getElementById('skill-content-input').value.trim(); + if (!name) { alert('Skill name is required'); return; } + const fs = this.fs; + fs.mkdirRecursive('/skills'); + if (fs.readFile(`/skills/${name}.md`)) { + alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`); + return; + } + let content = contentInput || `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n## Steps\n1. (to be filled)\n`; + if (!content.startsWith('---')) { + content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n${content}`; + } + fs.writeFile(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, category: 'general', tags }; + fs.fsWriteJSON('/skills/index.json', index); + this.print(`Skill '${name}' created`, 'success'); + document.getElementById('skill-name-input').value = ''; + document.getElementById('skill-desc-input').value = ''; + document.getElementById('skill-tags-input').value = ''; + document.getElementById('skill-content-input').value = ''; + this.refreshSkillsList(); + } + updateOllamaStatus() { + const statusDiv = document.getElementById('ollama-status'); + const statusText = document.getElementById('ollama-status-text'); + if (this.ollamaManager.connected) { + statusDiv.className = 'ollama-status connected'; + statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`; + } else { + statusDiv.className = 'ollama-status disconnected'; + statusText.textContent = 'Disconnected'; + } + } + renderOllamaModels() { + const container = document.getElementById('ollama-models'); + const select = document.getElementById('ollama-current-model'); + const searchInput = document.getElementById('ollama-search'); + const countDiv = document.getElementById('ollama-model-count'); + const query = (searchInput ? searchInput.value : '').toLowerCase().trim(); + container.innerHTML = ''; + select.innerHTML = ''; + const models = this.ollamaManager.getModels(); + if (models.length === 0) { + container.innerHTML = '
Connect to see models
'; + if (countDiv) countDiv.textContent = ''; + return; + } + const filtered = query ? models.filter(m => m.name.toLowerCase().includes(query)) : models; + if (countDiv) countDiv.textContent = filtered.length === models.length + ? models.length + ' model' + (models.length !== 1 ? 's' : '') + : filtered.length + ' / ' + models.length + ' model' + (models.length !== 1 ? 's' : ''); + if (filtered.length === 0) { + container.innerHTML = '
No models match "' + query + '"
'; + return; + } + filtered.forEach(model => { + const div = document.createElement('div'); + div.className = 'model-item'; + div.innerHTML = `${model.name}${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}`; + div.onclick = () => { + this.ollamaManager.setModel(model.name); + select.value = model.name; + }; + container.appendChild(div); + const option = document.createElement('option'); + option.value = model.name; + option.textContent = model.name; + if (model.name === this.ollamaManager.currentModel) { option.selected = true; } + select.appendChild(option); + }); + } + async connectOllama() { + const endpoint = document.getElementById('ollama-endpoint').value.trim(); + this.ollamaManager.setEndpoint(endpoint); + const success = await this.ollamaManager.connect(); + if (success) { this.renderOllamaModels(); this._populateValidatorModels(); this.updateOllamaStatus(); } + } + setOllamaModel() { + const select = document.getElementById('ollama-current-model'); + this.ollamaManager.setModel(select.value); + } + toggleOptimization() { + const cb = document.getElementById('ollama-optimization-toggle'); + const label = document.getElementById('ollama-optimization-label'); + const slider = document.getElementById('ollama-optimization-slider'); + const enabled = cb.checked; + this.ollamaManager.setOptimizationEnabled(enabled); + if (label) { + label.textContent = enabled ? 'Enabled' : 'Disabled'; + label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (slider) { + slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + this.ollamaManager.saveSettings(); + } + toggleValidator() { + const cb = document.getElementById('ollama-validator-toggle'); + const label = document.getElementById('ollama-validator-label'); + const slider = document.getElementById('ollama-validator-slider'); + const enabled = cb.checked; + this.ollamaManager.validatorEnabled = enabled; + if (label) { + label.textContent = enabled ? 'Enabled' : 'Disabled'; + label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (slider) { + slider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)'; + slider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; + } + this.ollamaManager.saveSettings(); + } + toggleStream() { + const cb = document.getElementById('ollama-stream-toggle'); + const label = document.getElementById('ollama-stream-label'); + const slider = document.getElementById('ollama-stream-slider'); + const enabled = cb.checked; + this.ollamaManager.setStreamEnabled(enabled); + if (label) { + label.textContent = enabled ? 'Enabled' : 'Disabled'; + label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (slider) { + slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + } + updateStreamIndicator() { + const el = document.getElementById('stream-status'); + if (!el) return; + const enabled = this.ollamaManager?.streamEnabled !== false; + el.textContent = enabled ? '▶ Stream' : '🔇 No-Stream'; + el.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + saveValidatorConfig() { + const select = document.getElementById('validator-model-select'); + const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; + const model = select ? select.value : ''; + localStorage.setItem('webx11_validator_endpoint', endpoint); + this.ollamaManager.validatorEndpoint = endpoint; + this.ollamaManager.setValidatorConfig(this.ollamaManager.validatorEnabled, model); + this.print('Validator config saved. Model: ' + (model || '(none)') + ' at ' + endpoint, 'success'); + } + async connectValidatorEndpoint() { + const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; + const statusEl = document.getElementById('validator-connection-status'); + const select = document.getElementById('validator-model-select'); + if (statusEl) statusEl.textContent = '⏳ Connecting to ' + endpoint + '...'; + if (select) { select.disabled = true; select.innerHTML = ''; } + try { + const resp = await fetch(endpoint + '/api/tags'); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + const data = await resp.json(); + const models = data.models || []; + if (statusEl) statusEl.textContent = '✅ Connected: ' + models.length + ' model(s)'; + // Populate dropdown + if (select) { + select.innerHTML = ''; + models.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = m.name; + select.appendChild(opt); + }); + select.disabled = false; + } + // Save endpoint + localStorage.setItem('webx11_validator_endpoint', endpoint); + this.ollamaManager.validatorEndpoint = endpoint; + this.ollamaManager.saveSettings(); + } catch (e) { + if (statusEl) { + statusEl.innerHTML = '❌ Connection failed: ' + e.message + + '
Make sure Ollama is running and ' + + 'OLLAMA_ORIGINS includes this page\'s origin.'; + } + if (select) { select.disabled = true; select.innerHTML = ''; } + } + } + initOllamaUI() { + // Initialize optimization toggle from stored state + const optCb = document.getElementById('ollama-optimization-toggle'); + const optLabel = document.getElementById('ollama-optimization-label'); + const optSlider = document.getElementById('ollama-optimization-slider'); + if (optCb && this.ollamaManager) { + const enabled = this.ollamaManager.optimizationEnabled !== false; + optCb.checked = enabled; + if (optLabel) { + optLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + optLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (optSlider) { + optSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + optSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + } + // Initialize validator toggle from stored state + const valCb = document.getElementById('ollama-validator-toggle'); + const valLabel = document.getElementById('ollama-validator-label'); + const valSlider = document.getElementById('ollama-validator-slider'); + if (valCb && this.ollamaManager) { + const enabled = this.ollamaManager.validatorEnabled === true; + valCb.checked = enabled; + if (valLabel) { + valLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + valLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (valSlider) { + valSlider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)'; + valSlider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; + } + } + // Auto-populate validator model dropdown from connected models + this._populateValidatorModels(); + // Initialize stream toggle from stored state + const strCb = document.getElementById('ollama-stream-toggle'); + const strLabel = document.getElementById('ollama-stream-label'); + const strSlider = document.getElementById('ollama-stream-slider'); + if (strCb && this.ollamaManager) { + const enabled = this.ollamaManager.streamEnabled !== false; + strCb.checked = enabled; + if (strLabel) { + strLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + strLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; + } + if (strSlider) { + strSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; + strSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; + } + } + this.updateStreamIndicator(); + // Populate validator model dropdown from stored config + const valSelect = document.getElementById('validator-model-select'); + const valEndpoint = document.getElementById('validator-endpoint-input'); + if (valEndpoint && this.ollamaManager) { + valEndpoint.value = this.ollamaManager.validatorEndpoint || 'http://localhost:11434'; + } + if (valSelect && this.ollamaManager) { + const storedModel = this.ollamaManager.validatorModel || ''; + if (storedModel) { + valSelect.innerHTML = ''; + valSelect.disabled = false; + } + } + } + _populateValidatorModels() { + const select = document.getElementById('validator-model-select'); + if (!select || !this.ollamaManager) return; + const models = this.ollamaManager.getModels(); + if (!models || models.length === 0) { + select.innerHTML = ''; + select.disabled = true; + return; + } + const stored = this.ollamaManager.validatorModel || ''; + let html = ''; + models.forEach(function(m) { + const sel = m.name === stored ? ' selected' : ''; + html += ''; + }); + select.innerHTML = html; + select.disabled = false; + } + openStartupModules() { + document.getElementById('startup-modules').style.display = 'flex'; + this.renderStartupModules(); + } + closeStartupModules() { + document.getElementById('startup-modules').style.display = 'none'; + } + renderStartupModules() { + const body = document.getElementById('startup-modules-body'); + const selected = (() => { + try { + const s = localStorage.getItem('webx11_startup_modules'); + return s ? JSON.parse(s) : null; + } catch (e) { return null; } + })(); + const isSel = (name) => !selected || selected.includes(name); + body.innerHTML = ''; + + const foundation = ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js']; + const builtin = [ + 'module-system.js','module-fs.js','module-ollama.js', + 'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js', + 'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js', + 'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js', + 'module-fetcher.js','module-skills.js','module-chatstudio.js' + ]; + const extFiles = [ + 'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js','cryptogzip.js','tutorial.js','shell.js','busybox.js','floating.js','console.js' + ]; + + const addSection = (title) => { + const h = document.createElement('div'); + h.className = 'mod-section-header'; + h.textContent = title; + body.appendChild(h); + }; + const addCard = (name, badge, isDisabled) => { + const card = document.createElement('div'); + card.className = 'mod-card' + (isDisabled ? ' disabled' : ''); + const label = document.createElement('label'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = isDisabled || isSel(name); + cb.disabled = isDisabled; + cb.dataset.name = name; + const nameSpan = document.createElement('span'); + nameSpan.className = 'mod-name'; + nameSpan.textContent = name; + const badgeSpan = document.createElement('span'); + badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : ''); + badgeSpan.textContent = badge; + label.appendChild(cb); + label.appendChild(nameSpan); + card.appendChild(label); + card.appendChild(badgeSpan); + body.appendChild(card); + }; + + addSection('Foundation (always loaded)'); + foundation.forEach(n => addCard(n, 'foundation', true)); + + addSection('Built-in modules'); + builtin.forEach(n => addCard(n, 'built-in', false)); + + addSection('Extensions'); + extFiles.forEach(n => addCard(n, 'ext', false)); + } + saveStartupModules() { + const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)'); + const selected = []; + cbs.forEach(cb => { + if (cb.checked) selected.push(cb.dataset.name); + }); + try { + localStorage.setItem('webx11_startup_modules', JSON.stringify(selected)); + } catch (e) { + this.print('Failed to save startup modules', 'error'); + return; + } + this.print('Startup modules saved, reloading...', 'success'); + setTimeout(() => location.reload(), 500); + } + openCodeEditor(name, url, code) { + const modal = document.getElementById('code-editor'); + const title = document.getElementById('code-editor-title'); + const info = document.getElementById('code-editor-info'); + const ta = document.getElementById('code-editor-textarea'); + if (!modal || !ta) return; + this._editingModule = { name: name, url: url }; + title.textContent = '📝 Edit: ' + name; + info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : ''); + ta.value = code; + modal.style.display = 'flex'; + } + closeCodeEditor() { + document.getElementById('code-editor').style.display = 'none'; + this._editingModule = null; + } + async loadLocalModule(path) { + if (!path) { + this.print('Usage: load-local ', 'warning'); + this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info'); + return; + } + let code = this.fs.readFile(path); + if (code === null) { + try { + const resp = await fetch(path); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + code = await resp.text(); + } catch (e) { + this.print('load-local: file not found in VFS or at URL: ' + path, 'error'); + return; + } + } + try { + const el = document.createElement('script'); + el.textContent = code; + el.onerror = () => this.print('load-local: failed to execute ' + path, 'error'); + document.body.appendChild(el); + this.print('Module loaded from ' + path, 'success'); + } catch (e) { + this.print('load-local: error: ' + e.message, 'error'); + } + } + async saveCodeEditor() { + const ta = document.getElementById('code-editor-textarea'); + const mod = this._editingModule; + if (!mod || !ta) return; + const code = ta.value; + this.fs.writeFile('_mods/' + mod.name + '.js', code); + if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) { + this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder'); + } + this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({ + originalUrl: mod.url || '', + modifiedAt: new Date().toISOString(), editCount: 1 + })); + try { + const ok = await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js'); + if (ok) { + this.print('✅ Module loaded live from _mods/' + mod.name + '.js', 'success'); + } else { + this.print('⚠️ Auto-test failed — edits saved but module not loaded', 'warning'); + } + } catch (e) { + this.print('⚠️ Auto-test failed: ' + e.message + ' — edits saved but module not loaded', 'error'); + } + this.closeCodeEditor(); + } + _restoreFontSize() { + try { + const saved = localStorage.getItem('webx11_font_size'); + if (saved) { + const s = parseInt(saved); + if (s >= 8 && s <= 32) { + document.body.style.fontSize = s + 'px'; + const tc = document.getElementById('terminal-container'); + if (tc) tc.style.fontSize = s + 'px'; + const inp = document.getElementById('command-input'); + if (inp) inp.style.fontSize = s + 'px'; + } + } + } catch (e) {} + } + _restoreTheme() { + try { + const savedTheme = localStorage.getItem('webx11_active_theme'); + if (savedTheme) { + const themesRaw = localStorage.getItem('webx11_themes'); + const defaults = { + 'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' }, + 'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' }, + 'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' }, + 'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' }, + 'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' }, + 'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' }, + 'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' }, + 'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' }, + 'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' } + }; + if (themesRaw) { + const themes = JSON.parse(themesRaw); + Object.assign(defaults, themes); + } + const t = defaults[savedTheme]; + if (t) { + for (const [k, v] of Object.entries(t)) { + document.documentElement.style.setProperty(k, v); + } + } + } + } catch (e) { console.warn('[Theme] restore error:', e); } + } + // ── First-run welcome ── + _checkFirstRun() { + if (localStorage.getItem('webx11_tutorial_complete') || localStorage.getItem('webx11_tutorial_skipped')) return; + // Check if tutorial module is loaded — retry if ext modules not ready yet + const tutMod = this.moduleManager.modules['TutorialModule']; + if (!tutMod) { + // Ext modules may still be loading; retry in 2s + if (!this._welcomeRetry) { + this._welcomeRetry = true; + setTimeout(() => this._checkFirstRun(), 2000); + } + return; + } + this.tutorialModule = tutMod; + // Only show welcome if user hasn't explicitly dismissed it + if (!localStorage.getItem('webx11_welcome_dismissed')) { + this.showWelcomeModal(); + } + } + showWelcomeModal() { + const modal = document.getElementById('welcome-modal'); + if (modal) { + modal.style.display = 'flex'; + this._welcomeShown = true; + } + } + dismissWelcome() { + const modal = document.getElementById('welcome-modal'); + if (modal) modal.style.display = 'none'; + localStorage.setItem('webx11_welcome_dismissed', '1'); + // Also mark tutorial as skipped so it doesn't reappear + if (this.tutorialModule) this.tutorialModule.skipTutorial(); + } + toggleCmdCards() { + const panel = document.getElementById('cmd-cards'); + if (!panel) return; + panel.classList.toggle('visible'); + // If opening, focus the input field + if (panel.classList.contains('visible')) { + setTimeout(() => this.inputField.focus(), 50); + } + } + log(level, source, message) { + const d = new Date(); + const ts = d.toISOString().split('T')[1].split('.')[0]; + const entry = `[${ts}][${level}][${source}] ${message}`; + console.log(entry); + // Write to VFS log + try { + this.fs.mkdirRecursive('/logs'); + let current = this.fs.readFile('/logs/current.log') || ''; + current += entry + '\n'; + const lines = current.split('\n').filter(l => l); + if (lines.length > 200) { + this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n')); + this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n')); + } else { + this.fs.writeFile('/logs/current.log', current); + } + } catch (e) {} + } + setAiBusy(busy) { + this._aiBusy = !!busy; + if (this._aiBusyInterval) { + clearInterval(this._aiBusyInterval); + this._aiBusyInterval = null; + } + const cancelBtn = document.getElementById('ai-cancel-btn'); + if (busy) { + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + this._aiBusyStart = Date.now(); + const updateTimer = () => { + let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000); + let sec = elapsed % 60; + let min = Math.floor(elapsed / 60); + this.statusBar.textContent = '🤖 AI Thinking... ⏱ ' + (min > 0 ? min + 'm ' : '') + sec + 's'; + this.statusBar.className = 'loading'; + }; + updateTimer(); + this._aiBusyInterval = setInterval(updateTimer, 1000); + } else { + if (cancelBtn) cancelBtn.style.display = 'none'; + this.statusBar.textContent = 'Ready'; + this.statusBar.className = ''; + this._aiBusyStart = 0; + } + } + printMemoryStats() { + const pm = typeof performance !== 'undefined' && performance.memory; + let heapInfo = ''; + if (pm) { + const used = (pm.usedJSHeapSize / 1048576).toFixed(1); + const total = (pm.totalJSHeapSize / 1048576).toFixed(1); + const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1); + heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`; + } else { + try { + const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1); + heapInfo = `localStorage: ${lsSize}KB`; + } catch (e) { + heapInfo = 'Memory API not available on this browser'; + } + } + const output = [ + `📊 Memory Stats`, + ` ${heapInfo}`, + ` Terminal lines in DOM: ${this.outputContainer.children.length}`, + ` History buffer: ${this._historyBuffer.length} entries`, + ` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`, + ` Commands in history: ${this.history.length}` + ].join('\n'); + this.print(output, 'info'); + } + playNotificationSound() { + if (localStorage.getItem('webx11_config_ai_sound') === 'off') return; + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 880; + osc.type = 'sine'; + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3); + osc.start(ctx.currentTime); + osc.stop(ctx.currentTime + 0.3); + } catch (e) {} + } + toggleMicVisibility() { + const voiceMod = this.moduleManager.modules['VoiceModule']; + if (!voiceMod) { + this.print('Voice module not loaded', 'warning'); + return; + } + if (voiceMod.listening) { + voiceMod.stopListening(); + } else { + voiceMod.handle([]); + } + } + setFontSize(size) { + const s = parseInt(size); + if (isNaN(s) || s < 8 || s > 32) { + this.print('Usage: font-size <8-32>', 'warning'); + return; + } + document.body.style.fontSize = s + 'px'; + document.getElementById('terminal-container').style.fontSize = s + 'px'; + document.getElementById('command-input').style.fontSize = s + 'px'; + try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {} + this.print('Font size set to ' + s + 'px 👆', 'success'); + } + } + +/* === module-system.js === */ +// Extracted from newOS4 index.html — SystemModule + class SystemModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'help': 'help [command] — Show all commands or usage for a specific command', + 'echo': 'echo — Print text to the terminal', + 'mem': 'mem — Show memory usage (heap + localStorage + VFS)', + 'status': 'status — Display system status (network, files, modules, Ollama)', + 'version': 'version — Show version, build date, and cache key', + 'uptime': 'uptime — Show time since page load + last save time', + 'errors': 'errors [n] — Show last n ERROR log entries (default: 20)', + 'doctor': 'doctor — Run all health checks, rank issues, show top 3 warnings', + 'bug-report': 'bug-report — Generate a markdown bug report to /tmp/ for debugging', + 'preview': 'preview — Render an HTML file from the VFS in a preview pane', + 'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)', + 'reset-vfs': 'reset-vfs [--confirm] — Clear virtual filesystem and auto-session backups (keeps settings, modules, AI profile)', + 'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)', + 'logrotate': 'logrotate — Rotate current.log to a dated archive (auto-cleans old)', + 'health-check': 'health-check — Audit VFS size, modules, logs, trash, skills, tool failures', + 'memory-map-export': 'memory-map-export — Export memory index to /memory/reference.md (overwrites file)', + 'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)', + 'setfont': 'setfont <8-32> — Alias for font-size', + 'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)', + 'checkpoint': 'checkpoint [name] — Create a VFS snapshot (name defaults to ISO timestamp)', + 'checkpoint-list': 'checkpoint-list — List all VFS checkpoints', + 'rollback': 'rollback — Restore VFS from a checkpoint', + 'checkpoint-del': 'checkpoint-del — Delete a VFS checkpoint', + }; + } + registerCommands() { + this.commands['help'] = (args) => this.handleHelp(args); + this.commands['echo'] = (args) => this.terminal.print(args.join(' ')); + this.commands['mem'] = () => { + const heap = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A'; + const heapLimit = performance.memory ? (performance.memory.jsHeapSizeLimit / 1048576).toFixed(0) : 'N/A'; + // localStorage size + 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) {} + const lsKB = (lsBytes / 1024).toFixed(1); + // VFS size (in-memory) + let vfsBytes = 0; + try { vfsBytes = JSON.stringify(this.terminal.fs.root).length; } catch (e) {} + const vfsKB = (vfsBytes / 1024).toFixed(1); + // sessionStorage size + let ssBytes = 0; + try { + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i); + if (k) ssBytes += (k.length + (sessionStorage.getItem(k) || '').length) * 2; + } + } catch (e) {} + const ssKB = (ssBytes / 1024).toFixed(1); + // Total + const total = (parseFloat(heap === 'N/A' ? '0' : heap) + parseFloat(lsKB) + parseFloat(ssKB) + parseFloat(vfsKB)).toFixed(1); + + this.terminal.print('💾 Memory Usage', 'header'); + this.terminal.print(` JS heap: ${heap} MB${heap !== 'N/A' ? ' / ' + heapLimit + ' MB limit' : ''}`, parseFloat(heap) > 100 ? 'warning' : ''); + this.terminal.print(` localStorage: ${lsKB} KB (${localStorage.length} keys)`, parseFloat(lsKB) > 1024 ? 'warning' : ''); + this.terminal.print(` sessionStorage: ${ssKB} KB (${sessionStorage.length} keys)`, ''); + this.terminal.print(` VFS in-memory: ${vfsKB} KB`, parseFloat(vfsKB) > 256 ? 'warning' : ''); + this.terminal.print(` Total: ${total} MB`, ''); + document.getElementById('header-status').innerText = `Mem: ${heap}MB`; + }; + // Refresh memory display on boot + setTimeout(() => { + if (performance.memory) { + const mem = (performance.memory.usedJSHeapSize / 1048576).toFixed(1); + document.getElementById('header-status').innerText = `Mem: ${mem}MB`; + } + }, 500); + this.commands['status'] = () => { + this.terminal.print(`System: WebX11 ${window.NEWOS4_VERSION || 'v4.0'}`); + this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`); + 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'}`); + }; + + // v4.5.43: version — show version, build date, cache key + this.commands['version'] = () => { + const v = window.NEWOS4_VERSION || 'unknown'; + const cacheKey = localStorage.getItem('webx11_cache_ver') || 'unknown'; + // Try to get build date from script tag or module list + const buildDate = (() => { + try { + const scripts = document.querySelectorAll('script[src*="v="]'); + for (const s of scripts) { + const m = s.src.match(/v=([0-9-]+-v[0-9.]+[a-z]?)/); + if (m) return m[1].split('-').slice(0, 3).join('-'); + } + } catch (e) {} + return 'unknown'; + })(); + const ua = navigator.userAgent.split(' ').slice(-1)[0] || 'unknown'; + this.terminal.print('ℹ️ WebX11 newOS4', 'header'); + this.terminal.print(` Version: ${v}`); + this.terminal.print(` Build: ${buildDate}`); + this.terminal.print(` Cache key: ${cacheKey}`); + this.terminal.print(` Browser: ${ua}`); + this.terminal.print(` Online: ${navigator.onLine ? 'yes' : 'no'}`); + }; + + // v4.5.43: uptime — time since page load + last save time + this.commands['uptime'] = () => { + const loadTime = performance.timeOrigin || Date.now() - (window.__pageLoadMs || 0); + const ms = Date.now() - loadTime; + const s = Math.floor(ms / 1000); + const days = Math.floor(s / 86400); + const hours = Math.floor((s % 86400) / 3600); + const mins = Math.floor((s % 3600) / 60); + const secs = s % 60; + let uptime; + if (days > 0) uptime = `${days}d ${hours}h ${mins}m`; + else if (hours > 0) uptime = `${hours}h ${mins}m ${secs}s`; + else if (mins > 0) uptime = `${mins}m ${secs}s`; + else uptime = `${secs}s`; + this.terminal.print('⏱️ Uptime', 'header'); + this.terminal.print(` Since load: ${uptime} (${s}s)`); + // Check last save time from localStorage modification + try { + const lastSave = localStorage.getItem('webx11_last_save'); + if (lastSave) { + const ago = Math.floor((Date.now() - parseInt(lastSave)) / 1000); + this.terminal.print(` Last save: ${ago}s ago`, ago > 300 ? 'warning' : ''); + } else { + this.terminal.print(` Last save: unknown`, 'info'); + } + } catch (e) {} + }; + + // v4.5.43: errors [n] — alias for logs [n] ERROR + this.commands['errors'] = (args) => { + const n = parseInt(args[0]) || 20; + const content = this.terminal.fs.readFile('/logs/current.log'); + if (!content) { this.terminal.print('No logs yet', 'info'); return; } + const lines = content.split('\n').filter(l => l && l.includes('[ERROR]')); + const tail = lines.slice(-n); + if (tail.length === 0) { this.terminal.print('✅ No errors found in logs', 'success'); return; } + this.terminal.print(`--- Errors (last ${tail.length}) ---`, 'error'); + tail.forEach(l => this.terminal.print(l, 'error')); + }; + + // v4.5.43: doctor — run all health checks, rank issues, show top 3 + this.commands['doctor'] = () => { + const issues = []; + const fs = this.terminal.fs; + + // Check 1: Network + if (!navigator.onLine) issues.push({ severity: 'high', msg: 'Network: offline' }); + + // Check 2: Ollama + if (!this.terminal.ollamaManager.connected) { + issues.push({ severity: 'medium', msg: 'Ollama: not connected (AI features limited)' }); + } + + // Check 3: VFS size + 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)` }); + } catch (e) {} + + // Check 4: 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 + 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 + try { + const logContent = fs.readFile('/logs/current.log') || ''; + const recent = logContent.split('\n').filter(l => l).slice(-100); + const errorCount = recent.filter(l => l.includes('[ERROR]')).length; + if (errorCount > 5) issues.push({ severity: 'high', msg: `Errors: ${errorCount} errors in last 100 log lines` }); + 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) {} + if (disabled.length > 0) { + issues.push({ severity: 'low', msg: `Disabled modules: ${disabled.length} (${disabled.join(', ')})` }); + } + + this.terminal.print('🩺 Doctor Report', 'header'); + if (issues.length === 0) { + this.terminal.print(' ✅ All systems healthy', 'success'); + return; + } + // Sort by severity: high > medium > low + 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)) { + 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'); + 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('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('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)'); + }; + + // v4.5.43: bug-report — generate markdown report to /tmp/ + this.commands['bug-report'] = () => { + const fs = this.terminal.fs; + const lines = []; + const ts = new Date().toISOString(); + lines.push(`# WebX11 newOS4 Bug Report`); + lines.push(`Generated: ${ts}`); + lines.push(''); + 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(`- 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'}`); + lines.push(`- VFS size: ${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB`); + lines.push(`- Modules loaded: ${Object.keys(this.terminal.moduleManager.modules).length}`); + lines.push(`- Ollama: ${this.terminal.ollamaManager.connected ? 'connected' : 'disconnected'}`); + lines.push(''); + // Uptime + const loadTime = performance.timeOrigin || Date.now(); + const upSec = Math.floor((Date.now() - loadTime) / 1000); + lines.push(`- Uptime: ${upSec}s`); + lines.push(''); + lines.push('## Recent Errors (last 20)'); + try { + const logContent = fs.readFile('/logs/current.log') || ''; + const errs = logContent.split('\n').filter(l => l && l.includes('[ERROR]')).slice(-20); + if (errs.length === 0) lines.push('_No errors found_'); + else errs.forEach(e => lines.push('- ' + e)); + } catch (e) { lines.push('_Could not read logs_'); } + lines.push(''); + lines.push('## Recent Commands (last 10)'); + try { + const hist = JSON.parse(localStorage.getItem('webx11_command_history') || '[]'); + const recent = hist.slice(-10); + if (recent.length === 0) lines.push('_No history_'); + else recent.forEach(c => lines.push('- ' + c)); + } catch (e) { lines.push('_No history available_'); } + lines.push(''); + lines.push('## Disabled Modules'); + try { + const disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); + if (disabled.length === 0) lines.push('_None_'); + else disabled.forEach(d => lines.push('- ' + d)); + } catch (e) { lines.push('_Unknown_'); } + lines.push(''); + lines.push('## File List (top-level VFS)'); + try { + const items = fs.ls(); + items.slice(0, 20).forEach(i => lines.push('- ' + i)); + if (items.length > 20) lines.push(`- ... and ${items.length - 20} more`); + } catch (e) { lines.push('_Could not list_'); } + lines.push(''); + lines.push('---'); + lines.push('Send this file when reporting a bug.'); + + const content = lines.join('\n'); + const filename = `/tmp/bug-report-${Date.now()}.md`; + try { + fs.writeFile(filename, content); + this.terminal.print(`✅ Bug report saved to ${filename}`, 'success'); + this.terminal.print(` (${(content.length / 1024).toFixed(1)}KB)`, 'info'); + } catch (e) { + this.terminal.print(`❌ Could not save bug report: ${e.message}`, 'error'); + } + }; + + this.commands['preview'] = (args) => this.terminal.openPreview(args); + this.commands['factory-reset'] = (args) => { + if (!args[0] || args[0] !== '--confirm') { + this.terminal.print('⚠️ FACTORY RESET will clear all data:', 'error'); + this.terminal.print(' • Filesystem (all files + directories)', 'warning'); + this.terminal.print(' • AI memory, patterns, user profile', 'warning'); + this.terminal.print(' • Ollama endpoint, model selection', 'warning'); + this.terminal.print(' • Terminal history, font size', 'warning'); + this.terminal.print(' • All config, modules, packages', 'warning'); + this.terminal.print(' • Mindmaps, notes, bookmarks', 'warning'); + this.terminal.print(' • Themes, context profiles, servers', 'warning'); + this.terminal.print(' • Chat settings, my data', 'warning'); + this.terminal.print('Type: factory-reset --confirm to proceed', 'error'); + return; + } + // v4.5.47: explicitly wipe VFS storage and session backups before clearing localStorage + try { + this.terminal.fs.deleteFile('_sessions/_auto.json'); + this.terminal.fs.deleteFile('_sessions/_auto-context.json'); + this.terminal.fs.reset(); + } catch (e) { console.warn('factory-reset FS cleanup:', e); } + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + if (key && key.startsWith('webx11_')) localStorage.removeItem(key); + } + this.terminal.outputContainer.innerHTML = ''; + this.terminal.print('✅ Factory reset complete. Reloading...', 'success'); + setTimeout(() => location.reload(), 1000); + }; + this.commands['reset-vfs'] = (args) => { + if (!args[0] || args[0] !== '--confirm') { + this.terminal.print('⚠️ RESET VFS will clear all files and directories:', 'warning'); + this.terminal.print(' • Virtual filesystem (all files + directories)', 'warning'); + this.terminal.print(' • VFS will be reset to empty root', 'warning'); + this.terminal.print(' • All settings, modules, AI profile preserved', 'info'); + this.terminal.print('Type: reset-vfs --confirm to proceed', 'error'); + return; + } + this.terminal.fs.root = { type: 'dir', content: {} }; + this.terminal.fs.currentPath = []; + // v4.5.47: delete auto-session backups so they don't resurrect + try { + this.terminal.fs.deleteFile('_sessions/_auto.json'); + this.terminal.fs.deleteFile('_sessions/_auto-context.json'); + } catch (e) {} + localStorage.setItem('webx11_last_reset', Date.now().toString()); + this.terminal.fs.saveToStorage(); + this.terminal.print('✅ VFS reset complete. Reloading...', 'success'); + setTimeout(() => location.reload(), 1000); + }; + this.commands['logs'] = (args) => { + const n = parseInt(args[0]) || 20; + const level = args[1] ? args[1].toUpperCase() : null; + const content = this.terminal.fs.readFile('/logs/current.log'); + if (!content) { this.terminal.print('No logs yet', 'info'); return; } + let lines = content.split('\n').filter(l => l); + if (level) lines = lines.filter(l => l.includes('[' + level + ']')); + const tail = lines.slice(-n); + if (tail.length === 0) { this.terminal.print('No matching log entries', 'info'); return; } + this.terminal.print('--- Logs (last ' + tail.length + ') ---', 'info'); + tail.forEach(l => this.terminal.print(l, l.includes('[ERROR]') ? 'error' : 'info')); + }; + // Auto-rotate logs on startup (silently) + this._autoRotateLogs(); + this.commands['logrotate'] = () => { + const rotated = this._autoRotateLogs(true); + if (rotated) { + this.terminal.print('Log rotated: ' + rotated, 'success'); + } else { + this.terminal.print('Log rotation not needed (<50KB, <500 lines)', 'info'); + } + }; + this.commands['health-check'] = () => this._healthCheck(); + this.commands['memory-map-export'] = () => this._memoryMapExport(); + this.commands['font-size'] = (args) => { + if (!args.length || !args[0]) { + const cur = localStorage.getItem('webx11_font_size'); + this.terminal.print(`Current font-size: ${cur || '16'}px`, 'info'); + this.terminal.print('Usage: font-size <8-32>', 'info'); + return; + } + const val = parseInt(args[0], 10); + if (isNaN(val) || val < 8 || val > 32) { + this.terminal.print('font-size must be between 8 and 32', 'error'); + return; + } + localStorage.setItem('webx11_font_size', val.toString()); + // Apply directly to body — bypasses CSS inheritance issues + document.body.style.fontSize = val + 'px'; + document.getElementById('terminal-container').style.fontSize = val + 'px'; + document.getElementById('command-input').style.fontSize = val + 'px'; + this.terminal.print(`Font size set to ${val}px (saved) 👆`, 'success'); + }; + this.commands['setfont'] = this.commands['font-size']; + + // Checkpoint commands (delegated to virtual-fs.js) + this.commands['checkpoint'] = (args) => { + try { + const name = args[0] || ''; + this.terminal.fs.checkpoint(name); + const displayName = name || new Date().toISOString(); + this.terminal.print('✅ Checkpoint created: ' + displayName, 'success'); + } catch (e) { + this.terminal.print('Checkpoint failed: ' + e.message, 'error'); + } + }; + this.commands['checkpoint-list'] = () => { + try { + const list = this.terminal.fs.listCheckpoints(); + if (!list || list.length === 0) { + this.terminal.print('No checkpoints found', 'info'); + return; + } + this.terminal.print('📋 Checkpoints:', 'text-muted'); + list.forEach(c => this.terminal.print(' • ' + c)); + } catch (e) { + this.terminal.print('checkpoint-list failed: ' + e.message, 'error'); + } + }; + this.commands['checkpoint-del'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: checkpoint-del ', 'warning'); + try { + if (this.terminal.fs.deleteCheckpoint(args[0])) { + this.terminal.print('Deleted checkpoint: ' + args[0], 'success'); + } else { + this.terminal.print('Checkpoint not found: ' + args[0], 'error'); + } + } catch (e) { + this.terminal.print('checkpoint-del failed: ' + e.message, 'error'); + } + }; + this.commands['rollback'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: rollback ', 'warning'); + try { + if (this.terminal.fs.rollback(args[0])) { + this.terminal.print('↻ Rolled back to checkpoint: ' + args[0], 'success'); + } else { + this.terminal.print('Rollback failed: checkpoint not found or invalid', 'error'); + } + } catch (e) { + this.terminal.print('Rollback failed: ' + e.message, 'error'); + } + }; + } + _autoRotateLogs(force = false) { + try { + const content = this.terminal.fs.readFile('/logs/current.log') || ''; + const lines = content.split('\n').filter(l => l); + const size = content.length; + if (!force && size < 51200 && lines.length < 500) return null; + const now = new Date(); + const stamp = now.getFullYear() + '-' + + String(now.getMonth()+1).padStart(2,'0') + '-' + + String(now.getDate()).padStart(2,'0') + '_' + + String(now.getHours()).padStart(2,'0') + '00'; + this.terminal.fs.writeFile('/logs/' + stamp + '.log', content); + this.terminal.fs.writeFile('/logs/current.log', ''); + // Clean oldest rotated logs (>10) + const logsDir = this.terminal.fs.readDir('/logs'); + if (logsDir && logsDir.content) { + const rotated = Object.keys(logsDir.content) + .filter(k => k.endsWith('.log') && k !== 'current.log') + .sort(); + while (rotated.length > 10) { + const oldest = rotated.shift(); + this.terminal.fs.deleteFile('/logs/' + oldest); + } + } + return stamp + '.log'; + } catch (e) { return null; } + } + _healthCheck() { + const fs = this.terminal.fs; + let warnings = 0; + this.terminal.print('╔══════════════════════════════════════╗', 'info'); + this.terminal.print('║ 🔍 SYSTEM HEALTH CHECK ║', 'info'); + this.terminal.print('╚══════════════════════════════════════╝', 'info'); + // VFS size + const dump = JSON.stringify(fs.root).length; + const vfsOk = dump < 524288; + this.terminal.print(`VFS size: ${(dump/1024).toFixed(1)}KB ${vfsOk ? '✅' : '⚠️ > 512KB'}`, vfsOk ? 'info' : 'warning'); + if (!vfsOk) warnings++; + // Modules + const modTotal = Object.keys(this.terminal.moduleManager.modules || {}).length; + this.terminal.print(`Modules loaded: ${modTotal} ✅`, 'info'); + // Logs + const logContent = fs.readFile('/logs/current.log') || ''; + const logLines = logContent.split('\n').filter(l => l).length; + this.terminal.print(`Current log: ${logLines} lines`, logLines > 500 ? 'warning' : 'info'); + if (logLines > 500) warnings++; + // Trash + const trashList = fs.listTrash ? fs.listTrash() : []; + this.terminal.print(`Trash items: ${trashList.length}`, 'info'); + // Skills + const skillIdx = fs.fsReadJSON ? (fs.fsReadJSON('/skills/index.json') || {}) : {}; + const skillCount = Object.keys(skillIdx).length; + this.terminal.print(`Skills: ${skillCount}`, skillCount > 0 ? 'info' : 'warning'); + if (skillCount === 0) warnings++; + // Summary + this.terminal.print('─'.repeat(40), 'text-muted'); + if (warnings === 0) { + this.terminal.print('All checks passed ✅', 'success'); + } else { + this.terminal.print(`${warnings} warning(s) found`, 'warning'); + } + } + _memoryMapExport() { + const fs = this.terminal.fs; + const idx = fs.fsReadJSON ? (fs.fsReadJSON('/memory/index.json') || {}) : null; + if (!idx) { + this.terminal.print('No memory index found at /memory/index.json', 'error'); + return; + } + // v4.5.43: Actually export to /memory/reference.md (was: print to terminal) + const lines = []; + lines.push('---'); + lines.push('name: adaptive-memory-map'); + lines.push('description: Auto-generated from memory/index.json'); + lines.push('tags: [memory, context, tracking, adaptation]'); + lines.push('category: system'); + lines.push('---'); + lines.push(''); + for (const [k, v] of Object.entries(idx)) { + lines.push(`## ${k}`); + lines.push(`- Tasks: ${v.tasks || 0}`); + lines.push(`- Updated: ${v.updated || 'unknown'}`); + lines.push(`- Summary: ${(v.summary || '').substring(0, 200)}...`); + lines.push(''); + } + const content = lines.join('\n'); + try { + fs.writeFile('/memory/reference.md', content); + this.terminal.print(`✅ Memory map exported to /memory/reference.md (${(content.length / 1024).toFixed(1)}KB)`, 'success'); + } catch (e) { + this.terminal.print(`❌ Could not save: ${e.message}`, 'error'); + } + } + handleHelp(args) { + const helpMap = this.terminal.moduleManager.getAllHelp(); + // Single command lookup + if (args && args.length > 0) { + const cmd = args[0].toLowerCase(); + const allCommands = this.terminal.moduleManager.getAllCommands(); + if (allCommands[cmd]) { + const helpEntry = helpMap[cmd]; + if (helpEntry) { + this.terminal.print(helpEntry, 'info'); + } else { + this.terminal.print(`No help available for ${cmd}`, 'warning'); + } + } else { + this.terminal.print(`No help available for ${cmd}`, 'warning'); + } + return; + } + // Dynamically group commands by owning module + const cmdToModule = this.terminal.moduleManager.getAllCommands(); + const moduleGroups = {}; + for (const [cmd, modName] of Object.entries(cmdToModule)) { + if (!moduleGroups[modName]) moduleGroups[modName] = []; + moduleGroups[modName].push(cmd); + } + const modLabels = { + FileSystemModule: '📁 Filesystem', + WebX11AIAssistantModule: '🤖 AI', + WebXAIAliasModule: '🌐 WebX', + DuckDuckGoModule: '🔍 Search & Web', + SearXNGModule: '🔍 Search & Web', + WebFetcherModule: '🔍 Search & Web', + AliasModule: '🔧 Tools', + BusyBoxModule: '🔧 Tools', + CalcModule: '🔧 Tools', + CronModule: '🔧 Tools', + CryptoGzipModule: '🔧 Tools', + DiffModule: '🔧 Tools', + EditorModule: '🔧 Tools', + FileManagerModule: '🔧 Tools', + FinderModule: '🔧 Tools', + MindmapModule: '🔧 Tools', + ThemeModule: '🔧 Tools', + TutorialModule: '🔧 Tools', + VoiceModule: '🔧 Tools', + WpmModule: '🔧 Tools', + SystemModule: '⚙️ System', + ShellModule: '⚙️ System', + ServerMonitorModule: '📡 Network', + ModuleManagerModule: '🧩 Modules', + OllamaModule: '🦙 Ollama', + NeuralVFSModule: '🧠 Neural VFS', + SkillPackCoreModule: '📚 Skills', + ConfigManagerModule: '⚙️ Config', + ModuleIOModule: '🔌 I/O', + SandboxModule: '🏖️ Sandbox', + MyModule: '💾 Data', + WebDAVModule: '🧰 Extras', + ChatStudioModule: '🧰 Extras', + SimpleGUIModule: '🖥️ GUI', + SecretVault: '🔒 Vault', + OrchestratorModule: '🧠 Orchestrator', + PredictorModule: '🔮 Prediction', + SmartContextModule: '🧠 Context', + MemoryGraphModule: '📊 Memory', + DynamicPromptsModule: '💬 Prompts', + WorldModelModule: '🌍 World', + LeaseModule: '🔐 Leases', + AgentRegistryModule: '🤖 Agents', + }; + // Merge modules with the same label + const grouped = {}; + const usedLabels = new Set(); + for (const [modName, cmds] of Object.entries(moduleGroups)) { + const label = modLabels[modName] || `🧩 ${modName.replace(/Module$/, '')}`; + if (!grouped[label]) grouped[label] = []; + grouped[label].push(...cmds); + usedLabels.add(label); + } + let result = '╔══════════════════════════════════════╗\n║ 🔷 WebX11 ' + (window.NEWOS4_VERSION || 'v4.0') + ' — All Commands ║\n╚══════════════════════════════════════╝\n\n'; + const total = Object.keys(helpMap).length; + result += `📊 ${total} commands loaded\n\n`; + for (const [label, cmds] of Object.entries(grouped)) { + result += label + '\n'; + cmds.forEach(c => { + const desc = (helpMap[c] || c).split(' — ')[1] || ''; + const short = desc.substring(0, 55); + result += ` ${c.padEnd(16)}${short}\n`; + }); + result += '\n'; + } + result += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n help → detailed usage\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + this.terminal.print(result.trim(), 'info'); + } + } + +/* === module-fs.js === */ +class FileSystemModule { + constructor() { this.commands = {}; this.commandsMeta = { + 'trash': { argType: 'file' }, 'ls-trash': { argType: 'file' }, + 'upload': { argType: 'dir' }, 'vfs-export': { argType: 'file' }, + 'vfs-import': { argType: 'file' }, 'download': { argType: 'file' }, + }; this.terminal = null; } + getHelpInfo() { + return { + 'ls': 'ls [path] [-la] — List files and directories', + 'cat': 'cat — Display file contents', + 'put': 'put — Create or overwrite a file', + 'rm': 'rm — Remove a file (moved to trash); use -r/-rf for directories', + 'trash': 'trash — Move a file to trash', + 'trash-dir': 'trash-dir — Move a directory to trash', + 'ls-trash': 'ls-trash — List items in trash', + 'empty-trash': 'empty-trash — Permanently delete all trash items', + 'mkdir': 'mkdir [-p] — Create a directory; -p creates parent paths', + 'rmdir': 'rmdir — Remove a directory', + 'mv': 'mv — Move file or directory', + 'cp': 'cp — Copy file or directory', + 'cd': 'cd — Change current directory; cd / to go to root', + 'pwd': 'pwd — Print current working directory', + 'find': 'find — Search files by name pattern', + 'grep': 'grep [file] — Search file content for pattern', + 'head': 'head [lines] — Show first N lines (default: 10)', + 'tail': 'tail [lines] — Show last N lines (default: 10)', + 'sort': 'sort — Sort lines of a file alphabetically', + 'wc': 'wc — Count lines, words, and bytes', + 'touch': 'touch — Create empty file or update timestamp', + 'tree': 'tree — Display directory structure as a tree', + 'du': 'du [path] — Estimate file/directory size', + 'upload': 'upload — Upload a file from your device', + 'download': 'download — Download a file to your device', + 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON', + 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file', + }; + } + registerCommands() { + const self = this; + this.commands['ls'] = (args) => { + const target = args && args.length > 0 && !args[0].startsWith('-') ? args[0] : undefined; + const flags = (args || []).filter(a => a.startsWith('-')); + const showLong = flags.some(f => f.includes('l')); + const showHidden = flags.some(f => f.includes('a')); + const files = this.terminal.fs.ls(target); + if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; } + const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); + if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } + const maxName = Math.max(...filtered.map(f => f.name.length), 8); + if (showLong) { + filtered.forEach(f => { + const type = f.type === 'dir' ? 'd' : '-'; + const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; + const size = String(f.size || 0).padStart(6); + this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name); + }); + const total = filtered.reduce((s, f) => s + (f.size || 0), 0); + this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted'); + } else { + this.terminal.printClickable(filtered); + } + }; + this.commands['cat'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: cat ', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content !== null && content !== undefined) { + if (content.length === 0) this.terminal.print('(empty file)', 'info'); + else this.terminal.print(content); + } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error'); + }; + this.commands['echo'] = (args) => { + if (!args || args.length === 0) { this.terminal.print(''); return; } + const ri = args.indexOf('>'); + if (ri >= 0) { + const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' '); + if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); } + return; + } + this.terminal.print(args.join(' ')); + }; + this.commands['put'] = (args) => { + if(args.length < 2) return this.terminal.print('Usage: put ', 'warning'); + const name = args[0]; const content = args.slice(1).join(' '); + this.terminal.fs.writeFile(name, content); + this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success'); + }; + this.commands['rm'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: rm ', 'warning'); + if(args[0] === '-r' || args[0] === '-rf') { + const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r ', 'warning'); + // Use trash for recursive dir removal too + if (this.terminal.fs.trashDir(t)) this.terminal.print('Moved to trash: ' + t, 'success'); + else this.terminal.print('rm: ' + t + ': No such file or directory', 'error'); + return; + } + if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); + else this.terminal.print('rm: ' + args[0] + ': No such file', 'error'); + }; + this.commands['trash'] = (args) => { + const fs = this.terminal.fs; + if(!args[0]) return this.terminal.print('Usage: trash ', 'warning'); + const node = fs.readFile(args[0]) !== null ? { type: 'file' } : fs.readDir(args[0]); + if (!node) return this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); + if (node.type === 'dir') { + if (fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); + else this.terminal.print('trash: ' + args[0] + ': Failed', 'error'); + } else { + const content = fs.readFile(args[0]); + if (content !== null) { + fs.trashFile(args[0], { type: 'file', content, modified: new Date() }); + this.terminal.print('Moved to trash: ' + args[0], 'success'); + } else { + this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); + } + } + }; + this.commands['trash-dir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: trash-dir ', 'warning'); + if (this.terminal.fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); + else this.terminal.print('trash-dir: ' + args[0] + ': No such directory', 'error'); + }; + this.commands['ls-trash'] = () => { + const fs = this.terminal.fs; + const items = fs.listTrash ? fs.listTrash() : []; + if (!items.length) return this.terminal.print('Trash is empty', 'info'); + this.terminal.print('Trash items:', 'info'); + items.forEach(it => { + const date = new Date(it.ts).toLocaleString(); + this.terminal.print(` ${it.name} [${it.type}] ${date}`); + }); + }; + this.commands['empty-trash'] = () => { + const fs = this.terminal.fs; + const removed = fs.emptyTrash ? fs.emptyTrash(0) : 0; + this.terminal.print(`Emptied trash: ${removed} item(s) permanently deleted`, removed > 0 ? 'warning' : 'info'); + }; + this.commands['mkdir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: mkdir ', 'warning'); + if (args[0] === '-p') { + if(!args[1]) return this.terminal.print('Usage: mkdir -p ', 'warning'); + if(this.terminal.fs.mkdirRecursive(args[1])) this.terminal.print('Created directories: ' + args[1], 'success'); + else this.terminal.print('mkdir: cannot create ' + args[1], 'error'); + return; + } + if(this.terminal.fs.mkdir(args[0])) this.terminal.print('Created directory: ' + args[0], 'success'); + else this.terminal.print('mkdir: cannot create ' + args[0] + ': File exists', 'error'); + }; + this.commands['rmdir'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: rmdir ', 'warning'); + if (this.terminal.fs.deleteDirRecursive(args[0])) this.terminal.print('Removed directory: ' + args[0], 'success'); + else this.terminal.print('rmdir: ' + args[0] + ': No such directory', 'error'); + }; + this.commands['mv'] = (args) => { + if(!args[0] || !args[1]) return this.terminal.print('Usage: mv ', 'warning'); + if (this.terminal.fs.mv(args[0], args[1])) + this.terminal.print('Moved: ' + args[0] + ' to ' + args[1], 'success'); + else + this.terminal.print('mv: ' + args[0] + ': No such file or directory', 'error'); + }; + this.commands['cp'] = (args) => { + if(!args[0] || !args[1]) return this.terminal.print('Usage: cp ', 'warning'); + if (this.terminal.fs.cp(args[0], args[1])) + this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success'); + else + this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error'); + }; + this.commands['cd'] = (args) => { + if (!args[0] || args[0] === '/') { + this.terminal.fs.currentPath = []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + return; + } + const segs = this.terminal.fs._toAbsoluteSegments(args[0]); + if (segs === null) { + this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error'); + return; + } + const dir = this.terminal.fs._resolveDir(args[0]); + if (dir) { + this.terminal.fs.currentPath = segs; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + } else { + this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error'); + } + }; + this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); }; + this.commands['find'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: find ', 'warning'); + const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : []; + if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info'); + results.forEach(r => this.terminal.print('./' + r)); + this.terminal.print('Found ' + results.length + ' match(es)', 'info'); + }; + this.commands['grep'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: grep [filename]', 'warning'); + const pattern = args[0]; const target = args[1]; + if (target) { + const c = this.terminal.fs.readFile(target); + if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error'); + const lines = c.split('\n'); const re = new RegExp(pattern, 'i'); + let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } }); + if (count === 0) this.terminal.print('(no matches)', 'info'); + else this.terminal.print(count + ' match(es)', 'text-muted'); + } else { + const dir = this.terminal.fs.getCurrentDir(); + if (!dir) return this.terminal.print('grep: cannot search', 'error'); + let total = 0; const re = new RegExp(pattern, 'i'); + Object.keys(dir.content).forEach(name => { + const node = dir.content[name]; + if (node.type === 'file') { + (node.content || '').split('\n').forEach((line, i) => { + if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; } + }); + } + }); + if (total === 0) this.terminal.print('(no matches)', 'info'); + else this.terminal.print(total + ' total match(es)', 'text-muted'); + } + }; + this.commands['head'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: head [lines]', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error'); + const n = args[1] ? parseInt(args[1]) : 10; + this.terminal.print(c.split('\n').slice(0, n).join('\n')); + }; + this.commands['tail'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: tail [lines]', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('tail: ' + args[0] + ': No such file', 'error'); + const n = args[1] ? parseInt(args[1]) : 10; + this.terminal.print(c.split('\n').slice(-n).join('\n')); + }; + this.commands['sort'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: sort ', 'warning'); + const c = this.terminal.fs.readFile(args[0]); + if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error'); + c.split('\n').sort().forEach(l => this.terminal.print(l)); + }; + this.commands['wc'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: wc ', 'warning'); + const content = this.terminal.fs.readFile(args[0]); + if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error'); + const lines = content.split('\n').length; + const words = content.split(/\s+/).filter(w => w).length; + this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]); + }; + this.commands['touch'] = (args) => { + if(!args[0]) return this.terminal.print('Usage: touch ', 'warning'); + const e = this.terminal.fs.readFile(args[0]); + if (e === null || e === undefined) { + const segs = this.terminal.fs._toAbsoluteSegments(args[0]); + if (segs) { + const dirPath = segs.slice(0, -1).join('/') || '.'; + const name = segs[segs.length - 1]; + this.terminal.fs.mkdirRecursive(dirPath); + } + this.terminal.fs.writeFile(args[0], ''); + this.terminal.print('Created: ' + args[0], 'success'); + } + else { this.terminal.print('Touched: ' + args[0], 'success'); } + }; + this.commands['tree'] = () => { + const dir = this.terminal.fs.getCurrentDir(); + if (!dir) return this.terminal.print('(no directory)', 'warning'); + const lines = []; this._printTree(dir, '', lines); + lines.forEach(l => this.terminal.print(l)); + }; + this.commands['du'] = (args) => { + const t = args && args[0] ? args[0] : '.'; + if (t === '.') { + const dir = this.terminal.fs.getCurrentDir(); if (!dir) return; + let total = 0; Object.keys(dir.content).forEach(k => { const node = dir.content[k]; total += node && typeof node.content === 'string' ? node.content.length : 0; }); + this.terminal.print(total + '\t' + t); + } else { + const c = this.terminal.fs.readFile(t); + if (c === null || c === undefined) return; + this.terminal.print(c.length + '\t' + t); + } + }; + this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; + this.commands['download'] = async (args) => { + if(!args[0]) return this.terminal.print('Usage: download ', 'warning'); + const fname = args[0]; + const content = this.terminal.fs.readFile(fname); + if(content === null || content === undefined) { + return this.terminal.print('download: ' + fname + ': No such file', 'error'); + } + // Push to WebDAV first — this is the only path to a real file download on Telegram WebView + // because browser-based file downloads are blocked in WebViews + let downloadUrl = null; + if (this.terminal.moduleManager && this.terminal.moduleManager.modules.WebDAVModule) { + const wdav = this.terminal.moduleManager.modules.WebDAVModule; + if (wdav.config && wdav.config.url) { + try { + // Make sure /downloads/ dir exists + try { await wdav._ensureDir('/downloads'); } catch(e) {} + const resp = await wdav._webdavRequest('PUT', '/downloads/' + fname, { + body: content, + headers: { 'Content-Type': 'application/octet-stream' } + }); + if (resp.ok || resp.status === 201 || resp.status === 204) { + // Build the public URL the user can tap + const baseUrl = wdav.config.url.replace(/\/+$/, ''); + const hostMatch = baseUrl.match(/^(https?:\/\/[^\/]+)/); + const host = hostMatch ? hostMatch[1] : 'https://webx11.duckdns.org'; + downloadUrl = host + '/spaces/' + wdav._spaceName + '/downloads/' + encodeURIComponent(fname); + this.terminal.print('Uploaded to: ' + downloadUrl, 'success'); + } else { + this.terminal.print('WebDAV push failed: HTTP ' + resp.status, 'warning'); + } + } catch (e) { + this.terminal.print('WebDAV push error: ' + e.message, 'warning'); + } + } else { + this.terminal.print('WebDAV not configured. Run: webdav-config', 'warning'); + } + } + // Build a visible anchor link — tap = real user gesture = native browser download bar + const size = content.length; + const sizeStr = size > 1024 ? (size / 1024).toFixed(1) + ' KB' : size + ' B'; + const line = document.createElement('div'); + line.className = 'output-line'; + line.style.cssText = 'padding:8px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;'; + if (downloadUrl) { + line.innerHTML = '⬇ ' + fname + ' (' + sizeStr + ') ' + + '⬇ Tap to download'; + } else { + line.innerHTML = '⬇ ' + fname + ' (' + sizeStr + ') — run webdav-config first to enable downloads'; + } + this.terminal.outputContainer.appendChild(line); + this.terminal.scrollToBottom(); + }; + this.commands['vfs-export'] = () => { + const data = this.terminal.fs.root; + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], {type: 'application/json'}); + const sizeMB = blob.size / (1024 * 1024); + if (sizeMB > 5) { + this.terminal.print('Warning: VFS size is ' + sizeMB.toFixed(1) + ' MB — large download may take time', 'warning'); + } + const now = new Date(); + const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'webx11-filesystem-' + dateStr + '.json'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Exported VFS to webx11-filesystem-' + dateStr + '.json', 'success'); + }; + this.commands['vfs-size'] = () => { + try { + const json = JSON.stringify(this.terminal.fs.root); + const totalSize = (json.length / 1024 / 1024).toFixed(2); + const root = this.terminal.fs.root; + const sizeMap = []; + function walk(node, path) { + if (!node || !node.content) return; + for (const [name, child] of Object.entries(node.content)) { + 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) }); + } else if (child.type === 'dir') { + walk(child, fullPath); + } + } + } + walk(root, ''); + sizeMap.sort((a, b) => b.size - a.size); + 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']; + for (const dir of sysDirs) { + const dirNode = this.terminal.fs.readDir(dir); + 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'); + } + } + } catch (e) { + this.terminal.print('vfs-size error: ' + e.message, 'error'); + } + }; + + // Session commands (save/load/export/import) + this.commands['session-save'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-save ', 'warning'); + const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_'); + // Strip system dirs to avoid recursive bloat in snapshots + const fsRoot = JSON.parse(JSON.stringify(this.terminal.fs.root)); + if (fsRoot && fsRoot.content) { + delete fsRoot.content['_sessions']; + delete fsRoot.content['_snapshots']; + delete fsRoot.content['.trash']; + } + const snapshot = { + type: 'session', + savedAt: new Date().toISOString(), + fsRoot: fsRoot, + currentPath: this.terminal.fs.currentPath, + version: 1 + }; + const filePath = '_sessions/' + name + '.json'; + this.terminal.fs.mkdirRecursive('_sessions'); + this.terminal.fs.writeFile(filePath, JSON.stringify(snapshot, null, 2)); + this.terminal.print('Session saved: ' + name, 'success'); + }; + this.commands['session-load'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-load ', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + const content = this.terminal.fs.readFile(filePath); + if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); + try { + const session = JSON.parse(content); + if (!session || session.type !== 'session' || !session.fsRoot) { + return this.terminal.print('Invalid session file: ' + args[0], 'error'); + } + this.terminal.fs.root = session.fsRoot; + this.terminal.fs.currentPath = session.currentPath || []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success'); + this.terminal.executeCommand('ls'); + } catch (e) { + this.terminal.print('Failed to load session: ' + e.message, 'error'); + } + }; + this.commands['session-list'] = () => { + this.terminal.fs.mkdirRecursive('_sessions'); + const files = this.terminal.fs.ls('_sessions'); + if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info'); + this.terminal.print('Saved sessions:', 'text-muted'); + files.forEach(f => { + if (f.type === 'file' && f.name.endsWith('.json')) { + try { + const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name)); + const label = f.name.replace('.json', ''); + const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown'; + const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0; + this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')'); + } catch (e) { + this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)'); + } + } + }); + }; + this.commands['session-delete'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-delete ', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + if (this.terminal.fs.deleteFile(filePath)) { + this.terminal.print('Session deleted: ' + args[0], 'success'); + } else { + this.terminal.print('Session not found: ' + args[0], 'error'); + } + }; + this.commands['session-export'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: session-export ', 'warning'); + const filePath = '_sessions/' + args[0] + '.json'; + const content = this.terminal.fs.readFile(filePath); + if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); + const blob = new Blob([content], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'webx11-session-' + args[0] + '.json'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); + this.terminal.print('Exported session: ' + args[0], 'success'); + }; + this.commands['session-import'] = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const session = JSON.parse(evt.target.result); + if (!session || session.type !== 'session' || !session.fsRoot) { + this.terminal.print('Invalid session file (not a valid session export)', 'error'); + return; + } + this.terminal.fs.root = session.fsRoot; + this.terminal.fs.currentPath = session.currentPath || []; + this.terminal.fs.saveToStorage(); + this.terminal.updatePrompt(); + this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success'); + this.terminal.executeCommand('ls'); + } catch (err) { + this.terminal.print('Failed to import session: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }; + this.commands['vfs-import'] = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + try { + const data = JSON.parse(evt.target.result); + if (!data || !data.type) { + this.terminal.print('Invalid VFS file', 'error'); + return; + } + if (this.terminal.fs.restoreFromExport(data)) { + this.terminal.updatePrompt(); + this.terminal.print('VFS restored from: ' + file.name, 'success'); + this.terminal.executeCommand('ls'); + } else { + this.terminal.print('Failed to restore VFS', 'error'); + } + } catch (err) { + this.terminal.print('Failed to import VFS: ' + err.message, 'error'); + } + }; + reader.readAsText(file); + }; + input.click(); + }; + } + _printTree(dir, prefix, lines) { + const names = Object.keys(dir.content || {}).sort(); + names.forEach((name, i) => { + const isLast = i === names.length - 1; + const node = dir.content[name]; + const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; + const label = node.type === 'dir' ? name + '/' : name; + lines.push(prefix + connector + label); + if (node.type === 'dir') { + this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines); + } + }); + } +} + +/* === module-ollama.js === */ +// Extracted from newOS4 index.html — OllamaModule (merged with OllamaModelsModule) +// Commands: ollama, ollamaconnect, ollamadisconnect, ollamamodels, ollamasetmodel, ollamasetproxy, models, model-info, model-pull, model-delete, model-run, ai, ask +class OllamaModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'ollama': 'ollama — Open the Ollama model manager UI', + 'ollamaconnect': 'ollamaconnect — Connect to the Ollama server', + 'ollamadisconnect': 'ollamadisconnect — Disconnect from the Ollama server', + 'ollamamodels': 'ollamamodels — List all available models from Ollama', + 'models': 'models — List all available Ollama models (table format)', + 'model-info': 'model-info — Show details about a specific model', + 'model-pull': 'model-pull — Pull a model from Ollama registry', + 'model-delete': 'model-delete — Delete a model from local storage', + 'model-run': 'model-run [prompt] — Run inference with a model', + 'ollamasetmodel': 'ollamasetmodel — Set the active Ollama model', + 'ollamasetproxy': 'ollamasetproxy [endpoint] — View or change the Ollama endpoint URL', + }; + } + registerCommands() { + this.commands['ollama'] = () => { this.terminal.openOllamaManager(); }; + this.commands['ollamaconnect'] = async () => { + await this.terminal.ollamaManager.connect(); + this.terminal.updateOllamaStatus(); + }; + this.commands['ollamadisconnect'] = () => { + this.terminal.ollamaManager.connected = false; + this.terminal.ollamaManager.models = []; + this.terminal.print('Ollama disconnected', 'info'); + this.terminal.updateOllamaStatus(); + }; + this.commands['ollamamodels'] = () => { + // Redirect to the consolidated models command + this.listModels(); + }; + this.commands['ollamasetmodel'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: ollamasetmodel ", 'warning'); + this.terminal.ollamaManager.setModel(args[0]); + }; + this.commands['ollamasetproxy'] = (args) => { + if(!args[0]) { + const ep = this.terminal.ollamaManager.endpoint; + return this.terminal.print(`Current Ollama endpoint: ${ep}`, 'info'); + } + this.terminal.ollamaManager.setEndpoint(args[0]); + this.terminal.print(`Ollama endpoint set to: ${args[0]}`, 'success'); + }; + this.commands['ai'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ai ", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + this.commands['ask'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ask ", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + // Merged from OllamaModelsModule + this.commands['models'] = () => this.listModels(); + this.commands['model-info'] = (args) => this.modelInfo(args); + this.commands['model-pull'] = (args) => this.pullModel(args); + this.commands['model-delete'] = (args) => this.deleteModel(args); + this.commands['model-run'] = (args) => this.runModel(args); + } + async listModels() { + const endpoint = this.terminal?.ollamaManager?.endpoint || localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + try { + const response = await fetch(`${endpoint}/api/tags`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const models = data.models || []; + if (models.length === 0) { + this.terminal.print("No models found. Run 'ollama pull ' first.", "warning"); return; + } + this.terminal.print("=== Available Ollama Models ===", "info"); + this.terminal.printHTML(` + + + + + `); + models.forEach(m => { + const sizeGB = (m.size / 1e9).toFixed(2); + this.terminal.printHTML(` + + + + `); + }); + this.terminal.printHTML(`
NameSizeFamily
${m.name}${sizeGB} GB${m.details?.family || 'N/A'}
`); + } catch (e) { + this.terminal.print(`Failed to fetch models: ${e.message}`, "error"); + } + } + async modelInfo(args) { + if (!args[0]) { + this.terminal.print("Usage: model-info ", "warning"); return; + } + const endpoint = this.terminal?.ollamaManager?.endpoint || localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; + try { + const response = await fetch(`${endpoint}/api/show`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: args[0] }) + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.terminal.print(`=== ${args[0]} ===`, "info"); + this.terminal.print(`License: ${data.license || 'N/A'}`); + this.terminal.print(`Parameters: ${data.details?.parameter_size || 'N/A'}`); + this.terminal.print(`Family: ${data.details?.family || 'N/A'}`); + } catch (e) { + this.terminal.print(`Failed to get model info: ${e.message}`, "error"); + } + } + pullModel(args) { + this.terminal.print("⚠️ Model pulling must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama pull ${args[0] || ''}`, "info"); + } + deleteModel(args) { + this.terminal.print("⚠️ Model deletion must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama rm ${args[0] || ''}`, "info"); + } + async runModel(args) { + if (!args[0]) { + this.terminal.print("Usage: model-run [prompt]", "warning"); return; + } + const model = args[0]; + const prompt = args.slice(1).join(' ') || 'Hello!'; + this.terminal.print(`Running ${model}...`, "info"); + await this.terminal.ollamaManager.chat(prompt, model); + } +} + +/* === module-my.js === */ +class MyModule { + constructor() { + this.commands = {}; this.terminal = null; this.userData = {}; + try { const d = localStorage.getItem('webx11_my_data'); if (d) this.userData = JSON.parse(d); } catch (e) {} + } + getHelpInfo() { + return { + 'my': 'my — Show module info and stored key count', + 'my-set': 'my-set — Store a key-value pair', + 'my-get': 'my-get — Retrieve a stored value by key', + 'my-list': 'my-list — List all stored keys', + 'my-export': 'my-export — Export all stored data as JSON', + 'my-import': 'my-import — Import data from JSON string', + 'my-clear': 'my-clear — Clear all stored data' + }; + } + _save() { + try { localStorage.setItem('webx11_my_data', JSON.stringify(this.userData)); } catch (e) {} + } + registerCommands() { + this.commands['my'] = () => this.showInfo(); + this.commands['my-set'] = (args) => this.setData(args); + this.commands['my-get'] = (args) => this.getData(args); + this.commands['my-list'] = () => this.listData(); + this.commands['my-export'] = () => this.exportData(); + this.commands['my-import'] = (args) => this.importData(args); + this.commands['my-clear'] = () => this.clearData(); + } + showInfo() { + this.terminal.print("=== My Module v2.0 ===", "info"); + this.terminal.print("Personal workspace for custom commands"); + this.terminal.print(`Stored keys: ${Object.keys(this.userData).length}`, "success"); + } + setData(args) { + if (args.length < 2) { + this.terminal.print("Usage: my-set ", "warning"); return; + } + const key = args[0], value = args.slice(1).join(' '); + this.userData[key] = { value, timestamp: new Date().toISOString() }; + this._save(); + this.terminal.print(`Saved: ${key} = ${value}`, "success"); + } + getData(args) { + if (!args[0]) { + this.terminal.print("Usage: my-get ", "warning"); return; + } + if (this.userData[args[0]]) { + this.terminal.print(`${args[0]}: ${this.userData[args[0]].value}`, "info"); + } else { + this.terminal.print(`Key '${args[0]}' not found`, "error"); + } + } + listData() { + const keys = Object.keys(this.userData); + if (!keys.length) return this.terminal.print('No data stored.', 'info'); + this.terminal.print('=== My Data ===', 'info'); + keys.forEach(k => { + const v = this.userData[k]; + const type = typeof v.value; + this.terminal.print(` ${k} (${type}): ${String(v.value).substring(0, 40)}`); + }); + } + exportData() { + this.terminal.print(JSON.stringify(this.userData, null, 2)); + } + importData(args) { + if (!args[0]) return this.terminal.print('Usage: my-import ', 'warning'); + try { + const data = JSON.parse(args.join(' ')); + Object.assign(this.userData, data); + this._save(); + this.terminal.print(`Imported ${Object.keys(data).length} keys`, 'success'); + } catch (e) { + this.terminal.print('Invalid JSON: ' + e.message, 'error'); + } + } + clearData() { + this.userData = {}; + localStorage.removeItem('webx11_my_data'); + this.terminal.print("Personal data cleared", "success"); + } +} + +/* === module-modmgr.js === */ +// Extracted from newOS4 index.html — ModuleManagerModule +// v4.5.43: Enhanced with module-info, module-list, module-stats, module-disable/enable, +// module-reload, module-reset. Categories: CORE/EXT/FOUNDATION. + class ModuleManagerModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'modules': 'modules — Open the module manager GUI', + 'listmods': 'listmods — List all loaded modules with status', + 'modules-list': 'modules-list [core|ext] — List all available modules from the module index', + 'module-list': 'module-list [flags] — List loaded modules grouped by category. Flags: --flat, --core, --ext, --foundation, --lazy, --eager, --loaded, --unloaded, --search=, --failed, --disabled', + 'module-info': 'module-info — Show module details: path, status, size, type, source, commands list', + 'module-stats': 'module-stats — Show aggregate module statistics (loaded/unloaded/lazy/failed/disabled + total size)', + 'module-load': 'module-load — Mark a lazy module as loaded', + 'module-unload': 'module-unload — Mark a module as unloaded', + 'module-reload': 'module-reload — Unload then reload a module (shows diff)', + 'module-reset': 'module-reset — Reset a module to a clean state (clears its state, keeps commands)', + 'module-disable': 'module-disable — Disable a module from auto-loading on refresh (persists)', + 'module-enable': 'module-enable — Re-enable a previously disabled module', + 'loadmod': 'loadmod — Load a module from a URL', + 'removemod': 'removemod — Remove a loaded module by name', + }; + } + // Helper: read disabled list from localStorage + _getDisabled() { + try { return JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } + catch { return []; } + } + _setDisabled(list) { + localStorage.setItem('webx11_module_disabled', JSON.stringify(list)); + } + // Helper: get category for a module (CORE / EXTENSION / FOUNDATION / UNKNOWN) + _getCategory(mod) { + if (!mod) return 'UNKNOWN'; + if (mod.path && mod.path.includes('/modules/ext/')) return 'EXTENSION'; + if (mod.path && mod.path.includes('/modules/core/')) return 'CORE'; + if (mod.type === 'foundation' || (mod.path && mod.path.includes('FOUNDATION'))) return 'FOUNDATION'; + return 'CORE'; // default + } + // Helper: collect all modules (from registry) into an array of {name, mod} + _collectAll() { + const mm = this.terminal.moduleManager; + const result = []; + const allMods = mm.modules || new Map(); + // moduleManager.modules is a Map + if (allMods instanceof Map) { + for (const [name, mod] of allMods) { + result.push({ name, mod }); + } + } else { + for (const name of Object.keys(allMods)) { + result.push({ name, mod: allMods[name] }); + } + } + return result; + } + // Helper: get a single module by name + _getMod(name) { + const mm = this.terminal.moduleManager; + if (mm.modules instanceof Map) return mm.modules.get(name); + return mm.modules[name]; + } + registerCommands() { + this.commands['modules'] = () => { this.terminal.openModuleManager(); }; + this.commands['loadmod'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: loadmod ", 'warning'); + await this.terminal.moduleManager.loadModuleFromURL(args[0]); + this.terminal.renderModuleList(); + }; + this.commands['removemod'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: removemod ", 'warning'); + if(this.terminal.moduleManager.unregisterModule(args[0])) { + this.terminal.print(`Module '${args[0]}' removed`, 'success'); + } else { + this.terminal.print(`Module not found: ${args[0]}`, 'error'); + } + }; + this.commands['listmods'] = () => { + const mods = this.terminal.moduleManager.getModuleList(); + if(mods.length === 0) return this.terminal.print("No modules loaded"); + mods.forEach(m => { + const status = m.loaded ? '✓' : '✗'; + const badge = m.url ? '' : ' [Built-in]'; + this.terminal.print(`${status} ${m.name}${badge}`); + }); + }; + this.commands['modules-list'] = async (args) => { + try { + const filter = (args[0] || '').toLowerCase(); + const resp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); + if (!resp.ok) return this.terminal.print('Failed to load module index', 'error'); + const idx = await resp.json(); + let mods = Object.values(idx.modules); + if (filter === 'core' || filter === 'ext') mods = mods.filter(m => m.type === filter); + if (filter === 'infra') mods = mods.filter(m => m.layer === 'infrastructure'); + const loaded = this.terminal.moduleManager.modules || {}; + const loadedCount = Object.keys(loaded).length; + const byLayer = {}; + mods.forEach(m => { + const l = m.layer || m.type || 'other'; + if (!byLayer[l]) byLayer[l] = []; + byLayer[l].push(m); + }); + const layerSummary = Object.entries(byLayer).map(([l, ms]) => `${ms.length} ${l}`).join(', '); + this.terminal.print(`📦 Module Index (${mods.length} modules: ${layerSummary} — ${loadedCount} loaded)`, 'header'); + for (const [layer, layerMods] of Object.entries(byLayer)) { + this.terminal.print(` ── ${layer.toUpperCase()} (${layerMods.length}) ──`, 'info'); + for (const m of layerMods) { + const isLoaded = loaded[m.class] ? '✓' : ' '; + const layerTag = m.layer === 'infrastructure' ? '[infra]' : m.type === 'core' ? '[core]' : '[ext]'; + const cmdHint = m.commands.length > 0 ? ` ${m.commands.slice(0, 4).join(', ')}${m.commands.length > 4 ? '...' : ''}` : ''; + const desc = m.description ? ` — ${m.description.substring(0, 60)}` : ''; + this.terminal.print(` ${isLoaded} ${layerTag} ${m.name}${cmdHint}${desc}`, ''); + } + } + } catch (e) { + this.terminal.print(`modules-list error: ${e.message}`, 'error'); + } + }; + + // ── v4.5.43 enhanced module commands ───────────────────── + + // module-list [flags] — grouped by category with filters + this.commands['module-list'] = (args) => { + const flags = args.filter(a => a.startsWith('--')); + const allMods = this._collectAll(); + const disabled = this._getDisabled(); + + // Apply filters + let filtered = allMods; + if (flags.includes('--core')) { + filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'CORE'); + } + if (flags.includes('--ext')) { + filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'EXTENSION'); + } + if (flags.includes('--foundation')) { + filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'FOUNDATION'); + } + if (flags.includes('--lazy')) { + filtered = filtered.filter(({ mod }) => mod.lazy); + } + if (flags.includes('--eager')) { + filtered = filtered.filter(({ mod }) => !mod.lazy); + } + if (flags.includes('--loaded')) { + filtered = filtered.filter(({ mod }) => mod.loaded !== false); + } + if (flags.includes('--unloaded')) { + filtered = filtered.filter(({ mod }) => mod.loaded === false); + } + if (flags.includes('--failed')) { + filtered = filtered.filter(({ mod }) => mod.status === 'failed' || mod.error); + } + if (flags.includes('--disabled')) { + filtered = filtered.filter(({ name }) => disabled.includes(name)); + } + const searchFlag = flags.find(f => f.startsWith('--search=')); + if (searchFlag) { + const pattern = searchFlag.split('=')[1].toLowerCase(); + filtered = filtered.filter(({ name, mod }) => + name.toLowerCase().includes(pattern) || + (mod.description || '').toLowerCase().includes(pattern) + ); + } + + if (filtered.length === 0) { + this.terminal.print('No modules match filters', 'info'); + return; + } + + const flat = flags.includes('--flat'); + + if (flat) { + // Old format: wall of text + this.terminal.print(`📦 Modules (${filtered.length} of ${allMods.length})`, 'header'); + for (const { name, mod } of filtered) { + const size = mod.size ? `${(mod.size / 1024).toFixed(1)}KB` : '?KB'; + const lazy = mod.lazy ? ' [L]' : ''; + const loaded = mod.loaded !== false ? '✓' : '✗'; + const isDisabled = disabled.includes(name) ? ' [DISABLED]' : ''; + this.terminal.print(` ${loaded} ${name.padEnd(28)} ${size.padStart(8)}${lazy}${isDisabled}`); + } + } else { + // Grouped by category + const groups = { CORE: [], EXTENSION: [], FOUNDATION: [], UNKNOWN: [] }; + for (const { name, mod } of filtered) { + const cat = this._getCategory(mod); + groups[cat].push({ name, mod }); + } + for (const cat of ['CORE', 'EXTENSION', 'FOUNDATION', 'UNKNOWN']) { + if (groups[cat].length === 0) continue; + this.terminal.print(`── ${cat} (${groups[cat].length}) ──`, 'info'); + for (const { name, mod } of groups[cat]) { + const size = mod.size ? `${(mod.size / 1024).toFixed(1)}KB` : '?KB'; + const lazy = mod.lazy ? ' [L]' : ''; + const loaded = mod.loaded !== false ? '✓' : '✗'; + const isDisabled = disabled.includes(name) ? ' [DISABLED]' : ''; + const errMark = (mod.status === 'failed' || mod.error) ? ' ⚠️' : ''; + this.terminal.print(` ${loaded} ${name.padEnd(28)} ${size.padStart(8)}${lazy}${isDisabled}${errMark}`); + } + } + } + + // Aggregate summary + const totalSize = filtered.reduce((sum, { mod }) => sum + (mod.size || 0), 0); + const lazyCount = filtered.filter(({ mod }) => mod.lazy).length; + const failedCount = filtered.filter(({ mod }) => mod.status === 'failed' || mod.error).length; + const disabledCount = filtered.filter(({ name }) => disabled.includes(name)).length; + this.terminal.print('─'.repeat(50), 'text-muted'); + this.terminal.print( + `Total: ${filtered.length} modules, ${(totalSize / 1024).toFixed(1)}KB, ` + + `${lazyCount} lazy, ${failedCount} failed, ${disabledCount} disabled`, + failedCount > 0 ? 'warning' : 'info' + ); + }; + + // module-info — detailed view including commands + this.commands['module-info'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-info ', 'warning'); + const mod = this._getMod(args[0]); + if (!mod) return this.terminal.print(`Module not found: ${args[0]}`, 'error'); + + const disabled = this._getDisabled(); + const isDisabled = disabled.includes(args[0]); + const cat = this._getCategory(mod); + const sizeKB = mod.size ? (mod.size / 1024).toFixed(1) : '?'; + const commandCount = mod.commands ? Object.keys(mod.commands).length : 0; + const status = mod.status || (mod.loaded !== false ? 'loaded' : 'unloaded'); + + this.terminal.print(`📋 Module: ${args[0]}`, 'header'); + this.terminal.print(` Status: ${status}${isDisabled ? ' [DISABLED]' : ''}`); + this.terminal.print(` Category: ${cat}`); + this.terminal.print(` Type: ${mod.type || 'unknown'}`); + this.terminal.print(` Path: ${mod.path || 'unknown'}`); + this.terminal.print(` Size: ${sizeKB} KB`); + this.terminal.print(` Lazy: ${mod.lazy ? 'yes' : 'no'}`); + this.terminal.print(` Commands: ${commandCount}`); + + if (mod.description) { + this.terminal.print(` Description: ${mod.description}`); + } + + // List the commands + if (mod.commands && commandCount > 0) { + this.terminal.print(` ── Commands (${commandCount}) ──`, 'info'); + const cmds = Object.keys(mod.commands).sort(); + // Print in 2 columns + const half = Math.ceil(cmds.length / 2); + for (let i = 0; i < half; i++) { + const left = cmds[i] || ''; + const right = cmds[i + half] || ''; + this.terminal.print(` ${left.padEnd(24)} ${right}`); + } + } + + if (mod.error) { + this.terminal.print(` ⚠️ Error: ${mod.error}`, 'error'); + } + }; + + // module-stats — aggregate view + this.commands['module-stats'] = () => { + const all = this._collectAll(); + const disabled = this._getDisabled(); + const loaded = all.filter(({ mod }) => mod.loaded !== false).length; + const unloaded = all.filter(({ mod }) => mod.loaded === false).length; + const lazy = all.filter(({ mod }) => mod.lazy).length; + const eager = all.length - lazy; + const failed = all.filter(({ mod }) => mod.status === 'failed' || mod.error).length; + const disabledCount = all.filter(({ name }) => disabled.includes(name)).length; + const totalSize = all.reduce((sum, { mod }) => sum + (mod.size || 0), 0); + const totalCmds = all.reduce((sum, { mod }) => sum + (mod.commands ? Object.keys(mod.commands).length : 0), 0); + + this.terminal.print('📊 Module Stats', 'header'); + this.terminal.print(` Total: ${all.length} modules`); + this.terminal.print(` Loaded: ${loaded}`); + this.terminal.print(` Unloaded: ${unloaded}`); + this.terminal.print(` Lazy: ${lazy}`); + this.terminal.print(` Eager: ${eager}`); + this.terminal.print(` Failed: ${failed}`, failed > 0 ? 'warning' : ''); + this.terminal.print(` Disabled: ${disabledCount}`, disabledCount > 0 ? 'warning' : ''); + this.terminal.print(` Total size: ${(totalSize / 1024).toFixed(1)} KB`); + this.terminal.print(` Total cmds: ${totalCmds}`); + }; + + // module-disable — persists in localStorage + this.commands['module-disable'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-disable ', 'warning'); + const name = args[0]; + if (!this._getMod(name)) return this.terminal.print(`Module not found: ${name}`, 'error'); + const list = this._getDisabled(); + if (list.includes(name)) { + return this.terminal.print(`Module '${name}' is already disabled`, 'info'); + } + list.push(name); + this._setDisabled(list); + this.terminal.print(`✅ Module '${name}' disabled (will not auto-load on next refresh)`, 'success'); + this.terminal.print(` To re-enable: module-enable ${name}`, 'info'); + }; + + // module-enable — removes from disabled list + this.commands['module-enable'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-enable ', 'warning'); + const name = args[0]; + const list = this._getDisabled(); + if (!list.includes(name)) { + return this.terminal.print(`Module '${name}' is not disabled`, 'info'); + } + const newList = list.filter(n => n !== name); + this._setDisabled(newList); + this.terminal.print(`✅ Module '${name}' re-enabled (will auto-load on next refresh)`, 'success'); + }; + + // module-load — mark a lazy module as loaded + this.commands['module-load'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-load ', 'warning'); + const name = args[0]; + const mod = this._getMod(name); + if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); + if (mod.loaded !== false) { + return this.terminal.print(`Module '${name}' is already loaded`, 'info'); + } + mod.loaded = true; + this.terminal.print(`✅ Module '${name}' marked as loaded`, 'success'); + }; + + // module-unload — mark as unloaded + this.commands['module-unload'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-unload ', 'warning'); + const name = args[0]; + const mod = this._getMod(name); + if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); + if (mod.loaded === false) { + return this.terminal.print(`Module '${name}' is already unloaded`, 'info'); + } + mod.loaded = false; + this.terminal.print(`✅ Module '${name}' marked as unloaded (commands will not be available)`, 'success'); + }; + + // module-reload — unload + reload with diff + this.commands['module-reload'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-reload ', 'warning'); + const name = args[0]; + const mod = this._getMod(name); + if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); + const wasLoaded = mod.loaded !== false; + const wasLazy = mod.lazy; + this.terminal.print(`🔄 Reloading '${name}' (was: ${wasLoaded ? 'loaded' : 'unloaded'}, lazy=${wasLazy})`, 'info'); + mod.loaded = false; + setTimeout(() => { + mod.loaded = true; + this.terminal.print(`✅ Module '${name}' reloaded`, 'success'); + const cmdCount = mod.commands ? Object.keys(mod.commands).length : 0; + this.terminal.print(` Now: loaded=true, lazy=${mod.lazy}, ${cmdCount} commands`, 'info'); + }, 100); + }; + + // module-reset — clear module state and reload + this.commands['module-reset'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-reset ', 'warning'); + const name = args[0]; + const mod = this._getMod(name); + if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); + this.terminal.print(`🔄 Resetting module '${name}' state...`, 'info'); + // Unload + mod.loaded = false; + // Clear any state-related localStorage keys for this module + let clearedKeys = 0; + try { + for (let i = localStorage.length - 1; i >= 0; i--) { + const key = localStorage.key(i); + // Match module-specific keys (e.g., "webx11__..." or "webx11_") + if (key && (key === `webx11_${name}` || key.startsWith(`webx11_${name}_`) || key.startsWith(`${name}_`))) { + localStorage.removeItem(key); + clearedKeys++; + } + } + } catch (e) {} + // Reload after a tick + setTimeout(() => { + mod.loaded = true; + this.terminal.print(`✅ Module '${name}' reset (cleared ${clearedKeys} localStorage keys)`, 'success'); + }, 100); + }; + } + } + +/* === module-searxng.js === */ +class SearXNGModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'search': 'search [--summarize/-s] [--fetch/-f] [--images/-i] [--videos/-v] — Search using SearXNG; --summarize/-s adds AI overview, --fetch/-f fetches page content, --images/-i searches for images, --videos/-v searches for videos', + 's': 's [--summarize] [--fetch] [--images] [--videos] — Alias for search', + 'images': 'images — Search for images', + 'videos': 'videos — Search for videos', + }; + } + registerCommands() { + this.commands['search'] = (args) => this.search(args); + this.commands['s'] = (args) => this.search(args); + this.commands['images'] = (args) => this.search(args, 'images'); + this.commands['videos'] = (args) => this.search(args, 'videos'); + } + async search(args, forceCategory) { + if (!args[0] && !forceCategory) { + this.terminal.print("Usage: search [--summarize] [--fetch] [--images] [--videos]", "warning"); + this.terminal.print("Example: search quantum computing --summarize", "info"); + this.terminal.print("Example: search cats --images", "info"); + this.terminal.print("Example: search cats --videos", "info"); + this.terminal.print("Shortcuts: images or videos ", "info"); + return; + } + const argsList = args.slice(); + const summarize = argsList.includes('--summarize') || argsList.includes('-s'); + const fetchContent = argsList.includes('--fetch') || argsList.includes('-f'); + const wantImages = forceCategory === 'images' || argsList.includes('--images') || argsList.includes('-i'); + const wantVideos = forceCategory === 'videos' || argsList.includes('--videos') || argsList.includes('-v'); + const query = argsList.filter(a => !a.startsWith('-')).join(' '); + if (!query) { this.terminal.print('Search query is required', 'warning'); return; } + + const category = wantImages ? 'images' : (wantVideos ? 'videos' : 'general'); + const categoryLabel = category === 'images' ? '🖼️' : (category === 'videos' ? '🎬' : '🔍'); + + this.terminal.print(`${categoryLabel} Searching ${category}: ${query}`, 'info'); + if (summarize) this.terminal.print('⏳ AI overview will be generated (1-3s)...', 'info'); + + let params = 'q=' + encodeURIComponent(query) + '&categories=' + category; + if (summarize) params += '&summarize=true'; + if (fetchContent) params += '&fetch_content=true'; + + try { + // 30s client-side timeout (matches module-fetcher) + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 30000); + const resp = await fetch('/api/search?' + params, { signal: controller.signal }); + clearTimeout(timer); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Search failed: ' + (err.error || 'HTTP ' + resp.status), 'error'); + return; + } + const data = await resp.json(); + const results = data.results || []; + + if (results.length === 0) { + this.terminal.print('No results found.', 'info'); + return; + } + + this.terminal.print(`\n=== ${results.length} results (${category}, via ${data.source || 'unknown'}) ===\n`, 'success'); + + if (data.ai_overview) { + this.terminal.print('🤖 AI Overview:', 'info'); + this.terminal.print(data.ai_overview, 'ollama'); + this.terminal.print(''); + } + + results.forEach((r, i) => { + const snippet = r.content || ''; + const contentSnippet = r.content_snippet || ''; + const imgSrc = r.img_src || r.thumbnail_src || ''; + const videoThumb = r.thumbnail || ''; + + if (category === 'images' && imgSrc) { + this.terminal.printHTML(` +
+ ${r.title || ''} +
${i + 1}. ${r.title || 'No Title'} +
+ `); + } else if (category === 'videos' && videoThumb) { + this.terminal.printHTML(` +
+ ${r.title || ''} +
${i + 1}. ${r.title || 'No Title'}
+ ${r.url || ''} +
+ `); + } else { + this.terminal.printHTML(` +
+ ${i + 1}. ${r.title || 'No Title'}
+ ${r.url || 'No URL'}
+ ${(snippet || '').substring(0, 200)}... + ${contentSnippet ? `
📄 ${contentSnippet.substring(0, 150)}...` : ''} + ${imgSrc && category === 'general' ? `
🖼️ View image` : ''} +
+ `); + } + }); + } catch (e) { + if (e.name === 'AbortError') { + this.terminal.print('⏱ Search timed out (30s). Try removing --summarize or --fetch flags.', 'error'); + } else { + this.terminal.print('Search failed: ' + e.message, 'error'); + } + } + } +} + +/* === module-servermon.js === */ +// module-servermon.js — Server monitoring + self-optimization telemetry +// Commands: server, servers, server-check, server-remove, monitor, system-health +class ServerMonitorModule { + constructor() { + this.commands = {}; this.terminal = null; + this.servers = this.loadServers(); this.monitorInterval = null; + this._latencyLog = []; + this._maxLatencyEntries = 200; + } + getHelpInfo() { + return { + 'server': 'server — Add a server to monitor', + 'servers': 'servers — List all monitored servers', + 'server-check': 'server-check — Check a server status', + 'server-remove': 'server-remove — Remove a server', + 'monitor': 'monitor — Toggle auto-monitoring (checks every 60s)', + 'system-health': 'system-health — Show tool latency, module load times, VFS size, and error rates', + 'test-net': 'test-net — Check proxy API, Ollama, and internet connectivity', + }; + } + registerCommands() { + this.commands['server'] = (args) => this.addServer(args); + this.commands['servers'] = () => this.listServers(); + this.commands['server-check'] = (args) => this.checkServer(args); + this.commands['server-remove'] = (args) => this.removeServer(args); + this.commands['monitor'] = (args) => this.toggleMonitor(args); + this.commands['system-health'] = () => this.showSystemHealth(); + // v4.5.43: test-net — check proxy/ollama/internet connectivity + this.commands['test-net'] = () => this.testNetwork(); + } + async testNetwork() { + this.terminal.print('🌐 Network Test', 'header'); + // 1. Browser online + const online = navigator.onLine; + this.terminal.print(` ${online ? '✅' : '🔴'} Browser: ${online ? 'online' : 'offline'}`, online ? 'success' : 'error'); + // 2. Proxy API + try { + const t0 = Date.now(); + const resp = await fetch('/api/stats', { signal: AbortSignal.timeout(5000) }); + const ms = Date.now() - t0; + if (resp.ok) { + const data = await resp.json(); + this.terminal.print(` ✅ Proxy: ok (${ms}ms, uptime ${data.uptime_seconds || '?'}s)`, 'success'); + } else { + this.terminal.print(` 🔴 Proxy: HTTP ${resp.status}`, 'error'); + } + } catch (e) { + this.terminal.print(` 🔴 Proxy: ${e.message || 'unreachable'}`, 'error'); + } + // 3. Ollama + const ollamaOk = this.terminal.ollamaManager && this.terminal.ollamaManager.connected; + this.terminal.print(` ${ollamaOk ? '✅' : '🟡'} Ollama: ${ollamaOk ? 'connected' : 'disconnected'}`, ollamaOk ? 'success' : 'warning'); + // 4. Internet (test by fetching a small file) + try { + const t0 = Date.now(); + const resp = await fetch('https://duckduckgo.com/?q=test', { method: 'HEAD', signal: AbortSignal.timeout(5000), mode: 'no-cors' }); + const ms = Date.now() - t0; + this.terminal.print(` ✅ Internet: reachable (${ms}ms)`, 'success'); + } catch (e) { + this.terminal.print(` 🔴 Internet: ${e.message || 'unreachable'}`, 'error'); + } + } + logToolCall(name, durationMs, error) { + this._latencyLog.push({ + name, + duration: durationMs, + error: !!error, + time: Date.now() + }); + if (this._latencyLog.length > this._maxLatencyEntries) { + this._latencyLog = this._latencyLog.slice(-this._maxLatencyEntries); + } + // Persist every 20 calls + if (this._latencyLog.length % 20 === 0) { + this._persistLatency(); + } + } + _persistLatency() { + try { + this.terminal.fs.fsWriteJSON('/system/metrics/tool-latency.json', this._latencyLog.slice(-100)); + } catch (e) {} + } + _loadLatency() { + try { + const data = this.terminal.fs.fsReadJSON('/system/metrics/tool-latency.json'); + if (data) this._latencyLog = data; + } catch (e) {} + } + showSystemHealth() { + this._loadLatency(); + const fs = this.terminal.fs; + + // Tool latency stats + const toolStats = {}; + for (const entry of this._latencyLog) { + if (!toolStats[entry.name]) toolStats[entry.name] = { calls: 0, totalMs: 0, errors: 0, maxMs: 0 }; + toolStats[entry.name].calls++; + toolStats[entry.name].totalMs += entry.duration; + toolStats[entry.name].errors += entry.error ? 1 : 0; + toolStats[entry.name].maxMs = Math.max(toolStats[entry.name].maxMs, entry.duration); + } + + this.terminal.print('📊 System Health', 'header'); + + // Tool latency table (sorted by avg desc) + this.terminal.print('── Tool Latency ──', 'info'); + const sorted = Object.entries(toolStats) + .map(([name, s]) => ({ name, avg: (s.totalMs / s.calls).toFixed(0), ...s })) + .sort((a, b) => b.avg - a.avg); + for (const t of sorted.slice(0, 10)) { + const avgMs = t.avg.padStart(5); + const maxMs = String(t.maxMs).padStart(5); + const err = t.errors > 0 ? ` ⚠️${t.errors}` : ''; + this.terminal.print(` ${avgMs}ms avg / ${maxMs}ms max — ${t.name} (${t.calls} calls)${err}`, t.errors > 0 ? 'warning' : ''); + } + if (sorted.length > 10) this.terminal.print(` ... and ${sorted.length - 10} more tools`, 'info'); + + // VFS stats + this.terminal.print('── VFS Stats ──', 'info'); + try { + const totalSize = this._vfsSize(fs.root); + const fileCount = this._vfsCount(fs.root, 'file'); + const dirCount = this._vfsCount(fs.root, 'dir'); + this.terminal.print(` ${fileCount} files, ${dirCount} dirs, ${(totalSize / 1024).toFixed(0)} KB total`, ''); + } catch (e) { this.terminal.print(' VFS stats unavailable', 'warning'); } + + // Module stats + this.terminal.print('── Module Stats ──', 'info'); + const mm = this.terminal.moduleManager; + const loaded = Object.keys(mm.modules).length; + const reg = (mm.moduleRegistry || []).length; + this.terminal.print(` ${loaded} modules loaded, ${reg} in registry`, ''); + + // Memory + this.terminal.print('── Memory ──', 'info'); + if (performance.memory) { + const heap = (performance.memory.usedJSHeapSize / 1048576).toFixed(1); + const total = (performance.memory.jsHeapSizeLimit / 1048576).toFixed(0); + this.terminal.print(` ${heap} MB used / ${total} MB limit`, heap > 100 ? 'warning' : ''); + } else { + this.terminal.print(' Memory info not available (non-Chrome browser)', ''); + } + + // Total tool call count + const totalCalls = this._latencyLog.length; + const totalErrors = this._latencyLog.filter(e => e.error).length; + this.terminal.print(` ${totalCalls} tool calls logged, ${totalErrors} errors (${totalCalls > 0 ? (totalErrors / totalCalls * 100).toFixed(1) : 0}% error rate)`, totalErrors > 0 ? 'warning' : ''); + } + _vfsSize(node) { + if (!node) return 0; + if (node.type === 'file') return (node.content || '').length; + let s = 0; + for (const k in (node.content || {})) s += this._vfsSize(node.content[k]); + return s; + } + _vfsCount(node, type) { + if (!node) return 0; + let c = 0; + for (const k in (node.content || {})) { + const child = node.content[k]; + if (child.type === type) c++; + c += this._vfsCount(child, type); + } + return c; + } + loadServers() { + try { + const saved = localStorage.getItem('webx11_servers'); + return saved ? JSON.parse(saved) : []; + } catch (e) { return []; } + } + saveServers() { + localStorage.setItem('webx11_servers', JSON.stringify(this.servers)); + } + addServer(args) { + if (args.length < 2) { + this.terminal.print("Usage: server ", "warning"); return; + } + const name = args[0], url = args[1]; + this.servers.push({ name, url, status: 'unknown', lastCheck: null }); + this.saveServers(); + this.terminal.print(`Server '${name}' added`, "success"); + } + listServers() { + if (this.servers.length === 0) { + this.terminal.print("No servers configured", "info"); return; + } + this.terminal.print("=== Monitored Servers ===", "info"); + this.servers.forEach((s, i) => { + const statusIcon = s.status === 'online' ? '🟢' : s.status === 'offline' ? '🔴' : '⚪'; + this.terminal.print(`${i + 1}. ${statusIcon} ${s.name} - ${s.url}`); + if (s.lastCheck) { this.terminal.print(` Last check: ${s.lastCheck}`, "info"); } + }); + } + async checkServer(args) { + if (!args[0]) { + this.terminal.print("Usage: server-check ", "warning"); return; + } + const server = this.findServer(args[0]); + if (!server) { + this.terminal.print("Server not found", "error"); return; + } + this.terminal.print(`Checking ${server.name}...`, "info"); + try { + const start = Date.now(); + const response = await fetch(server.url, { method: 'HEAD', mode: 'no-cors' }); + const latency = Date.now() - start; + server.status = 'online'; server.lastCheck = new Date().toLocaleString(); server.latency = latency; + this.saveServers(); + this.terminal.print(`✅ ${server.name} is online (${latency}ms)`, "success"); + } catch (e) { + server.status = 'offline'; server.lastCheck = new Date().toLocaleString(); + this.saveServers(); + this.terminal.print(`❌ ${server.name} is offline`, "error"); + } + } + removeServer(args) { + if (!args[0]) { + this.terminal.print("Usage: server-remove ", "warning"); return; + } + const index = this.findServerIndex(args[0]); + if (index === -1) { + this.terminal.print("Server not found", "error"); return; + } + this.servers.splice(index, 1); + this.saveServers(); + this.terminal.print("Server removed", "success"); + } + toggleMonitor(args) { + if (this.monitorInterval) { + clearInterval(this.monitorInterval); + this.monitorInterval = null; + this.terminal.print("Auto-monitor stopped", "warning"); + } else { + this.terminal.print("Auto-monitor started (every 60s)", "success"); + this.monitorInterval = setInterval(() => { + this.servers.forEach((s, i) => { this.checkServer([String(i + 1)]); }); + }, 60000); + } + } + findServer(identifier) { + const index = this.findServerIndex(identifier); + return index !== -1 ? this.servers[index] : null; + } + findServerIndex(identifier) { + const idx = parseInt(identifier) - 1; + if (!isNaN(idx) && idx >= 0 && idx < this.servers.length) { return idx; } + return this.servers.findIndex(s => s.name.toLowerCase() === identifier.toLowerCase()); + } + } + +/* === module-config.js === */ +// Extracted from newOS4 index.html — ConfigManagerModule + class ConfigManagerModule { + constructor() { + this.commands = {}; this.terminal = null; + this.configPrefix = 'webx11_config_'; + } + getHelpInfo() { + return { + 'config': 'config [key] — Show all config or a specific key', + 'config-set': 'config-set — Set a configuration value', + 'config-list': 'config-list — List all configuration keys', + 'config-delete': 'config-delete — Delete a configuration key', + 'config-export': 'config-export — Export all config as JSON', + 'config-reset': 'config-reset — Reset all configuration to defaults', + }; + } + registerCommands() { + this.commands['config'] = (args) => this.getConfig(args); + this.commands['config-set'] = (args) => this.setConfig(args); + this.commands['config-list'] = () => this.listConfig(); + this.commands['config-delete'] = (args) => this.deleteConfig(args); + this.commands['config-export'] = () => this.exportConfig(); + this.commands['config-reset'] = () => this.resetConfig(); + } + getConfig(args) { + if (!args[0]) { + this.terminal.print("Usage: config ", "warning"); return; + } + const value = localStorage.getItem(this.configPrefix + args[0]); + if (value) { + this.terminal.print(`${args[0]}: ${value}`, "info"); + } else { + this.terminal.print(`Key '${args[0]}' not found`, "error"); + } + } + setConfig(args) { + if (args.length < 2) { + this.terminal.print("Usage: config-set ", "warning"); return; + } + const key = args[0], value = args.slice(1).join(' '); + localStorage.setItem(this.configPrefix + key, value); + this.terminal.print(`Config saved: ${key} = ${value}`, "success"); + } + listConfig() { + const configs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { + configs[key.replace(this.configPrefix, '')] = localStorage.getItem(key); + } + } + if (Object.keys(configs).length === 0) { + this.terminal.print("No configuration saved", "info"); return; + } + this.terminal.print("=== Configuration ===", "info"); + Object.entries(configs).forEach(([k, v]) => { this.terminal.print(`${k}: ${v}`); }); + } + deleteConfig(args) { + if (!args[0]) { + this.terminal.print("Usage: config-delete ", "warning"); return; + } + localStorage.removeItem(this.configPrefix + args[0]); + this.terminal.print(`Config '${args[0]}' deleted`, "success"); + } + exportConfig() { + const configs = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { + configs[key] = localStorage.getItem(key); + } + } + const json = JSON.stringify(configs, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'webx11-config.json'; a.click(); + URL.revokeObjectURL(url); + this.terminal.print("Configuration exported", "success"); + } + resetConfig() { + if (confirm("Reset all configuration to defaults?")) { + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this.configPrefix)) { keysToRemove.push(key); } + } + keysToRemove.forEach(k => localStorage.removeItem(k)); + this.terminal.print("Configuration reset to defaults", "success"); + } + } + } + +/* === module-simplegui.js === */ +// Extracted from newOS4 index.html — SimpleGUIModule + class SimpleGUIModule { + constructor() { this.commands = {}; this.terminal = null; this.guiElements = {}; } + getHelpInfo() { + return { + 'gui': 'gui — Show the GUI builder overview', + 'gui-button': 'gui-button