diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-27 06:00:43 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-27 06:00:43 +0200 |
| commit | 212708b464c2bdbdd9670c00804623dca0729d2c (patch) | |
| tree | ee190cbb0d1524350baea641df65aa19817744b5 /modules/core | |
| parent | f4d934d341056142da1212c845eeeb170fd452c2 (diff) | |
auto: 2026-05-27_06:00 changes
Diffstat (limited to 'modules/core')
| -rw-r--r-- | modules/core/module-aiassist.js | 49 | ||||
| -rw-r--r-- | modules/core/module-fs.js | 49 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 7 | ||||
| -rw-r--r-- | modules/core/module-manager.js | 24 | ||||
| -rw-r--r-- | modules/core/module-skills.js | 68 | ||||
| -rw-r--r-- | modules/core/module-system.js | 95 | ||||
| -rw-r--r-- | modules/core/terminal.js | 452 | ||||
| -rw-r--r-- | modules/core/virtual-fs.js | 440 |
8 files changed, 908 insertions, 276 deletions
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js index f9e1e98..e983e6a 100644 --- a/modules/core/module-aiassist.js +++ b/modules/core/module-aiassist.js @@ -538,6 +538,22 @@ RULES: } return null; } + _trackToolFailure(toolName, error) { + try { + const key = 'webx11_tool_failures'; + const raw = localStorage.getItem(key) || '{}'; + const map = JSON.parse(raw); + if (!map[toolName]) map[toolName] = { count: 0, last: '', errors: [] }; + map[toolName].count++; + map[toolName].last = new Date().toISOString(); + map[toolName].errors.push(error); + if (map[toolName].errors.length > 5) map[toolName].errors.shift(); + localStorage.setItem(key, JSON.stringify(map)); + if (map[toolName].count >= 3) { + this.terminal.print(`⚠️ Tool '${toolName}' has failed ${map[toolName].count} times. Consider creating a workaround skill.`, 'warning'); + } + } catch (e) { /* ignore */ } + } async agenticLoop(messages) { let toolTurns = 0; while (toolTurns < this.MAX_TOOL_TURNS) { @@ -579,8 +595,11 @@ RULES: 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 { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info'); } messages.push({ role: 'tool', content: toolResult }); @@ -598,8 +617,11 @@ RULES: const dObs = this._summarizeObservation(toolResult, 150); const dErr = this._isToolError(toolResult); if (dErr) { + this.terminal.log('WARN', 'AI', `Tool ${fn.name} failed: ${dErr} (${t1}s)`); + this._trackToolFailure(fn.name, dErr); this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning'); } else { + this.terminal.log('INFO', 'AI', `Tool ${fn.name} ok (${t1}s)`); this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info'); } messages.push({ role: 'tool', content: toolResult }); @@ -803,8 +825,9 @@ RULES: return name === 'write_file' || name === 'delete_file' || name === 'run_command' || name === 'make_directory' || name === 'edit_module'; } async runTool(name, argsStr) { + let a = {}; try { - const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); + a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); switch (name) { case 'list_files': { const originalPath = a.path || ''; @@ -1058,7 +1081,7 @@ RULES: for (const [name, meta] of Object.entries(index)) { const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0); if (score > 0) { - const content = fs.fsRead(`/skills/${name}.md`) || ''; + const content = fs.readFile(`/skills/${name}.md`) || ''; matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) }); } } @@ -1078,7 +1101,7 @@ RULES: } const fs = this.terminal.fs; fs.mkdirRecursive('/skills'); - fs.fsWrite(`/skills/${name}.md`, content); + 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); @@ -1093,7 +1116,7 @@ RULES: const fs = this.terminal.fs; const index = fs.fsReadJSON('/skills/index.json') || {}; if (!index[pname]) return JSON.stringify({ error: `Skill "${pname}" not found` }); - fs.fsWrite(`/skills/${pname}.md`, pcontent); + 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---/); @@ -1134,8 +1157,10 @@ RULES: } } async runModuleTool(name, argsStr) { - const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {}); - switch (name) { + 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); @@ -1211,6 +1236,10 @@ RULES: return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods }); } default: return JSON.stringify({ error: `Unknown module tool: ${name}` }); + } + } catch (e) { + this._errorLog.push({ tool: name, args: a, error: e.message, timestamp: Date.now() }); + return JSON.stringify({ error: e.message, stack: e.stack?.substring(0, 200) }); } } clearContext() { @@ -1358,11 +1387,11 @@ category: productivity - User says "map out", "diagram", "brainstorm", "organize thoughts" - Visualizing project structure or task decomposition`; - fs.fsWrite('/skills/mindmap-user-guide.md', mindmapSkill); + fs.writeFile('/skills/mindmap-user-guide.md', mindmapSkill); const existingIndex = fs.fsReadJSON('/skills/index.json') || {}; existingIndex['mindmap-user-guide'] = { name: 'mindmap-user-guide', description: 'How to create, view, and manage brainstorming mindmaps using the mindmap terminal module', tags: ['mindmap', 'brainstorming', 'organization', 'planning'], category: 'productivity', created: existingIndex['mindmap-user-guide']?.created || new Date().toISOString(), updated: new Date().toISOString() }; fs.fsWriteJSON('/skills/index.json', existingIndex); - fs.fsWrite('/skills/_seeded', '1'); + fs.writeFile('/skills/_seeded', '1'); } _trackPattern(userMessage) { const lower = userMessage.toLowerCase(); @@ -1381,7 +1410,7 @@ category: productivity for (const [tool, errors] of Object.entries(grouped)) { if (errors.length >= 3) { const skillName = 'tool-' + tool + '-workaround'; - const existing = this.terminal.fs.fsRead('/skills/' + skillName + '.md'); + 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; @@ -1397,7 +1426,7 @@ category: productivity const now = Date.now(); const issues = []; for (const [name, meta] of Object.entries(index)) { - const content = fs.fsRead('/skills/' + name + '.md'); + const content = fs.readFile('/skills/' + name + '.md'); if (!content || content.length < 50) { issues.push({ name, issue: 'empty or too short' }); continue; diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js index c4bc786..4ba34b0 100644 --- a/modules/core/module-fs.js +++ b/modules/core/module-fs.js @@ -5,7 +5,11 @@ class FileSystemModule { '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; use -r/-rf for directories', + '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', @@ -80,13 +84,52 @@ class FileSystemModule { 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'); - if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + t, 'success'); + // 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('Removed: ' + args[0], 'success'); + 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') { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 850d627..2510fde 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -99,7 +99,10 @@ window.__moduleLoaderReload = reloadWithSelection; - const CACHE = '?v=18'; + const CACHE = '?v=' + (localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'); + if (!localStorage.getItem('webx11_cache_ver')) { + localStorage.setItem('webx11_cache_ver', '2026-05-27-14'); + } async function init() { try { @@ -153,7 +156,7 @@ // Phase 4: Ext modules (filtered) if (typeof webx11._loadExtModules === 'function') { - const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr']; + const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip']; const extSelected = selected ? extKeys.filter(k => isSelected(k + '.js', selected)) : extKeys; diff --git a/modules/core/module-manager.js b/modules/core/module-manager.js index 8e9e911..c508778 100644 --- a/modules/core/module-manager.js +++ b/modules/core/module-manager.js @@ -42,10 +42,12 @@ } createModuleClass(code, url) { try { - const classMatch = code.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); + // 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 ' + code + '\nreturn ' + className)(); + const mod = new Function('return ' + cleanCode + '\nreturn ' + className)(); return mod; } throw new Error('No valid module class found'); @@ -78,6 +80,24 @@ } 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.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 => ({ diff --git a/modules/core/module-skills.js b/modules/core/module-skills.js index ed6e14f..9d0adec 100644 --- a/modules/core/module-skills.js +++ b/modules/core/module-skills.js @@ -1,6 +1,30 @@ // Extracted from newOS4 index.html — SkillsModule (v4.2 — Self-improving AI) class SkillsModule { - constructor() { this.commands = {}; this.terminal = null; } + constructor() { + this.commands = {}; + this.terminal = null; + this._skillIndexCache = null; + this._skillIndexDirty = false; + this._skillIndexFlushTimer = null; + } + _readSkillIndex() { + if (this._skillIndexCache !== null) return this._skillIndexCache; + const fs = this.terminal.fs; + this._skillIndexCache = fs.fsReadJSON('/skills/index.json') || {}; + return this._skillIndexCache; + } + _markSkillIndexDirty() { + this._skillIndexDirty = true; + if (this._skillIndexFlushTimer) clearTimeout(this._skillIndexFlushTimer); + this._skillIndexFlushTimer = setTimeout(() => this._flushSkillIndex(), 2000); + } + _flushSkillIndex() { + if (!this._skillIndexDirty || this._skillIndexCache === null) return; + const fs = this.terminal.fs; + fs.fsWriteJSON('/skills/index.json', this._skillIndexCache); + this._skillIndexDirty = false; + this._skillIndexFlushTimer = null; + } getHelpInfo() { return { 'skill-list': 'skill-list — List all installed skills', @@ -35,7 +59,7 @@ } listSkills() { this.ensureSkillsDir(); - const index = this.terminal.fs.fsReadJSON('/skills/index.json') || {}; + const index = this._readSkillIndex(); const names = Object.keys(index); if (names.length === 0) { this.terminal.print('No skills found.', 'info'); @@ -54,7 +78,7 @@ this.terminal.print('Usage: skill-view <name>', 'warning'); return; } - const content = this.terminal.fs.fsRead(`/skills/${name}.md`); + const content = this.terminal.fs.readFile(`/skills/${name}.md`); if (!content) { this.terminal.print(`Skill '${name}' not found`, 'error'); return; @@ -73,15 +97,15 @@ const category = args[2] || 'general'; const tags = (args[3] || '').split(',').filter(Boolean); const fs = this.terminal.fs; - if (fs.fsRead(`/skills/${name}.md`)) { + if (fs.readFile(`/skills/${name}.md`)) { this.terminal.print(`Skill '${name}' already exists. Use skill-patch to modify it.`, 'warning'); return; } const content = '---\nname: ' + name + '\ndescription: ' + description + '\ntags: [' + tags.join(', ') + ']\ncategory: ' + category + '\n---\n## Steps\n1. (to be filled)\n'; - fs.fsWrite(`/skills/${name}.md`, content); - const index = fs.fsReadJSON('/skills/index.json') || {}; + fs.writeFile(`/skills/${name}.md`, content); + const index = this._readSkillIndex(); index[name] = { name, description, category, tags }; - fs.fsWriteJSON('/skills/index.json', index); + this._markSkillIndexDirty(); this.terminal.print(`Skill '${name}' created. Edit its content with skill-patch ${name}`, 'success'); } patchSkill(args) { @@ -91,7 +115,7 @@ return; } const fs = this.terminal.fs; - const existing = fs.fsRead(`/skills/${name}.md`); + const existing = fs.readFile(`/skills/${name}.md`); if (!existing) { this.terminal.print(`Skill '${name}' not found. Use skill-create first.`, 'error'); return; @@ -105,7 +129,7 @@ return; } const newContent = args.slice(1).join(' '); - fs.fsWrite(`/skills/${name}.md`, newContent); + fs.writeFile(`/skills/${name}.md`, newContent); this.terminal.print(`Skill '${name}' patched`, 'success'); } deleteSkill(args) { @@ -115,14 +139,14 @@ return; } const fs = this.terminal.fs; - if (!fs.fsRead(`/skills/${name}.md`)) { + if (!fs.readFile(`/skills/${name}.md`)) { this.terminal.print(`Skill '${name}' not found`, 'error'); return; } - fs.fsWrite(`/skills/${name}.md`, ''); - const index = fs.fsReadJSON('/skills/index.json') || {}; + fs.writeFile(`/skills/${name}.md`, ''); + const index = this._readSkillIndex(); delete index[name]; - fs.fsWriteJSON('/skills/index.json', index); + this._markSkillIndexDirty(); this.terminal.print(`Skill '${name}' deleted`, 'success'); } getWebDAVModule() { @@ -141,12 +165,12 @@ } this.ensureSkillsDir(); const fs = this.terminal.fs; - const content = fs.fsRead(`/skills/${name}.md`); + const content = fs.readFile(`/skills/${name}.md`); if (!content) { this.terminal.print(`Skill '${name}' not found in VFS`, 'error'); return; } - const index = fs.fsReadJSON('/skills/index.json') || {}; + const index = this._readSkillIndex(); const meta = index[name]; try { // Ensure remote skills dir @@ -188,7 +212,7 @@ const content = await resp.text(); this.ensureSkillsDir(); const fs = this.terminal.fs; - fs.fsWrite(`/skills/${name}.md`, content); + fs.writeFile(`/skills/${name}.md`, content); // Try to pull the index to get metadata; if fails, create minimal entry let meta = { name, description: 'Imported from WebDAV', category: 'general', tags: [] }; try { @@ -200,9 +224,9 @@ } } } catch (e) { /* use default meta */ } - const index = fs.fsReadJSON('/skills/index.json') || {}; + const index = this._readSkillIndex(); index[name] = meta; - fs.fsWriteJSON('/skills/index.json', index); + this._markSkillIndexDirty(); this.terminal.print(`Skill '${name}' imported from WebDAV (${content.length} bytes)`, 'success'); } catch (e) { this.terminal.print('Import failed: ' + e.message, 'error'); @@ -216,7 +240,7 @@ } this.ensureSkillsDir(); const fs = this.terminal.fs; - const localIndex = fs.fsReadJSON('/skills/index.json') || {}; + const localIndex = this._readSkillIndex(); const localNames = new Set(Object.keys(localIndex)); this.terminal.print('Syncing skills with WebDAV...', 'info'); let imported = 0, exported = 0; @@ -233,7 +257,7 @@ // Export local-only skills to remote for (const name of localNames) { if (!remoteNames.has(name)) { - const content = fs.fsRead(`/skills/${name}.md`); + const content = fs.readFile(`/skills/${name}.md`); if (content) { await wd._ensureDir('skills'); await wd._webdavRequest('PUT', 'skills/' + name + '.md', { @@ -250,14 +274,14 @@ const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md'); if (resp.ok) { const content = await resp.text(); - fs.fsWrite(`/skills/${name}.md`, content); + fs.writeFile(`/skills/${name}.md`, content); localIndex[name] = remoteIndex[name]; imported++; } } } // Push updated local index back to remote - fs.fsWriteJSON('/skills/index.json', localIndex); + this._markSkillIndexDirty(); await wd._ensureDir('skills'); await wd._webdavRequest('PUT', 'skills/index.json', { body: JSON.stringify(localIndex, null, 2), diff --git a/modules/core/module-system.js b/modules/core/module-system.js index f21946a..b561ef2 100644 --- a/modules/core/module-system.js +++ b/modules/core/module-system.js @@ -14,6 +14,9 @@ 'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)', 'reset-vfs': 'reset-vfs [--confirm] — Clear only the virtual filesystem (keeps settings, modules, AI profile)', '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 — Generate memory map markdown from memory/index.json', '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)', @@ -92,6 +95,93 @@ 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(); + } + _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; + } + this.terminal.print('---\nname: adaptive-memory-map\ndescription: Auto-generated from memory/index.json\ntags: [memory, context, tracking, adaptation]\ncategory: system\n---\n', 'info'); + for (const [k,v] of Object.entries(idx)) { + this.terminal.print(`## ${k}\n- Tasks: ${v.tasks || 0}\n- Updated: ${v.updated || 'unknown'}\n- Summary: ${(v.summary || '').substring(0,100)}...`, 'info'); + } + this.terminal.print('(Not saved — render to file with memory-map-export > /memory/reference.md if desired)', 'text-muted'); } handleHelp(args) { const helpMap = this.terminal.moduleManager.getAllHelp(); @@ -116,8 +206,9 @@ files: ['ls','ll','cat','put','rm','mv','cp','mkdir','rmdir','cd','pwd','find','grep','head','tail','sort','wc','touch','tree','du','upload','download','vfs-export','vfs-import','edit','diff'], ai: ['ai','ask','ai-clear','ai-system','ai-context','ai-allow','ai-deny','ai-stop','ai-model'], search: ['search','s','ddg','duck','fetch','web','extract'], - tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias'], - system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs'], + tools: ['calc','cron-every','cron-list','cron-stop','cron-clear','wpm','mindmap','theme','voice','alias','unalias', + 'which','env','date','cal','yes','seq','base64','printf','cut','uniq','hostname','whoami','basename','dirname','sleep','expand'], + system: ['help','clear','cls','echo','mem','status','font-size','setfont','smart-prompt','logs','preview','factory-reset','reset-vfs','export','unset','script','set','test'], net: ['server','servers','server-check','server-remove','monitor'], mods: ['modules','listmods','loadmod','removemod','load-local','loadlocal','startup-modules'], ollama: ['ollama','ollamaconnect','ollamadisconnect','ollamamodels','ollamasetmodel','ollamasetproxy','models','model-info','model-pull','model-delete','model-run'], diff --git a/modules/core/terminal.js b/modules/core/terminal.js index ee1afa4..2617f79 100644 --- a/modules/core/terminal.js +++ b/modules/core/terminal.js @@ -1,4 +1,184 @@ // Extracted from newOS4 index.html — WebX11Terminal + class WebX11SecurityManager { + constructor(terminal, level = 1) { + this.terminal = terminal; + this.level = level; + this.commandMeta = new Map(); + this.lastRouteTime = 0; + this.lastRouteCmd = ''; + this.blockedCount = 0; + this.destructiveCmds = new Set(['rm','delete','del','edit','write','save','overwrite','format','kill','stop','shutdown','reboot']); + this.systemCmds = new Set(['reboot','shutdown','format','kill','halt','poweroff']); + this.suspiciousFlags = new Set(['--dump','--execute','--eval','--exec','--shell','--script']); + this.whitelist = new Set(['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history']); + this._registerBuiltIn(); + } + _registerBuiltIn() { + const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','cat','run','font-size','load-local','loadlocal','mem','security','security-level','startup-modules','smart-prompt','cmd-allow','cmd-deny']; + internals.forEach(c => this.registerCommand(c, { category: 'builtin' })); + } + setLevel(n) { + if (n >= 0 && n <= 3) { + this.level = n; + this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success'); + if (n === 0) { + this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning'); + } + } + else { this.terminal.print('Level must be 0-3', 'error'); } + } + getLevel() { return this.level; } + getLevelName() { + return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown'; + } + registerCommand(name, meta = {}) { + this.commandMeta.set(name, { destructive: false, category: 'user', ...meta }); + } + _collectAllCommands() { + const cmds = new Set([...this.commandMeta.keys()]); + for (const modName in this.terminal.moduleManager.modules) { + const mod = this.terminal.moduleManager.modules[modName]; + if (mod.commands) { + for (const c in mod.commands) cmds.add(c); + } + } + const builtins = ['clear','help','ls','df','security','security-level']; + builtins.forEach(c => cmds.add(c)); + return [...cmds]; + } + _levenshtein(a, b) { + a = a.toLowerCase(); b = b.toLowerCase(); + const m = a.length, n = b.length; + if (!m) return n; if (!n) return m; + let prev = new Uint16Array(n + 1); + let curr = new Uint16Array(n + 1); + for (let j = 0; j <= n; j++) prev[j] = j; + for (let i = 1; i <= m; i++) { + let next = new Uint16Array(n + 1); + next[0] = i; + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + next[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); + } + [prev, curr] = [curr, next]; + } + 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; + } + _classify(raw) { + const parts = raw.trim().split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1); + // Internal commands always allowed at terminal level, but security still classifies + const all = this._collectAllCommands(); + const known = all.includes(cmd); + const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase())); + const destructive = this.destructiveCmds.has(cmd) || hasSuspicious; + const system = this.systemCmds.has(cmd); + const unknown = !known; + return { cmd, args, known, destructive, system, unknown, hasSuspicious }; + } + async checkAndRun(rawInput) { + const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); + // Level 3 Audit: just log and allow + if (this.level === 3) { + this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info'); + return this._dispatch(cmd, args); + } + // Always allow builtins that are safe, regardless of level + if (['help','security','security-level'].includes(cmd)) { + return this._dispatch(cmd, args); + } + // Unknown command handling + if (unknown && !this.whitelist.has(cmd)) { + this.blockedCount++; + const sug = this.suggest(cmd); + if (sug) { + this.terminal.print(`Security: Unknown command "${cmd}". Did you mean: "${sug.cmd}"?`, 'warning'); + } else { + this.terminal.print(`Security: Unknown command "${cmd}" — blocked. Use 'security-level 0' to disable.`, 'warning'); + } + // At levels 0-1: suggest/fix but still route to AI (terminal is also a chat interface) + // At level 2+: block entirely (paranoid) + if (this.level >= 2) { + this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).`, 'error'); + return; + } + if (this.level >= 1) { + if (sug) { + this.terminal.print(`Security: Did you mean "${sug.cmd}"? Routing to AI...`, 'warning'); + } else { + this.terminal.print(`Security: Routing to AI...`, 'info'); + } + } + return this._tryAIRoute(rawInput); + } + // Suspicious flags (e.g. --execute) at any level > 0 + if (hasSuspicious && this.level > 0) { + this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error'); + this.blockedCount++; + return; + } + // Destructive commands + if (destructive) { + if (this.level === 0) { + this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning'); + return this._dispatch(cmd, args); + } + if (this.level >= 1) { + // Reuse your existing confirm UI if present, otherwise print confirm message + if (this.terminal.showConfirmationBar) { + this.terminal._pendingCommand = () => this._dispatch(cmd, args); + this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`); + return; + } else { + this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning'); + return; + } + } + } + // System commands at level >=1 + if (system && this.level >= 1) { + this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error'); + this.blockedCount++; + return; + } + // Standard dispatch for everything else + return this._dispatch(cmd, args); + } + async _dispatch(cmd, args) { + return this.terminal.dispatchCommand(cmd, args); + } + async _tryAIRoute(rawInput) { + // AI routing cooldown guard + const now = Date.now(); + if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) { + this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning'); + return; + } + this.lastRouteCmd = rawInput; + this.lastRouteTime = now; + try { + const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; + if (aiMod) { + this.terminal.print('→ Routing to AI assistant...', 'info'); + await aiMod.chat([rawInput.trim()]); + } + } catch (e) { + this.terminal.print(`Security: AI error: ${e.message}`, 'error'); + } + } + } + class WebX11Terminal { constructor() { this.outputContainer = document.getElementById('terminal-container'); @@ -15,6 +195,7 @@ } } 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); this._pipeInput = null; this._pipeBufferSize = 5000; @@ -267,6 +448,53 @@ this.outputContainer.appendChild(line); this.scrollToBottom(); } + showConfirmationBar(desc) { + const area = document.getElementById('ai-confirm-area'); + if (!area) { + this.print('Confirmation UI unavailable — command blocked.', 'error'); + return; + } + this._pendingCommandDesc = desc; + area.innerHTML = ` + <span class="confirm-desc">⚠️ ${desc}</span> + <button class="confirm-btn" id="cmd-confirm-allow" onclick="(function(){var a=document.getElementById('cmd-confirm-allow');if(a.__allow) a.__allow();}).call(this)">✅ Allow</button> + <button class="confirm-btn deny" id="cmd-confirm-deny" onclick="(function(){var d=document.getElementById('cmd-confirm-deny');if(d.__deny) d.__deny();}).call(this)">❌ Deny</button> + <span style="font-size:0.75em;color:var(--text-muted);width:100%;text-align:center">Type: cmd-allow or cmd-deny</span> + `; + area.classList.add('visible'); + const allowBtn = document.getElementById('cmd-confirm-allow'); + const denyBtn = document.getElementById('cmd-confirm-deny'); + const onAllow = () => { + this._resolveCmdConfirm(true); + }; + const onDeny = () => { + this._resolveCmdConfirm(false); + }; + allowBtn.addEventListener('click', onAllow); + denyBtn.addEventListener('click', onDeny); + allowBtn.__allow = onAllow; + denyBtn.__deny = onDeny; + } + _resolveCmdConfirm(approved) { + const area = document.getElementById('ai-confirm-area'); + if (area) { + area.classList.remove('visible'); + area.innerHTML = ''; + } + if (approved) { + this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success'); + if (this._pendingCommand) { + const fn = this._pendingCommand; + this._pendingCommand = null; + this._pendingCommandDesc = null; + fn(); + } + } else { + this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning'); + this._pendingCommand = null; + this._pendingCommandDesc = null; + } + } _loadHistory() { try { const saved = localStorage.getItem('webx11_terminal_output'); @@ -331,7 +559,7 @@ setTimeout(() => { this._historySavePending = false; try { - const slice = this._historyBuffer.slice(-30); + 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) @@ -425,6 +653,55 @@ 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); + } showAutocomplete() { const input = this.inputField.value; const trimmed = input.trim(); @@ -442,11 +719,24 @@ const cmdPart = parts[0].toLowerCase(); const lowerInput = input.toLowerCase(); - // Commands that take file/dir arguments for autocomplete - const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view']; - const dirArgCommands = ['ls', 'cd']; - // Session commands that autocomplete from _sessions/*.json - const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save']; + // Expanded command lists for autocomplete + const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list']; + const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir']; + const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import']; + const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', '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) { @@ -458,25 +748,13 @@ return; } - // Determine if we should show session names, dirs only, or both if (sessionArgCommands.includes(cmdPart)) { - this.fs.mkdirRecursive('_sessions'); - const all = this.fs.ls('_sessions'); - this.suggestions = all - .filter(e => e.type === 'file' && e.name.endsWith('.json')) - .map(e => e.name.replace('.json', '')) - .filter(n => n.startsWith(partialTrimmed)); - } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { - // Dir-only commands - const entries = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = entries - .filter(e => e.name.startsWith(partialTrimmed)) - .map(e => e.name + '/'); - } else if (fileArgCommands.includes(cmdPart)) { - const all = this.fs.ls(); - this.suggestions = all - .filter(e => e.name.startsWith(partialTrimmed)) - .map(e => e.type === 'dir' ? e.name + '/' : e.name); + 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 { this.hideAutocomplete(); return; @@ -484,7 +762,6 @@ this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { - this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); @@ -495,24 +772,18 @@ // Just typed a space after command — show all matching entries if (parts.length > 1) { if (sessionArgCommands.includes(cmdPart)) { - this.fs.mkdirRecursive('_sessions'); - const all = this.fs.ls('_sessions'); - this.suggestions = all - .filter(e => e.type === 'file' && e.name.endsWith('.json')) - .map(e => e.name.replace('.json', '')); - } else if (cmdPart === 'cd' || cmdPart === 'rmdir' || dirArgCommands.includes(cmdPart)) { - const dirs = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = dirs.map(d => d.name + '/'); - } else if (fileArgCommands.includes(cmdPart)) { - const all = this.fs.ls(); - this.suggestions = all.map(e => e.type === 'dir' ? e.name + '/' : e.name); + 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 { this.hideAutocomplete(); return; } this._autocompletePrefix = cmdPart + ' '; if (this.suggestions.length > 0) { - this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir'); this.renderAutocomplete(); } else { this.hideAutocomplete(); @@ -520,10 +791,9 @@ return; } - // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block + // Directory autocomplete for bare 'cd ' (no partial yet) if (lowerInput.startsWith('cd ')) { - const dirs = this.fs.ls().filter(f => f.type === 'dir'); - this.suggestions = dirs.map(d => d.name + '/'); + this.suggestions = this._getPathSuggestions('', 'dir'); if (this.suggestions.length > 0) { this._isCdAutocomplete = true; this.renderAutocomplete(); @@ -623,15 +893,17 @@ catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); } } async _loadExtModules(filterKeys) { - const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; + const cacheVer = localStorage.getItem('webx11_cache_ver') || '2026-05-27-14'; try { - const res = await fetch('packages.json'); + const res = await fetch('packages.json?v=' + cacheVer); if (!res.ok) throw new Error(`HTTP ${res.status}`); const index = await res.json(); for (const key of autoLoadKeys) { const pkg = index.packages[key]; if (pkg) { - await this.moduleManager.loadModuleFromURL(pkg.url); + const url = pkg.url + (pkg.url.includes('?') ? '&v=' : '?v=') + cacheVer; + await this.moduleManager.loadModuleFromURL(url); } } } catch (err) { @@ -640,7 +912,7 @@ } async reloadPersistedModules() { const registry = this.moduleManager.moduleRegistry; - const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder']; + const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr', 'cryptogzip', 'shell']; for (const mod of registry) { if (mod.enabled && mod.url) { // Skip ext modules — they load via _loadExtModules @@ -659,65 +931,8 @@ await this.executePipeChain(pipeParts); return; } - const parts = rawInput.trim().split(/\s+/); - const cmd = parts[0].toLowerCase(); - const args = parts.slice(1); - 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 === 'clear') { - this.outputContainer.innerHTML = ''; - this._historyBuffer = []; - localStorage.removeItem('webx11_terminal_output'); - return; - } - let executed = false; - for (const modName in this.moduleManager.modules) { - const mod = this.moduleManager.modules[modName]; - if (mod.commands && mod.commands[cmd]) { - try { - await mod.commands[cmd](args); - executed = true; - } catch (err) { - this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); - } - break; - } - } - if (!executed) { - // Route unknown commands to the AI agent - try { - const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; - if (aiMod) { - this.print('→', 'info'); - await aiMod.chat([rawInput.trim()]); - return; - } - } catch (e) { - this.print(`Agent error: ${e.message}`, 'error'); - return; - } - } + // Security gate for all single commands and internal builtins + await this.security.checkAndRun(rawInput); } splitPipe(input) { const parts = []; @@ -761,6 +976,25 @@ } async dispatchCommand(cmd, args) { if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; } + if (cmd === 'startup-modules') { this.openStartupModules(); return; } + if (cmd === 'font-size' || cmd === 'setfont') { this.setFontSize(args[0]); return; } + if (cmd === 'load-local' || cmd === 'loadlocal') { this.loadLocalModule(args[0]); return; } + if (cmd === 'smart-prompt') { const val = args[0] && args[0] === 'off' ? 'off' : 'on'; localStorage.setItem('webx11_smart_prompt', val); this.print('Smart prompt ' + val, 'success'); this.updatePrompt(); return; } + if (cmd === 'mem') { this.printMemoryStats(); return; } + if (cmd === 'security') { + this.print(`Security level: ${this.security.getLevel()} (${this.security.getLevelName()})`, 'info'); + this.print(`Blocked commands: ${this.security.blockedCount}`, 'info'); + 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 === 'cmd-allow') { this._resolveCmdConfirm(true); return; } + if (cmd === 'cmd-deny') { this._resolveCmdConfirm(false); return; } for (const modName in this.moduleManager.modules) { const mod = this.moduleManager.modules[modName]; if (mod.commands && mod.commands[cmd]) { @@ -768,17 +1002,7 @@ catch (err) { this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error'); return; } } } - // Route unknown commands to the AI agent - try { - const aiMod = this.moduleManager.modules['WebX11AIAssistantModule']; - if (aiMod) { - this.print('→ Routing to AI assistant...', 'info'); - await aiMod.chat([cmd + ' ' + args.join(' ')]); - return; - } - } catch (e) { - this.print(`Agent error: ${e.message}`, 'error'); - } + // No AI routing here — security manager handles that via checkAndRun this.print(`Command not found: ${cmd}`, 'error'); } openFileBrowser() { diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js index 68d02bf..d2d3103 100644 --- a/modules/core/virtual-fs.js +++ b/modules/core/virtual-fs.js @@ -2,33 +2,83 @@ class VirtualFileSystem { constructor() { this.root = { type: 'dir', content: {} }; this.currentPath = []; - this.loadFromStorage(); - } - loadFromStorage() { + this._ready = this.loadFromStorage(); + } + _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 saved = localStorage.getItem('webx11_filesystem'); - if (saved) { - // Safety: refuse to load if data is excessively large (>512KB raw) - if (saved.length > 524288) { - console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); - localStorage.removeItem('webx11_filesystem'); - return; + const v2 = localStorage.getItem('webx11_filesystem_v2'); + if (v2) { + try { + const text = await this._decompress(v2); + const data = JSON.parse(text); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + return true; + } catch (e) { + console.error('FS v2 decompress error:', e); + return this._loadLegacy(); } - const data = JSON.parse(saved); - this.root = data.root || this.root; - this.currentPath = data.currentPath || []; } - } catch (e) { console.error('FS load error:', e); } + return this._loadLegacy(); + } catch (e) { console.error('FS load error:', e); return false; } + } + _loadLegacy() { + const saved = localStorage.getItem('webx11_filesystem'); + if (saved) { + if (saved.length > 524288) { + console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); + localStorage.removeItem('webx11_filesystem'); + return false; + } + const data = JSON.parse(saved); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + console.info('Loaded legacy FS; will migrate to compressed on next save.'); + return true; + } + return false; } saveToStorage() { if (this._storageSavePending) return; this._storageSavePending = true; - setTimeout(() => { + setTimeout(async () => { this._storageSavePending = false; try { - localStorage.setItem('webx11_filesystem', JSON.stringify({ - root: this.root, currentPath: this.currentPath - })); + const json = JSON.stringify({ root: this.root, currentPath: this.currentPath }); + const compressed = await this._compress(json); + if (compressed) { + localStorage.setItem('webx11_filesystem_v2', compressed); + localStorage.removeItem('webx11_filesystem'); + } else { + localStorage.setItem('webx11_filesystem', json); + } } catch (e) { console.error('FS save error:', e); } }, 2000); } @@ -122,25 +172,62 @@ class VirtualFileSystem { if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'file') { - delete resolved.dir.content[resolved.name]; - this.saveToStorage(); + this.trashFile(path, node); return true; } return false; } - mkdir(path) { + trashFile(path, node) { + try { + const name = path.split('/').pop() || 'unknown'; + const ts = Date.now(); + if (!this.root.content['.trash']) { + this.root.content['.trash'] = { type: 'dir', content: {} }; + } + const trash = this.root.content['.trash']; + const trashName = ts + '-' + name; + trash.content[trashName] = JSON.parse(JSON.stringify(node)); + const resolved = this._resolvePath(path); + if (resolved) delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + } catch (e) { console.error('Trash error:', e); } + } + trashDir(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; - const name = resolved.name; - if (name === '.' || name === '..') return false; - if (resolved.dir.content[name]) return false; - resolved.dir.content[name] = { type: 'dir', content: {} }; - this.saveToStorage(); - return true; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') { + try { + const name = resolved.name; + const ts = Date.now(); + if (!this.root.content['.trash']) { + this.root.content['.trash'] = { type: 'dir', content: {} }; + } + const trash = this.root.content['.trash']; + const flatten = (n, prefix) => { + if (n.type === 'file') { + const flatName = prefix + '-' + name; + let ctr = 0; + let unique = flatName; + while (trash.content[unique]) { unique = flatName + '-' + (++ctr); } + trash.content[unique] = JSON.parse(JSON.stringify(n)); + } else if (n.type === 'dir') { + for (const [childName, child] of Object.entries(n.content || {})) { + flatten(child, prefix + '-' + childName); + } + } + }; + flatten(node, ts + '-dir-' + name); + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } catch (e) { console.error('Trash dir error:', e); } + } + return false; } mkdirRecursive(path) { const segs = this._toAbsoluteSegments(path); - if (!segs || segs.length === 0) return true; + if (!segs) return false; let dir = this.root; for (const seg of segs) { if (!dir.content[seg]) { @@ -153,122 +240,209 @@ class VirtualFileSystem { this.saveToStorage(); return true; } - readDir(path) { - const resolved = this._resolvePath(path); - if (!resolved) return null; - if (resolved.name) { - const node = resolved.dir.content[resolved.name]; - return (node && node.type === 'dir') ? node : null; - } - return resolved.dir; - } - copyFile(srcPath, dstPath) { - const src = this._resolvePath(srcPath); + rename(oldPath, newPath) { + const src = this._resolvePath(oldPath); if (!src || !src.name) return false; - const srcNode = src.dir.content[src.name]; - if (!srcNode || srcNode.type !== 'file') return false; - const dst = this._resolvePath(dstPath); + const dst = this._resolvePath(newPath); if (!dst || !dst.name) return false; - const copy = JSON.parse(JSON.stringify(srcNode)); - dst.dir.content[dst.name] = copy; + const node = src.dir.content[src.name]; + if (!node) return false; + dst.dir.content[dst.name] = node; + delete src.dir.content[src.name]; this.saveToStorage(); return true; } - copyDir(srcPath, dstPath) { + copy(srcPath, dstPath) { const src = this._resolvePath(srcPath); if (!src || !src.name) return false; - const srcNode = src.dir.content[src.name]; - if (!srcNode || srcNode.type !== 'dir') return false; const dst = this._resolvePath(dstPath); if (!dst || !dst.name) return false; - if (dst.dir.content[dst.name]) return false; - const copy = JSON.parse(JSON.stringify(srcNode)); - dst.dir.content[dst.name] = copy; + const node = src.dir.content[src.name]; + if (!node) return false; + dst.dir.content[dst.name] = JSON.parse(JSON.stringify(node)); this.saveToStorage(); return true; } - moveFile(srcPath, dstPath) { - if (this.copyFile(srcPath, dstPath)) { - const src = this._resolvePath(srcPath); - if (src && src.name) { - delete src.dir.content[src.name]; - this.saveToStorage(); - return true; - } + move(srcPath, dstPath) { + return this.rename(srcPath, dstPath); + } + cp(srcPath, dstPath) { + return this.copy(srcPath, dstPath); + } + mv(srcPath, dstPath) { + return this.rename(srcPath, dstPath); + } + rm(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (!node) return false; + if (node.type === 'file') { + this.trashFile(path, node); + return true; + } + if (node.type === 'dir') { + this.trashDir(path); + return true; } return false; } - moveDir(srcPath, dstPath) { - if (this.copyDir(srcPath, dstPath)) { - const src = this._resolvePath(srcPath); - if (src && src.name) { - delete src.dir.content[src.name]; - this.saveToStorage(); - return true; - } + rmdir(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') { + if (Object.keys(node.content).length > 0) return false; + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; } return false; } - // Old-compat aliases for agent tools and SkillsModule - fsRead(path) { - const content = this.readFile(path); - return content !== null ? content : null; + touch(path) { + return this.writeFile(path, ''); } - fsWrite(path, content) { - return this.writeFile(path, content); + readDir(path) { + const resolved = this._resolvePath(path); + if (!resolved) return null; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return null; + } else { + dir = resolved.dir; + } + return Object.keys(dir.content).map(name => ({ + name, + type: dir.content[name].type, + size: dir.content[name].content ? dir.content[name].content.length : 0 + })); + } + fsWriteJSON(path, obj) { + return this.writeFile(path, JSON.stringify(obj)); } fsReadJSON(path) { - const c = this.fsRead(path); - if (c === null) return null; - try { return JSON.parse(c); } catch (e) { return null; } + const content = this.readFile(path); + if (!content) return null; + try { + return JSON.parse(content); + } catch (e) { + return null; + } } - fsWriteJSON(path, obj) { - return this.fsWrite(path, JSON.stringify(obj)); + tree(path = '', prefix = '') { + const resolved = this._resolvePath(path); + if (!resolved) return ''; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return ''; + } else { + dir = resolved.dir; + } + let result = ''; + const entries = Object.keys(dir.content).sort(); + for (let i = 0; i < entries.length; i++) { + const name = entries[i]; + const isLast = i === entries.length - 1; + const connector = isLast ? '└── ' : '├── '; + result += prefix + connector + name + '\n'; + if (dir.content[name].type === 'dir') { + const ext = isLast ? ' ' : '│ '; + result += this.tree(path + '/' + name, prefix + ext); + } + } + return result; } - findFiles(pattern) { + find(pattern, path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return []; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return []; + } else { + dir = resolved.dir; + } const results = []; - const search = (node, path) => { - if (node.type === 'dir' && node.content) { - for (const k of Object.keys(node.content)) { - const sub = node.content[k]; - const subPath = path ? path + '/' + k : k; - if (sub.type === 'dir') search(sub, subPath); - else if (sub.type === 'file' && k.toLowerCase().includes(pattern)) results.push(subPath); - } + const regex = new RegExp(pattern, 'i'); + for (const name of Object.keys(dir.content)) { + const fullPath = path ? path + '/' + name : name; + if (regex.test(name)) { + results.push({ path: fullPath, type: dir.content[name].type }); } - }; - search(this.root, ''); - return results.sort(); + if (dir.content[name].type === 'dir') { + results.push(...this.find(pattern, fullPath)); + } + } + return results; + } + grep(pattern, path = '') { + const results = []; + const regex = new RegExp(pattern, 'i'); + const files = this.find('.', path).filter(e => e.type === 'file'); + for (const file of files) { + const content = this.readFile(file.path); + if (content && regex.test(content)) { + results.push(file.path); + } + } + return results; } - deleteDirRecursive(path) { + chmod(path, mode) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; - if (!node || node.type !== 'dir') return false; - delete resolved.dir.content[resolved.name]; + if (!node) return false; + node.mode = mode; this.saveToStorage(); return true; } - // Full state serialization for session save/restore - serializeState() { - return JSON.stringify({ - root: this.root, - currentPath: this.currentPath, - version: 1 - }); + du(path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return 0; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return node.content ? node.content.length : 0; + } else { + dir = resolved.dir; + } + let size = 0; + for (const name of Object.keys(dir.content)) { + const node = dir.content[name]; + if (node.type === 'file') { + size += node.content ? node.content.length : 0; + } else if (node.type === 'dir') { + size += this.du(path ? path + '/' + name : name); + } + } + return size; + } + getSize(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return 0; + const node = resolved.dir.content[resolved.name]; + if (!node) return 0; + if (node.type === 'file') return node.content ? node.content.length : 0; + return this.du(path); } - restoreState(json) { - try { - const data = JSON.parse(json); - if (!data || !data.root || !data.root.type) return false; - this.root = data.root; - this.currentPath = data.currentPath || []; - this.saveToStorage(); - return true; - } catch (e) { - console.error('FS restore error:', e); - return false; + 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) { @@ -276,8 +450,15 @@ class VirtualFileSystem { try { content = JSON.parse(content); } catch (e) { return false; } } if (!content || !content.type) return false; - this.root = content; - this.currentPath = []; + // 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; } @@ -309,7 +490,10 @@ class VirtualFileSystem { } rollback(name) { const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); - if (!raw || !raw.root) return false; + if (!raw || !raw.root) { + console.error('Rollback failed: checkpoint not found or invalid'); + return false; + } this.root = raw.root; this.currentPath = raw.currentPath || []; this.saveToStorage(); @@ -317,15 +501,29 @@ class VirtualFileSystem { } listCheckpoints() { const dir = this.readDir('/_snapshots'); - if (!dir || !dir.content) return []; - const prefix = 'checkpoint-'; - const suffix = '.json'; - return Object.keys(dir.content) - .filter(k => k.startsWith(prefix) && k.endsWith(suffix)) - .map(k => k.slice(prefix.length, k.length - suffix.length)) - .sort(); + 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) { - return this.deleteFile('/_snapshots/checkpoint-' + name + '.json'); + 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); } } + +// Also expose for module system +window.VirtualFileSystem = VirtualFileSystem; |