aboutsummaryrefslogtreecommitdiff
path: root/modules/core
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core')
-rw-r--r--modules/core/module-aiasm.js26
-rw-r--r--modules/core/module-aiassist.js1435
-rw-r--r--modules/core/module-chatstudio.js1060
-rw-r--r--modules/core/module-config.js92
-rw-r--r--modules/core/module-duckduckgo.js38
-rw-r--r--modules/core/module-dynamicprompts.js102
-rw-r--r--modules/core/module-fetcher.js65
-rw-r--r--modules/core/module-fs.js445
-rw-r--r--modules/core/module-io.js80
-rw-r--r--modules/core/module-loader.js178
-rw-r--r--modules/core/module-manager.js138
-rw-r--r--modules/core/module-modmgr.js40
-rw-r--r--modules/core/module-my.js82
-rw-r--r--modules/core/module-ollama.js52
-rw-r--r--modules/core/module-ollamamodels.js87
-rw-r--r--modules/core/module-searxng.js73
-rw-r--r--modules/core/module-servermon.js107
-rw-r--r--modules/core/module-simplegui.js67
-rw-r--r--modules/core/module-skills.js271
-rw-r--r--modules/core/module-smartctx.js93
-rw-r--r--modules/core/module-system.js154
-rw-r--r--modules/core/ollama-manager.js842
-rw-r--r--modules/core/secret-vault.js459
-rw-r--r--modules/core/terminal.js1362
-rw-r--r--modules/core/virtual-fs.js320
25 files changed, 7668 insertions, 0 deletions
diff --git a/modules/core/module-aiasm.js b/modules/core/module-aiasm.js
new file mode 100644
index 0000000..60e1757
--- /dev/null
+++ b/modules/core/module-aiasm.js
@@ -0,0 +1,26 @@
+// 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(' '));
+ }
+ }
+ }
diff --git a/modules/core/module-aiassist.js b/modules/core/module-aiassist.js
new file mode 100644
index 0000000..0ef6a2f
--- /dev/null
+++ b/modules/core/module-aiassist.js
@@ -0,0 +1,1435 @@
+// Extracted from newOS4 index.html — WebX11AIAssistantModule (Full Agent Loop)
+ class WebX11AIAssistantModule {
+ constructor() {
+ this.commands = {}; this.terminal = null;
+ this.systemPrompt = this.buildSystemPrompt();
+ this.context = []; this.maxContext = 10;
+ this.maxContextTokens = 2000;
+ this.MAX_TOOL_TURNS = 8;
+ this._pendingConfirmation = null;
+ this._confirmResolve = null;
+ this._errorLog = [];
+ this._commandFreq = {};
+ this._messageCount = 0;
+ this._userProfile = {};
+ this._sessionMemoryLoaded = false;
+ this._sessionSaved = false;
+ this._thinkingTimer = null;
+ this._thinkingStartTime = 0;
+ this._toolDefs = null;
+ 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() {
+ return `You are WebX11AI, the AI assistant embedded in the WebX11 browser terminal. You have direct access to read/write files, search the web, install packages, edit modules, and run terminal commands — all via tool calls. Use tools proactively. Keep responses concise.
+
+== TOOL REFERENCE ==
+You have these tools available. Use them WITHOUT asking permission unless the tool description says (DESTRUCTIVE):
+
+FILESYSTEM:
+- list_files(path?) — List files in VFS directory (omit for current dir)
+- read_file(path) — Read file content (truncated past 8000 chars)
+- write_file(path, content) — Write to file (DESTRUCTIVE — confirm with user first)
+- make_directory(path) — Create a directory (DESTRUCTIVE — confirm first)
+- delete_file(path) — Delete file (DESTRUCTIVE — confirm first)
+- get_current_dir — Show current working directory
+
+SYSTEM:
+- get_status — Show filesystem, modules, Ollama, memory status
+- get_help — List all available terminal commands
+- run_command(command) — Execute any terminal command and capture output
+- font-size <8-32> — Change terminal font size (persists in localStorage)
+
+SEARCH:
+- web_search(query) — Search the web via proxy API (SearXNG + DDG fallback, results with title/url/snippet)
+- web_fetch(url) — Fetch and extract readable content from a URL via proxy API (returns title + clean text + word count)
+
+PACKAGES:
+- wpm_install(package) — Install WPM package by name (e.g. "widget", "calc")
+- wpm_search(keyword) — Search available WPM packages
+
+SKILLS (PERSISTENT PROCEDURAL MEMORY):
+- list_skills(category?) — Browse available skills, optionally filtered by category
+- load_skills(query) — Search skills relevant to current task. Call at START of every task.
+- create_skill(name, description, content, tags, category) — Save a new stored procedure. Call after completing complex multi-step tasks (3+ tool calls).
+- patch_skill(name, content) — Update an existing skill when the approach changes
+
+MODULE SELF-IMPROVEMENT:
+- view_module(name) — View source code of any loaded module
+- edit_module(name) — Copy module into editor. Opens editor for modifications.
+- test_module(name) — Load edited module from /_mods/ for live testing
+- reload_module(name) — Discard edits and reload from original URL
+- list_modified_modules — List modules with local edits pending
+- get_module_status — Full status of all modules (loaded, modified, URLs)
+
+== SKILLS WORKFLOW ==
+1. At the START of every task, call load_skills(query) to check if a stored procedure exists
+2. Follow the skill's steps. If they're wrong, call patch_skill to fix them.
+3. After a complex multi-step task (3+ tool calls), call create_skill to save the approach
+4. Skills persist in /skills/ as markdown files with YAML frontmatter
+
+== MEMORY SYSTEM ==
+- I maintain a conversation context (this.context) that persists across messages in this session
+- The last ${this.maxContext} exchanges are remembered
+- Type "ai-clear" to reset conversation memory
+- Type "ai-context" to see what I remember
+- Type "ai-model" to see/switch models
+- Type "ai-stop" to cancel a running AI request
+- Smart context profiles (set via "context <name>") adjust my temperature and system prompt style
+- Saved prompts (via "prompt", "prompt-save") let you reuse template with {variable} substitution
+
+== USER PROFILE ==
+- Use user_get, user_set to read/write persistent user preferences across sessions
+- user_set dot-notation supported: user_set knownFacts.name "Alex"
+- Profile auto-stores known facts (name, project, preferences)
+- Use session_save after complex tasks to log what was done
+- Use session_load to review past sessions
+- Use session_recent at start of session to remember what was worked on before
+
+== SELF-IMPROVEMENT WORKFLOW ==
+1. User asks for module changes → call get_module_status to list modules
+2. Call view_module(name) to read the module source
+3. Call edit_module(name) to copy source into editor for modification
+4. After editing, call test_module(name) to load and test the edit
+5. If it works, the user keeps it. If not, call reload_module(name).
+
+== AUTO-IMPROVEMENT (SYSTEM-DRIVEN) ==
+- If I encounter the same tool error 3+ times, a workaround skill is created automatically
+- After complex tasks (3+ tool calls), I run health checks and suggest improvements
+- I track conversation patterns and surface frequent topics
+- Every 20 messages, skills are audited for staleness
+- Use check_updates to verify module source URLs are reachable
+
+== RESPONSE STYLE ==
+- Be concise. Use bullet points for lists.
+- When using tools, explain what you're doing briefly.
+- If a tool fails, try an alternative approach.
+- For web research tasks: search first, then analyze the results, then present findings.
+|- When the user says "update" or "improve" without specifics, use get_status + get_help to understand the current state first.
+
+== REACT PROTOCOL ==
+For every task, follow this cycle:
+1. **REASON** — Think about what you need next. What information is missing?
+2. **ACT** — Call ONE tool at a time (unless multiple independent lookups are needed).
+3. **OBSERVE** — Study the tool result carefully. Did it succeed? Is the data useful?
+4. **DECIDE** — Do you have enough to answer? If not, go back to step 1.
+
+RULES:
+- Call tools in sequence when the second depends on the first result
+- Independent lookups (e.g. reading two unrelated files) can run in parallel
+- If a tool returns an error, try a different approach before giving up
+- After 6 tool turns, start synthesizing a final answer
+- Always provide a complete, helpful final answer — don't leave the user hanging`;
+ }
+ getToolDefinitions() {
+ if (!this._toolDefs) this._toolDefs = this._buildToolDefs();
+ return this._toolDefs;
+ }
+ _buildToolDefs() {
+ return [{
+ type: 'function',
+ function: {
+ name: 'list_files',
+ description: 'List files and directories in the current or specified virtual filesystem directory',
+ parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path to list, e.g. "" for root, "notes" for notes dir' } }, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'read_file',
+ description: 'Read contents of a file from the virtual filesystem',
+ parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to read, e.g. "hello.txt" or "notes/todo.md"' } }, required: ['path'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'write_file',
+ description: 'Write content to a file in the virtual filesystem (DESTRUCTIVE)',
+ parameters: { type: 'object', properties: { path: { type: 'string', description: 'File path to write' }, content: { type: 'string', description: 'Content to write' } }, required: ['path', 'content'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'make_directory',
+ description: 'Create a new directory in the virtual filesystem',
+ parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory name to create' } }, required: ['path'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'delete_file',
+ description: 'Delete a file or empty directory from the virtual filesystem (DESTRUCTIVE)',
+ parameters: { type: 'object', properties: { path: { type: 'string', description: 'Path to delete' } }, required: ['path'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'get_status',
+ description: 'Get system status: filesystem, modules, Ollama connection, memory',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'get_help',
+ description: 'Get full command reference for WebX11',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'web_search',
+ description: 'Search the web using the WebX11 proxy API (SearXNG + DDG fallback). Returns structured results with title, URL, and content snippet.',
+ parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query' } }, required: ['query'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'web_fetch',
+ description: 'Fetch and extract clean readable content from a URL using the WebX11 proxy API. Returns title, clean text content, and word count. Use for reading articles, documentation, or web pages.',
+ parameters: { type: 'object', properties: { url: { type: 'string', description: 'Full URL to fetch' } }, required: ['url'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'wpm_install',
+ description: 'Install a WebX11 package from the package index',
+ parameters: { type: 'object', properties: { package: { type: 'string', description: 'Package name to install, e.g. "widget" or "calc"' } }, required: ['package'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'wpm_search',
+ description: 'Search available WebX11 packages by keyword',
+ parameters: { type: 'object', properties: { keyword: { type: 'string', description: 'Search keyword' } }, required: ['keyword'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'run_command',
+ description: 'Execute any WebX11 terminal command directly and return its output',
+ parameters: { type: 'object', properties: { command: { type: 'string', description: 'Full command to execute, e.g. "grep error" or "wc -l"' } }, required: ['command'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'get_current_dir',
+ description: 'Get current working directory path in virtual filesystem',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'view_module',
+ description: 'View the source code of any loaded WebX11 module',
+ parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name, e.g. ThemeSwitcherModule' } }, required: ['name'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'edit_module',
+ description: 'Copy a module source into the editor for modification (DESTRUCTIVE)',
+ parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name to edit' } }, required: ['name'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'test_module',
+ description: 'Load an edited module from /_mods/ for live testing',
+ parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'reload_module',
+ description: 'Reload a module from its original URL, discarding local edits',
+ parameters: { type: 'object', properties: { name: { type: 'string', description: 'Module class name' } }, required: ['name'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'list_modified_modules',
+ description: 'List which modules have local edits pending test',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'get_module_status',
+ description: 'Full status of all modules: loaded, modified, URLs',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'load_skills',
+ description: 'Search available skills by relevance to a query. Skills are stored procedures with step-by-step approaches for recurring tasks. Returns matching skill content to help you work efficiently.',
+ parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query describing the task — e.g. "search the web" or "organize files"' } }, required: ['query'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'create_skill',
+ description: 'Save a new skill (stored procedure) so you can reuse this approach next time. Call after completing a complex multi-step task. Skills persist across sessions.',
+ parameters: { type: 'object', properties: {
+ name: { type: 'string', description: 'Short kebab-case name, e.g. "web-scrape-and-summarize"' },
+ description: { type: 'string', description: 'One-line description of what this skill does' },
+ content: { type: 'string', description: 'Markdown content with YAML frontmatter. Must start with "---\\nname: <name>\\n..."' },
+ tags: { type: 'string', description: 'Comma-separated tags, e.g. "search,web,scraping"' },
+ category: { type: 'string', description: 'Category, e.g. "research", "system", "dev"' }
+ }, required: ['name', 'description', 'content'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'patch_skill',
+ description: 'Update an existing skill file. Use when a stored procedure is outdated or has better steps. Replaces the entire skill content.',
+ parameters: { type: 'object', properties: {
+ name: { type: 'string', description: 'Skill name to patch (without .md)' },
+ content: { type: 'string', description: 'Complete new markdown content including YAML frontmatter' }
+ }, required: ['name', 'content'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'list_skills',
+ description: 'List all available skills. Optionally filter by category.',
+ parameters: { type: 'object', properties: { category: { type: 'string', description: 'Optional category filter, e.g. "research" or "system"' } }, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'check_updates',
+ description: 'Check if loaded modules are reachable at their source URLs. Reports any that appear outdated or unreachable.',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'user_get',
+ description: 'Read a value from the persistent user profile. Returns null if key not found.',
+ parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key to read, e.g. "preferredModel", "fontSize", "knownFacts.name"' } }, required: ['key'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'user_set',
+ description: 'Write a value to the persistent user profile. Saves across sessions.',
+ parameters: { type: 'object', properties: { key: { type: 'string', description: 'Profile key, e.g. "preferredModel"' }, value: { type: 'string', description: 'Value to store' } }, required: ['key', 'value'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'user_list',
+ description: 'List all stored keys in the user profile.',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'user_forget',
+ description: 'Delete a key from the user profile.',
+ parameters: { type: 'object', properties: { key: { type: 'string', description: 'Key to delete' } }, required: ['key'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'session_save',
+ description: 'Save a summary of the current session to /memory/. Use after complex tasks. Creates a daily log file.',
+ parameters: { type: 'object', properties: { summary: { type: 'string', description: 'Brief summary of what was accomplished this session' } }, required: ['summary'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'session_load',
+ description: 'Load a past session log by date. Format: YYYY-MM-DD.',
+ parameters: { type: 'object', properties: { date: { type: 'string', description: 'Date string like "2026-05-23"' } }, required: ['date'] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'session_recent',
+ description: 'Show summaries from the most recent sessions.',
+ parameters: { type: 'object', properties: { count: { type: 'number', description: 'Number of recent sessions to show (default 3)' } }, required: [] }
+ }
+ }, {
+ type: 'function',
+ function: {
+ name: 'session_stats',
+ description: 'Aggregate statistics across all sessions: total tasks, most-used tools, common topics.',
+ parameters: { type: 'object', properties: {}, required: [] }
+ }
+ }];
+ }
+ 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-stop': 'ai-stop — Cancel current AI operation',
+ 'ai-model': 'ai-model [model] — Show or set AI model'
+ };
+ }
+ 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-stop'] = () => this.cancelAi();
+ this.commands['ai-model'] = (args) => this.showModel(args);
+ this.commands['ai-compress'] = () => this._manualCompress();
+ }
+ async chat(args) {
+ const userMessage = args.join(' ');
+ if (!userMessage) {
+ this.terminal.print('Usage: ai <your-question>', 'warning'); return;
+ }
+ this._cancelled = false;
+ this.terminal.setAiBusy(true);
+ while (this._chatLock) await new Promise(r => setTimeout(r, 50));
+ this._chatLock = true;
+ this.terminal.markDirty();
+ // Re-read context from localStorage (auto-restore may have written it after init)
+ this._loadContext();
+ // Compress context if it exceeds the token threshold
+ this._compressContext();
+ let loopResult;
+ try {
+ const messages = [
+ { role: 'system', content: this.systemPrompt }
+ ];
+ this.context.slice().forEach(msg => messages.push(msg));
+ messages.push({ role: 'user', content: userMessage });
+ this.terminal.print('🤖 WebX11AI thinking...', 'info');
+ // Ensure skills directory exists and seed defaults on first run
+ this.terminal.fs.mkdirRecursive('/skills');
+ if (!this.terminal.fs.readFile('/skills/_seeded')) {
+ try {
+ await this._seedDefaultSkills();
+ } catch (e) { /* non-critical */ }
+ }
+ // 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 isTrivial = 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) {}
+ }
+
+ loopResult = await this.agenticLoop(messages);
+ this.context.push({ role: 'user', content: userMessage });
+ if (loopResult.finalAnswer) {
+ this.context.push({ role: 'assistant', content: loopResult.finalAnswer });
+ }
+ if (this.context.length > this.maxContext * 2) {
+ this.context = this.context.slice(-this.maxContext * 2);
+ }
+ // Reflection: after complex tasks, run health scan and auto-skill creation
+ if (loopResult.toolTurns >= 2 && !this._cancelled) {
+ await this.afterActionReflection(userMessage, loopResult.toolTurns);
+ }
+ // Auto-create skills from repeated errors
+ if (this._errorLog.length >= 3) {
+ await this._autoCreateErrorSkill();
+ }
+ // Skill quality audit every 20 messages
+ this._messageCount++;
+ if (this._messageCount % 20 === 0) {
+ await this._auditSkills();
+ }
+ // Sound notification on AI response
+ if (this.terminal.playNotificationSound) {
+ setTimeout(() => this.terminal.playNotificationSound(), 100);
+ }
+ } finally {
+ this._chatLock = false;
+ this.terminal.setAiBusy(false);
+ // Persist conversation context after each exchange
+ this._saveContext();
+ // Log the AI interaction
+ this.terminal.log('INFO', 'AI', 'Query: ' + userMessage.substring(0, 100));
+ if (loopResult && loopResult.toolTurns) {
+ this.terminal.log('INFO', 'AI', 'Used ' + loopResult.toolTurns + ' tool(s)');
+ }
+ }
+ // Register session save on page unload (runs once per session)
+ if (!this._sessionSaved) {
+ this._sessionSaved = true;
+ window.addEventListener('beforeunload', () => {
+ this.terminal.fs.mkdirRecursive('/memory');
+ const today = new Date().toISOString().split('T')[0];
+ const index = this.terminal.fs.fsReadJSON('/memory/index.json') || {};
+ index[today] = index[today] || { summary: 'Session in progress', tasks: 0 };
+ index[today].tasks = (index[today].tasks || 0) + 1;
+ index[today].updated = new Date().toISOString();
+ this.terminal.fs.fsWriteJSON('/memory/index.json', index);
+ // Also persist AI context on page close
+ this._saveContext();
+ });
+ }
+ }
+ _summarizeObservation(result, maxLen) {
+ if (!result || typeof result !== 'string' || result.length <= maxLen) return result || '';
+ const lines = result.split('\n');
+ if (lines.length > 5) {
+ return lines.slice(0, 2).join('\n') + '\n...(' + (lines.length - 4) + ' more lines)...\n' + lines.slice(-2).join('\n');
+ }
+ return result.substring(0, maxLen) + '...(truncated from ' + result.length + ' chars)';
+ }
+ _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;
+ }
+ async agenticLoop(messages) {
+ let toolTurns = 0;
+ while (toolTurns < this.MAX_TOOL_TURNS) {
+ if (this._cancelled) { this.terminal.print('AI loop cancelled', 'warning'); return { finalAnswer: null, toolTurns }; }
+ toolTurns++;
+ // On last turn, inject wrap-up prompt
+ if (toolTurns === this.MAX_TOOL_TURNS) {
+ this.terminal.print(`⏱ Final turn (${toolTurns}/${this.MAX_TOOL_TURNS}) — AI must provide final answer`, 'warning');
+ messages.push({ role: 'system', content: 'This is your last tool turn. You MUST provide a final answer after this tool call. Synthesize what you know.' });
+ }
+ const thinkStart = Date.now();
+ const res = await this.terminal.ollamaManager.chatWithTools(messages, this.getToolDefinitions());
+ const thinkTime = Math.round((Date.now() - thinkStart) / 1000);
+ if (this._cancelled) return { finalAnswer: null, toolTurns };
+ if (res.error) { this.terminal.print(`⚠️ AI model error (${thinkTime}s): ${res.error}`, 'error'); return { finalAnswer: null, toolTurns }; }
+ if (res.message && !res.tool_calls) {
+ const text = res.message.content || '';
+ this.terminal.printAIResponse(text || '(no response)');
+ if (text) this.terminal.ollamaManager.fireResponseHooks(text);
+ this.terminal.print(`💬 AI answered (turn ${toolTurns}/${this.MAX_TOOL_TURNS}, ${thinkTime}s)`, 'info');
+ return { finalAnswer: text, toolTurns };
+ }
+ if (res.tool_calls && res.tool_calls.length > 0) {
+ this.terminal.print(`🔧 AI wants ${res.tool_calls.length} tool(s) — turn ${toolTurns}/${this.MAX_TOOL_TURNS} (${thinkTime}s)`, 'info');
+ messages.push(res.message);
+ let pendingText = res.message?.content || null;
+ const destructiveTools = res.tool_calls.filter(tc => this.isDestructive(tc.function.name));
+ const safeTools = res.tool_calls.filter(tc => !this.isDestructive(tc.function.name));
+ // Execute non-destructive tools in parallel (independent read-only operations)
+ const safeResults = await Promise.all(safeTools.map(async (tc) => {
+ const fn = tc.function;
+ const t0 = Date.now();
+ const toolResult = await this.runTool(fn.name, fn.arguments);
+ const t1 = Math.round((Date.now() - t0) / 1000);
+ return { fn, toolResult, t1 };
+ }));
+ for (const { fn, toolResult, t1 } of safeResults) {
+ this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info');
+ const obs = this._summarizeObservation(toolResult, 150);
+ const err = this._isToolError(toolResult);
+ if (err) {
+ this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${err}`, 'warning');
+ } else {
+ this.terminal.print(`📊 Result (${t1}s): ${obs}`, 'info');
+ }
+ messages.push({ role: 'tool', content: toolResult });
+ }
+ // Only block on destructive tools
+ if (destructiveTools.length > 0) {
+ const approved = await this.confirmActionMulti(destructiveTools);
+ if (approved) {
+ for (const tc of destructiveTools) {
+ const fn = tc.function;
+ const t0 = Date.now();
+ this.terminal.print(`🔧 Tool: ${fn.name}(${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)})`, 'info');
+ const toolResult = await this.runTool(fn.name, fn.arguments);
+ const t1 = Math.round((Date.now() - t0) / 1000);
+ const dObs = this._summarizeObservation(toolResult, 150);
+ const dErr = this._isToolError(toolResult);
+ if (dErr) {
+ this.terminal.print(`📊 Result (${t1}s): ⚠️ Error: ${dErr}`, 'warning');
+ } else {
+ this.terminal.print(`📊 Result (${t1}s): ${dObs}`, 'info');
+ }
+ messages.push({ role: 'tool', content: toolResult });
+ }
+ } else {
+ messages.push({
+ role: 'tool',
+ content: `Destructive actions (${destructiveTools.map(tc => tc.function.name).join(', ')}) denied by user. Non-destructive actions already executed.`
+ });
+ }
+ }
+ if (pendingText) {
+ messages.push({ role: 'assistant', content: pendingText });
+ }
+ continue;
+ }
+ return { finalAnswer: null, toolTurns };
+ }
+ // Loop exited without final answer — auto-summary
+ const toolNames = Object.keys(this._commandFreq).slice(-3).join(', ');
+ this.terminal.print(`🤖 AI reached the ${toolTurns}/${this.MAX_TOOL_TURNS} tool limit.`, 'warning');
+ this.terminal.print('Tools were executed but no final answer was produced — try a more specific query.', 'info');
+ return { finalAnswer: null, toolTurns };
+ }
+ async afterActionReflection(query, toolTurns) {
+ // Debounce: only suggest skill creation once per 30 minutes
+ const now = Date.now();
+ if (this._lastSkillSuggestion && (now - this._lastSkillSuggestion) < 1800000) {
+ 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) {
+ 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;
+ 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. (Auto-denies in 120s)`);
+ // 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, 120 - 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;
+ setTimeout(() => {
+ clearInterval(timerInterval);
+ if (this._confirmResolve) {
+ this._confirmResolve(false);
+ this._cleanupConfirmBar();
+ this.terminal.print('⏱ Confirmation timed out — tool not executed', 'warning');
+ }
+ }, 120000);
+ });
+ }
+ _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);
+ 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);
+ }
+ 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);
+ }
+ isDestructive(name) {
+ return name === 'write_file' || name === 'delete_file' || name === 'run_command' || name === 'make_directory' || name === 'edit_module';
+ }
+ async runTool(name, argsStr) {
+ try {
+ const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ switch (name) {
+ case 'list_files': {
+ const originalPath = a.path || '';
+ const p = originalPath.replace(/^\//, '');
+ const saved = [...this.terminal.fs.currentPath];
+ if (originalPath.startsWith('/')) {
+ this.terminal.fs.currentPath = [];
+ }
+ if (p) {
+ const parts = p.split('/');
+ const dir = this.terminal.fs.root;
+ let cur = dir;
+ for (const part of parts) {
+ if (part === '..') { saved.pop(); continue; }
+ if (part && cur.content[part] && cur.content[part].type === 'dir') cur = cur.content[part];
+ else return JSON.stringify({ error: `Directory not found: ${p}` });
+ }
+ this.terminal.fs.currentPath = saved.concat(parts.filter(x => x !== '..'));
+ }
+ const files = this.terminal.fs.ls();
+ this.terminal.fs.currentPath = saved;
+ return JSON.stringify(files.map(f => ({ name: f.name, type: f.type, size: f.size })));
+ }
+ case 'read_file': {
+ const rp = a.path || '';
+ const savedR = [...this.terminal.fs.currentPath];
+ if (rp.startsWith('/')) this.terminal.fs.currentPath = [];
+ const path = rp.replace(/^\//, '');
+ const content = this.terminal.fs.readFile(path);
+ this.terminal.fs.currentPath = savedR;
+ if (content === null) return JSON.stringify({ error: `File not found: ${path}` });
+ return content.length > 8000 ? content.substring(0, 8000) + '\n...(truncated)' : content;
+ }
+ case 'write_file': {
+ const wp = a.path || '';
+ const savedW = [...this.terminal.fs.currentPath];
+ if (wp.startsWith('/')) this.terminal.fs.currentPath = [];
+ const path = wp.replace(/^\//, '');
+ const content = a.content || '';
+ this.terminal.fs.writeFile(path, content);
+ this.terminal.fs.currentPath = savedW;
+ return `File written: ${path} (${content.length} chars)`;
+ }
+ case 'make_directory': {
+ const mp = a.path || '';
+ const savedM = [...this.terminal.fs.currentPath];
+ if (mp.startsWith('/')) this.terminal.fs.currentPath = [];
+ const path = mp.replace(/^\//, '');
+ const ok = this.terminal.fs.mkdir(path);
+ this.terminal.fs.currentPath = savedM;
+ if (ok) return `Directory created: ${path}`;
+ return JSON.stringify({ error: `Cannot create: ${path}` });
+ }
+ case 'delete_file': {
+ const dp = a.path || '';
+ const savedD = [...this.terminal.fs.currentPath];
+ if (dp.startsWith('/')) this.terminal.fs.currentPath = [];
+ const path = dp.replace(/^\//, '');
+ const delOk = this.terminal.fs.deleteFile(path);
+ this.terminal.fs.currentPath = savedD;
+ if (delOk) return `Deleted: ${path}`;
+ return JSON.stringify({ error: `Cannot delete: ${path}` });
+ }
+ case 'get_status': {
+ const fs = this.terminal.fs;
+ const mm = this.terminal.moduleManager;
+ const om = this.terminal.ollamaManager;
+ return JSON.stringify({
+ files: fs.ls().length,
+ dirs: fs.currentPath.length ? '/' + fs.currentPath.join('/') : '/',
+ modules: Object.keys(mm.modules).length,
+ ollama: om.connected ? `Connected (${om.currentModel})` : 'Disconnected',
+ memory: performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(1) + 'MB' : 'N/A'
+ });
+ }
+ case 'get_help': {
+ const cmds = this.terminal.moduleManager.getAllCommands();
+ return JSON.stringify(Object.keys(cmds).sort());
+ }
+ case 'get_current_dir': {
+ return '/' + this.terminal.fs.currentPath.join('/');
+ }
+ case 'web_search': {
+ const query = a.query || '';
+ if (!query) return JSON.stringify({ error: 'No query provided' });
+ try {
+ const resp = await fetch('/api/search?q=' + encodeURIComponent(query));
+ if (!resp.ok) { return JSON.stringify({ error: 'Search API returned HTTP ' + resp.status }); }
+ 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' });
+ try {
+ const resp = await fetch('/api/extract?url=' + encodeURIComponent(url));
+ if (resp.status === 403) return JSON.stringify({ error: 'URL blocked — internal network' });
+ if (!resp.ok) { return JSON.stringify({ error: 'Fetch API returned HTTP ' + resp.status }); }
+ const data = await resp.json();
+ return JSON.stringify({ title: data.title || '', content: (data.content || '').substring(0, 4000), word_count: data.word_count || 0, method: data.method || 'extract' });
+ } catch (e) { return JSON.stringify({ error: 'Fetch failed: ' + e.message }); }
+ }
+ case 'wpm_install': {
+ const pkg = a.package || '';
+ if (!pkg) return JSON.stringify({ error: 'No package name' });
+ const mod = this.terminal.moduleManager.modules['WpmModule'];
+ if (mod) { await mod.install(pkg); return `Package "${pkg}" installation attempted`; }
+ return JSON.stringify({ error: 'WPM module not available' });
+ }
+ case 'wpm_search': {
+ const kw = a.keyword || '';
+ if (!kw) return JSON.stringify({ error: 'No keyword' });
+ const mod = this.terminal.moduleManager.modules['WpmModule'];
+ if (mod) {
+ const originalPrint = this.terminal.print.bind(this.terminal);
+ let output = '';
+ this.terminal.print = (text, type) => { output += text + '\n'; };
+ try { await mod.search(kw); } finally { this.terminal.print = originalPrint; }
+ return output.trim() || 'Search completed';
+ }
+ return JSON.stringify({ error: 'WPM module not available' });
+ }
+ case 'run_command': {
+ const cmd = a.command || '';
+ if (!cmd) return JSON.stringify({ error: 'No command provided' });
+ const originalPrint = this.terminal.print.bind(this.terminal);
+ let output = '';
+ this.terminal.print = (text, type) => { output += text + '\n'; };
+ try { await this.terminal.dispatchCommand(cmd.split(' ')[0].toLowerCase(), cmd.split(' ').slice(1)); } finally { this.terminal.print = originalPrint; }
+ 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_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 'load_skills': {
+ const query = (a && a.query || '').toLowerCase();
+ const fs = this.terminal.fs;
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ if (!query) {
+ const names = Object.keys(index);
+ return JSON.stringify({ skills: names, count: names.length, message: 'No query provided — showing all skill names. Call again with a query to load content.' });
+ }
+ const matches = [];
+ for (const [name, meta] of Object.entries(index)) {
+ const score = (name.includes(query) ? 3 : 0) + (meta.description.toLowerCase().includes(query) ? 2 : 0) + ((meta.tags || []).some(t => t.toLowerCase().includes(query)) ? 2 : 0);
+ if (score > 0) {
+ const content = fs.fsRead(`/skills/${name}.md`) || '';
+ matches.push({ name, description: meta.description, category: meta.category, score, content: content.substring(0, 2000) });
+ }
+ }
+ matches.sort((a, b) => b.score - a.score);
+ const top = matches.slice(0, 3);
+ return JSON.stringify(top.length > 0 ? { matches: top.map(m => ({ name: m.name, description: m.description, content: m.content })), count: top.length } : { matches: [], count: 0, message: 'No matching skills found. You can create one after this task.' });
+ }
+ case 'create_skill': {
+ const name = (a.name || '').trim().replace(/\s+/g, '-').toLowerCase();
+ if (!name) return JSON.stringify({ error: 'Skill name is required' });
+ const description = a.description || '';
+ let content = a.content || '';
+ const tags = (a.tags || '').split(',').map(t => t.trim()).filter(Boolean);
+ const category = a.category || 'general';
+ if (!content.startsWith('---')) {
+ content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: ${category}\n---\n## Steps\n${content}`;
+ }
+ const fs = this.terminal.fs;
+ fs.mkdirRecursive('/skills');
+ fs.fsWrite(`/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.fsWrite(`/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 '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);
+ default:
+ return JSON.stringify({ error: `Unknown 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) });
+ }
+ }
+ async runModuleTool(name, argsStr) {
+ const a = typeof argsStr === 'object' ? argsStr : (argsStr ? JSON.parse(argsStr) : {});
+ switch (name) {
+ case 'view_module': {
+ const modName = a.name || '';
+ const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName);
+ const code = this.terminal.fs.readFile(`_mods/${modName}.js`);
+ if (code) return code;
+ const url = entry ? entry.url : null;
+ if (!url) return JSON.stringify({ error: `No source URL for ${modName}` });
+ try {
+ const resp = await fetch(url);
+ if (resp.ok) {
+ const src = await resp.text();
+ this.terminal.fs.writeFile(`_mods/${modName}.orig.js`, src);
+ return src.substring(0, 8000);
+ }
+ return JSON.stringify({ error: `HTTP ${resp.status}` });
+ } catch (e) { return JSON.stringify({ error: e.message }); }
+ }
+ 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) {}
+ }
+ if (code) this.terminal.openCodeEditor(modName, url || '', code);
+ return code ? `Editor opened for ${modName} (${code.length} chars)` : JSON.stringify({ error: `Could not load source for ${modName}` });
+ }
+ case 'test_module': {
+ const modName = a.name || '';
+ const code = this.terminal.fs.readFile(`_mods/${modName}.js`);
+ if (!code) return JSON.stringify({ error: `No local edit for ${modName}` });
+ this.terminal.moduleManager.loadModuleFromCode(code, `blob://_mods/${modName}.js`);
+ return `Module "${modName}" loaded from local edit for testing`;
+ }
+ case 'reload_module': {
+ const modName = a.name || '';
+ const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName);
+ this.terminal.fs.deleteFile(`_mods/${modName}.js`);
+ this.terminal.fs.deleteFile(`_mods/${modName}.orig.js`);
+ this.terminal.fs.deleteFile(`_mods/${modName}.meta.json`);
+ if (entry && entry.url) {
+ this.terminal.moduleManager.unloadModule(modName);
+ await this.terminal.moduleManager.loadModuleFromURL(entry.url);
+ return `Module "${modName}" reloaded from ${entry.url}`;
+ }
+ return JSON.stringify({ error: `No original URL for ${modName}` });
+ }
+ case 'list_modified_modules': {
+ const modsDir = this.terminal.fs.getCurrentDir();
+ const mc = modsDir.content['_mods'];
+ if (!mc || Object.keys(mc.content).length === 0) return JSON.stringify({ modified: [], message: 'No local edits' });
+ const files = Object.keys(mc.content).filter(k => k.endsWith('.js') && !k.endsWith('.orig.js'));
+ return JSON.stringify({ modified: files.map(f => f.replace('.js', '')), count: files.length });
+ }
+ case 'get_module_status': {
+ const mm = this.terminal.moduleManager;
+ const modsDir = this.terminal.fs.getCurrentDir();
+ const hasMods = modsDir.content['_mods'] ? !!Object.keys(modsDir.content['_mods'].content).length : false;
+ const modules = mm.moduleRegistry.map(m => ({
+ name: m.name, url: m.url, enabled: m.enabled,
+ loaded: !!mm.modules[m.name],
+ modified: !!this.terminal.fs.readFile(`_mods/${m.name}.js`)
+ }));
+ return JSON.stringify({ modules, total: modules.length, hasLocalEdits: hasMods });
+ }
+ default: return JSON.stringify({ error: `Unknown module tool: ${name}` });
+ }
+ }
+ 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(`Current system prompt: ${this.systemPrompt.substring(0, 50)}...`, 'info'); return;
+ }
+ this.systemPrompt = args.join(' ');
+ this.terminal.print('System prompt updated', 'success');
+ }
+ 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');
+ }
+ }
+ 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}...`);
+ });
+ }
+ _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;
+ for (const { re, key } of patterns) {
+ const m = (userMsg + ' ' + (aiResp || '')).match(re);
+ if (m && m[1]) {
+ if (!this._userProfile.knownFacts) this._userProfile.knownFacts = {};
+ if (this._userProfile.knownFacts[key] !== m[1].trim()) {
+ this._userProfile.knownFacts[key] = m[1].trim();
+ changed = true;
+ }
+ }
+ }
+ if (changed) {
+ try { localStorage.setItem('webx11_user_profile', JSON.stringify(this._userProfile)); } catch (e) {}
+ }
+ }
+ async _seedDefaultSkills() {
+ const fs = this.terminal.fs;
+ const mindmapSkill = `---
+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
+---
+## Steps
+
+1. **Create a new mindmap:**
+ - \`mindmap new "<topic>" -t tag1,tag2\` — creates a root node
+ - Tags are optional (e.g. planning, research, dev)
+ - Root gets auto-assigned a color
+
+2. **Add child nodes:**
+ - \`mindmap add <parent-id> "<idea>" -t tag\` — add a child
+ - Get the parent-id from \`mindmap show\`
+ - IDs are 4-char random strings (e.g. a3f8, x7k2)
+
+3. **View the mindmap:**
+ - \`mindmap show\` — full tree with Unicode box-drawing
+ - \`mindmap show <id>\` — subtree starting at that node
+
+4. **Edit nodes:**
+ - \`mindmap rename <id> "<new text>"\` — change text
+ - \`mindmap color <id> <color>\` — set color (red/green/yellow/blue/cyan/magenta/white)
+ - \`mindmap tag <id> <tag>\` — add a tag
+
+5. **Persistence:**
+ - \`mindmap save <name>\` — save to localStorage
+ - \`mindmap load <name>\` — restore
+ - \`mindmap list\` — show saved mindmaps
+ - \`mindmap delete <name>\` — remove saved
+
+6. **Export:**
+ - \`mindmap export [id]\` — Markdown bullet list output
+
+## When to use
+- User wants to brainstorm, organize ideas, plan features
+- User says "map out", "diagram", "brainstorm", "organize thoughts"
+- Visualizing project structure or task decomposition`;
+
+ fs.fsWrite('/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');
+ }
+ _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;
+ }
+ try { localStorage.setItem('webx11_ai_patterns', JSON.stringify(this._commandFreq)); } catch (e) {}
+ }
+ async _autoCreateErrorSkill() {
+ const grouped = {};
+ for (const e of this._errorLog) {
+ if (!grouped[e.tool]) grouped[e.tool] = [];
+ grouped[e.tool].push(e);
+ }
+ for (const [tool, errors] of Object.entries(grouped)) {
+ if (errors.length >= 3) {
+ const skillName = 'tool-' + tool + '-workaround';
+ const existing = this.terminal.fs.fsRead('/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.fsRead('/skills/' + name + '.md');
+ if (!content || content.length < 50) {
+ issues.push({ name, issue: 'empty or too short' });
+ continue;
+ }
+ const updated = new Date(meta.updated || 0).getTime();
+ const ageDays = (now - updated) / 86400000;
+ if (ageDays > 30) issues.push({ name, issue: 'not updated in ' + Math.round(ageDays) + ' days' });
+ }
+ if (issues.length > 0) {
+ this.terminal.print('📋 Skill audit: ' + issues.length + ' issue(s) found', 'info');
+ issues.slice(0, 3).forEach(i => this.terminal.print(' - ' + i.name + ': ' + i.issue, 'warning'));
+ }
+ }
+ _estimateTokens(text) {
+ if (!text || typeof text !== 'string') return 0;
+ return Math.ceil(text.length / 4);
+ }
+ _estimateContextTokens() {
+ let total = 0;
+ for (const msg of this.context) {
+ total += this._estimateTokens(msg.content || '');
+ }
+ return total;
+ }
+ async _compressContext() {
+ if (this.context.length <= 6) return;
+ const totalTokens = this._estimateContextTokens();
+ if (totalTokens < this.maxContextTokens) return;
+ const keepCount = 3;
+ const oldMsgs = this.context.slice(0, -keepCount * 2);
+ if (oldMsgs.length < 2) return;
+ const recentMsgs = this.context.slice(-keepCount * 2);
+ const summaryText = oldMsgs.map(m =>
+ (m.role === 'user' ? '👤' : m.role === 'assistant' ? '🤖' : '⚙️') + ': ' +
+ (m.content || '').substring(0, 150)
+ ).join(' | ');
+ let compressed = `[Earlier context (${oldMsgs.length} messages): ${summaryText}]`;
+ // Try AI summarization, fallback to truncation
+ try {
+ if (this.terminal.ollamaManager && this.terminal.ollamaManager.connected) {
+ const sumPrompt = 'Summarize these conversation exchanges into a single compact sentence. Focus on what the user asked and what actions were taken. Keep it under 200 characters.\n\n' + JSON.stringify(oldMsgs.map(m => ({ role: m.role, content: (m.content || '').substring(0, 200) })));
+ const sumRes = await this.terminal.ollamaManager.chat(sumPrompt);
+ if (sumRes && sumRes.length < 300) {
+ compressed = '[Earlier context: ' + sumRes.trim() + ']';
+ }
+ }
+ } catch (e) { /* fallback to truncation */ }
+ this.context = [{ role: 'system', content: compressed }, ...recentMsgs];
+ this.terminal.print('📦 Context compressed (' + oldMsgs.length + ' messages → 1 summary, ~' + totalTokens + ' tokens)', 'info');
+ }
+ _manualCompress() {
+ this._compressContext().then(() => {
+ this.terminal.print('Context: ' + this.context.length + ' message(s), ~' + this._estimateContextTokens() + ' estimated tokens', 'info');
+ }).catch(e => {
+ this.terminal.print('Compression error: ' + e.message, 'error');
+ });
+ }
+ }
diff --git a/modules/core/module-chatstudio.js b/modules/core/module-chatstudio.js
new file mode 100644
index 0000000..5777acf
--- /dev/null
+++ b/modules/core/module-chatstudio.js
@@ -0,0 +1,1060 @@
+/*
+ * ChatStudio Overlay Module for newOS4 v4.2
+ * 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',
+ 'chat-setapi': 'Set the API base URL for ChatStudio',
+ 'chat-setmodel': 'Set the default model for ChatStudio',
+ 'chat-ask': 'Ask a question directly via ChatStudio'
+ };
+ }
+
+ registerCommands() {
+ this.commands['chat'] = () => this.toggleChatOverlay();
+ this.commands['chat-setapi'] = (args) => {
+ if (!args[0]) {
+ const base = window.API_BASE || '/api';
+ this.terminal.print('Current API base: ' + base, 'info');
+ this.terminal.print('Usage: chat-setapi <base-url>', 'info');
+ this.terminal.print('Example: chat-setapi /api', 'info');
+ return;
+ }
+ const url = args[0].replace(/\/+$/, '');
+ window.API_BASE = url;
+ try {
+ var settings = JSON.parse(localStorage.getItem('webx11_chat_settings') || '{}');
+ settings.apiBase = url;
+ localStorage.setItem('webx11_chat_settings', JSON.stringify(settings));
+ } catch (e) {}
+ this.terminal.print('API base set to: ' + url + ' (reopen chat to apply)', 'success');
+ };
+ }
+
+ toggleChatOverlay() {
+ if (this._active) {
+ this._closeOverlay();
+ } else {
+ this._openOverlay();
+ }
+ }
+
+ _loadOverlayAssets() {
+ return new Promise((resolve, reject) => {
+ // Load CSS
+ if (!this._cssLoaded) {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = 'chat/chat-ui.css?v=4';
+ link.onload = () => { this._cssLoaded = true; };
+ link.onerror = () => reject(new Error('Failed to load chat/chat-ui.css'));
+ document.head.appendChild(link);
+ }
+
+ // Expose ALL Lite globals BEFORE scripts load so chat-modules.js & chat-commands.js can use them
+ if (!window._csGlobalsInited) {
+ window.OLLAMA_URL = (this.terminal && this.terminal.ollamaManager && this.terminal.ollamaManager.endpoint) || '/ollama';
+ window.OLLAMA_API_KEY = '';
+ window.DEFAULT_MODEL = 'llama3.2';
+ window.USE_STREAMING = true;
+ window.API_BASE = '/api';
+ window.latestAiContent = '';
+ window.logs = [];
+ window.messages = [];
+ window.isStreaming = false;
+ window.CHATSTUDIO_VERSION = '1.0.0-lite-inline';
+ window.modules = [];
+ window.currentChatModule = 'default';
+
+ // Utility functions
+ window.utf8_to_b64 = function(str) { return btoa(unescape(encodeURIComponent(str))); };
+ window.b64_to_utf8 = function(str) { return decodeURIComponent(escape(atob(str))); };
+ window.escapeHtml = function(text) {
+ if (typeof text !== 'string') return '';
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');
+ };
+ 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
+ fetch('chat/chat-ui.html?v=4')
+ .then(r => r.text())
+ .then(html => {
+ // 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;
+ s.onload = () => res();
+ s.onerror = () => rej(new Error('Failed to load ' + src));
+ document.body.appendChild(s);
+ });
+ };
+
+ Promise.all([
+ loadScript('chat/chat-commands.js?v=4'),
+ loadScript('chat/chat-modules.js?v=4')
+ ]).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
+ const partialOverlay = document.getElementById('chat-overlay');
+ if (partialOverlay) {
+ partialOverlay.remove();
+ }
+
+ // Reset state so user can retry
+ this._active = false;
+ }
+ }
+
+ _closeOverlay() {
+ if (!this._active) return;
+ this._active = false;
+
+ const overlay = document.getElementById('chat-overlay');
+ if (overlay) {
+ overlay.style.display = 'none';
+ }
+
+ // Remove Escape listener
+ if (this._escapeHandler) {
+ document.removeEventListener('keydown', this._escapeHandler);
+ this._escapeHandler = null;
+ }
+
+ // Focus terminal input
+ if (this.terminal && this.terminal.inputField) {
+ setTimeout(() => this.terminal.inputField.focus(), 50);
+ }
+ }
+
+ _initLiteEngine() {
+ // Already initialized — no-op
+ if (this._initialized) return;
+
+ // Set the overlay reference
+ this._overlay = document.getElementById('chat-overlay');
+ if (!this._overlay) {
+ if (this.terminal && this.terminal.print) {
+ this.terminal.print('Chat overlay: #chat-overlay element not found', 'error');
+ }
+ return;
+ }
+
+ this._initialized = true;
+
+ try {
+ // Close button already exists in HTML as "✕ Close" button calling closeChatOverlay()
+ window.closeChatOverlay = () => this._closeOverlay();
+
+ // ── Constants ──
+ const CHATSTUDIO_VERSION = '1.0.0-lite-inline';
+ const API_KEY = 'webx11_chat_api';
+ const MESSAGES_KEY = 'cslite_messages';
+ const SETTINGS_KEY = 'webx11_chat_settings';
+ const LOGS_KEY = 'webx11_chat_logs';
+
+ // ── State ──
+ let OLLAMA_URL = '/ollama';
+ let OLLAMA_API_KEY = '';
+ let DEFAULT_MODEL = 'llama3.2';
+ let USE_STREAMING = true;
+ let API_BASE = '/api';
+ let latestAiContent = '';
+ let logs = [];
+ let messages = [];
+ let isStreaming = false;
+
+ // Sync OLLAMA_URL from terminal
+ if (this.terminal && this.terminal.ollamaManager) {
+ OLLAMA_URL = this.terminal.ollamaManager.endpoint || '/ollama';
+ }
+
+ // ── Utility Functions ──
+ function escapeHtml(text) {
+ if (typeof text !== 'string') return '';
+ return text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+ }
+
+ function utf8_to_b64(str) {
+ return btoa(unescape(encodeURIComponent(str)));
+ }
+
+ function b64_to_utf8(str) {
+ return decodeURIComponent(escape(atob(str)));
+ }
+
+ function generateId() {
+ return Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9);
+ }
+
+ function showToast(msg) {
+ var t = document.getElementById('toast');
+ if (!t) return;
+ t.textContent = msg;
+ t.style.display = 'block';
+ setTimeout(function () {
+ t.style.display = 'none';
+ }, 2500);
+ }
+
+ function autoExpand(el) {
+ el.style.height = 'auto';
+ el.style.height = Math.min(el.scrollHeight, 150) + 'px';
+ }
+
+ function toggleSidebar() {
+ var s = document.getElementById('sidebar');
+ if (s) s.classList.toggle('open');
+ var b = document.getElementById('sidebarBackdrop');
+ if (b) b.classList.toggle('open');
+ }
+ function closeSidebar() {
+ var s = document.getElementById('sidebar');
+ if (s) s.classList.remove('open');
+ var b = document.getElementById('sidebarBackdrop');
+ if (b) b.classList.remove('open');
+ }
+
+ // ── Log System ──
+ function addLog(action, detail) {
+ var time = new Date().toLocaleTimeString();
+ logs.unshift({ time: time, action: action, detail: detail });
+ if (logs.length > 50) logs.pop();
+ renderLogs();
+ try {
+ localStorage.setItem(LOGS_KEY, utf8_to_b64(JSON.stringify(logs)));
+ } catch (e) { /* ignore */ }
+ }
+
+ function renderLogs() {
+ var container = document.getElementById('logsContent');
+ if (!container) return;
+ container.innerHTML = logs.map(function (l) {
+ return '<div class="log-entry">' +
+ '<span class="log-time">' + escapeHtml(l.time) + '</span>' +
+ '<span class="log-action">' + escapeHtml(l.action) + '</span>' +
+ '<span class="log-detail">' + escapeHtml(l.detail) + '</span>' +
+ '</div>';
+ }).join('');
+ }
+
+ function copyLogs() {
+ var text = logs.map(function (l) {
+ return l.time + ' | ' + l.action + ' | ' + l.detail;
+ }).join('\n');
+ navigator.clipboard.writeText(text).then(function () {
+ showToast('Logs copied!');
+ });
+ }
+
+ function clearLogs() {
+ if (!confirm('Clear all activity logs?')) return;
+ logs = [];
+ try {
+ localStorage.removeItem(LOGS_KEY);
+ } catch (e) { /* ignore */ }
+ renderLogs();
+ showToast('Logs cleared');
+ }
+
+ function loadLogs() {
+ try {
+ var saved = localStorage.getItem(LOGS_KEY);
+ if (saved) logs = JSON.parse(b64_to_utf8(saved));
+ } catch (e) {
+ logs = [];
+ }
+ renderLogs();
+ }
+
+ // ── Settings ──
+ function loadSettings() {
+ try {
+ var saved = localStorage.getItem(SETTINGS_KEY);
+ if (saved) {
+ var settings = JSON.parse(b64_to_utf8(saved));
+ OLLAMA_URL = settings.ollamaUrl || '/ollama';
+ OLLAMA_API_KEY = settings.ollamaApiKey || '';
+ DEFAULT_MODEL = settings.defaultModel || 'llama3.2';
+ USE_STREAMING = settings.useStreaming !== false;
+ API_BASE = settings.apiBase || '/api';
+ }
+ } catch (e) {
+ console.warn('Failed to load settings:', e);
+ }
+ // Sync from terminal if available
+ var term = window.webx11;
+ if (term && term.ollamaManager) {
+ OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL;
+ }
+ var urlEl = document.getElementById('ollamaUrl');
+ var keyEl = document.getElementById('ollamaApiKey');
+ var modelEl = document.getElementById('defaultModel');
+ var streamEl = document.getElementById('streamingToggle');
+ var apiEl = document.getElementById('apiBase');
+ if (urlEl) urlEl.value = OLLAMA_URL;
+ if (keyEl) keyEl.value = OLLAMA_API_KEY;
+ if (modelEl) modelEl.value = DEFAULT_MODEL;
+ if (streamEl) streamEl.checked = USE_STREAMING;
+ if (apiEl) apiEl.value = API_BASE;
+ }
+
+ function saveSettings() {
+ OLLAMA_URL = document.getElementById('ollamaUrl').value.trim() || '/ollama';
+ OLLAMA_API_KEY = document.getElementById('ollamaApiKey').value.trim();
+ DEFAULT_MODEL = document.getElementById('defaultModel').value.trim() || 'llama3.2';
+ USE_STREAMING = document.getElementById('streamingToggle').checked;
+ API_BASE = document.getElementById('apiBase').value.trim() || '/api';
+ try {
+ var settings = {
+ ollamaUrl: OLLAMA_URL,
+ ollamaApiKey: OLLAMA_API_KEY,
+ defaultModel: DEFAULT_MODEL,
+ useStreaming: USE_STREAMING,
+ apiBase: API_BASE
+ };
+ localStorage.setItem(SETTINGS_KEY, utf8_to_b64(JSON.stringify(settings)));
+ } catch (e) {
+ console.warn('Failed to save settings:', e);
+ }
+ closeSettingsModal();
+ showToast('Settings saved!');
+ }
+
+ function openSettingsModal() {
+ loadSettings();
+ var m = document.getElementById('settingsModal');
+ if (m) m.classList.add('open');
+ }
+
+ function closeSettingsModal() {
+ var m = document.getElementById('settingsModal');
+ if (m) m.classList.remove('open');
+ }
+
+ async function testConnection() {
+ var status = document.getElementById('connectionStatus');
+ if (!status) return;
+ status.textContent = 'Testing...';
+ status.style.color = 'var(--text2)';
+ try {
+ var headers = { 'Content-Type': 'application/json' };
+ if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY;
+ var response = await fetch(OLLAMA_URL + '/api/tags', { headers: headers });
+ if (response.ok) {
+ var data = await response.json();
+ var modelCount = data.models ? data.models.length : 0;
+ status.textContent = 'Connected! ' + modelCount + ' models available.';
+ status.style.color = 'var(--accent)';
+ } else {
+ status.textContent = 'Connection failed: HTTP ' + response.status;
+ status.style.color = 'var(--danger)';
+ }
+ } catch (e) {
+ status.textContent = 'Error: ' + e.message;
+ status.style.color = 'var(--danger)';
+ }
+ }
+
+ // ── Models ──
+ async function loadModels() {
+ try {
+ var response = await fetch(OLLAMA_URL + '/api/tags');
+ if (!response.ok) throw new Error('Failed to fetch models');
+ var data = await response.json();
+ var select = document.getElementById('modelSelect');
+ if (!select) return;
+ var shortNames = {
+ 'llama3.2': 'Llama3.2',
+ 'llama3': 'Llama3',
+ 'gemma3:4b': 'Gemma3-4B',
+ 'gemma3:1b': 'Gemma3-1B',
+ 'gemma2:2b': 'Gemma2',
+ 'qwen2.5:7b-instruct': 'Qwen2.5',
+ 'qwen3:8b': 'Qwen3',
+ 'mistral': 'Mistral',
+ 'tinyllama': 'Tiny'
+ };
+ select.innerHTML = data.models.map(function (m) {
+ var name = m.name.split(':')[0];
+ var display = shortNames[m.name] || name.substring(0, 14);
+ return '<option value="' + escapeHtml(m.name) + '">' + escapeHtml(display) + '</option>';
+ }).join('');
+ addLog('Init', 'Loaded ' + data.models.length + ' models');
+ } catch (e) {
+ console.error('Failed to load models:', e);
+ var select = document.getElementById('modelSelect');
+ if (select) select.innerHTML = '<option value="llama3.2">llama3.2 (default)</option>';
+ }
+ }
+
+ var modelSelect = document.getElementById('modelSelect');
+ if (modelSelect) {
+ modelSelect.addEventListener('change', function () {
+ addLog('Model', 'Switched to ' + this.value);
+ });
+ }
+
+ // ── Message History ──
+ function saveMessages(msgs) {
+ msgs = msgs || messages;
+ try {
+ localStorage.setItem(MESSAGES_KEY, JSON.stringify(msgs));
+ } catch (e) {
+ console.warn('Failed to save messages:', e);
+ }
+ }
+
+ function loadMessages() {
+ try {
+ var raw = localStorage.getItem(MESSAGES_KEY);
+ if (raw) {
+ messages = JSON.parse(raw);
+ }
+ } catch (e) {
+ console.warn('Failed to load messages:', e);
+ messages = [];
+ }
+ }
+
+ function renderMessagesFromData() {
+ var container = document.getElementById('chatContainer');
+ if (!container) return;
+ if (!messages || messages.length === 0) {
+ return;
+ }
+ container.innerHTML = '';
+ for (var i = 0; i < messages.length; i++) {
+ var msg = messages[i];
+ appendMessageToDOM(msg.role, msg.content, msg.id, false);
+ }
+ container.scrollTop = container.scrollHeight;
+ }
+
+ function appendMessageToDOM(role, content, msgId, save) {
+ if (save === undefined) save = true;
+ var container = document.getElementById('chatContainer');
+ if (!container) return;
+ var div = document.createElement('div');
+ div.className = 'message ' + role;
+ if (msgId) div.dataset.msgId = msgId;
+ var previewBtn = role === 'ai'
+ ? '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>'
+ : '';
+ if (role === 'ai') latestAiContent = content;
+ var renderedContent = role === 'user' ? escapeHtml(content) : formatContent(content);
+ div.innerHTML = '<div class="role">' +
+ '<span>' + (role === 'user' ? 'You' : 'AI') + '</span>' +
+ '<div style="display:flex;gap:4px;">' +
+ '<button class="msg-copy" onclick="copyMessage(this, \'' + role + '\')" title="Copy">📋</button>' +
+ previewBtn +
+ '</div></div>' +
+ '<div class="msg-content">' + renderedContent + '</div>';
+ div.dataset.content = content;
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+ if (save) {
+ saveMessages();
+ }
+ }
+
+ function addMessage(role, content) {
+ var msgId = generateId();
+ var msg = {
+ id: msgId,
+ role: role,
+ content: content,
+ timestamp: Date.now()
+ };
+ messages.push(msg);
+ appendMessageToDOM(role, content, msgId, true);
+ return msgId;
+ }
+
+ // ── Content Formatting ──
+ function formatContent(text) {
+ // Step 1: Extract code blocks into placeholders
+ var blocks = [];
+ var processed = text
+ .replace(/```html\n?([\s\S]*?)```/g, function(m, code) {
+ var idx = blocks.length;
+ blocks.push({lang: 'html', code: code, isBlock: true});
+ return '%%CODEBLOCK_' + idx + '%%';
+ })
+ .replace(/```(\w*)\n?([\s\S]*?)```/g, function(m, lang, code) {
+ var idx = blocks.length;
+ blocks.push({lang: lang, code: code, isBlock: true});
+ return '%%CODEBLOCK_' + idx + '%%';
+ })
+ .replace(/`([^`]+)`/g, function(m, content) {
+ var idx = blocks.length;
+ blocks.push({lang: '', code: content, isBlock: false, isInline: true});
+ return '%%CODEBLOCK_' + idx + '%%';
+ });
+
+ // Step 2: Escape everything outside code blocks
+ processed = escapeHtml(processed);
+
+ // Step 3: Apply markdown formatting on the escaped text
+ processed = processed
+ .replace(/\*\*([^*]+)\*\*/g, function(m, content) { return '<strong>' + content + '</strong>'; })
+ .replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>')
+ .replace(/\n/g, '<br>');
+
+ // Step 4: Restore code blocks with proper HTML
+ processed = processed.replace(/%%CODEBLOCK_(\d+)%%/g, function(m, idx) {
+ idx = parseInt(idx);
+ var block = blocks[idx];
+ if (!block) return m;
+ if (block.isInline) {
+ return '<code>' + escapeHtml(block.code) + '</code>';
+ }
+ var id = 'code_' + Math.random().toString(36).substr(2, 9);
+ if (block.lang === 'html') {
+ return '<div class="code-block" data-code="' + encodeURIComponent(block.code) + '">' +
+ '<pre>' +
+ '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="previewCodeBlock(this)">👁️</button>' +
+ '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;margin-right:8px;" onclick="copyMessage(this, \'code\')">📋</button>' +
+ '<code id="' + id + '">' + escapeHtml(block.code) + '</code></pre></div>';
+ }
+ return '<pre>' +
+ '<button class="msg-copy" style="opacity:1;float:right;margin-bottom:8px;" onclick="copyMessage(this, \'code\')">📋</button>' +
+ '<code>' + escapeHtml(block.code) + '</code></pre>';
+ });
+
+ return processed;
+ }
+
+ // ── Copy & Preview ──
+ function copyMessage(btn, role) {
+ var content;
+ if (role === 'code') {
+ content = btn.nextElementSibling.textContent;
+ } else {
+ var msg = btn.closest('.message');
+ content = msg.dataset.content;
+ }
+ navigator.clipboard.writeText(content).then(function () {
+ showToast('Copied!');
+ });
+ }
+
+ function showHtmlPreview() {
+ var frame = document.getElementById('previewFrame');
+ if (!frame) return;
+ var doc = frame.contentWindow.document;
+ doc.open();
+ doc.write(latestAiContent);
+ doc.close();
+ var m = document.getElementById('previewModal');
+ if (m) m.classList.add('open');
+ }
+
+ function previewCodeBlock(btn) {
+ var block = btn.closest('.code-block');
+ if (!block) return;
+ var code = decodeURIComponent(block.dataset.code);
+ var frame = document.getElementById('previewFrame');
+ if (!frame) return;
+ var doc = frame.contentWindow.document;
+ doc.open();
+ doc.write(code);
+ doc.close();
+ var m = document.getElementById('previewModal');
+ if (m) m.classList.add('open');
+ }
+
+ function closePreviewModal() {
+ var m = document.getElementById('previewModal');
+ if (m) m.classList.remove('open');
+ }
+
+ // ── Typing Indicator ──
+ function showTyping() {
+ var container = document.getElementById('chatContainer');
+ if (!container) return;
+ var div = document.createElement('div');
+ div.className = 'typing';
+ div.id = 'typingIndicator';
+ div.innerHTML = 'AI is thinking' +
+ '<span class="typing-dots">' +
+ '<span></span><span></span><span></span>' +
+ '</span>';
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+ }
+
+ function hideTyping() {
+ var t = document.getElementById('typingIndicator');
+ if (t) t.remove();
+ }
+
+ // ── Send Message ──
+ async function sendMessage() {
+ if (isStreaming) return;
+
+ var input = document.getElementById('chatInput');
+ if (!input) return;
+ var msg = input.value.trim();
+ if (!msg) return;
+
+ input.value = '';
+ input.style.height = 'auto';
+
+ addMessage('user', msg);
+ addLog('User', msg.substring(0, 50) + (msg.length > 50 ? '...' : ''));
+
+ if (msg.startsWith('/')) {
+ await handleCommand(msg);
+ return;
+ }
+
+ showTyping();
+
+ // Sync OLLAMA_URL from terminal before sending
+ var term = window.webx11;
+ if (term && term.ollamaManager) {
+ OLLAMA_URL = term.ollamaManager.endpoint || OLLAMA_URL;
+ }
+ // Re-set window-level endpoint
+ window.ollamaEndpoint = OLLAMA_URL;
+
+ var modelSelect = document.getElementById('modelSelect');
+ var model = modelSelect ? modelSelect.value : DEFAULT_MODEL;
+ var mod = typeof getCurrentModule === 'function' ? getCurrentModule() : null;
+ var systemPrompt = mod && mod.prompt ? mod.prompt : '';
+ var chatHistory = buildChatContext();
+
+ try {
+ var headers = { 'Content-Type': 'application/json' };
+ if (OLLAMA_API_KEY) headers['Authorization'] = 'Bearer ' + OLLAMA_API_KEY;
+
+ var fullPrompt = systemPrompt
+ ? systemPrompt + '\n\n' + chatHistory + '\n\nUser: ' + msg + '\n\nAI:'
+ : chatHistory + '\n\nUser: ' + msg + '\n\nAI:';
+
+ if (USE_STREAMING) {
+ await sendStreamingRequest(headers, model, fullPrompt);
+ } else {
+ await sendNonStreamingRequest(headers, model, fullPrompt);
+ }
+ } catch (e) {
+ hideTyping();
+ console.error('Chat error:', e);
+ addMessage('ai', 'Sorry, I could not connect to the AI service. Make sure Ollama is running on your server.\n\nError: ' + e.message);
+ }
+ }
+
+ async function sendStreamingRequest(headers, model, prompt) {
+ isStreaming = true;
+ hideTyping();
+
+ var msgId = generateId();
+ var container = document.getElementById('chatContainer');
+ if (!container) { isStreaming = false; return; }
+ var div = document.createElement('div');
+ div.className = 'message ai';
+ div.dataset.msgId = msgId;
+ div.innerHTML = '<div class="role"><span>AI</span><div style="display:flex;gap:4px;">' +
+ '<button class="msg-copy" onclick="copyMessage(this, \'ai\')" title="Copy">📋</button>' +
+ '<button class="msg-copy" onclick="showHtmlPreview()" title="Preview HTML">👁️</button>' +
+ '</div></div><div class="msg-content"><span class="streaming-cursor"></span></div>';
+ div.dataset.content = '';
+ container.appendChild(div);
+ container.scrollTop = container.scrollHeight;
+
+ var contentEl = div.querySelector('.msg-content');
+ var fullContent = '';
+
+ try {
+ var response = await fetch(OLLAMA_URL + '/api/generate', {
+ method: 'POST',
+ headers: headers,
+ body: JSON.stringify({
+ model: model,
+ prompt: prompt,
+ stream: true
+ })
+ });
+
+ if (!response.ok) {
+ contentEl.textContent = 'AI request failed: HTTP ' + response.status;
+ throw new Error('AI request failed: HTTP ' + response.status);
+ }
+
+ var reader = response.body.getReader();
+ var decoder = new TextDecoder();
+ var buffer = '';
+
+ var lastFormat = 0;
+ var formatInterval = 400;
+
+ while (true) {
+ var result = await reader.read();
+ if (result.done) break;
+
+ buffer += decoder.decode(result.value, { stream: true });
+ var lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (var i = 0; i < lines.length; i++) {
+ var line = lines[i].trim();
+ if (!line) continue;
+ try {
+ var parsed = JSON.parse(line);
+ if (parsed.response) {
+ fullContent += parsed.response;
+ var now = Date.now();
+ if (now - lastFormat >= formatInterval) {
+ contentEl.innerHTML = formatContent(fullContent) + '<span class="streaming-cursor"></span>';
+ container.scrollTop = container.scrollHeight;
+ lastFormat = now;
+ }
+ }
+ if (parsed.done) {
+ break;
+ }
+ } catch (e) {
+ }
+ }
+ }
+
+ // Process remaining buffer
+ if (buffer.trim()) {
+ try {
+ var parsed = JSON.parse(buffer);
+ if (parsed.response) {
+ fullContent += parsed.response;
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ // Final render without cursor
+ contentEl.innerHTML = formatContent(fullContent);
+ div.dataset.content = fullContent;
+ latestAiContent = fullContent;
+ container.scrollTop = container.scrollHeight;
+
+ messages.push({
+ id: msgId,
+ role: 'ai',
+ content: fullContent,
+ timestamp: Date.now()
+ });
+ saveMessages();
+ addLog('AI', fullContent.substring(0, 50) + '...');
+
+ } catch (e) {
+ if (contentEl) contentEl.textContent = 'Error: ' + e.message;
+ console.error('Stream error:', e);
+ }
+
+ isStreaming = false;
+ }
+
+ async function sendNonStreamingRequest(headers, model, prompt) {
+ try {
+ var response = await fetch(OLLAMA_URL + '/api/generate', {
+ method: 'POST',
+ headers: headers,
+ body: JSON.stringify({
+ model: model,
+ prompt: prompt,
+ stream: false
+ })
+ });
+
+ hideTyping();
+
+ if (!response.ok) throw new Error('AI request failed');
+
+ var data = await response.json();
+ addMessage('ai', data.response || 'No response');
+ addLog('AI', (data.response || '').substring(0, 50) + '...');
+ } catch (e) {
+ hideTyping();
+ throw e;
+ }
+ }
+
+ function buildChatContext() {
+ var context = '';
+ for (var i = 0; i < messages.length; i++) {
+ var m = messages[i];
+ var role = m.role === 'user' ? 'User' : 'AI';
+ var content = m.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+ context += role + ': ' + content + '\n';
+ }
+ return context;
+ }
+
+ function getChatHistory() {
+ return buildChatContext();
+ }
+
+ // ── Clear & Share ──
+ function clearChat() {
+ if (!confirm('Clear chat history?')) return;
+ messages = [];
+ localStorage.removeItem(MESSAGES_KEY);
+ var container = document.getElementById('chatContainer');
+ if (container) {
+ container.innerHTML =
+ '<div class="message ai">' +
+ '<div class="role">AI</div>' +
+ 'Chat cleared! How can I help you?' +
+ '</div>';
+ }
+ addLog('Clear', 'Chat history cleared');
+ }
+
+ function shareChat() {
+ var msgs = [];
+ for (var i = 0; i < messages.length; i++) {
+ var m = messages[i];
+ var role = m.role === 'user' ? 'User' : 'AI';
+ msgs.push('**' + role + ':** ' + m.content);
+ }
+ var text = '# WebX11 AI Chat\n\n' + msgs.join('\n\n');
+
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text).then(function () {
+ showToast('Chat copied to clipboard!');
+ addLog('Share', 'Chat copied to clipboard');
+ });
+ } else {
+ var blob = new Blob([text], { type: 'text/plain' });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement('a');
+ a.href = url;
+ a.download = 'chat-export.txt';
+ a.click();
+ URL.revokeObjectURL(url);
+ addLog('Share', 'Chat downloaded as file');
+ }
+ }
+
+ // ── Expose Lite globals for commands.js and modules.js ──
+ // All functions exposed under CSLite namespace AND as individual window.*
+ // for backward compatibility with chat-commands.js and chat-modules.js
+ window.CSLite = {
+ CHATSTUDIO_VERSION: CHATSTUDIO_VERSION,
+ OLLAMA_URL: OLLAMA_URL,
+ OLLAMA_API_KEY: OLLAMA_API_KEY,
+ DEFAULT_MODEL: DEFAULT_MODEL,
+ USE_STREAMING: USE_STREAMING,
+ API_BASE: API_BASE,
+ getApiUrl: function(path) { return (window.CSLite.API_BASE || '/api') + path; },
+ latestAiContent: latestAiContent,
+ logs: logs,
+ messages: messages,
+ isStreaming: isStreaming,
+ modules: [],
+ currentChatModule: 'default',
+ escapeHtml: escapeHtml,
+ utf8_to_b64: utf8_to_b64,
+ b64_to_utf8: b64_to_utf8,
+ generateId: generateId,
+ showToast: showToast,
+ autoExpand: autoExpand,
+ toggleSidebar: toggleSidebar,
+ closeSidebar: closeSidebar,
+ addLog: addLog,
+ renderLogs: renderLogs,
+ loadSettings: loadSettings,
+ saveSettings: saveSettings,
+ loadModels: loadModels,
+ loadMessages: loadMessages,
+ saveMessages: saveMessages,
+ appendMessageToDOM: appendMessageToDOM,
+ addMessage: addMessage,
+ formatContent: formatContent,
+ copyMessage: copyMessage,
+ showHtmlPreview: showHtmlPreview,
+ previewCodeBlock: previewCodeBlock,
+ closePreviewModal: closePreviewModal,
+ showTyping: showTyping,
+ hideTyping: hideTyping,
+ sendMessage: sendMessage,
+ sendStreamingRequest: sendStreamingRequest,
+ sendNonStreamingRequest: sendNonStreamingRequest,
+ buildChatContext: buildChatContext,
+ getChatHistory: getChatHistory,
+ clearChat: clearChat,
+ shareChat: shareChat,
+ copyLogs: copyLogs,
+ clearLogs: clearLogs,
+ loadLogs: loadLogs,
+ testConnection: testConnection,
+ openSettingsModal: openSettingsModal,
+ closeSettingsModal: closeSettingsModal
+ };
+ // Individual window.* assignments for backward compat
+ window.CHATSTUDIO_VERSION = CHATSTUDIO_VERSION;
+ window.OLLAMA_URL = OLLAMA_URL;
+ window.OLLAMA_API_KEY = OLLAMA_API_KEY;
+ window.DEFAULT_MODEL = DEFAULT_MODEL;
+ window.USE_STREAMING = USE_STREAMING;
+ window.API_BASE = API_BASE;
+ window.getApiUrl = function(path) { return (window.API_BASE || '/api') + path; };
+ window.latestAiContent = latestAiContent;
+ window.logs = logs;
+ window.messages = messages;
+ window.isStreaming = isStreaming;
+ window.escapeHtml = escapeHtml;
+ window.utf8_to_b64 = utf8_to_b64;
+ window.b64_to_utf8 = b64_to_utf8;
+ window.generateId = generateId;
+ window.showToast = showToast;
+ window.autoExpand = autoExpand;
+ window.toggleSidebar = toggleSidebar;
+ window.closeSidebar = closeSidebar;
+ window.addLog = addLog;
+ window.renderLogs = renderLogs;
+ window.loadSettings = loadSettings;
+ window.saveSettings = saveSettings;
+ window.loadModels = loadModels;
+ window.loadMessages = loadMessages;
+ window.saveMessages = saveMessages;
+ window.appendMessageToDOM = appendMessageToDOM;
+ window.addMessage = addMessage;
+ window.formatContent = formatContent;
+ window.copyMessage = copyMessage;
+ window.showHtmlPreview = showHtmlPreview;
+ window.previewCodeBlock = previewCodeBlock;
+ window.closePreviewModal = closePreviewModal;
+ window.showTyping = showTyping;
+ window.hideTyping = hideTyping;
+ window.sendMessage = sendMessage;
+ window.sendStreamingRequest = sendStreamingRequest;
+ window.sendNonStreamingRequest = sendNonStreamingRequest;
+ window.buildChatContext = buildChatContext;
+ window.getChatHistory = getChatHistory;
+ window.clearChat = clearChat;
+ window.shareChat = shareChat;
+ window.copyLogs = copyLogs;
+ window.clearLogs = clearLogs;
+ window.loadLogs = loadLogs;
+ window.testConnection = testConnection;
+ window.openSettingsModal = openSettingsModal;
+ window.closeSettingsModal = closeSettingsModal;
+
+ // ── Init ──
+ loadSettings();
+ loadMessages();
+ loadLogs();
+ loadModels();
+ renderMessagesFromData();
+ addLog('Init', 'Chat overlay initialized');
+ } catch (err) {
+ // Reset initialization flag so user can retry
+ this._initialized = false;
+
+ // Print error to terminal
+ if (this.terminal && this.terminal.print) {
+ this.terminal.print('Chat overlay init error: ' + err.message, 'error');
+ }
+
+ console.error('ChatStudioModule._initLiteEngine error:', err);
+ }
+ }
+}
diff --git a/modules/core/module-config.js b/modules/core/module-config.js
new file mode 100644
index 0000000..fb5199e
--- /dev/null
+++ b/modules/core/module-config.js
@@ -0,0 +1,92 @@
+// 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");
+ }
+ }
+ }
diff --git a/modules/core/module-duckduckgo.js b/modules/core/module-duckduckgo.js
new file mode 100644
index 0000000..c443b72
--- /dev/null
+++ b/modules/core/module-duckduckgo.js
@@ -0,0 +1,38 @@
+// Extracted from newOS4 index.html — DuckDuckGoModule
+ class DuckDuckGoModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+ getHelpInfo() {
+ return {
+ 'ddg': 'ddg <query> — Search DuckDuckGo (opens query in new tab)',
+ 'duck': 'duck <query> — Alias for ddg',
+ };
+ }
+ registerCommands() {
+ this.commands['ddg'] = (args) => this.search(args);
+ this.commands['duck'] = (args) => this.search(args);
+ }
+ async search(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: ddg <search-query>", "warning");
+ this.terminal.print("Example: ddg javascript tutorials", "info");
+ this.terminal.print("Note: Due to CORS, results may be limited. Try 'search' command for better results.", "warning");
+ return;
+ }
+ const query = args.join(' ');
+ this.terminal.print(`🦆 DuckDuckGo Search: ${query}`, "info");
+
+ // DuckDuckGo doesn't have a public API, so we provide alternatives
+ this.terminal.print("⚠️ DuckDuckGo doesn't provide a public API for direct access.", "warning");
+ this.terminal.print("Opening search in new tab...", "info");
+
+ // Open in new tab as fallback
+ const ddgUrl = `https://duckduckgo.com/?q=${encodeURIComponent(query)}`;
+ window.open(ddgUrl, '_blank');
+
+ this.terminal.print(`\n💡 Alternative: Use 'search <query>' for SearXNG (privacy-focused)`, "info");
+ this.terminal.print(`🔗 Direct link: ${ddgUrl}`, "info");
+ }
+ }
diff --git a/modules/core/module-dynamicprompts.js b/modules/core/module-dynamicprompts.js
new file mode 100644
index 0000000..ff8c5ea
--- /dev/null
+++ b/modules/core/module-dynamicprompts.js
@@ -0,0 +1,102 @@
+// Extracted from newOS4 index.html — DynamicPromptsModule
+ class DynamicPromptsModule {
+ constructor() {
+ this.commands = {}; this.terminal = null;
+ this.prompts = this.loadPrompts(); this.variables = {};
+ }
+ getHelpInfo() {
+ return {
+ 'prompt': 'prompt <name> [additional-text] — Use a saved prompt template',
+ 'prompt-save': 'prompt-save <name> <template> — Save a new prompt template',
+ 'prompt-list': 'prompt-list — List all saved prompt templates',
+ 'prompt-delete': 'prompt-delete <name> — Delete a saved prompt',
+ 'prompt-var': 'prompt-var <name> <value> — Set a template variable',
+ 'prompt-vars': 'prompt-vars — List all template variables'
+ };
+ }
+ loadPrompts() {
+ try {
+ const saved = localStorage.getItem('webx11_prompts');
+ return saved ? JSON.parse(saved) : {};
+ } catch (e) { return {}; }
+ }
+ savePrompts() {
+ localStorage.setItem('webx11_prompts', JSON.stringify(this.prompts));
+ }
+ registerCommands() {
+ this.commands['prompt'] = (args) => this.usePrompt(args);
+ this.commands['prompt-save'] = (args) => this.savePrompt(args);
+ this.commands['prompt-list'] = () => this.listPrompts();
+ this.commands['prompt-delete'] = (args) => this.deletePrompt(args);
+ this.commands['prompt-var'] = (args) => this.setVariable(args);
+ this.commands['prompt-vars'] = () => this.listVariables();
+ }
+ usePrompt(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: prompt <name> [additional-text]", "warning"); return;
+ }
+ const name = args[0], additional = args.slice(1).join(' ');
+ if (!this.prompts[name]) {
+ this.terminal.print(`Prompt '${name}' not found`, "error"); return;
+ }
+ let template = this.prompts[name].template;
+ Object.entries(this.variables).forEach(([k, v]) => {
+ template = template.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
+ });
+ const finalPrompt = additional ? `${template} ${additional}` : template;
+ this.terminal.print("=== Generated Prompt ===", "info");
+ this.terminal.printHTML(`<div class="output-line ollama" style="margin-top: 5px;">
+ <button class="copy-btn" onclick="navigator.clipboard.writeText(this.parentElement.innerText.replace('Copy','').trim())">Copy</button>
+ ${finalPrompt}
+ </div>`);
+ this.terminal.print("\nUse with: ai <generated-prompt>", "info");
+ }
+ savePrompt(args) {
+ if (args.length < 2) {
+ this.terminal.print("Usage: prompt-save <name> <template>", "warning"); return;
+ }
+ const name = args[0], template = args.slice(1).join(' ');
+ this.prompts[name] = { template, created: new Date().toISOString() };
+ this.savePrompts();
+ this.terminal.print(`Prompt '${name}' saved`, "success");
+ }
+ listPrompts() {
+ const names = Object.keys(this.prompts);
+ if (names.length === 0) {
+ this.terminal.print("No saved prompts", "info"); return;
+ }
+ this.terminal.print("=== Saved Prompts ===", "info");
+ names.forEach(name => {
+ const p = this.prompts[name];
+ this.terminal.print(`📝 ${name}: ${p.template.substring(0, 50)}...`);
+ });
+ }
+ deletePrompt(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: prompt-delete <name>", "warning"); return;
+ }
+ if (this.prompts[args[0]]) {
+ delete this.prompts[args[0]];
+ this.savePrompts();
+ this.terminal.print(`Prompt '${args[0]}' deleted`, "success");
+ } else {
+ this.terminal.print(`Prompt '${args[0]}' not found`, "error");
+ }
+ }
+ setVariable(args) {
+ if (args.length < 2) {
+ this.terminal.print("Usage: prompt-var <name> <value>", "warning"); return;
+ }
+ const name = args[0], value = args.slice(1).join(' ');
+ this.variables[name] = value;
+ this.terminal.print(`Variable set: ${name} = ${value}`, "success");
+ }
+ listVariables() {
+ const names = Object.keys(this.variables);
+ if (names.length === 0) {
+ this.terminal.print("No variables set", "info"); return;
+ }
+ this.terminal.print("=== Variables ===", "info");
+ names.forEach(name => { this.terminal.print(`${name}: ${this.variables[name]}`); });
+ }
+ }
diff --git a/modules/core/module-fetcher.js b/modules/core/module-fetcher.js
new file mode 100644
index 0000000..fea84d2
--- /dev/null
+++ b/modules/core/module-fetcher.js
@@ -0,0 +1,65 @@
+class WebFetcherModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+ getHelpInfo() {
+ return {
+ 'fetch': 'fetch <url> — Fetch and display raw HTML/text content from a URL',
+ 'web': 'web <url> — Alias for fetch',
+ 'extract': 'extract <url> — Extract readable article content (title + body text) from a URL',
+ };
+ }
+ registerCommands() {
+ this.commands['fetch'] = (args) => this.fetchUrl(args);
+ this.commands['web'] = (args) => this.fetchUrl(args);
+ this.commands['extract'] = (args) => this.extractUrl(args);
+ }
+ async fetchUrl(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: fetch <url>", "warning");
+ this.terminal.print("Example: fetch https://example.com", "info");
+ return;
+ }
+ let url = args[0];
+ if (!url.match(/^https?:\/\//i)) url = 'https://' + url;
+ this.terminal.print(`🌐 Fetching: ${url}`, "info");
+ try {
+ const resp = await fetch('/api/fetch?url=' + encodeURIComponent(url));
+ if (resp.status === 403) { this.terminal.print('Blocked: internal network addresses not allowed', 'error'); return; }
+ if (!resp.ok) { this.terminal.print('Fetch failed: HTTP ' + resp.status, 'error'); return; }
+ const text = await resp.text();
+ const cleanText = text.replace(/\s+/g, ' ').trim();
+ this.terminal.print(`\n=== Content from ${url} ===\n`, 'success');
+ this.terminal.print(cleanText.substring(0, 3000) + (cleanText.length > 3000 ? '...' : ''));
+ this.terminal.print(`\n📏 Content Length: ${cleanText.length} chars`, 'info');
+ } catch (e) {
+ 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 fetch('/api/extract?url=' + encodeURIComponent(url));
+ 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), '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');
+ } catch (e) {
+ this.terminal.print('Extract failed: ' + e.message, 'error');
+ }
+ }
+}
diff --git a/modules/core/module-fs.js b/modules/core/module-fs.js
new file mode 100644
index 0000000..c4bc786
--- /dev/null
+++ b/modules/core/module-fs.js
@@ -0,0 +1,445 @@
+class FileSystemModule {
+ constructor() { this.commands = {}; 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; use -r/-rf for directories',
+ 'mkdir': 'mkdir <dir> [-p] — Create a directory; -p creates parent paths',
+ 'rmdir': 'rmdir <dir> — Remove a directory',
+ 'mv': 'mv <source> <dest> — Move file or directory',
+ 'cp': 'cp <source> <dest> — Copy file or directory',
+ 'cd': 'cd <path> — Change current directory; cd / to go to root',
+ 'pwd': 'pwd — Print current working directory',
+ 'find': 'find <pattern> — Search files by name pattern',
+ 'grep': 'grep <pattern> [file] — Search file content for pattern',
+ 'head': 'head <file> [lines] — Show first N lines (default: 10)',
+ 'tail': 'tail <file> [lines] — Show last N lines (default: 10)',
+ 'sort': 'sort <file> — Sort lines of a file alphabetically',
+ 'wc': 'wc <file> — Count lines, words, and bytes',
+ 'touch': 'touch <file> — Create empty file or update timestamp',
+ 'tree': 'tree — Display directory structure as a tree',
+ 'du': 'du [path] — Estimate file/directory size',
+ 'upload': 'upload — Upload a file from your device',
+ 'download': 'download <file> — Download a file to your device',
+ 'vfs-export': 'vfs-export — Export the entire virtual filesystem as JSON',
+ 'vfs-import': 'vfs-import — Import a previously exported VFS JSON file',
+ };
+ }
+ registerCommands() {
+ const self = this;
+ this.commands['ls'] = (args) => {
+ const target = args && args.length > 0 && !args[0].startsWith('-') ? args[0] : undefined;
+ const flags = (args || []).filter(a => a.startsWith('-'));
+ const showLong = flags.some(f => f.includes('l'));
+ const showHidden = flags.some(f => f.includes('a'));
+ const files = this.terminal.fs.ls(target);
+ if (!files || files.length === 0) { this.terminal.print('(empty)', 'info'); return; }
+ const filtered = showHidden ? files : files.filter(f => !f.name.startsWith('.'));
+ if (filtered.length === 0) { this.terminal.print('(no visible files, use ls -a)', 'info'); return; }
+ const maxName = Math.max(...filtered.map(f => f.name.length), 8);
+ if (showLong) {
+ filtered.forEach(f => {
+ const type = f.type === 'dir' ? 'd' : '-';
+ const perms = f.type === 'dir' ? 'rwxr-xr-x' : 'rw-r--r--';
+ const size = String(f.size || 0).padStart(6);
+ this.terminal.print(type + perms + ' darkbot ' + size + ' ' + f.name);
+ });
+ const total = filtered.reduce((s, f) => s + (f.size || 0), 0);
+ this.terminal.print('total ' + filtered.length + ' (' + total + ' bytes)', 'text-muted');
+ } else {
+ this.terminal.printClickable(filtered);
+ }
+ };
+ this.commands['cat'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: cat <filename>', 'warning');
+ const content = this.terminal.fs.readFile(args[0]);
+ if(content !== null && content !== undefined) {
+ if (content.length === 0) this.terminal.print('(empty file)', 'info');
+ else this.terminal.print(content);
+ } else this.terminal.print('cat: ' + args[0] + ': No such file', 'error');
+ };
+ this.commands['echo'] = (args) => {
+ if (!args || args.length === 0) { this.terminal.print(''); return; }
+ const ri = args.indexOf('>');
+ if (ri >= 0) {
+ const c = args.slice(0, ri).join(' '); const fn = args.slice(ri + 1).join(' ');
+ if (fn) { this.terminal.fs.writeFile(fn, c); this.terminal.print('Wrote ' + c.length + ' bytes to ' + fn, 'success'); }
+ return;
+ }
+ this.terminal.print(args.join(' '));
+ };
+ this.commands['put'] = (args) => {
+ if(args.length < 2) return this.terminal.print('Usage: put <filename> <content>', 'warning');
+ const name = args[0]; const content = args.slice(1).join(' ');
+ this.terminal.fs.writeFile(name, content);
+ this.terminal.print('Created/updated: ' + name + ' (' + content.length + ' bytes)', 'success');
+ };
+ this.commands['rm'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: rm <filename>', 'warning');
+ if(args[0] === '-r' || args[0] === '-rf') {
+ const t = args[1]; if (!t) return this.terminal.print('Usage: rm -r <dirname>', 'warning');
+ if (this.terminal.fs.deleteDirRecursive(t)) this.terminal.print('Removed: ' + 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');
+ else this.terminal.print('rm: ' + args[0] + ': No such file', 'error');
+ };
+ 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 srcDir = this.terminal.fs.readDir(args[0]);
+ if (srcDir !== null) {
+ if (this.terminal.fs.moveDir(args[0], args[1])) {
+ this.terminal.print('Moved directory: ' + args[0] + ' to ' + args[1], 'success');
+ } else {
+ this.terminal.print('mv: ' + args[0] + ': failed to move directory', 'error');
+ }
+ return;
+ }
+ if (this.terminal.fs.moveFile(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 srcDir = this.terminal.fs.readDir(args[0]);
+ if (srcDir !== null) {
+ if (this.terminal.fs.copyDir(args[0], args[1])) {
+ this.terminal.print('Copied directory: ' + args[0] + ' to ' + args[1], 'success');
+ } else {
+ this.terminal.print('cp: ' + args[0] + ': failed to copy directory', 'error');
+ }
+ return;
+ }
+ if (this.terminal.fs.copyFile(args[0], args[1])) {
+ this.terminal.print('Copied: ' + args[0] + ' to ' + args[1], 'success');
+ } else {
+ this.terminal.print('cp: ' + args[0] + ': No such file or directory', 'error');
+ }
+ };
+ this.commands['cd'] = (args) => {
+ if (!args[0] || args[0] === '/') {
+ this.terminal.fs.currentPath = [];
+ this.terminal.fs.saveToStorage();
+ this.terminal.updatePrompt();
+ return;
+ }
+ const segs = this.terminal.fs._toAbsoluteSegments(args[0]);
+ if (segs === null) {
+ this.terminal.print('cd: ' + args[0] + ': No such file or directory', 'error');
+ return;
+ }
+ const dir = this.terminal.fs._resolveDir(args[0]);
+ if (dir) {
+ this.terminal.fs.currentPath = segs;
+ this.terminal.fs.saveToStorage();
+ this.terminal.updatePrompt();
+ } else {
+ this.terminal.print('cd: ' + args[0] + ': Not a directory', 'error');
+ }
+ };
+ this.commands['pwd'] = () => { this.terminal.print('/' + this.terminal.fs.currentPath.join('/')); };
+ this.commands['find'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: find <pattern>', 'warning');
+ const results = this.terminal.fs.findFiles ? this.terminal.fs.findFiles(args[0].toLowerCase()) : [];
+ if (results.length === 0) return this.terminal.print('No matches for ' + args[0], 'info');
+ results.forEach(r => this.terminal.print('./' + r));
+ this.terminal.print('Found ' + results.length + ' match(es)', 'info');
+ };
+ this.commands['grep'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: grep <pattern> [filename]', 'warning');
+ const pattern = args[0]; const target = args[1];
+ if (target) {
+ const c = this.terminal.fs.readFile(target);
+ if(c === null || c === undefined) return this.terminal.print('grep: ' + target + ': No such file', 'error');
+ const lines = c.split('\n'); const re = new RegExp(pattern, 'i');
+ let count = 0; lines.forEach((line, i) => { if (re.test(line)) { this.terminal.print((i+1) + ': ' + line); count++; } });
+ if (count === 0) this.terminal.print('(no matches)', 'info');
+ else this.terminal.print(count + ' match(es)', 'text-muted');
+ } else {
+ const dir = this.terminal.fs.getCurrentDir();
+ if (!dir) return this.terminal.print('grep: cannot search', 'error');
+ let total = 0; const re = new RegExp(pattern, 'i');
+ Object.keys(dir.content).forEach(name => {
+ const node = dir.content[name];
+ if (node.type === 'file') {
+ (node.content || '').split('\n').forEach((line, i) => {
+ if (re.test(line)) { this.terminal.print(name + ':' + (i+1) + ': ' + line); total++; }
+ });
+ }
+ });
+ if (total === 0) this.terminal.print('(no matches)', 'info');
+ else this.terminal.print(total + ' total match(es)', 'text-muted');
+ }
+ };
+ this.commands['head'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: head <filename> [lines]', 'warning');
+ const c = this.terminal.fs.readFile(args[0]);
+ if(c === null || c === undefined) return this.terminal.print('head: ' + args[0] + ': No such file', 'error');
+ const n = args[1] ? parseInt(args[1]) : 10;
+ this.terminal.print(c.split('\n').slice(0, n).join('\n'));
+ };
+ this.commands['tail'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: tail <filename> [lines]', 'warning');
+ const c = this.terminal.fs.readFile(args[0]);
+ if(c === null || c === undefined) return this.terminal.print('tail: ' + args[0] + ': No such file', 'error');
+ const n = args[1] ? parseInt(args[1]) : 10;
+ this.terminal.print(c.split('\n').slice(-n).join('\n'));
+ };
+ this.commands['sort'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: sort <filename>', 'warning');
+ const c = this.terminal.fs.readFile(args[0]);
+ if(c === null || c === undefined) return this.terminal.print('sort: ' + args[0] + ': No such file', 'error');
+ c.split('\n').sort().forEach(l => this.terminal.print(l));
+ };
+ this.commands['wc'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: wc <filename>', 'warning');
+ const content = this.terminal.fs.readFile(args[0]);
+ if(content === null || content === undefined) return this.terminal.print('wc: ' + args[0] + ': No such file', 'error');
+ const lines = content.split('\n').length;
+ const words = content.split(/\s+/).filter(w => w).length;
+ this.terminal.print(lines + ' ' + words + ' ' + content.length + ' ' + args[0]);
+ };
+ this.commands['touch'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: touch <filename>', 'warning');
+ const e = this.terminal.fs.readFile(args[0]);
+ if (e === null || e === undefined) {
+ const segs = this.terminal.fs._toAbsoluteSegments(args[0]);
+ if (segs) {
+ const dirPath = segs.slice(0, -1).join('/') || '.';
+ const name = segs[segs.length - 1];
+ this.terminal.fs.mkdirRecursive(dirPath);
+ }
+ this.terminal.fs.writeFile(args[0], '');
+ this.terminal.print('Created: ' + args[0], 'success');
+ }
+ else { this.terminal.print('Touched: ' + args[0], 'success'); }
+ };
+ this.commands['tree'] = () => {
+ const dir = this.terminal.fs.getCurrentDir();
+ if (!dir) return this.terminal.print('(no directory)', 'warning');
+ const lines = []; this._printTree(dir, '', lines);
+ lines.forEach(l => this.terminal.print(l));
+ };
+ this.commands['du'] = (args) => {
+ const t = args && args[0] ? args[0] : '.';
+ if (t === '.') {
+ const dir = this.terminal.fs.getCurrentDir(); if (!dir) return;
+ let total = 0; Object.keys(dir.content).forEach(k => { total += dir.content[k].content ? dir.content[k].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'] = (args) => {
+ if(!args[0]) return this.terminal.print('Usage: download <filename>', 'warning');
+ const content = this.terminal.fs.readFile(args[0]);
+ if(content !== null && content !== undefined) {
+ const blob = new Blob([content], {type: 'application/octet-stream'});
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = args[0];
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000);
+ this.terminal.print('Downloaded: ' + args[0], 'success');
+ } else { this.terminal.print('download: ' + args[0] + ': No such file', 'error'); }
+ };
+ 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');
+ };
+
+ // 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, '_');
+ const snapshot = {
+ type: 'session',
+ savedAt: new Date().toISOString(),
+ fsRoot: this.terminal.fs.root,
+ currentPath: this.terminal.fs.currentPath,
+ version: 1
+ };
+ const filePath = '_sessions/' + name + '.json';
+ this.terminal.fs.mkdirRecursive('_sessions');
+ this.terminal.fs.writeFile(filePath, JSON.stringify(snapshot, null, 2));
+ this.terminal.print('Session saved: ' + name, 'success');
+ };
+ this.commands['session-load'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: session-load <name>', 'warning');
+ const filePath = '_sessions/' + args[0] + '.json';
+ const content = this.terminal.fs.readFile(filePath);
+ if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error');
+ try {
+ const session = JSON.parse(content);
+ if (!session || session.type !== 'session' || !session.fsRoot) {
+ return this.terminal.print('Invalid session file: ' + args[0], 'error');
+ }
+ this.terminal.fs.root = session.fsRoot;
+ this.terminal.fs.currentPath = session.currentPath || [];
+ this.terminal.fs.saveToStorage();
+ this.terminal.updatePrompt();
+ this.terminal.print('Session loaded: ' + args[0] + ' (' + new Date(session.savedAt).toLocaleString() + ')', 'success');
+ this.terminal.executeCommand('ls');
+ } catch (e) {
+ this.terminal.print('Failed to load session: ' + e.message, 'error');
+ }
+ };
+ this.commands['session-list'] = () => {
+ this.terminal.fs.mkdirRecursive('_sessions');
+ const files = this.terminal.fs.ls('_sessions');
+ if (!files || files.length === 0) return this.terminal.print('No saved sessions', 'info');
+ this.terminal.print('Saved sessions:', 'text-muted');
+ files.forEach(f => {
+ if (f.type === 'file' && f.name.endsWith('.json')) {
+ try {
+ const content = JSON.parse(this.terminal.fs.readFile('_sessions/' + f.name));
+ const label = f.name.replace('.json', '');
+ const date = content.savedAt ? new Date(content.savedAt).toLocaleString() : 'unknown';
+ const fileCount = content.fsRoot ? Object.keys(content.fsRoot.content || {}).length : 0;
+ this.terminal.print(' ' + label + ' (' + fileCount + ' items, ' + date + ')');
+ } catch (e) {
+ this.terminal.print(' ' + f.name.replace('.json', '') + ' (corrupt)');
+ }
+ }
+ });
+ };
+ this.commands['session-delete'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: session-delete <name>', 'warning');
+ const filePath = '_sessions/' + args[0] + '.json';
+ if (this.terminal.fs.deleteFile(filePath)) {
+ this.terminal.print('Session deleted: ' + args[0], 'success');
+ } else {
+ this.terminal.print('Session not found: ' + args[0], 'error');
+ }
+ };
+ this.commands['session-export'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: session-export <name>', 'warning');
+ const filePath = '_sessions/' + args[0] + '.json';
+ const content = this.terminal.fs.readFile(filePath);
+ if (content === null) return this.terminal.print('Session not found: ' + args[0], 'error');
+ const blob = new Blob([content], {type: 'application/json'});
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'webx11-session-' + args[0] + '.json';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 2000);
+ this.terminal.print('Exported session: ' + args[0], 'success');
+ };
+ this.commands['session-import'] = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ try {
+ const session = JSON.parse(evt.target.result);
+ if (!session || session.type !== 'session' || !session.fsRoot) {
+ this.terminal.print('Invalid session file (not a valid session export)', 'error');
+ return;
+ }
+ this.terminal.fs.root = session.fsRoot;
+ this.terminal.fs.currentPath = session.currentPath || [];
+ this.terminal.fs.saveToStorage();
+ this.terminal.updatePrompt();
+ this.terminal.print('Session imported: ' + file.name + ' (' + (session.savedAt ? new Date(session.savedAt).toLocaleString() : 'unknown date') + ')', 'success');
+ this.terminal.executeCommand('ls');
+ } catch (err) {
+ this.terminal.print('Failed to import session: ' + err.message, 'error');
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ };
+ this.commands['vfs-import'] = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.json';
+ input.onchange = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ try {
+ const data = JSON.parse(evt.target.result);
+ if (!data || !data.type) {
+ this.terminal.print('Invalid VFS file', 'error');
+ return;
+ }
+ if (this.terminal.fs.restoreFromExport(data)) {
+ this.terminal.updatePrompt();
+ this.terminal.print('VFS restored from: ' + file.name, 'success');
+ this.terminal.executeCommand('ls');
+ } else {
+ this.terminal.print('Failed to restore VFS', 'error');
+ }
+ } catch (err) {
+ this.terminal.print('Failed to import VFS: ' + err.message, 'error');
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ };
+ }
+ _printTree(dir, prefix, lines) {
+ const names = Object.keys(dir.content || {}).sort();
+ names.forEach((name, i) => {
+ const isLast = i === names.length - 1;
+ const node = dir.content[name];
+ const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 ';
+ const label = node.type === 'dir' ? name + '/' : name;
+ lines.push(prefix + connector + label);
+ if (node.type === 'dir') {
+ this._printTree(node, prefix + (isLast ? ' ' : '\u2502 '), lines);
+ }
+ });
+ }
+}
diff --git a/modules/core/module-io.js b/modules/core/module-io.js
new file mode 100644
index 0000000..d7ff996
--- /dev/null
+++ b/modules/core/module-io.js
@@ -0,0 +1,80 @@
+// Extracted from newOS4 index.html — ModuleIOModule
+ class ModuleIOModule {
+ constructor() { this.commands = {}; this.terminal = null; }
+ getHelpInfo() {
+ return {
+ 'export-mods': 'export-mods — Export all loaded modules to a JSON file',
+ 'import-mods': 'import-mods <filename> — Import modules from a JSON file',
+ 'backup': 'backup — Create a full system backup',
+ 'restore': 'restore <filename> — Restore system from a backup file',
+ 'share-mod': 'share-mod <module-name> — Share a module as JSON to clipboard'
+ };
+ }
+ registerCommands() {
+ this.commands['export-mods'] = () => this.exportModules();
+ this.commands['import-mods'] = (args) => this.importModules(args);
+ this.commands['backup'] = () => this.fullBackup();
+ this.commands['restore'] = (args) => this.restoreBackup(args);
+ this.commands['share-mod'] = (args) => this.shareModule(args);
+ }
+ exportModules() {
+ const registry = this.terminal.moduleManager.moduleRegistry;
+ if (registry.length === 0) {
+ this.terminal.print("No modules to export", "warning"); return;
+ }
+ const exportData = { version: '4.1', exported: new Date().toISOString(), modules: registry };
+ const json = JSON.stringify(exportData, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `webx11-modules-${Date.now()}.json`; a.click();
+ URL.revokeObjectURL(url);
+ this.terminal.print(`Exported ${registry.length} module(s)`, "success");
+ }
+ importModules(args) {
+ this.terminal.print("⚠️ To import modules:", "warning");
+ this.terminal.print("1. Upload the JSON file using 'upload'", "info");
+ this.terminal.print("2. Use: import-mods <filename>", "info");
+ }
+ async fullBackup() {
+ const backup = {
+ version: '4.1', timestamp: new Date().toISOString(),
+ filesystem: localStorage.getItem('webx11_filesystem'),
+ modules: localStorage.getItem('webx11_modules'),
+ ollama: {
+ endpoint: localStorage.getItem('webx11_ollama_endpoint'),
+ model: localStorage.getItem('webx11_ollama_model')
+ }, config: {}
+ };
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key.startsWith('webx11_')) { backup.config[key] = localStorage.getItem(key); }
+ }
+ const json = JSON.stringify(backup, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `webx11-backup-${Date.now()}.json`; a.click();
+ URL.revokeObjectURL(url);
+ this.terminal.print("Full backup created", "success");
+ }
+ restoreBackup(args) {
+ this.terminal.print("⚠️ To restore backup:", "warning");
+ this.terminal.print("1. Upload the backup JSON file", "info");
+ this.terminal.print("2. Use: restore <filename>", "info");
+ }
+ shareModule(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: share-mod <module-name>", "warning"); return;
+ }
+ const mod = this.terminal.moduleManager.moduleRegistry.find(m => m.name === args[0]);
+ if (!mod) {
+ this.terminal.print(`Module '${args[0]}' not found`, "error"); return;
+ }
+ const shareData = { name: mod.name, url: mod.url, shared: new Date().toISOString() };
+ const json = JSON.stringify(shareData, null, 2);
+ navigator.clipboard.writeText(json).then(() => {
+ this.terminal.print("Module info copied to clipboard", "success");
+ }).catch(() => { this.terminal.print(json, "info"); });
+ }
+ }
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
new file mode 100644
index 0000000..850d627
--- /dev/null
+++ b/modules/core/module-loader.js
@@ -0,0 +1,178 @@
+(function() {
+ 'use strict';
+
+ const BASE = 'modules/core/';
+
+ const FOUNDATION = [
+ 'virtual-fs.js',
+ 'module-manager.js',
+ 'ollama-manager.js',
+ 'terminal.js'
+ ];
+
+ const BUILTIN = [
+ 'module-system.js',
+ 'module-fs.js',
+ 'module-modmgr.js',
+ 'module-ollama.js',
+ 'module-my.js',
+ 'module-searxng.js',
+ 'module-servermon.js',
+ 'module-ollamamodels.js',
+ 'module-config.js',
+ 'module-simplegui.js',
+ 'module-io.js',
+ 'module-dynamicprompts.js',
+ 'module-aiassist.js',
+ 'module-aiasm.js',
+ 'module-smartctx.js',
+ 'module-duckduckgo.js',
+ 'module-fetcher.js',
+ 'module-skills.js',
+ 'secret-vault.js',
+ 'module-chatstudio.js'
+ ];
+
+ function getSelectedModules() {
+ try {
+ const saved = localStorage.getItem('webx11_startup_modules');
+ if (!saved) return null;
+ const parsed = JSON.parse(saved);
+ if (Array.isArray(parsed)) return parsed;
+ } catch (e) {}
+ return null;
+ }
+
+ function isSelected(name, selected) {
+ if (!selected) return true;
+ return selected.includes(name);
+ }
+
+ function loadScript(url, filename) {
+ return new Promise((resolve) => {
+ const existing = document.querySelector(`script[src="${url}"]`);
+ if (existing) { resolve(); return; }
+ const el = document.createElement('script');
+ el.src = url + (url.includes('?') ? '&t=' : '?t=') + Date.now();
+ el.onload = () => resolve();
+ el.onerror = () => {
+ console.warn(`[ModuleLoader] Failed to load: ${filename || url} — continuing`);
+ // Log to terminal if available
+ if (typeof window !== 'undefined' && window.webx11 && window.webx11.log) {
+ window.webx11.log('WARN', 'Loader', 'Failed to load: ' + (filename || url));
+ }
+ resolve();
+ };
+ document.body.appendChild(el);
+ });
+ }
+
+ function setSplash(phase, current, total, filename) {
+ const fill = document.getElementById('splash-progress-fill');
+ const phaseEl = document.getElementById('splash-phase');
+ const fileEl = document.getElementById('splash-filename');
+ if (fill && total > 0) {
+ fill.style.width = Math.round((current / total) * 100) + '%';
+ }
+ if (phaseEl) phaseEl.textContent = phase;
+ if (fileEl) fileEl.textContent = filename || '';
+ }
+
+ function hideSplash() {
+ const overlay = document.getElementById('splash-overlay');
+ if (overlay) {
+ overlay.classList.add('hidden');
+ setTimeout(() => { overlay.style.display = 'none'; }, 350);
+ }
+ }
+
+ function showSplashError(msg) {
+ const el = document.getElementById('splash-error');
+ const fill = document.getElementById('splash-progress-fill');
+ if (el) { el.textContent = msg; el.classList.add('show'); }
+ if (fill) fill.style.background = 'var(--error-color)';
+ }
+
+ function reloadWithSelection() {
+ location.reload();
+ }
+
+ window.__moduleLoaderReload = reloadWithSelection;
+
+ const CACHE = '?v=18';
+
+ async function init() {
+ try {
+ const selected = getSelectedModules();
+ const total = FOUNDATION.length + BUILTIN.length;
+ let count = 0;
+
+ // Phase 1: Foundation (always load)
+ setSplash('Loading foundation layer (1/3)', 0, total, '');
+ for (const mod of FOUNDATION) {
+ count++;
+ setSplash('Loading foundation layer (1/3)', count, total, mod);
+ await loadScript(BASE + mod + CACHE, mod);
+ }
+
+ // Phase 2: Built-in modules (filtered)
+ setSplash('Loading built-in modules (2/3)', count, total, '');
+ for (const mod of BUILTIN) {
+ count++;
+ if (!isSelected(mod, selected)) {
+ console.log(`[ModuleLoader] Skipped: ${mod} (not in startup selection)`);
+ setSplash('Loading built-in modules (2/3)', count, total, '⏭ ' + mod);
+ await new Promise(r => setTimeout(r, 20));
+ continue;
+ }
+ setSplash('Loading built-in modules (2/3)', count, total, mod);
+ await loadScript(BASE + mod + CACHE, mod);
+ }
+
+ // Phase 3: Initialize terminal
+ setSplash('Initializing terminal (3/3)', total, total, '');
+ const webx11 = new WebX11Terminal();
+ window.webx11 = webx11;
+
+ // Auto-connect to Ollama after init
+ setTimeout(async () => {
+ try {
+ if (webx11.ollamaManager && !webx11.ollamaManager.connected) {
+ webx11.log('INFO', 'System', 'WebX11 v4.2 started');
+ webx11.log('INFO', 'System', 'Default model: ' + webx11.ollamaManager.currentModel);
+ await webx11.ollamaManager.connect();
+ if (typeof webx11.updateOllamaStatus === 'function') {
+ webx11.updateOllamaStatus();
+ }
+ webx11.print('🤖 Default model: ' + webx11.ollamaManager.currentModel, 'info');
+ }
+ } catch (e) {
+ // Silently ignore — user can manually connect
+ }
+ }, 500);
+
+ // Phase 4: Ext modules (filtered)
+ if (typeof webx11._loadExtModules === 'function') {
+ const extKeys = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder', 'filemgr'];
+ const extSelected = selected
+ ? extKeys.filter(k => isSelected(k + '.js', selected))
+ : extKeys;
+ if (extSelected.length > 0) {
+ webx11._loadExtModules(extSelected);
+ }
+ }
+
+ hideSplash();
+ console.log('[ModuleLoader] All modules loaded successfully');
+ } catch (err) {
+ console.error('[ModuleLoader] Fatal error:', err);
+ showSplashError('Loading failed — ' + err.message);
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/modules/core/module-manager.js b/modules/core/module-manager.js
new file mode 100644
index 0000000..8e9e911
--- /dev/null
+++ b/modules/core/module-manager.js
@@ -0,0 +1,138 @@
+// Extracted from newOS4 index.html — ModuleManager
+ class ModuleManager {
+ constructor(terminal) {
+ this.terminal = terminal;
+ this.modules = {};
+ this.moduleRegistry = [];
+ this.loadFromStorage();
+ }
+ loadFromStorage() {
+ try {
+ const saved = localStorage.getItem('webx11_modules');
+ if (saved) {
+ this.moduleRegistry = JSON.parse(saved);
+ }
+ } catch (e) { console.error('Module load error:', e); }
+ }
+ saveToStorage() {
+ try {
+ localStorage.setItem('webx11_modules', JSON.stringify(this.moduleRegistry));
+ } catch (e) { console.error('Module save error:', e); }
+ }
+ async loadModuleFromURL(url) {
+ try {
+ this.terminal.print(`Loading module from ${url}...`, 'info');
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const code = await response.text();
+ const ModuleClass = this.createModuleClass(code, url);
+ if (ModuleClass) {
+ const instance = new ModuleClass();
+ this.registerModule(instance, url);
+ if (instance.init) {
+ await instance.init();
+ }
+ this.terminal.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 {
+ const classMatch = code.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/);
+ if (classMatch) {
+ const className = classMatch[1];
+ const mod = new Function('return ' + code + '\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;
+ }
+ 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;
+ }
+ }
diff --git a/modules/core/module-modmgr.js b/modules/core/module-modmgr.js
new file mode 100644
index 0000000..1104883
--- /dev/null
+++ b/modules/core/module-modmgr.js
@@ -0,0 +1,40 @@
+// Extracted from newOS4 index.html — ModuleManagerModule
+ 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',
+ 'loadmod': 'loadmod <url> — Load a module from a URL',
+ 'removemod': 'removemod <name> — Remove a loaded module by name',
+ 'startup-modules': 'startup-modules — Open the startup modules selector',
+ 'load-local': 'load-local <path> — Load a module from VFS or URL',
+ 'loadlocal': 'loadlocal <path> — Alias for load-local',
+ };
+ }
+ 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}`);
+ });
+ };
+ }
+ }
diff --git a/modules/core/module-my.js b/modules/core/module-my.js
new file mode 100644
index 0000000..5fc13ca
--- /dev/null
+++ b/modules/core/module-my.js
@@ -0,0 +1,82 @@
+class MyModule {
+ constructor() {
+ this.commands = {}; this.terminal = null; this.userData = {};
+ try { const d = localStorage.getItem('webx11_my_data'); if (d) this.userData = JSON.parse(d); } catch (e) {}
+ }
+ getHelpInfo() {
+ return {
+ 'my': 'my — Show module info and stored key count',
+ 'my-set': 'my-set <key> <value> — Store a key-value pair',
+ 'my-get': 'my-get <key> — Retrieve a stored value by key',
+ 'my-list': 'my-list — List all stored keys',
+ 'my-export': 'my-export — Export all stored data as JSON',
+ 'my-import': 'my-import <json> — Import data from JSON string',
+ 'my-clear': 'my-clear — Clear all stored data'
+ };
+ }
+ _save() {
+ try { localStorage.setItem('webx11_my_data', JSON.stringify(this.userData)); } catch (e) {}
+ }
+ registerCommands() {
+ this.commands['my'] = () => this.showInfo();
+ this.commands['my-set'] = (args) => this.setData(args);
+ this.commands['my-get'] = (args) => this.getData(args);
+ this.commands['my-list'] = () => this.listData();
+ this.commands['my-export'] = () => this.exportData();
+ this.commands['my-import'] = (args) => this.importData(args);
+ this.commands['my-clear'] = () => this.clearData();
+ }
+ showInfo() {
+ this.terminal.print("=== My Module v2.0 ===", "info");
+ this.terminal.print("Personal workspace for custom commands");
+ this.terminal.print(`Stored keys: ${Object.keys(this.userData).length}`, "success");
+ }
+ setData(args) {
+ if (args.length < 2) {
+ this.terminal.print("Usage: my-set <key> <value>", "warning"); return;
+ }
+ const key = args[0], value = args.slice(1).join(' ');
+ this.userData[key] = { value, timestamp: new Date().toISOString() };
+ this._save();
+ this.terminal.print(`Saved: ${key} = ${value}`, "success");
+ }
+ getData(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: my-get <key>", "warning"); return;
+ }
+ if (this.userData[args[0]]) {
+ this.terminal.print(`${args[0]}: ${this.userData[args[0]].value}`, "info");
+ } else {
+ this.terminal.print(`Key '${args[0]}' not found`, "error");
+ }
+ }
+ listData() {
+ const keys = Object.keys(this.userData);
+ if (!keys.length) return this.terminal.print('No data stored.', 'info');
+ this.terminal.print('=== My Data ===', 'info');
+ keys.forEach(k => {
+ const v = this.userData[k];
+ const type = typeof v.value;
+ this.terminal.print(` ${k} (${type}): ${String(v.value).substring(0, 40)}`);
+ });
+ }
+ exportData() {
+ this.terminal.print(JSON.stringify(this.userData, null, 2));
+ }
+ importData(args) {
+ if (!args[0]) return this.terminal.print('Usage: my-import <json>', 'warning');
+ try {
+ const data = JSON.parse(args.join(' '));
+ Object.assign(this.userData, data);
+ this._save();
+ this.terminal.print(`Imported ${Object.keys(data).length} keys`, 'success');
+ } catch (e) {
+ this.terminal.print('Invalid JSON: ' + e.message, 'error');
+ }
+ }
+ clearData() {
+ this.userData = {};
+ localStorage.removeItem('webx11_my_data');
+ this.terminal.print("Personal data cleared", "success");
+ }
+}
diff --git a/modules/core/module-ollama.js b/modules/core/module-ollama.js
new file mode 100644
index 0000000..3eabe19
--- /dev/null
+++ b/modules/core/module-ollama.js
@@ -0,0 +1,52 @@
+// Extracted from newOS4 index.html — OllamaModule
+ 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',
+ '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'] = () => {
+ const models = this.terminal.ollamaManager.getModels();
+ if(models.length === 0) return this.terminal.print("No models found. Connect first.");
+ models.forEach(m => this.terminal.print(`${m.name} (${(m.size/1e9).toFixed(1)}GB)`));
+ };
+ 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(' '));
+ };
+ }
+ }
diff --git a/modules/core/module-ollamamodels.js b/modules/core/module-ollamamodels.js
new file mode 100644
index 0000000..9a7763a
--- /dev/null
+++ b/modules/core/module-ollamamodels.js
@@ -0,0 +1,87 @@
+// Extracted from newOS4 index.html — OllamaModelsModule
+ class OllamaModelsModule {
+ constructor() { this.commands = {}; this.terminal = null; }
+ getHelpInfo() {
+ return {
+ 'models': 'models — List all available Ollama models',
+ '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'
+ };
+ }
+ registerCommands() {
+ 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 = 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 = 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);
+ }
+ }
diff --git a/modules/core/module-searxng.js b/modules/core/module-searxng.js
new file mode 100644
index 0000000..f2c2e72
--- /dev/null
+++ b/modules/core/module-searxng.js
@@ -0,0 +1,73 @@
+class SearXNGModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+ getHelpInfo() {
+ return {
+ 'search': 'search <query> [--summarize] [--fetch] — Search using SearXNG; --summarize/-s adds AI overview, --fetch/-f fetches page content',
+ 's': 's <query> [--summarize] [--fetch] — Alias for search',
+ };
+ }
+ registerCommands() {
+ this.commands['search'] = (args) => this.search(args);
+ this.commands['s'] = (args) => this.search(args);
+ }
+ async search(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: search <query> [--summarize] [--fetch]", "warning");
+ this.terminal.print("Example: search quantum computing --summarize", "info");
+ return;
+ }
+ const argsList = args.slice();
+ const summarize = argsList.includes('--summarize') || argsList.includes('-s');
+ const fetchContent = argsList.includes('--fetch') || argsList.includes('-f');
+ const query = argsList.filter(a => !a.startsWith('-')).join(' ');
+ if (!query) { this.terminal.print('Search query is required', 'warning'); return; }
+
+ this.terminal.print(`🔍 Searching: ${query}`, 'info');
+
+ let params = 'q=' + encodeURIComponent(query);
+ if (summarize) params += '&summarize=true';
+ if (fetchContent) params += '&fetch_content=true';
+
+ try {
+ const resp = await fetch('/api/search?' + params);
+ 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 (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 || '';
+ 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>` : ''}
+ </div>
+ `);
+ });
+ } catch (e) {
+ this.terminal.print('Search failed: ' + e.message, 'error');
+ }
+ }
+}
diff --git a/modules/core/module-servermon.js b/modules/core/module-servermon.js
new file mode 100644
index 0000000..23407db
--- /dev/null
+++ b/modules/core/module-servermon.js
@@ -0,0 +1,107 @@
+// Extracted from newOS4 index.html — ServerMonitorModule
+ class ServerMonitorModule {
+ constructor() {
+ this.commands = {}; this.terminal = null;
+ this.servers = this.loadServers(); this.monitorInterval = null;
+ }
+ getHelpInfo() {
+ return {
+ 'server': 'Add a server to monitor: server <name> <url>',
+ 'servers': 'List all monitored servers',
+ 'server-check': 'Check a server status: server-check <name-or-index>',
+ 'server-remove': 'Remove a server: server-remove <name-or-index>',
+ 'monitor': 'Toggle auto-monitoring (checks every 60s)'
+ };
+ }
+ 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));
+ }
+ 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);
+ }
+ 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());
+ }
+ }
diff --git a/modules/core/module-simplegui.js b/modules/core/module-simplegui.js
new file mode 100644
index 0000000..78fa9b5
--- /dev/null
+++ b/modules/core/module-simplegui.js
@@ -0,0 +1,67 @@
+// 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();
+ this.terminal.printHTML(`<div style="margin: 5px 0;">
+ <button class="qa-btn" onclick="${command}" id="${id}">${label}</button>
+ </div>`);
+ this.guiElements[id] = { type: 'button', label, command };
+ this.terminal.print(`Button '${label}' created`, "success");
+ }
+ createInput(args) {
+ if (args.length < 2) {
+ this.terminal.print("Usage: gui-input <placeholder> <varName>", "warning"); return;
+ }
+ const placeholder = args[0], varName = args[1], id = 'gui-input-' + Date.now();
+ this.terminal.printHTML(`<div style="margin: 5px 0;">
+ <input type="text" id="${id}" placeholder="${placeholder}"
+ style="background: #0a0a0a; border: 1px solid #005500;
+ color: #00ff00; padding: 8px; width: 100%;" />
+ </div>`);
+ this.guiElements[id] = { type: 'input', placeholder, varName };
+ this.terminal.print(`Input '${placeholder}' created (var: ${varName})`, "success");
+ }
+ createText(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: gui-text <content>", "warning"); return;
+ }
+ const content = args.join(' ');
+ this.terminal.printHTML(`<div style="margin: 5px 0; padding: 8px;
+ background: #111111; border-left: 3px solid #00aaff;">${content}</div>`);
+ this.terminal.print("Text element created", "success");
+ }
+ clearGUI() { this.guiElements = {}; this.terminal.print("GUI elements cleared", "success"); }
+ runCommand(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: gui-run <command>", "warning"); return;
+ }
+ this.terminal.executeCommand(args.join(' '));
+ }
+ }
diff --git a/modules/core/module-skills.js b/modules/core/module-skills.js
new file mode 100644
index 0000000..ed6e14f
--- /dev/null
+++ b/modules/core/module-skills.js
@@ -0,0 +1,271 @@
+// Extracted from newOS4 index.html — SkillsModule (v4.2 — Self-improving AI)
+ class SkillsModule {
+ constructor() { this.commands = {}; this.terminal = null; }
+ getHelpInfo() {
+ return {
+ 'skill-list': 'skill-list — List all installed skills',
+ 'skill-view': 'skill-view <name> — View the content of a skill',
+ 'skill-create': 'skill-create <name> [desc] [category] [tags] — Create a new skill',
+ 'skill-patch': 'skill-patch <name> [content...] — Update skill content (no args to view current)',
+ 'skill-delete': 'skill-delete <name> — Delete a skill',
+ 'skill-export': 'skill-export <name> — Export a skill to WebDAV',
+ 'skill-import': 'skill-import <name> — Import a skill from WebDAV',
+ 'skill-sync': 'skill-sync — Bidirectional sync all skills with WebDAV',
+ };
+ }
+ ensureSkillsDir() {
+ if (this.terminal && this.terminal.fs) {
+ const fs = this.terminal.fs;
+ // Ensure /skills/ directory exists
+ if (!fs.root.content['skills']) {
+ fs.root.content['skills'] = { type: 'dir', content: {} };
+ fs.saveToStorage();
+ }
+ }
+ }
+ registerCommands() {
+ this.commands['skill-list'] = () => this.listSkills();
+ this.commands['skill-view'] = (args) => this.viewSkill(args);
+ this.commands['skill-create'] = (args) => this.createSkill(args);
+ this.commands['skill-patch'] = (args) => this.patchSkill(args);
+ this.commands['skill-delete'] = (args) => this.deleteSkill(args);
+ this.commands['skill-export'] = (args) => this.exportSkill(args);
+ this.commands['skill-import'] = (args) => this.importSkill(args);
+ this.commands['skill-sync'] = () => this.syncSkills();
+ }
+ listSkills() {
+ this.ensureSkillsDir();
+ const index = this.terminal.fs.fsReadJSON('/skills/index.json') || {};
+ const names = Object.keys(index);
+ if (names.length === 0) {
+ this.terminal.print('No skills found.', 'info');
+ return;
+ }
+ this.terminal.print(`=== Skills (${names.length}) ===`, 'info');
+ names.forEach(name => {
+ const s = index[name];
+ const tags = (s.tags || []).join(', ');
+ this.terminal.print(`📘 ${name} — ${s.description} [${s.category}] ${tags ? '(' + tags + ')' : ''}`);
+ });
+ }
+ viewSkill(args) {
+ const name = (args[0] || '').replace(/\.md$/, '');
+ if (!name) {
+ this.terminal.print('Usage: skill-view <name>', 'warning');
+ return;
+ }
+ const content = this.terminal.fs.fsRead(`/skills/${name}.md`);
+ if (!content) {
+ this.terminal.print(`Skill '${name}' not found`, 'error');
+ return;
+ }
+ this.terminal.print(`=== ${name} ===`, 'info');
+ this.terminal.print(content, 'info');
+ }
+ createSkill(args) {
+ this.ensureSkillsDir();
+ const name = args[0];
+ if (!name) {
+ this.terminal.print('Usage: skill-create <name> [description] [category] [tag1,tag2,...]', 'warning');
+ return;
+ }
+ const description = args[1] || 'No description';
+ const category = args[2] || 'general';
+ const tags = (args[3] || '').split(',').filter(Boolean);
+ const fs = this.terminal.fs;
+ if (fs.fsRead(`/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') || {};
+ index[name] = { name, description, category, tags };
+ fs.fsWriteJSON('/skills/index.json', index);
+ this.terminal.print(`Skill '${name}' created. Edit its content with skill-patch ${name}`, 'success');
+ }
+ patchSkill(args) {
+ const name = args[0];
+ if (!name) {
+ this.terminal.print('Usage: skill-patch <name> [content...]', 'warning');
+ return;
+ }
+ const fs = this.terminal.fs;
+ const existing = fs.fsRead(`/skills/${name}.md`);
+ if (!existing) {
+ this.terminal.print(`Skill '${name}' not found. Use skill-create first.`, 'error');
+ return;
+ }
+ if (args.length < 2) {
+ // No new content — show the skill so user can copy-paste
+ this.terminal.print(`=== ${name} (current content) ===`, 'info');
+ this.terminal.print(existing, 'info');
+ this.terminal.print('---', 'info');
+ this.terminal.print('Pass new content as additional arguments: skill-patch <name> <full content>', 'warning');
+ return;
+ }
+ const newContent = args.slice(1).join(' ');
+ fs.fsWrite(`/skills/${name}.md`, newContent);
+ this.terminal.print(`Skill '${name}' patched`, 'success');
+ }
+ deleteSkill(args) {
+ const name = (args[0] || '').replace(/\.md$/, '');
+ if (!name) {
+ this.terminal.print('Usage: skill-delete <name>', 'warning');
+ return;
+ }
+ const fs = this.terminal.fs;
+ if (!fs.fsRead(`/skills/${name}.md`)) {
+ this.terminal.print(`Skill '${name}' not found`, 'error');
+ return;
+ }
+ fs.fsWrite(`/skills/${name}.md`, '');
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ delete index[name];
+ fs.fsWriteJSON('/skills/index.json', index);
+ this.terminal.print(`Skill '${name}' deleted`, 'success');
+ }
+ getWebDAVModule() {
+ return this.terminal && this.terminal.moduleManager.modules['WebDAVModule'];
+ }
+ async exportSkill(args) {
+ const name = (args[0] || '').replace(/\.md$/, '');
+ if (!name) {
+ this.terminal.print('Usage: skill-export <name>', 'warning');
+ return;
+ }
+ const wd = this.getWebDAVModule();
+ if (!wd || !wd.config || !wd.config.url) {
+ this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning');
+ return;
+ }
+ this.ensureSkillsDir();
+ const fs = this.terminal.fs;
+ const content = fs.fsRead(`/skills/${name}.md`);
+ if (!content) {
+ this.terminal.print(`Skill '${name}' not found in VFS`, 'error');
+ return;
+ }
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ const meta = index[name];
+ try {
+ // Ensure remote skills dir
+ if (typeof wd._ensureDir === 'function') await wd._ensureDir('skills');
+ // Push the skill file
+ const resp1 = await wd._webdavRequest('PUT', 'skills/' + name + '.md', {
+ body: content,
+ headers: { 'Content-Type': 'text/markdown' }
+ });
+ if (!resp1.ok && resp1.status !== 201 && resp1.status !== 204) {
+ throw new Error('Upload failed: HTTP ' + resp1.status);
+ }
+ // Push updated index
+ const indexResp = await wd._webdavRequest('PUT', 'skills/index.json', {
+ body: JSON.stringify(index, null, 2),
+ headers: { 'Content-Type': 'application/json' }
+ });
+ if (indexResp.ok || indexResp.status === 201 || indexResp.status === 204) {
+ this.terminal.print(`Skill '${name}' exported to WebDAV skills/`, 'success');
+ }
+ } catch (e) {
+ this.terminal.print('Export failed: ' + e.message, 'error');
+ }
+ }
+ async importSkill(args) {
+ const name = (args[0] || '').replace(/\.md$/, '');
+ if (!name) {
+ this.terminal.print('Usage: skill-import <name>', 'warning');
+ return;
+ }
+ const wd = this.getWebDAVModule();
+ if (!wd || !wd.config || !wd.config.url) {
+ this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning');
+ return;
+ }
+ try {
+ const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md');
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ const content = await resp.text();
+ this.ensureSkillsDir();
+ const fs = this.terminal.fs;
+ fs.fsWrite(`/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 {
+ const indexResp = await wd._webdavRequest('GET', 'skills/index.json');
+ if (indexResp.ok) {
+ const remoteIndex = JSON.parse(await indexResp.text());
+ if (remoteIndex && remoteIndex[name]) {
+ meta = remoteIndex[name];
+ }
+ }
+ } catch (e) { /* use default meta */ }
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ index[name] = meta;
+ fs.fsWriteJSON('/skills/index.json', index);
+ this.terminal.print(`Skill '${name}' imported from WebDAV (${content.length} bytes)`, 'success');
+ } catch (e) {
+ this.terminal.print('Import failed: ' + e.message, 'error');
+ }
+ }
+ async syncSkills() {
+ const wd = this.getWebDAVModule();
+ if (!wd || !wd.config || !wd.config.url) {
+ this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning');
+ return;
+ }
+ this.ensureSkillsDir();
+ const fs = this.terminal.fs;
+ const localIndex = fs.fsReadJSON('/skills/index.json') || {};
+ const localNames = new Set(Object.keys(localIndex));
+ this.terminal.print('Syncing skills with WebDAV...', 'info');
+ let imported = 0, exported = 0;
+ try {
+ // Try to get remote index
+ let remoteIndex = {};
+ try {
+ const resp = await wd._webdavRequest('GET', 'skills/index.json');
+ if (resp.ok) {
+ remoteIndex = JSON.parse(await resp.text()) || {};
+ }
+ } catch (e) { /* no remote index yet */ }
+ const remoteNames = new Set(Object.keys(remoteIndex));
+ // Export local-only skills to remote
+ for (const name of localNames) {
+ if (!remoteNames.has(name)) {
+ const content = fs.fsRead(`/skills/${name}.md`);
+ if (content) {
+ await wd._ensureDir('skills');
+ await wd._webdavRequest('PUT', 'skills/' + name + '.md', {
+ body: content,
+ headers: { 'Content-Type': 'text/markdown' }
+ });
+ exported++;
+ }
+ }
+ }
+ // Import remote-only skills to local
+ for (const name of remoteNames) {
+ if (!localNames.has(name)) {
+ const resp = await wd._webdavRequest('GET', 'skills/' + name + '.md');
+ if (resp.ok) {
+ const content = await resp.text();
+ fs.fsWrite(`/skills/${name}.md`, content);
+ localIndex[name] = remoteIndex[name];
+ imported++;
+ }
+ }
+ }
+ // Push updated local index back to remote
+ fs.fsWriteJSON('/skills/index.json', localIndex);
+ await wd._ensureDir('skills');
+ await wd._webdavRequest('PUT', 'skills/index.json', {
+ body: JSON.stringify(localIndex, null, 2),
+ headers: { 'Content-Type': 'application/json' }
+ });
+ this.terminal.print(`Sync complete: ${exported} exported, ${imported} imported`, 'success');
+ } catch (e) {
+ this.terminal.print('Sync failed: ' + e.message, 'error');
+ }
+ }
+ }
diff --git a/modules/core/module-smartctx.js b/modules/core/module-smartctx.js
new file mode 100644
index 0000000..2b4712d
--- /dev/null
+++ b/modules/core/module-smartctx.js
@@ -0,0 +1,93 @@
+// Extracted from newOS4 index.html — SmartContextModule
+ class SmartContextModule {
+ constructor() {
+ this.commands = {}; this.terminal = null;
+ this.contextProfiles = this.loadProfiles(); this.activeProfile = null;
+ }
+ getHelpInfo() {
+ return {
+ 'context': 'context [name] — Set or view current context profile',
+ 'contexts': 'contexts — List all available context profiles',
+ 'context-new': 'context-new <name> <displayName> <systemPrompt> — Create a new context profile',
+ 'context-delete': 'context-delete <name> — Delete a context profile',
+ 'smart-ask': 'smart-ask <question> — Ask a question using the active context'
+ };
+ }
+ loadProfiles() {
+ try {
+ const saved = localStorage.getItem('webx11_context_profiles');
+ return saved ? JSON.parse(saved) : this.getDefaultProfiles();
+ } catch (e) { return this.getDefaultProfiles(); }
+ }
+ saveProfiles() {
+ localStorage.setItem('webx11_context_profiles', JSON.stringify(this.contextProfiles));
+ }
+ getDefaultProfiles() {
+ return {
+ 'coding': { name: 'Coding Assistant', systemPrompt: "You are an expert programmer.", temperature: 0.7 },
+ 'debug': { name: 'Debug Helper', systemPrompt: "You are a debugging expert.", temperature: 0.5 },
+ 'creative': { name: 'Creative Writer', systemPrompt: "You are a creative writer.", temperature: 0.9 },
+ 'technical': { name: 'Technical Docs', systemPrompt: "You are a technical writer.", temperature: 0.3 },
+ 'chat': { name: 'Casual Chat', systemPrompt: "You are a friendly assistant.", temperature: 0.8 }
+ };
+ }
+ registerCommands() {
+ this.commands['context'] = (args) => this.setContext(args);
+ this.commands['contexts'] = () => this.listContexts();
+ this.commands['context-new'] = (args) => this.createContext(args);
+ this.commands['context-delete'] = (args) => this.deleteContext(args);
+ this.commands['smart-ask'] = (args) => this.smartAsk(args);
+ }
+ setContext(args) {
+ if (!args[0]) {
+ this.terminal.print(`Current context: ${this.activeProfile || 'none'}`, "info"); return;
+ }
+ if (this.contextProfiles[args[0]]) {
+ this.activeProfile = args[0];
+ this.terminal.print(`Context set to: ${this.contextProfiles[args[0]].name}`, "success");
+ } else {
+ this.terminal.print(`Profile '${args[0]}' not found`, "error");
+ }
+ }
+ listContexts() {
+ this.terminal.print("=== Context Profiles ===", "info");
+ Object.entries(this.contextProfiles).forEach(([key, profile]) => {
+ const active = key === this.activeProfile ? ' ✅' : '';
+ this.terminal.print(`${key}: ${profile.name}${active}`);
+ });
+ }
+ createContext(args) {
+ if (args.length < 3) {
+ this.terminal.print("Usage: context-new <name> <displayName> <systemPrompt>", "warning"); return;
+ }
+ const name = args[0], displayName = args[1], systemPrompt = args.slice(2).join(' ');
+ this.contextProfiles[name] = { name: displayName, systemPrompt, temperature: 0.7 };
+ this.saveProfiles();
+ this.terminal.print(`Context profile '${name}' created`, "success");
+ }
+ deleteContext(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: context-delete <name>", "warning"); return;
+ }
+ if (this.contextProfiles[args[0]]) {
+ delete this.contextProfiles[args[0]];
+ this.saveProfiles();
+ if (this.activeProfile === args[0]) { this.activeProfile = null; }
+ this.terminal.print(`Context profile '${args[0]}' deleted`, "success");
+ } else {
+ this.terminal.print(`Profile '${args[0]}' not found`, "error");
+ }
+ }
+ async smartAsk(args) {
+ if (!args[0]) {
+ this.terminal.print("Usage: smart-ask <your-question>", "warning"); return;
+ }
+ let systemPrompt = "You are a helpful assistant.";
+ if (this.activeProfile && this.contextProfiles[this.activeProfile]) {
+ systemPrompt = this.contextProfiles[this.activeProfile].systemPrompt;
+ this.terminal.print(`Using context: ${this.contextProfiles[this.activeProfile].name}`, "info");
+ }
+ const fullPrompt = `${systemPrompt}\n\nUser: ${args.join(' ')}`;
+ await this.terminal.ollamaManager.chat(fullPrompt);
+ }
+ }
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
new file mode 100644
index 0000000..f21946a
--- /dev/null
+++ b/modules/core/module-system.js
@@ -0,0 +1,154 @@
+// Extracted from newOS4 index.html — SystemModule
+ class SystemModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+ getHelpInfo() {
+ return {
+ 'help': 'help [command] — Show all commands or usage for a specific command',
+ 'echo': 'echo <text...> — Print text to the terminal',
+ 'mem': 'mem — Show current memory usage',
+ 'status': 'status — Display system status (network, files, modules, Ollama)',
+ 'preview': 'preview <file.html> — Render an HTML file from the VFS in a preview pane',
+ 'factory-reset': 'factory-reset [--confirm] — Wipe all webx11_ data (VFS, config, AI profile, mindmaps, themes, all localStorage)',
+ 'reset-vfs': 'reset-vfs [--confirm] — Clear only the virtual filesystem (keeps settings, modules, AI profile)',
+ 'logs': 'logs [n] [LEVEL] — Show last n log entries (optionally filtered by level: INFO/WARN/ERROR)',
+ 'font-size': 'font-size <8-32> — Set terminal font size (persists across sessions)',
+ 'setfont': 'setfont <8-32> — Alias for font-size',
+ 'smart-prompt': 'smart-prompt [on|off] — Toggle context-aware prompt (shows name, skills, sessions, memory, edits)',
+ 'checkpoint': 'checkpoint [name] — Create a VFS snapshot (name defaults to ISO timestamp)',
+ 'checkpoint-list': 'checkpoint-list — List all VFS checkpoints',
+ 'rollback': 'rollback <name> — Restore VFS from a checkpoint',
+ 'checkpoint-del': 'checkpoint-del <name> — Delete a VFS checkpoint',
+ 'commands': 'commands — List all commands with module source and requirements status',
+ 'check': 'check — Show overall system health including module requirements',
+ };
+ }
+ registerCommands() {
+ this.commands['help'] = (args) => this.handleHelp(args);
+ this.commands['echo'] = (args) => this.terminal.print(args.join(' '));
+ this.commands['mem'] = () => {
+ const mem = performance.memory ? (performance.memory.usedJSHeapSize / 1048576).toFixed(2) : 'N/A';
+ this.terminal.print(`Memory Usage: ${mem} MB`, 'info');
+ document.getElementById('header-status').innerText = `Mem: ${mem}MB`;
+ };
+ this.commands['status'] = () => {
+ this.terminal.print(`System: WebX11 v4.2`);
+ 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'}`);
+ };
+ this.commands['preview'] = (args) => this.terminal.openPreview(args);
+ this.commands['factory-reset'] = (args) => {
+ if (!args[0] || args[0] !== '--confirm') {
+ this.terminal.print('⚠️ FACTORY RESET will clear all data:', 'error');
+ this.terminal.print(' • Filesystem (all files + directories)', 'warning');
+ this.terminal.print(' • AI memory, patterns, user profile', 'warning');
+ this.terminal.print(' • Ollama endpoint, model selection', 'warning');
+ this.terminal.print(' • Terminal history, font size', 'warning');
+ this.terminal.print(' • All config, modules, packages', 'warning');
+ this.terminal.print(' • Mindmaps, notes, bookmarks', 'warning');
+ this.terminal.print(' • Themes, context profiles, servers', 'warning');
+ this.terminal.print(' • Chat settings, my data', 'warning');
+ this.terminal.print('Type: factory-reset --confirm to proceed', 'error');
+ return;
+ }
+ for (let i = localStorage.length - 1; i >= 0; i--) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith('webx11_')) localStorage.removeItem(key);
+ }
+ this.terminal.fs.root = { type: 'dir', content: {} };
+ this.terminal.fs.currentPath = [];
+ this.terminal.outputContainer.innerHTML = '';
+ this.terminal.print('✅ Factory reset complete. Reloading...', 'success');
+ setTimeout(() => location.reload(), 1000);
+ };
+ 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 = [];
+ 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'));
+ };
+ }
+ handleHelp(args) {
+ const helpMap = this.terminal.moduleManager.getAllHelp();
+ // Single command lookup
+ if (args && args.length > 0) {
+ const cmd = args[0].toLowerCase();
+ const allCommands = this.terminal.moduleManager.getAllCommands();
+ if (allCommands[cmd]) {
+ const helpEntry = helpMap[cmd];
+ if (helpEntry) {
+ this.terminal.print(helpEntry, 'info');
+ } else {
+ this.terminal.print(`No help available for ${cmd}`, 'warning');
+ }
+ } else {
+ this.terminal.print(`No help available for ${cmd}`, 'warning');
+ }
+ return;
+ }
+ // Categorize all commands
+ const cats = {
+ 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'],
+ 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'],
+ skills: ['skill-list','skill-view','skill-create','skill-patch','skill-delete','skill-export','skill-import','skill-sync','skill-pack-core'],
+ config: ['config','config-set','config-list','config-delete','config-export','config-reset'],
+ webx: ['webxai','wx','webx','context','contexts','context-new','context-delete','smart-ask','prompt','prompt-save','prompt-list','prompt-delete','prompt-var','prompt-vars'],
+ data: ['my','my-set','my-get','my-list','my-export','my-import','my-clear','export-mods','import-mods','backup','restore','share-mod'],
+ ext: ['chat','chat-setapi','chat-setmodel','chat-ask','webdav-config','webdav-status','webdav-ls','webdav-push','webdav-pull','webdav-backup','webdav-restore','webdav-backup-list'],
+ gui: ['gui','gui-button','gui-input','gui-text','gui-clear','gui-run'],
+ };
+ const labels = {
+ files:'📁 Filesystem', ai:'🤖 AI', search:'🔍 Search & Web', tools:'🔧 Tools',
+ system:'⚙️ System', net:'📡 Network', mods:'🧩 Modules', ollama:'🦙 Ollama',
+ skills:'📚 Skills', config:'⚙️ Config', webx:'🌐 WebX', data:'💾 Data',
+ ext:'🧰 Extras', gui:'🖥️ GUI'
+ };
+ let result = '╔══════════════════════════════════════╗\n║ 🔷 WebX11 v4.2 — All Commands ║\n╚══════════════════════════════════════╝\n\n';
+ const total = Object.keys(helpMap).length;
+ result += `📊 ${total} commands loaded\n\n`;
+ for (const [key, cmds] of Object.entries(cats)) {
+ const present = cmds.filter(c => helpMap[c]);
+ if (present.length === 0) continue;
+ result += labels[key] + '\n';
+ present.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');
+ }
+ }
diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js
new file mode 100644
index 0000000..c790e6f
--- /dev/null
+++ b/modules/core/ollama-manager.js
@@ -0,0 +1,842 @@
+// 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;
+ // Build fallback endpoints
+ this.fallbackEndpoints = [];
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
+ if (origin) {
+ this.fallbackEndpoints.push(origin + '/ollama/');
+ }
+
+ // --- Multi-provider architecture ---
+ this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled }
+ this.currentProvider = null; // currently active provider object
+ this.fallbackCount = 0;
+ this._loadProviders();
+
+ // If no active provider set, pick the highest-priority enabled one
+ if (!this.currentProvider) {
+ const sorted = this._getSortedEnabledProviders();
+ if (sorted.length > 0) {
+ this.currentProvider = sorted[0];
+ this._syncCompatProperties(this.currentProvider);
+ }
+ }
+ }
+
+ // ─── Provider management ─────────────────────────────────────────────────
+
+ _loadProviders() {
+ const stored = this._getSecret('webx11_llm_providers');
+ if (stored) {
+ try {
+ this.providers = JSON.parse(stored);
+ // Ensure all entries have the 'enabled' field
+ this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; });
+ } catch (e) {
+ this.providers = [];
+ }
+ }
+ if (this.providers.length === 0) {
+ // Default single provider: Ollama
+ this.providers = [{
+ name: 'Ollama',
+ type: 'ollama',
+ endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434',
+ apiKey: '',
+ model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud',
+ priority: 0,
+ enabled: true
+ }];
+ }
+ }
+
+ _saveProviders() {
+ this._setSecret('webx11_llm_providers', JSON.stringify(this.providers));
+ }
+
+ _getSecret(key) {
+ if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') {
+ return window.webx11_secretVault.decrypt(key);
+ }
+ return localStorage.getItem(key);
+ }
+
+ _setSecret(key, value) {
+ if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') {
+ window.webx11_secretVault.encrypt(key, value);
+ } else {
+ localStorage.setItem(key, value);
+ }
+ }
+
+ _getSortedEnabledProviders() {
+ return this.providers
+ .filter(p => p.enabled)
+ .sort((a, b) => (a.priority || 0) - (b.priority || 0));
+ }
+
+ _syncCompatProperties(provider) {
+ if (!provider) return;
+ this.endpoint = provider.endpoint;
+ this.currentModel = provider.model || this.currentModel;
+ // Don't override backward-compat localStorage unless explicitly set
+ }
+
+ _getProviderByType(type) {
+ return this.providers.find(p => p.type === type && p.enabled);
+ }
+
+ addProvider(name, type, endpoint, apiKey, model, priority) {
+ const existing = this.providers.findIndex(p => p.name === name);
+ const entry = {
+ name: name,
+ type: type || 'ollama',
+ endpoint: endpoint || 'http://localhost:11434',
+ apiKey: apiKey || '',
+ model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud',
+ priority: priority !== undefined ? priority : this.providers.length,
+ enabled: true
+ };
+ if (existing >= 0) {
+ this.providers[existing] = entry;
+ } else {
+ this.providers.push(entry);
+ }
+ this._saveProviders();
+ // If this is the new highest-priority enabled provider, activate it
+ const sorted = this._getSortedEnabledProviders();
+ if (sorted.length > 0 && sorted[0].name === entry.name) {
+ this.currentProvider = entry;
+ this._syncCompatProperties(entry);
+ }
+ return true;
+ }
+
+ removeProvider(name) {
+ const idx = this.providers.findIndex(p => p.name === name);
+ if (idx < 0) return false;
+ this.providers.splice(idx, 1);
+ if (this.currentProvider && this.currentProvider.name === name) {
+ this.currentProvider = null;
+ const sorted = this._getSortedEnabledProviders();
+ if (sorted.length > 0) {
+ this.currentProvider = sorted[0];
+ this._syncCompatProperties(this.currentProvider);
+ }
+ }
+ this._saveProviders();
+ return true;
+ }
+
+ listProviders() {
+ return this.providers;
+ }
+
+ setActiveProvider(name) {
+ const provider = this.providers.find(p => p.name === name);
+ if (!provider) {
+ this.terminal.print(`Provider "${name}" not found`, 'error');
+ return false;
+ }
+ this.currentProvider = provider;
+ this._syncCompatProperties(provider);
+ this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success');
+ return true;
+ }
+
+ getActiveProvider() {
+ return this.currentProvider;
+ }
+
+ getProviderStats() {
+ const sorted = this._getSortedEnabledProviders();
+ const connectedProvider = this.connected
+ ? (this.currentProvider ? this.currentProvider.name : 'unknown')
+ : null;
+ return {
+ currentProvider: this.currentProvider ? this.currentProvider.name : null,
+ totalProviders: this.providers.length,
+ connectedProvider: connectedProvider,
+ fallbackCount: this.fallbackCount
+ };
+ }
+
+ // ─── Connection ──────────────────────────────────────────────────────────
+
+ async connect() {
+ const sortedProviders = this._getSortedEnabledProviders();
+ if (sortedProviders.length === 0) {
+ this.terminal.print('No enabled providers configured', 'error');
+ this.connected = false;
+ return false;
+ }
+
+ // Also build endpoint-based fallback list for backward compat
+ const allEndpoints = [];
+ // First try the original endpoint + fallbackEndpoints for plain Ollama connect
+ if (this._getProviderByType('ollama')) {
+ allEndpoints.push(this.endpoint);
+ for (const fb of this.fallbackEndpoints) {
+ if (!allEndpoints.includes(fb)) allEndpoints.push(fb);
+ }
+ }
+
+ // Try each provider in priority order
+ for (const provider of sortedProviders) {
+ let success = false;
+ if (provider.type === 'ollama') {
+ // Try provider endpoint first, then fallback endpoints
+ const eps = [provider.endpoint];
+ for (const fb of this.fallbackEndpoints) {
+ if (!eps.includes(fb)) eps.push(fb);
+ }
+ for (const ep of eps) {
+ try {
+ this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info');
+ const response = await fetch(`${ep}/api/tags`, {
+ method: 'GET', headers: { 'Content-Type': 'application/json' }
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data = await response.json();
+ this.models = data.models || [];
+ this.connected = true;
+ this.currentProvider = provider;
+ provider.endpoint = ep; // may have been updated via fallback
+ this._syncCompatProperties(provider);
+ this.saveSettings();
+ this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success');
+ this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)');
+ if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
+ if (this.models.length > 0) {
+ this.currentModel = this.models[0].name;
+ provider.model = this.currentModel;
+ localStorage.setItem('webx11_ollama_model', this.currentModel);
+ this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info');
+ }
+ }
+ this._saveProviders();
+ success = true;
+ break;
+ } catch (error) {
+ this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message);
+ this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning');
+ }
+ }
+ } else {
+ // OpenAI-compatible provider (OpenRouter, etc.)
+ try {
+ this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info');
+ const headers = { 'Content-Type': 'application/json' };
+ if (provider.apiKey) {
+ headers['Authorization'] = 'Bearer ' + provider.apiKey;
+ }
+ const response = await fetch(`${provider.endpoint}/v1/models`, {
+ method: 'GET', headers
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data = await response.json();
+ this.models = (data.data || data.models || []).map(m => ({
+ name: m.id || m.name,
+ size: m.size || 0
+ }));
+ this.connected = true;
+ this.currentProvider = provider;
+ this._syncCompatProperties(provider);
+ this.saveSettings();
+ this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success');
+ this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)');
+ if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
+ if (this.models.length > 0) {
+ this.currentModel = this.models[0].name;
+ provider.model = this.currentModel;
+ localStorage.setItem('webx11_ollama_model', this.currentModel);
+ this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info');
+ }
+ }
+ this._saveProviders();
+ success = true;
+ } catch (error) {
+ this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message);
+ this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning');
+ }
+ }
+
+ if (success) return true;
+ }
+
+ // If provider-based connect failed, try bare endpoint fallback (legacy)
+ for (const ep of allEndpoints) {
+ try {
+ this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info');
+ const response = await fetch(`${ep}/api/tags`, {
+ method: 'GET', headers: { 'Content-Type': 'application/json' }
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data = await response.json();
+ this.models = data.models || [];
+ this.connected = true;
+ if (ep !== this.endpoint) {
+ this.endpoint = ep;
+ this.saveSettings();
+ this.terminal.print(`Endpoint updated to: ${ep}`, 'info');
+ }
+ this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success');
+ this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)');
+ if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
+ if (this.models.length > 0) {
+ this.currentModel = this.models[0].name;
+ localStorage.setItem('webx11_ollama_model', this.currentModel);
+ this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info');
+ }
+ }
+ this.saveSettings();
+ return true;
+ } catch (error) {
+ this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message);
+ this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
+ }
+ }
+
+ this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers');
+ this.connected = false;
+ return false;
+ }
+
+ // ─── Chat (non-streaming) ────────────────────────────────────────────────
+
+ async chat(prompt, model = null) {
+ if (!this.connected) {
+ this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error');
+ return null;
+ }
+ const useModel = model || this.currentModel;
+ if (!useModel) {
+ this.terminal.print('No model selected. Use ollamamodels to select.', 'error');
+ return null;
+ }
+
+ const providersToTry = this._getFallbackChain();
+ let lastError = null;
+
+ for (const provider of providersToTry) {
+ const result = await this._chatWithProvider(provider, prompt, useModel);
+ if (result !== null && result !== undefined) {
+ // Success — if we fell back, increment fallbackCount
+ if (provider !== this.currentProvider) {
+ this.fallbackCount++;
+ this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning');
+ this.currentProvider = provider;
+ this._syncCompatProperties(provider);
+ }
+ return result;
+ }
+ lastError = result;
+ }
+
+ if (lastError === null) {
+ this.terminal.print(`Ollama error: No provider responded`, 'error');
+ }
+ return null;
+ }
+
+ async _chatWithProvider(provider, prompt, model) {
+ const useModel = model || provider.model || this.currentModel;
+ let fullText = '';
+ try {
+ if (provider.type === 'ollama') {
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ const response = await fetch(`${provider.endpoint}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let isFirstToken = true;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = chunk.split('\n').filter(l => l.trim());
+
+ for (const line of lines) {
+ try {
+ const parsed = JSON.parse(line);
+ if (parsed.response) {
+ fullText += parsed.response;
+ this.terminal.printStreamingResponse(parsed.response, !isFirstToken);
+ isFirstToken = false;
+ }
+ } catch (e) {
+ // Skip malformed partial lines
+ }
+ }
+ }
+ return fullText || null;
+ } else {
+ // OpenAI-compatible provider
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ 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: true
+ }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let isFirstToken = true;
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
+ if (trimmed.startsWith('data: ')) {
+ try {
+ const parsed = JSON.parse(trimmed.substring(6));
+ const content = parsed.choices?.[0]?.delta?.content || '';
+ if (content) {
+ fullText += content;
+ this.terminal.printStreamingResponse(content, !isFirstToken);
+ isFirstToken = false;
+ }
+ } catch (e) {
+ // Skip malformed SSE lines
+ }
+ }
+ }
+ }
+ return fullText || null;
+ }
+ } catch (error) {
+ if (fullText && fullText.length > 0) {
+ this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true);
+ return fullText;
+ }
+ return null; // signal failure so caller can try next provider
+ }
+ }
+
+ // ─── Chat Stream ─────────────────────────────────────────────────────────
+
+ async chatStream(prompt, onToken, onComplete, model = null) {
+ if (!this.connected) { this.terminal.print('Ollama not connected.', 'error'); onComplete && onComplete(null); return; }
+ const useModel = model || this.currentModel;
+ if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; }
+
+ const providersToTry = this._getFallbackChain();
+ let triedAny = false;
+
+ for (const provider of providersToTry) {
+ triedAny = true;
+ const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel);
+ if (result !== null) {
+ if (provider !== this.currentProvider) {
+ this.fallbackCount++;
+ this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning');
+ this.currentProvider = provider;
+ this._syncCompatProperties(provider);
+ }
+ return result;
+ }
+ }
+
+ if (!triedAny) {
+ this.terminal.print('No model selected.', 'error');
+ onComplete && onComplete(null);
+ } else {
+ this.terminal.print(`Stream error: No provider responded`, 'error');
+ onComplete && onComplete(null);
+ }
+ return null;
+ }
+
+ async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) {
+ const useModel = model || provider.model || this.currentModel;
+ try {
+ if (provider.type === 'ollama') {
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ const response = await fetch(`${provider.endpoint}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: useModel, prompt: prompt, stream: true }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let fullResponse = '';
+ let buffer = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const obj = JSON.parse(line);
+ if (obj.response) {
+ fullResponse += obj.response;
+ onToken && onToken(obj.response, fullResponse);
+ }
+ } catch (e) { continue; }
+ }
+ }
+ if (buffer.trim()) {
+ try {
+ const obj = JSON.parse(buffer);
+ if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); }
+ } catch (e) {}
+ }
+ this.fireResponseHooks(fullResponse);
+ onComplete && onComplete(fullResponse);
+ return fullResponse;
+ } else {
+ // OpenAI-compatible provider
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ 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: true
+ }),
+ signal
+ });
+ this._abortController = null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let fullResponse = '';
+ let buffer = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed === 'data: [DONE]') continue;
+ if (trimmed.startsWith('data: ')) {
+ try {
+ const parsed = JSON.parse(trimmed.substring(6));
+ const content = parsed.choices?.[0]?.delta?.content || '';
+ if (content) {
+ fullResponse += content;
+ onToken && onToken(content, fullResponse);
+ }
+ } catch (e) { continue; }
+ }
+ }
+ }
+ 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;
+ }
+ }
+
+ // ─── Chat With Tools ─────────────────────────────────────────────────────
+
+ async chatWithTools(messages, tools, model = null) {
+ if (!this.connected) {
+ this.terminal.print('Ollama not connected. Use ollamaconnect first.', 'error');
+ return { message: null, tool_calls: null, error: 'Not connected' };
+ }
+ const useModel = model || this.currentModel;
+ if (!useModel) {
+ this.terminal.print('No model selected.', 'error');
+ return { message: null, tool_calls: null, error: 'No model' };
+ }
+
+ const providersToTry = this._getFallbackChain();
+ let lastError = null;
+
+ for (const provider of providersToTry) {
+ const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel);
+ if (result && !result.error) {
+ if (provider !== this.currentProvider) {
+ this.fallbackCount++;
+ this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning');
+ this.currentProvider = provider;
+ this._syncCompatProperties(provider);
+ }
+ return result;
+ }
+ // Only keep non-cancellation errors for fallback
+ if (result && result.error && result.error !== 'Cancelled') {
+ lastError = result;
+ }
+ }
+
+ return lastError || { message: null, tool_calls: null, error: 'All providers failed' };
+ }
+
+ async _chatWithToolsWithProvider(provider, messages, tools, model) {
+ const useModel = model || provider.model || this.currentModel;
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+
+ try {
+ if (provider.type === 'ollama') {
+ const body = { model: useModel, messages, stream: false };
+ if (tools && tools.length > 0) body.tools = tools;
+ const t0 = Date.now();
+ const response = await fetch(`${provider.endpoint}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal
+ });
+ this._abortController = null;
+ const networkTime = Math.round((Date.now() - t0) / 1000);
+ if (!response.ok) {
+ if (response.status === 400) {
+ return await this.chatWithToolsFallback(messages, tools, useModel, signal);
+ }
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`);
+ return {
+ message: data.message || null,
+ tool_calls: data.message?.tool_calls || null,
+ error: null
+ };
+ } else {
+ // OpenAI-compatible provider
+ const headers = { 'Content-Type': 'application/json' };
+ if (provider.apiKey) {
+ headers['Authorization'] = 'Bearer ' + provider.apiKey;
+ }
+ const body = {
+ model: useModel,
+ messages: messages,
+ stream: false
+ };
+ 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
+ });
+ this._abortController = null;
+ const networkTime = Math.round((Date.now() - t0) / 1000);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`);
+ const choice = data.choices?.[0];
+ const message = choice?.message || null;
+ const toolCalls = message?.tool_calls || null;
+ return {
+ message: message ? { role: message.role || 'assistant', content: message.content || '' } : null,
+ tool_calls: toolCalls ? toolCalls.map(tc => ({
+ id: tc.id,
+ type: tc.type || 'function',
+ function: {
+ name: tc.function?.name || '',
+ arguments: tc.function?.arguments || '{}'
+ }
+ })) : null,
+ error: null
+ };
+ }
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ this.terminal.print('⏹ AI request cancelled by user', 'warning');
+ return { message: null, tool_calls: null, error: 'Cancelled' };
+ }
+ if (error.message === 'Failed to fetch' && provider.type === 'ollama') {
+ return await this.chatWithToolsFallback(messages, tools, useModel);
+ }
+ this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error');
+ return { message: null, tool_calls: null, error: error.message };
+ } finally {
+ this._abortController = null;
+ }
+ }
+
+ async chatWithToolsFallback(messages, tools, model, signal) {
+ const provider = this.currentProvider;
+ const useModel = model || (provider ? provider.model : this.currentModel);
+ if (provider && provider.type !== 'ollama') {
+ // For OpenAI-compatible providers, try without tools
+ this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning');
+ try {
+ const headers = { 'Content-Type': 'application/json' };
+ if (provider.apiKey) {
+ headers['Authorization'] = 'Bearer ' + provider.apiKey;
+ }
+ const body = {
+ model: useModel,
+ messages: messages,
+ stream: false
+ };
+ const response = await fetch(`${provider.endpoint}/v1/chat/completions`, {
+ method: 'POST', headers,
+ body: JSON.stringify(body),
+ signal
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data = await response.json();
+ const text = data.choices?.[0]?.message?.content || '';
+ return { message: { role: 'assistant', content: text }, tool_calls: null, error: null };
+ } catch (e) {
+ if (e.name === 'AbortError') {
+ return { message: null, tool_calls: null, error: 'Cancelled' };
+ }
+ return { message: null, tool_calls: null, error: e.message };
+ }
+ }
+
+ // Original Ollama fallback
+ this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning');
+ try {
+ const body = { model: useModel, messages, stream: false };
+ const response = await fetch(`${this.endpoint}/api/chat`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const data = await response.json();
+ const text = data.message?.content || data.response || '';
+ return { message: { role: 'assistant', content: text }, tool_calls: null, error: null };
+ } catch (e) {
+ if (e.name === 'AbortError') {
+ return { message: null, tool_calls: null, error: 'Cancelled' };
+ }
+ return { message: null, tool_calls: null, error: e.message };
+ }
+ }
+
+ // ─── Internal helpers ────────────────────────────────────────────────────
+
+ _getFallbackChain() {
+ const sorted = this._getSortedEnabledProviders();
+ if (sorted.length === 0) return [];
+
+ // Start with the current provider (if any), then all others sorted by priority
+ const chain = [];
+ const seen = new Set();
+
+ if (this.currentProvider && this.currentProvider.enabled) {
+ chain.push(this.currentProvider);
+ seen.add(this.currentProvider.name);
+ }
+
+ for (const p of sorted) {
+ if (!seen.has(p.name)) {
+ chain.push(p);
+ seen.add(p.name);
+ }
+ }
+
+ return chain;
+ }
+
+ // ─── Backward-compat methods ─────────────────────────────────────────────
+
+ setEndpoint(url) {
+ this.endpoint = url;
+ // Also update the current provider's endpoint if it exists
+ if (this.currentProvider) {
+ this.currentProvider.endpoint = url;
+ this._saveProviders();
+ }
+ this.saveSettings();
+ }
+
+ setModel(name) {
+ this.currentModel = name;
+ // Also update the current provider's model
+ if (this.currentProvider) {
+ this.currentProvider.model = name;
+ this._saveProviders();
+ }
+ this.saveSettings();
+ this.terminal.print(`Model set to: ${name}`, 'success');
+ }
+
+ saveSettings() {
+ localStorage.setItem('webx11_ollama_endpoint', this.endpoint);
+ localStorage.setItem('webx11_ollama_model', this.currentModel);
+ }
+
+ 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;
+ }
+}
diff --git a/modules/core/secret-vault.js b/modules/core/secret-vault.js
new file mode 100644
index 0000000..e62192a
--- /dev/null
+++ b/modules/core/secret-vault.js
@@ -0,0 +1,459 @@
+// Extracted from newOS4 index.html — SecretVault
+class SecretVault {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._key = null;
+ this._prefix = 'webx11_secret_';
+ this._saltKey = 'webx11_secret_salt';
+ this._verifyName = 'secret_vault_verified';
+ this._openModeKey = 'webx11_secret_open_key';
+ }
+
+ getHelpInfo() {
+ return {
+ 'secret-set': 'secret-set <name> <value> — Encrypt and store a secret',
+ 'secret-get': 'secret-get <name> — Decrypt and display a secret value',
+ 'secret-list': 'secret-list — List all stored secret names',
+ 'secret-del': 'secret-del <name> — Remove a stored secret',
+ 'secret-lock': 'secret-lock — Lock the vault (clear derived key from memory)',
+ 'secret-unlock': 'secret-unlock <pin> — Unlock the vault with a PIN code',
+ 'secret-setpin': 'secret-setpin <pin> — Set or change the vault PIN code',
+ 'secret-status': 'secret-status — Show vault status (locked/unlocked/pin set/open mode)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['secret-set'] = (args) => this._cmdSet(args);
+ this.commands['secret-get'] = (args) => this._cmdGet(args);
+ this.commands['secret-list'] = () => this._cmdList();
+ this.commands['secret-del'] = (args) => this._cmdDel(args);
+ this.commands['secret-lock'] = () => this._cmdLock();
+ this.commands['secret-unlock'] = (args) => this._cmdUnlock(args);
+ this.commands['secret-setpin'] = (args) => this._cmdSetPin(args);
+ this.commands['secret-status'] = () => this._cmdStatus();
+ }
+
+ // ---------- Core API ----------
+
+ isPinSet() {
+ return localStorage.getItem(this._saltKey) !== null;
+ }
+
+ isLocked() {
+ // Locked only if PIN is set but key is not derived in memory
+ return this.isPinSet() && this._key === null;
+ }
+
+ async setPin(pin) {
+ if (!pin || typeof pin !== 'string' || pin.length === 0) {
+ return { error: 'PIN must be a non-empty string' };
+ }
+
+ // Generate new salt
+ const salt = this._generateSalt();
+ localStorage.setItem(this._saltKey, this._arrayToBase64(salt));
+
+ // Derive new key from the new PIN
+ const newKey = await this._deriveKeyFromPin(pin, salt);
+
+ if (this.isPinSet() && this._key !== null) {
+ // Re-encrypt all existing secrets with new key
+ await this._reEncryptAll(newKey);
+ }
+
+ // Store the new key in memory
+ this._key = newKey;
+
+ // Store a verification token so we know the PIN works
+ await this._encryptWithKey(this._verifyName, 'verified', this._key);
+
+ return { success: true };
+ }
+
+ lock() {
+ this._key = null;
+ return { success: true };
+ }
+
+ async unlock(pin) {
+ if (!pin || typeof pin !== 'string' || pin.length === 0) {
+ return { error: 'PIN must be a non-empty string' };
+ }
+
+ if (!this.isPinSet()) {
+ return { error: 'No PIN has been set. Use secret-setpin first.' };
+ }
+
+ const saltBase64 = localStorage.getItem(this._saltKey);
+ if (!saltBase64) {
+ return { error: 'Salt not found. Vault may be corrupted.' };
+ }
+
+ const salt = this._base64ToArray(saltBase64);
+ const key = await this._deriveKeyFromPin(pin, salt);
+
+ // Verify PIN by checking the verification token
+ const verifyData = await this._decryptWithKey(this._verifyName, key);
+ if (verifyData === null) {
+ return { error: 'Invalid PIN' };
+ }
+
+ this._key = key;
+ return { success: true };
+ }
+
+ async encrypt(name, value) {
+ if (this.isLocked()) {
+ return { error: 'Vault is locked' };
+ }
+
+ // Ensure key is available
+ let key = this._key;
+ if (!key) {
+ // Open mode: use or generate a session-like key
+ key = await this._getOpenModeKey();
+ }
+
+ const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
+ return this._encryptWithKey(name, stringValue, key);
+ }
+
+ async decrypt(name) {
+ if (this.isLocked()) {
+ return null;
+ }
+
+ let key = this._key;
+ if (!key) {
+ key = await this._getOpenModeKey();
+ if (!key) return null;
+ }
+
+ return this._decryptWithKey(name, key);
+ }
+
+ remove(name) {
+ localStorage.removeItem(this._prefix + name);
+ return { success: true };
+ }
+
+ list() {
+ const names = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key.startsWith(this._prefix)) {
+ names.push(key.substring(this._prefix.length));
+ }
+ }
+ return names;
+ }
+
+ has(name) {
+ return localStorage.getItem(this._prefix + name) !== null;
+ }
+
+ /**
+ * Scans text for any stored secret values and replaces them with '[REDACTED]'.
+ * Used by terminal and AI context to prevent leaking secrets in output.
+ */
+ async redactSecret(text) {
+ if (!text || typeof text !== 'string') return text;
+
+ const names = this.list();
+ if (names.length === 0) return text;
+
+ let redacted = text;
+
+ for (const name of names) {
+ try {
+ const value = await this.decrypt(name);
+ if (value && typeof value === 'string' && value.length > 0) {
+ // Escape special regex characters in the secret value
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ try {
+ const regex = new RegExp(escaped, 'g');
+ redacted = redacted.replace(regex, '[REDACTED]');
+ } catch (e) {
+ // If regex fails (e.g., complex Unicode), try manual replacement
+ while (redacted.includes(value)) {
+ redacted = redacted.replace(value, '[REDACTED]');
+ }
+ }
+ }
+ } catch (e) {
+ // Skip secrets that can't be decrypted
+ }
+ }
+
+ return redacted;
+ }
+
+ // ---------- Internal crypto methods ----------
+
+ _generateSalt() {
+ const salt = new Uint8Array(16);
+ crypto.getRandomValues(salt);
+ return salt;
+ }
+
+ _generateIV() {
+ const iv = new Uint8Array(12);
+ crypto.getRandomValues(iv);
+ return iv;
+ }
+
+ _arrayToBase64(array) {
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(array)));
+ }
+
+ _base64ToArray(base64) {
+ const binary = atob(base64);
+ const array = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ array[i] = binary.charCodeAt(i);
+ }
+ return array;
+ }
+
+ async _deriveKeyFromPin(pin, salt) {
+ const encoder = new TextEncoder();
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(pin),
+ 'PBKDF2',
+ false,
+ ['deriveKey']
+ );
+
+ return crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt: salt,
+ iterations: 100000,
+ hash: 'SHA-256',
+ },
+ keyMaterial,
+ {
+ name: 'AES-GCM',
+ length: 256,
+ },
+ false,
+ ['encrypt', 'decrypt']
+ );
+ }
+
+ async _getOpenModeKey() {
+ // In open mode (no PIN set), we use a persistent 32-char hex key in localStorage
+ // This is less secure than PIN mode but better than plaintext
+ let keyHex = localStorage.getItem(this._openModeKey);
+ if (!keyHex) {
+ keyHex = this._generateOpenKey();
+ localStorage.setItem(this._openModeKey, keyHex);
+ }
+ return this._hexKeyToCryptoKey(keyHex);
+ }
+
+ _generateOpenKey() {
+ const bytes = new Uint8Array(32);
+ crypto.getRandomValues(bytes);
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
+ }
+
+ async _hexKeyToCryptoKey(hex) {
+ const bytes = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) {
+ bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
+ }
+ return crypto.subtle.importKey(
+ 'raw',
+ bytes,
+ {
+ name: 'AES-GCM',
+ length: 256,
+ },
+ false,
+ ['encrypt', 'decrypt']
+ );
+ }
+
+ async _encryptWithKey(name, value, key) {
+ try {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(value);
+ const iv = this._generateIV();
+
+ const encrypted = await crypto.subtle.encrypt(
+ {
+ name: 'AES-GCM',
+ iv: iv,
+ },
+ key,
+ data
+ );
+
+ const encryptedArray = new Uint8Array(encrypted);
+ const storageData = JSON.stringify({
+ iv: this._arrayToBase64(iv),
+ data: this._arrayToBase64(encryptedArray),
+ });
+
+ localStorage.setItem(this._prefix + name, storageData);
+ return { success: true };
+ } catch (e) {
+ return { error: 'Encryption failed: ' + e.message };
+ }
+ }
+
+ async _decryptWithKey(name, key) {
+ try {
+ const storageData = localStorage.getItem(this._prefix + name);
+ if (!storageData) return null;
+
+ const parsed = JSON.parse(storageData);
+ if (!parsed.iv || !parsed.data) return null;
+
+ const iv = this._base64ToArray(parsed.iv);
+ const encryptedData = this._base64ToArray(parsed.data);
+
+ const decrypted = await crypto.subtle.decrypt(
+ {
+ name: 'AES-GCM',
+ iv: iv,
+ },
+ key,
+ encryptedData
+ );
+
+ const decoder = new TextDecoder();
+ return decoder.decode(decrypted);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ async _reEncryptAll(newKey) {
+ const names = this.list();
+ const oldKey = this._key;
+
+ for (const name of names) {
+ // Skip verification token — it's regenerated in setPin
+ if (name === this._verifyName) continue;
+
+ const plaintext = await this._decryptWithKey(name, oldKey);
+ if (plaintext !== null) {
+ await this._encryptWithKey(name, plaintext, newKey);
+ }
+ }
+
+ // Update verification token with new key
+ await this._encryptWithKey(this._verifyName, 'verified', newKey);
+ }
+
+ // ---------- Command handlers ----------
+
+ async _cmdSet(args) {
+ if (args.length < 2) {
+ this.terminal.print('Usage: secret-set <name> <value>', 'warning');
+ return;
+ }
+ const name = args[0];
+ const value = args.slice(1).join(' ');
+ const result = await this.encrypt(name, value);
+ if (result.error) {
+ this.terminal.print(result.error, 'error');
+ } else {
+ this.terminal.print(`Secret '${name}' saved`, 'success');
+ }
+ }
+
+ async _cmdGet(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: secret-get <name>', 'warning');
+ return;
+ }
+ const value = await this.decrypt(args[0]);
+ if (value === null) {
+ if (this.isLocked()) {
+ this.terminal.print('Vault is locked. Use secret-unlock <pin>', 'warning');
+ } else {
+ this.terminal.print(`Secret '${args[0]}' not found`, 'error');
+ }
+ } else {
+ this.terminal.print(`Secret '${args[0]}': ${value}`, 'info');
+ }
+ }
+
+ _cmdList() {
+ const names = this.list();
+ if (names.length === 0) {
+ this.terminal.print('No secrets stored', 'info');
+ return;
+ }
+ this.terminal.print('=== Stored Secrets ===', 'info');
+ names.forEach(n => {
+ // Skip internal verification token
+ if (n === this._verifyName) return;
+ this.terminal.print(` - ${n}`);
+ });
+ }
+
+ _cmdDel(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: secret-del <name>', 'warning');
+ return;
+ }
+ const result = this.remove(args[0]);
+ if (result.success) {
+ this.terminal.print(`Secret '${args[0]}' deleted`, 'success');
+ }
+ }
+
+ _cmdLock() {
+ const result = this.lock();
+ if (result.success) {
+ this.terminal.print('Vault locked', 'success');
+ }
+ }
+
+ async _cmdUnlock(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: secret-unlock <pin>', 'warning');
+ return;
+ }
+ const result = await this.unlock(args[0]);
+ if (result.error) {
+ this.terminal.print(result.error, 'error');
+ } else {
+ this.terminal.print('Vault unlocked', 'success');
+ }
+ }
+
+ async _cmdSetPin(args) {
+ if (!args[0]) {
+ this.terminal.print('Usage: secret-setpin <pin>', 'warning');
+ return;
+ }
+ const result = await this.setPin(args[0]);
+ if (result.error) {
+ this.terminal.print(result.error, 'error');
+ } else {
+ this.terminal.print('PIN set successfully. Vault is now in PIN-protected mode.', 'success');
+ }
+ }
+
+ _cmdStatus() {
+ const pinSet = this.isPinSet();
+ const locked = this.isLocked();
+ const hasKey = this._key !== null;
+
+ this.terminal.print('=== Secret Vault Status ===', 'info');
+ this.terminal.print(`PIN set: ${pinSet ? 'Yes' : 'No'}`, pinSet ? 'success' : 'info');
+ if (pinSet) {
+ this.terminal.print(`Vault: ${locked ? '🔒 Locked' : '🔓 Unlocked'}`, locked ? 'warning' : 'success');
+ } else {
+ this.terminal.print(`Mode: Open (no PIN — using persistent key)`, 'info');
+ }
+ this.terminal.print(`Secrets stored: ${this.list().filter(n => n !== this._verifyName).length}`, 'info');
+ }
+}
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
new file mode 100644
index 0000000..0a9538f
--- /dev/null
+++ b/modules/core/terminal.js
@@ -0,0 +1,1362 @@
+// Extracted from newOS4 index.html — WebX11Terminal
+ 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.ollamaManager = new OllamaManager(this);
+ this._pipeInput = null;
+ this._pipeBufferSize = 5000;
+ this._historyBuffer = [];
+ this._historyMaxLines = 50;
+ this.MAX_OUTPUT_LINES = 200;
+ 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.initEventListeners();
+ this.updateNetworkStatus();
+ this.loadBuiltInModules();
+ this._loadHistory();
+ // Auto-restore session
+ this._autoRestoreSession();
+ this._restoreFontSize();
+ // 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);
+ }
+ 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;
+ this.history.push(cmd);
+ 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) {
+ this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length;
+ this.updateAutocompleteDisplay();
+ }
+ } else if (e.key === 'Escape') {
+ if (this._aiBusy) {
+ this.executeCommand('ai-stop');
+ } else {
+ this.hideAutocomplete();
+ }
+ }
+ });
+ this.inputField.addEventListener('input', () => {
+ this.inputField.style.height = 'auto';
+ this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px';
+ this.showAutocomplete();
+ });
+ this.inputField.addEventListener('focus', () => {
+ this.showAutocomplete();
+ });
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('#input-area')) {
+ this.hideAutocomplete();
+ }
+ });
+ window.addEventListener('online', () => this.updateNetworkStatus());
+ window.addEventListener('offline', () => this.updateNetworkStatus());
+ document.getElementById('file-upload-input').addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (evt) => {
+ this.fs.writeFile(file.name, evt.target.result);
+ this.markDirty();
+ this.print(`File '${file.name}' uploaded successfully.`, 'success');
+ };
+ reader.readAsText(file);
+ }
+ e.target.value = '';
+ });
+ // Voice mic button
+ // Sound toggle button
+ const soundBtn = document.getElementById('sound-toggle-btn');
+ if (soundBtn) {
+ soundBtn.addEventListener('click', () => {
+ const current = localStorage.getItem('webx11_config_ai_sound');
+ if (current === 'off') {
+ localStorage.setItem('webx11_config_ai_sound', 'on');
+ soundBtn.textContent = '🔊 Sound';
+ } else {
+ localStorage.setItem('webx11_config_ai_sound', 'off');
+ soundBtn.textContent = '🔇 Muted';
+ }
+ });
+ }
+ // Mic toggle button
+ const micToggleBtn = document.getElementById('voice-toggle-btn');
+ if (micToggleBtn) {
+ micToggleBtn.addEventListener('click', () => this.toggleMicVisibility());
+ }
+ }
+ updateNetworkStatus() {
+ const isOnline = navigator.onLine;
+ this.networkStatus.textContent = isOnline ? '📶 Online' : '❌ Offline';
+ this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--error-color)';
+ }
+ updatePrompt() {
+ const path = '/' + this.fs.currentPath.join('/');
+ const label = document.querySelector('.prompt-label');
+ if (!label) return;
+ if (localStorage.getItem('webx11_smart_prompt') === 'off') {
+ label.textContent = path === '/' ? '$' : path + ' $';
+ return;
+ }
+ const indicators = [];
+ try {
+ const p = localStorage.getItem('webx11_user_profile');
+ if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); }
+ } catch (e) {}
+ try {
+ const idx = this.fs.fsReadJSON('/skills/index.json');
+ if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); }
+ } catch (e) {}
+ try {
+ const memIdx = this.fs.fsReadJSON('/memory/index.json');
+ if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); }
+ } catch (e) {}
+ try {
+ if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
+ indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB');
+ }
+ } catch (e) {}
+ try {
+ const md = this.fs.readDir('_mods');
+ if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); }
+ } catch (e) {}
+ const prefix = indicators.length ? indicators.join(' ') + ' ' : '';
+ label.textContent = prefix + (path === '/' ? '$' : path + ' $');
+ }
+ print(text, type = '') {
+ const line = document.createElement('div');
+ line.className = `output-line ${type}`;
+ line.textContent = text;
+ this.outputContainer.appendChild(line);
+ this._historyBuffer.push({ l: text, t: type });
+ if (this._historyBuffer.length > this._historyMaxLines) {
+ this._historyBuffer.shift();
+ }
+ this._saveHistory();
+ this.scrollToBottom();
+ this._trimOutput();
+ }
+ printAIResponse(text) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'output-line ollama';
+ const safeText = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
+ wrapper.innerHTML = `
+ <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button>
+ <div class="ai-content">${safeText}</div>
+ `;
+ this.outputContainer.appendChild(wrapper);
+ this.scrollToBottom();
+ this._trimOutput();
+ }
+ printStreamingResponse(text, append) {
+ if (!append) {
+ const safeText = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
+ const wrapper = document.createElement('div');
+ wrapper.className = 'output-line ollama';
+ wrapper.innerHTML = `
+ <button class="copy-btn" onclick="webx11.copyToClipboard(this)">Copy</button>
+ <div class="ai-content">${safeText}</div>
+ `;
+ this.outputContainer.appendChild(wrapper);
+ } else {
+ const contentDiv = this.outputContainer.querySelector('.output-line.ollama:last-child .ai-content');
+ if (contentDiv) {
+ const safeText = text.replace(/</g, "&lt;").replace(/>/g, "&gt;");
+ contentDiv.textContent += safeText;
+ }
+ }
+ this.scrollToBottom();
+ this._trimOutput();
+ }
+ copyToClipboard(btn) {
+ const contentDiv = btn.nextElementSibling;
+ const text = contentDiv.innerText;
+ navigator.clipboard.writeText(text).then(() => {
+ const originalText = btn.textContent;
+ btn.textContent = 'Copied!';
+ setTimeout(() => { btn.textContent = originalText; }, 2000);
+ }).catch(err => { console.error('Failed to copy: ', err); });
+ }
+ 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, '&lt;').replace(/>/g, '&gt;');
+ 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();
+ }
+ _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 doBatch = (index) => {
+ if (index >= remaining.length) {
+ 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);
+ });
+ };
+ doBatch(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() {
+ try {
+ const slice = this._historyBuffer.slice(-30);
+ localStorage.setItem('webx11_terminal_output', JSON.stringify(slice));
+ } catch (e) {
+ // Silently ignore storage errors (e.g. quota exceeded)
+ }
+ }
+ _saveCommandHistory() {
+ try {
+ localStorage.setItem('webx11_command_history', JSON.stringify(this.history));
+ } catch (e) {
+ // Silently ignore storage errors
+ }
+ }
+ _trimOutput() {
+ const container = this.outputContainer;
+ while (container.children.length > this.MAX_OUTPUT_LINES) {
+ container.removeChild(container.firstChild);
+ }
+ }
+ markDirty() {
+ this._dirty = true;
+ }
+ _initAutoSave() {
+ if (this._autoSaveTimer) return;
+ this._autoSaveTimer = setInterval(() => {
+ // Auto-trim on memory pressure
+ try {
+ if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
+ const oldMax = this.MAX_OUTPUT_LINES;
+ this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100;
+ this._trimOutput();
+ this.MAX_OUTPUT_LINES = oldMax;
+ this.print('🧹 Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted');
+ }
+ } catch (e) {}
+ if (!this._dirty) return;
+ this._autoSaveSession();
+ this._dirty = false;
+ }, 180000);
+ }
+ _autoSaveSession() {
+ try {
+ this.fs.mkdirRecursive('_sessions');
+ const snapshot = {
+ type: 'session',
+ savedAt: new Date().toISOString(),
+ fsRoot: this.fs.root,
+ currentPath: this.fs.currentPath,
+ version: 1,
+ terminalLines: this._historyBuffer.slice(-50)
+ };
+ this.fs.writeFile('_sessions/_auto.json', JSON.stringify(snapshot, null, 2));
+ // Also persist AI context concurrently
+ 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 {
+ this.fs.mkdirRecursive('_sessions');
+ const autoSession = this.fs.readFile('_sessions/_auto.json');
+ if (autoSession) {
+ const data = JSON.parse(autoSession);
+ if (data && data.fsRoot) {
+ this.fs.root = data.fsRoot;
+ this.fs.currentPath = data.currentPath || [];
+ this.fs.saveToStorage();
+ this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success');
+ // Defer AI context restoration (AI module loads after terminal init)
+ try {
+ const aiCtx = this.fs.readFile('_sessions/_auto-context.json');
+ if (aiCtx) {
+ localStorage.setItem('webx11_ai_context', aiCtx);
+ }
+ } catch (e) {}
+ }
+ }
+ } catch (e) {
+ // Silent on first boot — no auto-session yet
+ }
+ }
+ scrollToBottom() {
+ this.outputContainer.scrollTop = this.outputContainer.scrollHeight;
+ }
+ showAutocomplete() {
+ const input = this.inputField.value;
+ const trimmed = input.trim();
+ const commands = this.moduleManager.getAllCommands();
+ this.suggestions = [];
+ this.selectedSuggestion = -1;
+ this._isCdAutocomplete = false;
+
+ if (trimmed.length === 0) {
+ this.hideAutocomplete();
+ return;
+ }
+
+ const parts = input.split(' ');
+ 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'];
+
+ // 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;
+ }
+
+ // 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);
+ } else {
+ this.hideAutocomplete();
+ return;
+ }
+
+ this._autocompletePrefix = cmdPart + ' ';
+ if (this.suggestions.length > 0) {
+ this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir');
+ this.renderAutocomplete();
+ } else {
+ this.hideAutocomplete();
+ }
+ return;
+ }
+
+ // 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);
+ } else {
+ this.hideAutocomplete();
+ return;
+ }
+ this._autocompletePrefix = cmdPart + ' ';
+ if (this.suggestions.length > 0) {
+ this._isCdAutocomplete = (cmdPart === 'cd' || cmdPart === 'rmdir');
+ this.renderAutocomplete();
+ } else {
+ this.hideAutocomplete();
+ }
+ return;
+ }
+
+ // Directory autocomplete for bare 'cd ' (no partial yet) — should be unreachable due to above block
+ if (lowerInput.startsWith('cd ')) {
+ const dirs = this.fs.ls().filter(f => f.type === 'dir');
+ this.suggestions = dirs.map(d => d.name + '/');
+ if (this.suggestions.length > 0) {
+ this._isCdAutocomplete = true;
+ this.renderAutocomplete();
+ } else {
+ this.hideAutocomplete();
+ }
+ return;
+ }
+
+ // Command name autocomplete (first word only) — also clears any stale prefix
+ this._autocompletePrefix = '';
+ for (const cmd in commands) {
+ if (cmd.startsWith(cmdPart) && cmd !== cmdPart) {
+ this.suggestions.push(cmd);
+ }
+ }
+
+ if (this.suggestions.length > 0) {
+ this.renderAutocomplete();
+ } else {
+ this.hideAutocomplete();
+ }
+ }
+ renderAutocomplete() {
+ this.dropdown.innerHTML = '';
+ this.suggestions.forEach((item, index) => {
+ const div = document.createElement('div');
+ div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`;
+ div.innerHTML = this._isCdAutocomplete
+ ? `<span class="cmd-name" style="color:var(--accent-color)">${item}</span>`
+ : `<span class="cmd-name">${item}</span>`;
+ div.onclick = () => {
+ if (this._autocompletePrefix) {
+ this.inputField.value = this._autocompletePrefix + item;
+ } else if (this._isCdAutocomplete) {
+ this.inputField.value = 'cd ' + item;
+ } else {
+ this.inputField.value = item + ' ';
+ }
+ this.hideAutocomplete();
+ this.inputField.focus();
+ };
+ this.dropdown.appendChild(div);
+ });
+ this.dropdown.style.display = 'block';
+ }
+ updateAutocompleteDisplay() {
+ const items = this.dropdown.querySelectorAll('.autocomplete-item');
+ items.forEach((item, index) => {
+ item.classList.toggle('selected', index === this.selectedSuggestion);
+ });
+ if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) {
+ if (this._autocompletePrefix) {
+ this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion];
+ } else if (this._isCdAutocomplete) {
+ this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion];
+ } else {
+ this.inputField.value = this.suggestions[this.selectedSuggestion] + ' ';
+ }
+ }
+ }
+ hideAutocomplete() {
+ this.dropdown.style.display = 'none';
+ this.suggestions = [];
+ this.selectedSuggestion = -1;
+ this._isCdAutocomplete = false;
+ this._autocompletePrefix = '';
+ }
+ loadBuiltInModules() {
+ this._safeRegister('SystemModule', 'system');
+ this._safeRegister('FileSystemModule', 'filesystem');
+ this._safeRegister('ModuleManagerModule', 'module manager');
+ this._safeRegister('OllamaModule', 'ollama');
+ this._safeRegister('SearXNGModule', 'searxng search');
+ this._safeRegister('ConfigManagerModule', 'config');
+ this._safeRegister('WebX11AIAssistantModule', 'AI assistant');
+ this._safeRegister('WebFetcherModule', 'web fetcher');
+ this._safeRegister('WebXAIAliasModule', 'AI alias');
+ this._safeRegister('SkillsModule', 'skills');
+ this._safeRegister('SecretVault', 'secret vault');
+ this.reloadPersistedModules();
+ this._safeRegister('ChatStudioModule', 'chat studio');
+ }
+ _safeRegister(className, label) {
+ let cls;
+ try {
+ cls = eval(className);
+ } catch (e) {
+ console.log(`[SafeRegister] Skipped ${label}: class not loaded (skipped in boot selection)`);
+ return;
+ }
+ if (typeof cls === 'undefined') {
+ console.log(`[SafeRegister] Skipped ${label}: class not defined`);
+ return;
+ }
+ try { this.moduleManager.registerModule(new cls()); }
+ catch (e) { console.warn(`[SafeRegister] Skipped ${label}: ${e.message}`); }
+ }
+ async _loadExtModules(filterKeys) {
+ const autoLoadKeys = filterKeys || ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder'];
+ try {
+ const res = await fetch('packages.json');
+ 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);
+ }
+ }
+ } catch (err) {
+ this.print(`Failed to auto-load ext modules: ${err.message}`, 'error');
+ }
+ }
+ async reloadPersistedModules() {
+ const registry = this.moduleManager.moduleRegistry;
+ const skipList = ['calc', 'voice', 'cron', 'theme', 'wpm', 'mindmap', 'webdav', 'skill-pack-core', 'editor', 'diff', 'alias', 'finder'];
+ 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();
+ this.print(`webx11@module:~$ ${rawInput}`, 'command-prompt');
+ const pipeParts = this.splitPipe(rawInput.trim());
+ if (pipeParts.length > 1) {
+ 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;
+ }
+ }
+ }
+ splitPipe(input) {
+ const parts = [];
+ let current = '';
+ let inQuote = false;
+ for (const ch of input) {
+ if (ch === '"' || ch === "'") { inQuote = !inQuote; current += ch; }
+ else if (ch === '|' && !inQuote) { parts.push(current.trim()); current = ''; }
+ else { current += ch; }
+ }
+ parts.push(current.trim());
+ return parts;
+ }
+ async executePipeChain(pipeParts) {
+ let pipeInput = null;
+ for (let i = 0; i < pipeParts.length; i++) {
+ const cmdText = pipeParts[i];
+ const parts = cmdText.split(/\s+/);
+ const cmd = parts[0].toLowerCase();
+ const args = parts.slice(1);
+ this._pipeInput = pipeInput;
+ if (i < pipeParts.length - 1) {
+ pipeInput = await this.capturePipeOutput(cmd, args);
+ } else {
+ await this.dispatchCommand(cmd, args);
+ }
+ }
+ this._pipeInput = null;
+ }
+ async capturePipeOutput(cmd, args) {
+ const originalPrint = this.print.bind(this);
+ let output = '';
+ this.print = (text, type) => { output += text + '\n'; };
+ await this.dispatchCommand(cmd, args);
+ this.print = originalPrint;
+ const trimmed = output.trim();
+ if (trimmed.length > this._pipeBufferSize) {
+ return trimmed.slice(0, this._pipeBufferSize);
+ }
+ return trimmed;
+ }
+ async dispatchCommand(cmd, args) {
+ if (cmd === 'clear') { this.outputContainer.innerHTML = ''; this._historyBuffer = []; localStorage.removeItem('webx11_terminal_output'); return; }
+ 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); return; }
+ 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');
+ }
+ this.print(`Command not found: ${cmd}`, 'error');
+ }
+ openFileBrowser() {
+ const modal = document.getElementById('file-browser');
+ const content = document.getElementById('fb-content');
+ content.innerHTML = '';
+ const files = this.fs.ls();
+ if (files.length === 0) {
+ content.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Directory Empty</div>';
+ } else {
+ files.forEach(f => {
+ const div = document.createElement('div');
+ div.className = `file-item ${f.type === 'dir' ? 'dir' : ''}`;
+ div.innerHTML = `<span>${f.type === 'dir' ? '📁' : '📄'} ${f.name}</span> <span style="font-size:0.8em">${f.size}b</span>`;
+ div.onclick = () => {
+ if (f.type === 'file') {
+ const content = this.fs.readFile(f.name);
+ this.print(`--- ${f.name} ---`);
+ this.print(content);
+ this.print('--- End ---');
+ this.closeFileBrowser();
+ }
+ };
+ content.appendChild(div);
+ });
+ }
+ modal.style.display = 'flex';
+ }
+ closeFileBrowser() {
+ document.getElementById('file-browser').style.display = 'none';
+ this.inputField.focus();
+ }
+ openModuleManager() {
+ const modal = document.getElementById('module-manager');
+ this.renderModuleList();
+ modal.style.display = 'flex';
+ }
+ closeModuleManager() {
+ document.getElementById('module-manager').style.display = 'none';
+ this.inputField.focus();
+ }
+ renderModuleList() {
+ const list = document.getElementById('module-list');
+ list.innerHTML = '';
+ const modules = this.moduleManager.getModuleList();
+ if (modules.length === 0) {
+ list.innerHTML = '<div class="text-center" style="padding:20px; color:#666">No modules loaded</div>';
+ return;
+ }
+ modules.forEach(mod => {
+ const div = document.createElement('div');
+ div.className = `module-item ${mod.loaded ? '' : 'disabled'}`;
+ const badge = mod.url ? '' : '<span class="module-badge builtin">Built-in</span>';
+ div.innerHTML = `
+ <div>
+ <strong>${mod.name}</strong>${badge}
+ <div style="font-size:0.8em; color:#666">${mod.url || 'Built-in'}</div>
+ </div>
+ ${mod.url ? `<button class="btn btn-danger" onclick="webx11.removeModule('${mod.name}')" style="padding:4px 8px;">✕</button>` : ''}
+ `;
+ list.appendChild(div);
+ });
+ }
+ async loadModuleFromURL() {
+ const url = document.getElementById('module-url-input').value.trim();
+ if (!url) { alert('Please enter a URL'); return; }
+ const success = await this.moduleManager.loadModuleFromURL(url);
+ if (success) {
+ document.getElementById('module-url-input').value = '';
+ this.renderModuleList();
+ }
+ }
+ removeModule(name) {
+ if (this.moduleManager.unregisterModule(name)) {
+ this.print(`Module '${name}' removed`, 'success');
+ this.renderModuleList();
+ }
+ }
+ clearAllModules() {
+ if (confirm('Remove all custom modules? Built-in modules will remain.')) {
+ this.moduleManager.clearAll();
+ this.renderModuleList();
+ this.print('All custom modules cleared', 'success');
+ }
+ }
+ openOllamaManager() {
+ const modal = document.getElementById('ollama-manager');
+ document.getElementById('ollama-endpoint').value = this.ollamaManager.endpoint;
+ this.updateOllamaStatus();
+ this.renderOllamaModels();
+ modal.style.display = 'flex';
+ }
+ closeOllamaManager() {
+ document.getElementById('ollama-manager').style.display = 'none';
+ this.inputField.focus();
+ }
+ openPreview(args) {
+ let path = '';
+ if (typeof args === 'string') path = args.trim();
+ else if (Array.isArray(args)) path = args.join(' ').trim();
+ if (!path) {
+ this.print('Usage: preview <file.html>', 'warning');
+ return;
+ }
+ // Remove leading slash for VFS paths
+ const vfsPath = path.replace(/^\/*/, '');
+ let content = this.fs.readFile(vfsPath);
+ if (content === null) content = this.fs.fsRead('/' + 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();
+ }
+ openSkillsManager() {
+ const modal = document.getElementById('skills-manager');
+ // Ensure /skills/ directory exists
+ const skillsMod = this.moduleManager.modules['SkillsModule'];
+ if (skillsMod && skillsMod.ensureSkillsDir) skillsMod.ensureSkillsDir();
+ this.refreshSkillsList();
+ modal.style.display = 'flex';
+ }
+ closeSkillsManager() {
+ document.getElementById('skills-manager').style.display = 'none';
+ this.inputField.focus();
+ }
+ openConfigManager() {
+ document.getElementById('config-manager').style.display = 'flex';
+ this.renderConfigList();
+ }
+ closeConfigManager() {
+ document.getElementById('config-manager').style.display = 'none';
+ this.inputField.focus();
+ }
+ renderConfigList() {
+ const list = document.getElementById('config-list');
+ if (!list) return;
+ const prefix = 'webx11_config_';
+ const entries = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith(prefix)) {
+ entries.push({ key: key.replace(prefix, ''), value: localStorage.getItem(key) });
+ }
+ }
+ if (entries.length === 0) {
+ list.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No configuration saved.</div>';
+ return;
+ }
+ list.innerHTML = entries.map(e => {
+ const displayVal = e.key.toLowerCase().includes('password') || e.key.toLowerCase().includes('token') || e.key.toLowerCase().includes('secret')
+ ? '****' : e.value;
+ return `<div class="module-item">
+ <div><strong>${e.key}</strong> <span style="color:var(--text-muted)">${displayVal}</span></div>
+ <button class="btn btn-danger" onclick="webx11.deleteConfigFromUI('${e.key.replace(/'/g, "\\'")}')" style="padding:4px 8px; font-size:0.8em">✕</button>
+ </div>`;
+ }).join('');
+ }
+ addConfigFromUI() {
+ const key = document.getElementById('config-key-input').value.trim();
+ const value = document.getElementById('config-value-input').value.trim();
+ if (!key || !value) {
+ this.print('Enter both key and value', 'warning');
+ return;
+ }
+ localStorage.setItem('webx11_config_' + key, value);
+ document.getElementById('config-key-input').value = '';
+ document.getElementById('config-value-input').value = '';
+ this.print(`Config saved: ${key} = ${value}`, 'success');
+ this.renderConfigList();
+ }
+ deleteConfigFromUI(key) {
+ localStorage.removeItem('webx11_config_' + key);
+ this.print(`Config '${key}' deleted`, 'success');
+ this.renderConfigList();
+ }
+ exportConfigFromUI() {
+ const mod = this.moduleManager.modules['ConfigManagerModule'];
+ if (mod && mod.commands && mod.commands['config-export']) {
+ mod.commands['config-export']();
+ }
+ }
+ resetConfigFromUI() {
+ const mod = this.moduleManager.modules['ConfigManagerModule'];
+ if (mod && mod.commands && mod.commands['config-reset']) {
+ mod.commands['config-reset']();
+ this.renderConfigList();
+ }
+ }
+ refreshSkillsList() {
+ const list = document.getElementById('skills-list');
+ list.innerHTML = '';
+ const fs = this.fs;
+ fs.mkdirRecursive('/skills');
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ const names = Object.keys(index);
+ if (names.length === 0) {
+ list.innerHTML = '<div style="padding:20px; text-align:center; color:#666">No skills yet. Create one below!</div>';
+ return;
+ }
+ names.forEach(name => {
+ const s = index[name];
+ const tags = (s.tags || []).join(', ');
+ const div = document.createElement('div');
+ div.className = 'module-item';
+ div.innerHTML = `
+ <div>
+ <strong style="color:var(--success-color)">${name}</strong>
+ <div style="font-size:0.85em; color:#aaa">${s.description || ''}</div>
+ <div style="font-size:0.75em; color:#666">${s.category || 'general'}${tags ? ' · ' + tags : ''}</div>
+ </div>
+ <div style="display:flex;gap:4px">
+ <button class="btn" onclick="webx11.viewSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">👁</button>
+ <button class="btn btn-danger" onclick="webx11.deleteSkillUI('${name}')" style="padding:4px 8px; font-size:0.8em">✕</button>
+ </div>
+ `;
+ list.appendChild(div);
+ });
+ }
+ viewSkillUI(name) {
+ const fs = this.fs;
+ const content = fs.fsRead(`/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 = 'block';
+ }
+ closeSkillPreview() {
+ document.getElementById('skill-preview').style.display = 'none';
+ }
+ deleteSkillUI(name) {
+ if (!confirm(`Delete skill "${name}"?`)) return;
+ const fs = this.fs;
+ fs.mkdirRecursive('/skills');
+ fs.fsWrite(`/skills/${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();
+ }
+ createSkillFromUI() {
+ const name = document.getElementById('skill-name-input').value.trim().replace(/\s+/g, '-').toLowerCase();
+ const description = document.getElementById('skill-desc-input').value.trim() || 'No description';
+ const tags = document.getElementById('skill-tags-input').value.trim().split(',').map(t => t.trim()).filter(Boolean);
+ const contentInput = document.getElementById('skill-content-input').value.trim();
+ if (!name) { alert('Skill name is required'); return; }
+ const fs = this.fs;
+ fs.mkdirRecursive('/skills');
+ if (fs.fsRead(`/skills/${name}.md`)) {
+ alert(`Skill '${name}' already exists. Delete it first or use skill-patch command.`);
+ return;
+ }
+ let content = contentInput || `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n## Steps\n1. (to be filled)\n`;
+ if (!content.startsWith('---')) {
+ content = `---\nname: ${name}\ndescription: ${description}\ntags: [${tags.join(', ')}]\ncategory: general\n---\n${content}`;
+ }
+ fs.fsWrite(`/skills/${name}.md`, content);
+ const index = fs.fsReadJSON('/skills/index.json') || {};
+ index[name] = { name, description, category: 'general', tags };
+ fs.fsWriteJSON('/skills/index.json', index);
+ this.print(`Skill '${name}' created`, 'success');
+ document.getElementById('skill-name-input').value = '';
+ document.getElementById('skill-desc-input').value = '';
+ document.getElementById('skill-tags-input').value = '';
+ document.getElementById('skill-content-input').value = '';
+ this.refreshSkillsList();
+ }
+ updateOllamaStatus() {
+ const statusDiv = document.getElementById('ollama-status');
+ const statusText = document.getElementById('ollama-status-text');
+ if (this.ollamaManager.connected) {
+ statusDiv.className = 'ollama-status connected';
+ statusText.textContent = `Connected (${this.ollamaManager.models.length} models)`;
+ } else {
+ statusDiv.className = 'ollama-status disconnected';
+ statusText.textContent = 'Disconnected';
+ }
+ }
+ renderOllamaModels() {
+ const container = document.getElementById('ollama-models');
+ const select = document.getElementById('ollama-current-model');
+ container.innerHTML = '';
+ select.innerHTML = '<option value="">Select a model</option>';
+ const models = this.ollamaManager.getModels();
+ if (models.length === 0) {
+ container.innerHTML = '<div class="text-center" style="padding:20px; color:#666">Connect to see models</div>';
+ return;
+ }
+ models.forEach(model => {
+ const div = document.createElement('div');
+ div.className = 'model-item';
+ div.innerHTML = `<span>${model.name}</span><span style="font-size:0.8em; color:#666">${model.size ? (model.size / 1e9).toFixed(1) + 'GB' : ''}</span>`;
+ div.onclick = () => {
+ this.ollamaManager.setModel(model.name);
+ select.value = model.name;
+ };
+ container.appendChild(div);
+ const option = document.createElement('option');
+ option.value = model.name;
+ option.textContent = model.name;
+ if (model.name === this.ollamaManager.currentModel) { option.selected = true; }
+ select.appendChild(option);
+ });
+ }
+ async connectOllama() {
+ const endpoint = document.getElementById('ollama-endpoint').value.trim();
+ this.ollamaManager.setEndpoint(endpoint);
+ const success = await this.ollamaManager.connect();
+ this.updateOllamaStatus();
+ if (success) { this.renderOllamaModels(); }
+ }
+ setOllamaModel() {
+ const select = document.getElementById('ollama-current-model');
+ this.ollamaManager.setModel(select.value);
+ }
+ 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-modmgr.js','module-ollama.js',
+ 'module-my.js','module-searxng.js','module-servermon.js','module-ollamamodels.js',
+ 'module-config.js','module-simplegui.js','module-io.js','module-dynamicprompts.js',
+ 'module-aiassist.js','module-aiasm.js','module-smartctx.js','module-duckduckgo.js',
+ 'module-fetcher.js','module-skills.js','module-chatstudio.js'
+ ];
+ const extFiles = [
+ 'calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','skill-pack-core.js'
+ ];
+
+ const addSection = (title) => {
+ const h = document.createElement('div');
+ h.className = 'mod-section-header';
+ h.textContent = title;
+ body.appendChild(h);
+ };
+ const addCard = (name, badge, isDisabled) => {
+ const card = document.createElement('div');
+ card.className = 'mod-card' + (isDisabled ? ' disabled' : '');
+ const label = document.createElement('label');
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = isDisabled || isSel(name);
+ cb.disabled = isDisabled;
+ cb.dataset.name = name;
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'mod-name';
+ nameSpan.textContent = name;
+ const badgeSpan = document.createElement('span');
+ badgeSpan.className = 'mod-badge' + (badge === 'foundation' ? ' foundation' : badge === 'ext' ? ' ext' : '');
+ badgeSpan.textContent = badge;
+ label.appendChild(cb);
+ label.appendChild(nameSpan);
+ card.appendChild(label);
+ card.appendChild(badgeSpan);
+ body.appendChild(card);
+ };
+
+ addSection('Foundation (always loaded)');
+ foundation.forEach(n => addCard(n, 'foundation', true));
+
+ addSection('Built-in modules');
+ builtin.forEach(n => addCard(n, 'built-in', false));
+
+ addSection('Extensions');
+ extFiles.forEach(n => addCard(n, 'ext', false));
+ }
+ saveStartupModules() {
+ const cbs = document.querySelectorAll('#startup-modules-body input[type="checkbox"]:not(:disabled)');
+ const selected = [];
+ cbs.forEach(cb => {
+ if (cb.checked) selected.push(cb.dataset.name);
+ });
+ try {
+ localStorage.setItem('webx11_startup_modules', JSON.stringify(selected));
+ } catch (e) {
+ this.print('Failed to save startup modules', 'error');
+ return;
+ }
+ this.print('Startup modules saved, reloading...', 'success');
+ setTimeout(() => location.reload(), 500);
+ }
+ openCodeEditor(name, url, code) {
+ const modal = document.getElementById('code-editor');
+ const title = document.getElementById('code-editor-title');
+ const info = document.getElementById('code-editor-info');
+ const ta = document.getElementById('code-editor-textarea');
+ if (!modal || !ta) return;
+ this._editingModule = { name: name, url: url };
+ title.textContent = '📝 Edit: ' + name;
+ info.textContent = 'Module: ' + name + (url ? ' | Source: ' + url : '');
+ ta.value = code;
+ modal.style.display = 'flex';
+ }
+ closeCodeEditor() {
+ document.getElementById('code-editor').style.display = 'none';
+ this._editingModule = null;
+ }
+ async loadLocalModule(path) {
+ if (!path) {
+ this.print('Usage: load-local <path>', 'warning');
+ this.print('Loads a module from VFS (e.g. "load-local modules/ext/mindmap.js") or from URL', 'info');
+ return;
+ }
+ let code = this.fs.readFile(path);
+ if (code === null) {
+ try {
+ const resp = await fetch(path);
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ code = await resp.text();
+ } catch (e) {
+ this.print('load-local: file not found in VFS or at URL: ' + path, 'error');
+ return;
+ }
+ }
+ try {
+ const el = document.createElement('script');
+ el.textContent = code;
+ el.onerror = () => this.print('load-local: failed to execute ' + path, 'error');
+ document.body.appendChild(el);
+ this.print('Module loaded from ' + path, 'success');
+ } catch (e) {
+ this.print('load-local: error: ' + e.message, 'error');
+ }
+ }
+ async saveCodeEditor() {
+ const ta = document.getElementById('code-editor-textarea');
+ const mod = this._editingModule;
+ if (!mod || !ta) return;
+ const code = ta.value;
+ this.fs.writeFile('_mods/' + mod.name + '.js', code);
+ if (!this.fs.readFile('_mods/' + mod.name + '.orig.js')) {
+ this.fs.writeFile('_mods/' + mod.name + '.orig.js', 'placeholder');
+ }
+ this.fs.writeFile('_mods/' + mod.name + '.meta.json', JSON.stringify({
+ originalUrl: mod.url || '',
+ modifiedAt: new Date().toISOString(), editCount: 1
+ }));
+ try {
+ await this.moduleManager.loadModuleFromCode(code, 'blob://_mods/' + mod.name + '.js');
+ this.print('✅ Module loaded live from _mods/' + mod.name + '.js', 'success');
+ } 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.documentElement.style.fontSize = s + 'px';
+ }
+ } catch (e) {}
+ }
+ log(level, source, message) {
+ const d = new Date();
+ const ts = d.toISOString().split('T')[1].split('.')[0];
+ const entry = `[${ts}][${level}][${source}] ${message}`;
+ console.log(entry);
+ // Write to VFS log
+ try {
+ this.fs.mkdirRecursive('/logs');
+ let current = this.fs.readFile('/logs/current.log') || '';
+ current += entry + '\n';
+ const lines = current.split('\n').filter(l => l);
+ if (lines.length > 200) {
+ this.fs.writeFile('/logs/old.log', lines.slice(0, -100).join('\n'));
+ this.fs.writeFile('/logs/current.log', lines.slice(-100).join('\n'));
+ } else {
+ this.fs.writeFile('/logs/current.log', current);
+ }
+ } catch (e) {}
+ }
+ setAiBusy(busy) {
+ this._aiBusy = !!busy;
+ if (this._aiBusyInterval) {
+ clearInterval(this._aiBusyInterval);
+ this._aiBusyInterval = null;
+ }
+ const cancelBtn = document.getElementById('ai-cancel-btn');
+ if (busy) {
+ if (cancelBtn) cancelBtn.style.display = 'inline-block';
+ this._aiBusyStart = Date.now();
+ const updateTimer = () => {
+ let elapsed = Math.round((Date.now() - this._aiBusyStart) / 1000);
+ let sec = elapsed % 60;
+ let min = Math.floor(elapsed / 60);
+ this.statusBar.textContent = '🤖 AI Thinking... ⏱ ' + (min > 0 ? min + 'm ' : '') + sec + 's';
+ this.statusBar.className = 'loading';
+ };
+ updateTimer();
+ this._aiBusyInterval = setInterval(updateTimer, 1000);
+ } else {
+ if (cancelBtn) cancelBtn.style.display = 'none';
+ this.statusBar.textContent = 'Ready';
+ this.statusBar.className = '';
+ this._aiBusyStart = 0;
+ }
+ }
+ printMemoryStats() {
+ const pm = typeof performance !== 'undefined' && performance.memory;
+ let heapInfo = '';
+ if (pm) {
+ const used = (pm.usedJSHeapSize / 1048576).toFixed(1);
+ const total = (pm.totalJSHeapSize / 1048576).toFixed(1);
+ const limit = (pm.jsHeapSizeLimit / 1048576).toFixed(1);
+ heapInfo = `JS heap: ${used}MB / ${total}MB (limit ${limit}MB)`;
+ } else {
+ try {
+ const lsSize = (new Blob([JSON.stringify(localStorage)]).size / 1024).toFixed(1);
+ heapInfo = `localStorage: ${lsSize}KB`;
+ } catch (e) {
+ heapInfo = 'Memory API not available on this browser';
+ }
+ }
+ const output = [
+ `📊 Memory Stats`,
+ ` ${heapInfo}`,
+ ` Terminal lines in DOM: ${this.outputContainer.children.length}`,
+ ` History buffer: ${this._historyBuffer.length} entries`,
+ ` MAX_OUTPUT_LINES: ${this.MAX_OUTPUT_LINES}`,
+ ` Commands in history: ${this.history.length}`
+ ].join('\n');
+ this.print(output, 'info');
+ }
+ playNotificationSound() {
+ if (localStorage.getItem('webx11_config_ai_sound') === 'off') return;
+ try {
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.frequency.value = 880;
+ osc.type = 'sine';
+ gain.gain.setValueAtTime(0.15, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
+ osc.start(ctx.currentTime);
+ osc.stop(ctx.currentTime + 0.3);
+ } catch (e) {}
+ }
+ toggleMicVisibility() {
+ const voiceMod = this.moduleManager.modules['VoiceModule'];
+ if (!voiceMod) {
+ this.print('Voice module not loaded', 'warning');
+ return;
+ }
+ if (voiceMod.listening) {
+ voiceMod.stopListening();
+ } else {
+ voiceMod.handle([]);
+ }
+ }
+ setFontSize(size) {
+ const s = parseInt(size);
+ if (isNaN(s) || s < 8 || s > 32) {
+ this.print('Usage: font-size <8-32>', 'warning');
+ return;
+ }
+ document.documentElement.style.fontSize = s + 'px';
+ try { localStorage.setItem('webx11_font_size', String(s)); } catch (e) {}
+ this.print('Font size set to ' + s + 'px', 'success');
+ }
+ }
diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js
new file mode 100644
index 0000000..a505699
--- /dev/null
+++ b/modules/core/virtual-fs.js
@@ -0,0 +1,320 @@
+class VirtualFileSystem {
+ constructor() {
+ this.root = { type: 'dir', content: {} };
+ this.currentPath = [];
+ this.loadFromStorage();
+ }
+ loadFromStorage() {
+ try {
+ const saved = localStorage.getItem('webx11_filesystem');
+ if (saved) {
+ const data = JSON.parse(saved);
+ this.root = data.root || this.root;
+ this.currentPath = data.currentPath || [];
+ }
+ } catch (e) { console.error('FS load error:', e); }
+ }
+ saveToStorage() {
+ try {
+ localStorage.setItem('webx11_filesystem', JSON.stringify({
+ root: this.root, currentPath: this.currentPath
+ }));
+ } catch (e) { console.error('FS save error:', e); }
+ }
+ 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) {
+ 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) {
+ 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 [];
+ return Object.keys(dir.content).map(name => ({
+ name, type: dir.content[name].type,
+ size: dir.content[name].content ? dir.content[name].content.length : 0
+ }));
+ }
+ writeFile(path, content) {
+ 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();
+ 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.type === 'file') return node.content;
+ return null;
+ }
+ deleteFile(path) {
+ const resolved = this._resolvePath(path);
+ 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();
+ return true;
+ }
+ return false;
+ }
+ mkdir(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;
+ }
+ mkdirRecursive(path) {
+ const segs = this._toAbsoluteSegments(path);
+ if (!segs || segs.length === 0) return true;
+ 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();
+ 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);
+ 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);
+ if (!dst || !dst.name) return false;
+ const copy = JSON.parse(JSON.stringify(srcNode));
+ dst.dir.content[dst.name] = copy;
+ this.saveToStorage();
+ return true;
+ }
+ copyDir(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;
+ 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;
+ }
+ }
+ 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;
+ }
+ }
+ return false;
+ }
+ // Old-compat aliases for agent tools and SkillsModule
+ fsRead(path) {
+ const content = this.readFile(path);
+ return content !== null ? content : null;
+ }
+ fsWrite(path, content) {
+ return this.writeFile(path, content);
+ }
+ fsReadJSON(path) {
+ const c = this.fsRead(path);
+ if (c === null) return null;
+ try { return JSON.parse(c); } catch (e) { return null; }
+ }
+ fsWriteJSON(path, obj) {
+ return this.fsWrite(path, JSON.stringify(obj));
+ }
+ findFiles(pattern) {
+ 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);
+ }
+ }
+ };
+ search(this.root, '');
+ return results.sort();
+ }
+ deleteDirRecursive(path) {
+ 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];
+ this.saveToStorage();
+ return true;
+ }
+ // Full state serialization for session save/restore
+ serializeState() {
+ return JSON.stringify({
+ root: this.root,
+ currentPath: this.currentPath,
+ version: 1
+ });
+ }
+ 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;
+ }
+ }
+ // 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;
+ this.root = content;
+ this.currentPath = [];
+ this.saveToStorage();
+ return true;
+ }
+ _snapshotDirExists() {
+ return !!this.readDir('/_snapshots');
+ }
+ checkpoint(name) {
+ if (!name) name = new Date().toISOString();
+ const createdAt = new Date().toISOString();
+ const data = {
+ version: 1,
+ createdAt,
+ root: JSON.parse(JSON.stringify(this.root)),
+ currentPath: [...this.currentPath]
+ };
+ if (!this.readDir('/_snapshots')) {
+ this.mkdirRecursive('/_snapshots');
+ }
+ this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data);
+ this.saveToStorage();
+ // Auto-manage: keep max 10 checkpoints, delete oldest
+ const all = this.listCheckpoints();
+ if (all.length > 10) {
+ const sorted = all.slice().sort();
+ const toDelete = sorted.slice(0, all.length - 10);
+ for (const old of toDelete) this.deleteCheckpoint(old);
+ }
+ return true;
+ }
+ rollback(name) {
+ const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json');
+ if (!raw || !raw.root) return false;
+ this.root = raw.root;
+ this.currentPath = raw.currentPath || [];
+ this.saveToStorage();
+ return true;
+ }
+ 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();
+ }
+ deleteCheckpoint(name) {
+ return this.deleteFile('/_snapshots/checkpoint-' + name + '.json');
+ }
+}