// Core module bundle// Generated from /var/www/webx11.duckdns.org/spaces/newos4/modules/core /* === model-profiles.js === */ // modules/core/model-profiles.js — per-model-class tool budgets. // class is picked by an ordered rule list; the FIRST match wins, so keep specific ids above size rules. const MODEL_CLASSES = { tiny: { ctx: 8192, readPageChars: 4000, toolResultChars: 3000, toolContextBudgetChars: 12000, fetchChars: 3000 }, small: { ctx: 16384, readPageChars: 8000, toolResultChars: 4000, toolContextBudgetChars: 16000, fetchChars: 4000 }, mid: { ctx: 32768, readPageChars: 16000, toolResultChars: 6000, toolContextBudgetChars: 24000, fetchChars: 6000 }, large: { ctx: 65536, readPageChars: 32000, toolResultChars: 12000, toolContextBudgetChars: 48000, fetchChars: 12000 }, }; // Ordered: first match wins. Only ids we know; everything else falls through to the size parse. const MODEL_IDS = { 'gemma4:31b-cloud': 'large', 'qwen3:0.6b': 'tiny', 'gemma2:2b': 'tiny', 'llama3.1:8b': 'small', 'gemma4:31b': 'mid', }; // "235b" -> large, "31b" -> mid, "8b" -> small, "0.6b"/"2b" -> tiny function classFor(modelId) { const id = String(modelId || '').toLowerCase(); if (!id) return 'mid'; if (MODEL_IDS[id]) return MODEL_IDS[id]; if (id.endsWith('-cloud') || id.includes(':cloud')) return 'large'; const m = id.match(/(\d+(?:\.\d+)?)\s*b\b/); if (!m) return 'mid'; const p = parseFloat(m[1]); if (p <= 3) return 'tiny'; if (p <= 9) return 'small'; if (p <= 32) return 'mid'; return 'large'; } function profileFor(modelId) { return MODEL_CLASSES[classFor(modelId)] || MODEL_CLASSES.mid; } // The bundle concatenates this file FIRST, so this binding exists before any module method runs. // module-aiassist/ollama-manager resolve budgets through it: a require() there resolved relative // to whoever called them and always returned null (found by the live ai-config probe 2026-11-02). const MODEL_PROFILES = { MODEL_CLASSES, classFor, profileFor }; if (typeof module !== 'undefined' && module.exports) module.exports = { MODEL_CLASSES, classFor, profileFor, MODEL_PROFILES }; /* === 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) } // ── Auto-purge trash older than 24 hours ── _purgeTrash() { try { const trash = this.root.content && this.root.content['.trash']; if (!trash || !trash.content) return; const now = Date.now(); const maxAge = 86400000; // 24 hours let purged = 0; for (const name of Object.keys(trash.content)) { const tsMatch = name.match(/^(\d+)/); if (tsMatch) { const ts = parseInt(tsMatch[1], 10); if (now - ts > maxAge) { delete trash.content[name]; purged++; } } } if (purged > 0) { this.saveToStorage(); } } catch (e) { /* silent */ } } // ── Virtual /sys/ filesystem (live runtime state, never persisted) ── _initVirtualMounts() { if (this._virtualMounts) return; const vm = (name, factory) => ({ _factory: factory, _name: name }); const dir = () => ({ _dir: true, content: {} }); const vmCache = {}; // factories can cache costly results within same tick const mm = () => typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; const ai = () => mm()?.WebX11AIAssistantModule || null; const ds = () => mm()?.DebugSupervisorModule || null; const heap = () => typeof performance !== 'undefined' && performance.memory ? performance.memory : null; const fmt = (bytes) => { if (!bytes && bytes !== 0) return 'N/A'; const kb = bytes / 1024; if (kb < 1024) return kb.toFixed(1) + ' KB'; return (kb / 1024).toFixed(1) + ' MB'; }; const fmtBytes = (bytes) => { if (!bytes && bytes !== 0) return 'N/A'; if (bytes < 1024) return bytes + ' B'; const kb = bytes / 1024; if (kb < 1024) return kb.toFixed(1) + ' KB'; return (kb / 1024).toFixed(1) + ' MB'; }; // Walk VFS root to count files/dirs and find largest const vfsWalk = (dir, prefix) => { let files = 0, dirs = 0, total = 0, largest = []; const walk = (node, p) => { if (!node) return; if (node._factory || node._dir) return; // skip virtual if (node.type === 'file') { files++; const size = node.content ? node.content.length : 0; total += size; largest.push({ path: p, size }); } else if (node.type === 'dir') { dirs++; for (const [name, child] of Object.entries(node.content || {})) { walk(child, p + '/' + name); } } }; walk(dir, prefix); largest.sort((a, b) => b.size - a.size); return { files, dirs, total, largest: largest.slice(0, 20) }; }; this._virtualMounts = { // /sys/info info: vm('info', () => [ '=== newOS4 Runtime Debug (/sys/) ===', `Version: ${window.NEWOS4_VERSION || '?'}`, `JS heap: ${fmt(heap()?.usedJSHeapSize)} / ${fmt(heap()?.jsHeapSizeLimit)}`, `DOM nodes: ${typeof document !== 'undefined' ? document.querySelectorAll('*').length : 'N/A'}`, `Loaded modules: ${mm() ? Object.keys(mm()).length : 'N/A'}`, '', ' cat /sys/heap/stats — Memory overview', ' cat /sys/heap/breakdown — What consumes what', ' cat /sys/logs/tool-calls — AI tool call log', ' cat /sys/logs/errors — AI error log', ' cat /sys/vfs/stats — VFS file/dir counts', ' cat /sys/vfs/largest — Top 20 largest files', ' ls /sys/modules/ — Loaded modules', ' cat /sys/config/ai — AI config parameters', ' cat /sys/dom/node-count — DOM element breakdown', ].join('\n')), // /sys/heap/ heap: { _dir: true, content: { stats: vm('stats', () => { const h = heap(); const lsSize = (k) => { try { return (localStorage.getItem(k) || '').length / 1024; } catch(e) { return 0; } }; return [ '=== Heap Stats ===', ` JS heap used: ${fmt(h?.usedJSHeapSize)}`, ` JS heap limit: ${fmt(h?.jsHeapSizeLimit)}`, ` JS heap % used: ${h ? ((h.usedJSHeapSize / h.jsHeapSizeLimit) * 100).toFixed(1) + '%' : 'N/A'}`, '', ` VFS localStorage: ${lsSize('webx11_filesystem_v2').toFixed(1)} KB`, ` AI localStorage: ${lsSize('webx11_ai_context').toFixed(1)} KB`, ` Config localStorage: ${lsSize('webx11_ai_config').toFixed(1)} KB`, ].join('\n'); }), breakdown: vm('breakdown', () => { const a = ai(); const cCount = a?.context?.length || 0; const lCount = a?._toolCallLog?.length || 0; const eCount = ds() ? Object.keys(ds()._errorDB?.errors || {}).length : 0; const modules = mm() ? Object.keys(mm()).length : 0; const domNodes = typeof document !== 'undefined' ? document.querySelectorAll('*').length : 0; return [ '=== Heap Breakdown (estimates) ===', ` Module code + state: ~${(modules * 1.2).toFixed(1)} MB (${modules} modules × ~1.2MB)`, ` VFS tree (in-memory): varies with file count`, ` AI context messages: ${cCount} (max: ${a?.maxContext || '?'})`, ` AI tool call log: ${lCount} (cap: 50)`, ` AI error log: ${eCount} (cap: 20)`, ` DOM nodes: ${domNodes}`, ].join('\n'); }) }}, // /sys/modules/ modules: { _dir: true, content: { list: vm('list', () => { const m = mm(); if (!m) return '(moduleManager not available)'; return Object.keys(m).sort().join('\n'); }) // module subdirectories are dynamic — handled in _virtualLs }}, // /sys/vfs/ vfs: { _dir: true, content: { stats: vm('stats', () => { const w = vfsWalk(this.root, ''); return [ '=== VFS Stats ===', ` Files: ${w.files}`, ` Directories: ${w.dirs}`, ` Total content: ${fmtBytes(w.total)}`, ` localStorage: ${((localStorage.getItem('webx11_filesystem_v2') || '').length / 1024).toFixed(1)} KB (compressed)`, ].join('\n'); }), largest: vm('largest', () => { const w = vfsWalk(this.root, ''); if (w.largest.length === 0) return '(empty VFS)'; const maxW = Math.max(...w.largest.map(f => f.path.length), 8); let out = '=== Top 20 Largest Files ===\n'; w.largest.forEach((f, i) => { out += ` ${(i + 1).toString().padStart(2)}. ${f.path.padEnd(maxW)} ${fmtBytes(f.size)}\n`; }); return out; }) }}, // /sys/logs/ logs: { _dir: true, content: { 'tool-calls': vm('tool-calls', () => { const a = ai(); if (!a || !a._toolCallLog || a._toolCallLog.length === 0) return '(empty)'; return a._toolCallLog.map((e, i) => `[${i}] ${JSON.stringify(e)}`).join('\n'); }), 'errors': vm('errors', () => { const d = ds(); if (!d || !d._errorDB || Object.keys(d._errorDB.errors).length === 0) return '(empty)'; return Object.entries(d._errorDB.errors) .sort((a, b) => b[1].lastSeen - a[1].lastSeen) .map(([hash, e]) => `[${hash}] ${e.type}/${e.severity} ×${e.count}: ${e.message.substring(0, 100)}`) .join('\n'); }), 'commands': vm('commands', () => { const a = ai(); if (!a || !a._commandFreq || Object.keys(a._commandFreq).length === 0) return '(empty)'; const sorted = Object.entries(a._commandFreq).sort((a,b) => b[1] - a[1]); return sorted.map(([cmd, n]) => `${cmd.padEnd(20)} ${n}x`).join('\n'); }) }}, // /sys/dom/ dom: { _dir: true, content: { 'node-count': vm('node-count', () => { if (typeof document === 'undefined') return 'N/A'; const all = document.querySelectorAll('*'); const byTag = {}; all.forEach(el => { const t = el.tagName.toLowerCase(); byTag[t] = (byTag[t] || 0) + 1; }); const sorted = Object.entries(byTag).sort((a,b) => b[1] - a[1]); let out = `Total DOM nodes: ${all.length}\n\n=== By Tag ===\n`; sorted.forEach(([tag, count]) => { out += ` ${tag.padEnd(15)} ${count}\n`; }); return out; }) }}, // /sys/config/ config: { _dir: true, content: { version: vm('version', () => { return `NEWOS4_VERSION: ${window.NEWOS4_VERSION || '?'}\n`; }), ai: vm('ai', () => { const a = ai(); if (!a) return '(AI module not loaded)'; return [ '=== AI Config ===', ` maxContext: ${a.maxContext}`, ` MAX_TOOL_TURNS: ${a.MAX_TOOL_TURNS}`, ` maxContextTokens: ${a.maxContextTokens}`, ` confirmTimeoutMs: ${a._confirmTimeoutMs}`, ` skillDebounceMs: ${a._skillDebounceMs}`, ` context messages: ${a.context?.length || 0}`, ` tool call log cap: 50 (auto-capped)`, ` error log cap: 20 (auto-capped)`, ].join('\n'); }) }} }; } // Check if a path is under /sys/ _isVirtualPath(path) { if (!path) return false; const normalized = path.startsWith('/') ? path : '/' + path; return normalized === '/sys' || normalized.startsWith('/sys/'); } // Resolve a virtual path: returns {dir: virtualNode, name: lastSegment} or null _resolveVirtualPath(path) { this._initVirtualMounts(); const normalized = path.startsWith('/') ? path : '/' + path; let segments = normalized.split('/').filter(s => s); // Remove leading 'sys' if (segments[0] === 'sys') segments = segments.slice(1); if (segments.length === 0) { // /sys itself — return the root virtual mounts node as a dir const allContent = {}; for (const [k, v] of Object.entries(this._virtualMounts)) { if (v._dir) allContent[k] = { type: 'dir', content: v.content, _virtual: true }; else allContent[k] = { type: 'file', content: null, _virtual: true, _factory: v._factory }; } // Add dynamic module subdirs to modules listing if (this._virtualMounts.modules?._dir) { const mm = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (mm) { for (const modName of Object.keys(mm)) { if (!allContent[modName]) { allContent[modName] = { type: 'dir', content: {}, _virtual: true }; } } } } return { dir: { content: allContent }, name: null }; } // Walk the tree let node = this._virtualMounts; for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i]; if (!node[seg]) { // Check dynamic module subdir under /sys/modules/ const mods = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (node === this._virtualMounts?.modules?.content && mods && mods[seg]) { // Dynamic module subdir — create virtual node node = { _dir: true, content: { info: { type: 'file', content: null, _virtual: true, _factory: () => { const mod = mods[seg]; const cmdKeys = mod.commands ? Object.keys(mod.commands) : []; return [ `Module: ${seg}`, ` Type: ${typeof mod}`, ` Commands: ${cmdKeys.join(', ') || '(none)'}`, ` Has help: ${mod.getHelpInfo ? 'yes' : 'no'}`, ` Constructor: ${mod.constructor?.name || 'anonymous'}`, ].join('\n'); }} } }; continue; } return null; } node = node[seg]; if (node && node._dir) node = node.content || {}; } const lastName = segments[segments.length - 1]; if (node && node[lastName]) { const entry = node[lastName]; if (entry._dir) { // Static subdir with known content const allContent = {}; for (const [k, v] of Object.entries(entry.content || {})) { if (v._dir) allContent[k] = { type: 'dir', content: v.content, _virtual: true }; else allContent[k] = { type: 'file', content: null, _virtual: true, _factory: v._factory }; } // Add dynamic module subdirs if (segments[0] === 'modules' || (segments.length >= 2 && segments[segments.length - 2] === 'modules')) { const mm = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (mm) { for (const modName of Object.keys(mm)) { if (!allContent[modName]) { allContent[modName] = { type: 'dir', content: {}, _virtual: true }; } } } } return { dir: { content: allContent }, name: null }; } else { return { dir: { content: node }, name: lastName }; } } // Check if it's a module subdirectory itself (e.g. /sys/modules/calc) const mods = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null; if (node && mods && mods[lastName] && segments.length >= 1) { // It's a module name — return its info dir return { dir: { content: { info: { type: 'file', content: null, _virtual: true, _factory: () => { const mod = mods[lastName]; const cmdKeys = mod.commands ? Object.keys(mod.commands) : []; return [ `Module: ${lastName}`, ` Type: ${typeof mod}`, ` Commands: ${cmdKeys.join(', ') || '(none)'}`, ` Has help: ${mod.getHelpInfo ? 'yes' : 'no'}`, ` Constructor: ${mod.constructor?.name || 'anonymous'}`, ].join('\n'); }} }}, name: null }; } return null; } _u8ToBase64(u8) { let s = ''; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); } _base64ToU8(b64) { const s = atob(b64); const u8 = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); return u8; } async _compress(text) { if (typeof CompressionStream === 'undefined') return null; const blob = new Blob([text]); const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); const resp = new Response(stream); const buf = await resp.arrayBuffer(); return this._u8ToBase64(new Uint8Array(buf)); } async _decompress(b64) { if (typeof DecompressionStream === 'undefined') throw new Error('DecompressionStream not available'); const u8 = this._base64ToU8(b64); const blob = new Blob([u8]); const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); const resp = new Response(stream); return await resp.text(); } async loadFromStorage() { try { const v2 = localStorage.getItem('webx11_filesystem_v2'); if (v2) { try { const text = await this._decompress(v2); const data = JSON.parse(text); this.root = data.root || this.root; this.currentPath = data.currentPath || []; this._purgeTrash(); return true; } catch (e) { console.error('FS v2 decompress error:', e); return this._loadLegacy(); } } // P0: Check IndexedDB fallback if localStorage is empty try { const idbData = await this._loadFromIndexedDB(); if (idbData) { const text = await this._decompress(idbData); const data = JSON.parse(text); this.root = data.root || this.root; this.currentPath = data.currentPath || []; this._usingIndexedDB = true; this._purgeTrash(); console.info('FS loaded from IndexedDB fallback'); return true; } } catch (e) { /* IndexedDB not available or empty */ } return this._loadLegacy(); } catch (e) { console.error('FS load error:', e); return false; } } _loadLegacy() { const saved = localStorage.getItem('webx11_filesystem'); if (saved) { if (saved.length > 524288) { console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); localStorage.removeItem('webx11_filesystem'); return false; } const data = JSON.parse(saved); this.root = data.root || this.root; this.currentPath = data.currentPath || []; console.info('Loaded legacy FS; will migrate to compressed on next save.'); return true; } return false; } _emitChange(path) { // Notify NeuralVFS via window event — zero coupling // Skip internal index/system files to prevent re-trigger loops if (!path) return; const normalized = path.startsWith('/') ? path : '/' + path; const sysPaths = ['/.semantic_index', '/.memory_graph', '/.trash', '/_snapshots', '/_sessions', '/logs', '/memory', '/system', '/_mods', '/backups', '/sys']; if (sysPaths.some(p => normalized.startsWith(p))) return; if (typeof window !== 'undefined' && window.__nvfsMarkDirty) { window.__nvfsMarkDirty(path); } } saveToStorage() { if (this._storageSavePending) return; this._storageSavePending = true; setTimeout(async () => { this._storageSavePending = false; try { const json = JSON.stringify({ root: this.root, currentPath: this.currentPath }); const compressed = await this._compress(json); if (compressed) { try { localStorage.setItem('webx11_filesystem_v2', compressed); localStorage.removeItem('webx11_filesystem'); } catch (quotaErr) { // P0: IndexedDB fallback when localStorage quota exceeded console.warn('localStorage quota exceeded, falling back to IndexedDB:', quotaErr.message); await this._saveToIndexedDB(compressed); this._usingIndexedDB = true; } } else { localStorage.setItem('webx11_filesystem', json); } } catch (e) { console.error('FS save error:', e); } }, 2000); } // P0: IndexedDB fallback storage for large VFS data _openIDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('webx11_vfs', 1); req.onupgradeneeded = () => { req.result.createObjectStore('vfs'); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async _saveToIndexedDB(data) { const db = await this._openIDB(); return new Promise((resolve, reject) => { const tx = db.transaction('vfs', 'readwrite'); tx.objectStore('vfs').put(data, 'filesystem_v2'); tx.oncomplete = () => { db.close(); resolve(true); }; tx.onerror = () => { db.close(); reject(tx.error); }; }); } async _loadFromIndexedDB() { const db = await this._openIDB(); return new Promise((resolve, reject) => { const tx = db.transaction('vfs', 'readonly'); const req = tx.objectStore('vfs').get('filesystem_v2'); req.onsuccess = () => { db.close(); resolve(req.result || null); }; req.onerror = () => { db.close(); reject(req.error); }; }); } getCurrentDir() { let dir = this.root; for (let part of this.currentPath) { if (dir.content[part] && dir.content[part].type === 'dir') { dir = dir.content[part]; } else { return null; } } return dir; } _toAbsoluteSegments(path) { if (!path || path === '.' || path === '') return [...this.currentPath]; if (path === '/') return []; const parts = path.split('/').filter(p => p && p !== '.'); let base = path.startsWith('/') ? [] : [...this.currentPath]; const result = [...base]; for (const p of parts) { if (p === '..') { if (result.length > 0) result.pop(); else return null; } else { result.push(p); } } return result; } _resolvePath(path) { if (this._isVirtualPath(path)) return this._resolveVirtualPath(path); const segs = this._toAbsoluteSegments(path); if (segs === null) return null; if (segs.length === 0) return { dir: this.root, name: null }; let dir = this.root; for (let i = 0; i < segs.length - 1; i++) { if (!dir.content[segs[i]] || dir.content[segs[i]].type !== 'dir') return null; dir = dir.content[segs[i]]; } return { dir, name: segs[segs.length - 1] }; } // newOS4 patch: _resolvePath() validates intermediate segments only, so it returns a // result for a leaf that does not exist. Mutating callers then acted on a phantom // path and reported success (deleteCheckpoint printed 'Deleted checkpoint' and // deleted nothing). _requireLeaf() refuses any resolved path whose leaf is absent. _requireLeaf(resolved) { if (!resolved) return null; if (resolved.name) { const node = (resolved.dir && resolved.dir.content) ? resolved.dir.content[resolved.name] : null; if (!node) return null; } return resolved; } _resolveDir(path) { if (this._isVirtualPath(path)) { const r = this._resolveVirtualPath(path); 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/ — but ONLY when the directory being listed IS the root. // The old test was on the argument (`!path`), not on the result: `cd _agents; ls` // printed a phantom `sys` inside _agents, and a follow-up list_files('_agents/sys') // failed with 'Directory not found'. The listing lied; the resolver was right. if (dir === this.root && !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._requireLeaf(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)); this._pruneTrash(); const resolved = this._resolvePath(path); if (resolved) delete resolved.dir.content[resolved.name]; this.saveToStorage(); this._emitChange(path); } catch (e) { console.error('Trash error:', e); } } trashDir(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') { try { const name = resolved.name; const ts = Date.now(); if (!this.root.content['.trash']) { this.root.content['.trash'] = { type: 'dir', content: {} }; } const trash = this.root.content['.trash']; // Keep the subtree intact so it can be restored later; the old flatten loop // turned every descendant into a sibling file and lost empty dirs entirely. const baseName = ts + '-dir-' + name; let unique = baseName; let ctr = 0; while (trash.content[unique]) { unique = baseName + '-' + (++ctr); } trash.content[unique] = JSON.parse(JSON.stringify(node)); this._pruneTrash(); delete resolved.dir.content[resolved.name]; this.saveToStorage(); this._emitChange(path); return true; } catch (e) { console.error('Trash dir error:', e); } } return false; } // Reported 2026-10-18 ("this.fs.emptyTrash is not a function") and verified 2026-10-23: // terminal.js:1777 called this method but nothing ever defined it, so `empty-trash --confirm` // threw instead of emptying. Returns true only when something was actually removed. emptyTrash() { const trash = this.root.content && this.root.content['.trash']; if (!trash || !trash.content) return false; const names = Object.keys(trash.content); if (!names.length) return false; names.forEach(n => { delete trash.content[n]; }); this.saveToStorage(); this._emitChange('/.trash'); return true; } _pruneTrash(maxEntries = 50) { // Bounded trash: deletions are moves-to-trash, so without a cap / .trash/ was an // unbounded second accumulator. Names start with a millis timestamp → sort = oldest first. const trash = this.root.content['.trash']; if (!trash) return 0; const keys = Object.keys(trash.content); if (keys.length <= maxEntries) return 0; keys.sort(); const drop = keys.slice(0, keys.length - maxEntries); drop.forEach(k => { delete trash.content[k]; }); return drop.length; } 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; // mv a a is a no-op, not a delete (assign-then-delete on the same key used to erase it) if (src.dir === dst.dir && src.name === dst.name) return true; const existing = dst.dir.content[dst.name]; // Never clobber a directory node: its children would vanish with it (MAINT-010). if (existing && existing.type === 'dir' && !existing._virtual) 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; const existing = dst.dir.content[dst.name]; // Never clobber a directory node: its children would vanish with it (MAINT-010). if (existing && existing.type === 'dir' && !existing._virtual) 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/ — but ONLY when the directory being listed IS the root. // The old test was on the argument (`!path`), not on the result: `cd _agents; ls` // printed a phantom `sys` inside _agents, and a follow-up list_files('_agents/sys') // failed with 'Directory not found'. The listing lied; the resolver was right. if (dir === this.root && !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'; // The loop pushes the virtual 'sys' name into `entries`, which has no node behind it: // dereferencing dir.content['sys'].type threw 'Cannot read properties of undefined' // and `tree` died on the root listing — often after printing a partial tree, which is // where the disagreeing file counts came from. const child = dir.content[name]; if (child && child.type === 'dir') { 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 = []; // v4.6.64: an invalid pattern must not throw into the error ledger (packet §8). let regex; try { regex = new RegExp(pattern, 'i'); } catch (e) { this._lastPatternError = e.message; return []; } 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 = []; // v4.6.64: see find() above — a bad pattern is a result, not an exception. let regex; try { regex = new RegExp(pattern, 'i'); } catch (e) { this._lastPatternError = e.message; return []; } 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(); name = String(name).trim(); if (!name || name.indexOf('/') !== -1) { throw new Error('checkpoint name must be non-empty and must not contain "/"'); } // Two unnamed calls can land in the same millisecond; without this the second silently // overwrites the first (measured: 6 calls -> 3 distinct names). if (this.listCheckpoints().indexOf(name) !== -1) { let n = 2; while (this.listCheckpoints().indexOf(name + '-' + n) !== -1) n++; name = name + '-' + n; } const createdAt = new Date().toISOString(); // Strip system dirs to prevent fractal bloat const rootClone = JSON.parse(JSON.stringify(this.root)); if (rootClone && rootClone.content) { delete rootClone.content['_sessions']; delete rootClone.content['_snapshots']; delete rootClone.content['.trash']; // newOS4 patch: module originals and the semantic index dominated every // snapshot (one checkpoint reached 4.13MB) and grew on each new checkpoint. delete rootClone.content['_mods']; delete rootClone.content['.semantic_index']; } const data = { version: 1, createdAt, root: rootClone, currentPath: [...this.currentPath] }; if (!this.readDir('/_snapshots')) { this.mkdirRecursive('/_snapshots'); } this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data); this.saveToStorage(); // Auto-manage: keep the 3 NEWEST checkpoints — by createdAt, not by name, so a custom // name ("aaa-oldest") cannot pin itself forever while newer snapshots are deleted. const all = this.listCheckpoints(); if (all.length > 3) { const aged = all.map(n => { const snap = this.fsReadJSON('/_snapshots/checkpoint-' + n + '.json'); return { name: n, at: (snap && snap.createdAt) || '' }; }).sort((a, b) => (a.at < b.at ? -1 : (a.at > b.at ? 1 : 0))); for (const old of aged.slice(0, aged.length - 3)) this.deleteCheckpoint(old.name); } return name; } 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; } // The snapshot deliberately omits scaffolding (bloat guard at :944-950). Restoring the // clone verbatim deletes every checkpoint, every agent session and the trash, so keep the // live copies and re-attach them after the root swap. const keep = {}; for (const dir of ['_snapshots', '_sessions', '.trash', '_mods', '.semantic_index']) { const node = this.root && this.root.content ? this.root.content[dir] : null; if (node) keep[dir] = node; } this.root = raw.root; this.currentPath = raw.currentPath || []; if (!this.root.content) this.root.content = {}; for (const dir of Object.keys(keep)) this.root.content[dir] = keep[dir]; 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._requireLeaf(this._resolvePath(path)); if (resolved && resolved.name) { delete resolved.dir.content[resolved.name]; this.saveToStorage(); return true; } return false; } // Old-compat aliases for agent tools and SkillsModule read(path) { return this.readFile(path); } write(path, content) { return this.writeFile(path, content); } delete(path) { return this.deleteFile(path); } mkdir(path) { return this.mkdirRecursive(path); } list(path) { return this.ls(path); } dir(path) { return this.ls(path); } reset() { this.root = { type: 'dir', content: {} }; this.currentPath = []; // Wipe all localStorage keys ['webx11_filesystem', 'webx11_filesystem_v2'].forEach(k => localStorage.removeItem(k)); // P0: Also clear IndexedDB fallback try { indexedDB.deleteDatabase('webx11_vfs'); } catch (e) {} this._usingIndexedDB = false; return true; } } // Also expose for module system window.VirtualFileSystem = VirtualFileSystem; /* === module-manager.js === */ // Extracted from newOS4 index.html — ModuleManager class ModuleManager { constructor(terminal) { this.terminal = terminal; this.modules = {}; this.moduleRegistry = []; this._timers = []; // v4.5.68: central timer registry this.loadFromStorage(); } // v4.5.68: Central timer registry — all modules should use these instead of raw setInterval _setInterval(fn, ms) { const id = setInterval(fn, ms); this._timers.push(id); return id; } _clearTimer(id) { clearInterval(id); this._timers = this._timers.filter(t => t !== id); } clearAllTimers() { this._timers.forEach(id => clearInterval(id)); this._timers = []; } 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 { // Security: block cross-origin URLs for module loading if (url && url.match(/^https?:\/\//) && !url.startsWith(window.location.origin)) { this.terminal.print(`⚠️ Security: only same-origin URLs allowed for module loading`, 'error'); return false; } 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 { async _httpError(response) { // Surface what the server actually said. "HTTP 429" tells the user nothing; the body // carries e.g. "Demo limit reached. Login or sign up for more." (2026-10-28) let detail = ''; try { const text = await response.text(); if (text) { try { const j = JSON.parse(text); detail = (j && (j.error || j.message)) || ''; if (j && j.needed_usd != null && j.balance_usd != null) { detail += ` (balance $${j.balance_usd}, need $${j.needed_usd})`; } } catch (_) { detail = text.slice(0, 200); } } } catch (_) { /* body unreadable or already consumed */ } if (/^\s* this._flushUsageLog()); } // --- AI Agent Loop Optimization Toggle --- this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') === 'true'; // v4.5.66: default OFF // --- 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') || 'gemma4:31b-cloud'; this.validatorEndpoint = localStorage.getItem('webx11_validator_endpoint') || ''; // --- Model Parameters (temperature, top_p, max_tokens, thinking) --- this.modelParams = this._loadModelParams(); // 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 ───────────────────────────────────────────────── // Ollama's server-side default context is small (2k-4k depending on the build). The agent // sends a system prompt, up to 20 messages and up to 24k chars of tool results, so without an // explicit num_ctx the server silently truncates the prompt — the model "forgets" what it was // just told. Send it on every Ollama request; the OpenAI-compatible branch has its own params. _ctxTokens() { const n = Number(this.numCtx || (typeof localStorage !== 'undefined' ? localStorage.getItem('webx11_ollama_numctx') : 0)); if (Number.isFinite(n) && n >= 512) return Math.floor(n); // No explicit setting: take the model class default (large/cloud 65536, mid 32768, …) try { const mp = this.modelProfiles || (typeof MODEL_PROFILES !== 'undefined' ? MODEL_PROFILES : null); if (mp && mp.profileFor && this.currentModel) return mp.profileFor(this.currentModel).ctx; } catch (e) {} return 16384; } _ollamaOptions(extra) { return Object.assign({ num_ctx: this._ctxTokens() }, extra || {}); } _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 }]; } this.providers.forEach(p => { if (p.endpoint) p.endpoint = p.endpoint.replace(/\/+$/, ''); }); } _saveProviders() { this._setSecret('webx11_llm_providers', JSON.stringify(this.providers)); } _loadModelParams() { try { const saved = localStorage.getItem('webx11_model_params'); if (saved) { const parsed = JSON.parse(saved); return { temperature: parsed.temperature !== undefined ? parsed.temperature : 0.7, top_p: parsed.top_p !== undefined ? parsed.top_p : 0.9, max_tokens: parsed.max_tokens !== undefined ? parsed.max_tokens : 4096, thinking: parsed.thinking || 'off' }; } } catch (e) {} return { temperature: 0.7, top_p: 0.9, max_tokens: 4096, thinking: 'off' }; } _saveModelParams() { try { localStorage.setItem('webx11_model_params', JSON.stringify(this.modelParams)); } catch (e) {} } _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 await this._httpError(response); 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); if (!this._quietConnect) 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 await this._httpError(response); 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); if (!this._quietConnect) 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 { if (!this._quietConnect) 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 await this._httpError(response); 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); if (!this._quietConnect) this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning'); } } this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers'); this.connected = false; return false; } // v4.5.68: Fetch with timeout — wraps fetch() with AbortController to prevent network hangs async fetchWithTimeout(url, options = {}, timeoutMs = 30000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const resp = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timer); return resp; } catch (e) { clearTimeout(timer); if (e.name === 'AbortError') throw new Error(`Request timed out after ${timeoutMs / 1000}s`); throw e; } } // ─── Lazy connect ──────────────────────────────────────────────────────── /** * Every request path calls this first. Nothing connects at boot, so the user's first message * used to die with "Use ollamaconnect first" even when the endpoint was healthy * (reported 2026-10-26/27). One attempt, then the caller prints the real reason. * Concurrent callers share the same in-flight promise. */ async _ensureConnected() { if (this.connected) return true; if (this._connecting) { try { await this._connecting; } catch (e) { /* fall through to the state check */ } return this.connected; } // An automatic connect must not silently rewrite the model the user picked. const savedModel = (typeof localStorage !== 'undefined') ? localStorage.getItem('webx11_ollama_model') : null; this.terminal.print('Ollama: connecting on first use…', 'info'); let timedOut = false; try { // One failure should read as one failure: connect() prints a line per endpoint it // tries, which made an auto-connect failure seven lines of noise. Keep the detail in // the log, print only the outcome for an automatic attempt. this._quietConnect = true; this._connecting = this.connect(); // connect() uses a bare fetch(), so a blackholed endpoint would hang this promise // forever and — because the promise is shared — every later message with it. const deadline = this.connectTimeoutMs || 10000; const outcome = await Promise.race([ this._connecting.then(() => 'done', e => { throw e; }), new Promise(resolve => setTimeout(() => resolve('timeout'), deadline)), ]); timedOut = outcome === 'timeout' && !this.connected; } catch (e) { // A throwing terminal.log() must not leave _connecting poisoned: log defensively. try { this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + (e && e.message ? e.message : e)); } catch (_) {} } finally { this._quietConnect = false; this._connecting = null; } if (this.connected && savedModel && !(this.models || []).some(m => m.name === savedModel)) { this.currentModel = savedModel; if (this.currentProvider) this.currentProvider.model = savedModel; // connect() may already have overwritten the stored preference; put it back. try { localStorage.setItem('webx11_ollama_model', savedModel); } catch (_) {} this.terminal.print('Keeping your saved model "' + savedModel + '" — this endpoint does not list it.', 'warning'); } if (!this.connected) { this.terminal.print(timedOut ? 'Auto-connect timed out after ' + ((this.connectTimeoutMs || 10000) / 1000) + 's — the endpoint is not answering. Check the Ollama tab or run ollamaconnect.' : 'Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning'); } return this.connected; } // ─── Chat (non-streaming) ──────────────────────────────────────────────── async chat(prompt, model = null) { // Per-call state: never let a reason captured by an earlier failure be replayed here. this._lastChatError = null; if (!this.connected && !(await this._ensureConnected())) { this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', '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) { // Name the real cause when one was captured; the generic wording is only a fallback. const reason = this._lastChatError; this.terminal.print( reason ? `Ollama error: ${reason}` : '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; const t0 = Date.now(); 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, options: this._ollamaOptions() }), signal }); if (!response.ok) throw await this._httpError(response); if (this.streamEnabled) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let isFirstToken = true; let lastObj = null; 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; } lastObj = parsed; // capture last for eval_count } catch (e) { // Skip malformed partial lines } } } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count); } else { const data = await response.json(); fullText = data.response || ''; if (fullText) { this.terminal.printStreamingResponse(fullText, false); } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); } 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 await this._httpError(response); if (this.streamEnabled) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let isFirstToken = true; let buffer = ''; let lastUsage = null; 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; } if (parsed.usage) lastUsage = parsed.usage; } catch (e) { // Skip malformed SSE lines } } } } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens); } else { const data = await response.json(); fullText = data.choices?.[0]?.message?.content || ''; if (fullText) { this.terminal.printStreamingResponse(fullText, false); } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); } return fullText || null; } } catch (error) { // Remember why this provider failed so chat() can name the real cause // instead of falling back to the generic "No provider responded". if (error.status === 401) { this._sessionRepairNotice(); } this._lastChatError = error.message || String(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 && !(await this._ensureConnected())) { this.terminal.print('Ollama not connected: the endpoint is unreachable.', '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; const t0 = Date.now(); 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, options: this._ollamaOptions() }), signal }); if (!response.ok) throw await this._httpError(response); let fullResponse = ''; if (this.streamEnabled) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let lastObj = null; 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); } lastObj = obj; } catch (e) { continue; } } } if (buffer.trim()) { try { const obj = JSON.parse(buffer); if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); } lastObj = obj; } catch (e) {} } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count); } else { const data = await response.json(); fullResponse = data.response || ''; if (fullResponse) { onToken && onToken(fullResponse, fullResponse); } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); } 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 await this._httpError(response); let fullResponse = ''; if (this.streamEnabled) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let lastUsage = null; 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); } if (parsed.usage) lastUsage = parsed.usage; } catch (e) { continue; } } } } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens); } else { const data = await response.json(); fullResponse = data.choices?.[0]?.message?.content || ''; if (fullResponse) { onToken && onToken(fullResponse, fullResponse); } const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); } 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) { // Per-call state: never let a reason captured by an earlier failure be replayed here. this._lastChatError = null; if (!this.connected && !(await this._ensureConnected())) { this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', '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; } } // Name the real cause when one was captured; the generic wording is only a fallback. const reason = this._lastChatError; return lastError || { message: null, tool_calls: null, error: reason ? `All providers failed: ${reason}` : '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, options: this._ollamaOptions() }; 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 await this._httpError(response); } const data = await response.json(); this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); this._logUsage(provider, useModel, networkTime * 1000, data.prompt_eval_count, data.eval_count); 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 }; // Apply model parameters (user-configured temperature, top_p, max_tokens, thinking) if (this.modelParams) { body.temperature = this.modelParams.temperature; body.top_p = this.modelParams.top_p; body.max_tokens = this.modelParams.max_tokens; } if (isDeepSeek) { // DeepSeek defaults: user params override hardcoded defaults if (body.temperature === undefined) body.temperature = 0.3; if (body.max_tokens === undefined) body.max_tokens = 4096; body.stop = ['<|end|>']; // DeepSeek thinking mode if (this.modelParams && this.modelParams.thinking !== 'off') { body.thinking = { type: this.modelParams.thinking }; } } 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 await this._httpError(response); } const data = await response.json(); this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); this._logUsage(provider, useModel, networkTime * 1000, data.usage?.prompt_tokens, data.usage?.completion_tokens); 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) { const normalised = toolCalls.map(tc => ({ id: tc.id, type: tc.type || 'function', function: { name: tc.function?.name || '', arguments: tc.function?.arguments || '{}' } })); return { // tool_calls must live INSIDE the assistant message: the caller pushes // result.message and then a role:'tool' reply, and a tool message whose // tool_call_id has no matching assistant call is the HTTP 400 this // branch exists to avoid. message: { role: 'assistant', content: '', tool_calls: normalised }, tool_calls: normalised, 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: 'DeepSeek returned empty response after retry — model may be overloaded or context too long' }; } return { message: message ? { role: message.role || 'assistant', content: content, // OpenAI-compatible providers require tool_calls INSIDE the assistant message; // a bare { role:'tool' } with no preceding tool_calls is rejected with HTTP 400. ...(Array.isArray(toolCalls) && toolCalls.length ? { tool_calls: toolCalls } : {}), } : 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); } if (error.status === 401) { this._sessionRepairNotice(); } this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error'); this._lastChatError = error.message || String(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); const t0 = Date.now(); 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 await this._httpError(response); const data = await response.json(); const text = data.choices?.[0]?.message?.content || ''; const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens); 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 if (useModel && useModel.includes('minicpm5') && tools && tools.length > 0) { this.terminal.print(`🧩 Using XML tool mode for ${useModel}...`, 'info'); try { const toolDesc = tools.map(t => JSON.stringify({type:'function',function:t.function})).join('\n'); const toolContext = { role: 'system', content: `You are a tool orchestrator. When you need to call a tool, output XML like: param-value Available tools: ${toolDesc} Only output XML function calls for tool tasks. If no tool is needed, answer normally.` }; const msgsWithTools = [toolContext, ...messages]; const body = { model: useModel, messages: msgsWithTools, stream: false, options: this._ollamaOptions({ 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 await this._httpError(response); const data = await response.json(); const text = (data.message?.content || '').trim(); const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); // Parse XML function calls from the response const toolCalls = []; const funcRegex = //gs; let match; while ((match = funcRegex.exec(text)) !== null) { const funcName = match[1]; const args = {}; const paramRegex = /(.*?)<\/param>/gs; let pmatch; while ((pmatch = paramRegex.exec(match[0])) !== null) { args[pmatch[1]] = pmatch[3] .replace('', '') .replace(/^\s+|\s+$/g, ''); } if (funcName && Object.keys(args).length > 0) { toolCalls.push({ id: 'call_' + Math.random().toString(36).slice(2, 10), type: 'function', function: { name: funcName, arguments: JSON.stringify(args) } }); } } if (toolCalls.length > 0) { this.terminal.print(`✅ XML tool call(s) parsed from ${useModel}`, 'success'); return { message: { role: 'assistant', content: '' }, tool_calls: toolCalls, error: null }; } return { message: { role: 'assistant', content: text || '(no response)' }, tool_calls: null, error: null }; } catch (e) { if (e.name === 'AbortError') { return { message: null, tool_calls: null, error: 'Cancelled' }; } return { message: null, tool_calls: null, error: e.message }; } } // Original Ollama fallback this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning'); try { const body = { model: useModel, messages, stream: false, options: this._ollamaOptions() }; const response = await fetch(`${this.endpoint}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal }); if (!response.ok) throw await this._httpError(response); const data = await response.json(); const text = data.message?.content || data.response || ''; const latency = Date.now() - t0; this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count); 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 }; } } // ─── Usage Tracking ────────────────────────────────────────────────────── _logUsage(provider, model, latencyMs, promptTokens, completionTokens) { const entry = { ts: Date.now(), provider: provider?.name || 'unknown', model: model || 'unknown', latency: Math.round(latencyMs || 0), prompt_tokens: promptTokens || 0, completion_tokens: completionTokens || 0, total_tokens: (promptTokens || 0) + (completionTokens || 0) }; this._usageBuffer.push(entry); this._debouncedFlush(); } _debouncedFlush() { if (this._usageFlushTimer) clearTimeout(this._usageFlushTimer); if (this._usageBuffer.length >= 5) { this._flushUsageLog(); } else { this._usageFlushTimer = setTimeout(() => this._flushUsageLog(), 2000); } } _flushUsageLog() { if (this._usageBuffer.length === 0) return; this._usageLog.push(...this._usageBuffer); this._usageBuffer = []; if (this._usageLog.length > 500) { this._usageLog = this._usageLog.slice(-500); } this._saveUsageLog(); } _loadUsageLog() { try { const raw = localStorage.getItem('webx11_ollama_usage_log'); this._usageLog = raw ? JSON.parse(raw) : []; } catch (e) { this._usageLog = []; } } _saveUsageLog() { try { localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog)); } catch (e) { // localStorage quota exceeded — trim to 250 this._usageLog = this._usageLog.slice(-250); try { localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog)); } catch (e2) {} } } getUsageStats(filters = {}) { let log = this._usageLog; if (filters.model) { log = log.filter(e => e.model === filters.model); } if (filters.provider) { log = log.filter(e => e.provider === filters.provider); } if (filters.since_hours) { const cutoff = Date.now() - (filters.since_hours * 3600 * 1000); log = log.filter(e => e.ts >= cutoff); } const total = log.length; const totalPrompt = log.reduce((s, e) => s + e.prompt_tokens, 0); const totalCompletion = log.reduce((s, e) => s + e.completion_tokens, 0); const totalLatency = log.reduce((s, e) => s + e.latency, 0); const avgLatency = total > 0 ? Math.round(totalLatency / total) : 0; // Per-model breakdown const byModel = {}; for (const e of log) { if (!byModel[e.model]) byModel[e.model] = { requests: 0, tokens: 0 }; byModel[e.model].requests++; byModel[e.model].tokens += e.total_tokens; } // Per-provider breakdown const byProvider = {}; for (const e of log) { if (!byProvider[e.provider]) byProvider[e.provider] = { requests: 0, tokens: 0 }; byProvider[e.provider].requests++; byProvider[e.provider].tokens += e.total_tokens; } // Time span const timeSpan = total > 1 ? Math.round((log[log.length - 1].ts - log[0].ts) / 1000) : 0; return { total, totalPrompt, totalCompletion, totalLatency, avgLatency, timeSpan, byModel, byProvider }; } // ─── 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) { if (typeof url === 'string') url = url.replace(/\/+$/, ''); 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._baseDestructive = ['delete','del','write','save','overwrite','format','kill','stop','shutdown','reboot']; this._baseSystem = ['reboot','shutdown','format','kill','halt','poweroff']; this._baseSuspiciousFlags = ['--dump','--execute','--eval','--exec','--shell','--script']; this._baseWhitelist = ['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history','find','grep','tree','head','tail','sed','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','reset','factory-reset','reset-vfs','js','js-run','node','debug','debug-ai','debug-stats','debug-history','debug-analyze','debug-trends','debug-fix','debug-health','session-new','orchestrate','orchestrate-parallel','orchestrate-decompose','orchestrate-result','orchestrator-status','orchestrator-spawn','orchestrator-kill','orchestrator-purge','orchestrator-config','orchestrator-list','orchestrator-sleep','orchestrator-wake','orchestrator-restart-policy','a2a-send','a2a-list','a2a-post','a2a-poll','a2a-ack','a2a-reject','a2a-status','a2a-prune','consensus','consensus-status','consensus-history','pipeline','agent-list','agent-register','agent-status','agent-unregister','contract-list','contract-register','voice','speak','analyze','dashboard','context-auto']; this.destructiveCmds = new Set(this._baseDestructive); this.systemCmds = new Set(this._baseSystem); this.suspiciousFlags = new Set(this._baseSuspiciousFlags); this.whitelist = new Set(this._baseWhitelist); // AI loop-guard: depth counter (reentrant, race-safe vs parallel tool calls) this._aiToolDepth = 0; // Load persisted user deltas (add/remove) so hardcoded defaults can be demoted. this._loadSecurityDeltas(); this._registerBuiltIn(); } _loadSecurityDeltas() { try { const schema = this.terminal._getDataSchemaVersion ? this.terminal._getDataSchemaVersion() : 1; localStorage.setItem('webx11_data_schema_version', String(schema)); const raw = localStorage.getItem('webx11_security_deltas'); if (!raw) { // One-time migration from legacy flat keys to delta format. const legacy = {}; try { legacy.destructive = JSON.parse(localStorage.getItem('webx11_security_destructive') || '[]'); } catch (e) {} try { legacy.system = JSON.parse(localStorage.getItem('webx11_security_system') || '[]'); } catch (e) {} try { legacy.whitelist = JSON.parse(localStorage.getItem('webx11_security_whitelist') || '[]'); } catch (e) {} try { legacy.flags = JSON.parse(localStorage.getItem('webx11_security_flags') || '[]'); } catch (e) {} const delta = { add: {}, remove: {} }; for (const c of (legacy.destructive || [])) if (!this._baseDestructive.includes(c)) { if (!delta.add.destructive) delta.add.destructive = []; delta.add.destructive.push(c); } for (const c of (legacy.system || [])) if (!this._baseSystem.includes(c)) { if (!delta.add.system) delta.add.system = []; delta.add.system.push(c); } for (const c of (legacy.whitelist || [])) if (!this._baseWhitelist.includes(c)) { if (!delta.add.whitelist) delta.add.whitelist = []; delta.add.whitelist.push(c); } for (const f of (legacy.flags || [])) if (!this._baseSuspiciousFlags.includes(f)) { if (!delta.add.flags) delta.add.flags = []; delta.add.flags.push(f); } localStorage.setItem('webx11_security_deltas', JSON.stringify(delta)); localStorage.removeItem('webx11_security_destructive'); localStorage.removeItem('webx11_security_system'); localStorage.removeItem('webx11_security_whitelist'); localStorage.removeItem('webx11_security_flags'); return; } const d = JSON.parse(raw); if (d.remove) { for (const c of (d.remove.destructive || [])) this.destructiveCmds.delete(c); for (const c of (d.remove.system || [])) this.systemCmds.delete(c); for (const c of (d.remove.whitelist || [])) this.whitelist.delete(c); for (const f of (d.remove.flags || [])) this.suspiciousFlags.delete(f); } if (d.add) { for (const c of (d.add.destructive || [])) this.destructiveCmds.add(c); for (const c of (d.add.system || [])) this.systemCmds.add(c); for (const c of (d.add.whitelist || [])) this.whitelist.add(c); for (const f of (d.add.flags || [])) this.suspiciousFlags.add(f); } } catch (e) {} } _saveSecurityDeltas() { const delta = { add: {}, remove: {} }; for (const c of this.destructiveCmds) if (!this._baseDestructive.includes(c)) { if (!delta.add.destructive) delta.add.destructive = []; delta.add.destructive.push(c); } for (const c of this._baseDestructive) if (!this.destructiveCmds.has(c)) { if (!delta.remove.destructive) delta.remove.destructive = []; delta.remove.destructive.push(c); } for (const c of this.systemCmds) if (!this._baseSystem.includes(c)) { if (!delta.add.system) delta.add.system = []; delta.add.system.push(c); } for (const c of this._baseSystem) if (!this.systemCmds.has(c)) { if (!delta.remove.system) delta.remove.system = []; delta.remove.system.push(c); } for (const c of this.whitelist) if (!this._baseWhitelist.includes(c)) { if (!delta.add.whitelist) delta.add.whitelist = []; delta.add.whitelist.push(c); } for (const c of this._baseWhitelist) if (!this.whitelist.has(c)) { if (!delta.remove.whitelist) delta.remove.whitelist = []; delta.remove.whitelist.push(c); } for (const f of this.suspiciousFlags) if (!this._baseSuspiciousFlags.includes(f)) { if (!delta.add.flags) delta.add.flags = []; delta.add.flags.push(f); } for (const f of this._baseSuspiciousFlags) if (!this.suspiciousFlags.has(f)) { if (!delta.remove.flags) delta.remove.flags = []; delta.remove.flags.push(f); } localStorage.setItem('webx11_security_deltas', JSON.stringify(delta)); } _registerBuiltIn() { const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','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','expand','sysmsg','orchestrate','orchestrate-parallel','orchestrate-decompose','orchestrate-result','orchestrator-status','orchestrator-spawn','orchestrator-kill','orchestrator-purge','orchestrator-config','orchestrator-list','orchestrator-sleep','orchestrator-wake','orchestrator-restart-policy','a2a-send','a2a-list','a2a-post','a2a-poll','a2a-ack','a2a-reject','a2a-status','a2a-prune','consensus','consensus-status','consensus-history','pipeline','agent-list','agent-register','agent-status','agent-unregister','contract-list','contract-register','voice','speak','analyze','dashboard']; 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 }); this.invalidateCommandCache(); } invalidateCommandCache() { this._commandCacheDirty = true; } _collectAllCommands() { if (!this._commandCacheDirty && this._commandCache) return this._commandCache; 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)); this._commandCache = [...cmds]; this._commandCacheDirty = false; return this._commandCache; } _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(String(raw || '').trim()); // `help |` and `| wc -l` produce an empty pipeline stage; parts[0] was undefined // and every such invocation threw TypeError from this line (989 occurrences, // first filed 2026-10-20). Classify the empty command as inert instead. if (!parts.length) { return { cmd: '', args: [], known: true, destructive: false, system: false, unknown: false, hasSuspicious: false }; } 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 }; } // v4.6.58: this terminal has no shell. `;` at least failed loudly, but `&&`/`||` // folded the right-hand side into the left command's argv, and a `|` arriving from // the AI run_command tool (which bypasses splitPipe) degraded to an empty pattern // matching every line. Both are silent wrong answers — refuse instead (packet §1). detectComposition(input) { const s = String(input || ''); let inSingle = false, inDouble = false; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } if (inSingle || inDouble) continue; if (ch === '&' && s[i + 1] === '&') return '&&'; if (ch === '|' && s[i + 1] === '|') return '||'; if (ch === '|') return '|'; if (ch === ';') return ';'; } return null; } async checkAndRun(rawInput) { // v4.6.130: keep the untokenised line for the command itself. _parseArgs turns // quote characters into delimiters and drops them, so `js "abc".toUpperCase()` // reaches the module as `abc.toUpperCase()` and every string literal dies. // Security classification still runs on the tokenised form below. this._currentRaw = rawInput; const badOp = this.detectComposition(rawInput); if (badOp) { this.terminal.print('⚠️ No shell here — `' + badOp + '` is not interpreted. ' + 'Run one command per line (the interactive terminal supports `cmd | cmd`; ' + 'AI run_command does not).', 'error'); return; } 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); // At level 2+: block entirely (paranoid) if (this.level >= 2) { this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).${sug ? ` Did you mean: "${sug.cmd}"?` : ''}`, 'error'); return; } // Level 1: typo of a safe whitelisted command → one-tap clickable fix, no AI call burned if (sug && sug.dist <= 1 && this.whitelist.has(sug.cmd) && this.terminal.showSuggestionBar && !(this._aiToolDepth > 0)) { this.terminal.print(`Unknown command "${cmd}" — did you mean:`, 'warning'); this.terminal.showSuggestionBar(sug.cmd, args); return; } // AI-invoked commands must not route back to AI (infinite loop) — return structured error if (this._aiToolDepth > 0) { this.terminal.print(`Command not found: ${cmd}${sug ? `. Did you mean: ${sug.cmd}?` : ''}`, 'error'); return; } // Level 1 fallback: route to AI (terminal is also a chat interface) if (sug) { this.terminal.print(`Security: Unknown command "${cmd}" — routing to AI (did you mean "${sug.cmd}"?)...`, 'warning'); } else { this.terminal.print(`Security: Unknown command "${cmd}" — 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, this._currentRaw || null); } 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', 100, (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.registerConfigItem('terminal-sysmsg', true, (v) => { this._showSystemMessages = !!v; }); this._pipeInput = null; this._pipeBufferSize = 5000; this._activeFollow = null; // Print-capture stack: [{output: ''}] — see print() head. LIFO order: // nested/parallel captures each get their own buffer. this._printCaptureStack = []; this._historyBuffer = []; this._historyMaxLines = 50; this._historySavePending = false; this.MAX_OUTPUT_LINES = 100; this._showSystemMessages = true; 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(); } /* ---------- helpers ---------- */ static _escapeHtml(str) { if (str == null) return ''; return String(str).replace(/[&<>'"]/g, m => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[m])); } _escapeHtml(str) { return WebX11Terminal._escapeHtml(str); } _getDataSchemaVersion() { return 1; } _escapeYamlString(str) { return '"' + String(str == null ? '' : str).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; } _invalidateCommandCache() { if (this.security) this.security.invalidateCommandCache(); } _pushPrintCapture(onChunk) { // onChunk(buffer) called at pop with the full captured text if (!this._printCaptureStack) this._printCaptureStack = []; this._printCaptureStack.push({ output: '', onChunk }); } _popPrintCapture() { const cap = this._printCaptureStack && this._printCaptureStack.pop(); if (cap && cap.onChunk) cap.onChunk(cap.output); } stopFollow(msg) { if (!this._activeFollow) return false; clearInterval(this._activeFollow.interval); const f = this._activeFollow.file; this._activeFollow = null; if (msg) this.print(msg, 'warning'); else this.print('^C — stopped following ' + f, 'warning'); return true; } 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(); this.clearOutput(); } else if (e.ctrlKey && (e.key === 'c' || e.key === 'C')) { // Ctrl+C — stop tail -f if active, else cancel current input line (standard terminal) e.preventDefault(); if (this.stopFollow()) return; if (this.inputField.value) { 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 if (this.stopFollow()) { // tail -f stopped } 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(); } }); // 2026-10-22: dashboard.html wrote this key on ⟳ Refresh and nothing listened. window.addEventListener('storage', function (ev) { if (ev.key !== 'webx11_dashboard_data_request') return; const aiMod = this.moduleManager && this.moduleManager.modules ? this.moduleManager.modules.WebX11AIAssistantModule : null; if (aiMod && typeof aiMod._collectDashboardData === 'function') { aiMod._collectDashboardData(); } }.bind(this)); 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() { // navigator.onLine is a hint, not a fact: an Android WebView reports false while // fully connected (the same lie `status` used to print). Never turn that hint into // "❌ Offline" — claim Online only when the browser says so, otherwise say unknown // and point at `doctor`, which probes for real. const isOnline = navigator.onLine; this.networkStatus.textContent = isOnline ? '📶 Online' : '📶 Network?'; this.networkStatus.title = isOnline ? '' : 'Browser reports offline — run doctor for a live probe'; this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--text-muted)'; } updatePrompt() { const path = '/' + this.fs.currentPath.join('/'); 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 = '') { // Print-capture stack: while active, output goes to the innermost capture // buffer instead of the DOM. Reentrant — parallel/nested captures each // collect their own output (race-safe, replaces monkey-patched print). if (this._printCaptureStack && this._printCaptureStack.length > 0) { const cap = this._printCaptureStack[this._printCaptureStack.length - 1]; cap.output += text + '\n'; return; } if (['error','warning','success','info','text-muted'].includes(type) && !this._showSystemMessages && !type.startsWith('command')) { // Still log to history buffer so it can be shown again when toggled on, // but don't create DOM nodes while system messages are hidden. this._historyBuffer.push({ l: text, t: type }); if (this._historyBuffer.length > this._historyMaxLines) this._historyBuffer.shift(); return; } const line = document.createElement('div'); // Use card layout for typed messages (error/warning/info/success/text-muted), plain for bare text const isTyped = ['error','warning','success'].includes(type); if (isTyped) { line.className = 'output-line msg-card system ' + type; const safeText = String(text).replace(//g, ">"); line.innerHTML = '
⚙ System
' + safeText + '
'; } else { line.className = 'output-line'; line.textContent = text; // Plain lines get a lightweight inline copy button const copyBtn = document.createElement('button'); copyBtn.className = 'line-copy-btn'; copyBtn.textContent = 'Copy'; copyBtn.setAttribute('onclick', 'webx11.copyLineToClipboard(this)'); copyBtn.setAttribute('aria-label', 'Copy line'); line.appendChild(copyBtn); } this.outputContainer.appendChild(line); this._historyBuffer.push({ l: text, t: type }); if (this._historyBuffer.length > this._historyMaxLines) { this._historyBuffer.shift(); } this._saveHistory(); this.scrollToBottom(); this._trimOutput(); } _renderHTMLPreview(content) { // AI returns raw HTML. We must HTML-escape for the srcdoc attribute // so the parser treats it as attribute content, not tags. // srcdoc decodes entities back to actual HTML inside the iframe. const hasOwnStyle = /body{background:#000;color:#0f0;margin:16px;font-family:monospace;}${content}`; const safeSrcdoc = themedContent .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/`/g, '`'); return `
📄 Rendered HTML
`; } _renderHTMLBlock(text) { const renderRegex = /:::render-html\r?\n([\s\S]*?)\r?\n:::/g; let match; let lastIndex = 0; const fragments = []; while ((match = renderRegex.exec(text)) !== null) { // Text before this block const before = text.slice(lastIndex, match.index); if (before.trim()) { const safeBefore = before.replace(//g, ">"); fragments.push({ type: 'text', html: '
' + safeBefore + '
' }); } fragments.push({ type: 'render', html: this._renderHTMLPreview(match[1].trim()) }); lastIndex = match.index + match[0].length; } const after = text.slice(lastIndex); if (after.trim()) { const safeAfter = after.replace(//g, ">"); fragments.push({ type: 'text', html: '
' + safeAfter + '
' }); } return fragments.length > 0 ? fragments : null; } printAIResponse(text) { this._flushStreamChunks(); const fragments = this._renderHTMLBlock(text); if (fragments) { const wrapper = document.createElement('div'); wrapper.className = 'output-line msg-card ai'; wrapper.innerHTML = '
🤖 AI
'; const body = wrapper.querySelector('.msg-card-body'); for (const frag of fragments) { const div = document.createElement('div'); div.innerHTML = frag.html; body.appendChild(div); } this.outputContainer.appendChild(wrapper); this.scrollToBottom(); this._trimOutput(); return; } const wrapper = document.createElement('div'); wrapper.className = 'output-line msg-card ai'; const safeText = text.replace(//g, ">"); wrapper.innerHTML = '
🤖 AI
' + safeText + '
'; this.outputContainer.appendChild(wrapper); this.scrollToBottom(); this._trimOutput(); } printStreamingResponse(text, append) { if (!this._streamChunks) this._streamChunks = []; if (!append) { const safeText = text.replace(//g, ">"); const wrapper = document.createElement('div'); wrapper.className = 'output-line msg-card ai'; wrapper.innerHTML = '
🤖 AI
' + safeText + '
'; this.outputContainer.appendChild(wrapper); this._streamChunks = []; } else { const contentDiv = this.outputContainer.querySelector('.msg-card.ai:last-child .msg-card-body'); if (contentDiv) { this._streamChunks.push(text); if (this._streamChunks.length >= 5) { contentDiv.textContent += this._streamChunks.join(''); this._streamChunks = []; if (contentDiv.textContent.length > 50000) { contentDiv.textContent = contentDiv.textContent.slice(-30000); } } } } this.scrollToBottom(); this._trimOutput(); } _flushStreamChunks() { const contentDiv = this.outputContainer.querySelector('.msg-card.ai:last-child .msg-card-body'); if (contentDiv && this._streamChunks && this._streamChunks.length > 0) { contentDiv.textContent += this._streamChunks.join(''); this._streamChunks = []; } } printUserPrompt(rawInput) { const wrapper = document.createElement('div'); wrapper.className = 'output-line msg-card user'; const safe = rawInput.replace(//g, '>'); const hasHtml = /<[a-z][\s\S]*>/i.test(rawInput.trim()); let buttons = ''; if (hasHtml) buttons += ''; buttons += ''; wrapper.innerHTML = '
👤 User' + buttons + '
' + safe + '
'; this.outputContainer.appendChild(wrapper); this._historyBuffer.push({ l: rawInput, t: 'command-prompt' }); if (this._historyBuffer.length > this._historyMaxLines) this._historyBuffer.shift(); this.scrollToBottom(); this._trimOutput(); } editPrompt(text) { this.inputField.value = text; this.inputField.focus(); this.inputField.setSelectionRange(text.length, text.length); } copyToClipboard(btn) { let text = ''; const card = btn.closest('.msg-card'); if (card) { const cardBody = card.querySelector('.msg-card-body'); if (cardBody) text = cardBody.textContent; } else { const nd = btn.nextElementSibling; if (nd) text = nd.innerText || nd.textContent; } this._copyText(text, btn); } copyLineToClipboard(btn) { const line = btn.parentElement; const text = line ? line.childNodes[0].textContent : ''; this._copyText(text, btn); } _copyText(text, btn=null) { if (!text) return; 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); if (btn) { btn.textContent = 'Copied!'; setTimeout(function(){ btn.textContent = 'Copy'; }, 2000); } this.print('📋 Copied to clipboard', 'success'); } catch (e) { this.print('Copy failed: ' + e.message, 'error'); } } printClickable(items) { const line = document.createElement('div'); line.className = 'output-line'; items.forEach(f => { const span = document.createElement('span'); const safeName = f.name.replace(//g, '>'); if (f.type === 'dir') { span.className = 'clickable-dir'; span.textContent = '[dir]' + f.name + '/'; span.onclick = () => { this.executeCommand('cd ' + f.name); setTimeout(() => this.executeCommand('ls'), 50); }; } else { span.className = 'clickable-file'; span.textContent = ' ' + f.name; span.onclick = () => this.executeCommand('cat ' + f.name); } line.appendChild(span); }); this.outputContainer.appendChild(line); this.scrollToBottom(); } printHTML(html) { const line = document.createElement('div'); line.className = 'output-line'; line.innerHTML = html; this.outputContainer.appendChild(line); this.scrollToBottom(); } showSuggestionBar(cmd, args) { // One-tap typo fix: renders clickable "✅ Run / ❌ Dismiss" bar const area = document.getElementById('ai-confirm-area'); const runCmd = cmd + ((args && args.length) ? ' ' + args.join(' ') : ''); if (!area) { // Fallback for hosts without the confirm UI: print clickable command this.print(`Did you mean: ${runCmd} — type it to run.`, 'info'); return; } area.innerHTML = ` 💡 Did you mean: ${runCmd}? `; area.classList.add('visible'); const allowBtn = document.getElementById('cmd-confirm-allow'); const denyBtn = document.getElementById('cmd-confirm-deny'); const onAllow = () => { area.classList.remove('visible'); area.innerHTML = ''; this.executeCommand(runCmd); }; const onDeny = () => { area.classList.remove('visible'); area.innerHTML = ''; this.print('Dismissed.', 'text-muted'); }; allowBtn.addEventListener('click', onAllow); denyBtn.addEventListener('click', onDeny); allowBtn.__allow = onAllow; denyBtn.__deny = onDeny; } showConfirmationBar(desc) { const area = document.getElementById('ai-confirm-area'); if (!area) { this.print('Confirmation UI unavailable — command blocked.', 'error'); return; } this._pendingCommandDesc = desc; area.innerHTML = ` ⚠️ ${desc} Type: cmd-allow or cmd-deny `; area.classList.add('visible'); const allowBtn = document.getElementById('cmd-confirm-allow'); const denyBtn = document.getElementById('cmd-confirm-deny'); const onAllow = () => { this._resolveCmdConfirm(true); }; const onDeny = () => { this._resolveCmdConfirm(false); }; allowBtn.addEventListener('click', onAllow); denyBtn.addEventListener('click', onDeny); allowBtn.__allow = onAllow; denyBtn.__deny = onDeny; } _resolveCmdConfirm(approved) { const area = document.getElementById('ai-confirm-area'); if (area) { area.classList.remove('visible'); area.innerHTML = ''; } if (approved) { this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success'); if (this._pendingCommand) { const fn = this._pendingCommand; this._pendingCommand = null; this._pendingCommandDesc = null; fn(); } } else { this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning'); this._pendingCommand = null; this._pendingCommandDesc = null; } } _loadHistory() { try { const saved = localStorage.getItem('webx11_terminal_output'); if (!saved) return; const entries = JSON.parse(saved); if (!Array.isArray(entries)) return; this._historyBuffer = entries.slice(-this._historyMaxLines); // Check localStorage quota warning let quotaWarned = false; try { const testKey = '_quota_test_' + Date.now(); localStorage.setItem(testKey, 'x'.repeat(1024)); localStorage.removeItem(testKey); } catch (e) { quotaWarned = true; } // Lazy-load history: render first 20 immediately, batch the rest const batchSize = 20; const toRender = entries.slice(-this._historyMaxLines); const initial = toRender.slice(0, batchSize); for (const entry of initial) { const line = document.createElement('div'); line.className = `output-line ${entry.t || ''}`; line.textContent = entry.l || ''; this.outputContainer.appendChild(line); } if (toRender.length > batchSize) { const remaining = toRender.slice(batchSize); const MAX_BATCHES = 50; const doBatch = (index, batchCount) => { if (index >= remaining.length || batchCount >= MAX_BATCHES) { this.scrollToBottom(); return; } requestAnimationFrame(() => { const end = Math.min(index + batchSize, remaining.length); for (let i = index; i < end; i++) { const line = document.createElement('div'); line.className = `output-line ${remaining[i].t || ''}`; line.textContent = remaining[i].l || ''; this.outputContainer.appendChild(line); } doBatch(end, (batchCount || 0) + 1); }); }; doBatch(0, 0); } else { this.scrollToBottom(); } if (quotaWarned) { const warnLine = document.createElement('div'); warnLine.className = 'output-line warning'; warnLine.textContent = '⚠ localStorage is near quota — history may be incomplete'; this.outputContainer.appendChild(warnLine); } } catch (e) { // Silently ignore corrupt history data } } _saveHistory() { if (this._historySaveTimer) clearTimeout(this._historySaveTimer); this._historySaveTimer = setTimeout(() => { this._historySaveTimer = null; 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); } } toggleExpand() { const container = document.getElementById('terminal-container'); if (!container) return; container.classList.toggle('terminal-expanded'); const on = container.classList.contains('terminal-expanded'); this.print(on ? 'Terminal expanded' : 'Terminal compact', 'text-muted'); } toggleSystemMessages(arg) { let desired; if (arg === 'on') desired = true; else if (arg === 'off') desired = false; else desired = !this._showSystemMessages; this._showSystemMessages = desired; this._saveTerminalConfig(); this.print('System messages ' + (desired ? 'on' : 'off'), 'text-muted'); } clearOutput() { if (this.outputContainer) { this.outputContainer.innerHTML = ''; } this._historyBuffer = []; if (this._historySaveTimer) { clearTimeout(this._historySaveTimer); this._historySaveTimer = null; } try { localStorage.removeItem('webx11_terminal_output'); } catch (e) {} } 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'); const snapshot = { type: 'session', version: 2, savedAt: new Date().toISOString(), currentPath: this.fs.currentPath, terminalLines: this._historyBuffer.slice(-50) }; const snapshotJson = JSON.stringify(snapshot); 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.version === 2) { // v2: metadata-only restore (VFS is persisted independently in localStorage) this.fs.currentPath = data.currentPath || []; this.fs.saveToStorage(); this.updatePrompt(); this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success'); // Optionally re-render last few terminal lines if (data.terminalLines && Array.isArray(data.terminalLines)) { data.terminalLines.slice(-10).forEach(line => { const text = typeof line === 'string' ? line : (line.l || ''); this.print(text, typeof line === 'object' ? (line.t || '') : ''); }); } try { const aiCtx = this.fs.readFile('_sessions/_auto-context.json'); if (aiCtx) { localStorage.setItem('webx11_ai_context', aiCtx); } } catch (e) {} this.fs.deleteFile('_sessions/_auto.json'); this.fs.deleteFile('_sessions/_auto-context.json'); } else if (data && data.fsRoot) { // v1: full VFS restore (legacy) this.fs.root = data.fsRoot; this.fs.currentPath = data.currentPath || []; this.fs.saveToStorage(); this.updatePrompt(); this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success'); try { const aiCtx = this.fs.readFile('_sessions/_auto-context.json'); if (aiCtx) { localStorage.setItem('webx11_ai_context', aiCtx); } } catch (e) {} 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', 'session-new']; const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview']; // Collect commands that take arguments from ext module metadata const extFileCmds = new Set(); const extDirCmds = new Set(); for (const modName in this.moduleManager.modules) { const mod = this.moduleManager.modules[modName]; if (mod.commandsMeta) { for (const [c, meta] of Object.entries(mod.commandsMeta)) { if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c); if (meta.argType === 'dir') extDirCmds.add(c); } } } // If we have more than one word, we're past the command name — autocomplete file/dir/session names if (parts.length > 1 && parts[1].length > 0) { const partial = parts.slice(1).join(' '); const partialTrimmed = partial.trim(); if (partialTrimmed === '..') { this.hideAutocomplete(); return; } if (this._tryEnumAutocomplete(cmdPart)) { this.suggestions = this.suggestions.filter(s => s.startsWith(partialTrimmed)); } else if (sessionArgCommands.includes(cmdPart)) { this.suggestions = this._getPathSuggestions(partialTrimmed, 'session'); } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir'); this._isCdAutocomplete = true; } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { this.suggestions = this._getPathSuggestions(partialTrimmed, 'file'); } else { // Fallback: show files anyway this.suggestions = this._getPathSuggestions(partialTrimmed, 'file'); } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { this.renderAutocomplete(); } else { this.hideAutocomplete(); } return; } // Just typed a space after command — show all matching entries if (parts.length > 1) { if (this._tryEnumAutocomplete(cmdPart)) { // suggestions already populated } else if (sessionArgCommands.includes(cmdPart)) { this.suggestions = this._getPathSuggestions('', 'session'); } else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) { this.suggestions = this._getPathSuggestions('', 'dir'); this._isCdAutocomplete = true; } else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) { this.suggestions = this._getPathSuggestions('', 'file'); } else { // Fallback: show files this.suggestions = this._getPathSuggestions('', 'file'); } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { this.renderAutocomplete(); } else { this.hideAutocomplete(); } return; } // Directory autocomplete for bare 'cd ' (no partial yet) if (lowerInput.startsWith('cd ')) { this.suggestions = this._getPathSuggestions('', 'dir'); if (this.suggestions.length > 0) { this._isCdAutocomplete = true; this.renderAutocomplete(); } else { this.hideAutocomplete(); } return; } // Command name autocomplete (first word only) — also clears any stale prefix this._autocompletePrefix = ''; for (const cmd in commands) { if (cmd.startsWith(cmdPart) && cmd !== cmdPart) { this.suggestions.push(cmd); } } if (this.suggestions.length > 0) { this.renderAutocomplete(); } else { this.hideAutocomplete(); } } renderAutocomplete() { this.dropdown.innerHTML = ''; this.suggestions.forEach((item, index) => { const div = document.createElement('div'); div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`; div.innerHTML = this._isCdAutocomplete ? `${item}` : `${item}`; div.onclick = () => { if (this._autocompletePrefix) { this.inputField.value = this._autocompletePrefix + item; } else if (this._isCdAutocomplete) { this.inputField.value = 'cd ' + item; } else { this.inputField.value = item + ' '; } this.hideAutocomplete(); this.inputField.focus(); }; this.dropdown.appendChild(div); }); this.dropdown.style.display = 'block'; } updateAutocompleteDisplay() { const items = this.dropdown.querySelectorAll('.autocomplete-item'); items.forEach((item, index) => { item.classList.toggle('selected', index === this.selectedSuggestion); }); if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { if (this._autocompletePrefix) { this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; } else if (this._isCdAutocomplete) { this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion]; } else { this.inputField.value = this.suggestions[this.selectedSuggestion] + ' '; } } } hideAutocomplete() { this.dropdown.style.display = 'none'; this.suggestions = []; this.selectedSuggestion = -1; this._isCdAutocomplete = false; this._autocompletePrefix = ''; } loadBuiltInModules() { this._safeRegister('SystemModule', 'system'); this._safeRegister('FileSystemModule', 'filesystem'); this._safeRegister('ModuleManagerModule', 'module manager'); this._safeRegister('OllamaModule', 'ollama'); this._safeRegister('BillingModule', 'billing'); 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('SmartContextModule', 'smart context'); this._safeRegister('SimpleGUIModule', 'simple GUI builder'); this._safeRegister('DebugSupervisorModule', 'debug supervisor'); // 2026-11-12 — UI shell re-enabled BEHIND AN OPT-IN FLAG (plan Task 9, v4.6.162). // v4.6.157 had disabled it outright after crash reports; the crash itself was never // reproduced (F2) and the two defects the investigation did find (F3 413, F4 arg // coercion) were fixed instead. So the six modes come back, but only for a visitor // who asks for them: `?ui=1` on the URL or localStorage['webx11_ui_experimental']=1. // Without the flag nothing changes: the shell is not registered, no mode can mount // (the ui-*.js files begin with `if (!terminal.uiShell) return;`), no first-run // picker appears, and `ui` does not exist. Guard: admin/scripts/verify-newos4-ui-gate.py // (cron ui-gate-guard) fails if the gate is lost or the bare call returns. const _uiExp = (function () { try { if (new URLSearchParams(location.search).get('ui') === '1') return true; return localStorage.getItem('webx11_ui_experimental') === '1'; } catch (e) { return false; } })(); if (_uiExp) this._safeRegister('UIShellModule', 'UI shell'); // v4.5.47 used to _safeRegister() seven critical ext classes here (EditorModule, // ShellModule, AliasModule, FinderModule, FileManagerModule, DiffModule, // BusyBoxModule). They are declared in modules/ext/critical-ext-bundle.js, which is // a