diff options
Diffstat (limited to 'modules/core')
| -rw-r--r-- | modules/core/core-bundle.js | 15347 | ||||
| -rw-r--r-- | modules/core/module-aiassist.js | 124 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 167 | ||||
| -rw-r--r-- | modules/core/module-system.js | 16 | ||||
| -rw-r--r-- | modules/core/terminal.js | 60 |
5 files changed, 15546 insertions, 168 deletions
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 <function name="x"><param name="y">val</param> 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: +<function name="function-name"><param name="param-name">param-value</param> + +Available tools: +<tools> +${toolDesc} +</tools> + +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 = /<function\s+name="([^"]+)"(.*?)<\/function>/gs; + let match; + while ((match = funcRegex.exec(text)) !== null) { + const funcName = match[1]; + const args = {}; + const paramRegex = /<param\s+name="([^"]+)"(.*?)>(.*?)<\/param>/gs; + let pmatch; + while ((pmatch = paramRegex.exec(match[0])) !== null) { + args[pmatch[1]] = pmatch[3] + .replace('<![CDATA[', '').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, "<").replace(/>/g, ">"); + line.innerHTML = '<div class="msg-card-header"><span class="msg-role">⚙ System</span><span class="msg-buttons"><button class="card-btn copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button></span></div><div class="msg-card-body">' + safeText + '</div>'; + } 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 = /<style|<html|<head/i.test(content); + const themedContent = hasOwnStyle + ? content + : `<style>body{background:#000;color:#0f0;margin:16px;font-family:monospace;}</style><body style="background:#000;color:#0f0;font-family:monospace;margin:16px">${content}</body>`; + const safeSrcdoc = themedContent + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/`/g, '`'); + return `<div class="ai-render-container"><div style="display:flex;gap:6px;padding:4px 8px;background:var(--bg-tertiary);border-bottom:1px solid var(--border-color);align-items:center;"><span style="color:var(--text-muted);font-size:0.75em;">📄 Rendered HTML</span><span style="flex:1"></span><button class="qa-btn" style="padding:2px 10px;font-size:0.75em;" onclick="webx11.openRenderPreview(this)" data-content="${safeSrcdoc.replace(/"/g, '"')}">⛶ Fullscreen</button></div><iframe sandbox="allow-scripts" srcdoc="${safeSrcdoc}" class="ai-render-iframe" onload="var _s=this;setTimeout(function(){_s.style.height=Math.max((_s.contentWindow.document.documentElement.scrollHeight||200),200)+'px'},100)"></iframe></div>`; + } + _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, "<").replace(/>/g, ">"); + fragments.push({ type: 'text', html: '<div class="ai-content">' + safeBefore + '</div>' }); + } + 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, "<").replace(/>/g, ">"); + fragments.push({ type: 'text', html: '<div class="ai-content">' + safeAfter + '</div>' }); + } + 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 = '<div class="msg-card-header"><span class="msg-role">🤖 AI</span><span class="msg-buttons"><button class="card-btn copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button></span></div><div class="msg-card-body"></div>'; + 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, "<").replace(/>/g, ">"); + wrapper.innerHTML = '<div class="msg-card-header"><span class="msg-role">🤖 AI</span><span class="msg-buttons"><button class="card-btn copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button></span></div><div class="msg-card-body">' + safeText + '</div>'; + this.outputContainer.appendChild(wrapper); + this.scrollToBottom(); + this._trimOutput(); + } + printStreamingResponse(text, append) { + if (!this._streamChunks) this._streamChunks = []; + if (!append) { + const safeText = text.replace(/</g, "<").replace(/>/g, ">"); + const wrapper = document.createElement('div'); + wrapper.className = 'output-line msg-card ai'; + wrapper.innerHTML = '<div class="msg-card-header"><span class="msg-role">🤖 AI</span><span class="msg-buttons"><button class="card-btn copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button></span></div><div class="msg-card-body">' + safeText + '</div>'; + 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, '<').replace(/>/g, '>'); + const hasHtml = /<[a-z][\s\S]*>/i.test(rawInput.trim()); + let buttons = '<button class="card-btn copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button>'; + if (hasHtml) buttons += '<button class="card-btn preview-btn" onclick="webx11.previewHtmlFromPrompt(this)" data-content="' + safe.replace(/"/g, '"') + '">Preview</button>'; + buttons += '<button class="card-btn edit-btn" onclick="webx11.editPrompt(\'' + safe.replace(/'/g, "\\'") + '\')">Edit</button>'; + wrapper.innerHTML = '<div class="msg-card-header"><span class="msg-role">👤 User</span><span class="msg-buttons">' + buttons + '</span></div><div class="msg-card-body">' + safe + '</div>'; + 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, '<').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 = ` + <span class="confirm-desc">⚠️ ${desc}</span> + <button class="confirm-btn" id="cmd-confirm-allow" onclick="(function(){var a=document.getElementById('cmd-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="cmd-confirm-deny" onclick="(function(){var d=document.getElementById('cmd-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: cmd-allow or cmd-deny</span> + `; + 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 + ? `<span class="cmd-name" style="color:var(--accent-color)">${item}</span>` + : `<span class="cmd-name">${item}</span>`; + 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 <key> <value>', '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 <key> <value>', '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 <cmd>', '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 <cmd>', '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 <cmd>', '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 <flag>', '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 <search> 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 = '<div class="text-center" style="padding:20px; color:#666">Directory Empty</div>'; + } else { + files.forEach(f => { + const div = document.createElement('div'); + div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`; + div.innerHTML = `<span>${f.type === 'dir' ? '📁' : '📄'} ${f.name}</span> <span style="font-size:0.8em">${f.size}b</span>`; + 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 = '<div class="text-center" style="padding:20px; color:#666">No modules loaded</div>'; + return; + } + modules.forEach(mod => { + const div = document.createElement('div'); + div.className = `module-item ${mod.loaded ? '' : 'disabled'}`; + const badge = mod.url ? '' : '<span class="module-badge builtin">Built-in</span>'; + div.innerHTML = ` + <div> + <strong>${mod.name}</strong>${badge} + <div style="font-size:0.8em; color:#666">${mod.url || 'Built-in'}</div> + </div> + ${mod.url ? `<button class="btn btn-danger" onclick="webx11.removeModule('${mod.name}')" style="padding:4px 8px;">✕</button>` : ''} + `; + 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 <file.html>', '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 = /<html|<head|<body/i.test(decoded); + const fullDoc = hasOwnDoc ? decoded : `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><style>body{background:#000;color:#0f0;font-family:monospace;margin:16px;}</style></head><body>${decoded}</body></html>`; + 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 = '<div style="padding:20px; text-align:center; color:#666">No configuration saved.</div>'; + 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 `<div class="module-item"> + <div><strong>${e.key}</strong> <span style="color:var(--text-muted)">${displayVal}</span></div> + <button class="btn btn-danger" onclick="webx11.deleteConfigFromUI('${e.key.replace(/'/g, "\\'")}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div>`; + }).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 = '<div style="padding:20px; text-align:center; color:#666">No skills yet. Create one below!</div>'; + return; + } + names.forEach(name => { + const s = index[name]; + const tags = (s.tags || []).join(', '); + const div = document.createElement('div'); + div.className = 'module-item'; + div.innerHTML = ` + <div> + <strong style="color:var(--success-color)">${name}</strong> + <div style="font-size:0.85em; color:#aaa">${s.description || ''}</div> + <div style="font-size:0.75em; color:#666">${s.category || 'general'}${tags ? ' · ' + tags : ''}</div> + </div> + <div style="display:flex;gap:4px"> + <button class="btn" onclick="webx11.viewSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">👁</button> + <button class="btn btn-danger" onclick="webx11.deleteSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">✕</button> + </div> + `; + 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 = '<option value="">Select a model</option>'; + const models = this.ollamaManager.getModels(); + if (models.length === 0) { + container.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Connect to see models</div>'; + 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 = '<div class="text-center" style="padding:20px; color:#888">No models match "' + query + '"</div>'; + return; + } + filtered.forEach(model => { + const div = document.createElement('div'); + div.className = 'model-item'; + div.innerHTML = `<span>${model.name}</span><span style="font-size:0.8em; color:#666">${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}</span>`; + 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 = '<option value="">Connecting...</option>'; } + 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 = '<option value="">Select a validator model</option>'; + 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 + + '<br><span style="font-size:0.85em;color:var(--text-muted);">Make sure Ollama is running and ' + + '<code>OLLAMA_ORIGINS</code> includes this page\'s origin.</span>'; + } + if (select) { select.disabled = true; select.innerHTML = '<option value="">Connection failed — try again</option>'; } + } + } + 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 = '<option value="' + storedModel + '">' + storedModel + '</option><option value="">Disable (use main AI model)</option>'; + 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 = '<option value="">Connect to load models</option>'; + select.disabled = true; + return; + } + const stored = this.ollamaManager.validatorModel || ''; + let html = '<option value="">Disable (use main AI model)</option>'; + models.forEach(function(m) { + const sel = m.name === stored ? ' selected' : ''; + html += '<option value="' + m.name + '"' + sel + '>' + m.name + '</option>'; + }); + 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 <path>', '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 <text...> — 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 <file.html> — 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 <name> — Restore VFS from a checkpoint', + 'checkpoint-del': 'checkpoint-del <name> — 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 <name>', '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 <name>', '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 <command> → 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 <file> — Display file contents', + 'put': 'put <file> <content> — Create or overwrite a file', + 'rm': 'rm <file> — Remove a file (moved to trash); use -r/-rf for directories', + 'trash': 'trash <file> — Move a file to trash', + 'trash-dir': 'trash-dir <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 <dir> [-p] — Create a directory; -p creates parent paths', + 'rmdir': 'rmdir <dir> — Remove a directory', + 'mv': 'mv <source> <dest> — Move file or directory', + 'cp': 'cp <source> <dest> — Copy file or directory', + 'cd': 'cd <path> — Change current directory; cd / to go to root', + 'pwd': 'pwd — Print current working directory', + 'find': 'find <pattern> — Search files by name pattern', + 'grep': 'grep <pattern> [file] — Search file content for pattern', + 'head': 'head <file> [lines] — Show first N lines (default: 10)', + 'tail': 'tail <file> [lines] — Show last N lines (default: 10)', + 'sort': 'sort <file> — Sort lines of a file alphabetically', + 'wc': 'wc <file> — Count lines, words, and bytes', + 'touch': 'touch <file> — 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 <file> — 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 <filename>', '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 <filename> <content>', '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 <filename>', 'warning'); + if(args[0] === '-r' || args[0] === '-rf') { + const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', '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 <filename>', '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 <dirname>', '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 <dirname>', 'warning'); + if (args[0] === '-p') { + if(!args[1]) return this.terminal.print('Usage: mkdir -p <path>', '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 <dirname>', '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 <source> <dest>', '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 <source> <dest>', '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 <pattern>', '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 <pattern> [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 <filename> [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 <filename> [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 <filename>', '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 <filename>', '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 <filename>', '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 <filename>', '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 = '<span>⬇ ' + fname + ' (' + sizeStr + ') </span>' + + '<a href="' + downloadUrl + '" target="_blank" rel="noopener" ' + + 'style="background:var(--success-color);color:#000;border:none;padding:8px 16px;' + + 'border-radius:6px;cursor:pointer;font-size:0.9em;min-height:44px;' + + 'display:inline-flex;align-items:center;text-decoration:none;font-weight:bold;">⬇ Tap to download</a>'; + } else { + line.innerHTML = '<span>⬇ ' + fname + ' (' + sizeStr + ') — run <code>webdav-config</code> first to enable downloads</span>'; + } + 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 <name>', '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 <name>', '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 <name>', '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 <name>', '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 <model-name> — Show details about a specific model', + 'model-pull': 'model-pull <model-name> — Pull a model from Ollama registry', + 'model-delete': 'model-delete <model-name> — Delete a model from local storage', + 'model-run': 'model-run <model-name> [prompt] — Run inference with a model', + 'ollamasetmodel': 'ollamasetmodel <name> — 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 <name>", '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 <prompt>", 'warning'); + await this.terminal.ollamaManager.chat(args.join(' ')); + }; + this.commands['ask'] = async (args) => { + if(!args[0]) return this.terminal.print("Usage: ask <prompt>", '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 <model>' first.", "warning"); return; + } + this.terminal.print("=== Available Ollama Models ===", "info"); + this.terminal.printHTML(`<table style="width: 100%; border-collapse: collapse; margin: 10px 0;"> + <tr style="border-bottom: 1px solid #005500;"> + <th style="text-align: left; padding: 5px;">Name</th> + <th style="text-align: left; padding: 5px;">Size</th> + <th style="text-align: left; padding: 5px;">Family</th> + </tr>`); + models.forEach(m => { + const sizeGB = (m.size / 1e9).toFixed(2); + this.terminal.printHTML(`<tr style="border-bottom: 1px solid #111;"> + <td style="padding: 5px; color: #00ff00;">${m.name}</td> + <td style="padding: 5px; color: #888888;">${sizeGB} GB</td> + <td style="padding: 5px; color: #00aaff;">${m.details?.family || 'N/A'}</td> + </tr>`); + }); + this.terminal.printHTML(`</table>`); + } catch (e) { + this.terminal.print(`Failed to fetch models: ${e.message}`, "error"); + } + } + async modelInfo(args) { + if (!args[0]) { + this.terminal.print("Usage: model-info <model-name>", "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] || '<model-name>'}`, "info"); + } + deleteModel(args) { + this.terminal.print("⚠️ Model deletion must be done via Ollama CLI", "warning"); + this.terminal.print(`Run: ollama rm ${args[0] || '<model-name>'}`, "info"); + } + async runModel(args) { + if (!args[0]) { + this.terminal.print("Usage: model-run <model-name> [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 <key> <value> — Store a key-value pair', + 'my-get': 'my-get <key> — 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 <json> — 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 <key> <value>", "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 <key>", "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 <json>', '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=<pat>, --failed, --disabled', + 'module-info': 'module-info <name> — 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 <name> — Mark a lazy module as loaded', + 'module-unload': 'module-unload <name> — Mark a module as unloaded', + 'module-reload': 'module-reload <name> — Unload then reload a module (shows diff)', + 'module-reset': 'module-reset <name> — Reset a module to a clean state (clears its state, keeps commands)', + 'module-disable': 'module-disable <name> — Disable a module from auto-loading on refresh (persists)', + 'module-enable': 'module-enable <name> — Re-enable a previously disabled module', + 'loadmod': 'loadmod <url> — Load a module from a URL', + 'removemod': 'removemod <name> — 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<name, moduleInstance> + 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 <url>", 'warning'); + await this.terminal.moduleManager.loadModuleFromURL(args[0]); + this.terminal.renderModuleList(); + }; + this.commands['removemod'] = (args) => { + if(!args[0]) return this.terminal.print("Usage: removemod <name>", '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 <name> — detailed view including commands + this.commands['module-info'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-info <name>', '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 <name> — persists in localStorage + this.commands['module-disable'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-disable <name>', '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 <name> — removes from disabled list + this.commands['module-enable'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-enable <name>', '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 <name> — mark a lazy module as loaded + this.commands['module-load'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-load <name>', '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 <name> — mark as unloaded + this.commands['module-unload'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-unload <name>', '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 <name> — unload + reload with diff + this.commands['module-reload'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-reload <name>', '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 <name> — clear module state and reload + this.commands['module-reset'] = (args) => { + if (!args[0]) return this.terminal.print('Usage: module-reset <name>', '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_<module>_..." or "webx11_<module>") + 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 <query> [--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 <query> [--summarize] [--fetch] [--images] [--videos] — Alias for search', + 'images': 'images <query> — Search for images', + 'videos': 'videos <query> — 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 <query> [--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 <query> or videos <query>", "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(` + <div class="search-result search-result-image"> + <a href="${r.url}" target="_blank"><img src="${imgSrc}" style="max-width:200px;max-height:150px;border-radius:4px;object-fit:cover;" alt="${r.title || ''}" loading="lazy"></a> + <br/><strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong> + </div> + `); + } else if (category === 'videos' && videoThumb) { + this.terminal.printHTML(` + <div class="search-result search-result-video"> + <a href="${r.url}" target="_blank"><img src="${videoThumb}" style="max-width:200px;max-height:150px;border-radius:4px;object-fit:cover;" alt="${r.title || ''}" loading="lazy"></a> + <br/><strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong><br/> + <span style="color: #888888; font-size: 0.85em;">${r.url || ''}</span> + </div> + `); + } else { + this.terminal.printHTML(` + <div class="search-result"> + <strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong><br/> + <span style="color: #888888; font-size: 0.85em;">${r.url || 'No URL'}</span><br/> + <span style="color: #00ff00; font-size: 0.85em;">${(snippet || '').substring(0, 200)}...</span> + ${contentSnippet ? `<br/><span style="color: #aaaaaa; font-size: 0.8em;">📄 ${contentSnippet.substring(0, 150)}...</span>` : ''} + ${imgSrc && category === 'general' ? `<br/><a href="${imgSrc}" target="_blank" style="color: #ff8800; font-size: 0.8em;">🖼️ View image</a>` : ''} + </div> + `); + } + }); + } 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 <name> <url> — Add a server to monitor', + 'servers': 'servers — List all monitored servers', + 'server-check': 'server-check <name-or-index> — Check a server status', + 'server-remove': 'server-remove <name-or-index> — 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 <name> <url>", "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 <name-or-index>", "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 <name-or-index>", "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 <key> <value> — Set a configuration value', + 'config-list': 'config-list — List all configuration keys', + 'config-delete': 'config-delete <key> — 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 <key>", "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 <key> <value>", "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 <key>", "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 <label> <command> — Add a clickable button', + 'gui-input': 'gui-input <placeholder> <varName> — Add a text input field', + 'gui-text': 'gui-text <content> — Add a text element', + 'gui-clear': 'gui-clear — Clear all GUI elements', + 'gui-run': 'gui-run <command> — Run a terminal command from the GUI' + }; + } + registerCommands() { + this.commands['gui'] = () => this.showGUI(); + this.commands['gui-button'] = (args) => this.createButton(args); + this.commands['gui-input'] = (args) => this.createInput(args); + this.commands['gui-text'] = (args) => this.createText(args); + this.commands['gui-clear'] = () => this.clearGUI(); + this.commands['gui-run'] = (args) => this.runCommand(args); + } + showGUI() { + this.terminal.print("=== Simple GUI Builder ===", "info"); + this.terminal.print("Commands: gui-button, gui-input, gui-text, gui-clear", "info"); + } + createButton(args) { + if (args.length < 2) { + this.terminal.print("Usage: gui-button <label> <command>", "warning"); return; + } + const label = args[0], command = args.slice(1).join(' '); + const id = 'gui-btn-' + Date.now(); + const safeCmd = command.replace(/'/g, "\\'"); + this.terminal.printHTML(`<div style="margin: 5px 0;"> + <button class="qa-btn" onclick="(window.webx11||window).executeCommand('${safeCmd}')" id="${id}">${label}</button> + </div>`); + this.guiElements[id] = { type: 'button', label, command }; + this.terminal.print(`Button '${label}' created`, "success"); + } + createInput(args) { + if (args.length < 2) { + this.terminal.print("Usage: gui-input <placeholder> <varName>", "warning"); return; + } + const placeholder = args[0], varName = args[1], id = 'gui-input-' + Date.now(); + this.terminal.printHTML(`<div style="margin: 5px 0;"> + <input type="text" id="${id}" placeholder="${placeholder}" + style="background: #0a0a0a; border: 1px solid #005500; + color: #00ff00; padding: 8px; width: 100%;" /> + </div>`); + this.guiElements[id] = { type: 'input', placeholder, varName }; + this.terminal.print(`Input '${placeholder}' created (var: ${varName})`, "success"); + } + createText(args) { + if (!args[0]) { + this.terminal.print("Usage: gui-text <content>", "warning"); return; + } + const content = args.join(' '); + this.terminal.printHTML(`<div style="margin: 5px 0; padding: 8px; + background: #111111; border-left: 3px solid #00aaff;">${content}</div>`); + this.terminal.print("Text element created", "success"); + } + clearGUI() { this.guiElements = {}; this.terminal.print("GUI elements cleared", "success"); } + runCommand(args) { + if (!args[0]) { + this.terminal.print("Usage: gui-run <command>", "warning"); return; + } + this.terminal.executeCommand(args.join(' ')); + } + } + +/* === module-io.js === */ +// Extracted from newOS4 index.html — ModuleIOModule + class ModuleIOModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'export-mods': 'export-mods — Export all loaded modules to a JSON file', + 'import-mods': 'import-mods <filename> — Import modules from a JSON file', + 'backup': 'backup — Create a full system backup', + 'restore': 'restore <filename> — Restore system from a backup file', + 'share-mod': 'share-mod <module-name> — Share a module as JSON to clipboard' + }; + } + registerCommands() { + this.commands['export-mods'] = () => this.exportModules(); + this.commands['import-mods'] = (args) => this.importModules(args); + this.commands['backup'] = () => this.fullBackup(); + this.commands['restore'] = (args) => this.restoreBackup(args); + this.commands['share-mod'] = (args) => this.shareModule(args); + } + exportModules() { + const registry = this.terminal.moduleManager.moduleRegistry; + if (registry.length === 0) { + this.terminal.print("No modules to export", "warning"); return; + } + const exportData = { version: '4.1', exported: new Date().toISOString(), modules: registry }; + const json = JSON.stringify(exportData, 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-modules-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print(`Exported ${registry.length} module(s)`, "success"); + } + importModules(args) { + this.terminal.print("⚠️ To import modules:", "warning"); + this.terminal.print("1. Upload the JSON file using 'upload'", "info"); + this.terminal.print("2. Use: import-mods <filename>", "info"); + } + async fullBackup() { + const backup = { + version: '4.1', timestamp: new Date().toISOString(), + filesystem: localStorage.getItem('webx11_filesystem'), + modules: localStorage.getItem('webx11_modules'), + ollama: { + endpoint: localStorage.getItem('webx11_ollama_endpoint'), + model: localStorage.getItem('webx11_ollama_model') + }, config: {} + }; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith('webx11_')) { backup.config[key] = localStorage.getItem(key); } + } + const json = JSON.stringify(backup, 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-backup-${Date.now()}.json`; a.click(); + URL.revokeObjectURL(url); + this.terminal.print("Full backup created", "success"); + } + restoreBackup(args) { + this.terminal.print("⚠️ To restore backup:", "warning"); + this.terminal.print("1. Upload the backup JSON file", "info"); + this.terminal.print("2. Use: restore <filename>", "info"); + } + shareModule(args) { + if (!args[0]) { + this.terminal.print("Usage: share-mod <module-name>", "warning"); return; + } + const mod = this.terminal.moduleManager.moduleRegistry.find(m => m.name === args[0]); + if (!mod) { + this.terminal.print(`Module '${args[0]}' not found`, "error"); return; + } + const shareData = { name: mod.name, url: mod.url, shared: new Date().toISOString() }; + const json = JSON.stringify(shareData, null, 2); + navigator.clipboard.writeText(json).then(() => { + this.terminal.print("Module info copied to clipboard", "success"); + }).catch(() => { this.terminal.print(json, "info"); }); + } + } + +/* === module-dynamicprompts.js === */ +// Extracted from newOS4 index.html — DynamicPromptsModule + class DynamicPromptsModule { + constructor() { + this.commands = {}; this.terminal = null; + this.prompts = this.loadPrompts(); this.variables = {}; + } + getHelpInfo() { + return { + 'prompt': 'prompt <name> [additional-text] — Use a saved prompt template', + 'prompt-save': 'prompt-save <name> <template> — Save a new prompt template', + 'prompt-list': 'prompt-list — List all saved prompt templates', + 'prompt-delete': 'prompt-delete <name> — Delete a saved prompt', + 'prompt-var': 'prompt-var <name> <value> — Set a template variable', + 'prompt-vars': 'prompt-vars — List all template variables' + }; + } + loadPrompts() { + try { + const saved = localStorage.getItem('webx11_prompts'); + return saved ? JSON.parse(saved) : {}; + } catch (e) { return {}; } + } + savePrompts() { + localStorage.setItem('webx11_prompts', JSON.stringify(this.prompts)); + } + registerCommands() { + this.commands['prompt'] = (args) => this.usePrompt(args); + this.commands['prompt-save'] = (args) => this.savePrompt(args); + this.commands['prompt-list'] = () => this.listPrompts(); + this.commands['prompt-delete'] = (args) => this.deletePrompt(args); + this.commands['prompt-var'] = (args) => this.setVariable(args); + this.commands['prompt-vars'] = () => this.listVariables(); + } + usePrompt(args) { + if (!args[0]) { + this.terminal.print("Usage: prompt <name> [additional-text]", "warning"); return; + } + const name = args[0], additional = args.slice(1).join(' '); + if (!this.prompts[name]) { + this.terminal.print(`Prompt '${name}' not found`, "error"); return; + } + let template = this.prompts[name].template; + Object.entries(this.variables).forEach(([k, v]) => { + template = template.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + const finalPrompt = additional ? `${template} ${additional}` : template; + this.terminal.print("=== Generated Prompt ===", "info"); + this.terminal.printHTML(`<div class="output-line ollama" style="margin-top: 5px;"> + <button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.innerText.replace('Copy','').trim())">Copy</button> + ${finalPrompt} + </div>`); + this.terminal.print("\nUse with: ai <generated-prompt>", "info"); + } + savePrompt(args) { + if (args.length < 2) { + this.terminal.print("Usage: prompt-save <name> <template>", "warning"); return; + } + const name = args[0], template = args.slice(1).join(' '); + this.prompts[name] = { template, created: new Date().toISOString() }; + this.savePrompts(); + this.terminal.print(`Prompt '${name}' saved`, "success"); + } + listPrompts() { + const names = Object.keys(this.prompts); + if (names.length === 0) { + this.terminal.print("No saved prompts", "info"); return; + } + this.terminal.print("=== Saved Prompts ===", "info"); + names.forEach(name => { + const p = this.prompts[name]; + this.terminal.print(`📝 ${name}: ${p.template.substring(0, 50)}...`); + }); + } + deletePrompt(args) { + if (!args[0]) { + this.terminal.print("Usage: prompt-delete <name>", "warning"); return; + } + if (this.prompts[args[0]]) { + delete this.prompts[args[0]]; + this.savePrompts(); + this.terminal.print(`Prompt '${args[0]}' deleted`, "success"); + } else { + this.terminal.print(`Prompt '${args[0]}' not found`, "error"); + } + } + setVariable(args) { + if (args.length < 2) { + this.terminal.print("Usage: prompt-var <name> <value>", "warning"); return; + } + const name = args[0], value = args.slice(1).join(' '); + this.variables[name] = value; + this.terminal.print(`Variable set: ${name} = ${value}`, "success"); + } + listVariables() { + const names = Object.keys(this.variables); + if (names.length === 0) { + this.terminal.print("No variables set", "info"); return; + } + this.terminal.print("=== Variables ===", "info"); + names.forEach(name => { this.terminal.print(`${name}: ${this.variables[name]}`); }); + } + } + +/* === module-aiassist.js === */ +// Extracted from newOS4 index.html — WebX11AIAssistantModule (Full Agent Loop) + class WebX11AIAssistantModule { + constructor() { + this.commands = {}; this.terminal = null; + this.systemPrompt = this.buildSystemPrompt(); + this.context = []; this.maxContext = 10; + this.maxContextTokens = 2000; + this.MAX_TOOL_TURNS = 8; + this._pendingConfirmation = null; + this._confirmResolve = null; + this._errorLog = []; + this._commandFreq = {}; + this._messageCount = 0; + this._userProfile = {}; + this._sessionMemoryLoaded = false; + this._sessionSaved = false; + this._thinkingTimer = null; + this._thinkingStartTime = 0; + this._validatorSuppressed = false; + this._toolDefs = null; + this._toolCallLog = []; + this._debugPanelEnabled = false; + // 🔒 Guardrail Layer + this._guardrailPolicy = null; + this._shadowSnapshot = null; + this._guardrailViolations = {}; + this._confirmTimeoutMs = 120000; + this._skillDebounceMs = 1800000; + this._chatLockTimeoutMs = 10000; + this._loadGuardrailPolicy(); + // Restore AI config from localStorage (maxContext, MAX_TOOL_TURNS, etc.) + try { const cfg = localStorage.getItem('webx11_ai_config'); if (cfg) { const p = JSON.parse(cfg); if (p.maxTurns) this.MAX_TOOL_TURNS = p.maxTurns; if (p.maxContext) this.maxContext = p.maxContext; if (p.maxContextTokens) this.maxContextTokens = p.maxContextTokens; if (p.confirmTimeoutMs) this._confirmTimeoutMs = p.confirmTimeoutMs; if (p.skillDebounceMs) this._skillDebounceMs = p.skillDebounceMs; if (p.chatLockTimeoutMs) this._chatLockTimeoutMs = p.chatLockTimeoutMs; } } catch (e) {} + try { const s = localStorage.getItem('webx11_ai_patterns'); if (s) this._commandFreq = JSON.parse(s); } catch (e) {} + try { const p = localStorage.getItem('webx11_user_profile'); if (p) this._userProfile = JSON.parse(p); } catch (e) {} + // Restore AI conversation context from localStorage + try { + const saved = localStorage.getItem('webx11_ai_context'); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed && Array.isArray(parsed.context)) { + this.context = parsed.context; + this._messageCount = parsed.messageCount || 0; + } + } + } catch (e) {} + } +buildSystemPrompt() { + // Check for user-overridden prompt in localStorage + try { + const saved = localStorage.getItem('webx11_ai_system_prompt'); + if (saved) { + const now = new Date(); + const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + return `Current date: ${dateStr}\nCurrent time: ${timeStr}\n\n${saved}`; + } + } catch (e) {} + const now = new Date(); + const dateStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); + const nvfs = this.terminal?.moduleManager?.modules?.NeuralVFSModule; + let nvfsNote = ''; + try { + if (nvfs && nvfs.lastScan && nvfs.lastScan !== '(loaded from disk)') { + const ageMs = Date.now() - new Date(nvfs.lastScan).getTime(); + if (ageMs > 14400000) nvfsNote = ' |nvfs ' + Math.floor(ageMs/3600000) + 'h stale'; + } + } catch (e) {} + return 'Current date: ' + dateStr + nvfsNote + '\n' + + 'VFS→Modules→AI. nvfs-index for commands. Tool→Observe→Answer. nvfs-resolve. Destructive cmds need confirm.\n' + + 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind: vaultmind_query(ep,msg,agent) — Hyperliquid prices/portfolio/chat\n' + + 'web_fetch tool: GET for normal pages (returns title+content+word_count), POST for JSON APIs like Hyperliquid /info (pass method="POST" and body as JSON string).\n' + + 'Other: floating, webdav, cron, vault, sandbox, agents, wpm, mindmap, memgraph, guardrail, wget, tar, secret-* — discoverable via help'; + } + getToolDefinitions() { + if (!this._toolDefs) this._toolDefs = this._buildToolDefs(); + return this._toolDefs; + } + _buildToolDefs() { + return [{ + type: 'function', + function: { + name: 'list_files', + description: 'List VFS directory contents', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Dir path ("" for root)' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'read_file', + description: 'Read VFS file', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'write_file', + description: 'Write file (DESTRUCTIVE)', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, content: { type: 'string', description: 'Content' } }, required: ['path', 'content'] } + } + }, { + type: 'function', + function: { + name: 'make_directory', + description: 'Create VFS directory', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Dir name' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'delete_file', + description: 'Delete file or empty dir (DESTRUCTIVE)', + parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' } }, required: ['path'] } + } + }, { + type: 'function', + function: { + name: 'get_status', + description: 'System status: FS, modules, memory', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'get_help', + description: 'Command reference', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'web_search', + description: 'Search web via SearXNG + DDG', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query' } }, required: ['query'] } + } + }, { + type: 'function', + function: { + name: 'web_fetch', + description: 'Fetch & extract URL content. Supports GET (default) and POST (for APIs like Hyperliquid /info).', + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: 'Full URL' }, + method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' }, + body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' }, + content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' } + }, + required: ['url'] + } + } + }, { + type: 'function', + function: { + name: 'wpm_install', + description: 'Install WPM package', + parameters: { type: 'object', properties: { package: { type: 'string', description: 'Package name' } }, required: ['package'] } + } + }, { + type: 'function', + function: { + name: 'wpm_search', + description: 'Search WPM packages', + parameters: { type: 'object', properties: { keyword: { type: 'string', description: 'Keyword' } }, required: ['keyword'] } + } + }, { + type: 'function', + function: { + name: 'run_command', + description: 'Run terminal command (safe-read commands auto-approved; mutating ones require confirmation)', + parameters: { type: 'object', properties: { command: { type: 'string', description: 'Command to run' } }, required: ['command'] } + } + }, { + type: 'function', + function: { + name: 'get_current_dir', + description: 'Current VFS directory', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'view_module', + description: 'View module source code', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'edit_module', + description: 'Edit module source (DESTRUCTIVE)', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'test_module', + description: 'Test edited module from /_mods/', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'reload_module', + description: 'Reload module from original URL', + parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } + } + }, { + type: 'function', + function: { + name: 'list_modified_modules', + description: 'List modules with local edits', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'get_module_status', + description: 'Module status: loaded, modified, URLs', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'load_skills', + description: 'Search skills relevant to task', + parameters: { type: 'object', properties: { query: { type: 'string', description: 'Task description' } }, required: ['query'] } + } + }, { + type: 'function', + function: { + name: 'create_skill', + description: 'Save a new skill', + parameters: { type: 'object', properties: { + name: { type: 'string', description: 'kebab-case name' }, + description: { type: 'string', description: 'One-line description' }, + content: { type: 'string', description: 'Markdown with YAML frontmatter' }, + tags: { type: 'string', description: 'Comma-separated' }, + category: { type: 'string', description: 'e.g. research, system, dev' } + }, required: ['name', 'description', 'content'] } + } + }, { + type: 'function', + function: { + name: 'patch_skill', + description: 'Replace skill content', + parameters: { type: 'object', properties: { + name: { type: 'string', description: 'Skill name' }, + content: { type: 'string', description: 'New markdown content' } + }, required: ['name', 'content'] } + } + }, { + type: 'function', + function: { + name: 'list_skills', + description: 'Browse skills (optional filter)', + parameters: { type: 'object', properties: { category: { type: 'string', description: 'Filter by category' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'check_updates', + description: 'Check module URLs are reachable', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'user_get', + description: 'Read user profile key', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'e.g. preferredModel' } }, required: ['key'] } + } + }, { + type: 'function', + function: { + name: 'user_set', + description: 'Write user profile key', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key' }, value: { type: 'string', description: 'Value' } }, required: ['key', 'value'] } + } + }, { + type: 'function', + function: { + name: 'user_list', + description: 'List stored profile keys', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'user_forget', + description: 'Delete profile key', + parameters: { type: 'object', properties: { key: { type: 'string', description: 'Key to delete' } }, required: ['key'] } + } + }, { + type: 'function', + function: { + name: 'session_save', + description: 'Save session summary', + parameters: { type: 'object', properties: { summary: { type: 'string', description: 'What was accomplished' } }, required: ['summary'] } + } + }, { + type: 'function', + function: { + name: 'session_load', + description: 'Load past session by date', + parameters: { type: 'object', properties: { date: { type: 'string', description: 'YYYY-MM-DD' } }, required: ['date'] } + } + }, { + type: 'function', + function: { + name: 'session_recent', + description: 'Show recent sessions', + parameters: { type: 'object', properties: { count: { type: 'number', description: 'Count (default 3)' } }, required: [] } + } + }, { + type: 'function', + function: { + name: 'session_terminal', + description: 'Read last terminal output', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'session_stats', + description: 'Session aggregate stats', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'list_agents', + description: 'List registered agent IDs, status, and capabilities', + parameters: { type: 'object', properties: {}, required: [] } + } + }, { + type: 'function', + function: { + name: 'delegate_task', + description: '(DESTRUCTIVE) Delegate a task to a worker agent. Auto-spawns if needed.', + parameters: { + type: 'object', + properties: { + task: { type: 'string', description: 'The task description to delegate' } + }, + required: ['task'] + } + } + }, { + type: 'function', + function: { + name: 'spawn_agent', + description: '(DESTRUCTIVE) Spawn a new worker agent with a role and capabilities', + parameters: { + type: 'object', + properties: { + role: { type: 'string', description: 'Agent role (e.g. researcher, writer)' }, + capabilities: { type: 'string', description: 'Comma-separated capabilities (optional)' } + }, + required: ['role'] + } + } + }, { + type: 'function', + function: { + name: 'nvfs_resolve', + description: 'Search VFS files by semantic concept using Neural VFS index. Call nvfs-index first to build the index.', + parameters: { + type: 'object', + properties: { + concept: { type: 'string', description: 'Concept to search (e.g. skill, memory, module, webdav, security)' } + }, + required: ['concept'] + } + } + }, { + type: 'function', + function: { + name: 'list_available_modules', + description: 'List all available modules with metadata (core/ext, commands, class name, layer). Filter by type with type=core or type=ext.', + parameters: { + type: 'object', + properties: { + type: { type: 'string', description: 'Optional: filter to "core" or "ext"' } + }, + required: [] + } + } + }, { + type: 'function', + function: { + name: 'smart_context_list', + description: 'List available AI context profiles (e.g. coding, debug, creative, technical). Returns profile names and system prompts.', + parameters: { + type: 'object', + properties: {}, + required: [] + } + } +}, { + type: 'function', + function: { + name: 'smart_context_set', + description: "Set the active AI context profile. Changes the AI's behavior (temperature, style) for subsequent responses.", + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'Profile name (e.g. "coding", "debug", "creative", "technical", "chat")' } + }, + required: ['name'] + } + } + }, { + type: 'function', + function: { + name: 'create_mindmap', + description: 'Create a mindmap with the given topic and child nodes, then render it as inline SVG in the terminal. Creates root and all children in one call. Use this when the user asks for a mindmap or visual architecture.', + parameters: { + type: 'object', + properties: { + topic: { type: 'string', description: 'Root topic of the mindmap (e.g. "WebX11 Architecture")' }, + children: { type: 'string', description: 'Comma-separated list of child node names (e.g. "AI Engine, VFS System, Modules")' } + }, + required: ['topic'] + } + } + }, { + type: 'function', + function: { + name: 'vaultmind_query', + description: "Query VaultMind AI for Hyperliquid market data (prices, portfolio, transactions, copy-trade) or ask an AI agent. Uses same-origin /vmai/ API.", + parameters: { + type: 'object', + properties: { + endpoint: { type: 'string', description: 'prices, portfolio, transactions, copy-trade, or chat' }, + message: { type: 'string', description: 'Question for the AI agent (chat endpoint only)' }, + agent: { type: 'string', enum: ['market', 'trading', 'transaction', 'copy'], description: 'Which AI agent to query (chat endpoint only)' } + }, + required: ['endpoint'] + } + } + }]; + } + getHelpInfo() { + return { + 'ai': 'ai <question> — Ask the AI assistant anything', + 'ask': 'ask <question> — Alias for ai command', + 'ai-clear': 'ai-clear — Clear conversation context', + 'ai-system': 'ai-system <prompt> — Set system prompt', + 'ai-context': 'ai-context — Show current conversation context', + 'ai-allow': 'ai-allow — Confirm pending AI action', + 'ai-deny': 'ai-deny — Deny pending AI action', + 'ai-ignore': 'ai-ignore — Suppress validator warnings for this session', + 'ai-stop': 'ai-stop — Cancel current AI operation', + 'ai-model': 'ai-model [model] — Show or set AI model', + 'ai-config': 'ai-config [key] [value] — View or set AI loop config (max-turns, max-context, confirm-timeout(0=disabled))', + 'mem-debug': 'mem-debug — Show memory breakdown by component', + 'reset-mem': 'reset-mem — Reset AI memory (logs, context, profile, guardrails) without losing VFS or config' + }; + } + registerCommands() { + this.commands['ai'] = (args) => this.chat(args); + this.commands['ask'] = (args) => this.chat(args); + this.commands['ai-clear'] = () => this.clearContext(); + this.commands['ai-system'] = (args) => this.setSystemPrompt(args); + this.commands['ai-context'] = () => this.showContext(); + this.commands['ai-allow'] = () => this.confirmPending(); + this.commands['ai-deny'] = () => this.denyPending(); + this.commands['ai-ignore'] = () => this.ignoreValidator(); + this.commands['ai-stop'] = () => this.cancelAi(); + this.commands['ai-model'] = (args) => this.showModel(args); + this.commands['ai-compress'] = () => this._manualCompress(); + this.commands['ai-config'] = (args) => this.aiConfig(args); + this.commands['debug'] = () => this.toggleDebug(); + this.commands['guardrail'] = (args) => this.guardrailCmd(args); + this.commands['guardrail-add'] = (args) => this.guardrailCmd(['add', ...args]); + this.commands['guardrail-remove'] = (args) => this.guardrailCmd(['remove', ...args]); + this.commands['shadow'] = (args) => this.shadowExec(args); + this.commands['shadow-commit'] = () => this.shadowCommit(); + this.commands['shadow-rollback'] = () => this.shadowRollback(); + this.commands['mem-debug'] = () => this.memDebug(); + this.commands['reset-mem'] = () => this.resetMem(); + } + async chat(args) { + const userMessage = args.join(' '); + if (!userMessage) { + this.terminal.print('Usage: ai <your-question>', 'warning'); return; + } + this._cancelled = false; + this.terminal.setAiBusy(true); + const lockStart = Date.now(); + while (this._chatLock) { + if (Date.now() - lockStart > this._chatLockTimeoutMs) { + this._chatLock = false; + this.terminal.print('⚠️ Chat lock timed out — force released', 'warning'); + break; + } + await new Promise(r => setTimeout(r, 50)); + } + this._chatLock = true; + this.terminal.markDirty(); + // Re-read context from localStorage (auto-restore may have written it after init) + this._loadContext(); + // Compress context if it exceeds the token threshold + this._compressContext(); + let loopResult; + try { + const messages = [ + { role: 'system', content: this.systemPrompt } + ]; + this.context.slice().forEach(msg => messages.push(msg)); + messages.push({ role: 'user', content: userMessage }); + this.terminal.print('🤖 WebX11AI thinking...', 'info'); + // Auto-load skills relevant to the user's query (awaited, not fire-and-forget) + // Skip skill loading for trivial queries (< 10 chars, no actionable keywords) + const optEnabled = this.terminal?.ollamaManager?.optimizationEnabled !== false; + const isTrivial = optEnabled && userMessage.length < 10 && !/\b(search|write|install|edit|create|make|find|delete|remove|change|update|improve|add|fix|build|set|load|save|help)\b/i.test(userMessage); + if (!isTrivial) { + try { + const skillRes = await this.runTool('load_skills', { query: userMessage }); + const skillData = JSON.parse(skillRes); + if (skillData && skillData.matches && skillData.matches.length > 0) { + const skillNotes = skillData.matches.map(m => `[Skill: ${m.name}] ${m.content}`).join('\n\n---\n\n'); + messages.push({ role: 'system', content: `Relevant skills for this task:\n\n${skillNotes}\n\nUse the steps above as guidance. If they're wrong or outdated, use patch_skill to fix them after the task.` }); + this.terminal.print(`📚 Loaded ${skillData.count} relevant skill(s)`, 'info'); + } + } catch (e) { /* skills not available yet — first run */ } + } + + // Track conversation patternsfor learning + this._trackPattern(userMessage); + + // Extract facts from user message + this._extractFacts(userMessage, ''); + + // On first message of session, auto-load recent memory + if (!this._sessionMemoryLoaded) { + this._sessionMemoryLoaded = true; + this.terminal.fs.mkdirRecursive('/memory'); + try { + const recentRes = await this.runTool('session_recent', { count: 3 }); + const rd = JSON.parse(recentRes); + if (rd.sessions && rd.sessions.length > 0) { + const memNotes = rd.sessions.map(s => '[' + s.date + '] ' + (s.tasks || 1) + ' tasks: ' + (s.summary || 'No summary')).join('\n'); + messages.push({ role: 'system', content: 'Recent sessions:\n' + memNotes + '\nThis provides continuity. Refer to past work when relevant.' }); + this.terminal.print('📖 Loaded ' + rd.sessions.length + ' recent session(s)', 'info'); + } + } catch (e) {} + } + + // Inject memory graph context for cross-session awareness + try { + const memgraph = this.terminal.moduleManager.modules['MemoryGraphModule']; + if (memgraph) { + const mctx = memgraph.getContextForPrompt(); + if (mctx) { + messages.push({ role: 'system', content: mctx }); + } + } + } catch (e) { /* memory graph not available */ } + + // Inject world model context for system self-awareness + try { + const wm = this.terminal.moduleManager.modules['WorldModelModule']; + if (wm) { + const wctx = wm.getContextForPrompt(); + if (wctx) { + messages.push({ role: 'system', content: wctx }); + } + } + } catch (e) { /* world model not available */ } + + loopResult = await this.agenticLoop(messages); + this.context.push({ role: 'user', content: userMessage }); + if (loopResult.finalAnswer) { + this.context.push({ role: 'assistant', content: loopResult.finalAnswer }); + } + if (this.context.length > this.maxContext * 2) { + this.context = this.context.slice(-this.maxContext * 2); + } + // Reflection: after complex tasks, run health scan and auto-skill creation + if (loopResult.toolTurns >= 2 && !this._cancelled) { + await this.afterActionReflection(userMessage, loopResult.toolTurns); + } + // Auto-create skills from repeated errors + if (this._errorLog.length >= 3) { + await this._autoCreateErrorSkill(); + } + // Skill quality audit every 20 messages + this._messageCount++; + if (this._messageCount % 20 === 0) { + await this._auditSkills(); + } + // Sound notification on AI response + if (this.terminal.playNotificationSound) { + setTimeout(() => this.terminal.playNotificationSound(), 100); + } + } finally { + this._chatLock = false; + this.terminal.setAiBusy(false); + // Persist conversation context after each exchange + this._saveContext(); + // Log the AI interaction + this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); + if (loopResult && loopResult.toolTurns) { + this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s)'); + } + } + // Register session save on page unload (runs once per session) + if (!this._sessionSaved) { + this._sessionSaved = true; + window.addEventListener('beforeunload', () => { + this.terminal.fs.mkdirRecursive('/memory'); + const today = new Date().toISOString().split('T')[0]; + const index = this.terminal.fs.fsReadJSON('/memory/index.json') || {}; + index[today] = index[today] || { summary: 'Session in progress', tasks: 0 }; + index[today].tasks = (index[today].tasks || 0) + 1; + index[today].updated = new Date().toISOString(); + this.terminal.fs.fsWriteJSON('/memory/index.json', index); + // Also persist AI context on page close + this._saveContext(); + }); + } + } + _summarizeObservation(result, maxLen) { + if (!result || typeof result !== 'string' || result.length <= maxLen) return result || ''; + const lines = result.split('\n'); + if (lines.length > 5) { + return lines.slice(0, 2).join('\n') + '\n...(' + (lines.length - 4) + ' more lines)...\n' + lines.slice(-2).join('\n'); + } + return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)'; + } + _escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + } + _updateDebugPanel() { + if (!this._debugPanelEnabled) return; + const panel = document.getElementById('debug-panel'); + if (!panel) return; + const recent = this._toolCallLog.slice(-20); + let html = ''; + for (const entry of recent) { + const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; + const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); + html += '<div class="debug-entry ' + statusClass + '">' + + '<span class="debug-turn">#' + entry.turn + '</span> ' + + '<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' + + '<span class="debug-args">' + this._escapeHtml(argsStr) + '</span> ' + + '<span class="debug-time">' + entry.duration + 's</span>' + + (entry.error ? ' <span class="debug-err">⚠️</span>' : '') + + '</div>'; + } + panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' + + '<span>🔧 Tool Log <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' + + '<span style="display:flex;gap:4px;">' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' + + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' + + '<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' + + '</span></div>' + html; + } + _isToolError(result) { + if (!result || typeof result !== 'string') return null; + const trimmed = result.trim(); + if (trimmed.startsWith('{"error":') || trimmed.startsWith("{'error':")) { + try { const parsed = JSON.parse(trimmed); return parsed.error || null; } + catch (e) { return null; } + } + return null; + } + _trackToolFailure(toolName, error) { + try { + if (!this._toolFailuresBuffer) this._toolFailuresBuffer = {}; + if (!this._toolFailuresBuffer[toolName]) this._toolFailuresBuffer[toolName] = { count: 0, last: '', errors: [] }; + const entry = this._toolFailuresBuffer[toolName]; + entry.count++; + entry.last = new Date().toISOString(); + entry.errors.push(error); + if (entry.errors.length > 5) entry.errors.shift(); + if (entry.count >= 3) { + this.terminal.print(`⚠️ Tool '${toolName}' has failed ${entry.count} times. Consider creating a workaround skill.`, 'warning'); + } + if (this._toolFailureFlushTimer) clearTimeout(this._toolFailureFlushTimer); + this._toolFailureFlushTimer = setTimeout(() => this._flushToolFailures(), 2000); + } catch (e) { /* ignore */ } + } + _flushToolFailures() { + try { + if (!this._toolFailuresBuffer || Object.keys(this._toolFailuresBuffer).length === 0) return; + const key = 'webx11_tool_failures'; + const raw = localStorage.getItem(key) || '{}'; + const map = JSON.parse(raw); + for (const [toolName, entry] of Object.entries(this._toolFailuresBuffer)) { + if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] }; + map[toolName].count += entry.count; + map[toolName].last = entry.last; + map[toolName].errors.push(...entry.errors); + if (map[toolName].errors.length > 10) map[toolName].errors = map[toolName].errors.slice(-10); + } + localStorage.setItem(key, JSON.stringify(map)); + this._toolFailuresBuffer = {}; + } catch (e) { /* ignore */ } + } + async agenticLoop(messages) { + let toolTurns = 0; + while (toolTurns < this.MAX_TOOL_TURNS) { + if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; } + toolTurns++; + // On last turn, inject wrap-up prompt + if (toolTurns === this.MAX_TOOL_TURNS) { + this.terminal.print(`⏱ Final turn (${toolTurns}/${this.MAX_TOOL_TURNS}) — AI must provide final answer`, 'warning'); + messages.push({ role: 'system', content: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.' }); + } + const thinkStart = Date.now(); + const res = await this.terminal.ollamaManager.chatWithTools(messages, this.getToolDefinitions()); + const thinkTime = Math.round((Date.now() - thinkStart) / 1000); + if (this._cancelled) return { finalAnswer: null, toolTurns }; + if (res.error) { this.terminal.print(`⚠️ AI model error (${thinkTime}s): ${res.error}`, 'error'); return { finalAnswer: null, toolTurns }; } + if (res.message && !res.tool_calls) { + const text = res.message.content || ''; + this.terminal.printAIResponse(text || '(no response)'); + if (text) this.terminal.ollamaManager.fireResponseHooks(text); + this.terminal.print(`💬 AI answered (turn ${toolTurns}/${this.MAX_TOOL_TURNS}, ${thinkTime}s)`, 'info'); + return { finalAnswer: text, toolTurns }; + } + if (res.tool_calls && res.tool_calls.length > 0) { + this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${this.MAX_TOOL_TURNS} (${thinkTime}s)`, 'info'); + messages.push(res.message); + let pendingText = res.message?.content || null; + const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name, tc.function.arguments)); + const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name, tc.function.arguments)); + // Execute non-destructive tools in parallel (independent read-only operations) + const safeResults = await Promise.all(safeTools.map(async (tc) => { + const fn = tc.function; + const t0 = Date.now(); + const toolResult = await this.runTool(fn.name, fn.arguments); + const t1 = Math.round((Date.now() - t0) / 1000); + this._toolCallLog.push({ + turn: toolTurns, + name: fn.name, + args: fn.arguments, + result: toolResult, + duration: t1, + error: this._isToolError(toolResult) ? true : false + }); + // Auto-trim to 50 — prevents unbounded memory growth + if (this._toolCallLog.length > 50) { + this._toolCallLog = this._toolCallLog.slice(-50); + } + // Telemetry + const sm = this.terminal.moduleManager.modules['ServerMonitorModule']; + if (sm && sm.logToolCall) sm.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult)); + if (this._debugPanelEnabled) this._updateDebugPanel(); + return { fn, toolResult, t1 }; + })); + for (const { fn, toolResult, t1 } of safeResults) { + this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); + // Memory graph: record tool call + file access + try { + const _mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; + if (_mg) { + _mg.recordToolCall(fn.name, !this._isToolError(toolResult)); + if (fn.name === 'read_file' || fn.name === 'write_file' || fn.name === 'view_module' || fn.name === 'edit_module') { + const _path = (typeof fn.arguments === 'object' ? (fn.arguments?.path || '') : ''); + if (_path) _mg.recordFileAccess(_path); + } + } + } catch (_e) { /* memory graph not available */ } + const obs = this._summarizeObservation(toolResult, 150); + const err = this._isToolError(toolResult); + if (err) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${err} (${t1}s)`); + this._trackToolFailure(fn.name, err); + this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); + } else if (toolResult && toolResult.length > 150) { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); + const safeId = 'toolresult-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); + this.terminal.printHTML(`📊 Result (${t1}s): <span id="${safeId}-short">${this._escapeHtml(obs)} <a href="#" onclick="var e=document.getElementById('${safeId}-full');var s=document.getElementById('${safeId}-short');if(e){e.style.display='inline';s.style.display='none'}return false" style="color:var(--info-color);cursor:pointer">[+] show full (${toolResult.length} chars)</a></span><span id="${safeId}-full" style="display:none">${this._escapeHtml(toolResult)} <a href="#" onclick="var e=document.getElementById('${safeId}-full');var s=document.getElementById('${safeId}-short');if(e&&s){e.style.display='none';s.style.display='inline'}return false" style="color:var(--warning-color);cursor:pointer">[-] collapse</a></span>`); + } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); + this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + // Only block on destructive tools + if (destructiveTools.length > 0) { + // 🎯 CONFIDENCE ESCALATION — auto-approve only if confidence >= 90% + let autoApprove = false; + if (pendingText) { + const confidence = this._parseConfidence(pendingText); + if (confidence !== null && confidence >= 90) { + autoApprove = true; + this.terminal.print(`🎯 AI confidence: ${confidence}% — auto-approved`, 'success'); + } else if (confidence !== null) { + this.terminal.print(`🎯 AI confidence: ${confidence}% — below 90% threshold, requiring manual approval`, 'warning'); + } else { + this.terminal.print('🎯 No confidence declared for destructive action — requiring manual approval', 'warning'); + } + } + const approved = autoApprove || await this.confirmActionMulti(destructiveTools); + if (approved) { + for (const tc of destructiveTools) { + const fn = tc.function; + const t0 = Date.now(); + this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); + const toolResult = await this.runTool(fn.name, fn.arguments); + const t1 = Math.round((Date.now() - t0) / 1000); + this._toolCallLog.push({ + turn: toolTurns, + name: fn.name, + args: fn.arguments, + result: toolResult, + duration: t1, + error: this._isToolError(toolResult) ? true : false + }); + // Auto-trim to 50 — prevents unbounded memory growth + if (this._toolCallLog.length > 50) { + this._toolCallLog = this._toolCallLog.slice(-50); + } + // Telemetry + const sm2 = this.terminal.moduleManager.modules['ServerMonitorModule']; + if (sm2 && sm2.logToolCall) sm2.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult)); + if (this._debugPanelEnabled) this._updateDebugPanel(); + // Memory graph: record destructive tool call + try { + const _mg2 = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; + if (_mg2) { + _mg2.recordToolCall(fn.name, !this._isToolError(toolResult)); + if (fn.name === 'write_file' || fn.name === 'edit_module') { + const _path2 = (typeof fn.arguments === 'object' ? (fn.arguments?.path || '') : ''); + if (_path2) _mg2.recordFileAccess(_path2); + } + } + } catch (_e2) { /* memory graph not available */ } + const dObs = this._summarizeObservation(toolResult, 150); + const dErr = this._isToolError(toolResult); + if (dErr) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`); + this._trackToolFailure(fn.name, dErr); + this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); + } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); + this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); + } + messages.push({ role: 'tool', content: toolResult }); + } + } else { + messages.push({ + role: 'tool', + content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied by user. Non-destructive actions already executed.` + }); + } + } +// Validator: check safe tool results for consistency + if (safeResults.length > 0 && pendingText && !this._validatorSuppressed) { + const om = this.terminal.ollamaManager; + if (om && om.validatorEnabled && om.validatorModel) { + let invalidTools = []; + for (const { fn, toolResult } of safeResults) { + const valid = await om.validateToolResult(fn.name, fn.arguments, toolResult, pendingText || ''); + if (!valid && !this._cancelled) { + invalidTools.push(fn.name); + this.terminal.print('🔍 Validator: ' + fn.name + ' result — AI note may not match (logged)', 'info'); + } + } + if (invalidTools.length > 0) { + pendingText += '\n\n⚠️ **Validator flagged** ' + invalidTools.join(', ') + ' — the AI analysis may not match what was returned. Use `ai-ignore` to dismiss and proceed.'; + } + } + } + if (pendingText) { + messages.push({ role: 'assistant', content: pendingText }); + } + continue; + } + return { finalAnswer: null, toolTurns }; + } + // Loop exited without final answer — auto-summary + const toolNames = Object.keys(this._commandFreq).slice(-3).join(', '); + this.terminal.print(`🤖 AI reached the ${toolTurns}/${this.MAX_TOOL_TURNS} tool limit.`, 'warning'); + this.terminal.print('Tools were executed but no final answer was produced — try a more specific query.', 'info'); + return { finalAnswer: null, toolTurns }; + } + async afterActionReflection(query, toolTurns) { + // Debounce: only suggest skill creation once per 30 minutes + const now = Date.now(); + if (this._lastSkillSuggestion && (now - this._lastSkillSuggestion) < this._skillDebounceMs) { + return; + } + const suggestions = []; + const fs = this.terminal.fs; + try { + // Batch: read all status info in one VFS pass + const reg = this.terminal.moduleManager.moduleRegistry || []; + const total = reg.length; + const loaded = Object.keys(this.terminal.moduleManager.modules).length; + if (loaded < total) { + suggestions.push(loaded + '/' + total + ' modules loaded — use loadmod to restore'); + } + } catch (e) {} + try { + const skillIdx = fs.fsReadJSON('/skills/index.json'); + const skillCount = skillIdx ? Object.keys(skillIdx).length : 0; + if (skillCount === 0 && toolTurns >= 4) { + suggestions.push('Save this approach as a skill with create_skill'); + this._lastSkillSuggestion = now; + } + } catch (e) {} + try { + const modsDir = fs.getCurrentDir(); + const mc = modsDir.content['_mods']; + const modCount = mc && mc.content ? Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')).length : 0; + if (modCount > 0) { + suggestions.push(modCount + ' pending module edit(s) in /_mods/ — use test_module to verify'); + } + } catch (e) {} + try { + if (performance.memory && performance.memory.usedJSHeapSize > 100 * 1048576) { + suggestions.push('High memory (' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB) — consider clearing terminal'); + } + } catch (e) {} + if (toolTurns >= 4 && suggestions.length === 0) { + suggestions.push('Used ' + toolTurns + ' tools — create a skill to save this workflow'); + this._lastSkillSuggestion = now; + } + if (suggestions.length > 0) { + this.terminal.print('💡 ' + suggestions.join(' | '), 'info'); + } + } + async confirmActionMulti(toolCalls) { + // No-confirm mode: auto-approve + if (this._confirmTimeoutMs === 0) { + this.terminal.print(`⚡ No-confirm mode: auto-approving ${toolCalls.map(tc => tc.function.name).join(', ')}`, 'info'); + return true; + } + const names = toolCalls.map(tc => tc.function.name).join(', '); + const args = toolCalls.map(tc => typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments)).join(' | '); + const truncatedArgs = args.length > 60 ? args.substring(0, 60) + '...' : args; + const timeoutLabel = this._confirmTimeoutMs === 0 + ? '' + : ` (Auto-denies in ${this._confirmTimeoutMs / 1000}s)`; + this.terminal.printHTML(`⚠️ AI wants to: ${names}(${truncatedArgs}). <span class="cmd-link" onclick="webx11.executeCommand('ai-allow')" style="color:var(--success-color);cursor:pointer;text-decoration:underline;">ai-allow</span> to confirm, <span class="cmd-link" onclick="webx11.executeCommand('ai-deny')" style="color:var(--error-color);cursor:pointer;text-decoration:underline;">ai-deny</span> to refuse.${timeoutLabel}`); + // Show confirm bar OUTSIDE scrollable output (always visible) + const area = document.getElementById('ai-confirm-area'); + if (!area) { + // Fallback to promise resolve + return true; + } + area.innerHTML = ` + <span class="confirm-desc">⚠️ ${names}(${truncatedArgs})</span> + <button class="confirm-btn" id="ai-confirm-allow" onclick="(function(){var a=document.getElementById('ai-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="ai-confirm-deny" onclick="(function(){var d=document.getElementById('ai-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span class="confirm-timer" id="ai-confirm-timer"></span> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: ai-allow or ai-deny</span> + `; + area.classList.add('visible'); + this._confirmArea = area; + this._confirmBar = area; + + // Attach button handlers via both addEventListener + DOM property (inline onclick fallback) + const allowBtn = document.getElementById('ai-confirm-allow'); + const denyBtn = document.getElementById('ai-confirm-deny'); + const onAllow = () => { + this._resolveConfirm(true); + }; + const onDeny = () => { + this._resolveConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + + const startTime = Date.now(); + return new Promise((resolve) => { + this._pendingConfirmation = { names, args }; + this._confirmResolve = resolve; + this._confirmAllowBtn = allowBtn; + this._confirmDenyBtn = denyBtn; + this._confirmOnAllow = onAllow; + this._confirmOnDeny = onDeny; + // Countdown timer updates with escalating visibility + const timerInterval = setInterval(() => { + const elapsed = Math.round((Date.now() - startTime) / 1000); + const remaining = Math.max(0, (this._confirmTimeoutMs / 1000) - elapsed); + const timerEl = document.getElementById('ai-confirm-timer'); + if (timerEl) { + timerEl.textContent = `⏱ ${remaining}s`; + timerEl.className = 'confirm-timer'; + if (remaining > 30) { + timerEl.style.color = 'var(--warning-color)'; + } else if (remaining > 10) { + timerEl.classList.add('urgent'); + } else { + timerEl.classList.add('critical'); + } + } + if (remaining === 60) { + this.terminal.print(`⏱ 60s remaining to respond to AI confirmation for: ${names}`, 'warning'); + } else if (remaining === 30) { + this.terminal.print(`⏱ 30s — ${names} will be auto-denied soon!`, 'warning'); + } else if (remaining === 10) { + this.terminal.print(`⏱ 10s remaining! Type ai-allow or click Allow now!`, 'warning'); + } + }, 500); + this._confirmTimerInterval = timerInterval; + this._confirmTimeout = setTimeout(() => { + clearInterval(timerInterval); + if (this._confirmResolve) { + this._confirmResolve(false); + this._cleanupConfirmBar(); + this.terminal.print('⏱ Confirmation timed out — tool not executed', 'warning'); + } + }, this._confirmTimeoutMs); + }); + } + _resolveConfirm(approved) { + if (this._confirmResolve) { + this._confirmResolve(approved); + this._cleanupConfirmBar(); + if (approved) { + this.terminal.print('Action allowed', 'success'); + } else { + this.terminal.print('Action denied', 'warning'); + } + } + } + _cleanupConfirmBar() { + clearInterval(this._confirmTimerInterval); + clearTimeout(this._confirmTimeout); + this._confirmTimeout = null; + this._confirmTimerInterval = null; + this._confirmResolve = null; + this._pendingConfirmation = null; + this._confirmAllowBtn = null; + this._confirmDenyBtn = null; + this._confirmOnAllow = null; + this._confirmOnDeny = null; + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + this._confirmArea = null; + this._confirmBar = null; + const timerEl = document.getElementById('ai-confirm-timer'); + if (timerEl) timerEl.style.animation = ''; + } + confirmPending() { + this._resolveConfirm(true); + } + denyPending() { + this._resolveConfirm(false); + } + ignoreValidator() { + this._validatorSuppressed = true; + this.terminal.print('🔍 Validator suppressed for this session', 'info'); + } + cancelAi() { + this._cancelled = true; + this.terminal.print('⏹ Cancelling AI...', 'warning'); + // Abort any in-flight HTTP request + if (this.terminal.ollamaManager) { + this.terminal.ollamaManager.abort(); + } + // Clear any pending confirmation + if (this._confirmResolve) { + this._resolveConfirm(false); + } + // Immediately reset UI so user knows it stopped + this.terminal.setAiBusy(false); + } + toggleDebug() { + this._debugPanelEnabled = !this._debugPanelEnabled; + let panel = document.getElementById('debug-panel'); + if (!panel) { + // Create the debug panel + panel = document.createElement('div'); + panel.id = 'debug-panel'; + panel.style.cssText = 'position:fixed;right:10px;top:50px;width:340px;max-height:350px;overflow-y:auto;background:#0a0a0a;border:1px solid #333;border-radius:6px;z-index:100;font-family:monospace;font-size:0.75em;display:none;'; + document.body.appendChild(panel); + // Add close button handler + panel.addEventListener('click', function() { + // Toggle collapsed/expanded state + }); + } + panel.style.display = this._debugPanelEnabled ? 'block' : 'none'; + if (this._debugPanelEnabled) { + this._updateDebugPanel(); + this.terminal.print('🔧 Debug panel enabled — shows tool call log in bottom-right corner. Close with: debug', 'info'); + } else { + this.terminal.print('🔧 Debug panel disabled', 'info'); + } + } + // 🔒 === GUARDRAIL LAYER === + _loadGuardrailPolicy() { + try { + const raw = localStorage.getItem('webx11_guardrail_policy'); + if (raw) { + this._guardrailPolicy = JSON.parse(raw); + } else { + // Default policy — seed on first run + this._guardrailPolicy = { + version: 1, + rules: [ + { + id: 'rm-rf-block', + pattern: '/rm\\s+-rf/', + description: 'Prevent recursive force deletes outside trash', + severity: 'deny', + tools: ['run_command', 'write_file'] + }, + { + id: 'curl-pipe-bash', + pattern: '/(curl|wget)\\s+.*\\|\\s*(bash|sh)/', + description: 'Block curl-to-bash execution', + severity: 'deny', + tools: ['run_command'] + }, + { + id: 'rm-outer-vfs', + pattern: '/delete_file.*\\.(user\\.json|token|session|key)/', + description: 'Block deletion of sensitive metadata files', + severity: 'deny', + tools: ['delete_file'] + }, + { + id: 'mass-delete', + pattern: '/rm\\s+-rf\\s+\\/|delete_file.*\\*|\\.*\\/\\*/', + description: 'Block mass-delete operations', + severity: 'deny', + tools: ['run_command', 'delete_file'] + }, + { + id: 'chmod-777', + pattern: '/chmod\\s+777/', + description: 'Block world-writable permissions', + severity: 'deny', + tools: ['run_command'] + }, + { + id: 'external-dl', + pattern: '/(wget|curl)\\s+-[oO]\\s+/', + description: 'External file download — requires high confidence', + severity: 'warn', + tools: ['run_command'] + }, + { + id: 'env-overwrite', + pattern: '/export\\s+\\w+\\s*=\\s*/', + description: 'Modifying environment variables', + severity: 'warn', + tools: ['run_command'] + }, + { + id: 'eval-danger', + pattern: '/(eval|exec|system)\\s*\\(/', + description: 'Dynamic code execution', + severity: 'deny', + tools: ['run_command'] + } + ], + descriptions: {} + }; + localStorage.setItem('webx11_guardrail_policy', JSON.stringify(this._guardrailPolicy)); + } + } catch (e) { + this._guardrailPolicy = { version: 1, rules: [] }; + } + } + _saveGuardrailPolicy() { + try { + localStorage.setItem('webx11_guardrail_policy', JSON.stringify(this._guardrailPolicy)); + } catch (e) {} + } + checkGuardrail(toolName, args) { + if (!this._guardrailPolicy || !this._guardrailPolicy.rules) return null; + const argsStr = typeof args === 'object' ? JSON.stringify(args) : String(args); + for (const rule of this._guardrailPolicy.rules) { + if (!rule.tools.includes(toolName)) continue; + try { + const re = new RegExp(rule.pattern.slice(1, -1), 'i'); + if (re.test(argsStr)) { + this._guardrailViolations[rule.id] = (this._guardrailViolations[rule.id] || 0) + 1; + return rule; // { id, pattern, description, severity, tools } + } + } catch (e) { /* skip malformed regex */ } + } + return null; + } + // 🎯 === CONFIDENCE ESCALATION === + _parseConfidence(text) { + if (!text || typeof text !== 'string') return null; + // Look for patterns like "Confidence: 85%" or "confidence=95%" or "I am 72% confident" + const patterns = [ + /confidence[:\s]*(\d+)%/i, + /confidence[=:]?\s*(\d+)\s*%/i, + /(\d+)%\s*confiden/i, + /confiden[ts]\s+(\d+)%/i + ]; + for (const re of patterns) { + const m = text.match(re); + if (m) return parseInt(m[1], 10); + } + return null; + } + // 📸 === SHADOW VFS (Sandboxed Execution) === + _snapshotShadowVFS() { + // Deep-clone the VFS root state into a snapshot + try { + const root = this.terminal.fs.root; + this._shadowSnapshot = JSON.parse(JSON.stringify(root)); + return true; + } catch (e) { + this.terminal.print('⚠️ Shadow VFS snapshot failed: ' + e.message, 'error'); + return false; + } + } + _diffShadowVFS(consume = true) { + if (!this._shadowSnapshot) return null; + try { + const current = this.terminal.fs.root; + const changes = []; + this._walkDiff(this._shadowSnapshot, current, '', changes); + if (consume) { + this._shadowSnapshot = null; + } + return changes; + } catch (e) { + this._shadowSnapshot = null; + return [{ type: 'diff-error', message: e.message }]; + } + } + _walkDiff(snapshot, live, path, changes) { + const snapKeys = snapshot && snapshot.content ? Object.keys(snapshot.content) : []; + const liveKeys = live && live.content ? Object.keys(live.content) : []; + // New files in live + for (const key of liveKeys) { + const livePath = path ? path + '/' + key : key; + const liveEntry = live.content[key]; + const snapEntry = snapshot.content ? snapshot.content[key] : undefined; + if (!snapEntry) { + const entrySize = liveEntry.type === 'file' ? (liveEntry.content || '').length : 0; + changes.push({ type: 'created', path: livePath, entryType: liveEntry.type, size: entrySize }); + } else if (liveEntry.type === 'file' && liveEntry.content !== snapEntry.content) { + const oldLen = (snapEntry.content || '').length; + const newLen = (liveEntry.content || '').length; + changes.push({ type: 'modified', path: livePath, sizeBefore: oldLen, sizeAfter: newLen }); + } + // Recurse into directories + if (liveEntry.type === 'dir' && snapEntry && snapEntry.type === 'dir') { + this._walkDiff(snapEntry, liveEntry, livePath, changes); + } + } + // Deleted files + for (const key of snapKeys) { + if (!liveKeys.includes(key)) { + changes.push({ type: 'deleted', path: path ? path + '/' + key : key }); + } + } + } + // 🔒 === GUARDRAIL COMMANDS === + guardrailCmd(args) { + if (args.length === 0) { + this.terminal.print('=== Guardrail Policy ===', 'info'); + if (!this._guardrailPolicy) { + this.terminal.print('No guardrail policy loaded', 'warning'); + return; + } + this.terminal.print(`Rules: ${this._guardrailPolicy.rules.length}`, 'info'); + for (const rule of this._guardrailPolicy.rules) { + const icon = rule.severity === 'deny' ? '🔴' : '🟡'; + this.terminal.print(`${icon} ${rule.id}: ${rule.description}`, 'info'); + } + const violations = Object.values(this._guardrailViolations).reduce((a, b) => a + b, 0); + if (violations > 0) { + this.terminal.print(`⚠️ ${violations} violation(s) this session`, 'warning'); + } + this.terminal.print('Usage: guardrail-add <id> <severity> <pattern> <tools...> | guardrail-remove <id>', 'info'); + return; + } + const sub = args[0].toLowerCase(); + if (sub === 'add' && args.length >= 4) { + const id = args[1]; + const severity = args[2]; + const pattern = args[3]; + const tools = args.slice(4); + this._guardrailPolicy.rules.push({ id, severity, pattern, tools, description: 'Custom rule' }); + this._saveGuardrailPolicy(); + this.terminal.print(`✅ Guardrail rule '${id}' added`, 'success'); + } else if (sub === 'remove' && args.length >= 2) { + const idx = this._guardrailPolicy.rules.findIndex(r => r.id === args[1]); + if (idx >= 0) { + this._guardrailPolicy.rules.splice(idx, 1); + this._saveGuardrailPolicy(); + this.terminal.print(`✅ Guardrail rule '${args[1]}' removed`, 'success'); + } else { + this.terminal.print(`❌ Rule '${args[1]}' not found`, 'error'); + } + } else { + this.terminal.print('Usage: guardrail-add <id> <severity> <pattern> <tools...> | guardrail-remove <id>', 'warning'); + } + } + // 🎯 SHADOW COMMAND + shadowExec(args) { + // Usage: shadow <command> + // Snapshot VFS, run command, diff output + if (args.length === 0) { + this.terminal.print('Usage: shadow <command> — Execute command in shadow VFS (sandbox preview)', 'warning'); + return; + } + const cmd = args.join(' '); + this._snapshotShadowVFS(); + const cmdName = cmd.split(' ')[0].toLowerCase(); + const cmdArgs = cmd.split(' ').slice(1); + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + this.terminal.dispatchCommand(cmdName, cmdArgs).then(() => { + this.terminal.print = originalPrint; + const diff = this._diffShadowVFS(false); + if (!diff || diff.length === 0) { + this.terminal.print('📦 Shadow: No filesystem changes detected.', 'success'); + } else { + this.terminal.print(`📦 Shadow: ${diff.length} change(s) detected:`, 'info'); + for (const c of diff) { + const icon = c.type === 'created' ? '➕' : c.type === 'modified' ? '✏️' : '🗑️'; + const sizeStr = c.size !== undefined ? c.size + 'B' : ''; + const detail = c.type === 'created' ? `(${c.entryType}${sizeStr ? ', ' + sizeStr : ''})` : + c.type === 'modified' ? `(${c.sizeBefore}→${c.sizeAfter}B)` : ''; + this.terminal.print(`${icon} ${c.path} ${detail}`, c.type === 'deleted' ? 'warning' : 'info'); + } + // Ask to commit or rollback + this.terminal.print('🔒 VFS modified — type "shadow-commit" to accept or "shadow-rollback" to undo.', 'warning'); + } + }).catch(e => { + this.terminal.print = originalPrint; + this.terminal.print('⚠️ Shadow command error: ' + e.message, 'error'); + }); + } + shadowCommit() { + this._shadowSnapshot = null; + this.terminal.print('✅ Shadow changes committed to VFS', 'success'); + } + shadowRollback() { + if (!this._shadowSnapshot) { + this.terminal.print('❌ No shadow snapshot to rollback to', 'error'); + return; + } + try { + this.terminal.fs.root = JSON.parse(JSON.stringify(this._shadowSnapshot)); + this.terminal.fs.saveToLocalStorage(); + this._shadowSnapshot = null; + this.terminal.print('↩️ VFS rolled back to pre-shadow state', 'success'); + } catch (e) { + this.terminal.print('⚠️ Rollback failed: ' + e.message, 'error'); + } + } + // 🔒 OVERRIDES for isDestructive — adds runTool intercept + // Now command-aware: run_command is only destructive for mutating commands + isDestructive(name, args = {}) { + if (name === 'run_command') { + const cmdStr = typeof args === 'object' ? (args?.command || '') : String(args); + const cmd = cmdStr.split(' ')[0].toLowerCase(); + const safeRead = new Set(['ls','cat','pwd','echo','date','env','whoami','which','hostname', + 'cal','tree','find','grep','head','tail','sort','wc','du','diff','basename','dirname', + 'yes','seq','cut','uniq','expand','printf','base64','help','clear','set','export', + 'unalias','history','ll','h','mem','status','df','free','top','ps','health-check', + 'system-health','modules','listmods','modules-list','get-help-info','alias', + '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']); + return !safeRead.has(cmd); + } + return ['write_file','delete_file','make_directory','edit_module'].includes(name); + } + async runTool(name, argsStr) { + let a = {}; + try { + // 🔒 GUARDRAIL CHECK — intercept before any tool executes + const guardrail = this.checkGuardrail(name, argsStr); + if (guardrail && guardrail.severity === 'deny') { + const errMsg = `🔒 GUARDRAIL BLOCKED: ${guardrail.description} (rule: ${guardrail.id})`; + this.terminal.print(errMsg, 'error'); + this.terminal.log('WARN', 'GUARDRAIL', errMsg); + return JSON.stringify({ error: errMsg }); + } + if (guardrail && guardrail.severity === 'warn') { + this.terminal.print(`🟡 GUARDRAIL WARNING: ${guardrail.description} (rule: ${guardrail.id})`, 'warning'); + } + a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { + case 'list_files': { + const originalPath = a.path || ''; + const p = originalPath.replace(/^\//, ''); + const saved = [...this.terminal.fs.currentPath]; + if (originalPath.startsWith('/')) { + this.terminal.fs.currentPath = []; + } + if (p) { + const parts = p.split('/'); + const dir = this.terminal.fs.root; + let cur = dir; + let found = true; + for (const part of parts) { + if (part === '..') { saved.pop(); continue; } + if (part && cur.content[part] && cur.content[part].type === 'dir') cur = cur.content[part]; + else { found = false; break; } + } + if (!found) { + // Retry from root — cwd may be stale + this.terminal.fs.currentPath = []; + cur = this.terminal.fs.root; + found = true; + for (const part of parts) { + if (part === '..') continue; + if (part && cur.content[part] && cur.content[part].type === 'dir') cur = cur.content[part]; + else { found = false; break; } + } + if (!found) { + this.terminal.fs.currentPath = saved; + return JSON.stringify({ error: `Directory not found: ${p}` }); + } + } + this.terminal.fs.currentPath = saved.concat(parts.filter(x => x !== '..')); + } + const files = this.terminal.fs.ls(); + this.terminal.fs.currentPath = saved; + return JSON.stringify(files.map(f => ({ name: f.name, type: f.type, size: f.size }))); + } + case 'read_file': { + const rp = a.path || ''; + const savedR = [...this.terminal.fs.currentPath]; + if (rp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = rp.replace(/^\//, ''); + const content = this.terminal.fs.readFile(path); + this.terminal.fs.currentPath = savedR; + if (content === null) return JSON.stringify({ error: `File not found: ${path}` }); + return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content; + } + case 'write_file': { + const wp = a.path || ''; + const savedW = [...this.terminal.fs.currentPath]; + if (wp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = wp.replace(/^\//, ''); + const content = a.content || ''; + this.terminal.fs.writeFile(path, content); + this.terminal.fs.currentPath = savedW; + return `File written: ${path} (${content.length} chars)`; + } + case 'make_directory': { + const mp = a.path || ''; + const savedM = [...this.terminal.fs.currentPath]; + if (mp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = mp.replace(/^\//, ''); + const ok = this.terminal.fs.mkdir(path); + this.terminal.fs.currentPath = savedM; + if (ok) return `Directory created: ${path}`; + return JSON.stringify({ error: `Cannot create: ${path}` }); + } + case 'delete_file': { + const dp = a.path || ''; + const savedD = [...this.terminal.fs.currentPath]; + if (dp.startsWith('/')) this.terminal.fs.currentPath = []; + const path = dp.replace(/^\//, ''); + const delOk = this.terminal.fs.deleteFile(path); + this.terminal.fs.currentPath = savedD; + if (delOk) return `Deleted: ${path}`; + return JSON.stringify({ error: `Cannot delete: ${path}` }); + } + case 'get_status': { + const fs = this.terminal.fs; + const mm = this.terminal.moduleManager; + const om = this.terminal.ollamaManager; + return JSON.stringify({ + files: fs.ls().length, + dirs: fs.currentPath.length ? '/' + fs.currentPath.join('/') : '/', + modules: Object.keys(mm.modules).length, + ollama: om.connected ? `Connected (${om.currentModel})` : 'Disconnected', + memory: performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A' + }); + } + case 'get_help': { + const cmds = this.terminal.moduleManager.getAllCommands(); + return JSON.stringify(Object.keys(cmds).sort()); + } + case 'get_current_dir': { + return '/' + this.terminal.fs.currentPath.join('/'); + } + case 'web_search': { + const query = a.query || ''; + if (!query) return JSON.stringify({ error: 'No query provided' }); + try { + const resp = await fetch('/api/search?q=' + encodeURIComponent(query)); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return JSON.stringify({ error: 'Search failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' }); + } + const data = await resp.json(); + const results = data.results || []; + if (results.length === 0) return JSON.stringify({ message: 'No results found' }); + return JSON.stringify({ count: results.length, source: data.source, results: results.map(r => ({ title: r.title, url: r.url, content: (r.content || '').substring(0, 300) })) }); + } catch (e) { return JSON.stringify({ error: 'Search failed: ' + e.message }); } + } + case 'web_fetch': { + const url = a.url || ''; + if (!url) return JSON.stringify({ error: 'No URL provided' }); + const method = (a.method || 'GET').toUpperCase(); + const body = a.body || ''; + const contentType = a.content_type || (method === 'POST' ? 'application/json' : ''); + try { + // Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content) + const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract'; + const params = new URLSearchParams({ url }); + if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); } + const resp = await fetch(endpoint + '?' + params.toString()); + if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' }); + } + // For POST/route, the response is raw text; for GET/extract it's JSON + if (method === 'POST') { + const text = await resp.text(); + return JSON.stringify({ method, content: text.substring(0, 4000), content_length: text.length }); + } + const data = await resp.json(); + return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method_proxy: data.method || 'extract' }); + } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } + } + case 'wpm_install': { + const pkg = a.package || ''; + if (!pkg) return JSON.stringify({ error: 'No package name' }); + const mod = this.terminal.moduleManager.modules['WpmModule']; + if (mod) { await mod.install(pkg); return `Package "${pkg}" installation attempted`; } + return JSON.stringify({ error: 'WPM module not available' }); + } + case 'wpm_search': { + const kw = a.keyword || ''; + if (!kw) return JSON.stringify({ error: 'No keyword' }); + const mod = this.terminal.moduleManager.modules['WpmModule']; + if (mod) { + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + try { await mod.search(kw); } finally { this.terminal.print = originalPrint; } + return output.trim() || 'Search completed'; + } + return JSON.stringify({ error: 'WPM module not available' }); + } + case 'run_command': { + const cmd = a.command || ''; + if (!cmd) return JSON.stringify({ error: 'No command provided' }); + // 📸 Shadow VFS auto-snapshot before run_command + this._snapshotShadowVFS(); + const originalPrint = this.terminal.print.bind(this.terminal); + let output = ''; + this.terminal.print = (text, type) => { output += text + '\n'; }; + try { await this.terminal.security.checkAndRun(cmd); } finally { this.terminal.print = originalPrint; } + // 📸 Diff and append shadow report + const shadowDiff = this._diffShadowVFS(); + if (shadowDiff && shadowDiff.length > 0) { + const diffSummary = shadowDiff.map(c => `${c.type}:${c.path}`).join(', '); + output += `\n📦 [Shadow VFS: ${shadowDiff.length} change(s) — ${diffSummary}]`; + this._shadowSnapshot = null; // Changes considered "committed" since AI explicitly ran command + } + return output.trim() || '(no output)'; + } + case 'check_updates': { + const reg = this.terminal.moduleManager.moduleRegistry; + const results = []; + for (const mod of reg) { + if (mod.url && mod.url.startsWith('http')) { + try { + const resp = await fetch(mod.url, { method: 'HEAD' }); + results.push({ name: mod.name, url: mod.url, status: resp.ok ? 'reachable' : 'error (' + resp.status + ')' }); + } catch (e) { + results.push({ name: mod.name, url: mod.url, status: 'unreachable' }); + } + } + } + return JSON.stringify({ checked: results.length, modules: results }); + } + case 'user_get': { + const key = a.key || ''; + if (!key) return JSON.stringify({ error: 'Key is required' }); + const val = key.split('.').reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, this._userProfile); + return JSON.stringify({ key, value: val !== undefined ? val : null }); + } + case 'user_set': { + const ukey = a.key || ''; + if (!ukey) return JSON.stringify({ error: 'Key and value are required' }); + const uval = a.value || ''; + const keys = ukey.split('.'); + let obj = this._userProfile; + for (let i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') obj[keys[i]] = {}; + obj = obj[keys[i]]; + } + obj[keys[keys.length - 1]] = uval; + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + return JSON.stringify({ success: true, key: ukey, value: uval }); + } + case 'user_list': { + return JSON.stringify({ profile: this._userProfile, keys: Object.keys(this._userProfile) }); + } + case 'user_forget': { + const fkey = a.key || ''; + if (!fkey) return JSON.stringify({ error: 'Key is required' }); + if (fkey.includes('.')) { + const parts = fkey.split('.'); + let obj = this._userProfile; + for (let i = 0; i < parts.length - 1; i++) { + if (!obj[parts[i]]) return JSON.stringify({ error: 'Key not found' }); + obj = obj[parts[i]]; + } + delete obj[parts[parts.length - 1]]; + } else { + delete this._userProfile[fkey]; + } + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + return JSON.stringify({ success: true, key: fkey }); + } + case 'session_save': { + const summary = a.summary || 'No summary provided'; + const fs = this.terminal.fs; + fs.mkdirRecursive('/memory'); + const today = new Date().toISOString().split('T')[0]; + const log = { date: today, summary, toolCallCount: 0, skillsCreated: [], modulesEdited: [], timestamp: new Date().toISOString() }; + fs.fsWriteJSON('/memory/' + today + '.json', log); + const index = fs.fsReadJSON('/memory/index.json') || {}; + index[today] = { summary, tasks: (index[today]?.tasks || 0) + 1, updated: new Date().toISOString() }; + fs.fsWriteJSON('/memory/index.json', index); + return JSON.stringify({ success: true, date: today }); + } + case 'session_load': { + const sdate = a.date || ''; + if (!sdate) return JSON.stringify({ error: 'Date required (YYYY-MM-DD)' }); + const sfs = this.terminal.fs; + const slog = sfs.fsReadJSON('/memory/' + sdate + '.json'); + if (!slog) return JSON.stringify({ error: 'No session log for ' + sdate }); + return JSON.stringify(slog); + } + case 'session_recent': { + const count = a.count || 3; + const rfs = this.terminal.fs; + const rindex = rfs.fsReadJSON('/memory/index.json') || {}; + const dates = Object.keys(rindex).sort().reverse().slice(0, count); + const sessions = dates.map(d => ({ date: d, ...rindex[d] })); + return JSON.stringify({ sessions, count: sessions.length }); + } + case 'session_terminal': { + const tfs = this.terminal.fs; + try { + const raw = tfs.readFile('_sessions/_auto.json'); + if (!raw) return JSON.stringify({ error: 'No auto-saved session found' }); + const data = JSON.parse(raw); + const lines = (data.terminalLines || []).slice(-50); + const text = lines.map(l => { + const ts = l.t ? '[' + l.t + '] ' : ''; + return ts + l.l; + }).join('\n'); + return JSON.stringify({ lines: lines.length, output: text, savedAt: data.savedAt || '' }); + } catch (e) { + return JSON.stringify({ error: 'Could not read terminal history: ' + e.message }); + } + } + case 'session_stats': { + const sfs = this.terminal.fs; + const sindex = sfs.fsReadJSON('/memory/index.json') || {}; + const entries = Object.entries(sindex); + let totalTasks = 0; + for (const [, meta] of entries) totalTasks += meta.tasks || 0; + return JSON.stringify({ totalSessions: entries.length, totalTasks, dates: entries.map(([d]) => d) }); + } + case 'list_agents': { + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (!registry) return JSON.stringify({ error: 'Agent registry not available' }); + const agents = this.terminal.fs.ls('/agents/'); + const results = []; + if (agents) { + agents.forEach(a => { + if (a.type === 'dir' && a.name !== 'configured') { + const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); + if (data) results.push(JSON.parse(data)); + } + }); + } + return JSON.stringify({ agents: results, count: results.length }); + } + case 'delegate_task': { + const task = a.task || ''; + if (!task) return JSON.stringify({ error: 'No task provided' }); + const orch = this.terminal.moduleManager.modules['OrchestratorModule']; + if (!orch) return JSON.stringify({ error: 'Orchestrator not available' }); + orch.delegateTask(task); + return JSON.stringify({ message: `Task delegated: ${task.substring(0, 80)}` }); + } + case 'spawn_agent': { + const role = a.role || 'worker'; + const caps = a.capabilities ? a.capabilities.split(',').map(s => s.trim()) : ['list_files', 'read_file']; + const orch = this.terminal.moduleManager.modules['OrchestratorModule']; + if (!orch) return JSON.stringify({ error: 'Orchestrator not available' }); + const agent = orch.spawnAgent(role, caps); + if (!agent) return JSON.stringify({ error: 'Agent spawn failed — limit reached or concurrent spawn conflict' }); + return JSON.stringify({ message: `Agent spawned: ${agent.id}`, agent }); + } + case 'load_skills': { + const query = (a && a.query || '').toLowerCase(); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + if (!query) { + const names = Object.keys(index); + return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' }); + } + const matches = []; + for (const [name, meta] of Object.entries(index)) { + const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0); + if (score > 0) { + const content = fs.readFile(`/skills/${name}.md`) || ''; + matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) }); + } + } + matches.sort((a, b) => b.score - a.score); + const top = matches.slice(0, 3); + return JSON.stringify(top.length > 0 ? { matches: top.map(m => ({ name: m.name, description: m.description, content: m.content })), count: top.length } : { matches: [], count: 0, message: 'No matching skills found. You can create one after this task.' }); + } + case 'create_skill': { + const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase(); + if (!name) return JSON.stringify({ error: 'Skill name is required' }); + const description = a.description || ''; + let content = a.content || ''; + const tags = (a.tags || '').split(',').map(t => t.trim()).filter(Boolean); + const category = a.category || 'general'; + if (!content.startsWith('---')) { + content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: ${category}\n---\n## Steps\n${content}`; + } + const fs = this.terminal.fs; + fs.mkdirRecursive('/skills'); + fs.writeFile(`/skills/${name}.md`, content); + const index = fs.fsReadJSON('/skills/index.json') || {}; + index[name] = { name, description, tags, category, created: index[name]?.created || new Date().toISOString(), updated: new Date().toISOString() }; + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`💡 Skill saved: ${name}`, 'success'); + return JSON.stringify({ success: true, name, message: `Skill "${name}" created (${content.length} chars)` }); + } + case 'patch_skill': { + const pname = (a.name || '').trim(); + if (!pname) return JSON.stringify({ error: 'Skill name is required' }); + const pcontent = a.content || ''; + if (!pcontent) return JSON.stringify({ error: 'Skill content is required' }); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` }); + fs.writeFile(`/skills/${pname}.md`, pcontent); + index[pname].updated = new Date().toISOString(); + // Re-parse frontmatter for metadata updates + const metaMatch = pcontent.match(/^---\n([\s\S]*?)\n---/); + if (metaMatch) { + const metaLines = metaMatch[1].split('\n'); + metaLines.forEach(line => { + const [k, ...v] = line.split(':'); + if (k === 'description') index[pname].description = v.join(':').trim(); + if (k === 'tags') index[pname].tags = v.join(':').trim().replace(/[\[\]]/g, '').split(',').map(t => t.trim()); + if (k === 'category') index[pname].category = v.join(':').trim(); + }); + } + fs.fsWriteJSON('/skills/index.json', index); + this.terminal.print(`💡 Skill patched: ${pname}`, 'success'); + return JSON.stringify({ success: true, name: pname, message: `Skill "${pname}" updated` }); + } + case 'list_skills': { + const filterCat = (a.category || '').toLowerCase(); + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + const list = Object.entries(index).map(([name, meta]) => ({ name, description: meta.description, category: meta.category || 'general', tags: meta.tags || [], updated: meta.updated })) + .filter(s => !filterCat || (s.category || '').toLowerCase() === filterCat); + return JSON.stringify({ skills: list, count: list.length }); + } + case 'nvfs_resolve': { + const concept = (a.concept || '').toLowerCase(); + if (!concept) return JSON.stringify({ error: 'Concept is required' }); + const nvfs = this.terminal.moduleManager.modules['NeuralVFSModule']; + if (!nvfs) return JSON.stringify({ error: 'Neural VFS module not loaded — run nvfs-index first' }); + // Build index if empty + if (Object.keys(nvfs.index).length === 0) { + const saved = this.terminal.fs.fsReadJSON('/.semantic_index/map.json'); + if (saved) { nvfs.index = saved; } + else { return JSON.stringify({ error: 'No semantic index found — run nvfs-index first' }); } + } + const exact = nvfs.index[concept]; + const fuzzy = Object.keys(nvfs.index).filter(k => + k.includes(concept) || concept.includes(k) + ).filter(k => k !== concept); + const results = []; + if (exact) results.push(...exact.map(e => ({ concept, path: e.path, summary: e.summary }))); + for (const f of fuzzy.slice(0, 5)) { + results.push(...nvfs.index[f].slice(0, 3).map(e => ({ concept: f, path: e.path, summary: e.summary }))); + } + return JSON.stringify({ concept, exact: !!exact, count: results.length, results: results.slice(0, 20) }); + } + case 'list_available_modules': { + try { + const resp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); + if (!resp.ok) return JSON.stringify({ error: 'Module index not available' }); + const idx = await resp.json(); + const filterType = (a.type || '').toLowerCase(); + let mods = Object.values(idx.modules); + if (filterType) mods = mods.filter(m => m.type === filterType); + return JSON.stringify({ + total: mods.length, + available: idx.total, + type: filterType || 'all', + modules: mods.map(m => ({ + name: m.name, + type: m.type, + class: m.class, + commands: m.commands.slice(0, 8), + description: (m.description || '').substring(0, 80), + auto_load: m.auto_load + })) + }); + } catch (e) { return JSON.stringify({ error: 'Failed to load module index: ' + e.message }); } + } + case 'smart_context_list': { + const scm = this.terminal.moduleManager.modules['SmartContextModule']; + if (!scm) return JSON.stringify({ error: 'SmartContextModule not loaded', profiles: [] }); + const profiles = scm.contextProfiles || {}; + return JSON.stringify({ + profiles: Object.entries(profiles).map(([key, p]) => ({ + name: key, + displayName: p.name || key, + systemPrompt: (p.systemPrompt || '').substring(0, 80), + temperature: p.temperature || 0.7, + active: scm.activeProfile === key + })) + }); + } + case 'smart_context_set': { + const scm = this.terminal.moduleManager.modules['SmartContextModule']; + if (!scm) return JSON.stringify({ error: 'SmartContextModule not loaded' }); + const name = (a.name || '').toLowerCase(); + if (!scm.contextProfiles[name]) { + return JSON.stringify({ error: `Profile '${name}' not found`, available: Object.keys(scm.contextProfiles) }); + } + scm.activeProfile = name; + const profile = scm.contextProfiles[name]; + return JSON.stringify({ success: true, name, displayName: profile.name, systemPrompt: profile.systemPrompt, temperature: profile.temperature }); + } + case 'create_mindmap': { + const mindmap = this.terminal.moduleManager.modules['MindmapModule']; + if (!mindmap) return JSON.stringify({ error: 'MindmapModule not loaded' }); + const topic = a.topic || 'Mindmap'; + const children = a.children || ''; + const args = topic.split(' '); + mindmap._new(args); + const cur = mindmap._ensureMindmap(); + if (!cur) return JSON.stringify({ error: 'Failed to create mindmap' }); + const rootId = cur.root.id; + const added = []; + if (children) { + const childNames = children.split(',').map(c => c.trim()).filter(Boolean); + for (const child of childNames) { + mindmap._add([rootId, child]); + added.push(child); + } + } + mindmap._svg([]); + return JSON.stringify({ success: true, topic, children: added.length ? added : 'none', nodes: mindmap._countNodes(cur.root), rendered: 'inline SVG in terminal' }); + } + case 'view_module': + case 'edit_module': + case 'test_module': + case 'reload_module': + case 'list_modified_modules': + case 'get_module_status': + return await this.runModuleTool(name, argsStr); + case 'vaultmind_query': { + const ep = a.endpoint; + const validEps = ['prices', 'portfolio', 'transactions', 'copy-trade', 'chat']; + if (!validEps.includes(ep)) { + return JSON.stringify({ error: 'Invalid endpoint. Valid: ' + validEps.join(', ') }); + } + try { + let url = '/vmai/' + ep; + let opts = { method: 'GET', headers: { 'Accept': 'application/json' } }; + if (ep === 'chat') { + opts.method = 'POST'; + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify({ + message: a.message || 'What is the current market status?', + agent: a.agent || 'market' + }); + } + const resp = await fetch(url, opts); + const data = await resp.json(); + return JSON.stringify({ success: true, data }); + } catch (e) { + return JSON.stringify({ error: 'VaultMind API error: ' + e.message }); + } + } + default: + return JSON.stringify({ error: `Unknown tool: ${name}` }); + } + } catch (e) { + this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); + if (this._errorLog.length > 20) this._errorLog.shift(); + return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); + } + } + async runModuleTool(name, argsStr) { + let a = {}; + try { + a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + switch (name) { + case 'view_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + const code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (code) return code; + const url = entry ? entry.url : null; + // Try registry URL first + if (url) { + try { + const resp = await fetch(url); + if (resp.ok) { + const src = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src); + return src; + } + } catch (e) { /* fall through */ } + } + // Fallback: use modules-index.json to map class name to filename + try { + const idxResp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); + if (idxResp.ok) { + const idx = await idxResp.json(); + // modName is the class name (e.g. "WebX11AIAssistantModule") + // or already a filename (e.g. "module-aiassist") + // Try matching by class name first + let match = Object.values(idx.modules).find(m => m.class === modName); + // Then by name + if (!match) match = idx.modules[modName]; + if (match) { + const filePath = match.file; // e.g. "modules/core/module-aiassist.js" + const fetchUrl = `/spaces/newos4/${filePath}`; + const resp = await fetch(fetchUrl); + if (resp.ok) { + const src = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src); + return src; + } + } + } + } catch (e) { /* fall through */ } + return JSON.stringify({ error: `Cannot find source for ${modName} — not in registry, not in modules-index.json` }); + } + case 'edit_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + const url = entry ? entry.url : null; + let code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (!code && url) { + try { + const resp = await fetch(url); + if (resp.ok) { + code = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ originalUrl: url, modifiedAt: new Date().toISOString(), editCount: 0 })); + } + } catch (e) {} + } + // Fallback: use modules-index.json for core modules (not in registry) + if (!code) { + try { + const idxResp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); + if (idxResp.ok) { + const idx = await idxResp.json(); + let match = Object.values(idx.modules).find(m => m.class === modName); + if (!match) match = idx.modules[modName]; + if (match) { + const filePath = match.file; + const fetchUrl = `/spaces/newos4/${filePath}`; + const resp = await fetch(fetchUrl); + if (resp.ok) { + code = await resp.text(); + this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.js`, code); + this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ originalUrl: fetchUrl, modifiedAt: new Date().toISOString(), editCount: 0 })); + } + } + } + } catch (e) {} + } + if (code) this.terminal.openCodeEditor(modName, url || '', code); + return code ? `Editor opened for ${modName} (${code.length} chars)` : JSON.stringify({ error: `Could not load source for ${modName}` }); + } + case 'test_module': { + const modName = a.name || ''; + const code = this.terminal.fs.readFile(`_mods/${modName}.js`); + if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); + this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); + return `Module "${modName}" loaded from local edit for testing`; + } + case 'reload_module': { + const modName = a.name || ''; + const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); + this.terminal.fs.deleteFile(`_mods/${modName}.js`); + this.terminal.fs.deleteFile(`_mods/${modName}.orig.js`); + this.terminal.fs.deleteFile(`_mods/${modName}.meta.json`); + if (entry && entry.url) { + this.terminal.moduleManager.unloadModule(modName); + await this.terminal.moduleManager.loadModuleFromURL(entry.url); + return `Module "${modName}" reloaded from ${entry.url}`; + } + return JSON.stringify({ error: `No original URL for ${modName}` }); + } + case 'list_modified_modules': { + const modsDir = this.terminal.fs.getCurrentDir(); + const mc = modsDir.content['_mods']; + if (!mc || Object.keys(mc.content).length === 0) return JSON.stringify({ modified: [], message: 'No local edits' }); + const files = Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')); + return JSON.stringify({ modified: files.map(f => f.replace('.js', '')), count: files.length }); + } + case 'get_module_status': { + const mm = this.terminal.moduleManager; + const modsDir = this.terminal.fs.getCurrentDir(); + const hasMods = modsDir.content['_mods'] ? !!Object.keys(modsDir.content['_mods'].content).length : false; + // Load module index for file path lookup + let idxMap = {}; + try { + const resp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); + if (resp.ok) { + const idx = await resp.json(); + for (const [name, meta] of Object.entries(idx.modules)) { + idxMap[meta.class] = '/spaces/newos4/' + meta.file; + } + } + } catch (e) {} + const modules = []; + const seen = new Set(); + for (const [className, instance] of Object.entries(mm.modules)) { + const name = instance.constructor ? instance.constructor.name : className; + if (seen.has(name)) continue; + seen.add(name); + modules.push({ + name: name, + url: idxMap[name] || null, + enabled: true, + loaded: true, + modified: !!this.terminal.fs.readFile(`_mods/${name}.js`), + source: 'loaded' + }); + } + for (const m of mm.moduleRegistry) { + if (seen.has(m.name)) continue; + seen.add(m.name); + modules.push({ + name: m.name, url: m.url || idxMap[m.name] || null, enabled: m.enabled, + loaded: !!mm.modules[m.name], + modified: !!this.terminal.fs.readFile(`_mods/${m.name}.js`), + source: m.url ? 'ext' : 'core' + }); + } + return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods }); + } + default: return JSON.stringify({ error: `Unknown module tool: ${name}` }); + } + } catch (e) { + this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); + if (this._errorLog.length > 20) this._errorLog.shift(); + return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); + } + } + clearContext() { + this.context = []; + localStorage.removeItem('webx11_ai_context'); + this.terminal.print('AI conversation context cleared', 'success'); + } + _saveContext() { + try { + const data = JSON.stringify({ + context: this.context.slice(-20), + messageCount: this._messageCount, + savedAt: Date.now() + }); + localStorage.setItem('webx11_ai_context', data); + // Also persist to VFS for crash survival + try { + const fs = this.terminal.fs; + if (fs) { + fs.mkdirRecursive('_sessions'); + fs.writeFile('_sessions/_auto-context.json', data); + } + } catch (e) {} + } catch (e) { + // localStorage quota exceeded — silently ignore + } + } + _loadContext() { + try { + const saved = localStorage.getItem('webx11_ai_context'); + if (saved) { + const data = JSON.parse(saved); + if (data && data.context) { + this.context = data.context.slice(-20); + this._messageCount = data.messageCount || 0; + } + } + } catch (e) {} + } + setSystemPrompt(args) { + if (!args[0]) { + this.terminal.print('=== System Prompt ===', 'info'); + this.systemPrompt.split('\n').forEach(line => this.terminal.print(line, 'text-muted')); + this.terminal.print(`=== End (${this.systemPrompt.length} chars) ===`, 'info'); + return; + } + this.systemPrompt = args.join(' '); + // Persist to localStorage — next page load will use this instead of built-in + try { localStorage.setItem('webx11_ai_system_prompt', this.systemPrompt); } catch (e) {} + this.terminal.print('System prompt updated (persisted)', 'success'); + } + memDebug() { + const toolLogSize = this._toolCallLog.length; + const errLogSize = this._errorLog.length; + const freqKeys = Object.keys(this._commandFreq).length; + const contextSize = this.context.length; + const factKeys = this._userProfile?.knownFacts ? Object.keys(this._userProfile.knownFacts).length : 0; + const shadowSize = this._shadowSnapshot ? JSON.stringify(this._shadowSnapshot).length : 0; + const guardrailSize = this._guardrailPolicy ? JSON.stringify(this._guardrailPolicy).length : 0; + const vfsSize = (localStorage.getItem('webx11_filesystem') || '').length + + (localStorage.getItem('webx11_filesystem_v2') || '').length; + const aiCtxSize = (localStorage.getItem('webx11_ai_context') || '').length; + const aiPatternSize = (localStorage.getItem('webx11_ai_patterns') || '').length; + const userProfileSize = (localStorage.getItem('webx11_user_profile') || '').length; + const systemPromptSize = (localStorage.getItem('webx11_ai_system_prompt') || '').length; + const guardrailPolicySize = (localStorage.getItem('webx11_guardrail_policy') || '').length; + this.terminal.print('=== Memory Debug ===', 'info'); + this.terminal.print(` _toolCallLog: ${toolLogSize} entries (cap: 50)`, toolLogSize > 30 ? 'warning' : 'text-muted'); + this.terminal.print(` _errorLog: ${errLogSize} entries (cap: 20)`, errLogSize > 10 ? 'warning' : 'text-muted'); + this.terminal.print(` _commandFreq: ${freqKeys} tracked patterns (cap: 30)`, freqKeys > 20 ? 'warning' : 'text-muted'); + this.terminal.print(` AI context: ${contextSize} messages (max: ${this.maxContext * 2})`, 'text-muted'); + this.terminal.print(` User facts: ${factKeys} facts (cap: 50)`, 'text-muted'); + this.terminal.print(` VFS localStorage: ${(vfsSize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` AI context localStorage: ${(aiCtxSize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` AI patterns localStorage: ${(aiPatternSize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` User profile localStorage: ${(userProfileSize / 1024).toFixed(1)} KB`, userProfileSize > 10240 ? 'warning' : 'text-muted'); + this.terminal.print(` System prompt localStorage: ${(systemPromptSize / 1024).toFixed(1)} KB`, systemPromptSize > 10240 ? 'warning' : 'text-muted'); + this.terminal.print(` Guardrail policy localStorage: ${(guardrailPolicySize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` Runtime shadow snapshot: ${(shadowSize / 1024).toFixed(1)} KB`, shadowSize > 10240 ? 'warning' : 'text-muted'); + this.terminal.print(` Runtime guardrail policy: ${(guardrailSize / 1024).toFixed(1)} KB`, 'text-muted'); + const heap = performance.memory ? performance.memory.usedJSHeapSize : null; + if (heap) { + this.terminal.print(` JS heap: ${(heap / 1048576).toFixed(1)} MB / ${(performance.memory.jsHeapSizeLimit / 1048576).toFixed(1)} MB`, 'text-muted'); + } + this.terminal.print('Use reset-mem to clear AI internal logs, context, profile, and guardrail policy', 'info'); + } + resetMem() { + // Capture size before clearing for the success message + const aiCtxSize = (localStorage.getItem('webx11_ai_context') || '').length; + const userProfileSize = (localStorage.getItem('webx11_user_profile') || '').length; + const patternSize = (localStorage.getItem('webx11_ai_patterns') || '').length; + const guardrailSize = (localStorage.getItem('webx11_guardrail_policy') || '').length; + + // Runtime in-memory resets + this._toolCallLog = []; + this._errorLog = []; + this._commandFreq = {}; + this.context = []; + this._messageCount = 0; + this._userProfile = {}; + this._guardrailViolations = {}; + this._shadowSnapshot = null; + this._guardrailPolicy = null; + this._toolDefs = null; // will be rebuilt on next use + + // Persisted localStorage resets + localStorage.removeItem('webx11_ai_context'); + localStorage.removeItem('webx11_ai_patterns'); + localStorage.removeItem('webx11_user_profile'); + localStorage.removeItem('webx11_guardrail_policy'); + localStorage.removeItem('webx11_ai_system_prompt'); + + // VFS backups that _autoRestoreSession can resurrect + try { + const fs = this.terminal.fs; + if (fs) { + fs.deleteFile('_sessions/_auto-context.json'); + fs.deleteFile('_sessions/_auto.json'); + } + } catch (e) {} + + // Mark reset timestamp so auto-restore skips stale backups + localStorage.setItem('webx11_last_reset', Date.now().toString()); + + const totalCleared = aiCtxSize + userProfileSize + patternSize + guardrailSize; + this.terminal.print(`✅ AI memory reset: ${(totalCleared / 1024).toFixed(1)} KB cleared`, 'success'); + this.terminal.print(' • tool logs, error logs, command patterns, conversation context', 'text-muted'); + this.terminal.print(' • user profile / known facts, guardrail policy, shadow snapshot', 'text-muted'); + this.terminal.print(' • VFS session backups and cached system prompt', 'text-muted'); + this.terminal.print('VFS files, terminal history, AI config, and module registry preserved', 'text-muted'); + } + showModel(args) { + const om = this.terminal.ollamaManager; + if (args.length > 0) { + const name = args.join(' '); + if (om.models && om.models.some(m => m.name === name)) { + om.currentModel = name; + localStorage.setItem('webx11_ollama_model', name); + this.terminal.print(`AI model set to: ${name}`, 'success'); + } else { + this.terminal.print(`Model "${name}" not found in available models. Use ai-model to list.`, 'warning'); + } + return; + } + this.terminal.print(`Current AI model: ${om.currentModel || 'none'}`, 'info'); + if (om.models && om.models.length > 0) { + const maxShow = 20; + const names = om.models.slice(0, maxShow).map(m => m.name).join(', '); + this.terminal.print(`Available (${om.models.length}): ${names}${om.models.length > maxShow ? `... +${om.models.length - maxShow} more` : ''}`, 'info'); + } else { + this.terminal.print('No models available. Use ollamaconnect first.', 'warning'); + } + } + aiConfig(args) { + if (args.length === 0) { + this.terminal.print('=== AI Config ===', 'info'); + this.terminal.print(`max-turns: ${this.MAX_TOOL_TURNS} (default: 8)`, ''); + this.terminal.print(`max-context: ${this.maxContext} (default: 10)`, ''); + this.terminal.print(`max-context-tokens: ${this.maxContextTokens} (default: 2000)`, ''); + this.terminal.print(`trivial-optimization: ${(this.terminal?.ollamaManager?.optimizationEnabled !== false) ? 'on' : 'off'}`, ''); + this.terminal.print(`confirm-timeout: ${this._confirmTimeoutMs > 0 ? this._confirmTimeoutMs / 1000 + 's' : 'disabled (no auto-deny)'} (default: 120s)`, ''); + this.terminal.print(`chat-lock-timeout: ${this._chatLockTimeoutMs / 1000}s (default: 10s)`, ''); + this.terminal.print(`skill-debounce: ${this._skillDebounceMs / 60000}min (default: 30min)`, ''); + this.terminal.print('Usage: ai-config <key> <value>', 'info'); + this.terminal.print('Keys: max-turns, max-context, max-context-tokens, confirm-timeout(0=disabled), chat-lock-timeout, skill-debounce, trivial-optimization', 'info'); + return; + } + const key = args[0]; + const val = args[1]; + if (key === 'max-turns') { + const n = parseInt(val); + if (isNaN(n) || n < 1 || n > 50) return this.terminal.print('max-turns must be 1-50', 'warning'); + this.MAX_TOOL_TURNS = n; + } else if (key === 'max-context') { + const n = parseInt(val); + if (isNaN(n) || n < 1 || n > 100) return this.terminal.print('max-context must be 1-100', 'warning'); + this.maxContext = n; + } else if (key === 'max-context-tokens') { + const n = parseInt(val); + if (isNaN(n) || n < 100 || n > 10000) return this.terminal.print('max-context-tokens must be 100-10000', 'warning'); + this.maxContextTokens = n; + } else if (key === 'confirm-timeout') { + const n = parseInt(val); + if (isNaN(n) || n < 0) return this.terminal.print('confirm-timeout must be 0+ seconds (0 = no confirm / auto-approve all)', 'warning'); + this._confirmTimeoutMs = n * 1000; + } else if (key === 'chat-lock-timeout') { + const n = parseInt(val); + if (isNaN(n) || n < 1 || n > 60) return this.terminal.print('chat-lock-timeout must be 1-60 seconds', 'warning'); + this._chatLockTimeoutMs = n * 1000; + } else if (key === 'skill-debounce') { + const n = parseInt(val); + if (isNaN(n) || n < 1 || n > 240) return this.terminal.print('skill-debounce must be 1-240 minutes', 'warning'); + this._skillDebounceMs = n * 60000; + } else if (key === 'trivial-optimization') { + if (this.terminal?.ollamaManager) { + this.terminal.ollamaManager.optimizationEnabled = val === 'on'; + } + } else { + return this.terminal.print(`Unknown key: ${key}. Valid: max-turns, max-context, max-context-tokens, confirm-timeout, chat-lock-timeout, skill-debounce, trivial-optimization`, 'warning'); + } + // Persist + localStorage.setItem('webx11_ai_config', JSON.stringify({ + maxTurns: this.MAX_TOOL_TURNS, + maxContext: this.maxContext, + maxContextTokens: this.maxContextTokens, + confirmTimeoutMs: this._confirmTimeoutMs, + chatLockTimeoutMs: this._chatLockTimeoutMs, + skillDebounceMs: this._skillDebounceMs + })); + this.terminal.print(`ai-config ${key} set to ${val}`, 'success'); + } + showContext() { + if (this.context.length === 0) { + this.terminal.print('No conversation context', 'info'); return; + } + this.terminal.print(`=== Context (${this.context.length} messages) ===`, 'info'); + this.context.forEach(msg => { + const icon = msg.role === 'user' ? '👤' : msg.role === 'assistant' ? '🤖' : '🔧'; + const content = typeof msg.content === 'string' ? msg.content.substring(0, 60) : JSON.stringify(msg).substring(0, 60); + this.terminal.print(`${icon} ${content}...`); + }); + } + // Extract facts about user from conversation + _extractFacts(userMsg, aiResp) { + const patterns = [ + { re: /my name is (\w+)/i, key: 'name' }, + { re: /call me (\w+)/i, key: 'name' }, + { re: /i(?:'m| am) working on (.+)/i, key: 'project' }, + { re: /(?:my|the) project is (.+)/i, key: 'project' }, + { re: /i have a server at (.+)/i, key: 'server' }, + { re: /i use (\w+).*?(?:editor|ide|text)/i, key: 'preferredEditor' }, + { re: /i (?:like|prefer|love) (.+)/i, key: 'preference' }, + ]; + let changed = false; + if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {}; + for (const { re, key } of patterns) { + const m = (userMsg + ' ' + (aiResp || '')).match(re); + if (m && m[1]) { + const val = m[1].trim(); + if (this._userProfile.knownFacts[key] !== val) { + this._userProfile.knownFacts[key] = val; + changed = true; + } + } + } + // v4.5.47: cap knownFacts so the profile doesn't bloat indefinitely + const MAX_FACTS = 50; + const factKeys = Object.keys(this._userProfile.knownFacts); + if (factKeys.length > MAX_FACTS) { + this._userProfile.knownFacts = Object.fromEntries( + factKeys.slice(-MAX_FACTS).map(k => [k, this._userProfile.knownFacts[k]]) + ); + changed = true; + } + if (changed) { + try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} + } + } + _trackPattern(userMessage) { + const lower = userMessage.toLowerCase(); + const patterns = ['font size','bigger text','smaller text','export','backup','save','install','load module','search','find','color','theme','skill','learn','remember','mindmap','brainstorm']; + for (const p of patterns) { + if (lower.includes(p)) this._commandFreq[p] = (this._commandFreq[p] || 0) + 1; + } + // v4.5.47: cap command frequency map to top 30 patterns to prevent unbounded growth + const MAX_PATTERNS = 30; + const keys = Object.keys(this._commandFreq); + if (keys.length > MAX_PATTERNS) { + const sorted = keys.sort((a, b) => this._commandFreq[b] - this._commandFreq[a]); + this._commandFreq = Object.fromEntries(sorted.slice(0, MAX_PATTERNS).map(k => [k, this._commandFreq[k]])); + } + try { localStorage.setItem('webx11_ai_patterns', JSON.stringify(this._commandFreq)); } catch (e) {} + } + async _autoCreateErrorSkill() { + const grouped = {}; + for (const e of this._errorLog) { + if (!grouped[e.tool]) grouped[e.tool] = []; + grouped[e.tool].push(e); + } + for (const [tool, errors] of Object.entries(grouped)) { + if (errors.length >= 3) { + const skillName = 'tool-' + tool + '-workaround'; + const existing = this.terminal.fs.readFile('/skills/' + skillName + '.md'); + if (existing) continue; + const steps = errors.map((e, i) => (i + 1) + '. When calling ' + tool + ', avoid: "' + JSON.stringify(e.args) + '" which caused: ' + e.error).join('\n'); + const content = '---\nname: ' + skillName + '\ndescription: Workarounds for ' + tool + ' failures\ntags: [' + tool + ', error, workaround]\ncategory: system\n---\n## Steps\n' + steps; + await this.runTool('create_skill', { name: skillName, description: 'Workarounds for ' + tool + ' failures', content: content, tags: tool + ',error,workaround', category: 'system' }); + this.terminal.print('🤖 Auto-created skill from repeated errors: ' + skillName, 'info'); + } + } + this._errorLog = []; + } + async _auditSkills() { + const fs = this.terminal.fs; + const index = fs.fsReadJSON('/skills/index.json') || {}; + const now = Date.now(); + const issues = []; + for (const [name, meta] of Object.entries(index)) { + const content = fs.readFile('/skills/' + name + '.md'); + if (!content || content.length < 50) { + issues.push({ name, issue: 'empty or too short' }); + continue; + } + const updated = new Date(meta.updated || 0).getTime(); + const ageDays = (now - updated) / 86400000; + if (ageDays > 30) issues.push({ name, issue: 'not updated in ' + Math.round(ageDays) + ' days' }); + } + if (issues.length > 0) { + this.terminal.print('📋 Skill audit: ' + issues.length + ' issue(s) found', 'info'); + issues.slice(0, 3).forEach(i => this.terminal.print(' - ' + i.name + ': ' + i.issue, 'warning')); + } + } + _estimateTokens(text) { + if (!text || typeof text !== 'string') return 0; + return Math.ceil(text.length / 4); + } + _estimateContextTokens() { + let total = 0; + for (const msg of this.context) { + total += this._estimateTokens(msg.content || ''); + } + return total; + } + async _compressContext() { + if (this.context.length <= 6) return; + const totalTokens = this._estimateContextTokens(); + if (totalTokens < this.maxContextTokens) return; + const keepCount = 3; + const oldMsgs = this.context.slice(0, -keepCount * 2); + if (oldMsgs.length < 2) return; + const recentMsgs = this.context.slice(-keepCount * 2); + const summaryText = oldMsgs.map(m => + (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + + (m.content || '').substring(0, 150) + ).join(' | '); + let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`; + // Try AI summarization, fallback to truncation + try { + if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) { + const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) }))); + const sumRes = await this.terminal.ollamaManager.chat(sumPrompt); + if (sumRes && sumRes.length < 300) { + compressed = '[Earlier context: ' + sumRes.trim() + ']'; + } + } + } catch (e) { /* fallback to truncation */ } + this.context = [{ role: 'system', content: compressed }, ...recentMsgs]; + this.terminal.print('📦 Context compressed (' + oldMsgs.length + ' messages → 1 summary, ~' + totalTokens + ' tokens)', 'info'); + } + _manualCompress() { + this._compressContext().then(() => { + this.terminal.print('Context: ' + this.context.length + ' message(s), ~' + this._estimateContextTokens() + ' estimated tokens', 'info'); + }).catch(e => { + this.terminal.print('Compression error: ' + e.message, 'error'); + }); + } + } + +/* === module-aiasm.js === */ +// Extracted from newOS4 index.html — WebXAIAliasModule + class WebXAIAliasModule { + constructor() { this.commands = {}; this.terminal = null; } + getHelpInfo() { + return { + 'webxai': 'webxai <question> — Send a question to the AI assistant', + 'wx': 'wx <question> — Alias for webxai', + 'webx': 'webx <question> — Alias for webxai' + }; + } + registerCommands() { + this.commands['webxai'] = (args) => this.forwardToAI(args); + this.commands['wx'] = (args) => this.forwardToAI(args); + this.commands['webx'] = (args) => this.forwardToAI(args); + } + forwardToAI(args) { + if (!args[0]) { + this.terminal.print("Usage: webxai <your-question>", "warning"); return; + } + if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { + this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); + } else { + this.terminal.ollamaManager.chat(args.join(' ')); + } + } + } + +/* === module-smartctx.js === */ +// Extracted from newOS4 index.html — SmartContextModule + class SmartContextModule { + constructor() { + this.commands = {}; this.terminal = null; + this.contextProfiles = this.loadProfiles(); this.activeProfile = null; + } + getHelpInfo() { + return { + 'context': 'context [name] — Set or view current context profile', + 'contexts': 'contexts — List all available context profiles', + 'context-new': 'context-new <name> <displayName> <systemPrompt> — Create a new context profile', + 'context-delete': 'context-delete <name> — Delete a context profile', + 'smart-ask': 'smart-ask <question> — Ask a question using the active context' + }; + } + loadProfiles() { + try { + const saved = localStorage.getItem('webx11_context_profiles'); + return saved ? JSON.parse(saved) : this.getDefaultProfiles(); + } catch (e) { return this.getDefaultProfiles(); } + } + saveProfiles() { + localStorage.setItem('webx11_context_profiles', JSON.stringify(this.contextProfiles)); + } + getDefaultProfiles() { + return { + 'coding': { name: 'Coding Assistant', systemPrompt: "You are an expert programmer.", temperature: 0.7 }, + 'debug': { name: 'Debug Helper', systemPrompt: "You are a debugging expert.", temperature: 0.5 }, + 'creative': { name: 'Creative Writer', systemPrompt: "You are a creative writer.", temperature: 0.9 }, + 'technical': { name: 'Technical Docs', systemPrompt: "You are a technical writer.", temperature: 0.3 }, + 'chat': { name: 'Casual Chat', systemPrompt: "You are a friendly assistant.", temperature: 0.8 } + }; + } + registerCommands() { + this.commands['context'] = (args) => this.setContext(args); + this.commands['contexts'] = () => this.listContexts(); + this.commands['context-new'] = (args) => this.createContext(args); + this.commands['context-delete'] = (args) => this.deleteContext(args); + this.commands['smart-ask'] = (args) => this.smartAsk(args); + } + setContext(args) { + if (!args[0]) { + this.terminal.print(`Current context: ${this.activeProfile || 'none'}`, "info"); return; + } + if (this.contextProfiles[args[0]]) { + this.activeProfile = args[0]; + this.terminal.print(`Context set to: ${this.contextProfiles[args[0]].name}`, "success"); + } else { + this.terminal.print(`Profile '${args[0]}' not found`, "error"); + } + } + listContexts() { + this.terminal.print("=== Context Profiles ===", "info"); + Object.entries(this.contextProfiles).forEach(([key, profile]) => { + const active = key === this.activeProfile ? ' ✅' : ''; + this.terminal.print(`${key}: ${profile.name}${active}`); + }); + } + createContext(args) { + if (args.length < 3) { + this.terminal.print("Usage: context-new <name> <displayName> <systemPrompt>", "warning"); return; + } + const name = args[0], displayName = args[1], systemPrompt = args.slice(2).join(' '); + this.contextProfiles[name] = { name: displayName, systemPrompt, temperature: 0.7 }; + this.saveProfiles(); + this.terminal.print(`Context profile '${name}' created`, "success"); + } + deleteContext(args) { + if (!args[0]) { + this.terminal.print("Usage: context-delete <name>", "warning"); return; + } + if (this.contextProfiles[args[0]]) { + delete this.contextProfiles[args[0]]; + this.saveProfiles(); + if (this.activeProfile === args[0]) { this.activeProfile = null; } + this.terminal.print(`Context profile '${args[0]}' deleted`, "success"); + } else { + this.terminal.print(`Profile '${args[0]}' not found`, "error"); + } + } + async smartAsk(args) { + if (!args[0]) { + this.terminal.print("Usage: smart-ask <your-question>", "warning"); return; + } + let systemPrompt = "You are a helpful assistant."; + if (this.activeProfile && this.contextProfiles[this.activeProfile]) { + systemPrompt = this.contextProfiles[this.activeProfile].systemPrompt; + this.terminal.print(`Using context: ${this.contextProfiles[this.activeProfile].name}`, "info"); + } + const fullPrompt = `${systemPrompt}\n\nUser: ${args.join(' ')}`; + await this.terminal.ollamaManager.chat(fullPrompt); + } + } + +/* === module-duckduckgo.js === */ +// Extracted from newOS4 index.html — DuckDuckGoModule + class DuckDuckGoModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'ddg': 'ddg <query> — Search DuckDuckGo (opens query in new tab)', + 'duck': 'duck <query> — Alias for ddg', + }; + } + registerCommands() { + this.commands['ddg'] = (args) => this.search(args); + this.commands['duck'] = (args) => this.search(args); + } + async search(args) { + if (!args[0]) { + this.terminal.print("Usage: ddg <search-query>", "warning"); + this.terminal.print("Example: ddg javascript tutorials", "info"); + this.terminal.print("Note: Due to CORS, results may be limited. Try 'search' command for better results.", "warning"); + return; + } + const query = args.join(' '); + this.terminal.print(`🦆 DuckDuckGo Search: ${query}`, "info"); + + // DuckDuckGo doesn't have a public API, so we provide alternatives + this.terminal.print("⚠️ DuckDuckGo doesn't provide a public API for direct access.", "warning"); + this.terminal.print("Opening search in new tab...", "info"); + + // Open in new tab as fallback + const ddgUrl = `https://duckduckgo.com/?q=${encodeURIComponent(query)}`; + window.open(ddgUrl, '_blank'); + + this.terminal.print(`\n💡 Alternative: Use 'search <query>' for SearXNG (privacy-focused)`, "info"); + this.terminal.print(`🔗 Direct link: ${ddgUrl}`, "info"); + } + } + +/* === module-fetcher.js === */ +class WebFetcherModule { + constructor() { + this.commands = {}; + this.terminal = null; + } + getHelpInfo() { + return { + 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content', + 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL', + }; + } + registerCommands() { + this.commands['fetch'] = (args) => this.fetchUrl(args); + this.commands['extract'] = (args) => this.extractUrl(args); + } + async _fetchWithTimeout(url, timeoutMs = 30000) { + // Wrap fetch with AbortController for client-side timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timeout); + } + } + _parseFlags(args) { + // Extract --key=value flags from args; return {flags, positional} + const flags = {}; + const positional = []; + for (const a of args) { + const m = String(a).match(/^--([a-zA-Z][\w-]*)(?:=(.*))?$/); + if (m) { + flags[m[1]] = m[2] !== undefined ? m[2] : true; + } else { + positional.push(a); + } + } + return { flags, positional }; + } + async fetchUrl(args) { + const { flags, positional } = this._parseFlags(args || []); + if (!positional[0]) { + this.terminal.print("Usage: fetch <url> [--post=BODY] [--type=CT] [--raw]", "warning"); + this.terminal.print(" Example: fetch https://example.com", "info"); + this.terminal.print(" Example: fetch https://api.hyperliquid.xyz/info --post='{\"type\":\"l2Book\",\"coin\":\"HYPE\"}' --type=application/json", "info"); + return; + } + let url = positional[0]; + if (!url.match(/^https?:\/\//i)) url = 'https://' + url; + const method = flags.post ? 'POST' : 'GET'; + const showRaw = !!flags.raw; + this.terminal.print(`🌐 Fetching (${method}): ${url}`, "info"); + if (flags.post) this.terminal.print(`📤 Body: ${String(flags.post).substring(0, 200)}${String(flags.post).length > 200 ? '...' : ''}`, "info"); + try { + const params = new URLSearchParams({ url }); + if (flags.post) { params.set('method', 'POST'); params.set('body', flags.post); } + if (flags.type) params.set('content_type', flags.type); + const resp = await this._fetchWithTimeout('/api/fetch?' + params.toString(), 30000); + if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } + if (!resp.ok) { + // Try to parse JSON error from proxy + const ct = resp.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Fetch failed: ' + (err.error || 'HTTP ' + resp.status) + (err.details ? ' — ' + err.details.split('\n')[0] : ''), 'error'); + } else { + this.terminal.print('Fetch failed: HTTP ' + resp.status, 'error'); + } + return; + } + const text = await resp.text(); + const limit = showRaw ? text.length : 3000; + this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success'); + this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : '')); + this.terminal.print(`\n📏 Content Length: ${text.length} chars${showRaw ? ' (raw, untruncated)' : ''}`, 'info'); + } catch (e) { + if (e.name === 'AbortError') { + this.terminal.print(`⏱ Request timed out (30s). Try \`extract\` instead of \`fetch\` for slow sites.`, 'error'); + } else { + this.terminal.print('Fetch failed: ' + e.message, 'error'); + } + } + } + async extractUrl(args) { + if (!args[0]) { + this.terminal.print("Usage: extract <url>", "warning"); + this.terminal.print("Example: extract https://example.com", "info"); + return; + } + let url = args[0]; + if (!url.match(/^https?:\/\//i)) url = 'https://' + url; + this.terminal.print(`📄 Extracting: ${url}`, "info"); + try { + const resp = await this._fetchWithTimeout('/api/extract?url=' + encodeURIComponent(url), 30000); + if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + this.terminal.print('Extract failed: ' + (err.error || 'HTTP ' + resp.status) + (err.details ? ' — ' + err.details.split('\n')[0] : ''), 'error'); + return; + } + const data = await resp.json(); + if (data.title) this.terminal.print(`\n📰 ${data.title}\n`, 'success'); + this.terminal.print(data.content || '(no content extracted)'); + this.terminal.print(`\n📏 ${data.word_count || 0} words (via ${data.method || 'unknown'})`, 'info'); + // F5: Follow-up hint + this.terminal.print(`💡 Try \`summarize ${url}\` for AI summary, or \`nvfs-resolve <concept>\` to find related files`, 'info'); + } catch (e) { + if (e.name === 'AbortError') { + this.terminal.print(`⏱ Extract timed out (30s). Site may be slow or blocking automated access.`, 'error'); + } else { + this.terminal.print('Extract failed: ' + e.message, 'error'); + } + } + } +} + +/* === secret-vault.js === */ +// Extracted from newOS4 index.html — SecretVault +class SecretVault { + constructor() { + this.commands = {}; + this.terminal = null; + this._key = null; + this._prefix = 'webx11_secret_'; + this._saltKey = 'webx11_secret_salt'; + this._verifyName = 'secret_vault_verified'; + this._openModeKey = 'webx11_secret_open_key'; + } + + getHelpInfo() { + return { + 'secret-set': 'secret-set <name> <value> — Encrypt and store a secret', + 'secret-get': 'secret-get <name> — Decrypt and display a secret value', + 'secret-list': 'secret-list — List all stored secret names', + 'secret-del': 'secret-del <name> — Remove a stored secret', + 'secret-lock': 'secret-lock — Lock the vault (clear derived key from memory)', + 'secret-unlock': 'secret-unlock <pin> — Unlock the vault with a PIN code', + 'secret-setpin': 'secret-setpin <pin> — Set or change the vault PIN code', + 'secret-status': 'secret-status — Show vault status (locked/unlocked/pin set/open mode)', + }; + } + + registerCommands() { + this.commands['secret-set'] = (args) => this._cmdSet(args); + this.commands['secret-get'] = (args) => this._cmdGet(args); + this.commands['secret-list'] = () => this._cmdList(); + this.commands['secret-del'] = (args) => this._cmdDel(args); + this.commands['secret-lock'] = () => this._cmdLock(); + this.commands['secret-unlock'] = (args) => this._cmdUnlock(args); + this.commands['secret-setpin'] = (args) => this._cmdSetPin(args); + this.commands['secret-status'] = () => this._cmdStatus(); + } + + // ---------- Core API ---------- + + isPinSet() { + return localStorage.getItem(this._saltKey) !== null; + } + + isLocked() { + // Locked only if PIN is set but key is not derived in memory + return this.isPinSet() && this._key === null; + } + + async setPin(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + // Generate new salt + const salt = this._generateSalt(); + localStorage.setItem(this._saltKey, this._arrayToBase64(salt)); + + // Derive new key from the new PIN + const newKey = await this._deriveKeyFromPin(pin, salt); + + if (this.isPinSet() && this._key !== null) { + // Re-encrypt all existing secrets with new key + await this._reEncryptAll(newKey); + } + + // Store the new key in memory + this._key = newKey; + + // Store a verification token so we know the PIN works + await this._encryptWithKey(this._verifyName, 'verified', this._key); + + return { success: true }; + } + + lock() { + this._key = null; + return { success: true }; + } + + async unlock(pin) { + if (!pin || typeof pin !== 'string' || pin.length === 0) { + return { error: 'PIN must be a non-empty string' }; + } + + if (!this.isPinSet()) { + return { error: 'No PIN has been set. Use secret-setpin first.' }; + } + + const saltBase64 = localStorage.getItem(this._saltKey); + if (!saltBase64) { + return { error: 'Salt not found. Vault may be corrupted.' }; + } + + const salt = this._base64ToArray(saltBase64); + const key = await this._deriveKeyFromPin(pin, salt); + + // Verify PIN by checking the verification token + const verifyData = await this._decryptWithKey(this._verifyName, key); + if (verifyData === null) { + return { error: 'Invalid PIN' }; + } + + this._key = key; + return { success: true }; + } + + async encrypt(name, value) { + if (this.isLocked()) { + return { error: 'Vault is locked' }; + } + + // Ensure key is available + let key = this._key; + if (!key) { + // Open mode: use or generate a session-like key + key = await this._getOpenModeKey(); + } + + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + return this._encryptWithKey(name, stringValue, key); + } + + async decrypt(name) { + if (this.isLocked()) { + return null; + } + + let key = this._key; + if (!key) { + key = await this._getOpenModeKey(); + if (!key) return null; + } + + return this._decryptWithKey(name, key); + } + + remove(name) { + localStorage.removeItem(this._prefix + name); + return { success: true }; + } + + list() { + const names = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith(this._prefix)) { + names.push(key.substring(this._prefix.length)); + } + } + return names; + } + + has(name) { + return localStorage.getItem(this._prefix + name) !== null; + } + + /** + * Scans text for any stored secret values and replaces them with '[REDACTED]'. + * Used by terminal and AI context to prevent leaking secrets in output. + */ + async redactSecret(text) { + if (!text || typeof text !== 'string') return text; + + const names = this.list(); + if (names.length === 0) return text; + + let redacted = text; + + for (const name of names) { + try { + const value = await this.decrypt(name); + if (value && typeof value === 'string' && value.length > 0) { + // Escape special regex characters in the secret value + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + try { + const regex = new RegExp(escaped, 'g'); + redacted = redacted.replace(regex, '[REDACTED]'); + } catch (e) { + // If regex fails (e.g., complex Unicode), try manual replacement + while (redacted.includes(value)) { + redacted = redacted.replace(value, '[REDACTED]'); + } + } + } + } catch (e) { + // Skip secrets that can't be decrypted + } + } + + return redacted; + } + + // ---------- Internal crypto methods ---------- + + _generateSalt() { + const salt = new Uint8Array(16); + crypto.getRandomValues(salt); + return salt; + } + + _generateIV() { + const iv = new Uint8Array(12); + crypto.getRandomValues(iv); + return iv; + } + + _arrayToBase64(array) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(array))); + } + + _base64ToArray(base64) { + const binary = atob(base64); + const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + return array; + } + + async _deriveKeyFromPin(pin, salt) { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(pin), + 'PBKDF2', + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _getOpenModeKey() { + // In open mode (no PIN set), we use a persistent 32-char hex key in localStorage + // This is less secure than PIN mode but better than plaintext + let keyHex = localStorage.getItem(this._openModeKey); + if (!keyHex) { + keyHex = this._generateOpenKey(); + localStorage.setItem(this._openModeKey, keyHex); + } + return this._hexKeyToCryptoKey(keyHex); + } + + _generateOpenKey() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _hexKeyToCryptoKey(hex) { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return crypto.subtle.importKey( + 'raw', + bytes, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async _encryptWithKey(name, value, key) { + try { + const encoder = new TextEncoder(); + const data = encoder.encode(value); + const iv = this._generateIV(); + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + data + ); + + const encryptedArray = new Uint8Array(encrypted); + const storageData = JSON.stringify({ + iv: this._arrayToBase64(iv), + data: this._arrayToBase64(encryptedArray), + }); + + localStorage.setItem(this._prefix + name, storageData); + return { success: true }; + } catch (e) { + return { error: 'Encryption failed: ' + e.message }; + } + } + + async _decryptWithKey(name, key) { + try { + const storageData = localStorage.getItem(this._prefix + name); + if (!storageData) return null; + + const parsed = JSON.parse(storageData); + if (!parsed.iv || !parsed.data) return null; + + const iv = this._base64ToArray(parsed.iv); + const encryptedData = this._base64ToArray(parsed.data); + + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (e) { + return null; + } + } + + async _reEncryptAll(newKey) { + const names = this.list(); + const oldKey = this._key; + + for (const name of names) { + // Skip verification token — it's regenerated in setPin + if (name === this._verifyName) continue; + + const plaintext = await this._decryptWithKey(name, oldKey); + if (plaintext !== null) { + await this._encryptWithKey(name, plaintext, newKey); + } + } + + // Update verification token with new key + await this._encryptWithKey(this._verifyName, 'verified', newKey); + } + + // ---------- Command handlers ---------- + + async _cmdSet(args) { + if (args.length < 2) { + this.terminal.print('Usage: secret-set <name> <value>', 'warning'); + return; + } + const name = args[0]; + const value = args.slice(1).join(' '); + const result = await this.encrypt(name, value); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print(`Secret '${name}' saved`, 'success'); + } + } + + async _cmdGet(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-get <name>', 'warning'); + return; + } + const value = await this.decrypt(args[0]); + if (value === null) { + if (this.isLocked()) { + this.terminal.print('Vault is locked. Use secret-unlock <pin>', 'warning'); + } else { + this.terminal.print(`Secret '${args[0]}' not found`, 'error'); + } + } else { + this.terminal.print(`Secret '${args[0]}': ${value}`, 'info'); + } + } + + _cmdList() { + const names = this.list(); + if (names.length === 0) { + this.terminal.print('No secrets stored', 'info'); + return; + } + this.terminal.print('=== Stored Secrets ===', 'info'); + names.forEach(n => { + // Skip internal verification token + if (n === this._verifyName) return; + this.terminal.print(` - ${n}`); + }); + } + + _cmdDel(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-del <name>', 'warning'); + return; + } + const result = this.remove(args[0]); + if (result.success) { + this.terminal.print(`Secret '${args[0]}' deleted`, 'success'); + } + } + + _cmdLock() { + const result = this.lock(); + if (result.success) { + this.terminal.print('Vault locked', 'success'); + } + } + + async _cmdUnlock(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-unlock <pin>', 'warning'); + return; + } + const result = await this.unlock(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('Vault unlocked', 'success'); + } + } + + async _cmdSetPin(args) { + if (!args[0]) { + this.terminal.print('Usage: secret-setpin <pin>', 'warning'); + return; + } + const result = await this.setPin(args[0]); + if (result.error) { + this.terminal.print(result.error, 'error'); + } else { + this.terminal.print('PIN set successfully. Vault is now in PIN-protected mode.', 'success'); + } + } + + _cmdStatus() { + const pinSet = this.isPinSet(); + const locked = this.isLocked(); + const hasKey = this._key !== null; + + this.terminal.print('=== Secret Vault Status ===', 'info'); + this.terminal.print(`PIN set: ${pinSet ? 'Yes' : 'No'}`, pinSet ? 'success' : 'info'); + if (pinSet) { + this.terminal.print(`Vault: ${locked ? '🔒 Locked' : '🔓 Unlocked'}`, locked ? 'warning' : 'success'); + } else { + this.terminal.print(`Mode: Open (no PIN — using persistent key)`, 'info'); + } + this.terminal.print(`Secrets stored: ${this.list().filter(n => n !== this._verifyName).length}`, 'info'); + } +} + +/* === module-chatstudio.js === */ +/* + * ChatStudio Overlay Module for newOS4 — reads window.NEWOS4_VERSION at runtime + * Integrates ChatStudio Lite's full chat UI as an overlay launched via `chat` command. + * All Lite engine functions are inlined from lite/cslite-core.js, adapted to use + * the terminal's OllamaManager for endpoint configuration. + */ +class ChatStudioModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._overlay = null; + this._active = false; + this._cssLoaded = false; + this._escapeHandler = null; + this._initialized = false; + } + + getHelpInfo() { + return { + 'chat': 'Toggle the ChatStudio overlay (inside: /setapi, /setmodel, /ask, /help)', + }; + } + + registerCommands() { + this.commands['chat'] = () => this.toggleChatOverlay(); + } + + toggleChatOverlay() { + if (this._active) { + this._closeOverlay(); + } else { + this._openOverlay(); + } + } + + _loadOverlayAssets() { + return new Promise((resolve, reject) => { + // Load CSS + if (!this._cssLoaded) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'chat/chat-ui.css?v=5'; + link.onload = () => { this._cssLoaded = true; }; + link.onerror = () => reject(new Error('Failed to load chat/chat-ui.css')); + document.head.appendChild(link); + } + + // Expose ALL Lite globals BEFORE scripts load so chat-modules.js & chat-commands.js can use them + if (!window._csGlobalsInited) { + window.OLLAMA_URL = (this.terminal && this.terminal.ollamaManager && this.terminal.ollamaManager.endpoint) || '/ollama'; + window.OLLAMA_API_KEY = ''; + window.DEFAULT_MODEL = 'llama3.2'; + window.USE_STREAMING = true; + window.API_BASE = '/api'; + window.latestAiContent = ''; + window.logs = []; + window.messages = []; + window.isStreaming = false; + window.CHATSTUDIO_VERSION = '1.0.0-lite-inline'; + window.modules = []; + window.currentChatModule = 'default'; + + // Utility functions + window.utf8_to_b64 = function(str) { return btoa(unescape(encodeURIComponent(str))); }; + window.b64_to_utf8 = function(str) { return decodeURIComponent(escape(atob(str))); }; + window.escapeHtml = function(text) { + if (typeof text !== 'string') return ''; + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"'); + }; + window.generateId = function() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); }; + window.showToast = function(msg) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.style.display = 'block'; + setTimeout(function() { t.style.display = 'none'; }, 2500); + }; + window.addLog = function(action, detail) { + // Placeholder — replaced by full impl in _initLiteEngine + }; + // Placeholder functions — replaced by full implementations in _initLiteEngine + window.addMessage = function(role, content) {}; + window.showTyping = function() {}; + window.hideTyping = function() {}; + window.loadSettings = function() {}; + window.saveSettings = function() {}; + window.loadModels = function() {}; + window.switchModule = function(id) {}; + window.getCurrentModule = function() {}; + window.sendMessage = function() {}; + window.clearChat = function() {}; + window.closeSettingsModal = function() {}; + window.openSettingsModal = function() {}; + window._csGlobalsInited = true; + } + + // Fetch HTML template (cached after first load) + if (this._overlayHtml) { + // Overlay div already exists (just hidden). No-op. + resolve(); + return; + } + fetch('chat/chat-ui.html?v=5') + .then(r => r.text()) + .then(html => { + this._overlayHtml = html; // cache for re-open + // Insert overlay into body if not already there + if (!document.getElementById('chat-overlay')) { + const temp = document.createElement('div'); + temp.innerHTML = html; + document.body.appendChild(temp.firstElementChild); + + // Load commands.js and modules.js dynamically + const loadScript = (src) => { + return new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = src + '?v=5'; + s.onload = () => res(); + s.onerror = () => rej(new Error('Failed to load ' + src)); + document.body.appendChild(s); + }); + }; + + Promise.all([ + loadScript('chat/chat-commands.js'), + loadScript('chat/chat-modules.js') + ]).then(() => { + resolve(); + }); + } else { + resolve(); + } + }) + .catch(() => reject(new Error('Failed to load chat/chat-ui.html'))); + }); + } + + async _openOverlay() { + if (this._active) return; + this._active = true; + + try { + // Load overlay assets first (CSS, HTML, scripts) + await this._loadOverlayAssets(); + + // Initialize the Lite engine + this._initLiteEngine(); + + // Show overlay + const overlay = document.getElementById('chat-overlay'); + if (overlay) { + overlay.style.display = 'flex'; + overlay.style.flexDirection = 'column'; + } + + // Add Escape listener + this._escapeHandler = (e) => { + if (e.key === 'Escape') { + this._closeOverlay(); + } + }; + document.addEventListener('keydown', this._escapeHandler); + + // Set window-level endpoint for Lite to use + if (this.terminal && this.terminal.ollamaManager) { + window.ollamaEndpoint = this.terminal.ollamaManager.endpoint; + } + } catch (err) { + // Print error to terminal + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay failed: ' + err.message, 'error'); + } + + // Show a toast on the main page if possible + if (typeof window.showToast === 'function') { + window.showToast('Chat overlay failed: ' + err.message); + } + + // Clean up any partial overlay DOM — hide instead of remove so cached HTML path still works + const partialOverlay = document.getElementById('chat-overlay'); + if (partialOverlay) { + partialOverlay.style.display = 'none'; + } + + // Reset state so user can retry + this._active = false; + } + } + + _closeOverlay() { + if (!this._active) return; + this._active = false; + + const overlay = document.getElementById('chat-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + + // Remove Escape listener + if (this._escapeHandler) { + document.removeEventListener('keydown', this._escapeHandler); + this._escapeHandler = null; + } + + // Focus terminal input + if (this.terminal && this.terminal.inputField) { + setTimeout(() => this.terminal.inputField.focus(), 50); + } + } + + _initLiteEngine() { + // Already initialized — no-op + if (this._initialized) return; + + // Set the overlay reference + this._overlay = document.getElementById('chat-overlay'); + if (!this._overlay) { + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay: #chat-overlay element not found', 'error'); + } + return; + } + + this._initialized = true; + + try { + // Close button already exists in HTML as "✕ Close" button calling closeChatOverlay() + window.closeChatOverlay = () => this._closeOverlay(); + + // ── Constants ── + const CHATSTUDIO_VERSION = '1.0.0-lite-inline'; + const API_KEY = 'webx11_chat_api'; + const MESSAGES_KEY = 'cslite_messages'; + const SETTINGS_KEY = 'webx11_chat_settings'; + const LOGS_KEY = 'webx11_chat_logs'; + + // ── State ── + let OLLAMA_URL = '/ollama'; + let OLLAMA_API_KEY = ''; + let DEFAULT_MODEL = 'llama3.2'; + let USE_STREAMING = true; + let API_BASE = '/api'; + let latestAiContent = ''; + let logs = []; + let messages = []; + let isStreaming = false; + + // Sync OLLAMA_URL from terminal + if (this.terminal && this.terminal.ollamaManager) { + OLLAMA_URL = this.terminal.ollamaManager.endpoint || '/ollama'; + } + + // ── Utility Functions ── + function escapeHtml(text) { + if (typeof text !== 'string') return ''; + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + function utf8_to_b64(str) { + return btoa(unescape(encodeURIComponent(str))); + } + + function b64_to_utf8(str) { + return decodeURIComponent(escape(atob(str))); + } + + function generateId() { + return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); + } + + function showToast(msg) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.style.display = 'block'; + setTimeout(function () { + t.style.display = 'none'; + }, 2500); + } + + function autoExpand(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + } + + // Close sidebar when clicking outside on mobile + var backdrop = document.getElementById('sidebarBackdrop'); + if (!backdrop) { + backdrop = document.createElement('div'); + backdrop.id = 'sidebarBackdrop'; + backdrop.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:99;display:none;background:transparent'; + backdrop.onclick = function() { closeSidebar(); }; + document.getElementById('chat-overlay')?.appendChild(backdrop); + } + + function toggleSidebar() { + var s = document.getElementById('sidebar'); + if (!s) return; + // On mobile (<700px) use .open, on desktop use .collapsed + var isMobile = window.innerWidth <= 700; + if (isMobile) { + s.classList.toggle('open'); + } else { + s.classList.toggle('collapsed'); + // Toggle backdrop on mobile regardless + } + if (backdrop) { + backdrop.style.display = s.classList.contains('open') || s.classList.contains('collapsed') ? 'block' : 'none'; + } + } + function closeSidebar() { + var s = document.getElementById('sidebar'); + if (s) { s.classList.remove('open'); s.classList.remove('collapsed'); } + if (backdrop) backdrop.style.display = 'none'; + } + + // ── Log System ── + function addLog(action, detail) { + var time = new Date().toLocaleTimeString(); + logs.unshift({ time: time, action: action, detail: detail }); + if (logs.length > 50) logs.pop(); + renderLogs(); + try { + localStorage.setItem(LOGS_KEY, utf8_to_b64(JSON.stringify(logs))); + } catch (e) { /* ignore */ } + } + + function renderLogs() { + var container = document.getElementById('logsContent'); + if (!container) return; + container.innerHTML = logs.map(function (l) { + return '<div class="log-entry">' + + '<span class="log-time">' + escapeHtml(l.time) + '</span>' + + '<span class="log-action">' + escapeHtml(l.action) + '</span>' + + '<span class="log-detail">' + escapeHtml(l.detail) + '</span>' + + '</div>'; + }).join(''); + } + + function copyLogs() { + var text = logs.map(function (l) { + return l.time + ' | ' + l.action + ' | ' + l.detail; + }).join('\n'); + navigator.clipboard.writeText(text).then(function () { + showToast('Logs copied!'); + }); + } + + function clearLogs() { + if (!confirm('Clear all activity logs?')) return; + logs = []; + try { + localStorage.removeItem(LOGS_KEY); + } catch (e) { /* ignore */ } + renderLogs(); + showToast('Logs cleared'); + } + + function loadLogs() { + try { + var saved = localStorage.getItem(LOGS_KEY); + if (saved) logs = JSON.parse(b64_to_utf8(saved)); + } catch (e) { + logs = []; + } + renderLogs(); + } + + // ── Settings ── + function loadSettings() { + try { + var saved = localStorage.getItem(SETTINGS_KEY); + if (saved) { + var settings = JSON.parse(b64_to_utf8(saved)); + OLLAMA_URL = settings.ollamaUrl || '/ollama'; + OLLAMA_API_KEY = settings.ollamaApiKey || ''; + DEFAULT_MODEL = settings.defaultModel || 'llama3.2'; + USE_STREAMING = settings.useStreaming !== false; + API_BASE = settings.apiBase || '/api'; + } + } catch (e) { + console.warn('Failed to load settings:', e); + } + // Sync from terminal if available + var term = window.webx11; + if (term && term.ollamaManager) { + OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; + } + var urlEl = document.getElementById('ollamaUrl'); + var keyEl = document.getElementById('ollamaApiKey'); + var modelEl = document.getElementById('defaultModel'); + var streamEl = document.getElementById('streamingToggle'); + var apiEl = document.getElementById('apiBase'); + if (urlEl) urlEl.value = OLLAMA_URL; + if (keyEl) keyEl.value = OLLAMA_API_KEY; + if (modelEl) modelEl.value = DEFAULT_MODEL; + if (streamEl) streamEl.checked = USE_STREAMING; + if (apiEl) apiEl.value = API_BASE; + } + + function saveSettings() { + OLLAMA_URL = document.getElementById('ollamaUrl').value.trim() || '/ollama'; + OLLAMA_API_KEY = document.getElementById('ollamaApiKey').value.trim(); + DEFAULT_MODEL = document.getElementById('defaultModel').value.trim() || 'llama3.2'; + USE_STREAMING = document.getElementById('streamingToggle').checked; + API_BASE = (document.getElementById('apiBase')?.value || '/api').trim(); + try { + var settings = { + ollamaUrl: OLLAMA_URL, + ollamaApiKey: OLLAMA_API_KEY, + defaultModel: DEFAULT_MODEL, + useStreaming: USE_STREAMING, + apiBase: API_BASE + }; + localStorage.setItem(SETTINGS_KEY, utf8_to_b64(JSON.stringify(settings))); + } catch (e) { + console.warn('Failed to save settings:', e); + } + closeSettingsModal(); + showToast('Settings saved!'); + } + + function openSettingsModal() { + loadSettings(); + var m = document.getElementById('settingsModal'); + if (m) m.classList.add('open'); + } + + function closeSettingsModal() { + var m = document.getElementById('settingsModal'); + if (m) m.classList.remove('open'); + } + + async function testConnection() { + var status = document.getElementById('connectionStatus'); + if (!status) return; + status.textContent = 'Testing...'; + status.style.color = 'var(--text2)'; + try { + var headers = { 'Content-Type': 'application/json' }; + if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; + var response = await fetch(OLLAMA_URL + '/api/tags', { headers: headers }); + if (response.ok) { + var data = await response.json(); + var modelCount = data.models ? data.models.length : 0; + status.textContent = 'Connected! ' + modelCount + ' models available.'; + status.style.color = 'var(--accent)'; + } else { + status.textContent = 'Connection failed: HTTP ' + response.status; + status.style.color = 'var(--danger)'; + } + } catch (e) { + status.textContent = 'Error: ' + e.message; + status.style.color = 'var(--danger)'; + } + } + + // ── Models ── + async function loadModels() { + try { + var response = await fetch(OLLAMA_URL + '/api/tags'); + if (!response.ok) throw new Error('Failed to fetch models'); + var data = await response.json(); + var select = document.getElementById('modelSelect'); + if (!select) return; + var shortNames = { + 'llama3.2': 'Llama3.2', + 'llama3': 'Llama3', + 'gemma3:4b': 'Gemma3-4B', + 'gemma3:1b': 'Gemma3-1B', + 'gemma2:2b': 'Gemma2', + 'qwen2.5:7b-instruct': 'Qwen2.5', + 'qwen3:8b': 'Qwen3', + 'mistral': 'Mistral', + 'tinyllama': 'Tiny' + }; + select.innerHTML = data.models.map(function (m) { + var name = m.name.split(':')[0]; + var display = shortNames[m.name] || name.substring(0, 14); + return '<option value="' + escapeHtml(m.name) + '">' + escapeHtml(display) + '</option>'; + }).join(''); + addLog('Init', 'Loaded ' + data.models.length + ' models'); + } catch (e) { + console.error('Failed to load models:', e); + var select = document.getElementById('modelSelect'); + if (select) select.innerHTML = '<option value="llama3.2">llama3.2 (default)</option>'; + } + } + + var modelSelect = document.getElementById('modelSelect'); + if (modelSelect) { + modelSelect.addEventListener('change', function () { + addLog('Model', 'Switched to ' + this.value); + }); + } + + // ── Message History ── + function saveMessages(msgs) { + msgs = msgs || messages; + try { + localStorage.setItem(MESSAGES_KEY, JSON.stringify(msgs)); + } catch (e) { + console.warn('Failed to save messages:', e); + } + } + + function loadMessages() { + try { + var raw = localStorage.getItem(MESSAGES_KEY); + if (raw) { + messages = JSON.parse(raw); + } + } catch (e) { + console.warn('Failed to load messages:', e); + messages = []; + } + } + + function renderMessagesFromData() { + var container = document.getElementById('chatContainer'); + if (!container) return; + if (!messages || messages.length === 0) { + return; + } + container.innerHTML = ''; + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + appendMessageToDOM(msg.role, msg.content, msg.id, false); + } + container.scrollTop = container.scrollHeight; + } + + function appendMessageToDOM(role, content, msgId, save) { + if (save === undefined) save = true; + var container = document.getElementById('chatContainer'); + if (!container) return; + var div = document.createElement('div'); + div.className = 'message ' + role; + if (msgId) div.dataset.msgId = msgId; + var previewBtn = role === 'ai' + ? '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + : ''; + if (role === 'ai') latestAiContent = content; + var renderedContent = role === 'user' ? escapeHtml(content) : formatContent(content); + div.innerHTML = '<div class="role">' + + '<span>' + (role === 'user' ? 'You' : 'AI') + '</span>' + + '<div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'' + role + '\')" title="Copy">📋</button>' + + previewBtn + + '</div></div>' + + '<div class="msg-content">' + renderedContent + '</div>'; + div.dataset.content = content; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + if (save) { + saveMessages(); + } + } + + function addMessage(role, content) { + var msgId = generateId(); + var msg = { + id: msgId, + role: role, + content: content, + timestamp: Date.now() + }; + messages.push(msg); + appendMessageToDOM(role, content, msgId, true); + return msgId; + } + + // ── Content Formatting ── + function formatContent(text) { + // Step 1: Extract code blocks into placeholders + var blocks = []; + var processed = text + .replace(/```html\n?([\s\S]*?)```/g, function(m, code) { + var idx = blocks.length; + blocks.push({lang: 'html', code: code, isBlock: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }) + .replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) { + var idx = blocks.length; + blocks.push({lang: lang, code: code, isBlock: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }) + .replace(/`([^`]+)`/g, function(m, content) { + var idx = blocks.length; + blocks.push({lang: '', code: content, isBlock: false, isInline: true}); + return '%%CODEBLOCK_' + idx + '%%'; + }); + + // Step 2: Escape everything outside code blocks + processed = escapeHtml(processed); + + // Step 3: Apply markdown formatting on the escaped text + processed = processed + .replace(/\*\*([^*]+)\*\*/g, function(m, content) { return '<strong>' + content + '</strong>'; }) + .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') + .replace(/\n/g, '<br>'); + + // Step 4: Restore code blocks with proper HTML + processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, function(m, idx) { + idx = parseInt(idx); + var block = blocks[idx]; + if (!block) return m; + if (block.isInline) { + return '<code>' + escapeHtml(block.code) + '</code>'; + } + var id = 'code_' + Math.random().toString(36).substr(2, 9); + if (block.lang === 'html') { + return '<div class="code-block" data-code="' + encodeURIComponent(block.code) + '">' + + '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="previewCodeBlock(this)">👁️</button>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;margin-right:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code id="' + id + '">' + escapeHtml(block.code) + '</code></pre></div>'; + } + return '<pre>' + + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + + '<code>' + escapeHtml(block.code) + '</code></pre>'; + }); + + return processed; + } + + // ── Copy & Preview ── + function copyMessage(btn, role) { + var content; + if (role === 'code') { + content = btn.nextElementSibling.textContent; + } else { + var msg = btn.closest('.message'); + content = msg.dataset.content; + } + navigator.clipboard.writeText(content).then(function () { + showToast('Copied!'); + }); + } + + function showHtmlPreview() { + var frame = document.getElementById('previewFrame'); + if (!frame) return; + var doc = frame.contentWindow.document; + doc.open(); + doc.write(latestAiContent); + doc.close(); + var m = document.getElementById('previewModal'); + if (m) m.classList.add('open'); + } + + function previewCodeBlock(btn) { + var block = btn.closest('.code-block'); + if (!block) return; + var code = decodeURIComponent(block.dataset.code); + var frame = document.getElementById('previewFrame'); + if (!frame) return; + var doc = frame.contentWindow.document; + doc.open(); + doc.write(code); + doc.close(); + var m = document.getElementById('previewModal'); + if (m) m.classList.add('open'); + } + + function closePreviewModal() { + var m = document.getElementById('previewModal'); + if (m) m.classList.remove('open'); + } + + // ── Typing Indicator ── + function showTyping() { + var container = document.getElementById('chatContainer'); + if (!container) return; + var div = document.createElement('div'); + div.className = 'typing'; + div.id = 'typingIndicator'; + div.innerHTML = 'AI is thinking' + + '<span class="typing-dots">' + + '<span></span><span></span><span></span>' + + '</span>'; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + + function hideTyping() { + var t = document.getElementById('typingIndicator'); + if (t) t.remove(); + } + + // ── Send Message ── + async function sendMessage() { + if (isStreaming) return; + + var input = document.getElementById('chatInput'); + if (!input) return; + var msg = input.value.trim(); + if (!msg) return; + + input.value = ''; + input.style.height = 'auto'; + + addMessage('user', msg); + addLog('User', msg.substring(0, 50) + (msg.length > 50 ? '...' : '')); + + if (msg.startsWith('/')) { + await handleCommand(msg); + return; + } + + showTyping(); + + // Sync OLLAMA_URL from terminal before sending + var term = window.webx11; + if (term && term.ollamaManager) { + OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; + } + // Re-set window-level endpoint + window.ollamaEndpoint = OLLAMA_URL; + + var modelSelect = document.getElementById('modelSelect'); + var model = modelSelect ? modelSelect.value : DEFAULT_MODEL; + var mod = typeof getCurrentModule === 'function' ? getCurrentModule() : null; + var systemPrompt = mod && mod.prompt ? mod.prompt : ''; + var chatHistory = buildChatContext(); + + try { + var headers = { 'Content-Type': 'application/json' }; + if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; + + var fullPrompt = systemPrompt + ? systemPrompt + '\n\n' + chatHistory + '\n\nUser: ' + msg + '\n\nAI:' + : chatHistory + '\n\nUser: ' + msg + '\n\nAI:'; + + if (USE_STREAMING) { + await sendStreamingRequest(headers, model, fullPrompt); + } else { + await sendNonStreamingRequest(headers, model, fullPrompt); + } + } catch (e) { + hideTyping(); + console.error('Chat error:', e); + addMessage('ai', 'Sorry, I could not connect to the AI service. Make sure Ollama is running on your server.\n\nError: ' + e.message); + } + } + + async function sendStreamingRequest(headers, model, prompt) { + isStreaming = true; + hideTyping(); + + var msgId = generateId(); + var container = document.getElementById('chatContainer'); + if (!container) { isStreaming = false; return; } + var div = document.createElement('div'); + div.className = 'message ai'; + div.dataset.msgId = msgId; + div.innerHTML = '<div class="role"><span>AI</span><div style="display:flex;gap:4px;">' + + '<button class="msg-copy" onclick="copyMessage(this, \'ai\')" title="Copy">📋</button>' + + '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + + '</div></div><div class="msg-content"><span class="streaming-cursor"></span></div>'; + div.dataset.content = ''; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + + var contentEl = div.querySelector('.msg-content'); + var fullContent = ''; + + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: true + }) + }); + + if (!response.ok) { + contentEl.textContent = 'AI request failed: HTTP ' + response.status; + throw new Error('AI request failed: HTTP ' + response.status); + } + + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + + var lastFormat = 0; + var formatInterval = 400; + + while (true) { + var result = await reader.read(); + if (result.done) break; + + buffer += decoder.decode(result.value, { stream: true }); + var lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim(); + if (!line) continue; + try { + var parsed = JSON.parse(line); + if (parsed.response) { + fullContent += parsed.response; + var now = Date.now(); + if (now - lastFormat >= formatInterval) { + contentEl.innerHTML = formatContent(fullContent) + '<span class="streaming-cursor"></span>'; + container.scrollTop = container.scrollHeight; + lastFormat = now; + } + } + if (parsed.done) { + break; + } + } catch (e) { + } + } + } + + // Process remaining buffer + if (buffer.trim()) { + try { + var parsed = JSON.parse(buffer); + if (parsed.response) { + fullContent += parsed.response; + } + } catch (e) { /* ignore */ } + } + + // Final render without cursor + contentEl.innerHTML = formatContent(fullContent); + div.dataset.content = fullContent; + latestAiContent = fullContent; + container.scrollTop = container.scrollHeight; + + messages.push({ + id: msgId, + role: 'ai', + content: fullContent, + timestamp: Date.now() + }); + saveMessages(); + addLog('AI', fullContent.substring(0, 50) + '...'); + + } catch (e) { + if (contentEl) contentEl.textContent = 'Error: ' + e.message; + console.error('Stream error:', e); + } + + isStreaming = false; + } + + async function sendNonStreamingRequest(headers, model, prompt) { + try { + var response = await fetch(OLLAMA_URL + '/api/generate', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: false + }) + }); + + hideTyping(); + + if (!response.ok) throw new Error('AI request failed'); + + var data = await response.json(); + addMessage('ai', data.response || 'No response'); + addLog('AI', (data.response || '').substring(0, 50) + '...'); + } catch (e) { + hideTyping(); + throw e; + } + } + + function buildChatContext() { + var context = ''; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var role = m.role === 'user' ? 'User' : 'AI'; + var content = m.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + context += role + ': ' + content + '\n'; + } + return context; + } + + function getChatHistory() { + return buildChatContext(); + } + + // ── Clear & Share ── + function clearChat() { + if (!confirm('Clear chat history?')) return; + messages = []; + localStorage.removeItem(MESSAGES_KEY); + var container = document.getElementById('chatContainer'); + if (container) { + container.innerHTML = + '<div class="message ai">' + + '<div class="role">AI</div>' + + 'Chat cleared! How can I help you?' + + '</div>'; + } + addLog('Clear', 'Chat history cleared'); + } + + function shareChat() { + var msgs = []; + for (var i = 0; i < messages.length; i++) { + var m = messages[i]; + var role = m.role === 'user' ? 'User' : 'AI'; + msgs.push('**' + role + ':** ' + m.content); + } + var text = '# WebX11 AI Chat\n\n' + msgs.join('\n\n'); + + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function () { + showToast('Chat copied to clipboard!'); + addLog('Share', 'Chat copied to clipboard'); + }); + } else { + var blob = new Blob([text], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'chat-export.txt'; + a.click(); + URL.revokeObjectURL(url); + addLog('Share', 'Chat downloaded as file'); + } + } + + // ── Expose Lite globals for commands.js and modules.js ── + // All functions exposed under CSLite namespace AND as individual window.* + // for backward compatibility with chat-commands.js and chat-modules.js + window.CSLite = { + CHATSTUDIO_VERSION: CHATSTUDIO_VERSION, + OLLAMA_URL: OLLAMA_URL, + OLLAMA_API_KEY: OLLAMA_API_KEY, + DEFAULT_MODEL: DEFAULT_MODEL, + USE_STREAMING: USE_STREAMING, + API_BASE: API_BASE, + getApiUrl: function(path) { return (window.CSLite.API_BASE || '/api') + path; }, + latestAiContent: latestAiContent, + logs: logs, + messages: messages, + isStreaming: isStreaming, + modules: [], + currentChatModule: 'default', + escapeHtml: escapeHtml, + utf8_to_b64: utf8_to_b64, + b64_to_utf8: b64_to_utf8, + generateId: generateId, + showToast: showToast, + autoExpand: autoExpand, + toggleSidebar: toggleSidebar, + closeSidebar: closeSidebar, + addLog: addLog, + renderLogs: renderLogs, + loadSettings: loadSettings, + saveSettings: saveSettings, + loadModels: loadModels, + loadMessages: loadMessages, + saveMessages: saveMessages, + appendMessageToDOM: appendMessageToDOM, + addMessage: addMessage, + formatContent: formatContent, + copyMessage: copyMessage, + showHtmlPreview: showHtmlPreview, + previewCodeBlock: previewCodeBlock, + closePreviewModal: closePreviewModal, + showTyping: showTyping, + hideTyping: hideTyping, + sendMessage: sendMessage, + sendStreamingRequest: sendStreamingRequest, + sendNonStreamingRequest: sendNonStreamingRequest, + buildChatContext: buildChatContext, + getChatHistory: getChatHistory, + clearChat: clearChat, + shareChat: shareChat, + copyLogs: copyLogs, + clearLogs: clearLogs, + loadLogs: loadLogs, + testConnection: testConnection, + openSettingsModal: openSettingsModal, + closeSettingsModal: closeSettingsModal + }; + // Individual window.* assignments for backward compat + window.CHATSTUDIO_VERSION = CHATSTUDIO_VERSION; + window.OLLAMA_URL = OLLAMA_URL; + window.OLLAMA_API_KEY = OLLAMA_API_KEY; + window.DEFAULT_MODEL = DEFAULT_MODEL; + window.USE_STREAMING = USE_STREAMING; + window.API_BASE = API_BASE; + window.getApiUrl = function(path) { return (window.API_BASE || '/api') + path; }; + window.latestAiContent = latestAiContent; + window.logs = logs; + window.messages = messages; + window.isStreaming = isStreaming; + window.escapeHtml = escapeHtml; + window.utf8_to_b64 = utf8_to_b64; + window.b64_to_utf8 = b64_to_utf8; + window.generateId = generateId; + window.showToast = showToast; + window.autoExpand = autoExpand; + window.toggleSidebar = toggleSidebar; + window.closeSidebar = closeSidebar; + window.addLog = addLog; + window.renderLogs = renderLogs; + window.loadSettings = loadSettings; + window.saveSettings = saveSettings; + window.loadModels = loadModels; + window.loadMessages = loadMessages; + window.saveMessages = saveMessages; + window.appendMessageToDOM = appendMessageToDOM; + window.addMessage = addMessage; + window.formatContent = formatContent; + window.copyMessage = copyMessage; + window.showHtmlPreview = showHtmlPreview; + window.previewCodeBlock = previewCodeBlock; + window.closePreviewModal = closePreviewModal; + window.showTyping = showTyping; + window.hideTyping = hideTyping; + window.sendMessage = sendMessage; + window.sendStreamingRequest = sendStreamingRequest; + window.sendNonStreamingRequest = sendNonStreamingRequest; + window.buildChatContext = buildChatContext; + window.getChatHistory = getChatHistory; + window.clearChat = clearChat; + window.shareChat = shareChat; + window.copyLogs = copyLogs; + window.clearLogs = clearLogs; + window.loadLogs = loadLogs; + window.testConnection = testConnection; + window.openSettingsModal = openSettingsModal; + window.closeSettingsModal = closeSettingsModal; + + // ── Init ── + loadSettings(); + loadMessages(); + loadLogs(); + loadModels(); + renderMessagesFromData(); + addLog('Init', 'Chat overlay initialized'); + } catch (err) { + // Reset initialization flag so user can retry + this._initialized = false; + + // Print error to terminal + if (this.terminal && this.terminal.print) { + this.terminal.print('Chat overlay init error: ' + err.message, 'error'); + } + + console.error('ChatStudioModule._initLiteEngine error:', err); + } + } +} + +/* === module-agent-registry.js === */ +// module-agent-registry.js — VFS-backed agent directory with A2A contracts +// Commands: agent-list, agent-register, agent-unregister, agent-status, contract-register, contract-list +class AgentRegistryModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._contracts = {}; + } + registerCommands() { + this.commands['agent-list'] = () => this.listAgents(); + this.commands['agent-status'] = (args) => this.showAgentStatus(args[0]); + this.commands['agent-register'] = (args) => this.registerAgent(args); + this.commands['agent-unregister'] = (args) => this.unregisterAgent(args[0]); + this.commands['contract-register'] = (args) => this.registerContract(args); + this.commands['contract-list'] = () => this.listContracts(); + } + init(terminal) { + this.terminal = terminal; + if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdir('agents'); + if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdir('.contracts'); + this._loadContracts(); + this.terminal.print('📋 Agent Registry loaded', 'info'); + } + _loadContracts() { + try { + const data = this.terminal.fs.readFile('/.contracts/index.json'); + if (data) this._contracts = JSON.parse(data); + } catch (e) {} + // Try loading individual contract files + const dir = this.terminal.fs.readDir('/.contracts/'); + if (dir) { + dir.forEach(e => { + if (e.type === 'file' && e.name.endsWith('.contract.json')) { + try { + const c = JSON.parse(this.terminal.fs.readFile('/.contracts/' + e.name)); + this._contracts[c.role] = c; + } catch (e) { console.warn('Bad contract:', e.name, e.message); } + } + }); + } + } + _saveContracts() { + try { + this.terminal.fs.fsWriteJSON('/.contracts/index.json', this._contracts); + } catch (e) { console.error('Contract save error:', e); } + } + registerAgent(args) { + const id = args[1] || `agent-${Date.now()}`; + const capabilities = args.slice(2).length ? args.slice(2) : ['list_files', 'read_file']; + const agentDir = `agents/${id}`; + if (!this.terminal.fs.readDir('/' + agentDir + '/')) this.terminal.fs.mkdir(agentDir); + this.terminal.fs.writeFile(`${agentDir}/status.json`, JSON.stringify({ + id, + status: 'idle', + capabilities, + registered: new Date().toISOString(), + lastHeartbeat: new Date().toISOString(), + contract: null + })); + this.terminal.print(`✅ Agent '${id}' registered with ${capabilities.length} capabilities`, 'success'); + } + registerContract(args) { + const role = args[0]; + if (!role) return this.terminal.print('Usage: contract-register <role> [input_fields...] [--output output_fields...]', 'warning'); + + const inputIdx = args.indexOf('--output'); + const hasOutputFlag = inputIdx !== -1; + + let inputFields = [], outputFields = []; + if (hasOutputFlag) { + inputFields = args.slice(1, inputIdx).filter(f => !f.startsWith('--')); + outputFields = args.slice(inputIdx + 1).filter(f => !f.startsWith('--')); + } else { + inputFields = args.slice(1).filter(f => !f.startsWith('--')); + outputFields = ['result']; // default output + } + + const contract = { + role, + capabilities: inputFields, + input_schema: inputFields, + output_schema: outputFields, + created: new Date().toISOString(), + updated: new Date().toISOString() + }; + this._contracts[role] = contract; + // Save as individual contract file + this.terminal.fs.fsWriteJSON(`/.contracts/${role}.contract.json`, contract); + this._saveContracts(); + this.terminal.print(`✅ Contract registered for role '${role}'`, 'success'); + this.terminal.print(` Input fields: ${inputFields.join(', ') || '(free-form)'}`, 'info'); + this.terminal.print(` Output fields: ${outputFields.join(', ') || '(free-form)'}`, 'info'); + } + listContracts() { + const roles = Object.keys(this._contracts); + if (roles.length === 0) return this.terminal.print('No contracts registered', 'info'); + this.terminal.print(`📜 A2A Contracts (${roles.length}):`, 'header'); + roles.forEach(r => { + const c = this._contracts[r]; + this.terminal.print(` ${r} — input:[${c.input_schema.join(',')}] → output:[${c.output_schema.join(',')}]`, 'info'); + }); + } + validateTask(role, task) { + const contract = this._contracts[role]; + if (!contract) return { valid: true, reason: 'no contract for role' }; // permissive fallback + if (contract.input_schema.length === 0) return { valid: true, reason: 'free-form input' }; + // Simple validation: check required input fields are present in task + const missing = contract.input_schema.filter(f => !task.toLowerCase().includes(f.toLowerCase())); + if (missing.length > 0) { + return { valid: false, reason: `missing required fields: ${missing.join(', ')}` }; + } + return { valid: true, reason: 'matched' }; + } + getContract(role) { + return this._contracts[role] || null; + } + listAgents() { + const agents = this.terminal.fs.ls('/agents/'); + if (!agents || agents.length === 0) { + return this.terminal.print('No agents registered', 'info'); + } + this.terminal.print(`📋 Registered Agents (${agents.length}):`, 'header'); + agents.forEach(a => { + if (a.type === 'dir' && a.name !== 'configured') { + const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); + const s = data ? JSON.parse(data) : { status: 'unknown', capabilities: [] }; + this.terminal.print(` ${a.name} — ${s.status} (${(s.capabilities || []).length} capabilities)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info'); + } + }); + } + unregisterAgent(id) { + const ok = this.terminal.fs.deleteFile('agents/' + id); + if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success'); + else this.terminal.print(`❌ Agent '${id}' not found`, 'error'); + } + showAgentStatus(id) { + if (!id) return this.terminal.print('Usage: agent-status <agent-id>', 'error'); + const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); + if (!data) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); + const s = JSON.parse(data); + this.terminal.print(`Agent: ${s.id}`, 'header'); + this.terminal.print(` Status: ${s.status}`, 'info'); + this.terminal.print(` Capabilities: ${(s.capabilities || []).join(', ')}`, 'info'); + this.terminal.print(` Contract: ${s.contract || 'none'}`, 'info'); + this.terminal.print(` Registered: ${s.registered}`, 'info'); + this.terminal.print(` Last Heartbeat: ${s.lastHeartbeat}`, 'info'); + } + getAgent(id) { + const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); + return data ? JSON.parse(data) : null; + } + updateHeartbeat(id) { + const agent = this.getAgent(id); + if (!agent) return false; + agent.lastHeartbeat = new Date().toISOString(); + this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify(agent)); + return true; + } +} + +/* === module-leases.js === */ +// module-leases.js — Lease system for agent task delegation +// Commands: lease-create, lease-list, lease-revoke, lease-renew +class LeaseModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._leases = {}; + this._watchdogTimer = null; + } + registerCommands() { + this.commands['lease-create'] = (args) => this.createLease(args); + this.commands['lease-list'] = () => this.listLeases(); + this.commands['lease-revoke'] = (args) => this.revokeLease(args[0]); + this.commands['lease-renew'] = (args) => this.renewLease(args[0], parseInt(args[1]) || 30); + } + init(terminal) { + this.terminal = terminal; + if (!this.terminal.fs.readFile('/leases/')) { + this.terminal.fs.mkdir('leases'); + } + // Restore persisted leases + const files = this.terminal.fs.ls('/leases/'); + if (files) { + files.forEach(f => { + if (f.name.endsWith('.json')) { + const data = this.terminal.fs.readFile(`/leases/${f.name}`); + if (data) { + try { + const lease = JSON.parse(data); + this._leases[lease.id] = lease; + } catch (e) {} + } + } + }); + } + // Start watchdog every 5 seconds + this._watchdogTimer = setInterval(() => this._checkExpired(), 5000); + this.terminal.print('📜 Lease system loaded', 'info'); + } + createLease(args) { + const workerId = args[1]; + const duration = parseInt(args[2]) || 30; + const task = args.slice(3).join(' ') || 'generic task'; + if (!workerId) return this.terminal.print('Usage: lease-create <agent-id> [duration_sec] [task]', 'error'); + + const lease = { + id: `lease-${Date.now()}`, + workerId, + orchestratorId: 'orchestrator-0', + task, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + duration * 1000).toISOString(), + status: 'active', + duration + }; + this._leases[lease.id] = lease; + this.terminal.fs.writeFile(`leases/${lease.id}.json`, JSON.stringify(lease)); + this.terminal.print(`📜 Lease ${lease.id} created for ${workerId} (${duration}s, task: ${task.substring(0, 40)})`, 'success'); + return lease; + } + listLeases() { + const all = Object.values(this._leases); + const active = all.filter(l => l.status === 'active'); + const expired = all.filter(l => l.status === 'expired'); + const revoked = all.filter(l => l.status === 'revoked'); + this.terminal.print(`📜 Leases: ${active.length} active, ${expired.length} expired, ${revoked.length} revoked`, 'header'); + active.forEach(l => { + const remaining = Math.max(0, Math.round((new Date(l.expiresAt) - Date.now()) / 1000)); + this.terminal.print(` ${l.id} → ${l.workerId} (${remaining}s remain) — ${l.task.substring(0, 40)}`, 'info'); + }); + } + revokeLease(id) { + if (!this._leases[id]) return this.terminal.print(`❌ Lease ${id} not found`, 'error'); + this._leases[id].status = 'revoked'; + this._leases[id].revokedAt = new Date().toISOString(); + this.terminal.fs.writeFile(`leases/${id}.json`, JSON.stringify(this._leases[id])); + this.terminal.print(`✅ Lease ${id} revoked`, 'warning'); + } + renewLease(id, additionalSeconds) { + if (!this._leases[id] || this._leases[id].status !== 'active') { + return this.terminal.print(`❌ Lease ${id} not found or not active`, 'error'); + } + const lease = this._leases[id]; + const newExpiry = new Date(Date.now() + additionalSeconds * 1000); + lease.expiresAt = newExpiry.toISOString(); + lease.duration += additionalSeconds; + lease.renewedAt = new Date().toISOString(); + this.terminal.fs.writeFile(`leases/${id}.json`, JSON.stringify(lease)); + this.terminal.print(`✅ Lease ${id} renewed (+${additionalSeconds}s)`, 'success'); + } + _checkExpired() { + const now = Date.now(); + Object.values(this._leases).forEach(lease => { + if (lease.status === 'active' && new Date(lease.expiresAt) < now) { + lease.status = 'expired'; + lease.expiredAt = new Date().toISOString(); + this.terminal.fs.writeFile(`leases/${lease.id}.json`, JSON.stringify(lease)); + this.terminal.log('INFO', 'Lease', `Lease ${lease.id} for ${lease.workerId} expired`); + } + }); + } +} + +/* === module-orchestrator.js === */ +// module-orchestrator.js — Multi-agent orchestration system with A2A mailbox transport +// Commands: orchestrate, orchestrator-status, orchestrator-spawn, orchestrator-kill, +// orchestrator-purge, orchestrator-config, orchestrator-list, orchestrator-sleep, +// orchestrator-wake, orchestrator-restart-policy, a2a-send, a2a-list +class OrchestratorModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._agents = {}; + this._SPAWN_LIMIT = 5; + this._SPAWN_STRATEGY = 'eager'; + this._HEARTBEAT_TIMEOUT = 15000; + this._IDLE_TIMEOUT = 120000; + this._RESTART_POLICY = 'never'; + this._A2A_POLLING = true; + this._A2A_POLL_INTERVAL = 500; + this._HEARTBEAT_CHECK = 3000; + this._IDLE_SWEEP_INTERVAL = 30000; + this._DEAD_PURGE_TIMEOUT = 60000; + this._a2aPollTimer = null; + this._heartbeatInterval = null; + this._a2aMessages = {}; + this._restartBackoffs = {}; + this._eventBus = {}; + this._idleSweepInterval = null; + this._spawnLock = false; + this._CONSENSUS_SIZE = 3; + this._CONSENSUS_STRATEGY = 'majority'; + this._CONSENSUS_TIMEOUT = 60000; + this._consensusRounds = []; + this._activeConsensus = null; + this._pipelineHistory = []; + this._PIPELINE_CONFIG = { autoFix: false, stageTimeout: 30000 }; + this._loadConfig(); + } + _loadConfig() { + try { const c = localStorage.getItem('webx11_orchestrator_config'); if (c) { const p = JSON.parse(c); if (p.spawnLimit) this._SPAWN_LIMIT = p.spawnLimit; if (p.heartbeatTimeout) this._HEARTBEAT_TIMEOUT = p.heartbeatTimeout; if (p.idleTimeout) this._IDLE_TIMEOUT = p.idleTimeout; if (p.restartPolicy) this._RESTART_POLICY = p.restartPolicy; if (p.a2aPollInterval) this._A2A_POLL_INTERVAL = p.a2aPollInterval; if (p.heartbeatCheck) this._HEARTBEAT_CHECK = p.heartbeatCheck; if (p.idleSweepInterval) this._IDLE_SWEEP_INTERVAL = p.idleSweepInterval; if (p.deadPurgeTimeout) this._DEAD_PURGE_TIMEOUT = p.deadPurgeTimeout; if (p.consensusSize) this._CONSENSUS_SIZE = p.consensusSize; if (p.consensusStrategy) this._CONSENSUS_STRATEGY = p.consensusStrategy; if (p.consensusTimeout) this._CONSENSUS_TIMEOUT = p.consensusTimeout; if (p.pipelineAutoFix !== undefined) this._PIPELINE_CONFIG.autoFix = p.pipelineAutoFix; if (p.pipelineStageTimeout) this._PIPELINE_CONFIG.stageTimeout = p.pipelineStageTimeout; } } catch (e) {} + } + _saveConfig() { + localStorage.setItem('webx11_orchestrator_config', JSON.stringify({ + spawnLimit: this._SPAWN_LIMIT, + heartbeatTimeout: this._HEARTBEAT_TIMEOUT, + idleTimeout: this._IDLE_TIMEOUT, + restartPolicy: this._RESTART_POLICY, + a2aPollInterval: this._A2A_POLL_INTERVAL, + heartbeatCheck: this._HEARTBEAT_CHECK, + idleSweepInterval: this._IDLE_SWEEP_INTERVAL, + deadPurgeTimeout: this._DEAD_PURGE_TIMEOUT, + consensusSize: this._CONSENSUS_SIZE, + consensusStrategy: this._CONSENSUS_STRATEGY, + consensusTimeout: this._CONSENSUS_TIMEOUT, + pipelineAutoFix: this._PIPELINE_CONFIG.autoFix, + pipelineStageTimeout: this._PIPELINE_CONFIG.stageTimeout + })); + } + registerCommands() { + this.commands['orchestrate'] = (args) => this.delegateTask(args.join(' ')); + this.commands['orchestrator-status'] = () => this.showStatus(); + this.commands['orchestrator-spawn'] = (args) => this.spawnAgent(args[0], args.slice(1)); + this.commands['orchestrator-kill'] = (args) => this.killAgent(args[0]); + this.commands['orchestrator-purge'] = () => this.purgeDeadAgents(); + this.commands['orchestrator-config'] = (args) => this.setConfig(args); + this.commands['orchestrator-list'] = () => this.listAgents(); + this.commands['orchestrator-sleep'] = (args) => this.sleepAgent(args[0]); + this.commands['orchestrator-wake'] = (args) => this.wakeAgent(args[0]); + this.commands['orchestrator-restart-policy'] = (args) => this.setRestartPolicy(args); + this.commands['a2a-send'] = (args) => this.sendA2AMessage(args); + this.commands['a2a-list'] = () => this.listA2AMessages(); + this.commands['orchestrate-parallel'] = (args) => this.delegateTask(args.join(' '), { parallel: true }); + this.commands['orchestrate-decompose'] = (args) => this.showDecomposition(args.join(' ')); + this.commands['orchestrate-result'] = () => this.showParallelResult(); + this.commands['consensus'] = (args) => this.handleConsensus(args); + this.commands['consensus-status'] = () => this.consensusStatus(); + this.commands['consensus-history'] = () => this.consensusHistory(); + this.commands['pipeline'] = (args) => this.runPipeline(args); + } + // ─── Task Decomposition Engine ─── + _decomposeGoal(goal) { + if (!goal || typeof goal !== 'string') return [{ goal }]; + const lower = goal; + // Heuristic: only split at conjunctions when both sides have distinct action verbs + // Action verb patterns from _deriveCapabilities + const ACTION_VERBS = [ + 'search', 'find', 'research', 'lookup', 'query', + 'write', 'create', 'generate', 'compose', 'draft', 'save', + 'delete', 'remove', 'trash', 'rm', + 'read', 'view', 'cat', 'show', 'display', 'inspect', 'preview', + 'edit', 'modify', 'update', 'patch', 'change', 'fix', + 'install', 'package', 'setup', + 'list', 'status', 'health', 'audit', 'report', 'check', + 'fetch', 'scrape', 'download', + 'build', 'develop', 'compile', + 'analyze', 'analyse', 'summarize', 'review', 'investigate', + 'deploy', 'publish', 'upload', + 'monitor', 'watch', 'poll', 'track', 'observe', + 'test', 'verify', 'cmp', 'diff', 'compare' + ]; + const hasActionVerb = (s) => ACTION_VERBS.some(v => { + const re = new RegExp('\\b' + v + '\\b', 'i'); + return re.test(s); + }); + // Try splitting on " and " / " then " / " also " / "; " / ", then" + const separators = [ + /\s+and\s+/i, + /\s+then\s+/i, + /\s+also\s+/i, + /;\s+/, + /,\s+then\s+/i, + /\s+followed by\s+/i, + ]; + let bestSplit = null; + for (const sep of separators) { + const parts = goal.split(sep); + if (parts.length >= 2 && parts.every(p => hasActionVerb(p.trim()))) { + bestSplit = parts.map(p => p.trim()).filter(p => p.length > 3); + break; // first matching separator wins + } + } + if (!bestSplit || bestSplit.length < 2) { + return [{ goal }]; // atomic + } + return bestSplit.map(sub => ({ + goal: sub, + role: this._deriveRole(sub), + caps: this._deriveCapabilities(sub) + })); + } + getHelpInfo() { + return { + 'orchestrate': 'orchestrate <task> — Delegate a task to an available agent (auto-spawns if needed)', + 'orchestrate-parallel': 'orchestrate-parallel <task> — Decompose and delegate sub-tasks in parallel (auto-splits on "and"/"then")', + 'orchestrate-decompose': 'orchestrate-decompose <task> — Preview how a task would be decomposed into sub-tasks', + 'orchestrate-result': 'orchestrate-result — Show the last parallel batch results (stored in VFS)', + 'consensus': 'consensus <question> — Start a consensus round: spawn 3 agents, debate the question, resolve by majority', + 'consensus-status': 'consensus-status — Show current/active consensus round status', + 'consensus-history': 'consensus-history — Show past consensus rounds and their resolutions', + 'pipeline': 'pipeline <test|deploy|verify|history|config> — Autonomous CI/CD: test agents/queue/modules, deploy (test→auto-fix→verify), history (show runs), config (show config)', + 'orchestrator-status': 'orchestrator-status — Show agent pool status and config', + 'orchestrator-spawn': 'orchestrator-spawn [role] [capabilities...] — Manually spawn a new agent', + 'orchestrator-kill': 'orchestrator-kill <agent-id> — Terminate an agent and revoke leases', + 'orchestrator-purge': 'orchestrator-purge — Remove dead agents (>60s since heartbeat)', + 'orchestrator-config': 'orchestrator-config <key> <value> — Tune: spawn-limit, spawn-strategy, heartbeat-timeout, idle-timeout, restart-policy, a2a-polling, a2a-poll-interval, heartbeat-check, idle-sweep-interval, dead-purge-timeout', + 'orchestrator-list': 'orchestrator-list — List all spawned agents with full details', + 'orchestrator-sleep': 'orchestrator-sleep <agent-id> — Suspend agent to VFS', + 'orchestrator-wake': 'orchestrator-wake <agent-id> — Restore agent from VFS sleep', + 'orchestrator-restart-policy': 'orchestrator-restart-policy <agent-id> <on-failure|always|never> — Set per-agent restart policy', + 'a2a-send': 'a2a-send <agent-id> <json-message> — Send an A2A message to an agent\'s inbox', + 'a2a-list': 'a2a-list — Show pending A2A messages across all agents', + }; + } + init(terminal) { + this.terminal = terminal; + // Register orchestrator in the agent registry + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (registry) { + registry.registerAgent(['register', 'orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']); + } + // Reanimate sleeping agents from VFS + this._reanimateFromVFS(); + // A2A mailbox polling loop (500ms) + if (this._A2A_POLLING) { + this._a2aPollTimer = setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); + } + // Heartbeat watchdog + this._heartbeatInterval = setInterval(() => this._checkHeartbeats(), this._HEARTBEAT_CHECK); + // Idle timeout sweep + this._idleSweepInterval = setInterval(() => this._sweepIdle(), this._IDLE_SWEEP_INTERVAL); + this.terminal.print('🧠 Orchestrator loaded — A2A mailbox transport active', 'success'); + // Subscribe to event bus for in-memory A2A delivery (instant, no VFS poll) + this._on('a2a:message', (msg) => { + if (msg.type === 'heartbeat' && msg.from && this._agents[msg.from]) { + this._agents[msg.from].lastHeartbeat = Date.now(); + } + if (msg.type === 'response' && msg.from && this._agents[msg.from]) { + this._agents[msg.from].status = 'idle'; + this._agents[msg.from].currentTask = null; + } + }); + } + + // ─── Event Bus (in-memory pub/sub for agent communication) ─── + + _on(event, callback) { + if (!this._eventBus[event]) this._eventBus[event] = []; + this._eventBus[event].push(callback); + return () => { // Return unsubscribe function + this._eventBus[event] = this._eventBus[event].filter(cb => cb !== callback); + }; + } + + _emit(event, data) { + for (const cb of (this._eventBus[event] || [])) { + try { cb(data); } catch (e) { /* subscriber error */ } + } + } + + // ─── A2A Mailbox Transport ─── + + sendA2AMessage(args) { + if (args.length < 2) return this.terminal.print('Usage: a2a-send <agent-id> <json-message>', 'error'); + const agentId = args[0]; + let payload; + try { + payload = JSON.parse(args.slice(1).join(' ')); + } catch (e) { + // Treat as plain text message + payload = { type: 'request', payload: { text: args.slice(1).join(' ') } }; + } + const agent = this._agents[agentId]; + if (!agent) return this.terminal.print(`❌ Agent '${agentId}' not found`, 'error'); + + const msg = { + a2a_version: '0.1', + msg_id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`, + type: payload.type || 'request', + from: 'orchestrator-0', + to: agentId, + timestamp: new Date().toISOString(), + ttl_seconds: 30, + contract: agent.capabilities ? { capabilities: agent.capabilities } : {}, + payload: payload.payload || payload + }; + + // Write to agent's VFS inbox + if (!this.terminal.fs.readFile(`/agents/${agentId}/`)) this.terminal.fs.mkdir(`agents/${agentId}`); + this.terminal.fs.writeFile(`agents/${agentId}/inbox.json`, JSON.stringify(msg)); + + // Track in memory + if (!this._a2aMessages[agentId]) this._a2aMessages[agentId] = []; + this._a2aMessages[agentId].push({ ...msg, _processed: false }); + + this.terminal.print(`📨 A2A message sent to ${agentId} (${msg.type}, id: ${msg.msg_id})`, 'success'); + + // Emit via event bus for in-memory delivery (bypasses VFS polling) + this._emit('a2a:message', msg); + this._emit(`a2a:message:${agentId}`, msg); + } + + listA2AMessages() { + const ids = Object.keys(this._a2aMessages); + if (ids.length === 0) return this.terminal.print('No A2A messages', 'info'); + this.terminal.print('📨 A2A Messages:', 'header'); + for (const agentId of ids) { + const msgs = this._a2aMessages[agentId]; + const pending = msgs.filter(m => !m._processed).length; + this.terminal.print(` ${agentId}: ${msgs.length} total (${pending} pending)`, 'info'); + for (const m of msgs.slice(-3)) { + const status = m._processed ? '✅' : '⏳'; + this.terminal.print(` ${status} ${m.msg_id} ${m.type} → ${m.to} (${(m.payload?.text || JSON.stringify(m.payload)).substring(0, 40)})`, 'text-muted'); + } + } + } + + _pollAgentMailboxes() { + const fs = this.terminal.fs; + if (!fs) return; + for (const [id, agent] of Object.entries(this._agents)) { + try { + const inbox = fs.fsReadJSON(`/agents/${id}/inbox.json`); + if (!inbox) continue; + // Check if this message is already processed + const existing = this._a2aMessages[id] || []; + if (existing.find(m => m.msg_id === inbox.msg_id && m._processed)) continue; + + // Route based on message type + if (inbox.a2a_version && inbox.type) { + inbox._processed = true; + if (!this._a2aMessages[id]) this._a2aMessages[id] = []; + this._a2aMessages[id].push(inbox); + + if (inbox.type === 'request') { + this.terminal.print(`📨 A2A: ${inbox.from} → ${id} (${inbox.msg_id})`, 'info'); + // Check outbox for response + this._checkAgentOutbox(id); + } else if (inbox.type === 'broadcast') { + this.terminal.print(`📢 A2A broadcast from ${inbox.from}: ${(inbox.payload?.text || JSON.stringify(inbox.payload)).substring(0, 60)}`, 'info'); + } else if (inbox.type === 'heartbeat') { + agent.lastHeartbeat = Date.now(); + } else if (inbox.type === 'response') { + this.terminal.print(`📨 A2A response from ${id}: ${(inbox.payload?.result || JSON.stringify(inbox.payload)).substring(0, 60)}`, 'success'); + agent.status = 'idle'; + agent.currentTask = null; + } + } + } catch (e) { /* skip agent with bad inbox */ } + } + } + + _checkAgentOutbox(agentId) { + try { + const outbox = this.terminal.fs.fsReadJSON(`/agents/${agentId}/outbox.json`); + if (outbox && outbox.status === 'completed') { + const msg = { + a2a_version: '0.1', + msg_id: `resp-${Date.now()}`, + type: 'response', + from: agentId, + to: 'orchestrator-0', + timestamp: new Date().toISOString(), + payload: outbox + }; + if (!this._a2aMessages['orchestrator-0']) this._a2aMessages['orchestrator-0'] = []; + this._a2aMessages['orchestrator-0'].push(msg); + this.terminal.print(`📨 A2A: ${agentId} completed task (outbox)`, 'success'); + } + } catch (e) {} + } + + // ─── Agent Lifecycle ─── + + _sanitizeId(name) { + return (name || 'worker').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 32) || 'worker'; + } + spawnAgent(role, capabilities) { + if (this._spawnLock) { + this.terminal.print('⚠️ Spawn in progress — queued', 'warning'); + return null; + } + this._spawnLock = true; + try { + const safeRole = this._sanitizeId(role); + const id = `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`; + if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { + this.terminal.print(`❌ Agent spawn limit (${this._SPAWN_LIMIT}) reached`, 'error'); + return null; + } + const agent = { + id, + role: role || 'worker', + capabilities: capabilities.length ? capabilities : ['list_files', 'read_file', 'web_search'], + createdAt: new Date().toISOString(), + lastHeartbeat: Date.now(), + status: 'idle', + currentTask: null, + leaseId: null, + restartPolicy: this._RESTART_POLICY, + failureCount: 0, + restartCount: 0, + nextRestartAt: null + }; + this._agents[id] = agent; + // Create VFS agent directory with status + if (!this.terminal.fs.readFile(`/agents/${id}/`)) this.terminal.fs.mkdir(`agents/${id}`); + this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify(agent)); + // Register in agent registry + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (registry) registry.registerAgent(['register', id, ...agent.capabilities]); + this.terminal.print(`🤖 Agent '${id}' spawned (role: ${role || 'worker'}, ${agent.capabilities.length} caps)`, 'success'); + return agent; + } finally { + this._spawnLock = false; + } + } + + killAgent(id) { + if (!this._agents[id]) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); + // Revoke any active lease + const leaseModule = this.terminal.moduleManager.modules['LeaseModule']; + if (leaseModule) { + Object.values(leaseModule._leases).forEach(l => { + if (l.workerId === id && l.status === 'active') leaseModule.revokeLease(l.id); + }); + } + // Unregister from registry + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + if (registry) registry.unregisterAgent(id); + // Clean up VFS + this.terminal.fs.deleteFile('agents/' + id); + delete this._agents[id]; + delete this._restartBackoffs[id]; + this.terminal.print(`💀 Agent '${id}' killed`, 'warning'); + } + + sleepAgent(id) { + if (!this._agents[id]) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); + const agent = this._agents[id]; + // Serialize to VFS + const state = { + ...agent, + _sleepTime: new Date().toISOString() + }; + delete state.leaseId; // leases don't survive sleep + this.terminal.fs.writeFile(`agents/${id}/sleep.json`, JSON.stringify(state)); + agent.status = 'sleeping'; + this.terminal.print(`💤 Agent '${id}' sleeping (state saved to VFS)`, 'warning'); + } + + wakeAgent(id) { + const data = this.terminal.fs.fsReadJSON(`/agents/${id}/sleep.json`); + if (!data) return this.terminal.print(`❌ No saved state for '${id}' — use orchestrator-spawn instead`, 'error'); + // Restore agent from VFS + const agent = { + ...data, + status: 'idle', + currentTask: null, + leaseId: null, + lastHeartbeat: Date.now() + }; + delete agent._sleepTime; + this._agents[id] = agent; + this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify({ ...agent, status: 'idle' })); + this.terminal.fs.deleteFile(`agents/${id}/sleep.json`); + this.terminal.print(`🔄 Agent '${id}' reanimated from VFS sleep`, 'success'); + } + + _reanimateFromVFS() { + const fs = this.terminal.fs; + const agentsDir = fs.readDir('/agents/'); + if (!agentsDir) return; + let reanimated = 0; + for (const entry of agentsDir) { + if (entry.type !== 'dir') continue; + const sleepData = fs.fsReadJSON(`/agents/${entry.name}/sleep.json`); + if (sleepData) { + const agent = { + ...sleepData, + status: 'idle', + currentTask: null, + leaseId: null, + lastHeartbeat: Date.now() + }; + delete agent._sleepTime; + this._agents[entry.name] = agent; + fs.writeFile(`agents/${entry.name}/status.json`, JSON.stringify({ ...agent, status: 'idle' })); + reanimated++; + } + } + if (reanimated > 0) { + this.terminal.print(`🔄 Reanimated ${reanimated} agent(s) from VFS sleep`, 'info'); + } + } + + // ─── Restart Policies ─── + + setRestartPolicy(args) { + if (args.length < 2) return this.terminal.print('Usage: orchestrator-restart-policy <agent-id> <on-failure|always|never>', 'error'); + const id = args[0]; + const policy = args[1]; + if (!['on-failure', 'always', 'never'].includes(policy)) { + return this.terminal.print('Policy must be: on-failure, always, or never', 'error'); + } + if (id === 'default') { + this._RESTART_POLICY = policy; + return this.terminal.print(`✅ Default restart policy set to '${policy}'`, 'success'); + } + const agent = this._agents[id]; + if (!agent) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); + agent.restartPolicy = policy; + this.terminal.print(`✅ Restart policy for '${id}' set to '${policy}'`, 'success'); + } + + _handleAgentFailure(id, error) { + const agent = this._agents[id]; + if (!agent) return; + agent.failureCount = (agent.failureCount || 0) + 1; + agent.lastError = error; + agent.lastErrorAt = new Date().toISOString(); + + let shouldRestart = false; + if (agent.restartPolicy === 'always') { + shouldRestart = true; + } else if (agent.restartPolicy === 'on-failure' && agent.failureCount <= 1) { + shouldRestart = true; + } + + if (shouldRestart) { + const backoff = Math.min(Math.pow(2, agent.failureCount), 60); // 2^N capped at 60s + agent.nextRestartAt = Date.now() + (backoff * 1000); + agent.restartCount = (agent.restartCount || 0) + 1; + this._restartBackoffs[id] = setTimeout(() => { + if (this._agents[id]) { + this._agents[id].status = 'idle'; + this._agents[id].currentTask = null; + this.terminal.print(`🔄 Agent '${id}' auto-restarted (backoff: ${backoff}s, attempt: ${agent.restartCount})`, 'info'); + } + }, backoff * 1000); + this.terminal.print(`⚠️ Agent '${id}' failed — restart in ${backoff}s (policy: ${agent.restartPolicy})`, 'warning'); + } else { + agent.status = 'failed'; + this.terminal.print(`❌ Agent '${id}' failed — no restart (policy: ${agent.restartPolicy})`, 'error'); + } + } + + // ─── Autonomous Spawning ─── + + _deriveCapabilities(task) { + const caps = ['list_files', 'read_file']; + const lower = task.toLowerCase(); + const CAP_MAP = [ + { patterns: ['search', 'find', 'research', 'lookup', 'query', 'look up'], caps: ['web_search'] }, + { patterns: ['write', 'create', 'generate', 'compose', 'draft', 'save'], caps: ['write_file'] }, + { patterns: ['delete', 'remove', 'trash', 'rm '], caps: ['delete_file'] }, + { patterns: ['read', 'view', 'cat', 'show', 'display', 'inspect', 'preview'], caps: [] }, // already has read_file + { patterns: ['edit', 'modify', 'update', 'patch', 'change', 'fix'], caps: ['write_file'] }, + { patterns: ['install', 'package', 'wpm', 'setup'], caps: ['wpm_install'] }, + { patterns: ['module', 'plugin', 'ext'], caps: ['view_module', 'edit_module'] }, + { patterns: ['list', 'status', 'health', 'audit', 'report', 'check'], caps: ['get_status'] }, + { patterns: ['fetch', 'url', 'http', 'scrape'], caps: ['web_fetch'] }, + { patterns: ['code', 'program', 'script', 'build', 'develop', 'compile'], caps: ['list_files', 'read_file', 'write_file'] }, + { patterns: ['analyze', 'analyse', 'summarize', 'review', 'investigate'], caps: ['web_search', 'list_files', 'read_file'] }, + { patterns: ['deploy', 'publish', 'upload'], caps: ['write_file', 'webdav_upload'] }, + { patterns: ['monitor', 'watch', 'poll', 'track', 'observe'], caps: ['get_status'] }, + ]; + for (const entry of CAP_MAP) { + if (entry.patterns.some(p => lower.includes(p))) { + entry.caps.forEach(c => caps.push(c)); + } + } + return [...new Set(caps)]; + } + + _deriveRole(task) { + const lower = task.toLowerCase(); + if (lower.includes('search') || lower.includes('find')) return 'searcher'; + if (lower.includes('write') || lower.includes('create') || lower.includes('save')) return 'writer'; + if (lower.includes('delete') || lower.includes('remove') || lower.includes('clean')) return 'janitor'; + if (lower.includes('install') || lower.includes('package')) return 'packager'; + if (lower.includes('module') || lower.includes('code') || lower.includes('fix')) return 'coder'; + if (lower.includes('health') || lower.includes('check') || lower.includes('audit')) return 'auditor'; + if (lower.includes('fetch') || lower.includes('url') || lower.includes('http')) return 'fetcher'; + return 'worker'; + } + + // ─── Config ─── + + setConfig(args) { + if (args.length < 2) return this.terminal.print('Usage: orchestrator-config <key> <value>', 'error'); + const key = args[0]; + const val = args[1]; + + const configMap = { + 'spawn-limit': () => { this._SPAWN_LIMIT = parseInt(val); return `spawn-limit set to ${val}`; }, + 'spawn-strategy': () => { if (!['eager', 'lazy'].includes(val)) return 'Must be eager or lazy'; this._SPAWN_STRATEGY = val; return `spawn-strategy set to ${val}`; }, + 'heartbeat-timeout': () => { this._HEARTBEAT_TIMEOUT = parseInt(val); return `heartbeat-timeout set to ${val}ms`; }, + 'idle-timeout': () => { this._IDLE_TIMEOUT = parseInt(val); return `idle-timeout set to ${val}ms`; }, + 'restart-policy': () => { if (!['on-failure', 'always', 'never'].includes(val)) return 'Must be on-failure, always, or never'; this._RESTART_POLICY = val; return `default restart-policy set to ${val}`; }, + 'a2a-polling': () => { + this._A2A_POLLING = val === 'on' || val === 'true'; + if (this._A2A_POLLING && !this._a2aPollTimer) { + this._a2aPollTimer = setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); + } else if (!this._A2A_POLLING && this._a2aPollTimer) { + clearInterval(this._a2aPollTimer); + this._a2aPollTimer = null; + } + return `a2a-polling ${this._A2A_POLLING ? 'on' : 'off'}`; + }, + 'a2a-poll-interval': () => { const n = parseInt(val); if (isNaN(n) || n < 50 || n > 30000) return 'Must be 50-30000ms'; clearInterval(this._a2aPollTimer); this._a2aPollTimer = null; this._A2A_POLL_INTERVAL = n; if (this._A2A_POLLING) { this._a2aPollTimer = setInterval(() => this._pollAgentMailboxes(), n); } return `a2a-poll-interval set to ${n}ms`; }, + 'heartbeat-check': () => { const n = parseInt(val); if (isNaN(n) || n < 500 || n > 60000) return 'Must be 500-60000ms'; clearInterval(this._heartbeatInterval); this._HEARTBEAT_CHECK = n; this._heartbeatInterval = setInterval(() => this._checkHeartbeats(), n); return `heartbeat-check set to ${n}ms`; }, + 'idle-sweep-interval': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 600000) return 'Must be 5000-600000ms'; clearInterval(this._idleSweepInterval); this._IDLE_SWEEP_INTERVAL = n; this._idleSweepInterval = setInterval(() => this._sweepIdle(), n); return `idle-sweep-interval set to ${n}ms`; }, + 'dead-purge-timeout': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 600000) return 'Must be 5000-600000ms'; this._DEAD_PURGE_TIMEOUT = n; return `dead-purge-timeout set to ${n}ms`; }, + 'consensus-size': () => { const n = parseInt(val); if (isNaN(n) || n < 2 || n > 10) return 'Must be 2-10'; this._CONSENSUS_SIZE = n; return `consensus-size set to ${n}`; }, + 'consensus-strategy': () => { if (!['majority', 'unanimous', 'weighted'].includes(val)) return 'Must be majority, unanimous, or weighted'; this._CONSENSUS_STRATEGY = val; return `consensus-strategy set to ${val}`; }, + 'consensus-timeout': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 300000) return 'Must be 5000-300000ms'; this._CONSENSUS_TIMEOUT = n; return `consensus-timeout set to ${n}ms`; }, + 'pipeline-auto-fix': () => { this._PIPELINE_CONFIG.autoFix = val === 'on' || val === 'true'; return `auto-fix ${this._PIPELINE_CONFIG.autoFix ? 'on' : 'off'}`; }, + 'pipeline-stage-timeout': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 120000) return 'Must be 5000-120000ms'; this._PIPELINE_CONFIG.stageTimeout = n; return `stage-timeout set to ${n}ms`; } + }; + + if (!configMap[key]) { + return this.terminal.print(`Unknown config key: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); + } + const result = configMap[key](); + this.terminal.print(`✅ ${result}`, 'success'); + this._saveConfig(); + } + + // ─── Show Decomposition Preview ─── + + showDecomposition(goal) { + if (!goal) return this.terminal.print('Usage: orchestrate-decompose <goal>', 'error'); + const parts = this._decomposeGoal(goal); + this.terminal.print('🔍 Decomposition of "' + goal.substring(0, 60) + '":', 'header'); + if (parts.length === 1 && parts[0].goal === goal) { + this.terminal.print(' → Atomic (no split detected)', 'info'); + return; + } + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + this.terminal.print(' [' + (i + 1) + '] ' + p.goal.substring(0, 50), 'success'); + this.terminal.print(' Role: ' + p.role + ' | Caps: ' + (p.caps || []).join(', '), 'text-muted'); + } + this.terminal.print('→ ' + parts.length + ' sub-task(s) — run with: orchestrate-parallel "' + goal + '"', 'info'); + } + + // ─── Show Last Parallel Result ─── + + showParallelResult() { + const data = this.terminal.fs.fsReadJSON('orchestrator/parallel-results.json'); + if (!data) return this.terminal.print('No parallel results saved yet', 'info'); + this.terminal.print('📊 Last Parallel Batch Results', 'header'); + this.terminal.print(' Task: ' + (data.parent_task || '').substring(0, 60), 'info'); + this.terminal.print(' ' + data.successes + '/' + data.total + ' succeeded' + (data.failures > 0 ? ', ' + data.failures + ' failed' : ''), 'info'); + this.terminal.print(' Timestamp: ' + data.timestamp, 'text-muted'); + for (const s of (data.summaries || [])) { + const icon = s.status === 'ok' ? '✅' : '❌'; + const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 80) : (s.error || ''); + this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 40) + ' → ' + snippet, 'info'); + } + } + + // ─── Consensus Protocol ─── + + async handleConsensus(args) { + if (!args || args.length === 0) return this.terminal.print('Usage: consensus <question>', 'error'); + const question = args.join(' '); + if (this._activeConsensus) return this.terminal.print('❌ A consensus round is already in progress — wait for it to resolve', 'error'); + + const roundId = `consensus-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}`; + const size = this._CONSENSUS_SIZE; + this.terminal.print(`🤝 Starting consensus round ${roundId} (${size} agents, strategy: ${this._CONSENSUS_STRATEGY})`, 'header'); + this.terminal.print(` Question: ${question.substring(0, 80)}`, 'info'); + + this._activeConsensus = { + id: roundId, + question, + strategy: this._CONSENSUS_STRATEGY, + size, + votes: [], + startedAt: new Date().toISOString(), + resolved: false, + result: null + }; + + // Spawn N agents with same question + const tasks = []; + for (let i = 0; i < size; i++) { + tasks.push(`analyze and answer: ${question}. Provide your reasoning then your final answer as "VOTE: <answer>"`); + } + + const timeout = setTimeout(() => { + if (this._activeConsensus && !this._activeConsensus.resolved) { + this._resolveConsensus('timeout'); + } + }, this._CONSENSUS_TIMEOUT); + + try { + const results = await Promise.allSettled(tasks.map(t => this.delegateTask(t))); + + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const voteText = r.status === 'fulfilled' ? (r.value || '') : 'FAILED'; + // Extract VOTE: marker or use full text + let answer = 'no vote'; + const voteMatch = voteText.match(/VOTE:\s*(.+?)(?:\n|$)/i); + if (voteMatch) answer = voteMatch[1].trim(); + else if (voteText.length > 0 && voteText !== 'FAILED') answer = voteText.substring(0, 60).trim(); + + this._activeConsensus.votes.push({ + agentIdx: i, + answer, + raw: voteText.substring(0, 200), + status: r.status === 'fulfilled' ? 'voted' : 'failed' + }); + } + } catch (e) { + this.terminal.print(`⚠️ Consensus error: ${e.message}`, 'warning'); + } finally { + clearTimeout(timeout); + } + + this._resolveConsensus('complete'); + return this._activeConsensus.result; + } + + _resolveConsensus(reason) { + const round = this._activeConsensus; + if (!round || round.resolved) return; + round.resolved = true; + round.resolvedAt = new Date().toISOString(); + round.resolvedBy = reason; + + // Count votes + const voteCounts = {}; + for (const v of round.votes) { + if (v.status === 'failed') continue; + voteCounts[v.answer] = (voteCounts[v.answer] || 0) + 1; + } + + let winner = null; + let maxVotes = 0; + let totalVoted = 0; + for (const [ans, count] of Object.entries(voteCounts)) { + totalVoted += count; + if (count > maxVotes) { + maxVotes = count; + winner = ans; + } + } + + if (this._CONSENSUS_STRATEGY === 'majority') { + const needed = Math.ceil(round.size / 2); + if (maxVotes >= needed) { + round.result = { consensus: 'reached', answer: winner, votes: maxVotes, total: round.size }; + } else { + round.result = { consensus: 'hung', votes: voteCounts, total: round.size }; + } + } else if (this._CONSENSUS_STRATEGY === 'unanimous') { + if (totalVoted === round.size && Object.keys(voteCounts).length === 1) { + round.result = { consensus: 'reached', answer: winner, votes: maxVotes, total: round.size }; + } else { + round.result = { consensus: 'hung', votes: voteCounts, total: round.size }; + } + } else { + // weighted — fallback to majority + const needed = Math.ceil(round.size / 2); + round.result = maxVotes >= needed + ? { consensus: 'reached', answer: winner, votes: maxVotes, total: round.size } + : { consensus: 'hung', votes: voteCounts, total: round.size }; + } + + // Store the round + this._consensusRounds.push(round); + if (!this.terminal.fs.readDir('orchestrator')) this.terminal.fs.mkdir('orchestrator'); + this.terminal.fs.writeFile(`orchestrator/consensus-round-${round.id}.json`, JSON.stringify(round, null, 2)); + + // Print result + const r = round.result; + if (r.consensus === 'reached') { + this.terminal.print(`✅ Consensus reached: "${r.answer}" (${r.votes}/${r.total} votes, ${round.strategy})`, 'success'); + } else { + this.terminal.print(`⚖️ Consensus hung — no clear winner. Votes: ${JSON.stringify(r.votes)}`, 'warning'); + } + + this._activeConsensus = null; + } + + consensusStatus() { + if (this._activeConsensus) { + const r = this._activeConsensus; + this.terminal.print('🤝 Active Consensus Round', 'header'); + this.terminal.print(` ID: ${r.id}`, 'info'); + this.terminal.print(` Question: ${r.question.substring(0, 60)}`, 'info'); + this.terminal.print(` Strategy: ${r.strategy} | Size: ${r.size}`, 'info'); + this.terminal.print(` Votes collected: ${r.votes.length}/${r.size}`, 'info'); + for (const v of r.votes) { + this.terminal.print(` ${v.status === 'voted' ? '✅' : '❌'} Agent ${v.agentIdx}: ${v.answer}`, 'info'); + } + } else { + this.terminal.print('No active consensus round', 'info'); + } + } + + consensusHistory() { + const rounds = this._consensusRounds; + if (rounds.length === 0) { + this.terminal.print('No consensus rounds in memory — checking VFS...', 'info'); + // Try VFS + const dir = this.terminal.fs.readDir('orchestrator/'); + if (dir) { + const files = dir.filter(e => e.name && e.name.startsWith('consensus-round-')); + if (files.length > 0) { + this.terminal.print(`📜 ${files.length} round(s) in VFS`, 'header'); + for (const f of files.slice(-5)) { + const data = this.terminal.fs.fsReadJSON(`orchestrator/${f.name}`); + if (data) { + const r = data.result; + const status = r?.consensus === 'reached' ? '✅' : '⚖️'; + this.terminal.print(` ${status} ${data.id}: "${data.question.substring(0, 40)}" → ${r?.answer || 'no result'}`, 'info'); + } + } + return; + } + } + this.terminal.print('No past consensus rounds found', 'info'); + return; + } + this.terminal.print('📜 Consensus History', 'header'); + for (const round of rounds.slice(-10)) { + const r = round.result; + const status = r?.consensus === 'reached' ? '✅' : '⚖️'; + this.terminal.print(` ${status} ${round.id}: "${round.question.substring(0, 40)}"`, 'info'); + this.terminal.print(` Strategy: ${round.strategy} | Votes: ${round.votes.length}/${round.size} | Result: ${r?.answer || 'hung'}`, 'text-muted'); + } + } + + // ─── Autonomous CI/CD Pipeline ─── + + async runPipeline(args) { + if (!args || args.length === 0) return this.terminal.print('Usage: pipeline <command> [options]', 'error'); + const cmd = args[0].toLowerCase(); + const opts = args.slice(1); + + const run = { + id: `pipeline-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}`, + command: cmd, + startedAt: new Date().toISOString(), + stages: [], + status: 'running' + }; + + try { + if (cmd === 'test') { + await this._pipelineStage(run, 'agent-health', () => this._testAgentHealth()); + await this._pipelineStage(run, 'a2a-mailbox', () => this._testA2AMailbox()); + await this._pipelineStage(run, 'module-check', () => this._testModules()); + run.status = 'passed'; + this.terminal.print('✅ All pipeline tests passed', 'success'); + } else if (cmd === 'deploy') { + const deployTask = opts.join(' ') || 'run all checks'; + await this._pipelineStage(run, 'test', () => this.runPipeline(['test'])); + if (run.stages.find(s => s.name === 'test' && s.status === 'failed')) { + if (this._PIPELINE_CONFIG.autoFix) { + await this._pipelineStage(run, 'auto-fix', () => this._autoFix()); + } else { + run.status = 'blocked'; + this.terminal.print('❌ Pipeline blocked — tests failed. Set pipeline-auto-fix on to auto-repair', 'error'); + return; + } + } + await this._pipelineStage(run, 'verify', () => this._pipelineVerify()); + run.status = 'passed'; + this.terminal.print('✅ Pipeline deploy passed all stages', 'success'); + } else if (cmd === 'verify') { + await this._pipelineStage(run, 'verify', () => this._pipelineVerify()); + run.status = 'passed'; + } else if (cmd === 'history') { + this._pipelineHistory.slice(-10).forEach(p => { + const icon = p.status === 'passed' ? '✅' : (p.status === 'blocked' ? '🚫' : '❌'); + this.terminal.print(` ${icon} ${p.id}: ${p.command} (${p.startedAt.slice(11, 19)})`, 'info'); + p.stages.forEach(s => { + this.terminal.print(` ${s.status === 'passed' ? '✅' : '❌'} ${s.name} (${(s.duration || 0).toFixed(0)}ms)`, 'text-muted'); + }); + }); + if (this._pipelineHistory.length === 0) this.terminal.print('No pipeline runs yet', 'info'); + return; + } else if (cmd === 'config') { + this.terminal.print('Pipeline Config:', 'header'); + this.terminal.print(` auto-fix: ${this._PIPELINE_CONFIG.autoFix ? 'on' : 'off'}`, 'info'); + this.terminal.print(` stage-timeout: ${this._PIPELINE_CONFIG.stageTimeout}ms`, 'info'); + return; + } else { + return this.terminal.print('Unknown pipeline command. Usage: test, deploy, verify, history, config', 'error'); + } + } catch (e) { + run.status = 'failed'; + run.error = e.message; + this.terminal.print(`❌ Pipeline failed: ${e.message}`, 'error'); + } + + run.completedAt = new Date().toISOString(); + this._pipelineHistory.push(run); + if (!this.terminal.fs.readDir('orchestrator')) this.terminal.fs.mkdir('orchestrator'); + this.terminal.fs.writeFile(`orchestrator/pipeline-${run.id}.json`, JSON.stringify(run, null, 2)); + return run; + } + + async _pipelineStage(run, name, fn) { + const stage = { name, status: 'running', startedAt: Date.now() }; + run.stages.push(stage); + this.terminal.print(`⏳ Pipeline stage: ${name}...`, 'info'); + + try { + const timeout = this._PIPELINE_CONFIG.stageTimeout; + const result = await Promise.race([ + fn(), + new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timed out (${timeout}ms)`)), timeout)) + ]); + stage.status = 'passed'; + stage.duration = Date.now() - stage.startedAt; + stage.result = typeof result === 'string' ? result.substring(0, 200) : 'ok'; + this.terminal.print(`✅ Stage ${name} passed (${stage.duration}ms)`, 'success'); + return result; + } catch (e) { + stage.status = 'failed'; + stage.duration = Date.now() - stage.startedAt; + stage.error = e.message; + this.terminal.print(`❌ Stage ${name} failed: ${e.message}`, 'error'); + throw e; + } + } + + _testAgentHealth() { + const total = Object.keys(this._agents).length; + const busy = Object.values(this._agents).filter(a => a.status === 'busy').length; + const failed = Object.values(this._agents).filter(a => a.status === 'failed').length; + + const issues = []; + if (failed > 0) issues.push(`${failed} agent(s) in failed state`); + if (total > 10) issues.push(`high agent count: ${total}`); + if (issues.length > 0) throw new Error(issues.join('; ')); + this.terminal.print(` Agents: ${total} (${busy} busy, ${failed} failed) — healthy`, 'info'); + return `agents_ok:${total}:${busy}:${failed}`; + } + + _testA2AMailbox() { + // Verify A2A polling is running + if (!this._A2A_POLLING) throw new Error('A2A polling is disabled'); + if (!this._a2aPollTimer) throw new Error('A2A polling timer not running'); + // Verify each agent has a VFS directory + for (const [id, agent] of Object.entries(this._agents)) { + const dir = this.terminal.fs.readDir(`/agents/${id}/`); + if (!dir) throw new Error(`Agent ${id} missing VFS directory`); + } + this.terminal.print(` A2A: ${Object.keys(this._agents).length} agent mailboxes OK`, 'info'); + return `a2a_ok:${Object.keys(this._agents).length}`; + } + + _testModules() { + const mm = this.terminal.moduleManager; + if (!mm || !mm.modules) throw new Error('ModuleManager not available'); + const required = ['OrchestratorModule', 'AgentRegistryModule', 'LeaseModule', 'MemoryGraphModule']; + const missing = required.filter(n => !mm.modules[n]); + if (missing.length > 0) throw new Error(`Missing modules: ${missing.join(', ')}`); + // Check orchestrator is in registry + const registry = mm.modules['AgentRegistryModule']; + if (registry && !registry.agents?.find(a => a.id === 'orchestrator-0')) { + this.terminal.print(' ⚠️ orchestrator-0 not in registry (will register on next init)', 'warning'); + } + this.terminal.print(` Modules: ${required.length}/${required.length} required loaded`, 'info'); + return `modules_ok:${required.length}`; + } + + _pipelineVerify() { + // Verify running state — internal checks + const issues = []; + + // Check orchestrator status + const total = Object.keys(this._agents).length; + const failed = Object.values(this._agents).filter(a => a.status === 'failed').length; + if (failed > 0) issues.push(`${failed} failed agents`); + if (total + failed > 0 && failed === total) issues.push('all agents failed'); + + // Verify last parallel result exists + const lastResult = this.terminal.fs.fsReadJSON('orchestrator/parallel-results.json'); + if (!lastResult) this.terminal.print(' ℹ️ No parallel results yet (first deploy)', 'info'); + + // Verify consensus history + const consensusDir = this.terminal.fs.readDir('orchestrator/'); + if (consensusDir) { + const rounds = consensusDir.filter(e => e.name && e.name.startsWith('consensus-round-')); + this.terminal.print(` VFS: ${rounds.length} consensus rounds archived`, 'info'); + } + + // Check memory graph + const memgraph = this.terminal.moduleManager?.modules['MemoryGraphModule']; + if (memgraph && typeof memgraph.getStats === 'function') { + const stats = memgraph.getStats(); + this.terminal.print(` Memory graph: ${stats?.edges || 0} edges, ${stats?.nodes || 0} nodes`, 'info'); + } + + if (issues.length > 0) throw new Error(issues.join('; ')); + return `verify_ok:${total} agents, ${issues.length} issues`; + } + + async _autoFix() { + this.terminal.print('🔄 Auto-fix running...', 'warning'); + let fixed = 0; + + // Fix 1: Purge failed agents + const failedIds = Object.entries(this._agents) + .filter(([, a]) => a.status === 'failed') + .map(([id]) => id); + for (const id of failedIds) { + this.killAgent(id); + fixed++; + } + if (fixed > 0) this.terminal.print(` Purged ${fixed} failed agent(s)`, 'warning'); + + // Fix 2: Restart A2A poller if stopped + if (!this._A2A_POLLING) { + this._A2A_POLLING = true; + this._a2aPollTimer = setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); + this.terminal.print(' Restarted A2A polling', 'warning'); + fixed++; + } + + // Fix 3: Ensure VFS orchestrator dir exists + if (!this.terminal.fs.readDir('orchestrator')) { + this.terminal.fs.mkdir('orchestrator'); + fixed++; + } + + if (fixed === 0) this.terminal.print(' No auto-fix actions needed', 'info'); + return `fixed ${fixed} issues`; + } + + // ─── Task Delegation ─── + + async delegateTask(task, opts = {}) { + if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); + + // Parallel fan-out: decompose and run sub-tasks concurrently + if (opts.parallel) { + const parts = this._decomposeGoal(task); + if (parts.length > 1) { + this.terminal.print('🧩 Decomposed into ' + parts.length + ' sub-tasks — running in parallel', 'info'); + + // Auto-scale: ensure enough idle agents for N sub-tasks + const idleCount = Object.values(this._agents).filter(a => a.status === 'idle').length; + const totalSlots = this._SPAWN_LIMIT; + const usedSlots = Object.keys(this._agents).length; + const availableSlots = totalSlots - usedSlots; + const needed = parts.length - idleCount; + if (needed > 0 && availableSlots > 0) { + const toSpawn = Math.min(needed, availableSlots); + this.terminal.print('📈 Auto-scaling: spawning ' + toSpawn + ' pre-warm agent(s)', 'info'); + for (let i = 0; i < toSpawn; i++) { + this.spawnAgent('worker', ['list_files', 'read_file', 'write_file', 'web_search']); + } + } + + const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); + const successes = results.filter(r => r.status === 'fulfilled').length; + const failures = results.filter(r => r.status === 'rejected').length; + + // Result synthesis: collect outbox results from each sub-task's agent + const summaries = []; + for (let i = 0; i < parts.length; i++) { + const sub = parts[i]; + const r = results[i]; + if (r.status === 'fulfilled') { + // Find the agent that handled this sub-task + const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); + if (agent) { + const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); + const snippet = outbox?.result ? outbox.result.substring(0, 200) : 'completed (no outbox)'; + summaries.push({ task: sub.goal, agent: agent.id, status: 'ok', result: snippet }); + } else { + summaries.push({ task: sub.goal, status: 'ok', result: 'completed (agent recycled)' }); + } + } else { + summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); + } + } + + // Store merged result to VFS for persistence + const merged = { + type: 'parallel_result', + parent_task: task, + timestamp: new Date().toISOString(), + total: parts.length, + successes, + failures, + summaries + }; + if (!this.terminal.fs.readDir('orchestrator')) this.terminal.fs.mkdir('orchestrator'); + this.terminal.fs.writeFile('orchestrator/parallel-results.json', JSON.stringify(merged, null, 2)); + + // Print synthesis to terminal + this.terminal.print('📊 Parallel Batch Results:', 'header'); + for (const s of summaries) { + const icon = s.status === 'ok' ? '✅' : '❌'; + const agentTag = s.agent ? ' [' + s.agent.substring(0, 20) + ']' : ''; + const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; + this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); + } + this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); + return merged; + } + // Single sub-task — fall through to normal path + } + + // Find an idle agent and immediately stamp it busy (atomic claim) + let agent = Object.values(this._agents).find(a => a.status === 'idle'); + if (agent) { + agent.status = 'busy'; + } + + // Auto-spawn with role-derived capabilities + if (!agent) { + if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { + // Try finding any non-failed agent to reuse + const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); + if (reuse) { + reuse.status = 'busy'; + agent = reuse; + } + } + if (!agent) { + const role = this._deriveRole(task); + const caps = this._deriveCapabilities(task); + agent = this.spawnAgent(role, caps); + if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); + agent.status = 'busy'; // claim immediately after spawn + } + } + if (!agent) return this.terminal.print('❌ No available agents', 'error'); + + // A2A Contract handshake + const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; + let contractValid = true; + if (registry) { + const result = registry.validateTask(agent.role, task); + if (!result.valid) { + this.terminal.print(`⚠️ A2A validation: ${result.reason}`, 'warning'); + contractValid = false; + } else if (result.reason === 'matched') { + this.terminal.print(`✅ A2A contract matched for role '${agent.role}'`, 'success'); + } + } + + // Create a lease + const leaseModule = this.terminal.moduleManager.modules['LeaseModule']; + const lease = leaseModule ? leaseModule.createLease(['lease-create', agent.id, '60', task]) : null; + + // Mark agent as busy + agent.status = 'busy'; + agent.currentTask = task; + agent.leaseId = lease?.id; + agent.lastHeartbeat = Date.now(); + + // Send A2A request via inbox + this.sendA2AMessage([agent.id, JSON.stringify({ + type: 'request', + payload: { task, from: 'orchestrator-0', leaseId: lease?.id } + })]); + + this.terminal.print(`🎯 Delegated \"${task.substring(0, 60)}\" to ${agent.id} (lease: ${lease?.id || 'none'})`, 'info'); + + // Execute via AI assistant + try { + const aiModule = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (aiModule && typeof aiModule.chat === 'function') { + const result = await aiModule.chat([`[DELEGATED] ${task}`]); + agent.status = 'completed'; + + // Write result to outbox + this.terminal.fs.writeFile(`agents/${agent.id}/outbox.json`, JSON.stringify({ + result: typeof result === 'string' ? result : (result?.content || 'completed'), + status: 'completed', + timestamp: new Date().toISOString() + })); + + // Record in memory graph + const memgraph = this.terminal.moduleManager.modules['MemoryGraphModule']; + if (memgraph) { + memgraph.recordTask(task, typeof result === 'string' ? result : (result?.content || ''), agent.id); + memgraph.recordFileAccess(`/agents/${agent.id}/outbox.json`); + } + + this.terminal.print(`✅ ${agent.id} completed task`, 'success'); + + // Return result for callers that await delegateTask + const outboxResult = typeof result === 'string' ? result : (result?.content || 'completed'); + + // Check restart policy — if always, set back to idle for next task + if (agent.restartPolicy === 'always') { + agent.lastTask = agent.currentTask; + agent.status = 'idle'; + agent.currentTask = null; + agent.lastHeartbeat = Date.now(); + } + } else { + this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); + } + } catch (e) { + this._handleAgentFailure(agent.id, e.message); + } + + // Return to idle pool (unless failed/restarting) + if (agent.status === 'failed') { + // handled by _handleAgentFailure + throw new Error(agent.lastError || 'agent failed'); + } else if (agent.status !== 'busy') { + agent.lastTask = agent.currentTask; + agent.currentTask = null; + agent.status = 'idle'; + agent.lastHeartbeat = Date.now(); + } + return outboxResult || 'completed'; + } + + // ─── Monitoring ─── + + _checkHeartbeats() { + const now = Date.now(); + Object.entries(this._agents).forEach(([id, agent]) => { + if (agent.status === 'busy' && now - agent.lastHeartbeat > this._HEARTBEAT_TIMEOUT) { + this.terminal.print(`⚠️ Agent '${id}' unresponsive (${Math.round((now - agent.lastHeartbeat) / 1000)}s since heartbeat)`, 'warning'); + } + }); + } + + _sweepIdle() { + const now = Date.now(); + Object.entries(this._agents).forEach(([id, agent]) => { + if (agent.status === 'idle' && now - agent.lastHeartbeat > this._IDLE_TIMEOUT) { + this.terminal.print(`💤 Agent '${id}' idle for >${this._IDLE_TIMEOUT/1000}s — auto-sleeping`, 'info'); + this.sleepAgent(id); + } + }); + } + + purgeDeadAgents() { + const now = Date.now(); + let count = 0; + Object.entries(this._agents).forEach(([id, agent]) => { + if (now - agent.lastHeartbeat > this._DEAD_PURGE_TIMEOUT) { + this.killAgent(id); + count++; + } + }); + this.terminal.print(`🧹 Purged ${count} dead agents`, count > 0 ? 'warning' : 'info'); + } + + // ─── Status & List ─── + + showStatus() { + const total = Object.keys(this._agents).length; + const busy = Object.values(this._agents).filter(a => a.status === 'busy').length; + const idle = Object.values(this._agents).filter(a => a.status === 'idle').length; + const sleeping = Object.values(this._agents).filter(a => a.status === 'sleeping').length; + const failed = Object.values(this._agents).filter(a => a.status === 'failed' || a.status === 'restarting').length; + const a2aTotal = Object.values(this._a2aMessages).reduce((s, msgs) => s + msgs.length, 0); + const a2aPending = Object.values(this._a2aMessages).reduce((s, msgs) => s + msgs.filter(m => !m._processed).length, 0); + + this.terminal.print('🧠 Orchestrator Status', 'header'); + this.terminal.print(` Agents: ${total} (${busy} busy, ${idle} idle, ${sleeping} sleeping, ${failed} failed)`, 'info'); + this.terminal.print(` Spawn limit: ${this._SPAWN_LIMIT} (strategy: ${this._SPAWN_STRATEGY})`, 'info'); + this.terminal.print(` Default restart: ${this._RESTART_POLICY}`, 'info'); + this.terminal.print(` A2A Messages: ${a2aTotal} (${a2aPending} pending)`, 'info'); + Object.values(this._agents).forEach(a => { + const task = a.currentTask ? ` (task: ${a.currentTask.substring(0, 40)})` : ''; + const backoff = a.nextRestartAt && a.nextRestartAt > Date.now() ? ` [restart in ${Math.round((a.nextRestartAt - Date.now()) / 1000)}s]` : ''; + this.terminal.print(` ${a.id} [${a.role}] — ${a.status}${task}${backoff}`, 'info'); + }); + } + + listAgents() { + const all = Object.values(this._agents); + if (all.length === 0) return this.terminal.print('No agents spawned', 'info'); + this.terminal.print(`📋 Agents (${all.length}):`, 'header'); + all.forEach(a => { + const caps = (a.capabilities || []).join(', '); + this.terminal.print(` ${a.id} [${a.role}] — ${a.status}`, 'info'); + this.terminal.print(` Caps: ${caps}`, 'text-muted'); + this.terminal.print(` Policy: ${a.restartPolicy || this._RESTART_POLICY} | Failures: ${a.failureCount || 0} | Restarts: ${a.restartCount || 0}`, 'text-muted'); + if (a.currentTask) this.terminal.print(` Task: ${a.currentTask.substring(0, 60)}`, 'text-muted'); + }); + } +} + +// Required for module system +if (typeof window !== 'undefined') { + window.OrchestratorModule = OrchestratorModule; +} + +/* === module-sandbox.js === */ +// module-sandbox.js — Sandboxed execution pool using WebWorkers +// Commands: sandbox-status, sandbox-config, sandbox-run +class SandboxModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.workers = []; + this.MAX_WORKERS = 4; + this.MIN_WORKERS = 1; + this.queue = []; + this.activeTasks = {}; + this._nextTaskId = 0; + this._totalRuns = 0; + this._totalErrors = 0; + this._totalDuration = 0; + this._fallbackMode = false; // true if WebWorkers unavailable + this._workerReady = {}; + } + + getHelpInfo() { + return { + 'sandbox-status': 'sandbox-status — Show sandbox pool health and task metrics', + 'sandbox-config': 'sandbox-config <key> <value> — Tune: max-workers, min-workers, pool-timeout', + 'sandbox-run': 'sandbox-run <task> — Run a task in the sandbox (automatic routing)', + }; + } + + registerCommands() { + this.commands['sandbox-status'] = () => this.showStatus(); + this.commands['sandbox-config'] = (args) => this.setConfig(args); + this.commands['sandbox-run'] = (args) => this.runInSandbox(args.join(' ')); + } + + init(terminal) { + this.terminal = terminal; + this.workerUrl = '/spaces/newos4/modules/core/sandbox-worker.js?v=2026-06-05-v4.4.0'; + + // Detect Worker availability + if (typeof Worker === 'undefined') { + this._fallbackMode = true; + this.terminal.print('⚠️ WebWorkers not available — sandbox using inline fallback', 'warning'); + } else { + this._initPool(); + // Deferred check: workers may fail asynchronously (headless browsers) + // If no worker proves alive within 1.5s, switch to inline fallback + this._workerCheckTimer = setTimeout(() => { + if (!this._fallbackMode && this.workers.length > 0) { + const alive = this.workers.some(w => !w._failed && this._workerReady[w._sandboxId]); + if (!alive) { + this._fallbackMode = true; + this.workers.forEach(w => w.terminate()); + this.workers = []; + this.terminal.print('⚠️ WebWorkers unavailable — switching to inline fallback', 'warning'); + } + } + }, 1500); + } + + this.terminal.print('🧪 Sandbox module loaded', 'info'); + } + + _initPool() { + // Start with MIN_WORKERS, grow on demand up to MAX_WORKERS + for (let i = 0; i < this.MIN_WORKERS; i++) { + const w = this._spawnWorker(); + if (!w) break; // first failure kills the pool + } + } + + _spawnWorker() { + if (this.workers.length >= this.MAX_WORKERS) return null; + if (this._fallbackMode) return null; + try { + const worker = new Worker(this.workerUrl); + const id = `w${this.workers.length + 1}`; + worker._sandboxId = id; + worker._busy = false; + worker._failed = false; + + // Detect immediate worker failure (headless browsers, restricted contexts) + const failTimer = setTimeout(() => { + if (!worker._failed) { + // Worker survived 2 seconds - likely functional + } + }, 2000); + + worker.onmessage = (e) => { + clearTimeout(failTimer); + if (e.data && e.data.type === 'ready') { + this._workerReady[id] = true; + } else { + this._handleResult(e.data, id); + } + }; + worker.onerror = (e) => { + clearTimeout(failTimer); + worker._failed = true; + worker.terminate(); + console.warn(`[Sandbox] Worker ${id} failed (headless env) — switching to inline`); + this._fallbackMode = true; + this.workers.forEach(w => w.terminate()); + this.workers = []; + this.terminal.print('⚠️ WebWorkers unavailable in this environment — using inline fallback', 'warning'); + }; + + this.workers.push(worker); + return worker; + } catch (e) { + console.warn('[Sandbox] Failed to spawn worker:', e); + this._fallbackMode = true; + return null; + } + } + + _replaceWorker(id) { + // In headless environments, workers don't work — don't retry + if (this._fallbackMode) return; + const idx = this.workers.findIndex(w => w._sandboxId === id); + if (idx !== -1) { + this.workers[idx].terminate(); + this.workers.splice(idx, 1); + delete this._workerReady[id]; + } + } + + _getIdleWorker() { + for (const w of this.workers) { + if (!w._busy) return w; + } + // Grow pool if under max + if (this.workers.length < this.MAX_WORKERS) { + const w = this._spawnWorker(); + if (w) return w; + } + return null; + } + + async runInSandbox(task, capabilities) { + if (!task) return this.terminal.print('Usage: sandbox-run <task>', 'error'); + + // Check if any worker is actually alive and ready + const hasAliveWorker = this.workers.some(w => + !w._failed && this._workerReady[w._sandboxId] + ); + const useInline = this._fallbackMode || this.workers.length === 0 || !hasAliveWorker; + + if (useInline) { + return this._runInline(task, capabilities); + } + + const worker = this._getIdleWorker(); + if (!worker) { + // Queue it + return new Promise((resolve) => { + this.queue.push({ task, capabilities, resolve }); + this.terminal.print(`⏳ Task queued (${this.queue.length} waiting)`, 'info'); + this._drainQueue(); + }); + } + + return this._executeOnWorker(worker, task, capabilities); + } + + async _executeOnWorker(worker, task, capabilities) { + const taskId = `task-${++this._nextTaskId}`; + worker._busy = true; + + const startTime = performance.now(); + + return new Promise((resolve) => { + const handler = (e) => { + if (e.data && e.data.taskId === taskId) { + worker.removeEventListener('message', handler); + worker._busy = false; + + const duration = ((performance.now() - startTime) / 1000).toFixed(2); + this._totalRuns++; + this._totalDuration += parseFloat(duration); + + if (e.data.error) { + this._totalErrors++; + this.terminal.print(`❌ Sandbox task failed: ${e.data.error}`, 'error'); + resolve({ error: e.data.error, duration }); + } else { + this.terminal.print(`✅ Sandbox task complete (${duration}s, ${e.data.toolCalls || 0} tools)`, 'success'); + resolve({ result: e.data.result, duration, toolCalls: e.data.toolCalls || 0 }); + } + + this._drainQueue(); + } + }; + + worker.addEventListener('message', handler); + + // Get VFS snapshot from terminal + let vfsSnapshot = null; + try { + if (this.terminal && this.terminal.fs && this.terminal.fs.root) { + vfsSnapshot = JSON.stringify({ + root: this.terminal.fs.root, + currentPath: this.terminal.fs.currentPath || [] + }); + } + } catch (e) { + console.warn('[Sandbox] Could not serialize VFS:', e.message); + } + + const caps = capabilities || ['list_files', 'read_file']; + const allowedCommands = ['ls', 'cat', 'echo', 'find', 'grep', 'help', 'status', + 'touch', 'mkdir', 'rm', 'cp', 'mv']; + + worker.postMessage({ + taskId, + task, + capabilities: caps, + vfsSnapshot, + allowedCommands, + apiProxy: window.location.origin + '/api' + }); + + // Timeout after 30 seconds + setTimeout(() => { + if (worker._busy) { + worker._busy = false; + this._totalErrors++; + this.terminal.print(`⏰ Sandbox task ${taskId} timed out after 30s`, 'error'); + resolve({ error: 'Timeout (30s)', duration: 30 }); + this._drainQueue(); + } + }, 30000); + }); + } + + _handleResult(data, workerId) { + // Handled via promise in _executeOnWorker + // This is the fallback handler for tasks not tracked by promise + if (data.error) { + this.terminal.print(`[Sandbox ${workerId}] Error: ${data.error}`, 'error'); + } + } + + _drainQueue() { + if (this.queue.length === 0) return; + const next = this.queue.shift(); + this.runInSandbox(next.task, next.capabilities).then(result => { + next.resolve(result); + }); + } + + _runInline(task, capabilities) { + // Fallback when WebWorkers unavailable — runs in main thread + const startTime = performance.now(); + const parts = task.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + let result = ''; + let toolCalls = 0; + + const fs = this.terminal ? this.terminal.fs : null; + + if (cmd === 'ls' && fs) { + const path = parts[1] || '/'; + const entries = fs.ls(path); + if (!entries) return this.terminal.print(`ls: ${path}: No such directory`, 'error'); + result = entries.map(e => `${e.type === 'dir' ? '[dir]' : '[file]'} ${e.name}`).join('\\n'); + toolCalls++; + } else if ((cmd === 'cat' || cmd === 'read') && fs) { + const path = parts[1] || ''; + const content = fs.readFile(path); + if (content === null) return this.terminal.print(`cat: ${path}: No such file`, 'error'); + result = typeof content === 'string' ? content.substring(0, 8000) : String(content); + toolCalls++; + } else if (cmd === 'find' && fs) { + const q = parts.slice(1).join(' ') || ''; + // Walk VFS + const results = []; + const walk = (p) => { + const entries = fs.ls(p); + if (!entries) return; + for (const e of entries) { + const full = p === '/' ? '/' + e.name : p + '/' + e.name; + if (e.type === 'file' && (!q || full.toLowerCase().includes(q.toLowerCase()))) { + results.push(full); + } + if (e.type === 'dir') walk(full); + } + }; + walk('/'); + result = results.join('\\n') || '(empty)'; + toolCalls++; + } else if (cmd === 'status') { + let f = 0, d = 0; + const walk = (p) => { + const entries = fs ? fs.ls(p) : []; + if (!entries) return; + for (const e of entries) { + if (e.type === 'file') f++; + else { d++; walk(p === '/' ? '/' + e.name : p + '/' + e.name); } + } + }; + walk('/'); + result = `Files: ${f}, Dirs: ${d}`; + toolCalls++; + } else { + result = `Task: "${task}" (inline fallback)`; + } + + const duration = ((performance.now() - startTime) / 1000).toFixed(2); + this._totalRuns++; + this._totalDuration += parseFloat(duration); + this.terminal.print(result, 'info'); + return { result, duration, toolCalls }; + } + + // ─── Config ─── + + setConfig(args) { + if (args.length < 2) return this.terminal.print('Usage: sandbox-config <key> <value>', 'error'); + const key = args[0]; + const val = args[1]; + + const configMap = { + 'max-workers': () => { + const n = parseInt(val); + if (isNaN(n) || n < 1 || n > 8) return 'Must be 1-8'; + this.MAX_WORKERS = n; + // Scale down if needed + while (this.workers.length > n) { + const w = this.workers.pop(); + w.terminate(); + } + // Scale up + for (let i = this.workers.length; i < Math.min(n, this.MIN_WORKERS); i++) { + this._spawnWorker(); + } + return `max-workers set to ${n}`; + }, + 'min-workers': () => { + const n = parseInt(val); + if (isNaN(n) || n < 0 || n > this.MAX_WORKERS) return `Must be 0-${this.MAX_WORKERS}`; + this.MIN_WORKERS = n; + while (this.workers.length < n) this._spawnWorker(); + return `min-workers set to ${n}`; + }, + }; + + if (!configMap[key]) { + return this.terminal.print(`Unknown: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); + } + const msg = configMap[key](); + this.terminal.print(`✅ ${msg}`, 'success'); + } + + // ─── Status ─── + + showStatus() { + const busy = this.workers.filter(w => w._busy).length; + const idle = this.workers.length - busy; + const avgDuration = this._totalRuns > 0 ? (this._totalDuration / this._totalRuns).toFixed(2) : 'N/A'; + + this.terminal.print('🧪 Sandbox Pool', 'header'); + this.terminal.print(` Workers: ${this.workers.length}/${this.MAX_WORKERS} (${busy} busy, ${idle} idle)`, 'info'); + this.terminal.print(` Mode: ${this._fallbackMode ? '⚠️ Inline fallback' : '✅ WebWorker'}`, 'info'); + this.terminal.print(` Queue: ${this.queue.length} pending`, 'info'); + this.terminal.print(` Total runs: ${this._totalRuns} (errors: ${this._totalErrors})`, 'info'); + this.terminal.print(` Avg duration: ${avgDuration}s`, 'info'); + } +} + +// Required for module system +if (typeof window !== 'undefined') { + window.SandboxModule = SandboxModule; +} + +/* === module-memory-graph.js === */ +// module-memory-graph.js — Cross-session persistent memory graph +// Nodes: files, concepts, tasks, patterns, users +// Edges: relationships with weight, last_used +// Commands: memgraph-status, memgraph-search, memgraph-stats, memgraph-export, memgraph-prune +class MemoryGraphModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._nodes = {}; + this._edges = []; + this._indexPath = '/.memory_graph/'; + this._debounceTimer = null; + this._initialized = false; + this._totalNodeAccesses = 0; + } + + getHelpInfo() { + return { + 'memgraph-status': 'memgraph-status — Show node/edge counts, last access', + 'memgraph-search': 'memgraph-search <query> — Find nodes by label or type', + 'memgraph-stats': 'memgraph-stats — Most connected nodes, strongest edges, cluster info', + 'memgraph-export': 'memgraph-export — Export graph as JSON', + 'memgraph-prune': 'memgraph-prune — Remove stale low-weight edges (>30d, <3 accesses)', + }; + } + + registerCommands() { + this.commands['memgraph-status'] = () => this.showStatus(); + this.commands['memgraph-search'] = (args) => this.search(args.join(' ')); + this.commands['memgraph-stats'] = () => this.showStats(); + this.commands['memgraph-export'] = () => this.exportGraph(); + this.commands['memgraph-prune'] = () => this.prune(); + } + + init(terminal) { + this.terminal = terminal; + this._loadFromVFS(); + this._initialized = true; + this.terminal.print('🧠 Memory Graph loaded — cross-session context active', 'info'); + // Log startup for graph + this.addNode('session', `session-${Date.now()}`, `Session ${new Date().toLocaleDateString()}`); + } + + // ─── Node API ─── + + addNode(type, id, label, metadata = {}) { + const key = `${type}:${id}`; + if (!this._nodes[key]) { + this._nodes[key] = { + id: key, + type, + label: label || id, + metadata: metadata || {}, + created: new Date().toISOString(), + updated: new Date().toISOString(), + access_count: 1 + }; + } else { + this._nodes[key].updated = new Date().toISOString(); + this._nodes[key].access_count++; + if (metadata && Object.keys(metadata).length > 0) { + Object.assign(this._nodes[key].metadata, metadata); + } + } + this._totalNodeAccesses++; + this._debounceSave(); + return key; + } + + getNode(type, id) { + return this._nodes[`${type}:${id}`] || null; + } + + findNodes(query) { + const q = query.toLowerCase(); + return Object.values(this._nodes).filter(n => + n.label.toLowerCase().includes(q) || + n.type.toLowerCase().includes(q) || + (n.id && n.id.toLowerCase().includes(q)) + ); + } + + // ─── Edge API ─── + + addEdge(sourceKey, targetKey, type, weight = 1) { + // Check if edge already exists + let edge = this._edges.find(e => + e.source === sourceKey && e.target === targetKey && e.type === type + ); + if (edge) { + edge.weight = (edge.weight + weight) / 2; + edge.last_used = new Date().toISOString(); + edge.use_count++; + } else { + this._edges.push({ + source: sourceKey, + target: targetKey, + type, + weight, + created: new Date().toISOString(), + last_used: new Date().toISOString(), + use_count: 1 + }); + } + this._debounceSave(); + } + + // ─── Event-based Learning ─── + + recordFileAccess(path) { + const name = path.split('/').pop() || path; + const nodeKey = this.addNode('file', path, name); + const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; + this.addEdge(sessionKey, nodeKey, 'accessed', 0.5); + + // Extract file extension edge + const ext = path.split('.').pop(); + if (ext && ext.length < 10) { + this.addNode('type', ext, `.${ext} files`); + this.addEdge(nodeKey, `type:${ext}`, 'has_type', 0.3); + } + } + + recordToolCall(toolName, success) { + const nodeKey = this.addNode('tool', toolName, toolName); + const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; + this.addEdge(sessionKey, nodeKey, success ? 'used' : 'failed', success ? 0.5 : 0.8); + } + + recordConcept(concept, filePath) { + const conceptKey = this.addNode('concept', concept, concept); + const fileKey = this.addNode('file', filePath, filePath.split('/').pop()); + this.addEdge(conceptKey, fileKey, 'maps_to', 0.7); + this.addEdge(fileKey, conceptKey, 'tagged_as', 0.7); + } + + recordTask(task, result, agentId) { + const taskId = `task-${Date.now()}`; + const taskKey = this.addNode('task', taskId, task.substring(0, 60), { + full: task, + result: result?.substring(0, 100), + agent: agentId + }); + const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; + this.addEdge(sessionKey, taskKey, 'executed', 0.6); + + // Extract keywords as concepts + const words = task.toLowerCase().split(/\s+/).filter(w => w.length > 3); + for (const word of [...new Set(words)].slice(0, 5)) { + this.addNode('concept', word, word); + this.addEdge(taskKey, `concept:${word}`, 'involves', 0.4); + } + + // Link to tools used (if any found in task) + const knownTools = ['list_files', 'read_file', 'write_file', 'web_search', 'web_fetch', + 'edit_module', 'view_module', 'run_command', 'orchestrate']; + for (const tool of knownTools) { + if (task.toLowerCase().includes(tool.replace('_', ' '))) { + this.recordToolCall(tool, true); + } + } + } + + // ─── Context Injection ─── + + getContextSummary() { + // Get top active nodes from recent session + const now = Date.now(); + const today = `session:session-${Math.floor(now / 86400000)}`; + const yesterday = `session:session-${Math.floor(now / 86400000) - 1}`; + + const lines = []; + const totalNodes = Object.keys(this._nodes).length; + const totalEdges = this._edges.length; + + lines.push(`Memory Graph: ${totalNodes} nodes, ${totalEdges} edges`); + + // Recent task context + const recentTasks = Object.values(this._nodes) + .filter(n => n.type === 'task') + .sort((a, b) => new Date(b.updated) - new Date(a.updated)) + .slice(0, 3); + + if (recentTasks.length > 0) { + lines.push('Recent tasks:'); + for (const t of recentTasks) { + lines.push(` - ${t.label.substring(0, 50)}`); + } + } + + // Most accessed files + const topFiles = Object.values(this._nodes) + .filter(n => n.type === 'file') + .sort((a, b) => b.access_count - a.access_count) + .slice(0, 3); + + if (topFiles.length > 0) { + lines.push('Most accessed files:'); + for (const f of topFiles) { + lines.push(` - ${f.label} (${f.access_count}x)`); + } + } + + return lines.join('\\n'); + } + + getContextForPrompt() { + const summary = this.getContextSummary(); + if (!summary) return ''; + return `[Memory Graph Context]\\n${summary}\\n`; + } + + // ─── Commands ─── + + showStatus() { + const types = {}; + for (const n of Object.values(this._nodes)) { + types[n.type] = (types[n.type] || 0) + 1; + } + const typeStr = Object.entries(types) + .sort((a, b) => b[1] - a[1]) + .map(([t, c]) => `${t}: ${c}`) + .join(', '); + + this.terminal.print('🧠 Memory Graph', 'header'); + this.terminal.print(` Nodes: ${Object.keys(this._nodes).length} (${typeStr})`, 'info'); + this.terminal.print(` Edges: ${this._edges.length}`, 'info'); + this.terminal.print(` Total accesses: ${this._totalNodeAccesses}`, 'info'); + this.terminal.print(` Path: ${this._indexPath}`, 'info'); + } + + search(query) { + if (!query) return this.terminal.print('Usage: memgraph-search <query>', 'error'); + const results = this.findNodes(query); + if (results.length === 0) return this.terminal.print(`No nodes matching "${query}"`, 'info'); + + this.terminal.print(`🔍 Found ${results.length} nodes:`, 'header'); + for (const n of results.slice(0, 20)) { + const edges = this._edges.filter(e => e.source === n.id || e.target === n.id); + this.terminal.print(` ${n.type}:${n.label} (${edges.length} edges, ${n.access_count}x)`, 'info'); + for (const e of edges.slice(0, 3)) { + const other = e.source === n.id ? e.target : e.source; + const otherLabel = (this._nodes[other] || {}).label || other; + this.terminal.print(` └─ ${e.type} → ${otherLabel} (w:${e.weight.toFixed(1)})`, 'text-muted'); + } + } + if (results.length > 20) this.terminal.print(` ... and ${results.length - 20} more`, 'info'); + } + + showStats() { + if (Object.keys(this._nodes).length === 0) { + return this.terminal.print('No graph data yet. Use commands normally to build it.', 'info'); + } + + // Most connected nodes + const connected = {}; + for (const e of this._edges) { + connected[e.source] = (connected[e.source] || 0) + 1; + connected[e.target] = (connected[e.target] || 0) + 1; + } + const top = Object.entries(connected) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + + this.terminal.print('📊 Memory Graph Stats', 'header'); + this.terminal.print(' Most connected:', 'info'); + for (const [key, count] of top) { + const label = (this._nodes[key] || {}).label || key; + this.terminal.print(` ${label} — ${count} connections`, 'info'); + } + + // Strongest edges + const strong = [...this._edges].sort((a, b) => b.weight - a.weight).slice(0, 3); + if (strong.length > 0) { + this.terminal.print(' Strongest relationships:', 'info'); + for (const e of strong) { + const sLabel = (this._nodes[e.source] || {}).label || e.source; + const tLabel = (this._nodes[e.target] || {}).label || e.target; + this.terminal.print(` ${sLabel} → ${tLabel} (${e.type}, w:${e.weight.toFixed(2)}, used ${e.use_count}x)`, 'info'); + } + } + + // Cluster density + const density = this._edges.length / Math.max(Object.keys(this._nodes).length, 1); + this.terminal.print(` Density: ${density.toFixed(2)} edges/node`, 'info'); + } + + exportGraph() { + const exportData = { + version: 1, + exported: new Date().toISOString(), + nodes: Object.values(this._nodes), + edges: this._edges, + stats: { + totalNodes: Object.keys(this._nodes).length, + totalEdges: this._edges.length, + totalAccesses: this._totalNodeAccesses + } + }; + const json = JSON.stringify(exportData, null, 2); + // Save to VFS + try { + if (!this.terminal.fs.readDir(this._indexPath)) { + this.terminal.fs.mkdirRecursive(this._indexPath); + } + this.terminal.fs.writeFile(`${this._indexPath}export.json`, json); + this.terminal.print(`✅ Graph exported (${json.length} bytes) to ${this._indexPath}export.json`, 'success'); + } catch (e) { + this.terminal.print(`❌ Export failed: ${e.message}`, 'error'); + } + } + + prune() { + const now = Date.now(); + const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; + + const before = this._edges.length; + this._edges = this._edges.filter(e => { + const age = now - new Date(e.last_used).getTime(); + // Keep if: used recently (<30d) OR used 3+ times regardless of age + if (age < THIRTY_DAYS) return true; + if (e.use_count >= 3) return true; + // Or if weight is high + if (e.weight > 0.8) return true; + return false; + }); + const pruned = before - this._edges.length; + + // Also prune orphan nodes (no edges) + const connectedNodes = new Set(); + for (const e of this._edges) { + connectedNodes.add(e.source); + connectedNodes.add(e.target); + } + const nodeBefore = Object.keys(this._nodes).length; + for (const key of Object.keys(this._nodes)) { + if (!connectedNodes.has(key)) { + // Keep session and high-access nodes + if (this._nodes[key].access_count >= 5) continue; + delete this._nodes[key]; + } + } + const nodesPruned = nodeBefore - Object.keys(this._nodes).length; + + this._saveNow(); + this.terminal.print(`🧹 Pruned ${pruned} edges and ${nodesPruned} orphan nodes`, nodesPruned > 0 ? 'warning' : 'info'); + } + + // ─── Persistence ─── + + _loadFromVFS() { + try { + const fs = this.terminal.fs; + if (!fs) return; + + // Try loading from export first + const exportData = fs.fsReadJSON(`${this._indexPath}export.json`); + if (exportData && exportData.nodes) { + for (const n of exportData.nodes) { + this._nodes[n.id] = n; + } + this._edges = exportData.edges || []; + this._totalNodeAccesses = (exportData.stats?.totalAccesses) || 0; + return; + } + + // Fallback: load nodes and edges separately + const nodes = fs.fsReadJSON(`${this._indexPath}nodes.json`); + if (nodes) this._nodes = nodes; + const edges = fs.fsReadJSON(`${this._indexPath}edges.json`); + if (edges) this._edges = edges; + } catch (e) { + console.warn('[MemoryGraph] Load error (fresh start):', e.message); + } + } + + _saveNow() { + try { + const fs = this.terminal.fs; + if (!fs) return; + if (!fs.readDir(this._indexPath)) { + fs.mkdirRecursive(this._indexPath); + } + fs.fsWriteJSON(`${this._indexPath}nodes.json`, this._nodes); + fs.fsWriteJSON(`${this._indexPath}edges.json`, this._edges); + } catch (e) { + console.error('[MemoryGraph] Save error:', e.message); + } + } + + _debounceSave() { + if (this._debounceTimer) clearTimeout(this._debounceTimer); + this._debounceTimer = setTimeout(() => this._saveNow(), 2000); + } +} + +// Required for module system +if (typeof window !== 'undefined') { + window.MemoryGraphModule = MemoryGraphModule; +} + +/* === module-predictor.js === */ +// module-predictor.js — Predictive resource allocation engine +// Analyzes historical telemetry + memory graph data to predict task duration/turns +// Commands: predict, predictor-status, predictor-config +class PredictorModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._dataPoints = []; + this._predictions = 0; + this._accuracy = { correct: 0, total: 0 }; + this._CONFIDENCE_THRESHOLD = 0.5; + this._MIN_DATA_POINTS = 5; + this._loaded = false; + } + + getHelpInfo() { + return { + 'predict': 'predict <task description> — Predict task duration, tool turns, and confidence', + 'predictor-status': 'predictor-status — Model accuracy, data points, prediction count', + 'predictor-config': 'predictor-config <key> <value> — Tune: confidence-threshold, min-data-points', + }; + } + + registerCommands() { + this.commands['predict'] = (args) => this.predict(args.join(' ')); + this.commands['predictor-status'] = () => this.showStatus(); + this.commands['predictor-config'] = (args) => this.setConfig(args); + } + + init(terminal) { + this.terminal = terminal; + this._loadFromVFS(); + this._loaded = true; + this.terminal.print('📊 Predictor loaded — analyzing task patterns', 'info'); + } + + // ─── Prediction Engine ─── + + async predict(task) { + if (!task) return this.terminal.print('Usage: predict <task description>', 'error'); + + const startTime = performance.now(); + + // Extract features + const features = this._extractFeatures(task); + + // Find similar historical tasks + const similar = this._findSimilar(features); + + // Generate prediction + const prediction = this._computePrediction(similar, features); + + // Record this prediction for accuracy tracking + const duration = ((performance.now() - startTime) / 1000).toFixed(2); + + this._predictions++; + this._dataPoints.push({ + features, + predictedDuration: prediction.duration, + predictedTurns: prediction.toolTurns, + confidence: prediction.confidence, + similarCount: similar.length, + timestamp: new Date().toISOString() + }); + + this._debounceSave(); + + // Display result + this.terminal.print('📈 Task Prediction', 'header'); + this.terminal.print(` Task: ${task.substring(0, 50)}${task.length > 50 ? '...' : ''}`, 'info'); + + if (prediction.confidence >= this._CONFIDENCE_THRESHOLD) { + this.terminal.print(` ⏱ Estimated duration: ${prediction.duration}s`, 'info'); + this.terminal.print(` 🔧 Estimated tool turns: ${prediction.toolTurns}`, 'info'); + this.terminal.print(` 📊 Confidence: ${(prediction.confidence * 100).toFixed(0)}% (based on ${similar.length} similar tasks)`, 'success'); + } else if (similar.length > 0) { + this.terminal.print(` ⏱ Estimated duration: ${prediction.duration}s (low confidence)`, 'warning'); + this.terminal.print(` 🔧 Tool turns: ~${prediction.toolTurns}`, 'warning'); + this.terminal.print(` 📊 Confidence: ${(prediction.confidence * 100).toFixed(0)}% — only ${similar.length} similar data point(s)`, 'warning'); + this.terminal.print(' Tip: More task data will improve predictions.', 'info'); + } else { + this.terminal.print(' ❓ No historical data to base prediction on.', 'warning'); + this.terminal.print(' Tip: Run `orchestrate` with various tasks to build a baseline.', 'info'); + // Provide hardcoded defaults + this.terminal.print(' Default estimate: ~30s duration, ~5 tool turns', 'info'); + } + + // If got memory graph, offer recommendations + if (prediction.confidence >= this._CONFIDENCE_THRESHOLD && prediction.duration > 30) { + this.terminal.print(` 💡 Recommendation: Set TTL to ${Math.ceil(prediction.duration * 1.5)}s for predictable completion`, 'info'); + } + + return prediction; + } + + _extractFeatures(task) { + const lower = task.toLowerCase(); + return { + length: task.length, + wordCount: task.split(/\s+/).length, + hasSearch: /\b(search|find|look)\b/.test(lower), + hasWrite: /\b(write|create|save|edit)\b/.test(lower), + hasDelete: /\b(delete|remove|rm)\b/.test(lower), + hasInstall: /\b(install|wpm|package)\b/.test(lower), + hasRead: /\b(read|cat|view|list)\b/.test(lower), + hasWeb: /\b(fetch|url|http|web)\b/.test(lower), + hasOrchestrate: /\b(orchestrat|delegate|agent)\b/.test(lower), + hasModule: /\b(module|edit_module|view_module)\b/.test(lower), + hasSync: /\b(sync|recall|remember|previous session|state sync)\b/.test(lower), + complexity: Math.min(task.length / 50, 10), // 0-10 scale + keywords: [...new Set(lower.split(/\s+/).filter(w => w.length > 3))].slice(0, 10) + }; + } + + _findSimilar(features) { + return this._dataPoints.filter(dp => { + // Skip predictions (only use actual executions with observed results) + if (!dp.observedDuration) return false; + const f = dp.features; + if (!f) return false; + // Feature similarity scoring + let score = 0; + const matches = [ + features.hasSearch === f.hasSearch, + features.hasWrite === f.hasWrite, + features.hasDelete === f.hasDelete, + features.hasInstall === f.hasInstall, + features.hasRead === f.hasRead, + features.hasWeb === f.hasWeb, + features.hasOrchestrate === f.hasOrchestrate, + features.hasModule === f.hasModule, + features.hasSync === f.hasSync, + ]; + score = matches.filter(Boolean).length / matches.length; + // Keyword overlap bonus + const kOverlap = features.keywords.filter(k => f.keywords?.includes(k)).length; + const kScore = kOverlap / Math.max(features.keywords.length, 1); + score = score * 0.6 + kScore * 0.4; + return score > 0.3; + }); + } + + _computePrediction(similar, features) { + if (similar.length === 0) { + // Hardcoded defaults + let baseDuration = 15; + let baseTurns = 3; + if (features.hasSearch || features.hasWeb) { baseDuration = 25; baseTurns = 5; } + if (features.hasWrite) { baseDuration = 20; baseTurns = 4; } + if (features.hasOrchestrate) { baseDuration = 40; baseTurns = 8; } + if (features.hasSync) { baseDuration = 30; baseTurns = 6; } + if (features.complexity > 5) baseDuration *= 1.5; + return { + duration: baseDuration, + toolTurns: baseTurns, + confidence: 0.1, + similarCount: 0 + }; + } + + const avgDuration = similar.reduce((s, dp) => s + (dp.observedDuration || 30), 0) / similar.length; + const avgTurns = similar.reduce((s, dp) => s + (dp.observedTurns || 3), 0) / similar.length; + const confidence = Math.min(similar.length / 20, 1) * 0.8 + 0.1; + + // Apply task complexity multiplier + const complexityMultiplier = 1 + (features.complexity - 3) * 0.1; + const adjustedDuration = Math.round(avgDuration * Math.max(complexityMultiplier, 0.5)); + + return { + duration: adjustedDuration, + toolTurns: Math.round(avgTurns), + confidence: Math.min(confidence, 0.95), + similarCount: similar.length + }; + } + + // ─── Learning from Results ─── + + recordResult(features, duration, toolTurns, success) { + this._dataPoints.push({ + features, + observedDuration: duration, + observedTurns: toolTurns, + success, + timestamp: new Date().toISOString() + }); + this._accuracy.total++; + // Compare with last prediction for this feature set + const lastPrediction = [...this._dataPoints] + .reverse() + .find(dp => dp.predictedDuration && dp.features?.hasSearch === features.hasSearch); + if (lastPrediction) { + const error = Math.abs(lastPrediction.predictedDuration - duration) / duration; + if (error < 0.3) this._accuracy.correct++; + } + this._debounceSave(); + } + + // ─── Commands ─── + + showStatus() { + const actualExecutions = this._dataPoints.filter(dp => dp.observedDuration).length; + const accPct = this._accuracy.total > 0 + ? ((this._accuracy.correct / this._accuracy.total) * 100).toFixed(0) + : 'N/A'; + + this.terminal.print('📊 Predictor Status', 'header'); + this.terminal.print(` Total data points: ${this._dataPoints.length}`, 'info'); + this.terminal.print(` Actual executions: ${actualExecutions}`, 'info'); + this.terminal.print(` Predictions made: ${this._predictions}`, 'info'); + this.terminal.print(` Accuracy: ${accPct}% (${this._accuracy.correct}/${this._accuracy.total})`, 'info'); + this.terminal.print(` Confidence threshold: ${this._CONFIDENCE_THRESHOLD}`, 'info'); + this.terminal.print(` Min data points: ${this._MIN_DATA_POINTS}`, 'info'); + } + + setConfig(args) { + if (args.length < 2) return this.terminal.print('Usage: predictor-config <key> <value>', 'error'); + const key = args[0]; + const val = args[1]; + + const configMap = { + 'confidence-threshold': () => { + const n = parseFloat(val); + if (isNaN(n) || n < 0 || n > 1) return 'Must be 0.0-1.0'; + this._CONFIDENCE_THRESHOLD = n; + return `confidence-threshold set to ${n}`; + }, + 'min-data-points': () => { + const n = parseInt(val); + if (isNaN(n) || n < 1) return 'Must be >= 1'; + this._MIN_DATA_POINTS = n; + return `min-data-points set to ${n}`; + }, + }; + + if (!configMap[key]) return this.terminal.print(`Unknown: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); + const msg = configMap[key](); + this.terminal.print(`✅ ${msg}`, 'success'); + } + + // ─── Persistence ─── + + _loadFromVFS() { + try { + const fs = this.terminal.fs; + if (!fs) return; + const data = fs.fsReadJSON('/system/metrics/predictor.json'); + if (data) { + this._dataPoints = data.dataPoints || []; + this._predictions = data.predictions || 0; + this._accuracy = data.accuracy || { correct: 0, total: 0 }; + } + } catch (e) { + console.warn('[Predictor] Load error (fresh start):', e.message); + } + } + + _saveNow() { + try { + const fs = this.terminal.fs; + if (!fs) return; + if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); + fs.fsWriteJSON('/system/metrics/predictor.json', { + dataPoints: this._dataPoints.slice(-500), // keep last 500 + predictions: this._predictions, + accuracy: this._accuracy + }); + } catch (e) { + console.error('[Predictor] Save error:', e.message); + } + } + + _debounceSave() { + clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => this._saveNow(), 3000); + } +} + +// Required for module system +if (typeof window !== 'undefined') { + window.PredictorModule = PredictorModule; +} + +/* === module-world-model.js === */ +// module-world-model.js — Global World Model: system self-awareness synthesis engine +// Aggregates from memory graph, neural VFS, skills index, telemetry +// Commands: world-model, world-model-summary, world-model-ask, world-model-refresh, world-model-config +class WorldModelModule { + constructor() { + this.commands = {}; + this.terminal = null; + this._model = null; + this._lastRefresh = null; + this._REFRESH_INTERVAL = 300000; // 5 minutes + this._MAX_PROJECTS = 10; + this._refreshTimer = null; + } + + getHelpInfo() { + return { + 'world-model': 'world-model — Show full world model as structured output', + 'world-model-summary': 'world-model-summary — One-line system self-awareness statement', + 'world-model-ask': 'world-model-ask <question> — Query the model (e.g. "what changed since yesterday")', + 'world-model-refresh': 'world-model-refresh — Force re-synthesis from all subsystems', + 'world-model-config': 'world-model-config <key> <value> — Tune: refresh-interval, max-projects', + }; + } + + registerCommands() { + this.commands['world-model'] = () => this.showModel(); + this.commands['world-model-summary'] = () => this.showSummary(); + this.commands['world-model-ask'] = (args) => this.ask(args.join(' ')); + this.commands['world-model-refresh'] = () => this.refresh(); + this.commands['world-model-config'] = (args) => this.setConfig(args); + } + + init(terminal) { + this.terminal = terminal; + this._loadFromVFS(); + // Deferred initial synthesis — wait for ext modules (SkillPackCore etc) to fully load + setTimeout(() => { + this._synthesize(); + this.terminal.print('🌐 World Model synthesized — system self-awareness active', 'info'); + // Second pass after ext modules definitely finished loading + setTimeout(() => { + this._synthesize(); + this.terminal.print('🌐 World Model refresh — full module load confirmed', 'info'); + }, 7000); + }, 5000); + // Auto-refresh on a timer + this._refreshTimer = setInterval(() => { + const age = Date.now() - (this._lastRefresh ? new Date(this._lastRefresh).getTime() : 0); + if (age > this._REFRESH_INTERVAL) this._synthesize(); + }, this._REFRESH_INTERVAL); + this.terminal.print('🌐 World Model loaded — system self-awareness active', 'info'); + } + + // ─── Synthesis Engine ─── + + _synthesize() { + try { + const fs = this.terminal.fs; + if (!fs) return; + + // Derive version from actual system state — no hardcoded strings + const mInfo = this._gatherModuleInfo(); + const agentInfo = this._gatherAgentInfo(); + const fileInfo = this._gatherFileInfo(fs); + const skillInfo = this._gatherSkillInfo(fs); + const liveVersion = this._deriveVersion(mInfo); + + const model = { + version: liveVersion, + synthesized: new Date().toISOString(), + system: this._gatherSystemInfo(fs, mInfo.loaded, liveVersion), + modules: mInfo, + files: fileInfo, + skills: skillInfo, + memory: this._gatherMemoryInfo(), + agents: agentInfo, + telemetry: this._gatherTelemetry(fs), + projects: this._gatherProjects(fs), + recent: this._gatherRecent(fs) + }; + + this._model = model; + this._lastRefresh = model.synthesized; + this._saveToVFS(fs); + + // Log synthesis summary + const mCount = model.modules?.loaded || '?'; + const sCount = model.skills?.count || '?'; + const fCount = model.files?.total || '?'; + console.log(`[WorldModel] Synthesis complete: ${mCount} modules, ${sCount} skills, ${fCount} files`); + } catch (e) { + console.error('[WorldModel] Synthesis error:', e.message); + } + } + + _deriveVersion(mInfo) { + // Single source of truth: the constant set at bootstrap + if (window.NEWOS4_VERSION) return window.NEWOS4_VERSION; + // No fallback version string — if the constant is missing, report unknown + return 'v?'; + } + + _getTotalModules() { + try { + const mm = this.terminal?.moduleManager; + if (!mm) return 0; + // Count registry entries (all known modules) vs loaded + const registry = mm.moduleRegistry || []; + return registry.length || Object.keys(mm.modules || {}).length + 5; // +5 for expected unloaded + } catch (e) { + return Object.keys(this.terminal?.moduleManager?.modules || {}).length || 0; + } + } + + _gatherSystemInfo(fs, moduleCount, version) { + const healthData = fs.fsReadJSON('/system/metrics/health.json'); + return { + version: `newOS4 ${version}`, + modules: moduleCount || Object.keys(this.terminal?.moduleManager?.modules || {}).length || 0, + vfsSize: JSON.stringify(fs?.root || {}).length, + online: typeof navigator !== 'undefined' ? navigator.onLine : true, + memory: typeof performance !== 'undefined' && performance.memory + ? `${(performance.memory.usedJSHeapSize / 1048576).toFixed(1)}MB` + : 'N/A', + health: healthData?.status || 'OK', + lastHealthCheck: healthData?.timestamp || null + }; + } + + _gatherModuleInfo() { + const mm = this.terminal?.moduleManager; + if (!mm) return { loaded: 0, modified: [] }; + const modules = Object.keys(mm.modules || {}).map(name => { + const mod = mm.modules[name]; + const clsName = mod?.constructor?.name || name; + return { name: clsName, hasCommands: !!mod?.commands }; + }); + // Check for local modifications + const modsDir = this.terminal.fs?.readDir('/_mods/'); + const modified = modsDir + ? modsDir.filter(e => e.type === 'file' && e.name.endsWith('.js') && !e.name.endsWith('.orig.js')) + .map(e => e.name.replace('.js', '')) + : []; + return { + loaded: modules.length, + total: this._getTotalModules(), + modules, + modified, + modifiedCount: modified.length + }; + } + + _gatherFileInfo(fs) { + if (!fs) return { total: 0, size: '0KB' }; + let fileCount = 0, dirCount = 0; + const walk = (path) => { + const entries = fs.ls(path); + if (!entries) return; + for (const e of entries) { + if (e.type === 'file') fileCount++; + else { dirCount++; walk(path === '/' ? '/' + e.name : path + '/' + e.name); } + } + }; + walk('/'); + return { + total: fileCount, + directories: dirCount, + size: `${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB` + }; + } + + _gatherSkillInfo(fs) { + try { + const idx = fs.fsReadJSON('/skills/index.json'); + const count = idx ? Object.keys(idx).length : 0; + return { count, skills: idx ? Object.keys(idx) : [] }; + } catch (e) { + return { count: 0, skills: [] }; + } + } + + _gatherMemoryInfo() { + const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; + if (mg && mg._nodes) { + const nodes = Object.keys(mg._nodes).length; + const edges = (mg._edges || []).length; + return { nodes, edges, active: true }; + } + // Fallback: count memory files + try { + const memIndex = this.terminal.fs?.fsReadJSON('/memory/index.json'); + return { nodes: memIndex ? Object.keys(memIndex).length : 0, edges: 0, active: false }; + } catch (e) { + return { nodes: 0, edges: 0, active: false }; + } + } + + _gatherAgentInfo() { + const orch = this.terminal?.moduleManager?.modules?.['OrchestratorModule']; + if (!orch || !orch._agents) return { agents: 0, active: 0 }; + const agents = Object.keys(orch._agents).length; + const active = Object.values(orch._agents).filter(a => a.status === 'busy').length; + return { agents, active, totalLeases: Object.keys(orch._a2aMessages || {}).length }; + } + + _gatherTelemetry(fs) { + try { + const latency = fs.fsReadJSON('/system/metrics/tool-latency.json'); + const errors = fs.fsReadJSON('/system/metrics/error-log.json'); + return { + toolLatencyDataPoints: latency ? Object.keys(latency).length : 0, + recentErrors: errors ? (Array.isArray(errors) ? errors.length : Object.keys(errors).length) : 0, + }; + } catch (e) { + return { toolLatencyDataPoints: 0, recentErrors: 0 }; + } + } + + _gatherProjects(fs) { + try { + const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; + if (mg && mg._nodes) { + // Find task clusters as "projects" + const tasks = Object.values(mg._nodes).filter(n => n.type === 'task'); + const recentFiles = Object.values(mg._nodes) + .filter(n => n.type === 'file') + .sort((a, b) => b.access_count - a.access_count) + .slice(0, this._MAX_PROJECTS); + return { + totalTasks: tasks.length, + activeProjects: recentFiles.slice(0, 5).map(f => ({ + name: f.label, + accesses: f.access_count, + lastModified: f.updated + })) + }; + } + return { totalTasks: 0, activeProjects: [] }; + } catch (e) { + return { totalTasks: 0, activeProjects: [] }; + } + } + + _gatherRecent(fs) { + const recent = []; + try { + // Check modified modules + const modsDir = fs.readDir('/_mods/'); + if (modsDir) { + for (const e of modsDir) { + if (e.type === 'file' && e.name.endsWith('.js') && !e.name.endsWith('.orig.js') && !e.name.endsWith('.meta.json')) { + recent.push(`Modified module: ${e.name.replace('.js', '')}`); + } + } + } + // Check skills activity + const skills = fs.ls('/skills/'); + if (skills && skills.length > 0) { + recent.push(`Skills directory: ${skills.filter(s => s.type === 'file').length} files`); + } + } catch (e) {} + return recent; + } + + // ─── Commands ─── + + showModel() { + if (!this._model) return this.terminal.print('No model yet — run world-model-refresh', 'warning'); + const m = this._model; + + this.terminal.print('🌐 World Model', 'header'); + this.terminal.print(` Version: ${m.version} (last synthsized: ${new Date(m.synthesized).toLocaleTimeString()})`, 'info'); + + this.terminal.print(` ⚙️ System — ${m.system.modules} modules loaded, ${m.system.vfsSize} VFS, ${m.system.memory}`, 'info'); + this.terminal.print(` 🧩 Modules — ${m.modules.loaded} loaded (${m.modules.modifiedCount} modified)`, 'info'); + this.terminal.print(` 📁 Files — ${m.files.total} files, ${m.files.directories} dirs, ${m.files.size}`, 'info'); + this.terminal.print(` 📚 Skills — ${m.skills.count} installed`, 'info'); + this.terminal.print(` 🧠 Memory Graph — ${m.memory.nodes} nodes, ${m.memory.edges} edges${m.memory.active ? ' ✅' : ''}`, 'info'); + this.terminal.print(` 🤖 Agents — ${m.agents.agents} registered (${m.agents.active} active)`, 'info'); + this.terminal.print(` 📊 Telemetry — ${m.telemetry.toolLatencyDataPoints} tool latency points, ${m.telemetry.recentErrors} recent errors`, 'info'); + + if (m.projects.activeProjects.length > 0) { + this.terminal.print(' Active context:', 'header'); + for (const p of m.projects.activeProjects.slice(0, 3)) { + this.terminal.print(` 📌 ${p.name} (${p.accesses}x accesses)`, 'info'); + } + } + + if (m.recent.length > 0) { + this.terminal.print(' Recent changes:', 'header'); + for (const r of m.recent.slice(0, 3)) { + this.terminal.print(` ${r}`, 'info'); + } + } + } + + showSummary() { + if (!this._model) return this.terminal.print('No model yet', 'warning'); + const m = this._model; + const summary = `newOS4 ${m.version} · ${m.modules.loaded}/${m.modules.total} modules · ${m.skills.count} skills · ${m.files.total} files · ${m.agents.agents} agents · ${m.memory.nodes} graph nodes · ${m.telemetry.recentErrors > 0 ? `⚠️ ${m.telemetry.recentErrors} errors` : '✅ OK'}`; + this.terminal.print(summary, 'info'); + return summary; + } + + ask(question) { + if (!question) return this.terminal.print('Usage: world-model-ask <question>', 'error'); + if (!this._model) return this.terminal.print('No model — run world-model-refresh first', 'warning'); + + const q = question.toLowerCase(); + const m = this._model; + let answer = ''; + + if (q.includes('module') || q.includes('loaded')) { + answer = `Loaded ${m.modules.loaded} modules (${m.modules.modifiedCount} modified: ${m.modules.modified.join(', ') || 'none'})`; + } else if (q.includes('skill')) { + answer = `Installed ${m.skills.count} skills: ${m.skills.skills.join(', ') || 'none'}`; + } else if (q.includes('file') || q.includes('vfs')) { + answer = `${m.files.total} files in ${m.files.directories} directories, total ${m.files.size}`; + } else if (q.includes('agent') || q.includes('orchestrat')) { + answer = `${m.agents.agents} agents (${m.agents.active} active)`; + } else if (q.includes('health') || q.includes('status')) { + answer = `Health: ${m.system.health} · Memory: ${m.system.memory} · ${m.telemetry.recentErrors > 0 ? `${m.telemetry.recentErrors} recent errors` : 'No errors'}`; + } else if (q.includes('memory') || q.includes('graph')) { + answer = `Memory Graph: ${m.memory.nodes} nodes, ${m.memory.edges} edges (${m.memory.active ? 'active' : 'inactive'})`; + } else if (q.includes('change') || q.includes('yesterday') || q.includes('recent')) { + const recent = m.recent; + answer = recent.length > 0 ? `Recent changes: ${recent.join('; ')}` : 'No recent changes detected'; + } else if (q.includes('project') || q.includes('work')) { + const projects = m.projects.activeProjects; + answer = projects.length > 0 + ? `Active projects: ${projects.map(p => `${p.name} (${p.accesses}x)`).join(', ')}` + : 'No active projects detected'; + } else if (q.includes('telemetry') || q.includes('latency')) { + answer = `${m.telemetry.toolLatencyDataPoints} tool latency data points tracked`; + } else if (q.includes('summary') || q.includes('overview') || q.includes('all')) { + answer = this.showSummary(); + return; + } else { + answer = `I'm not sure how to answer "${question}". Try: modules, skills, files, agents, health, memory, changes, projects, telemetry, or summary.`; + } + + this.terminal.print(`❓ ${question}`, 'info'); + this.terminal.print(`💡 ${answer}`, 'success'); + } + + refresh() { + this._synthesize(); + this.terminal.print(`✅ World Model refreshed at ${new Date().toLocaleTimeString()}`, 'success'); + } + + setConfig(args) { + if (args.length < 2) return this.terminal.print('Usage: world-model-config <key> <value>', 'error'); + const key = args[0]; + const val = args[1]; + + const configMap = { + 'refresh-interval': () => { + const n = parseInt(val) * 1000; + if (isNaN(n) || n < 10000) return 'Must be >= 10 seconds'; + this._REFRESH_INTERVAL = n; + if (this._refreshTimer) clearInterval(this._refreshTimer); + this._refreshTimer = setInterval(() => this._synthesize(), n); + return `refresh-interval set to ${val}s`; + }, + 'max-projects': () => { + const n = parseInt(val); + if (isNaN(n) || n < 1) return 'Must be >= 1'; + this._MAX_PROJECTS = n; + return `max-projects set to ${n}`; + }, + }; + + if (!configMap[key]) return this.terminal.print(`Unknown: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); + const msg = configMap[key](); + this.terminal.print(`✅ ${msg}`, 'success'); + } + + // ─── Context Injection ─── + + getContextForPrompt() { + if (!this._model) return ''; + const m = this._model; + return `[World Model Context]\\nSystem: newOS4 ${m.version} · ${m.modules.loaded} modules · ${m.skills.count} skills · ${m.files.total} files · ${m.agents.agents} agents${m.telemetry.recentErrors > 0 ? ` · ⚠️ ${m.telemetry.recentErrors} errors` : ' · ✅ OK'}${m.modules.modifiedCount > 0 ? `\\nModified modules: ${m.modules.modified.slice(0, 3).join(', ')}${m.modules.modifiedCount > 3 ? ` (+${m.modules.modifiedCount - 3} more)` : ''}` : ''}`; + } + + // ─── Persistence ─── + + _loadFromVFS() { + try { + const fs = this.terminal.fs; + if (!fs) return; + const data = fs.fsReadJSON('/system/world-model.json'); + if (data) { + this._model = data; + this._lastRefresh = data.synthesized; + } + } catch (e) { + console.warn('[WorldModel] Load error (fresh start):', e.message); + } + } + + _saveToVFS(fs) { + try { + if (!fs.readDir('/system')) fs.mkdir('system'); + fs.fsWriteJSON('/system/world-model.json', this._model); + } catch (e) { + console.error('[WorldModel] Save error:', e.message); + } + } +} + +// Required for module system +if (typeof window !== 'undefined') { + window.WorldModelModule = WorldModelModule; +} + +/* === module-neural-vfs.js === */ +class NeuralVFSModule { + constructor() { + this.commands = {}; + this.commandsMeta = { + 'nvfs-index': { argType: 'file' }, 'nvfs-status': { argType: 'none' }, + 'nvfs-resolve': { argType: 'file' }, + 'nvfs-tree': { argType: 'file' }, + 'nvfs-config': { argType: 'enum', enumFn: '_getNvfsConfigKeys' }, + 'nvfs-alias': { argType: 'enum', enumFn: '_getNvfsAliasActions' }, + 'nvfs-sync': { argType: 'none' }, + 'nvfs-save': { argType: 'none' }, + 'nvfs-cancel': { argType: 'none' }, + }; + this.terminal = null; + this.index = {}; + this.indexPath = '/.semantic_index/map.json'; + this.hashCachePath = '/.semantic_index/hash_cache.json'; + this._hashCache = {}; + // Configurable display limits + this._fuzzyLimit = 10; + this._treeFiles = 5; + this._treeConcepts = 15; + this._resolveFiles = 20; + this._loadConfig(); + // Auto-sync state + this._dirtyPaths = new Set(); + this._autoSyncTimer = null; + this._aliasMap = {}; + this._loadAliases(); + // Deep Indexing toggle + this._deepIndexEnabled = localStorage.getItem('webx11_nvfs_deep') === 'true'; + this._deepModel = localStorage.getItem('webx11_nvfs_deep_model') || 'ministral-3:3b-cloud'; + this._deepIndexing = false; + this._deepQueue = []; + this._deepProcessed = 0; + this._deepTotal = 0; + // Cancel flag + this._cancelRequested = false; + // Status bar polling — updates #status-text with indexing indicator + if (typeof window !== 'undefined') { + this._statusInterval = setInterval(() => this._updateStatusBar(), 2000); + } + // Register as window-level event listener for mutation events from VirtualFS + if (typeof window !== 'undefined') { + window.__nvfsMarkDirty = (path) => this.markDirty(path); + } + } + _loadConfig() { + try { const c = localStorage.getItem('webx11_nvfs_config'); if (c) { const p = JSON.parse(c); if (p.fuzzyLimit) this._fuzzyLimit = p.fuzzyLimit; if (p.treeFiles) this._treeFiles = p.treeFiles; if (p.treeConcepts) this._treeConcepts = p.treeConcepts; if (p.resolveFiles) this._resolveFiles = p.resolveFiles; } } catch (e) {} + } + _saveConfig() { + localStorage.setItem('webx11_nvfs_config', JSON.stringify({ fuzzyLimit: this._fuzzyLimit, treeFiles: this._treeFiles, treeConcepts: this._treeConcepts, resolveFiles: this._resolveFiles })); + } + + getHelpInfo() { + return { + 'nvfs-index': 'nvfs-index [--deep] — Scan VFS and build semantic concept index', + 'nvfs-status': 'nvfs-status — Show semantic index stats and last scan time', + 'nvfs-resolve': 'nvfs-resolve <concept> — Find files related to a concept', + 'nvfs-tree': 'nvfs-tree [concept] — Show concept-to-file mapping as a tree', + 'nvfs-config': 'nvfs-config [key] [value] — View or set Neural VFS config (fuzzy-limit, tree-files, tree-concepts, resolve-files, deep-index, deep-model)', + 'nvfs-alias': 'nvfs-alias list|add|remove|clear — Manage concept aliases', + 'nvfs-sync': 'nvfs-sync [--deep] — Incremental background sync (auto-sync dirty files)', + 'nvfs-save': 'nvfs-save — Force-save current in-memory index to VFS (survives refresh)', + 'nvfs-cancel': 'nvfs-cancel — Cancel running index operation' + }; + } + + registerCommands() { + this.commands['nvfs-index'] = (args) => this.buildIndex(args); + this.commands['nvfs-status'] = (args) => this.showStatus(); + this.commands['nvfs-resolve'] = (args) => this.resolve(args); + this.commands['nvfs-tree'] = (args) => this.tree(args); + this.commands['nvfs-config'] = (args) => this.nvfsConfig(args); + this.commands['nvfs-alias'] = (args) => this.nvfsAlias(args); + this.commands['nvfs-sync'] = async (args) => this.nvfsSync(args); + this.commands['nvfs-save'] = async (args) => this.nvfsSave(); + this.commands['nvfs-cancel'] = (args) => this.cancelIndex(); + } + + // ── Core Index Builder (async, chunked) ── + + async buildIndex(args) { + const useDeep = this._deepIndexEnabled || (args && args.includes('--deep')); + const fs = this.terminal.fs; + if (!fs) return this.terminal.print('VFS not available', 'error'); + + // Safety guard: warn if deep mode is off but the saved index has LLM concepts + // (show once per session — doesn't repeat on every nvfs-index call) + if (!useDeep && !this._warnedAboutLLMStrip) { + const prevIndex = fs.fsReadJSON(this.indexPath) || {}; + const hasLLM = Object.values(prevIndex).some(entries => + Array.isArray(entries) && entries.some(e => e.source === 'llm') + ); + if (hasLLM) { + this._warnedAboutLLMStrip = true; + this.terminal.print('⚠️ Deep indexing is OFF but the saved index contains LLM-generated concepts.', 'warning'); + this.terminal.print(' Running nvfs-index without --deep will STRIP all LLM concepts from re-indexed files.', 'warning'); + this.terminal.print(' To keep them: nvfs-config deep-index on or nvfs-index --deep', 'warning'); + } + } + + this.terminal.print(`🔍 Scanning VFS for semantic concepts${useDeep ? ' (deep mode)' : ''}...`, 'info'); + + const start = Date.now(); + let newIndex = {}; + let fileCount = 0; + let conceptCount = 0; + let cachedCount = 0; + + // Load previous index and hash cache for incremental diffing + const prevIndex = fs.fsReadJSON(this.indexPath) || {}; + this._loadHashCache(fs); + + // Collect current file list + const fileList = []; + this._collectFiles('/', fs, fileList); + + if (fileList.length === 0) { + return this.terminal.print('No files found to index', 'warning'); + } + + // Detect deleted files — strip orphaned entries from prevIndex + const currentPaths = new Set(fileList.map(f => f.path)); + let deletedCount = 0; + for (const [concept, entries] of Object.entries(prevIndex)) { + const remaining = entries.filter(e => currentPaths.has(e.path)); + if (remaining.length !== entries.length) deletedCount++; + if (remaining.length > 0) prevIndex[concept] = remaining; + else delete prevIndex[concept]; + } + + // Warm start: clone prevIndex and count live paths for stats + newIndex = JSON.parse(JSON.stringify(prevIndex)); + const livePaths = new Set(); + for (const entries of Object.values(newIndex)) { + for (const e of entries) livePaths.add(e.path); + } + conceptCount = Object.keys(newIndex).length; + + // Chunked async processing — yields to UI between chunks + const chunkSize = 10; + let i = 0; + const processChunk = () => { + if (this._cancelRequested) { + this._cancelRequested = false; + this._deepQueue = []; + this._deepIndexing = false; + this.terminal.print('⛔ Index cancelled', 'warning'); + return; + } + const end = Math.min(i + chunkSize, fileList.length); + for (let j = i; j < end; j++) { + const { path, node } = fileList[j]; + fileCount++; + const content = node.content || ''; + const hash = this._contentHash(content); + + // Skip if hash unchanged — content is same as last scan + const cached = this._getCached(path, hash); + if (cached) { + cachedCount++; + if (!livePaths.has(path)) { + // File was in cache but somehow missing from prevIndex + this._reindexFile(path, content, newIndex, useDeep); + } + continue; + } + + // Changed or new file — extract concepts fresh + this._reindexFile(path, content, newIndex, useDeep); + this._hashCache[path] = { hash }; + } + i = end; + if (i < fileList.length) { + const pct = Math.round(i / fileList.length * 100); + const cMsg = cachedCount > 0 ? ` (${cachedCount} cached)` : ''; + this.terminal.print(`⏳ Indexing... ${pct}% (${i}/${fileList.length} files${cMsg})`, 'info'); + setTimeout(processChunk, 0); + } else { + // Also index skills + this._indexSkills(fs, newIndex); + this.index = newIndex; + this.lastScan = new Date().toISOString(); + this.fileCount = fileCount; + this.conceptCount = Object.keys(newIndex).length; + this._saveIndex(fs); + if (fs._clearConceptCache) fs._clearConceptCache(); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + const processedMsg = cachedCount > 0 ? ` (${cachedCount} cached, ${fileCount - cachedCount} reindexed)` : ''; + this.terminal.print(`✅ Indexed ${fileCount} files → ${Object.keys(newIndex).length} concepts in ${elapsed}s${processedMsg}`, 'success'); + if (deletedCount > 0) this.terminal.print(` Purged ${deletedCount} orphaned concepts from deleted files`, 'info'); + this.terminal.print(` Index saved to ${this.indexPath}`, 'info'); + } + }; + processChunk(); + } + + _reindexFile(path, content, index, useDeep) { + if (useDeep) { + // Deep mode: queue for async LLM concept extraction — fast-path filename concepts first + const fastConcepts = this._extractConcepts(path, ''); + for (const concept of fastConcepts) { + if (!index[concept]) index[concept] = []; + const exists = index[concept].some(e => e.path === path); + if (!exists) index[concept].push({ path, summary: this._summarize(content), source: 'filename' }); + } + this._deepQueue.push({ path, content }); + if (!this._deepIndexing) { + this._deepIndexing = true; + this._processDeepQueue(index).catch(e => console.error('Deep index error:', e)); + } + return; + } + const concepts = this._extractConcepts(path, content); + // Remove old entries for this path + for (const entries of Object.values(index)) { + const idx = entries.findIndex(e => e.path === path); + if (idx !== -1) entries.splice(idx, 1); + } + // Add new entries + for (const concept of concepts) { + if (!index[concept]) { + index[concept] = []; + this.conceptCount++; + } + index[concept].push({ path, summary: this._summarize(content) }); + } + } + + _collectFiles(path, fs, list) { + const entries = fs.readDir(path); + if (!entries) return; + for (const entry of entries) { + const fullPath = path === '/' ? '/' + entry.name : path + '/' + entry.name; + if (entry.type === 'file') { + if (this._isSystemFile(fullPath)) continue; + list.push({ path: fullPath, node: { type: 'file', content: fs.readFile(fullPath) || '' } }); + } else if (entry.type === 'dir') { + this._collectFiles(fullPath, fs, list); + } + } + } + + // ── Resolve ── + + resolve(args) { + if (!args[0]) return this.terminal.print('Usage: nvfs-resolve <concept>', 'warning'); + + const concept = args.join(' ').toLowerCase(); + + // Check aliases first + if (this._aliasMap[concept]) { + const targets = this._aliasMap[concept]; + this.terminal.print(`🔗 Alias "${concept}" → ${targets.length} target(s):`, 'info'); + for (const t of targets) this.terminal.print(` ${t}`, 'success'); + return; + } + + const matches = this.index[concept] || []; + + // Fuzzy fallback — find partial matches + const fuzzy = Object.keys(this.index).filter(k => + k.includes(concept) || concept.includes(k) + ).filter(k => k !== concept); + + if (matches.length === 0 && fuzzy.length === 0) { + // Hybrid discovery: fall back to live VFS scan + const shellResult = this._hybridShellCheck(concept); + if (shellResult.length > 0) { + this.terminal.print(`🔍 Index empty for "${concept}" but found ${shellResult.length} file(s) via live scan. Run nvfs-index to refresh.`, 'warning'); + for (const f of shellResult.slice(0, this._resolveFiles)) { + this.terminal.print(` ${f.path}${f.summary ? ' — ' + f.summary : ''}`, 'success'); + } + if (shellResult.length > this._resolveFiles) { + this.terminal.print(` ... and ${shellResult.length - this._resolveFiles} more`, 'info'); + } + return; + } + return this.terminal.print(`No files found for concept "${concept}"`, 'warning'); + } + + if (matches.length > 0) { + this.terminal.print(`📂 "${concept}" → ${matches.length} file(s):`, 'info'); + for (const m of matches.slice(0, this._resolveFiles)) { + this.terminal.print(` ${m.path}`, 'success'); + if (m.summary) this.terminal.print(` ${m.summary}`, '') + } + if (matches.length > this._resolveFiles) { + this.terminal.print(` ... and ${matches.length - this._resolveFiles} more`, 'info'); + } + } + + if (fuzzy.length > 0) { + this.terminal.print(`\n🔀 Similar concepts: ${fuzzy.slice(0, this._fuzzyLimit).join(', ')}`, 'info'); + } + } + + // ── Status ── + + showStatus() { + const count = Object.keys(this.index).length; + if (count === 0) { + // Try loading from VFS + const fs = this.terminal.fs; + const saved = fs.fsReadJSON(this.indexPath); + if (saved) { + this.index = saved; + this.lastScan = '(loaded from disk)'; + this.terminal.print(`📖 Loaded index from ${this.indexPath}`, 'info'); + } else { + return this.terminal.print('No semantic index found. Run nvfs-index first.', 'warning'); + } + } + + const totalFiles = new Set(); + for (const entries of Object.values(this.index)) { + for (const e of entries) totalFiles.add(e.path); + } + + this.terminal.print(`🧠 Neural VFS Index`, 'info'); + this.terminal.print(` Concepts: ${Object.keys(this.index).length}`, ''); + this.terminal.print(` Files indexed: ${totalFiles.size}`, ''); + this.terminal.print(` Last scan: ${this.lastScan || 'never'}`, ''); + this.terminal.print(` Mode: ${this._deepIndexEnabled ? '🌊 Deep (LLM-assisted)' : '⚡ Fast (metadata-only)'}`, ''); + // LLM/fast concept breakdown — proves persistence is working without DevTools + const allEntries = Object.values(this.index).flat(); + const llmCount = allEntries.filter(e => e.source === 'llm').length; + const fastCount = allEntries.length - llmCount; + const llmFiles = new Set(allEntries.filter(e => e.source === 'llm').map(e => e.path)).size; + this.terminal.print(` Fast concepts: ${fastCount}`, ''); + this.terminal.print(` LLM concepts: ${llmCount} (across ${llmFiles} file${llmFiles === 1 ? '' : 's'})`, ''); + + // Staleness check + if (this.lastScan && this.lastScan !== '(loaded from disk)') { + const ageMs = Date.now() - new Date(this.lastScan).getTime(); + const ageHours = Math.floor(ageMs / 3600000); + const ageMinutes = Math.floor((ageMs % 3600000) / 60000); + if (ageHours >= 1) { + this.terminal.print(` Age: ${ageHours}h${ageMinutes > 0 ? ` ${ageMinutes}m` : ''}`, ''); + } + if (ageHours >= 4) { + this.terminal.print(` ⚠️ Index is ${ageHours} hours old — structural changes may be missing. Run nvfs-index to refresh.`, 'warning'); + } + } + + // Dirty file warning + if (this._dirtyPaths && this._dirtyPaths.size > 0) { + this.terminal.print(` ⚠️ ${this._dirtyPaths.size} file(s) changed since last scan (auto-sync pending)`, 'warning'); + } + + // Alias count + const aliasCount = Object.keys(this._aliasMap).length; + if (aliasCount > 0) { + this.terminal.print(` Aliases: ${aliasCount}`, ''); + } + } + + // ── Tree View ── + + tree(args) { + const concepts = Object.keys(this.index); + if (concepts.length === 0) { + return this.terminal.print('No index. Run nvfs-index first.', 'warning'); + } + + const filter = args[0] ? args[0].toLowerCase() : null; + const filtered = filter ? concepts.filter(c => c.includes(filter)) : concepts; + + this.terminal.print(`🌳 Concept Tree${filter ? ` (filter: "${filter}")` : ''}:`, 'info'); + for (const concept of filtered.slice(0, this._treeConcepts)) { + this.terminal.print(` 📁 ${concept}`, 'success'); + for (const entry of this.index[concept].slice(0, this._treeFiles)) { + this.terminal.print(` └─ ${entry.path}`, ''); + } + if (this.index[concept].length > this._treeFiles) { + this.terminal.print(` └─ ... (+${this.index[concept].length - this._treeFiles} more)`, ''); + } + } + if (filtered.length > this._treeConcepts) { + this.terminal.print(` ... and ${filtered.length - this._treeConcepts} more concepts`, 'info'); + } + } + + // ── Nvfs Config ── + + nvfsConfig(args) { + if (args.length === 0) { + this.terminal.print('=== Neural VFS Config ===', 'info'); + this.terminal.print(`fuzzy-limit: ${this._fuzzyLimit} (default: 10)`, ''); + this.terminal.print(`tree-files: ${this._treeFiles} (default: 5)`, ''); + this.terminal.print(`tree-concepts: ${this._treeConcepts} (default: 15)`, ''); + this.terminal.print(`resolve-files: ${this._resolveFiles} (default: 20)`, ''); + this.terminal.print(`deep-index: ${this._deepIndexEnabled ? 'on' : 'off'} (LLM-assisted concept extraction)`, ''); + this.terminal.print(`deep-model: ${this._deepModel} (model for deep indexing)`, ''); + this.terminal.print('Usage: nvfs-config <key> <value>', 'info'); + this.terminal.print('Keys: fuzzy-limit, tree-files, tree-concepts, resolve-files, deep-index (on|off), deep-model <name>', 'info'); + return; + } + const key = args[0]; + if (key === 'deep-index') { + const val = args[1]; + if (val !== 'on' && val !== 'off') return this.terminal.print('deep-index must be "on" or "off"', 'warning'); + this._deepIndexEnabled = val === 'on'; + localStorage.setItem('webx11_nvfs_deep', this._deepIndexEnabled ? 'true' : 'false'); + // If turning off mid-queue, abort pending deep work + if (!this._deepIndexEnabled && this._deepIndexing) { + this._deepQueue = []; + this._deepIndexing = false; + this._deepProcessed = 0; + this._deepTotal = 0; + this._updateStatusBar(); + this.terminal.print(' Deep index queue aborted', 'warning'); + } + this.terminal.print(`nvfs-config deep-index set to ${val}`, 'success'); + if (this._deepIndexEnabled) this.terminal.print(` Deep mode uses ${this._deepModel} for semantic concept extraction`, 'info'); + return; + } + if (key === 'deep-model') { + const val = args.slice(1).join(' '); + if (!val) return this.terminal.print('Usage: nvfs-config deep-model <model-name>', 'warning'); + this._deepModel = val; + localStorage.setItem('webx11_nvfs_deep_model', val); + this.terminal.print(`nvfs-config deep-model set to ${val}`, 'success'); + return; + } + const val = parseInt(args[1]); + if (isNaN(val) || val < 1 || val > 200) return this.terminal.print('Value must be 1-200', 'warning'); + if (key === 'fuzzy-limit') { this._fuzzyLimit = val; } + else if (key === 'tree-files') { this._treeFiles = val; } + else if (key === 'tree-concepts') { this._treeConcepts = val; } + else if (key === 'resolve-files') { this._resolveFiles = val; } + else { return this.terminal.print(`Unknown key: ${key}. Valid: fuzzy-limit, tree-files, tree-concepts, resolve-files, deep-index (on|off), deep-model <name>`, 'warning'); } + this._saveConfig(); + this.terminal.print(`nvfs-config ${key} set to ${val}`, 'success'); + } + + // ── Nvfs Alias ── + + nvfsAlias(args) { + const action = args[0]; + if (!action || action === 'list') { + const keys = Object.keys(this._aliasMap); + if (keys.length === 0) return this.terminal.print('No aliases defined. Use nvfs-alias add <name> <target,paths>', 'info'); + this.terminal.print('🔗 Concept Aliases:', 'info'); + for (const [name, targets] of Object.entries(this._aliasMap)) { + this.terminal.print(` ${name} → ${targets.join(', ')}`, 'success'); + } + return; + } + if (action === 'add') { + const name = args[1]; + if (!name || !args[2]) return this.terminal.print('Usage: nvfs-alias add <name> <target1,target2,...>', 'warning'); + const targets = args.slice(2).join(' ').split(',').map(t => t.trim()).filter(Boolean); + this._aliasMap[name] = targets; + this._saveAliases(); + this.terminal.print(`Alias "${name}" added (${targets.length} target(s))`, 'success'); + return; + } + if (action === 'remove') { + const name = args[1]; + if (!name || !this._aliasMap[name]) return this.terminal.print('Alias not found', 'warning'); + delete this._aliasMap[name]; + this._saveAliases(); + this.terminal.print(`Alias "${name}" removed`, 'success'); + return; + } + if (action === 'clear') { + this._aliasMap = {}; + this._saveAliases(); + this.terminal.print('All aliases cleared', 'success'); + return; + } + this.terminal.print('Usage: nvfs-alias list|add <name> <targets>|remove <name>|clear', 'warning'); + } + + // ── Private Helpers ── + + _walk(path, fs, index, cb) { + const entries = fs.readDir(path); + if (!entries) return; + for (const entry of entries) { + const fullPath = path === '/' ? '/' + entry.name : path + '/' + entry.name; + if (entry.type === 'file') { + cb(fullPath, { type: 'file', content: fs.readFile(fullPath) || '' }); + } else if (entry.type === 'dir') { + this._walk(fullPath, fs, index, cb); + } + } + } + + _isSystemFile(path) { + const normalized = path.startsWith('/') ? path : '/' + path; + const sysPrefixes = ['/.trash', '/.semantic_index', '/.memory_graph', '/_snapshots', '/_sessions', '/logs', '/memory', '/system', '/sys', '/_mods', '/backups']; + if (sysPrefixes.some(p => normalized.startsWith(p))) return true; + if (normalized.includes('/node_modules/')) return true; + return false; + } + + _contentHash(str) { + if (!str) return ''; + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash) + str.charCodeAt(i); + hash = hash & hash; + } + return Math.abs(hash).toString(36).slice(0, 12); + } + + _loadHashCache(fs) { + const saved = fs.fsReadJSON(this.hashCachePath); + if (saved && typeof saved === 'object') this._hashCache = saved; + } + + _getCached(path, hash) { + const c = this._hashCache[path]; + if (c && c.hash === hash) return c; + return null; + } + + _extractConcepts(path, content) { + const concepts = new Set(); + const name = path.split('/').pop() || ''; + const nameLower = name.toLowerCase(); + + // Extract from filename (split on camelCase, kebab, dots, underscores) + const words = nameLower + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[-_.]/g, ' ') + .replace(/\.(md|js|html|json|css|txt|yaml|yml|toml|env|sh)$/i, '') + .split(/\s+/) + .filter(w => w.length > 2 && !/^\d+$/.test(w)); + + for (const word of words) { + concepts.add(word); + } + + // Extract file extension as concept + const ext = path.split('.').pop(); + if (ext && ['md', 'js', 'html', 'json', 'css'].includes(ext)) { + concepts.add(ext + '-file'); + } + + // Extract path-level concepts + const parts = path.split('/').filter(p => p.length > 0); + for (const part of parts) { + if (['modules', 'skills', 'core', 'ext', 'backups', 'chat', 'downloads'].includes(part)) { + concepts.add(part); + } + } + + // Extract #tags from content (YAML frontmatter or inline) + if (content) { + let tags = []; + const yamlMatch = content.match(/tags:\s*\[([^\]]+)\]/i); + if (yamlMatch) { + tags = yamlMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '').replace(/^#/, '')); + } else { + const hashTags = content.match(/#[a-zA-Z]\w+/g) || []; + tags = hashTags.map(t => t.slice(1)); + } + for (const tag of tags) { + if (tag.length > 2) concepts.add(tag.toLowerCase()); + } + + // Extract first H1/H2 as concept + const headerMatch = content.match(/^#\s+(.+)/m) || content.match(/^##\s+(.+)/m); + if (headerMatch) { + const header = headerMatch[1].toLowerCase().replace(/[^a-z0-9\s]/g, '').trim(); + if (header.length > 3 && header.length < 40) { + concepts.add(header); + } + } + } + + return [...concepts]; + } + + _hybridShellCheck(concept) { + const fs = this.terminal?.fs; + if (!fs) return []; + const words = concept.split(/\s+/).filter(w => w.length > 2); + if (words.length === 0) return []; + const results = []; + this._walk('/', fs, {}, (path, node) => { + if (this._isSystemFile(path)) return; + const lower = path.toLowerCase(); + if (words.some(w => lower.includes(w))) { + results.push({ path, summary: this._summarize(node.content || '') }); + } + }); + return results; + } + + _summarize(content) { + if (!content || content.length < 10) return ''; + // Strip YAML frontmatter + const cleaned = content.replace(/---[\s\S]*?---/, '').trim(); + + // 1. Try first ## section heading + const sectionMatch = cleaned.match(/^##\s+(.+)/m); + if (sectionMatch && sectionMatch[1].trim().length > 5) { + const s = sectionMatch[1].trim(); + return s.slice(0, 100) + (s.length > 100 ? '...' : ''); + } + + // 2. Try first paragraph > 40 chars after frontmatter + const lines = cleaned.split('\n').filter(l => l.trim().length > 0); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length > 40 && !trimmed.startsWith('#') && !trimmed.startsWith('//')) { + return trimmed.slice(0, 100) + (trimmed.length > 100 ? '...' : ''); + } + } + + // 3. First meaningful sentence (> 30 chars) + const sentence = cleaned.match(/[^.!?\n]+[.!?]/); + if (sentence && sentence[0].trim().length > 30) { + const s = sentence[0].trim(); + return s.slice(0, 100) + (s.length > 100 ? '...' : ''); + } + + // 4. Fallback: first line > 20 chars (original behavior) + const firstLine = lines.find(l => l.trim().length > 20); + if (firstLine) { + const tl = firstLine.trim(); + return tl.slice(0, 100) + (tl.length > 100 ? '...' : ''); + } + return ''; + } + + _indexSkills(fs, index) { + const skills = fs.readDir('/skills'); + if (!skills) return; + for (const skill of skills) { + if (skill.type !== 'file' || !skill.name.endsWith('.md')) continue; + // Already indexed by _walk — but we add a "skill" tag + const base = skill.name.replace('.md', ''); + if (!index['skill']) index['skill'] = []; + index['skill'].push({ path: '/skills/' + skill.name, summary: 'Skill: ' + base }); + } + } + + _saveIndex(fs) { + try { + if (!fs.readDir('/.semantic_index')) { + fs.mkdirRecursive('/.semantic_index'); + } + fs.fsWriteJSON(this.indexPath, this.index); + // Also persist content hash cache for incremental diffing + fs.fsWriteJSON(this.hashCachePath, this._hashCache); + } catch (e) { + console.error('NeuralVFS save error:', e); + } + } + + // ── Auto-Sync ── + + markDirty(path) { + this._dirtyPaths.add(path); + // Debounced auto-sync: wait 10s after last mutation + if (this._autoSyncTimer) clearTimeout(this._autoSyncTimer); + this._autoSyncTimer = setTimeout(() => this._reindexDirtyPaths(), 10000); + } + + async _reindexDirtyPaths() { + if (this._dirtyPaths.size === 0) return; + const fs = this.terminal?.fs; + if (!fs) { this._dirtyPaths.clear(); return; } + + const start = Date.now(); + let reindexed = 0; + const reindexedPaths = []; + this._loadHashCache(fs); + + for (const path of [...this._dirtyPaths]) { + const content = fs.readFile(path); + if (content !== null) { + const hash = this._contentHash(content); + const cached = this._getCached(path, hash); + if (!cached) { + this._reindexFile(path, content, this.index, this._deepIndexEnabled); + this._hashCache[path] = { hash }; + reindexed++; + reindexedPaths.push(path); + } + } else { + // File was deleted — remove from index + for (const entries of Object.values(this.index)) { + const idx = entries.findIndex(e => e.path === path); + if (idx !== -1) entries.splice(idx, 1); + } + delete this._hashCache[path]; + } + } + this._dirtyPaths.clear(); + this._autoSyncTimer = null; + this.lastScan = new Date().toISOString(); + this._saveIndex(fs); + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + if (reindexed > 0) { + this.terminal?.print(`🔄 Auto-sync: re-indexed ${reindexed} changed file(s) in ${elapsed}s`, 'success'); + const cap = 20; + for (const p of reindexedPaths.slice(0, cap)) { + this.terminal?.print(` • ${p}`, ''); + } + if (reindexedPaths.length > cap) { + this.terminal?.print(` ... and ${reindexedPaths.length - cap} more`, 'info'); + } + } + } + + // ── Enum helpers for autocomplete ── + + _getNvfsConfigKeys() { + return ['fuzzy-limit', 'tree-files', 'tree-concepts', 'resolve-files', 'deep-index', 'deep-model']; + } + + _getNvfsAliasActions() { + return ['list', 'add', 'remove', 'clear']; + } + + // ── Aliases ── + + _loadAliases() { + try { const r = localStorage.getItem('webx11_nvfs_aliases'); if (r) this._aliasMap = JSON.parse(r); } catch (e) {} + } + _saveAliases() { + localStorage.setItem('webx11_nvfs_aliases', JSON.stringify(this._aliasMap)); + } + + // ── Status Bar Polling ── + + _updateStatusBar() { + if (!this.terminal || !this.terminal.statusBar) return; + const el = this.terminal.statusBar; + if (this._deepIndexing) { + const pct = this._deepTotal > 0 ? Math.round(this._deepProcessed / this._deepTotal * 100) : 0; + el.textContent = `🧠 Deep Indexing... ${this._deepProcessed}/${this._deepTotal} (${pct}%)`; + el.style.color = 'var(--accent, #00ff88)'; + } else if (this._autoSyncTimer !== null || this._dirtyPaths.size > 0) { + el.textContent = `🔄 Auto-sync pending (${this._dirtyPaths.size} dirty)`; + el.style.color = 'var(--warning, #ffaa00)'; + } else { + const count = Object.keys(this.index).length; + el.textContent = `🧠 ${count} concepts${this._deepIndexEnabled ? ' 🌊' : ''}`; + el.style.color = ''; + } + } + + // ── Deep Indexing (LLM-assisted) ── + + _parseLLMConcepts(llmResponse) { + // LLM returns markdown bullet list: "- concept: description" + const concepts = []; + if (!llmResponse) return concepts; + const lines = llmResponse.split('\n'); + for (const line of lines) { + const trimmed = line.replace(/^[-*\s]+/, '').trim(); + if (!trimmed) continue; + // Split on first colon or comma or dash + const colonIdx = trimmed.indexOf(':'); + const commaIdx = trimmed.indexOf(','); + const delimIdx = colonIdx > 0 ? colonIdx : (commaIdx > 0 ? commaIdx : -1); + const concept = delimIdx > 0 ? trimmed.slice(0, delimIdx).trim().toLowerCase() : trimmed.toLowerCase(); + if (concept && concept.length > 2 && concept.length < 60) { + concepts.push(concept); + } + } + return [...new Set(concepts)]; + } + + async _callLLM(prompt, context) { + const om = this.terminal?.ollamaManager; + if (!om || !om.connected) return null; + const fullPrompt = `Extract 2-5 key searchable concepts from this file. Return one per line as lowercase keywords (1-3 words). + +File: ${context.path} +Content: +${context.content.slice(0, 2000)} + +Concepts:`; + try { + const result = await om._chatWithProvider(om.currentProvider, fullPrompt, this._deepModel); + return result; + } catch (e) { + console.warn('LLM concept extraction failed:', e); + return null; + } + } + + async _processDeepQueue(index) { + this._deepProcessed = 0; + this._deepTotal = this._deepQueue.length; + const batchSize = 3; + const results = []; + + while (this._deepQueue.length > 0) { + // Check cancel between batches + if (this._cancelRequested) { + this._cancelRequested = false; + this._deepIndexing = false; + this._deepQueue = []; + this._deepProcessed = 0; + this._deepTotal = 0; + this._updateStatusBar(); + this.terminal?.print('⛔ Deep index cancelled', 'warning'); + return; + } + const batch = this._deepQueue.splice(0, batchSize); + for (const item of batch) { + // Check cancel before each LLM call so user can abort mid-batch + if (this._cancelRequested) { + this._cancelRequested = false; + this._deepIndexing = false; + this._deepQueue = []; + this._deepProcessed = 0; + this._deepTotal = 0; + this._updateStatusBar(); + // Persist any concepts collected so far before exiting + const vfs = this.terminal?.fs; + if (vfs) this._saveIndex(vfs); + this.terminal?.print('⛔ Deep index cancelled mid-batch', 'warning'); + return; + } + const context = { path: item.path, content: item.content }; + const llmResponse = await this._callLLM('', context); + // Check cancel after the (possibly long) LLM call before processing + if (this._cancelRequested) { + this._cancelRequested = false; + this._deepIndexing = false; + this._deepQueue = []; + this._deepProcessed = 0; + this._deepTotal = 0; + this._updateStatusBar(); + const vfs = this.terminal?.fs; + if (vfs) this._saveIndex(vfs); + this.terminal?.print('⛔ Deep index cancelled after LLM call', 'warning'); + return; + } + if (llmResponse) { + const concepts = this._parseLLMConcepts(llmResponse); + results.push({ path: item.path, concepts }); + for (const concept of concepts) { + if (!index[concept]) index[concept] = []; + const exists = index[concept].some(e => e.path === item.path); + if (!exists) index[concept].push({ + path: item.path, + summary: this._summarize(item.content), + source: 'llm' + }); + } + } + this._deepProcessed++; + // Update status bar live + this._updateStatusBar(); + } + // Yield to UI between batches + await new Promise(r => setTimeout(r, 50)); + } + + this._deepIndexing = false; + this._deepQueue = []; + this._deepProcessed = 0; + this._deepTotal = 0; + this._updateStatusBar(); + + if (results.length > 0) { + const llmConcepts = results.reduce((s, r) => s + r.concepts.length, 0); + const totalLLM = Object.values(index).flat().filter(e => e.source === 'llm').length; + this.terminal?.print(`🧠 Deep pass: added ${llmConcepts} new LLM concepts (${results.length} file${results.length === 1 ? '' : 's'}). Total in index: ${totalLLM}`, 'success'); + // List processed files so user can see if one file keeps re-processing (loop detection) + const cap = 20; + if (results.length <= cap) { + this.terminal?.print(` Files processed this pass:`, 'info'); + for (const r of results) { + this.terminal?.print(` • ${r.path} (${r.concepts.length} concepts)`, ''); + } + } else { + this.terminal?.print(` First ${cap} of ${results.length} files:`, 'info'); + for (const r of results.slice(0, cap)) { + this.terminal?.print(` • ${r.path} (${r.concepts.length} concepts)`, ''); + } + } + } + // Persist LLM-generated concepts to disk so they survive page refresh. + // Do NOT bypass _storageSavePending (race condition: bypassing re-triggers + // a debounced saveToStorage 2s later that can clobber LLM concepts if the + // user edits a file in the meantime). Just trigger a normal save — the + // 2s debounce is fine because the user isn't waiting on the keyboard. + const vfs = this.terminal?.fs; + if (vfs) { + // await any in-flight save so this write lands on a clean slate + if (vfs._storageSavePending) { + await new Promise(resolve => { + const check = setInterval(() => { + if (!vfs._storageSavePending) { clearInterval(check); resolve(); } + }, 50); + }); + } + this._saveIndex(vfs); + } + } + + // ── Nvfs Save (force-persist in-memory index) ── + + async nvfsSave() { + const fs = this.terminal?.fs; + if (!fs) return this.terminal?.print('VFS not available', 'error'); + const count = Object.keys(this.index).length; + if (count === 0) return this.terminal?.print('No index in memory to save', 'warning'); + // Await any in-flight VFS save first — otherwise our write lands mid-flush + // and the debounce timer fires 2s later, clobbering our concepts. + if (fs._storageSavePending) { + await new Promise(resolve => { + const check = setInterval(() => { + if (!fs._storageSavePending) { clearInterval(check); resolve(); } + }, 50); + }); + } + this._saveIndex(fs); + this.terminal?.print(`✅ Saved ${count} concepts to ${this.indexPath}`, 'success'); + this.terminal?.print(' Index will survive page refresh', 'info'); + } + + // ── Nvfs Sync (incremental background) ── + + async nvfsSync(args) { + const useDeep = args && args.includes('--deep'); + if (this._dirtyPaths.size === 0) { + this.terminal.print('No dirty files to sync', 'info'); + return; + } + this.terminal.print(`🔄 Syncing ${this._dirtyPaths.size} dirty file(s)${useDeep ? ' (deep mode)' : ''}...`, 'info'); + const fs = this.terminal?.fs; + if (!fs) return; + await this._reindexDirtyPaths(); + // After reindex, if deep mode, queue remaining queued items for LLM processing + if (useDeep && this._deepQueue.length > 0) { + this._deepIndexing = true; + await this._processDeepQueue(this.index); + } + } + + cancelIndex() { + this._cancelRequested = true; + this.terminal?.print('⏳ Cancelling index...', 'warning'); + } + + async init(terminal) { + // Auto-load saved index from VFS on startup so last index survives refresh + const fs = terminal?.fs || this.terminal?.fs; + if (!fs) return; + // Await VFS async loadFromStorage() — it uses CompressionStream which is async. + // module-manager.js awaits this function (line 33), so the rest of init runs + // before any other module can call nvfs-status. + await Promise.resolve(fs._ready); + const saved = fs.fsReadJSON(this.indexPath); + if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) { + this.index = saved; + this._loadHashCache(fs); + this._loadAliases(); + this.lastScan = '(loaded from disk)'; + // One-time migration: purge any stale /sys/... entries left over from + // pre-v4.5.34 builds where the /sys prefix wasn't in _isSystemFile(). + // These entries cause a permanent re-index loop because /sys files use + // _factory dynamic content (timestamps, heap stats) so their hash + // never matches and they get re-queued every nvfs-index --deep run. + let purgedConcepts = 0; + let purgedFiles = 0; + for (const [concept, entries] of Object.entries(this.index)) { + if (!Array.isArray(entries)) continue; + const before = entries.length; + const filtered = entries.filter(e => !(e.path && e.path.startsWith('/sys'))); + if (filtered.length !== before) purgedConcepts += (before - filtered.length); + if (filtered.length > 0) this.index[concept] = filtered; + else delete this.index[concept]; + } + for (const path of Object.keys(this._hashCache)) { + if (path.startsWith('/sys')) { + delete this._hashCache[path]; + purgedFiles++; + } + } + if (purgedConcepts > 0 || purgedFiles > 0) { + this._saveIndex(fs); + this.terminal?.print(`🧹 Migrated stale index: purged ${purgedConcepts} /sys concepts + ${purgedFiles} hash entries from pre-v4.5.34 build`, 'warning'); + } + } + } +} + +// Required for module system +if (typeof window !== 'undefined') { + window.NeuralVFSModule = NeuralVFSModule; +} + +/* === end core bundle === */ diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index 5273678..44a7574 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -67,7 +67,7 @@ buildSystemPrompt() { } catch (e) {} return 'Current date: ' + dateStr + nvfsNote + '\n' + 'VFS→Modules→AI. nvfs-index for commands. Tool→Observe→Answer. nvfs-resolve. Destructive cmds need confirm.\n' - + 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills\n' + + 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind: vaultmind_query(ep,msg,agent) — Hyperliquid prices/portfolio/chat\n' + 'web_fetch tool: GET for normal pages (returns title+content+word_count), POST for JSON APIs like Hyperliquid /info (pass method="POST" and body as JSON string).\n' + 'Other: floating, webdav, cron, vault, sandbox, agents, wpm, mindmap, memgraph, guardrail, wget, tar, secret-* — discoverable via help'; } @@ -423,6 +423,21 @@ buildSystemPrompt() { required: ['topic'] } } + }, { + type: 'function', + function: { + name: 'vaultmind_query', + description: "Query VaultMind AI for Hyperliquid market data (prices, portfolio, transactions, copy-trade) or ask an AI agent. Uses same-origin /vmai/ API.", + parameters: { + type: 'object', + properties: { + endpoint: { type: 'string', description: 'prices, portfolio, transactions, copy-trade, or chat' }, + message: { type: 'string', description: 'Question for the AI agent (chat endpoint only)' }, + agent: { type: 'string', enum: ['market', 'trading', 'transaction', 'copy'], description: 'Which AI agent to query (chat endpoint only)' } + }, + required: ['endpoint'] + } + } }]; } getHelpInfo() { @@ -439,7 +454,7 @@ buildSystemPrompt() { 'ai-model': 'ai-model [model] — Show or set AI model', 'ai-config': 'ai-config [key] [value] — View or set AI loop config (max-turns, max-context, confirm-timeout(0=disabled))', 'mem-debug': 'mem-debug — Show memory breakdown by component', - 'reset-mem': 'reset-mem — Reset AI memory (logs, patterns) without losing VFS or config' + 'reset-mem': 'reset-mem — Reset AI memory (logs, context, profile, guardrails) without losing VFS or config' }; } registerCommands() { @@ -1859,6 +1874,30 @@ buildSystemPrompt() { case 'list_modified_modules': case 'get_module_status': return await this.runModuleTool(name, argsStr); + case 'vaultmind_query': { + const ep = a.endpoint; + const validEps = ['prices', 'portfolio', 'transactions', 'copy-trade', 'chat']; + if (!validEps.includes(ep)) { + return JSON.stringify({ error: 'Invalid endpoint. Valid: ' + validEps.join(', ') }); + } + try { + let url = '/vmai/' + ep; + let opts = { method: 'GET', headers: { 'Accept': 'application/json' } }; + if (ep === 'chat') { + opts.method = 'POST'; + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify({ + message: a.message || 'What is the current market status?', + agent: a.agent || 'market' + }); + } + const resp = await fetch(url, opts); + const data = await resp.json(); + return JSON.stringify({ success: true, data }); + } catch (e) { + return JSON.stringify({ error: 'VaultMind API error: ' + e.message }); + } + } default: return JSON.stringify({ error: `Unknown tool: ${name}` }); } @@ -2087,31 +2126,80 @@ buildSystemPrompt() { const errLogSize = this._errorLog.length; const freqKeys = Object.keys(this._commandFreq).length; const contextSize = this.context.length; + const factKeys = this._userProfile?.knownFacts ? Object.keys(this._userProfile.knownFacts).length : 0; + const shadowSize = this._shadowSnapshot ? JSON.stringify(this._shadowSnapshot).length : 0; + const guardrailSize = this._guardrailPolicy ? JSON.stringify(this._guardrailPolicy).length : 0; const vfsSize = (localStorage.getItem('webx11_filesystem') || '').length + (localStorage.getItem('webx11_filesystem_v2') || '').length; const aiCtxSize = (localStorage.getItem('webx11_ai_context') || '').length; + const aiPatternSize = (localStorage.getItem('webx11_ai_patterns') || '').length; + const userProfileSize = (localStorage.getItem('webx11_user_profile') || '').length; + const systemPromptSize = (localStorage.getItem('webx11_ai_system_prompt') || '').length; + const guardrailPolicySize = (localStorage.getItem('webx11_guardrail_policy') || '').length; this.terminal.print('=== Memory Debug ===', 'info'); this.terminal.print(` _toolCallLog: ${toolLogSize} entries (cap: 50)`, toolLogSize > 30 ? 'warning' : 'text-muted'); this.terminal.print(` _errorLog: ${errLogSize} entries (cap: 20)`, errLogSize > 10 ? 'warning' : 'text-muted'); - this.terminal.print(` _commandFreq: ${freqKeys} tracked patterns`, freqKeys > 15 ? 'warning' : 'text-muted'); + this.terminal.print(` _commandFreq: ${freqKeys} tracked patterns (cap: 30)`, freqKeys > 20 ? 'warning' : 'text-muted'); this.terminal.print(` AI context: ${contextSize} messages (max: ${this.maxContext * 2})`, 'text-muted'); + this.terminal.print(` User facts: ${factKeys} facts (cap: 50)`, 'text-muted'); this.terminal.print(` VFS localStorage: ${(vfsSize / 1024).toFixed(1)} KB`, 'text-muted'); this.terminal.print(` AI context localStorage: ${(aiCtxSize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` AI patterns localStorage: ${(aiPatternSize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` User profile localStorage: ${(userProfileSize / 1024).toFixed(1)} KB`, userProfileSize > 10240 ? 'warning' : 'text-muted'); + this.terminal.print(` System prompt localStorage: ${(systemPromptSize / 1024).toFixed(1)} KB`, systemPromptSize > 10240 ? 'warning' : 'text-muted'); + this.terminal.print(` Guardrail policy localStorage: ${(guardrailPolicySize / 1024).toFixed(1)} KB`, 'text-muted'); + this.terminal.print(` Runtime shadow snapshot: ${(shadowSize / 1024).toFixed(1)} KB`, shadowSize > 10240 ? 'warning' : 'text-muted'); + this.terminal.print(` Runtime guardrail policy: ${(guardrailSize / 1024).toFixed(1)} KB`, 'text-muted'); const heap = performance.memory ? performance.memory.usedJSHeapSize : null; if (heap) { this.terminal.print(` JS heap: ${(heap / 1048576).toFixed(1)} MB / ${(performance.memory.jsHeapSizeLimit / 1048576).toFixed(1)} MB`, 'text-muted'); } - this.terminal.print('Use reset-mem to clear AI internal logs without touching VFS/config', 'info'); + this.terminal.print('Use reset-mem to clear AI internal logs, context, profile, and guardrail policy', 'info'); } resetMem() { + // Capture size before clearing for the success message + const aiCtxSize = (localStorage.getItem('webx11_ai_context') || '').length; + const userProfileSize = (localStorage.getItem('webx11_user_profile') || '').length; + const patternSize = (localStorage.getItem('webx11_ai_patterns') || '').length; + const guardrailSize = (localStorage.getItem('webx11_guardrail_policy') || '').length; + + // Runtime in-memory resets this._toolCallLog = []; this._errorLog = []; this._commandFreq = {}; this.context = []; + this._messageCount = 0; + this._userProfile = {}; + this._guardrailViolations = {}; + this._shadowSnapshot = null; + this._guardrailPolicy = null; + this._toolDefs = null; // will be rebuilt on next use + + // Persisted localStorage resets localStorage.removeItem('webx11_ai_context'); localStorage.removeItem('webx11_ai_patterns'); - this.terminal.print('AI memory reset: tool logs, error logs, command patterns, and context cleared', 'success'); - this.terminal.print('VFS, config, skills, system prompt, and floating elements preserved', 'text-muted'); + localStorage.removeItem('webx11_user_profile'); + localStorage.removeItem('webx11_guardrail_policy'); + localStorage.removeItem('webx11_ai_system_prompt'); + + // VFS backups that _autoRestoreSession can resurrect + try { + const fs = this.terminal.fs; + if (fs) { + fs.deleteFile('_sessions/_auto-context.json'); + fs.deleteFile('_sessions/_auto.json'); + } + } catch (e) {} + + // Mark reset timestamp so auto-restore skips stale backups + localStorage.setItem('webx11_last_reset', Date.now().toString()); + + const totalCleared = aiCtxSize + userProfileSize + patternSize + guardrailSize; + this.terminal.print(`✅ AI memory reset: ${(totalCleared / 1024).toFixed(1)} KB cleared`, 'success'); + this.terminal.print(' • tool logs, error logs, command patterns, conversation context', 'text-muted'); + this.terminal.print(' • user profile / known facts, guardrail policy, shadow snapshot', 'text-muted'); + this.terminal.print(' • VFS session backups and cached system prompt', 'text-muted'); + this.terminal.print('VFS files, terminal history, AI config, and module registry preserved', 'text-muted'); } showModel(args) { const om = this.terminal.ollamaManager; @@ -2204,6 +2292,7 @@ buildSystemPrompt() { this.terminal.print(`${icon} ${content}...`); }); } + // Extract facts about user from conversation _extractFacts(userMsg, aiResp) { const patterns = [ { re: /my name is (\w+)/i, key: 'name' }, @@ -2215,16 +2304,26 @@ buildSystemPrompt() { { re: /i (?:like|prefer|love) (.+)/i, key: 'preference' }, ]; let changed = false; + if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {}; for (const { re, key } of patterns) { const m = (userMsg + ' ' + (aiResp || '')).match(re); if (m && m[1]) { - if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {}; - if (this._userProfile.knownFacts[key] !== m[1].trim()) { - this._userProfile.knownFacts[key] = m[1].trim(); + const val = m[1].trim(); + if (this._userProfile.knownFacts[key] !== val) { + this._userProfile.knownFacts[key] = val; changed = true; } } } + // v4.5.47: cap knownFacts so the profile doesn't bloat indefinitely + const MAX_FACTS = 50; + const factKeys = Object.keys(this._userProfile.knownFacts); + if (factKeys.length > MAX_FACTS) { + this._userProfile.knownFacts = Object.fromEntries( + factKeys.slice(-MAX_FACTS).map(k => [k, this._userProfile.knownFacts[k]]) + ); + changed = true; + } if (changed) { try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} } @@ -2235,6 +2334,13 @@ buildSystemPrompt() { for (const p of patterns) { if (lower.includes(p)) this._commandFreq[p] = (this._commandFreq[p] || 0) + 1; } + // v4.5.47: cap command frequency map to top 30 patterns to prevent unbounded growth + const MAX_PATTERNS = 30; + const keys = Object.keys(this._commandFreq); + if (keys.length > MAX_PATTERNS) { + const sorted = keys.sort((a, b) => this._commandFreq[b] - this._commandFreq[a]); + this._commandFreq = Object.fromEntries(sorted.slice(0, MAX_PATTERNS).map(k => [k, this._commandFreq[k]])); + } try { localStorage.setItem('webx11_ai_patterns', JSON.stringify(this._commandFreq)); } catch (e) {} } async _autoCreateErrorSkill() { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 0261256..e0fa8e1 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -1,76 +1,14 @@ (function() { 'use strict'; - const BASE = 'modules/core/'; + // Core module bundle loaded synchronously in main.html. + // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - 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-modmgr.js', - 'module-searxng.js', - 'module-servermon.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', - 'secret-vault.js', - 'module-chatstudio.js', - 'module-agent-registry.js', - 'module-leases.js', - 'module-orchestrator.js', - 'module-sandbox.js', - 'module-memory-graph.js', - 'module-predictor.js', - 'module-world-model.js', - 'module-neural-vfs.js' - ]; - - function getSelectedModules() { - try { - const saved = localStorage.getItem('webx11_startup_modules'); - if (!saved) return null; - const parsed = JSON.parse(saved); - if (Array.isArray(parsed)) return parsed; - } catch (e) {} - return null; - } - - function isSelected(name, selected) { - if (!selected) return true; - return selected.includes(name); - } - - function loadScript(url, filename) { - return new Promise((resolve) => { - const existing = document.querySelector(`script[src="${url}"]`); - if (existing) { resolve(); return; } - const el = document.createElement('script'); - el.src = url + (url.includes('?') ? '&t=' : '?t=') + Date.now(); - el.onload = () => resolve(); - el.onerror = () => { - console.warn(`[ModuleLoader] Failed to load: ${filename || url} — continuing`); - // Log to terminal if available - if (typeof window !== 'undefined' && window.webx11 && window.webx11.log) { - window.webx11.log('WARN', 'Loader', 'Failed to load: ' + (filename || url)); - } - resolve(); - }; - document.body.appendChild(el); - }); + const LATEST_VER = '2026-06-13-v4.5.49'; + const CACHE = '?v=' + LATEST_VER; + const stored = localStorage.getItem('webx11_cache_ver'); + if (stored !== LATEST_VER) { + localStorage.setItem('webx11_cache_ver', LATEST_VER); } function setSplash(phase, current, total, filename) { @@ -99,94 +37,37 @@ if (fill) fill.style.background = 'var(--error-color)'; } - function reloadWithSelection() { - location.reload(); - } - - window.__moduleLoaderReload = reloadWithSelection; - - const LATEST_VER = '2026-06-11-v4.5.45'; - const CACHE = '?v=' + LATEST_VER; - const stored = localStorage.getItem('webx11_cache_ver'); - if (stored !== LATEST_VER) { - localStorage.setItem('webx11_cache_ver', LATEST_VER); - } - async function init() { try { - const selected = getSelectedModules(); - const total = FOUNDATION.length + BUILTIN.length; + const total = 3; let count = 0; - // Phase 1: Foundation (always load) - setSplash('Loading foundation layer (1/3)', 0, total, ''); - for (const mod of FOUNDATION) { - count++; - setSplash('Loading foundation layer (1/3)', count, total, mod); - await loadScript(BASE + mod + CACHE, mod); - } - - // Phase 2: Built-in modules (filtered) - setSplash('Loading built-in modules (2/3)', count, total, ''); - // v4.5.43: Get disabled modules from localStorage (set by module-disable command) - let disabledMods = []; - try { disabledMods = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {} + setSplash('Core bundle loaded (1/3)', ++count, total, 'core-bundle.js'); + setSplash('Critical ext bundle loaded (2/3)', ++count, total, 'critical-ext-bundle.js'); - for (const mod of BUILTIN) { - count++; - if (!isSelected(mod, selected)) { - console.log(`[ModuleLoader] Skipped: ${mod} (not in startup selection)`); - setSplash('Loading built-in modules (2/3)', count, total, '⏭ ' + mod); - await new Promise(r => setTimeout(r, 20)); - continue; - } - // v4.5.43: Skip disabled modules - const modName = mod.replace(/\.js$/, ''); - if (disabledMods.includes(modName)) { - console.log(`[ModuleLoader] Skipped: ${mod} (disabled via module-disable)`); - setSplash('Loading built-in modules (2/3)', count, total, '⊘ ' + mod); - await new Promise(r => setTimeout(r, 20)); - continue; - } - setSplash('Loading built-in modules (2/3)', count, total, mod); - await loadScript(BASE + mod + CACHE, mod); - } - - // Phase 3: Initialize terminal setSplash('Initializing terminal (3/3)', total, total, ''); const webx11 = new WebX11Terminal(); window.webx11 = webx11; - // Auto-connect to Ollama after init - setTimeout(async () => { - try { - if (webx11.ollamaManager && !webx11.ollamaManager.connected) { - webx11.log('INFO', 'System', 'WebX11 ' + (window.NEWOS4_VERSION || 'v4.0') + ' started'); - webx11.log('INFO', 'System', 'Default model: ' + webx11.ollamaManager.currentModel); - await webx11.ollamaManager.connect(); - if (typeof webx11.updateOllamaStatus === 'function') { - webx11.updateOllamaStatus(); - } - webx11.print('🤖 Default model: ' + webx11.ollamaManager.currentModel, 'info'); - } - } catch (e) { - // Silently ignore — user can manually connect - } - }, 500); - - // Phase 4: Ext modules (filtered) + // Optional ext modules via packages.json if (typeof webx11._loadExtModules === 'function') { - const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'tutorial', 'shell', 'busybox', 'floating', 'console']; - const extSelected = selected - ? extKeys.filter(k => isSelected(k + '.js', selected)) - : extKeys; - if (extSelected.length > 0) { - webx11._loadExtModules(extSelected); + const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'cryptogzip', 'tutorial', 'floating', 'console']; + const saved = localStorage.getItem('webx11_startup_modules'); + let selected = null; + try { if (saved) selected = JSON.parse(saved); } catch (e) {} + if (selected && Array.isArray(selected)) { + extKeys = extKeys.filter(k => selected.includes(k + '.js')); + } + let disabledMods = []; + try { disabledMods = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {} + extKeys = extKeys.filter(k => !disabledMods.includes(k)); + if (extKeys.length > 0) { + setTimeout(() => webx11._loadExtModules(extKeys), 800); } } hideSplash(); - console.log('[ModuleLoader] All modules loaded successfully'); + console.log('[ModuleLoader] Core loader initialized'); } catch (err) { console.error('[ModuleLoader] Fatal error:', err); showSplashError('Loading failed — ' + err.message); diff --git a/modules/core/module-system.js b/modules/core/module-system.js index 28a19f3..72735a7 100644 --- a/modules/core/module-system.js +++ b/modules/core/module-system.js @@ -17,7 +17,7 @@ 'bug-report': 'bug-report — Generate a markdown bug report to /tmp/ for debugging', 'preview': 'preview <file.html> — 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 only the virtual filesystem (keeps settings, modules, AI profile)', + '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', @@ -317,12 +317,16 @@ 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.fs.root = { type: 'dir', content: {} }; - this.terminal.fs.currentPath = []; this.terminal.outputContainer.innerHTML = ''; this.terminal.print('✅ Factory reset complete. Reloading...', 'success'); setTimeout(() => location.reload(), 1000); @@ -338,6 +342,12 @@ } 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); diff --git a/modules/core/terminal.js b/modules/core/terminal.js index 647060a..1aad5bb 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -857,10 +857,19 @@ } _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 || []; @@ -873,6 +882,9 @@ 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) { @@ -1145,6 +1157,14 @@ 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)) { @@ -1221,21 +1241,35 @@ } catch (e) {} } async _loadExtModules(filterKeys) { - const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell', 'tutorial', 'busybox', 'floating', 'console']; + 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'; - try { - const res = await fetch('packages.json?v=' + cacheVer); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const index = await res.json(); - 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); - } + 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); } - } catch (err) { - this.print(`Failed to auto-load ext modules: ${err.message}`, 'error'); } } async reloadPersistedModules() { |