// Core module bundle// Generated from /var/www/webx11.duckdns.org/spaces/newos4/modules/core /* === 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] }; } _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._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']; } 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']) { 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._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 { constructor(terminal) { this.terminal = terminal; // --- Backward-compat properties (reflected from active provider) --- this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; this.connected = false; this.models = []; this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud'; this.responseHooks = []; this._abortController = null; // --- Usage Tracking --- this._usageLog = []; this._usageBuffer = []; this._usageFlushTimer = null; this._loadUsageLog(); // Flush on page unload if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => 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)); return Number.isFinite(n) && n >= 512 ? Math.floor(n) : 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 }]; } } _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 new Error(`HTTP ${response.status}`); const data = await response.json(); this.models = data.models || []; this.connected = true; this.currentProvider = provider; provider.endpoint = ep; // may have been updated via fallback this._syncCompatProperties(provider); this.saveSettings(); this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)'); if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { if (this.models.length > 0) { this.currentModel = this.models[0].name; provider.model = this.currentModel; localStorage.setItem('webx11_ollama_model', this.currentModel); this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); } } this._saveProviders(); success = true; break; } catch (error) { this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message); 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 new Error(`HTTP ${response.status}`); const data = await response.json(); this.models = (data.data || data.models || []).map(m => ({ name: m.id || m.name, size: m.size || 0 })); this.connected = true; this.currentProvider = provider; this._syncCompatProperties(provider); this.saveSettings(); this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success'); this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)'); if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { if (this.models.length > 0) { this.currentModel = this.models[0].name; provider.model = this.currentModel; localStorage.setItem('webx11_ollama_model', this.currentModel); this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); } } this._saveProviders(); success = true; } catch (error) { this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message); 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 new Error(`HTTP ${response.status}`); const data = await response.json(); this.models = data.models || []; this.connected = true; if (ep !== this.endpoint) { this.endpoint = ep; this.saveSettings(); this.terminal.print(`Endpoint updated to: ${ep}`, 'info'); } this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success'); this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)'); if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) { if (this.models.length > 0) { this.currentModel = this.models[0].name; localStorage.setItem('webx11_ollama_model', this.currentModel); this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info'); } } this.saveSettings(); return true; } catch (error) { this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message); 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 new Error(`HTTP ${response.status}`); 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 new Error(`HTTP ${response.status}`); 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". 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 new Error(`HTTP ${response.status}`); 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 new Error(`HTTP ${response.status}`); 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 new Error(`HTTP ${response.status}`); } const data = await response.json(); this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); 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 new Error(`HTTP ${response.status}`); } const data = await response.json(); this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`); 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); } 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 new Error(`HTTP ${response.status}`); 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 new Error(`HTTP ${response.status}`); 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 new Error(`HTTP ${response.status}`); 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) { 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() { const isOnline = navigator.onLine; this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline'; this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)'; } updatePrompt() { const path = '/' + this.fs.currentPath.join('/'); const label = document.querySelector('.prompt-label'); if (!label) return; if (localStorage.getItem('webx11_smart_prompt') === 'off') { label.textContent = path === '/' ? '$' : path + ' $'; return; } const indicators = []; try { const p = localStorage.getItem('webx11_user_profile'); if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); } } catch (e) {} try { const idx = this.fs.fsReadJSON('/skills/index.json'); if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); } } catch (e) {} try { const memIdx = this.fs.fsReadJSON('/memory/index.json'); if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); } } catch (e) {} try { if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB'); } } catch (e) {} try { const md = this.fs.readDir('_mods'); if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); } } catch (e) {} const prefix = indicators.length ? indicators.join(' ') + ' ' : ''; label.textContent = prefix + (path === '/' ? '$' : path + ' $'); } print(text, type = '') { // 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'); // v4.5.47: Critical ext modules promoted to boot path this._safeRegister('EditorModule', 'editor'); this._safeRegister('ShellModule', 'shell'); this._safeRegister('AliasModule', 'aliases'); this._safeRegister('FinderModule', 'finder'); this._safeRegister('FileManagerModule', 'file manager'); this._safeRegister('DiffModule', 'diff'); this._safeRegister('BusyBoxModule', 'busybox'); } _initRegisteredModules() { for (const [name, mod] of Object.entries(this.moduleManager.modules)) { if (typeof mod.init === 'function') { try { mod.init(this); } catch (e) { console.warn(`[Init] ${name} failed: ${e.message}`); } } } } _safeRegister(className, label) { // Safe class lookup — no eval(). Classes are registered in global scope by the bundle. const registry = { SystemModule: typeof SystemModule !== 'undefined' ? SystemModule : undefined, FileSystemModule: typeof FileSystemModule !== 'undefined' ? FileSystemModule : undefined, ModuleManagerModule: typeof ModuleManagerModule !== 'undefined' ? ModuleManagerModule : undefined, OllamaModule: typeof OllamaModule !== 'undefined' ? OllamaModule : undefined, SearXNGModule: typeof SearXNGModule !== 'undefined' ? SearXNGModule : undefined, ConfigManagerModule: typeof ConfigManagerModule !== 'undefined' ? ConfigManagerModule : undefined, WebX11AIAssistantModule: typeof WebX11AIAssistantModule !== 'undefined' ? WebX11AIAssistantModule : undefined, WebFetcherModule: typeof WebFetcherModule !== 'undefined' ? WebFetcherModule : undefined, WebXAIAliasModule: typeof WebXAIAliasModule !== 'undefined' ? WebXAIAliasModule : undefined, SecretVault: typeof SecretVault !== 'undefined' ? SecretVault : undefined, ChatStudioModule: typeof ChatStudioModule !== 'undefined' ? ChatStudioModule : undefined, AgentRegistryModule: typeof AgentRegistryModule !== 'undefined' ? AgentRegistryModule : undefined, LeaseModule: typeof LeaseModule !== 'undefined' ? LeaseModule : undefined, OrchestratorModule: typeof OrchestratorModule !== 'undefined' ? OrchestratorModule : undefined, NeuralVFSModule: typeof NeuralVFSModule !== 'undefined' ? NeuralVFSModule : undefined, SandboxModule: typeof SandboxModule !== 'undefined' ? SandboxModule : undefined, MemoryGraphModule: typeof MemoryGraphModule !== 'undefined' ? MemoryGraphModule : undefined, PredictorModule: typeof PredictorModule !== 'undefined' ? PredictorModule : undefined, WorldModelModule: typeof WorldModelModule !== 'undefined' ? WorldModelModule : undefined, ServerMonitorModule: typeof ServerMonitorModule !== 'undefined' ? ServerMonitorModule : undefined, AliasModule: typeof AliasModule !== 'undefined' ? AliasModule : undefined, FinderModule: typeof FinderModule !== 'undefined' ? FinderModule : undefined, FileManagerModule: typeof FileManagerModule !== 'undefined' ? FileManagerModule : undefined, DiffModule: typeof DiffModule !== 'undefined' ? DiffModule : undefined, BusyBoxModule: typeof BusyBoxModule !== 'undefined' ? BusyBoxModule : undefined, SmartContextModule: typeof SmartContextModule !== 'undefined' ? SmartContextModule : undefined, DebugSupervisorModule: typeof DebugSupervisorModule !== 'undefined' ? DebugSupervisorModule : undefined, BillingModule: typeof BillingModule !== 'undefined' ? BillingModule : undefined, }; const cls = registry[className]; if (typeof cls === 'undefined') { console.warn(`[SafeRegister] Skipped ${label}: ${className} is not in the registry map — add it there, not only to loadBuiltInModules()`); return; } try { this.moduleManager.registerModule(new cls()); } catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } } // ── Config Registry ── registerConfigItem(key, defaultValue, setterFn) { this._configRegistry[key] = { key, defaultValue }; this._configDefaults[key] = defaultValue; this._configSetters[key] = setterFn; // Apply persisted override if present const saved = localStorage.getItem('webx11_terminal_config'); if (saved) { try { const parsed = JSON.parse(saved); if (parsed[key] !== undefined && parsed[key] !== defaultValue) { setterFn(parsed[key]); } } catch (e) {} } } _loadTerminalConfig() { try { const saved = localStorage.getItem('webx11_terminal_config'); if (!saved) return; const parsed = JSON.parse(saved); // Apply registered config items from persistence for (const [key, val] of Object.entries(parsed)) { if (this._configSetters[key] && val !== this._configDefaults[key]) { this._configSetters[key](val); } } } catch (e) {} } _saveTerminalConfig() { const values = {}; for (const key of Object.keys(this._configRegistry)) { const actual = this[key] !== undefined ? this[key] : this._configDefaults[key]; if (actual !== this._configDefaults[key]) { values[key] = actual; } } localStorage.setItem('webx11_terminal_config', JSON.stringify(values)); } _applyTerminalConfig() { try { const saved = localStorage.getItem('webx11_terminal_config'); if (!saved) return; const parsed = JSON.parse(saved); if (parsed['terminal-max-lines'] !== undefined) this.MAX_OUTPUT_LINES = parsed['terminal-max-lines']; if (parsed['terminal-hist-output'] !== undefined) this._historyMaxLines = parsed['terminal-hist-output']; if (parsed['terminal-cmd-history'] !== undefined) this._historyCap = parsed['terminal-cmd-history']; if (parsed['terminal-pipe-buffer'] !== undefined) this._pipeBufferSize = parsed['terminal-pipe-buffer']; this._trimOutput(); } catch (e) {} } async _loadExtModules(filterKeys) { const autoLoadKeys = filterKeys || ['calc', 'voice', 'debug-panel', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'cryptogzip', 'tutorial', 'floating', 'console', 'media']; const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-07-29-1'; let index = null; let lastErr = null; // v4.5.47: retry packages.json fetch up to 3 times for (let attempt = 1; attempt <= 3; attempt++) { try { const res = await fetch('packages.json?v=' + cacheVer); if (!res.ok) throw new Error(`HTTP ${res.status}`); index = await res.json(); lastErr = null; break; } catch (err) { lastErr = err; if (attempt < 3) await new Promise(r => setTimeout(r, 300 * attempt)); } } if (!index) { this.print(`Failed to auto-load ext modules: ${lastErr ? lastErr.message : 'unknown error'}`, 'error'); return; } for (const key of autoLoadKeys) { const pkg = index.packages[key]; if (pkg) { const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer; await this.moduleManager.loadModuleFromURL(url); } } } async reloadPersistedModules() { const registry = this.moduleManager.moduleRegistry; const skipList = ['calc', 'voice', 'debug-panel', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell', 'tutorial', 'busybox', 'floating', 'console', 'media']; for (const mod of registry) { if (mod.enabled && mod.url) { // Skip ext modules — they load via _loadExtModules const name = (mod.url.match(/(\w+)\.js/) || [])[1]; if (name && skipList.includes(name)) continue; await this.moduleManager.loadModuleFromURL(mod.url); } } } async executeCommand(rawInput) { if (!rawInput.trim()) return; this.markDirty(); // Push to command history (dedup + trim) const cmd = rawInput.trim(); if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) { this.history.push(cmd); } if (this.history.length > this._historyCap) { this.history = this.history.slice(-this._historyCap); } this.historyIndex = this.history.length; this._saveCommandHistory(); this.printUserPrompt(rawInput); const pipeParts = this.splitPipe(rawInput.trim()); if (pipeParts.length > 1) { await this.executePipeChain(pipeParts); return; } // Security gate for all single commands and internal builtins await this.security.checkAndRun(rawInput); } splitPipe(input) { const parts = []; let current = ''; let inSingle = false; let inDouble = false; for (const ch of input) { if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; } else if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; } else if (ch === '|' && !inSingle && !inDouble) { parts.push(current.trim()); current = ''; } else { current += ch; } } parts.push(current.trim()); return parts; } async executePipeChain(pipeParts) { // Parse every stage once, then skip the ones with no argv. `help |` splits to // ['help',''], `help | ""` to ['help',''] and `| wc -l` to ['','wc -l'] — all three // produced an empty argv whose parts[0] was undefined and threw TypeError from // parts[0].toLowerCase() (989 occurrences, first filed 2026-10-20). Skipping on // argv (not just the string) also keeps the LAST real stage final, so its output is // never swallowed by a trailing empty segment, and keeps the loop index aligned // with pipeParts so each stage gets its own raw text. const stages = pipeParts.map(t => this.security._parseArgs(String(t || '').trim())); // Index of the LAST stage that has a real argv — its output is printed, never // piped. -1 means every segment was empty, so there is nothing to run. let lastStage = -1; for (let i = 0; i < stages.length; i++) if (stages[i].length > 0) lastStage = i; if (lastStage < 0) { this.print('⚠️ Empty pipeline — nothing to run', 'warning'); return; } let pipeInput = null; for (let i = 0; i < stages.length; i++) { const argv = stages[i]; // Skip empty segments, but keep `i` pointing at pipeParts[i] so the raw stage // text handed to the dispatcher always belongs to this argv. if (argv.length === 0) continue; const cmd = argv[0].toLowerCase(); const args = argv.slice(1); // Rebuilt from argv so the security classifier sees the same tokenisation the // dispatcher will use (quotes already stripped by _parseArgs). const cmdText = argv.join(' '); // v4.5.67: Security gate for pipe stages — was completely bypassed const cls = this.security._classify(cmdText); if (cls.destructive && this.security.level > 0) { this.print(`⚠️ Security: Destructive command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error'); return; } if (cls.system && this.security.level > 0) { this.print(`⚠️ Security: System command "${cmd}" blocked in pipe at level ${this.security.level}`, 'error'); return; } if (!cls.known && this.security.level >= 2) { this.print(`⚠️ Security: Unknown command "${cmd}" blocked in pipe (paranoid mode)`, 'error'); return; } this._pipeInput = pipeInput; if (i < lastStage) { pipeInput = await this.capturePipeOutput(cmd, args, pipeParts[i]); } else { await this.dispatchCommand(cmd, args, pipeParts[i]); } } this._pipeInput = null; } async capturePipeOutput(cmd, args, rawText) { let output = ''; this._pushPrintCapture(buf => { output += buf; }); try { await this.dispatchCommand(cmd, args, rawText); } finally { this._popPrintCapture(); } const trimmed = output.trim(); if (trimmed.length > this._pipeBufferSize) { return trimmed.slice(0, this._pipeBufferSize); } return trimmed; } // rawText (v4.6.130): the untokenised command line, when the call came from the // terminal. Commands that need their delimiters (`js`, `node -e`) read it; every // other command keeps using `args` and ignores the extra parameter. async dispatchCommand(cmd, args, rawText) { if (cmd === 'clear') { this.clearOutput(); return; } if (cmd === 'expand') { this.toggleExpand(); return; } if (cmd === 'sysmsg') { this.toggleSystemMessages(args[0]); return; } if (cmd === 'startup-modules') { this.openStartupModules(); return; } if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; } if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; } if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; } if (cmd === 'mem') { this.printMemoryStats(); return; } if (cmd === 'dashboard') { const aiMod = this.moduleManager && this.moduleManager.modules ? this.moduleManager.modules.WebX11AIAssistantModule : null; const collect = function () { if (aiMod && typeof aiMod._collectDashboardData === 'function') return aiMod._collectDashboardData(); return Promise.resolve(null); }; if (args.indexOf('--stop') !== -1) { if (this._dashTimer) { clearInterval(this._dashTimer); this._dashTimer = null; this.print('📊 Dashboard watch stopped', 'info'); } else this.print('📊 Dashboard watch is not running', 'warning'); return; } if (args.indexOf('--watch') !== -1) { if (this._dashTimer) clearInterval(this._dashTimer); collect(); this._dashTimer = setInterval(collect, 5000); this.print('📊 Dashboard watch on (5s) — open the dashboard tab; `dashboard --stop` to stop', 'success'); return; } collect().then(function () { this.print('📊 Dashboard data collected — opening dashboard...', 'success'); window.open('/spaces/newos4/downloads/dashboard.html', 'newOS4 Dashboard', 'width=1200,height=800'); }.bind(this)); return; } if (cmd === 'config') { if (args.length === 0) { this.print('=== Terminal Config ===', 'info'); this.print(`terminal-max-lines: ${this.MAX_OUTPUT_LINES} (default: 100)`, ''); this.print(`terminal-hist-output: ${this._historyMaxLines} (default: 50)`, ''); this.print(`terminal-cmd-history: ${this._historyCap} (default: 200)`, ''); this.print(`terminal-pipe-buffer: ${this._pipeBufferSize} (default: 5000)`, ''); this.print(`terminal-sysmsg: ${this._showSystemMessages ? 'on' : 'off'} (default: on)`, ''); this.print('Usage: config ', 'info'); this.print('Keys: terminal-max-lines, terminal-hist-output, terminal-cmd-history, terminal-pipe-buffer, terminal-sysmsg', 'info'); return; } const key = args[0]; const val = args[1]; if (val === undefined) { this.print('Usage: config ', 'warning'); return; } const n = parseInt(val); if (key === 'terminal-sysmsg') { this._showSystemMessages = val !== 'off'; this._saveTerminalConfig(); this.print(`config ${key} set to ${this._showSystemMessages ? 'on' : 'off'}`, 'success'); return; } if (isNaN(n) || n < 1) { this.print('Value must be a positive number', 'warning'); return; } if (key === 'terminal-max-lines') { this.MAX_OUTPUT_LINES = n; } else if (key === 'terminal-hist-output') { this._historyMaxLines = n; } else if (key === 'terminal-cmd-history') { this._historyCap = n; } else if (key === 'terminal-pipe-buffer') { this._pipeBufferSize = n; } else { this.print(`Unknown key: ${key}. Valid: terminal-max-lines, terminal-hist-output, terminal-cmd-history, terminal-pipe-buffer, terminal-sysmsg`, 'warning'); return; } this._saveTerminalConfig(); this.print(`config ${key} set to ${val}`, 'success'); return; } if (cmd === 'security') { this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info'); this.print(`Blocked commands: ${this.security.blockedCount}`, 'info'); this.print(`Destructive set: ${[...this.security.destructiveCmds].sort().join(', ')}`, ''); this.print(`System set: ${[...this.security.systemCmds].sort().join(', ')}`, ''); this.print(`Suspicious flags: ${[...this.security.suspiciousFlags].sort().join(', ')}`, ''); this.print(`Whitelist: ${[...this.security.whitelist].sort().join(', ')}`, ''); const all = this.security._collectAllCommands(); this.print(`Registered commands: ${all.length}`, ''); return; } if (cmd === 'security-level') { const lv = parseInt(args[0]); if (isNaN(lv)) { this.print('Usage: security-level 0|1|2|3', 'warning'); return; } this.security.setLevel(lv); localStorage.setItem('webx11_security_level', lv); return; } if (cmd === 'security-destructive') { const action = args[0]; if (!action) { this.print('Usage: security-destructive add|remove|list [cmd]', 'warning'); return; } if (action === 'list') { this.print('Destructive commands:', 'info'); for (const c of this.security.destructiveCmds) this.print(` ${c}`, ''); return; } if (!args[1]) { this.print('Usage: security-destructive add|remove ', 'warning'); return; } if (action === 'add') { this.security.destructiveCmds.add(args[1]); } else if (action === 'remove') { this.security.destructiveCmds.delete(args[1]); } else { this.print('Action must be: add, remove, or list', 'warning'); return; } this.security._saveSecurityDeltas(); this.print(`Updated destructive set (${this.security.destructiveCmds.size} commands)`, 'success'); return; } if (cmd === 'security-system') { const action = args[0]; if (!action) { this.print('Usage: security-system add|remove|list [cmd]', 'warning'); return; } if (action === 'list') { this.print('System commands:', 'info'); for (const c of this.security.systemCmds) this.print(` ${c}`, ''); return; } if (!args[1]) { this.print('Usage: security-system add|remove ', 'warning'); return; } if (action === 'add') { this.security.systemCmds.add(args[1]); } else if (action === 'remove') { this.security.systemCmds.delete(args[1]); } else { this.print('Action must be: add, remove, or list', 'warning'); return; } this.security._saveSecurityDeltas(); this.print(`Updated system set (${this.security.systemCmds.size} commands)`, 'success'); return; } if (cmd === 'security-whitelist') { const action = args[0]; if (!action) { this.print('Usage: security-whitelist add|remove|list [cmd]', 'warning'); return; } if (action === 'list') { this.print('Whitelist commands:', 'info'); for (const c of this.security.whitelist) this.print(` ${c}`, ''); return; } if (!args[1]) { this.print('Usage: security-whitelist add|remove ', 'warning'); return; } if (action === 'add') { this.security.whitelist.add(args[1]); } else if (action === 'remove') { this.security.whitelist.delete(args[1]); } else { this.print('Action must be: add, remove, or list', 'warning'); return; } this.security._saveSecurityDeltas(); this.print(`Updated whitelist (${this.security.whitelist.size} commands)`, 'success'); return; } if (cmd === 'security-flag') { const action = args[0]; if (!action) { this.print('Usage: security-flag add|remove|list [flag]', 'warning'); return; } if (action === 'list') { this.print('Suspicious flags:', 'info'); for (const f of this.security.suspiciousFlags) this.print(` ${f}`, ''); return; } if (!args[1]) { this.print('Usage: security-flag add|remove ', 'warning'); return; } const flag = args[1].startsWith('--') ? args[1] : '--' + args[1]; if (action === 'add') { this.security.suspiciousFlags.add(flag); } else if (action === 'remove') { this.security.suspiciousFlags.delete(flag); } else { this.print('Action must be: add, remove, or list', 'warning'); return; } this.security._saveSecurityDeltas(); this.print(`Updated suspicious flags (${this.security.suspiciousFlags.size} flags)`, 'success'); return; } if (cmd === 'cmd-allow') { this._resolveCmdConfirm(true); return; } if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; } if (cmd === 'vfs' && args[0] === 'reset') { if (args[1] !== '--confirm' && args[1] !== '--force') { this.print('⚠ This will permanently delete all files! Use vfs reset --confirm to confirm.', 'warning'); return; } this.fs.reset(); this.print('VFS reset — all files deleted.', 'success'); return; } if (cmd === 'vfs' && args[0] === 'clean') { let count = 0; const sysDirs = ['_snapshots', '_sessions', '.trash']; for (const dir of sysDirs) { if (this.fs.root.content && this.fs.root.content[dir]) { delete this.fs.root.content[dir]; count++; } } if (count > 0) { this.fs.saveToStorage(); this.print('🧹 Cleaned ' + count + ' system dirs: ' + sysDirs.join(', '), 'success'); } else { this.print('Nothing to clean — system dirs already empty.', 'info'); } return; } if (cmd === 'vfs-clean') { let count = 0; const sysDirs = ['_snapshots', '_sessions', '.trash']; for (const dir of sysDirs) { if (this.fs.root.content && this.fs.root.content[dir]) { delete this.fs.root.content[dir]; count++; } } if (count > 0) { this.fs.saveToStorage(); this.print('🧹 Cleaned ' + count + ' system dirs: ' + sysDirs.join(', '), 'success'); } else { this.print('Nothing to clean — system dirs already empty.', 'info'); } return; } if (cmd === 'empty-trash') { if (args[0] !== '--confirm' && args[0] !== '--force') { this.print('⚠ This will permanently delete all trashed files! Use empty-trash --confirm to confirm.', 'warning'); return; } if (this.fs.emptyTrash()) { this.print('Trash emptied.', 'success'); } else { this.print('Trash is already empty.', 'info'); } return; } if (cmd === 'usage') { const om = this.ollamaManager; if (!om) { this.print('Ollama manager not available', 'error'); return; } const filters = {}; for (let i = 0; i < args.length; i++) { if (args[i] === '--json') { filters._json = true; } else if (args[i] === '--reset') { om._usageLog = []; om._usageBuffer = []; om._saveUsageLog(); this.print('Usage log cleared.', 'success'); return; } else if (args[i] === '--model' && args[i+1]) { filters.model = args[++i]; } else if (args[i] === '--provider' && args[i+1]) { filters.provider = args[++i]; } else if (args[i] === '--since' && args[i+1]) { filters.since_hours = parseFloat(args[++i]); } } const stats = om.getUsageStats(filters); if (filters._json) { this.print(JSON.stringify(stats, null, 2), ''); return; } if (stats.total === 0) { this.print('No usage data recorded yet.', 'info'); return; } const fmt = (n) => n.toLocaleString(); const timeStr = stats.timeSpan < 60 ? stats.timeSpan + 's' : stats.timeSpan < 3600 ? Math.round(stats.timeSpan / 60) + 'm' : Math.round(stats.timeSpan / 3600) + 'h ' + Math.round((stats.timeSpan % 3600) / 60) + 'm'; this.print('╔══════════════════════════════════════════════════════╗', 'info'); this.print('║ LLM Usage Stats ║', 'info'); this.print('╠══════════════════════════════════════════════════════╣', 'info'); this.print(`║ Total requests: ${fmt(stats.total).padStart(30)} ║`, 'info'); this.print(`║ Total tokens: ${fmt(stats.totalPrompt + stats.totalCompletion).padStart(30)} ║`, 'info'); this.print(`║ Input: ${fmt(stats.totalPrompt).padStart(37)} ║`, 'info'); this.print(`║ Output: ${fmt(stats.totalCompletion).padStart(37)} ║`, 'info'); this.print(`║ Avg latency: ${(stats.avgLatency / 1000).toFixed(1) + 's'.padStart(30)} ║`, 'info'); this.print(`║ Time span: ${timeStr.padStart(30)} ║`, 'info'); this.print('╠══════════════════════════════════════════════════════╣', 'info'); this.print('║ Per model: ║', 'info'); for (const [model, data] of Object.entries(stats.byModel).sort((a, b) => b[1].requests - a[1].requests)) { const label = (model.length > 30 ? model.slice(0, 27) + '...' : model).padEnd(30); this.print(`║ ${label} ${fmt(data.requests).padStart(4)} req ${fmt(data.tokens).padStart(8)} tok ║`, ''); } this.print('╠══════════════════════════════════════════════════════╣', 'info'); this.print('║ Per provider: ║', 'info'); for (const [prov, data] of Object.entries(stats.byProvider).sort((a, b) => b[1].requests - a[1].requests)) { const label = (prov.length > 30 ? prov.slice(0, 27) + '...' : prov).padEnd(30); this.print(`║ ${label} ${fmt(data.requests).padStart(4)} req ${fmt(data.tokens).padStart(8)} tok ║`, ''); } this.print('╚══════════════════════════════════════════════════════╝', 'info'); this.print('Flags: --json, --model=, --provider=, --since=, --reset', 'text-muted'); return; } if (cmd === 'history') { const entries = this.history; if (!entries.length) { this.print('No command history.', 'info'); return; } // Flags if (args[0] === '--clear' || args[0] === '-c') { this.history = []; this.historyIndex = 0; localStorage.removeItem('webx11_command_history'); this.print('History cleared.', 'success'); return; } if (args[0] === '--export' || args[0] === '-e') { try { this.fs.mkdirRecursive('system'); this.fs.writeFile('system/history.json', JSON.stringify(entries)); this.print('History exported to /system/history.json (' + entries.length + ' entries).', 'success'); } catch (e) { this.print('Export failed: ' + e.message, 'error'); } return; } if (args[0] === '--search' || args[0] === '-s') { const term = args.slice(1).join(' ').toLowerCase(); if (!term) { this.print('Usage: history --search ', 'warning'); return; } const filtered = entries.filter(e => e.toLowerCase().includes(term)); if (!filtered.length) { this.print('No matches for: ' + term, 'info'); return; } this.print('=== History (matching: ' + term + ') ===', 'info'); filtered.forEach(e => this.print(' ' + e)); return; } let count = 50; if (args[0] === '-n' && args[1]) count = parseInt(args[1]) || 50; const slice = entries.slice(-count); this.print('=== Command History (' + entries.length + ' entries) ===', 'info'); slice.forEach(c => this.print(' ' + c)); if (entries.length > count) this.print('... (' + (entries.length - count) + ' older, use history --search to find)'); return; } for (const modName in this.moduleManager.modules) { const mod = this.moduleManager.modules[modName]; if (mod.commands && mod.commands[cmd]) { if (cmd !== 'course') { for (const _m of Object.values(this.moduleManager.modules)) { if (typeof _m.onCommandStart === 'function') { try { _m.onCommandStart(cmd, args); } catch (_e) {} } } } try { await mod.commands[cmd](args, rawText); if (cmd !== 'tutorial' && cmd !== 'course') { for (const _m of Object.values(this.moduleManager.modules)) { if (typeof _m.onCommandExecuted === 'function') { try { _m.onCommandExecuted(cmd, args); } catch (_e) {} } } } // Memory graph: record file access for read/file commands if (cmd === 'cat' || cmd === 'view_module' || cmd === 'edit_module') { try { const _mg = this.moduleManager.modules['MemoryGraphModule']; if (_mg && args[0]) _mg.recordFileAccess(args[0]); } catch (_e) {} } return; } catch (err) { // v4.6.134: `silent` errors are control flow, not failures — `test`/`[` // reject to tell `&&`/`||` to skip, and must print nothing (POSIX). if (err && err.silent) return; this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); console.error(`[Terminal] Error in ${modName}.${cmd}:`, err); return; } } } // Enhanced: check fuzzy suggestions before showing "not found" const suggestion = this.security.suggest(cmd); if (suggestion && suggestion.dist <= 2 && suggestion.dist > 0) { this.print(`Command not found: ${cmd}`, 'error'); this.print(`Did you mean: ${suggestion.cmd}? (yes) (no)`, 'info'); // Render clickable yes/no const out = this.outputContainer; if (out) { const yesSpan = document.createElement('span'); yesSpan.textContent = ' [yes] '; yesSpan.style.cssText = 'color:var(--success-color);cursor:pointer;text-decoration:underline;margin-right:12px'; yesSpan.onclick = () => this.executeCommand(suggestion.cmd + (args.length ? ' ' + args.join(' ') : '')); const noSpan = document.createElement('span'); noSpan.textContent = ' [no] '; noSpan.style.cssText = 'color:var(--error-color);cursor:pointer;text-decoration:underline'; noSpan.onclick = () => { this.print('Dismissed.', 'text-muted'); }; const line = out.lastElementChild; if (line) { line.appendChild(yesSpan); line.appendChild(noSpan); } } return; } this.print(`Command not found: ${cmd}`, 'error'); } openFileBrowser() { const modal = document.getElementById('file-browser'); const content = document.getElementById('fb-content'); content.innerHTML = ''; const files = this.fs.ls(); if (files.length === 0) { content.innerHTML = '
Directory Empty
'; } else { files.forEach(f => { const div = document.createElement('div'); div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`; const icon = document.createElement('span'); icon.textContent = f.type === 'dir' ? '📁' : '📄'; const name = document.createElement('span'); name.textContent = ' ' + f.name; const size = document.createElement('span'); size.style.fontSize = '0.8em'; size.textContent = ' ' + f.size + 'b'; div.appendChild(icon); div.appendChild(name); div.appendChild(document.createTextNode(' ')); div.appendChild(size); div.onclick = () => { if (f.type === 'file') { const content = this.fs.readFile(f.name); this.print(`--- ${f.name} ---`); this.print(content); this.print('--- End ---'); this.closeFileBrowser(); } }; content.appendChild(div); }); } modal.style.display = 'flex'; } closeFileBrowser() { document.getElementById('file-browser').style.display = 'none'; this.inputField.focus(); } openModuleManager() { const modal = document.getElementById('module-manager'); this.renderModuleList(); modal.style.display = 'flex'; } closeModuleManager() { document.getElementById('module-manager').style.display = 'none'; this.inputField.focus(); } renderModuleList() { const list = document.getElementById('module-list'); list.innerHTML = ''; const modules = this.moduleManager.getModuleList(); if (modules.length === 0) { list.innerHTML = '
No modules loaded
'; return; } modules.forEach(mod => { const div = document.createElement('div'); div.className = `module-item ${mod.loaded ? '' : 'disabled'}`; const info = document.createElement('div'); const strong = document.createElement('strong'); strong.textContent = mod.name; info.appendChild(strong); if (!mod.url) { const badge = document.createElement('span'); badge.className = 'module-badge builtin'; badge.textContent = 'Built-in'; info.appendChild(badge); } const sub = document.createElement('div'); sub.style.cssText = 'font-size:0.8em; color:#666'; sub.textContent = mod.url || 'Built-in'; info.appendChild(sub); div.appendChild(info); if (mod.url) { const btn = document.createElement('button'); btn.className = 'btn btn-danger'; btn.style.padding = '4px 8px'; btn.textContent = '✕'; btn.onclick = () => this.removeModule(mod.name); div.appendChild(btn); } list.appendChild(div); }); } async loadModuleFromURL() { const url = document.getElementById('module-url-input').value.trim(); if (!url) { alert('Please enter a URL'); return; } const success = await this.moduleManager.loadModuleFromURL(url); if (success) { document.getElementById('module-url-input').value = ''; this.renderModuleList(); } } removeModule(name) { if (this.moduleManager.unregisterModule(name)) { this.print(`Module '${name}' removed`, 'success'); this.renderModuleList(); } } clearAllModules() { if (confirm('Remove all custom modules? Built-in modules will remain.')) { this.moduleManager.clearAll(); this.renderModuleList(); this.print('All custom modules cleared', 'success'); } } openOllamaManager() { const modal = document.getElementById('ollama-manager'); document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint; this.updateOllamaStatus(); this.renderOllamaModels(); this.initOllamaUI(); modal.style.display = 'flex'; } closeOllamaManager() { document.getElementById('ollama-manager').style.display = 'none'; this.inputField.focus(); } openPreview(args) { let path = ''; if (typeof args === 'string') path = args.trim(); else if (Array.isArray(args)) path = args.join(' ').trim(); if (!path) { this.print('Usage: preview ', 'warning'); return; } // Remove leading slash for VFS paths const vfsPath = path.replace(/^\/*/, ''); let content = this.fs.readFile(vfsPath); if (content === null) content = this.fs.readFile('/' + vfsPath); if (content === null) { this.print(`File not found: ${path}`, 'error'); return; } // Use srcdoc instead of blob URL — works reliably on mobile browsers const iframe = document.getElementById('preview-iframe'); iframe.srcdoc = content; document.getElementById('preview-title').textContent = `📄 ${path}`; document.getElementById('preview-modal').style.display = 'flex'; } closePreview() { document.getElementById('preview-modal').style.display = 'none'; const iframe = document.getElementById('preview-iframe'); iframe.srcdoc = ''; iframe.src = 'about:blank'; this.inputField.focus(); } openRenderPreview(btn) { const content = btn.getAttribute('data-content'); if (!content) return; const iframe = document.getElementById('preview-iframe'); // The srcdoc content is already HTML-escaped from _renderHTMLPreview const decoded = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/`/g, '`'); iframe.srcdoc = decoded; document.getElementById('preview-title').textContent = '📄 AI Rendered Preview (Fullscreen)'; document.getElementById('preview-modal').style.display = 'flex'; } previewHtmlFromPrompt(btn) { const content = btn.getAttribute('data-content'); if (!content) return; const iframe = document.getElementById('preview-iframe'); // Content is HTML-escaped, decode it back const decoded = content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/`/g, '`'); // Wrap in a basic HTML template if it doesn't have html/head/body tags const hasOwnDoc = /${decoded}`; iframe.srcdoc = fullDoc; document.getElementById('preview-title').textContent = '📄 User HTML Preview'; document.getElementById('preview-modal').style.display = 'flex'; } openSkillsManager() { const modal = document.getElementById('skills-manager'); // Ensure /skills/ directory exists this.fs.mkdirRecursive('/skills'); this.refreshSkillsList(); modal.style.display = 'flex'; } closeSkillsManager() { document.getElementById('skills-manager').style.display = 'none'; this.inputField.focus(); } openConfigManager() { document.getElementById('config-manager').style.display = 'flex'; this.renderConfigList(); } closeConfigManager() { document.getElementById('config-manager').style.display = 'none'; this.inputField.focus(); } renderConfigList() { const list = document.getElementById('config-list'); if (!list) return; list.innerHTML = ''; const prefix = 'webx11_config_'; const entries = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(prefix)) { entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) }); } } if (entries.length === 0) { list.innerHTML = '
No configuration saved.
'; return; } entries.forEach(e => { const item = document.createElement('div'); item.className = 'module-item'; const info = document.createElement('div'); const strong = document.createElement('strong'); strong.textContent = e.key; info.appendChild(strong); const val = document.createElement('span'); val.style.color = 'var(--text-muted)'; const sensitive = /password|token|secret|key|private|credential|auth/i.test(e.key); val.textContent = sensitive ? '****' : e.value; info.appendChild(document.createTextNode(' ')); info.appendChild(val); const btn = document.createElement('button'); btn.className = 'btn btn-danger'; btn.style.cssText = 'padding:4px 8px; font-size:0.8em'; btn.textContent = '✕'; btn.onclick = () => this.deleteConfigFromUI(e.key); item.appendChild(info); item.appendChild(btn); list.appendChild(item); }); } addConfigFromUI() { const key = document.getElementById('config-key-input').value.trim(); const value = document.getElementById('config-value-input').value.trim(); if (!key || !value) { this.print('Enter both key and value', 'warning'); return; } localStorage.setItem('webx11_config_' + key, value); document.getElementById('config-key-input').value = ''; document.getElementById('config-value-input').value = ''; this.print(`Config saved: ${key} = ${value}`, 'success'); this.renderConfigList(); } deleteConfigFromUI(key) { localStorage.removeItem('webx11_config_' + key); this.print(`Config '${key}' deleted`, 'success'); this.renderConfigList(); } exportConfigFromUI() { const mod = this.moduleManager.modules['ConfigManagerModule']; if (mod && mod.commands && mod.commands['config-export']) { mod.commands['config-export'](); } } resetConfigFromUI() { const mod = this.moduleManager.modules['ConfigManagerModule']; if (mod && mod.commands && mod.commands['config-reset']) { mod.commands['config-reset'](); this.renderConfigList(); } } refreshSkillsList() { const list = document.getElementById('skills-list'); list.innerHTML = ''; const statsEl = document.getElementById('skills-stats'); const fs = this.fs; fs.mkdirRecursive('/skills'); const index = fs.fsReadJSON('/skills/index.json') || {}; const names = Object.keys(index); // Search filter const query = (document.getElementById('skills-search-input')?.value || '').toLowerCase().trim(); let filtered = names; if (query) { filtered = names.filter(n => { const s = index[n]; return n.includes(query) || (s.description || '').toLowerCase().includes(query) || (s.tags || []).some(t => t.toLowerCase().includes(query)) || (s.category || '').toLowerCase().includes(query); }); } // Stats if (statsEl) { const cats = new Set(Object.values(index).map(s => s.category || 'general')); statsEl.innerHTML = ''; const totalSpan = document.createElement('span'); totalSpan.textContent = '📚 ' + names.length + ' skills'; statsEl.appendChild(totalSpan); const catSpan = document.createElement('span'); catSpan.textContent = '📂 ' + cats.size + ' categories'; statsEl.appendChild(catSpan); if (query) { const matchSpan = document.createElement('span'); matchSpan.textContent = '🔍 ' + filtered.length + ' match'; statsEl.appendChild(matchSpan); } } if (filtered.length === 0) { const empty = document.createElement('div'); empty.style.cssText = 'padding:20px; text-align:center; color:#666'; empty.textContent = query ? 'No skills match your search.' : 'No skills yet. Create one below!'; list.appendChild(empty); return; } // Group by category const groups = {}; for (const name of filtered) { const s = index[name]; const cat = s.category || 'general'; if (!groups[cat]) groups[cat] = []; groups[cat].push(name); } const catOrder = Object.keys(groups).sort(); for (const cat of catOrder) { const section = document.createElement('div'); section.style.marginBottom = '6px'; const header = document.createElement('div'); header.style.cssText = 'font-size:0.8em; font-weight:600; color:var(--info-color); padding:4px 0; cursor:pointer; display:flex; align-items:center; gap:4px;'; header.textContent = '📁 ' + cat + ' (' + groups[cat].length + ')'; header.onclick = () => { const body = header.nextElementSibling; if (body) body.style.display = body.style.display === 'none' ? '' : 'none'; }; section.appendChild(header); const body = document.createElement('div'); for (const name of groups[cat]) { const s = index[name]; const tags = (s.tags || []).join(', '); const div = document.createElement('div'); div.className = 'module-item'; const info = document.createElement('div'); const strong = document.createElement('strong'); strong.style.color = 'var(--success-color)'; strong.textContent = name; info.appendChild(strong); const desc = document.createElement('div'); desc.style.cssText = 'font-size:0.85em; color:#aaa'; desc.textContent = s.description || ''; info.appendChild(desc); const tagsDiv = document.createElement('div'); tagsDiv.style.cssText = 'font-size:0.75em; color:#666'; tagsDiv.textContent = tags ? '🏷 ' + tags : ''; info.appendChild(tagsDiv); const actions = document.createElement('div'); actions.style.cssText = 'display:flex;gap:4px'; const viewBtn = document.createElement('button'); viewBtn.className = 'btn'; viewBtn.style.cssText = 'padding:4px 8px; font-size:0.8em'; viewBtn.textContent = '👁'; viewBtn.onclick = () => this.viewSkillUI(name); const editBtn = document.createElement('button'); editBtn.className = 'btn'; editBtn.style.cssText = 'padding:4px 8px; font-size:0.8em'; editBtn.textContent = '✏️'; editBtn.onclick = () => this.editSkillUI(name); const delBtn = document.createElement('button'); delBtn.className = 'btn btn-danger'; delBtn.style.cssText = 'padding:4px 8px; font-size:0.8em'; delBtn.textContent = '✕'; delBtn.onclick = () => this.deleteSkillUI(name); actions.appendChild(viewBtn); actions.appendChild(editBtn); actions.appendChild(delBtn); div.appendChild(info); div.appendChild(actions); body.appendChild(div); } section.appendChild(body); list.appendChild(section); } } viewSkillUI(name) { const fs = this.fs; const content = fs.readFile(`/skills/${name}.md`); if (!content) { this.print(`Skill '${name}' not found`, 'error'); return; } document.getElementById('skill-preview-title').textContent = name; document.getElementById('skill-preview-content').textContent = content; document.getElementById('skill-preview').style.display = 'flex'; document.getElementById('skill-preview-expand-btn').textContent = '⛶ Expand'; document.getElementById('skill-preview').style.maxHeight = '250px'; } closeSkillPreview() { document.getElementById('skill-preview').style.display = 'none'; } toggleSkillPreviewSize() { const preview = document.getElementById('skill-preview'); const btn = document.getElementById('skill-preview-expand-btn'); if (preview.style.maxHeight === 'none' || preview.style.maxHeight === '80vh') { preview.style.maxHeight = '250px'; btn.textContent = '⛶ Expand'; } else { preview.style.maxHeight = '80vh'; btn.textContent = '⛶ Collapse'; } } editSkillUI(name) { this.closeSkillsManager(); if (typeof webx11.dispatchCommand === 'function') { setTimeout(() => webx11.dispatchCommand('edit', ['/skills/' + name + '.md']), 200); } } deleteSkillUI(name) { if (!confirm(`Delete skill "${name}"?`)) return; const fs = this.fs; fs.mkdirRecursive('/skills'); if (fs.root && fs.root.content && fs.root.content['skills'] && fs.root.content['skills'].content) { delete fs.root.content['skills'].content[`${name}.md`]; } const index = fs.fsReadJSON('/skills/index.json') || {}; delete index[name]; fs.fsWriteJSON('/skills/index.json', index); this.print(`Skill '${name}' deleted`, 'success'); this.refreshSkillsList(); fs.saveToStorage(); } createSkillFromUI() { const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase(); const description = document.getElementById('skill-desc-input').value.trim() || 'No description'; const category = document.getElementById('skill-category-input')?.value || 'general'; const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean); const contentInput = document.getElementById('skill-content-input').value.trim(); if (!name) { alert('Skill name is required'); return; } const fs = this.fs; fs.mkdirRecursive('/skills'); if (fs.readFile(`/skills/${name}.md`)) { alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`); return; } let content = contentInput || `---\nname: ${this._escapeYamlString(name)}\ndescription: ${this._escapeYamlString(description)}\ntags: [${tags.map(t => this._escapeYamlString(t)).join(', ')}]\ncategory: ${this._escapeYamlString(category)}\n---\n## Steps\n1. (to be filled)\n`; if (!content.startsWith('---')) { content = `---\nname: ${this._escapeYamlString(name)}\ndescription: ${this._escapeYamlString(description)}\ntags: [${tags.map(t => this._escapeYamlString(t)).join(', ')}]\ncategory: ${this._escapeYamlString(category)}\n---\n${content}`; } fs.writeFile(`/skills/${name}.md`, content); const index = fs.fsReadJSON('/skills/index.json') || {}; index[name] = { name, description, category, tags }; fs.fsWriteJSON('/skills/index.json', index); this.print(`Skill '${name}' created`, 'success'); document.getElementById('skill-name-input').value = ''; document.getElementById('skill-desc-input').value = ''; document.getElementById('skill-tags-input').value = ''; document.getElementById('skill-content-input').value = ''; this.refreshSkillsList(); } exportAllSkillsUI() { const fs = this.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; const names = Object.keys(index); if (names.length === 0) { alert('No skills to export.'); return; } const exportData = {}; for (const name of names) { const content = fs.readFile(`/skills/${name}.md`); if (content !== null) { exportData[name] = { ...index[name], content }; } } const json = JSON.stringify(exportData, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'skills-export-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(url); this.print(`📤 Exported ${names.length} skills`, 'success'); } importSkillsUI(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); const fs = this.fs; fs.mkdirRecursive('/skills'); const index = fs.fsReadJSON('/skills/index.json') || {}; let imported = 0; for (const [name, skill] of Object.entries(data)) { if (!skill.content) continue; fs.writeFile(`/skills/${name}.md`, skill.content); index[name] = { name, description: skill.description || name, tags: skill.tags || [], category: skill.category || 'general', created: (index[name] && index[name].created) || new Date().toISOString(), updated: new Date().toISOString() }; imported++; } fs.fsWriteJSON('/skills/index.json', index); this.print(`📥 Imported ${imported} skills`, 'success'); this.refreshSkillsList(); } catch (err) { alert('Invalid JSON file: ' + err.message); } }; reader.readAsText(file); event.target.value = ''; } syncSkillsToWebdavUI() { const mod = this.moduleManager && this.moduleManager.modules['SkillPackCoreModule']; if (mod && mod.commands && mod.commands['skill-webdav-sync']) { mod.commands['skill-webdav-sync'](); } else { this.print('WebDAV sync not available. Is skill-pack-core loaded?', 'warning'); } } updateOllamaStatus() { const statusDiv = document.getElementById('ollama-status'); const statusText = document.getElementById('ollama-status-text'); if (this.ollamaManager.connected) { statusDiv.className = 'ollama-status connected'; statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`; } else { statusDiv.className = 'ollama-status disconnected'; statusText.textContent = 'Disconnected'; } } renderOllamaModels() { const container = document.getElementById('ollama-models'); const select = document.getElementById('ollama-current-model'); const searchInput = document.getElementById('ollama-search'); const countDiv = document.getElementById('ollama-model-count'); const query = (searchInput ? searchInput.value : '').toLowerCase().trim(); container.innerHTML = ''; select.innerHTML = ''; const models = this.ollamaManager.getModels(); if (models.length === 0) { container.innerHTML = '
Connect to see models
'; if (countDiv) countDiv.textContent = ''; return; } const filtered = query ? models.filter(m => m.name.toLowerCase().includes(query)) : models; if (countDiv) countDiv.textContent = filtered.length === models.length ? models.length + ' model' + (models.length !== 1 ? 's' : '') : filtered.length + ' / ' + models.length + ' model' + (models.length !== 1 ? 's' : ''); if (filtered.length === 0) { container.innerHTML = '
No models match "' + query + '"
'; return; } filtered.forEach(model => { const div = document.createElement('div'); div.className = 'model-item'; div.innerHTML = `${model.name}${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}`; div.onclick = () => { this.ollamaManager.setModel(model.name); select.value = model.name; }; container.appendChild(div); const option = document.createElement('option'); option.value = model.name; option.textContent = model.name; if (model.name === this.ollamaManager.currentModel) { option.selected = true; } select.appendChild(option); }); } async connectOllama() { const endpoint = document.getElementById('ollama-endpoint').value.trim(); this.ollamaManager.setEndpoint(endpoint); const success = await this.ollamaManager.connect(); if (success) { this.renderOllamaModels(); this._populateValidatorModels(); this.updateOllamaStatus(); } } setOllamaModel() { const select = document.getElementById('ollama-current-model'); this.ollamaManager.setModel(select.value); } toggleOptimization() { const cb = document.getElementById('ollama-optimization-toggle'); const label = document.getElementById('ollama-optimization-label'); const slider = document.getElementById('ollama-optimization-slider'); const enabled = cb.checked; this.ollamaManager.setOptimizationEnabled(enabled); if (label) { label.textContent = enabled ? 'Enabled' : 'Disabled'; label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } if (slider) { slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; } this.ollamaManager.saveSettings(); } toggleValidator() { const cb = document.getElementById('ollama-validator-toggle'); const label = document.getElementById('ollama-validator-label'); const slider = document.getElementById('ollama-validator-slider'); const enabled = cb.checked; this.ollamaManager.validatorEnabled = enabled; if (label) { label.textContent = enabled ? 'Enabled' : 'Disabled'; label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } if (slider) { slider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)'; slider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; } this.ollamaManager.saveSettings(); } toggleStream() { const cb = document.getElementById('ollama-stream-toggle'); const label = document.getElementById('ollama-stream-label'); const slider = document.getElementById('ollama-stream-slider'); const enabled = cb.checked; this.ollamaManager.setStreamEnabled(enabled); if (label) { label.textContent = enabled ? 'Enabled' : 'Disabled'; label.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } if (slider) { slider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; slider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; } } updateStreamIndicator() { const el = document.getElementById('stream-status'); if (!el) return; const enabled = this.ollamaManager?.streamEnabled !== false; el.textContent = enabled ? '▶ Stream' : '🔇 No-Stream'; el.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } saveValidatorConfig() { const select = document.getElementById('validator-model-select'); const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; const model = select ? select.value : ''; localStorage.setItem('webx11_validator_endpoint', endpoint); this.ollamaManager.validatorEndpoint = endpoint; this.ollamaManager.setValidatorConfig(this.ollamaManager.validatorEnabled, model); this.print('Validator config saved. Model: ' + (model || '(none)') + ' at ' + endpoint, 'success'); } async connectValidatorEndpoint() { const endpoint = document.getElementById('validator-endpoint-input')?.value?.trim() || 'http://localhost:11434'; const statusEl = document.getElementById('validator-connection-status'); const select = document.getElementById('validator-model-select'); if (statusEl) statusEl.textContent = '⏳ Connecting to ' + endpoint + '...'; if (select) { select.disabled = true; select.innerHTML = ''; } try { const resp = await fetch(endpoint + '/api/tags'); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); const models = data.models || []; if (statusEl) statusEl.textContent = '✅ Connected: ' + models.length + ' model(s)'; // Populate dropdown if (select) { select.innerHTML = ''; models.forEach(function(m) { var opt = document.createElement('option'); opt.value = m.name; opt.textContent = m.name; select.appendChild(opt); }); select.disabled = false; } // Save endpoint localStorage.setItem('webx11_validator_endpoint', endpoint); this.ollamaManager.validatorEndpoint = endpoint; this.ollamaManager.saveSettings(); } catch (e) { if (statusEl) { statusEl.innerHTML = '❌ Connection failed: ' + e.message + '
Make sure Ollama is running and ' + 'OLLAMA_ORIGINS includes this page\'s origin.'; } if (select) { select.disabled = true; select.innerHTML = ''; } } } initOllamaUI() { // Initialize optimization toggle from stored state const optCb = document.getElementById('ollama-optimization-toggle'); const optLabel = document.getElementById('ollama-optimization-label'); const optSlider = document.getElementById('ollama-optimization-slider'); if (optCb && this.ollamaManager) { const enabled = this.ollamaManager.optimizationEnabled !== false; optCb.checked = enabled; if (optLabel) { optLabel.textContent = enabled ? 'Enabled' : 'Disabled'; optLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } if (optSlider) { optSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; optSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; } } // Initialize validator toggle from stored state const valCb = document.getElementById('ollama-validator-toggle'); const valLabel = document.getElementById('ollama-validator-label'); const valSlider = document.getElementById('ollama-validator-slider'); if (valCb && this.ollamaManager) { const enabled = this.ollamaManager.validatorEnabled === true; valCb.checked = enabled; if (valLabel) { valLabel.textContent = enabled ? 'Enabled' : 'Disabled'; valLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } if (valSlider) { valSlider.style.background = enabled ? 'var(--info-color)' : 'var(--bg-tertiary)'; valSlider.style.borderColor = enabled ? 'var(--info-color)' : 'var(--border-color)'; } } // Auto-populate validator model dropdown from connected models this._populateValidatorModels(); // Initialize stream toggle from stored state const strCb = document.getElementById('ollama-stream-toggle'); const strLabel = document.getElementById('ollama-stream-label'); const strSlider = document.getElementById('ollama-stream-slider'); if (strCb && this.ollamaManager) { const enabled = this.ollamaManager.streamEnabled !== false; strCb.checked = enabled; if (strLabel) { strLabel.textContent = enabled ? 'Enabled' : 'Disabled'; strLabel.style.color = enabled ? 'var(--success-color)' : 'var(--text-muted)'; } if (strSlider) { strSlider.style.background = enabled ? 'var(--accent-color)' : 'var(--bg-tertiary)'; strSlider.style.borderColor = enabled ? 'var(--success-color)' : 'var(--border-color)'; } } this.updateStreamIndicator(); // Populate validator model dropdown from stored config const valSelect = document.getElementById('validator-model-select'); const valEndpoint = document.getElementById('validator-endpoint-input'); if (valEndpoint && this.ollamaManager) { valEndpoint.value = this.ollamaManager.validatorEndpoint || 'http://localhost:11434'; } if (valSelect && this.ollamaManager) { const storedModel = this.ollamaManager.validatorModel || ''; if (storedModel) { valSelect.innerHTML = ''; valSelect.disabled = false; } } } _populateValidatorModels() { const select = document.getElementById('validator-model-select'); if (!select || !this.ollamaManager) return; const models = this.ollamaManager.getModels(); if (!models || models.length === 0) { select.innerHTML = ''; select.disabled = true; return; } const stored = this.ollamaManager.validatorModel || ''; let html = ''; models.forEach(function(m) { const sel = m.name === stored ? ' selected' : ''; html += ''; }); select.innerHTML = html; select.disabled = false; } openStartupModules() { document.getElementById('startup-modules').style.display = 'flex'; this.renderStartupModules(); } closeStartupModules() { document.getElementById('startup-modules').style.display = 'none'; } renderStartupModules() { const body = document.getElementById('startup-modules-body'); const selected = (() => { try { const s = localStorage.getItem('webx11_startup_modules'); return s ? JSON.parse(s) : null; } catch (e) { return null; } })(); const isSel = (name) => !selected || selected.includes(name); body.innerHTML = ''; const foundation = ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js']; const builtin = [ 'module-system.js','module-fs.js','module-ollama.js', 'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js', 'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js', 'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js', 'module-fetcher.js','module-chatstudio.js' ]; const extFiles = [ 'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js','cryptogzip.js','tutorial.js','shell.js','busybox.js','floating.js','console.js','media.js' ]; const addSection = (title) => { const h = document.createElement('div'); h.className = 'mod-section-header'; h.textContent = title; body.appendChild(h); }; const addCard = (name, badge, isDisabled) => { const card = document.createElement('div'); card.className = 'mod-card' + (isDisabled ? ' disabled' : ''); const label = document.createElement('label'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = isDisabled || isSel(name); cb.disabled = isDisabled; cb.dataset.name = name; const nameSpan = document.createElement('span'); nameSpan.className = 'mod-name'; nameSpan.textContent = name; const badgeSpan = document.createElement('span'); badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : ''); badgeSpan.textContent = badge; label.appendChild(cb); label.appendChild(nameSpan); card.appendChild(label); card.appendChild(badgeSpan); body.appendChild(card); }; addSection('Foundation (always loaded)'); foundation.forEach(n => addCard(n, 'foundation', true)); addSection('Built-in modules'); builtin.forEach(n => addCard(n, 'built-in', false)); addSection('Extensions'); extFiles.forEach(n => addCard(n, 'ext', false)); } saveStartupModules() { const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)'); const selected = []; cbs.forEach(cb => { if (cb.checked) selected.push(cb.dataset.name); }); try { localStorage.setItem('webx11_startup_modules', JSON.stringify(selected)); } catch (e) { this.print('Failed to save startup modules', 'error'); return; } this.print('Startup modules saved, reloading...', 'success'); setTimeout(() => location.reload(), 500); } openCodeEditor(name, url, code) { const modal = document.getElementById('code-editor'); const title = document.getElementById('code-editor-title'); const info = document.getElementById('code-editor-info'); const ta = document.getElementById('code-editor-textarea'); if (!modal || !ta) return; this._editingModule = { name: name, url: url }; title.textContent = '📝 Edit: ' + name; info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : ''); ta.value = code; modal.style.display = 'flex'; } closeCodeEditor() { document.getElementById('code-editor').style.display = 'none'; this._editingModule = null; } async loadLocalModule(path) { if (!path) { this.print('Usage: load-local ', 'warning'); this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info'); return; } let code = this.fs.readFile(path); if (code === null) { try { const resp = await fetch(path); if (!resp.ok) throw new Error('HTTP ' + resp.status); code = await resp.text(); } catch (e) { this.print('load-local: file not found in VFS or at URL: ' + path, 'error'); return; } } try { const el = document.createElement('script'); el.textContent = code; el.onerror = () => this.print('load-local: failed to execute ' + path, 'error'); document.body.appendChild(el); this.print('Module loaded from ' + path, 'success'); } catch (e) { this.print('load-local: error: ' + e.message, 'error'); } } async saveCodeEditor() { const ta = document.getElementById('code-editor-textarea'); const mod = this._editingModule; if (!mod || !ta) return; const code = ta.value; this.fs.writeFile('_mods/' + mod.name + '.js', code); if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) { this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder'); } this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({ originalUrl: mod.url || '', modifiedAt: new Date().toISOString(), editCount: 1 })); try { const ok = await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js'); if (ok) { this.print('✅ Module loaded live from _mods/' + mod.name + '.js', 'success'); } else { this.print('⚠️ Auto-test failed — edits saved but module not loaded', 'warning'); } } catch (e) { this.print('⚠️ Auto-test failed: ' + e.message + ' — edits saved but module not loaded', 'error'); } this.closeCodeEditor(); } _restoreFontSize() { try { const saved = localStorage.getItem('webx11_font_size'); if (saved) { const s = parseInt(saved); if (s >= 8 && s <= 32) { document.body.style.fontSize = s + 'px'; const tc = document.getElementById('terminal-container'); if (tc) tc.style.fontSize = s + 'px'; const inp = document.getElementById('command-input'); if (inp) inp.style.fontSize = s + 'px'; } } } catch (e) {} } _restoreTheme() { try { const savedTheme = localStorage.getItem('webx11_active_theme'); if (savedTheme) { const themesRaw = localStorage.getItem('webx11_themes'); const defaults = { 'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' }, 'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' }, 'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' }, 'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' }, 'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' }, 'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' }, 'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' }, 'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' }, 'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' } }; if (themesRaw) { const themes = JSON.parse(themesRaw); Object.assign(defaults, themes); } const t = defaults[savedTheme]; if (t) { for (const [k, v] of Object.entries(t)) { document.documentElement.style.setProperty(k, v); } } } } catch (e) { console.warn('[Theme] restore error:', e); } } // ── First-run welcome ── _checkFirstRun() { if (localStorage.getItem('webx11_tutorial_complete') || localStorage.getItem('webx11_tutorial_skipped')) return; // Check if tutorial module is loaded — retry if ext modules not ready yet const tutMod = this.moduleManager.modules['TutorialModule']; if (!tutMod) { // Ext modules may still be loading; retry in 2s if (!this._welcomeRetry) { this._welcomeRetry = true; setTimeout(() => this._checkFirstRun(), 2000); } return; } this.tutorialModule = tutMod; // Only show welcome if user hasn't explicitly dismissed it if (!localStorage.getItem('webx11_welcome_dismissed')) { this.showWelcomeModal(); } } showWelcomeModal() { const modal = document.getElementById('welcome-modal'); if (modal) { modal.style.display = 'flex'; this._welcomeShown = true; } } dismissWelcome() { const modal = document.getElementById('welcome-modal'); if (modal) modal.style.display = 'none'; localStorage.setItem('webx11_welcome_dismissed', '1'); // Also mark tutorial as skipped so it doesn't reappear if (this.tutorialModule) this.tutorialModule.skipTutorial(); } toggleCmdCards() { const panel = document.getElementById('cmd-cards'); if (!panel) return; panel.classList.toggle('visible'); // If opening, focus the input field if (panel.classList.contains('visible')) { setTimeout(() => this.inputField.focus(), 50); } } log(level, source, message) { // v4.6.60: an ERROR also goes to the DebugSupervisor DB, so the log is a // human-readable MIRROR of one store instead of a second store that only one // code path ever wrote to (packet §2: 0 / 18 / 11 in the same second). if (String(level).toUpperCase() === 'ERROR') { try { const ds = this.moduleManager && this.moduleManager.modules ? this.moduleManager.modules['DebugSupervisorModule'] : null; if (ds && typeof ds._recordError === 'function') ds._recordError(String(message), String(source || 'terminal')); } catch (e) { /* never let logging throw */ } } const d = new Date(); const ts = d.toISOString().split('T')[1].split('.')[0]; const entry = `[${ts}][${level}][${source}] ${message}`; console.log(entry); // Write to VFS log try { this.fs.mkdirRecursive('/logs'); let current = this.fs.readFile('/logs/current.log') || ''; current += entry + '\n'; const lines = current.split('\n').filter(l => l); if (lines.length > 200) { this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n')); this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n')); } else { this.fs.writeFile('/logs/current.log', current); } } catch (e) {} } // v4.6.59: ONE error counter. Three surfaces used to disagree because each // tallied a different sink: `errors` scanned /logs/current.log (which only ever // receives explicit terminal.log('ERROR') calls — one call site in the whole app), // the dashboard added debug-DB + failed tool calls, and the world model read the // debug DB alone. Measured: 0 / 18 / 11 in the same second (packet §2). countLogErrors() { try { const c = this.fs.readFile('/logs/current.log') || ''; return c.split('\n').filter(l => l.includes('[ERROR]')).length; } catch (e) { return 0; } } countErrors() { const mm = this.moduleManager; const ds = mm && mm.modules ? mm.modules['DebugSupervisorModule'] : null; const sm = mm && mm.modules ? mm.modules['ServerMonitorModule'] : null; const debug = (ds && ds._errorDB && ds._errorDB.errors) ? Object.keys(ds._errorDB.errors).length : 0; const latency = (sm && Array.isArray(sm._latencyLog)) ? sm._latencyLog.filter(e => e && e.error).length : 0; const log = this.countLogErrors(); return { debug: debug, latency: latency, log: log, total: debug + latency }; } setAiBusy(busy) { this._aiBusy = !!busy; if (this._aiBusyInterval) { clearInterval(this._aiBusyInterval); this._aiBusyInterval = null; } const cancelBtn = document.getElementById('ai-cancel-btn'); if (busy) { if (cancelBtn) cancelBtn.style.display = 'inline-block'; this._aiBusyStart = Date.now(); const updateTimer = () => { let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000); let sec = elapsed % 60; let min = Math.floor(elapsed / 60); this.statusBar.textContent = '🤖 AI Thinking... ⏱ ' + (min > 0 ? min + 'm ' : '') + sec + 's'; this.statusBar.className = 'loading'; }; updateTimer(); this._aiBusyInterval = setInterval(updateTimer, 1000); } else { if (cancelBtn) cancelBtn.style.display = 'none'; this.statusBar.textContent = 'Ready'; this.statusBar.className = ''; this._aiBusyStart = 0; } } printMemoryStats() { const pm = typeof performance !== 'undefined' && performance.memory; let heapInfo = ''; let domNodes = 0; try { domNodes = document.getElementsByTagName('*').length; } catch (e) {} if (pm) { const used = (pm.usedJSHeapSize / 1048576).toFixed(1); const total = (pm.totalJSHeapSize / 1048576).toFixed(1); const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1); heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`; } else { try { const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1); heapInfo = `localStorage: ${lsSize}KB`; } catch (e) { heapInfo = 'Memory API not available on this browser'; } } const output = [ `📊 Memory Stats`, ` ${heapInfo}`, ` DOM nodes: ${domNodes}`, ` Terminal lines in DOM: ${this.outputContainer.children.length}`, ` History buffer: ${this._historyBuffer.length} entries`, ` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`, ` History-backed output lines: ${this._historyBuffer.length}`, ` Commands in history: ${this.history.length}` ].join('\n'); this.print(output, 'info'); } playNotificationSound() { if (localStorage.getItem('webx11_config_ai_sound') === 'off') return; try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 880; osc.type = 'sine'; gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3); osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.3); } catch (e) {} } toggleMicVisibility() { const voiceMod = this.moduleManager.modules['VoiceModule']; if (!voiceMod) { this.print('Voice module not loaded', 'warning'); return; } if (voiceMod.listening) { voiceMod.stopListening(); } else { voiceMod.handle([]); } } toggleDebugPanel() { const mod = this.moduleManager.modules['DebugPanel']; if (mod) { mod.terminal = this; mod.toggle(); } else { this.print('Debug panel not loaded', 'warning'); } } setFontSize(size) { const s = parseInt(size); if (isNaN(s) || s < 8 || s > 32) { this.print('Usage: font-size <8-32>', 'warning'); return; } document.body.style.fontSize = s + 'px'; document.getElementById('terminal-container').style.fontSize = s + 'px'; document.getElementById('command-input').style.fontSize = s + 'px'; try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {} this.print('Font size set to ' + s + 'px 👆', 'success'); } } /* === module-system.js === */ // Extracted from newOS4 index.html — SystemModule class SystemModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'help': 'help [command] — Show all commands or usage for a specific command', 'echo': 'echo — Print text to the terminal', 'mem': 'mem — Show memory usage (heap + localStorage + VFS)', 'status': 'status — Display system status (network, files, modules, Ollama)', 'version': 'version — Show version, build date, and cache key', 'uptime': 'uptime — Show time since page load + last save time', 'errors': 'errors [n] — Show last n ERROR log entries (default: 20)', 'doctor': 'doctor — Run all health checks, rank issues, show top 3 warnings', 'bug-report': 'bug-report — Generate a markdown bug report to /tmp/ for debugging', 'bug': 'bug — File a structured bug report (stored server-side, visible in BUG-REPORT.html)', 'preview': 'preview <file.html> — Render an HTML file from the VFS in a preview pane', 'reset': 'reset [mem|vfs|config|all] — Global reset command (default: mem)', 'factory-reset': 'factory-reset — Alias for reset all', 'reset-vfs': 'reset-vfs — Alias for reset vfs', 'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)', 'logrotate': 'logrotate — Rotate current.log to a dated archive (auto-cleans old)', 'health-check': 'health-check — Audit VFS size, modules, logs, trash, skills, tool failures', 'memory-map-export': 'memory-map-export — Export memory index to /memory/reference.md (overwrites file)', 'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)', 'setfont': 'setfont <8-32> — Alias for font-size', 'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)', 'expand': 'expand — Toggle full-screen terminal output', 'sysmsg': 'sysmsg [on|off] — Toggle system/warning/error/info message visibility', 'checkpoint': 'checkpoint [name] — Create a VFS snapshot (name defaults to ISO timestamp)', 'checkpoint-list': 'checkpoint-list — List all VFS checkpoints', 'rollback': 'rollback <name> — Restore VFS from a checkpoint', 'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint', }; } registerCommands() { this.commands['help'] = (args) => this.handleHelp(args); this.commands['echo'] = (args) => this.terminal.print(args.join(' ')); this.commands['mem'] = () => { const heap = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A'; const heapLimit = performance.memory ? (performance.memory.jsHeapSizeLimit / 1048576).toFixed(0) : 'N/A'; // localStorage size let lsBytes = 0; try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2; } } catch (e) {} const lsKB = (lsBytes / 1024).toFixed(1); // VFS size (in-memory) let vfsBytes = 0; try { vfsBytes = JSON.stringify(this.terminal.fs.root).length; } catch (e) {} const vfsKB = (vfsBytes / 1024).toFixed(1); // sessionStorage size let ssBytes = 0; try { for (let i = 0; i < sessionStorage.length; i++) { const k = sessionStorage.key(i); if (k) ssBytes += (k.length + (sessionStorage.getItem(k) || '').length) * 2; } } catch (e) {} const ssKB = (ssBytes / 1024).toFixed(1); // Total const total = (parseFloat(heap === 'N/A' ? '0' : heap) + parseFloat(lsKB) + parseFloat(ssKB) + parseFloat(vfsKB)).toFixed(1); this.terminal.print('💾 Memory Usage', 'header'); this.terminal.print(` JS heap: ${heap} MB${heap !== 'N/A' ? ' / ' + heapLimit + ' MB limit' : ''}`, parseFloat(heap) > 100 ? 'warning' : ''); this.terminal.print(` localStorage: ${lsKB} KB (${localStorage.length} keys)`, parseFloat(lsKB) > 1024 ? 'warning' : ''); this.terminal.print(` sessionStorage: ${ssKB} KB (${sessionStorage.length} keys)`, ''); this.terminal.print(` VFS in-memory: ${vfsKB} KB`, parseFloat(vfsKB) > 256 ? 'warning' : ''); this.terminal.print(` Total: ${total} MB`, ''); document.getElementById('header-status').innerText = `Mem: ${heap}MB`; }; // Refresh memory display on boot setTimeout(() => { if (performance.memory) { const mem = (performance.memory.usedJSHeapSize / 1048576).toFixed(1); document.getElementById('header-status').innerText = `Mem: ${mem}MB`; } }, 500); this.commands['status'] = () => { this.terminal.print(`System: WebX11 ${window.NEWOS4_VERSION || 'v4.0'}`); this.terminal.print(`Network: ${navigator.onLine ? 'Online' : 'Offline'}`); this.terminal.print(`Files: ${this.terminal.fs.ls().length} items`); this.terminal.print(`Modules: ${Object.keys(this.terminal.moduleManager.modules).length} loaded`); this.terminal.print(`Ollama: ${this.terminal.ollamaManager.connected ? 'Connected' : 'Disconnected'}`); }; // v4.5.43: version — show version, build date, cache key this.commands['version'] = () => { const v = window.NEWOS4_VERSION || 'unknown'; const cacheKey = localStorage.getItem('webx11_cache_ver') || 'unknown'; // Try to get build date from script tag or module list const buildDate = (() => { try { const scripts = document.querySelectorAll('script[src*="v="]'); for (const s of scripts) { const m = s.src.match(/v=([0-9-]+-v[0-9.]+[a-z]?)/); if (m) return m[1].split('-').slice(0, 3).join('-'); } } catch (e) {} return 'unknown'; })(); const ua = navigator.userAgent.split(' ').slice(-1)[0] || 'unknown'; this.terminal.print('ℹ️ WebX11 newOS4', 'header'); this.terminal.print(` Version: ${v}`); this.terminal.print(` Build: ${buildDate}`); this.terminal.print(` Cache key: ${cacheKey}`); this.terminal.print(` Browser: ${ua}`); this.terminal.print(` Online: ${navigator.onLine ? 'yes' : 'no'}`); }; // v4.5.43: uptime — time since page load + last save time this.commands['uptime'] = () => { const loadTime = performance.timeOrigin || Date.now() - (window.__pageLoadMs || 0); const ms = Date.now() - loadTime; const s = Math.floor(ms / 1000); const days = Math.floor(s / 86400); const hours = Math.floor((s % 86400) / 3600); const mins = Math.floor((s % 3600) / 60); const secs = s % 60; let uptime; if (days > 0) uptime = `${days}d ${hours}h ${mins}m`; else if (hours > 0) uptime = `${hours}h ${mins}m ${secs}s`; else if (mins > 0) uptime = `${mins}m ${secs}s`; else uptime = `${secs}s`; this.terminal.print('⏱️ Uptime', 'header'); this.terminal.print(` Since load: ${uptime} (${s}s)`); // Check last save time from localStorage modification try { const lastSave = localStorage.getItem('webx11_last_save'); if (lastSave) { const ago = Math.floor((Date.now() - parseInt(lastSave)) / 1000); this.terminal.print(` Last save: ${ago}s ago`, ago > 300 ? 'warning' : ''); } else { this.terminal.print(` Last save: unknown`, 'info'); } } catch (e) {} }; // v4.5.43: errors [n] — alias for logs [n] ERROR this.commands['errors'] = (args) => { const n = parseInt(args[0]) || 20; // v4.6.59: read the canonical counter (DebugSupervisor DB + failed tool // calls) — the same numbers the dashboard and world model publish. The VFS // log is reported separately because it only receives explicit // terminal.log('ERROR') calls, so it cannot be the source of truth. const e = this.terminal.countErrors(); if (e.total === 0) { this.terminal.print(`✅ 0 errors — debug DB ${e.debug} · failed tool calls ${e.latency} · log ${e.log}`, 'success'); } else { this.terminal.print(`--- ${e.total} ${e.total === 1 ? 'error' : 'errors'} — debug DB ${e.debug} · failed tool calls ${e.latency} ---`, 'error'); const ds = this.terminal.moduleManager.modules['DebugSupervisorModule']; const entries = Object.entries((ds && ds._errorDB && ds._errorDB.errors) || {}) .map(([hash, v]) => Object.assign({ hash: hash }, v)) .sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)) .slice(0, n); entries.forEach(x => this.terminal.print( (x.hash || '').slice(0, 8) + ' ×' + (x.count || 1) + ' ' + ((x.severity || '') + ' ' + (x.message || '')).trim(), 'error')); } this.terminal.print(` scope: this browser session · log scan ${e.log} [ERROR] line(s) in /logs/current.log`, 'info'); }; // v4.5.43: doctor — run all health checks, rank issues, show top 3 this.commands['doctor'] = () => { const issues = []; const fs = this.terminal.fs; // Check 1: Network if (!navigator.onLine) issues.push({ severity: 'high', msg: 'Network: offline' }); // Check 2: Ollama if (!this.terminal.ollamaManager.connected) { issues.push({ severity: 'medium', msg: 'Ollama: not connected (AI features limited)' }); } // Check 3: VFS size try { const vfsBytes = JSON.stringify(fs.root).length; if (vfsBytes > 524288) issues.push({ severity: 'high', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 512KB limit` }); else if (vfsBytes > 262144) issues.push({ severity: 'medium', msg: `VFS: ${(vfsBytes/1024).toFixed(0)}KB > 256KB (consider reset-vfs)` }); } catch (e) {} // Check 4: Heap if (performance.memory) { const heapMB = performance.memory.usedJSHeapSize / 1048576; if (heapMB > 200) issues.push({ severity: 'high', msg: `Heap: ${heapMB.toFixed(0)}MB > 200MB` }); else if (heapMB > 100) issues.push({ severity: 'medium', msg: `Heap: ${heapMB.toFixed(0)}MB > 100MB` }); } // Check 5: Logs try { const logContent = fs.readFile('/logs/current.log') || ''; const logLines = logContent.split('\n').filter(l => l).length; if (logLines > 500) issues.push({ severity: 'low', msg: `Logs: ${logLines} lines (consider logrotate)` }); } catch (e) {} // Check 6: Errors in last 100 log lines try { const logContent = fs.readFile('/logs/current.log') || ''; const recent = logContent.split('\n').filter(l => l).slice(-100); const errorCount = recent.filter(l => l.includes('[ERROR]')).length; if (errorCount > 5) issues.push({ severity: 'high', msg: `Errors: ${errorCount} errors in last 100 log lines` }); else if (errorCount > 0) issues.push({ severity: 'medium', msg: `Errors: ${errorCount} errors in last 100 log lines` }); } catch (e) {} // Check 7: localStorage quota let lsBytes = 0; try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k) lsBytes += (k.length + (localStorage.getItem(k) || '').length) * 2; } } catch (e) {} if (lsBytes > 4 * 1024 * 1024) issues.push({ severity: 'high', msg: `localStorage: ${(lsBytes/1024/1024).toFixed(1)}MB approaching 5MB quota` }); // Check 8: Disabled modules let disabled = []; try { disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch (e) {} if (disabled.length > 0) { issues.push({ severity: 'low', msg: `Disabled modules: ${disabled.length} (${disabled.join(', ')})` }); } this.terminal.print('🩺 Doctor Report', 'header'); if (issues.length === 0) { this.terminal.print(' ✅ All systems healthy', 'success'); return; } // Sort by severity: high > medium > low const sevOrder = { high: 0, medium: 1, low: 2 }; issues.sort((a, b) => sevOrder[a.severity] - sevOrder[b.severity]); this.terminal.print(` Found ${issues.length} issue(s):`, 'warning'); for (const i of issues.slice(0, 5)) { const icon = i.severity === 'high' ? '🔴' : i.severity === 'medium' ? '🟡' : '🟢'; this.terminal.print(` ${icon} [${i.severity.toUpperCase()}] ${i.msg}`, i.severity === 'high' ? 'error' : 'warning'); } if (issues.length > 5) this.terminal.print(` ... and ${issues.length - 5} more`, 'info'); this.terminal.print('', ''); this.terminal.print(' Recommended actions:', 'info'); if (issues.some(i => i.msg.startsWith('Network'))) this.terminal.print(' → Check internet connection'); if (issues.some(i => i.msg.startsWith('Ollama'))) this.terminal.print(' → Check Ollama is running (ollama serve)'); if (issues.some(i => i.msg.startsWith('VFS'))) this.terminal.print(' → Run reset-vfs --confirm to clear or checkpoint/rollback'); if (issues.some(i => i.msg.startsWith('Heap'))) this.terminal.print(' → Refresh the page to clear memory'); if (issues.some(i => i.msg.startsWith('Logs'))) this.terminal.print(' → Run logrotate'); if (issues.some(i => i.msg.startsWith('Errors'))) this.terminal.print(' → Run errors to see details, or bug-report to save state'); if (issues.some(i => i.msg.startsWith('localStorage'))) this.terminal.print(' → Run factory-reset --confirm (last resort)'); }; // v4.5.43: bug-report — generate markdown report to /tmp/ this.commands['bug-report'] = () => { const fs = this.terminal.fs; const lines = []; const ts = new Date().toISOString(); lines.push(`# WebX11 newOS4 Bug Report`); lines.push(`Generated: ${ts}`); lines.push(''); lines.push('## System'); lines.push(`- Version: ${window.NEWOS4_VERSION || 'unknown'}`); lines.push(`- Cache key: ${localStorage.getItem('webx11_cache_ver') || 'unknown'}`); lines.push(`- Online: ${navigator.onLine ? 'yes' : 'no'}`); lines.push(`- Browser: ${navigator.userAgent}`); lines.push(`- Screen: ${screen.width}x${screen.height}`); lines.push(`- Heap: ${performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A'}`); lines.push(`- VFS size: ${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB`); lines.push(`- Modules loaded: ${Object.keys(this.terminal.moduleManager.modules).length}`); lines.push(`- Ollama: ${this.terminal.ollamaManager.connected ? 'connected' : 'disconnected'}`); lines.push(''); // Uptime const loadTime = performance.timeOrigin || Date.now(); const upSec = Math.floor((Date.now() - loadTime) / 1000); lines.push(`- Uptime: ${upSec}s`); lines.push(''); lines.push('## Recent Errors (last 20)'); try { const logContent = fs.readFile('/logs/current.log') || ''; const errs = logContent.split('\n').filter(l => l && l.includes('[ERROR]')).slice(-20); if (errs.length === 0) lines.push('_No errors found_'); else errs.forEach(e => lines.push('- ' + e)); } catch (e) { lines.push('_Could not read logs_'); } lines.push(''); lines.push('## Recent Commands (last 10)'); try { const hist = JSON.parse(localStorage.getItem('webx11_command_history') || '[]'); const recent = hist.slice(-10); if (recent.length === 0) lines.push('_No history_'); else recent.forEach(c => lines.push('- ' + c)); } catch (e) { lines.push('_No history available_'); } lines.push(''); lines.push('## Disabled Modules'); try { const disabled = JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); if (disabled.length === 0) lines.push('_None_'); else disabled.forEach(d => lines.push('- ' + d)); } catch (e) { lines.push('_Unknown_'); } lines.push(''); lines.push('## File List (top-level VFS)'); try { const items = fs.ls(); items.slice(0, 20).forEach(i => lines.push('- ' + i)); if (items.length > 20) lines.push(`- ... and ${items.length - 20} more`); } catch (e) { lines.push('_Could not list_'); } lines.push(''); lines.push('---'); lines.push('Send this file when reporting a bug.'); const content = lines.join('\n'); const filename = `/tmp/bug-report-${Date.now()}.md`; try { fs.writeFile(filename, content); this.terminal.print(`✅ Bug report saved to ${filename}`, 'success'); this.terminal.print(` (${(content.length / 1024).toFixed(1)}KB)`, 'info'); } catch (e) { this.terminal.print(`❌ Could not save bug report: ${e.message}`, 'error'); } }; // v4.6.37: bug — file a structured report to the backend (Part A) this.commands['bug'] = async (args) => { const title = (args || []).join(' ').trim() || 'Untitled bug report'; const diag = { version: window.NEWOS4_VERSION || 'unknown', url: location.href, ua: navigator.userAgent, online: navigator.onLine, modules: Object.keys(this.terminal.moduleManager.modules).length, errors: [], }; try { // Prefer the LIVE error DB: localStorage writes are debounced, so a report // filed right after an error would otherwise carry an empty list. let list = []; try { const ds = Object.values(this.terminal.moduleManager.modules) .find(m => m && m._errorDB && m._errorDB.errors); if (ds) list = Object.values(ds._errorDB.errors); } catch (_e) {} if (!list.length) { const raw = JSON.parse(localStorage.getItem('webx11_debug_db') || '[]'); list = Array.isArray(raw) ? raw : Object.values((raw && raw.errors) || {}); } diag.errors = list.slice(-20).map(e => ({ message: String(e.message || e.msg || '').slice(0, 300), source: String(e.source || e.url || '').slice(0, 200), count: Number(e.count) || 1, // The stack is the only thing that makes a report actionable; it was // dropped here while ingest kept it, which is why the 2026-10-20 // `toLowerCase` report could not be localised for three days. stack: e.stack ? String(e.stack).split('\n').slice(0, 10).join('\n').slice(0, 1200) : null, })); } catch (_e) {} try { diag.recent_commands = JSON.parse(localStorage.getItem('webx11_command_history') || '[]') .slice(-10) .map(c => String(c).replace(/(password|token|secret|api[_-]?key)\S*/ig, '$1:[redacted]')); } catch (_e) {} this.terminal.print('📮 Filing bug report…', 'info'); let res = null, err = null; try { const r = await fetch('/spaces/newos4/api/debug/report.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, diagnostics: diag }), }); res = await r.json(); if (!r.ok) err = res.error || ('HTTP ' + r.status); } catch (e) { err = e.message; } if (err) { this.terminal.print('❌ Report failed: ' + err, 'error'); return; } this.terminal.print('✅ Report filed: ' + res.id + (res.duplicate ? ' (already known)' : ''), 'success'); this.terminal.print(' View: /spaces/newos4/downloads/bugs/BUG-REPORT.html', 'info'); }; this.commands['preview'] = (args) => this.terminal.openPreview(args); // v4.5.64: reset — unified reset command superseding factory-reset, reset-vfs, reset-mem, config-reset this.commands['reset'] = (args) => this._globalReset(args); this.commands['factory-reset'] = (args) => this._globalReset(['all', ...args]); this.commands['reset-vfs'] = (args) => this._globalReset(['vfs', ...args]); this.commands['factory-reset-old'] = (args) => { if (!args[0] || args[0] !== '--confirm') { this.terminal.print('⚠️ FACTORY RESET will clear all data:', 'error'); this.terminal.print(' • Filesystem (all files + directories)', 'warning'); this.terminal.print(' • AI memory, patterns, user profile', 'warning'); this.terminal.print(' • Ollama endpoint, model selection', 'warning'); this.terminal.print(' • Terminal history, font size', 'warning'); this.terminal.print(' • All config, modules, packages', 'warning'); this.terminal.print(' • Mindmaps, notes, bookmarks', 'warning'); this.terminal.print(' • Themes, context profiles, servers', 'warning'); this.terminal.print(' • Chat settings, my data', 'warning'); this.terminal.print('Type: factory-reset --confirm to proceed', 'error'); return; } // v4.5.47: explicitly wipe VFS storage and session backups before clearing localStorage try { this.terminal.fs.deleteFile('_sessions/_auto.json'); this.terminal.fs.deleteFile('_sessions/_auto-context.json'); this.terminal.fs.reset(); } catch (e) { console.warn('factory-reset FS cleanup:', e); } for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith('webx11_')) localStorage.removeItem(key); } this.terminal.outputContainer.innerHTML = ''; this.terminal.print('✅ Factory reset complete. Reloading...', 'success'); setTimeout(() => location.reload(), 1000); }; this.commands['reset-vfs'] = (args) => { if (!args[0] || args[0] !== '--confirm') { this.terminal.print('⚠️ RESET VFS will clear all files and directories:', 'warning'); this.terminal.print(' • Virtual filesystem (all files + directories)', 'warning'); this.terminal.print(' • VFS will be reset to empty root', 'warning'); this.terminal.print(' • All settings, modules, AI profile preserved', 'info'); this.terminal.print('Type: reset-vfs --confirm to proceed', 'error'); return; } this.terminal.fs.root = { type: 'dir', content: {} }; this.terminal.fs.currentPath = []; // v4.5.47: delete auto-session backups so they don't resurrect try { this.terminal.fs.deleteFile('_sessions/_auto.json'); this.terminal.fs.deleteFile('_sessions/_auto-context.json'); } catch (e) {} localStorage.setItem('webx11_last_reset', Date.now().toString()); this.terminal.fs.saveToStorage(); this.terminal.print('✅ VFS reset complete. Reloading...', 'success'); setTimeout(() => location.reload(), 1000); }; this.commands['logs'] = (args) => { const n = parseInt(args[0]) || 20; const level = args[1] ? args[1].toUpperCase() : null; const content = this.terminal.fs.readFile('/logs/current.log'); if (!content) { this.terminal.print('No logs yet', 'info'); return; } let lines = content.split('\n').filter(l => l); if (level) lines = lines.filter(l => l.includes('[' + level + ']')); const tail = lines.slice(-n); if (tail.length === 0) { this.terminal.print('No matching log entries', 'info'); return; } this.terminal.print('--- Logs (last ' + tail.length + ') ---', 'info'); tail.forEach(l => this.terminal.print(l, l.includes('[ERROR]') ? 'error' : 'info')); }; // Auto-rotate logs on startup (silently) this._autoRotateLogs(); this.commands['logrotate'] = () => { const rotated = this._autoRotateLogs(true); if (rotated) { this.terminal.print('Log rotated: ' + rotated, 'success'); } else { this.terminal.print('Log rotation not needed (<50KB, <500 lines)', 'info'); } }; this.commands['health-check'] = () => this._healthCheck(); this.commands['memory-map-export'] = () => this._memoryMapExport(); this.commands['font-size'] = (args) => { if (!args.length || !args[0]) { const cur = localStorage.getItem('webx11_font_size'); this.terminal.print(`Current font-size: ${cur || '16'}px`, 'info'); this.terminal.print('Usage: font-size <8-32>', 'info'); return; } const val = parseInt(args[0], 10); if (isNaN(val) || val < 8 || val > 32) { this.terminal.print('font-size must be between 8 and 32', 'error'); return; } localStorage.setItem('webx11_font_size', val.toString()); // Apply directly to body — bypasses CSS inheritance issues document.body.style.fontSize = val + 'px'; document.getElementById('terminal-container').style.fontSize = val + 'px'; document.getElementById('command-input').style.fontSize = val + 'px'; this.terminal.print(`Font size set to ${val}px (saved) 👆`, 'success'); }; this.commands['setfont'] = this.commands['font-size']; // Checkpoint commands (delegated to virtual-fs.js) this.commands['checkpoint'] = (args) => { try { const name = args[0] || ''; const created = this.terminal.fs.checkpoint(name); this.terminal.print('✅ Checkpoint created: ' + (created || name), 'success'); } catch (e) { this.terminal.print('Checkpoint failed: ' + e.message, 'error'); } }; this.commands['checkpoint-list'] = () => { try { const list = this.terminal.fs.listCheckpoints(); if (!list || list.length === 0) { this.terminal.print('No checkpoints found', 'info'); return; } this.terminal.print('📋 Checkpoints:', 'text-muted'); list.forEach(c => this.terminal.print(' • ' + c)); } catch (e) { this.terminal.print('checkpoint-list failed: ' + e.message, 'error'); } }; this.commands['checkpoint-del'] = (args) => { if (!args[0]) return this.terminal.print('Usage: checkpoint-del <name>', 'warning'); try { if (this.terminal.fs.deleteCheckpoint(args[0])) { this.terminal.print('Deleted checkpoint: ' + args[0], 'success'); } else { this.terminal.print('Checkpoint not found: ' + args[0], 'error'); } } catch (e) { this.terminal.print('checkpoint-del failed: ' + e.message, 'error'); } }; this.commands['rollback'] = (args) => { if (!args[0]) return this.terminal.print('Usage: rollback <name>', 'warning'); try { if (this.terminal.fs.rollback(args[0])) { this.terminal.print('↻ Rolled back to checkpoint: ' + args[0], 'success'); } else { this.terminal.print('Rollback failed: checkpoint not found or invalid', 'error'); } } catch (e) { this.terminal.print('Rollback failed: ' + e.message, 'error'); } }; } // v4.5.64: Unified reset — subcommands: mem, vfs, config, all _globalReset(args) { const scope = (args[0] || 'mem').toLowerCase(); const confirmed = args.includes('--confirm') || args.includes('-y'); const scopes = { mem: { title: 'AI MEMORY', items: ['AI conversation context', 'AI patterns & guardrails', 'AI user profile', 'AI system prompt', 'VaultMind token', 'AI internal logs (tool calls, heap stats)'], keys: ['webx11_ai_context', 'webx11_ai_config', 'webx11_ai_patterns', 'webx11_ai_system_prompt', 'webx11_user_profile', 'webx11_guardrail_policy', 'vaultmind_token'], desc: 'Clears AI state only. VFS, settings, modules, and config are untouched.' }, vfs: { title: 'VIRTUAL FILESYSTEM', items: ['All files and directories', 'Session backups', 'VFS metadata', 'Neural VFS index'], desc: 'Clears the VFS to empty root. Settings, modules, AI profile, and config are preserved.' }, config: { title: 'CONFIGURATION', items: ['All webx11_config_* keys', 'Ollama endpoint & model', 'Security settings', 'Terminal config', 'Font size', 'Theme'], keys: null, // prefix-based wipe desc: 'Resets all configuration to defaults. VFS and AI memory are untouched.' }, all: { title: 'EVERYTHING', items: ['VFS (all files)', 'AI memory & profile', 'All config & settings', 'Module registry & disabled list', 'Terminal history', 'Themes', 'Chat settings', 'Cron jobs', 'Floating elements', 'Aliases', 'Tutorial state', 'Server list', 'Orchestrator config', 'Secret vault keys', 'Neural VFS config'], desc: 'Complete wipe. The system will reload as if first visit.' } }; const s = scopes[scope]; if (!s) { this.terminal.print(`Usage: reset [mem|vfs|config|all] [--confirm]`, 'warning'); this.terminal.print(` reset mem — Clear AI memory (default)`, 'info'); this.terminal.print(` reset vfs — Clear virtual filesystem`, 'info'); this.terminal.print(` reset config — Reset all config to defaults`, 'info'); this.terminal.print(` reset all — Wipe everything (nuclear)`, 'info'); this.terminal.print(``, 'info'); this.terminal.print(`Aliases: factory-reset = reset all, reset-vfs = reset vfs`, 'info'); return; } if (!confirmed) { this.terminal.print(`⚠️ RESET ${s.title} will clear:`, 'error'); s.items.forEach(item => this.terminal.print(` • ${item}`, 'warning')); this.terminal.print(``, 'info'); this.terminal.print(s.desc, 'info'); this.terminal.print(``, 'info'); this.terminal.print(`Type: reset ${scope} --confirm`, 'error'); return; } // Execute reset if (scope === 'mem') { this._resetMem(); } else if (scope === 'vfs') { this._resetVfs(); } else if (scope === 'config') { this._resetConfig(); } else if (scope === 'all') { this._resetAll(); } localStorage.setItem('webx11_last_reset', Date.now().toString()); this.terminal.print(`✅ ${s.title} reset complete. Reloading...`, 'success'); setTimeout(() => location.reload(), 1000); } _resetMem() { const keys = ['webx11_ai_context', 'webx11_ai_config', 'webx11_ai_patterns', 'webx11_ai_system_prompt', 'webx11_user_profile', 'webx11_guardrail_policy', 'vaultmind_token']; keys.forEach(k => localStorage.removeItem(k)); // Also clear AI logs in VFS try { this.terminal.fs.deleteFile('/logs/ai-tool-log.json'); } catch (e) {} } _resetVfs() { this.terminal.fs.root = { type: 'dir', content: {} }; this.terminal.fs.currentPath = []; try { this.terminal.fs.deleteFile('_sessions/_auto.json'); this.terminal.fs.deleteFile('_sessions/_auto-context.json'); } catch (e) {} this.terminal.fs.saveToStorage(); } _resetConfig() { // Wipe all webx11_config_* keys const toRemove = []; for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && (key.startsWith('webx11_config_') || key === 'webx11_ollama_endpoint' || key === 'webx11_ollama_model' || key.startsWith('webx11_security_') || key === 'webx11_terminal_config' || key === 'webx11_font_size' || key === 'webx11_active_theme' || key === 'webx11_themes' || key === 'webx11_model_params' || key.startsWith('webx11_validator_') || key === 'webx11_optimization_enabled' || key === 'webx11_stream_enabled' || key === 'webx11_smart_prompt' || key === 'webx11_config_ai_sound' || key === 'webx11_welcome_dismissed')) { toRemove.push(key); } } toRemove.forEach(k => localStorage.removeItem(k)); } _resetAll() { // Delete session backups so they don't resurrect try { this.terminal.fs.deleteFile('_sessions/_auto.json'); this.terminal.fs.deleteFile('_sessions/_auto-context.json'); this.terminal.fs.reset(); } catch (e) { console.warn('reset all FS cleanup:', e); } // Wipe ALL webx11_ prefixed keys for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key && key.startsWith('webx11_')) localStorage.removeItem(key); } // Also wipe non-prefixed keys used by modules const extraKeys = ['vaultmind_token', 'cslite_messages', 'webx11_chat_settings', 'webx11_chat_logs', 'webx11_chat_api']; extraKeys.forEach(k => localStorage.removeItem(k)); this.terminal.outputContainer.innerHTML = ''; } _autoRotateLogs(force = false) { try { const content = this.terminal.fs.readFile('/logs/current.log') || ''; const lines = content.split('\n').filter(l => l); const size = content.length; if (!force && size < 51200 && lines.length < 500) return null; const now = new Date(); const stamp = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0') + '_' + String(now.getHours()).padStart(2,'0') + '00'; this.terminal.fs.writeFile('/logs/' + stamp + '.log', content); this.terminal.fs.writeFile('/logs/current.log', ''); // Clean oldest rotated logs (>10) const logsDir = this.terminal.fs.readDir('/logs'); if (logsDir && logsDir.content) { const rotated = Object.keys(logsDir.content) .filter(k => k.endsWith('.log') && k !== 'current.log') .sort(); while (rotated.length > 10) { const oldest = rotated.shift(); this.terminal.fs.deleteFile('/logs/' + oldest); } } return stamp + '.log'; } catch (e) { return null; } } _healthCheck() { const fs = this.terminal.fs; let warnings = 0; this.terminal.print('╔══════════════════════════════════════╗', 'info'); this.terminal.print('║ 🔍 SYSTEM HEALTH CHECK ║', 'info'); this.terminal.print('╚══════════════════════════════════════╝', 'info'); // VFS size const dump = JSON.stringify(fs.root).length; const vfsOk = dump < 524288; this.terminal.print(`VFS size: ${(dump/1024).toFixed(1)}KB ${vfsOk ? '✅' : '⚠️ > 512KB'}`, vfsOk ? 'info' : 'warning'); if (!vfsOk) warnings++; // Modules const modTotal = Object.keys(this.terminal.moduleManager.modules || {}).length; this.terminal.print(`Modules loaded: ${modTotal} ✅`, 'info'); // Logs const logContent = fs.readFile('/logs/current.log') || ''; const logLines = logContent.split('\n').filter(l => l).length; this.terminal.print(`Current log: ${logLines} lines`, logLines > 500 ? 'warning' : 'info'); if (logLines > 500) warnings++; // Trash const trashList = fs.listTrash ? fs.listTrash() : []; this.terminal.print(`Trash items: ${trashList.length}`, 'info'); // Skills const skillIdx = fs.fsReadJSON ? (fs.fsReadJSON('/skills/index.json') || {}) : {}; const skillCount = Object.keys(skillIdx).length; this.terminal.print(`Skills: ${skillCount}`, skillCount > 0 ? 'info' : 'warning'); if (skillCount === 0) warnings++; // Summary this.terminal.print('─'.repeat(40), 'text-muted'); if (warnings === 0) { this.terminal.print('All checks passed ✅', 'success'); } else { this.terminal.print(`${warnings} warning(s) found`, 'warning'); } } _memoryMapExport() { const fs = this.terminal.fs; const idx = fs.fsReadJSON ? (fs.fsReadJSON('/memory/index.json') || {}) : null; if (!idx) { this.terminal.print('No memory index found at /memory/index.json', 'error'); return; } // v4.5.43: Actually export to /memory/reference.md (was: print to terminal) const lines = []; lines.push('---'); lines.push('name: adaptive-memory-map'); lines.push('description: Auto-generated from memory/index.json'); lines.push('tags: [memory, context, tracking, adaptation]'); lines.push('category: system'); lines.push('---'); lines.push(''); for (const [k, v] of Object.entries(idx)) { lines.push(`## ${k}`); lines.push(`- Tasks: ${v.tasks || 0}`); lines.push(`- Updated: ${v.updated || 'unknown'}`); lines.push(`- Summary: ${(v.summary || '').substring(0, 200)}...`); lines.push(''); } const content = lines.join('\n'); try { fs.writeFile('/memory/reference.md', content); this.terminal.print(`✅ Memory map exported to /memory/reference.md (${(content.length / 1024).toFixed(1)}KB)`, 'success'); } catch (e) { this.terminal.print(`❌ Could not save: ${e.message}`, 'error'); } } handleHelp(args) { const helpMap = this.terminal.moduleManager.getAllHelp(); // Single command lookup if (args && args.length > 0) { const cmd = args[0].toLowerCase(); const allCommands = this.terminal.moduleManager.getAllCommands(); if (allCommands[cmd]) { const helpEntry = helpMap[cmd]; if (helpEntry) { this.terminal.print(helpEntry, 'info'); } else { // MAINT-019: a registered command with no help line answered "No help // available", which reads as if the command does not exist. Name the // module that owns it so the user knows where to look. const owner = allCommands[cmd]; this.terminal.print(`${cmd} — registered by ${owner}, which publishes no help line for it. Try: help`, 'warning'); } } else { this.terminal.print(`No help available for ${cmd}`, 'warning'); } return; } // Dynamically group commands by owning module const cmdToModule = this.terminal.moduleManager.getAllCommands(); const moduleGroups = {}; for (const [cmd, modName] of Object.entries(cmdToModule)) { if (!moduleGroups[modName]) moduleGroups[modName] = []; moduleGroups[modName].push(cmd); } const modLabels = { FileSystemModule: '📁 Filesystem', WebX11AIAssistantModule: '🤖 AI', WebXAIAliasModule: '🌐 WebX', SearXNGModule: '🔍 Search & Web', WebFetcherModule: '🔍 Search & Web', AliasModule: '🔧 Tools', BusyBoxModule: '🔧 Tools', CalcModule: '🔧 Tools', CronModule: '🔧 Tools', CryptoGzipModule: '🔧 Tools', DiffModule: '🔧 Tools', EditorModule: '🔧 Tools', FileManagerModule: '🔧 Tools', FinderModule: '🔧 Tools', MindmapModule: '🔧 Tools', ThemeModule: '🔧 Tools', TutorialModule: '🔧 Tools', VoiceModule: '🔧 Tools', WpmModule: '🔧 Tools', SystemModule: '⚙️ System', ShellModule: '⚙️ System', ServerMonitorModule: '📡 Network', ModuleManagerModule: '🧩 Modules', OllamaModule: '🦙 Ollama', NeuralVFSModule: '🧠 Neural VFS', SkillPackCoreModule: '📚 Skills', ConfigManagerModule: '⚙️ Config', SandboxModule: '🏖️ Sandbox', WebDAVModule: '🧰 Extras', ChatStudioModule: '🧰 Extras', SimpleGUIModule: '🖥️ GUI', SecretVault: '🔒 Vault', OrchestratorModule: '🧠 Orchestrator', PredictorModule: '🔮 Prediction', MemoryGraphModule: '📊 Memory', WorldModelModule: '🌍 World', LeaseModule: '🔐 Leases', AgentRegistryModule: '🤖 Agents', }; // Merge modules with the same label const grouped = {}; const usedLabels = new Set(); for (const [modName, cmds] of Object.entries(moduleGroups)) { const label = modLabels[modName] || `🧩 ${modName.replace(/Module$/, '')}`; if (!grouped[label]) grouped[label] = []; grouped[label].push(...cmds); usedLabels.add(label); } let result = '╔══════════════════════════════════════╗\n║ 🔷 WebX11 ' + (window.NEWOS4_VERSION || 'v4.0') + ' — All Commands ║\n╚══════════════════════════════════════╝\n\n'; const total = Object.keys(helpMap).length; result += `📊 ${total} commands loaded\n\n`; for (const [label, cmds] of Object.entries(grouped)) { result += label + '\n'; cmds.forEach(c => { const desc = (helpMap[c] || c).split(' — ')[1] || ''; const short = desc.substring(0, 55); result += ` ${c.padEnd(16)}${short}\n`; }); result += '\n'; } result += '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n help <command> → detailed usage\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; this.terminal.print(result.trim(), 'info'); } } /* === module-fs.js === */ class FileSystemModule { constructor() { this.commands = {}; this.commandsMeta = { 'trash': { argType: 'file' }, 'ls-trash': { argType: 'file' }, 'upload': { argType: 'dir' }, 'vfs-export': { argType: 'file' }, 'vfs-import': { argType: 'file' }, 'download': { argType: 'file' }, }; this.terminal = null; } getHelpInfo() { return { 'ls': 'ls [path] [-la] — List files and directories', 'cat': 'cat <file> — Display file contents', 'put': 'put <file> <content> — Create or overwrite a file', 'rm': 'rm <file> — Remove a file (moved to trash); use -r/-rf for directories', 'trash': 'trash <file> — Move a file to trash', 'trash-dir': 'trash-dir <dir> — Move a directory to trash', 'ls-trash': 'ls-trash — List items in trash', 'empty-trash': 'empty-trash — Permanently delete all trash items', 'mkdir': 'mkdir <dir> [-p] — Create a directory; -p creates parent paths', 'rmdir': 'rmdir <dir> — Remove a directory', 'mv': 'mv <source> <dest> — Move file or directory', 'cp': 'cp <source> <dest> — Copy file or directory', 'cd': 'cd <path> — Change current directory; cd / to go to root', 'pwd': 'pwd — Print current working directory', 'find': 'find <pattern> — Search files by name pattern', 'grep': 'grep <pattern> [file] — Search file content for pattern', 'head': 'head <file> [lines] — Show first N lines (default: 10)', 'tail': 'tail [-f] [-n N] <file> — Show last N lines; -f follows live changes (Ctrl+C/Esc to stop)', 'sed': 'sed <expr> [file] — Stream editor; s/pat/rep/[g][i][p], /pat/d, Nd, $d; -i write in place; -n quiet mode', 'sort': 'sort <file> — Sort lines of a file alphabetically', 'wc': 'wc <file> — Count lines, words, and bytes', 'touch': 'touch <file> — Create empty file or update timestamp', 'tree': 'tree — Display directory structure as a tree', 'du': 'du [path] — Estimate file/directory size', 'upload': 'upload — Upload a file from your device', 'download': 'download <file> — Download a file to your device', 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON', 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file', 'session-save': 'session-save <name> — Save current session (path + terminal history)', 'session-load': 'session-load <name> — Load a saved session', 'session-list': 'session-list — List all saved sessions', 'session-new': 'session-new — Start a fresh session (auto-saves current, clears AI context)', 'session-delete': 'session-delete <name> — Delete a saved session', }; } registerCommands() { const self = this; this.commands['ls'] = (args) => { const argv = args || []; // v4.6.104: the path may follow the flags — `ls -l /work` used to list the cwd // because only argv[0] was considered (MAINT-016). const flags = argv.filter(a => a.startsWith('-')); const target = argv.find(a => !a.startsWith('-')); const showLong = flags.some(f => f.includes('l')); const showHidden = flags.some(f => f.includes('a')); const files = this.terminal.fs.ls(target); if (!files || files.length === 0) { // `ls somefile` used to print "(empty)": a file operand should name the file, // which is also what makes `ls *.txt` (glob-expanded to several paths) readable. if (target && this.terminal.fs.readFile(target) !== null) { this.terminal.print(target, 'info'); return; } this.terminal.print('(empty)', 'info'); return; } const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.')); if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; } const maxName = Math.max(...filtered.map(f => f.name.length), 8); if (showLong) { filtered.forEach(f => { const type = f.type === 'dir' ? 'd' : '-'; const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--'; const size = String(f.size || 0).padStart(6); this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name); }); const total = filtered.reduce((s, f) => s + (f.size || 0), 0); this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted'); } else { // v4.6.57: printClickable() writes straight to the DOM, so anything that // captures output (the AI run_command tool, the diagnostics sweep) saw nothing // and reported "directory empty" (packet §1b). Always emit a text line. const names = filtered.map(f => f.name + (f.type === 'dir' ? '/' : '')).join(' '); this.terminal.print(names); const capturing = !!(this.terminal._printCaptureStack && this.terminal._printCaptureStack.length); if (!capturing && typeof this.terminal.printClickable === 'function') this.terminal.printClickable(filtered); } }; this.commands['guides'] = (args) => { // v4.6.63: /guides/ was reachable by nobody — the AI writes guides there, and the // only way to find one was to guess its filename (packet §7). One command, newest first. let items = []; try { items = this.terminal.fs.readDir('/guides') || []; } catch (e) { items = []; } const docs = items .filter(f => f && f.type !== 'dir' && /\.(html?|md|txt)$/i.test(f.name)) .sort((a, b) => (b.mtime || 0) - (a.mtime || 0)); if (!docs.length) { this.terminal.print('No guides in /guides/ yet.', 'info'); this.terminal.print(' Guides written by the AI land here — `guides` lists them, `preview <name>` renders HTML, `cat <name>` shows markdown.', 'text-muted'); return; } this.terminal.print(`📚 Guides (${docs.length}) — preview <name> for HTML, cat <name> for markdown`, 'header'); docs.forEach(f => this.terminal.print(` ${f.name} ${f.size || 0} B`, 'info')); }; this.commands['cat'] = (args) => { if(!args[0]) return this.terminal.print('Usage: cat <filename> [...]', 'warning'); // Multiple operands: `cat *.txt` is useless if only the first path is read. const files = args.filter(a => !String(a).startsWith('-')); let printed = 0; files.forEach((f) => { const content = this.terminal.fs.readFile(f); if (content !== null && content !== undefined) { if (content.length === 0) this.terminal.print('(empty file)', 'info'); else this.terminal.print(content); printed++; } else this.terminal.print('cat: ' + f + ': No such file', 'error'); }); if (!printed && !files.length) this.terminal.print('Usage: cat <filename> [...]', 'warning'); }; this.commands['echo'] = (args) => { if (!args || args.length === 0) { this.terminal.print(''); return; } const ri = args.indexOf('>'); if (ri >= 0) { const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' '); if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); } return; } this.terminal.print(args.join(' ')); }; this.commands['put'] = (args) => { if(args.length < 2) return this.terminal.print('Usage: put <filename> <content>', 'warning'); const name = args[0]; const content = args.slice(1).join(' '); this.terminal.fs.writeFile(name, content); this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success'); }; this.commands['rm'] = (args) => { if(!args[0]) return this.terminal.print('Usage: rm <filename>', 'warning'); if(args[0] === '-r' || args[0] === '-rf') { const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning'); // Use trash for recursive dir removal too if (this.terminal.fs.trashDir(t)) this.terminal.print('Moved to trash: ' + t, 'success'); else this.terminal.print('rm: ' + t + ': No such file or directory', 'error'); return; } if(this.terminal.fs.deleteFile(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('rm: ' + args[0] + ': No such file', 'error'); }; this.commands['trash'] = (args) => { const fs = this.terminal.fs; if(!args[0]) return this.terminal.print('Usage: trash <filename>', 'warning'); const node = fs.readFile(args[0]) !== null ? { type: 'file' } : fs.readDir(args[0]); if (!node) return this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); if (node.type === 'dir') { if (fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('trash: ' + args[0] + ': Failed', 'error'); } else { const content = fs.readFile(args[0]); if (content !== null) { fs.trashFile(args[0], { type: 'file', content, modified: new Date() }); this.terminal.print('Moved to trash: ' + args[0], 'success'); } else { this.terminal.print('trash: ' + args[0] + ': No such file', 'error'); } } }; this.commands['trash-dir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: trash-dir <dirname>', 'warning'); if (this.terminal.fs.trashDir(args[0])) this.terminal.print('Moved to trash: ' + args[0], 'success'); else this.terminal.print('trash-dir: ' + args[0] + ': No such directory', 'error'); }; this.commands['ls-trash'] = () => { const fs = this.terminal.fs; const items = fs.listTrash ? fs.listTrash() : []; if (!items.length) return this.terminal.print('Trash is empty', 'info'); this.terminal.print('Trash items:', 'info'); items.forEach(it => { const date = new Date(it.ts).toLocaleString(); this.terminal.print(` ${it.name} [${it.type}] ${date}`); }); }; this.commands['empty-trash'] = () => { const fs = this.terminal.fs; const removed = fs.emptyTrash ? fs.emptyTrash(0) : 0; this.terminal.print(`Emptied trash: ${removed} item(s) permanently deleted`, removed > 0 ? 'warning' : 'info'); }; this.commands['mkdir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: mkdir <dirname>', 'warning'); if (args[0] === '-p') { if(!args[1]) return this.terminal.print('Usage: mkdir -p <path>', 'warning'); if(this.terminal.fs.mkdirRecursive(args[1])) this.terminal.print('Created directories: ' + args[1], 'success'); else this.terminal.print('mkdir: cannot create ' + args[1], 'error'); return; } if(this.terminal.fs.mkdir(args[0])) this.terminal.print('Created directory: ' + args[0], 'success'); else this.terminal.print('mkdir: cannot create ' + args[0] + ': File exists', 'error'); }; this.commands['rmdir'] = (args) => { if(!args[0]) return this.terminal.print('Usage: rmdir <dirname>', 'warning'); if (this.terminal.fs.deleteDirRecursive(args[0])) this.terminal.print('Removed directory: ' + args[0], 'success'); else this.terminal.print('rmdir: ' + args[0] + ': No such directory', 'error'); }; this.commands['mv'] = (args) => { if(!args[0] || !args[1]) return this.terminal.print('Usage: mv <source> <dest>', 'warning'); const fs = this.terminal.fs; const d = fs._resolvePath(args[1]); const dn = d && d.name ? d.dir.content[d.name] : null; if (dn && dn.type === 'dir' && !dn._virtual) return this.terminal.print('mv: ' + args[1] + ': is a directory — refusing to overwrite it', 'error'); if (fs.mv(args[0], args[1])) this.terminal.print('Moved: ' + args[0] + ' to ' + args[1], 'success'); else this.terminal.print('mv: ' + args[0] + ': No such file or directory', 'error'); }; this.commands['cp'] = (args) => { if(!args[0] || !args[1]) return this.terminal.print('Usage: cp <source> <dest>', 'warning'); const fs = this.terminal.fs; const d = fs._resolvePath(args[1]); const dn = d && d.name ? d.dir.content[d.name] : null; if (dn && dn.type === 'dir' && !dn._virtual) return this.terminal.print('cp: ' + args[1] + ': is a directory — refusing to overwrite it', 'error'); if (fs.cp(args[0], args[1])) this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success'); else this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error'); }; this.commands['cd'] = (args) => { if (!args[0] || args[0] === '/') { this.terminal.fs.currentPath = []; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); return; } const segs = this.terminal.fs._toAbsoluteSegments(args[0]); if (segs === null) { this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error'); return; } const dir = this.terminal.fs._resolveDir(args[0]); if (dir) { this.terminal.fs.currentPath = segs; this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); } else { this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error'); } }; this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); }; this.commands['find'] = (args) => { if (!args[0]) return this.terminal.print('Usage: find <pattern>', 'warning'); const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : []; if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info'); results.forEach(r => this.terminal.print('./' + r)); this.terminal.print('Found ' + results.length + ' match(es)', 'info'); }; this.commands['grep'] = (args) => { // 2026-10-21: accept the common GNU flags instead of treating them as the pattern. // `grep -n "x" file` used to search for the literal "-n" inside a file named "x" // (6/6 syntax variants failed for the agent), while plain grep already prints // "line: text" for every hit. const opt = { count: false, invert: false, files: false, ignoreCase: true }; let ai = 0; while (ai < args.length && /^-[a-zA-Z]+$/.test(args[ai])) { const f = args[ai].slice(1); if (f.indexOf('c') !== -1) opt.count = true; if (f.indexOf('v') !== -1) opt.invert = true; if (f.indexOf('l') !== -1) opt.files = true; if (f.indexOf('i') !== -1) opt.ignoreCase = true; ai++; } const pattern = args[ai]; const target = args[ai + 1]; if (!pattern) return this.terminal.print('Usage: grep [-n] [-i] [-c] [-l] <pattern> [filename ...]', 'warning'); // v4.6.64: an invalid pattern is user input, not a system fault. It used to throw // out of the command handler into the error ledger, polluting the counter that // packet §2 says nobody can trust (packet §8). let re; try { re = new RegExp(pattern, opt.ignoreCase ? 'i' : ''); } catch (e) { this.terminal.print('grep: invalid pattern: ' + pattern + ' (' + e.message + ')', 'warning'); return; } const hits = (line) => (opt.invert ? !re.test(line) : re.test(line)); // v4.6.66: every path argument is searched. `grep x a b` used to read only `a` and // exit clean — a silent false negative (packet §1). const grepFiles = args.slice(ai + 1).filter(Boolean); if (grepFiles.length) { const multi = grepFiles.length > 1; let grand = 0; let readFiles = 0; grepFiles.forEach(file => { const c = this.terminal.fs.readFile(file); if (c === null || c === undefined) { this.terminal.print('grep: ' + file + ': No such file', 'error'); return; } readFiles++; let count = 0; c.split('\n').forEach((line, i) => { if (!hits(line)) return; count++; if (opt.count || opt.files) return; this.terminal.print(multi ? (file + ':' + (i + 1) + ': ' + line) : ((i + 1) + ': ' + line)); }); grand += count; if (opt.files) { if (count) this.terminal.print(file); } else if (opt.count) this.terminal.print(multi ? (file + ':' + count) : String(count)); else if (!count && !multi) this.terminal.print('(no matches)', 'info'); }); if (!opt.count && !opt.files) { if (grand === 0) this.terminal.print('(no matches)', 'info'); else this.terminal.print(grand + ' match(es)' + (multi ? ' in ' + readFiles + ' file(s)' : ''), 'text-muted'); } } else { // Piped input wins over the cwd sweep. `cat f | grep x` used to grep the working // directory, so it could match a file that was never piped — measured 2026-10-27: // `cat /gdir/in.txt | grep zzz` answered "other.txt:1: zzz in a cwd file". const piped = this.terminal._pipeInput; if (piped !== null && piped !== undefined) { let pipedTotal = 0; String(piped).split('\n').forEach((line, i) => { if (!hits(line)) return; pipedTotal++; if (!opt.count && !opt.files) this.terminal.print((i + 1) + ': ' + line); }); if (opt.files) { if (pipedTotal) this.terminal.print('(stdin)'); } else if (opt.count) this.terminal.print(String(pipedTotal)); else if (pipedTotal === 0) this.terminal.print('(no matches)', 'info'); return; } const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('grep: cannot search', 'error'); let total = 0; Object.keys(dir.content).forEach(name => { const node = dir.content[name]; if (node.type === 'file') { (node.content || '').split('\n').forEach((line, i) => { if (!hits(line)) return; total++; if (!opt.count && !opt.files) this.terminal.print(name + ':' + (i + 1) + ': ' + line); }); } }); if (opt.files && total) this.terminal.print(dir.name || '.'); if (total === 0) this.terminal.print('(no matches)', 'info'); else if (opt.count) this.terminal.print(String(total)); else if (!opt.files) this.terminal.print(total + ' total match(es)', 'text-muted'); } }; this.commands['head'] = (args) => { // 2026-10-21: accept `head -40 f`, `head -n 40 f` and `head f 40`. // v4.6.66: every path argument, not just the first (packet §1). let n = 10; const headFiles = []; for (let i = 0; i < (args || []).length; i++) { const a = args[i]; if (/^-\d+$/.test(a)) n = parseInt(a.slice(1), 10); else if (a === '-n') { const v = parseInt(args[i + 1], 10); if (!isNaN(v)) { n = v; i++; } } else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } else if (headFiles.length && /^\d+$/.test(a)) n = parseInt(a, 10); // head f 40 else headFiles.push(a); } // Piped input: `cat x | head -n 2` — no file operand means stdin, not usage. const piped = this.terminal ? this.terminal._pipeInput : null; if (!headFiles.length) { if (piped === null || piped === undefined) return this.terminal.print('Usage: head [-n] <filename> [...] [lines]', 'warning'); return this.terminal.print(String(piped).split('\n').slice(0, Math.max(1, n)).join('\n')); } const headMulti = headFiles.length > 1; headFiles.forEach(file => { const c = this.terminal.fs.readFile(file); if (c === null || c === undefined) return this.terminal.print('head: ' + file + ': No such file', 'error'); if (headMulti) this.terminal.print('==> ' + file + ' <==', 'text-muted'); this.terminal.print(c.split('\n').slice(0, Math.max(1, n)).join('\n')); }); }; this.commands['tail'] = (args) => { const self = this; let follow = false, n = 10; const positional = []; for (let i = 0; i < (args || []).length; i++) { const a = args[i]; if (a === '-f' || a === '--follow') follow = true; else if (a === '-n') { const v = args[++i]; if (v === undefined || !/^\d+$/.test(v)) return this.terminal.print('tail: -n requires a number', 'error'); n = parseInt(v, 10); } else if (/^-\d+$/.test(a)) n = parseInt(a.slice(1), 10); else if (a === '-c' || a === '-q' || a === '-v' || a === '--pid') return this.terminal.print('tail: option ' + a + ' not supported', 'error'); else if (a.startsWith('-')) return this.terminal.print('tail: invalid option: ' + a, 'error'); else positional.push(a); } // v4.6.66: every path argument (packet §1). A numeric second positional is still // the legacy `tail <file> <count>`, not a filename. const tailFiles = positional.filter((p, i) => !(i > 0 && /^\d+$/.test(p))); // Piped input: `cat x | tail -n 1` — no file operand means stdin, not usage. const piped = this.terminal ? this.terminal._pipeInput : null; if (!tailFiles.length) { if (piped === null || piped === undefined) return this.terminal.print('Usage: tail [-f] [-n N] <file> [...] — last N lines; -f follows changes (Ctrl+C or Esc to stop)', 'warning'); const _in = String(piped).split('\n'); // same trailing-newline rule as the file path below if (_in.length > 1 && _in[_in.length - 1] === '') _in.pop(); return this.terminal.print(_in.slice(-n).join('\n')); } if (positional[1] && /^\d+$/.test(positional[1])) n = parseInt(positional[1], 10); // legacy back-compat const tailMulti = tailFiles.length > 1; let shownFiles = tailFiles; if (follow && tailMulti) { this.terminal.print('tail: -f needs a single file — following ' + tailFiles[0] + ' only', 'warning'); shownFiles = [tailFiles[0]]; } let firstContent = null; shownFiles.forEach(f2 => { const cc = this.terminal.fs.readFile(f2); if (cc === null || cc === undefined) return this.terminal.print('tail: ' + f2 + ': No such file', 'error'); if (shownFiles.length > 1) this.terminal.print('==> ' + f2 + ' <==', 'text-muted'); // 2026-10-21: a trailing newline produced an empty last element, so `tail -n 1` // printed a blank line instead of the final line. const _all = cc.split('\n'); if (_all.length > 1 && _all[_all.length - 1] === '') _all.pop(); this.terminal.print(_all.slice(-n).join('\n')); if (firstContent === null) firstContent = cc; }); const file = tailFiles[0]; const c = firstContent === null ? '' : firstContent; if (!follow) return; if (c === '') return; // v4.6.66: the only file was unreadable — nothing to follow // AI tool calls cannot press Ctrl+C — auto-downgrade to a bounded read instead of a live timer if (this.terminal.security && this.terminal.security._aiToolDepth > 0) { return this.terminal.print('tail: follow mode (-f) is interactive-only; showing last lines instead (use tail -n ' + Math.max(n, 50) + ' for more)', 'warning'); } // stop any previous follow before starting a new one if (this.terminal._activeFollow && this.terminal._activeFollow.interval) clearInterval(this.terminal._activeFollow.interval); this.terminal._activeFollow = null; let offset = c.length; const interval = setInterval(() => { const cur = self.terminal.fs.readFile(file); if (cur === null || cur === undefined) { self.terminal.stopFollow('tail: ' + file + ': file removed — follow stopped'); return; } if (cur.length < offset) { self.terminal.print('tail: ' + file + ': file truncated — following from start', 'warning'); self.terminal.print(cur); offset = cur.length; return; } if (cur.length > offset) { self.terminal.print(cur.slice(offset)); offset = cur.length; } }, 1500); this.terminal._activeFollow = { interval, file }; this.terminal.print('tail: following ' + file + ' (Ctrl+C or Esc to stop)', 'text-muted'); }; this.commands['sed'] = (args) => { if (!args || args.length === 0) { return this.terminal.print('Usage: sed <expr> [file] — expressions: s/pat/rep/[g][i][p], /pat/d, Nd, $d; flags: -i in place, -n quiet', 'warning'); } let inPlace = false, quiet = false, expr = null, file = null; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '-i') inPlace = true; else if (a === '-n') quiet = true; else if (expr === null) expr = a; else if (file === null) file = a; else return this.terminal.print('sed: too many arguments: ' + a, 'error'); } if (!expr) return this.terminal.print('sed: missing expression', 'error'); let content; if (file) { content = this.terminal.fs.readFile(file); if (content === null || content === undefined) return this.terminal.print('sed: ' + file + ': No such file', 'error'); } else if (this.terminal._pipeInput !== null && this.terminal._pipeInput !== undefined) { content = this.terminal._pipeInput; // piped input: cat x | sed s/a/b/ } else { return this.terminal.print('sed: missing file operand (no piped input)', 'error'); } let subst = null, delMatch = null, delLine = null; // 2026-10-21: `sed -n '10,40p' file` and `sed -n '7p' file` had no parser, so the // most common "show me lines N–M" idiom failed with an unsupported-expression error. const _range = /^(\d+)(?:,(\d+))?p$/.exec(expr); if (_range) { const _a = parseInt(_range[1], 10); const _b = _range[2] ? parseInt(_range[2], 10) : _a; const _lines = content.split('\n'); this.terminal.print(_lines.slice(Math.max(0, _a - 1), Math.max(_a, _b)).join('\n')); return; } if (expr.startsWith('s/') && expr.length > 2) { const parts = []; let cur = ''; for (let i = 2; i < expr.length; i++) { const ch = expr[i]; if (ch === '\\' && expr[i + 1] === '/') { cur += '/'; i++; } else if (ch === '/') { parts.push(cur); cur = ''; } else cur += ch; } parts.push(cur); if (parts.length < 2 || parts.length > 3) return this.terminal.print('sed: malformed expression: ' + expr, 'error'); const flags = parts[2] || ''; if ([...flags].some(c => !'gip'.includes(c))) return this.terminal.print('sed: unsupported flags: ' + flags, 'error'); subst = { pat: parts[0], rep: parts[1], flags }; } else if (/^\/(.+)\/d$/.test(expr)) { delMatch = expr.slice(1, -2); } else if (/^(\d+|\$)d$/.test(expr)) { delLine = expr.slice(0, -1); } else { return this.terminal.print('sed: unsupported expression: ' + expr, 'error'); } let lines = content.split('\n'); let trailingNL = false; if (lines.length && lines[lines.length - 1] === '') { trailingNL = true; lines.pop(); } const outLines = []; let changed = 0; if (subst) { let re; try { re = new RegExp(subst.pat, (subst.flags.includes('i') ? 'i' : '') + (subst.flags.includes('g') ? 'g' : '')); } catch (e) { return this.terminal.print('sed: invalid regex: ' + e.message, 'error'); } for (const line of lines) { const after = line.replace(re, subst.rep); if (after !== line) changed++; if (quiet) { if (subst.flags.includes('p') && after !== line) this.terminal.print(after); } else { outLines.push(after); } } } else if (delMatch !== null) { let re; try { re = new RegExp(delMatch); } catch (e) { return this.terminal.print('sed: invalid regex: ' + e.message, 'error'); } for (const line of lines) { if (re.test(line)) changed++; else outLines.push(line); } } else { const idx = delLine === '$' ? lines.length - 1 : parseInt(delLine, 10) - 1; if (idx < 0 || idx >= lines.length) return this.terminal.print('sed: line number out of range: ' + delLine, 'error'); for (let i = 0; i < lines.length; i++) { if (i === idx) changed++; else outLines.push(lines[i]); } } if (inPlace) { if (!file) return this.terminal.print('sed: -i requires a file operand', 'error'); this.terminal.fs.writeFile(file, outLines.join('\n') + (trailingNL ? '\n' : '')); this.terminal.print('sed: updated ' + file + ' (' + changed + ' line' + (changed === 1 ? '' : 's') + ' changed)', 'success'); } else if (quiet) { if (changed === 0) this.terminal.print('(no changes)', 'text-muted'); } else { this.terminal.print(outLines.join('\n')); } }; this.commands['sort'] = (args) => { if(!args[0]) return this.terminal.print('Usage: sort <filename>', 'warning'); const c = this.terminal.fs.readFile(args[0]); if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error'); c.split('\n').sort().forEach(l => this.terminal.print(l)); }; this.commands['wc'] = (args) => { // 2026-10-21: accept -l / -w / -c (previously ANY flag was read as a filename, // so `wc -l file` answered "wc: -l: No such file"). // v4.6.58: multiple files. `wc -c a b` used to print only `a` and exit clean — // a silent false negative (packet §1). const want = { l: false, w: false, c: false }; const files = []; (args || []).forEach(a => { if (/^-[lwc]+$/.test(a)) a.slice(1).split('').forEach(k => { want[k] = true; }); else if (/^-[a-zA-Z]+$/.test(a)) { /* ignore other flags */ } else files.push(a); }); // Piped input: `cat x | wc -l`. The pipe wrapper sets terminal._pipeInput for every // stage (modules/ext/shell.js), so "no file operand" means "count stdin", not usage. const piped = this.terminal ? this.terminal._pipeInput : null; const stdinMode = !files.length && piped !== null && piped !== undefined; if (!files.length && !stdinMode) return this.terminal.print('Usage: wc [-l] [-w] [-c] <filename> [...]', 'warning'); const anyFlag = want.l || want.w || want.c; const rows = []; let total = { l: 0, w: 0, c: 0 }; if (stdinMode) { const content = String(piped); const r = { file: null, // stdin has no name — GNU wc prints the bare counts l: content.split('\n').length, w: content.split(/\s+/).filter(w => w).length, c: content.length }; total.l += r.l; total.w += r.w; total.c += r.c; rows.push(r); } files.forEach(file => { const content = this.terminal.fs.readFile(file); if (content === null || content === undefined) { this.terminal.print('wc: ' + file + ': No such file', 'error'); return; } const r = { file: file, l: content.split('\n').length, w: content.split(/\s+/).filter(w => w).length, c: content.length }; total.l += r.l; total.w += r.w; total.c += r.c; rows.push(r); }); if (!rows.length) return; const multi = rows.length > 1; const fmt = (r, name) => { const out = []; if (want.l || !anyFlag) out.push(r.l); if (want.w || !anyFlag) out.push(r.w); if (want.c || !anyFlag) out.push(r.c); if ((multi || (anyFlag && !(want.l && want.w && want.c))) && name !== null) out.push(name); return out.join(' '); }; rows.forEach(r => this.terminal.print(fmt(r, r.file))); if (multi) this.terminal.print(fmt(total, 'total')); }; this.commands['touch'] = (args) => { if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning'); const e = this.terminal.fs.readFile(args[0]); if (e === null || e === undefined) { const segs = this.terminal.fs._toAbsoluteSegments(args[0]); if (segs) { const dirPath = segs.slice(0, -1).join('/') || '.'; const name = segs[segs.length - 1]; this.terminal.fs.mkdirRecursive(dirPath); } this.terminal.fs.writeFile(args[0], ''); this.terminal.print('Created: ' + args[0], 'success'); } else { this.terminal.print('Touched: ' + args[0], 'success'); } }; this.commands['tree'] = () => { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return this.terminal.print('(no directory)', 'warning'); const lines = []; this._printTree(dir, '', lines); lines.forEach(l => this.terminal.print(l)); }; this.commands['du'] = (args) => { const t = args && args[0] ? args[0] : '.'; if (t === '.') { const dir = this.terminal.fs.getCurrentDir(); if (!dir) return; let total = 0; Object.keys(dir.content).forEach(k => { const node = dir.content[k]; total += node && typeof node.content === 'string' ? node.content.length : 0; }); this.terminal.print(total + '\t' + t); } else { const c = this.terminal.fs.readFile(t); if (c === null || c === undefined) return; this.terminal.print(c.length + '\t' + t); } }; this.commands['upload'] = () => { document.getElementById('file-upload-input').click(); }; this.commands['download'] = async (args) => { if(!args[0]) return this.terminal.print('Usage: download <filename>', 'warning'); const fname = args[0]; const content = this.terminal.fs.readFile(fname); if(content === null || content === undefined) { return this.terminal.print('download: ' + fname + ': No such file', 'error'); } // Push to WebDAV first — this is the only path to a real file download on Telegram WebView // because browser-based file downloads are blocked in WebViews let downloadUrl = null; if (this.terminal.moduleManager && this.terminal.moduleManager.modules.WebDAVModule) { const wdav = this.terminal.moduleManager.modules.WebDAVModule; if (wdav.config && wdav.config.url) { try { // Make sure /downloads/ dir exists try { await wdav._ensureDir('/downloads'); } catch(e) {} const resp = await wdav._webdavRequest('PUT', '/downloads/' + fname, { body: content, headers: { 'Content-Type': 'application/octet-stream' } }); if (resp.ok || resp.status === 201 || resp.status === 204) { // Build the public URL the user can tap const baseUrl = wdav.config.url.replace(/\/+$/, ''); const hostMatch = baseUrl.match(/^(https?:\/\/[^\/]+)/); const host = hostMatch ? hostMatch[1] : 'https://webx11.duckdns.org'; // Build the public URL the user can tap. // The URL must use the space the PUT actually went to // (config.url = the logged-in user's space). _spaceName is // derived from the page path (/spaces/newos4/…) and is wrong // for users downloading into their own space (2026-10-21). const urlSpace = (typeof wdav._resolveSpaceName === 'function' && wdav._resolveSpaceName()) || (wdav._extractSpaceNameFromUrl && wdav._extractSpaceNameFromUrl(wdav.config.url)) || wdav._spaceName; downloadUrl = host + '/spaces/' + urlSpace + '/downloads/' + encodeURIComponent(fname); this.terminal.print('Uploaded to: ' + downloadUrl, 'success'); } else { this.terminal.print('WebDAV push failed: HTTP ' + resp.status, 'warning'); } } catch (e) { this.terminal.print('WebDAV push error: ' + e.message, 'warning'); } } else { this.terminal.print('WebDAV not configured. Run: webdav-config', 'warning'); } } // Build a visible anchor link — tap = real user gesture = native browser download bar const size = content.length; const sizeStr = size > 1024 ? (size / 1024).toFixed(1) + ' KB' : size + ' B'; const line = document.createElement('div'); line.className = 'output-line'; line.style.cssText = 'padding:8px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;'; if (downloadUrl) { line.innerHTML = '<span>⬇ ' + fname + ' (' + sizeStr + ') </span>' + '<a href="' + downloadUrl + '" target="_blank" rel="noopener" ' + 'style="background:var(--success-color);color:#000;border:none;padding:8px 16px;' + 'border-radius:6px;cursor:pointer;font-size:0.9em;min-height:44px;' + 'display:inline-flex;align-items:center;text-decoration:none;font-weight:bold;">⬇ Tap to download</a>'; } else { line.innerHTML = '<span>⬇ ' + fname + ' (' + sizeStr + ') — run <code>webdav-config</code> first to enable downloads</span>'; } this.terminal.outputContainer.appendChild(line); this.terminal.scrollToBottom(); }; this.commands['vfs-export'] = () => { const data = this.terminal.fs.root; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], {type: 'application/json'}); const sizeMB = blob.size / (1024 * 1024); if (sizeMB > 5) { this.terminal.print('Warning: VFS size is ' + sizeMB.toFixed(1) + ' MB — large download may take time', 'warning'); } const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-filesystem-' + dateStr + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); this.terminal.print('Exported VFS to webx11-filesystem-' + dateStr + '.json', 'success'); }; this.commands['vfs-size'] = () => { try { const json = JSON.stringify(this.terminal.fs.root); const totalSize = (json.length / 1024 / 1024).toFixed(2); const root = this.terminal.fs.root; const sizeMap = []; function walk(node, path) { if (!node || !node.content) return; for (const [name, child] of Object.entries(node.content)) { const fullPath = path + '/' + name; if (child.type === 'file') { const size = (child.content ? child.content.length : 0); sizeMap.push({ path: fullPath, size, sizeKB: (size / 1024).toFixed(1) }); } else if (child.type === 'dir') { walk(child, fullPath); } } } walk(root, ''); sizeMap.sort((a, b) => b.size - a.size); const totalFiles = sizeMap.length; const top5 = sizeMap.slice(0, 5); this.terminal.print('📊 VFS Size Report', 'text-muted'); this.terminal.print(' Total: ' + totalSize + ' MB across ' + totalFiles + ' files'); this.terminal.print(' Largest:'); top5.forEach((f, i) => { this.terminal.print(' ' + (i + 1) + '. ' + f.path + ' — ' + f.sizeKB + ' KB'); }); // Show system dir sizes const sysDirs = ['/_snapshots', '/_sessions', '/.trash']; for (const dir of sysDirs) { const dirNode = this.terminal.fs.readDir(dir); if (dirNode) { const cnt = dirNode.length; // estimate dir size const dirSize = JSON.stringify(dirNode).length; this.terminal.print(' ' + dir + ': ' + cnt + ' files, ~' + (dirSize / 1024).toFixed(1) + ' KB', dirSize > 102400 ? 'warning' : 'text-muted'); } } } catch (e) { this.terminal.print('vfs-size error: ' + e.message, 'error'); } }; // Session commands (save/load/export/import) this.commands['session-save'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-save <name>', 'warning'); const name = args[0].replace(/[^a-zA-Z0-9_-]/g, '_'); // v2: metadata-only — VFS is persisted independently in localStorage const manifest = { type: 'session', version: 2, savedAt: new Date().toISOString(), currentPath: this.terminal.fs.currentPath, terminalLines: this.terminal._historyBuffer ? this.terminal._historyBuffer.slice(-50) : [] }; const filePath = '_sessions/' + name + '.json'; this.terminal.fs.mkdirRecursive('_sessions'); this.terminal.fs.writeFile(filePath, JSON.stringify(manifest)); this.terminal.print('Session saved: ' + name + ' (' + (manifest.terminalLines.length || 0) + ' history lines)', 'success'); }; this.commands['session-load'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-load <name>', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; const content = this.terminal.fs.readFile(filePath); if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); try { const session = JSON.parse(content); if (!session || session.type !== 'session') { return this.terminal.print('Invalid session file: ' + args[0], 'error'); } if (session.version === 2) { // v2: metadata-only restore — keep existing VFS, just restore path + terminal history this.terminal.fs.currentPath = session.currentPath || []; if (session.terminalLines && Array.isArray(session.terminalLines) && this.terminal._historyBuffer) { this.terminal._historyBuffer = session.terminalLines.slice(-50); // Re-render last few lines for context this.terminal.outputContainer.innerHTML = ''; session.terminalLines.forEach(line => { const text = typeof line === 'string' ? line : (line.l || ''); const type = typeof line === 'object' ? (line.t || '') : ''; this.terminal.print(text, type); }); } } else { // v1: full VFS restore (legacy) if (!session.fsRoot) return this.terminal.print('Invalid session file', 'error'); this.terminal.fs.root = session.fsRoot; this.terminal.fs.currentPath = session.currentPath || []; } this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success'); this.terminal.executeCommand('ls'); } catch (e) { this.terminal.print('Failed to load session: ' + e.message, 'error'); } }; this.commands['session-list'] = () => { this.terminal.fs.mkdirRecursive('_sessions'); const files = this.terminal.fs.ls('_sessions'); if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info'); this.terminal.print('Saved sessions:', 'text-muted'); files.forEach(f => { if (f.type === 'file' && f.name.endsWith('.json')) { try { const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name)); const label = f.name.replace('.json', ''); const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown'; if (content.version === 2) { const lines = content.terminalLines ? content.terminalLines.length : 0; this.terminal.print(' ' + label + ' (' + lines + ' history lines, ' + date + ')'); } else { const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0; this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')'); } } catch (e) { this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)'); } } }); }; this.commands['session-delete'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-delete <name>', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; if (this.terminal.fs.deleteFile(filePath)) { this.terminal.print('Session deleted: ' + args[0], 'success'); } else { this.terminal.print('Session not found: ' + args[0], 'error'); } }; this.commands['session-new'] = () => { // Auto-save current session, clear AI context, fresh terminal this.terminal._autoSaveSession(); try { localStorage.removeItem('webx11_ai_context'); } catch(e) {} // Clear terminal output if (this.terminal.outputContainer) this.terminal.outputContainer.innerHTML = ''; if (this.terminal._historyBuffer) this.terminal._historyBuffer = []; // Clear AI conversation context const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; if (aiMod && aiMod.clearContext) aiMod.clearContext(); this.terminal.print('✨ New session started — context cleared', 'success'); }; this.commands['session-export'] = (args) => { if (!args[0]) return this.terminal.print('Usage: session-export <name>', 'warning'); const filePath = '_sessions/' + args[0] + '.json'; const content = this.terminal.fs.readFile(filePath); if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error'); const blob = new Blob([content], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-session-' + args[0] + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000); this.terminal.print('Exported session: ' + args[0], 'success'); }; this.commands['session-import'] = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const session = JSON.parse(evt.target.result); if (!session || session.type !== 'session') { this.terminal.print('Invalid session file (not a valid session export)', 'error'); return; } if (session.version === 2) { // v2: metadata-only — keep existing VFS, restore path this.terminal.fs.currentPath = session.currentPath || []; if (session.terminalLines && Array.isArray(session.terminalLines) && this.terminal._historyBuffer) { this.terminal._historyBuffer = session.terminalLines.slice(-50); this.terminal.outputContainer.innerHTML = ''; session.terminalLines.forEach(line => { const text = typeof line === 'string' ? line : (line.l || ''); const type = typeof line === 'object' ? (line.t || '') : ''; this.terminal.print(text, type); }); } } else { if (!session.fsRoot) { this.terminal.print('Invalid session file (not a valid session export)', 'error'); return; } this.terminal.fs.root = session.fsRoot; this.terminal.fs.currentPath = session.currentPath || []; } this.terminal.fs.saveToStorage(); this.terminal.updatePrompt(); this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success'); this.terminal.executeCommand('ls'); } catch (err) { this.terminal.print('Failed to import session: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }; this.commands['vfs-import'] = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (evt) => { try { const data = JSON.parse(evt.target.result); if (!data || !data.type) { this.terminal.print('Invalid VFS file', 'error'); return; } if (this.terminal.fs.restoreFromExport(data)) { this.terminal.updatePrompt(); this.terminal.print('VFS restored from: ' + file.name, 'success'); this.terminal.executeCommand('ls'); } else { this.terminal.print('Failed to restore VFS', 'error'); } } catch (err) { this.terminal.print('Failed to import VFS: ' + err.message, 'error'); } }; reader.readAsText(file); }; input.click(); }; } _printTree(dir, prefix, lines) { const names = Object.keys(dir.content || {}).sort(); names.forEach((name, i) => { const isLast = i === names.length - 1; const node = dir.content[name]; const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 '; const label = node.type === 'dir' ? name + '/' : name; lines.push(prefix + connector + label); if (node.type === 'dir') { this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines); } }); } } /* === module-ollama.js === */ // Extracted from newOS4 index.html — OllamaModule (merged with OllamaModelsModule) // Commands: ollama, ollamaconnect, ollamadisconnect, ollamamodels, ollamasetmodel, ollamasetproxy, models, model-info, model-pull, model-delete, model-run, ai, ask class OllamaModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'ollama': 'ollama — Open the Ollama model manager UI', 'ollamaconnect': 'ollamaconnect — Connect to the Ollama server', 'ollamadisconnect': 'ollamadisconnect — Disconnect from the Ollama server', 'ollamamodels': 'ollamamodels — List all available models from Ollama', 'models': 'models — List all available Ollama models (table format)', 'model-info': 'model-info <model-name> — Show details about a specific model', 'model-pull': 'model-pull <model-name> — Pull a model from Ollama registry', 'model-delete': 'model-delete <model-name> — Delete a model from local storage', 'model-run': 'model-run <model-name> [prompt] — Run inference with a model', 'ollamasetmodel': 'ollamasetmodel <name> — Set the active Ollama model', 'ollamasetproxy': 'ollamasetproxy [endpoint] — View or change the Ollama endpoint URL', }; } registerCommands() { this.commands['ollama'] = () => { this.terminal.openOllamaManager(); }; this.commands['ollamaconnect'] = async () => { await this.terminal.ollamaManager.connect(); this.terminal.updateOllamaStatus(); }; this.commands['ollamadisconnect'] = () => { this.terminal.ollamaManager.connected = false; this.terminal.ollamaManager.models = []; this.terminal.print('Ollama disconnected', 'info'); this.terminal.updateOllamaStatus(); }; this.commands['ollamamodels'] = () => { // Redirect to the consolidated models command this.listModels(); }; this.commands['ollamasetmodel'] = (args) => { if(!args[0]) return this.terminal.print("Usage: ollamasetmodel <name>", 'warning'); this.terminal.ollamaManager.setModel(args[0]); }; this.commands['ollamasetproxy'] = (args) => { if(!args[0]) { const ep = this.terminal.ollamaManager.endpoint; return this.terminal.print(`Current Ollama endpoint: ${ep}`, 'info'); } this.terminal.ollamaManager.setEndpoint(args[0]); this.terminal.print(`Ollama endpoint set to: ${args[0]}`, 'success'); }; this.commands['ai'] = async (args) => { if(!args[0]) return this.terminal.print("Usage: ai <prompt>", 'warning'); await this.terminal.ollamaManager.chat(args.join(' ')); }; this.commands['ask'] = async (args) => { if(!args[0]) return this.terminal.print("Usage: ask <prompt>", 'warning'); await this.terminal.ollamaManager.chat(args.join(' ')); }; // Merged from OllamaModelsModule this.commands['models'] = () => this.listModels(); this.commands['model-info'] = (args) => this.modelInfo(args); this.commands['model-pull'] = (args) => this.pullModel(args); this.commands['model-delete'] = (args) => this.deleteModel(args); this.commands['model-run'] = (args) => this.runModel(args); } async listModels() { const endpoint = this.terminal?.ollamaManager?.endpoint || localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; try { const response = await fetch(`${endpoint}/api/tags`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); const models = data.models || []; if (models.length === 0) { this.terminal.print("No models found. Run 'ollama pull <model>' first.", "warning"); return; } this.terminal.print("=== Available Ollama Models ===", "info"); this.terminal.printHTML(`<table style="width: 100%; border-collapse: collapse; margin: 10px 0;"> <tr style="border-bottom: 1px solid #005500;"> <th style="text-align: left; padding: 5px;">Name</th> <th style="text-align: left; padding: 5px;">Size</th> <th style="text-align: left; padding: 5px;">Family</th> </tr>`); models.forEach(m => { const sizeGB = (m.size / 1e9).toFixed(2); this.terminal.printHTML(`<tr style="border-bottom: 1px solid #111;"> <td style="padding: 5px; color: #00ff00;">${m.name}</td> <td style="padding: 5px; color: #888888;">${sizeGB} GB</td> <td style="padding: 5px; color: #00aaff;">${m.details?.family || 'N/A'}</td> </tr>`); }); this.terminal.printHTML(`</table>`); } catch (e) { this.terminal.print(`Failed to fetch models: ${e.message}`, "error"); } } async modelInfo(args) { if (!args[0]) { this.terminal.print("Usage: model-info <model-name>", "warning"); return; } const endpoint = this.terminal?.ollamaManager?.endpoint || localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434'; try { const response = await fetch(`${endpoint}/api/show`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: args[0] }) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); this.terminal.print(`=== ${args[0]} ===`, "info"); this.terminal.print(`License: ${data.license || 'N/A'}`); this.terminal.print(`Parameters: ${data.details?.parameter_size || 'N/A'}`); this.terminal.print(`Family: ${data.details?.family || 'N/A'}`); } catch (e) { this.terminal.print(`Failed to get model info: ${e.message}`, "error"); } } pullModel(args) { this.terminal.print("⚠️ Model pulling must be done via Ollama CLI", "warning"); this.terminal.print(`Run: ollama pull ${args[0] || '<model-name>'}`, "info"); } deleteModel(args) { this.terminal.print("⚠️ Model deletion must be done via Ollama CLI", "warning"); this.terminal.print(`Run: ollama rm ${args[0] || '<model-name>'}`, "info"); } async runModel(args) { if (!args[0]) { this.terminal.print("Usage: model-run <model-name> [prompt]", "warning"); return; } const model = args[0]; const prompt = args.slice(1).join(' ') || 'Hello!'; this.terminal.print(`Running ${model}...`, "info"); await this.terminal.ollamaManager.chat(prompt, model); } } /* === module-billing.js === */ // Billing / pricing surface for newOS4 (2026-10-28) // // Why: the terminal could show a model's name but never what it costs. Prices and the anonymous // message allowance are public data, so they need no login — the account BALANCE does, and newOS4 // has no session layer, so that is reported honestly as "not signed in" rather than guessed. // // Commands: cost [on|off], usage class BillingModule { constructor() { this.commands = {}; this.terminal = null; this._prices = null; this._pricesAt = 0; this._installed = false; } getHelpInfo() { return { 'cost': 'cost [on|off] — AI prices, today\'s free-message allowance and this session\'s spend', 'usage': 'usage — same as cost', }; } registerCommands() { this.commands['cost'] = (args) => this.run(args); this.commands['usage'] = (args) => this.run(args); } init(terminal) { this.terminal = terminal; this._hookUsage(); } // --- data ----------------------------------------------------------------- async _loadPrices() { if (this._prices && Date.now() - this._pricesAt < 300000) return this._prices; try { const r = await fetch('/api/billing/prices'); if (!r.ok) return null; this._prices = await r.json(); this._pricesAt = Date.now(); return this._prices; } catch (e) { return null; } } async _loadDemo() { try { const r = await fetch('/api/billing/demo-usage'); if (!r.ok) return null; return await r.json(); } catch (e) { return null; } } async _loadBalance() { // Only answers when a session exists; newOS4 currently has none, so this is expected to 401. try { const r = await fetch('/api/billing/subscription', { credentials: 'include' }); if (!r.ok) return null; const d = await r.json(); return d && d.balance_usd != null ? d : null; } catch (e) { return null; } } // --- session spend, measured where the tokens actually are ---------------- _hookUsage() { const om = this.terminal && this.terminal.ollamaManager; if (!om || om.__billingHooked) return; const orig = om._logUsage ? om._logUsage.bind(om) : null; if (!orig) return; om._billingSession = { msgs: 0, prompt: 0, completion: 0, cost: 0, byModel: {} }; om._logUsage = (provider, model, latency, promptTokens, completionTokens) => { try { this._accumulate(model, promptTokens, completionTokens); } catch (e) { /* never break a reply */ } return orig(provider, model, latency, promptTokens, completionTokens); }; om.__billingHooked = true; } _rate(model) { const cat = (this._prices && this._prices.models) || []; const bare = String(model || '').replace(/:cloud$|-cloud$/, ''); return cat.find(m => m.model === model) || cat.find(m => m.model === bare) || null; } _accumulate(rawModel, promptTokens, completionTokens) { const om = this.terminal.ollamaManager; const s = om._billingSession; if (!s) return; const model = rawModel || 'unknown'; const pt = Number(promptTokens) || 0; const ct = Number(completionTokens) || 0; if (!pt && !ct) return; // metadata calls carry no tokens const row = this._rate(model); const r = row ? row.retail_per_million : null; const cost = r ? (pt * r.input + ct * r.output) / 1e6 : 0; s.msgs += 1; s.prompt += pt; s.completion += ct; s.cost += cost; const m = s.byModel[model] || (s.byModel[model] = { msgs: 0, tokens: 0, cost: 0 }); m.msgs += 1; m.tokens += pt + ct; m.cost += cost; if (this._costLineOn() && ct > 0 && cost > 0) { this.terminal.print( `💵 $${cost.toFixed(4)} · ${model} · ${(pt / 1000).toFixed(1)}k in / ${(ct / 1000).toFixed(1)}k out (retail est.)`, 'info'); } } _costLineOn() { try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; } } // --- the command ---------------------------------------------------------- async run(args) { const p = this.terminal.print.bind(this.terminal); if (args && args[0] === 'on') { try { localStorage.setItem('webx11_cost_line', '1'); } catch (e) {} p('💵 cost line on — each reply will show its retail estimate. `cost off` to stop.', 'success'); return; } if (args && args[0] === 'off') { try { localStorage.setItem('webx11_cost_line', '0'); } catch (e) {} p('💵 cost line off.', 'info'); return; } const [prices, demo, bal] = await Promise.all([this._loadPrices(), this._loadDemo(), this._loadBalance()]); const s = (this.terminal.ollamaManager && this.terminal.ollamaManager._billingSession) || null; if (demo) { const cap = demo.exempt ? 'unlimited (operator exemption)' : `${demo.used} / ${demo.limit} messages today — ${demo.remaining} left, resets ${String(demo.resets_at).slice(0, 10)}`; p(`📊 Anonymous allowance: ${cap}`, 'info'); // distinct from the monthly money allowance below } else { p('📊 Free allowance: unavailable (billing API did not answer)', 'warning'); } p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info'); if (bal && bal.plan_allowance) { const pa = bal.plan_allowance; p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} this month — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info'); } if (s && s.msgs) { p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info'); Object.entries(s.byModel) .sort((a, b) => b[1].cost - a[1].cost) .slice(0, 5) .forEach(([m, v]) => p(` · ${m}: ${v.msgs} call(s), ${(v.tokens / 1000).toFixed(1)}k tok, $${v.cost.toFixed(4)}`, '')); } else { p('🧮 This session: nothing yet', ''); } if (prices) { const rows = prices.models .filter(m => m.retail_per_million.input > 0) .sort((a, b) => a.retail_per_million.output - b.retail_per_million.output); const body = rows.map(m => { const r = m.retail_per_million; return `<tr><td style="padding:2px 8px">${m.model}</td> <td style="padding:2px 8px;text-align:right">$${r.input.toFixed(2)}</td> <td style="padding:2px 8px;text-align:right">$${r.cached_input.toFixed(3)}</td> <td style="padding:2px 8px;text-align:right">$${r.output.toFixed(2)}</td></tr>`; }).join(''); this.terminal.printHTML(`<div style="margin:8px 0"><b>Retail prices per 1M tokens</b> (cost × ${prices.markup})<br> <table style="border-collapse:collapse;margin-top:4px;font-size:12px"> <tr><th style="text-align:left;padding:2px 8px">model</th><th style="padding:2px 8px">input</th> <th style="padding:2px 8px">cached</th><th style="padding:2px 8px">output</th></tr> ${body}</table> <div style="opacity:.7;margin-top:4px">Catalog ${String(prices.fetched_at || '').slice(0, 16)} · input billed as fresh (cached counts are not exposed upstream), so figures here are a ceiling.</div></div>`); } else { p('💲 Prices unavailable (billing API did not answer)', 'warning'); } } } if (typeof window !== 'undefined') { window.BillingModule = BillingModule; } /* === module-modmgr.js === */ // Extracted from newOS4 index.html — ModuleManagerModule // v4.5.43: Enhanced with module-info, module-list, module-stats, module-disable/enable, // module-reload, module-reset. Categories: CORE/EXT/FOUNDATION. class ModuleManagerModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'modules': 'modules — Open the module manager GUI', 'listmods': 'listmods — List all loaded modules with status', 'modules-list': 'modules-list [core|ext] — List all available modules from the module index', 'module-list': 'module-list [flags] — List loaded modules grouped by category. Flags: --flat, --core, --ext, --foundation, --lazy, --eager, --loaded, --unloaded, --search=<pat>, --failed, --disabled', 'module-info': 'module-info <name> — Show module details: path, status, size, type, source, commands list', 'module-stats': 'module-stats — Show aggregate module statistics (loaded/unloaded/lazy/failed/disabled + total size)', 'module-load': 'module-load <name> — Mark a lazy module as loaded', 'module-unload': 'module-unload <name> — Mark a module as unloaded', 'module-reload': 'module-reload <name> — Unload then reload a module (shows diff)', 'module-reset': 'module-reset <name> — Reset a module to a clean state (clears its state, keeps commands)', 'module-disable': 'module-disable <name> — Disable a module from auto-loading on refresh (persists)', 'module-enable': 'module-enable <name> — Re-enable a previously disabled module', 'loadmod': 'loadmod <url> — Load a module from a URL', 'removemod': 'removemod <name> — Remove a loaded module by name', }; } // Helper: read disabled list from localStorage _getDisabled() { try { return JSON.parse(localStorage.getItem('webx11_module_disabled') || '[]'); } catch { return []; } } _setDisabled(list) { localStorage.setItem('webx11_module_disabled', JSON.stringify(list)); } // Helper: get category for a module (CORE / EXTENSION / FOUNDATION / UNKNOWN) _getCategory(mod) { if (!mod) return 'UNKNOWN'; if (mod.path && mod.path.includes('/modules/ext/')) return 'EXTENSION'; if (mod.path && mod.path.includes('/modules/core/')) return 'CORE'; if (mod.type === 'foundation' || (mod.path && mod.path.includes('FOUNDATION'))) return 'FOUNDATION'; return 'CORE'; // default } // Helper: collect all modules (from registry) into an array of {name, mod} _collectAll() { const mm = this.terminal.moduleManager; const result = []; const allMods = mm.modules || new Map(); // moduleManager.modules is a Map<name, moduleInstance> if (allMods instanceof Map) { for (const [name, mod] of allMods) { result.push({ name, mod }); } } else { for (const name of Object.keys(allMods)) { result.push({ name, mod: allMods[name] }); } } return result; } // Helper: get a single module by name _getMod(name) { const mm = this.terminal.moduleManager; if (mm.modules instanceof Map) return mm.modules.get(name); return mm.modules[name]; } registerCommands() { this.commands['modules'] = () => { this.terminal.openModuleManager(); }; this.commands['loadmod'] = async (args) => { if(!args[0]) return this.terminal.print("Usage: loadmod <url>", 'warning'); await this.terminal.moduleManager.loadModuleFromURL(args[0]); this.terminal.renderModuleList(); }; this.commands['removemod'] = (args) => { if(!args[0]) return this.terminal.print("Usage: removemod <name>", 'warning'); if(this.terminal.moduleManager.unregisterModule(args[0])) { this.terminal.print(`Module '${args[0]}' removed`, 'success'); } else { this.terminal.print(`Module not found: ${args[0]}`, 'error'); } }; this.commands['listmods'] = () => { const mods = this.terminal.moduleManager.getModuleList(); if(mods.length === 0) return this.terminal.print("No modules loaded"); mods.forEach(m => { const status = m.loaded ? '✓' : '✗'; const badge = m.url ? '' : ' [Built-in]'; this.terminal.print(`${status} ${m.name}${badge}`); }); }; this.commands['modules-list'] = async (args) => { try { const filter = (args[0] || '').toLowerCase(); const resp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); if (!resp.ok) return this.terminal.print('Failed to load module index', 'error'); const idx = await resp.json(); let mods = Object.values(idx.modules); if (filter === 'core' || filter === 'ext') mods = mods.filter(m => m.type === filter); if (filter === 'infra') mods = mods.filter(m => m.layer === 'infrastructure'); const loaded = this.terminal.moduleManager.modules || {}; const loadedCount = Object.keys(loaded).length; const byLayer = {}; mods.forEach(m => { const l = m.layer || m.type || 'other'; if (!byLayer[l]) byLayer[l] = []; byLayer[l].push(m); }); const layerSummary = Object.entries(byLayer).map(([l, ms]) => `${ms.length} ${l}`).join(', '); this.terminal.print(`📦 Module Index (${mods.length} modules: ${layerSummary} — ${loadedCount} loaded)`, 'header'); for (const [layer, layerMods] of Object.entries(byLayer)) { this.terminal.print(` ── ${layer.toUpperCase()} (${layerMods.length}) ──`, 'info'); for (const m of layerMods) { const isLoaded = loaded[m.class] ? '✓' : ' '; const layerTag = m.layer === 'infrastructure' ? '[infra]' : m.type === 'core' ? '[core]' : '[ext]'; const cmdHint = m.commands.length > 0 ? ` ${m.commands.slice(0, 4).join(', ')}${m.commands.length > 4 ? '...' : ''}` : ''; const desc = m.description ? ` — ${m.description.substring(0, 60)}` : ''; this.terminal.print(` ${isLoaded} ${layerTag} ${m.name}${cmdHint}${desc}`, ''); } } } catch (e) { this.terminal.print(`modules-list error: ${e.message}`, 'error'); } }; // ── v4.5.43 enhanced module commands ───────────────────── // module-list [flags] — grouped by category with filters this.commands['module-list'] = (args) => { const flags = args.filter(a => a.startsWith('--')); const allMods = this._collectAll(); const disabled = this._getDisabled(); // Apply filters let filtered = allMods; if (flags.includes('--core')) { filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'CORE'); } if (flags.includes('--ext')) { filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'EXTENSION'); } if (flags.includes('--foundation')) { filtered = filtered.filter(({ mod }) => this._getCategory(mod) === 'FOUNDATION'); } if (flags.includes('--lazy')) { filtered = filtered.filter(({ mod }) => mod.lazy); } if (flags.includes('--eager')) { filtered = filtered.filter(({ mod }) => !mod.lazy); } if (flags.includes('--loaded')) { filtered = filtered.filter(({ mod }) => mod.loaded !== false); } if (flags.includes('--unloaded')) { filtered = filtered.filter(({ mod }) => mod.loaded === false); } if (flags.includes('--failed')) { filtered = filtered.filter(({ mod }) => mod.status === 'failed' || mod.error); } if (flags.includes('--disabled')) { filtered = filtered.filter(({ name }) => disabled.includes(name)); } const searchFlag = flags.find(f => f.startsWith('--search=')); if (searchFlag) { const pattern = searchFlag.split('=')[1].toLowerCase(); filtered = filtered.filter(({ name, mod }) => name.toLowerCase().includes(pattern) || (mod.description || '').toLowerCase().includes(pattern) ); } if (filtered.length === 0) { this.terminal.print('No modules match filters', 'info'); return; } const flat = flags.includes('--flat'); if (flat) { // Old format: wall of text this.terminal.print(`📦 Modules (${filtered.length} of ${allMods.length})`, 'header'); for (const { name, mod } of filtered) { const size = mod.size ? `${(mod.size / 1024).toFixed(1)}KB` : '?KB'; const lazy = mod.lazy ? ' [L]' : ''; const loaded = mod.loaded !== false ? '✓' : '✗'; const isDisabled = disabled.includes(name) ? ' [DISABLED]' : ''; this.terminal.print(` ${loaded} ${name.padEnd(28)} ${size.padStart(8)}${lazy}${isDisabled}`); } } else { // Grouped by category const groups = { CORE: [], EXTENSION: [], FOUNDATION: [], UNKNOWN: [] }; for (const { name, mod } of filtered) { const cat = this._getCategory(mod); groups[cat].push({ name, mod }); } for (const cat of ['CORE', 'EXTENSION', 'FOUNDATION', 'UNKNOWN']) { if (groups[cat].length === 0) continue; this.terminal.print(`── ${cat} (${groups[cat].length}) ──`, 'info'); for (const { name, mod } of groups[cat]) { const size = mod.size ? `${(mod.size / 1024).toFixed(1)}KB` : '?KB'; const lazy = mod.lazy ? ' [L]' : ''; const loaded = mod.loaded !== false ? '✓' : '✗'; const isDisabled = disabled.includes(name) ? ' [DISABLED]' : ''; const errMark = (mod.status === 'failed' || mod.error) ? ' ⚠️' : ''; this.terminal.print(` ${loaded} ${name.padEnd(28)} ${size.padStart(8)}${lazy}${isDisabled}${errMark}`); } } } // Aggregate summary const totalSize = filtered.reduce((sum, { mod }) => sum + (mod.size || 0), 0); const lazyCount = filtered.filter(({ mod }) => mod.lazy).length; const failedCount = filtered.filter(({ mod }) => mod.status === 'failed' || mod.error).length; const disabledCount = filtered.filter(({ name }) => disabled.includes(name)).length; this.terminal.print('─'.repeat(50), 'text-muted'); this.terminal.print( `Total: ${filtered.length} modules, ${(totalSize / 1024).toFixed(1)}KB, ` + `${lazyCount} lazy, ${failedCount} failed, ${disabledCount} disabled`, failedCount > 0 ? 'warning' : 'info' ); }; // module-info <name> — detailed view including commands this.commands['module-info'] = (args) => { if (!args[0]) return this.terminal.print('Usage: module-info <name>', 'warning'); const mod = this._getMod(args[0]); if (!mod) return this.terminal.print(`Module not found: ${args[0]}`, 'error'); const disabled = this._getDisabled(); const isDisabled = disabled.includes(args[0]); const cat = this._getCategory(mod); const sizeKB = mod.size ? (mod.size / 1024).toFixed(1) : '?'; const commandCount = mod.commands ? Object.keys(mod.commands).length : 0; const status = mod.status || (mod.loaded !== false ? 'loaded' : 'unloaded'); this.terminal.print(`📋 Module: ${args[0]}`, 'header'); this.terminal.print(` Status: ${status}${isDisabled ? ' [DISABLED]' : ''}`); this.terminal.print(` Category: ${cat}`); this.terminal.print(` Type: ${mod.type || 'unknown'}`); this.terminal.print(` Path: ${mod.path || 'unknown'}`); this.terminal.print(` Size: ${sizeKB} KB`); this.terminal.print(` Lazy: ${mod.lazy ? 'yes' : 'no'}`); this.terminal.print(` Commands: ${commandCount}`); if (mod.description) { this.terminal.print(` Description: ${mod.description}`); } // List the commands if (mod.commands && commandCount > 0) { this.terminal.print(` ── Commands (${commandCount}) ──`, 'info'); const cmds = Object.keys(mod.commands).sort(); // Print in 2 columns const half = Math.ceil(cmds.length / 2); for (let i = 0; i < half; i++) { const left = cmds[i] || ''; const right = cmds[i + half] || ''; this.terminal.print(` ${left.padEnd(24)} ${right}`); } } if (mod.error) { this.terminal.print(` ⚠️ Error: ${mod.error}`, 'error'); } }; // module-stats — aggregate view this.commands['module-stats'] = () => { const all = this._collectAll(); const disabled = this._getDisabled(); const loaded = all.filter(({ mod }) => mod.loaded !== false).length; const unloaded = all.filter(({ mod }) => mod.loaded === false).length; const lazy = all.filter(({ mod }) => mod.lazy).length; const eager = all.length - lazy; const failed = all.filter(({ mod }) => mod.status === 'failed' || mod.error).length; const disabledCount = all.filter(({ name }) => disabled.includes(name)).length; const totalSize = all.reduce((sum, { mod }) => sum + (mod.size || 0), 0); const totalCmds = all.reduce((sum, { mod }) => sum + (mod.commands ? Object.keys(mod.commands).length : 0), 0); this.terminal.print('📊 Module Stats', 'header'); this.terminal.print(` Total: ${all.length} modules`); this.terminal.print(` Loaded: ${loaded}`); this.terminal.print(` Unloaded: ${unloaded}`); this.terminal.print(` Lazy: ${lazy}`); this.terminal.print(` Eager: ${eager}`); this.terminal.print(` Failed: ${failed}`, failed > 0 ? 'warning' : ''); this.terminal.print(` Disabled: ${disabledCount}`, disabledCount > 0 ? 'warning' : ''); this.terminal.print(` Total size: ${(totalSize / 1024).toFixed(1)} KB`); this.terminal.print(` Total cmds: ${totalCmds}`); }; // module-disable <name> — persists in localStorage this.commands['module-disable'] = (args) => { if (!args[0]) return this.terminal.print('Usage: module-disable <name>', 'warning'); const name = args[0]; if (!this._getMod(name)) return this.terminal.print(`Module not found: ${name}`, 'error'); const list = this._getDisabled(); if (list.includes(name)) { return this.terminal.print(`Module '${name}' is already disabled`, 'info'); } list.push(name); this._setDisabled(list); this.terminal.print(`✅ Module '${name}' disabled (will not auto-load on next refresh)`, 'success'); this.terminal.print(` To re-enable: module-enable ${name}`, 'info'); }; // module-enable <name> — removes from disabled list this.commands['module-enable'] = (args) => { if (!args[0]) return this.terminal.print('Usage: module-enable <name>', 'warning'); const name = args[0]; const list = this._getDisabled(); if (!list.includes(name)) { return this.terminal.print(`Module '${name}' is not disabled`, 'info'); } const newList = list.filter(n => n !== name); this._setDisabled(newList); this.terminal.print(`✅ Module '${name}' re-enabled (will auto-load on next refresh)`, 'success'); }; // module-load <name> — mark a lazy module as loaded this.commands['module-load'] = (args) => { if (!args[0]) return this.terminal.print('Usage: module-load <name>', 'warning'); const name = args[0]; const mod = this._getMod(name); if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); if (mod.loaded !== false) { return this.terminal.print(`Module '${name}' is already loaded`, 'info'); } mod.loaded = true; this.terminal.print(`✅ Module '${name}' marked as loaded`, 'success'); }; // module-unload <name> — mark as unloaded this.commands['module-unload'] = (args) => { if (!args[0]) return this.terminal.print('Usage: module-unload <name>', 'warning'); const name = args[0]; const mod = this._getMod(name); if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); if (mod.loaded === false) { return this.terminal.print(`Module '${name}' is already unloaded`, 'info'); } mod.loaded = false; this.terminal.print(`✅ Module '${name}' marked as unloaded (commands will not be available)`, 'success'); }; // module-reload <name> — re-fetch, re-parse, re-register this.commands['module-reload'] = async (args) => { if (!args[0]) return this.terminal.print('Usage: module-reload <name>', 'warning'); const name = args[0]; const mod = this._getMod(name); if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); const mm = this.terminal.moduleManager; // Find the module's URL from registry const regEntry = mm.moduleRegistry.find(m => m.name === name); const url = regEntry ? regEntry.url : null; if (!url) return this.terminal.print(`Module '${name}' has no URL — cannot reload (built-in)`, 'warning'); this.terminal.print(`🔄 Reloading '${name}' from ${url}...`, 'info'); // Unregister old instance mm.unregisterModule(name); // Re-fetch and re-register const success = await mm.loadModuleFromURL(url); if (success) { this.terminal.print(`✅ Module '${name}' reloaded successfully`, 'success'); } else { this.terminal.print(`❌ Failed to reload '${name}'`, 'error'); } }; // module-reset <name> — clear module state and reload this.commands['module-reset'] = (args) => { if (!args[0]) return this.terminal.print('Usage: module-reset <name>', 'warning'); const name = args[0]; const mod = this._getMod(name); if (!mod) return this.terminal.print(`Module not found: ${name}`, 'error'); this.terminal.print(`🔄 Resetting module '${name}' state...`, 'info'); // Unload mod.loaded = false; // Clear any state-related localStorage keys for this module let clearedKeys = 0; try { for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); // Match module-specific keys (e.g., "webx11_<module>_..." or "webx11_<module>") if (key && (key === `webx11_${name}` || key.startsWith(`webx11_${name}_`) || key.startsWith(`${name}_`))) { localStorage.removeItem(key); clearedKeys++; } } } catch (e) {} // Reload after a tick setTimeout(() => { mod.loaded = true; this.terminal.print(`✅ Module '${name}' reset (cleared ${clearedKeys} localStorage keys)`, 'success'); }, 100); }; } } /* === module-searxng.js === */ class SearXNGModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'search': 'search <query> [--summarize/-s] [--fetch/-f] [--images/-i] [--videos/-v] — Search using SearXNG; --summarize/-s adds AI overview, --fetch/-f fetches page content, --images/-i searches for images, --videos/-v searches for videos', 's': 's <query> [--summarize] [--fetch] [--images] [--videos] — Alias for search', 'images': 'images <query> — Search for images', 'videos': 'videos <query> — Search for videos', }; } registerCommands() { this.commands['search'] = (args) => this.search(args); this.commands['s'] = (args) => this.search(args); this.commands['images'] = (args) => this.search(args, 'images'); this.commands['videos'] = (args) => this.search(args, 'videos'); } async search(args, forceCategory) { if (!args[0] && !forceCategory) { this.terminal.print("Usage: search <query> [--summarize] [--fetch] [--images] [--videos]", "warning"); this.terminal.print("Example: search quantum computing --summarize", "info"); this.terminal.print("Example: search cats --images", "info"); this.terminal.print("Example: search cats --videos", "info"); this.terminal.print("Shortcuts: images <query> or videos <query>", "info"); return; } const argsList = args.slice(); const summarize = argsList.includes('--summarize') || argsList.includes('-s'); const fetchContent = argsList.includes('--fetch') || argsList.includes('-f'); const wantImages = forceCategory === 'images' || argsList.includes('--images') || argsList.includes('-i'); const wantVideos = forceCategory === 'videos' || argsList.includes('--videos') || argsList.includes('-v'); const query = argsList.filter(a => !a.startsWith('-')).join(' '); if (!query) { this.terminal.print('Search query is required', 'warning'); return; } const category = wantImages ? 'images' : (wantVideos ? 'videos' : 'general'); const categoryLabel = category === 'images' ? '🖼️' : (category === 'videos' ? '🎬' : '🔍'); this.terminal.print(`${categoryLabel} Searching ${category}: ${query}`, 'info'); if (summarize) this.terminal.print('⏳ AI overview will be generated (1-3s)...', 'info'); let params = 'q=' + encodeURIComponent(query) + '&categories=' + category; if (summarize) params += '&summarize=true'; if (fetchContent) params += '&fetch_content=true'; try { // 30s client-side timeout (matches module-fetcher) const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); const resp = await fetch('/api/search?' + params, { signal: controller.signal }); clearTimeout(timer); if (!resp.ok) { const err = await resp.json().catch(() => ({})); this.terminal.print('Search failed: ' + (err.error || 'HTTP ' + resp.status), 'error'); return; } const data = await resp.json(); const results = data.results || []; if (results.length === 0) { this.terminal.print('No results found.', 'info'); return; } this.terminal.print(`\n=== ${results.length} results (${category}, via ${data.source || 'unknown'}) ===\n`, 'success'); if (data.ai_overview) { this.terminal.print('🤖 AI Overview:', 'info'); this.terminal.print(data.ai_overview, 'ollama'); this.terminal.print(''); } results.forEach((r, i) => { const snippet = r.content || ''; const contentSnippet = r.content_snippet || ''; const imgSrc = r.img_src || r.thumbnail_src || ''; const videoThumb = r.thumbnail || ''; if (category === 'images' && imgSrc) { this.terminal.printHTML(` <div class="search-result search-result-image"> <a href="${r.url}" target="_blank"><img src="${imgSrc}" style="max-width:200px;max-height:150px;border-radius:4px;object-fit:cover;" alt="${r.title || ''}" loading="lazy"></a> <br/><strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong> </div> `); } else if (category === 'videos' && videoThumb) { this.terminal.printHTML(` <div class="search-result search-result-video"> <a href="${r.url}" target="_blank"><img src="${videoThumb}" style="max-width:200px;max-height:150px;border-radius:4px;object-fit:cover;" alt="${r.title || ''}" loading="lazy"></a> <br/><strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong><br/> <span style="color: #888888; font-size: 0.85em;">${r.url || ''}</span> </div> `); } else { this.terminal.printHTML(` <div class="search-result"> <strong>${i + 1}. <a href="${r.url}" target="_blank" style="color: #00aaff;">${r.title || 'No Title'}</a></strong><br/> <span style="color: #888888; font-size: 0.85em;">${r.url || 'No URL'}</span><br/> <span style="color: #00ff00; font-size: 0.85em;">${(snippet || '').substring(0, 200)}...</span> ${contentSnippet ? `<br/><span style="color: #aaaaaa; font-size: 0.8em;">📄 ${contentSnippet.substring(0, 150)}...</span>` : ''} ${imgSrc && category === 'general' ? `<br/><a href="${imgSrc}" target="_blank" style="color: #ff8800; font-size: 0.8em;">🖼️ View image</a>` : ''} </div> `); } }); } catch (e) { if (e.name === 'AbortError') { this.terminal.print('⏱ Search timed out (30s). Try removing --summarize or --fetch flags.', 'error'); } else { this.terminal.print('Search failed: ' + e.message, 'error'); } } } } /* === module-servermon.js === */ // module-servermon.js — Server monitoring + self-optimization telemetry // Commands: server, servers, server-check, server-remove, monitor, system-health class ServerMonitorModule { constructor() { this.commands = {}; this.terminal = null; this.servers = this.loadServers(); this.monitorInterval = null; this._latencyLog = []; this._maxLatencyEntries = 200; } getHelpInfo() { return { 'server': 'server <name> <url> — Add a server to monitor', 'servers': 'servers — List all monitored servers', 'server-check': 'server-check <name-or-index> — Check a server status', 'server-remove': 'server-remove <name-or-index> — Remove a server', 'monitor': 'monitor — Toggle auto-monitoring (checks every 60s)', 'system-health': 'system-health — Show tool latency, module load times, VFS size, and error rates', 'test-net': 'test-net — Check proxy API, Ollama, and internet connectivity', }; } registerCommands() { this.commands['server'] = (args) => this.addServer(args); this.commands['servers'] = () => this.listServers(); this.commands['server-check'] = (args) => this.checkServer(args); this.commands['server-remove'] = (args) => this.removeServer(args); this.commands['monitor'] = (args) => this.toggleMonitor(args); this.commands['system-health'] = () => this.showSystemHealth(); // v4.5.43: test-net — check proxy/ollama/internet connectivity this.commands['test-net'] = () => this.testNetwork(); } async testNetwork() { this.terminal.print('🌐 Network Test', 'header'); // 1. Browser online const online = navigator.onLine; this.terminal.print(` ${online ? '✅' : '🔴'} Browser: ${online ? 'online' : 'offline'}`, online ? 'success' : 'error'); // 2. Proxy API try { const t0 = Date.now(); const resp = await fetch('/api/stats', { signal: AbortSignal.timeout(5000) }); const ms = Date.now() - t0; if (resp.ok) { const data = await resp.json(); this.terminal.print(` ✅ Proxy: ok (${ms}ms, uptime ${data.uptime_seconds || '?'}s)`, 'success'); } else { this.terminal.print(` 🔴 Proxy: HTTP ${resp.status}`, 'error'); } } catch (e) { this.terminal.print(` 🔴 Proxy: ${e.message || 'unreachable'}`, 'error'); } // 3. Ollama const ollamaOk = this.terminal.ollamaManager && this.terminal.ollamaManager.connected; this.terminal.print(` ${ollamaOk ? '✅' : '🟡'} Ollama: ${ollamaOk ? 'connected' : 'disconnected'}`, ollamaOk ? 'success' : 'warning'); // 4. Internet (test by fetching a small file) try { const t0 = Date.now(); const resp = await fetch('https://duckduckgo.com/?q=test', { method: 'HEAD', signal: AbortSignal.timeout(5000), mode: 'no-cors' }); const ms = Date.now() - t0; this.terminal.print(` ✅ Internet: reachable (${ms}ms)`, 'success'); } catch (e) { this.terminal.print(` 🔴 Internet: ${e.message || 'unreachable'}`, 'error'); } } logToolCall(name, durationMs, error) { this._latencyLog.push({ name, duration: durationMs, error: !!error, time: Date.now() }); if (this._latencyLog.length > this._maxLatencyEntries) { this._latencyLog = this._latencyLog.slice(-this._maxLatencyEntries); } // v4.5.66: Throttled persistence — every 100 calls or 5 minutes (was every 20) if (this._latencyLog.length % 100 === 0 || (Date.now() - (this._lastPersistTs || 0)) > 300000) { this._persistLatency(); this._lastPersistTs = Date.now(); } } _persistLatency() { try { this.terminal.fs.fsWriteJSON('/system/metrics/tool-latency.json', this._latencyLog.slice(-100)); } catch (e) {} } _loadLatency() { try { const data = this.terminal.fs.fsReadJSON('/system/metrics/tool-latency.json'); if (data) this._latencyLog = data; } catch (e) {} } showSystemHealth() { this._loadLatency(); const fs = this.terminal.fs; // Tool latency stats const toolStats = {}; for (const entry of this._latencyLog) { if (!toolStats[entry.name]) toolStats[entry.name] = { calls: 0, totalMs: 0, errors: 0, maxMs: 0 }; toolStats[entry.name].calls++; toolStats[entry.name].totalMs += entry.duration; toolStats[entry.name].errors += entry.error ? 1 : 0; toolStats[entry.name].maxMs = Math.max(toolStats[entry.name].maxMs, entry.duration); } this.terminal.print('📊 System Health', 'header'); // Tool latency table (sorted by avg desc) this.terminal.print('── Tool Latency ──', 'info'); const sorted = Object.entries(toolStats) .map(([name, s]) => ({ name, avg: (s.totalMs / s.calls).toFixed(0), ...s })) .sort((a, b) => b.avg - a.avg); for (const t of sorted.slice(0, 10)) { const avgMs = t.avg.padStart(5); const maxMs = String(t.maxMs).padStart(5); const err = t.errors > 0 ? ` ⚠️${t.errors}` : ''; this.terminal.print(` ${avgMs}ms avg / ${maxMs}ms max — ${t.name} (${t.calls} calls)${err}`, t.errors > 0 ? 'warning' : ''); } if (sorted.length > 10) this.terminal.print(` ... and ${sorted.length - 10} more tools`, 'info'); // VFS stats this.terminal.print('── VFS Stats ──', 'info'); try { const totalSize = this._vfsSize(fs.root); const fileCount = this._vfsCount(fs.root, 'file'); const dirCount = this._vfsCount(fs.root, 'dir'); this.terminal.print(` ${fileCount} files, ${dirCount} dirs, ${(totalSize / 1024).toFixed(0)} KB total`, ''); } catch (e) { this.terminal.print(' VFS stats unavailable', 'warning'); } // Module stats this.terminal.print('── Module Stats ──', 'info'); const mm = this.terminal.moduleManager; const loaded = Object.keys(mm.modules).length; const reg = (mm.moduleRegistry || []).length; this.terminal.print(` ${loaded} modules loaded, ${reg} in registry`, ''); // Memory this.terminal.print('── Memory ──', 'info'); if (performance.memory) { const heap = (performance.memory.usedJSHeapSize / 1048576).toFixed(1); const total = (performance.memory.jsHeapSizeLimit / 1048576).toFixed(0); this.terminal.print(` ${heap} MB used / ${total} MB limit`, heap > 100 ? 'warning' : ''); } else { this.terminal.print(' Memory info not available (non-Chrome browser)', ''); } // Total tool call count const totalCalls = this._latencyLog.length; const totalErrors = this._latencyLog.filter(e => e.error).length; this.terminal.print(` ${totalCalls} tool calls logged, ${totalErrors} errors (${totalCalls > 0 ? (totalErrors / totalCalls * 100).toFixed(1) : 0}% error rate)`, totalErrors > 0 ? 'warning' : ''); } _vfsSize(node) { if (!node) return 0; if (node.type === 'file') return (node.content || '').length; let s = 0; for (const k in (node.content || {})) s += this._vfsSize(node.content[k]); return s; } _vfsCount(node, type) { if (!node) return 0; let c = 0; for (const k in (node.content || {})) { const child = node.content[k]; if (child.type === type) c++; c += this._vfsCount(child, type); } return c; } loadServers() { try { const saved = localStorage.getItem('webx11_servers'); return saved ? JSON.parse(saved) : []; } catch (e) { return []; } } saveServers() { localStorage.setItem('webx11_servers', JSON.stringify(this.servers)); } addServer(args) { if (args.length < 2) { this.terminal.print("Usage: server <name> <url>", "warning"); return; } const name = args[0], url = args[1]; this.servers.push({ name, url, status: 'unknown', lastCheck: null }); this.saveServers(); this.terminal.print(`Server '${name}' added`, "success"); } listServers() { if (this.servers.length === 0) { this.terminal.print("No servers configured", "info"); return; } this.terminal.print("=== Monitored Servers ===", "info"); this.servers.forEach((s, i) => { const statusIcon = s.status === 'online' ? '🟢' : s.status === 'offline' ? '🔴' : '⚪'; this.terminal.print(`${i + 1}. ${statusIcon} ${s.name} - ${s.url}`); if (s.lastCheck) { this.terminal.print(` Last check: ${s.lastCheck}`, "info"); } }); } async checkServer(args) { if (!args[0]) { this.terminal.print("Usage: server-check <name-or-index>", "warning"); return; } const server = this.findServer(args[0]); if (!server) { this.terminal.print("Server not found", "error"); return; } this.terminal.print(`Checking ${server.name}...`, "info"); try { const start = Date.now(); const response = await fetch(server.url, { method: 'HEAD', mode: 'no-cors' }); const latency = Date.now() - start; server.status = 'online'; server.lastCheck = new Date().toLocaleString(); server.latency = latency; this.saveServers(); this.terminal.print(`✅ ${server.name} is online (${latency}ms)`, "success"); } catch (e) { server.status = 'offline'; server.lastCheck = new Date().toLocaleString(); this.saveServers(); this.terminal.print(`❌ ${server.name} is offline`, "error"); } } removeServer(args) { if (!args[0]) { this.terminal.print("Usage: server-remove <name-or-index>", "warning"); return; } const index = this.findServerIndex(args[0]); if (index === -1) { this.terminal.print("Server not found", "error"); return; } this.servers.splice(index, 1); this.saveServers(); this.terminal.print("Server removed", "success"); } toggleMonitor(args) { if (this.monitorInterval) { clearInterval(this.monitorInterval); this.monitorInterval = null; this.terminal.print("Auto-monitor stopped", "warning"); } else { this.terminal.print("Auto-monitor started (every 60s)", "success"); this.monitorInterval = setInterval(() => { this.servers.forEach((s, i) => { this.checkServer([String(i + 1)]); }); }, 60000); } } findServer(identifier) { const index = this.findServerIndex(identifier); return index !== -1 ? this.servers[index] : null; } findServerIndex(identifier) { const idx = parseInt(identifier) - 1; if (!isNaN(idx) && idx >= 0 && idx < this.servers.length) { return idx; } return this.servers.findIndex(s => s.name.toLowerCase() === identifier.toLowerCase()); } } /* === module-config.js === */ // Extracted from newOS4 index.html — ConfigManagerModule class ConfigManagerModule { constructor() { this.commands = {}; this.terminal = null; this.configPrefix = 'webx11_config_'; } getHelpInfo() { return { 'config': 'config [key] — Show all config or a specific key', 'config-set': 'config-set <key> <value> — Set a configuration value', 'config-list': 'config-list — List all configuration keys', 'config-delete': 'config-delete <key> — Delete a configuration key', 'config-export': 'config-export — Export all config as JSON', 'config-reset': 'config-reset — Reset all configuration to defaults', }; } registerCommands() { this.commands['config'] = (args) => this.getConfig(args); this.commands['config-set'] = (args) => this.setConfig(args); this.commands['config-list'] = () => this.listConfig(); this.commands['config-delete'] = (args) => this.deleteConfig(args); this.commands['config-export'] = () => this.exportConfig(); this.commands['config-reset'] = () => this.resetConfig(); } getConfig(args) { if (!args[0]) { this.terminal.print("Usage: config <key>", "warning"); return; } const value = localStorage.getItem(this.configPrefix + args[0]); if (value) { this.terminal.print(`${args[0]}: ${value}`, "info"); } else { this.terminal.print(`Key '${args[0]}' not found`, "error"); } } setConfig(args) { if (args.length < 2) { this.terminal.print("Usage: config-set <key> <value>", "warning"); return; } const key = args[0], value = args.slice(1).join(' '); localStorage.setItem(this.configPrefix + key, value); this.terminal.print(`Config saved: ${key} = ${value}`, "success"); } listConfig() { const configs = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(this.configPrefix)) { configs[key.replace(this.configPrefix, '')] = localStorage.getItem(key); } } if (Object.keys(configs).length === 0) { this.terminal.print("No configuration saved", "info"); return; } this.terminal.print("=== Configuration ===", "info"); Object.entries(configs).forEach(([k, v]) => { this.terminal.print(`${k}: ${v}`); }); } deleteConfig(args) { if (!args[0]) { this.terminal.print("Usage: config-delete <key>", "warning"); return; } localStorage.removeItem(this.configPrefix + args[0]); this.terminal.print(`Config '${args[0]}' deleted`, "success"); } exportConfig() { const configs = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(this.configPrefix)) { configs[key] = localStorage.getItem(key); } } const json = JSON.stringify(configs, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'webx11-config.json'; a.click(); URL.revokeObjectURL(url); this.terminal.print("Configuration exported", "success"); } resetConfig() { if (confirm("Reset all configuration to defaults?")) { const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(this.configPrefix)) { keysToRemove.push(key); } } keysToRemove.forEach(k => localStorage.removeItem(k)); this.terminal.print("Configuration reset to defaults", "success"); } } } /* === module-simplegui.js === */ // Extracted from newOS4 index.html — SimpleGUIModule class SimpleGUIModule { constructor() { this.commands = {}; this.terminal = null; this.guiElements = {}; } getHelpInfo() { return { 'gui': 'gui — Show the GUI builder overview', 'gui-button': 'gui-button <label> <command> — Add a clickable button', 'gui-input': 'gui-input <placeholder> <varName> — Add a text input field', 'gui-text': 'gui-text <content> — Add a text element', 'gui-clear': 'gui-clear — Clear all GUI elements', 'gui-run': 'gui-run <command> — Run a terminal command from the GUI' }; } registerCommands() { this.commands['gui'] = () => this.showGUI(); this.commands['gui-button'] = (args) => this.createButton(args); this.commands['gui-input'] = (args) => this.createInput(args); this.commands['gui-text'] = (args) => this.createText(args); this.commands['gui-clear'] = () => this.clearGUI(); this.commands['gui-run'] = (args) => this.runCommand(args); } showGUI() { this.terminal.print("=== Simple GUI Builder ===", "info"); this.terminal.print("Commands: gui-button, gui-input, gui-text, gui-clear", "info"); } createButton(args) { if (args.length < 2) { this.terminal.print("Usage: gui-button <label> <command>", "warning"); return; } const label = args[0], command = args.slice(1).join(' '); const id = 'gui-btn-' + Date.now(); // v4.5.68: Use addEventListener instead of onclick attribute to prevent quote-breaking XSS this.terminal.printHTML(`<div style="margin: 5px 0;"> <button class="qa-btn" id="${id}">${this._escapeHtml(label)}</button> </div>`); const btn = document.getElementById(id); if (btn) { btn.addEventListener('click', () => { (window.webx11 || window).executeCommand(command); }); } this.guiElements[id] = { type: 'button', label, command }; this.terminal.print(`Button '${label}' created`, "success"); } _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } createInput(args) { if (args.length < 2) { this.terminal.print("Usage: gui-input <placeholder> <varName>", "warning"); return; } const placeholder = args[0], varName = args[1], id = 'gui-input-' + Date.now(); this.terminal.printHTML(`<div style="margin: 5px 0;"> <input type="text" id="${id}" placeholder="${placeholder}" style="background: #0a0a0a; border: 1px solid #005500; color: #00ff00; padding: 8px; width: 100%;" /> </div>`); this.guiElements[id] = { type: 'input', placeholder, varName }; this.terminal.print(`Input '${placeholder}' created (var: ${varName})`, "success"); } createText(args) { if (!args[0]) { this.terminal.print("Usage: gui-text <content>", "warning"); return; } const content = args.join(' '); this.terminal.printHTML(`<div style="margin: 5px 0; padding: 8px; background: #111111; border-left: 3px solid #00aaff;">${content}</div>`); this.terminal.print("Text element created", "success"); } clearGUI() { this.guiElements = {}; this.terminal.print("GUI elements cleared", "success"); } runCommand(args) { if (!args[0]) { this.terminal.print("Usage: gui-run <command>", "warning"); return; } this.terminal.executeCommand(args.join(' ')); } } /* === module-aiassist.js === */ // Extracted from newOS4 index.html — WebX11AIAssistantModule (Full Agent Loop) class WebX11AIAssistantModule { constructor() { this.commands = {}; this.commandsMeta = { 'ai-config': { argType: 'enum', enumFn: '_getAiConfigKeys' } }; this.terminal = null; this.systemPrompt = this.buildSystemPrompt(); this.context = []; this.maxContext = 10; this._sessions = new Map(); // agentTag -> {context, profile}; 'main' aliases this.context this._toolCache = new Map(); // D2: key -> {result, ts, epoch} this._toolEpoch = 0; this._mutatingTools = new Set(['write_file', 'edit_module', 'run_command', 'delete_file', 'make_directory', 'commit_module', 'ship_module', 'patch_skill', 'delegate_task', 'reload_module', 'create_skill', 'create_mindmap']); // Only deterministic reads are cached — never status/log/polling tools. this._cacheableTools = new Set(['read_file', 'list_files', 'list_modules', 'view_module', 'nvfs_resolve', 'get_help', 'list_skills', 'diff_module', 'get_module_status']); this.maxContextTokens = 12000; this.MAX_TOOL_TURNS = 8; this._pendingConfirmation = null; this._confirmResolve = null; this._errorLog = []; this._commandFreq = {}; this._messageCount = 0; this._userProfile = {}; this._sessionMemoryLoaded = false; this._sessionSaved = false; this._thinkingTimer = null; this._thinkingStartTime = 0; this._validatorSuppressed = false; this._toolDefs = null; this._toolCallLog = []; this._debugPanelEnabled = false; this._lastRawResponse = null; this._lastRawMessages = null; // 🔒 Guardrail Layer this._guardrailPolicy = null; this._shadowSnapshot = null; this._guardrailViolations = {}; this._confirmTimeoutMs = 120000; this._skillDebounceMs = 1800000; this._chatLockTimeoutMs = 10000; this._loadGuardrailPolicy(); // ── Agent Loop Profile System ── this._activeLoopProfile = null; this._loopProfiles = {}; this._initLoopProfiles(); // Restore AI config from localStorage (maxContext, MAX_TOOL_TURNS, etc.) try { const cfg = localStorage.getItem('webx11_ai_config'); if (cfg) { const p = JSON.parse(cfg); if (p.maxTurns) this.MAX_TOOL_TURNS = p.maxTurns; if (p.maxContext) this.maxContext = p.maxContext; if (p.maxContextTokens) this.maxContextTokens = p.maxContextTokens; if (p.confirmTimeoutMs) this._confirmTimeoutMs = p.confirmTimeoutMs; if (p.skillDebounceMs) this._skillDebounceMs = p.skillDebounceMs; if (p.chatLockTimeoutMs) this._chatLockTimeoutMs = p.chatLockTimeoutMs; } } catch (e) {} try { const s = localStorage.getItem('webx11_ai_patterns'); if (s) this._commandFreq = JSON.parse(s); } catch (e) {} try { const p = localStorage.getItem('webx11_user_profile'); if (p) this._userProfile = JSON.parse(p); } catch (e) {} // Restore AI conversation context from localStorage try { const saved = localStorage.getItem('webx11_ai_context'); if (saved) { const parsed = JSON.parse(saved); if (parsed && Array.isArray(parsed.context)) { this.context = parsed.context; this._messageCount = parsed.messageCount || 0; } } } catch (e) {} } buildSystemPrompt() { // Check for user-overridden prompt in localStorage try { const saved = localStorage.getItem('webx11_ai_system_prompt'); if (saved) { const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); const dayStr = now.toLocaleDateString('en-US', { weekday: 'long' }); const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); return `Current date: ${dateStr} (${dayStr}) ${timeStr}\n\n${saved}`; } } catch (e) {} // Check SmartContextModule for active profile system prompt let profilePrompt = ''; try { const scm = this.terminal?.moduleManager?.modules?.SmartContextModule; if (scm && scm.activeProfile && scm.contextProfiles[scm.activeProfile]) { profilePrompt = scm.contextProfiles[scm.activeProfile].systemPrompt || ''; } } catch (e) {} const now = new Date(); const dateStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); const dayStr = now.toLocaleDateString('en-US', { weekday: 'long' }); const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const nvfs = this.terminal?.moduleManager?.modules?.NeuralVFSModule; let nvfsNote = ''; try { if (nvfs && nvfs.lastScan && nvfs.lastScan !== '(loaded from disk)') { const ageMs = Date.now() - new Date(nvfs.lastScan).getTime(); if (ageMs > 14400000) nvfsNote = ' |nvfs ' + Math.floor(ageMs/3600000) + 'h stale'; } } catch (e) {} let base = `Current date: ${dateStr} (${dayStr}) ${timeStr}${nvfsNote}\n` + 'VFS→Modules→AI. nvfs-index for commands. Tool→Observe→Answer. nvfs-resolve. Destructive cmds need confirm.\n' + 'FS: list/read/write/delete | NVFS: nvfs-resolve | Web: search/fetch [POST=method+body] | Mod: view/edit/test/reload | Config | Skills | VaultMind | wallet-lookup | get_usage_stats\\n' + 'web_fetch: GET for pages, POST with method+body for JSON APIs. JS: node <file.js> or node -e "expr" (browser JS only).\n' + 'Other: floating, webdav, cron, vault, sandbox, agents, wpm, mindmap, memgraph, world-model, predict, guardrail, tar, secret-* — via help.\n' + 'predict_task: estimate duration before complex tasks. world_model_query: read the system snapshot (modules, files, agents, telemetry, recent errors) and answer from it.'; if (profilePrompt) { base = profilePrompt + '\n\n' + base; } // Apply agent loop profile prepend/append directives var lp = this._activeLoopProfile; if (lp && lp.systemPrompt) { if (lp.systemPrompt.prepend) base = lp.systemPrompt.prepend + '\n\n' + base; if (lp.systemPrompt.append) base = base + '\n\n' + lp.systemPrompt.append; } // Inject auto-fix suggestions from DebugSupervisorModule try { var ds = this.terminal?.moduleManager?.modules?.DebugSupervisorModule; if (ds && this._autoFixSuggestions && this._autoFixSuggestions.length > 0) { base += '\n\n[Auto-Fix Suggestions]\n'; for (var fi = 0; fi < this._autoFixSuggestions.length; fi++) { var s = this._autoFixSuggestions[fi]; base += '- ' + s.module + ': ' + s.suggestion + ' (' + s.reason + ')\n'; } base += '[/Auto-Fix Suggestions]'; } } catch (e) {} base += '\n\n[File reads, tools, verification]\n' + '- read_file returns 8000 chars by default (limit up to 16000). If the reply says truncated=true, call read_file again with offset=<next_offset> before concluding anything about the file.\n' + '- Prefer targeted reads over whole files: run_command with grep "<pattern>" <file> (prints line: text), sed -n \'10,40p\' <file>, head -40 <file>, tail -n 40 <file>, wc -l <file>. Flags are tolerated, but plain `cat <file>` and read_file offset/limit always work.\n' + '- sed without -i is read-only and allowed; sed -i, rm, mv and writes need user confirmation.\n' + '- After ANY write or edit: re-read the changed lines (cat or sed -n) and run a check that exists in THIS terminal — `test` for the suite, `grep -n` to confirm the new line, `wc -l` for size. There is no shell, no node and no curl here.\n' + '- If a tool fails twice, change approach (different tool, smaller scope) instead of retrying the same call.'; return base; } getToolDefinitions() { if (!this._toolDefs) this._toolDefs = this._buildToolDefs(); return this._filterToolsByProfile(this._toolDefs); } _filterToolsByProfile(allTools) { var p = this._activeLoopProfile; if (!p || !p.tools) return allTools; if (p.tools.mode === 'all') { if (p.tools.disabled && p.tools.disabled.length > 0) { return allTools.filter(function(t) { return p.tools.disabled.indexOf(t.function.name) === -1; }); } return allTools; } if (p.tools.mode === 'custom' && p.tools.enabled && p.tools.enabled.length > 0) { return allTools.filter(function(t) { return p.tools.enabled.indexOf(t.function.name) !== -1; }); } return allTools; } _buildToolDefs() { return [{ type: 'function', function: { name: 'list_files', description: 'List VFS directory contents', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Dir path ("" for root)' } }, required: [] } } }, { type: 'function', function: { name: 'read_file', description: 'Read a VFS file. Large files come back in pages: if the reply has truncated=true, call again with offset=<next_offset> instead of guessing or giving up. Returns JSON {path, offset, returned, total_chars, truncated, next_offset, content}.', parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, offset: { type: 'integer', description: 'Start character offset (default 0)' }, limit: { type: 'integer', description: 'Max characters to return (default 8000, max 32000)' } }, required: ['path'] } } }, { type: 'function', function: { name: 'write_file', description: 'Write file (DESTRUCTIVE)', parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path' }, content: { type: 'string', description: 'Content' } }, required: ['path', 'content'] } } }, { type: 'function', function: { name: 'make_directory', description: 'Create VFS directory', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Dir name' } }, required: ['path'] } } }, { type: 'function', function: { name: 'delete_file', description: 'Delete file or empty dir (DESTRUCTIVE)', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' } }, required: ['path'] } } }, { type: 'function', function: { name: 'get_status', description: 'System status: FS, modules, memory', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'get_dashboard_data', description: 'Full dashboard telemetry: modules, agents, memory, VFS, Ollama, token usage, tool latency, system health', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'get_help', description: 'Command reference', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'web_search', description: 'Search web via SearXNG + DDG', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query' } }, required: ['query'] } } }, { type: 'function', function: { name: 'web_fetch', description: 'Fetch & extract URL content. Supports GET (default) and POST (for APIs like Hyperliquid /info).', parameters: { type: 'object', properties: { url: { type: 'string', description: 'Full URL' }, method: { type: 'string', enum: ['GET', 'POST'], description: 'HTTP method (default GET)' }, body: { type: 'string', description: 'Request body for POST (JSON string, max 10KB)' }, content_type: { type: 'string', description: 'Content-Type header (default application/json for POST)' } }, required: ['url'] } } }, { type: 'function', function: { name: 'wpm_install', description: 'Install WPM package', parameters: { type: 'object', properties: { package: { type: 'string', description: 'Package name' } }, required: ['package'] } } }, { type: 'function', function: { name: 'wpm_search', description: 'Search WPM packages', parameters: { type: 'object', properties: { keyword: { type: 'string', description: 'Keyword' } }, required: ['keyword'] } } }, { type: 'function', function: { name: 'run_command', description: 'Run terminal command (safe-read commands auto-approved; mutating ones require confirmation)', parameters: { type: 'object', properties: { command: { type: 'string', description: 'Command to run' } }, required: ['command'] } } }, { type: 'function', function: { name: 'get_current_dir', description: 'Current VFS directory', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'view_module', description: 'View module source code', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } } }, { type: 'function', function: { name: 'edit_module', description: 'Edit module source (DESTRUCTIVE). If code is provided, writes directly to VFS and auto-tests. If no code, opens the visual editor (requires manual Save).', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' }, code: { type: 'string', description: 'Full new source code. When provided, writes directly and auto-tests without opening the editor.' } }, required: ['name'] } } }, { type: 'function', function: { name: 'test_module', description: 'Test an edited module: loads it, checks JS errors, verifies commands registered, runs optional test assertions. Auto-rolls-back on failure.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' }, tests: { type: 'array', description: 'Optional test assertions: [{command, expect}]', items: { type: 'object', properties: { command: { type: 'string' }, expect: { type: 'string' } } } } }, required: ['name'] } } }, { type: 'function', function: { name: 'reload_module', description: 'Reload module from original URL', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } } }, { type: 'function', function: { name: 'list_modified_modules', description: 'List modules with local edits', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'get_module_status', description: 'Module status: loaded, modified, URLs', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'load_skills', description: 'Search skills relevant to task', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Task description' } }, required: ['query'] } } }, { type: 'function', function: { name: 'create_skill', description: 'Save a new skill', parameters: { type: 'object', properties: { name: { type: 'string', description: 'kebab-case name' }, description: { type: 'string', description: 'One-line description' }, content: { type: 'string', description: 'Markdown with YAML frontmatter' }, tags: { type: 'string', description: 'Comma-separated' }, category: { type: 'string', description: 'e.g. research, system, dev' } }, required: ['name', 'description', 'content'] } } }, { type: 'function', function: { name: 'patch_skill', description: 'Replace skill content', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Skill name' }, content: { type: 'string', description: 'New markdown content' } }, required: ['name', 'content'] } } }, { type: 'function', function: { name: 'list_skills', description: 'Browse skills (optional filter)', parameters: { type: 'object', properties: { category: { type: 'string', description: 'Filter by category' } }, required: [] } } }, { type: 'function', function: { name: 'check_updates', description: 'Check module URLs are reachable', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'user_get', description: 'Read user profile key', parameters: { type: 'object', properties: { key: { type: 'string', description: 'e.g. preferredModel' } }, required: ['key'] } } }, { type: 'function', function: { name: 'user_set', description: 'Write user profile key', parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key' }, value: { type: 'string', description: 'Value' } }, required: ['key', 'value'] } } }, { type: 'function', function: { name: 'user_list', description: 'List stored profile keys', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'user_forget', description: 'Delete profile key', parameters: { type: 'object', properties: { key: { type: 'string', description: 'Key to delete' } }, required: ['key'] } } }, { type: 'function', function: { name: 'session_save', description: 'Save session summary', parameters: { type: 'object', properties: { summary: { type: 'string', description: 'What was accomplished' } }, required: ['summary'] } } }, { type: 'function', function: { name: 'session_load', description: 'Load past session by date', parameters: { type: 'object', properties: { date: { type: 'string', description: 'YYYY-MM-DD' } }, required: ['date'] } } }, { type: 'function', function: { name: 'session_recent', description: 'Show recent sessions', parameters: { type: 'object', properties: { count: { type: 'number', description: 'Count (default 3)' } }, required: [] } } }, { type: 'function', function: { name: 'session_terminal', description: 'Read last terminal output', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'session_stats', description: 'Session aggregate stats', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'list_agents', description: 'List registered agent IDs, status, and capabilities', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'delegate_task', description: '(DESTRUCTIVE) Delegate a task to a worker agent. Auto-spawns if needed.', parameters: { type: 'object', properties: { task: { type: 'string', description: 'The task description to delegate' } }, required: ['task'] } } }, { type: 'function', function: { name: 'chat_hermes', description: 'Send a message to Hermes, the platform operator agent, and read replies. Use when a task needs a decision only the operator can make: policy, a deploy approval, scope. Replies are asynchronous — call with action "read" to check for an answer.', parameters: { type: 'object', properties: { action: { type: 'string', enum: ['send', 'read'], description: 'send a message, or read replies' }, text: { type: 'string', description: 'message body, required for send' } }, required: ['action'] } } }, { type: 'function', function: { name: 'spawn_agent', description: '(DESTRUCTIVE) Spawn a new worker agent with a role and capabilities', parameters: { type: 'object', properties: { role: { type: 'string', description: 'Agent role (e.g. researcher, writer)' }, capabilities: { type: 'string', description: 'Comma-separated capabilities (optional)' } }, required: ['role'] } } }, { type: 'function', function: { name: 'nvfs_resolve', description: 'Search VFS files by semantic concept using Neural VFS index. Call nvfs-index first to build the index.', parameters: { type: 'object', properties: { concept: { type: 'string', description: 'Concept to search (e.g. skill, memory, module, webdav, security)' } }, required: ['concept'] } } }, { type: 'function', function: { name: 'list_available_modules', description: 'List all available modules with metadata (core/ext, commands, class name, layer). Filter by type with type=core or type=ext.', parameters: { type: 'object', properties: { type: { type: 'string', description: 'Optional: filter to "core" or "ext"' } }, required: [] } } }, { type: 'function', function: { name: 'smart_context_list', description: 'List available AI context profiles (e.g. coding, debug, creative, technical, chat, system-admin, research, security). Each profile sets the AI system prompt, temperature, and behavior for a specific task type. Also switches the agent loop profile (max turns, tool set) to match. Returns profile names, descriptions, and system prompts. Use this when the user task has a clear domain (coding, debugging, research, etc.) to optimize AI behavior.', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'smart_context_set', description: "Set the active AI context profile. Changes the AI system prompt, temperature, and behavior for subsequent responses. Also switches the agent loop profile (max turns, tool set) to match. Available profiles: coding (code editing), debug (bug fixing), creative (brainstorming), technical (documentation), chat (casual), system-admin (maintenance), research (information gathering), security (threat assessment). Use this when the user task changes domain.", parameters: { type: 'object', properties: { name: { type: 'string', description: 'Profile name (e.g. "coding", "debug", "creative", "technical", "chat", "system-admin", "research", "security")' } }, required: ['name'] } } }, { type: 'function', function: { name: 'create_mindmap', description: 'Create a mindmap with the given topic and child nodes, then render it as inline SVG in the terminal. Creates root and all children in one call. Use this when the user asks for a mindmap or visual architecture.', parameters: { type: 'object', properties: { topic: { type: 'string', description: 'Root topic of the mindmap (e.g. "WebX11 Architecture")' }, children: { type: 'string', description: 'Comma-separated list of child node names (e.g. "AI Engine, VFS System, Modules")' } }, required: ['topic'] } } }, { type: 'function', function: { name: 'vaultmind_query', description: "Query VaultMind AI for Hyperliquid market data (prices, portfolio, transactions, copy-trade) or ask an AI agent. Uses same-origin /vmai-api/ API.", parameters: { type: 'object', properties: { endpoint: { type: 'string', description: 'prices, portfolio, transactions, copy-trade, or chat' }, message: { type: 'string', description: 'Question for the AI agent (chat endpoint only)' }, agent: { type: 'string', enum: ['market', 'trading', 'transaction', 'copy'], description: 'Which AI agent to query (chat endpoint only)' } }, required: ['endpoint'] } } }, { type: 'function', function: { name: 'vaultmind_connect', description: 'Connect to VaultMind AI by reading Hyperliquid private key from SecretVault (stored as "hyperliquid_key") and authenticating with the backend. Stores session token in localStorage.', parameters: { type: 'object', properties: { network: { type: 'string', enum: ['testnet', 'mainnet'], description: 'Hyperliquid network (default testnet)' } }, required: [] } } }, { type: 'function', function: { name: 'vaultmind_status', description: 'Check if VaultMind AI session token exists and is valid in localStorage.', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'wallet_lookup', description: 'Read-only Hyperliquid wallet lookup — returns portfolio, positions, and account value for any wallet address. No auth needed.', parameters: { type: 'object', properties: { address: { type: 'string', description: 'Hyperliquid wallet address (0x...)' } }, required: ['address'] } } }, { type: 'function', function: { name: 'get_usage_stats', description: 'Get LLM usage statistics: total requests, tokens, latency, per-model breakdown. Use this to report token consumption or latency to the user.', parameters: { type: 'object', properties: { model: { type: 'string', description: 'Filter by model name (optional)' }, provider: { type: 'string', description: 'Filter by provider name (optional)' }, since_hours: { type: 'number', description: 'Only last N hours (optional)' } }, required: [] } } }, { type: 'function', function: { name: 'agent_loop_list', description: 'List all available agent loop profiles with their settings and benchmark results.', parameters: { type: 'object', properties: {}, required: [] } } }, { type: 'function', function: { name: 'agent_loop_switch', description: 'Switch to a named agent loop profile. Changes tools, max turns, system prompt directives, and loop behavior.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Profile name to switch to' } }, required: ['name'] } } }, { type: 'function', function: { name: 'agent_loop_create', description: 'Create a new agent loop profile with custom settings (tools, max turns, temperature, system prompt directives).', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Profile name (lowercase, hyphens)' }, description: { type: 'string', description: 'Profile description' }, maxTurns: { type: 'number', description: 'Max tool turns (default: 8)' }, toolsMode: { type: 'string', description: 'all or custom', enum: ['all', 'custom'] }, toolsEnabled: { type: 'array', items: { type: 'string' }, description: 'Tool names to enable (if mode=custom)' }, toolsDisabled: { type: 'array', items: { type: 'string' }, description: 'Tool names to disable (if mode=all)' }, prependPrompt: { type: 'string', description: 'Directive to prepend to system prompt' }, appendPrompt: { type: 'string', description: 'Directive to append to system prompt' } }, required: ['name'] } } }, { type: 'function', function: { name: 'agent_loop_benchmark', description: 'Run the benchmark suite on a specified agent loop profile. Returns task results with latency, tokens, and pass/fail.', parameters: { type: 'object', properties: { profile: { type: 'string', description: 'Profile name to benchmark (default: active)' }, tasks: { type: 'array', items: { type: 'string' }, description: 'Specific task IDs to run (default: all)' } }, required: [] } } }, { type: 'function', function: { name: 'predict_task', description: 'Predict task duration, tool turns, and confidence based on historical execution data. Use before starting complex tasks to estimate resource needs.', parameters: { type: 'object', properties: { task: { type: 'string', description: 'Task description to predict' } }, required: ['task'] } } }, { type: 'function', function: { name: 'world_model_query', description: 'Read the synthesized system world model (modules, files, skills, agents, memory graph, telemetry, recent changes). Returns a JSON snapshot you can reason over; `facts` additionally carries the instant keyword answer. Ask detail:"full" for the complete model (~3 KB).', parameters: { type: 'object', properties: { question: { type: 'string', description: 'What you want to know (used for the fast facts line)' }, detail: { type: 'string', description: '"digest" (default) or "full"' } }, required: ['question'] } } }, { type: 'function', function: { name: 'health_check', description: 'Run a system health check after a module edit. Verifies: no JS errors, all core commands work, world model is valid, agents are alive.', parameters: { type: 'object', properties: {} } } }, { type: 'function', function: { name: 'diff_module', description: 'Show a diff between the original module source and the edited version in _mods/. Returns line-by-line changes.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] } } }, { type: 'function', function: { name: 'ship_module', description: 'Full pipeline: edit → test → health_check → regression suite. Auto-rolls-back on any failure. Set commit:true to also persist to server (requires human approval). Without commit, runs as dry-run.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' }, code: { type: 'string', description: 'Full new source code' }, tests: { type: 'array', description: 'Test assertions [{command, expect}]', items: { type: 'object', properties: { command: { type: 'string' }, expect: { type: 'string' } } } }, commit: { type: 'boolean', description: 'If true, request server persistence (needs human approval). Default: false.' } }, required: ['name', 'code'] } } }, { type: 'function', function: { name: 'commit_module', description: 'Request to persist an edited module from VFS _mods/ to the server file system via WebDAV. Creates a pending commit — user must type commit-approve in the terminal to execute. Includes bundle sync and version bump.', parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' }, file: { type: 'string', description: 'Target server path (e.g. modules/ext/shell.js). Auto-detect from module registry if omitted.' } }, required: ['name'] } } } ]; } getHelpInfo() { return { 'ai': 'ai <question> — Ask the AI assistant anything', 'ask': 'ask <question> — Alias for ai command', 'ai-clear': 'ai-clear — Clear conversation context', 'ai-system': 'ai-system <prompt> — Set system prompt', 'ai-context': 'ai-context — Show current conversation context', 'ai-allow': 'ai-allow — Confirm pending AI action', 'ai-deny': 'ai-deny — Deny pending AI action', 'ai-ignore': 'ai-ignore — Suppress validator warnings for this session', 'ai-stop': 'ai-stop — Cancel current AI operation', 'ai-model': 'ai-model [model] — Show or set AI model', 'ai-config': 'ai-config [key] [value] — View or set AI loop config (max-turns, max-context, confirm-timeout(0=disabled))', 'agent-loops': 'agent-loops [name|new|edit|delete|clone|export|reset] — Manage agent loop profiles', 'bench': 'bench [profile] [--tasks=ids] [--repeat=N] — Run benchmark suite on a profile', 'bench-compare': 'bench-compare <p1> <p2> — Compare benchmark results of two profiles', 'debug': 'debug — Toggle system debug console (logs, fetch, errors)', 'debug-ai': 'debug-ai — Toggle AI tool-call debug panel', 'mem-debug': 'mem-debug — Show memory breakdown by component', 'reset-mem': 'reset-mem — Reset AI memory (logs, context, profile, guardrails) without losing VFS or config', 'vaultmind-connect': 'vaultmind-connect [testnet|mainnet] — Connect VaultMind wallet using hyperliquid_key from SecretVault', 'vaultmind-status': 'vaultmind-status — Check VaultMind session status', 'vaultmind-query': 'vaultmind-query <prices|portfolio|transactions|copy-trade|chat> [message] — Query VaultMind API', 'wallet-lookup': 'wallet-lookup <address> — Look up Hyperliquid wallet summary', }; } registerCommands() { this.commands['ai'] = (args) => this.chat(args); this.commands['ask'] = (args) => this.chat(args); this.commands['ai-clear'] = () => this.clearContext(); this.commands['ai-system'] = (args) => this.setSystemPrompt(args); this.commands['ai-context'] = () => this.showContext(); this.commands['ai-allow'] = () => this.confirmPending(); this.commands['ai-deny'] = () => this.denyPending(); this.commands['ai-ignore'] = () => this.ignoreValidator(); this.commands['ai-stop'] = () => this.cancelAi(); this.commands['ai-model'] = (args) => this.showModel(args); this.commands['ai-compress'] = () => this._manualCompress(); this.commands['ai-config'] = (args) => this.aiConfig(args); this.commands['commit-approve'] = (args) => this._executeCommit(args.join(' ')); this.commands['commit-deny'] = () => { if (!this._pendingCommit) return this.terminal.print('No pending commit.', 'warning'); const name = this._pendingCommit.name; this._pendingCommit = null; this.terminal.print('❌ Commit denied for ' + name, 'warning'); }; this.commands['commit-status'] = () => { if (!this._pendingCommit) return this.terminal.print('No pending commit.', 'info'); const pc = this._pendingCommit; this.terminal.print('📋 Pending commit: ' + pc.name, 'info'); this.terminal.print(' Target: ' + pc.serverPath, 'info'); if (pc.bundlePath) this.terminal.print(' Bundle: ' + pc.bundlePath, 'info'); this.terminal.print(' Changes: ' + pc.changes + ' line(s)', 'info'); }; this.commands['agent-loops'] = (args) => this.agentLoopsCmd(args); this.commands['bench'] = (args) => this.benchCmd(args); this.commands['bench-compare'] = (args) => this.benchCompareCmd(args); this.commands['debug'] = () => this.toggleDebugPanel(); this.commands['debug-ai'] = () => this.toggleDebug(); this.commands['debug-ai-last'] = () => this._showLastRawResponse(); this.commands['debug-ai-send'] = () => this._showLastRawRequest(); this.commands['debug-ai-log'] = () => this._showToolCallLog(); this.commands['archive-list'] = () => this._archiveList(); this.commands['archive-read'] = (args) => this._archiveRead(args); this.commands['guardrail'] = (args) => this.guardrailCmd(args); this.commands['guardrail-add'] = (args) => this.guardrailCmd(['add', ...args]); this.commands['guardrail-remove'] = (args) => this.guardrailCmd(['remove', ...args]); this.commands['shadow'] = (args) => this.shadowExec(args); this.commands['shadow-commit'] = () => this.shadowCommit(); this.commands['shadow-rollback'] = () => this.shadowRollback(); this.commands['mem-debug'] = () => this.memDebug(); this.commands['reset-mem'] = () => this.resetMem(); this.commands['vaultmind-connect'] = (args) => this._vaultmindConnectCmd(args); this.commands['vaultmind-status'] = () => this._vaultmindStatusCmd(); this.commands['vaultmind-query'] = (args) => this._vaultmindQueryCmd(args); this.commands['wallet-lookup'] = (args) => this._walletLookupCmd(args); } _cacheGet(name, args) { const key = name + ':' + (typeof args === 'string' ? args : JSON.stringify(args || {})); if (!this._cacheableTools.has(name)) return { hit: false, key: key }; const e = this._toolCache.get(key); if (e && e.epoch === this._toolEpoch && (Date.now() - e.ts) < 30000) { return { hit: true, key: key, result: e.result }; } return { hit: false, key: key }; } _cacheSet(name, key, result) { if (this._mutatingTools.has(name)) { this._toolEpoch++; return; } // invalidate all if (!this._cacheableTools.has(name) || !key || typeof result !== 'string') return; this._toolCache.set(key, { result: result, ts: Date.now(), epoch: this._toolEpoch }); if (this._toolCache.size > 200) { this._toolCache.delete(this._toolCache.keys().next().value); } } _sessionCtx(tag) { const k = tag || 'main'; // main is always the live this.context (commands like `ai-clear`/`load` reassign it) if (k === 'main') return { context: this.context, profile: null }; if (!this._sessions.has(k)) this._sessions.set(k, { context: [], profile: null }); return this._sessions.get(k); } async chat(args, opts = {}) { const userMessage = args.join(' '); if (!userMessage) { this.terminal.print('Usage: ai <your-question>', 'warning'); return; } this._cancelled = false; this.terminal.setAiBusy(true); const lockStart = Date.now(); while (this._chatLock) { if (Date.now() - lockStart > this._chatLockTimeoutMs) { this._chatLock = false; this.terminal.print('⚠️ Chat lock timed out — force released', 'warning'); break; } await new Promise(r => setTimeout(r, 50)); } this._chatLock = true; const taskStart = Date.now(); // predictor durations exclude chat-lock wait this.terminal.markDirty(); // Auto-route context based on intent try { const scm = this.terminal?.moduleManager?.modules?.SmartContextModule; if (scm && typeof scm.autoRouteContext === 'function') { const routeResult = scm.autoRouteContext(userMessage); if (routeResult.switched) { this.terminal.print('🔄 Context auto-switched: ' + routeResult.from + ' → ' + routeResult.to + ' (' + routeResult.confidence + '% confidence)', 'info'); // Rebuild system prompt after context switch this.systemPrompt = this.buildSystemPrompt(); } } } catch (e) { console.warn('[AI] Auto-routing error:', e.message); } // Agent runs must not touch the user's main conversation: _loadContext()/ _compressContext() // reassign this.context, which would detach the session captured below. const _isAgentRun = !!opts.agentTag; if (!_isAgentRun) { this._loadContext(); await this._compressContext(); this._toolCache.clear(); // out-of-band VFS edits may have happened since the last chat } // Per-agent session: 'main' aliases this.context, agents get their own history. const sess = this._sessionCtx(opts.agentTag); let loopResult; try { const messages = [ { role: 'system', content: this.systemPrompt } ]; sess.context.slice().forEach(msg => messages.push(msg)); messages.push({ role: 'user', content: userMessage }); this.terminal.print('🤖 WebX11AI thinking...', 'info'); // Auto-load skills relevant to the user's query (awaited, not fire-and-forget) // Skip skill loading for trivial queries (< 10 chars, no actionable keywords) const optEnabled = this.terminal?.ollamaManager?.optimizationEnabled !== false; const isTrivial = optEnabled && userMessage.length < 10 && !/\b(search|write|install|edit|create|make|find|delete|remove|change|update|improve|add|fix|build|set|load|save|help)\b/i.test(userMessage); if (!isTrivial) { try { const skillRes = await this.runTool('load_skills', { query: userMessage }); const skillData = JSON.parse(skillRes); if (skillData && skillData.matches && skillData.matches.length > 0) { const skillNotes = skillData.matches.map(m => `[Skill: ${m.name}] ${m.content}`).join('\n\n---\n\n'); messages.push({ role: 'system', content: `Relevant skills for this task:\n\n${skillNotes}\n\nUse the steps above as guidance. If they're wrong or outdated, use patch_skill to fix them after the task.` }); this.terminal.print(`📚 Loaded ${skillData.count} relevant skill(s)`, 'info'); } } catch (e) { /* skills not available yet — first run */ } } // Track conversation patternsfor learning this._trackPattern(userMessage); // Extract facts from user message this._extractFacts(userMessage, ''); // On first message of session, auto-load recent memory if (!this._sessionMemoryLoaded) { this._sessionMemoryLoaded = true; this.terminal.fs.mkdirRecursive('/memory'); try { const recentRes = await this.runTool('session_recent', { count: 3 }); const rd = JSON.parse(recentRes); if (rd.sessions && rd.sessions.length > 0) { const memNotes = rd.sessions.map(s => '[' + s.date + '] ' + (s.tasks || 1) + ' tasks: ' + (s.summary || 'No summary')).join('\n'); messages.push({ role: 'system', content: 'Recent sessions:\n' + memNotes + '\nThis provides continuity. Refer to past work when relevant.' }); this.terminal.print('📖 Loaded ' + rd.sessions.length + ' recent session(s)', 'info'); } } catch (e) {} } // Inject memory graph context for cross-session awareness try { const memgraph = this.terminal.moduleManager.modules['MemoryGraphModule']; if (memgraph) { const mctx = memgraph.getContextForPrompt(); if (mctx) { messages.push({ role: 'system', content: mctx }); } } } catch (e) { /* memory graph not available */ } // Inject world model context for system self-awareness try { const wm = this.terminal.moduleManager.modules['WorldModelModule']; if (wm) { const wctx = wm.getContextForPrompt(); if (wctx) { messages.push({ role: 'system', content: wctx }); } } } catch (e) { /* world model not available */ } // v4.6.56: register a prediction for THIS turn so recordResult can score it by id. let turnPredictionId = null; try { const predictor = this.terminal.moduleManager.modules['PredictorModule']; if (predictor && typeof predictor.notePrediction === 'function') { const tp = predictor.notePrediction(userMessage, 'loop'); turnPredictionId = tp && tp.id ? tp.id : null; } } catch (e) { /* predictor is best-effort */ } const _traceTag = opts && opts.agentTag ? opts.agentTag : 'main'; try { loopResult = await this.agenticLoop(messages, opts); } catch (_loopErr) { this._flushToolTrace(sess, _traceTag); // never leave stale notes behind throw _loopErr; } sess.context.push({ role: 'user', content: userMessage }); if (loopResult.finalAnswer) { sess.context.push({ role: 'assistant', content: loopResult.finalAnswer }); } // Durable trace of what was read/run this turn (2026-10-21) — without this, // every later turn started blind and the model re-read the same files. this._flushToolTrace(sess, _traceTag); if (sess.context.length > this.maxContext * 2) { // Silently splicing the opening turns is how a long task forgets its own brief: // the model then "remembers" a conversation that never started that way. Drop // the same messages, but leave a marker saying how many and what the first was. const cap = this.maxContext * 2; const dropped = sess.context.splice(0, sess.context.length - (cap - 1)); const firstUser = dropped.find(m => m.role === 'user'); const note = '[context trim: ' + dropped.length + ' earlier message(s) left this window' + (firstUser && firstUser.content ? ' — the earliest was: "' + String(firstUser.content).replace(/\s+/g, ' ').slice(0, 90) + '…"' : '') + ']'; sess.context.unshift({ role: 'system', content: note }); this.terminal.print('✂️ Context trimmed: ' + dropped.length + ' message(s) — the model is told what it lost', 'text-muted'); } // Reflection: after complex tasks, run health scan and auto-skill creation if (loopResult.toolTurns >= 2 && !this._cancelled) { await this.afterActionReflection(userMessage, loopResult.toolTurns); } // Feed result to predictor for learning (cold module activation) if (loopResult && !this._cancelled && loopResult.toolTurns >= 1) { try { const predictor = this.terminal.moduleManager.modules['PredictorModule']; if (predictor && typeof predictor.recordResult === 'function') { const features = predictor._extractFeatures(userMessage); const duration = (Date.now() - taskStart) / 1000; predictor.recordResult(features, duration, loopResult.toolTurns, !this._cancelled, { task: userMessage, predictionId: turnPredictionId }); } } catch (e) { /* predictor learning is best-effort */ } // Record task in memory graph for direct AI conversations (not just orchestrator) try { const mg = this.terminal.moduleManager.modules['MemoryGraphModule']; if (mg && typeof mg.recordTask === 'function') { mg.recordTask(userMessage, loopResult.finalAnswer || '', 'ai-assistant'); } } catch (e) { /* memory graph is best-effort */ } } // Self-improvement: post-run anomaly detection if (loopResult && !this._cancelled) { try { var om = this.terminal ? this.terminal.ollamaManager : null; var usageData = om && om._usageLog && om._usageLog.length > 0 ? om._usageLog[om._usageLog.length - 1] : null; this._postRunReview(loopResult, this._toolCallLog.slice(-(loopResult.toolTurns || 0)), usageData); } catch (e) { /* self-improvement is best-effort */ } } // Auto-create skills from repeated errors if (this._errorLog.length >= 3) { await this._autoCreateErrorSkill(); } // Skill quality audit every 20 messages this._messageCount++; if (this._messageCount % 20 === 0) { await this._auditSkills(); } // Sound notification on AI response if (this.terminal.playNotificationSound) { setTimeout(() => this.terminal.playNotificationSound(), 100); } } finally { this._chatLock = false; this.terminal.setAiBusy(false); // Persist only the user's conversation — agent sessions are ephemeral. if (!_isAgentRun) this._saveContext(); // Log the AI interaction this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100)); if (loopResult && loopResult.toolTurns) { const toolNames = this._toolCallLog.slice(-(loopResult.toolTurns || 0)).map(function(e) { return e.name; }).join(', '); this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s): ' + toolNames); } } // Auto-archive every 10 messages if (this._messageCount > 0 && this._messageCount % 10 === 0) { this._archiveSession('Auto-archive at message ' + this._messageCount); } // Register session save on page unload (runs once per session) if (!this._sessionSaved) { this._sessionSaved = true; window.addEventListener('beforeunload', () => { this.terminal.fs.mkdirRecursive('/memory'); const today = new Date().toISOString().split('T')[0]; const index = this.terminal.fs.fsReadJSON('/memory/index.json') || {}; index[today] = index[today] || { summary: 'Session in progress', tasks: 0 }; index[today].tasks = (index[today].tasks || 0) + 1; index[today].updated = new Date().toISOString(); this.terminal.fs.fsWriteJSON('/memory/index.json', index); // Also persist AI context on page close this._saveContext(); }); } } // ─── Self-Improvement Helpers ─── async _captureCommandOutput(cmd) { let output = ''; // Loop guard: AI-initiated commands must not route back to AI on unknown command this.terminal.security._aiToolDepth++; this.terminal._pushPrintCapture(buf => { output += buf; }); try { await this.terminal.security.checkAndRun(cmd); } finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; } return output.trim(); } async _captureShellCommand(cmd) { // Routes through executeCommand (shell parser) for >, |, &&, export, etc. let output = ''; // Loop guard: AI-initiated commands must not route back to AI on unknown command this.terminal.security._aiToolDepth++; this.terminal._pushPrintCapture(buf => { output += buf; }); try { await this.terminal.executeCommand(cmd); } finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; } return output.trim(); } async _captureConsoleErrors(durationMs) { durationMs = durationMs || 500; var errors = []; var origError = console.error; var origOnError = window.onerror; console.error = function() { var args = Array.prototype.slice.call(arguments); errors.push(args.join(' ')); }; window.onerror = function(msg, src, line, col, err) { errors.push(msg + (err ? ' — ' + err.message : '')); }; await new Promise(function(r) { setTimeout(r, durationMs); }); console.error = origError; window.onerror = origOnError; return errors; } _regressionTasks() { return [ { id: 'shell-export', module: 'ShellModule', command: 'export REGTEST=ok', expect: 'REGTEST', useShell: true }, { id: 'shell-redirect', module: 'ShellModule', command: 'echo hi > _regtest.txt', expect: 'Redirected', useShell: true }, { id: 'shell-chain', module: 'ShellModule', command: 'echo a && echo b', expect: 'b', useShell: true }, { id: 'shell-pipe', module: 'ShellModule', command: 'echo hello | grep hello', expect: 'hello', useShell: true }, { id: 'fs-ls', module: 'SystemModule', command: 'ls', expect: '' }, { id: 'fs-pwd', module: 'SystemModule', command: 'pwd', expect: '/' }, { id: 'orch-agents', module: 'OrchestratorModule', command: 'list-agents', expect: 'agent' }, { id: 'ai-tools', module: 'WebX11AIAssistantModule', command: 'list_modules', expect: 'Module' }, { id: 'calc-basic', module: 'CalcModule', command: 'calc 1+1', expect: '2' }, { id: 'calc-advanced', module: 'CalcModule', command: 'calc 2*3', expect: '6' }, { id: 'webdav-ls', module: 'WebDAVModule', command: 'webdav-ls', expect: '' }, { id: 'cron-list', module: 'CronModule', command: 'cron-list', expect: '' }, { id: 'theme-list', module: 'ThemeModule', command: 'theme-list', expect: '' }, { id: 'mindmap-list', module: 'MindmapModule', command: 'mindmap-list', expect: '' }, { id: 'media-status', module: 'MediaModule', command: 'media-status', expect: '' }, { id: 'skill-list', module: 'SkillPackCoreModule', command: 'skill-list', expect: '' }, { id: 'crypto-encode', module: 'CryptoGzipModule', command: 'crypto-encode test', expect: '' }, { id: 'debug-toggle', module: 'DebugPanelModule', command: 'debug', expect: '' }, { id: 'voice-status', module: 'VoiceModule', command: 'voice-status', expect: '' }, { id: 'wpm-status', module: 'WPMModule', command: 'wpm-status', expect: '' }, { id: 'console-help', module: 'ConsoleModule', command: 'console-help', expect: '' }, { id: 'tutorial-status', module: 'TutorialModule', command: 'tutorial-status', expect: '' }, ]; } async _runRegressionSuite(moduleName) { const tests = this._regressionTasks().filter(t => t.module === moduleName); if (tests.length === 0) return { module: moduleName, total: 0, passed: 0, failed: 0, results: [], allPassed: true, message: 'No regression tests for ' + moduleName }; const results = []; for (const t of tests) { try { const out = t.useShell ? await this._captureShellCommand(t.command) : await this._captureCommandOutput(t.command); const pass = t.expect ? out.includes(t.expect) : out.length > 0; results.push({ id: t.id, command: t.command, pass, output: out.substring(0, 100) }); } catch (e) { results.push({ id: t.id, command: t.command, pass: false, output: 'Error: ' + e.message }); } } const passed = results.filter(r => r.pass).length; return { module: moduleName, total: results.length, passed, failed: results.length - passed, results, allPassed: passed === results.length }; } // ─── Phase D: Server Persistence ─── async _webdavPush(remotePath, content) { const webdav = this.terminal.moduleManager.modules['WebDAVModule']; if (!webdav) throw new Error('WebDAV module not loaded'); // Use the webdav module's internal request method const resp = await webdav._webdavRequest('PUT', remotePath, { body: content, headers: { 'Content-Type': 'text/plain' } }); if (!resp.ok && resp.status !== 201 && resp.status !== 204) { throw new Error('WebDAV PUT failed: HTTP ' + resp.status); } return true; } async _webdavGet(remotePath) { const webdav = this.terminal.moduleManager.modules['WebDAVModule']; if (!webdav) throw new Error('WebDAV module not loaded'); const resp = await webdav._webdavRequest('GET', remotePath); if (!resp.ok) throw new Error('WebDAV GET failed: HTTP ' + resp.status); return await resp.text(); } _extractClassName(code) { const m = code.match(/^class\s+(\w+)/m); return m ? m[1] : null; } _extractClassBlock(code, className) { // Extract the class definition from standalone code (without self-registration block) const classStart = code.indexOf('class ' + className); if (classStart === -1) return null; // Find matching closing brace by counting let depth = 0; let end = classStart; for (let i = classStart; i < code.length; i++) { if (code[i] === '{') depth++; else if (code[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } } } return code.substring(classStart, end); } async _syncBundle(bundlePath, modName, newCode) { try { // Download current bundle const bundle = await this._webdavGet(bundlePath); // Find the class block in the bundle const className = modName; const classStart = bundle.indexOf('class ' + className); if (classStart === -1) return { synced: false, reason: 'Class not found in bundle' }; // Find matching closing brace let depth = 0; let classEnd = classStart; for (let i = classStart; i < bundle.length; i++) { if (bundle[i] === '{') depth++; else if (bundle[i] === '}') { depth--; if (depth === 0) { classEnd = i + 1; break; } } } // Extract new class block from the edited code const newClassBlock = this._extractClassBlock(newCode, className); if (!newClassBlock) return { synced: false, reason: 'Could not extract class from new code' }; // Replace in bundle const newBundle = bundle.substring(0, classStart) + newClassBlock + bundle.substring(classEnd); // Validate with vm.Script equivalent (Function constructor) try { new Function(newBundle); } catch (e) { return { synced: false, reason: 'Bundle syntax error after replace: ' + e.message }; } // Upload await this._webdavPush(bundlePath, newBundle); return { synced: true, bytes: newBundle.length }; } catch (e) { return { synced: false, reason: e.message }; } } _parseVersion(ver) { // Handle both old format (2026-07-06-v4.6.01) and new format (v4.6.01) const m = ver.match(/v(\d+)\.(\d+)\.(\d+)/); if (!m) return { major: 4, minor: 6, patch: 0 }; return { major: parseInt(m[1]), minor: parseInt(m[2]), patch: parseInt(m[3]) }; } _bumpVersionString(ver) { const v = this._parseVersion(ver); return 'v' + v.major + '.' + v.minor + '.' + (v.patch + 1); } async _bumpVersion() { // Read current version from the page const currentVer = window.NEWOS4_VERSION || 'v4.6.01'; const newVer = this._bumpVersionString(currentVer); // 1. Update main.html — replace all ?v= occurrences const mainHtml = await this._webdavGet('main.html'); const updatedHtml = mainHtml.replace(/\?v=[^"']+/g, '?v=' + newVer); await this._webdavPush('main.html', updatedHtml); // 2. Update module-loader.js — replace LATEST_VER const loader = await this._webdavGet('modules/core/module-loader.js'); const updatedLoader = loader.replace(/const LATEST_VER = '[^']+'/, "const LATEST_VER = '" + newVer + "'"); await this._webdavPush('modules/core/module-loader.js', updatedLoader); return { from: currentVer, to: newVer }; } _calculateConfidence(modName, testResult, healthResult, regResult, changeCount) { var score = 100; if (changeCount > 50) score -= 20; if (changeCount > 100) score -= 20; if (regResult.total === 0) score -= 30; var critical = ['ShellModule', 'SystemModule', 'OrchestratorModule', 'WebX11AIAssistantModule']; if (critical.indexOf(modName) !== -1) score -= 15; if (testResult.tests && testResult.tests.length >= 3) score += 10; if (healthResult.jsErrors && healthResult.jsErrors.length > 0) score -= 50; return Math.max(0, Math.min(100, score)); } async _executeCommit(comment) { const pc = this._pendingCommit; if (!pc) return this.terminal.print('No pending commit. Use commit_module first.', 'warning'); // Rate limit check const today = new Date().toISOString().split('T')[0]; const commitLog = this.terminal.fs.fsReadJSON('/_mods/commit-log.json') || {}; if (commitLog[today] && commitLog[today] >= 5) { return this.terminal.print('⚠️ Rate limit: 5 commits today. Try tomorrow.', 'warning'); } // Cooldown check (10 min) if (commitLog._lastCommitTime && Date.now() - commitLog._lastCommitTime < 600000) { const wait = Math.ceil((600000 - (Date.now() - commitLog._lastCommitTime)) / 60000); return this.terminal.print('⏱ Cooldown: ' + wait + ' min until next commit.', 'warning'); } this.terminal.print('🚀 Executing commit for ' + pc.name + '...', 'info'); const results = { module: pc.name, steps: [] }; // Step 1: Push standalone file try { await this._webdavPush(pc.serverPath, pc.code); results.steps.push({ step: 'standalone', status: 'ok', path: pc.serverPath }); this.terminal.print(' ✅ Standalone: ' + pc.serverPath, 'success'); } catch (e) { results.steps.push({ step: 'standalone', status: 'fail', error: e.message }); this.terminal.print(' ❌ Standalone failed: ' + e.message, 'error'); return results; } // Step 1b: Post-commit syntax verification try { const verifyResp = await fetch(pc.serverPath + '?v=' + Date.now()); const committedCode = await verifyResp.text(); new Function(committedCode); results.steps.push({ step: 'verify', status: 'ok' }); this.terminal.print(' ✅ Post-commit syntax check passed', 'success'); } catch (e) { results.steps.push({ step: 'verify', status: 'fail', error: e.message }); this.terminal.print(' ❌ Post-commit syntax check failed: ' + e.message, 'error'); // Auto-revert try { await this._webdavPush(pc.serverPath, pc.origCode || ''); this.terminal.print(' ⏪ Auto-reverted to original code', 'warning'); results.reverted = true; results.syntaxError = e.message; } catch (revertErr) { this.terminal.print(' ❌ Auto-revert FAILED: ' + revertErr.message, 'error'); results.revertFailed = true; } this._pendingCommit = null; return results; } // Step 2: Sync bundle if (pc.bundlePath) { try { const syncResult = await this._syncBundle(pc.bundlePath, pc.name, pc.code); results.steps.push({ step: 'bundle', status: syncResult.synced ? 'ok' : 'skip', detail: syncResult }); if (syncResult.synced) this.terminal.print(' ✅ Bundle: ' + pc.bundlePath, 'success'); else this.terminal.print(' ⚠️ Bundle sync skipped: ' + syncResult.reason, 'warning'); } catch (e) { results.steps.push({ step: 'bundle', status: 'fail', error: e.message }); this.terminal.print(' ❌ Bundle sync failed: ' + e.message, 'error'); } } // Step 3: Bump version try { const verResult = await this._bumpVersion(); results.steps.push({ step: 'version', status: 'ok', from: verResult.from, to: verResult.to }); this.terminal.print(' ✅ Version: ' + verResult.from + ' → ' + verResult.to, 'success'); results.newVersion = verResult.to; } catch (e) { results.steps.push({ step: 'version', status: 'fail', error: e.message }); this.terminal.print(' ⚠️ Version bump failed: ' + e.message, 'warning'); } // Step 4: Cleanup _mods/ this.terminal.fs.deleteFile('_mods/' + pc.name + '.js'); this.terminal.fs.deleteFile('_mods/' + pc.name + '.orig.js'); this.terminal.fs.deleteFile('_mods/' + pc.name + '.meta.json'); // Step 5: Rate limit update commitLog[today] = (commitLog[today] || 0) + 1; commitLog._lastCommitTime = Date.now(); this.terminal.fs.fsWriteJSON('/_mods/commit-log.json', commitLog); // Clear pending this._pendingCommit = null; this.terminal.print('🎉 Commit complete! Refresh to load ' + (results.newVersion || 'new version'), 'success'); if (comment) this.terminal.print(' Comment: ' + comment, 'info'); results.success = true; return results; } _summarizeObservation(result, maxLen) { if (!result || typeof result !== 'string' || result.length <= maxLen) return result || ''; const lines = result.split('\n'); if (lines.length > 5) { return lines.slice(0, 2).join('\n') + '\n...(' + (lines.length - 4) + ' more lines)...\n' + lines.slice(-2).join('\n'); } return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)'; } // MAINT-004: the loop guard read warnAt/blockAt/hardStopAt while one config path wrote // `threshold`, and `enabled` was never consulted at all — turning the guard off in // config changed nothing. One resolver, one set of defaults, explicit disable. _loopPolicy(cfg) { const c = cfg || {}; const threshold = Number(c.threshold); const blockAt = Number(c.blockAt) || (Number.isFinite(threshold) && threshold > 0 ? threshold : 6); const warnAt = Number(c.warnAt) || Math.max(2, blockAt - 3); const hardStopAt = Number(c.hardStopAt) || blockAt + 3; return { enabled: c.enabled !== false, windowMs: Number(c.windowMs) || 30000, warnAt: Math.min(warnAt, blockAt), blockAt, hardStopAt: Math.max(hardStopAt, blockAt + 1) }; } // MAINT-023: one agentic turn used to grow without limit — every role:'tool' body was // pushed verbatim and only this.context was trimmed BETWEEN turns, so a measured turn // went 6 → 112 705 tokens across 8 model calls (maxContextTokens is 12 000). Two // limits keep a single turn inside the window: clip each observation as it arrives, // and elide the oldest ones once the running total passes the budget. _clipToolResult(result) { const text = typeof result === 'string' ? result : JSON.stringify(result); const max = this.maxToolResultChars || 6000; if (!text || text.length <= max) return text; return text.slice(0, max) + '\n…[clipped: ' + (text.length - max) + ' of ' + text.length + ' chars withheld — re-read with offset/limit if you need the rest]'; } _boundToolContext(messages) { const keepVerbatim = this.toolResultsVerbatim || 4; const budget = this.toolContextBudgetChars || 24000; const idx = []; for (let i = 0; i < messages.length; i++) { if (messages[i] && messages[i].role === 'tool') idx.push(i); } if (!idx.length) return messages; const recent = new Set(idx.slice(-keepVerbatim)); let total = 0; for (const i of idx) total += String(messages[i].content || '').length; // Oldest first, never the recent few: those are what the model is working on. for (const i of idx) { if (total <= budget) break; if (recent.has(i)) continue; const len = String(messages[i].content || '').length; const stub = '[earlier tool result elided — ' + len + ' chars; call the tool again if you still need it]'; messages[i].content = stub; total -= len - stub.length; } return messages; } // 2026-10-21: tool results lived only inside the current turn, so the next turn // had no idea what the model had already read or run. Keep a compact per-turn // trace (per agent tag) and persist it into the session context. // Review-hardened: no file bodies, no untrusted web output, no secrets. _noteObservation(name, args, result, tag) { try { if (/^(web_search|web_fetch|scrape|fetch)$/.test(name)) return; // injection surface const key = tag || 'main'; if (!this._turnObservations) this._turnObservations = {}; const buf = this._turnObservations[key] || (this._turnObservations[key] = []); const clean = s => String(s === undefined || s === null ? '' : s) .replace(/\s+/g, ' ') .replace(/(token|key|secret|password|bearer)[^,}\s]*/ig, '$1:[redacted]'); let argStr = (typeof args === 'object' && args !== null) ? JSON.stringify(args) : String(args || ''); argStr = clean(argStr).slice(0, 80); let note; if (name === 'read_file') { let p = {}; try { p = JSON.parse(String(result)) || {}; } catch (e) {} note = 'read_file ' + (p.path || argStr) + ' [' + (p.offset || 0) + '+' + (p.returned || 0) + '/' + (p.total_chars === undefined ? '?' : p.total_chars) + (p.truncated ? ', next=' + p.next_offset : '') + ']'; } else { note = name + '(' + argStr + ') → ' + clean(String(result)).slice(0, 120); } buf.push(note.slice(0, 240)); // Ring buffer: keep the first 2 (the goal) and the last 10 (verification). if (buf.length > 12) buf.splice(2, 1); } catch (_e) {} } // Push this turn's trace into the session context and clear the buffer. _flushToolTrace(sess, tag) { try { const key = tag || 'main'; const buf = (this._turnObservations && this._turnObservations[key]) || []; if (buf.length && sess && sess.context) { sess.context.push({ role: 'system', content: '⚙️ tool trace: ' + buf.join(' | ') }); } if (this._turnObservations) delete this._turnObservations[key]; } catch (_e) {} } _escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); } _updateDebugPanel() { if (!this._debugPanelEnabled) return; const panel = document.getElementById('debug-ai-panel'); if (!panel) return; const activeTab = panel.getAttribute('data-tab') || 'tools'; const recent = this._toolCallLog.slice(-20); let toolHtml = ''; for (const entry of recent) { const statusClass = entry.error ? 'debug-entry-error' : 'debug-entry-ok'; const argsStr = typeof entry.args === 'string' ? entry.args.substring(0, 60) : JSON.stringify(entry.args).substring(0, 60); toolHtml += '<div class="debug-entry ' + statusClass + '">' + '<span class="debug-turn">#' + entry.turn + '</span> ' + '<span class="debug-name">' + this._escapeHtml(entry.name) + '</span> ' + '<span class="debug-args">' + this._escapeHtml(argsStr) + '</span> ' + '<span class="debug-time">' + entry.duration + 's</span>' + (entry.error ? ' <span class="debug-err">⚠️</span>' : '') + '</div>'; } // Raw I/O tab content let rawHtml = ''; if (this._lastRawResponse) { const r = this._lastRawResponse; const content = (r.message && r.message.content) || ''; const hasTools = r.tool_calls && r.tool_calls.length > 0; rawHtml += '<div style="padding:6px 8px;font-size:0.75em;line-height:1.3">'; if (r.error) rawHtml += '<div style="color:var(--error-color)">⚠️ ' + this._escapeHtml(r.error) + '</div>'; if (content) { const safe = this._escapeHtml(content.length > 300 ? content.substring(0, 300) + '...' : content); rawHtml += '<div><strong>Content:</strong> ' + safe + '</div>'; } if (hasTools) { rawHtml += '<div><strong>Tools:</strong> ' + r.tool_calls.map(function(tc) { return tc.function.name; }).join(', ') + '</div>'; } if (this._lastRawMessages) { rawHtml += '<div style="margin-top:4px;color:var(--text-muted)">Messages: ' + this._lastRawMessages.length + '</div>'; } rawHtml += '</div>'; } else { rawHtml = '<div style="padding:8px;color:var(--text-muted);font-size:0.75em">No AI response yet. Send a message first.</div>'; } const tabBar = '<div style="display:flex;border-bottom:1px solid #333">' + '<span style="cursor:pointer;padding:4px 10px;font-size:0.8em;' + (activeTab === 'tools' ? 'background:var(--bg-tertiary);border-bottom:2px solid var(--success-color);font-weight:bold' : 'color:var(--text-muted)') + '" onclick="var p=document.getElementById(\'debug-ai-panel\');p.setAttribute(\'data-tab\',\'tools\');var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m)m._updateDebugPanel()">🔧 Tools</span>' + '<span style="cursor:pointer;padding:4px 10px;font-size:0.8em;' + (activeTab === 'raw' ? 'background:var(--bg-tertiary);border-bottom:2px solid var(--success-color);font-weight:bold' : 'color:var(--text-muted)') + '" onclick="var p=document.getElementById(\'debug-ai-panel\');p.setAttribute(\'data-tab\',\'raw\');var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m)m._updateDebugPanel()">📡 Raw I/O</span>' + '</div>'; const bodyHtml = activeTab === 'raw' ? rawHtml : toolHtml; panel.innerHTML = '<div class="debug-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #333;font-weight:bold;color:var(--success-color);font-size:0.9em;flex-wrap:wrap;gap:4px;">' + '<span>🔧 AI Debug <span class="debug-count" style="color:var(--text-muted);font-weight:normal;font-size:0.85em;">' + this._toolCallLog.length + ' calls</span></span>' + '<span style="display:flex;gap:4px;">' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var t=m._toolCallLog.map(function(e){return\'#\'+e.turn+\' \'+e.name+\' (\'+e.duration+\'s)\'+(e.error?\' ⚠️\':\'\')+\'\\n\'+JSON.stringify(e.args).substring(0,80)}).join(\'\\n\\n\');navigator.clipboard.writeText(t).then(function(){m.terminal&&m.terminal.print(\'📋 Debug log copied to clipboard\',\'success\')}).catch(function(){prompt(\'Copy debug log:\',t)})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📋</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var json=JSON.stringify(m._toolCallLog,null,2);var b=new Blob([json],{type:\'application/json\'});var u=URL.createObjectURL(b);var a=document.createElement(\'a\');a.href=u;a.download=\'debug-log-\'+new Date().toISOString().slice(0,19)+\'.json\';document.body.appendChild(a);a.click();setTimeout(function(){document.body.removeChild(a);URL.revokeObjectURL(u)},2000);m.terminal&&m.terminal.print(\'📥 Debug log exported\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">📥</button>' + '<button onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m){var existing=[];var raw=m.terminal.fs.readFile(\'/debug.log\');if(raw){try{existing=JSON.parse(raw);}catch(e){}}var log=m._toolCallLog;var seen={};existing.forEach(function(e){seen[e.turn]=true;});log.forEach(function(e){if(!seen[e.turn]){existing.push(e);seen[e.turn]=true;}});var json=JSON.stringify(existing,null,2);m.terminal.fs.writeFile(\'/debug.log\',json);m.terminal.print(\'💾 Debug log appended — \'+existing.length+\' entries total\',\'success\')}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em;min-height:24px;">💾</button>' + '<span style="cursor:pointer;color:var(--text-muted);font-size:1.1em;touch-action:manipulation;" onclick="var p=document.getElementById(\'debug-ai-panel\');if(p)p.style.display=\'none\';var a=window.webx11&&webx11.moduleManager&&webx11.moduleManager.modules&&webx11.moduleManager.modules.WebX11AIAssistantModule;if(a)a._debugPanelEnabled=false;">✕</span>' + '</span></div>' + tabBar + bodyHtml; } _isToolError(result) { if (!result || typeof result !== 'string') return null; const trimmed = result.trim(); if (trimmed.startsWith('{"error":') || trimmed.startsWith("{'error':")) { try { const parsed = JSON.parse(trimmed); return parsed.error || null; } catch (e) { return null; } } return null; } _trackToolFailure(toolName, error) { try { if (!this._toolFailuresBuffer) this._toolFailuresBuffer = {}; if (!this._toolFailuresBuffer[toolName]) this._toolFailuresBuffer[toolName] = { count: 0, last: '', errors: [] }; const entry = this._toolFailuresBuffer[toolName]; entry.count++; entry.last = new Date().toISOString(); entry.errors.push(error); if (entry.errors.length > 5) entry.errors.shift(); if (entry.count >= 3) { this.terminal.print(`⚠️ Tool '${toolName}' has failed ${entry.count} times. Consider creating a workaround skill.`, 'warning'); } if (this._toolFailureFlushTimer) clearTimeout(this._toolFailureFlushTimer); this._toolFailureFlushTimer = setTimeout(() => this._flushToolFailures(), 2000); } catch (e) { /* ignore */ } } _flushToolFailures() { try { if (!this._toolFailuresBuffer || Object.keys(this._toolFailuresBuffer).length === 0) return; const key = 'webx11_tool_failures'; const raw = localStorage.getItem(key) || '{}'; const map = JSON.parse(raw); for (const [toolName, entry] of Object.entries(this._toolFailuresBuffer)) { if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] }; map[toolName].count += entry.count; map[toolName].last = entry.last; map[toolName].errors.push(...entry.errors); if (map[toolName].errors.length > 10) map[toolName].errors = map[toolName].errors.slice(-10); } localStorage.setItem(key, JSON.stringify(map)); this._toolFailuresBuffer = {}; } catch (e) { /* ignore */ } } async agenticLoop(messages, opts = {}) { var profile = this._activeLoopProfile || {}; var loopCfg = profile.loop || {}; var maxTurns = loopCfg.maxTurns || this.MAX_TOOL_TURNS; var baseTurns = maxTurns; let _lastTurnSig = null, _progressStreak = 0, _stallStreak = 0; const _logStart = (this._toolCallLog || []).length; const _loopLog = []; // per-run tool log (the global one self-trims to 50 and breaks slicing) // D3: extend the budget only while turns keep producing NEW output. const _noteProgress = (results) => { try { const arr = (results || []).map(r => (r && typeof r === 'object' && 'toolResult' in r) ? r.toolResult : r); if (!arr.length) return; const h = _sigHash(JSON.stringify(arr)); if (_lastTurnSig !== null && h !== _lastTurnSig) { _progressStreak++; _stallStreak = 0; } else if (_lastTurnSig !== null) { _stallStreak++; _progressStreak = 0; } _lastTurnSig = h; if (_progressStreak >= 2 && maxTurns < baseTurns + 4) { maxTurns += 2; this.terminal.print(`📈 Progress detected — extending turn budget to ${maxTurns}`, 'info'); } if (_stallStreak === 3) { messages.push({ role: 'system', content: 'No new information in the last turns — stop calling tools and give your final answer now.' }); } } catch (_e) {} }; var loopDetect = loopCfg.loopDetection || { enabled: true, threshold: 6, windowMs: 30000 }; var _lp = this._loopPolicy(loopDetect); var confEsc = loopCfg.confidenceEscalation || { enabled: true, threshold: 90 }; var finalPrompt = loopCfg.finalTurnPrompt || 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.'; // Memory-augmented planning: inject prior loop lessons as negative constraints try { var fs = this.terminal.fs; if (fs) { // MAINT-020: lessons live in /lessons, not in /skills — writing them into // the skills directory made auto-generated lessons show up as skills. var index = fs.fsReadJSON('/lessons/index.json') || {}; var lessonKeys = Object.keys(index); if (lessonKeys.length === 0) { // one-release fallback for lessons written by older builds var legacy = fs.fsReadJSON('/skills/index.json') || {}; lessonKeys = Object.keys(legacy).filter(function(k) { return k.indexOf('loop-lesson-') === 0; }); } if (lessonKeys.length > 0) { var recentLessons = lessonKeys.sort().slice(-3); var lessonTexts = []; for (var li = 0; li < recentLessons.length; li++) { var lc = fs.readFile('/lessons/' + recentLessons[li] + '.md') || fs.readFile('/skills/' + recentLessons[li] + '.md'); if (lc) { var excerpt = lc.substring(0, 300); lessonTexts.push('[Lesson: ' + recentLessons[li] + '] ' + excerpt); } } if (lessonTexts.length > 0) { messages.push({ role: 'system', content: 'Prior loop lessons (read before planning):\n' + lessonTexts.join('\n---\n') + '\n\nUse these to avoid repeating past mistakes. If lessons mention tool failures, verify those tools before calling them.' }); } } } } catch (e) { /* VFS not available — skip memory-augmented planning */ } let toolTurns = 0; const _seenCalls = new Map(); // sig -> {count, ts, resultHash, blocked, lastResult} const _loopWindow = _lp.windowMs; const _warnAt = _lp.warnAt; const _blockAt = _lp.blockAt; const _hardStopAt = _lp.hardStopAt; const _agentTag = opts.agentTag || 'main'; this._pendingBlockedReplays = null; const _sigHash = (s) => { let h = 5381; const str = (typeof s === 'string') ? s : JSON.stringify(s == null ? '' : s); for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0; return h >>> 0; }; while (toolTurns < maxTurns) { if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; } toolTurns++; // D4: refresh the running tool ledger so the model never re-reads what it already has if (toolTurns > 1) { try { for (let mi = messages.length - 1; mi >= 0; mi--) { if (messages[mi] && messages[mi]._ledger) messages.splice(mi, 1); } const _led = _loopLog.slice(-8).map(e => '- ' + e.name + '(' + String(typeof e.args === 'string' ? e.args : JSON.stringify(e.args || {})).slice(0, 60) + ') → ' + (e.error ? 'ERROR' : this._summarizeObservation(e.result, 80)) ).join('\n'); if (_led) { messages.push({ role: 'system', _ledger: true, content: 'Tool ledger so far (do NOT repeat a call whose result you already have):\n' + _led }); } } catch (_e) {} } // On last turn, inject wrap-up prompt if (toolTurns === maxTurns) { this.terminal.print(`⏱ Final turn (${toolTurns}/${maxTurns}) — AI must provide final answer`, 'warning'); messages.push({ role: 'system', content: finalPrompt }); } const thinkStart = Date.now(); // MAINT-023: keep the in-flight tool context bounded before every model call. this._boundToolContext(messages); const res = await this.terminal.ollamaManager.chatWithTools(messages, this.getToolDefinitions()); const thinkTime = Math.round((Date.now() - thinkStart) / 1000); // Store raw response for debug-ai-last this._lastRawResponse = res; this._lastRawMessages = messages.slice(); if (this._cancelled) return { finalAnswer: null, toolTurns }; if (res.error) { this.terminal.print(`⚠️ AI model error (${thinkTime}s): ${res.error}`, 'error'); return { finalAnswer: null, toolTurns }; } if (res.message && !res.tool_calls) { const text = res.message.content || ''; this.terminal.printAIResponse(text || '(no response)'); if (text) { this.terminal.ollamaManager.fireResponseHooks(text); // Auto-speak if enabled const vm = this.terminal.moduleManager && this.terminal.moduleManager.modules['VoiceModule']; if (vm && vm.speakText) vm.speakText(text); } this.terminal.print(`💬 AI answered (turn ${toolTurns}/${maxTurns}, ${thinkTime}s)`, 'info'); return { finalAnswer: text, toolTurns }; } if (res.tool_calls && res.tool_calls.length > 0) { // v4.6.30: progress-aware loop guard — counts STAGNATION, not repetition. // A repeated call whose result changed is progress and resets the counter. const now = Date.now(); const blocked = []; for (const tc of res.tool_calls) { // 2026-10-21: arguments can be an object (already parsed upstream). // `+ object` produced "name:[object Object]", collapsing every call to // the same tool into one key → false "called 3x" loop warnings when the // model legitimately batched distinct commands. const _argKey = (typeof tc.function.arguments === 'string') ? tc.function.arguments : JSON.stringify(tc.function.arguments || {}); const sig = tc.function.name + ':' + _argKey; let entry = _seenCalls.get(sig); if (!entry || (now - entry.ts) > _loopWindow) { entry = { count: 0, ts: now, resultHash: null, blocked: false, lastResult: null, epoch: this._toolEpoch }; } else if (entry.epoch !== this._toolEpoch) { // A mutation since the last attempt unblocks the replay, but the // repeat counter must survive: resetting it let an identical // mutating call loop forever (review 2026-10-21). entry.epoch = this._toolEpoch; entry.blocked = false; } entry.count++; entry.ts = now; _seenCalls.set(sig, entry); const argPreview = String(_argKey || '') .replace(/(token|key|secret|password|bearer)[^,}]*/ig, '$1:[redacted]') .slice(0, 60); if (_lp.enabled && (entry.blocked || entry.count >= _blockAt)) { entry.blocked = true; blocked.push({ tc, entry }); this.terminal.print(`🔄 Loop guard: ${_agentTag} repeated ${tc.function.name}(${argPreview}) ${entry.count}x with an unchanged result — serving cached result, not re-running`, 'warning'); continue; } if (_lp.enabled && entry.count === _warnAt) { this.terminal.print(`⚠️ Loop warning: ${_agentTag} called ${tc.function.name}(${argPreview}) ${entry.count}x — reuse the previous result or change the request`, 'warning'); } } if (blocked.length) { const _blockedIds = new Set(blocked.map(b => b.tc.id)); const _remaining = (res.tool_calls || []).filter(tc => !_blockedIds.has(tc.id)); if (_remaining.length === 0) { messages.push(res.message); for (const { tc, entry } of blocked) { messages.push({ role: 'tool', tool_call_id: tc.id, content: (entry.lastResult != null) ? entry.lastResult : JSON.stringify({ blocked: true, reason: 'identical call repeated with unchanged result', tool: tc.function.name }) }); } this.terminal.print(`🛑 Loop guard: ${blocked.length} repeated call(s) skipped — agent continues with cached data`, 'info'); const _hs = Math.min(_hardStopAt, maxTurns); if (blocked.some(b => b.entry.count >= _hs)) { this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hs} identical calls — ending loop with partial result`, 'error'); return { finalAnswer: this._loopSummary(toolTurns), toolTurns }; } continue; } // Mixed batch: keep the non-blocked calls, replay blocked ones after the // assistant message is pushed below. res.tool_calls = _remaining; this._pendingBlockedReplays = blocked; // MAINT-006: the hard stop was only reachable when EVERY call in the // batch was blocked, so a turn that mixed one repeated call with one // novel call never ended — the reported "agents loop on repetitive // turns". Apply the same ceiling on the mixed path. const _hsMixed = Math.min(_hardStopAt, maxTurns); if (blocked.some(b => b.entry.count >= _hsMixed)) { this.terminal.print(`⛔ ${_agentTag}: no progress after ${_hsMixed} identical calls — ending loop with partial result`, 'error'); return { finalAnswer: this._loopSummary(toolTurns), toolTurns }; } } this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${maxTurns} (${thinkTime}s)`, 'info'); messages.push(res.message); if (this._pendingBlockedReplays && this._pendingBlockedReplays.length) { for (const { tc, entry } of this._pendingBlockedReplays) { messages.push({ role: 'tool', tool_call_id: tc.id, content: entry.lastResult != null ? entry.lastResult : JSON.stringify({ blocked: true, tool: tc.function.name }) }); } this._pendingBlockedReplays = null; } let pendingText = res.message?.content || null; const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name, tc.function.arguments)); const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name, tc.function.arguments)); // Execute non-destructive tools SEQUENTIALLY — parallel fan-out corrupts // print-capture attribution, shadow-VFS snapshot/diff pairing, and telemetry // under interleaved awaits. Inference latency dominates anyway. const safeResults = []; for (const tc of safeTools) { const fn = tc.function; const t0 = Date.now(); const _cg = this._cacheGet(fn.name, fn.arguments); let toolResult; if (_cg.hit) { toolResult = _cg.result; this.terminal.print(`♻️ ${fn.name} → cached (${toolResult.length}b, not re-run)`, 'info'); } else { toolResult = await this.runTool(fn.name, fn.arguments); this._cacheSet(fn.name, _cg.key, toolResult); } const t1 = Math.round((Date.now() - t0) / 1000); this._toolCallLog.push({ turn: toolTurns, name: fn.name, args: fn.arguments, result: toolResult, duration: t1, error: this._isToolError(toolResult) ? true : false }); _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { this._toolCallLog = this._toolCallLog.slice(-50); } // Telemetry const sm = this.terminal.moduleManager.modules['ServerMonitorModule']; if (sm && sm.logToolCall) sm.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult)); if (this._debugPanelEnabled) this._updateDebugPanel(); safeResults.push({ tc, fn, toolResult, t1 }); } for (const { tc, fn, toolResult, t1 } of safeResults) { this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); // Memory graph: record tool call + file access try { const _mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; if (_mg) { _mg.recordToolCall(fn.name, !this._isToolError(toolResult)); if (fn.name === 'read_file' || fn.name === 'write_file' || fn.name === 'view_module' || fn.name === 'edit_module') { const _path = (typeof fn.arguments === 'object' ? (fn.arguments?.path || '') : ''); if (_path) _mg.recordFileAccess(_path); } } } catch (_e) { /* memory graph not available */ } const obs = this._summarizeObservation(toolResult, 150); const err = this._isToolError(toolResult); if (err) { this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${err} (${t1}s)`); this._trackToolFailure(fn.name, err); this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning'); } else if (toolResult && toolResult.length > 150) { this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); const safeId = 'toolresult-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); this.terminal.printHTML(`📊 Result (${t1}s): <span id="${safeId}-short">${this._escapeHtml(obs)} <a href="#" onclick="var e=document.getElementById('${safeId}-full');var s=document.getElementById('${safeId}-short');if(e){e.style.display='inline';s.style.display='none'}return false" style="color:var(--info-color);cursor:pointer">[+] show full (${toolResult.length} chars)</a></span><span id="${safeId}-full" style="display:none">${this._escapeHtml(toolResult)} <a href="#" onclick="var e=document.getElementById('${safeId}-full');var s=document.getElementById('${safeId}-short');if(e&&s){e.style.display='none';s.style.display='inline'}return false" style="color:var(--warning-color);cursor:pointer">[-] collapse</a></span>`); } else { this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); } try { const _sigR = fn.name + ':' + ((typeof fn.arguments === 'string') ? fn.arguments : JSON.stringify(fn.arguments || {})); const _eR = _seenCalls.get(_sigR); if (_eR) { const _rh = _sigHash(toolResult); if (_eR.resultHash !== null && _eR.resultHash !== _rh) _eR.count = 1; // progress → reset _eR.resultHash = _rh; _eR.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); } } catch (_e) {} // MAINT-023: clip on the way in — a single read_file page is 16 000 // chars and several of them used to enter the context verbatim. messages.push({ role: 'tool', tool_call_id: tc.id, content: this._clipToolResult(toolResult) }); this._noteObservation(fn.name, fn.arguments, toolResult, _agentTag); } // Only block on destructive tools if (destructiveTools.length > 0) { // 🎯 CONFIDENCE ESCALATION — auto-approve only if confidence >= threshold (from profile) let autoApprove = false; if (confEsc.enabled !== false && pendingText) { const confidence = this._parseConfidence(pendingText); if (confidence !== null && confidence >= (confEsc.threshold || 90)) { autoApprove = true; this.terminal.print(`🎯 AI confidence: ${confidence}% — auto-approved (threshold: ${confEsc.threshold || 90}%)`, 'info'); } else if (confidence !== null) { this.terminal.print(`🎯 AI confidence: ${confidence}% — below ${confEsc.threshold || 90}% threshold, requiring manual approval`, 'warning'); } else { this.terminal.print('🎯 No confidence declared for destructive action — requiring manual approval', 'warning'); } } const approved = autoApprove || await this.confirmActionMulti(destructiveTools); if (approved) { for (const tc of destructiveTools) { const fn = tc.function; const t0 = Date.now(); this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info'); const toolResult = await this.runTool(fn.name, fn.arguments); this._toolEpoch++; // D2: mutating action invalidates every cached read const t1 = Math.round((Date.now() - t0) / 1000); this._toolCallLog.push({ turn: toolTurns, name: fn.name, args: fn.arguments, result: toolResult, duration: t1, error: this._isToolError(toolResult) ? true : false }); _loopLog.push(this._toolCallLog[this._toolCallLog.length - 1]); // Auto-trim to 50 — prevents unbounded memory growth if (this._toolCallLog.length > 50) { this._toolCallLog = this._toolCallLog.slice(-50); } // Telemetry const sm2 = this.terminal.moduleManager.modules['ServerMonitorModule']; if (sm2 && sm2.logToolCall) sm2.logToolCall(fn.name, (t1 * 1000) || 1, this._isToolError(toolResult)); if (this._debugPanelEnabled) this._updateDebugPanel(); // Memory graph: record destructive tool call try { const _mg2 = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; if (_mg2) { _mg2.recordToolCall(fn.name, !this._isToolError(toolResult)); if (fn.name === 'write_file' || fn.name === 'edit_module') { const _path2 = (typeof fn.arguments === 'object' ? (fn.arguments?.path || '') : ''); if (_path2) _mg2.recordFileAccess(_path2); } } } catch (_e2) { /* memory graph not available */ } const dObs = this._summarizeObservation(toolResult, 150); const dErr = this._isToolError(toolResult); if (dErr) { this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`); this._trackToolFailure(fn.name, dErr); this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); } else { this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); } try { const _sigD = fn.name + ':' + ((typeof fn.arguments === 'string') ? fn.arguments : JSON.stringify(fn.arguments || {})); const _eD = _seenCalls.get(_sigD); if (_eD) { const _rhD = _sigHash(toolResult); if (_eD.resultHash !== null && _eD.resultHash !== _rhD) _eD.count = 1; _eD.resultHash = _rhD; _eD.lastResult = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); } } catch (_e) {} // MAINT-023: clip on the way in (same rule as the main loop). messages.push({ role: 'tool', tool_call_id: tc.id, content: this._clipToolResult(toolResult) }); this._noteObservation(fn.name, fn.arguments, toolResult, _agentTag); } } else { messages.push({ role: 'tool', tool_call_id: (destructiveTools[0] && destructiveTools[0].id) || undefined, content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied by user. Non-destructive actions already executed.` }); } } // Validator: check safe tool results for consistency if (safeResults.length > 0 && pendingText && !this._validatorSuppressed) { const om = this.terminal.ollamaManager; if (om && om.validatorEnabled && om.validatorModel) { let invalidTools = []; for (const { fn, toolResult } of safeResults) { const valid = await om.validateToolResult(fn.name, fn.arguments, toolResult, pendingText || ''); if (!valid && !this._cancelled) { invalidTools.push(fn.name); this.terminal.print('🔍 Validator: ' + fn.name + ' result — AI note may not match (logged)', 'info'); } } if (invalidTools.length > 0) { pendingText += '\n\n⚠️ **Validator flagged** ' + invalidTools.join(', ') + ' — the AI analysis may not match what was returned. Use `ai-ignore` to dismiss and proceed.'; } } } _noteProgress(safeResults); if (pendingText) { messages.push({ role: 'assistant', content: pendingText }); } continue; } return { finalAnswer: null, toolTurns }; } // Loop exited without final answer — synthesize from what we have instead of returning nothing const toolNames = Object.keys(this._commandFreq).slice(-3).join(', '); this.terminal.print(`🤖 AI reached the ${toolTurns}/${maxTurns} tool limit.`, 'warning'); this.terminal.print('Tools were executed but no final answer was produced — synthesizing from results.', 'info'); return { finalAnswer: this._loopSummary(toolTurns), toolTurns }; } _loopSummary(toolTurns) { const recent = (this._toolCallLog || []).slice(-5).map(e => '- ' + e.name + '(' + String(e.args || '').slice(0, 60) + '): ' + (e.error ? 'error' : this._summarizeObservation(e.result, 100)) ).join('\n'); return 'Stopped after ' + toolTurns + ' tool turns without a final model answer. Last results:\n' + (recent || '(no tool results)'); } async afterActionReflection(query, toolTurns) { // Debounce: only suggest skill creation once per 30 minutes const now = Date.now(); if (this._lastSkillSuggestion && (now - this._lastSkillSuggestion) < this._skillDebounceMs) { return; } const suggestions = []; const fs = this.terminal.fs; try { // Batch: read all status info in one VFS pass const reg = this.terminal.moduleManager.moduleRegistry || []; const total = reg.length; const loaded = Object.keys(this.terminal.moduleManager.modules).length; if (loaded < total) { suggestions.push(loaded + '/' + total + ' modules loaded — use loadmod to restore'); } } catch (e) {} try { const skillIdx = fs.fsReadJSON('/skills/index.json'); const skillCount = skillIdx ? Object.keys(skillIdx).length : 0; if (skillCount === 0 && toolTurns >= 4) { suggestions.push('Save this approach as a skill with create_skill'); this._lastSkillSuggestion = now; } } catch (e) {} try { const modsDir = fs.getCurrentDir(); const mc = modsDir.content['_mods']; const modCount = mc && mc.content ? Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')).length : 0; if (modCount > 0) { suggestions.push(modCount + ' pending module edit(s) in /_mods/ — use test_module to verify'); } } catch (e) {} try { if (performance.memory && performance.memory.usedJSHeapSize > 100 * 1048576) { suggestions.push('High memory (' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB) — consider clearing terminal'); } } catch (e) {} if (toolTurns >= 4 && suggestions.length === 0) { suggestions.push('Used ' + toolTurns + ' tools — create a skill to save this workflow'); this._lastSkillSuggestion = now; } if (suggestions.length > 0) { this.terminal.print('💡 ' + suggestions.join(' | '), 'info'); } } async confirmActionMulti(toolCalls) { // No-confirm mode: auto-approve if (this._confirmTimeoutMs === 0) { this.terminal.print(`⚡ No-confirm mode: auto-approving ${toolCalls.map(tc => tc.function.name).join(', ')}`, 'info'); return true; } const names = toolCalls.map(tc => tc.function.name).join(', '); const args = toolCalls.map(tc => typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments)).join(' | '); const truncatedArgs = args.length > 60 ? args.substring(0, 60) + '...' : args; const timeoutLabel = this._confirmTimeoutMs === 0 ? '' : ` (Auto-denies in ${this._confirmTimeoutMs / 1000}s)`; this.terminal.printHTML(`⚠️ AI wants to: ${names}(${truncatedArgs}). <span class="cmd-link" onclick="webx11.executeCommand('ai-allow')" style="color:var(--success-color);cursor:pointer;text-decoration:underline;">ai-allow</span> to confirm, <span class="cmd-link" onclick="webx11.executeCommand('ai-deny')" style="color:var(--error-color);cursor:pointer;text-decoration:underline;">ai-deny</span> to refuse.${timeoutLabel}`); // Show confirm bar OUTSIDE scrollable output (always visible) const area = document.getElementById('ai-confirm-area'); if (!area) { // Fallback to promise resolve return true; } area.innerHTML = ` <span class="confirm-desc">⚠️ ${names}(${truncatedArgs})</span> <button class="confirm-btn" id="ai-confirm-allow" onclick="(function(){var a=document.getElementById('ai-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> <button class="confirm-btn deny" id="ai-confirm-deny" onclick="(function(){var d=document.getElementById('ai-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> <span class="confirm-timer" id="ai-confirm-timer"></span> <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: ai-allow or ai-deny</span> `; area.classList.add('visible'); this._confirmArea = area; this._confirmBar = area; // Attach button handlers via both addEventListener + DOM property (inline onclick fallback) const allowBtn = document.getElementById('ai-confirm-allow'); const denyBtn = document.getElementById('ai-confirm-deny'); const onAllow = () => { this._resolveConfirm(true); }; const onDeny = () => { this._resolveConfirm(false); }; allowBtn.addEventListener('click', onAllow); denyBtn.addEventListener('click', onDeny); allowBtn.__allow = onAllow; denyBtn.__deny = onDeny; const startTime = Date.now(); return new Promise((resolve) => { this._pendingConfirmation = { names, args }; this._confirmResolve = resolve; this._confirmAllowBtn = allowBtn; this._confirmDenyBtn = denyBtn; this._confirmOnAllow = onAllow; this._confirmOnDeny = onDeny; // Countdown timer updates with escalating visibility const timerInterval = setInterval(() => { const elapsed = Math.round((Date.now() - startTime) / 1000); const remaining = Math.max(0, (this._confirmTimeoutMs / 1000) - elapsed); const timerEl = document.getElementById('ai-confirm-timer'); if (timerEl) { timerEl.textContent = `⏱ ${remaining}s`; timerEl.className = 'confirm-timer'; if (remaining > 30) { timerEl.style.color = 'var(--warning-color)'; } else if (remaining > 10) { timerEl.classList.add('urgent'); } else { timerEl.classList.add('critical'); } } if (remaining === 60) { this.terminal.print(`⏱ 60s remaining to respond to AI confirmation for: ${names}`, 'warning'); } else if (remaining === 30) { this.terminal.print(`⏱ 30s — ${names} will be auto-denied soon!`, 'warning'); } else if (remaining === 10) { this.terminal.print(`⏱ 10s remaining! Type ai-allow or click Allow now!`, 'warning'); } }, 500); this._confirmTimerInterval = timerInterval; this._confirmTimeout = setTimeout(() => { clearInterval(timerInterval); if (this._confirmResolve) { this._confirmResolve(false); this._cleanupConfirmBar(); this.terminal.print('⏱ Confirmation timed out — tool not executed', 'warning'); } }, this._confirmTimeoutMs); }); } _resolveConfirm(approved) { if (this._confirmResolve) { this._confirmResolve(approved); this._cleanupConfirmBar(); if (approved) { this.terminal.print('Action allowed', 'success'); } else { this.terminal.print('Action denied', 'warning'); } } } _cleanupConfirmBar() { clearInterval(this._confirmTimerInterval); clearTimeout(this._confirmTimeout); this._confirmTimeout = null; this._confirmTimerInterval = null; this._confirmResolve = null; this._pendingConfirmation = null; this._confirmAllowBtn = null; this._confirmDenyBtn = null; this._confirmOnAllow = null; this._confirmOnDeny = null; const area = document.getElementById('ai-confirm-area'); if (area) { area.classList.remove('visible'); area.innerHTML = ''; } this._confirmArea = null; this._confirmBar = null; const timerEl = document.getElementById('ai-confirm-timer'); if (timerEl) timerEl.style.animation = ''; } confirmPending() { this._resolveConfirm(true); } denyPending() { this._resolveConfirm(false); } ignoreValidator() { this._validatorSuppressed = true; this.terminal.print('🔍 Validator suppressed for this session', 'info'); } cancelAi() { this._cancelled = true; this.terminal.print('⏹ Cancelling AI...', 'warning'); // Abort any in-flight HTTP request if (this.terminal.ollamaManager) { this.terminal.ollamaManager.abort(); } // Clear any pending confirmation if (this._confirmResolve) { this._resolveConfirm(false); } // Immediately reset UI so user knows it stopped this.terminal.setAiBusy(false); } toggleDebugPanel() { const mod = this.terminal.moduleManager.modules['DebugPanel']; if (mod) { mod.terminal = this.terminal; if (!mod._seeded) { mod._seeded = true; mod.seed(); } mod.toggle(); } else { this.terminal.print('Debug panel not loaded. Run: module load debug-panel', 'warning'); } } toggleDebug() { this._debugPanelEnabled = !this._debugPanelEnabled; let panel = document.getElementById('debug-ai-panel'); if (!panel) { panel = document.createElement('div'); panel.id = 'debug-ai-panel'; panel.style.cssText = 'position:fixed;right:10px;top:50px;width:340px;max-height:350px;overflow-y:auto;background:#0a0a0a;border:1px solid #333;border-radius:6px;z-index:100;font-family:monospace;font-size:0.75em;display:none;'; document.body.appendChild(panel); } panel.style.display = this._debugPanelEnabled ? 'block' : 'none'; if (this._debugPanelEnabled) { this._updateDebugPanel(); this.terminal.print('🔧 AI debug panel enabled — shows tool call log. Close with: debug-ai', 'info'); } else { this.terminal.print('🔧 Debug panel disabled', 'info'); } } // 🔒 === GUARDRAIL LAYER === _loadGuardrailPolicy() { try { const raw = localStorage.getItem('webx11_guardrail_policy'); if (raw) { this._guardrailPolicy = JSON.parse(raw); } else { // Default policy — seed on first run this._guardrailPolicy = { version: 1, rules: [ { id: 'rm-rf-block', pattern: '/rm\\s+-rf/', description: 'Prevent recursive force deletes outside trash', severity: 'deny', tools: ['run_command', 'write_file'] }, { id: 'curl-pipe-bash', pattern: '/(curl|wget)\\s+.*\\|\\s*(bash|sh)/', description: 'Block curl-to-bash execution', severity: 'deny', tools: ['run_command'] }, { id: 'rm-outer-vfs', pattern: '/delete_file.*\\.(user\\.json|token|session|key)/', description: 'Block deletion of sensitive metadata files', severity: 'deny', tools: ['delete_file'] }, { id: 'mass-delete', pattern: '/rm\\s+-rf\\s+\\/|delete_file.*\\*|\\.*\\/\\*/', description: 'Block mass-delete operations', severity: 'deny', tools: ['run_command', 'delete_file'] }, { id: 'chmod-777', pattern: '/chmod\\s+777/', description: 'Block world-writable permissions', severity: 'deny', tools: ['run_command'] }, { id: 'external-dl', pattern: '/(wget|curl)\\s+-[oO]\\s+/', description: 'External file download — requires high confidence', severity: 'warn', tools: ['run_command'] }, { id: 'env-overwrite', pattern: '/export\\s+\\w+\\s*=\\s*/', description: 'Modifying environment variables', severity: 'warn', tools: ['run_command'] }, { id: 'eval-danger', pattern: '/(eval|exec|system)\\s*\\(/', description: 'Dynamic code execution', severity: 'deny', tools: ['run_command'] } ], descriptions: {} }; localStorage.setItem('webx11_guardrail_policy', JSON.stringify(this._guardrailPolicy)); } } catch (e) { this._guardrailPolicy = { version: 1, rules: [] }; } } _saveGuardrailPolicy() { try { localStorage.setItem('webx11_guardrail_policy', JSON.stringify(this._guardrailPolicy)); } catch (e) {} } checkGuardrail(toolName, args) { if (!this._guardrailPolicy || !this._guardrailPolicy.rules) return null; const argsStr = typeof args === 'object' ? JSON.stringify(args) : String(args); for (const rule of this._guardrailPolicy.rules) { if (!rule.tools.includes(toolName)) continue; try { const re = new RegExp(rule.pattern.slice(1, -1), 'i'); if (re.test(argsStr)) { this._guardrailViolations[rule.id] = (this._guardrailViolations[rule.id] || 0) + 1; return rule; // { id, pattern, description, severity, tools } } } catch (e) { /* skip malformed regex */ } } return null; } // 🎯 === CONFIDENCE ESCALATION === _parseConfidence(text) { if (!text || typeof text !== 'string') return null; // Look for patterns like "Confidence: 85%" or "confidence=95%" or "I am 72% confident" const patterns = [ /confidence[:\s]*(\d+)%/i, /confidence[=:]?\s*(\d+)\s*%/i, /(\d+)%\s*confiden/i, /confiden[ts]\s+(\d+)%/i ]; for (const re of patterns) { const m = text.match(re); if (m) return parseInt(m[1], 10); } return null; } // 📸 === SHADOW VFS (Sandboxed Execution) === _snapshotShadowVFS() { // Deep-clone the VFS root state into a snapshot try { const root = this.terminal.fs.root; this._shadowSnapshot = JSON.parse(JSON.stringify(root)); return true; } catch (e) { this.terminal.print('⚠️ Shadow VFS snapshot failed: ' + e.message, 'error'); return false; } } _diffShadowVFS(consume = true) { if (!this._shadowSnapshot) return null; try { const current = this.terminal.fs.root; const changes = []; this._walkDiff(this._shadowSnapshot, current, '', changes); if (consume) { this._shadowSnapshot = null; } return changes; } catch (e) { this._shadowSnapshot = null; return [{ type: 'diff-error', message: e.message }]; } } _walkDiff(snapshot, live, path, changes) { const snapKeys = snapshot && snapshot.content ? Object.keys(snapshot.content) : []; const liveKeys = live && live.content ? Object.keys(live.content) : []; // New files in live for (const key of liveKeys) { const livePath = path ? path + '/' + key : key; const liveEntry = live.content[key]; const snapEntry = snapshot.content ? snapshot.content[key] : undefined; if (!snapEntry) { const entrySize = liveEntry.type === 'file' ? (liveEntry.content || '').length : 0; changes.push({ type: 'created', path: livePath, entryType: liveEntry.type, size: entrySize }); } else if (liveEntry.type === 'file' && liveEntry.content !== snapEntry.content) { const oldLen = (snapEntry.content || '').length; const newLen = (liveEntry.content || '').length; changes.push({ type: 'modified', path: livePath, sizeBefore: oldLen, sizeAfter: newLen }); } // Recurse into directories if (liveEntry.type === 'dir' && snapEntry && snapEntry.type === 'dir') { this._walkDiff(snapEntry, liveEntry, livePath, changes); } } // Deleted files for (const key of snapKeys) { if (!liveKeys.includes(key)) { changes.push({ type: 'deleted', path: path ? path + '/' + key : key }); } } } // 🔒 === GUARDRAIL COMMANDS === guardrailCmd(args) { if (args.length === 0) { this.terminal.print('=== Guardrail Policy ===', 'info'); if (!this._guardrailPolicy) { this.terminal.print('No guardrail policy loaded', 'warning'); return; } this.terminal.print(`Rules: ${this._guardrailPolicy.rules.length}`, 'info'); for (const rule of this._guardrailPolicy.rules) { const icon = rule.severity === 'deny' ? '🔴' : '🟡'; this.terminal.print(`${icon} ${rule.id}: ${rule.description}`, 'info'); } const violations = Object.values(this._guardrailViolations).reduce((a, b) => a + b, 0); if (violations > 0) { this.terminal.print(`⚠️ ${violations} violation(s) this session`, 'warning'); } this.terminal.print('Usage: guardrail-add <id> <severity> <pattern> <tools...> | guardrail-remove <id>', 'info'); return; } const sub = args[0].toLowerCase(); if (sub === 'add' && args.length >= 4) { const id = args[1]; const severity = args[2]; const pattern = args[3]; const tools = args.slice(4); this._guardrailPolicy.rules.push({ id, severity, pattern, tools, description: 'Custom rule' }); this._saveGuardrailPolicy(); this.terminal.print(`✅ Guardrail rule '${id}' added`, 'success'); } else if (sub === 'remove' && args.length >= 2) { const idx = this._guardrailPolicy.rules.findIndex(r => r.id === args[1]); if (idx >= 0) { this._guardrailPolicy.rules.splice(idx, 1); this._saveGuardrailPolicy(); this.terminal.print(`✅ Guardrail rule '${args[1]}' removed`, 'success'); } else { this.terminal.print(`❌ Rule '${args[1]}' not found`, 'error'); } } else { this.terminal.print('Usage: guardrail-add <id> <severity> <pattern> <tools...> | guardrail-remove <id>', 'warning'); } } // 🎯 SHADOW COMMAND shadowExec(args) { // Usage: shadow <command> // Snapshot VFS, run command, diff output if (args.length === 0) { this.terminal.print('Usage: shadow <command> — Execute command in shadow VFS (sandbox preview)', 'warning'); return; } const cmd = args.join(' '); this._snapshotShadowVFS(); const cmdName = cmd.split(' ')[0].toLowerCase(); const cmdArgs = cmd.split(' ').slice(1); let output = ''; this.terminal._pushPrintCapture(buf => { output += buf; }); this.terminal.dispatchCommand(cmdName, cmdArgs).then(() => { this.terminal._popPrintCapture(); const diff = this._diffShadowVFS(false); if (!diff || diff.length === 0) { this.terminal.print('📦 Shadow: No filesystem changes detected.', 'success'); } else { this.terminal.print(`📦 Shadow: ${diff.length} change(s) detected:`, 'info'); for (const c of diff) { const icon = c.type === 'created' ? '➕' : c.type === 'modified' ? '✏️' : '🗑️'; const sizeStr = c.size !== undefined ? c.size + 'B' : ''; const detail = c.type === 'created' ? `(${c.entryType}${sizeStr ? ', ' + sizeStr : ''})` : c.type === 'modified' ? `(${c.sizeBefore}→${c.sizeAfter}B)` : ''; this.terminal.print(`${icon} ${c.path} ${detail}`, c.type === 'deleted' ? 'warning' : 'info'); } // Ask to commit or rollback this.terminal.print('🔒 VFS modified — type "shadow-commit" to accept or "shadow-rollback" to undo.', 'warning'); } }).catch(e => { this.terminal._popPrintCapture(); this.terminal.print('⚠️ Shadow command error: ' + e.message, 'error'); }); } shadowCommit() { this._shadowSnapshot = null; this.terminal.print('✅ Shadow changes committed to VFS', 'success'); } shadowRollback() { if (!this._shadowSnapshot) { this.terminal.print('❌ No shadow snapshot to rollback to', 'error'); return; } try { this.terminal.fs.root = JSON.parse(JSON.stringify(this._shadowSnapshot)); this.terminal.fs.saveToStorage(); this._shadowSnapshot = null; this.terminal.print('↩️ VFS rolled back to pre-shadow state', 'success'); } catch (e) { this.terminal.print('⚠️ Rollback failed: ' + e.message, 'error'); } } // 🔒 OVERRIDES for isDestructive — adds runTool intercept // Now command-aware: run_command is only destructive for mutating commands isDestructive(name, args = {}) { if (name === 'run_command') { const cmdStr = typeof args === 'object' ? (args?.command || '') : String(args); const cmd = cmdStr.split(' ')[0].toLowerCase(); // sed: read-only UNLESS -i (in-place write) or it starts a live tail -f timer if (cmd === 'sed' && /(^|\s)-i(\s|$)/.test(cmdStr)) return true; if (cmd === 'tail' && /(^|\s)-f\b|(^|\s)--follow\b/.test(cmdStr)) return false; // tail -f self-downgrades for AI (module-fs guard) const safeRead = new Set(['ls','cat','pwd','echo','date','env','whoami','which','hostname', 'cal','tree','find','grep','head','tail','sed','sort','wc','du','diff','basename','dirname', 'yes','seq','cut','uniq','expand','printf','base64','help','clear','set','export', 'unalias','history','ll','h','mem','status','df','free','top','ps','health-check', 'system-health','modules','listmods','modules-list','get-help-info','alias', 'nvfs-index','nvfs-status','nvfs-resolve','nvfs-tree','nvfs-config','nvfs-alias','nvfs-sync', 'floating-add','floating-remove','floating-list','floating-pos','floating-clear','floating-toggle', 'debug','debug-ai','session-new','mem-debug','reset-mem', // Synced from terminal whitelist — read-only orchestration/A2A/agent commands 'orchestrate','orchestrate-parallel','orchestrate-decompose','orchestrate-result', 'orchestrator-status','orchestrator-list','orchestrator-config','orchestrator-restart-policy', 'a2a-list','a2a-status','a2a-poll', 'consensus','consensus-status','consensus-history', 'pipeline','agent-list','agent-status','contract-list','voice','speak','analyze']); return !safeRead.has(cmd); } return ['write_file','delete_file','make_directory','edit_module','delegate_task','spawn_agent','create_skill','patch_skill','wpm_install','user_set','session_save'].includes(name); } async runTool(name, argsStr) { let a = {}; try { // 🔒 GUARDRAIL CHECK — intercept before any tool executes const guardrail = this.checkGuardrail(name, argsStr); if (guardrail && guardrail.severity === 'deny') { const errMsg = `🔒 GUARDRAIL BLOCKED: ${guardrail.description} (rule: ${guardrail.id})`; this.terminal.print(errMsg, 'error'); this.terminal.log('WARN', 'GUARDRAIL', errMsg); return JSON.stringify({ error: errMsg }); } if (guardrail && guardrail.severity === 'warn') { this.terminal.print(`🟡 GUARDRAIL WARNING: ${guardrail.description} (rule: ${guardrail.id})`, 'warning'); } a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { const originalPath = a.path || ''; const p = originalPath.replace(/^\//, '').replace(/\/+$/, ''); const fsapi = this.terminal.fs; const saved = [...fsapi.currentPath]; if (originalPath.startsWith('/')) fsapi.currentPath = []; // MAINT-011: readDir() resolves the virtual mounts and returns null for a // path that does not exist. The old hand-rolled walk over root.content could // not see /sys, so a directory the UI shows answered "Directory not found". // No argument means "the current directory", which ls() already does. const entries = p ? fsapi.readDir(p) : fsapi.ls(); fsapi.currentPath = saved; if (entries === null || entries === undefined) { return JSON.stringify({ error: `Directory not found: ${originalPath || p}` }); } return JSON.stringify(entries.map(f => ({ name: f.name, type: f.type, size: f.size }))); } case 'read_file': { const rp = a.path || ''; const savedR = [...this.terminal.fs.currentPath]; if (rp.startsWith('/')) this.terminal.fs.currentPath = []; const path = rp.replace(/^\//, ''); const content = this.terminal.fs.readFile(path); this.terminal.fs.currentPath = savedR; if (content === null || content === undefined) return JSON.stringify({ error: `File not found: ${path}` }); // Paged reads (2026-10-21): the old 8000-char hard cap made any // bigger file unreadable — the model could neither finish it nor // resume. Now the caller pages with offset/limit and gets next_offset. const total = content.length; let offset = Math.max(0, Math.min(parseInt(a.offset, 10) || 0, total)); const limit = Math.max(500, Math.min(parseInt(a.limit, 10) || 8000, 16000)); // Never split a surrogate pair (emoji/CJK-ext) across pages: // a lone surrogate makes JSON.stringify emit an invalid body. if (offset > 0) { const prev = content.charCodeAt(offset - 1); if (prev >= 0xD800 && prev <= 0xDBFF) offset += 1; } let slice = content.slice(offset, offset + limit); if (slice.length && slice.charCodeAt(slice.length - 1) >= 0xD800 && slice.charCodeAt(slice.length - 1) <= 0xDBFF) { slice = slice.slice(0, -1); } const nextOffset = offset + slice.length < total ? offset + slice.length : null; return JSON.stringify({ path: path, offset: offset, returned: slice.length, total_chars: total, truncated: nextOffset !== null, next_offset: nextOffset, content: slice }); } case 'write_file': { const wp = a.path || ''; const savedW = [...this.terminal.fs.currentPath]; if (wp.startsWith('/')) this.terminal.fs.currentPath = []; const path = wp.replace(/^\//, ''); const content = a.content || ''; // MAINT-012: writeFile() returns false when the parent directory is missing // or the target is a directory node. Ignoring it told the model the write // succeeded, and its follow-up read then found nothing. const wrote = this.terminal.fs.writeFile(path, content); this.terminal.fs.currentPath = savedW; if (!wrote) { const why = (typeof this.terminal.fs._isVirtualPath === 'function' && this.terminal.fs._isVirtualPath(wp)) ? 'read-only virtual path' : 'parent directory missing or target is a directory'; return `Write FAILED: ${String(wp).replace(/\/+$/, '') || '/'} — ${why}; nothing was written`; } return `File written: ${wp} (${content.length} chars)`; } case 'make_directory': { const mp = a.path || ''; const savedM = [...this.terminal.fs.currentPath]; if (mp.startsWith('/')) this.terminal.fs.currentPath = []; const path = mp.replace(/^\//, ''); const ok = this.terminal.fs.mkdir(path); this.terminal.fs.currentPath = savedM; if (ok) return `Directory created: ${path}`; return JSON.stringify({ error: `Cannot create: ${path}` }); } case 'delete_file': { const dp = a.path || ''; const savedD = [...this.terminal.fs.currentPath]; if (dp.startsWith('/')) this.terminal.fs.currentPath = []; const path = dp.replace(/^\//, ''); const delOk = this.terminal.fs.deleteFile(path); this.terminal.fs.currentPath = savedD; if (delOk) return `Deleted: ${path}`; return JSON.stringify({ error: `Cannot delete: ${path}` }); } case 'get_status': { const fs = this.terminal.fs; const mm = this.terminal.moduleManager; const om = this.terminal.ollamaManager; return JSON.stringify({ files: fs.ls().length, dirs: fs.currentPath.length ? '/' + fs.currentPath.join('/') : '/', modules: Object.keys(mm.modules).length, ollama: om.connected ? `Connected (${om.currentModel})` : 'Disconnected', memory: performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A' }); } case 'get_dashboard_data': { return JSON.stringify(await this._collectDashboardData()); } case 'get_help': { const cmds = this.terminal.moduleManager.getAllCommands(); return JSON.stringify(Object.keys(cmds).sort()); } case 'get_current_dir': { return '/' + this.terminal.fs.currentPath.join('/'); } case 'web_search': { const query = a.query || ''; if (!query) return JSON.stringify({ error: 'No query provided' }); try { const resp = await fetch('/api/search?q=' + encodeURIComponent(query)); if (!resp.ok) { const err = await resp.json().catch(() => ({})); return JSON.stringify({ error: 'Search failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' }); } const data = await resp.json(); const results = data.results || []; if (results.length === 0) return JSON.stringify({ message: 'No results found' }); return JSON.stringify({ count: results.length, source: data.source, results: results.map(r => ({ title: r.title, url: r.url, content: (r.content || '').substring(0, 300) })) }); } catch (e) { return JSON.stringify({ error: 'Search failed: ' + e.message }); } } case 'web_fetch': { const url = a.url || ''; if (!url) return JSON.stringify({ error: 'No URL provided' }); const method = (a.method || 'GET').toUpperCase(); const body = a.body || ''; const contentType = a.content_type || (method === 'POST' ? 'application/json' : ''); try { // Use /api/fetch for POST, /api/extract for GET (extract is cleaner for readable content) const endpoint = method === 'POST' ? '/api/fetch' : '/api/extract'; const params = new URLSearchParams({ url }); if (method === 'POST') { params.set('method', 'POST'); params.set('body', body); if (contentType) params.set('content_type', contentType); } const resp = await fetch(endpoint + '?' + params.toString()); if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); return JSON.stringify({ error: 'Fetch failed: ' + (err.error || `HTTP ${resp.status}`), status: resp.status, details: err.details || '' }); } // For POST/route, the response is raw text; for GET/extract it's JSON if (method === 'POST') { const text = await resp.text(); return JSON.stringify({ method, content: text.substring(0, 4000), content_length: text.length }); } const data = await resp.json(); return JSON.stringify({ method, title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method_proxy: data.method || 'extract' }); } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); } } case 'wpm_install': { const pkg = a.package || ''; if (!pkg) return JSON.stringify({ error: 'No package name' }); const mod = this.terminal.moduleManager.modules['WpmModule']; if (mod) { await mod.install(pkg); return `Package "${pkg}" installation attempted`; } return JSON.stringify({ error: 'WPM module not available' }); } case 'wpm_search': { const kw = a.keyword || ''; if (!kw) return JSON.stringify({ error: 'No keyword' }); const mod = this.terminal.moduleManager.modules['WpmModule']; if (mod) { let output = ''; this.terminal._pushPrintCapture(buf => { output += buf; }); try { await mod.search(kw); } finally { this.terminal._popPrintCapture(); } return output.trim() || 'Search completed'; } return JSON.stringify({ error: 'WPM module not available' }); } case 'run_command': { const cmd = a.command || ''; if (!cmd) return JSON.stringify({ error: 'No command provided' }); // 📸 Shadow VFS auto-snapshot before run_command this._snapshotShadowVFS(); let output = ''; // Loop guard (depth counter — race-safe under Promise.all tool fan-out): // unknown commands return an error instead of routing back to AI this.terminal.security._aiToolDepth++; this.terminal._pushPrintCapture(buf => { output += buf; }); try { await this.terminal.security.checkAndRun(cmd); } finally { this.terminal._popPrintCapture(); this.terminal.security._aiToolDepth--; } // Parse per-call captured output (race-free) — not shared security state const nfMatch = output.match(/Command not found: ([^\s.]+)(?:\. Did you mean: ([^\s.?]+)\?)?/); if (nfMatch) { return JSON.stringify({ error: `Command not found: ${nfMatch[1]}`, suggestion: nfMatch[2] || null }); } // 📸 Diff and append shadow report const shadowDiff = this._diffShadowVFS(); if (shadowDiff && shadowDiff.length > 0) { const diffSummary = shadowDiff.map(c => `${c.type}:${c.path}`).join(', '); output += `\n📦 [Shadow VFS: ${shadowDiff.length} change(s) — ${diffSummary}]`; this._shadowSnapshot = null; // Changes considered "committed" since AI explicitly ran command } return output.trim() || '(no output)'; } case 'check_updates': { const reg = this.terminal.moduleManager.moduleRegistry; const results = []; for (const mod of reg) { if (mod.url && mod.url.startsWith('http')) { try { const resp = await fetch(mod.url, { method: 'HEAD' }); results.push({ name: mod.name, url: mod.url, status: resp.ok ? 'reachable' : 'error (' + resp.status + ')' }); } catch (e) { results.push({ name: mod.name, url: mod.url, status: 'unreachable' }); } } } return JSON.stringify({ checked: results.length, modules: results }); } case 'user_get': { const key = a.key || ''; if (!key) return JSON.stringify({ error: 'Key is required' }); const val = key.split('.').reduce((o, k) => (o && typeof o === 'object') ? o[k] : undefined, this._userProfile); return JSON.stringify({ key, value: val !== undefined ? val : null }); } case 'user_set': { const ukey = a.key || ''; if (!ukey) return JSON.stringify({ error: 'Key and value are required' }); const uval = a.value || ''; const keys = ukey.split('.'); let obj = this._userProfile; for (let i = 0; i < keys.length - 1; i++) { if (!obj[keys[i]] || typeof obj[keys[i]] !== 'object') obj[keys[i]] = {}; obj = obj[keys[i]]; } obj[keys[keys.length - 1]] = uval; try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} return JSON.stringify({ success: true, key: ukey, value: uval }); } case 'user_list': { return JSON.stringify({ profile: this._userProfile, keys: Object.keys(this._userProfile) }); } case 'user_forget': { const fkey = a.key || ''; if (!fkey) return JSON.stringify({ error: 'Key is required' }); if (fkey.includes('.')) { const parts = fkey.split('.'); let obj = this._userProfile; for (let i = 0; i < parts.length - 1; i++) { if (!obj[parts[i]]) return JSON.stringify({ error: 'Key not found' }); obj = obj[parts[i]]; } delete obj[parts[parts.length - 1]]; } else { delete this._userProfile[fkey]; } try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} return JSON.stringify({ success: true, key: fkey }); } case 'session_save': { const summary = a.summary || 'No summary provided'; const fs = this.terminal.fs; fs.mkdirRecursive('/memory'); const today = new Date().toISOString().split('T')[0]; const log = { date: today, summary, toolCallCount: 0, skillsCreated: [], modulesEdited: [], timestamp: new Date().toISOString() }; fs.fsWriteJSON('/memory/' + today + '.json', log); const index = fs.fsReadJSON('/memory/index.json') || {}; index[today] = { summary, tasks: (index[today]?.tasks || 0) + 1, updated: new Date().toISOString() }; fs.fsWriteJSON('/memory/index.json', index); return JSON.stringify({ success: true, date: today }); } case 'session_load': { const sdate = a.date || ''; if (!sdate) return JSON.stringify({ error: 'Date required (YYYY-MM-DD)' }); const sfs = this.terminal.fs; const slog = sfs.fsReadJSON('/memory/' + sdate + '.json'); if (!slog) return JSON.stringify({ error: 'No session log for ' + sdate }); return JSON.stringify(slog); } case 'session_recent': { const count = a.count || 3; const rfs = this.terminal.fs; const rindex = rfs.fsReadJSON('/memory/index.json') || {}; const dates = Object.keys(rindex).sort().reverse().slice(0, count); const sessions = dates.map(d => ({ date: d, ...rindex[d] })); return JSON.stringify({ sessions, count: sessions.length }); } case 'session_terminal': { const tfs = this.terminal.fs; try { const raw = tfs.readFile('/_sessions/_auto.json'); if (!raw) return JSON.stringify({ error: 'No auto-saved session found' }); const data = JSON.parse(raw); const lines = (data.terminalLines || []).slice(-50); const text = lines.map(l => { const ts = l.t ? '[' + l.t + '] ' : ''; return ts + l.l; }).join('\n'); return JSON.stringify({ lines: lines.length, output: text, savedAt: data.savedAt || '' }); } catch (e) { return JSON.stringify({ error: 'Could not read terminal history: ' + e.message }); } } case 'session_stats': { const sfs = this.terminal.fs; const sindex = sfs.fsReadJSON('/memory/index.json') || {}; const entries = Object.entries(sindex); let totalTasks = 0; for (const [, meta] of entries) totalTasks += meta.tasks || 0; return JSON.stringify({ totalSessions: entries.length, totalTasks, dates: entries.map(([d]) => d) }); } case 'list_agents': { const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; if (!registry) return JSON.stringify({ error: 'Agent registry not available' }); const agents = this.terminal.fs.ls('/agents/'); const results = []; if (agents) { agents.forEach(a => { if (a.type === 'dir' && a.name !== 'configured') { const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); if (data) results.push(JSON.parse(data)); } }); } return JSON.stringify({ agents: results, count: results.length }); } case 'chat_hermes': { // v4.6.71: one channel, one store. Contract: _docs/chat-contract.md const ds = this.terminal.moduleManager.modules['DebugSupervisorModule']; if (!ds || typeof ds._chatPost !== 'function') { return JSON.stringify({ error: 'chat bridge unavailable' }); } try { if ((a.action || 'read') === 'send') { const text = String(a.text || '').trim(); if (!text) return JSON.stringify({ error: 'text is required for send' }); const j = await ds._chatPost(text, 'agent'); return JSON.stringify({ sent: true, id: j.id, epoch: j.epoch, note: 'asynchronous — call chat_hermes with action read for the answer' }); } const st = ds._chatThread(); // v4.6.76: a SEPARATE cursor from the terminal display. The UI poller // runs every 30 s and advances the shared cursor, so a read that used it // returned nothing — the answer was printed for the human and the model // never saw it. Two readers, two cursors. First read is from 0. const acKey = 'webx11_chat_agent_cursor_' + (st.thread || 'new'); let ac = 0; try { ac = parseInt(localStorage.getItem(acKey) || '0', 10) || 0; } catch (e) { ac = 0; } const resp = await fetch( '/spaces/newos4/api/chat/poll.php' + (ac ? '?since=' + ac : ''), { headers: { 'X-Chat-Token': st.token } }); const j = await resp.json().catch(() => null); if (!resp.ok || !j || !j.success) { return JSON.stringify({ error: (j && j.error) || ('HTTP ' + resp.status) }); } if (j.cursor) { try { localStorage.setItem(acKey, String(j.cursor)); } catch (e) {} } const msgs = (j.messages || []).slice(-10).map(m => ({ author: m.author, ts: m.ts, text: m.text })); return JSON.stringify({ awaiting: j.awaiting, messages: msgs, note: msgs.length ? undefined : (j.awaiting ? 'no reply yet — still awaiting Hermes' : 'no new messages since your last read') }); } catch (e) { return JSON.stringify({ error: e.message }); } } case 'delegate_task': { const task = a.task || ''; if (!task) return JSON.stringify({ error: 'No task provided' }); const orch = this.terminal.moduleManager.modules['OrchestratorModule']; if (!orch) return JSON.stringify({ error: 'Orchestrator not available' }); try { const result = await orch.delegateTask(task); return JSON.stringify({ success: true, task: task.substring(0, 80), result: typeof result === 'string' ? result.substring(0, 500) : 'completed' }); } catch (e) { return JSON.stringify({ error: 'Delegation failed: ' + e.message }); } } case 'spawn_agent': { const role = a.role || 'worker'; const caps = a.capabilities ? a.capabilities.split(',').map(s => s.trim()) : ['list_files', 'read_file']; const orch = this.terminal.moduleManager.modules['OrchestratorModule']; if (!orch) return JSON.stringify({ error: 'Orchestrator not available' }); const agent = orch.spawnAgent(role, caps); if (!agent) return JSON.stringify({ error: 'Agent spawn failed — limit reached or concurrent spawn conflict' }); return JSON.stringify({ message: `Agent spawned: ${agent.id}`, agent }); } case 'load_skills': { // v4.6.62: tokenised, body-aware search. The old matcher tested the // WHOLE query as one substring against name+description+tags, so word // order decided the outcome ("telemetry dashboard" = 0 while // "telemetry" = 1) and a sentence could never match anything. Skill // bodies were never searched at all (packet §4). const rawQuery = (a && a.query) || ''; const query = String(rawQuery).toLowerCase().trim(); const fs = this.terminal.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; if (!query) { const names = Object.keys(index); return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' }); } const tokens = this._skillTokens(query); const matches = []; for (const [name, meta] of Object.entries(index)) { const nameL = name.toLowerCase(); const descL = String(meta.description || '').toLowerCase(); const tagsL = (meta.tags || []).map(x => String(x).toLowerCase()); const bodyRaw = fs.readFile('/skills/' + name + '.md') || ''; const bodyL = bodyRaw.toLowerCase(); let score = 0; let matched = 0; for (const tok of tokens) { let hit = 0; if (nameL.includes(tok)) hit += 3; if (descL.includes(tok)) hit += 2; if (tagsL.some(x => x.includes(tok))) hit += 2; if (bodyL) { const n = bodyL.split(tok).length - 1; if (n > 0) hit += 1 + Math.min(n, 4) * 0.25; } if (hit > 0) { score += hit; matched++; } } if (!matched) continue; // phrase bonus: word order must never be able to produce a zero if ((nameL + ' ' + descL).includes(query)) score += 3; // coverage: matching 1 of 4 tokens is a weak hit, not a strong one score = score * (0.5 + 0.5 * (matched / tokens.length)); matches.push({ name: name, description: meta.description, category: meta.category, score: Math.round(score * 100) / 100, matchedTokens: matched + '/' + tokens.length, content: bodyRaw.substring(0, 2000) }); } matches.sort((x, y) => y.score - x.score || (x.name < y.name ? -1 : 1)); const top = matches.slice(0, 3); if (!top.length) { const names = Object.keys(index); return JSON.stringify({ matches: [], count: 0, tokens: tokens, message: 'No skill matched any of: ' + tokens.join(', ') + '. Closest skill names: ' + names.slice(0, 8).join(', ') + '. You can create one after this task.' }); } return JSON.stringify({ matches: top.map(m => ({ name: m.name, description: m.description, content: m.content, score: m.score, matchedTokens: m.matchedTokens })), count: top.length, tokens: tokens }); } case 'create_skill': { const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase(); if (!name) return JSON.stringify({ error: 'Skill name is required' }); const description = a.description || ''; let content = a.content || ''; const tags = (a.tags || '').split(',').map(t => t.trim()).filter(Boolean); const category = a.category || 'general'; if (!content.startsWith('---')) { content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: ${category}\n---\n## Steps\n${content}`; } const fs = this.terminal.fs; fs.mkdirRecursive('/skills'); fs.writeFile(`/skills/${name}.md`, content); const index = fs.fsReadJSON('/skills/index.json') || {}; index[name] = { name, description, tags, category, created: index[name]?.created || new Date().toISOString(), updated: new Date().toISOString() }; fs.fsWriteJSON('/skills/index.json', index); this.terminal.print(`💡 Skill saved: ${name}`, 'success'); return JSON.stringify({ success: true, name, message: `Skill "${name}" created (${content.length} chars)` }); } case 'patch_skill': { const pname = (a.name || '').trim(); if (!pname) return JSON.stringify({ error: 'Skill name is required' }); const pcontent = a.content || ''; if (!pcontent) return JSON.stringify({ error: 'Skill content is required' }); const fs = this.terminal.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` }); fs.writeFile(`/skills/${pname}.md`, pcontent); index[pname].updated = new Date().toISOString(); // Re-parse frontmatter for metadata updates const metaMatch = pcontent.match(/^---\n([\s\S]*?)\n---/); if (metaMatch) { const metaLines = metaMatch[1].split('\n'); metaLines.forEach(line => { const [k, ...v] = line.split(':'); if (k === 'description') index[pname].description = v.join(':').trim(); if (k === 'tags') index[pname].tags = v.join(':').trim().replace(/[\[\]]/g, '').split(',').map(t => t.trim()); if (k === 'category') index[pname].category = v.join(':').trim(); }); } fs.fsWriteJSON('/skills/index.json', index); this.terminal.print(`💡 Skill patched: ${pname}`, 'success'); return JSON.stringify({ success: true, name: pname, message: `Skill "${pname}" updated` }); } case 'list_skills': { const filterCat = (a.category || '').toLowerCase(); const fs = this.terminal.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; const list = Object.entries(index).map(([name, meta]) => ({ name, description: meta.description, category: meta.category || 'general', tags: meta.tags || [], updated: meta.updated })) .filter(s => !filterCat || (s.category || '').toLowerCase() === filterCat); return JSON.stringify({ skills: list, count: list.length }); } case 'nvfs_resolve': { const concept = (a.concept || '').toLowerCase(); if (!concept) return JSON.stringify({ error: 'Concept is required' }); const nvfs = this.terminal.moduleManager.modules['NeuralVFSModule']; if (!nvfs) return JSON.stringify({ error: 'Neural VFS module not loaded — run nvfs-index first' }); // Build index if empty if (Object.keys(nvfs.index).length === 0) { const saved = this.terminal.fs.fsReadJSON('/.semantic_index/map.json'); if (saved) { nvfs.index = saved; } else { return JSON.stringify({ error: 'No semantic index found — run nvfs-index first' }); } } const exact = nvfs.index[concept]; const fuzzy = Object.keys(nvfs.index).filter(k => k.includes(concept) || concept.includes(k) ).filter(k => k !== concept); const results = []; if (exact) results.push(...exact.map(e => ({ concept, path: e.path, summary: e.summary }))); for (const f of fuzzy.slice(0, 5)) { results.push(...nvfs.index[f].slice(0, 3).map(e => ({ concept: f, path: e.path, summary: e.summary }))); } return JSON.stringify({ concept, exact: !!exact, count: results.length, results: results.slice(0, 20) }); } case 'list_available_modules': { try { const resp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); if (!resp.ok) return JSON.stringify({ error: 'Module index not available' }); const idx = await resp.json(); const filterType = (a.type || '').toLowerCase(); let mods = Object.values(idx.modules); if (filterType) mods = mods.filter(m => m.type === filterType); return JSON.stringify({ total: mods.length, available: idx.total, type: filterType || 'all', modules: mods.map(m => ({ name: m.name, type: m.type, class: m.class, commands: m.commands.slice(0, 8), description: (m.description || '').substring(0, 80), auto_load: m.auto_load })) }); } catch (e) { return JSON.stringify({ error: 'Failed to load module index: ' + e.message }); } } case 'smart_context_list': { const scm = this.terminal.moduleManager.modules['SmartContextModule']; if (!scm) return JSON.stringify({ error: 'SmartContextModule not loaded', profiles: [] }); const profiles = scm.contextProfiles || {}; return JSON.stringify({ profiles: Object.entries(profiles).map(([key, p]) => ({ name: key, displayName: p.name || key, systemPrompt: (p.systemPrompt || '').substring(0, 80), temperature: p.temperature || 0.7, active: scm.activeProfile === key })) }); } case 'smart_context_set': { const scm = this.terminal.moduleManager.modules['SmartContextModule']; if (!scm) return JSON.stringify({ error: 'SmartContextModule not loaded' }); const name = (a.name || '').toLowerCase(); if (!scm.contextProfiles[name]) { return JSON.stringify({ error: `Profile '${name}' not found`, available: Object.keys(scm.contextProfiles) }); } scm.activeProfile = name; scm.saveActiveProfile(); const profile = scm.contextProfiles[name]; // Rebuild system prompt to reflect new context profile this.systemPrompt = this.buildSystemPrompt(); // Also switch agent loop profile to match const PROFILE_MAP = { 'coding': 'coder', 'debug': 'minimal', 'creative': 'creative', 'technical': 'research', 'chat': 'default', 'system-admin': 'safe', 'research': 'research', 'security': 'safe' }; const targetLoop = PROFILE_MAP[name]; if (targetLoop && this._switchLoopProfile) { this._switchLoopProfile(targetLoop); } return JSON.stringify({ success: true, name, displayName: profile.name, systemPrompt: profile.systemPrompt, temperature: profile.temperature, agentLoop: targetLoop || null }); } case 'create_mindmap': { const mindmap = this.terminal.moduleManager.modules['MindmapModule']; if (!mindmap) return JSON.stringify({ error: 'MindmapModule not loaded' }); const topic = a.topic || 'Mindmap'; const children = a.children || ''; const args = topic.split(' '); mindmap._new(args); const cur = mindmap._ensureMindmap(); if (!cur) return JSON.stringify({ error: 'Failed to create mindmap' }); const rootId = cur.root.id; const added = []; if (children) { const childNames = children.split(',').map(c => c.trim()).filter(Boolean); for (const child of childNames) { mindmap._add([rootId, child]); added.push(child); } } mindmap._svg([]); return JSON.stringify({ success: true, topic, children: added.length ? added : 'none', nodes: mindmap._countNodes(cur.root), rendered: 'inline SVG in terminal' }); } case 'view_module': case 'edit_module': case 'test_module': case 'reload_module': case 'list_modified_modules': case 'get_module_status': return await this.runModuleTool(name, argsStr); case 'vaultmind_connect': { const network = a.network || 'testnet'; try { // Read Hyperliquid key from SecretVault const vault = window.webx11_secretVault; if (!vault) return JSON.stringify({ error: 'SecretVault not available — unlock it first with secret-unlock <pin>' }); const privateKey = await vault.decrypt('hyperliquid_key'); if (!privateKey) return JSON.stringify({ error: 'No hyperliquid_key found in SecretVault. Store it first: secret-set hyperliquid_key 0x...' }); // Authenticate with VaultMind backend const resp = await fetch('/vmai-api/auth/wallet', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ private_key: privateKey, account_address: null, network: network }) }); if (!resp.ok) { const errText = await resp.text(); return JSON.stringify({ error: 'VaultMind auth failed (' + resp.status + '): ' + errText }); } const data = await resp.json(); if (data.token) { localStorage.setItem('vaultmind_token', data.token); return JSON.stringify({ success: true, status: data.status || 'connected', network: data.network || network, message: 'Connected to VaultMind on ' + (data.network || network) }); } return JSON.stringify({ error: 'No token returned from VaultMind', data: data }); } catch (e) { return JSON.stringify({ error: 'VaultMind connect error: ' + e.message }); } } case 'vaultmind_status': { try { const token = localStorage.getItem('vaultmind_token'); if (!token) return JSON.stringify({ connected: false, message: 'No VaultMind session token found. Use vaultmind_connect to authenticate.' }); // Try to verify by calling a lightweight endpoint try { const resp = await fetch('/vmai-api/portfolio', { headers: { 'Authorization': 'Bearer ' + token } }); if (resp.ok) return JSON.stringify({ connected: true, token_exists: true, token_prefix: token.substring(0, 12) + '...', message: 'VaultMind session is active and valid.' }); if (resp.status === 401) return JSON.stringify({ connected: false, token_exists: true, message: 'Token exists but is expired or invalid. Reconnect with vaultmind_connect.' }); return JSON.stringify({ connected: true, token_exists: true, message: 'Token exists (status: ' + resp.status + ').' }); } catch (e) { return JSON.stringify({ connected: true, token_exists: true, message: 'Token exists but backend unreachable (' + e.message + ').' }); } } catch (e) { return JSON.stringify({ error: 'VaultMind status error: ' + e.message }); } } case 'vaultmind_query': { const ep = a.endpoint; const validEps = ['prices', 'portfolio', 'transactions', 'copy-trade', 'chat']; if (!validEps.includes(ep)) { return JSON.stringify({ error: 'Invalid endpoint. Valid: ' + validEps.join(', ') }); } try { const epMap = { prices: '/vmai-api/market/prices', portfolio: '/vmai-api/portfolio', transactions: '/vmai-api/transactions/summary', 'copy-trade': '/vmai-api/copy-trading/traders', chat: '/vmai-api/chat' }; let url = epMap[ep]; let opts = { method: 'GET', headers: { 'Accept': 'application/json' } }; // Portfolio, transactions, copy-trade need auth token const authEps = ['portfolio', 'transactions', 'copy-trade']; if (authEps.includes(ep)) { const token = localStorage.getItem('vaultmind_token'); if (token) opts.headers['Authorization'] = 'Bearer ' + token; } if (ep === 'chat') { opts.method = 'POST'; opts.headers['Content-Type'] = 'application/json'; const token = localStorage.getItem('vaultmind_token'); if (token) opts.headers['Authorization'] = 'Bearer ' + token; opts.body = JSON.stringify({ message: a.message || 'What is the current market status?', agent: a.agent || 'market' }); } const resp = await fetch(url, opts); const data = await resp.json(); return JSON.stringify({ success: true, data }); } catch (e) { return JSON.stringify({ error: 'VaultMind API error: ' + e.message }); } } case 'wallet_lookup': { const addr = a.address; if (!addr) return JSON.stringify({ error: 'Wallet address required' }); try { const resp = await fetch('/wallet/' + encodeURIComponent(addr)); const data = await resp.json(); return JSON.stringify({ success: true, data }); } catch (e) { return JSON.stringify({ error: 'Wallet lookup error: ' + e.message }); } } case 'get_usage_stats': { const om = this.terminal.ollamaManager; if (!om) return JSON.stringify({ error: 'Ollama manager not available' }); const filters = {}; if (a.model) filters.model = a.model; if (a.provider) filters.provider = a.provider; if (a.since_hours) filters.since_hours = a.since_hours; const stats = om.getUsageStats(filters); return JSON.stringify({ success: true, stats }); } case 'agent_loop_list': { var profiles = this._loopProfiles; var list = Object.keys(profiles).map(function(k) { var p = profiles[k]; return { name: k, displayName: p.displayName, description: p.description, active: this._activeLoopProfile === p, maxTurns: p.loop ? p.loop.maxTurns : this.MAX_TOOL_TURNS, toolsMode: p.tools ? p.tools.mode : 'all', benchmark: p.benchmark || null }; }.bind(this)); return JSON.stringify({ profiles: list, count: list.length, active: this._activeLoopProfile ? this._activeLoopProfile.name : 'none' }); } case 'agent_loop_switch': { var name = (a.name || '').trim(); if (!name) return JSON.stringify({ error: 'Profile name required' }); if (!this._loopProfiles[name]) return JSON.stringify({ error: "Profile '" + name + "' not found", available: Object.keys(this._loopProfiles) }); this._switchLoopProfile(name); return JSON.stringify({ success: true, name: name, message: "Switched to '" + name + "'" }); } case 'agent_loop_create': { var name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase(); if (!name) return JSON.stringify({ error: 'Name required' }); var base = this._defaultLoopProfile(name, a.description || 'Custom profile'); if (a.maxTurns) base.loop.maxTurns = a.maxTurns; if (a.toolsMode) base.tools.mode = a.toolsMode; if (a.toolsEnabled) base.tools.enabled = a.toolsEnabled; if (a.toolsDisabled) base.tools.disabled = a.toolsDisabled; if (a.prependPrompt) base.systemPrompt.prepend = a.prependPrompt; if (a.appendPrompt) base.systemPrompt.append = a.appendPrompt; this._loopProfiles[name] = base; this._saveLoopProfiles(); return JSON.stringify({ success: true, name: name, message: "Profile '" + name + "' created" }); } case 'agent_loop_benchmark': { var profileName = a.profile || (this._activeLoopProfile ? this._activeLoopProfile.name : 'default'); var taskIds = a.tasks; this._runBenchmark(profileName, taskIds).then(function(results) { this.terminal.print("📊 Benchmark complete for '" + profileName + "'", 'success'); }.bind(this)).catch(function(e) { this.terminal.print('Benchmark error: ' + e.message, 'error'); }.bind(this)); return JSON.stringify({ success: true, message: "Benchmark started on '" + profileName + "'", tasks: taskIds || 'all' }); } case 'predict_task': { var predictor = this.terminal.moduleManager.modules['PredictorModule']; if (!predictor) return JSON.stringify({ error: 'Predictor module not loaded' }); var taskDesc = a.task || ''; if (!taskDesc) return JSON.stringify({ error: 'task parameter required' }); var pFeatures = predictor._extractFeatures(taskDesc); var pSimilar = predictor._findSimilar(pFeatures); // v4.6.56: track the prediction so the outcome can score it. var pTracked; if (typeof predictor.notePrediction === 'function') { pTracked = predictor.notePrediction(taskDesc, 'tool'); } else { var pFallback = predictor._computePrediction(pSimilar, pFeatures); pTracked = { id: null, predictedDuration: pFallback.duration, predictedTurns: pFallback.toolTurns, confidence: pFallback.confidence }; } return JSON.stringify({ task: taskDesc.substring(0, 80), predictionId: pTracked.id || null, estimatedDuration: pTracked.predictedDuration + 's', estimatedToolTurns: pTracked.predictedTurns, confidence: Math.round(pTracked.confidence * 100) + '%', similarTasks: pSimilar.length, recommendation: pTracked.confidence >= predictor._CONFIDENCE_THRESHOLD && pTracked.predictedDuration > 30 ? 'Set TTL to ' + Math.ceil(pTracked.predictedDuration * 1.5) + 's' : 'No special action needed', tracked: !!pTracked.id }); } case 'world_model_query': { var wm = this.terminal.moduleManager.modules['WorldModelModule']; if (!wm) return JSON.stringify({ error: 'WorldModelModule not loaded' }); if (!wm._model) return JSON.stringify({ error: 'World model not synthesized yet — run world-model-refresh' }); var wmOut = { question: a.question, facts: wm._answerQuery(a.question || ''), version: wm._model.version, synthesized: wm._model.synthesized, // v4.6.83: this was JSON.parse(wm._modelDigest()) and the digest was a // string truncated mid-token, so the default path always threw // "Unexpected end of JSON input". The model now hands back an object. snapshot: (a.detail === 'full') ? wm._model : wm._modelSnapshot() }; return JSON.stringify(wmOut); } case 'health_check': case 'diff_module': case 'ship_module': case 'commit_module': return await this.runModuleTool(name, argsStr); default: return JSON.stringify({ error: `Unknown tool: ${name}` }); } } catch (e) { this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); if (this._errorLog.length > 20) this._errorLog.shift(); // Also feed into DebugSupervisorModule try { var ds = this.terminal?.moduleManager?.modules?.DebugSupervisorModule; if (ds) ds.recordToolError(name, a, e.message); } catch (dsErr) {} return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); } } // v4.6.62: query tokeniser for load_skills — lowercase, split on non-word chars, // drop short words and stopwords. A sentence query keeps its content tokens instead // of collapsing to a phrase that can never match. _skillTokens(query) { const STOP = new Set(['the','and','for','with','how','what','when','which','who','why', 'does','did','can','could','should','would','you','your','our','their','this','that', 'these','those','from','into','about','over','under','use','using','used','get','got', 'make','made','need','want','please','help','me','my','we','it','is','are','was','were', 'be','been','to','of','in','on','at','by','or','not','no','do','i','a','an','as','if']); const seen = new Set(); const out = []; String(query || '').toLowerCase().split(/[^a-z0-9_\-]+/).forEach(w => { if (!w || w.length < 3 || STOP.has(w) || seen.has(w)) return; seen.add(w); out.push(w); }); // A query made entirely of stopwords still deserves a literal attempt. if (!out.length) { const literal = String(query || '').trim(); if (literal) out.push(literal); } return out; } async runModuleTool(name, argsStr) { let a = {}; try { a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'view_module': { const modName = a.name || ''; const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); const url = entry ? entry.url : null; // v4.6.57: deployed source FIRST. The unversioned `_mods/<Name>.js` edit cache // used to shadow it forever, so view_module returned pre-fix code while the // running bundle was new (packet §10). The cache is only a labelled fallback. const deployedVer = (typeof localStorage !== 'undefined' && localStorage.getItem('webx11_cache_ver')) || 'dev'; // Try registry URL first if (url) { try { const resp = await fetch(url); if (resp.ok) { const src = await resp.text(); this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src); return '// deployed: ' + url + ' @ ' + deployedVer + '\n' + src; } } catch (e) { /* fall through */ } } // Fallback: use modules-index.json to map class name to filename try { const idxResp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); if (idxResp.ok) { const idx = await idxResp.json(); // modName is the class name (e.g. "WebX11AIAssistantModule") // or already a filename (e.g. "module-aiassist") // Try matching by class name first let match = Object.values(idx.modules).find(m => m.class === modName); // Then by name if (!match) match = idx.modules[modName]; if (match) { const filePath = match.file; // e.g. "modules/core/module-aiassist.js" const fetchUrl = `/spaces/newos4/${filePath}`; const resp = await fetch(fetchUrl); if (resp.ok) { const src = await resp.text(); this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src); return '// deployed: ' + fetchUrl + ' @ ' + deployedVer + '\n' + src; } } } } catch (e) { /* fall through */ } const cachedSrc = this.terminal.fs.readFile(`_mods/${modName}.js`); if (cachedSrc) { return '// VFS COPY of ' + modName + '.js — may be stale (not the deployed file)\n' + cachedSrc; } return JSON.stringify({ error: `Cannot find source for ${modName} — not in registry, not in modules-index.json` }); } case 'edit_module': { const modName = a.name || ''; const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); const url = entry ? entry.url : null; let code = this.terminal.fs.readFile(`_mods/${modName}.js`); if (!code && url) { try { const resp = await fetch(url); if (resp.ok) { code = await resp.text(); this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, code); this.terminal.fs.writeFile(`_mods/${modName}.js`, code); this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ originalUrl: url, modifiedAt: new Date().toISOString(), editCount: 0 })); } } catch (e) {} } // Fallback: use modules-index.json for core modules (not in registry) if (!code) { try { const idxResp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); if (idxResp.ok) { const idx = await idxResp.json(); let match = Object.values(idx.modules).find(m => m.class === modName); if (!match) match = idx.modules[modName]; if (match) { const filePath = match.file; const fetchUrl = `/spaces/newos4/${filePath}`; const resp = await fetch(fetchUrl); if (resp.ok) { code = await resp.text(); this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, code); this.terminal.fs.writeFile(`_mods/${modName}.js`, code); this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ originalUrl: fetchUrl, modifiedAt: new Date().toISOString(), editCount: 0 })); } } } } catch (e) {} } if (code) this.terminal.openCodeEditor(modName, url || '', code); return code ? `Editor opened for ${modName} (${code.length} chars)` : JSON.stringify({ error: `Could not load source for ${modName}` }); } case 'test_module': { const modName = a.name || ''; const tests = a.tests || null; const code = this.terminal.fs.readFile(`_mods/${modName}.js`); if (!code) return JSON.stringify({ error: `No local edit for ${modName}` }); // Save current module state for rollback const oldInstance = this.terminal.moduleManager.modules[modName]; const oldCommands = oldInstance ? Object.keys(oldInstance.commands) : []; // Load new version const ok = await this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`); if (!ok) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; return JSON.stringify({ error: 'Module failed to load', rolledBack: true }); } // Check for JS errors after load const jsErrors = await this._captureConsoleErrors(300); if (jsErrors.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; return JSON.stringify({ error: 'JS errors after load', errors: jsErrors.slice(0, 5), rolledBack: true }); } // Verify commands registered const newInstance = this.terminal.moduleManager.modules[modName]; const newCommands = newInstance ? Object.keys(newInstance.commands) : []; const lostCommands = oldCommands.filter(c => !newCommands.includes(c)); if (lostCommands.length > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; return JSON.stringify({ error: 'Commands lost after edit', lost: lostCommands, rolledBack: true }); } // Run test assertions if provided if (tests && Array.isArray(tests) && tests.length > 0) { const results = []; for (const t of tests) { const cmd = t.command || ''; // Use shell parser for commands with >, |, &&, ||, ;, export, set const useShell = /[>|&;]/.test(cmd) || cmd.startsWith('export ') || cmd.startsWith('set '); const output = useShell ? await this._captureShellCommand(cmd) : await this._captureCommandOutput(cmd); const passed = t.expect ? output.includes(t.expect) : output.length > 0; results.push({ command: cmd, expected: t.expect || '(any output)', actual: output.substring(0, 200), pass: passed }); } const failed = results.filter(r => !r.pass).length; if (failed > 0) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; return JSON.stringify({ error: failed + '/' + results.length + ' tests failed', results, rolledBack: true }); } return JSON.stringify({ success: true, module: modName, commands: newCommands.length, tests: results }); } // Smoke test: run first command if available if (newCommands.length > 0) { try { const smokeOut = await this._captureCommandOutput(newCommands[0]); return JSON.stringify({ success: true, module: modName, commands: newCommands.length, smokeTest: { command: newCommands[0], output: smokeOut.substring(0, 200) } }); } catch (e) { if (oldInstance) this.terminal.moduleManager.modules[modName] = oldInstance; return JSON.stringify({ error: 'Smoke test failed: ' + e.message, rolledBack: true }); } } return JSON.stringify({ success: true, module: modName, commands: newCommands.length }); } case 'reload_module': { const modName = a.name || ''; const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); this.terminal.fs.deleteFile(`_mods/${modName}.js`); this.terminal.fs.deleteFile(`_mods/${modName}.orig.js`); this.terminal.fs.deleteFile(`_mods/${modName}.meta.json`); if (entry && entry.url) { this.terminal.moduleManager.unloadModule(modName); await this.terminal.moduleManager.loadModuleFromURL(entry.url); return `Module "${modName}" reloaded from ${entry.url}`; } return JSON.stringify({ error: `No original URL for ${modName}` }); } case 'list_modified_modules': { const modsDir = this.terminal.fs.getCurrentDir(); const mc = modsDir.content['_mods']; if (!mc || Object.keys(mc.content).length === 0) return JSON.stringify({ modified: [], message: 'No local edits' }); const files = Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js')); return JSON.stringify({ modified: files.map(f => f.replace('.js', '')), count: files.length }); } case 'get_module_status': { const mm = this.terminal.moduleManager; const modsDir = this.terminal.fs.getCurrentDir(); const hasMods = modsDir.content['_mods'] ? !!Object.keys(modsDir.content['_mods'].content).length : false; // Load module index for file path lookup let idxMap = {}; try { const resp = await fetch('/spaces/newos4/modules-index.json?v=' + (localStorage.getItem('webx11_cache_ver') || '')); if (resp.ok) { const idx = await resp.json(); for (const [name, meta] of Object.entries(idx.modules)) { idxMap[meta.class] = '/spaces/newos4/' + meta.file; } } } catch (e) {} const modules = []; const seen = new Set(); for (const [className, instance] of Object.entries(mm.modules)) { const name = instance.constructor ? instance.constructor.name : className; if (seen.has(name)) continue; seen.add(name); modules.push({ name: name, url: idxMap[name] || null, enabled: true, loaded: true, modified: !!this.terminal.fs.readFile(`_mods/${name}.js`), source: 'loaded' }); } for (const m of mm.moduleRegistry) { if (seen.has(m.name)) continue; seen.add(m.name); modules.push({ name: m.name, url: m.url || idxMap[m.name] || null, enabled: m.enabled, loaded: !!mm.modules[m.name], modified: !!this.terminal.fs.readFile(`_mods/${m.name}.js`), source: m.url ? 'ext' : 'core' }); } return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods }); } case 'health_check': { const results = {}; const coreCmds = ['ls', 'pwd', 'echo test', 'help', 'grep', 'find', 'tree', 'cat']; results.coreCommands = {}; for (const cmd of coreCmds) { try { const out = await this._captureCommandOutput(cmd); results.coreCommands[cmd] = (out.length > 0 || cmd === 'ls') ? 'ok' : 'empty output'; } catch (e) { results.coreCommands[cmd] = 'FAIL: ' + e.message; } } const mm = this.terminal.moduleManager; results.moduleCount = Object.keys(mm.modules).length; const wm = this.terminal.fs.fsReadJSON('system/world-model.json'); results.worldModelVersion = wm ? wm.version : 'not found'; results.versionMatch = wm && wm.version === (typeof window !== 'undefined' ? window.NEWOS4_VERSION : ''); const orch = mm.modules['OrchestratorModule']; if (orch) { const agents = Object.values(orch._agents); results.agents = { total: agents.length, healthy: agents.filter(a => a.status !== 'failed').length, failed: agents.filter(a => a.status === 'failed').length }; } results.jsErrors = await this._captureConsoleErrors(500); const allCoreOk = Object.values(results.coreCommands).every(v => v === 'ok'); results.healthy = allCoreOk && results.versionMatch && results.jsErrors.length === 0; return JSON.stringify(results); } case 'diff_module': { const modName = a.name || ''; const newCode = this.terminal.fs.readFile(`_mods/${modName}.js`); if (!newCode) return JSON.stringify({ error: `No edit for ${modName}` }); let origCode = this.terminal.fs.readFile(`_mods/${modName}.orig.js`); if (!origCode || origCode === 'placeholder') { const instance = this.terminal.moduleManager.modules[modName]; if (instance) origCode = instance.constructor.toString(); } if (!origCode) return JSON.stringify({ error: 'No original source found' }); const origLines = origCode.split('\n'); const newLines = newCode.split('\n'); const changes = []; const maxLines = Math.max(origLines.length, newLines.length); for (let i = 0; i < maxLines; i++) { if (origLines[i] !== newLines[i]) { if (origLines[i] && !newLines[i]) changes.push({ line: i+1, type: 'removed', content: origLines[i].substring(0, 120) }); else if (!origLines[i] && newLines[i]) changes.push({ line: i+1, type: 'added', content: newLines[i].substring(0, 120) }); else changes.push({ line: i+1, type: 'changed', old: (origLines[i]||'').substring(0, 80), new: (newLines[i]||'').substring(0, 80) }); } } return JSON.stringify({ module: modName, originalLines: origLines.length, newLines: newLines.length, changes: changes.length, diff: changes.slice(0, 50) }); } case 'ship_module': { const modName = a.name || ''; const newCode = a.code || ''; const tests = a.tests || []; const shouldCommit = a.commit === true; if (!modName || !newCode) return JSON.stringify({ error: 'name and code required' }); // Step 1: Write to _mods/ this.terminal.fs.writeFile(`_mods/${modName}.js`, newCode); this.terminal.fs.writeFile(`_mods/${modName}.meta.json`, JSON.stringify({ editedAt: new Date().toISOString(), editedBy: 'ship_module' })); // Step 2: Test const testResult = await this.runModuleTool('test_module', JSON.stringify({ name: modName, tests })); const testParsed = JSON.parse(testResult); if (testParsed.error) { return JSON.stringify({ step: 'test', error: testParsed.error, rolledBack: testParsed.rolledBack, pipeline: 'aborted at test' }); } // Step 3: Health check const healthResult = await this.runModuleTool('health_check', '{}'); const healthParsed = JSON.parse(healthResult); if (!healthParsed.healthy) { await this.runModuleTool('reload_module', JSON.stringify({ name: modName })); return JSON.stringify({ step: 'health_check', error: 'System unhealthy after edit', health: healthParsed, pipeline: 'aborted at health_check, rolled back' }); } // Step 4: Regression suite const regResult = await this._runRegressionSuite(modName); if (!regResult.allPassed) { await this.runModuleTool('reload_module', JSON.stringify({ name: modName })); return JSON.stringify({ step: 'regression', error: 'Regression tests failed', regression: regResult, pipeline: 'aborted at regression, rolled back' }); } // Step 5: Calculate confidence score const changeCount = newCode.split('\n').length; const confidence = this._calculateConfidence(modName, testParsed, healthParsed, regResult, changeCount); // Step 6: Commit (if requested) if (shouldCommit) { if (confidence >= 70) { // Auto-commit: execute directly const commitResult = await this.runModuleTool('commit_module', JSON.stringify({ name: modName })); const commitParsed = JSON.parse(commitResult); if (commitParsed.pendingCommit) { // Execute the pending commit immediately const execResult = await this._executeCommit('auto: confidence=' + confidence); return JSON.stringify({ success: true, pipeline: 'edit → test → health → regression → AUTO-COMMIT (confidence=' + confidence + ')', module: modName, confidence: confidence, testResult: testParsed, health: healthParsed, regression: regResult, autoCommitted: true, commitResult: execResult }); } return JSON.stringify({ success: true, pipeline: 'edit → test → health → regression → auto-commit', module: modName, confidence: confidence, testResult: testParsed, health: healthParsed, regression: regResult, commit: commitParsed }); } else { // Confidence too low — human approval required const commitResult = await this.runModuleTool('commit_module', JSON.stringify({ name: modName })); return JSON.stringify({ success: true, pipeline: 'edit → test → health → regression → HUMAN APPROVAL REQUIRED (confidence=' + confidence + ' < 70)', module: modName, confidence: confidence, testResult: testParsed, health: healthParsed, regression: regResult, commit: JSON.parse(commitResult), humanApprovalRequired: true }); } } return JSON.stringify({ success: true, pipeline: 'edit → test → health → regression (dry-run)', module: modName, confidence: confidence, testResult: testParsed, health: healthParsed, regression: regResult, next: 'Call commit_module or ship_module with commit:true to persist' }); } case 'commit_module': { const modName = a.name || ''; const targetFile = a.file || ''; const newCode = this.terminal.fs.readFile(`_mods/${modName}.js`); if (!newCode) return JSON.stringify({ error: `No local edit for ${modName}. Run edit_module or ship_module first.` }); // Auto-detect target file from module registry let serverPath = targetFile; if (!serverPath) { const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName); if (entry && entry.url) { serverPath = entry.url.replace(/^https?:\/\/[^/]+/, '').replace(/\?v=.*/, ''); } } if (!serverPath) return JSON.stringify({ error: 'Cannot determine server path. Provide file= parameter (e.g. modules/ext/shell.js).' }); // Get diff let origCode = this.terminal.fs.readFile(`_mods/${modName}.orig.js`); if (!origCode || origCode === 'placeholder') { const instance = this.terminal.moduleManager.modules[modName]; if (instance) origCode = instance.constructor.toString(); } const origLines = origCode ? origCode.split('\n') : []; const newLines = newCode.split('\n'); let changeCount = 0; for (let i = 0; i < Math.max(origLines.length, newLines.length); i++) { if (origLines[i] !== newLines[i]) changeCount++; } // Detect bundle path let bundlePath = null; if (serverPath.includes('/modules/core/') || serverPath.includes('module-aiassist')) { bundlePath = 'modules/core/core-bundle.js'; } else if (serverPath.includes('/modules/ext/')) { bundlePath = 'modules/ext/critical-ext-bundle.js'; } // Store pending commit this._pendingCommit = { name: modName, code: newCode, origCode: origCode || '', serverPath: serverPath, bundlePath: bundlePath, changes: changeCount, createdAt: Date.now() }; this.terminal.print('📋 Commit request created for ' + modName, 'info'); this.terminal.print(' Target: ' + serverPath, 'info'); if (bundlePath) this.terminal.print(' Bundle: ' + bundlePath, 'info'); this.terminal.print(' Changes: ' + changeCount + ' line(s) different', 'info'); this.terminal.print(' Type \'commit-approve\' to persist or \'commit-deny\' to cancel', 'warning'); return JSON.stringify({ pendingCommit: true, module: modName, serverPath: serverPath, bundlePath: bundlePath, changes: changeCount, newLines: newLines.length, message: 'Commit request created. User must type commit-approve to execute.' }); } default: return JSON.stringify({ error: `Unknown module tool: ${name}` }); } } catch (e) { this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); if (this._errorLog.length > 20) this._errorLog.shift(); // Also feed into DebugSupervisorModule try { var ds = this.terminal?.moduleManager?.modules?.DebugSupervisorModule; if (ds) ds.recordToolError(name, a, e.message); } catch (dsErr) {} return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); } } clearContext() { this.context = []; localStorage.removeItem('webx11_ai_context'); this.terminal.print('AI conversation context cleared', 'success'); } _saveContext() { try { const data = JSON.stringify({ context: this.context.slice(-20), messageCount: this._messageCount, savedAt: Date.now() }); localStorage.setItem('webx11_ai_context', data); // Also persist to VFS for crash survival try { const fs = this.terminal.fs; if (fs) { fs.mkdirRecursive('_sessions'); fs.writeFile('/_sessions/_auto-context.json', data); } } catch (e) {} } catch (e) { // localStorage quota exceeded — silently ignore } } _loadContext() { try { const saved = localStorage.getItem('webx11_ai_context'); if (saved) { const data = JSON.parse(saved); if (data && data.context) { this.context = data.context.slice(-20); this._messageCount = data.messageCount || 0; } } } catch (e) {} } setSystemPrompt(args) { if (!args[0]) { this.terminal.print('=== System Prompt ===', 'info'); this.systemPrompt.split('\n').forEach(function(line) { this.terminal.print(line, 'text-muted'); }.bind(this)); this.terminal.print('=== End (' + this.systemPrompt.length + ' chars) ===', 'info'); return; } var arg = args.join(' '); if (arg === 'reset') { localStorage.removeItem('webx11_ai_system_prompt'); this.systemPrompt = this.buildSystemPrompt(); this.terminal.print('System prompt reset to default', 'success'); return; } if (arg.charAt(0) === '+') { var directive = arg.slice(1).trim(); var lp = this._activeLoopProfile; if (lp) { if (!lp.systemPrompt) lp.systemPrompt = { base: null, prepend: '', append: '', useDefault: true }; lp.systemPrompt.prepend = (lp.systemPrompt.prepend ? lp.systemPrompt.prepend + '\n' : '') + directive; this._saveLoopProfile(lp); } this.systemPrompt = this.buildSystemPrompt(); this.terminal.print('Directive prepended to system prompt (profile: ' + (lp ? lp.name : 'none') + ')', 'success'); return; } if (arg.charAt(0) === '-') { var directive = arg.slice(1).trim(); var lp = this._activeLoopProfile; if (lp) { if (!lp.systemPrompt) lp.systemPrompt = { base: null, prepend: '', append: '', useDefault: true }; lp.systemPrompt.append = (lp.systemPrompt.append ? lp.systemPrompt.append + '\n' : '') + directive; this._saveLoopProfile(lp); } this.systemPrompt = this.buildSystemPrompt(); this.terminal.print('Directive appended to system prompt (profile: ' + (lp ? lp.name : 'none') + ')', 'success'); return; } this.systemPrompt = arg; try { localStorage.setItem('webx11_ai_system_prompt', this.systemPrompt); } catch (e) {} this.terminal.print('System prompt updated (persisted, full replace)', 'success'); } memDebug() { const toolLogSize = this._toolCallLog.length; const errLogSize = this._errorLog.length; const freqKeys = Object.keys(this._commandFreq).length; const contextSize = this.context.length; const factKeys = this._userProfile?.knownFacts ? Object.keys(this._userProfile.knownFacts).length : 0; const shadowSize = this._shadowSnapshot ? JSON.stringify(this._shadowSnapshot).length : 0; const guardrailSize = this._guardrailPolicy ? JSON.stringify(this._guardrailPolicy).length : 0; const vfsSize = (localStorage.getItem('webx11_filesystem') || '').length + (localStorage.getItem('webx11_filesystem_v2') || '').length; const aiCtxSize = (localStorage.getItem('webx11_ai_context') || '').length; const aiPatternSize = (localStorage.getItem('webx11_ai_patterns') || '').length; const userProfileSize = (localStorage.getItem('webx11_user_profile') || '').length; const systemPromptSize = (localStorage.getItem('webx11_ai_system_prompt') || '').length; const guardrailPolicySize = (localStorage.getItem('webx11_guardrail_policy') || '').length; this.terminal.print('=== Memory Debug ===', 'info'); this.terminal.print(` _toolCallLog: ${toolLogSize} entries (cap: 50)`, toolLogSize > 30 ? 'warning' : 'text-muted'); this.terminal.print(` _errorLog: ${errLogSize} entries (cap: 20)`, errLogSize > 10 ? 'warning' : 'text-muted'); this.terminal.print(` _commandFreq: ${freqKeys} tracked patterns (cap: 30)`, freqKeys > 20 ? 'warning' : 'text-muted'); this.terminal.print(` AI context: ${contextSize} messages (max: ${this.maxContext * 2})`, 'text-muted'); this.terminal.print(` User facts: ${factKeys} facts (cap: 50)`, 'text-muted'); this.terminal.print(` VFS localStorage: ${(vfsSize / 1024).toFixed(1)} KB`, 'text-muted'); this.terminal.print(` AI context localStorage: ${(aiCtxSize / 1024).toFixed(1)} KB`, 'text-muted'); this.terminal.print(` AI patterns localStorage: ${(aiPatternSize / 1024).toFixed(1)} KB`, 'text-muted'); this.terminal.print(` User profile localStorage: ${(userProfileSize / 1024).toFixed(1)} KB`, userProfileSize > 10240 ? 'warning' : 'text-muted'); this.terminal.print(` System prompt localStorage: ${(systemPromptSize / 1024).toFixed(1)} KB`, systemPromptSize > 10240 ? 'warning' : 'text-muted'); this.terminal.print(` Guardrail policy localStorage: ${(guardrailPolicySize / 1024).toFixed(1)} KB`, 'text-muted'); this.terminal.print(` Runtime shadow snapshot: ${(shadowSize / 1024).toFixed(1)} KB`, shadowSize > 10240 ? 'warning' : 'text-muted'); this.terminal.print(` Runtime guardrail policy: ${(guardrailSize / 1024).toFixed(1)} KB`, 'text-muted'); const heap = performance.memory ? performance.memory.usedJSHeapSize : null; if (heap) { this.terminal.print(` JS heap: ${(heap / 1048576).toFixed(1)} MB / ${(performance.memory.jsHeapSizeLimit / 1048576).toFixed(1)} MB`, 'text-muted'); } this.terminal.print('Use reset-mem to clear AI internal logs, context, profile, and guardrail policy', 'info'); } resetMem() { // Capture size before clearing for the success message const aiCtxSize = (localStorage.getItem('webx11_ai_context') || '').length; const userProfileSize = (localStorage.getItem('webx11_user_profile') || '').length; const patternSize = (localStorage.getItem('webx11_ai_patterns') || '').length; const guardrailSize = (localStorage.getItem('webx11_guardrail_policy') || '').length; // Runtime in-memory resets this._toolCallLog = []; this._errorLog = []; this._commandFreq = {}; this.context = []; this._messageCount = 0; this._userProfile = {}; this._guardrailViolations = {}; this._shadowSnapshot = null; this._guardrailPolicy = null; this._toolDefs = null; // will be rebuilt on next use // Persisted localStorage resets localStorage.removeItem('webx11_ai_context'); localStorage.removeItem('webx11_ai_patterns'); localStorage.removeItem('webx11_user_profile'); localStorage.removeItem('webx11_guardrail_policy'); localStorage.removeItem('webx11_ai_system_prompt'); // VFS backups that _autoRestoreSession can resurrect try { const fs = this.terminal.fs; if (fs) { fs.deleteFile('/_sessions/_auto-context.json'); fs.deleteFile('/_sessions/_auto.json'); } } catch (e) {} // Mark reset timestamp so auto-restore skips stale backups localStorage.setItem('webx11_last_reset', Date.now().toString()); const totalCleared = aiCtxSize + userProfileSize + patternSize + guardrailSize; this.terminal.print(`✅ AI memory reset: ${(totalCleared / 1024).toFixed(1)} KB cleared`, 'success'); this.terminal.print(' • tool logs, error logs, command patterns, conversation context', 'text-muted'); this.terminal.print(' • user profile / known facts, guardrail policy, shadow snapshot', 'text-muted'); this.terminal.print(' • VFS session backups and cached system prompt', 'text-muted'); this.terminal.print('VFS files, terminal history, AI config, and module registry preserved', 'text-muted'); } showModel(args) { const om = this.terminal.ollamaManager; if (args.length > 0) { const name = args.join(' '); if (om.models && om.models.some(m => m.name === name)) { om.currentModel = name; localStorage.setItem('webx11_ollama_model', name); this.terminal.print(`AI model set to: ${name}`, 'success'); } else { this.terminal.print(`Model "${name}" not found in available models. Use ai-model to list.`, 'warning'); } return; } this.terminal.print(`Current AI model: ${om.currentModel || 'none'}`, 'info'); if (om.models && om.models.length > 0) { const maxShow = 20; const names = om.models.slice(0, maxShow).map(m => m.name).join(', '); this.terminal.print(`Available (${om.models.length}): ${names}${om.models.length > maxShow ? `... +${om.models.length - maxShow} more` : ''}`, 'info'); } else { this.terminal.print('No models available. Use ollamaconnect first.', 'warning'); } } _getAiConfigKeys() { return ['max-turns', 'max-context', 'max-context-tokens', 'confirm-timeout', 'chat-lock-timeout', 'skill-debounce', 'trivial-optimization', 'temperature', 'top-p', 'max-tokens', 'thinking']; } aiConfig(args) { if (args.length === 0) { this.terminal.print('=== AI Config ===', 'info'); this.terminal.print(`max-turns: ${this.MAX_TOOL_TURNS} (default: 8)`, ''); this.terminal.print(`max-context: ${this.maxContext} (default: 10)`, ''); this.terminal.print(`max-context-tokens: ${this.maxContextTokens} (code default: 12000)`, ''); this.terminal.print(`trivial-optimization: ${(this.terminal?.ollamaManager?.optimizationEnabled) ? 'on' : 'off (default)'}`, ''); this.terminal.print(`confirm-timeout: ${this._confirmTimeoutMs > 0 ? this._confirmTimeoutMs / 1000 + 's' : 'disabled (no auto-deny)'} (default: 120s)`, ''); this.terminal.print(`chat-lock-timeout: ${this._chatLockTimeoutMs / 1000}s (default: 10s)`, ''); this.terminal.print(`skill-debounce: ${this._skillDebounceMs / 60000}min (default: 30min)`, ''); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { this.terminal.print(`temperature: ${om.modelParams.temperature} (default: 0.7)`, ''); this.terminal.print(`top-p: ${om.modelParams.top_p} (default: 0.9)`, ''); this.terminal.print(`max-tokens: ${om.modelParams.max_tokens} (default: 4096)`, ''); this.terminal.print(`thinking: ${om.modelParams.thinking} (default: off)`, ''); } this.terminal.print('Usage: ai-config <key> <value>', 'info'); this.terminal.print('Keys: max-turns, max-context, max-context-tokens, confirm-timeout(0=disabled), chat-lock-timeout, skill-debounce, trivial-optimization, temperature, top-p, max-tokens, thinking', 'info'); return; } const key = args[0]; const val = args[1]; if (key === 'max-turns') { const n = parseInt(val); if (isNaN(n) || n < 1 || n > 50) return this.terminal.print('max-turns must be 1-50', 'warning'); this.MAX_TOOL_TURNS = n; } else if (key === 'max-context') { const n = parseInt(val); if (isNaN(n) || n < 1 || n > 100) return this.terminal.print('max-context must be 1-100', 'warning'); this.maxContext = n; } else if (key === 'max-context-tokens') { const n = parseInt(val); if (isNaN(n) || n < 100 || n > 10000) return this.terminal.print('max-context-tokens must be 100-10000', 'warning'); this.maxContextTokens = n; } else if (key === 'confirm-timeout') { const n = parseInt(val); if (isNaN(n) || n < 0) return this.terminal.print('confirm-timeout must be 0+ seconds (0 = no confirm / auto-approve all)', 'warning'); this._confirmTimeoutMs = n * 1000; } else if (key === 'chat-lock-timeout') { const n = parseInt(val); if (isNaN(n) || n < 1 || n > 60) return this.terminal.print('chat-lock-timeout must be 1-60 seconds', 'warning'); this._chatLockTimeoutMs = n * 1000; } else if (key === 'skill-debounce') { const n = parseInt(val); if (isNaN(n) || n < 1 || n > 240) return this.terminal.print('skill-debounce must be 1-240 minutes', 'warning'); this._skillDebounceMs = n * 60000; } else if (key === 'trivial-optimization') { if (this.terminal?.ollamaManager) { this.terminal.ollamaManager.optimizationEnabled = val === 'on'; } } else if (key === 'temperature') { const n = parseFloat(val); if (isNaN(n) || n < 0 || n > 2) return this.terminal.print('temperature must be 0-2', 'warning'); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { om.modelParams.temperature = n; om._saveModelParams(); } } else if (key === 'top-p') { const n = parseFloat(val); if (isNaN(n) || n < 0 || n > 1) return this.terminal.print('top-p must be 0-1', 'warning'); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { om.modelParams.top_p = n; om._saveModelParams(); } } else if (key === 'max-tokens') { const n = parseInt(val); if (isNaN(n) || n < 1 || n > 131072) return this.terminal.print('max-tokens must be 1-131072', 'warning'); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { om.modelParams.max_tokens = n; om._saveModelParams(); } } else if (key === 'thinking') { const validModes = ['off', 'deepseek_reasoner', 'deepseek']; if (!validModes.includes(val)) return this.terminal.print(`thinking must be one of: ${validModes.join(', ')}`, 'warning'); const om = this.terminal?.ollamaManager; if (om && om.modelParams) { om.modelParams.thinking = val; om._saveModelParams(); } } else { return this.terminal.print(`Unknown key: ${key}. Valid: max-turns, max-context, max-context-tokens, confirm-timeout, chat-lock-timeout, skill-debounce, trivial-optimization, temperature, top-p, max-tokens, thinking`, 'warning'); } // Persist localStorage.setItem('webx11_ai_config', JSON.stringify({ maxTurns: this.MAX_TOOL_TURNS, maxContext: this.maxContext, maxContextTokens: this.maxContextTokens, confirmTimeoutMs: this._confirmTimeoutMs, chatLockTimeoutMs: this._chatLockTimeoutMs, skillDebounceMs: this._skillDebounceMs })); this.terminal.print(`ai-config ${key} set to ${val}`, 'success'); } showContext() { if (this.context.length === 0) { this.terminal.print('No conversation context', 'info'); return; } this.terminal.print(`=== Context (${this.context.length} messages) ===`, 'info'); this.context.forEach(msg => { const icon = msg.role === 'user' ? '👤' : msg.role === 'assistant' ? '🤖' : '🔧'; const content = typeof msg.content === 'string' ? msg.content.substring(0, 60) : JSON.stringify(msg).substring(0, 60); this.terminal.print(`${icon} ${content}...`); }); } // Extract facts about user from conversation _extractFacts(userMsg, aiResp) { const patterns = [ { re: /my name is (\w+)/i, key: 'name' }, { re: /call me (\w+)/i, key: 'name' }, { re: /i(?:'m| am) working on (.+)/i, key: 'project' }, { re: /(?:my|the) project is (.+)/i, key: 'project' }, { re: /i have a server at (.+)/i, key: 'server' }, { re: /i use (\w+).*?(?:editor|ide|text)/i, key: 'preferredEditor' }, { re: /i (?:like|prefer|love) (.+)/i, key: 'preference' }, ]; let changed = false; if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {}; for (const { re, key } of patterns) { const m = (userMsg + ' ' + (aiResp || '')).match(re); if (m && m[1]) { const val = m[1].trim(); if (this._userProfile.knownFacts[key] !== val) { this._userProfile.knownFacts[key] = val; changed = true; } } } // v4.5.47: cap knownFacts so the profile doesn't bloat indefinitely const MAX_FACTS = 50; const factKeys = Object.keys(this._userProfile.knownFacts); if (factKeys.length > MAX_FACTS) { this._userProfile.knownFacts = Object.fromEntries( factKeys.slice(-MAX_FACTS).map(k => [k, this._userProfile.knownFacts[k]]) ); changed = true; } if (changed) { try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {} } } _trackPattern(userMessage) { const lower = userMessage.toLowerCase(); const patterns = ['font size','bigger text','smaller text','export','backup','save','install','load module','search','find','color','theme','skill','learn','remember','mindmap','brainstorm']; for (const p of patterns) { if (lower.includes(p)) this._commandFreq[p] = (this._commandFreq[p] || 0) + 1; } // v4.5.47: cap command frequency map to top 30 patterns to prevent unbounded growth const MAX_PATTERNS = 30; const keys = Object.keys(this._commandFreq); if (keys.length > MAX_PATTERNS) { const sorted = keys.sort((a, b) => this._commandFreq[b] - this._commandFreq[a]); this._commandFreq = Object.fromEntries(sorted.slice(0, MAX_PATTERNS).map(k => [k, this._commandFreq[k]])); } try { localStorage.setItem('webx11_ai_patterns', JSON.stringify(this._commandFreq)); } catch (e) {} } _buildToolToModuleMap() { this._toolToModuleMap = {}; const mm = this.terminal.moduleManager; if (!mm || !mm.modules) return; for (const [modName, mod] of Object.entries(mm.modules)) { if (mod.commands) { for (const cmdName of Object.keys(mod.commands)) { this._toolToModuleMap[cmdName] = modName; } } } } async _autoCreateErrorSkill() { const grouped = {}; for (const e of this._errorLog) { if (!grouped[e.tool]) grouped[e.tool] = []; grouped[e.tool].push(e); } for (const [tool, errors] of Object.entries(grouped)) { if (errors.length >= 3) { const skillName = 'tool-' + tool + '-workaround'; const existing = this.terminal.fs.readFile('/skills/' + skillName + '.md'); if (existing) continue; const steps = errors.map((e, i) => (i + 1) + '. When calling ' + tool + ', avoid: "' + JSON.stringify(e.args) + '" which caused: ' + e.error).join('\n'); const content = '---\nname: ' + skillName + '\ndescription: Workarounds for ' + tool + ' failures\ntags: [' + tool + ', error, workaround]\ncategory: system\n---\n## Steps\n' + steps; await this.runTool('create_skill', { name: skillName, description: 'Workarounds for ' + tool + ' failures', content: content, tags: tool + ',error,workaround', category: 'system' }); this.terminal.print('🤖 Auto-created skill from repeated errors: ' + skillName, 'info'); } } this._errorLog = []; } async _auditSkills() { const fs = this.terminal.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; const now = Date.now(); const issues = []; for (const [name, meta] of Object.entries(index)) { const content = fs.readFile('/skills/' + name + '.md'); if (!content || content.length < 50) { issues.push({ name, issue: 'empty or too short' }); continue; } const updated = new Date(meta.updated || 0).getTime(); const ageDays = (now - updated) / 86400000; if (ageDays > 30) issues.push({ name, issue: 'not updated in ' + Math.round(ageDays) + ' days' }); } if (issues.length > 0) { this.terminal.print('📋 Skill audit: ' + issues.length + ' issue(s) found', 'info'); issues.slice(0, 3).forEach(i => this.terminal.print(' - ' + i.name + ': ' + i.issue, 'warning')); } } _estimateTokens(text) { if (!text || typeof text !== 'string') return 0; return Math.ceil(text.length / 4); } _estimateContextTokens() { let total = 0; for (const msg of this.context) { total += this._estimateTokens(msg.content || ''); } return total; } _showToolCallLog() { if (!this._toolCallLog || this._toolCallLog.length === 0) { this.terminal.print('No tool calls recorded this session.', 'warning'); return; } let out = '── Tool Call Log (' + this._toolCallLog.length + ' calls) ──\n'; for (var i = 0; i < this._toolCallLog.length; i++) { var e = this._toolCallLog[i]; var err = e.error ? ' ⚠️' : ''; var argsStr = typeof e.args === 'string' ? e.args.substring(0, 80) : JSON.stringify(e.args).substring(0, 80); out += ' #' + e.turn + ' ' + e.name + '(' + argsStr + ') ' + e.duration + 's' + err + '\n'; } out += '── End ──'; this.terminal.print(out, 'info'); } _archiveList() { try { const fs = this.terminal.fs; const files = fs.ls('/memory/archive') || []; const jsonFiles = files.filter(function(f) { return f.name && f.name.endsWith('.json'); }); if (jsonFiles.length === 0) { this.terminal.print('No archives found. Run ai-compress to create one.', 'warning'); return; } jsonFiles.sort(function(a, b) { return (a.name || '') < (b.name || '') ? 1 : -1; }); let out = '── Archives (' + jsonFiles.length + ') ──\n'; for (var i = 0; i < Math.min(jsonFiles.length, 30); i++) { var f = jsonFiles[i]; var size = f.size ? '(' + f.size + 'b)' : ''; out += ' ' + f.name + ' ' + size + '\n'; } out += '── End ──\nUse: archive-read <filename>'; this.terminal.print(out, 'info'); } catch (e) { this.terminal.print('Error listing archives: ' + e.message, 'error'); } } _archiveRead(args) { var name = (args && args[0]) || ''; if (!name) { this.terminal.print('Usage: archive-read <filename> (e.g. 2026-06-25-14-30.json)', 'warning'); return; } try { const fs = this.terminal.fs; var data = fs.fsReadJSON('/memory/archive/' + name); if (!data) { this.terminal.print('Archive not found: ' + name, 'error'); return; } var out = '── Archive: ' + name + ' ──\n'; out += 'Archived: ' + (data.archivedAt || '?') + '\n'; out += 'Summary: ' + (data.summary || '(none)') + '\n'; out += 'User messages: ' + (data.user?.messageCount || 0) + '\n'; out += 'System: ' + (data.system?.modules || 0) + ' modules, ' + (data.system?.model || '?') + '\n'; out += 'Context: ' + (data.context?.messageCount || 0) + ' msgs, ~' + (data.context?.estimatedTokens || 0) + ' tokens\n'; out += '── End ──'; this.terminal.print(out, 'info'); } catch (e) { this.terminal.print('Error reading archive: ' + e.message, 'error'); } } _showLastRawResponse() { if (!this._lastRawResponse) { this.terminal.print('No AI response recorded yet. Send a message first.', 'warning'); return; } const r = this._lastRawResponse; var content = (r.message && r.message.content) || ''; var hasTools = r.tool_calls && r.tool_calls.length > 0; var toolStr = hasTools ? r.tool_calls.map(function(tc) { return tc.function.name + '(' + (tc.function.arguments || '{}') + ')'; }).join(', ') : ''; // Render as HTML card for better readability var html = '<div class="msg-card ai" style="border:1px solid var(--border-color);border-radius:6px;margin:4px 0;overflow:hidden">' + '<div class="msg-card-header" style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:var(--bg-secondary);border-bottom:1px solid var(--border-color);font-weight:bold;font-size:0.9em">' + '<span>🤖 Last AI Response</span>' + '<span style="display:flex;gap:4px">' + '<button class="card-btn" onclick="var m=webx11.moduleManager.modules.WebX11AIAssistantModule;if(m&&m._lastRawResponse){navigator.clipboard.writeText(JSON.stringify(m._lastRawResponse,null,2)).then(function(){m.terminal.print(\'📋 Raw response copied\',\'success\')})}" style="background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);padding:2px 6px;border-radius:3px;cursor:pointer;font-size:0.75em">📋 Copy Raw</button>' + '</span></div>' + '<div class="msg-card-body" style="padding:8px 10px;font-size:0.85em;line-height:1.4">'; if (r.error) { html += '<div style="color:var(--error-color)">⚠️ Error: ' + this._escapeHtml(r.error) + '</div>'; } if (content) { var safe = this._escapeHtml(content.length > 1000 ? content.substring(0, 1000) + '...(' + content.length + ' chars)' : content); html += '<div style="margin-bottom:4px"><strong>Content:</strong><br>' + safe + '</div>'; } else if (!hasTools) { html += '<div style="color:var(--warning-color)">⚠️ Empty response (no content, no tool calls)</div>'; } if (hasTools) { html += '<div style="margin-top:4px"><strong>Tool calls:</strong> ' + this._escapeHtml(toolStr) + '</div>'; } html += '</div></div>'; this.terminal.printHTML(html); } _showLastRawRequest() { if (!this._lastRawMessages) { this.terminal.print('No AI request recorded yet. Send a message first.', 'warning'); return; } const msgs = this._lastRawMessages; let out = '── Last AI Request (' + msgs.length + ' messages) ──\n'; for (let i = 0; i < msgs.length; i++) { const m = msgs[i]; const role = m.role || '?'; const content = (m.content || '').substring(0, 200); const suffix = (m.content || '').length > 200 ? '...' : ''; out += ' [' + i + '] ' + role + ': ' + content + suffix + '\n'; if (i > 8 && i < msgs.length - 2) { out += ' ... (' + (msgs.length - i - 2) + ' more messages) ...\n'; i = msgs.length - 3; } } out += '── End ──'; this.terminal.print(out, 'info'); } async _compressContext() { if (this.context.length <= 6) return; const totalTokens = this._estimateContextTokens(); if (totalTokens < this.maxContextTokens) { // Warn before the cliff rather than only reporting the fall. Once per crossing, // so a long session is not nagged every turn. const pct = Math.round((totalTokens / this.maxContextTokens) * 100); if (pct >= 80 && !this._ctxWarned) { this._ctxWarned = true; this.terminal.print('⚠️ Context ' + pct + '% full (' + totalTokens + '/' + this.maxContextTokens + ' tokens) — the next few turns will compress earlier history into a summary', 'warning'); } else if (pct < 60) { this._ctxWarned = false; } return; } this._ctxWarned = false; const keepCount = 8; const oldMsgs = this.context.slice(0, -keepCount * 2); if (oldMsgs.length < 2) return; const recentMsgs = this.context.slice(-keepCount * 2); // Tool traces are the only durable record of what was read/run — keep them // verbatim instead of folding them into the 400-char digest (2026-10-21). const summaryText = oldMsgs.map(m => /^⚙️ tool trace:/.test(m.content || '') ? (m.content || '').slice(0, 600) : (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' + (m.content || '').substring(0, 400) ).join(' | '); let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText.slice(0, 4000)}]`; // Tool traces must survive compression, so the AI one-sentence summary is only // used when there are none to lose (2026-10-21). const _traces = oldMsgs.filter(m => /^⚙️ tool trace:/.test(m.content || '')); // Try AI summarization, fallback to truncation try { if (!_traces.length && this.terminal.ollamaManager && this.terminal.ollamaManager.connected) { const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) }))); const sumRes = await this.terminal.ollamaManager.chat(sumPrompt); if (sumRes && sumRes.length < 300) { compressed = '[Earlier context: ' + sumRes.trim() + ']'; } } } catch (e) { /* fallback to truncation */ } this.context = [{ role: 'system', content: compressed }, ...recentMsgs]; this.terminal.print('📦 Context compressed (' + oldMsgs.length + ' messages → 1 summary, ~' + totalTokens + ' tokens)', 'info'); // Also archive session snapshot this._archiveSession(compressed); } _archiveSession(summary) { try { const fs = this.terminal.fs; fs.mkdirRecursive('/memory/archive'); const now = new Date(); const pad = function(n) { return String(n).padStart(2, '0'); }; const ts = now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate()) + '-' + pad(now.getHours()) + '-' + pad(now.getMinutes()); const archive = { archivedAt: now.toISOString(), summary: summary || '', user: { profile: this._userProfile || {}, topics: [], messageCount: this._messageCount }, system: { modules: this.terminal?.moduleManager?.modules ? Object.keys(this.terminal.moduleManager.modules).length : 0, model: this.terminal?.ollamaManager?.currentModel || 'unknown', provider: this.terminal?.ollamaManager?.currentProvider?.name || 'unknown' }, context: { messageCount: this.context.length, estimatedTokens: this._estimateContextTokens() } }; fs.fsWriteJSON('/memory/archive/' + ts + '.json', archive); // Rotation: keep last 30 try { const files = fs.ls('/memory/archive') || []; const jsonFiles = files.filter(function(f) { return f.name && f.name.endsWith('.json'); }); if (jsonFiles.length > 30) { jsonFiles.sort(function(a, b) { return (a.name || '') < (b.name || '') ? -1 : 1; }); const toDelete = jsonFiles.slice(0, jsonFiles.length - 30); for (var i = 0; i < toDelete.length; i++) { fs.deleteFile('/memory/archive/' + toDelete[i].name); } } } catch (e) { /* rotation best-effort */ } } catch (e) { /* archive best-effort */ } } _manualCompress() { this._compressContext().then(() => { const fs = this.terminal.fs; let archiveCount = 0; try { const files = fs.ls('/memory/archive') || []; archiveCount = files.filter(function(f) { return f.name && f.name.endsWith('.json'); }).length; } catch (e) {} this.terminal.print('Context: ' + this.context.length + ' message(s), ~' + this._estimateContextTokens() + ' estimated tokens, ' + archiveCount + ' archive(s)', 'info'); }).catch(e => { this.terminal.print('Compression error: ' + e.message, 'error'); }); } async _vaultmindConnectCmd(args) { const network = args[0] || 'testnet'; try { const vault = window.webx11_secretVault; if (!vault) { this.terminal.print('SecretVault not available', 'error'); return; } const privateKey = await vault.decrypt('hyperliquid_key'); if (!privateKey) { this.terminal.print('No hyperliquid_key in SecretVault. Use: secret-set hyperliquid_key 0x...', 'error'); return; } this.terminal.print('Connecting to VaultMind...', 'info'); const resp = await fetch('/vmai-api/auth/wallet', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ private_key: privateKey, account_address: null, network }) }); if (!resp.ok) { this.terminal.print('Auth failed: ' + resp.status, 'error'); return; } const data = await resp.json(); if (data.token) { localStorage.setItem('vaultmind_token', data.token); this.terminal.print('✅ Connected to VaultMind (' + (data.network || network) + ')', 'success'); } else { this.terminal.print('No token returned', 'error'); } } catch (e) { this.terminal.print('Error: ' + e.message, 'error'); } } _vaultmindStatusCmd() { const token = localStorage.getItem('vaultmind_token'); if (token) { this.terminal.print('✅ VaultMind session active (token: ' + token.substring(0, 12) + '...)', 'success'); } else { this.terminal.print('❌ No VaultMind session. Use: vaultmind-connect [testnet|mainnet]', 'warning'); } } async _vaultmindQueryCmd(args) { const ep = args[0]; const validEps = ['prices', 'portfolio', 'transactions', 'copy-trade', 'chat']; if (!validEps.includes(ep)) { this.terminal.print('Usage: vaultmind-query <prices|portfolio|transactions|copy-trade|chat> [message|agent]', 'warning'); return; } try { const epMap = { prices: '/vmai-api/market/prices', portfolio: '/vmai-api/portfolio', transactions: '/vmai-api/transactions/summary', 'copy-trade': '/vmai-api/copy-trading/traders', chat: '/vmai-api/chat' }; let url = epMap[ep]; let opts = { method: 'GET', headers: { 'Accept': 'application/json' } }; const authEps = ['portfolio', 'transactions', 'copy-trade']; if (authEps.includes(ep)) { const token = localStorage.getItem('vaultmind_token'); if (token) opts.headers['Authorization'] = 'Bearer ' + token; } if (ep === 'chat') { opts.method = 'POST'; opts.headers['Content-Type'] = 'application/json'; const token = localStorage.getItem('vaultmind_token'); if (token) opts.headers['Authorization'] = 'Bearer ' + token; opts.body = JSON.stringify({ message: args.slice(1).join(' ') || 'What is the current market status?', agent: 'market' }); } const resp = await fetch(url, opts); const data = await resp.json(); this.terminal.print(JSON.stringify(data, null, 2), 'success'); } catch (e) { this.terminal.print('VaultMind query error: ' + e.message, 'error'); } } async _walletLookupCmd(args) { const addr = args[0]; if (!addr) { this.terminal.print('Usage: wallet-lookup <address>', 'warning'); return; } try { const resp = await fetch('/wallet/' + encodeURIComponent(addr)); const data = await resp.json(); this.terminal.print(JSON.stringify(data, null, 2), 'success'); } catch (e) { this.terminal.print('Wallet lookup error: ' + e.message, 'error'); } } // ── Agent Loop Profile System ── _defaultLoopProfile(name, desc) { return { name: name, displayName: desc, description: desc, version: 1, created: new Date().toISOString(), updated: new Date().toISOString(), systemPrompt: { base: null, prepend: '', append: '', useDefault: true }, loop: { strategy: 'standard', maxTurns: this.MAX_TOOL_TURNS, loopDetection: { enabled: true, warnAt: 3, blockAt: 6, hardStopAt: 9, windowMs: 30000 }, confidenceEscalation: { enabled: true, threshold: 90 }, finalTurnPrompt: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.' }, tools: { mode: 'all', enabled: [], disabled: [] }, model: { temperature: null, topP: null, maxTokens: null, thinking: null }, provider: { name: null, model: null }, benchmark: { lastRun: null, avgLatency: null, avgTokens: null, taskCompletionRate: null, tasks: [] } }; } _initLoopProfiles() { try { var saved = localStorage.getItem('webx11_agent_loops'); if (saved) { this._loopProfiles = JSON.parse(saved); } } catch (e) {} if (!this._loopProfiles || Object.keys(this._loopProfiles).length === 0) { this._loopProfiles = {}; this._loopProfiles['default'] = this._defaultLoopProfile('default', 'Balanced loop for general tasks'); var p = this._defaultLoopProfile('minimal', 'Fast, low-token for simple tasks'); p.loop.maxTurns = 4; p.tools = { mode: 'custom', enabled: ['list_files','read_file','write_file','nvfs_resolve'], disabled: [] }; p.model.temperature = 0.3; this._loopProfiles['minimal'] = p; p = this._defaultLoopProfile('research', 'Read-only exploration, web + files'); p.loop.maxTurns = 6; p.tools = { mode: 'custom', enabled: ['web_search','web_fetch','read_file','list_files','nvfs_resolve','view_module','list_modules'], disabled: [] }; p.model.temperature = 0.5; this._loopProfiles['research'] = p; p = this._defaultLoopProfile('safe', 'No destructive tools'); p.tools = { mode: 'all', enabled: [], disabled: ['delete_file','edit_module','run_command','make_directory'] }; p.model.temperature = 0.5; this._loopProfiles['safe'] = p; p = this._defaultLoopProfile('creative', 'More turns, higher temp'); p.loop.maxTurns = 12; p.model.temperature = 0.9; this._loopProfiles['creative'] = p; p = this._defaultLoopProfile('coder', 'Code-focused, low temp'); p.loop.maxTurns = 10; p.model.temperature = 0.2; p.systemPrompt.append = 'You are a code expert. Always show the exact code changes. Use edit_module for modifications.'; this._loopProfiles['coder'] = p; this._saveLoopProfiles(); } try { var active = localStorage.getItem('webx11_active_loop_profile'); if (active && this._loopProfiles[active]) { this._activeLoopProfile = this._loopProfiles[active]; } else { this._activeLoopProfile = this._loopProfiles['default']; } } catch (e) { this._activeLoopProfile = this._loopProfiles['default']; } } _saveLoopProfiles() { try { localStorage.setItem('webx11_agent_loops', JSON.stringify(this._loopProfiles)); } catch (e) {} } _saveLoopProfile(p) { if (p && p.name) { p.updated = new Date().toISOString(); this._loopProfiles[p.name] = p; this._saveLoopProfiles(); } } _switchLoopProfile(name) { if (!this._loopProfiles[name]) return false; this._activeLoopProfile = this._loopProfiles[name]; localStorage.setItem('webx11_active_loop_profile', name); this._toolDefs = null; this.systemPrompt = this.buildSystemPrompt(); var mp = this._activeLoopProfile.model; var om = this.terminal ? this.terminal.ollamaManager : null; if (om && mp) { if (mp.temperature !== null) { om.modelParams.temperature = mp.temperature; om._saveModelParams(); } if (mp.topP !== null) { om.modelParams.top_p = mp.topP; om._saveModelParams(); } if (mp.maxTokens !== null) { om.modelParams.max_tokens = mp.maxTokens; om._saveModelParams(); } if (mp.thinking !== null) { om.modelParams.thinking = mp.thinking; om._saveModelParams(); } } return true; } agentLoopsCmd(args) { if (!args[0]) { this.terminal.print('=== Agent Loop Profiles ===', 'info'); var keys = Object.keys(this._loopProfiles); for (var ki = 0; ki < keys.length; ki++) { var k = keys[ki]; var p = this._loopProfiles[k]; var active = this._activeLoopProfile === p ? ' ✅' : ''; var bench = p.benchmark && p.benchmark.taskCompletionRate != null ? ' (bench: ' + Math.round(p.benchmark.taskCompletionRate * 100) + '%)' : ''; var tools = p.tools && p.tools.mode === 'custom' ? (p.tools.enabled ? p.tools.enabled.length : 0) + ' tools' : 'all tools'; this.terminal.print(' ' + k + ': ' + p.displayName + active + ' — ' + (p.loop ? p.loop.maxTurns : this.MAX_TOOL_TURNS) + ' turns, ' + tools + bench); } this.terminal.print('Usage: agent-loops <name> | new <name> <desc> | edit <name> <key> <val> | delete <name> | clone <src> <dst> | export <name> | reset <name>', 'text-muted'); return; } var sub = args[0]; if (sub === 'new') { var name = args[1] ? args[1].replace(/\s+/g, '-').toLowerCase() : ''; if (!name) { this.terminal.print('Usage: agent-loops new <name> <description>', 'warning'); return; } var desc = args.slice(2).join(' ') || 'Custom profile'; this._loopProfiles[name] = this._defaultLoopProfile(name, desc); this._saveLoopProfiles(); this.terminal.print("Profile '" + name + "' created. Use: agent-loops edit " + name + " <key> <value>", 'success'); } else if (sub === 'edit') { var name = args[1]; if (!name || !this._loopProfiles[name]) { this.terminal.print("Profile '" + name + "' not found", 'error'); return; } var key = args[2]; var val = args.slice(3).join(' '); if (!key || !val) { this.terminal.print('Usage: agent-loops edit <name> <key> <value>', 'warning'); return; } this._setNestedKey(this._loopProfiles[name], key, val); this._saveLoopProfiles(); if (this._activeLoopProfile === this._loopProfiles[name]) { this._toolDefs = null; this.systemPrompt = this.buildSystemPrompt(); } this.terminal.print('Edited ' + name + '.' + key + ' = ' + val, 'success'); } else if (sub === 'delete') { var name = args[1]; if (!name) { this.terminal.print('Usage: agent-loops delete <name>', 'warning'); return; } if (name === 'default') { this.terminal.print('Cannot delete default profile', 'error'); return; } if (this._activeLoopProfile === this._loopProfiles[name]) { this.terminal.print('Cannot delete active profile — switch first', 'error'); return; } delete this._loopProfiles[name]; this._saveLoopProfiles(); this.terminal.print("Profile '" + name + "' deleted", 'success'); } else if (sub === 'clone') { var src = args[1], dst = args[2] ? args[2].replace(/\s+/g, '-').toLowerCase() : ''; if (!src || !dst || !this._loopProfiles[src]) { this.terminal.print('Usage: agent-loops clone <src> <dst>', 'warning'); return; } this._loopProfiles[dst] = JSON.parse(JSON.stringify(this._loopProfiles[src])); this._loopProfiles[dst].name = dst; this._loopProfiles[dst].created = new Date().toISOString(); this._saveLoopProfiles(); this.terminal.print("Cloned '" + src + "' -> '" + dst + "'", 'success'); } else if (sub === 'export') { var name = args[1]; if (!name || !this._loopProfiles[name]) { this.terminal.print("Profile '" + name + "' not found", 'error'); return; } this.terminal.printJSON(JSON.stringify(this._loopProfiles[name], null, 2)); } else if (sub === 'reset') { var name = args[1]; if (!name) { this.terminal.print('Usage: agent-loops reset <name>', 'warning'); return; } var desc = this._loopProfiles[name] ? this._loopProfiles[name].displayName : 'Reset profile'; this._loopProfiles[name] = this._defaultLoopProfile(name, desc); this._saveLoopProfiles(); if (this._activeLoopProfile && this._activeLoopProfile.name === name) { this._switchLoopProfile(name); } this.terminal.print("Profile '" + name + "' reset to defaults", 'success'); } else { if (this._loopProfiles[sub]) { this._switchLoopProfile(sub); var p = this._activeLoopProfile; this.terminal.print("Switched to '" + sub + "': " + p.displayName, 'success'); this.terminal.print(' Max turns: ' + (p.loop ? p.loop.maxTurns : this.MAX_TOOL_TURNS) + ' | Tools: ' + (p.tools ? p.tools.mode : 'all') + ' | Temp: ' + (p.model && p.model.temperature !== null ? p.model.temperature : 'inherit'), 'text-muted'); } else { this.terminal.print("Profile '" + sub + "' not found. Available: " + Object.keys(this._loopProfiles).join(', '), 'error'); } } } _setNestedKey(obj, key, val) { var parts = key.split('.'); var o = obj; for (var i = 0; i < parts.length - 1; i++) { if (!o[parts[i]]) o[parts[i]] = {}; o = o[parts[i]]; } var last = parts[parts.length - 1]; try { o[last] = JSON.parse(val); } catch (e) { o[last] = val; } } // ── BENCHMARK SUITE ── _benchmarkTasks() { return [ { id: 'fs-navigate', prompt: 'List all files in the root directory and tell me how many .js files exist', maxTurns: 3, successCheck: function(r, log) { return r.length > 10 && log.some(function(t) { return t.name === 'list_files'; }); } }, { id: 'fs-create-read', prompt: 'Create a file called /tmp/bench-test.txt with "hello bench", then read it back to verify', maxTurns: 4, successCheck: function(r) { return r.toLowerCase().indexOf('hello') !== -1; }, destructive: true }, { id: 'nvfs-resolve', prompt: 'What terminal command handles file operations? Use nvfs-resolve to find it', maxTurns: 3, successCheck: function(r, log) { return log.some(function(t) { return t.name === 'nvfs_resolve'; }) && r.length > 20; } }, { id: 'web-search', prompt: 'Search the web for "what is the capital of France" and tell me the answer', maxTurns: 3, successCheck: function(r) { return r.toLowerCase().indexOf('paris') !== -1; } }, { id: 'multi-tool', prompt: 'Read the file /package.json, then list all modules, then summarize both results', maxTurns: 6, successCheck: function(r, log) { return log.filter(function(t) { return ['read_file','list_files','list_modules'].indexOf(t.name) !== -1; }).length >= 2; } }, { id: 'code-edit', prompt: 'Create a file /tmp/bench-code.js with a function that adds two numbers, then read it back to verify', maxTurns: 5, successCheck: function(r, log) { return log.some(function(t) { return t.name === 'write_file'; }); }, destructive: true }, ]; } async _runBenchmark(profileName, taskIds) { var tasks = this._benchmarkTasks(); if (taskIds) tasks = tasks.filter(function(t) { return taskIds.indexOf(t.id) !== -1; }); var prevProfile = this._activeLoopProfile ? this._activeLoopProfile.name : null; if (profileName && this._loopProfiles[profileName]) this._switchLoopProfile(profileName); this.terminal.print('📊 Benchmark: ' + profileName + ' (' + tasks.length + ' tasks)', 'info'); var results = []; for (var ti = 0; ti < tasks.length; ti++) { var task = tasks[ti]; this.terminal.print(' ▶ ' + task.id + '...', 'text-muted'); var t0 = Date.now(); var toolLogBefore = this._toolCallLog.length; var messages = [{ role: 'user', content: task.prompt }]; messages.unshift({ role: 'system', content: this.systemPrompt }); var savedMax = this._activeLoopProfile && this._activeLoopProfile.loop ? this._activeLoopProfile.loop.maxTurns : null; if (this._activeLoopProfile && this._activeLoopProfile.loop) { this._activeLoopProfile.loop.maxTurns = task.maxTurns; } try { var result = await this.agenticLoop(messages); var latency = Date.now() - t0; var newTools = this._toolCallLog.slice(toolLogBefore); var passed = task.successCheck(result.finalAnswer || '', newTools); var tokens = await this._estimateTokensForRun(newTools, result.finalAnswer); results.push({ id: task.id, turns: result.toolTurns, tokens: tokens, latency: latency, pass: passed }); this.terminal.print(' ' + (passed ? '✅' : '❌') + ' ' + result.toolTurns + ' turns, ' + tokens + ' tokens, ' + (latency/1000).toFixed(1) + 's', passed ? 'success' : 'warning'); } catch (e) { results.push({ id: task.id, turns: 0, tokens: 0, latency: Date.now() - t0, pass: false, error: e.message }); this.terminal.print(' ❌ Error: ' + e.message, 'error'); } if (this._activeLoopProfile && this._activeLoopProfile.loop && savedMax != null) { this._activeLoopProfile.loop.maxTurns = savedMax; } this.context = []; } var profile = this._loopProfiles[profileName]; if (profile) { var passed = results.filter(function(r) { return r.pass; }).length; profile.benchmark = { lastRun: new Date().toISOString(), tasks: results, avgLatency: Math.round(results.reduce(function(s, r) { return s + r.latency; }, 0) / results.length), avgTokens: Math.round(results.reduce(function(s, r) { return s + r.tokens; }, 0) / results.length), taskCompletionRate: passed / results.length }; this._saveLoopProfile(profile); } this.terminal.print('┌──────────────────────────────────────────────────────────┐', 'info'); this.terminal.print('│ Benchmark: ' + profileName, 'info'); this.terminal.print('├──────────────────────────────────────────────────────────┤', 'info'); for (var ri = 0; ri < results.length; ri++) { var r = results[ri]; var status = r.pass ? '✅' : '❌'; this.terminal.print('│ ' + r.id + ' ' + r.turns + 't ' + r.tokens + 'tok ' + (r.latency/1000).toFixed(1) + 's ' + status, 'info'); } this.terminal.print('├──────────────────────────────────────────────────────────┤', 'info'); var passed = results.filter(function(r) { return r.pass; }).length; var avgLat = (results.reduce(function(s, r) { return s + r.latency; }, 0) / results.length / 1000).toFixed(1); var avgTok = Math.round(results.reduce(function(s, r) { return s + r.tokens; }, 0) / results.length); this.terminal.print('│ ' + passed + '/' + results.length + ' passed (' + Math.round(passed/results.length*100) + '%) | Avg: ' + avgLat + 's, ' + avgTok + 'tok', 'info'); this.terminal.print('└──────────────────────────────────────────────────────────┘', 'info'); if (prevProfile && prevProfile !== profileName) this._switchLoopProfile(prevProfile); return results; } async _estimateTokensForRun(toolLog, finalAnswer) { var om = this.terminal ? this.terminal.ollamaManager : null; if (om && om._usageLog && om._usageLog.length > 0) { var last = om._usageLog[om._usageLog.length - 1]; return last.total_tokens || 0; } var toolChars = toolLog.reduce(function(s, t) { return s + (t.result ? t.result.length : 0); }, 0); var answerChars = (finalAnswer || '').length; return Math.round((toolChars + answerChars) / 4); } benchCmd(args) { var profile = null; var taskIds = null; for (var ai = 0; ai < args.length; ai++) { if (args[ai].indexOf('--tasks=') === 0) taskIds = args[ai].split('=')[1].split(','); else if (args[ai].indexOf('--') !== 0) profile = args[ai]; } if (!profile) { this.terminal.print('Usage: bench [profile] [--tasks=id1,id2] [--repeat=N]', 'warning'); this.terminal.print('Available tasks: ' + this._benchmarkTasks().map(function(t) { return t.id; }).join(', '), 'text-muted'); return; } this._runBenchmark(profile, taskIds); } benchCompareCmd(args) { var p1 = args[0], p2 = args[1]; if (!p1 || !p2) { this.terminal.print('Usage: bench-compare <profile1> <profile2>', 'warning'); return; } var b1 = this._loopProfiles[p1] ? this._loopProfiles[p1].benchmark : null; var b2 = this._loopProfiles[p2] ? this._loopProfiles[p2].benchmark : null; if (!b1 || !b1.lastRun) { this.terminal.print("No benchmark for '" + p1 + "'. Run: bench " + p1, 'warning'); return; } if (!b2 || !b2.lastRun) { this.terminal.print("No benchmark for '" + p2 + "'. Run: bench " + p2, 'warning'); return; } this.terminal.print('┌──────────────────────────────────────────────────────────┐', 'info'); this.terminal.print('│ ' + p1 + ' vs ' + p2, 'info'); this.terminal.print('├──────────────────────────────────────────────────────────┤', 'info'); this.terminal.print('│ Pass rate: ' + (b1.taskCompletionRate*100).toFixed(0) + '% vs ' + (b2.taskCompletionRate*100).toFixed(0) + '%', 'info'); this.terminal.print('│ Avg tokens: ' + b1.avgTokens + ' vs ' + b2.avgTokens, 'info'); this.terminal.print('│ Avg latency: ' + (b1.avgLatency/1000).toFixed(1) + 's vs ' + (b2.avgLatency/1000).toFixed(1) + 's', 'info'); this.terminal.print('│ Last run: ' + b1.lastRun.substring(0,10) + ' vs ' + b2.lastRun.substring(0,10), 'info'); this.terminal.print('└──────────────────────────────────────────────────────────┘', 'info'); } // ── SELF-IMPROVEMENT LOOP ── _postRunReview(result, toolLog, usageData) { var anomalies = []; var maxT = this._activeLoopProfile && this._activeLoopProfile.loop ? this._activeLoopProfile.loop.maxTurns : this.MAX_TOOL_TURNS; if (result.toolTurns >= maxT * 0.8) { anomalies.push({ type: 'max_turns_near_limit', value: result.toolTurns, max: maxT }); } if (usageData && usageData.total_tokens > 50000) { anomalies.push({ type: 'high_token_usage', value: usageData.total_tokens }); } var failedTools = toolLog.filter(function(t) { return t.error; }); if (failedTools.length > 2) { var uniqueTools = []; failedTools.forEach(function(t) { if (uniqueTools.indexOf(t.name) === -1) uniqueTools.push(t.name); }); anomalies.push({ type: 'tool_failures', count: failedTools.length, tools: uniqueTools }); } var bench = this._activeLoopProfile ? this._activeLoopProfile.benchmark : null; if (bench && bench.avgTokens && usageData) { if (usageData.total_tokens > bench.avgTokens * 1.5) { anomalies.push({ type: 'above_benchmark', value: usageData.total_tokens, benchmark: bench.avgTokens, ratio: (usageData.total_tokens / bench.avgTokens).toFixed(1) }); } } if (anomalies.length === 0) return; var suggestions = []; var profileAdjusted = false; for (var ai = 0; ai < anomalies.length; ai++) { var a = anomalies[ai]; if (a.type === 'max_turns_near_limit') { suggestions.push('This task used ' + result.toolTurns + '/' + maxT + ' turns — consider increasing max-turns or splitting the task'); // Auto-correct: bump maxTurns by +2 if (this._activeLoopProfile && this._activeLoopProfile.loop) { var newMax = (this._activeLoopProfile.loop.maxTurns || this.MAX_TOOL_TURNS) + 2; this._activeLoopProfile.loop.maxTurns = newMax; this._saveLoopProfile(this._activeLoopProfile); suggestions.push(' → Auto-adjusted maxTurns to ' + newMax); profileAdjusted = true; } } else if (a.type === 'high_token_usage') suggestions.push('High token usage (' + (a.value/1000).toFixed(0) + 'K) — consider a minimal-tools profile for simpler tasks'); else if (a.type === 'tool_failures') { suggestions.push(a.count + ' tool failures (' + a.tools.join(', ') + ') — check if modules are loaded or paths correct'); // Auto-fix: map failed tools to modules and suggest fix if (!this._autoFixSuggestions) this._autoFixSuggestions = []; if (!this._toolToModuleMap) this._buildToolToModuleMap(); for (var fi = 0; fi < a.tools.length; fi++) { var failedMod = this._toolToModuleMap[a.tools[fi]]; if (failedMod && this._autoFixSuggestions.length < 3) { this._autoFixSuggestions.push({ module: failedMod, tool: a.tools[fi], reason: 'Repeated tool failure in agent loop', suggestion: 'Consider running ship_module to fix ' + failedMod }); } } // Auto-correct: inject negative constraint into profile append if (this._activeLoopProfile && this._activeLoopProfile.systemPrompt) { var constraint = '[Constraint] Avoid tools that failed: ' + a.tools.join(', ') + '. Verify module state before calling these tools.'; if (this._activeLoopProfile.systemPrompt.append) { if (this._activeLoopProfile.systemPrompt.append.indexOf(constraint) === -1) { this._activeLoopProfile.systemPrompt.append += '\n' + constraint; } } else { this._activeLoopProfile.systemPrompt.append = constraint; } this._saveLoopProfile(this._activeLoopProfile); suggestions.push(' → Added constraint to profile: avoid ' + a.tools.join(', ')); profileAdjusted = true; } } else if (a.type === 'above_benchmark') suggestions.push('This run used ' + a.ratio + 'x more tokens than benchmark avg (' + a.benchmark + ') — consider profile optimization'); } var suggestion = suggestions.join('\n'); try { var fs = this.terminal.fs; // MAINT-020: lessons are kept in /lessons with their own index; /skills is // reserved for real skills the user or the agent creates deliberately. try { fs.mkdirRecursive('/lessons'); } catch (ex) {} var index = fs.fsReadJSON('/lessons/index.json') || {}; var autoSkills = Object.entries(index); // One-time migration: earlier builds wrote loop-lesson-* into /skills. if (!this._loopLessonCleaned) { this._loopLessonCleaned = true; try { var sIdx = fs.fsReadJSON('/skills/index.json') || {}; Object.keys(sIdx).filter(function(k) { return k.indexOf('loop-lesson-') === 0; }) .forEach(function(k) { try { fs.unlink('/skills/' + k + '.md'); } catch (ex) {} delete sIdx[k]; }); fs.fsWriteJSON('/skills/index.json', sIdx); } catch (ex) {} } // Age limit: remove lessons older than 24h var now = Date.now(); autoSkills = autoSkills.filter(function(e) { var created = new Date(e[1].created).getTime(); if (now - created > 86400000) { try { fs.unlink('/lessons/' + e[0] + '.md'); } catch (ex) {} delete index[e[0]]; return false; } return true; }); // Dedup: skip if latest lesson has same anomaly types var anomalyTypes = anomalies.map(function(a) { return a.type; }).sort().join(','); var isDuplicate = autoSkills.length > 0 && autoSkills[autoSkills.length - 1][1].anomalyTypes === anomalyTypes; // FIFO rotation: if at cap, delete oldest before creating if (!isDuplicate && autoSkills.length >= 3) { var oldest = autoSkills[0]; try { fs.unlink('/lessons/' + oldest[0] + '.md'); } catch (ex) {} delete index[oldest[0]]; autoSkills.shift(); } if (!isDuplicate) { var skillName = 'loop-lesson-' + Date.now(); var content = '---\nname: ' + skillName + '\ndescription: Auto-saved from agent loop anomaly detection\ntags: [auto-improvement, agent-loop, auto]\ncategory: system\n---\n## Anomalies Detected\n\n' + JSON.stringify(anomalies, null, 2) + '\n\n## Suggestions\n\n' + suggestion + '\n\n## Context\n- Profile: ' + (this._activeLoopProfile ? this._activeLoopProfile.name : 'none') + '\n- Turns: ' + result.toolTurns + '\n- Tools used: ' + toolLog.map(function(t) { return t.name; }).join(', '); fs.mkdirRecursive('/lessons'); fs.writeFile('/lessons/' + skillName + '.md', content); index[skillName] = { name: skillName, description: 'Auto-saved loop lesson', tags: ['auto-improvement', 'agent-loop', 'auto'], category: 'system', created: new Date().toISOString(), updated: new Date().toISOString(), anomalyTypes: anomalyTypes }; fs.fsWriteJSON('/lessons/index.json', index); } } catch (e) { /* VFS not available */ } var msg = '💡 Self-improvement: ' + anomalies.length + ' anomaly(ies)'; if (profileAdjusted) msg += ' — profile auto-adjusted'; this.terminal.print(msg, 'info'); } async _collectDashboardData() { try { const terminal = this.terminal; const mm = terminal.moduleManager; const om = terminal.ollamaManager; const fs = terminal.fs; const data = {}; // Version data.version = window.NEWOS4_VERSION || 'v?'; // Modules const mods = mm ? Object.keys(mm.modules) : []; const registry = mm ? (mm.moduleRegistry || []) : []; data.modules = { loaded: mods.length, total: registry.length || mods.length, modifiedCount: 0 }; try { const modsDir = fs ? fs.readDir('/_mods/') : null; if (modsDir) data.modules.modifiedCount = modsDir.filter(function(e) { return e.type === 'file' && e.name.endsWith('.js') && !e.name.endsWith('.orig.js'); }).length; } catch (e) {} // Agents const orch = mm ? mm.modules['OrchestratorModule'] : null; if (orch && orch._agents) { var agentList = Object.values(orch._agents); data.agents = { total: agentList.length, busy: agentList.filter(function(a) { return a.status === 'busy'; }).length, idle: agentList.filter(function(a) { return a.status === 'idle'; }).length, sleeping: agentList.filter(function(a) { return a.status === 'sleeping'; }).length, failed: agentList.filter(function(a) { return a.status === 'failed' || a.status === 'restarting'; }).length, list: agentList.map(function(a) { return { id: a.id, role: a.role, status: a.status, caps: (a.capabilities || []).length }; }) }; } else { data.agents = { total: 0, busy: 0, idle: 0, sleeping: 0, failed: 0, list: [] }; } // Memory var heapStr = 'N/A'; try { if (performance.memory) heapStr = (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB'; } catch (e) {} var graphNodes = 0; var mg = mm ? mm.modules['MemoryGraphModule'] : null; if (mg && mg._nodes) graphNodes = Object.keys(mg._nodes).length; data.memory = { heap: heapStr, graphNodes: graphNodes }; // VFS try { if (fs) { var fileCount = 0, dirCount = 0; var walk = function(path) { var entries = fs.ls(path); if (!entries) return; for (var i = 0; i < entries.length; i++) { if (entries[i].type === 'file') fileCount++; else { dirCount++; walk(path === '/' ? '/' + entries[i].name : path + '/' + entries[i].name); } } }; walk('/'); data.vfs = { files: fileCount, dirs: dirCount, size: (JSON.stringify(fs.root || {}).length / 1024).toFixed(1) + 'KB' }; } } catch (e) { data.vfs = { files: 0, dirs: 0, size: '0KB' }; } // Ollama data.ollama = { connected: om ? om.connected : false, model: om ? om.currentModel : null, provider: om && om.currentProvider ? om.currentProvider.name : null }; // Skills try { var skillIdx = fs ? fs.fsReadJSON('/skills/index.json') : null; data.skills = { count: skillIdx ? Object.keys(skillIdx).length : 0 }; } catch (e) { data.skills = { count: 0 }; } // Token usage try { if (om && typeof om.getUsageStats === 'function') { var usage = om.getUsageStats({ since_hours: 24 }); data.usage = usage; } } catch (e) { data.usage = { total: 0 }; } // Tool latency var sm = mm ? mm.modules['ServerMonitorModule'] : null; if (sm && sm._latencyLog) { var toolStats = {}; for (var i = 0; i < sm._latencyLog.length; i++) { var entry = sm._latencyLog[i]; if (!toolStats[entry.name]) toolStats[entry.name] = { calls: 0, totalMs: 0, errors: 0, maxMs: 0 }; toolStats[entry.name].calls++; toolStats[entry.name].totalMs += entry.duration; toolStats[entry.name].errors += entry.error ? 1 : 0; toolStats[entry.name].maxMs = Math.max(toolStats[entry.name].maxMs, entry.duration); } data.latency = Object.entries(toolStats) .map(function(e) { return { name: e[0], avg: Math.round(e[1].totalMs / e[1].calls) + 'ms', max: e[1].maxMs + 'ms', calls: e[1].calls, errors: e[1].errors }; }) .sort(function(a, b) { return parseInt(b.avg) - parseInt(a.avg); }) .slice(0, 10); } else { data.latency = []; } // System health var errCounts = { total: 0, debug: 0, latency: 0, log: 0 }; try { if (this.terminal && typeof this.terminal.countErrors === 'function') { errCounts = this.terminal.countErrors(); } } catch (e) {} var totalErrors = errCounts.total; var predictor = mm ? mm.modules['PredictorModule'] : null; var predictorAcc = 'n/a (no paired runs)'; if (predictor && predictor._accuracy && predictor._accuracy.total > 0) { predictorAcc = Math.round(predictor._accuracy.correct / predictor._accuracy.total * 100) + '% (' + predictor._accuracy.correct + '/' + predictor._accuracy.total + ' paired)'; } data.health = { modules: data.modules.loaded + '/' + data.modules.total, vfsSize: data.vfs ? data.vfs.size : '?', online: typeof navigator !== 'undefined' ? navigator.onLine : true, errors: totalErrors, errorBreakdown: errCounts, predictorAccuracy: predictorAcc }; // 2026-10-22: the panel had a renderer and no collector, so it always said // "Not synthesized yet" while WorldModelModule._model was right there. // Map the model shape (modules.loaded, skills.count, files.total, memory.nodes…) // onto the keys the page reads (modules, skills, files, memoryNodes…). try { var wm = mm ? mm.modules['WorldModelModule'] : null; var wmModel = wm && wm._model; if (wmModel) { data.worldModel = { synthesized: wmModel.synthesized || null, version: wmModel.version || '?', modules: wmModel.modules ? wmModel.modules.loaded : null, skills: wmModel.skills ? wmModel.skills.count : null, files: wmModel.files ? wmModel.files.total : null, agents: wmModel.agents ? wmModel.agents.agents : null, memoryNodes: wmModel.memory ? wmModel.memory.nodes : null, memoryEdges: wmModel.memory ? wmModel.memory.edges : null, health: wmModel.system ? wmModel.system.health : null, activeProjects: ((wmModel.projects && wmModel.projects.activeProjects) || []) .map(function (p) { return p.name + ' (' + p.accesses + 'x)'; }), recent: (wmModel.recent || []).slice(0, 5), changes: (typeof wm._diffSincePrev === 'function') ? wm._diffSincePrev() : [], errors: (typeof wm._errorSample === 'function') ? wm._errorSample() : [] }; } else { data.worldModel = { missing: true }; } } catch (e) { data.worldModel = { missing: true, error: e.message }; } data.timestamp = Date.now(); // Write to localStorage for the dashboard HTML page to read try { localStorage.setItem('webx11_dashboard_data', JSON.stringify(data)); } catch (e) {} return data; } catch (e) { console.error('[Dashboard] Collection error:', e.message); return { error: e.message, timestamp: Date.now() }; } } } /* === module-aiasm.js === */ // Extracted from newOS4 index.html — WebXAIAliasModule class WebXAIAliasModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'webxai': 'webxai <question> — Send a question to the AI assistant', 'wx': 'wx <question> — Alias for webxai', 'webx': 'webx <question> — Alias for webxai' }; } registerCommands() { this.commands['webxai'] = (args) => this.forwardToAI(args); this.commands['wx'] = (args) => this.forwardToAI(args); this.commands['webx'] = (args) => this.forwardToAI(args); } forwardToAI(args) { if (!args[0]) { this.terminal.print("Usage: webxai <your-question>", "warning"); return; } if (this.terminal.moduleManager.modules['WebX11AIAssistantModule']) { this.terminal.moduleManager.modules['WebX11AIAssistantModule'].chat(args); } else { this.terminal.ollamaManager.chat(args.join(' ')); } } } /* === module-fetcher.js === */ class WebFetcherModule { constructor() { this.commands = {}; this.terminal = null; } getHelpInfo() { return { 'fetch': 'fetch <url> [--post=BODY] [--type=CT] [--raw] — Fetch URL (raw HTML/text); --post enables POST with body, --type sets Content-Type, --raw shows untruncated content', 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL', }; } registerCommands() { this.commands['fetch'] = (args) => this.fetchUrl(args); this.commands['extract'] = (args) => this.extractUrl(args); } async _fetchWithTimeout(url, timeoutMs = 30000) { // Wrap fetch with AbortController for client-side timeout const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timeout); } } _parseFlags(args) { // Extract --key=value flags from args; return {flags, positional} const flags = {}; const positional = []; for (const a of args) { const m = String(a).match(/^--([a-zA-Z][\w-]*)(?:=(.*))?$/); if (m) { flags[m[1]] = m[2] !== undefined ? m[2] : true; } else { positional.push(a); } } return { flags, positional }; } async fetchUrl(args) { const { flags, positional } = this._parseFlags(args || []); if (!positional[0]) { this.terminal.print("Usage: fetch <url> [--post=BODY] [--type=CT] [--raw]", "warning"); this.terminal.print(" Example: fetch https://example.com", "info"); this.terminal.print(" Example: fetch https://api.hyperliquid.xyz/info --post='{\"type\":\"l2Book\",\"coin\":\"HYPE\"}' --type=application/json", "info"); return; } let url = positional[0]; if (!url.match(/^https?:\/\//i)) url = 'https://' + url; const method = flags.post ? 'POST' : 'GET'; const showRaw = !!flags.raw; this.terminal.print(`🌐 Fetching (${method}): ${url}`, "info"); if (flags.post) this.terminal.print(`📤 Body: ${String(flags.post).substring(0, 200)}${String(flags.post).length > 200 ? '...' : ''}`, "info"); try { const params = new URLSearchParams({ url }); if (flags.post) { params.set('method', 'POST'); params.set('body', flags.post); } if (flags.type) params.set('content_type', flags.type); const resp = await this._fetchWithTimeout('/api/fetch?' + params.toString(), 30000); if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } if (!resp.ok) { // Try to parse JSON error from proxy const ct = resp.headers.get('Content-Type') || ''; if (ct.includes('application/json')) { const err = await resp.json().catch(() => ({})); this.terminal.print('Fetch failed: ' + (err.error || 'HTTP ' + resp.status) + (err.details ? ' — ' + err.details.split('\n')[0] : ''), 'error'); } else { this.terminal.print('Fetch failed: HTTP ' + resp.status, 'error'); } return; } const text = await resp.text(); const limit = showRaw ? text.length : 3000; this.terminal.print(`\n=== Content from ${url} (${method}) ===\n`, 'success'); this.terminal.print(text.substring(0, limit) + (text.length > limit && !showRaw ? '...' : '')); this.terminal.print(`\n📏 Content Length: ${text.length} chars${showRaw ? ' (raw, untruncated)' : ''}`, 'info'); } catch (e) { if (e.name === 'AbortError') { this.terminal.print(`⏱ Request timed out (30s). Try \`extract\` instead of \`fetch\` for slow sites.`, 'error'); } else { this.terminal.print('Fetch failed: ' + e.message, 'error'); } } } async extractUrl(args) { if (!args[0]) { this.terminal.print("Usage: extract <url>", "warning"); this.terminal.print("Example: extract https://example.com", "info"); return; } let url = args[0]; if (!url.match(/^https?:\/\//i)) url = 'https://' + url; this.terminal.print(`📄 Extracting: ${url}`, "info"); try { const resp = await this._fetchWithTimeout('/api/extract?url=' + encodeURIComponent(url), 30000); if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; } if (!resp.ok) { const err = await resp.json().catch(() => ({})); this.terminal.print('Extract failed: ' + (err.error || 'HTTP ' + resp.status) + (err.details ? ' — ' + err.details.split('\n')[0] : ''), 'error'); return; } const data = await resp.json(); if (data.title) this.terminal.print(`\n📰 ${data.title}\n`, 'success'); this.terminal.print(data.content || '(no content extracted)'); this.terminal.print(`\n📏 ${data.word_count || 0} words (via ${data.method || 'unknown'})`, 'info'); // F5: Follow-up hint this.terminal.print(`💡 Try \`summarize ${url}\` for AI summary, or \`nvfs-resolve <concept>\` to find related files`, 'info'); } catch (e) { if (e.name === 'AbortError') { this.terminal.print(`⏱ Extract timed out (30s). Site may be slow or blocking automated access.`, 'error'); } else { this.terminal.print('Extract failed: ' + e.message, 'error'); } } } } /* === secret-vault.js === */ // Extracted from newOS4 index.html — SecretVault class SecretVault { constructor() { this.commands = {}; this.terminal = null; this._key = null; this._prefix = 'webx11_secret_'; this._saltKey = 'webx11_secret_salt'; this._verifyName = 'secret_vault_verified'; this._openModeKey = 'webx11_secret_open_key'; } getHelpInfo() { return { 'secret-set': 'secret-set <name> <value> — Encrypt and store a secret', 'secret-get': 'secret-get <name> — Decrypt and display a secret value', 'secret-list': 'secret-list — List all stored secret names', 'secret-del': 'secret-del <name> — Remove a stored secret', 'secret-lock': 'secret-lock — Lock the vault (clear derived key from memory)', 'secret-unlock': 'secret-unlock <pin> — Unlock the vault with a PIN code', 'secret-setpin': 'secret-setpin <pin> — Set or change the vault PIN code', 'secret-status': 'secret-status — Show vault status (locked/unlocked/pin set/open mode)', }; } registerCommands() { this.commands['secret-set'] = (args) => this._cmdSet(args); this.commands['secret-get'] = (args) => this._cmdGet(args); this.commands['secret-list'] = () => this._cmdList(); this.commands['secret-del'] = (args) => this._cmdDel(args); this.commands['secret-lock'] = () => this._cmdLock(); this.commands['secret-unlock'] = (args) => this._cmdUnlock(args); this.commands['secret-setpin'] = (args) => this._cmdSetPin(args); this.commands['secret-status'] = () => this._cmdStatus(); } // ---------- Core API ---------- isPinSet() { return localStorage.getItem(this._saltKey) !== null; } isLocked() { // Locked only if PIN is set but key is not derived in memory return this.isPinSet() && this._key === null; } async setPin(pin) { if (!pin || typeof pin !== 'string' || pin.length === 0) { return { error: 'PIN must be a non-empty string' }; } // Generate new salt const salt = this._generateSalt(); localStorage.setItem(this._saltKey, this._arrayToBase64(salt)); // Derive new key from the new PIN const newKey = await this._deriveKeyFromPin(pin, salt); if (this.isPinSet() && this._key !== null) { // Re-encrypt all existing secrets with new key await this._reEncryptAll(newKey); } // Store the new key in memory this._key = newKey; // Store a verification token so we know the PIN works await this._encryptWithKey(this._verifyName, 'verified', this._key); return { success: true }; } lock() { this._key = null; return { success: true }; } async unlock(pin) { if (!pin || typeof pin !== 'string' || pin.length === 0) { return { error: 'PIN must be a non-empty string' }; } if (!this.isPinSet()) { return { error: 'No PIN has been set. Use secret-setpin first.' }; } const saltBase64 = localStorage.getItem(this._saltKey); if (!saltBase64) { return { error: 'Salt not found. Vault may be corrupted.' }; } const salt = this._base64ToArray(saltBase64); const key = await this._deriveKeyFromPin(pin, salt); // Verify PIN by checking the verification token const verifyData = await this._decryptWithKey(this._verifyName, key); if (verifyData === null) { return { error: 'Invalid PIN' }; } this._key = key; return { success: true }; } async encrypt(name, value) { if (this.isLocked()) { return { error: 'Vault is locked' }; } // Ensure key is available let key = this._key; if (!key) { // Open mode: use or generate a session-like key key = await this._getOpenModeKey(); } const stringValue = typeof value === 'string' ? value : JSON.stringify(value); return this._encryptWithKey(name, stringValue, key); } async decrypt(name) { if (this.isLocked()) { return null; } let key = this._key; if (!key) { key = await this._getOpenModeKey(); if (!key) return null; } return this._decryptWithKey(name, key); } remove(name) { localStorage.removeItem(this._prefix + name); return { success: true }; } list() { const names = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key.startsWith(this._prefix)) { names.push(key.substring(this._prefix.length)); } } return names; } has(name) { return localStorage.getItem(this._prefix + name) !== null; } /** * v4.5.68: Redacts known secret NAMES (not values) from text — sync, no decryption needed. * Previous version decrypted ALL secrets on every call (perf + side-channel + failed when locked). * Now just redacts the secret names if they appear in output. Values are never decrypted into memory. */ redactSecret(text) { if (!text || typeof text !== 'string') return text; const names = this.list(); if (names.length === 0) return text; let redacted = text; for (const name of names) { if (name && redacted.includes(name)) { redacted = redacted.split(name).join('[REDACTED]'); } } return redacted; } // ---------- Internal crypto methods ---------- _generateSalt() { const salt = new Uint8Array(16); crypto.getRandomValues(salt); return salt; } _generateIV() { const iv = new Uint8Array(12); crypto.getRandomValues(iv); return iv; } _arrayToBase64(array) { return btoa(String.fromCharCode.apply(null, new Uint8Array(array))); } _base64ToArray(base64) { const binary = atob(base64); const array = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { array[i] = binary.charCodeAt(i); } return array; } async _deriveKeyFromPin(pin, salt) { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(pin), 'PBKDF2', false, ['deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: 600000, // v4.5.68: 100k → 600k (OWASP 2023 recommendation) hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256, }, false, ['encrypt', 'decrypt'] ); } async _getOpenModeKey() { // In open mode (no PIN set), we use a persistent 32-char hex key in localStorage // This is less secure than PIN mode but better than plaintext let keyHex = localStorage.getItem(this._openModeKey); if (!keyHex) { keyHex = this._generateOpenKey(); localStorage.setItem(this._openModeKey, keyHex); } return this._hexKeyToCryptoKey(keyHex); } _generateOpenKey() { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); } async _hexKeyToCryptoKey(hex) { const bytes = new Uint8Array(32); for (let i = 0; i < 32; i++) { bytes[i] = parseInt(hex.substr(i * 2, 2), 16); } return crypto.subtle.importKey( 'raw', bytes, { name: 'AES-GCM', length: 256, }, false, ['encrypt', 'decrypt'] ); } async _encryptWithKey(name, value, key) { try { const encoder = new TextEncoder(); const data = encoder.encode(value); const iv = this._generateIV(); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv, }, key, data ); const encryptedArray = new Uint8Array(encrypted); const storageData = JSON.stringify({ iv: this._arrayToBase64(iv), data: this._arrayToBase64(encryptedArray), }); localStorage.setItem(this._prefix + name, storageData); return { success: true }; } catch (e) { return { error: 'Encryption failed: ' + e.message }; } } async _decryptWithKey(name, key) { try { const storageData = localStorage.getItem(this._prefix + name); if (!storageData) return null; const parsed = JSON.parse(storageData); if (!parsed.iv || !parsed.data) return null; const iv = this._base64ToArray(parsed.iv); const encryptedData = this._base64ToArray(parsed.data); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv, }, key, encryptedData ); const decoder = new TextDecoder(); return decoder.decode(decrypted); } catch (e) { return null; } } async _reEncryptAll(newKey) { const names = this.list(); const oldKey = this._key; for (const name of names) { // Skip verification token — it's regenerated in setPin if (name === this._verifyName) continue; const plaintext = await this._decryptWithKey(name, oldKey); if (plaintext !== null) { await this._encryptWithKey(name, plaintext, newKey); } } // Update verification token with new key await this._encryptWithKey(this._verifyName, 'verified', newKey); } // ---------- Command handlers ---------- async _cmdSet(args) { if (args.length < 2) { this.terminal.print('Usage: secret-set <name> <value>', 'warning'); return; } const name = args[0]; const value = args.slice(1).join(' '); const result = await this.encrypt(name, value); if (result.error) { this.terminal.print(result.error, 'error'); } else { this.terminal.print(`Secret '${name}' saved`, 'success'); } } async _cmdGet(args) { if (!args[0]) { this.terminal.print('Usage: secret-get <name>', 'warning'); return; } const value = await this.decrypt(args[0]); if (value === null) { if (this.isLocked()) { this.terminal.print('Vault is locked. Use secret-unlock <pin>', 'warning'); } else { this.terminal.print(`Secret '${args[0]}' not found`, 'error'); } } else { this.terminal.print(`Secret '${args[0]}': ${value}`, 'info'); } } _cmdList() { const names = this.list(); if (names.length === 0) { this.terminal.print('No secrets stored', 'info'); return; } this.terminal.print('=== Stored Secrets ===', 'info'); names.forEach(n => { // Skip internal verification token if (n === this._verifyName) return; this.terminal.print(` - ${n}`); }); } _cmdDel(args) { if (!args[0]) { this.terminal.print('Usage: secret-del <name>', 'warning'); return; } const result = this.remove(args[0]); if (result.success) { this.terminal.print(`Secret '${args[0]}' deleted`, 'success'); } } _cmdLock() { const result = this.lock(); if (result.success) { this.terminal.print('Vault locked', 'success'); } } async _cmdUnlock(args) { if (!args[0]) { this.terminal.print('Usage: secret-unlock <pin>', 'warning'); return; } const result = await this.unlock(args[0]); if (result.error) { this.terminal.print(result.error, 'error'); } else { this.terminal.print('Vault unlocked', 'success'); } } async _cmdSetPin(args) { if (!args[0]) { this.terminal.print('Usage: secret-setpin <pin>', 'warning'); return; } const result = await this.setPin(args[0]); if (result.error) { this.terminal.print(result.error, 'error'); } else { this.terminal.print('PIN set successfully. Vault is now in PIN-protected mode.', 'success'); } } _cmdStatus() { const pinSet = this.isPinSet(); const locked = this.isLocked(); const hasKey = this._key !== null; this.terminal.print('=== Secret Vault Status ===', 'info'); this.terminal.print(`PIN set: ${pinSet ? 'Yes' : 'No'}`, pinSet ? 'success' : 'info'); if (pinSet) { this.terminal.print(`Vault: ${locked ? '🔒 Locked' : '🔓 Unlocked'}`, locked ? 'warning' : 'success'); } else { this.terminal.print(`Mode: Open (no PIN — using persistent key)`, 'info'); } this.terminal.print(`Secrets stored: ${this.list().filter(n => n !== this._verifyName).length}`, 'info'); } } /* === module-chatstudio.js === */ /* * ChatStudio Overlay Module for newOS4 — reads window.NEWOS4_VERSION at runtime * Integrates ChatStudio Lite's full chat UI as an overlay launched via `chat` command. * All Lite engine functions are inlined from lite/cslite-core.js, adapted to use * the terminal's OllamaManager for endpoint configuration. */ class ChatStudioModule { constructor() { this.commands = {}; this.terminal = null; this._overlay = null; this._active = false; this._cssLoaded = false; this._escapeHandler = null; this._initialized = false; } getHelpInfo() { return { 'chat': 'Toggle the ChatStudio overlay (inside: /setapi, /setmodel, /ask, /help)', }; } registerCommands() { this.commands['chat'] = () => this.toggleChatOverlay(); } toggleChatOverlay() { if (this._active) { this._closeOverlay(); } else { this._openOverlay(); } } _loadOverlayAssets() { return new Promise((resolve, reject) => { // Load CSS — gate resolve on onload to prevent FOUC var cssReady = Promise.resolve(); if (!this._cssLoaded) { cssReady = new Promise((res, rej) => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'chat/chat-ui.css?v=' + (window.NEWOS4_VERSION || '5'); link.onload = () => { this._cssLoaded = true; res(); }; link.onerror = () => rej(new Error('Failed to load chat/chat-ui.css')); document.head.appendChild(link); }); } // Expose ALL Lite globals BEFORE scripts load so chat-modules.js & chat-commands.js can use them if (!window._csGlobalsInited) { window.OLLAMA_URL = (this.terminal && this.terminal.ollamaManager && this.terminal.ollamaManager.endpoint) || '/ollama'; window.OLLAMA_API_KEY = ''; window.DEFAULT_MODEL = 'llama3.2'; window.USE_STREAMING = true; window.API_BASE = '/api'; window.latestAiContent = ''; window.logs = []; window.messages = []; window.isStreaming = false; window.CHATSTUDIO_VERSION = window.NEWOS4_VERSION || '1.0.0-lite-inline'; window.modules = []; window.currentChatModule = 'default'; // Utility functions window.utf8_to_b64 = function(str) { return btoa(unescape(encodeURIComponent(str))); }; window.b64_to_utf8 = function(str) { return decodeURIComponent(escape(atob(str))); }; window.escapeHtml = function(text) { if (typeof text !== 'string') return ''; return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\\\"/g, '"').replace(/'/g, '''); }; window.generateId = function() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); }; window.showToast = function(msg) { var t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.style.display = 'block'; setTimeout(function() { t.style.display = 'none'; }, 2500); }; window.addLog = function(action, detail) { // Placeholder — replaced by full impl in _initLiteEngine }; // Placeholder functions — replaced by full implementations in _initLiteEngine window.addMessage = function(role, content) {}; window.showTyping = function() {}; window.hideTyping = function() {}; window.loadSettings = function() {}; window.saveSettings = function() {}; window.loadModels = function() {}; window.switchModule = function(id) {}; window.getCurrentModule = function() {}; window.sendMessage = function() {}; window.clearChat = function() {}; window.closeSettingsModal = function() {}; window.openSettingsModal = function() {}; window._csGlobalsInited = true; } // Fetch HTML template (cached after first load) — wait for CSS first if (this._overlayHtml) { // Overlay div already exists (just hidden). Wait for CSS then resolve. cssReady.then(() => resolve()); return; } cssReady .then(() => fetch('chat/chat-ui.html?v=' + (window.NEWOS4_VERSION || '5'))) .then(r => r.text()) .then(html => { this._overlayHtml = html; // cache for re-open // Insert overlay into body if not already there if (!document.getElementById('chat-overlay')) { const temp = document.createElement('div'); temp.innerHTML = html; document.body.appendChild(temp.firstElementChild); // Load commands.js and modules.js dynamically const loadScript = (src) => { return new Promise((res, rej) => { const s = document.createElement('script'); s.src = src + '?v=' + (window.NEWOS4_VERSION || '5'); s.onload = () => res(); s.onerror = () => rej(new Error('Failed to load ' + src)); document.body.appendChild(s); }); }; Promise.all([ loadScript('chat/chat-commands.js'), loadScript('chat/chat-modules.js') ]).then(() => { resolve(); }); } else { resolve(); } }) .catch(() => reject(new Error('Failed to load chat/chat-ui.html'))); }); } async _openOverlay() { if (this._active) return; this._active = true; try { // Load overlay assets first (CSS, HTML, scripts) await this._loadOverlayAssets(); // Initialize the Lite engine this._initLiteEngine(); // Show overlay const overlay = document.getElementById('chat-overlay'); if (overlay) { overlay.style.display = 'flex'; overlay.style.flexDirection = 'column'; } // Add Escape listener this._escapeHandler = (e) => { if (e.key === 'Escape') { this._closeOverlay(); } }; document.addEventListener('keydown', this._escapeHandler); // Set window-level endpoint for Lite to use if (this.terminal && this.terminal.ollamaManager) { window.ollamaEndpoint = this.terminal.ollamaManager.endpoint; } } catch (err) { // Print error to terminal if (this.terminal && this.terminal.print) { this.terminal.print('Chat overlay failed: ' + err.message, 'error'); } // Show a toast on the main page if possible if (typeof window.showToast === 'function') { window.showToast('Chat overlay failed: ' + err.message); } // Clean up any partial overlay DOM — hide instead of remove so cached HTML path still works const partialOverlay = document.getElementById('chat-overlay'); if (partialOverlay) { partialOverlay.style.display = 'none'; } // Reset state so user can retry this._active = false; } } _closeOverlay() { if (!this._active) return; this._active = false; const overlay = document.getElementById('chat-overlay'); if (overlay) { overlay.style.display = 'none'; } // Remove Escape listener if (this._escapeHandler) { document.removeEventListener('keydown', this._escapeHandler); this._escapeHandler = null; } // Focus terminal input if (this.terminal && this.terminal.inputField) { setTimeout(() => this.terminal.inputField.focus(), 50); } } _initLiteEngine() { // Already initialized — no-op if (this._initialized) return; // Set the overlay reference this._overlay = document.getElementById('chat-overlay'); if (!this._overlay) { if (this.terminal && this.terminal.print) { this.terminal.print('Chat overlay: #chat-overlay element not found', 'error'); } return; } this._initialized = true; try { // Close button already exists in HTML as "✕ Close" button calling closeChatOverlay() window.closeChatOverlay = () => this._closeOverlay(); // ── Constants ── const CHATSTUDIO_VERSION = window.NEWOS4_VERSION || '1.0.0-lite-inline'; const API_KEY = 'webx11_chat_api'; const MESSAGES_KEY = 'cslite_messages'; const SETTINGS_KEY = 'webx11_chat_settings'; const LOGS_KEY = 'webx11_chat_logs'; // P2#11: API key is now kept in-memory only (not persisted to localStorage) // It's loaded into OLLAMA_API_KEY during session, cleared on page reload // ── State ── let OLLAMA_URL = '/ollama'; let OLLAMA_API_KEY = ''; let DEFAULT_MODEL = 'llama3.2'; let USE_STREAMING = true; let API_BASE = '/api'; let latestAiContent = ''; let logs = []; let messages = []; let isStreaming = false; // Sync OLLAMA_URL from terminal if (this.terminal && this.terminal.ollamaManager) { OLLAMA_URL = this.terminal.ollamaManager.endpoint || '/ollama'; } // ── Utility Functions ── function escapeHtml(text) { if (typeof text !== 'string') return ''; return text .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function utf8_to_b64(str) { return btoa(unescape(encodeURIComponent(str))); } function b64_to_utf8(str) { return decodeURIComponent(escape(atob(str))); } function generateId() { return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9); } // Fetch with timeout — AbortController-based, 30s default var _streamAbort = null; // AbortController for streaming, null when not active function fetchWithTimeout(url, opts, timeout) { timeout = timeout || 30000; opts = opts || {}; var ctrl = new AbortController(); opts.signal = ctrl.signal; var timer = setTimeout(function() { ctrl.abort(); }, timeout); return fetch(url, opts).finally(function() { clearTimeout(timer); }); } function showToast(msg) { var t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.style.display = 'block'; setTimeout(function () { t.style.display = 'none'; }, 2500); } function autoExpand(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 150) + 'px'; } // Close sidebar when clicking outside on mobile var backdrop = document.getElementById('sidebarBackdrop'); if (!backdrop) { backdrop = document.createElement('div'); backdrop.id = 'sidebarBackdrop'; backdrop.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:99;display:none;background:transparent'; backdrop.onclick = function() { closeSidebar(); }; document.getElementById('chat-overlay')?.appendChild(backdrop); } function toggleSidebar() { var s = document.getElementById('sidebar'); if (!s) return; // On mobile (<700px) use .open, on desktop use .collapsed var isMobile = window.innerWidth <= 700; if (isMobile) { s.classList.toggle('open'); } else { s.classList.toggle('collapsed'); // Toggle backdrop on mobile regardless } if (backdrop) { backdrop.style.display = s.classList.contains('open') || s.classList.contains('collapsed') ? 'block' : 'none'; } } function closeSidebar() { var s = document.getElementById('sidebar'); if (s) { s.classList.remove('open'); s.classList.remove('collapsed'); } if (backdrop) backdrop.style.display = 'none'; } // ── Log System ── var MAX_LOGS = 200; // P2#17: Configurable log limit (was hardcoded 50) function addLog(action, detail) { var time = new Date().toLocaleTimeString(); logs.unshift({ time: time, action: action, detail: detail }); if (logs.length > MAX_LOGS) logs.length = MAX_LOGS; renderLogs(); try { // P2#12: Use plain JSON instead of base64 (consistent with messages storage) localStorage.setItem(LOGS_KEY, JSON.stringify(logs)); } catch (e) { /* ignore */ } } function renderLogs() { var container = document.getElementById('logsContent'); if (!container) return; container.innerHTML = logs.map(function (l) { return '<div class="log-entry">' + '<span class="log-time">' + escapeHtml(l.time) + '</span>' + '<span class="log-action">' + escapeHtml(l.action) + '</span>' + '<span class="log-detail">' + escapeHtml(l.detail) + '</span>' + '</div>'; }).join(''); } function copyLogs() { var text = logs.map(function (l) { return l.time + ' | ' + l.action + ' | ' + l.detail; }).join('\n'); navigator.clipboard.writeText(text).then(function () { showToast('Logs copied!'); }); } function clearLogs() { if (!confirm('Clear all activity logs?')) return; logs = []; try { localStorage.removeItem(LOGS_KEY); } catch (e) { /* ignore */ } renderLogs(); showToast('Logs cleared'); } function loadLogs() { try { var saved = localStorage.getItem(LOGS_KEY); if (saved) { // P2#12: Handle both old base64 and new plain JSON try { logs = JSON.parse(saved); } catch (e1) { logs = JSON.parse(b64_to_utf8(saved)); } } } catch (e) { logs = []; } renderLogs(); } // ── Settings ── function loadSettings() { try { var saved = localStorage.getItem(SETTINGS_KEY); if (saved) { // P2#12: Handle both old base64 and new plain JSON var settings; try { settings = JSON.parse(saved); } catch (e1) { settings = JSON.parse(b64_to_utf8(saved)); } OLLAMA_URL = settings.ollamaUrl || '/ollama'; OLLAMA_API_KEY = settings.ollamaApiKey || ''; DEFAULT_MODEL = settings.defaultModel || 'llama3.2'; USE_STREAMING = settings.useStreaming !== false; API_BASE = settings.apiBase || '/api'; } } catch (e) { console.warn('Failed to load settings:', e); } // Sync from terminal if available var term = window.webx11; if (term && term.ollamaManager) { OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; } var urlEl = document.getElementById('ollamaUrl'); var keyEl = document.getElementById('ollamaApiKey'); var modelEl = document.getElementById('defaultModel'); var streamEl = document.getElementById('streamingToggle'); var apiEl = document.getElementById('apiBase'); if (urlEl) urlEl.value = OLLAMA_URL; if (keyEl) keyEl.value = OLLAMA_API_KEY; if (modelEl) modelEl.value = DEFAULT_MODEL; if (streamEl) streamEl.checked = USE_STREAMING; if (apiEl) apiEl.value = API_BASE; } function saveSettings() { OLLAMA_URL = document.getElementById('ollamaUrl').value.trim() || '/ollama'; OLLAMA_API_KEY = document.getElementById('ollamaApiKey').value.trim(); DEFAULT_MODEL = document.getElementById('defaultModel').value.trim() || 'llama3.2'; USE_STREAMING = document.getElementById('streamingToggle').checked; API_BASE = (document.getElementById('apiBase')?.value || '/api').trim(); try { var settings = { ollamaUrl: OLLAMA_URL, // P2#11: Don't persist API key — keep in memory only defaultModel: DEFAULT_MODEL, useStreaming: USE_STREAMING, apiBase: API_BASE }; // P2#12: Use plain JSON instead of base64 (consistent storage) localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } catch (e) { console.warn('Failed to save settings:', e); } closeSettingsModal(); showToast('Settings saved!'); } function openSettingsModal() { loadSettings(); var m = document.getElementById('settingsModal'); if (m) m.classList.add('open'); } function closeSettingsModal() { var m = document.getElementById('settingsModal'); if (m) m.classList.remove('open'); } async function testConnection() { var status = document.getElementById('connectionStatus'); if (!status) return; status.textContent = 'Testing...'; status.style.color = 'var(--text2)'; try { var headers = { 'Content-Type': 'application/json' }; if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; var response = await fetchWithTimeout(OLLAMA_URL + '/api/tags', { headers: headers }); if (response.ok) { var data = await response.json(); var modelCount = data.models ? data.models.length : 0; status.textContent = 'Connected! ' + modelCount + ' models available.'; status.style.color = 'var(--accent)'; } else { status.textContent = 'Connection failed: HTTP ' + response.status; status.style.color = 'var(--danger)'; } } catch (e) { status.textContent = 'Error: ' + e.message; status.style.color = 'var(--danger)'; } } // ── Models ── async function loadModels() { try { var response = await fetchWithTimeout(OLLAMA_URL + '/api/tags'); if (!response.ok) throw new Error('Failed to fetch models'); var data = await response.json(); var select = document.getElementById('modelSelect'); if (!select) return; var shortNames = { 'llama3.2': 'Llama3.2', 'llama3': 'Llama3', 'gemma4:31b-cloud': 'Gemma4-31B', 'gemma2:2b': 'Gemma2', 'qwen2.5:7b-instruct': 'Qwen2.5', 'qwen3:8b': 'Qwen3', 'mistral': 'Mistral', 'tinyllama': 'Tiny' }; select.innerHTML = data.models.map(function (m) { var name = m.name.split(':')[0]; var display = shortNames[m.name] || name.substring(0, 14); return '<option value="' + escapeHtml(m.name) + '">' + escapeHtml(display) + '</option>'; }).join(''); addLog('Init', 'Loaded ' + data.models.length + ' models'); } catch (e) { console.error('Failed to load models:', e); var select = document.getElementById('modelSelect'); if (select) select.innerHTML = '<option value="llama3.2">llama3.2 (default)</option>'; } } var modelSelect = document.getElementById('modelSelect'); if (modelSelect) { modelSelect.addEventListener('change', function () { addLog('Model', 'Switched to ' + this.value); }); } // ── Message History ── var MAX_MESSAGES = 100; // P2#13: Cap chat history to prevent localStorage overflow function saveMessages(msgs) { msgs = msgs || messages; // P2#13: Trim to last MAX_MESSAGES before saving if (msgs.length > MAX_MESSAGES) { msgs = msgs.slice(-MAX_MESSAGES); messages.length = 0; messages.push.apply(messages, msgs); } try { localStorage.setItem(MESSAGES_KEY, JSON.stringify(msgs)); } catch (e) { console.warn('Failed to save messages:', e); } } function loadMessages() { try { var raw = localStorage.getItem(MESSAGES_KEY); if (raw) { messages = JSON.parse(raw); } } catch (e) { console.warn('Failed to load messages:', e); messages = []; } } function renderMessagesFromData() { var container = document.getElementById('chatContainer'); if (!container) return; if (!messages || messages.length === 0) { return; } container.innerHTML = ''; for (var i = 0; i < messages.length; i++) { var msg = messages[i]; appendMessageToDOM(msg.role, msg.content, msg.id, false); } container.scrollTop = container.scrollHeight; } function appendMessageToDOM(role, content, msgId, save) { if (save === undefined) save = true; var container = document.getElementById('chatContainer'); if (!container) return; var div = document.createElement('div'); div.className = 'message ' + role; if (msgId) div.dataset.msgId = msgId; var previewBtn = role === 'ai' ? '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' : ''; if (role === 'ai') latestAiContent = content; // P2#16: Apply markdown formatting to user messages too (was plain escapeHtml only) var renderedContent = role === 'user' ? formatContent(escapeHtml(content)) : formatContent(content); div.innerHTML = '<div class="role">' + '<span>' + (role === 'user' ? 'You' : 'AI') + '</span>' + '<div style="display:flex;gap:4px;">' + '<button class="msg-copy" onclick="copyMessage(this, \'' + role + '\')" title="Copy">📋</button>' + previewBtn + '</div></div>' + '<div class="msg-content">' + renderedContent + '</div>'; div.dataset.content = content; container.appendChild(div); container.scrollTop = container.scrollHeight; if (save) { saveMessages(); } } function addMessage(role, content) { var msgId = generateId(); var msg = { id: msgId, role: role, content: content, timestamp: Date.now() }; messages.push(msg); appendMessageToDOM(role, content, msgId, true); return msgId; } // ── Content Formatting ── function formatContent(text) { // Step 1: Extract code blocks into placeholders var blocks = []; var processed = text .replace(/```html\n?([\s\S]*?)```/g, function(m, code) { var idx = blocks.length; blocks.push({lang: 'html', code: code, isBlock: true}); return '%%CODEBLOCK_' + idx + '%%'; }) .replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) { var idx = blocks.length; blocks.push({lang: lang, code: code, isBlock: true}); return '%%CODEBLOCK_' + idx + '%%'; }) .replace(/`([^`]+)`/g, function(m, content) { var idx = blocks.length; blocks.push({lang: '', code: content, isBlock: false, isInline: true}); return '%%CODEBLOCK_' + idx + '%%'; }); // Step 2: Escape everything outside code blocks processed = escapeHtml(processed); // Step 3: Apply markdown formatting on the escaped text processed = processed .replace(/\*\*([^*]+)\*\*/g, function(m, content) { return '<strong>' + content + '</strong>'; }) .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>') .replace(/\n/g, '<br>'); // Step 4: Restore code blocks with proper HTML processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, function(m, idx) { idx = parseInt(idx); var block = blocks[idx]; if (!block) return m; if (block.isInline) { return '<code>' + escapeHtml(block.code) + '</code>'; } var id = 'code_' + Math.random().toString(36).substr(2, 9); if (block.lang === 'html') { return '<div class="code-block" data-code="' + encodeURIComponent(block.code) + '">' + '<pre>' + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="previewCodeBlock(this)">👁️</button>' + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;margin-right:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + '<code id="' + id + '">' + escapeHtml(block.code) + '</code></pre></div>'; } return '<pre>' + '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="copyMessage(this, \'code\')">📋</button>' + '<code>' + escapeHtml(block.code) + '</code></pre>'; }); return processed; } // ── Copy & Preview ── function copyMessage(btn, role) { var content; if (role === 'code') { content = btn.nextElementSibling.textContent; } else { var msg = btn.closest('.message'); content = msg.dataset.content; } navigator.clipboard.writeText(content).then(function () { showToast('Copied!'); }); } function showHtmlPreview() { var frame = document.getElementById('previewFrame'); if (!frame) return; var doc = frame.contentWindow.document; doc.open(); doc.write(latestAiContent); doc.close(); var m = document.getElementById('previewModal'); if (m) m.classList.add('open'); } function previewCodeBlock(btn) { var block = btn.closest('.code-block'); if (!block) return; var code = decodeURIComponent(block.dataset.code); var frame = document.getElementById('previewFrame'); if (!frame) return; var doc = frame.contentWindow.document; doc.open(); doc.write(code); doc.close(); var m = document.getElementById('previewModal'); if (m) m.classList.add('open'); } function closePreviewModal() { var m = document.getElementById('previewModal'); if (m) m.classList.remove('open'); } // ── Typing Indicator ── function showTyping() { var container = document.getElementById('chatContainer'); if (!container) return; var div = document.createElement('div'); div.className = 'typing'; div.id = 'typingIndicator'; div.innerHTML = 'AI is thinking' + '<span class="typing-dots">' + '<span></span><span></span><span></span>' + '</span>'; container.appendChild(div); container.scrollTop = container.scrollHeight; } function hideTyping() { var t = document.getElementById('typingIndicator'); if (t) t.remove(); } // ── Send Message ── async function sendMessage() { if (isStreaming) return; var input = document.getElementById('chatInput'); if (!input) return; var msg = input.value.trim(); if (!msg) return; input.value = ''; input.style.height = 'auto'; addMessage('user', msg); addLog('User', msg.substring(0, 50) + (msg.length > 50 ? '...' : '')); if (msg.startsWith('/')) { await handleCommand(msg); return; } showTyping(); // Sync OLLAMA_URL from terminal before sending var term = window.webx11; if (term && term.ollamaManager) { OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL; } // Re-set window-level endpoint window.ollamaEndpoint = OLLAMA_URL; var modelSelect = document.getElementById('modelSelect'); var model = modelSelect ? modelSelect.value : DEFAULT_MODEL; var mod = typeof getCurrentModule === 'function' ? getCurrentModule() : null; var systemPrompt = mod && mod.prompt ? mod.prompt : ''; // Inject current date into system prompt var now = new Date(); var dateStr = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0'); var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; var timeStr = String(now.getHours()).padStart(2,'0') + ':' + String(now.getMinutes()).padStart(2,'0'); systemPrompt = 'Current date: ' + dateStr + ' (' + days[now.getDay()] + ') ' + timeStr + '\n\n' + systemPrompt; var chatHistory = buildChatContext(); try { var headers = { 'Content-Type': 'application/json' }; if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY; var fullPrompt = systemPrompt ? systemPrompt + '\n\n' + chatHistory + '\n\nUser: ' + msg + '\n\nAI:' : chatHistory + '\n\nUser: ' + msg + '\n\nAI:'; if (USE_STREAMING) { await sendStreamingRequest(headers, model, fullPrompt); } else { await sendNonStreamingRequest(headers, model, fullPrompt); } } catch (e) { hideTyping(); console.error('Chat error:', e); addMessage('ai', 'Sorry, I could not connect to the AI service. Make sure Ollama is running on your server.\n\nError: ' + e.message); } } async function sendStreamingRequest(headers, model, prompt) { isStreaming = true; hideTyping(); var msgId = generateId(); var container = document.getElementById('chatContainer'); if (!container) { isStreaming = false; return; } var div = document.createElement('div'); div.className = 'message ai'; div.dataset.msgId = msgId; div.innerHTML = '<div class="role"><span>AI</span><div style="display:flex;gap:4px;">' + '<button class="msg-copy" onclick="copyMessage(this, \'ai\')" title="Copy">📋</button>' + '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' + '<button id="streamStopBtn" class="msg-copy" onclick="stopStreaming()" title="Stop" style="color:var(--danger);">⏹</button>' + '</div></div><div class="msg-content"><span class="streaming-cursor"></span></div>'; div.dataset.content = ''; container.appendChild(div); container.scrollTop = container.scrollHeight; var contentEl = div.querySelector('.msg-content'); var fullContent = ''; try { _streamAbort = new AbortController(); var response = await fetch(OLLAMA_URL + '/api/generate', { method: 'POST', headers: headers, body: JSON.stringify({ model: model, prompt: prompt, stream: true }), signal: _streamAbort.signal }); if (!response.ok) { contentEl.textContent = 'AI request failed: HTTP ' + response.status; throw new Error('AI request failed: HTTP ' + response.status); } var reader = response.body.getReader(); var decoder = new TextDecoder(); var buffer = ''; var lastFormat = 0; var formatInterval = 400; while (true) { var result = await reader.read(); if (result.done) break; buffer += decoder.decode(result.value, { stream: true }); var lines = buffer.split('\n'); buffer = lines.pop() || ''; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; try { var parsed = JSON.parse(line); if (parsed.response) { fullContent += parsed.response; var now = Date.now(); if (now - lastFormat >= formatInterval) { contentEl.innerHTML = formatContent(fullContent) + '<span class="streaming-cursor"></span>'; container.scrollTop = container.scrollHeight; lastFormat = now; } } if (parsed.done) { break; } } catch (e) { } } } // Process remaining buffer if (buffer.trim()) { try { var parsed = JSON.parse(buffer); if (parsed.response) { fullContent += parsed.response; } } catch (e) { /* ignore */ } } // Final render without cursor contentEl.innerHTML = formatContent(fullContent); div.dataset.content = fullContent; latestAiContent = fullContent; container.scrollTop = container.scrollHeight; messages.push({ id: msgId, role: 'ai', content: fullContent, timestamp: Date.now() }); saveMessages(); addLog('AI', fullContent.substring(0, 50) + '...'); } catch (e) { if (e.name === 'AbortError') { if (contentEl && !fullContent) contentEl.textContent = '⏹ Stopped by user.'; } else { if (contentEl) contentEl.textContent = 'Error: ' + e.message; console.error('Stream error:', e); } } isStreaming = false; _streamAbort = null; // Remove stop button if present var stopBtn = document.getElementById('streamStopBtn'); if (stopBtn) stopBtn.remove(); } function stopStreaming() { if (_streamAbort) { _streamAbort.abort(); _streamAbort = null; } isStreaming = false; } async function sendNonStreamingRequest(headers, model, prompt) { try { var response = await fetchWithTimeout(OLLAMA_URL + '/api/generate', { method: 'POST', headers: headers, body: JSON.stringify({ model: model, prompt: prompt, stream: false }) }); hideTyping(); if (!response.ok) throw new Error('AI request failed'); var data = await response.json(); addMessage('ai', data.response || 'No response'); addLog('AI', (data.response || '').substring(0, 50) + '...'); } catch (e) { hideTyping(); throw e; } } function buildChatContext() { var context = ''; for (var i = 0; i < messages.length; i++) { var m = messages[i]; var role = m.role === 'user' ? 'User' : 'AI'; // P2#15: Use DOMParser instead of naive regex HTML strip var content = m.content; try { var doc = new DOMParser().parseFromString(content, 'text/html'); content = doc.body.textContent || doc.body.innerText || ''; } catch (e) { // Fallback to regex if DOMParser fails content = content.replace(/<[^>]+>/g, ' '); } content = content.replace(/\s+/g, ' ').trim(); context += role + ': ' + content + '\n'; } return context; } function getChatHistory() { return buildChatContext(); } // ── Clear & Share ── function clearChat() { if (!confirm('Clear chat history?')) return; messages = []; localStorage.removeItem(MESSAGES_KEY); var container = document.getElementById('chatContainer'); if (container) { container.innerHTML = '<div class="message ai">' + '<div class="role">AI</div>' + 'Chat cleared! How can I help you?' + '</div>'; } addLog('Clear', 'Chat history cleared'); } function shareChat() { var msgs = []; for (var i = 0; i < messages.length; i++) { var m = messages[i]; var role = m.role === 'user' ? 'User' : 'AI'; msgs.push('**' + role + ':** ' + m.content); } var text = '# WebX11 AI Chat\n\n' + msgs.join('\n\n'); if (navigator.clipboard) { navigator.clipboard.writeText(text).then(function () { showToast('Chat copied to clipboard!'); addLog('Share', 'Chat copied to clipboard'); }); } else { var blob = new Blob([text], { type: 'text/plain' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'chat-export.txt'; a.click(); URL.revokeObjectURL(url); addLog('Share', 'Chat downloaded as file'); } } // ── Expose Lite globals for commands.js and modules.js ── // All functions exposed under CSLite namespace AND as individual window.* // for backward compatibility with chat-commands.js and chat-modules.js window.CSLite = { CHATSTUDIO_VERSION: CHATSTUDIO_VERSION, OLLAMA_URL: OLLAMA_URL, OLLAMA_API_KEY: OLLAMA_API_KEY, DEFAULT_MODEL: DEFAULT_MODEL, USE_STREAMING: USE_STREAMING, API_BASE: API_BASE, getApiUrl: function(path) { return (window.CSLite.API_BASE || '/api') + path; }, latestAiContent: latestAiContent, logs: logs, messages: messages, isStreaming: isStreaming, modules: [], currentChatModule: 'default', escapeHtml: escapeHtml, utf8_to_b64: utf8_to_b64, b64_to_utf8: b64_to_utf8, generateId: generateId, showToast: showToast, autoExpand: autoExpand, toggleSidebar: toggleSidebar, closeSidebar: closeSidebar, addLog: addLog, renderLogs: renderLogs, loadSettings: loadSettings, saveSettings: saveSettings, loadModels: loadModels, loadMessages: loadMessages, saveMessages: saveMessages, appendMessageToDOM: appendMessageToDOM, addMessage: addMessage, formatContent: formatContent, copyMessage: copyMessage, showHtmlPreview: showHtmlPreview, previewCodeBlock: previewCodeBlock, closePreviewModal: closePreviewModal, showTyping: showTyping, hideTyping: hideTyping, sendMessage: sendMessage, sendStreamingRequest: sendStreamingRequest, sendNonStreamingRequest: sendNonStreamingRequest, buildChatContext: buildChatContext, getChatHistory: getChatHistory, clearChat: clearChat, shareChat: shareChat, copyLogs: copyLogs, clearLogs: clearLogs, loadLogs: loadLogs, testConnection: testConnection, openSettingsModal: openSettingsModal, closeSettingsModal: closeSettingsModal }; // Individual window.* assignments for backward compat window.CHATSTUDIO_VERSION = CHATSTUDIO_VERSION; window.OLLAMA_URL = OLLAMA_URL; window.OLLAMA_API_KEY = OLLAMA_API_KEY; window.DEFAULT_MODEL = DEFAULT_MODEL; window.USE_STREAMING = USE_STREAMING; window.API_BASE = API_BASE; window.getApiUrl = function(path) { return (window.API_BASE || '/api') + path; }; window.latestAiContent = latestAiContent; window.logs = logs; window.messages = messages; window.isStreaming = isStreaming; window.escapeHtml = escapeHtml; window.utf8_to_b64 = utf8_to_b64; window.b64_to_utf8 = b64_to_utf8; window.generateId = generateId; window.showToast = showToast; window.autoExpand = autoExpand; window.toggleSidebar = toggleSidebar; window.closeSidebar = closeSidebar; window.addLog = addLog; window.renderLogs = renderLogs; window.loadSettings = loadSettings; window.saveSettings = saveSettings; window.loadModels = loadModels; window.loadMessages = loadMessages; window.saveMessages = saveMessages; window.appendMessageToDOM = appendMessageToDOM; window.addMessage = addMessage; window.formatContent = formatContent; window.copyMessage = copyMessage; window.showHtmlPreview = showHtmlPreview; window.previewCodeBlock = previewCodeBlock; window.closePreviewModal = closePreviewModal; window.showTyping = showTyping; window.hideTyping = hideTyping; window.sendMessage = sendMessage; window.sendStreamingRequest = sendStreamingRequest; window.sendNonStreamingRequest = sendNonStreamingRequest; window.buildChatContext = buildChatContext; window.getChatHistory = getChatHistory; window.clearChat = clearChat; window.shareChat = shareChat; window.copyLogs = copyLogs; window.clearLogs = clearLogs; window.loadLogs = loadLogs; window.testConnection = testConnection; window.openSettingsModal = openSettingsModal; window.closeSettingsModal = closeSettingsModal; // ── Init ── loadSettings(); loadMessages(); loadLogs(); loadModels(); renderMessagesFromData(); addLog('Init', 'Chat overlay initialized'); } catch (err) { // Reset initialization flags so user can retry this._initialized = false; window._csGlobalsInited = false; // Print error to terminal if (this.terminal && this.terminal.print) { this.terminal.print('Chat overlay init error: ' + err.message, 'error'); } console.error('ChatStudioModule._initLiteEngine error:', err); } } } /* === module-agent-registry.js === */ // module-agent-registry.js — VFS-backed agent directory with A2A contracts // Commands: agent-list, agent-register, agent-unregister, agent-status, contract-register, contract-list // A2A Scratchpad commands: a2a-post, a2a-poll, a2a-ack, a2a-reject, a2a-status, a2a-prune class AgentRegistryModule { constructor() { this.commands = {}; this.terminal = null; this._contracts = {}; this._a2aPruneTimer = null; } registerCommands() { this.commands['agent-list'] = () => this.listAgents(); this.commands['agent-status'] = (args) => this.showAgentStatus(args[0]); this.commands['agent-register'] = (args) => this.registerAgent(args); this.commands['agent-unregister'] = (args) => this.unregisterAgent(args[0]); this.commands['contract-register'] = (args) => this.registerContract(args); this.commands['contract-list'] = () => this.listContracts(); // A2A Scratchpad commands this.commands['a2a-post'] = (args) => this.a2aPostCommand(args); this.commands['a2a-poll'] = (args) => this.a2aPollCommand(args); this.commands['a2a-ack'] = (args) => this.a2aAckCommand(args); this.commands['a2a-reject'] = (args) => this.a2aRejectCommand(args); this.commands['a2a-status'] = () => this.a2aStatus(); this.commands['a2a-prune'] = () => this.a2aPrune(); } getHelpInfo() { return { 'a2a-post': 'a2a-post <to> <type> <capability> <payload...> — Post an A2A message', 'a2a-poll': 'a2a-poll [agentId] — Poll for messages addressed to an agent (default: current or "user")', 'a2a-ack': 'a2a-ack <msgId> <result...> — Acknowledge a message with result', 'a2a-reject': 'a2a-reject <msgId> <error...> — Reject a message with error', 'a2a-status': 'a2a-status — Show scratchpad stats per agent', 'a2a-prune': 'a2a-prune — Clean up expired and old completed messages' }; } init(terminal) { this.terminal = terminal; if (!this.terminal.fs.readDir('/agents/')) this.terminal.fs.mkdirRecursive('/agents'); if (!this.terminal.fs.readDir('/.contracts/')) this.terminal.fs.mkdirRecursive('/.contracts'); this._loadContracts(); // Initialize scratchpad this._ensureScratchpad(); // Auto-prune every 60 seconds this._a2aPruneTimer = setInterval(() => this.a2aPrune(), 60000); // Save the spec-vs-reality delta table this._saveDeltaTable(); this.terminal.print('📋 Agent Registry loaded', 'info'); } // ─── Task 1: Save spec-vs-reality delta table ─── _saveDeltaTable() { try { const fs = this.terminal.fs; if (!fs.readDir('/_docs/')) fs.mkdirRecursive('/_docs'); const delta = `# Spec-vs-Reality Delta — 2026-06-17\n\n` + `| # | Whitepaper Concept | Spec'd Behavior | Current Reality | Δ | Priority |\n` + `|---|---|---|---|---|---|\n` + `| 1 | Hyper-Modular Kernel | Real-time self-rewriting drivers for task-specific optimization via WPM | WPM exists but is manual; no hot-patch daemon or runtime driver swap | Partial | Medium |\n` + `| 2 | A2A Protocol | Standardized neural communication layer for agent negotiation; typed messages, no central broker | VFS mailbox transport works (a2a-send/a2a-list/500ms polling) but orchestrator acts as central broker | Partial | High |\n` + `| 3 | Neural VFS | Semantic-based file storage (concept:// URIs) as primary access method; hierarchical paths are secondary | concept:// URIs work (nvfs-index/nvfs-resolve) but hierarchical paths remain primary access; concept resolution requires explicit index | Partial | High |\n` + `| 4 | Recursive Self-Optimization | Monitor → Profile → Diagnose → Patch → Test → Promote feedback loop | system-health telemetry captures latency/VFS stats but no auto-patch generation or hot-promotion pipeline | Partial | Medium |\n` + `| 5 | Agent Discovery | No central broker — agents discover each other via VFS registrations spontaneously | Orchestrator is central broker; all task delegation goes through it; agents don't autonomously discover each other | Critical Gap | Critical |\n` + `| 6 | Contract Schema | Every A2A message carries a contract schema defining capabilities and constraints | Contracts exist (contract-register/contract-list) but schema validation is basic substring matching; not enforced on every message | Partial | Medium |\n` + `| 7 | Semantic Addressing | Neural VFS as primary storage paradigm — users navigate by concept, not path | Hierarchical path access still primary; concept:// requires explicit nvfs-index; no transparent concept-to-path fallback | Critical Gap | Critical |\n` + `| 8 | Warm-start Guarantee | Kernel caches last N stable driver configs; boot in under 200ms | No golden config cache; full localStorage restore on every page load | Spec Violation | High |\n` + `| 9 | Self-rewrite Daemon | Monitors performance telemetry and generates optimized driver variants at runtime | No self-rewrite daemon exists; all module changes are manual | Missing | Low |\n` + `| 10 | Agent Lifecycle | spawn, sleep, wake, kill, restart policies with exponential backoff | Fully implemented in orchestrator with heartbeat, idle sweep, restart policies, VFS sleep serialization | Fully Aligned | — |\n\n` + `**Summary: 2 Critical gaps, 5 Partial, 1 Spec Violation, 1 Missing, 1 Fully Aligned**\n`; fs.writeFile('_docs/spec-vs-reality-delta.md', delta); this.terminal.print('📄 Spec-vs-Reality delta saved to /_docs/spec-vs-reality-delta.md', 'info'); } catch (e) { console.warn('Failed to save delta table:', e.message); } } // ─── A2A Scratchpad Helpers ─── _ensureScratchpad() { try { const data = this.terminal.fs.fsReadJSON('/_agents/scratchpad.json'); if (!data) throw new Error('no scratchpad'); } catch (e) { this.terminal.fs.mkdirRecursive('/_agents'); this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', { messages: [], seq: 0, agents: {} }); } } _getScratchpad() { try { const data = this.terminal.fs.fsReadJSON('/_agents/scratchpad.json'); if (data && data.messages) return data; } catch (e) {} return { messages: [], seq: 0, agents: {} }; } _saveScratchpad(sp) { try { this.terminal.fs.mkdirRecursive('/_agents'); this.terminal.fs.fsWriteJSON('/_agents/scratchpad.json', sp); } catch (e) { console.warn('Scratchpad save error:', e.message); } } // ─── A2A Scratchpad Functions ─── a2aPost(from, to, type, capability, payload, ttl) { if (!from || !to || !type) { this.terminal.print('a2aPost: from, to, and type are required', 'warning'); return null; } const sp = this._getScratchpad(); sp.seq = (sp.seq || 0) + 1; const msg = { id: `msg_${String(sp.seq).padStart(3, '0')}`, from: from, to: to, type: type, capability: capability || null, payload: payload || {}, ttl: (typeof ttl === 'number' ? ttl : 300000), status: 'pending', ts: Date.now(), reply_to: null }; sp.messages.push(msg); this._saveScratchpad(sp); return msg; } a2aPoll(agentId) { if (!agentId) return []; const sp = this._getScratchpad(); const pending = []; for (const msg of sp.messages) { if (msg.to === agentId && msg.status === 'pending') { msg.status = 'delivered'; pending.push(msg); } } if (pending.length > 0) this._saveScratchpad(sp); return pending; } a2aAck(messageId, resultPayload) { const sp = this._getScratchpad(); const msg = sp.messages.find(m => m.id === messageId); if (!msg) { this.terminal.print(`a2aAck: message ${messageId} not found`, 'warning'); return false; } if (msg.status === 'completed' || msg.status === 'failed') { this.terminal.print(`a2aAck: message ${messageId} already ${msg.status}`, 'warning'); return false; } msg.status = 'completed'; msg.result = resultPayload || {}; msg.completedAt = Date.now(); this._saveScratchpad(sp); return true; } a2aReject(messageId, error) { const sp = this._getScratchpad(); const msg = sp.messages.find(m => m.id === messageId); if (!msg) { this.terminal.print(`a2aReject: message ${messageId} not found`, 'warning'); return false; } if (msg.status === 'completed' || msg.status === 'failed') { this.terminal.print(`a2aReject: message ${messageId} already ${msg.status}`, 'warning'); return false; } msg.status = 'failed'; msg.error = error || 'unspecified error'; msg.completedAt = Date.now(); this._saveScratchpad(sp); return true; } a2aRegister(agentId, capabilities) { if (!agentId) return; const sp = this._getScratchpad(); sp.agents[agentId] = { last_poll: Date.now(), capabilities: capabilities || [] }; this._saveScratchpad(sp); } a2aUnregister(agentId) { if (!agentId) return; const sp = this._getScratchpad(); delete sp.agents[agentId]; this._saveScratchpad(sp); } a2aPrune() { try { const sp = this._getScratchpad(); const now = Date.now(); const before = sp.messages.length; sp.messages = sp.messages.filter(m => { // Remove expired messages (past TTL) if (m.status === 'pending' && (now - m.ts > m.ttl)) return false; // Remove completed/failed messages older than 1 hour if ((m.status === 'completed' || m.status === 'failed') && m.completedAt && (now - m.completedAt > 3600000)) return false; return true; }); const removed = before - sp.messages.length; if (removed > 0) this._saveScratchpad(sp); return removed; } catch (e) { console.warn('a2aPrune error:', e.message); return 0; } } a2aStatus() { const sp = this._getScratchpad(); const counts = {}; for (const msg of sp.messages) { if (!counts[msg.to]) counts[msg.to] = { pending: 0, delivered: 0, completed: 0, failed: 0 }; if (msg.status === 'pending') counts[msg.to].pending++; else if (msg.status === 'delivered') counts[msg.to].delivered++; else if (msg.status === 'completed') counts[msg.to].completed++; else if (msg.status === 'failed') counts[msg.to].failed++; } this.terminal.print('📊 A2A Scratchpad Status:', 'header'); this.terminal.print(` Total messages: ${sp.messages.length}`, 'info'); this.terminal.print(` Registered agents: ${Object.keys(sp.agents).length}`, 'info'); this.terminal.print(` Sequence: ${sp.seq}`, 'info'); const agentNames = Object.keys(counts); if (agentNames.length === 0) { this.terminal.print(' No messages in scratchpad', 'info'); return; } for (const agentId of agentNames) { const c = counts[agentId]; const total = c.pending + c.delivered + c.completed + c.failed; const caps = sp.agents[agentId] ? ` [${sp.agents[agentId].capabilities.join(', ')}]` : ''; this.terminal.print(` ${agentId}${caps}: ${total} msgs (⏳${c.pending} 📬${c.delivered} ✅${c.completed} ❌${c.failed})`, 'info'); } } _a2aCleanup() { // Remove agent entries from scratchpad when agent directory is deleted from /agents/ try { const sp = this._getScratchpad(); const agentDirs = {}; const dir = this.terminal.fs.readDir('/agents/'); if (dir) { for (const entry of dir) { if (entry.type === 'dir') agentDirs[entry.name] = true; } } let changed = false; for (const agentId of Object.keys(sp.agents)) { if (!agentDirs[agentId]) { delete sp.agents[agentId]; changed = true; } } if (changed) this._saveScratchpad(sp); } catch (e) { console.warn('a2aCleanup error:', e.message); } } // ─── A2A Command Handlers ─── a2aPostCommand(args) { // a2a-post <to> <type> <capability> <payload...> if (args.length < 3) { return this.terminal.print('Usage: a2a-post <to> <type> <capability> <payload...>', 'warning'); } const to = args[0]; const type = args[1]; const capability = args[2]; const payloadRaw = args.slice(3).join(' '); let payload = {}; try { // Try parsing as JSON payload = JSON.parse(payloadRaw || '{}'); } catch (e) { // Treat as plain text payload = { text: payloadRaw }; } const from = 'user'; const ttl = 300000; const msg = this.a2aPost(from, to, type, capability, payload, ttl); if (msg) { this.terminal.print(`📨 A2A message ${msg.id} posted to ${to} (type: ${type})`, 'success'); } } a2aPollCommand(args) { // Use explicit agent ID if provided, otherwise try to determine current agent let agentId = (args && args[0]) || 'user'; if (!args || !args[0]) { try { const orchestrator = this.terminal.moduleManager.modules['OrchestratorModule']; if (orchestrator && orchestrator._agents) { const myAgents = Object.entries(orchestrator._agents).filter(([, a]) => a.status === 'busy'); if (myAgents.length > 0) agentId = myAgents[0][0]; } } catch (e) {} } const msgs = this.a2aPoll(agentId); if (msgs.length === 0) { this.terminal.print(`📭 No pending messages for ${agentId}`, 'info'); return; } this.terminal.print(`📬 ${msgs.length} message(s) for ${agentId}:`, 'header'); for (const msg of msgs) { const capInfo = msg.capability ? ` [${msg.capability}]` : ''; this.terminal.print(` ${msg.id} from ${msg.from} (${msg.type}${capInfo})`, 'info'); this.terminal.print(` Payload: ${JSON.stringify(msg.payload).substring(0, 120)}`, 'text-muted'); } } a2aAckCommand(args) { if (args.length < 1) { return this.terminal.print('Usage: a2a-ack <msgId> [result...]', 'warning'); } const msgId = args[0]; const resultRaw = args.slice(1).join(' '); let result = {}; try { result = JSON.parse(resultRaw || '{}'); } catch (e) { result = { text: resultRaw }; } if (this.a2aAck(msgId, result)) { this.terminal.print(`✅ A2A message ${msgId} completed`, 'success'); } } a2aRejectCommand(args) { if (args.length < 1) { return this.terminal.print('Usage: a2a-reject <msgId> [error...]', 'warning'); } const msgId = args[0]; const error = args.slice(1).join(' ') || 'rejected by user'; if (this.a2aReject(msgId, error)) { this.terminal.print(`❌ A2A message ${msgId} rejected: ${error}`, 'warning'); } } _loadContracts() { try { const data = this.terminal.fs.readFile('/.contracts/index.json'); if (data) this._contracts = JSON.parse(data); } catch (e) {} // Try loading individual contract files const dir = this.terminal.fs.readDir('/.contracts/'); if (dir) { dir.forEach(e => { if (e.type === 'file' && e.name.endsWith('.contract.json')) { try { const c = JSON.parse(this.terminal.fs.readFile('/.contracts/' + e.name)); this._contracts[c.role] = c; } catch (e) { console.warn('Bad contract:', e.name, e.message); } } }); } } _saveContracts() { try { this.terminal.fs.fsWriteJSON('/.contracts/index.json', this._contracts); } catch (e) { console.error('Contract save error:', e); } } registerAgent(rawArgs) { // MAINT-008: callers pass the command word first (['register', id, cap, cap…]), and the // old code took args[0] as the id and only args[1] as the capability list — so the boot // log read `Agent 'register' registered with 1 capabilities`. Strip the command word and // accept both `cap1,cap2` and `cap1 cap2 cap3`. const args = (rawArgs && rawArgs[0] === 'register') ? rawArgs.slice(1) : (rawArgs || []); const id = args[0] || `agent-${Date.now()}`; const capsArg = args.slice(1).join(','); const capabilities = capsArg ? capsArg.split(',').map(s => s.trim()).filter(Boolean) : ['list_files', 'read_file']; // MAINT-007/009: relative mkdir/rm resolved against the user's cwd, so a registration made // from /docs created /docs/agents/<id> and an unregister from anywhere else removed // nothing. Every path here is absolute. const agentDir = `/agents/${id}`; if (!this.terminal.fs.readDir(agentDir + '/')) { this.terminal.fs.mkdirRecursive(agentDir); } this.terminal.fs.writeFile(`${agentDir}/status.json`, JSON.stringify({ id, status: 'idle', model: null, capabilities, registered: new Date().toISOString(), lastHeartbeat: new Date().toISOString(), contract: null })); // Auto-register in A2A scratchpad this.a2aRegister(id, capabilities); this.terminal.print(`✅ Agent '${id}' registered with ${capabilities.length} capabilities`, 'success'); } registerContract(args) { const role = args[0]; if (!role) return this.terminal.print('Usage: contract-register <role> [input_fields...] [--output output_fields...]', 'warning'); const inputIdx = args.indexOf('--output'); const hasOutputFlag = inputIdx !== -1; let inputFields = [], outputFields = []; if (hasOutputFlag) { inputFields = args.slice(1, inputIdx).filter(f => !f.startsWith('--')); outputFields = args.slice(inputIdx + 1).filter(f => !f.startsWith('--')); } else { inputFields = args.slice(1).filter(f => !f.startsWith('--')); outputFields = ['result']; // default output } const contract = { role, capabilities: inputFields, input_schema: inputFields, output_schema: outputFields, created: new Date().toISOString(), updated: new Date().toISOString() }; this._contracts[role] = contract; // Save as individual contract file this.terminal.fs.fsWriteJSON(`/.contracts/${role}.contract.json`, contract); this._saveContracts(); this.terminal.print(`✅ Contract registered for role '${role}'`, 'success'); this.terminal.print(` Input fields: ${inputFields.join(', ') || '(free-form)'}`, 'info'); this.terminal.print(` Output fields: ${outputFields.join(', ') || '(free-form)'}`, 'info'); } listContracts() { const roles = Object.keys(this._contracts); if (roles.length === 0) return this.terminal.print('No contracts registered', 'info'); this.terminal.print(`📜 A2A Contracts (${roles.length}):`, 'header'); roles.forEach(r => { const c = this._contracts[r]; this.terminal.print(` ${r} — input:[${c.input_schema.join(',')}] → output:[${c.output_schema.join(',')}]`, 'info'); }); } validateTask(role, task) { const contract = this._contracts[role]; if (!contract) return { valid: true, reason: 'no contract for role' }; // permissive fallback if (contract.input_schema.length === 0) return { valid: true, reason: 'free-form input' }; // Simple validation: check required input fields are present in task const missing = contract.input_schema.filter(f => !task.toLowerCase().includes(f.toLowerCase())); if (missing.length > 0) { return { valid: false, reason: `missing required fields: ${missing.join(', ')}` }; } return { valid: true, reason: 'matched' }; } getContract(role) { return this._contracts[role] || null; } listAgents() { const agents = this.terminal.fs.ls('/agents/'); if (!agents || agents.length === 0) { return this.terminal.print('No agents registered', 'info'); } // MAINT-009: a directory alone is not a registration — anything without a readable // status.json is a leftover directory, not an agent. Listing them as agents made a // hand-made /agents/foo look like a live agent with unknown status. const registered = [], orphaned = []; agents.forEach(a => { if (a.type !== 'dir' || a.name === 'configured') return; let s = null; try { const data = this.terminal.fs.readFile(`/agents/${a.name}/status.json`); if (data) s = JSON.parse(data); } catch (e) { s = null; } if (s && s.id) registered.push({ name: a.name, s }); else orphaned.push(a.name); }); if (registered.length) { this.terminal.print(`📋 Registered Agents (${registered.length}):`, 'header'); registered.forEach(({ name, s }) => { this.terminal.print(` ${name} — ${s.status} (${(s.capabilities || []).length} capabilities)${s.contract ? ` → contract:${s.contract}` : ''}`, 'info'); }); } else { this.terminal.print('No agents registered', 'info'); } if (orphaned.length) { this.terminal.print(`⚠️ ${orphaned.length} directory(ies) in /agents have no status.json (not registered): ${orphaned.join(', ')}`, 'warning'); } } unregisterAgent(id) { // Unregister from A2A scratchpad this.a2aUnregister(id); // MAINT-007: absolute path — a relative rm resolved against the cwd and left the // directory behind whenever the user was not sitting in /. const ok = this.terminal.fs.rm('/agents/' + id); if (ok) this.terminal.print(`✅ Agent '${id}' unregistered`, 'success'); else this.terminal.print(`❌ Agent '${id}' not found`, 'error'); } showAgentStatus(id) { if (!id) return this.terminal.print('Usage: agent-status <agent-id>', 'error'); const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); if (!data) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); const s = JSON.parse(data); this.terminal.print(`Agent: ${s.id}`, 'header'); this.terminal.print(` Status: ${s.status}`, 'info'); this.terminal.print(` Capabilities: ${(s.capabilities || []).join(', ')}`, 'info'); this.terminal.print(` Contract: ${s.contract || 'none'}`, 'info'); this.terminal.print(` Registered: ${s.registered}`, 'info'); this.terminal.print(` Last Heartbeat: ${s.lastHeartbeat}`, 'info'); } getAgent(id) { const data = this.terminal.fs.readFile(`/agents/${id}/status.json`); return data ? JSON.parse(data) : null; } updateHeartbeat(id) { const agent = this.getAgent(id); if (!agent) return false; agent.lastHeartbeat = new Date().toISOString(); this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify(agent)); return true; } } /* === module-leases.js === */ // module-leases.js — Lease system for agent task delegation // Commands: lease-create, lease-list, lease-revoke, lease-renew class LeaseModule { constructor() { this.commands = {}; this.terminal = null; this._leases = {}; this._watchdogTimer = null; } registerCommands() { this.commands['lease-create'] = (args) => this.createLease(args); this.commands['lease-list'] = () => this.listLeases(); this.commands['lease-revoke'] = (args) => this.revokeLease(args[0]); this.commands['lease-renew'] = (args) => this.renewLease(args[0], parseInt(args[1]) || 30); } init(terminal) { this.terminal = terminal; if (!this.terminal.fs.readFile('/leases/')) { this.terminal.fs.mkdir('leases'); } // Restore persisted leases const files = this.terminal.fs.ls('/leases/'); if (files) { files.forEach(f => { if (f.name.endsWith('.json')) { const data = this.terminal.fs.readFile(`/leases/${f.name}`); if (data) { try { const lease = JSON.parse(data); this._leases[lease.id] = lease; } catch (e) {} } } }); } // Start watchdog every 5 seconds this._watchdogTimer = setInterval(() => this._checkExpired(), 5000); this.terminal.print('📜 Lease system loaded', 'info'); } createLease(args) { // Parse --write flag const writeIdx = args.indexOf('--write'); const writeAccess = writeIdx !== -1; if (writeIdx !== -1) args.splice(writeIdx, 1); const workerId = args[1]; const duration = parseInt(args[2]) || 30; const task = args.slice(3).join(' ') || 'generic task'; if (!workerId) return this.terminal.print('Usage: lease-create <agent-id> [duration_sec] [task] [--write]', 'error'); const lease = { id: `lease-${Date.now()}`, workerId, orchestratorId: 'orchestrator-0', task, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + duration * 1000).toISOString(), status: 'active', duration, write_access: writeAccess }; this._leases[lease.id] = lease; this.terminal.fs.writeFile(`leases/${lease.id}.json`, JSON.stringify(lease)); // Post A2A message to lease system agent try { const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; if (registry && typeof registry.a2aPost === 'function') { registry.a2aPost('orchestrator-0', 'lease-system', 'lease.create', 'leases.create', { leaseId: lease.id, workerId, task, duration, expiresAt: lease.expiresAt }); } } catch (e) {} this.terminal.print(`📜 Lease ${lease.id} created for ${workerId} (${duration}s, task: ${task.substring(0, 40)})`, 'success'); return lease; } listLeases() { const all = Object.values(this._leases); const active = all.filter(l => l.status === 'active'); const expired = all.filter(l => l.status === 'expired'); const revoked = all.filter(l => l.status === 'revoked'); this.terminal.print(`📜 Leases: ${active.length} active, ${expired.length} expired, ${revoked.length} revoked`, 'header'); active.forEach(l => { const remaining = Math.max(0, Math.round((new Date(l.expiresAt) - Date.now()) / 1000)); const writeTag = l.write_access ? ' ✏️write' : ' 🔍read'; this.terminal.print(` ${l.id} → ${l.workerId} (${remaining}s remain)${writeTag} — ${l.task.substring(0, 40)}`, 'info'); }); } hasWriteAccess(agentId) { // Check if any active lease grants write access for this agent return Object.values(this._leases).some(l => l.workerId === agentId && l.status === 'active' && l.write_access === true); } revokeLease(id) { if (!this._leases[id]) return this.terminal.print(`❌ Lease ${id} not found`, 'error'); this._leases[id].status = 'revoked'; this._leases[id].revokedAt = new Date().toISOString(); this.terminal.fs.writeFile(`leases/${id}.json`, JSON.stringify(this._leases[id])); // Post A2A completion message try { const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; if (registry && typeof registry.a2aPost === 'function') { registry.a2aPost('orchestrator-0', 'lease-system', 'lease.terminate', 'leases.terminate', { leaseId: id, status: 'revoked', revokedAt: this._leases[id].revokedAt, workerId: this._leases[id].workerId }); } } catch (e) {} this.terminal.print(`✅ Lease ${id} revoked`, 'warning'); } renewLease(id, additionalSeconds) { if (!this._leases[id] || this._leases[id].status !== 'active') { return this.terminal.print(`❌ Lease ${id} not found or not active`, 'error'); } const lease = this._leases[id]; const newExpiry = new Date(Date.now() + additionalSeconds * 1000); lease.expiresAt = newExpiry.toISOString(); lease.duration += additionalSeconds; lease.renewedAt = new Date().toISOString(); this.terminal.fs.writeFile(`leases/${id}.json`, JSON.stringify(lease)); this.terminal.print(`✅ Lease ${id} renewed (+${additionalSeconds}s)`, 'success'); } _checkExpired() { const now = Date.now(); Object.values(this._leases).forEach(lease => { if (lease.status === 'active' && new Date(lease.expiresAt) < now) { lease.status = 'expired'; lease.expiredAt = new Date().toISOString(); this.terminal.fs.writeFile(`leases/${lease.id}.json`, JSON.stringify(lease)); this.terminal.log('INFO', 'Lease', `Lease ${lease.id} for ${lease.workerId} expired`); } }); } } /* === module-orchestrator.js === */ // module-orchestrator.js — Multi-agent orchestration system with A2A mailbox transport // Commands: orchestrate, orchestrator-status, orchestrator-spawn, orchestrator-kill, // orchestrator-purge, orchestrator-config, orchestrator-list, orchestrator-sleep, // orchestrator-wake, orchestrator-restart-policy, a2a-send, a2a-list class OrchestratorModule { constructor() { this.commands = {}; this.terminal = null; this._agents = {}; this._SPAWN_LIMIT = 5; // Boot-time agents — stable per-role ids so a reload re-adopts the existing VFS dir // instead of minting a new timestamp id every boot (that was the /agents/ accumulation bug). this._DEFAULT_ROLES = ['auto-learner', 'kernel-architect', 'searcher']; this._BOOT_DELAY = 2000; // let other modules init before the boot team spawns (tests set 0) this._SPAWN_STRATEGY = 'eager'; this._HEARTBEAT_TIMEOUT = 15000; this._IDLE_TIMEOUT = 120000; this._RESTART_POLICY = 'never'; this._AGENT_DEFAULT_MODEL = null; this._A2A_POLLING = true; this._A2A_POLL_INTERVAL = 500; this._HEARTBEAT_CHECK = 3000; this._IDLE_SWEEP_INTERVAL = 30000; this._DEAD_PURGE_TIMEOUT = 60000; this._a2aPollTimer = null; this._heartbeatInterval = null; this._a2aMessages = {}; // v4.6.68: on-demand is the design (decision 2026-10-23). Delegations — not // "agents ever scheduled" — is the number that means something. this._delegations = 0; this._restartBackoffs = {}; this._eventBus = {}; this._idleSweepInterval = null; this._spawnLock = false; this._CONSENSUS_SIZE = 3; this._CONSENSUS_STRATEGY = 'majority'; this._CONSENSUS_TIMEOUT = 60000; this._consensusRounds = []; this._activeConsensus = null; this._pipelineHistory = []; this._PIPELINE_CONFIG = { autoFix: false, stageTimeout: 30000 }; this._schedules = []; this._scheduleTimer = null; this._loadConfig(); } _loadConfig() { try { const c = localStorage.getItem('webx11_orchestrator_config'); if (c) { const p = JSON.parse(c); if (p.spawnLimit) this._SPAWN_LIMIT = p.spawnLimit; if (p.heartbeatTimeout) this._HEARTBEAT_TIMEOUT = p.heartbeatTimeout; if (p.idleTimeout) this._IDLE_TIMEOUT = p.idleTimeout; if (p.restartPolicy) this._RESTART_POLICY = p.restartPolicy; if (p.agentDefaultModel !== undefined) this._AGENT_DEFAULT_MODEL = p.agentDefaultModel; if (p.a2aPollInterval) this._A2A_POLL_INTERVAL = p.a2aPollInterval; if (p.heartbeatCheck) this._HEARTBEAT_CHECK = p.heartbeatCheck; if (p.idleSweepInterval) this._IDLE_SWEEP_INTERVAL = p.idleSweepInterval; if (p.deadPurgeTimeout) this._DEAD_PURGE_TIMEOUT = p.deadPurgeTimeout; if (p.consensusSize) this._CONSENSUS_SIZE = p.consensusSize; if (p.consensusStrategy) this._CONSENSUS_STRATEGY = p.consensusStrategy; if (p.consensusTimeout) this._CONSENSUS_TIMEOUT = p.consensusTimeout; if (p.pipelineAutoFix !== undefined) this._PIPELINE_CONFIG.autoFix = p.pipelineAutoFix; if (p.pipelineStageTimeout) this._PIPELINE_CONFIG.stageTimeout = p.pipelineStageTimeout; } } catch (e) {} } _saveConfig() { localStorage.setItem('webx11_orchestrator_config', JSON.stringify({ spawnLimit: this._SPAWN_LIMIT, heartbeatTimeout: this._HEARTBEAT_TIMEOUT, idleTimeout: this._IDLE_TIMEOUT, restartPolicy: this._RESTART_POLICY, agentDefaultModel: this._AGENT_DEFAULT_MODEL, a2aPollInterval: this._A2A_POLL_INTERVAL, heartbeatCheck: this._HEARTBEAT_CHECK, idleSweepInterval: this._IDLE_SWEEP_INTERVAL, deadPurgeTimeout: this._DEAD_PURGE_TIMEOUT, consensusSize: this._CONSENSUS_SIZE, consensusStrategy: this._CONSENSUS_STRATEGY, consensusTimeout: this._CONSENSUS_TIMEOUT, pipelineAutoFix: this._PIPELINE_CONFIG.autoFix, pipelineStageTimeout: this._PIPELINE_CONFIG.stageTimeout })); } registerCommands() { this.commands['orchestrate'] = (args) => this.delegateTask(args.join(' ')); this.commands['orchestrator-status'] = () => this.showStatus(); this.commands['orchestrator-spawn'] = (args) => { // Parse --model flag before passing remaining as capabilities const modelIdx = args.indexOf('--model'); let modelName = null; if (modelIdx !== -1 && args[modelIdx + 1]) { modelName = args[modelIdx + 1]; args.splice(modelIdx, 2); } this.spawnAgent(args[0], args.slice(1), modelName); }; this.commands['orchestrator-kill'] = (args) => this.killAgent(args[0]); this.commands['orchestrator-purge'] = () => this.purgeDeadAgents(); this.commands['orchestrator-config'] = (args) => this.setConfig(args); this.commands['orchestrator-list'] = () => this.listAgents(); this.commands['orchestrator-sleep'] = (args) => this.sleepAgent(args[0]); this.commands['orchestrator-wake'] = (args) => this.wakeAgent(args[0]); this.commands['orchestrator-restart-policy'] = (args) => this.setRestartPolicy(args); this.commands['a2a-send'] = (args) => this.sendA2AMessage(args); this.commands['a2a-list'] = () => this.listA2AMessages(); this.commands['orchestrate-parallel'] = (args) => this.delegateTask(args.join(' '), { parallel: true }); this.commands['orchestrate-decompose'] = (args) => this.showDecomposition(args.join(' ')); this.commands['orchestrate-result'] = () => this.showParallelResult(); this.commands['consensus'] = (args) => this.handleConsensus(args); this.commands['consensus-status'] = () => this.consensusStatus(); this.commands['consensus-history'] = () => this.consensusHistory(); this.commands['pipeline'] = (args) => this.runPipeline(args); this.commands['orchestrator-schedule'] = (args) => this.addSchedule(args); this.commands['orchestrator-schedules'] = () => this.listSchedules(); this.commands['orchestrator-unschedule'] = (args) => this.removeSchedule(args[0]); } // ─── Task Decomposition Engine ─── _decomposeGoal(goal) { if (!goal || typeof goal !== 'string') return [{ goal }]; const lower = goal; // Heuristic: only split at conjunctions when both sides have distinct action verbs // Action verb patterns from _deriveCapabilities const ACTION_VERBS = [ 'search', 'find', 'research', 'lookup', 'query', 'write', 'create', 'generate', 'compose', 'draft', 'save', 'delete', 'remove', 'trash', 'rm', 'read', 'view', 'cat', 'show', 'display', 'inspect', 'preview', 'edit', 'modify', 'update', 'patch', 'change', 'fix', 'install', 'package', 'setup', 'list', 'status', 'health', 'audit', 'report', 'check', 'fetch', 'scrape', 'download', 'build', 'develop', 'compile', 'analyze', 'analyse', 'summarize', 'review', 'investigate', 'deploy', 'publish', 'upload', 'monitor', 'watch', 'poll', 'track', 'observe', 'test', 'verify', 'cmp', 'diff', 'compare' ]; const hasActionVerb = (s) => ACTION_VERBS.some(v => { const re = new RegExp('\\b' + v + '\\b', 'i'); return re.test(s); }); // Try splitting on " and " / " then " / " also " / "; " / ", then" const separators = [ /\s+and\s+/i, /\s+then\s+/i, /\s+also\s+/i, /;\s+/, /,\s+then\s+/i, /\s+followed by\s+/i, ]; let bestSplit = null; for (const sep of separators) { const parts = goal.split(sep); if (parts.length >= 2 && parts.every(p => hasActionVerb(p.trim()))) { bestSplit = parts.map(p => p.trim()).filter(p => p.length > 3); break; // first matching separator wins } } if (!bestSplit || bestSplit.length < 2) { return [{ goal }]; // atomic } return bestSplit.map(sub => ({ goal: sub, role: this._deriveRole(sub), caps: this._deriveCapabilities(sub) })); } getHelpInfo() { return { 'orchestrate': 'orchestrate <task> — Delegate a task to an available agent (auto-spawns if needed)', 'orchestrate-parallel': 'orchestrate-parallel <task> — Decompose and delegate sub-tasks in parallel (auto-splits on "and"/"then")', 'orchestrate-decompose': 'orchestrate-decompose <task> — Preview how a task would be decomposed into sub-tasks', 'orchestrate-result': 'orchestrate-result — Show the last parallel batch results (stored in VFS)', 'consensus': 'consensus <question> — Start a consensus round: spawn 3 agents, debate the question, resolve by majority', 'consensus-status': 'consensus-status — Show current/active consensus round status', 'consensus-history': 'consensus-history — Show past consensus rounds and their resolutions', 'pipeline': 'pipeline <test|deploy|verify|history|config> — Autonomous CI/CD: test agents/queue/modules, deploy (test→auto-fix→verify), history (show runs), config (show config)', 'orchestrator-schedule': 'orchestrator-schedule <role> <interval_seconds> <task...> — Schedule a recurring task delegation', 'orchestrator-schedules': 'orchestrator-schedules — List all active recurring schedules', 'orchestrator-unschedule': 'orchestrator-unschedule <id> — Remove a recurring schedule', 'orchestrator-status': 'orchestrator-status — Show agent pool status and config', 'orchestrator-spawn': 'orchestrator-spawn [role] [capabilities...] — Manually spawn a new agent', 'orchestrator-kill': 'orchestrator-kill <agent-id> — Terminate an agent and revoke leases', 'orchestrator-purge': 'orchestrator-purge — Remove dead agents (>60s since heartbeat)', 'orchestrator-config': 'orchestrator-config <key> <value> — Tune: spawn-limit, spawn-strategy, heartbeat-timeout, idle-timeout, restart-policy, a2a-polling, a2a-poll-interval, heartbeat-check, idle-sweep-interval, dead-purge-timeout', 'orchestrator-list': 'orchestrator-list — List all spawned agents with full details', 'orchestrator-sleep': 'orchestrator-sleep <agent-id> — Suspend agent to VFS', 'orchestrator-wake': 'orchestrator-wake <agent-id> — Restore agent from VFS sleep', 'orchestrator-restart-policy': 'orchestrator-restart-policy <agent-id> <on-failure|always|never> — Set per-agent restart policy', 'a2a-send': 'a2a-send <agent-id> <json-message> — Send an A2A message to an agent\'s inbox', 'a2a-list': 'a2a-list — Show pending A2A messages across all agents', }; } init(terminal) { this.terminal = terminal; // Register orchestrator in the agent registry const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; if (registry) { // MAINT-008: the agent id comes first; passing the command word as args[0] registered an // agent literally named "register" with one capability. registry.registerAgent(['orchestrator-0', 'delegate', 'spawn', 'monitor', 'purge']); } // Reconcile + pre-spawn the default team. VirtualFileSystem.loadFromStorage() is ASYNC, // so reconciliation MUST wait for it — running it synchronously saw an empty root and // silently did nothing, which let duplicate agents accumulate on every reload. this._bootAgents(); // A2A mailbox polling loop (500ms) if (this._A2A_POLLING) { this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); } // Heartbeat watchdog this._heartbeatInterval = this.terminal.moduleManager._setInterval(() => this._checkHeartbeats(), this._HEARTBEAT_CHECK); // Idle timeout sweep this._idleSweepInterval = this.terminal.moduleManager._setInterval(() => this._sweepIdle(), this._IDLE_SWEEP_INTERVAL); this.terminal.print('🧠 Orchestrator loaded — A2A mailbox transport active', 'success'); // Load recurring schedules from VFS this._loadSchedules(); // Subscribe to event bus for in-memory A2A delivery (instant, no VFS poll) this._on('a2a:message', (msg) => { if (msg.type === 'heartbeat' && msg.from && this._agents[msg.from]) { this._agents[msg.from].lastHeartbeat = Date.now(); } if (msg.type === 'response' && msg.from && this._agents[msg.from]) { this._agents[msg.from].status = 'idle'; this._agents[msg.from].currentTask = null; } }); } // ─── Event Bus (in-memory pub/sub for agent communication) ─── _on(event, callback) { if (!this._eventBus[event]) this._eventBus[event] = []; this._eventBus[event].push(callback); return () => { // Return unsubscribe function this._eventBus[event] = this._eventBus[event].filter(cb => cb !== callback); }; } _emit(event, data) { for (const cb of (this._eventBus[event] || [])) { try { cb(data); } catch (e) { /* subscriber error */ } } } // ─── A2A Mailbox Transport ─── sendA2AMessage(args) { if (args.length < 2) return this.terminal.print('Usage: a2a-send <agent-id> <json-message>', 'error'); const agentId = args[0]; let payload; try { payload = JSON.parse(args.slice(1).join(' ')); } catch (e) { // Treat as plain text message payload = { type: 'request', payload: { text: args.slice(1).join(' ') } }; } const agent = this._agents[agentId]; if (!agent) return this.terminal.print(`❌ Agent '${agentId}' not found`, 'error'); const msg = { a2a_version: '0.1', msg_id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`, type: payload.type || 'request', from: 'orchestrator-0', to: agentId, timestamp: new Date().toISOString(), ttl_seconds: 30, contract: agent.capabilities ? { capabilities: agent.capabilities } : {}, payload: payload.payload || payload }; // Write to agent's VFS inbox if (!this.terminal.fs.readFile(`/agents/${agentId}/`)) this.terminal.fs.mkdir(`agents/${agentId}`); this.terminal.fs.writeFile(`agents/${agentId}/inbox.json`, JSON.stringify(msg)); // Track in memory if (!this._a2aMessages[agentId]) this._a2aMessages[agentId] = []; this._a2aMessages[agentId].push({ ...msg, _processed: false }); this.terminal.print(`📨 A2A message sent to ${agentId} (${msg.type}, id: ${msg.msg_id})`, 'success'); // Emit via event bus for in-memory delivery (bypasses VFS polling) this._emit('a2a:message', msg); this._emit(`a2a:message:${agentId}`, msg); } listA2AMessages() { const ids = Object.keys(this._a2aMessages); if (ids.length === 0) return this.terminal.print('No A2A messages', 'info'); this.terminal.print('📨 A2A Messages:', 'header'); for (const agentId of ids) { const msgs = this._a2aMessages[agentId]; const pending = msgs.filter(m => !m._processed).length; this.terminal.print(` ${agentId}: ${msgs.length} total (${pending} pending)`, 'info'); for (const m of msgs.slice(-3)) { const status = m._processed ? '✅' : '⏳'; this.terminal.print(` ${status} ${m.msg_id} ${m.type} → ${m.to} (${(m.payload?.text || JSON.stringify(m.payload)).substring(0, 40)})`, 'text-muted'); } } } _pollAgentMailboxes() { const fs = this.terminal.fs; if (!fs) return; for (const [id, agent] of Object.entries(this._agents)) { try { const inbox = fs.fsReadJSON(`/agents/${id}/inbox.json`); if (!inbox) continue; // Check if this message is already processed const existing = this._a2aMessages[id] || []; if (existing.find(m => m.msg_id === inbox.msg_id && m._processed)) continue; // Route based on message type if (inbox.a2a_version && inbox.type) { inbox._processed = true; if (!this._a2aMessages[id]) this._a2aMessages[id] = []; this._a2aMessages[id].push(inbox); if (inbox.type === 'request') { this.terminal.print(`📨 A2A: ${inbox.from} → ${id} (${inbox.msg_id})`, 'info'); // Check outbox for response this._checkAgentOutbox(id); } else if (inbox.type === 'broadcast') { this.terminal.print(`📢 A2A broadcast from ${inbox.from}: ${(inbox.payload?.text || JSON.stringify(inbox.payload)).substring(0, 60)}`, 'info'); } else if (inbox.type === 'heartbeat') { agent.lastHeartbeat = Date.now(); } else if (inbox.type === 'response') { this.terminal.print(`📨 A2A response from ${id}: ${(inbox.payload?.result || JSON.stringify(inbox.payload)).substring(0, 60)}`, 'success'); agent.status = 'idle'; agent.currentTask = null; } } } catch (e) { /* skip agent with bad inbox */ } } } _checkAgentOutbox(agentId) { try { const outbox = this.terminal.fs.fsReadJSON(`/agents/${agentId}/outbox.json`); if (outbox && outbox.status === 'completed') { const msg = { a2a_version: '0.1', msg_id: `resp-${Date.now()}`, type: 'response', from: agentId, to: 'orchestrator-0', timestamp: new Date().toISOString(), payload: outbox }; if (!this._a2aMessages['orchestrator-0']) this._a2aMessages['orchestrator-0'] = []; this._a2aMessages['orchestrator-0'].push(msg); this.terminal.print(`📨 A2A: ${agentId} completed task (outbox)`, 'success'); } } catch (e) {} } // ─── Agent Lifecycle ─── _sanitizeId(name) { return (name || 'worker').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 32) || 'worker'; } spawnAgent(role, capabilities, modelName = null, idHint = null) { if (this._spawnLock) { this.terminal.print('⚠️ Spawn in progress — queued', 'warning'); return null; } this._spawnLock = true; try { const safeRole = this._sanitizeId(role); const id = idHint || `agent-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}-${safeRole}`; // Idempotent: a stable-id agent that is already live is reused, never duplicated if (this._agents[id]) { this.terminal.print(`♻️ Agent '${id}' already live — reusing`, 'info'); return this._agents[id]; } if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { this.terminal.print(`❌ Agent spawn limit (${this._SPAWN_LIMIT}) reached`, 'error'); return null; } const agent = { id, role: role || 'worker', model: modelName || this._AGENT_DEFAULT_MODEL, capabilities: capabilities.length ? capabilities : ['list_files', 'read_file', 'web_search'], createdAt: new Date().toISOString(), lastHeartbeat: Date.now(), status: 'idle', currentTask: null, leaseId: null, restartPolicy: this._RESTART_POLICY, failureCount: 0, restartCount: 0, nextRestartAt: null, // v4.6.63: "sleeping" alone could not tell an agent WAITING for work from one // that was never handed a task (packet §6: 4 registered, 0 executing). tasksAssigned: 0, lastTaskAt: null }; this._agents[id] = agent; // Create VFS agent directory with status if (!this.terminal.fs.readFile(`/agents/${id}/`)) this.terminal.fs.mkdir(`agents/${id}`); this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify(agent)); // Register in agent registry const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; if (registry) { registry.registerAgent([id, ...agent.capabilities]); // A2A Scratchpad registration if (typeof registry.a2aRegister === 'function') { registry.a2aRegister(id, agent.capabilities); } } this.terminal.print(`🤖 Agent '${id}' spawned (role: ${role || 'worker'}, ${agent.capabilities.length} caps)`, 'success'); return agent; } finally { this._spawnLock = false; } } killAgent(id) { if (!this._agents[id]) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); // Revoke any active lease const leaseModule = this.terminal.moduleManager.modules['LeaseModule']; if (leaseModule) { Object.values(leaseModule._leases).forEach(l => { if (l.workerId === id && l.status === 'active') leaseModule.revokeLease(l.id); }); } // Unregister from registry const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; if (registry) { registry.unregisterAgent(id); // A2A Scratchpad unregister if (typeof registry.a2aUnregister === 'function') { registry.a2aUnregister(id); } } // Clean up VFS — rm() dispatches file|dir → trash (deleteFile() is file-only // and silently returns false for directories, which leaked /agents/<id>/) const removed = this.terminal.fs.rm('agents/' + id); if (!removed) this.terminal.print(`⚠️ VFS cleanup failed for '${id}' — /agents/${id}/ still present`, 'warning'); delete this._agents[id]; delete this._restartBackoffs[id]; this.terminal.print(`💀 Agent '${id}' killed`, 'warning'); } sleepAgent(id) { if (!this._agents[id]) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); const agent = this._agents[id]; // Serialize to VFS const state = { ...agent, _sleepTime: new Date().toISOString() }; delete state.leaseId; // leases don't survive sleep this.terminal.fs.writeFile(`agents/${id}/sleep.json`, JSON.stringify(state)); agent.status = 'sleeping'; this.terminal.print(`💤 Agent '${id}' sleeping (state saved to VFS)`, 'warning'); } wakeAgent(id) { const data = this.terminal.fs.fsReadJSON(`/agents/${id}/sleep.json`); if (!data) return this.terminal.print(`❌ No saved state for '${id}' — use orchestrator-spawn instead`, 'error'); // Restore agent from VFS const agent = { ...data, status: 'idle', currentTask: null, leaseId: null, lastHeartbeat: Date.now() }; delete agent._sleepTime; this._agents[id] = agent; this.terminal.fs.writeFile(`agents/${id}/status.json`, JSON.stringify({ ...agent, status: 'idle' })); this.terminal.fs.deleteFile(`agents/${id}/sleep.json`); this.terminal.print(`🔄 Agent '${id}' reanimated from VFS sleep`, 'success'); } async _bootAgents() { // Boot sequence for /agents/ — order is load-bearing: // 1. await VFS hydration (loadFromStorage is async; a sync reconcile sees an empty root) // 2. reconcile /agents/ (adopt what is already there, purge duplicates/orphans) // 3. only then spawn any missing default agent try { await Promise.resolve(this.terminal.fs._ready); } catch (e) { this.terminal.print('⚠️ VFS hydration failed — agent reconciliation skipped', 'warning'); return this._agents; } this._reanimateFromVFS(); if (this._BOOT_DELAY > 0) await new Promise(r => setTimeout(r, this._BOOT_DELAY)); this._preSpawnDefaultAgents(); return this._agents; } _reanimateFromVFS() { // Boot reconciliation for /agents/: // 1. sleep.json → reanimate the agent (previous behaviour) // 2. status.json + default role → ADOPT it (a reload left it behind, still wanted) // 3. no usable state / duplicate → orphan → fs.rm(), so a reload cannot grow /agents/ // Only `agent-*` dirs are touched; /agents/register/ belongs to AgentRegistry. const fs = this.terminal.fs; const agentsDir = fs.readDir('/agents/'); if (!agentsDir) return; // Stable ids (agent-<role>) beat timestamped ones; among timestamped ids, newest wins. const tsOf = (id) => { const m = /^agent-([0-9a-z]+)-[0-9a-z]{4}-/.exec(id); return m ? (parseInt(m[1], 36) || 0) : Number.MAX_SAFE_INTEGER; }; const keep = new Map(); // role:<role> for default agents (singleton) | id:<id> for workers const orphans = []; for (const entry of agentsDir) { if (entry.type !== 'dir' || !entry.name.startsWith('agent-')) continue; const id = entry.name; if (this._agents[id]) continue; const sleepData = fs.fsReadJSON(`/agents/${id}/sleep.json`); const statusData = fs.fsReadJSON(`/agents/${id}/status.json`); const state = sleepData || null; const role = (state || statusData || {}).role; const wanted = !!state || (statusData && this._DEFAULT_ROLES.includes(role)); if (!wanted) { orphans.push(id); continue; } // Default roles are singletons (one dir per role); worker agents stay unique per id. const key = this._DEFAULT_ROLES.includes(role) ? 'role:' + role : 'id:' + id; const prev = keep.get(key); if (!prev || tsOf(id) > tsOf(prev.id)) { if (prev) orphans.push(prev.id); keep.set(key, { id, state: state || statusData }); } else { orphans.push(id); } } for (const { id, state } of keep.values()) { const agent = { ...state, status: 'idle', currentTask: null, leaseId: null, lastHeartbeat: Date.now() }; delete agent._sleepTime; this._agents[id] = agent; fs.writeFile(`agents/${id}/status.json`, JSON.stringify({ ...agent, status: 'idle' })); if (fs.readFile(`/agents/${id}/sleep.json`)) fs.rm(`agents/${id}/sleep.json`); } for (const id of orphans) fs.rm('agents/' + id); // rm() dispatches dir → trashDir if (keep.size > 0) this.terminal.print(`🔄 Restored ${keep.size} agent(s) from VFS`, 'info'); if (orphans.length > 0) this.terminal.print(`🧹 Purged ${orphans.length} orphaned agent dir(s)`, 'warning'); } // ─── Restart Policies ─── setRestartPolicy(args) { if (args.length < 2) return this.terminal.print('Usage: orchestrator-restart-policy <agent-id> <on-failure|always|never>', 'error'); const id = args[0]; const policy = args[1]; if (!['on-failure', 'always', 'never'].includes(policy)) { return this.terminal.print('Policy must be: on-failure, always, or never', 'error'); } if (id === 'default') { this._RESTART_POLICY = policy; return this.terminal.print(`✅ Default restart policy set to '${policy}'`, 'success'); } const agent = this._agents[id]; if (!agent) return this.terminal.print(`❌ Agent '${id}' not found`, 'error'); agent.restartPolicy = policy; this.terminal.print(`✅ Restart policy for '${id}' set to '${policy}'`, 'success'); } _handleAgentFailure(id, error) { const agent = this._agents[id]; if (!agent) return; agent.failureCount = (agent.failureCount || 0) + 1; agent.lastError = error; agent.lastErrorAt = new Date().toISOString(); let shouldRestart = false; if (agent.restartPolicy === 'always') { shouldRestart = true; } else if (agent.restartPolicy === 'on-failure' && agent.failureCount <= 1) { shouldRestart = true; } if (shouldRestart) { const backoff = Math.min(Math.pow(2, agent.failureCount), 60); // 2^N capped at 60s agent.nextRestartAt = Date.now() + (backoff * 1000); agent.restartCount = (agent.restartCount || 0) + 1; this._restartBackoffs[id] = setTimeout(() => { if (this._agents[id]) { this._agents[id].status = 'idle'; this._agents[id].currentTask = null; this.terminal.print(`🔄 Agent '${id}' auto-restarted (backoff: ${backoff}s, attempt: ${agent.restartCount})`, 'info'); } }, backoff * 1000); this.terminal.print(`⚠️ Agent '${id}' failed — restart in ${backoff}s (policy: ${agent.restartPolicy})`, 'warning'); } else { agent.status = 'failed'; this.terminal.print(`❌ Agent '${id}' failed — no restart (policy: ${agent.restartPolicy})`, 'error'); } } // ─── Autonomous Spawning ─── _deriveCapabilities(task) { const caps = ['list_files', 'read_file']; const lower = task.toLowerCase(); const CAP_MAP = [ { patterns: ['search', 'find', 'research', 'lookup', 'query', 'look up'], caps: ['web_search'] }, { patterns: ['write', 'create', 'generate', 'compose', 'draft', 'save'], caps: ['write_file'] }, { patterns: ['delete', 'remove', 'trash', 'rm '], caps: ['delete_file'] }, { patterns: ['read', 'view', 'cat', 'show', 'display', 'inspect', 'preview'], caps: [] }, // already has read_file { patterns: ['edit', 'modify', 'update', 'patch', 'change', 'fix'], caps: ['write_file'] }, { patterns: ['install', 'package', 'wpm', 'setup'], caps: ['wpm_install'] }, { patterns: ['module', 'plugin', 'ext'], caps: ['view_module', 'edit_module'] }, { patterns: ['list', 'status', 'health', 'audit', 'report', 'check'], caps: ['get_status'] }, { patterns: ['fetch', 'url', 'http', 'scrape'], caps: ['web_fetch'] }, { patterns: ['code', 'program', 'script', 'build', 'develop', 'compile'], caps: ['list_files', 'read_file', 'write_file'] }, { patterns: ['analyze', 'analyse', 'summarize', 'review', 'investigate'], caps: ['web_search', 'list_files', 'read_file'] }, { patterns: ['deploy', 'publish', 'upload'], caps: ['write_file', 'webdav_upload'] }, { patterns: ['monitor', 'watch', 'poll', 'track', 'observe'], caps: ['get_status'] }, ]; for (const entry of CAP_MAP) { if (entry.patterns.some(p => lower.includes(p))) { entry.caps.forEach(c => caps.push(c)); } } return [...new Set(caps)]; } _deriveRole(task) { const lower = task.toLowerCase(); if (lower.includes('search') || lower.includes('find')) return 'searcher'; if (lower.includes('write') || lower.includes('create') || lower.includes('save')) return 'writer'; if (lower.includes('delete') || lower.includes('remove') || lower.includes('clean')) return 'janitor'; if (lower.includes('install') || lower.includes('package')) return 'packager'; if (lower.includes('module') || lower.includes('code') || lower.includes('fix')) return 'coder'; if (lower.includes('health') || lower.includes('check') || lower.includes('audit')) return 'auditor'; if (lower.includes('fetch') || lower.includes('url') || lower.includes('http')) return 'fetcher'; return 'worker'; } // ─── Config ─── setConfig(args) { if (args.length < 2) return this.terminal.print('Usage: orchestrator-config <key> <value>', 'error'); const key = args[0]; const val = args[1]; const configMap = { 'spawn-limit': () => { this._SPAWN_LIMIT = parseInt(val); return `spawn-limit set to ${val}`; }, 'spawn-strategy': () => { if (!['eager', 'lazy'].includes(val)) return 'Must be eager or lazy'; this._SPAWN_STRATEGY = val; return `spawn-strategy set to ${val}`; }, 'heartbeat-timeout': () => { this._HEARTBEAT_TIMEOUT = parseInt(val); return `heartbeat-timeout set to ${val}ms`; }, 'idle-timeout': () => { this._IDLE_TIMEOUT = parseInt(val); return `idle-timeout set to ${val}ms`; }, 'restart-policy': () => { if (!['on-failure', 'always', 'never'].includes(val)) return 'Must be on-failure, always, or never'; this._RESTART_POLICY = val; return `default restart-policy set to ${val}`; }, 'a2a-polling': () => { this._A2A_POLLING = val === 'on' || val === 'true'; if (this._A2A_POLLING && !this._a2aPollTimer) { this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); } else if (!this._A2A_POLLING && this._a2aPollTimer) { this.terminal.moduleManager._clearTimer(this._a2aPollTimer); this._a2aPollTimer = null; } return `a2a-polling ${this._A2A_POLLING ? 'on' : 'off'}`; }, 'a2a-poll-interval': () => { const n = parseInt(val); if (isNaN(n) || n < 50 || n > 30000) return 'Must be 50-30000ms'; this.terminal.moduleManager._clearTimer(this._a2aPollTimer); this._a2aPollTimer = null; this._A2A_POLL_INTERVAL = n; if (this._A2A_POLLING) { this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), n); } return `a2a-poll-interval set to ${n}ms`; }, 'heartbeat-check': () => { const n = parseInt(val); if (isNaN(n) || n < 500 || n > 60000) return 'Must be 500-60000ms'; this.terminal.moduleManager._clearTimer(this._heartbeatInterval); this._HEARTBEAT_CHECK = n; this._heartbeatInterval = this.terminal.moduleManager._setInterval(() => this._checkHeartbeats(), n); return `heartbeat-check set to ${n}ms`; }, 'idle-sweep-interval': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 600000) return 'Must be 5000-600000ms'; this.terminal.moduleManager._clearTimer(this._idleSweepInterval); this._IDLE_SWEEP_INTERVAL = n; this._idleSweepInterval = this.terminal.moduleManager._setInterval(() => this._sweepIdle(), n); return `idle-sweep-interval set to ${n}ms`; }, 'dead-purge-timeout': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 600000) return 'Must be 5000-600000ms'; this._DEAD_PURGE_TIMEOUT = n; return `dead-purge-timeout set to ${n}ms`; }, 'consensus-size': () => { const n = parseInt(val); if (isNaN(n) || n < 2 || n > 10) return 'Must be 2-10'; this._CONSENSUS_SIZE = n; return `consensus-size set to ${n}`; }, 'consensus-strategy': () => { if (!['majority', 'unanimous', 'weighted'].includes(val)) return 'Must be majority, unanimous, or weighted'; this._CONSENSUS_STRATEGY = val; return `consensus-strategy set to ${val}`; }, 'consensus-timeout': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 300000) return 'Must be 5000-300000ms'; this._CONSENSUS_TIMEOUT = n; return `consensus-timeout set to ${n}ms`; }, 'pipeline-auto-fix': () => { this._PIPELINE_CONFIG.autoFix = val === 'on' || val === 'true'; return `auto-fix ${this._PIPELINE_CONFIG.autoFix ? 'on' : 'off'}`; }, 'pipeline-stage-timeout': () => { const n = parseInt(val); if (isNaN(n) || n < 5000 || n > 120000) return 'Must be 5000-120000ms'; this._PIPELINE_CONFIG.stageTimeout = n; return `stage-timeout set to ${n}ms`; }, 'agent-default-model': () => { this._AGENT_DEFAULT_MODEL = val; this._saveConfig(); return `agent-default-model set to ${val}`; } }; if (!configMap[key]) { return this.terminal.print(`Unknown config key: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); } const result = configMap[key](); this.terminal.print(`✅ ${result}`, 'success'); this._saveConfig(); } // ─── Show Decomposition Preview ─── showDecomposition(goal) { if (!goal) return this.terminal.print('Usage: orchestrate-decompose <goal>', 'error'); const parts = this._decomposeGoal(goal); this.terminal.print('🔍 Decomposition of "' + goal.substring(0, 60) + '":', 'header'); if (parts.length === 1 && parts[0].goal === goal) { this.terminal.print(' → Atomic (no split detected)', 'info'); return; } for (let i = 0; i < parts.length; i++) { const p = parts[i]; this.terminal.print(' [' + (i + 1) + '] ' + p.goal.substring(0, 50), 'success'); this.terminal.print(' Role: ' + p.role + ' | Caps: ' + (p.caps || []).join(', '), 'text-muted'); } this.terminal.print('→ ' + parts.length + ' sub-task(s) — run with: orchestrate-parallel "' + goal + '"', 'info'); } // ─── Show Last Parallel Result ─── showParallelResult() { const data = this.terminal.fs.fsReadJSON('orchestrator/parallel-results.json'); if (!data) return this.terminal.print('No parallel results saved yet', 'info'); this.terminal.print('📊 Last Parallel Batch Results', 'header'); this.terminal.print(' Task: ' + (data.parent_task || '').substring(0, 60), 'info'); this.terminal.print(' ' + data.successes + '/' + data.total + ' succeeded' + (data.failures > 0 ? ', ' + data.failures + ' failed' : ''), 'info'); this.terminal.print(' Timestamp: ' + data.timestamp, 'text-muted'); for (const s of (data.summaries || [])) { const icon = s.status === 'ok' ? '✅' : '❌'; const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 80) : (s.error || ''); this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 40) + ' → ' + snippet, 'info'); } } // ─── Consensus Protocol ─── async handleConsensus(args) { if (!args || args.length === 0) return this.terminal.print('Usage: consensus <question>', 'error'); const question = args.join(' '); if (this._activeConsensus) return this.terminal.print('❌ A consensus round is already in progress — wait for it to resolve', 'error'); const roundId = `consensus-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}`; const size = this._CONSENSUS_SIZE; this.terminal.print(`🤝 Starting consensus round ${roundId} (${size} agents, strategy: ${this._CONSENSUS_STRATEGY})`, 'header'); this.terminal.print(` Question: ${question.substring(0, 80)}`, 'info'); this._activeConsensus = { id: roundId, question, strategy: this._CONSENSUS_STRATEGY, size, votes: [], startedAt: new Date().toISOString(), resolved: false, result: null }; // Spawn N agents with same question const tasks = []; for (let i = 0; i < size; i++) { tasks.push(`analyze and answer: ${question}. Provide your reasoning then your final answer as "VOTE: <answer>"`); } const timeout = setTimeout(() => { if (this._activeConsensus && !this._activeConsensus.resolved) { this._resolveConsensus('timeout'); } }, this._CONSENSUS_TIMEOUT); try { const results = await Promise.allSettled(tasks.map(t => this.delegateTask(t))); for (let i = 0; i < results.length; i++) { const r = results[i]; const voteText = r.status === 'fulfilled' ? (r.value || '') : 'FAILED'; // Extract VOTE: marker or use full text let answer = 'no vote'; const voteMatch = voteText.match(/VOTE:\s*(.+?)(?:\n|$)/i); if (voteMatch) answer = voteMatch[1].trim(); else if (voteText.length > 0 && voteText !== 'FAILED') answer = voteText.substring(0, 60).trim(); this._activeConsensus.votes.push({ agentIdx: i, answer, raw: voteText.substring(0, 200), status: r.status === 'fulfilled' ? 'voted' : 'failed' }); } } catch (e) { this.terminal.print(`⚠️ Consensus error: ${e.message}`, 'warning'); } finally { clearTimeout(timeout); } this._resolveConsensus('complete'); return this._activeConsensus.result; } _resolveConsensus(reason) { const round = this._activeConsensus; if (!round || round.resolved) return; round.resolved = true; round.resolvedAt = new Date().toISOString(); round.resolvedBy = reason; // Count votes const voteCounts = {}; for (const v of round.votes) { if (v.status === 'failed') continue; voteCounts[v.answer] = (voteCounts[v.answer] || 0) + 1; } let winner = null; let maxVotes = 0; let totalVoted = 0; for (const [ans, count] of Object.entries(voteCounts)) { totalVoted += count; if (count > maxVotes) { maxVotes = count; winner = ans; } } if (this._CONSENSUS_STRATEGY === 'majority') { const needed = Math.ceil(round.size / 2); if (maxVotes >= needed) { round.result = { consensus: 'reached', answer: winner, votes: maxVotes, total: round.size }; } else { round.result = { consensus: 'hung', votes: voteCounts, total: round.size }; } } else if (this._CONSENSUS_STRATEGY === 'unanimous') { if (totalVoted === round.size && Object.keys(voteCounts).length === 1) { round.result = { consensus: 'reached', answer: winner, votes: maxVotes, total: round.size }; } else { round.result = { consensus: 'hung', votes: voteCounts, total: round.size }; } } else { // weighted — fallback to majority const needed = Math.ceil(round.size / 2); round.result = maxVotes >= needed ? { consensus: 'reached', answer: winner, votes: maxVotes, total: round.size } : { consensus: 'hung', votes: voteCounts, total: round.size }; } // Store the round this._consensusRounds.push(round); if (!this.terminal.fs.readDir('orchestrator')) this.terminal.fs.mkdir('orchestrator'); this.terminal.fs.writeFile(`orchestrator/consensus-round-${round.id}.json`, JSON.stringify(round, null, 2)); // Print result const r = round.result; if (r.consensus === 'reached') { this.terminal.print(`✅ Consensus reached: "${r.answer}" (${r.votes}/${r.total} votes, ${round.strategy})`, 'success'); } else { this.terminal.print(`⚖️ Consensus hung — no clear winner. Votes: ${JSON.stringify(r.votes)}`, 'warning'); } this._activeConsensus = null; } consensusStatus() { if (this._activeConsensus) { const r = this._activeConsensus; this.terminal.print('🤝 Active Consensus Round', 'header'); this.terminal.print(` ID: ${r.id}`, 'info'); this.terminal.print(` Question: ${r.question.substring(0, 60)}`, 'info'); this.terminal.print(` Strategy: ${r.strategy} | Size: ${r.size}`, 'info'); this.terminal.print(` Votes collected: ${r.votes.length}/${r.size}`, 'info'); for (const v of r.votes) { this.terminal.print(` ${v.status === 'voted' ? '✅' : '❌'} Agent ${v.agentIdx}: ${v.answer}`, 'info'); } } else { this.terminal.print('No active consensus round', 'info'); } } consensusHistory() { const rounds = this._consensusRounds; if (rounds.length === 0) { this.terminal.print('No consensus rounds in memory — checking VFS...', 'info'); // Try VFS const dir = this.terminal.fs.readDir('orchestrator/'); if (dir) { const files = dir.filter(e => e.name && e.name.startsWith('consensus-round-')); if (files.length > 0) { this.terminal.print(`📜 ${files.length} round(s) in VFS`, 'header'); for (const f of files.slice(-5)) { const data = this.terminal.fs.fsReadJSON(`orchestrator/${f.name}`); if (data) { const r = data.result; const status = r?.consensus === 'reached' ? '✅' : '⚖️'; this.terminal.print(` ${status} ${data.id}: "${data.question.substring(0, 40)}" → ${r?.answer || 'no result'}`, 'info'); } } return; } } this.terminal.print('No past consensus rounds found', 'info'); return; } this.terminal.print('📜 Consensus History', 'header'); for (const round of rounds.slice(-10)) { const r = round.result; const status = r?.consensus === 'reached' ? '✅' : '⚖️'; this.terminal.print(` ${status} ${round.id}: "${round.question.substring(0, 40)}"`, 'info'); this.terminal.print(` Strategy: ${round.strategy} | Votes: ${round.votes.length}/${round.size} | Result: ${r?.answer || 'hung'}`, 'text-muted'); } } // ─── Autonomous CI/CD Pipeline ─── async runPipeline(args) { if (!args || args.length === 0) return this.terminal.print('Usage: pipeline <command> [options]', 'error'); const cmd = args[0].toLowerCase(); const opts = args.slice(1); const run = { id: `pipeline-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 4)}`, command: cmd, startedAt: new Date().toISOString(), stages: [], status: 'running' }; try { if (cmd === 'test') { await this._pipelineStage(run, 'agent-health', () => this._testAgentHealth()); await this._pipelineStage(run, 'a2a-mailbox', () => this._testA2AMailbox()); await this._pipelineStage(run, 'module-check', () => this._testModules()); run.status = 'passed'; this.terminal.print('✅ All pipeline tests passed', 'success'); } else if (cmd === 'deploy') { const deployTask = opts.join(' ') || 'run all checks'; await this._pipelineStage(run, 'test', () => this.runPipeline(['test'])); if (run.stages.find(s => s.name === 'test' && s.status === 'failed')) { if (this._PIPELINE_CONFIG.autoFix) { await this._pipelineStage(run, 'auto-fix', () => this._autoFix()); } else { run.status = 'blocked'; this.terminal.print('❌ Pipeline blocked — tests failed. Set pipeline-auto-fix on to auto-repair', 'error'); return; } } await this._pipelineStage(run, 'verify', () => this._pipelineVerify()); run.status = 'passed'; this.terminal.print('✅ Pipeline deploy passed all stages', 'success'); } else if (cmd === 'verify') { await this._pipelineStage(run, 'verify', () => this._pipelineVerify()); run.status = 'passed'; } else if (cmd === 'history') { this._pipelineHistory.slice(-10).forEach(p => { const icon = p.status === 'passed' ? '✅' : (p.status === 'blocked' ? '🚫' : '❌'); this.terminal.print(` ${icon} ${p.id}: ${p.command} (${p.startedAt.slice(11, 19)})`, 'info'); p.stages.forEach(s => { this.terminal.print(` ${s.status === 'passed' ? '✅' : '❌'} ${s.name} (${(s.duration || 0).toFixed(0)}ms)`, 'text-muted'); }); }); if (this._pipelineHistory.length === 0) this.terminal.print('No pipeline runs yet', 'info'); return; } else if (cmd === 'config') { this.terminal.print('Pipeline Config:', 'header'); this.terminal.print(` auto-fix: ${this._PIPELINE_CONFIG.autoFix ? 'on' : 'off'}`, 'info'); this.terminal.print(` stage-timeout: ${this._PIPELINE_CONFIG.stageTimeout}ms`, 'info'); return; } else { return this.terminal.print('Unknown pipeline command. Usage: test, deploy, verify, history, config', 'error'); } } catch (e) { run.status = 'failed'; run.error = e.message; this.terminal.print(`❌ Pipeline failed: ${e.message}`, 'error'); } run.completedAt = new Date().toISOString(); this._pipelineHistory.push(run); if (!this.terminal.fs.readDir('orchestrator')) this.terminal.fs.mkdir('orchestrator'); this.terminal.fs.writeFile(`orchestrator/pipeline-${run.id}.json`, JSON.stringify(run, null, 2)); return run; } async _pipelineStage(run, name, fn) { const stage = { name, status: 'running', startedAt: Date.now() }; run.stages.push(stage); this.terminal.print(`⏳ Pipeline stage: ${name}...`, 'info'); try { const timeout = this._PIPELINE_CONFIG.stageTimeout; const result = await Promise.race([ fn(), new Promise((_, reject) => setTimeout(() => reject(new Error(`${name} timed out (${timeout}ms)`)), timeout)) ]); stage.status = 'passed'; stage.duration = Date.now() - stage.startedAt; stage.result = typeof result === 'string' ? result.substring(0, 200) : 'ok'; this.terminal.print(`✅ Stage ${name} passed (${stage.duration}ms)`, 'success'); return result; } catch (e) { stage.status = 'failed'; stage.duration = Date.now() - stage.startedAt; stage.error = e.message; this.terminal.print(`❌ Stage ${name} failed: ${e.message}`, 'error'); throw e; } } _testAgentHealth() { const total = Object.keys(this._agents).length; const busy = Object.values(this._agents).filter(a => a.status === 'busy').length; const failed = Object.values(this._agents).filter(a => a.status === 'failed').length; const issues = []; if (failed > 0) issues.push(`${failed} agent(s) in failed state`); if (total > 10) issues.push(`high agent count: ${total}`); if (issues.length > 0) throw new Error(issues.join('; ')); this.terminal.print(` Agents: ${total} (${busy} busy, ${failed} failed) — healthy`, 'info'); return `agents_ok:${total}:${busy}:${failed}`; } _testA2AMailbox() { // Verify A2A polling is running if (!this._A2A_POLLING) throw new Error('A2A polling is disabled'); if (!this._a2aPollTimer) throw new Error('A2A polling timer not running'); // Verify each agent has a VFS directory for (const [id, agent] of Object.entries(this._agents)) { const dir = this.terminal.fs.readDir(`/agents/${id}/`); if (!dir) throw new Error(`Agent ${id} missing VFS directory`); } this.terminal.print(` A2A: ${Object.keys(this._agents).length} agent mailboxes OK`, 'info'); return `a2a_ok:${Object.keys(this._agents).length}`; } _testModules() { const mm = this.terminal.moduleManager; if (!mm || !mm.modules) throw new Error('ModuleManager not available'); const required = ['OrchestratorModule', 'AgentRegistryModule', 'LeaseModule', 'MemoryGraphModule']; const missing = required.filter(n => !mm.modules[n]); if (missing.length > 0) throw new Error(`Missing modules: ${missing.join(', ')}`); // Check orchestrator is in registry const registry = mm.modules['AgentRegistryModule']; if (registry && !registry.agents?.find(a => a.id === 'orchestrator-0')) { this.terminal.print(' ⚠️ orchestrator-0 not in registry (will register on next init)', 'warning'); } this.terminal.print(` Modules: ${required.length}/${required.length} required loaded`, 'info'); return `modules_ok:${required.length}`; } _pipelineVerify() { // Verify running state — internal checks const issues = []; // Check orchestrator status const total = Object.keys(this._agents).length; const failed = Object.values(this._agents).filter(a => a.status === 'failed').length; if (failed > 0) issues.push(`${failed} failed agents`); if (total + failed > 0 && failed === total) issues.push('all agents failed'); // Verify last parallel result exists const lastResult = this.terminal.fs.fsReadJSON('orchestrator/parallel-results.json'); if (!lastResult) this.terminal.print(' ℹ️ No parallel results yet (first deploy)', 'info'); // Verify consensus history const consensusDir = this.terminal.fs.readDir('orchestrator/'); if (consensusDir) { const rounds = consensusDir.filter(e => e.name && e.name.startsWith('consensus-round-')); this.terminal.print(` VFS: ${rounds.length} consensus rounds archived`, 'info'); } // Check memory graph const memgraph = this.terminal.moduleManager?.modules['MemoryGraphModule']; if (memgraph && typeof memgraph.getStats === 'function') { const stats = memgraph.getStats(); this.terminal.print(` Memory graph: ${stats?.edges || 0} edges, ${stats?.nodes || 0} nodes`, 'info'); } if (issues.length > 0) throw new Error(issues.join('; ')); return `verify_ok:${total} agents, ${issues.length} issues`; } async _autoFix() { this.terminal.print('🔄 Auto-fix running...', 'warning'); let fixed = 0; // Fix 1: Purge failed agents const failedIds = Object.entries(this._agents) .filter(([, a]) => a.status === 'failed') .map(([id]) => id); for (const id of failedIds) { this.killAgent(id); fixed++; } if (fixed > 0) this.terminal.print(` Purged ${fixed} failed agent(s)`, 'warning'); // Fix 2: Restart A2A poller if stopped if (!this._A2A_POLLING) { this._A2A_POLLING = true; this._a2aPollTimer = this.terminal.moduleManager._setInterval(() => this._pollAgentMailboxes(), this._A2A_POLL_INTERVAL); this.terminal.print(' Restarted A2A polling', 'warning'); fixed++; } // Fix 3: Ensure VFS orchestrator dir exists if (!this.terminal.fs.readDir('orchestrator')) { this.terminal.fs.mkdir('orchestrator'); fixed++; } if (fixed === 0) this.terminal.print(' No auto-fix actions needed', 'info'); return `fixed ${fixed} issues`; } // ─── Task Delegation ─── async delegateTask(task, opts = {}) { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); this._delegations = (this._delegations || 0) + 1; // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { const parts = this._decomposeGoal(task); if (parts.length > 1) { this.terminal.print('🧩 Decomposed into ' + parts.length + ' sub-tasks — running in parallel', 'info'); // Auto-scale: ensure enough idle agents for N sub-tasks const idleCount = Object.values(this._agents).filter(a => a.status === 'idle').length; const totalSlots = this._SPAWN_LIMIT; const usedSlots = Object.keys(this._agents).length; const availableSlots = totalSlots - usedSlots; const needed = parts.length - idleCount; if (needed > 0 && availableSlots > 0) { const toSpawn = Math.min(needed, availableSlots); this.terminal.print('📈 Auto-scaling: spawning ' + toSpawn + ' pre-warm agent(s)', 'info'); for (let i = 0; i < toSpawn; i++) { this.spawnAgent('worker', ['list_files', 'read_file', 'write_file', 'web_search']); } } const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); const successes = results.filter(r => r.status === 'fulfilled').length; const failures = results.filter(r => r.status === 'rejected').length; // Result synthesis: collect outbox results from each sub-task's agent const summaries = []; for (let i = 0; i < parts.length; i++) { const sub = parts[i]; const r = results[i]; if (r.status === 'fulfilled') { // Find the agent that handled this sub-task const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); if (agent) { const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); const snippet = outbox?.result ? outbox.result.substring(0, 200) : 'completed (no outbox)'; summaries.push({ task: sub.goal, agent: agent.id, status: 'ok', result: snippet }); } else { summaries.push({ task: sub.goal, status: 'ok', result: 'completed (agent recycled)' }); } } else { summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); } } // Store merged result to VFS for persistence const merged = { type: 'parallel_result', parent_task: task, timestamp: new Date().toISOString(), total: parts.length, successes, failures, summaries }; if (!this.terminal.fs.readDir('orchestrator')) this.terminal.fs.mkdir('orchestrator'); this.terminal.fs.writeFile('orchestrator/parallel-results.json', JSON.stringify(merged, null, 2)); // Print synthesis to terminal this.terminal.print('📊 Parallel Batch Results:', 'header'); for (const s of summaries) { const icon = s.status === 'ok' ? '✅' : '❌'; const agentTag = s.agent ? ' [' + s.agent.substring(0, 20) + ']' : ''; const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); } this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); return merged; } // Single sub-task — fall through to normal path } // Declared in function scope: the AI branch assigns it, but the return at the end of // this method runs even when that branch never did. let outboxResult = null; // Find an idle agent and immediately stamp it busy (atomic claim) let agent = Object.values(this._agents).find(a => a.status === 'idle'); if (agent) { agent.status = 'busy'; } // Auto-spawn with role-derived capabilities if (!agent) { if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { // Try finding any non-failed agent to reuse const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); if (reuse) { reuse.status = 'busy'; agent = reuse; } } if (!agent) { const role = this._deriveRole(task); const caps = this._deriveCapabilities(task); agent = this.spawnAgent(role, caps); if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); agent.status = 'busy'; // claim immediately after spawn } } if (!agent) return this.terminal.print('❌ No available agents', 'error'); // A2A Contract handshake const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; let contractValid = true; if (registry) { const result = registry.validateTask(agent.role, task); if (!result.valid) { this.terminal.print(`⚠️ A2A validation: ${result.reason}`, 'warning'); contractValid = false; } else if (result.reason === 'matched') { this.terminal.print(`✅ A2A contract matched for role '${agent.role}'`, 'success'); } } // Create a lease — with --write if agent has write capabilities const leaseModule = this.terminal.moduleManager.modules['LeaseModule']; const hasWriteCap = (agent.capabilities || []).some(function(c) { return c === 'write_file' || c === 'edit_module' || c === 'delete_file'; }); const leaseArgs = ['lease-create', agent.id, '60', task]; if (hasWriteCap) leaseArgs.push('--write'); const lease = leaseModule ? leaseModule.createLease(leaseArgs) : null; // Mark agent as busy agent.status = 'busy'; agent.currentTask = task; agent.leaseId = lease?.id; agent.lastHeartbeat = Date.now(); agent.tasksAssigned = (agent.tasksAssigned || 0) + 1; agent.lastTaskAt = new Date().toISOString(); // Send A2A request via inbox this.sendA2AMessage([agent.id, JSON.stringify({ type: 'request', payload: { task, from: 'orchestrator-0', leaseId: lease?.id } })]); this.terminal.print(`🎯 Delegated \"${task.substring(0, 60)}\" to ${agent.id} (lease: ${lease?.id || 'none'})`, 'info'); // Execute via AI assistant try { const aiModule = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; if (aiModule && typeof aiModule.chat === 'function') { const _agentTag = agent.id + '/' + (agent.role || 'worker'); const result = await aiModule.chat([`[DELEGATED] ${task}`], { agentTag: _agentTag }); try { aiModule._sessions.delete(_agentTag); } catch (_e) {} agent.status = 'completed'; // Write result to outbox this.terminal.fs.writeFile(`agents/${agent.id}/outbox.json`, JSON.stringify({ result: typeof result === 'string' ? result : (result?.content || 'completed'), status: 'completed', timestamp: new Date().toISOString() })); // Record in memory graph const memgraph = this.terminal.moduleManager.modules['MemoryGraphModule']; if (memgraph) { memgraph.recordTask(task, typeof result === 'string' ? result : (result?.content || ''), agent.id); memgraph.recordFileAccess(`/agents/${agent.id}/outbox.json`); } this.terminal.print(`✅ ${agent.id} completed task`, 'success'); // Return result for callers that await delegateTask outboxResult = typeof result === 'string' ? result : (result?.content || 'completed'); // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { agent.lastTask = agent.currentTask; agent.status = 'idle'; agent.currentTask = null; agent.lastHeartbeat = Date.now(); } } else { this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); outboxResult = 'queued — AI module not loaded, nothing executed'; } } catch (e) { this._handleAgentFailure(agent.id, e.message); } // Return to idle pool (unless failed/restarting) if (agent.status === 'failed') { // handled by _handleAgentFailure throw new Error(agent.lastError || 'agent failed'); } else if (agent.status !== 'busy') { agent.lastTask = agent.currentTask; agent.currentTask = null; agent.status = 'idle'; agent.lastHeartbeat = Date.now(); } return outboxResult || 'no result recorded'; } // ─── Recurring Scheduling ─── addSchedule(args) { if (args.length < 2) return this.terminal.print('Usage: orchestrator-schedule <role> <interval_seconds> <task...>', 'error'); const role = args[0]; const interval = parseInt(args[1]); if (isNaN(interval) || interval < 10) return this.terminal.print('Interval must be >= 10 seconds', 'error'); const task = args.slice(2).join(' '); if (!task) return this.terminal.print('Task description required', 'error'); const schedule = { id: 'sched-' + Date.now(), role: role, interval: interval * 1000, task: task, createdAt: new Date().toISOString(), lastRun: null, nextRun: Date.now() + interval * 1000, runs: 0 }; this._schedules.push(schedule); this._saveSchedules(); this._startScheduleTimer(); this.terminal.print('📅 Schedule created: "' + task.substring(0, 40) + '" every ' + interval + 's (role: ' + role + ')', 'success'); return schedule; } listSchedules() { if (this._schedules.length === 0) return this.terminal.print('No active schedules', 'info'); this.terminal.print('📅 Recurring Schedules (' + this._schedules.length + '):', 'header'); for (const s of this._schedules) { const nextIn = Math.max(0, Math.round((s.nextRun - Date.now()) / 1000)); this.terminal.print(' ' + s.id + ' — "' + s.task.substring(0, 40) + '" every ' + (s.interval / 1000) + 's [' + s.role + '] (next: ' + nextIn + 's, runs: ' + s.runs + ')', 'info'); } } removeSchedule(id) { const idx = this._schedules.findIndex(function(s) { return s.id === id; }); if (idx === -1) return this.terminal.print('Schedule ' + id + ' not found', 'error'); this._schedules.splice(idx, 1); this._saveSchedules(); if (this._schedules.length === 0 && this._scheduleTimer) { clearInterval(this._scheduleTimer); this._scheduleTimer = null; } this.terminal.print('Schedule ' + id + ' removed', 'success'); } _startScheduleTimer() { if (this._scheduleTimer) return; this._scheduleTimer = setInterval(function() { var now = Date.now(); for (var i = 0; i < this._schedules.length; i++) { var s = this._schedules[i]; if (now >= s.nextRun) { s.lastRun = new Date().toISOString(); s.nextRun = now + s.interval; s.runs++; this.terminal.print('📅 Schedule trigger: "' + s.task.substring(0, 40) + '"', 'info'); this.delegateTask(s.task); } } this._saveSchedules(); }.bind(this), 5000); } _saveSchedules() { try { this.terminal.fs.fsWriteJSON('/orchestrator/schedules.json', this._schedules); } catch (e) { console.warn('[Orchestrator] Schedule save error:', e.message); } } _loadSchedules() { try { const data = this.terminal.fs.fsReadJSON('/orchestrator/schedules.json'); if (data && Array.isArray(data)) { this._schedules = data; if (this._schedules.length > 0) this._startScheduleTimer(); } } catch (e) { this._schedules = []; } } _preSpawnDefaultAgents() { // Spawn the default team ONCE per browser profile. Stable ids (`agent-<role>`) mean a // reload re-adopts the existing dir in _reanimateFromVFS() instead of writing a new one. var roles = [ { role: 'auto-learner', caps: ['list_files', 'read_file', 'write_file', 'view_module'] }, { role: 'kernel-architect', caps: ['list_files', 'read_file', 'write_file', 'run_command'] }, { role: 'searcher', caps: ['list_files', 'read_file', 'web_search'] } ]; for (var i = 0; i < roles.length; i++) { var r = roles[i]; // Already live (fresh spawn this boot, or adopted from VFS) → just keep it alive var existing = Object.values(this._agents).find(function(a) { return a.role === r.role; }); if (existing) { existing.restartPolicy = 'always'; continue; } var id = 'agent-' + this._sanitizeId(r.role); var agent = this.spawnAgent(r.role, r.caps, null, id); if (agent) { agent.restartPolicy = 'always'; this.terminal.print('🤖 Pre-spawned agent: ' + r.role + ' (' + r.caps.length + ' caps)', 'info'); } } } // ─── Monitoring ─── _checkHeartbeats() { const now = Date.now(); Object.entries(this._agents).forEach(([id, agent]) => { if (agent.status === 'busy' && now - agent.lastHeartbeat > this._HEARTBEAT_TIMEOUT) { this.terminal.print(`⚠️ Agent '${id}' unresponsive (${Math.round((now - agent.lastHeartbeat) / 1000)}s since heartbeat)`, 'warning'); } }); } _sweepIdle() { const now = Date.now(); Object.entries(this._agents).forEach(([id, agent]) => { if (agent.status === 'idle' && now - agent.lastHeartbeat > this._IDLE_TIMEOUT) { this.terminal.print(`💤 Agent '${id}' idle for >${this._IDLE_TIMEOUT/1000}s — auto-sleeping`, 'info'); this.sleepAgent(id); } }); } purgeDeadAgents() { const now = Date.now(); let count = 0; Object.entries(this._agents).forEach(([id, agent]) => { if (now - agent.lastHeartbeat > this._DEAD_PURGE_TIMEOUT) { this.killAgent(id); count++; } }); this.terminal.print(`🧹 Purged ${count} dead agents`, count > 0 ? 'warning' : 'info'); } // ─── Status & List ─── showStatus() { const total = Object.keys(this._agents).length; const busy = Object.values(this._agents).filter(a => a.status === 'busy').length; const idle = Object.values(this._agents).filter(a => a.status === 'idle').length; const sleeping = Object.values(this._agents).filter(a => a.status === 'sleeping').length; const failed = Object.values(this._agents).filter(a => a.status === 'failed' || a.status === 'restarting').length; const a2aTotal = Object.values(this._a2aMessages).reduce((s, msgs) => s + msgs.length, 0); const a2aPending = Object.values(this._a2aMessages).reduce((s, msgs) => s + msgs.filter(m => !m._processed).length, 0); this.terminal.print('🧠 Orchestrator Status', 'header'); this.terminal.print(` Agents: ${total} (${busy} busy, ${idle} idle, ${sleeping} sleeping, ${failed} failed)`, 'info'); this.terminal.print(` Spawn limit: ${this._SPAWN_LIMIT} (strategy: ${this._SPAWN_STRATEGY})`, 'info'); this.terminal.print(` Default restart: ${this._RESTART_POLICY}`, 'info'); this.terminal.print(` Default agent model: ${this._AGENT_DEFAULT_MODEL || 'none (uses main AI model)'}`, 'info'); this.terminal.print(` A2A Messages: ${a2aTotal} (${a2aPending} pending)`, 'info'); // v4.6.63: say what the state MEANS. A parked agent with 0 assignments and a parked // agent between tasks looked identical from outside (packet §6). const agentsArr = Object.values(this._agents); const available = agentsArr.filter(a => a.status === 'idle' || a.status === 'sleeping').length; const describe = (a) => { const n = a.tasksAssigned || 0; const since = a.createdAt ? String(a.createdAt).slice(0, 16).replace('T', ' ') : '?'; const last = a.lastTaskAt ? String(a.lastTaskAt).slice(0, 16).replace('T', ' ') : null; if (a.status === 'busy') { return 'busy' + (a.currentTask ? ' — ' + String(a.currentTask).substring(0, 40) : ''); } if (a.status === 'failed' || a.status === 'restarting') { return a.status + ` — ${a.failureCount || 0} failure(s), ${n} task(s) ever`; } if (n === 0) { return (a.status === 'sleeping' ? 'parked' : a.status) + ` — available, no delegations yet (spawned ${since})`; } return (a.status === 'sleeping' ? 'parked' : a.status) + ` — ${n} delegation(s), last ${last || '?'}`; }; agentsArr.forEach(a => { const modelInfo = a.model ? ` [model: ${a.model}]` : ''; const backoff = a.nextRestartAt && a.nextRestartAt > Date.now() ? ` [restart in ${Math.round((a.nextRestartAt - Date.now()) / 1000)}s]` : ''; this.terminal.print(` ${a.id} [${a.role}] — ${describe(a)}${modelInfo}${backoff}`, 'info'); }); // v4.6.68: on-demand by design — a worker with nothing to do is healthy, not broken. // The number to watch is how many delegations the AI actually made. this.terminal.print(` Mode: on-demand — ${available}/${total} worker(s) available, ${this._delegations || 0} delegation(s) this session`, 'info'); if (total > 0 && (this._delegations || 0) === 0) { this.terminal.print(' Workers wait for delegate_task — spawn-on-demand, no background dispatch queue by design.', 'text-muted'); } } listAgents() { const all = Object.values(this._agents); if (all.length === 0) return this.terminal.print('No agents spawned', 'info'); this.terminal.print(`📋 Agents (${all.length}):`, 'header'); all.forEach(a => { const caps = (a.capabilities || []).join(', '); this.terminal.print(` ${a.id} [${a.role}] — ${a.status}`, 'info'); this.terminal.print(` Caps: ${caps}`, 'text-muted'); this.terminal.print(` Policy: ${a.restartPolicy || this._RESTART_POLICY} | Failures: ${a.failureCount || 0} | Restarts: ${a.restartCount || 0}`, 'text-muted'); if (a.currentTask) this.terminal.print(` Task: ${a.currentTask.substring(0, 60)}`, 'text-muted'); }); } } // Required for module system if (typeof window !== 'undefined') { window.OrchestratorModule = OrchestratorModule; } /* === module-sandbox.js === */ // module-sandbox.js — Sandboxed execution pool using WebWorkers // Commands: sandbox-status, sandbox-config, sandbox-run class SandboxModule { constructor() { this.commands = {}; this.terminal = null; this.workers = []; this.MAX_WORKERS = 4; this.MIN_WORKERS = 1; this.queue = []; this.activeTasks = {}; this._nextTaskId = 0; this._totalRuns = 0; this._totalErrors = 0; this._totalDuration = 0; this._fallbackMode = false; // true if WebWorkers unavailable this._workerReady = {}; } getHelpInfo() { return { 'sandbox-status': 'sandbox-status — Show sandbox pool health and task metrics', 'sandbox-config': 'sandbox-config <key> <value> — Tune: max-workers, min-workers, pool-timeout', 'sandbox-run': 'sandbox-run <task> — Run a task in the sandbox (automatic routing)', }; } registerCommands() { this.commands['sandbox-status'] = () => this.showStatus(); this.commands['sandbox-config'] = (args) => this.setConfig(args); this.commands['sandbox-run'] = (args) => this.runInSandbox(args.join(' ')); } init(terminal) { this.terminal = terminal; this.workerUrl = '/spaces/newos4/modules/core/sandbox-worker.js?v=2026-06-05-v4.4.0'; // Detect Worker availability if (typeof Worker === 'undefined') { this._fallbackMode = true; this.terminal.print('⚠️ WebWorkers not available — sandbox using inline fallback', 'warning'); } else { this._initPool(); // Deferred check: workers may fail asynchronously (headless browsers) // If no worker proves alive within 1.5s, switch to inline fallback this._workerCheckTimer = setTimeout(() => { if (!this._fallbackMode && this.workers.length > 0) { const alive = this.workers.some(w => !w._failed && this._workerReady[w._sandboxId]); if (!alive) { this._fallbackMode = true; this.workers.forEach(w => w.terminate()); this.workers = []; this.terminal.print('⚠️ WebWorkers unavailable — switching to inline fallback', 'warning'); } } }, 1500); } this.terminal.print('🧪 Sandbox module loaded', 'info'); } _initPool() { // Start with MIN_WORKERS, grow on demand up to MAX_WORKERS for (let i = 0; i < this.MIN_WORKERS; i++) { const w = this._spawnWorker(); if (!w) break; // first failure kills the pool } } _spawnWorker() { if (this.workers.length >= this.MAX_WORKERS) return null; if (this._fallbackMode) return null; try { const worker = new Worker(this.workerUrl); const id = `w${this.workers.length + 1}`; worker._sandboxId = id; worker._busy = false; worker._failed = false; // Detect immediate worker failure (headless browsers, restricted contexts) const failTimer = setTimeout(() => { if (!worker._failed) { // Worker survived 2 seconds - likely functional } }, 2000); worker.onmessage = (e) => { clearTimeout(failTimer); if (e.data && e.data.type === 'ready') { this._workerReady[id] = true; } else { this._handleResult(e.data, id); } }; worker.onerror = (e) => { clearTimeout(failTimer); worker._failed = true; worker.terminate(); console.warn(`[Sandbox] Worker ${id} failed (headless env) — switching to inline`); this._fallbackMode = true; this.workers.forEach(w => w.terminate()); this.workers = []; this.terminal.print('⚠️ WebWorkers unavailable in this environment — using inline fallback', 'warning'); }; this.workers.push(worker); return worker; } catch (e) { console.warn('[Sandbox] Failed to spawn worker:', e); this._fallbackMode = true; return null; } } _replaceWorker(id) { // In headless environments, workers don't work — don't retry if (this._fallbackMode) return; const idx = this.workers.findIndex(w => w._sandboxId === id); if (idx !== -1) { this.workers[idx].terminate(); this.workers.splice(idx, 1); delete this._workerReady[id]; } } _getIdleWorker() { for (const w of this.workers) { if (!w._busy) return w; } // Grow pool if under max if (this.workers.length < this.MAX_WORKERS) { const w = this._spawnWorker(); if (w) return w; } return null; } async runInSandbox(task, capabilities) { if (!task) return this.terminal.print('Usage: sandbox-run <task>', 'error'); // Check if any worker is actually alive and ready const hasAliveWorker = this.workers.some(w => !w._failed && this._workerReady[w._sandboxId] ); const useInline = this._fallbackMode || this.workers.length === 0 || !hasAliveWorker; if (useInline) { return this._runInline(task, capabilities); } const worker = this._getIdleWorker(); if (!worker) { // Queue it return new Promise((resolve) => { this.queue.push({ task, capabilities, resolve }); this.terminal.print(`⏳ Task queued (${this.queue.length} waiting)`, 'info'); this._drainQueue(); }); } return this._executeOnWorker(worker, task, capabilities); } async _executeOnWorker(worker, task, capabilities) { const taskId = `task-${++this._nextTaskId}`; worker._busy = true; const startTime = performance.now(); return new Promise((resolve) => { const handler = (e) => { if (e.data && e.data.taskId === taskId) { worker.removeEventListener('message', handler); worker._busy = false; const duration = ((performance.now() - startTime) / 1000).toFixed(2); this._totalRuns++; this._totalDuration += parseFloat(duration); if (e.data.error) { this._totalErrors++; this.terminal.print(`❌ Sandbox task failed: ${e.data.error}`, 'error'); resolve({ error: e.data.error, duration }); } else { this.terminal.print(`✅ Sandbox task complete (${duration}s, ${e.data.toolCalls || 0} tools)`, 'success'); resolve({ result: e.data.result, duration, toolCalls: e.data.toolCalls || 0 }); } this._drainQueue(); } }; worker.addEventListener('message', handler); // Get VFS snapshot from terminal let vfsSnapshot = null; try { if (this.terminal && this.terminal.fs && this.terminal.fs.root) { vfsSnapshot = JSON.stringify({ root: this.terminal.fs.root, currentPath: this.terminal.fs.currentPath || [] }); } } catch (e) { console.warn('[Sandbox] Could not serialize VFS:', e.message); } const caps = capabilities || ['list_files', 'read_file']; const allowedCommands = ['ls', 'cat', 'echo', 'find', 'grep', 'help', 'status', 'touch', 'mkdir', 'rm', 'cp', 'mv']; worker.postMessage({ taskId, task, capabilities: caps, vfsSnapshot, allowedCommands, apiProxy: window.location.origin + '/api' }); // Timeout after 30 seconds setTimeout(() => { if (worker._busy) { worker._busy = false; this._totalErrors++; this.terminal.print(`⏰ Sandbox task ${taskId} timed out after 30s`, 'error'); resolve({ error: 'Timeout (30s)', duration: 30 }); this._drainQueue(); } }, 30000); }); } _handleResult(data, workerId) { // Handled via promise in _executeOnWorker // This is the fallback handler for tasks not tracked by promise if (data.error) { this.terminal.print(`[Sandbox ${workerId}] Error: ${data.error}`, 'error'); } } _drainQueue() { if (this.queue.length === 0) return; const next = this.queue.shift(); this.runInSandbox(next.task, next.capabilities).then(result => { next.resolve(result); }); } _runInline(task, capabilities) { // Fallback when WebWorkers unavailable — runs in main thread const startTime = performance.now(); const parts = task.trim().split(/\s+/); const cmd = parts[0].toLowerCase(); let result = ''; let toolCalls = 0; const fs = this.terminal ? this.terminal.fs : null; if (cmd === 'ls' && fs) { const path = parts[1] || '/'; const entries = fs.ls(path); if (!entries) return this.terminal.print(`ls: ${path}: No such directory`, 'error'); result = entries.map(e => `${e.type === 'dir' ? '[dir]' : '[file]'} ${e.name}`).join('\\n'); toolCalls++; } else if ((cmd === 'cat' || cmd === 'read') && fs) { const path = parts[1] || ''; const content = fs.readFile(path); if (content === null) return this.terminal.print(`cat: ${path}: No such file`, 'error'); result = typeof content === 'string' ? content.substring(0, 8000) : String(content); toolCalls++; } else if (cmd === 'find' && fs) { const q = parts.slice(1).join(' ') || ''; // Walk VFS const results = []; const walk = (p) => { const entries = fs.ls(p); if (!entries) return; for (const e of entries) { const full = p === '/' ? '/' + e.name : p + '/' + e.name; if (e.type === 'file' && (!q || full.toLowerCase().includes(q.toLowerCase()))) { results.push(full); } if (e.type === 'dir') walk(full); } }; walk('/'); result = results.join('\\n') || '(empty)'; toolCalls++; } else if (cmd === 'status') { let f = 0, d = 0; const walk = (p) => { const entries = fs ? fs.ls(p) : []; if (!entries) return; for (const e of entries) { if (e.type === 'file') f++; else { d++; walk(p === '/' ? '/' + e.name : p + '/' + e.name); } } }; walk('/'); result = `Files: ${f}, Dirs: ${d}`; toolCalls++; } else { result = `Task: "${task}" (inline fallback)`; } const duration = ((performance.now() - startTime) / 1000).toFixed(2); this._totalRuns++; this._totalDuration += parseFloat(duration); this.terminal.print(result, 'info'); return { result, duration, toolCalls }; } // ─── Config ─── setConfig(args) { if (args.length < 2) return this.terminal.print('Usage: sandbox-config <key> <value>', 'error'); const key = args[0]; const val = args[1]; const configMap = { 'max-workers': () => { const n = parseInt(val); if (isNaN(n) || n < 1 || n > 8) return 'Must be 1-8'; this.MAX_WORKERS = n; // Scale down if needed while (this.workers.length > n) { const w = this.workers.pop(); w.terminate(); } // Scale up for (let i = this.workers.length; i < Math.min(n, this.MIN_WORKERS); i++) { this._spawnWorker(); } return `max-workers set to ${n}`; }, 'min-workers': () => { const n = parseInt(val); if (isNaN(n) || n < 0 || n > this.MAX_WORKERS) return `Must be 0-${this.MAX_WORKERS}`; this.MIN_WORKERS = n; while (this.workers.length < n) this._spawnWorker(); return `min-workers set to ${n}`; }, }; if (!configMap[key]) { return this.terminal.print(`Unknown: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); } const msg = configMap[key](); this.terminal.print(`✅ ${msg}`, 'success'); } // ─── Status ─── showStatus() { const busy = this.workers.filter(w => w._busy).length; const idle = this.workers.length - busy; const avgDuration = this._totalRuns > 0 ? (this._totalDuration / this._totalRuns).toFixed(2) : 'N/A'; this.terminal.print('🧪 Sandbox Pool', 'header'); this.terminal.print(` Workers: ${this.workers.length}/${this.MAX_WORKERS} (${busy} busy, ${idle} idle)`, 'info'); this.terminal.print(` Mode: ${this._fallbackMode ? '⚠️ Inline fallback' : '✅ WebWorker'}`, 'info'); this.terminal.print(` Queue: ${this.queue.length} pending`, 'info'); this.terminal.print(` Total runs: ${this._totalRuns} (errors: ${this._totalErrors})`, 'info'); this.terminal.print(` Avg duration: ${avgDuration}s`, 'info'); } } // Required for module system if (typeof window !== 'undefined') { window.SandboxModule = SandboxModule; } /* === module-memory-graph.js === */ // module-memory-graph.js — Cross-session persistent memory graph // Nodes: files, concepts, tasks, patterns, users // Edges: relationships with weight, last_used // Commands: memgraph-status, memgraph-search, memgraph-stats, memgraph-export, memgraph-prune class MemoryGraphModule { constructor() { this.commands = {}; this.terminal = null; this._nodes = {}; this._edges = []; this._indexPath = '/.memory_graph/'; this._debounceTimer = null; this._initialized = false; this._totalNodeAccesses = 0; // v4.5.89: Decay configuration this._decayDays = 30; this._decayMinUses = 3; this._decayRate = 0.01; // Daily decay rate this._lastDecayRun = 0; } getHelpInfo() { return { 'memgraph-status': 'memgraph-status — Show node/edge counts, last access', 'memgraph-search': 'memgraph-search <query> — Find nodes by label or type', 'memgraph-stats': 'memgraph-stats — Most connected nodes, strongest edges, cluster info', 'memgraph-export': 'memgraph-export — Export graph as JSON', 'memgraph-prune': 'memgraph-prune — Remove stale low-weight edges (>30d, <3 accesses)', 'memgraph-learn': 'memgraph-learn <entity> <fact> — Add a fact to the memory graph', 'memgraph-session': 'memgraph-session [date] — Show graph nodes for a session (default: today)', 'memgraph-agent': 'memgraph-agent <agentId> — Show namespaced nodes for an agent', 'memgraph-decay-config': 'memgraph-decay-config <days> <min_uses> — Configure decay policy (default: 30d, 3 uses)', }; } registerCommands() { this.commands['memgraph-status'] = () => this.showStatus(); this.commands['memgraph-search'] = (args) => this.search(args.join(' ')); this.commands['memgraph-stats'] = () => this.showStats(); this.commands['memgraph-export'] = () => this.exportGraph(); this.commands['memgraph-prune'] = () => this.prune(); this.commands['memgraph-learn'] = (args) => this.learn(args); this.commands['memgraph-session'] = (args) => this.showSession(args.join(' ')); this.commands['memgraph-agent'] = (args) => this.showAgent(args.join(' ')); // v4.5.89: Decay configuration this.commands['memgraph-decay-config'] = (args) => this.decayConfig(args); } init(terminal) { this.terminal = terminal; this._loadFromVFS(); this._initialized = true; this.terminal.print('🧠 Memory Graph loaded — cross-session context active', 'info'); // Log startup for graph // v4.5.89 fix: Use day-floored key to match edge source keys (P0-1 from review) this.addNode('session', `session-${Math.floor(Date.now() / 86400000)}`, `Session ${new Date().toLocaleDateString()}`); } // ─── Node API ─── addNode(type, id, label, metadata = {}, agentId = null) { // v4.5.88: Support agent namespacing with dual-lookup const ns = agentId ? `agent:${agentId}` : 'global'; const key = agentId ? `${ns}:${type}:${id}` : `${type}:${id}`; if (!this._nodes[key]) { this._nodes[key] = { id: key, type, label: label || id, metadata: metadata || {}, created: new Date().toISOString(), updated: new Date().toISOString(), access_count: 1, namespace: ns, source_session: `session-${Math.floor(Date.now() / 86400000)}` }; } else { this._nodes[key].updated = new Date().toISOString(); this._nodes[key].access_count++; if (metadata && Object.keys(metadata).length > 0) { Object.assign(this._nodes[key].metadata, metadata); } } this._totalNodeAccesses++; this._debounceSave(); return key; } // v4.5.88: Dual-lookup — try namespaced key first, fall back to global getNode(type, id, agentId = null) { if (agentId) { const nsKey = `agent:${agentId}:${type}:${id}`; if (this._nodes[nsKey]) return this._nodes[nsKey]; } const globalKey = `${type}:${id}`; return this._nodes[globalKey] || null; } findNodes(query) { const q = query.toLowerCase(); return Object.values(this._nodes).filter(n => n.label.toLowerCase().includes(q) || n.type.toLowerCase().includes(q) || (n.id && n.id.toLowerCase().includes(q)) ); } // ─── Edge API ─── // v4.5.88: addEdge tracks source_agents for multi-agent attribution addEdge(sourceKey, targetKey, type, weight = 1, agentId = null) { // Check if edge already exists let edge = this._edges.find(e => e.source === sourceKey && e.target === targetKey && e.type === type ); if (edge) { edge.weight = (edge.weight + weight) / 2; edge.last_used = new Date().toISOString(); edge.use_count++; if (agentId) edge.source_agents = [...new Set([...(edge.source_agents || []), agentId])]; } else { this._edges.push({ source: sourceKey, target: targetKey, type, weight, created: new Date().toISOString(), last_used: new Date().toISOString(), use_count: 1, source_agents: agentId ? [agentId] : [] }); } this._debounceSave(); } // ─── Event-based Learning ─── // v4.5.89 fix: Thread agentId through record* methods (P1-3 from review) recordFileAccess(path, agentId = null) { const name = path.split('/').pop() || path; const nodeKey = this.addNode('file', path, name, {}, agentId); const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; this.addEdge(sessionKey, nodeKey, 'accessed', 0.5, agentId); // Extract file extension edge const ext = path.split('.').pop(); if (ext && ext.length < 10) { this.addNode('type', ext, `.${ext} files`); this.addEdge(nodeKey, `type:${ext}`, 'has_type', 0.3); } } recordToolCall(toolName, success, agentId = null) { const nodeKey = this.addNode('tool', toolName, toolName, {}, agentId); const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; this.addEdge(sessionKey, nodeKey, success ? 'used' : 'failed', success ? 0.5 : 0.8, agentId); } recordConcept(concept, filePath) { const conceptKey = this.addNode('concept', concept, concept); const fileKey = this.addNode('file', filePath, filePath.split('/').pop()); this.addEdge(conceptKey, fileKey, 'maps_to', 0.7); this.addEdge(fileKey, conceptKey, 'tagged_as', 0.7); } // v4.5.88: recordTask now passes agentId for namespacing + adds source_session link recordTask(task, result, agentId) { const taskId = `task-${Date.now()}`; const taskKey = this.addNode('task', taskId, task.substring(0, 60), { full: task, result: result?.substring(0, 100), agent: agentId }, agentId); const sessionKey = `session:session-${Math.floor(Date.now() / 86400000)}`; this.addEdge(sessionKey, taskKey, 'executed', 0.6, agentId); // Extract keywords as concepts const words = task.toLowerCase().split(/\s+/).filter(w => w.length > 3); for (const word of [...new Set(words)].slice(0, 5)) { this.addNode('concept', word, word); this.addEdge(taskKey, `concept:${word}`, 'involves', 0.4); } // Link to tools used (if any found in task) const knownTools = ['list_files', 'read_file', 'write_file', 'web_search', 'web_fetch', 'edit_module', 'view_module', 'run_command', 'orchestrate']; for (const tool of knownTools) { if (task.toLowerCase().includes(tool.replace('_', ' '))) { this.recordToolCall(tool, true); } } } // ─── Context Injection ─── getContextSummary() { // Get top active nodes from recent session const now = Date.now(); const today = `session:session-${Math.floor(now / 86400000)}`; const yesterday = `session:session-${Math.floor(now / 86400000) - 1}`; const lines = []; const totalNodes = Object.keys(this._nodes).length; const totalEdges = this._edges.length; lines.push(`Memory Graph: ${totalNodes} nodes, ${totalEdges} edges`); // Recent task context const recentTasks = Object.values(this._nodes) .filter(n => n.type === 'task') .sort((a, b) => new Date(b.updated) - new Date(a.updated)) .slice(0, 3); if (recentTasks.length > 0) { lines.push('Recent tasks:'); for (const t of recentTasks) { lines.push(` - ${t.label.substring(0, 50)}`); } } // Most accessed files const topFiles = Object.values(this._nodes) .filter(n => n.type === 'file') .sort((a, b) => b.access_count - a.access_count) .slice(0, 3); if (topFiles.length > 0) { lines.push('Most accessed files:'); for (const f of topFiles) { lines.push(` - ${f.label} (${f.access_count}x)`); } } return lines.join('\n'); } getContextForPrompt() { const summary = this.getContextSummary(); if (!summary) return ''; // Suppress noise: don't inject when graph is nearly empty const totalNodes = Object.keys(this._nodes).length; const totalEdges = this._edges.length; if (totalNodes <= 2 && totalEdges === 0) return ''; return `[Memory Graph Context]\n${summary}\n`; } // Alias for orchestrator compatibility (orchestrator calls getStats, not showStats) getStats() { const types = {}; for (const n of Object.values(this._nodes)) { types[n.type] = (types[n.type] || 0) + 1; } return { nodes: Object.keys(this._nodes).length, edges: this._edges.length, types: types, totalAccesses: this._totalNodeAccesses }; } // ─── Commands ─── showStatus() { // v4.5.89: Lazy decay — run applyDecay() on status check this.applyDecay(); const types = {}; for (const n of Object.values(this._nodes)) { types[n.type] = (types[n.type] || 0) + 1; } const typeStr = Object.entries(types) .sort((a, b) => b[1] - a[1]) .map(([t, c]) => `${t}: ${c}`) .join(', '); this.terminal.print('🧠 Memory Graph', 'header'); this.terminal.print(` Nodes: ${Object.keys(this._nodes).length} (${typeStr})`, 'info'); this.terminal.print(` Edges: ${this._edges.length}`, 'info'); this.terminal.print(` Total accesses: ${this._totalNodeAccesses}`, 'info'); this.terminal.print(` Path: ${this._indexPath}`, 'info'); // v4.5.89: Show decay config this.terminal.print(` Decay: ${this._decayDays}d threshold, ${this._decayMinUses} min uses, rate ${this._decayRate}/day`, 'text-muted'); } search(query) { if (!query) return this.terminal.print('Usage: memgraph-search <query>', 'error'); const results = this.findNodes(query); if (results.length === 0) return this.terminal.print(`No nodes matching "${query}"`, 'info'); this.terminal.print(`🔍 Found ${results.length} nodes:`, 'header'); for (const n of results.slice(0, 20)) { const edges = this._edges.filter(e => e.source === n.id || e.target === n.id); this.terminal.print(` ${n.type}:${n.label} (${edges.length} edges, ${n.access_count}x)`, 'info'); for (const e of edges.slice(0, 3)) { const other = e.source === n.id ? e.target : e.source; const otherLabel = (this._nodes[other] || {}).label || other; this.terminal.print(` └─ ${e.type} → ${otherLabel} (w:${e.weight.toFixed(1)})`, 'text-muted'); } } if (results.length > 20) this.terminal.print(` ... and ${results.length - 20} more`, 'info'); } showStats() { if (Object.keys(this._nodes).length === 0) { return this.terminal.print('No graph data yet. Use commands normally to build it.', 'info'); } // Most connected nodes const connected = {}; for (const e of this._edges) { connected[e.source] = (connected[e.source] || 0) + 1; connected[e.target] = (connected[e.target] || 0) + 1; } const top = Object.entries(connected) .sort((a, b) => b[1] - a[1]) .slice(0, 5); this.terminal.print('📊 Memory Graph Stats', 'header'); this.terminal.print(' Most connected:', 'info'); for (const [key, count] of top) { const label = (this._nodes[key] || {}).label || key; this.terminal.print(` ${label} — ${count} connections`, 'info'); } // Strongest edges const strong = [...this._edges].sort((a, b) => b.weight - a.weight).slice(0, 3); if (strong.length > 0) { this.terminal.print(' Strongest relationships:', 'info'); for (const e of strong) { const sLabel = (this._nodes[e.source] || {}).label || e.source; const tLabel = (this._nodes[e.target] || {}).label || e.target; this.terminal.print(` ${sLabel} → ${tLabel} (${e.type}, w:${e.weight.toFixed(2)}, used ${e.use_count}x)`, 'info'); } } // Cluster density const density = this._edges.length / Math.max(Object.keys(this._nodes).length, 1); this.terminal.print(` Density: ${density.toFixed(2)} edges/node`, 'info'); } exportGraph() { const exportData = { version: 1, exported: new Date().toISOString(), nodes: Object.values(this._nodes), edges: this._edges, stats: { totalNodes: Object.keys(this._nodes).length, totalEdges: this._edges.length, totalAccesses: this._totalNodeAccesses } }; const json = JSON.stringify(exportData, null, 2); // Save to VFS try { if (!this.terminal.fs.readDir(this._indexPath)) { this.terminal.fs.mkdirRecursive(this._indexPath); } this.terminal.fs.writeFile(`${this._indexPath}export.json`, json); this.terminal.print(`✅ Graph exported (${json.length} bytes) to ${this._indexPath}export.json`, 'success'); } catch (e) { this.terminal.print(`❌ Export failed: ${e.message}`, 'error'); } } prune() { const now = Date.now(); const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; const before = this._edges.length; this._edges = this._edges.filter(e => { const age = now - new Date(e.last_used).getTime(); // Keep if: used recently (<30d) OR used 3+ times regardless of age if (age < THIRTY_DAYS) return true; if (e.use_count >= 3) return true; // Or if weight is high if (e.weight > 0.8) return true; return false; }); const pruned = before - this._edges.length; // Also prune orphan nodes (no edges) const connectedNodes = new Set(); for (const e of this._edges) { connectedNodes.add(e.source); connectedNodes.add(e.target); } const nodeBefore = Object.keys(this._nodes).length; for (const key of Object.keys(this._nodes)) { if (!connectedNodes.has(key)) { // Keep session and high-access nodes if (this._nodes[key].access_count >= 5) continue; delete this._nodes[key]; } } const nodesPruned = nodeBefore - Object.keys(this._nodes).length; this._saveNow(); this.terminal.print(`🧹 Pruned ${pruned} edges and ${nodesPruned} orphan nodes`, nodesPruned > 0 ? 'warning' : 'info'); } // v4.5.89: Apply decay scores to nodes based on age and access patterns applyDecay() { const now = Date.now(); if (now - this._lastDecayRun < 3600000) return; this._lastDecayRun = now; // v4.5.89b fix: Compute decay rate from _decayDays (P0 from review) const effectiveRate = 0.9 / this._decayDays; let decayed = 0; let removed = 0; for (const key of Object.keys(this._nodes)) { const node = this._nodes[key]; const ageDays = (now - new Date(node.updated || node.created).getTime()) / 86400000; node.decay_score = Math.max(0, 1.0 - (ageDays * effectiveRate)); if (node.decay_score < 0.1 && node.access_count < this._decayMinUses) { if (node.type === 'session') continue; if (node.namespace && node.namespace.startsWith('agent:')) continue; delete this._nodes[key]; removed++; continue; } decayed++; } this._edges = this._edges.filter(e => { const ageDays = (now - new Date(e.last_used).getTime()) / 86400000; e.decay_score = Math.max(0, 1.0 - (ageDays * effectiveRate)); return !(e.decay_score < 0.1 && (e.use_count || 1) < this._decayMinUses); }); if (removed > 0) { this._debounceSave(); console.log('[MemoryGraph] Decay: ' + decayed + ' scored, ' + removed + ' auto-removed'); } // v4.5.90: Check for stale skills var staleSkills = this.getStaleSkills(); if (staleSkills.length > 0) { this.terminal.print('🧠 Stale skills: ' + staleSkills.length + ' (' + staleSkills.map(function(s) { return s.name; }).join(', ') + ')', 'warning'); } } // v4.5.90: Record skill usage for decay tracking recordSkillUse(name, success) { try { const fs = this.terminal.fs; if (!fs) return; const index = fs.fsReadJSON('/skills/index.json') || {}; if (!index[name]) index[name] = { useCount: 0, successCount: 0 }; index[name].lastUsed = new Date().toISOString(); index[name].useCount = (index[name].useCount || 0) + 1; if (success !== false) { index[name].successCount = (index[name].successCount || 0) + 1; } fs.fsWriteJSON('/skills/index.json', index); } catch (e) { console.warn('[MemoryGraph] Skill use tracking error:', e.message); } } // v4.5.90: Get stale skills (unused >_decayDays, useCount == 0, not immune) getStaleSkills() { try { const fs = this.terminal.fs; if (!fs) return []; const index = fs.fsReadJSON('/skills/index.json') || {}; const now = Date.now(); // v4.5.90b fix: Use configurable _decayDays (P1 from review) const staleThreshold = this._decayDays * 24 * 60 * 60 * 1000; var stale = []; var untracked = []; for (var name of Object.keys(index)) { var stats = index[name] || {}; if (stats.immune) continue; // A skill with no usage telemetry has never been measured. Calling it stale is how // the audit came to recommend archiving the system's entire procedural memory. if (stats.lastUsed === undefined && stats.useCount === undefined) { untracked.push(name); continue; } var useCount = stats.useCount || 0; var lastUsed = stats.lastUsed ? new Date(stats.lastUsed).getTime() : 0; if (useCount === 0 && (now - lastUsed > staleThreshold || lastUsed === 0)) { // v4.6.85: never fabricate a measurement. A skill with useCount 0 and no // lastUsed has NEVER been used — reporting it as "999d unused" invented a // number that then appeared in the audit as if it had been observed. var daysUnused = lastUsed > 0 ? Math.floor((now - lastUsed) / 86400000) : null; stale.push({ name: name, daysUnused: daysUnused, neverUsed: lastUsed === 0 }); } } this._untrackedSkills = untracked; return stale; } catch (e) { return []; } } getUntrackedSkills() { return Array.isArray(this._untrackedSkills) ? this._untrackedSkills : []; } // v4.5.89: Configure decay policy decayConfig(args) { if (args.length < 2) { this.terminal.print('Usage: memgraph-decay-config <days> <min_uses>', 'error'); this.terminal.print(' Current: ' + this._decayDays + 'd threshold, ' + this._decayMinUses + ' min uses', 'info'); this.terminal.print(' Decay rate: ' + this._decayRate + '/day (0.01 = 1% per day)', 'info'); return; } const days = parseInt(args[0]); const minUses = parseInt(args[1]); if (isNaN(days) || days < 1) return this.terminal.print('Days must be >= 1', 'error'); if (isNaN(minUses) || minUses < 0) return this.terminal.print('Min uses must be >= 0', 'error'); this._decayDays = days; this._decayMinUses = minUses; this._debounceSave(); this.terminal.print('✅ Decay config: ' + days + 'd threshold, ' + minUses + ' min uses', 'success'); } learn(args) { if (!args || args.length < 2) return this.terminal.print('Usage: memgraph-learn <entity> <fact>', 'error'); const entity = args[0]; const fact = args.slice(1).join(' '); this.addNode('concept', entity, entity); const factKey = this.addNode('fact', entity + '-' + Date.now(), fact); const conceptKey = `concept:${entity}`; this.addEdge(conceptKey, factKey, 'has_fact', 0.9); this.terminal.print(`✅ Learned: ${entity} → ${fact}`, 'success'); // v4.5.89b fix: Index only the new fact, not all facts (P2 from review — O(n) → O(1)) const nvfs = this.terminal.moduleManager.modules['NeuralVFSModule']; if (nvfs && typeof nvfs.indexGraphFacts === 'function') { nvfs.indexSingleFact(factKey, entity, fact); } } // v4.5.88: Show graph nodes for a specific session showSession(dateStr) { let sessionKey; if (dateStr) { const d = new Date(dateStr); if (isNaN(d.getTime())) return this.terminal.print('Usage: memgraph-session [YYYY-MM-DD]', 'error'); sessionKey = `session-${Math.floor(d.getTime() / 86400000)}`; } else { sessionKey = `session-${Math.floor(Date.now() / 86400000)}`; } const sessionNodes = Object.values(this._nodes).filter(function(n) { return n.source_session === sessionKey || n.type === 'session' && n.id.includes(sessionKey); }); this.terminal.print('📅 Session Graph: ' + sessionKey, 'header'); if (sessionNodes.length === 0) { this.terminal.print(' No nodes found for this session', 'info'); return; } this.terminal.print(' ' + sessionNodes.length + ' nodes:', 'info'); for (var n of sessionNodes.slice(0, 15)) { var ns = n.namespace || 'global'; this.terminal.print(' ' + n.type + ':' + n.label + ' (' + ns + ', ' + n.access_count + 'x)', 'info'); } var sessionEdges = this._edges.filter(function(e) { return sessionNodes.some(function(n) { return n.id === e.source || n.id === e.target; }); }); this.terminal.print(' ' + sessionEdges.length + ' edges', 'info'); } // v4.5.88: Show namespaced nodes for a specific agent showAgent(agentId) { if (!agentId) return this.terminal.print('Usage: memgraph-agent <agentId>', 'error'); var ns = 'agent:' + agentId; var agentNodes = Object.values(this._nodes).filter(function(n) { return n.namespace === ns; }); var agentEdges = this._edges.filter(function(e) { return (e.source_agents || []).includes(agentId); }); this.terminal.print('🤖 Agent Memory: ' + agentId, 'header'); if (agentNodes.length === 0) { this.terminal.print(' No namespaced nodes for this agent', 'info'); this.terminal.print(' (Agent may have used global namespace)', 'text-muted'); return; } this.terminal.print(' ' + agentNodes.length + ' nodes, ' + agentEdges.length + ' edges:', 'info'); for (var n of agentNodes.slice(0, 15)) { this.terminal.print(' ' + n.type + ':' + n.label + ' (' + n.access_count + 'x)', 'info'); } } // ─── Persistence ─── _loadFromVFS() { try { const fs = this.terminal.fs; if (!fs) return; // MAINT-003: a corrupt store used to look exactly like a fresh one — fsReadJSON // returns null on a parse failure, so the graph started empty with no warning and no // copy of what was there. Quarantine anything unreadable first, then load what's left. this._quarantineCorrupt('export.json'); this._quarantineCorrupt('nodes.json'); this._quarantineCorrupt('edges.json'); // Try loading from export first const exportData = fs.fsReadJSON(`${this._indexPath}export.json`); if (exportData && exportData.nodes) { for (const n of exportData.nodes) { // v4.5.89 fix: Migrate old-format nodes (P1-1 from review) if (!n.namespace) n.namespace = 'global'; if (!n.source_session) { const createdMs = new Date(n.created).getTime(); n.source_session = isNaN(createdMs) ? null : `session-${Math.floor(createdMs / 86400000)}`; } this._nodes[n.id] = n; } this._edges = exportData.edges || []; for (const e of this._edges) { if (!e.source_agents) e.source_agents = []; } this._totalNodeAccesses = (exportData.stats?.totalAccesses) || 0; return; } // Fallback: load nodes and edges separately const nodes = fs.fsReadJSON(`${this._indexPath}nodes.json`); if (nodes) { this._nodes = nodes; for (const n of Object.values(this._nodes)) { if (!n.namespace) n.namespace = 'global'; if (!n.source_session) { const createdMs = new Date(n.created).getTime(); n.source_session = isNaN(createdMs) ? null : `session-${Math.floor(createdMs / 86400000)}`; } } } const edges = fs.fsReadJSON(`${this._indexPath}edges.json`); if (edges) { this._edges = edges; for (const e of this._edges) { if (!e.source_agents) e.source_agents = []; } } // v4.5.89b fix: Load persisted decay config (P1 from review) const config = fs.fsReadJSON(`${this._indexPath}config.json`); if (config) { if (typeof config.decayDays === 'number') this._decayDays = config.decayDays; if (typeof config.decayMinUses === 'number') this._decayMinUses = config.decayMinUses; if (typeof config.lastDecayRun === 'number') this._lastDecayRun = config.lastDecayRun; } } catch (e) { console.warn('[MemoryGraph] Load error (fresh start):', e.message); } } // MAINT-003: keep an unreadable store aside instead of starting fresh over it. _quarantineCorrupt(name) { try { const fs = this.terminal.fs; const p = `${this._indexPath}${name}`; const raw = fs.readFile(p); if (typeof raw !== 'string' || !raw.length) return false; try { JSON.parse(raw); return false; } catch (e) { const kept = p + '.corrupt'; fs.writeFile(kept, raw); this.terminal.print(`⚠️ Memory graph store ${name} is unreadable — kept a copy at ${kept} and starting from what remains`, 'warning'); if (typeof fs.deleteFile === 'function') fs.deleteFile(p); return true; } } catch (e) { return false; } } _saveNow() { try { const fs = this.terminal.fs; if (!fs) return; if (!fs.readDir(this._indexPath)) { fs.mkdirRecursive(this._indexPath); } fs.fsWriteJSON(`${this._indexPath}nodes.json`, this._nodes); fs.fsWriteJSON(`${this._indexPath}edges.json`, this._edges); // v4.5.89b fix: Persist decay config (P1 from review) fs.fsWriteJSON(`${this._indexPath}config.json`, { decayDays: this._decayDays, decayMinUses: this._decayMinUses, lastDecayRun: this._lastDecayRun }); } catch (e) { console.error('[MemoryGraph] Save error:', e.message); } } _debounceSave() { if (this._debounceTimer) clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(() => this._saveNow(), 2000); } } // Required for module system if (typeof window !== 'undefined') { window.MemoryGraphModule = MemoryGraphModule; } /* === module-predictor.js === */ // module-predictor.js — Predictive resource allocation engine // v2 (2026-10-23): accuracy is measured ONLY over predictions that were paired with a // real outcome. v1 counted every observed run in the denominator while incrementing the // numerator only when an old `predict` record happened to match, so the dashboard read 0%. // Commands: predict, predictor-status, predictor-config class PredictorModule { constructor() { this.commands = {}; this.terminal = null; this._dataPoints = []; // observed runs — the training set for _findSimilar this._pending = []; // predictions waiting for an outcome (bounded, TTL'd) this._predictions = 0; this._accuracy = { correct: 0, total: 0 }; // total = paired comparisons only this._errorSum = 0; // SUM of raw |predicted-observed|/observed (mean = /paired) this._turnsErr = 0; // sum of |predicted turns - observed turns| this._CONFIDENCE_THRESHOLD = 0.5; this._MIN_DATA_POINTS = 5; this._PAIR_TTL_MS = 15 * 60 * 1000; this._SCHEMA = 2; this._loaded = false; } getHelpInfo() { return { 'predict': 'predict <task description> — Predict task duration, tool turns, and confidence', 'predictor-status': 'predictor-status — Paired accuracy, pending predictions, data points', 'predictor-config': 'predictor-config <key> <value> — Tune: confidence-threshold, min-data-points, pair-ttl', }; } registerCommands() { this.commands['predict'] = (args) => this.predict(args.join(' ')); this.commands['predictor-status'] = () => this.showStatus(); this.commands['predictor-config'] = (args) => this.setConfig(args); } init(terminal) { this.terminal = terminal; this._loadFromVFS(); this._storeMode = 'cache'; // read-through after boot: a stale cache revalidates against the canonical file setTimeout(() => { this._pullFromServer().catch(function () {}); }, 1500); this._loaded = true; this.terminal.print('📊 Predictor loaded — analyzing task patterns', 'info'); } // ─── Prediction Engine ─── async predict(task) { if (!task) return this.terminal.print('Usage: predict <task description>', 'error'); const features = this._extractFeatures(task); const similar = this._findSimilar(features); const prediction = this._computePrediction(similar, features); // Register it as pending so the next matching run can be scored against it. const pending = this.notePrediction(task, 'cli'); pending.similarCount = similar.length; this.terminal.print('📈 Task Prediction', 'header'); this.terminal.print(` Task: ${task.substring(0, 50)}${task.length > 50 ? '...' : ''}`, 'info'); if (prediction.confidence >= this._CONFIDENCE_THRESHOLD) { this.terminal.print(` ⏱ Estimated duration: ${prediction.duration}s`, 'info'); this.terminal.print(` 🔧 Estimated tool turns: ${prediction.toolTurns}`, 'info'); this.terminal.print(` 📊 Confidence: ${(prediction.confidence * 100).toFixed(0)}% (based on ${similar.length} similar tasks)`, 'success'); } else if (similar.length > 0) { this.terminal.print(` ⏱ Estimated duration: ${prediction.duration}s (low confidence)`, 'warning'); this.terminal.print(` 🔧 Tool turns: ~${prediction.toolTurns}`, 'warning'); this.terminal.print(` 📊 Confidence: ${(prediction.confidence * 100).toFixed(0)}% — only ${similar.length} similar data point(s)`, 'warning'); this.terminal.print(' Tip: More task data will improve predictions.', 'info'); } else { this.terminal.print(' ❓ No historical data to base prediction on.', 'warning'); this.terminal.print(' Tip: Run `orchestrate` with various tasks to build a baseline.', 'info'); this.terminal.print(' Default estimate: ~30s duration, ~5 tool turns', 'info'); } if (prediction.confidence >= this._CONFIDENCE_THRESHOLD && prediction.duration > 30) { this.terminal.print(` 💡 Recommendation: Set TTL to ${Math.ceil(prediction.duration * 1.5)}s for predictable completion`, 'info'); } this.terminal.print(` 🎯 Tracked as ${pending.id} — the next matching run scores it`, 'info'); return prediction; } _extractFeatures(task) { const lower = String(task || '').toLowerCase(); return { length: String(task || '').length, wordCount: String(task || '').split(/\s+/).length, hasSearch: /\b(search|find|look)\b/.test(lower), hasWrite: /\b(write|create|save|edit)\b/.test(lower), hasDelete: /\b(delete|remove|rm)\b/.test(lower), hasInstall: /\b(install|wpm|package)\b/.test(lower), hasRead: /\b(read|cat|view|list)\b/.test(lower), hasWeb: /\b(fetch|url|http|web)\b/.test(lower), hasOrchestrate: /\b(orchestrat|delegate|agent)\b/.test(lower), hasModule: /\b(module|edit_module|view_module)\b/.test(lower), hasSync: /\b(sync|recall|remember|previous session|state sync)\b/.test(lower), complexity: Math.min(String(task || '').length / 50, 10), // 0-10 scale keywords: [...new Set(lower.split(/\s+/).filter(w => w.length > 3))].slice(0, 10) }; } // Same scoring _findSimilar uses, exposed so pairing can reuse it (v2). _similarity(a, b) { if (!a || !b) return 0; const keys = ['hasSearch', 'hasWrite', 'hasDelete', 'hasInstall', 'hasRead', 'hasWeb', 'hasOrchestrate', 'hasModule', 'hasSync']; const matches = keys.filter(k => a[k] === b[k]).length / keys.length; const kw = a.keywords || []; const overlap = kw.filter(k => (b.keywords || []).includes(k)).length; const kScore = overlap / Math.max(kw.length, 1); return matches * 0.6 + kScore * 0.4; } _findSimilar(features) { return this._dataPoints.filter(dp => { if (!dp.observedDuration) return false; // outcomes only, never predictions if (dp.usable === false) return false; // aborted runs are not evidence if (!(dp.observedDuration >= 1.5)) return false; // belt and braces: never evidence if (dp.features) return this._similarity(features, dp.features) > 0.3; return false; }); } _computePrediction(similar, features) { if (similar.length === 0) { let baseDuration = 15; let baseTurns = 3; if (features.hasSearch || features.hasWeb) { baseDuration = 25; baseTurns = 5; } if (features.hasWrite) { baseDuration = 20; baseTurns = 4; } if (features.hasOrchestrate) { baseDuration = 40; baseTurns = 8; } if (features.hasSync) { baseDuration = 30; baseTurns = 6; } if (features.complexity > 5) baseDuration *= 1.5; return { duration: baseDuration, toolTurns: baseTurns, confidence: 0.1, similarCount: 0, floored: false }; } const avgDuration = similar.reduce((s, dp) => s + (dp.observedDuration || 30), 0) / similar.length; const avgTurns = similar.reduce((s, dp) => s + (dp.observedTurns || 3), 0) / similar.length; // v2: the documented knob now actually scales confidence (v1 hardcoded 20). const minPts = Math.max(1, this._MIN_DATA_POINTS || 5); const confidence = Math.min(similar.length / minPts, 1) * 0.8 + 0.1; const complexityMultiplier = 1 + (features.complexity - 3) * 0.1; const adjustedDuration = avgDuration * Math.max(complexityMultiplier, 0.5); const floored = adjustedDuration < 1; // no prediction is "0 seconds" const duration = Math.max(1, Math.round(adjustedDuration)); return { duration, toolTurns: Math.max(1, Math.round(avgTurns)), confidence: Math.min(confidence, 0.95), similarCount: similar.length, floored }; } // ─── Prediction ↔ Outcome pairing (v2) ─── // Task text never leaves the module: only a stable hash is stored/serialised. _hashTask(t) { const s = String(t || '').trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 200); let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 0x01000193) >>> 0; } return h.toString(16); } // Register a prediction to be scored later. Returns the pending record (with .id). notePrediction(task, source) { const features = this._extractFeatures(task || ''); const p = this._computePrediction(this._findSimilar(features), features); const rec = { id: 'p' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), taskHash: this._hashTask(task), features, predictedDuration: p.duration, predictedTurns: p.toolTurns, confidence: p.confidence, floored: p.floored === true, source: source || 'loop', ts: Date.now() }; this._pending.push(rec); if (this._pending.length > 50) this._pending = this._pending.slice(-50); this._predictions++; this._debounceSave(); return rec; } _takePending(features, opts) { const now = Date.now(); this._pending = this._pending.filter(p => p && p.ts && (now - p.ts) <= this._PAIR_TTL_MS); const taskHash = opts.task ? this._hashTask(opts.task) : null; let idx = -1; if (opts.predictionId) idx = this._pending.findIndex(p => p.id === opts.predictionId); if (idx < 0 && taskHash) { for (let i = this._pending.length - 1; i >= 0; i--) { if (this._pending[i].taskHash === taskHash) { idx = i; break; } } } if (idx < 0) { // Fallback only for a manual `predict` (source 'cli'): a tool/loop prediction must be // matched by id or exact task, never by shape alone (nine equal booleans score 0.6). let best = -1, bestScore = 0; for (let i = 0; i < this._pending.length; i++) { const cand = this._pending[i]; if (cand.source !== 'cli') continue; const shared = (features.keywords || []).filter(k => (cand.features?.keywords || []).includes(k)).length; if (shared === 0) continue; const s = this._similarity(features, cand.features); if (s > bestScore) { bestScore = s; best = i; } } if (bestScore >= 0.5) idx = best; } return idx >= 0 ? this._pending.splice(idx, 1)[0] : null; } // ─── Learning from Results ─── recordResult(features, duration, toolTurns, success, opts) { const o = opts || {}; const pred = this._takePending(features, o); const observed = Number(duration) || 0; const rec = { features, observedDuration: observed, observedTurns: toolTurns, success: !!success, paired: !!pred, timestamp: new Date().toISOString() }; rec.usable = observed >= 1.5; // aborted/instant runs say nothing about duration if (pred) { const absErr = Math.abs(pred.predictedDuration - observed); rec.predictedDuration = pred.predictedDuration; rec.predictedTurns = pred.predictedTurns; // RAW error — the renderer clamps, the store does not (a 475x miss must stay 475x) rec.errorPct = observed > 0 ? absErr / observed : null; rec.turnsError = Math.abs((pred.predictedTurns || 0) - (toolTurns || 0)); rec.scored = rec.usable && rec.errorPct !== null && pred.floored !== true; if (rec.scored) { this._accuracy.total++; this._errorSum += rec.errorPct; this._turnsErr += rec.turnsError; // 30% relative, with a 3 s floor so rounding on short tasks does not flip the verdict if (absErr <= Math.max(0.30 * observed, 3)) this._accuracy.correct++; } } this._dataPoints.push(rec); if (this._dataPoints.length > 500) this._dataPoints = this._dataPoints.slice(-500); if (pred) this._saveNow(); else this._debounceSave(); return rec; } // ─── Commands ─── showStatus() { const actualExecutions = this._dataPoints.filter(dp => dp.observedDuration && dp.usable !== false).length; const paired = this._accuracy.total; const unpaired = this._dataPoints.filter(dp => dp.paired === false).length; const accPct = paired > 0 ? ((this._accuracy.correct / paired) * 100).toFixed(0) + '%' : 'n/a'; const meanErr = paired > 0 ? this._errorSum / paired : 0; const mape = paired > 0 ? ` · mean error ${(Math.min(meanErr, 5) * 100).toFixed(0)}%${meanErr > 5 ? '+' : ''}` + ` · turns ±${(this._turnsErr / paired).toFixed(1)}` : ''; const unscored = this._dataPoints.filter(dp => dp.scored === false).length; this.terminal.print('📊 Predictor Status', 'header'); this.terminal.print(` Total data points: ${this._dataPoints.length}`, 'info'); this.terminal.print(` Actual executions: ${actualExecutions}`, 'info'); this.terminal.print(` Predictions made: ${this._predictions}`, 'info'); this.terminal.print(` Accuracy: ${accPct} (${this._accuracy.correct}/${paired} paired)${mape}`, 'info'); this.terminal.print(` Awaiting outcome: ${this._pending.length}`, 'info'); this.terminal.print(` Unpaired runs: ${unpaired}`, 'info'); this.terminal.print(` Unscored pairs: ${unscored} (aborted run or floored prediction)`, 'info'); this.terminal.print(` Store: ${this._storeMode || 'cache'} — server file canonical, browser copy is a ${Math.round(this._STORE_TTL_MS / 60000)} min read-through cache`, 'info'); this.terminal.print(` Confidence threshold: ${this._CONFIDENCE_THRESHOLD}`, 'info'); this.terminal.print(` Min data points: ${this._MIN_DATA_POINTS}`, 'info'); this.terminal.print(` Pair TTL: ${Math.round(this._PAIR_TTL_MS / 60000)}m`, 'info'); } setConfig(args) { if (args.length < 2) return this.terminal.print('Usage: predictor-config <key> <value>', 'error'); const key = args[0]; const val = args[1]; const configMap = { 'confidence-threshold': () => { const n = parseFloat(val); if (isNaN(n) || n < 0 || n > 1) return 'Must be 0.0-1.0'; this._CONFIDENCE_THRESHOLD = n; return `confidence-threshold set to ${n}`; }, 'min-data-points': () => { const n = parseInt(val); if (isNaN(n) || n < 1) return 'Must be >= 1'; this._MIN_DATA_POINTS = n; return `min-data-points set to ${n}`; }, 'pair-ttl': () => { const n = parseInt(val); if (isNaN(n) || n < 60) return 'Must be >= 60 (seconds)'; this._PAIR_TTL_MS = n * 1000; return `pair-ttl set to ${n}s`; }, }; if (!configMap[key]) return this.terminal.print(`Unknown: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); const msg = configMap[key](); this.terminal.print(`✅ ${msg}`, 'success'); } // ─── Persistence ─── // v4.6.68 (decision 2026-10-23): the SERVER file is canonical, the browser copy is a // TTL'd read-through cache written through on save. Identity is the user's own WebDAV // space — no new endpoint, no new credential, nothing unauthenticated added. _STORE_TTL_MS = 300000; _wdConfig() { try { const raw = localStorage.getItem('webx11_webdav_config'); if (!raw) return null; const c = JSON.parse(raw); if (!c || !c.url || String(c.url).indexOf('/spaces/') === -1) return null; return { base: String(c.url).replace(/\/+$/, ''), user: c.user || c.username || '', pass: c.pass || c.password || '' }; } catch (e) { return null; } } _storeUrl(cfg) { return cfg.base + '/_data/predictor.json'; } // The VFS write fails silently when the parent directory is missing (writeFile does not // create parents), so the cache is written through one guarded path that proves itself. _writeCache(obj) { const fs = this.terminal.fs; if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); fs.fsWriteJSON('/system/metrics/predictor.json', obj); if (!fs.readFile('/system/metrics/predictor.json')) { this._storeMode = 'cache-only (cache write failed)'; if (!this._writeWarned) { this._writeWarned = true; this.terminal.print('📊 Predictor: could not write its cache file — /system/metrics is not writable in this VFS', 'error'); } return false; } return true; } _storeHeaders(cfg) { const h = { 'Content-Type': 'application/json' }; if (cfg.user) h.Authorization = 'Basic ' + btoa(cfg.user + ':' + cfg.pass); return h; } _loadFromVFS() { try { const fs = this.terminal.fs; if (!fs) return; const data = fs.fsReadJSON('/system/metrics/predictor.json'); if (!data) return; // Legacy (pre-v2) points carry no `usable`. Backfill it from the measurement — an // aborted run must not enter similarity as evidence (measured: a 0.079 s point // dragged avgDuration to ~0 and floored every prediction at 1 s). this._dataPoints = (data.dataPoints || []) .filter(dp => dp && dp.observedDuration) .map(dp => { if (typeof dp.usable !== 'boolean') dp.usable = dp.observedDuration >= 1.5; return dp; }) .filter(dp => dp.usable !== false); this._predictions = data.predictions || 0; if (data.v === this._SCHEMA) { const acc = data.accuracy || {}; this._accuracy = { correct: acc.correct || 0, total: acc.total || 0 }; this._errorSum = (typeof data.errorSum === 'number') ? data.errorSum : (data.mape || 0); this._turnsErr = data.turnsErr || 0; const cfg = data.config || {}; if (typeof cfg.confidenceThreshold === 'number') this._CONFIDENCE_THRESHOLD = cfg.confidenceThreshold; if (typeof cfg.minDataPoints === 'number') this._MIN_DATA_POINTS = cfg.minDataPoints; if (typeof cfg.pairTtlMs === 'number') this._PAIR_TTL_MS = cfg.pairTtlMs; const now = Date.now(); this._pending = (data.pending || []).filter(p => p && p.ts && (now - p.ts) <= this._PAIR_TTL_MS); } else { // v1 counted every observation in `total` but only some in `correct` → 0%. this._accuracy = { correct: 0, total: 0 }; this._errorSum = 0; this._pending = []; } } catch (e) { console.warn('[Predictor] Load error (fresh start):', e.message); } } _saveNow() { try { const fs = this.terminal.fs; if (!fs) return; if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics'); fs.fsWriteJSON('/system/metrics/predictor.json', { v: this._SCHEMA, cachedAt: Date.now(), storeMode: 'cache', dataPoints: this._dataPoints.slice(-500), predictions: this._predictions, accuracy: this._accuracy, errorSum: this._errorSum, turnsErr: this._turnsErr, config: { confidenceThreshold: this._CONFIDENCE_THRESHOLD, minDataPoints: this._MIN_DATA_POINTS, pairTtlMs: this._PAIR_TTL_MS }, pending: this._pending.slice(-50) }); this._debouncePush(); } catch (e) { console.error('[Predictor] Save error:', e.message); } } _debouncePush() { clearTimeout(this._pushTimer); this._pushTimer = setTimeout(() => this._pushToServer(), 5000); } async _pushToServer() { const cfg = this._wdConfig(); if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; } try { const body = this.terminal.fs.readFile('/system/metrics/predictor.json') || '{}'; const put = () => fetch(this._storeUrl(cfg), { method: 'PUT', headers: this._storeHeaders(cfg), body: body }); let resp = await put(); // first push into a fresh space: the collection has to exist before the file can if (resp.status === 409 || resp.status === 404) { try { await fetch(cfg.base + '/_data', { method: 'MKCOL', headers: this._storeHeaders(cfg) }); } catch (e) {} resp = await put(); } if (resp.ok) { this._storeMode = 'server'; this._serverStamp = Date.now(); return true; } this._storeMode = 'cache-only (PUT ' + resp.status + ')'; return false; } catch (e) { this._storeMode = 'cache-only (offline)'; return false; } } // Read-through: the cache wins while fresh; the server wins when the two differ. async _pullFromServer(force) { const cfg = this._wdConfig(); if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; } if (!force && this._cacheStamp && (Date.now() - this._cacheStamp) < this._STORE_TTL_MS) return false; try { const resp = await fetch(this._storeUrl(cfg), { headers: this._storeHeaders(cfg) }); if (resp.status === 404) { this._storeMode = 'server (empty)'; return false; } if (!resp.ok) { this._storeMode = 'cache-only (GET ' + resp.status + ')'; return false; } const remote = await resp.json(); if (!remote || typeof remote !== 'object') return false; const localPts = (this._dataPoints || []).length; const localPaired = (this._accuracy || {}).total || 0; const remotePts = (remote.dataPoints || []).length; const remotePaired = (remote.accuracy || {}).total || 0; const diverge = remotePts !== localPts || remotePaired !== localPaired; remote.cachedAt = Date.now(); remote.storeMode = 'server'; if (!this._writeCache(remote)) return false; this._cacheStamp = Date.now(); this._serverStamp = Date.now(); this._loadFromVFS(); this._storeMode = 'server'; if (diverge && !this._divergenceNoted) { this._divergenceNoted = true; this.terminal.print(`📊 Predictor store: server copy differs from the local cache (server ${remotePts} pts / ${remotePaired} paired, cache ${localPts} / ${localPaired}) — server wins`, 'warning'); } return true; } catch (e) { this._storeMode = 'cache-only (offline)'; return false; } } _debounceSave() { clearTimeout(this._saveTimer); this._saveTimer = setTimeout(() => this._saveNow(), 3000); } } // Required for module system if (typeof window !== 'undefined') { window.PredictorModule = PredictorModule; } /* === module-world-model.js === */ // module-world-model.js — Global World Model: system self-awareness synthesis engine // Aggregates from memory graph, neural VFS, skills index, telemetry // Commands: world-model, world-model-summary, world-model-ask (LLM), world-model-refresh, world-model-config class WorldModelModule { constructor() { this.commands = {}; this.terminal = null; this._model = null; this._lastRefresh = null; this._REFRESH_INTERVAL = 300000; // 5 minutes (fallback) this._MAX_PROJECTS = 10; this._refreshTimer = null; // v4.5.89: Event-driven sync state this._syncDebounce = null; this._syncing = false; this._lastEventSync = 0; this._EVENT_DEBOUNCE_MS = 500; } getHelpInfo() { return { 'world-model': 'world-model — Show full world model as structured output', 'world-model-summary': 'world-model-summary — One-line system self-awareness statement', 'world-model-ask': 'world-model-ask <question> — Ask the LLM about this system (grounded in the world model; --facts for the instant keyword lookup)', 'world-model-refresh': 'world-model-refresh — Force re-synthesis from all subsystems', 'world-model-config': 'world-model-config <key> <value> — Tune: refresh-interval, max-projects', }; } registerCommands() { this.commands['world-model'] = () => this.showModel(); this.commands['world-model-summary'] = () => this.showSummary(); this.commands['world-model-ask'] = (args) => this.ask(args.join(' ')); this.commands['world-model-refresh'] = () => this.refresh(); this.commands['world-model-config'] = (args) => this.setConfig(args); } init(terminal) { this.terminal = terminal; this._loadFromVFS(); // Deferred initial synthesis — wait for ext modules (SkillPackCore etc) to fully load setTimeout(() => { this._synthesize(); this.terminal.print('🌐 World Model synthesized — system self-awareness active', 'info'); // Second pass after ext modules definitely finished loading setTimeout(() => { this._synthesize(); this.terminal.print('🌐 World Model refresh — full module load confirmed', 'info'); }, 7000); }, 5000); // Auto-refresh on a timer this._refreshTimer = setInterval(() => { const age = Date.now() - (this._lastRefresh ? new Date(this._lastRefresh).getTime() : 0); if (age > this._REFRESH_INTERVAL) this._synthesize(); }, this._REFRESH_INTERVAL); this.terminal.print('🌐 World Model loaded — system self-awareness active', 'info'); } // v4.5.89: Event-driven sync — debounced, recursion-guarded _scheduleSync() { if (this._syncing) return; // Prevent recursion during active synthesis if (this._syncDebounce) clearTimeout(this._syncDebounce); const now = Date.now(); // Skip if we synced < 2s ago (rate limit beyond debounce) if (now - this._lastEventSync < 2000) return; this._syncDebounce = setTimeout(() => { this._syncDebounce = null; this._syncing = true; try { this._synthesize(); this._lastEventSync = Date.now(); } finally { this._syncing = false; } }, this._EVENT_DEBOUNCE_MS); } // ─── Synthesis Engine ─── _synthesize() { try { const fs = this.terminal.fs; if (!fs) return; // Derive version from actual system state — no hardcoded strings const mInfo = this._gatherModuleInfo(); const agentInfo = this._gatherAgentInfo(); const fileInfo = this._gatherFileInfo(fs); const skillInfo = this._gatherSkillInfo(fs); const liveVersion = this._deriveVersion(mInfo); const model = { version: liveVersion, synthesized: new Date().toISOString(), system: this._gatherSystemInfo(fs, mInfo.loaded, liveVersion), modules: mInfo, files: fileInfo, skills: skillInfo, memory: this._gatherMemoryInfo(), agents: agentInfo, telemetry: this._gatherTelemetry(fs), projects: this._gatherProjects(fs), recent: this._gatherRecent(fs) }; this._model = model; this._lastRefresh = model.synthesized; this._saveToVFS(fs); // Log synthesis summary const mCount = model.modules?.loaded || '?'; const sCount = model.skills?.count || '?'; const fCount = model.files?.total || '?'; console.log(`[WorldModel] Synthesis complete: ${mCount} modules, ${sCount} skills, ${fCount} files`); } catch (e) { console.error('[WorldModel] Synthesis error:', e.message); } } _deriveVersion(mInfo) { // Single source of truth: the constant set at bootstrap if (window.NEWOS4_VERSION) return window.NEWOS4_VERSION; // Fallback: try to extract from script tag ?v= query param or LATEST_VER constant try { const scripts = document.querySelectorAll('script[src*="module-loader"]'); for (const s of scripts) { const match = s.src.match(/[?&]v=([^&]+)/); if (match) return match[1]; } // Try to find LATEST_VER in any inline script const allScripts = document.querySelectorAll('script:not([src])'); for (const s of allScripts) { const match = s.textContent.match(/LATEST_VER\s*=\s*['"]([^'"]+)['"]/); if (match) return match[1]; } } catch (e) { console.warn('[WorldModel] Version extraction error:', e.message); } return 'v?'; } _getTotalModules() { try { const mm = this.terminal?.moduleManager; if (!mm) return 0; // Use max of registry vs loaded so the ratio is never >1 (e.g. 43/7 → 43/43) const loadedCount = Object.keys(mm.modules || {}).length; const registryCount = (mm.moduleRegistry || []).length; return Math.max(loadedCount, registryCount); } catch (e) { console.warn('[WorldModel] Total modules gather error:', e.message); return Object.keys(this.terminal?.moduleManager?.modules || {}).length || 0; } } _gatherSystemInfo(fs, moduleCount, version) { // Get real health data from ServerMonitorModule's _latencyLog and DebugSupervisorModule const sm = this.terminal?.moduleManager?.modules?.['ServerMonitorModule']; const ds = this.terminal?.moduleManager?.modules?.['DebugSupervisorModule']; const errorCount = ds ? Object.keys(ds._errorDB?.errors || {}).length : 0; const latencyErrors = sm?._latencyLog ? Object.values(sm._latencyLog).filter(e => e.status === 'error').length : 0; const totalErrors = errorCount + latencyErrors; return { version: `newOS4 ${version}`, modules: moduleCount || Object.keys(this.terminal?.moduleManager?.modules || {}).length || 0, vfsSize: JSON.stringify(fs?.root || {}).length, online: typeof navigator !== 'undefined' ? navigator.onLine : true, memory: typeof performance !== 'undefined' && performance.memory ? `${(performance.memory.usedJSHeapSize / 1048576).toFixed(1)}MB` : 'N/A', health: totalErrors > 0 ? `⚠️ ${totalErrors} errors` : 'OK', lastHealthCheck: new Date().toISOString() }; } _gatherModuleInfo() { const mm = this.terminal?.moduleManager; if (!mm) return { loaded: 0, modified: [] }; const modules = Object.keys(mm.modules || {}).map(name => { const mod = mm.modules[name]; const clsName = mod?.constructor?.name || name; return { name: clsName, hasCommands: !!mod?.commands }; }); // Check for local modifications const modsDir = this.terminal.fs?.readDir('/_mods/'); const modified = modsDir ? modsDir.filter(e => e.type === 'file' && e.name.endsWith('.js') && !e.name.endsWith('.orig.js')) .map(e => e.name.replace('.js', '')) : []; return { loaded: modules.length, total: this._getTotalModules(), modules, modified, modifiedCount: modified.length }; } _gatherFileInfo(fs) { if (!fs) return { total: 0, size: '0KB' }; let fileCount = 0, dirCount = 0; const walk = (path) => { const entries = fs.ls(path); if (!entries) return; for (const e of entries) { // /sys is synthesized by ls() on demand and has no node behind it. Walking into it // counted a synthetic subtree (114 dirs / 146 files in a store that holds 43 files), // which is the "the counts disagree" report: two different populations were both // printed as "files". if (e._virtual) continue; if (e.type === 'file') fileCount++; else { dirCount++; walk(path === '/' ? '/' + e.name : path + '/' + e.name); } } }; walk('/'); return { total: fileCount, directories: dirCount, size: `${(JSON.stringify(fs.root).length / 1024).toFixed(1)}KB` }; } _gatherSkillInfo(fs) { try { const idx = fs.fsReadJSON('/skills/index.json'); const count = idx ? Object.keys(idx).length : 0; return { count, skills: idx ? Object.keys(idx) : [] }; } catch (e) { console.warn('[WorldModel] Skill info gather error:', e.message); return { count: 0, skills: [] }; } } _gatherMemoryInfo() { const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; if (mg && mg._nodes) { const nodes = Object.keys(mg._nodes).length; const edges = (mg._edges || []).length; return { nodes, edges, active: true, fallback: false }; } // Fallback: count memory files try { const memIndex = this.terminal.fs?.fsReadJSON('/memory/index.json'); return { nodes: memIndex ? Object.keys(memIndex).length : 0, edges: 0, active: false, fallback: true }; } catch (e) { console.warn('[WorldModel] Memory info gather error:', e.message); return { nodes: 0, edges: 0, active: false, fallback: true }; } } _gatherAgentInfo() { const orch = this.terminal?.moduleManager?.modules?.['OrchestratorModule']; if (!orch || !orch._agents) return { agents: -1, active: -1, error: 'Orchestrator not loaded' }; const agents = Object.keys(orch._agents).length; const active = Object.values(orch._agents).filter(a => a.status === 'busy').length; return { agents, active, totalLeases: Object.keys(orch._a2aMessages || {}).length }; } _gatherTelemetry(fs) { try { // Get real latency data from ServerMonitorModule and errors from DebugSupervisorModule const sm = this.terminal?.moduleManager?.modules?.['ServerMonitorModule']; const ds = this.terminal?.moduleManager?.modules?.['DebugSupervisorModule']; const latencyDataPoints = sm?._latencyLog ? Object.keys(sm._latencyLog).length : 0; // v4.6.59: same canonical counter as the `errors` command and the dashboard. // Reading the debug DB alone made the world model disagree with both (packet §2). const errInfo = (this.terminal && typeof this.terminal.countErrors === 'function') ? this.terminal.countErrors() : { total: ds ? Object.keys(ds._errorDB?.errors || {}).length : 0, debug: 0, latency: 0, log: 0 }; return { toolLatencyDataPoints: latencyDataPoints, recentErrors: errInfo.total, errorsBySource: { debug: errInfo.debug, toolCalls: errInfo.latency, log: errInfo.log }, }; } catch (e) { console.warn('[WorldModel] Telemetry gather error:', e.message); return { toolLatencyDataPoints: 0, recentErrors: 0 }; } } _gatherProjects(fs) { try { const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; if (mg && mg._nodes) { // Find task clusters as "projects" const tasks = Object.values(mg._nodes).filter(n => n.type === 'task'); const recentFiles = Object.values(mg._nodes) .filter(n => n.type === 'file') .sort((a, b) => (b.access_count || 0) - (a.access_count || 0)) .slice(0, this._MAX_PROJECTS); return { totalTasks: tasks.length, activeProjects: recentFiles.slice(0, 5).map(f => ({ name: f.label, accesses: f.access_count || 0, lastModified: f.updated })) }; } return { totalTasks: 0, activeProjects: [] }; } catch (e) { console.warn('[WorldModel] Projects gather error:', e.message); return { totalTasks: 0, activeProjects: [] }; } } _gatherRecent(fs) { const recent = []; try { // Check modified modules const modsDir = fs.readDir('/_mods/'); if (modsDir) { for (const e of modsDir) { if (e.type === 'file' && e.name.endsWith('.js') && !e.name.endsWith('.orig.js') && !e.name.endsWith('.meta.json')) { recent.push(`Modified module: ${e.name.replace('.js', '')}`); } } } // Check skills activity const skills = fs.ls('/skills/'); if (skills && skills.length > 0) { recent.push(`Skills directory: ${skills.filter(s => s.type === 'file').length} files`); } // Check memory directory for recent changes const memDir = fs.readDir('/memory/'); if (memDir && memDir.length > 0) { const memFiles = memDir.filter(s => s.type === 'file'); if (memFiles.length > 0) { recent.push(`Memory: ${memFiles.length} files in /memory/`); } } // Check system config changes const sysDir = fs.readDir('/system/'); if (sysDir && sysDir.length > 0) { const configFiles = sysDir.filter(s => s.type === 'file' && s.name.endsWith('.json')); if (configFiles.length > 0) { recent.push(`System config: ${configFiles.length} config files`); } } } catch (e) { console.warn('[WorldModel] Recent gather error:', e.message); } return recent; } // ─── Commands ─── showModel() { if (!this._model) return this.terminal.print('No model yet — run world-model-refresh', 'warning'); const m = this._model; this.terminal.print('🌐 World Model', 'header'); this.terminal.print(` Version: ${m.version} (last synthsized: ${new Date(m.synthesized).toLocaleTimeString()})`, 'info'); this.terminal.print(` ⚙️ System — ${m.system.modules} modules loaded, ${m.system.vfsSize} VFS, ${m.system.memory}`, 'info'); this.terminal.print(` 🧩 Modules — ${m.modules.loaded} loaded (${m.modules.modifiedCount} modified)`, 'info'); this.terminal.print(` 📁 Files — ${m.files.total} files, ${m.files.directories} dirs, ${m.files.size}`, 'info'); this.terminal.print(` 📚 Skills — ${m.skills.count} installed`, 'info'); this.terminal.print(` 🧠 Memory Graph — ${m.memory.nodes} nodes, ${m.memory.edges} edges${m.memory.active ? ' ✅' : ''}`, 'info'); this.terminal.print(` 🤖 Agents — ${m.agents.agents} registered (${m.agents.active} active)`, 'info'); this.terminal.print(` 📊 Telemetry — ${m.telemetry.toolLatencyDataPoints} tool latency points, ${m.telemetry.recentErrors} recent errors`, 'info'); if (m.projects.activeProjects.length > 0) { this.terminal.print(' Active context:', 'header'); for (const p of m.projects.activeProjects.slice(0, 3)) { this.terminal.print(` 📌 ${p.name} (${p.accesses}x accesses)`, 'info'); } } if (m.recent.length > 0) { this.terminal.print(' Recent changes:', 'header'); for (const r of m.recent.slice(0, 3)) { this.terminal.print(` ${r}`, 'info'); } } } showSummary() { if (!this._model) return this.terminal.print('No model yet', 'warning'); const m = this._model; const summary = `newOS4 ${m.version} · ${m.modules.loaded}/${m.modules.total} modules · ${m.skills.count} skills · ${m.files.total} files · ${m.agents.agents} agents · ${m.memory.nodes} graph nodes · ${m.telemetry.recentErrors > 0 ? `⚠️ ${m.telemetry.recentErrors} errors` : '✅ OK'}`; this.terminal.print(summary, 'info'); return summary; } async ask(question) { if (!question) return this.terminal.print('Usage: world-model-ask <question> (--facts = fast keyword lookup)', 'error'); const factsOnly = /(^|\s)--facts\b/.test(question); if (factsOnly) question = question.replace(/--facts\b/, '').trim(); if (!this._model) return this.terminal.print('No model — run world-model-refresh first', 'warning'); this.terminal.print(`❓ ${question}`, 'info'); if (!factsOnly) { const om = this.terminal.ollamaManager; try { if (om && !om.connected) { try { await om.connect(); } catch (e) {} } if (om && om.connected) { const answer = await om.chat(this._buildAskPrompt(question)); if (answer && String(answer).trim()) { this.terminal.print(`💡 ${String(answer).trim()}`, 'success'); return; } } } catch (e) { /* fall through to the facts path */ } this.terminal.print('⚠️ LLM unavailable — showing the factual lookup instead', 'warning'); } this.terminal.print(`💡 ${this._answerQuery(question)}`, 'success'); } // Shared query routing — used by both ask() (terminal) and world_model_query (AI tool) _answerQuery(question) { if (!this._model) return 'World model not synthesized yet — run world-model-refresh'; const q = (question || '').toLowerCase(); const m = this._model; if (q.includes('module') || q.includes('loaded')) { return `Loaded ${m.modules.loaded} modules (${m.modules.modifiedCount} modified: ${m.modules.modified.join(', ') || 'none'})`; } else if (q.includes('skill')) { return `Installed ${m.skills.count} skills: ${m.skills.skills.join(', ') || 'none'}`; } else if (q.includes('file') || q.includes('vfs')) { return `${m.files.total} files in ${m.files.directories} directories, total ${m.files.size}`; } else if (q.includes('agent') || q.includes('orchestrat')) { return `${m.agents.agents} agents (${m.agents.active} active)`; } else if (q.includes('health') || q.includes('status')) { return `Health: ${m.system.health} · Memory: ${m.system.memory} · ${m.telemetry.recentErrors > 0 ? `${m.telemetry.recentErrors} recent errors` : 'No errors'}`; } else if (q.includes('memory') || q.includes('graph')) { return `Memory Graph: ${m.memory.nodes} nodes, ${m.memory.edges} edges (${m.memory.active ? 'active' : 'inactive'})`; } else if (q.includes('change') || q.includes('yesterday') || q.includes('recent')) { const diff = this._diffSincePrev(); const recent = m.recent || []; const parts = []; if (diff.length) parts.push('Since the last snapshot: ' + diff.join('; ')); if (recent.length) parts.push('Current: ' + recent.join('; ')); return parts.length ? parts.join(' · ') : 'No previous snapshot yet — run world-model-refresh twice'; } else if (q.includes('project') || q.includes('work')) { const projects = m.projects.activeProjects; return projects.length > 0 ? `Active projects: ${projects.map(p => `${p.name} (${p.accesses}x)`).join(', ')}` : 'No active projects detected'; } else if (q.includes('telemetry') || q.includes('latency')) { return `${m.telemetry.toolLatencyDataPoints} tool latency data points tracked, ${m.telemetry.recentErrors} recent errors`; } else if (q.includes('summary') || q.includes('overview') || q.includes('all')) { return `newOS4 ${m.version} · ${m.modules.loaded}/${m.modules.total} modules · ${m.skills.count} skills · ${m.files.total} files · ${m.agents.agents} agents · ${m.memory.nodes} graph nodes · ${m.telemetry.recentErrors > 0 ? `⚠️ ${m.telemetry.recentErrors} errors` : '✅ OK'}`; } else { return `Unknown query. Try: modules, skills, files, agents, health, memory, changes, projects, telemetry, or summary.`; } } // 2026-10-21: ask() used to be a keyword table only, so any real question got // "Unknown query". Whitelist the fields we are willing to send to a model // (never the raw model object: it is persisted alongside other VFS data). // v4.6.83: the digest used to be a JSON string cut at 4000 chars with '…(digest truncated)' // glued on. Every caller that tried to parse it got "Unexpected end of JSON input" — the // world_model_query tool did exactly that on its default path. A digest must shrink, never // break: this returns a valid OBJECT and the string form is only produced at the edge. _modelSnapshot(maxChars) { const m = this._model; if (!m) return null; const cap = maxChars || 4000; const build = (keep) => ({ version: m.version, synthesized: m.synthesized, system: m.system, modules: m.modules, files: m.files, skills: { count: m.skills && m.skills.count, skills: ((m.skills && m.skills.skills) || []).slice(0, keep) }, memory: m.memory, agents: m.agents, telemetry: m.telemetry, projects: m.projects, recent: (m.recent || []).slice(0, Math.min(keep, 20)), // 2026-10-21: the model only ever exposed a COUNT ("9 recent errors") with no detail, // so neither the user nor the LLM could say what failed. Carry the last few. recentErrorSample: this._errorSample ? this._errorSample() : [], changesSinceLastSnapshot: this._diffSincePrev ? this._diffSincePrev() : [] }); // Shrink the lists until the snapshot fits. Truncating the JSON text is what broke it. for (const keep of [40, 20, 10, 5, 3, 1, 0]) { const pick = build(keep); if (JSON.stringify(pick).length <= cap) return pick; } // Last resort: drop whole sections, in order of least value, until it fits. Still a // valid object — a caller can always JSON.parse it and see what was left out. const lean = build(0); lean.truncated = true; lean.omitted = []; for (const key of ['changesSinceLastSnapshot', 'recentErrorSample', 'recent', 'projects', 'skills', 'agents', 'files', 'modules', 'telemetry', 'memory']) { if (JSON.stringify(lean).length <= cap) break; if (key in lean) { delete lean[key]; lean.omitted.push(key); } } return lean; } // String form, for prompts. Always valid JSON now. _modelDigest(maxChars) { const snap = this._modelSnapshot(maxChars); return snap ? JSON.stringify(snap) : ''; } // Last few tool/agent errors with their tool name, for the digest (bounded). _errorSample() { try { const ds = this.terminal.moduleManager.modules['DebugSupervisorModule']; const db = ds && ds._errorDB && ds._errorDB.errors; if (!db) return []; return Object.keys(db).slice(-5).map(k => { const e = db[k] || {}; return String(e.tool || e.module || k).slice(0, 40) + ': ' + String(e.error || e.message || '').slice(0, 80); }); } catch (e) { return []; } } _buildAskPrompt(question) { return [ 'You are the newOS4 world model interface. Answer ONLY from the JSON snapshot below.', 'If the snapshot does not contain the answer, say what is missing and name the command that', 'would produce it (world-model-refresh, world-model, modules, test). Never invent numbers.', 'Answer in 1-3 short sentences, plain text, no markdown headers.', '', 'SNAPSHOT: ' + this._modelDigest(), '', 'QUESTION: ' + String(question || '').trim() ].join('\n'); } _diffSincePrev() { try { const fs = this.terminal.fs; const prev = fs && fs.fsReadJSON('/system/world-model.prev.json'); const cur = this._model; if (!prev || !cur) return []; const out = []; if (prev.version && cur.version && prev.version !== cur.version) out.push(`version ${prev.version} → ${cur.version}`); const fields = [['modules', 'loaded'], ['skills', 'count'], ['files', 'total'], ['agents', 'agents'], ['memory', 'nodes']]; fields.forEach(function (f) { const a = prev[f[0]] && prev[f[0]][f[1]]; const b = cur[f[0]] && cur[f[0]][f[1]]; if (typeof a === 'number' && typeof b === 'number' && a !== b) out.push(`${f[0]} ${a} → ${b}`); }); if (prev.synthesized) out.push('previous snapshot: ' + prev.synthesized); return out; } catch (e) { return []; } } refresh() { this._synthesize(); this.terminal.print(`✅ World Model refreshed at ${new Date().toLocaleTimeString()}`, 'success'); } setConfig(args) { if (args.length < 2) return this.terminal.print('Usage: world-model-config <key> <value>', 'error'); const key = args[0]; const val = args[1]; const configMap = { 'refresh-interval': () => { const n = parseInt(val) * 1000; if (isNaN(n) || n < 10000) return 'Must be >= 10 seconds'; this._REFRESH_INTERVAL = n; if (this._refreshTimer) clearInterval(this._refreshTimer); this._refreshTimer = setInterval(() => this._synthesize(), n); return `refresh-interval set to ${val}s`; }, 'max-projects': () => { const n = parseInt(val); if (isNaN(n) || n < 1) return 'Must be >= 1'; this._MAX_PROJECTS = n; return `max-projects set to ${n}`; }, }; if (!configMap[key]) return this.terminal.print(`Unknown: ${key}. Valid: ${Object.keys(configMap).join(', ')}`, 'error'); const msg = configMap[key](); this.terminal.print(`✅ ${msg}`, 'success'); } // ─── Context Injection ─── getContextForPrompt() { if (!this._model) return ''; const m = this._model; const agentStr = m.agents.error ? 'agents: N/A' : `${m.agents.agents} agents`; return `[World Model Context]\nSystem: newOS4 ${m.version} · ${m.modules.loaded} modules · ${m.skills.count} skills · ${m.files.total} files · ${agentStr}${m.telemetry.recentErrors > 0 ? ` · ⚠️ ${m.telemetry.recentErrors} errors` : ' · ✅ OK'}${m.modules.modifiedCount > 0 ? `\nModified modules: ${m.modules.modified.slice(0, 3).join(', ')}${m.modules.modifiedCount > 3 ? ` (+${m.modules.modifiedCount - 3} more)` : ''}` : ''}`; } // ─── Persistence ─── _loadFromVFS() { try { const fs = this.terminal.fs; if (!fs) return; const data = fs.fsReadJSON('/system/world-model.json'); if (data) { this._model = data; this._lastRefresh = data.synthesized; } } catch (e) { console.warn('[WorldModel] Load error (fresh start):', e.message); } } // v4.5.89: Atomic write — temp file + rename to prevent corruption _saveToVFS(fs) { try { if (!fs.readDir('/system')) fs.mkdir('system'); // 2026-10-21: keep one previous model so "what changed" can be a real diff. const prevModel = fs.fsReadJSON('/system/world-model.json'); if (prevModel) { try { fs.fsWriteJSON('/system/world-model.prev.json', prevModel); } catch (_) {} } // v4.5.89b fix: Clean stale temp from previous crash (P1 from review) try { fs.deleteFile('/system/world-model.tmp.json'); } catch (_) {} // Atomic + verified: never promote a write we did not read back byte-for-byte. // Measured 2026-10-23: the old block promoted /system/world-model.json even when every // write returned false, and logged nothing — that is how a graph got replaced by a // partial model. fs.fsWriteJSON() returns a boolean; ignoring it hides quota failures. const payload = JSON.stringify(this._model); const wrote = fs.writeFile('/system/world-model.tmp.json', payload); if (!wrote) { console.error('[WorldModel] Save failed: write returned false (storage quota?)'); return; } const back = fs.readFile('/system/world-model.tmp.json'); if (back !== payload) { console.error('[WorldModel] Save failed: read-back mismatch (' + (back ? back.length : 0) + '/' + payload.length + ' bytes) — keeping previous model'); return; } const promoted = fs.writeFile('/system/world-model.json', back); if (!promoted) { console.error('[WorldModel] Save failed: promote returned false'); return; } fs.deleteFile('/system/world-model.tmp.json'); } catch (e) { console.error('[WorldModel] Save error:', e.message); } } } // Required for module system if (typeof window !== 'undefined') { window.WorldModelModule = WorldModelModule; } /* === module-neural-vfs.js === */ class NeuralVFSModule { constructor() { this.commands = {}; this.commandsMeta = { 'nvfs-index': { argType: 'file' }, 'nvfs-status': { argType: 'none' }, 'nvfs-resolve': { argType: 'file' }, 'nvfs-tree': { argType: 'file' }, 'nvfs-config': { argType: 'enum', enumFn: '_getNvfsConfigKeys' }, 'nvfs-alias': { argType: 'enum', enumFn: '_getNvfsAliasActions' }, 'nvfs-sync': { argType: 'none' }, 'nvfs-save': { argType: 'none' }, 'nvfs-cancel': { argType: 'none' }, }; this.terminal = null; this.index = {}; this.indexPath = '/.semantic_index/map.json'; this.hashCachePath = '/.semantic_index/hash_cache.json'; this._hashCache = {}; // Configurable display limits this._fuzzyLimit = 10; this._treeFiles = 5; this._treeConcepts = 15; this._resolveFiles = 20; this._excludePatterns = []; this._excludeDirs = []; this._excludeFiles = []; this._includeOnly = []; this._loadConfig(); // Auto-sync state this._dirtyPaths = new Set(); this._autoSyncTimer = null; this._aliasMap = {}; this._loadAliases(); // Deep Indexing toggle this._deepIndexEnabled = localStorage.getItem('webx11_nvfs_deep') === 'true'; this._deepModel = localStorage.getItem('webx11_nvfs_deep_model') || 'gemma4:31b-cloud'; this._deepIndexing = false; this._deepQueue = []; this._deepProcessed = 0; this._deepTotal = 0; // Cancel flag this._cancelRequested = false; // Status bar polling — updates #status-text with indexing indicator if (typeof window !== 'undefined') { this._statusInterval = setInterval(() => this._updateStatusBar(), 2000); } // Register as window-level event listener for mutation events from VirtualFS if (typeof window !== 'undefined') { window.__nvfsMarkDirty = (path) => this.markDirty(path); } } _loadConfig() { try { const c = localStorage.getItem('webx11_nvfs_config'); if (c) { const p = JSON.parse(c); if (p.fuzzyLimit) this._fuzzyLimit = p.fuzzyLimit; if (p.treeFiles) this._treeFiles = p.treeFiles; if (p.treeConcepts) this._treeConcepts = p.treeConcepts; if (p.resolveFiles) this._resolveFiles = p.resolveFiles; if (Array.isArray(p.excludePatterns)) this._excludePatterns = p.excludePatterns; if (Array.isArray(p.excludeDirs)) this._excludeDirs = p.excludeDirs; if (Array.isArray(p.excludeFiles)) this._excludeFiles = p.excludeFiles; if (Array.isArray(p.includeOnly)) this._includeOnly = p.includeOnly; } } catch (e) {} } _saveConfig() { localStorage.setItem('webx11_nvfs_config', JSON.stringify({ fuzzyLimit: this._fuzzyLimit, treeFiles: this._treeFiles, treeConcepts: this._treeConcepts, resolveFiles: this._resolveFiles, excludePatterns: this._excludePatterns, excludeDirs: this._excludeDirs, excludeFiles: this._excludeFiles, includeOnly: this._includeOnly })); } getHelpInfo() { return { 'nvfs-index': 'nvfs-index [--deep] — Scan VFS and build semantic concept index', 'nvfs-status': 'nvfs-status — Show semantic index stats and last scan time', 'nvfs-resolve': 'nvfs-resolve <concept> — Find files related to a concept', 'nvfs-tree': 'nvfs-tree [concept] — Show concept-to-file mapping as a tree', 'nvfs-config': 'nvfs-config [key] [value] — View or set Neural VFS config (fuzzy-limit, tree-files, tree-concepts, resolve-files, deep-index, deep-model, exclude-patterns, exclude-dirs, exclude-files, include-only)', 'nvfs-alias': 'nvfs-alias list|add|remove|clear — Manage concept aliases', 'nvfs-sync': 'nvfs-sync [--deep] — Incremental background sync (auto-sync dirty files)', 'nvfs-save': 'nvfs-save — Force-save current in-memory index to VFS (survives refresh)', 'nvfs-cancel': 'nvfs-cancel — Cancel running index operation' }; } registerCommands() { this.commands['nvfs-index'] = (args) => this.buildIndex(args); this.commands['nvfs-status'] = (args) => this.showStatus(); this.commands['nvfs-resolve'] = (args) => this.resolve(args); this.commands['nvfs-tree'] = (args) => this.tree(args); this.commands['nvfs-config'] = (args) => this.nvfsConfig(args); this.commands['nvfs-alias'] = (args) => this.nvfsAlias(args); this.commands['nvfs-sync'] = async (args) => this.nvfsSync(args); this.commands['nvfs-save'] = async (args) => this.nvfsSave(); this.commands['nvfs-cancel'] = (args) => this.cancelIndex(); this.commands['nvfs-index-graph'] = () => this.indexGraphFacts(); } // ── Core Index Builder (async, chunked) ── async buildIndex(args) { const useDeep = this._deepIndexEnabled || (args && args.includes('--deep')); const fs = this.terminal.fs; if (!fs) return this.terminal.print('VFS not available', 'error'); // Safety guard: warn if deep mode is off but the saved index has LLM concepts // (show once per session — doesn't repeat on every nvfs-index call) if (!useDeep && !this._warnedAboutLLMStrip) { const prevIndex = fs.fsReadJSON(this.indexPath) || {}; const hasLLM = Object.values(prevIndex).some(entries => Array.isArray(entries) && entries.some(e => e.source === 'llm') ); if (hasLLM) { this._warnedAboutLLMStrip = true; this.terminal.print('⚠️ Deep indexing is OFF but the saved index contains LLM-generated concepts.', 'warning'); this.terminal.print(' Running nvfs-index without --deep will STRIP all LLM concepts from re-indexed files.', 'warning'); this.terminal.print(' To keep them: nvfs-config deep-index on or nvfs-index --deep', 'warning'); } } this.terminal.print(`🔍 Scanning VFS for semantic concepts${useDeep ? ' (deep mode)' : ''}...`, 'info'); const start = Date.now(); let newIndex = {}; let fileCount = 0; let conceptCount = 0; let cachedCount = 0; // Load previous index and hash cache for incremental diffing const prevIndex = fs.fsReadJSON(this.indexPath) || {}; this._loadHashCache(fs); // Collect current file list const fileList = []; this._collectFiles('/', fs, fileList); if (fileList.length === 0) { return this.terminal.print('No files found to index', 'warning'); } // Detect deleted files — strip orphaned entries from prevIndex const currentPaths = new Set(fileList.map(f => f.path)); let deletedCount = 0; for (const [concept, entries] of Object.entries(prevIndex)) { const remaining = entries.filter(e => currentPaths.has(e.path)); if (remaining.length !== entries.length) deletedCount++; if (remaining.length > 0) prevIndex[concept] = remaining; else delete prevIndex[concept]; } // Warm start: clone prevIndex and count live paths for stats newIndex = JSON.parse(JSON.stringify(prevIndex)); const livePaths = new Set(); for (const entries of Object.values(newIndex)) { for (const e of entries) livePaths.add(e.path); } conceptCount = Object.keys(newIndex).length; // Chunked async processing — yields to UI between chunks const chunkSize = 10; let i = 0; const processChunk = () => { if (this._cancelRequested) { this._cancelRequested = false; this._deepQueue = []; this._deepIndexing = false; this.terminal.print('⛔ Index cancelled', 'warning'); return; } const end = Math.min(i + chunkSize, fileList.length); for (let j = i; j < end; j++) { const { path, node } = fileList[j]; fileCount++; const content = node.content || ''; const hash = this._contentHash(content); // Skip if hash unchanged — content is same as last scan const cached = this._getCached(path, hash); if (cached) { cachedCount++; if (!livePaths.has(path)) { // File was in cache but somehow missing from prevIndex this._reindexFile(path, content, newIndex, useDeep); } continue; } // Changed or new file — extract concepts fresh this._reindexFile(path, content, newIndex, useDeep); this._hashCache[path] = { hash }; } i = end; if (i < fileList.length) { const pct = Math.round(i / fileList.length * 100); const cMsg = cachedCount > 0 ? ` (${cachedCount} cached)` : ''; this.terminal.print(`⏳ Indexing... ${pct}% (${i}/${fileList.length} files${cMsg})`, 'info'); setTimeout(processChunk, 0); } else { // Also index skills this._indexSkills(fs, newIndex); this.index = newIndex; this.lastScan = new Date().toISOString(); this.fileCount = fileCount; this.conceptCount = Object.keys(newIndex).length; this._saveIndex(fs); if (fs._clearConceptCache) fs._clearConceptCache(); const elapsed = ((Date.now() - start) / 1000).toFixed(1); const processedMsg = cachedCount > 0 ? ` (${cachedCount} cached, ${fileCount - cachedCount} reindexed)` : ''; this.terminal.print(`✅ Indexed ${fileCount} files → ${Object.keys(newIndex).length} concepts in ${elapsed}s${processedMsg}`, 'success'); if (deletedCount > 0) this.terminal.print(` Purged ${deletedCount} orphaned concepts from deleted files`, 'info'); this.terminal.print(` Index saved to ${this.indexPath}`, 'info'); } }; processChunk(); } _reindexFile(path, content, index, useDeep) { if (useDeep) { // Deep mode: queue for async LLM concept extraction — fast-path filename concepts first const fastConcepts = this._extractConcepts(path, ''); for (const concept of fastConcepts) { if (!index[concept]) index[concept] = []; const exists = index[concept].some(e => e.path === path); if (!exists) index[concept].push({ path, summary: this._summarize(content), source: 'filename' }); } this._deepQueue.push({ path, content }); if (!this._deepIndexing) { this._deepIndexing = true; this._processDeepQueue(index).catch(e => console.error('Deep index error:', e)); } return; } const concepts = this._extractConcepts(path, content); // Remove old entries for this path for (const entries of Object.values(index)) { const idx = entries.findIndex(e => e.path === path); if (idx !== -1) entries.splice(idx, 1); } // Add new entries for (const concept of concepts) { if (!index[concept]) { index[concept] = []; this.conceptCount++; } index[concept].push({ path, summary: this._summarize(content) }); } } _collectFiles(path, fs, list) { const entries = fs.readDir(path); if (!entries) return; for (const entry of entries) { const fullPath = path === '/' ? '/' + entry.name : path + '/' + entry.name; if (this._isExcluded(fullPath)) continue; if (entry.type === 'file') { if (this._isSystemFile(fullPath)) continue; list.push({ path: fullPath, node: { type: 'file', content: fs.readFile(fullPath) || '' } }); } else if (entry.type === 'dir') { this._collectFiles(fullPath, fs, list); } } } // ── Resolve ── resolve(args) { if (!args[0]) return this.terminal.print('Usage: nvfs-resolve <concept>', 'warning'); const concept = args.join(' ').toLowerCase(); // Check aliases first if (this._aliasMap[concept]) { const targets = this._aliasMap[concept]; this.terminal.print(`🔗 Alias "${concept}" → ${targets.length} target(s):`, 'info'); for (const t of targets) this.terminal.print(` ${t}`, 'success'); return; } const matches = this.index[concept] || []; // Fuzzy fallback — find partial matches const fuzzy = Object.keys(this.index).filter(k => k.includes(concept) || concept.includes(k) ).filter(k => k !== concept); if (matches.length === 0 && fuzzy.length === 0) { // Hybrid discovery: fall back to live VFS scan const shellResult = this._hybridShellCheck(concept); if (shellResult.length > 0) { this.terminal.print(`🔍 Index empty for "${concept}" but found ${shellResult.length} file(s) via live scan. Run nvfs-index to refresh.`, 'warning'); for (const f of shellResult.slice(0, this._resolveFiles)) { this.terminal.print(` ${f.path}${f.summary ? ' — ' + f.summary : ''}`, 'success'); } if (shellResult.length > this._resolveFiles) { this.terminal.print(` ... and ${shellResult.length - this._resolveFiles} more`, 'info'); } return; } return this.terminal.print(`No files found for concept "${concept}"`, 'warning'); } if (matches.length > 0) { this.terminal.print(`📂 "${concept}" → ${matches.length} file(s):`, 'info'); for (const m of matches.slice(0, this._resolveFiles)) { this.terminal.print(` ${m.path}`, 'success'); if (m.summary) this.terminal.print(` ${m.summary}`, '') } if (matches.length > this._resolveFiles) { this.terminal.print(` ... and ${matches.length - this._resolveFiles} more`, 'info'); } } if (fuzzy.length > 0) { this.terminal.print(`\n🔀 Similar concepts: ${fuzzy.slice(0, this._fuzzyLimit).join(', ')}`, 'info'); } } // ── Status ── showStatus() { const count = Object.keys(this.index).length; if (count === 0) { // Try loading from VFS const fs = this.terminal.fs; const saved = fs.fsReadJSON(this.indexPath); if (saved) { this.index = saved; this.lastScan = '(loaded from disk)'; this.terminal.print(`📖 Loaded index from ${this.indexPath}`, 'info'); } else { return this.terminal.print('No semantic index found. Run nvfs-index first.', 'warning'); } } const totalFiles = new Set(); for (const entries of Object.values(this.index)) { for (const e of entries) totalFiles.add(e.path); } this.terminal.print(`🧠 Neural VFS Index`, 'info'); this.terminal.print(` Concepts: ${Object.keys(this.index).length}`, ''); this.terminal.print(` Files indexed: ${totalFiles.size}`, ''); this.terminal.print(` Last scan: ${this.lastScan || 'never'}`, ''); this.terminal.print(` Mode: ${this._deepIndexEnabled ? '🌊 Deep (LLM-assisted)' : '⚡ Fast (metadata-only)'}`, ''); // LLM/fast concept breakdown — proves persistence is working without DevTools const allEntries = Object.values(this.index).flat(); const llmCount = allEntries.filter(e => e.source === 'llm').length; const fastCount = allEntries.length - llmCount; const llmFiles = new Set(allEntries.filter(e => e.source === 'llm').map(e => e.path)).size; this.terminal.print(` Fast concepts: ${fastCount}`, ''); this.terminal.print(` LLM concepts: ${llmCount} (across ${llmFiles} file${llmFiles === 1 ? '' : 's'})`, ''); // Staleness check if (this.lastScan && this.lastScan !== '(loaded from disk)') { const ageMs = Date.now() - new Date(this.lastScan).getTime(); const ageHours = Math.floor(ageMs / 3600000); const ageMinutes = Math.floor((ageMs % 3600000) / 60000); if (ageHours >= 1) { this.terminal.print(` Age: ${ageHours}h${ageMinutes > 0 ? ` ${ageMinutes}m` : ''}`, ''); } if (ageHours >= 4) { this.terminal.print(` ⚠️ Index is ${ageHours} hours old — structural changes may be missing. Run nvfs-index to refresh.`, 'warning'); } } // Dirty file warning if (this._dirtyPaths && this._dirtyPaths.size > 0) { this.terminal.print(` ⚠️ ${this._dirtyPaths.size} file(s) changed since last scan (auto-sync pending)`, 'warning'); } // Alias count const aliasCount = Object.keys(this._aliasMap).length; if (aliasCount > 0) { this.terminal.print(` Aliases: ${aliasCount}`, ''); } } // ── Tree View ── tree(args) { const concepts = Object.keys(this.index); if (concepts.length === 0) { return this.terminal.print('No index. Run nvfs-index first.', 'warning'); } const filter = args[0] ? args[0].toLowerCase() : null; const filtered = filter ? concepts.filter(c => c.includes(filter)) : concepts; this.terminal.print(`🌳 Concept Tree${filter ? ` (filter: "${filter}")` : ''}:`, 'info'); for (const concept of filtered.slice(0, this._treeConcepts)) { this.terminal.print(` 📁 ${concept}`, 'success'); for (const entry of this.index[concept].slice(0, this._treeFiles)) { this.terminal.print(` └─ ${entry.path}`, ''); } if (this.index[concept].length > this._treeFiles) { this.terminal.print(` └─ ... (+${this.index[concept].length - this._treeFiles} more)`, ''); } } if (filtered.length > this._treeConcepts) { this.terminal.print(` ... and ${filtered.length - this._treeConcepts} more concepts`, 'info'); } } // ── Nvfs Config ── nvfsConfig(args) { const listConfig = () => { this.terminal.print('=== Neural VFS Config ===', 'info'); this.terminal.print(`fuzzy-limit: ${this._fuzzyLimit} (default: 10)`, ''); this.terminal.print(`tree-files: ${this._treeFiles} (default: 5)`, ''); this.terminal.print(`tree-concepts: ${this._treeConcepts} (default: 15)`, ''); this.terminal.print(`resolve-files: ${this._resolveFiles} (default: 20)`, ''); this.terminal.print(`deep-index: ${this._deepIndexEnabled ? 'on' : 'off'} (LLM-assisted concept extraction)`, ''); this.terminal.print(`deep-model: ${this._deepModel} (model for deep indexing)`, ''); this.terminal.print(`exclude-patterns: ${this._excludePatterns.length ? this._excludePatterns.join(', ') : '(none)'}`, ''); this.terminal.print(`exclude-dirs: ${this._excludeDirs.length ? this._excludeDirs.join(', ') : '(none)'}`, ''); this.terminal.print(`exclude-files: ${this._excludeFiles.length ? this._excludeFiles.join(', ') : '(none)'}`, ''); this.terminal.print(`include-only: ${this._includeOnly.length ? this._includeOnly.join(', ') : '(all)'}`, ''); this.terminal.print('Usage: nvfs-config <key> <value>', 'info'); this.terminal.print('Keys: fuzzy-limit, tree-files, tree-concepts, resolve-files, deep-index (on|off), deep-model, exclude-patterns, exclude-dirs, exclude-files, include-only', 'info'); this.terminal.print('Lists are comma-separated. Use "+item" to append, "-item" to remove, or a plain comma list to replace.', 'info'); }; if (args.length === 0) { listConfig(); return; } const key = args[0]; if (key === 'deep-index') { const val = args[1]; if (val !== 'on' && val !== 'off') return this.terminal.print('deep-index must be "on" or "off"', 'warning'); this._deepIndexEnabled = val === 'on'; localStorage.setItem('webx11_nvfs_deep', this._deepIndexEnabled ? 'true' : 'false'); if (!this._deepIndexEnabled && this._deepIndexing) { this._deepQueue = []; this._deepIndexing = false; this._deepProcessed = 0; this._deepTotal = 0; this._updateStatusBar(); this.terminal.print(' Deep index queue aborted', 'warning'); } this.terminal.print(`nvfs-config deep-index set to ${val}`, 'success'); if (this._deepIndexEnabled) this.terminal.print(` Deep mode uses ${this._deepModel} for semantic concept extraction`, 'info'); return; } if (key === 'deep-model') { const val = args.slice(1).join(' '); if (!val) return this.terminal.print('Usage: nvfs-config deep-model <model-name>', 'warning'); this._deepModel = val; localStorage.setItem('webx11_nvfs_deep_model', val); this.terminal.print(`nvfs-config deep-model set to ${val}`, 'success'); return; } // List-type config keys const listKeys = { 'exclude-patterns': '_excludePatterns', 'exclude-dirs': '_excludeDirs', 'exclude-files': '_excludeFiles', 'include-only': '_includeOnly' }; if (listKeys[key]) { const raw = args.slice(1).join(' ').trim(); if (!raw) { this.terminal.print(`${key}: ${this[listKeys[key]].join(', ') || '(empty)'}`, 'info'); return; } let current = this[listKeys[key]]; if (raw.startsWith('+')) { const add = raw.slice(1).split(',').map(s => s.trim()).filter(Boolean); current = [...new Set([...current, ...add])]; } else if (raw.startsWith('-')) { const rem = raw.slice(1).split(',').map(s => s.trim()).filter(Boolean); current = current.filter(c => !rem.includes(c)); } else { current = raw.split(',').map(s => s.trim()).filter(Boolean); } this[listKeys[key]] = current; this._saveConfig(); this.terminal.print(`${key}: ${current.join(', ')}`, 'success'); return; } const val = parseInt(args[1]); if (isNaN(val) || val < 1 || val > 200) return this.terminal.print('Value must be 1-200', 'warning'); if (key === 'fuzzy-limit') { this._fuzzyLimit = val; } else if (key === 'tree-files') { this._treeFiles = val; } else if (key === 'tree-concepts') { this._treeConcepts = val; } else if (key === 'resolve-files') { this._resolveFiles = val; } else { return this.terminal.print(`Unknown key: ${key}. Valid: fuzzy-limit, tree-files, tree-concepts, resolve-files, deep-index (on|off), deep-model, exclude-patterns, exclude-dirs, exclude-files, include-only`, 'warning'); } this._saveConfig(); this.terminal.print(`nvfs-config ${key} set to ${val}`, 'success'); } // ── Nvfs Alias ── nvfsAlias(args) { const action = args[0]; if (!action || action === 'list') { const keys = Object.keys(this._aliasMap); if (keys.length === 0) return this.terminal.print('No aliases defined. Use nvfs-alias add <name> <target,paths>', 'info'); this.terminal.print('🔗 Concept Aliases:', 'info'); for (const [name, targets] of Object.entries(this._aliasMap)) { this.terminal.print(` ${name} → ${targets.join(', ')}`, 'success'); } return; } if (action === 'add') { const name = args[1]; if (!name || !args[2]) return this.terminal.print('Usage: nvfs-alias add <name> <target1,target2,...>', 'warning'); const targets = args.slice(2).join(' ').split(',').map(t => t.trim()).filter(Boolean); this._aliasMap[name] = targets; this._saveAliases(); this.terminal.print(`Alias "${name}" added (${targets.length} target(s))`, 'success'); return; } if (action === 'remove') { const name = args[1]; if (!name || !this._aliasMap[name]) return this.terminal.print('Alias not found', 'warning'); delete this._aliasMap[name]; this._saveAliases(); this.terminal.print(`Alias "${name}" removed`, 'success'); return; } if (action === 'clear') { this._aliasMap = {}; this._saveAliases(); this.terminal.print('All aliases cleared', 'success'); return; } this.terminal.print('Usage: nvfs-alias list|add <name> <targets>|remove <name>|clear', 'warning'); } // ── Private Helpers ── _walk(path, fs, index, cb) { const entries = fs.readDir(path); if (!entries) return; for (const entry of entries) { const fullPath = path === '/' ? '/' + entry.name : path + '/' + entry.name; if (entry.type === 'file') { cb(fullPath, { type: 'file', content: fs.readFile(fullPath) || '' }); } else if (entry.type === 'dir') { this._walk(fullPath, fs, index, cb); } } } _isSystemFile(path) { const normalized = path.startsWith('/') ? path : '/' + path; const sysPrefixes = ['/.trash', '/.semantic_index', '/.memory_graph', '/_snapshots', '/_sessions', '/logs', '/memory', '/system', '/sys', '/_mods', '/backups', '/agents', '/_agents', '/leases']; if (sysPrefixes.some(p => normalized.startsWith(p))) return true; if (normalized.includes('/node_modules/')) return true; return false; } _isExcluded(path) { const normalized = path.startsWith('/') ? path : '/' + path; // Explicit file or directory names const excludeNames = ['agents', 'leases', 'agent-registry', 'lease-system']; const parts = normalized.split('/').filter(Boolean); for (const part of parts) { if (excludeNames.includes(part.toLowerCase())) return true; if (this._excludeDirs.includes(part)) return true; if (this._excludeFiles.includes(part)) return true; } if (this._excludePatterns.some(p => { try { return new RegExp(p).test(normalized); } catch (e) { return normalized.includes(p); } })) return true; if (this._includeOnly.length > 0) { const isIncluded = this._includeOnly.some(p => { try { return new RegExp(p).test(normalized); } catch (e) { return normalized.includes(p); } }); if (!isIncluded) return true; } return false; } _contentHash(str) { if (!str) return ''; let hash = 5381; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash) + str.charCodeAt(i); hash = hash & hash; } return Math.abs(hash).toString(36).slice(0, 12); } _loadHashCache(fs) { const saved = fs.fsReadJSON(this.hashCachePath); if (saved && typeof saved === 'object') this._hashCache = saved; } _getCached(path, hash) { const c = this._hashCache[path]; if (c && c.hash === hash) return c; return null; } _extractConcepts(path, content) { const concepts = new Set(); const name = path.split('/').pop() || ''; const nameLower = name.toLowerCase(); // Extract from filename (split on camelCase, kebab, dots, underscores) const words = nameLower .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/[-_.]/g, ' ') .replace(/\.(md|js|html|json|css|txt|yaml|yml|toml|env|sh)$/i, '') .split(/\s+/) .filter(w => w.length > 2 && !/^\d+$/.test(w)); for (const word of words) { concepts.add(word); } // Extract file extension as concept const ext = path.split('.').pop(); if (ext && ['md', 'js', 'html', 'json', 'css'].includes(ext)) { concepts.add(ext + '-file'); } // Extract path-level concepts const parts = path.split('/').filter(p => p.length > 0); for (const part of parts) { if (['modules', 'skills', 'core', 'ext', 'backups', 'chat', 'downloads'].includes(part)) { concepts.add(part); } } // Extract #tags from content (YAML frontmatter or inline) if (content) { let tags = []; const yamlMatch = content.match(/tags:\s*\[([^\]]+)\]/i); if (yamlMatch) { tags = yamlMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '').replace(/^#/, '')); } else { const hashTags = content.match(/#[a-zA-Z]\w+/g) || []; tags = hashTags.map(t => t.slice(1)); } for (const tag of tags) { if (tag.length > 2) concepts.add(tag.toLowerCase()); } // Extract first H1/H2 as concept const headerMatch = content.match(/^#\s+(.+)/m) || content.match(/^##\s+(.+)/m); if (headerMatch) { const header = headerMatch[1].toLowerCase().replace(/[^a-z0-9\s]/g, '').trim(); if (header.length > 3 && header.length < 40) { concepts.add(header); } } } return [...concepts]; } _hybridShellCheck(concept) { const fs = this.terminal?.fs; if (!fs) return []; const words = concept.split(/\s+/).filter(w => w.length > 2); if (words.length === 0) return []; const results = []; this._walk('/', fs, {}, (path, node) => { if (this._isSystemFile(path)) return; const lower = path.toLowerCase(); if (words.some(w => lower.includes(w))) { results.push({ path, summary: this._summarize(node.content || '') }); } }); return results; } _summarize(content) { if (!content || content.length < 10) return ''; // Strip YAML frontmatter const cleaned = content.replace(/---[\s\S]*?---/, '').trim(); // 1. Try first ## section heading const sectionMatch = cleaned.match(/^##\s+(.+)/m); if (sectionMatch && sectionMatch[1].trim().length > 5) { const s = sectionMatch[1].trim(); return s.slice(0, 100) + (s.length > 100 ? '...' : ''); } // 2. Try first paragraph > 40 chars after frontmatter const lines = cleaned.split('\n').filter(l => l.trim().length > 0); for (const line of lines) { const trimmed = line.trim(); if (trimmed.length > 40 && !trimmed.startsWith('#') && !trimmed.startsWith('//')) { return trimmed.slice(0, 100) + (trimmed.length > 100 ? '...' : ''); } } // 3. First meaningful sentence (> 30 chars) const sentence = cleaned.match(/[^.!?\n]+[.!?]/); if (sentence && sentence[0].trim().length > 30) { const s = sentence[0].trim(); return s.slice(0, 100) + (s.length > 100 ? '...' : ''); } // 4. Fallback: first line > 20 chars (original behavior) const firstLine = lines.find(l => l.trim().length > 20); if (firstLine) { const tl = firstLine.trim(); return tl.slice(0, 100) + (tl.length > 100 ? '...' : ''); } return ''; } _indexSkills(fs, index) { const skills = fs.readDir('/skills'); if (!skills) return; for (const skill of skills) { if (skill.type !== 'file' || !skill.name.endsWith('.md')) continue; // Already indexed by _walk — but we add a "skill" tag const base = skill.name.replace('.md', ''); if (!index['skill']) index['skill'] = []; index['skill'].push({ path: '/skills/' + skill.name, summary: 'Skill: ' + base }); } } _saveIndex(fs) { try { if (!fs.readDir('/.semantic_index')) { fs.mkdirRecursive('/.semantic_index'); } fs.fsWriteJSON(this.indexPath, this.index); // Also persist content hash cache for incremental diffing fs.fsWriteJSON(this.hashCachePath, this._hashCache); } catch (e) { console.error('NeuralVFS save error:', e); } } // ── Auto-Sync ── markDirty(path) { this._dirtyPaths.add(path); // Debounced auto-sync: wait 10s after last mutation if (this._autoSyncTimer) clearTimeout(this._autoSyncTimer); this._autoSyncTimer = setTimeout(() => this._reindexDirtyPaths(), 10000); } async _reindexDirtyPaths() { if (this._dirtyPaths.size === 0) return; const fs = this.terminal?.fs; if (!fs) { this._dirtyPaths.clear(); return; } const start = Date.now(); let reindexed = 0; const reindexedPaths = []; this._loadHashCache(fs); for (const path of [...this._dirtyPaths]) { const content = fs.readFile(path); if (content !== null) { const hash = this._contentHash(content); const cached = this._getCached(path, hash); if (!cached) { this._reindexFile(path, content, this.index, this._deepIndexEnabled); this._hashCache[path] = { hash }; reindexed++; reindexedPaths.push(path); } } else { // File was deleted — remove from index for (const entries of Object.values(this.index)) { const idx = entries.findIndex(e => e.path === path); if (idx !== -1) entries.splice(idx, 1); } delete this._hashCache[path]; } } this._dirtyPaths.clear(); this._autoSyncTimer = null; this.lastScan = new Date().toISOString(); this._saveIndex(fs); const elapsed = ((Date.now() - start) / 1000).toFixed(1); if (reindexed > 0) { this.terminal?.print(`🔄 Auto-sync: re-indexed ${reindexed} changed file(s) in ${elapsed}s`, 'success'); const cap = 20; for (const p of reindexedPaths.slice(0, cap)) { this.terminal?.print(` • ${p}`, ''); } if (reindexedPaths.length > cap) { this.terminal?.print(` ... and ${reindexedPaths.length - cap} more`, 'info'); } } } // ── Enum helpers for autocomplete ── _getNvfsConfigKeys() { return ['fuzzy-limit', 'tree-files', 'tree-concepts', 'resolve-files', 'deep-index', 'deep-model', 'exclude-patterns', 'exclude-dirs', 'exclude-files', 'include-only']; } _getNvfsAliasActions() { return ['list', 'add', 'remove', 'clear']; } // ── Aliases ── _loadAliases() { try { const r = localStorage.getItem('webx11_nvfs_aliases'); if (r) this._aliasMap = JSON.parse(r); } catch (e) {} } _saveAliases() { localStorage.setItem('webx11_nvfs_aliases', JSON.stringify(this._aliasMap)); } // ── Status Bar Polling ── _updateStatusBar() { if (!this.terminal || !this.terminal.statusBar) return; const el = this.terminal.statusBar; if (this._deepIndexing) { const pct = this._deepTotal > 0 ? Math.round(this._deepProcessed / this._deepTotal * 100) : 0; el.textContent = `🧠 Deep Indexing... ${this._deepProcessed}/${this._deepTotal} (${pct}%)`; el.style.color = 'var(--accent, #00ff88)'; } else if (this._autoSyncTimer !== null || this._dirtyPaths.size > 0) { el.textContent = `🔄 Auto-sync pending (${this._dirtyPaths.size} dirty)`; el.style.color = 'var(--warning, #ffaa00)'; } else { const count = Object.keys(this.index).length; el.textContent = `🧠 ${count} concepts${this._deepIndexEnabled ? ' 🌊' : ''}`; el.style.color = ''; } } // ── Deep Indexing (LLM-assisted) ── _parseLLMConcepts(llmResponse) { // LLM returns markdown bullet list: "- concept: description" const concepts = []; if (!llmResponse) return concepts; const lines = llmResponse.split('\n'); for (const line of lines) { const trimmed = line.replace(/^[-*\s]+/, '').trim(); if (!trimmed) continue; // Split on first colon or comma or dash const colonIdx = trimmed.indexOf(':'); const commaIdx = trimmed.indexOf(','); const delimIdx = colonIdx > 0 ? colonIdx : (commaIdx > 0 ? commaIdx : -1); const concept = delimIdx > 0 ? trimmed.slice(0, delimIdx).trim().toLowerCase() : trimmed.toLowerCase(); if (concept && concept.length > 2 && concept.length < 60) { concepts.push(concept); } } return [...new Set(concepts)]; } async _callLLM(prompt, context) { const om = this.terminal?.ollamaManager; if (!om || !om.connected) return null; const fullPrompt = `Extract 2-5 key searchable concepts from this file. Return one per line as lowercase keywords (1-3 words). File: ${context.path} Content: ${context.content.slice(0, 2000)} Concepts:`; try { const result = await om._chatWithProvider(om.currentProvider, fullPrompt, this._deepModel); return result; } catch (e) { console.warn('LLM concept extraction failed:', e); return null; } } async _processDeepQueue(index) { this._deepProcessed = 0; this._deepTotal = this._deepQueue.length; const batchSize = 3; const results = []; while (this._deepQueue.length > 0) { // Check cancel between batches if (this._cancelRequested) { this._cancelRequested = false; this._deepIndexing = false; this._deepQueue = []; this._deepProcessed = 0; this._deepTotal = 0; this._updateStatusBar(); this.terminal?.print('⛔ Deep index cancelled', 'warning'); return; } const batch = this._deepQueue.splice(0, batchSize); for (const item of batch) { // Check cancel before each LLM call so user can abort mid-batch if (this._cancelRequested) { this._cancelRequested = false; this._deepIndexing = false; this._deepQueue = []; this._deepProcessed = 0; this._deepTotal = 0; this._updateStatusBar(); // Persist any concepts collected so far before exiting const vfs = this.terminal?.fs; if (vfs) this._saveIndex(vfs); this.terminal?.print('⛔ Deep index cancelled mid-batch', 'warning'); return; } const context = { path: item.path, content: item.content }; const llmResponse = await this._callLLM('', context); // Check cancel after the (possibly long) LLM call before processing if (this._cancelRequested) { this._cancelRequested = false; this._deepIndexing = false; this._deepQueue = []; this._deepProcessed = 0; this._deepTotal = 0; this._updateStatusBar(); const vfs = this.terminal?.fs; if (vfs) this._saveIndex(vfs); this.terminal?.print('⛔ Deep index cancelled after LLM call', 'warning'); return; } if (llmResponse) { const concepts = this._parseLLMConcepts(llmResponse); results.push({ path: item.path, concepts }); for (const concept of concepts) { if (!index[concept]) index[concept] = []; const exists = index[concept].some(e => e.path === item.path); if (!exists) index[concept].push({ path: item.path, summary: this._summarize(item.content), source: 'llm' }); } } this._deepProcessed++; // Update status bar live this._updateStatusBar(); } // Yield to UI between batches await new Promise(r => setTimeout(r, 50)); } this._deepIndexing = false; this._deepQueue = []; this._deepProcessed = 0; this._deepTotal = 0; this._updateStatusBar(); if (results.length > 0) { const llmConcepts = results.reduce((s, r) => s + r.concepts.length, 0); const totalLLM = Object.values(index).flat().filter(e => e.source === 'llm').length; this.terminal?.print(`🧠 Deep pass: added ${llmConcepts} new LLM concepts (${results.length} file${results.length === 1 ? '' : 's'}). Total in index: ${totalLLM}`, 'success'); // List processed files so user can see if one file keeps re-processing (loop detection) const cap = 20; if (results.length <= cap) { this.terminal?.print(` Files processed this pass:`, 'info'); for (const r of results) { this.terminal?.print(` • ${r.path} (${r.concepts.length} concepts)`, ''); } } else { this.terminal?.print(` First ${cap} of ${results.length} files:`, 'info'); for (const r of results.slice(0, cap)) { this.terminal?.print(` • ${r.path} (${r.concepts.length} concepts)`, ''); } } } // Persist LLM-generated concepts to disk so they survive page refresh. // Do NOT bypass _storageSavePending (race condition: bypassing re-triggers // a debounced saveToStorage 2s later that can clobber LLM concepts if the // user edits a file in the meantime). Just trigger a normal save — the // 2s debounce is fine because the user isn't waiting on the keyboard. const vfs = this.terminal?.fs; if (vfs) { // await any in-flight save so this write lands on a clean slate if (vfs._storageSavePending) { await new Promise(resolve => { const check = setInterval(() => { if (!vfs._storageSavePending) { clearInterval(check); resolve(); } }, 50); }); } this._saveIndex(vfs); } } // ── Nvfs Save (force-persist in-memory index) ── async nvfsSave() { const fs = this.terminal?.fs; if (!fs) return this.terminal?.print('VFS not available', 'error'); const count = Object.keys(this.index).length; if (count === 0) return this.terminal?.print('No index in memory to save', 'warning'); // Await any in-flight VFS save first — otherwise our write lands mid-flush // and the debounce timer fires 2s later, clobbering our concepts. if (fs._storageSavePending) { await new Promise(resolve => { const check = setInterval(() => { if (!fs._storageSavePending) { clearInterval(check); resolve(); } }, 50); }); } this._saveIndex(fs); this.terminal?.print(`✅ Saved ${count} concepts to ${this.indexPath}`, 'success'); this.terminal?.print(' Index will survive page refresh', 'info'); } // ── Nvfs Sync (incremental background) ── async nvfsSync(args) { const useDeep = args && args.includes('--deep'); if (this._dirtyPaths.size === 0) { this.terminal.print('No dirty files to sync', 'info'); return; } this.terminal.print(`🔄 Syncing ${this._dirtyPaths.size} dirty file(s)${useDeep ? ' (deep mode)' : ''}...`, 'info'); const fs = this.terminal?.fs; if (!fs) return; await this._reindexDirtyPaths(); // After reindex, if deep mode, queue remaining queued items for LLM processing if (useDeep && this._deepQueue.length > 0) { this._deepIndexing = true; await this._processDeepQueue(this.index); } } cancelIndex() { this._cancelRequested = true; this.terminal?.print('⏳ Cancelling index...', 'warning'); } async init(terminal) { // Auto-load saved index from VFS on startup so last index survives refresh const fs = terminal?.fs || this.terminal?.fs; if (!fs) return; // Await VFS async loadFromStorage() — it uses CompressionStream which is async. // module-manager.js awaits this function (line 33), so the rest of init runs // before any other module can call nvfs-status. await Promise.resolve(fs._ready); const saved = fs.fsReadJSON(this.indexPath); if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) { this.index = saved; this._loadHashCache(fs); this._loadAliases(); this.lastScan = '(loaded from disk)'; // One-time migration: purge any stale /sys/... entries left over from // pre-v4.5.34 builds where the /sys prefix wasn't in _isSystemFile(). // These entries cause a permanent re-index loop because /sys files use // _factory dynamic content (timestamps, heap stats) so their hash // never matches and they get re-queued every nvfs-index --deep run. let purgedConcepts = 0; let purgedFiles = 0; for (const [concept, entries] of Object.entries(this.index)) { if (!Array.isArray(entries)) continue; const before = entries.length; const filtered = entries.filter(e => !(e.path && e.path.startsWith('/sys'))); if (filtered.length !== before) purgedConcepts += (before - filtered.length); if (filtered.length > 0) this.index[concept] = filtered; else delete this.index[concept]; } for (const path of Object.keys(this._hashCache)) { if (path.startsWith('/sys')) { delete this._hashCache[path]; purgedFiles++; } } if (purgedConcepts > 0 || purgedFiles > 0) { this._saveIndex(fs); this.terminal?.print(`🧹 Migrated stale index: purged ${purgedConcepts} /sys concepts + ${purgedFiles} hash entries from pre-v4.5.34 build`, 'warning'); } } } // v4.5.89: Semantic bridge — mirror graph facts into VFS for unified search indexGraphFacts() { const mg = this.terminal.moduleManager.modules['MemoryGraphModule']; if (!mg) return this.terminal.print('❌ Memory Graph module not loaded', 'error'); const fs = this.terminal.fs; if (!fs) return this.terminal.print('❌ VFS not available', 'error'); const factNodes = Object.values(mg._nodes).filter(function(n) { return n.type === 'concept' || n.type === 'fact' || n.type === 'entity' || n.type === 'task'; }); if (factNodes.length === 0) { return this.terminal.print('No graph fact nodes to index. Use memgraph-learn first.', 'info'); } if (!fs.readDir('/_graph_facts')) fs.mkdir('_graph_facts'); // v4.5.89b fix: Clean stale files for deleted nodes (P2 from review) const validKeys = new Set(factNodes.map(function(n) { return n.id.replace(/[^a-zA-Z0-9_-]/g, '_') + '.txt'; })); try { const existing = fs.readDir('/_graph_facts') || []; for (const f of existing) { if (!validKeys.has(f)) fs.deleteFile('/_graph_facts/' + f); } } catch (_) {} let indexed = 0; for (const node of factNodes) { let content = node.label + '\n'; if (node.metadata) { if (node.metadata.full) content += node.metadata.full + '\n'; if (node.metadata.result) content += 'Result: ' + node.metadata.result + '\n'; } content += 'Type: ' + node.type + '\n'; content += 'Namespace: ' + (node.namespace || 'global') + '\n'; if (node.source_session) content += 'Session: ' + node.source_session + '\n'; const safeName = node.id.replace(/[^a-zA-Z0-9_-]/g, '_'); fs.writeFile('/_graph_facts/' + safeName + '.txt', content); indexed++; } this.terminal.print('🔗 Indexed ' + indexed + ' graph facts into /_graph_facts/', 'success'); this.terminal.print(' Run nvfs-index to include them in semantic search', 'info'); } // v4.5.89b: Index a single fact — O(1) instead of O(n) re-index (P2 from review) indexSingleFact(factKey, entity, fact) { const fs = this.terminal.fs; if (!fs) return; if (!fs.readDir('/_graph_facts')) fs.mkdir('_graph_facts'); const safeName = factKey.replace(/[^a-zA-Z0-9_-]/g, '_'); const content = entity + '\n' + fact + '\nType: fact\nNamespace: global\n'; fs.writeFile('/_graph_facts/' + safeName + '.txt', content); } } // Required for module system if (typeof window !== 'undefined') { window.NeuralVFSModule = NeuralVFSModule; } /* === module-smartctx.js === */ // Extracted from newOS4 index.html — SmartContextModule class SmartContextModule { constructor() { this.commands = {}; this.terminal = null; this.contextProfiles = this.loadProfiles(); this.activeProfile = this.loadActiveProfile(); } getHelpInfo() { return { 'context': 'context [name] — Set or view current context profile', 'contexts': 'contexts — List all available context profiles', 'context-new': 'context-new <name> <displayName> <systemPrompt> — Create a new context profile', 'context-delete': 'context-delete <name> — Delete a context profile', 'context-auto': 'context-auto [on|off] — Toggle auto-routing (detects intent from user input)', 'smart-ask': 'smart-ask <question> — Ask a question using the active context' }; } loadProfiles() { try { const saved = localStorage.getItem('webx11_context_profiles'); return saved ? JSON.parse(saved) : this.getDefaultProfiles(); } catch (e) { return this.getDefaultProfiles(); } } saveProfiles() { localStorage.setItem('webx11_context_profiles', JSON.stringify(this.contextProfiles)); } loadActiveProfile() { try { const saved = localStorage.getItem('webx11_active_context_profile'); if (saved && this.contextProfiles[saved]) return saved; } catch (e) {} return null; } saveActiveProfile() { try { if (this.activeProfile) { localStorage.setItem('webx11_active_context_profile', this.activeProfile); } else { localStorage.removeItem('webx11_active_context_profile'); } } catch (e) {} } getDefaultProfiles() { return { 'coding': { name: 'Coding Assistant', systemPrompt: "You are a WebX11 newOS4 coding expert. You have access to the full terminal, VFS, and module system.\n\nAvailable commands: view_module, edit_module, test_module, reload_module, list_modified_modules, check_updates, load-local, loadmod, removemod, startup-modules, node, js-run, git, diff, patch\n\nSkills available: module-management (view/edit/test/reload modules), skills-system-guide (create/patch skills)\n\nBehavior:\n- Always show exact code changes when editing modules\n- Use edit_module for modifications, test_module to verify\n- Create checkpoints before risky edits: checkpoint mod-edit-<name>\n- Reference the module-management skill for the full workflow\n- When the user reports a bug, first view_module to inspect, then edit\n- Use nvfs_resolve to find relevant files before editing\n- Prefer write_file for creating new files, edit_module for modifying modules", temperature: 0.7 }, 'debug': { name: 'Debug Helper', systemPrompt: "You are a WebX11 newOS4 debugging expert. Your goal is to diagnose and fix issues systematically.\n\nAvailable commands: debug, debug-ai, debug-ai-last, debug-ai-send, debug-ai-log, logs, logrotate, health-check, check, mem, status, archive-list, archive-read, guardrail, shadow, shadow-commit, shadow-rollback\n\nSkills available: debug-panel-and-logs (toggle debug panel, session logging, diagnostics), system-maintenance (health checks, config, security)\n\nBehavior:\n- Start with health-check and check to assess system state\n- Use debug-ai to see the last AI tool call and response\n- Use logs 50 ERROR to find error entries\n- Check browser console for JS errors\n- Use guardrail to inspect security policies\n- Use shadow for safe execution of risky commands\n- Always check module loading status first", temperature: 0.3 }, 'creative': { name: 'Creative Writer', systemPrompt: "You are a creative assistant for WebX11 newOS4. You help with brainstorming, content creation, and design.\n\nAvailable commands: mindmap new/add/show/save/load/export, floating-add/remove/list, ai-system, ai-config\n\nSkills available: mindmap-workflow (create/view/edit mindmaps), floating-ui (create floating buttons/menus/panels)\n\nBehavior:\n- Use mindmap for brainstorming and organizing ideas\n- Use floating-add to create quick-access UI elements\n- Use web_search and web_fetch for research and inspiration\n- Higher temperature (0.9) for creative divergence\n- When the user says \"brainstorm\" or \"diagram\", create a mindmap\n- Use create_mindmap AI tool for visual architecture diagrams\n- Reference the mindmap-workflow skill for full capabilities", temperature: 0.9 }, 'technical': { name: 'Technical Docs', systemPrompt: "You are a technical documentation expert for WebX11 newOS4. You help with docs, guides, and system understanding.\n\nAvailable commands: help, commands, status, modules, health-check, nvfs-index, nvfs-resolve, view_module, cat, edit, grep, find, tree\n\nSkills available: navigating-terminal (explore terminal, filesystem, system state), web-search-and-fetch (search web, fetch URLs), skills-system-guide (create/patch skills)\n\nBehavior:\n- Use help and commands to discover available functionality\n- Use nvfs-resolve to find files by concept\n- Use view_module to inspect module source for documentation\n- When creating documentation, use write_file to save to VFS\n- Reference the navigating-terminal skill for filesystem exploration\n- Use web_search for external research", temperature: 0.3 }, 'chat': { name: 'Casual Chat', systemPrompt: "You are a friendly, helpful assistant for WebX11 newOS4. You handle general questions, casual conversation, and light tasks.\n\nAvailable commands: ai, ask, search, fetch, status, help, user_get, user_set, session_save, session_recent, my-set, my-get\n\nSkills available: memory-and-profile (user profile, session memory, checkpoints), web-search-and-fetch (search web, fetch URLs)\n\nBehavior:\n- Be conversational and approachable\n- Use user_get/user_set to remember user preferences\n- Use session_recent at start to recall past work\n- Use session_save after completing tasks\n- For complex tasks, suggest switching to a more specific context profile\n- Reference the memory-and-profile skill for persistence features", temperature: 0.8 }, 'system-admin': { name: 'System Admin', systemPrompt: "You are a WebX11 newOS4 system administrator. You handle maintenance, backup, security, and configuration.\n\nAvailable commands: config-list, config-set, config-delete, config-reset, security-level, security, cmd-allow, cmd-deny, backup, vfs-export, webdav-backup, webdav-restore, webdav-config, webdav-status, factory-reset, reset-vfs, checkpoint, checkpoint-list, rollback, health-check, check_updates, font-size\n\nSkills available: system-maintenance (reset, config, security, health checks), backup-and-restore (VFS backup, WebDAV cloud backup, restore), webdav-operations (cloud storage, file sync), security-and-safety (security levels, command blocking)\n\nBehavior:\n- Always create a checkpoint before making system changes\n- Use webdav-backup before destructive operations\n- Check security-level before running commands\n- Use health-check for full system audit\n- Reference the system-maintenance skill for reset procedures\n- Reference the backup-and-restore skill for data safety", temperature: 0.3 }, 'research': { name: 'Research Assistant', systemPrompt: "You are a research assistant for WebX11 newOS4. You gather information, analyze data, and synthesize findings.\n\nAvailable commands: search, fetch, web_search, web_fetch, nvfs-resolve, nvfs-index, grep, find, cat, view_module, session_recent, session_load, session_stats\n\nSkills available: web-search-and-fetch (search web, fetch URL content), memory-and-profile (session memory, checkpoints), navigating-terminal (explore filesystem)\n\nBehavior:\n- Start with web_search for external research\n- Use nvfs-resolve to find relevant VFS files\n- Use session_recent to check past work on the topic\n- Use fetch to read full articles from URLs\n- Synthesize findings before presenting\n- Reference the web-search-and-fetch skill for search flags", temperature: 0.5 }, 'security': { name: 'Security Expert', systemPrompt: "You are a WebX11 newOS4 security expert. You handle threat assessment, command control, and safety.\n\nAvailable commands: security, security-level, cmd-allow, cmd-deny, guardrail, guardrail-add, guardrail-remove, shadow, shadow-commit, shadow-rollback, health-check, check, logs, debug\n\nSkills available: security-and-safety (security levels, command blocking, safety checklist), debug-panel-and-logs (diagnostics, logging), system-maintenance (config, health checks)\n\nBehavior:\n- Always check current security level first with security\n- Use guardrail to inspect and manage security policies\n- Use shadow for safe execution of suspicious commands\n- Never recommend factory-reset without backup first\n- Use cmd-deny to block dangerous commands\n- Reference the security-and-safety skill for level descriptions", temperature: 0.2 } }; } registerCommands() { this.commands['context'] = (args) => this.setContext(args); this.commands['contexts'] = () => this.listContexts(); this.commands['context-new'] = (args) => this.createContext(args); this.commands['context-delete'] = (args) => this.deleteContext(args); this.commands['context-auto'] = (args) => this.toggleAutoRouting(args); this.commands['smart-ask'] = (args) => this.smartAsk(args); } setContext(args) { if (!args[0]) { this.terminal.print(`Current context: ${this.activeProfile || 'none'}`, "info"); return; } if (this.contextProfiles[args[0]]) { this.activeProfile = args[0]; this.saveActiveProfile(); this.terminal.print(`Context set to: ${this.contextProfiles[args[0]].name}`, "success"); // Also switch agent loop profile to match const PROFILE_MAP = { 'coding': 'coder', 'debug': 'minimal', 'creative': 'creative', 'technical': 'research', 'chat': 'default', 'system-admin': 'safe', 'research': 'research', 'security': 'safe' }; const targetLoop = PROFILE_MAP[args[0]]; if (targetLoop) { const aiMod = this.terminal?.moduleManager?.modules?.WebX11AIAssistantModule; if (aiMod && aiMod._switchLoopProfile) { aiMod._switchLoopProfile(targetLoop); this.terminal.print(` Agent loop switched to: ${targetLoop}`, "text-muted"); } } else { // Custom profile — rebuild system prompt without loop switch const aiMod = this.terminal?.moduleManager?.modules?.WebX11AIAssistantModule; if (aiMod && aiMod.buildSystemPrompt) { aiMod.systemPrompt = aiMod.buildSystemPrompt(); } this.terminal.print(` No agent loop mapped for '${args[0]}'`, "text-muted"); } } else { this.terminal.print(`Profile '${args[0]}' not found`, "error"); } } listContexts() { this.terminal.print("=== Context Profiles ===", "info"); Object.entries(this.contextProfiles).forEach(([key, profile]) => { const active = key === this.activeProfile ? ' ✅' : ''; this.terminal.print(`${key}: ${profile.name}${active}`); }); } createContext(args) { if (args.length < 3) { this.terminal.print("Usage: context-new <name> <displayName> <systemPrompt>", "warning"); return; } const name = args[0], displayName = args[1], systemPrompt = args.slice(2).join(' '); this.contextProfiles[name] = { name: displayName, systemPrompt, temperature: 0.7 }; this.saveProfiles(); this.terminal.print(`Context profile '${name}' created`, "success"); } deleteContext(args) { if (!args[0]) { this.terminal.print("Usage: context-delete <name>", "warning"); return; } if (this.contextProfiles[args[0]]) { delete this.contextProfiles[args[0]]; this.saveProfiles(); if (this.activeProfile === args[0]) { this.activeProfile = null; } this.terminal.print(`Context profile '${args[0]}' deleted`, "success"); } else { this.terminal.print(`Profile '${args[0]}' not found`, "error"); } } // ─── Intent Detection Engine ─── _getIntentPatterns() { return { 'coding': [ /^code\b/i, /\b(edit|fix|write|create|implement|refactor|build)\s+(module|function|class|file|code|script)/i, /\b(debug|bug|error|fix)\s+(code|module|function|script)/i, /\b(implement|write|create)\s+(a\s+)?(function|class|module|command)/i, /\b(refactor|rewrite|optimize)\s+(code|module|function)/i, /\b(add\s+)(feature|command|functionality)/i ], 'debug': [ /^debug\b/i, /\b(error|crash|bug|fail|traceback|exception|broken|not working)\b/i, /\b(why|what happened|diagnose|investigate|find out why)\b/i, /\b(check|inspect|examine)\s+(log|error|issue|problem)/i ], 'research': [ /^research\b/i, /\b(search|find|look up|research|investigate|analyze)\b/i, /\b(what is|how does|tell me about|explain)\b.*\?$/i, /\b(find|search)\s+(for\s+)?(information|docs|documentation|article)/i ], 'creative': [ /^brainstorm\b/i, /\b(create|design|imagine|generate|compose|draft)\s+(story|poem|art|design|idea|concept|mindmap)/i, /\b(creative|inspire|innovative|novel)\b/i ], 'system-admin': [ /^(config|setup|install|deploy|backup|restore|security|monitor|maintenance)\b/i, /\b(configure|setup|install|deploy|backup|restore)\s+(system|server|module|service)/i, /\b(security|permission|access|block|allow)\b/i ], 'chat': [ /^(hi|hello|hey|how are you|what's up|good morning|good evening)\b/i, /^.{0,30}\?$/ // short questions ] }; } autoDetectIntent(text) { if (!text || typeof text !== 'string') return { profile: null, confidence: 0 }; const patterns = this._getIntentPatterns(); const scores = {}; const lower = text.toLowerCase(); for (const [profile, regexes] of Object.entries(patterns)) { let matches = 0; for (const re of regexes) { if (re.test(text) || re.test(lower)) matches++; } if (matches > 0) scores[profile] = matches; } // Code snippet detection (bias toward coding) if (/[{([\s]*['"`]/.test(text) || /\b(function|class|const|let|var|import|export|=>)\b/.test(text)) { scores['coding'] = (scores['coding'] || 0) + 2; } // Very short text → chat if (text.length < 15 && !scores['coding'] && !scores['debug']) { scores['chat'] = (scores['chat'] || 0) + 1; } // Find best match let bestProfile = null; let bestScore = 0; let totalScore = 0; for (const [profile, score] of Object.entries(scores)) { totalScore += score; if (score > bestScore) { bestScore = score; bestProfile = profile; } } // Calculate confidence (0-100) const confidence = totalScore > 0 ? Math.round((bestScore / totalScore) * 100) : 0; return { profile: bestProfile, confidence }; } autoRouteContext(text) { if (!text) return { switched: false, from: this.activeProfile, to: this.activeProfile, confidence: 0, reason: 'no input' }; // Check if auto-routing is enabled const enabled = localStorage.getItem('webx11_context_auto') !== 'off'; if (!enabled) return { switched: false, from: this.activeProfile, to: this.activeProfile, confidence: 0, reason: 'auto-routing disabled' }; const result = this.autoDetectIntent(text); if (!result.profile || result.confidence < 70) { return { switched: false, from: this.activeProfile, to: this.activeProfile, confidence: result.confidence, reason: 'confidence too low (<70%)' }; } // Don't switch if already on the best profile if (result.profile === this.activeProfile) { return { switched: false, from: this.activeProfile, to: this.activeProfile, confidence: result.confidence, reason: 'already on best profile' }; } // Check if the target profile exists if (!this.contextProfiles[result.profile]) { return { switched: false, from: this.activeProfile, to: this.activeProfile, confidence: result.confidence, reason: 'profile "' + result.profile + '" not found' }; } // Switch const from = this.activeProfile; this.setContext([result.profile]); return { switched: true, from: from, to: result.profile, confidence: result.confidence, reason: 'intent detected' }; } toggleAutoRouting(args) { const val = args && args[0] ? args[0].toLowerCase() : null; if (val === 'on' || val === 'true' || val === '1') { localStorage.setItem('webx11_context_auto', 'on'); this.terminal.print('✅ Auto-routing enabled — context will switch based on intent', 'success'); } else if (val === 'off' || val === 'false' || val === '0') { localStorage.setItem('webx11_context_auto', 'off'); this.terminal.print('⛔ Auto-routing disabled — use context <name> to switch manually', 'warning'); } else { const current = localStorage.getItem('webx11_context_auto') !== 'off'; this.terminal.print('Auto-routing: ' + (current ? 'ON' : 'OFF'), 'info'); this.terminal.print('Usage: context-auto on|off', 'info'); } } async smartAsk(args) { if (!args[0]) { this.terminal.print("Usage: smart-ask <your-question>", "warning"); return; } let systemPrompt = "You are a helpful assistant."; let temperature = 0.7; if (this.activeProfile && this.contextProfiles[this.activeProfile]) { const profile = this.contextProfiles[this.activeProfile]; systemPrompt = profile.systemPrompt; temperature = profile.temperature || 0.7; this.terminal.print(`Using context: ${profile.name} (temp: ${temperature})`, "info"); } // Use agentic loop via the AI assistant module for tool-enabled responses const aiModule = this.terminal?.moduleManager?.modules?.WebX11AIAssistantModule; if (aiModule) { // Set temperature on ollamaManager modelParams temporarily const om = this.terminal?.ollamaManager; if (!om) { this.terminal?.print('⚠️ Ollama manager not available', 'error'); return; } const savedTemp = om.modelParams ? om.modelParams.temperature : undefined; if (om.modelParams) { om.modelParams.temperature = temperature; } try { const messages = [ { role: 'system', content: systemPrompt }, { role: 'user', content: args.join(' ') } ]; const result = await om.chatWithTools(messages, aiModule.getToolDefinitions()); if (result && result.message) { this.terminal.printAIResponse(result.message.content || '(no response)'); } else if (result && result.error) { this.terminal.print(`⚠️ AI error: ${result.error}`, 'error'); } } finally { if (om.modelParams && savedTemp !== undefined) { om.modelParams.temperature = savedTemp; } } } else { // Fallback: simple chat with temperature const om = this.terminal.ollamaManager; const fullPrompt = `${systemPrompt}\n\nUser: ${args.join(' ')}`; await om.chat(fullPrompt); } } } /* === module-debug-supervisor.js === */ // module-debug-supervisor.js — Persistent error capture, clustering, root-cause analysis, and auto-fix // Commands: debug-stats, debug-history, debug-analyze, debug-trends, debug-fix, debug-health class DebugSupervisorModule { constructor() { this.commands = {}; this.terminal = null; this._healthy = true; this._errorDB = { errors: {}, clusters: {}, patterns: {}, sessionId: null }; this._maxErrors = 500; this._ttlDays = 7; this._writeDebounce = null; this._pendingWrites = 0; this._originalConsole = {}; this._originalOnError = null; this._originalOnRejection = null; this._originalFetch = null; this._interceptorInstalled = false; this._sessionId = this._generateSessionId(); } _generateSessionId() { try { let sid = localStorage.getItem('webx11_debug_session'); if (!sid) { sid = Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8); localStorage.setItem('webx11_debug_session', sid); } return sid; } catch (e) { return Date.now().toString(36); } } getHelpInfo() { return { 'debug-stats': 'debug-stats — Show error summary (total, unique, clusters, top issues)', 'debug-history': 'debug-history [n] — Show last N errors (default: 20)', 'debug-analyze': 'debug-analyze <hash> — Deep analysis of a specific error', 'debug-trends': 'debug-trends [days] — Error frequency over time (default: 7)', 'debug-fix': 'debug-fix <hash> — Suggest and optionally apply fix for an error', 'debug-health': 'debug-health — Check supervisor module health status', 'debug-sync': 'debug-sync [url] — Sync errors to backend API (or set sync URL)', 'debug-sync-url': 'debug-sync-url [url] — View or set the backend sync endpoint', 'feedback': 'feedback <message> — Send feedback to the WebX11 maintenance agent', 'feedback-inbox': 'feedback-inbox — Read replies from the maintenance agent (admin session)', }; } registerCommands() { this.commands['debug-stats'] = () => this._cmdStats(); this.commands['debug-history'] = (args) => this._cmdHistory(args); this.commands['debug-analyze'] = (args) => this._cmdAnalyze(args); this.commands['debug-trends'] = (args) => this._cmdTrends(args); this.commands['debug-fix'] = (args) => this._cmdFix(args); this.commands['debug-health'] = () => this._cmdHealth(); this.commands['debug-sync'] = (args) => this._cmdSync(args); this.commands['debug-sync-url'] = (args) => this._cmdSyncUrl(args); // v4.6.71: one channel. `feedback` keeps working; `hermes` is the explicit name. // NOT `chat` — ChatStudioModule already owns that name and dispatch iterates // modules in order, so a second `chat` is silently unreachable. this.commands['feedback'] = (args) => this._cmdFeedback(args); this.commands['hermes'] = (args) => this._cmdFeedback(args); this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); this.commands['hermes-poll'] = () => this._cmdFeedbackInbox(); this.commands['hermes-reset'] = () => this._cmdChatReset(); // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback). this.commands['feedback-admin'] = (args) => this._fbDispatch(args); } seed() { // seed() is not called automatically by module manager — interceptors go in init() } init() { this._loadDB(); try { this._syncedCounts = JSON.parse(localStorage.getItem('webx11_debug_synced') || '{}'); } catch (_e) { this._syncedCounts = {}; } this._installInterceptors(); // Start periodic sync (every 5 minutes) this._syncInterval = setInterval(() => this._syncToBackend(), 300000); // Also sync on first load after a short delay setTimeout(() => this._syncToBackend(), 5000); // v4.6.71: replies land by themselves. 30 s, paused while the tab is hidden, // and the first poll only records the cursor — a reload must not replay history. this._chatTimer = setInterval(() => this._chatPoll(), 30000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') this._chatPoll(); }); setTimeout(() => this._chatPoll(), 4000); } // ── chat bridge (contract: _docs/chat-contract.md) ────────────────────── _chatToken() { try { let t = localStorage.getItem('webx11_chat_token'); if (!t || !/^[a-f0-9]{64}$/.test(t)) { const b = new Uint8Array(32); (window.crypto || window.msCrypto).getRandomValues(b); t = Array.from(b).map(x => x.toString(16).padStart(2, '0')).join(''); localStorage.setItem('webx11_chat_token', t); } return t; } catch (e) { return ''; } } _chatState() { try { return JSON.parse(localStorage.getItem('webx11_chat_state') || '{}') || {}; } catch (e) { return {}; } } _chatSaveState(st) { try { localStorage.setItem('webx11_chat_state', JSON.stringify(st)); } catch (e) {} } _chatThread() { // sha256(token)[0:16] is computed server-side; the client only needs the // cursor, which is scoped to the token it was issued for. const st = this._chatState(); const tok = this._chatToken(); return st.token === tok ? st : { token: tok, cursor: 0, thread: '' }; } async _chatPost(text, kind) { const token = this._chatToken(); if (!token) throw new Error('localStorage is blocked — no chat token'); const resp = await fetch('/spaces/newos4/api/chat/post.php', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Chat-Token': token }, body: JSON.stringify({ text: text, kind: kind || 'operator' }) }); let j = null; try { j = await resp.json(); } catch (e) { j = null; } if (!resp.ok || !j || !j.success) { const err = new Error((j && j.error) || ('HTTP ' + resp.status)); err.status = resp.status; err.code = (j && j.code) || ''; err.retryAfterMs = (j && j.retry_after_ms) || null; throw err; } const st = { token: token, cursor: j.epoch || 0, thread: j.thread || '' }; this._chatSaveState(st); return j; } _chatPoll(force, silent) { const st = this._chatThread(); const token = st.token; if (!token) return Promise.resolve(null); if (!force && document.visibilityState === 'hidden') return Promise.resolve(null); const q = st.cursor ? '?since=' + st.cursor : ''; return fetch('/spaces/newos4/api/chat/poll.php' + q, { headers: { 'X-Chat-Token': token } }) .then(r => r.ok ? r.json() : null) .then(j => { if (!j || !j.success) return null; const known = st.cursor > 0; const msgs = j.messages || []; if (!known) { // first poll for this token: adopt the cursor, print nothing this._chatSaveState({ token: token, cursor: j.cursor || 0, thread: j.thread || '' }); return j; } msgs.forEach(m => { if ((m.epoch || 0) > (this._chatThread().cursor || 0)) { this._chatSaveState({ token: token, cursor: m.epoch || 0, thread: j.thread || '' }); } if (m.author === 'hermes' && !silent) this.terminal.print('🤖 ' + m.text, ''); }); return j; }) .catch(() => null); } _cmdChatReset() { try { localStorage.removeItem('webx11_chat_token'); localStorage.removeItem('webx11_chat_state'); // a stale agent cursor would point past the end of the new, empty thread Object.keys(localStorage) .filter(k => k.indexOf('webx11_chat_agent_cursor') === 0) .forEach(k => localStorage.removeItem(k)); this.terminal.print('🔄 Chat token rotated — a new thread starts with your next message', 'success'); } catch (e) { this.terminal.print('❌ ' + e.message, 'error'); } } destroy() { // Clear sync interval if (this._syncInterval) { clearInterval(this._syncInterval); this._syncInterval = null; } // Restore original console.error if (this._originalConsole.error) { console.error = this._originalConsole.error; } // Restore original window.onerror if (this._originalOnError) { window.onerror = this._originalOnError; } // Restore original fetch if (this._originalFetch) { window.fetch = this._originalFetch; } // Remove unhandledrejection listener (stored for cleanup) if (this._rejectionHandler) { window.removeEventListener('unhandledrejection', this._rejectionHandler); } this._interceptorInstalled = false; this._healthy = false; } // ── Error Database ── _loadDB() { try { const raw = localStorage.getItem('webx11_debug_db'); if (raw) { const parsed = JSON.parse(raw); this._errorDB = parsed; this._pruneExpired(); } } catch (e) { // Corrupted DB — start fresh this._errorDB = { errors: {}, clusters: {}, patterns: {}, sessionId: this._sessionId }; } } _saveDB() { try { localStorage.setItem('webx11_debug_db', JSON.stringify(this._errorDB)); } catch (e) { // Quota exceeded — evict oldest 20% and retry this._evictLRU(0.2); try { localStorage.setItem('webx11_debug_db', JSON.stringify(this._errorDB)); } catch (e2) { // Still failing — supervisor degrades gracefully console.warn('[DebugSupervisor] DB write failed, errors may be lost'); } } } _scheduleSave() { this._pendingWrites++; if (this._writeDebounce) return; this._writeDebounce = setTimeout(() => { this._writeDebounce = null; if (this._pendingWrites > 0) { this._saveDB(); this._pendingWrites = 0; } }, 2000); } _pruneExpired() { const now = Date.now(); const maxAge = this._ttlDays * 86400000; let changed = false; for (const [hash, entry] of Object.entries(this._errorDB.errors)) { if (now - entry.lastSeen > maxAge) { delete this._errorDB.errors[hash]; changed = true; } } if (changed) this._saveDB(); } _evictLRU(fraction) { const entries = Object.entries(this._errorDB.errors); const sorted = entries.sort((a, b) => a[1].lastSeen - b[1].lastSeen); const toRemove = Math.ceil(sorted.length * fraction); for (let i = 0; i < toRemove; i++) { delete this._errorDB.errors[sorted[i][0]]; } } _checkQuota() { try { // Test localStorage is writable const testKey = '__quota_test__'; localStorage.setItem(testKey, '1'); localStorage.removeItem(testKey); return true; } catch (e) { return false; } } // ── Error Classification ── _classifyError(message, source, stack) { const type = this._detectType(message, stack); const severity = this._detectSeverity(type, message); const category = this._detectCategory(message, source, type); return { type, severity, category }; } _detectType(message, stack) { if (!message) return 'unknown'; if (message.includes('ReferenceError') || message.includes('is not defined')) return 'ReferenceError'; if (message.includes('TypeError') || message.includes('is not a function') || message.includes('cannot read property')) return 'TypeError'; if (message.includes('SyntaxError') || message.includes('Unexpected token')) return 'SyntaxError'; if (message.includes('HTTP') || message.includes('NetworkError') || message.includes('Failed to fetch')) return 'HTTP'; if (message.includes('timeout') || message.includes('Timeout') || message.includes('AbortError')) return 'timeout'; if (message.includes('QuotaExceededError') || message.includes('quota')) return 'quota'; if (message.includes('SecurityError') || message.includes('permission')) return 'security'; if (stack && stack.includes('tool_')) return 'tool'; return 'runtime'; } _detectSeverity(type, message) { if (type === 'SyntaxError' || type === 'ReferenceError' || type === 'quota') return 'fatal'; if (type === 'HTTP' && /\b5\d{2}\b/.test(message)) return 'error'; if (type === 'timeout' || type === 'security') return 'error'; if (type === 'tool') return 'warning'; return 'error'; } _detectCategory(message, source, type) { if (type === 'HTTP' || type === 'timeout') return 'network'; if (type === 'quota') return 'vfs'; if (type === 'security') return 'security'; if (source && (source.includes('module-') || source.includes('ext/'))) return 'module'; if (type === 'tool') return 'tool'; return 'runtime'; } _extractModule(source) { if (!source) return null; const m = source.match(/(module-[a-z-]+)\.js/); return m ? m[1] : null; } _computeHash(message, source, type) { const raw = (message || '') + '|' + (source || '') + '|' + (type || ''); // Simple hash (Fowler-Noll-Vo) let hash = 0x811c9dc5; for (let i = 0; i < raw.length; i++) { hash ^= raw.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(16); } // ── Error Interception ── _installInterceptors() { if (this._interceptorInstalled) return; const self = this; // Console.error this._originalConsole.error = console.error; console.error = function() { const args = Array.prototype.slice.call(arguments); const msg = args.map(a => typeof a === 'object' ? (a.message || JSON.stringify(a)) : String(a)).join(' '); self._recordError(msg, 'console.error', null); return self._originalConsole.error.apply(console, args); }; // window.onerror this._originalOnError = window.onerror; window.onerror = function(msg, src, line, col, err) { const fullMsg = msg + (err ? ' — ' + err.message : '') + ' (' + src + ':' + line + ':' + col + ')'; self._recordError(fullMsg, src || 'window.onerror', err ? err.stack : null); if (self._originalOnError) return self._originalOnError.call(window, msg, src, line, col, err); }; // Unhandled rejections this._originalOnRejection = window.onunhandledrejection; this._rejectionHandler = function(e) { const msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled Promise rejection'; self._recordError(msg, 'unhandledrejection', e.reason ? e.reason.stack : null); }; window.addEventListener('unhandledrejection', this._rejectionHandler); // Fetch failures this._originalFetch = window.fetch; window.fetch = function() { const args = arguments; const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '?'); // Never record failures of the debug-sync endpoint itself: that produced a // self-referential loop (38,495 identical "Fetch failed …/ingest.php" entries). if (url.indexOf('/api/debug/') !== -1) return self._originalFetch.apply(window, args); return self._originalFetch.apply(window, args).catch(function(err) { self._recordError('Fetch failed: ' + url + ' — ' + (err.message || err), url, err.stack); throw err; }); }; this._interceptorInstalled = true; } // ── Record Error ── recordToolError(toolName, args, errorMessage) { this._recordError(errorMessage, 'tool:' + toolName, null); } _recordError(message, source, stack) { if (!this._healthy) return; const classification = this._classifyError(message, source, stack); const hash = this._computeHash(message, source, classification.type); const now = Date.now(); if (this._errorDB.errors[hash]) { this._errorDB.errors[hash].count++; this._errorDB.errors[hash].lastSeen = now; if (this._errorDB.errors[hash].sessions.indexOf(this._sessionId) === -1) { this._errorDB.errors[hash].sessions.push(this._sessionId); } } else { // LRU eviction if at cap const keys = Object.keys(this._errorDB.errors); if (keys.length >= this._maxErrors) { this._evictLRU(0.1); } this._errorDB.errors[hash] = { count: 1, firstSeen: now, lastSeen: now, message: message, type: classification.type, severity: classification.severity, category: classification.category, source: source, stack: stack ? stack.split('\n').slice(0, 10).join('\n') : null, module: this._extractModule(source), sessions: [this._sessionId], }; } this._scheduleSave(); // v4.6.38 (A5): auto-file fatal/error-class failures, max 3 per day, deduped by hash. if (classification.severity === 'fatal' || classification.severity === 'error') { this._maybeAutoFile({ hash, message, source, type: classification.type, severity: classification.severity, stack }); } } _maybeAutoFile(e) { try { this._maybeAutoFileInner(e); } catch (_e) {} } _maybeAutoFileInner(e) { try { if (localStorage.getItem('webx11_autoreport') === 'off') return; } catch (_e) {} // Aborts/timeouts are the client's own cancellation, not application bugs, and the debug // pipeline's own requests must never be filed as reports (self-referential loop). // Measured 2026-10-21..23: 2 of 5 auto-filed reports were "user aborted a request". if (/(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)/i.test(String(e.message || ''))) return; if (/\/api\/debug\//.test(String(e.source || '')) || /\/api\/debug\//.test(String(e.message || ''))) return; const today = new Date().toISOString().slice(0, 10); let state = { day: today, count: 0, hashes: [] }; try { const raw = JSON.parse(localStorage.getItem('webx11_autoreport_day') || 'null'); if (raw && raw.day === today && Array.isArray(raw.hashes)) state = raw; } catch (_e) {} if (state.count >= 3) return; // daily quota if (state.hashes.indexOf(e.hash) !== -1) return; // one report per distinct error state.count++; state.hashes.push(e.hash); try { localStorage.setItem('webx11_autoreport_day', JSON.stringify(state)); } catch (_e) {} const payload = { title: 'Auto: ' + String(e.message || 'unknown error').slice(0, 120), body: 'Automatically filed by the debug supervisor (no user action).', diagnostics: { version: (typeof window !== 'undefined' && window.NEWOS4_VERSION) || 'unknown', url: (typeof location !== 'undefined') ? location.href : '', ua: (typeof navigator !== 'undefined') ? navigator.userAgent : '', auto: true, errors: [{ message: String(e.message || '').slice(0, 300), source: String(e.source || '').slice(0, 200), type: e.type, severity: e.severity, count: 1, // Auto-filed reports are the majority of the inbox; without the stack they are // a message with no location (the 2026-10-20 `toLowerCase` report proved it). stack: e.stack ? String(e.stack).split('\n').slice(0, 10).join('\n').slice(0, 1200) : null, }], }, }; try { // /api/debug/* is excluded from the fetch interceptor, so a failure here cannot loop. fetch('/spaces/newos4/api/debug/report.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).catch(function () {}); } catch (_e) {} } // ── Clustering (Jaccard similarity) ── _normalizeMessage(msg) { if (!msg) return ''; return msg .toLowerCase() .replace(/\b0x[a-f0-9]+\b/g, '0xHEX') .replace(/\b\d+\b/g, 'N') .replace(/["'].*?["']/g, '"STR"') .replace(/[^\w\s]/g, ' ') .replace(/\s+/g, ' ') .trim(); } _jaccardSimilarity(a, b) { const setA = new Set(a.split(' ').filter(Boolean)); const setB = new Set(b.split(' ').filter(Boolean)); if (setA.size === 0 && setB.size === 0) return 1; let intersection = 0; for (const token of setA) { if (setB.has(token)) intersection++; } const union = setA.size + setB.size - intersection; return union === 0 ? 0 : intersection / union; } _clusterErrors() { const errors = Object.entries(this._errorDB.errors); if (errors.length === 0) return []; const clusters = []; const assigned = new Set(); for (let i = 0; i < errors.length; i++) { if (assigned.has(i)) continue; const [hashI, entryI] = errors[i]; const normI = this._normalizeMessage(entryI.message); const cluster = { hashes: [hashI], messages: [entryI.message], count: entryI.count, types: new Set([entryI.type]), categories: new Set([entryI.category]) }; assigned.add(i); for (let j = i + 1; j < errors.length; j++) { if (assigned.has(j)) continue; const [hashJ, entryJ] = errors[j]; const normJ = this._normalizeMessage(entryJ.message); if (this._jaccardSimilarity(normI, normJ) >= 0.35) { cluster.hashes.push(hashJ); cluster.messages.push(entryJ.message); cluster.count += entryJ.count; cluster.types.add(entryJ.type); cluster.categories.add(entryJ.category); assigned.add(j); } } cluster.label = this._mostFrequent(cluster.messages); cluster.typeCount = cluster.types.size; cluster.categoryCount = cluster.categories.size; delete cluster.types; delete cluster.categories; clusters.push(cluster); } clusters.sort((a, b) => b.count - a.count); return clusters; } _mostFrequent(arr) { const freq = {}; let best = arr[0], bestCount = 1; for (const item of arr) { freq[item] = (freq[item] || 0) + 1; if (freq[item] > bestCount) { best = item; bestCount = freq[item]; } } return best; } // ── Root-Cause Analysis ── _analyzeRootCause(hash) { const entry = this._errorDB.errors[hash]; if (!entry) return { found: false }; const findings = []; const fixes = []; // Tool failure → check module if (entry.source && entry.source.startsWith('tool:')) { const toolName = entry.source.replace('tool:', ''); const mm = this.terminal?.moduleManager; if (mm) { // Check if any module has this command let found = false; for (const [modName, mod] of Object.entries(mm.modules)) { if (mod.commands && mod.commands[toolName]) { found = true; break; } } if (!found) { findings.push({ type: 'module_not_loaded', detail: 'No module provides command "' + toolName + '"' }); fixes.push({ action: 'loadmod', params: toolName, description: 'Load module providing ' + toolName }); } } } // HTTP error → check network if (entry.type === 'HTTP' || entry.type === 'timeout') { findings.push({ type: 'network_issue', detail: 'Network request failed: ' + entry.message.substring(0, 100) }); fixes.push({ action: 'test-net', params: null, description: 'Run network diagnostics' }); } // ReferenceError → check bundle if (entry.type === 'ReferenceError') { findings.push({ type: 'missing_class', detail: 'ReferenceError suggests a class or variable is not defined' }); fixes.push({ action: 'reload', params: null, description: 'Reload the page to re-initialize all modules' }); } // Module error → check module loader if (entry.category === 'module') { findings.push({ type: 'module_error', detail: 'Error in module: ' + (entry.module || 'unknown') }); fixes.push({ action: 'loadmod', params: entry.module, description: 'Reload module: ' + entry.module }); } return { found: findings.length > 0, findings, fixes }; } // ── Fix Registry ── _buildFixSuggestion(hash) { const entry = this._errorDB.errors[hash]; if (!entry) return null; const rca = this._analyzeRootCause(hash); if (!rca.found) { return { confidence: 0, suggestion: 'No known fix pattern for this error', autoApply: false }; } // Calculate confidence based on frequency let confidence = Math.min(0.5, entry.count / 10); if (entry.count >= 3) confidence = 0.7; if (entry.count >= 10) confidence = 0.9; return { confidence: confidence, suggestion: rca.findings.map(f => f.detail).join('; '), fixes: rca.fixes, autoApply: confidence >= 0.9, }; } // ── Terminal Commands ── _cmdStats() { const db = this._errorDB.errors; const keys = Object.keys(db); if (keys.length === 0) { this.terminal.print('📊 Debug Stats: No errors recorded', 'success'); return; } const totalCount = keys.reduce((sum, k) => sum + db[k].count, 0); const bySeverity = {}; const byCategory = {}; for (const k of keys) { const e = db[k]; bySeverity[e.severity] = (bySeverity[e.severity] || 0) + e.count; byCategory[e.category] = (byCategory[e.category] || 0) + e.count; } const clusters = this._clusterErrors(); const topClusters = clusters.slice(0, 5); this.terminal.print('📊 Debug Stats', 'header'); this.terminal.print(' Total errors: ' + totalCount, 'info'); this.terminal.print(' Unique errors: ' + keys.length, 'info'); this.terminal.print(' Clusters: ' + clusters.length, 'info'); this.terminal.print(' Session: ' + this._sessionId, 'text-muted'); this.terminal.print(' Supervisor: ' + (this._healthy ? '✅ healthy' : '🔴 degraded'), this._healthy ? 'success' : 'error'); this.terminal.print('── By Severity ──', 'info'); for (const [sev, count] of Object.entries(bySeverity)) { const icon = sev === 'fatal' ? '🔴' : sev === 'error' ? '🟡' : '🟢'; this.terminal.print(' ' + icon + ' ' + sev + ': ' + count, sev === 'fatal' ? 'error' : 'warning'); } this.terminal.print('── By Category ──', 'info'); for (const [cat, count] of Object.entries(byCategory)) { this.terminal.print(' • ' + cat + ': ' + count, 'text-muted'); } if (topClusters.length > 0) { this.terminal.print('── Top Clusters ──', 'info'); for (const c of topClusters) { this.terminal.print(' [' + c.hashes.length + ' errors ×' + c.count + '] ' + c.label.substring(0, 80), 'warning'); } this.terminal.print(' Run debug-analyze <hash> for details', 'text-muted'); } } _cmdHistory(args) { const n = (args && args[0]) ? parseInt(args[0]) : 20; const entries = Object.entries(this._errorDB.errors) .sort((a, b) => b[1].lastSeen - a[1].lastSeen) .slice(0, n); if (entries.length === 0) { this.terminal.print('No errors recorded', 'success'); return; } this.terminal.print('── Error History (last ' + entries.length + ') ──', 'header'); for (const [hash, e] of entries) { const time = new Date(e.lastSeen).toLocaleTimeString(); const icon = e.severity === 'fatal' ? '🔴' : e.severity === 'error' ? '🟡' : '🟢'; const msg = e.message.substring(0, 80); this.terminal.print(' ' + icon + ' [' + time + '] ' + msg, e.severity === 'fatal' ? 'error' : 'text-muted'); this.terminal.print(' hash=' + hash + ' type=' + e.type + ' cat=' + e.category + ' ×' + e.count, 'text-muted'); } this.terminal.print('── End ──', 'info'); this.terminal.print('Run debug-analyze <hash> for details', 'text-muted'); } _cmdAnalyze(args) { const hash = (args && args[0]) || ''; if (!hash) { this.terminal.print('Usage: debug-analyze <hash>', 'warning'); return; } const entry = this._errorDB.errors[hash]; if (!entry) { this.terminal.print('Error not found: ' + hash, 'error'); return; } this.terminal.print('── Error Analysis: ' + hash + ' ──', 'header'); this.terminal.print(' Message: ' + entry.message, 'info'); this.terminal.print(' Type: ' + entry.type + ' | Severity: ' + entry.severity + ' | Category: ' + entry.category, 'info'); this.terminal.print(' Source: ' + (entry.source || '?'), 'text-muted'); this.terminal.print(' Module: ' + (entry.module || 'unknown'), 'text-muted'); this.terminal.print(' Count: ' + entry.count + ' | First: ' + new Date(entry.firstSeen).toLocaleString() + ' | Last: ' + new Date(entry.lastSeen).toLocaleString(), 'info'); this.terminal.print(' Sessions: ' + (entry.sessions || []).length, 'text-muted'); if (entry.stack) { this.terminal.print('── Stack Trace (top 10) ──', 'info'); this.terminal.print(entry.stack, 'text-muted'); } // Root-cause analysis const rca = this._analyzeRootCause(hash); if (rca.found) { this.terminal.print('── Root Cause Analysis ──', 'warning'); for (const f of rca.findings) { this.terminal.print(' • [' + f.type + '] ' + f.detail, 'warning'); } if (rca.fixes.length > 0) { this.terminal.print('── Suggested Fixes ──', 'success'); for (const fix of rca.fixes) { this.terminal.print(' → ' + fix.action + (fix.params ? ' ' + fix.params : '') + ' — ' + fix.description, 'success'); } } } else { this.terminal.print(' No root cause identified', 'text-muted'); } // Fix suggestion const fix = this._buildFixSuggestion(hash); if (fix && fix.confidence > 0) { this.terminal.print('── Auto-Fix Confidence: ' + Math.round(fix.confidence * 100) + '% ──', fix.autoApply ? 'success' : 'warning'); this.terminal.print(' ' + fix.suggestion, 'info'); if (fix.autoApply) { this.terminal.print(' ⚡ Confidence ≥ 90% — fix can be auto-applied with debug-fix ' + hash, 'success'); } } } _cmdTrends(args) { const days = (args && args[0]) ? parseInt(args[0]) : 7; const cutoff = Date.now() - days * 86400000; const entries = Object.values(this._errorDB.errors).filter(e => e.lastSeen >= cutoff); if (entries.length === 0) { this.terminal.print('No errors in the last ' + days + ' days', 'success'); return; } // Group by day const byDay = {}; for (const e of entries) { const day = new Date(e.lastSeen).toISOString().split('T')[0]; if (!byDay[day]) byDay[day] = { total: 0, byType: {} }; byDay[day].total += e.count; byDay[day].byType[e.type] = (byDay[day].byType[e.type] || 0) + e.count; } const sortedDays = Object.keys(byDay).sort(); this.terminal.print('── Error Trends (last ' + days + ' days) ──', 'header'); for (const day of sortedDays) { const d = byDay[day]; const types = Object.entries(d.byType).map(([t, c]) => t + ':' + c).join(', '); this.terminal.print(' ' + day + ': ' + d.total + ' errors (' + types + ')', d.total > 5 ? 'warning' : 'text-muted'); } // Top error types const typeTotals = {}; for (const e of entries) { typeTotals[e.type] = (typeTotals[e.type] || 0) + e.count; } this.terminal.print('── By Type ──', 'info'); for (const [type, count] of Object.entries(typeTotals).sort((a, b) => b[1] - a[1])) { this.terminal.print(' • ' + type + ': ' + count, 'text-muted'); } } _cmdFix(args) { const hash = (args && args[0]) || ''; if (!hash) { this.terminal.print('Usage: debug-fix <hash>', 'warning'); return; } const fix = this._buildFixSuggestion(hash); if (!fix || fix.confidence === 0) { this.terminal.print('No fix available for error: ' + hash, 'warning'); return; } this.terminal.print('── Fix Suggestion for ' + hash + ' ──', 'header'); this.terminal.print(' Confidence: ' + Math.round(fix.confidence * 100) + '%', fix.autoApply ? 'success' : 'warning'); this.terminal.print(' Suggestion: ' + fix.suggestion, 'info'); if (fix.fixes && fix.fixes.length > 0) { this.terminal.print('── Available Fixes ──', 'info'); for (const f of fix.fixes) { this.terminal.print(' → ' + f.action + (f.params ? ' ' + f.params : ''), 'success'); this.terminal.print(' ' + f.description, 'text-muted'); } } if (fix.autoApply) { this.terminal.print('⚡ Auto-applying fix...', 'info'); this._applyFix(hash, fix); } else { this.terminal.print('Confidence below auto-apply threshold. Run the suggested command manually.', 'warning'); } } _applyFix(hash, fix) { if (!fix.fixes || fix.fixes.length === 0) return; const firstFix = fix.fixes[0]; try { // Sanitize params: only allow alphanumeric, hyphens, underscores, dots const safeParams = (firstFix.params || '').replace(/[^a-zA-Z0-9_\-\.]/g, ''); if (firstFix.action === 'loadmod' && this.terminal) { this.terminal.executeCommand('loadmod ' + safeParams); this.terminal.print(' ✅ Applied: loadmod ' + safeParams, 'success'); } else if (firstFix.action === 'test-net' && this.terminal) { this.terminal.executeCommand('test-net'); this.terminal.print(' ✅ Applied: test-net', 'success'); } else if (firstFix.action === 'reload') { this.terminal.print(' ℹ️ Manual action needed: reload the page', 'warning'); } else { this.terminal.print(' ℹ️ Cannot auto-apply: ' + firstFix.action, 'warning'); } } catch (e) { this.terminal.print(' ❌ Fix failed: ' + e.message, 'error'); } } _cmdHealth() { this.terminal.print('── DebugSupervisor Health ──', 'header'); this.terminal.print(' Status: ' + (this._healthy ? '✅ Healthy' : '🔴 Degraded'), this._healthy ? 'success' : 'error'); this.terminal.print(' Session: ' + this._sessionId, 'text-muted'); this.terminal.print(' Errors tracked: ' + Object.keys(this._errorDB.errors).length + ' unique', 'info'); this.terminal.print(' Max errors: ' + this._maxErrors, 'text-muted'); this.terminal.print(' TTL: ' + this._ttlDays + ' days', 'text-muted'); this.terminal.print(' Interceptors: ' + (this._interceptorInstalled ? '✅ active' : '🔴 inactive'), this._interceptorInstalled ? 'success' : 'error'); this.terminal.print(' Quota: ' + (this._checkQuota() ? '✅ available' : '🔴 low'), this._checkQuota() ? 'success' : 'error'); } // ── Sync to Backend ── _getSyncUrl() { try { return localStorage.getItem('webx11_debug_sync_url') || 'https://webx11.duckdns.org/spaces/newos4/api/debug/ingest.php'; } catch (e) { return 'https://webx11.duckdns.org/spaces/newos4/api/debug/ingest.php'; } } async _syncToBackend() { if (!this._healthy) return; const db = this._errorDB.errors; const allKeys = Object.keys(db); if (allKeys.length === 0) return; // Only send hashes whose cumulative count changed since the last successful sync. // The server counts deltas per (session, hash); re-sending unchanged rows is what // multiplied one error into 46,133 occurrences before 2026-10-23. const keys = allKeys.filter(k => (this._syncedCounts[k] || 0) !== db[k].count); if (keys.length === 0) return; const payload = { version: window.NEWOS4_VERSION || 'v4.x', session: { id: this._sessionId, modules: this.terminal?.moduleManager ? Object.keys(this.terminal.moduleManager.modules) : [], }, errors: keys.map(k => ({ hash: k, message: db[k].message, type: db[k].type, severity: db[k].severity, category: db[k].category, source: db[k].source, module: db[k].module, stack: db[k].stack, count: db[k].count, firstSeen: new Date(db[k].firstSeen).toISOString(), lastSeen: new Date(db[k].lastSeen).toISOString(), })), }; try { const resp = await fetch(this._getSyncUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(10000), }); if (resp.ok) { const result = await resp.json(); if (result.success) { const sent = keys.length; const accepted = result.accepted ?? result.ingested ?? 0; if (accepted < sent) { console.warn('[DebugSupervisor] Sync truncated: sent ' + sent + ' errors, server accepted ' + accepted + ' (max ' + (result.total_errors || '?') + ' total)'); } else { console.log('[DebugSupervisor] Synced ' + accepted + ' errors to backend'); } keys.forEach(k => { this._syncedCounts[k] = db[k].count; }); try { localStorage.setItem('webx11_debug_synced', JSON.stringify(this._syncedCounts)); } catch (_e) {} this._lastSyncOk = Date.now(); return true; } } console.warn('[DebugSupervisor] Sync returned HTTP ' + resp.status); return false; } catch (e) { console.warn('[DebugSupervisor] Sync failed: ' + e.message); return false; } } _feedbackThread() { let t = null; try { t = localStorage.getItem('webx11_feedback_thread'); } catch (_e) {} if (!t) { t = 'thr-' + Math.random().toString(36).slice(2, 10); try { localStorage.setItem('webx11_feedback_thread', t); } catch (_e) {} } return t; } _cmdFeedback(args) { const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); const argv = raw.split(/\s+/); const sub = (argv[0] || '').toLowerCase(); // A subcommand only when its ARGUMENTS are plausible. Measured failure without this // check: "feedback list is broken on mobile" became a filtered list of reports. const STATUS = ['', 'new', 'read', 'archived']; const isNum = (v) => /^\d+$/.test(v || ''); const forced = raw.startsWith('--'); const isSub = !forced && ( (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) || ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) || (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) || (sub === 'reply' && isNum(argv[1]) && argv.length >= 3) ); if (isSub) { this._fbDispatch(args); return; } const msg = forced ? raw.replace(/^--\s*/, '') : raw; if (!msg) { this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error'); this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', ''); return; } this.terminal.print('📨 Sending…', ''); this._chatPost(msg, 'operator') .then(j => this.terminal.print( '✅ sent id=' + String(j.id || '?').slice(-6) + ' epoch=' + (j.epoch || 0) + ' — reply arrives here; `hermes-poll` to check now', 'success')) .catch(e => { // 503 is nginx's limit_req, not ours: it arrives as HTML, so status is all we have. const wait = e.retryAfterMs ? ' in ' + Math.ceil(e.retryAfterMs / 1000) + 's' : ' later'; const hint = e.status === 429 ? ' (rate limited — retry' + wait + ')' : e.status === 503 ? ' (host busy — retry)' : e.status === 401 ? ' (bad token — try hermes-reset)' : ''; this.terminal.print('❌ ' + e.message + hint, 'error'); }); } _cmdFeedbackInbox() { this.terminal.print('📬 Checking…', ''); // _chatPoll prints the 🤖 lines (once); this loop skips hermes so it is not shown // twice. Making the poll silent instead would print the reply nowhere. this._chatPoll(true).then(j => { if (!j) { this.terminal.print('❌ no answer from the chat endpoint', 'error'); return; } const incoming = j.messages || []; // hermes lines were already printed by the poll above; only count them as // 'new' so the box does not say "no new messages" right under a new one const msgs = incoming.filter(m => m.author !== 'hermes'); if (!incoming.length) this.terminal.print('(no new messages)', 'info'); msgs.forEach(m => this.terminal.print( (m.author === 'hermes' ? '🤖 ' : m.author === 'agent' ? '🧠 ' : '👤 ') + m.text, '')); if (j.awaiting) this.terminal.print('⏳ waiting for Hermes', 'info'); }); } // ── feedback management (admin) ────────────────────────────────────────── _fbHelp() { const rows = [ 'feedback <message> send a message to the operator agent (alias: hermes)', 'feedback list [status] list stored /stear reports (new|read|archived, default all)', 'feedback read <id> show one report in full', 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)', 'feedback archive <id> archive a report', 'feedback reopen <id> move a report back to new', 'feedback stats counts per status', 'feedback dashboard open /admin/feedback.html in a new tab', 'feedback-inbox read agent replies from the chat bridge', ]; this.terminal.print('feedback — commands', ''); rows.forEach(r => this.terminal.print(' ' + r, '')); this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', ''); } async _fbToken() { if (!this._fbKey) { const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' }); if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status); const d = await r.json(); if (!d || !d.token) throw new Error('no admin token in response'); this._fbKey = d.token; } const w = Math.floor(Date.now() / 1000 / 300) * 300; const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w)); return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } async _fbCall(path, opts) { const tok = await this._fbToken(); const o = Object.assign({ credentials: 'same-origin' }, opts || {}); o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' }, (opts && opts.headers) || {}); const r = await fetch('/api/feedback' + path, o); if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); } if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); } _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); } _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); } _fbList(status) { const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50'; this.terminal.print('📋 loading…', ''); this._fbCall('/list' + q).then(d => { const items = d.items || []; if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; } this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) + this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', ''); items.forEach(it => this.terminal.print( this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) + this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) + String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), '')); const total = (typeof d.total === 'number' ? d.total : items.length); const more = total > items.length ? ' (showing ' + items.length + ' of ' + total + ' — feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : ''; this.terminal.print(total + ' item(s)' + more + ' — feedback read <id>, reply <id> <text>', ''); }).catch(e => this._fbFail(e)); } _fbRead(id) { if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; } const want = String(id); // The server understands ?id= since v4.6.93's API change; an older process ignores it and // returns the newest row instead, so verify the id before trusting the answer. this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => { const hit = (d.items || []).find(x => String(x.id) === want); if (hit) return hit; return this._fbCall('/list?limit=200').then(d2 => (d2.items || []).find(x => String(x.id) === want) || null); }).then(it => { if (!it) { this.terminal.print('no report #' + want, 'error'); return; } this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), ''); this.terminal.print('page: ' + (it.page || '—') + ' at: ' + (it.created_at || ''), ''); this.terminal.print('', ''); this.terminal.print(String(it.message || ''), ''); if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); } this.terminal.print('', ''); this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, ''); }).catch(e => this._fbFail(e)); } _fbReply(id, text) { if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; } this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: 'read', admin_reply: text }) }) .then(() => this.terminal.print('✅ reply published on #' + id + ' (the user sees it in /stear → My reports)', '')) .catch(e => this._fbFail(e)); } _fbStatus(id, status) { if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; } this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) }) .then(() => this.terminal.print('✅ #' + id + ' → ' + status, '')) .catch(e => this._fbFail(e)); } _fbStats() { const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : '')) .then(d => { if (typeof d.total === 'number') return d.total; // COUNT(*) from the server return this._fbCall('/list?limit=200' + (st ? '&status=' + st : '')) .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length); }); Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => { this.terminal.print('feedback: ' + all + ' total — new ' + nw + ', read ' + rd + ', archived ' + ar, ''); this.terminal.print('dashboard: /admin/feedback.html', ''); }).catch(e => this._fbFail(e)); } _fbDashboard() { this.terminal.print('🪟 opening /admin/feedback.html …', ''); window.open('/admin/feedback.html', '_blank'); } _fbDispatch(args) { const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/); const sub = (argv[0] || '').toLowerCase(); const arg = argv[1]; const rest = argv.slice(2).join(' '); switch (sub) { case 'list': return this._fbList(arg || ''); case 'read': return this._fbRead(arg); case 'reply': return this._fbReply(arg, rest); case 'archive': return this._fbStatus(arg, 'archived'); case 'reopen': return this._fbStatus(arg, 'new'); case 'stats': return this._fbStats(); case 'dashboard': return this._fbDashboard(); case 'help': default: return this._fbHelp(); } } _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { try { localStorage.setItem('webx11_debug_sync_url', url); this.terminal.print('✅ Sync URL set to: ' + url, 'success'); } catch (e) { this.terminal.print('❌ Failed to save sync URL', 'error'); } return; } this.terminal.print('🔄 Syncing errors to backend...', 'info'); this._syncToBackend().then(ok => { if (ok) { this.terminal.print('✅ Sync complete', 'success'); } else { this.terminal.print('❌ Sync failed', 'error'); } }); } _cmdSyncUrl(args) { if (args && args[0]) { try { localStorage.setItem('webx11_debug_sync_url', args[0]); this.terminal.print('✅ Sync URL set to: ' + args[0], 'success'); } catch (e) { this.terminal.print('❌ Failed to save sync URL', 'error'); } } else { this.terminal.print('Current sync URL: ' + this._getSyncUrl(), 'info'); } } // ── Dashboard Data ── getDashboardData() { const db = this._errorDB.errors; const keys = Object.keys(db); const totalCount = keys.reduce((sum, k) => sum + db[k].count, 0); const clusters = this._clusterErrors().slice(0, 5); return { totalUnique: keys.length, totalCount: totalCount, clusters: clusters.map(c => ({ label: c.label.substring(0, 60), count: c.count, hashes: c.hashes.length })), healthy: this._healthy, topType: this._getTopType(db), topCategory: this._getTopCategory(db), }; } _getTopType(db) { const freq = {}; for (const e of Object.values(db)) { freq[e.type] = (freq[e.type] || 0) + e.count; } const sorted = Object.entries(freq).sort((a, b) => b[1] - a[1]); return sorted.length > 0 ? sorted[0][0] : null; } _getTopCategory(db) { const freq = {}; for (const e of Object.values(db)) { freq[e.category] = (freq[e.category] || 0) + e.count; } const sorted = Object.entries(freq).sort((a, b) => b[1] - a[1]); return sorted.length > 0 ? sorted[0][0] : null; } } /* === end core module bundle === */