aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--BUGFIX-SUGGESTIONS.md355
-rw-r--r--download-newos4.html88
-rw-r--r--help.html926
-rw-r--r--index.html810
-rw-r--r--index.html.pre-refactor35
-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
-rw-r--r--modules/ext/alias.js108
-rw-r--r--modules/ext/calc.js77
-rw-r--r--modules/ext/cron.js110
-rw-r--r--modules/ext/diff.js287
-rw-r--r--modules/ext/editor.js70
-rw-r--r--modules/ext/filemgr.js755
-rw-r--r--modules/ext/finder.js47
-rw-r--r--modules/ext/mindmap.js217
-rw-r--r--modules/ext/skill-pack-core.js285
-rw-r--r--modules/ext/theme.js172
-rw-r--r--modules/ext/voice.js102
-rw-r--r--modules/ext/webdav.js532
-rw-r--r--modules/ext/wpm.js149
-rw-r--r--packages.json112
-rw-r--r--packages.json.orig168
-rw-r--r--space.yaml14
-rw-r--r--summary.txt14
47 files changed, 13101 insertions, 0 deletions
diff --git a/BUGFIX-SUGGESTIONS.md b/BUGFIX-SUGGESTIONS.md
new file mode 100644
index 0000000..1d10fb6
--- /dev/null
+++ b/BUGFIX-SUGGESTIONS.md
@@ -0,0 +1,355 @@
+# newOS4 Bug Fix Suggestions
+
+**Generated:** 2026-05-24
+**Status:** Pending Review
+**Priority:** High (13 issues across 7 files)
+
+---
+
+## Executive Summary
+
+| Category | Issues | Files | Severity |
+|----------|--------|-------|----------|
+| File Structure | 5 | 4 | High |
+| Agent System | 5 | 2 | High |
+| AI Routing | 2 | 1 | Medium |
+| Cache | 1 | 1 | Low |
+| **Total** | **13** | **7** | |
+
+---
+
+## 1. File Structure Issues
+
+### 1.1 Splash Fallback Ext List Incomplete
+- **File:** `index.html`
+- **Line:** 770
+- **Issue:** Splash fallback hardcodes 5 ext modules but `modules/ext/` directory has 8 files
+- **Missing:** `mindmap.js`, `webdav.js`, `skill-pack-core.js`
+- **Fix:**
+ ```javascript
+ // Before (5 modules)
+ ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js'], false]
+
+ // After (8 modules)
+ ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js'], false]
+ ```
+
+---
+
+### 1.2 Preview Iframe Blocks JavaScript
+- **File:** `index.html`
+- **Line:** 684
+- **Issue:** `<iframe sandbox="allow-same-origin">` missing `allow-scripts`
+- **Impact:** HTML previews with JavaScript don't execute
+- **Fix:**
+ ```html
+ <!-- Before -->
+ <iframe sandbox="allow-same-origin"></iframe>
+
+ <!-- After -->
+ <iframe sandbox="allow-same-origin allow-scripts"></iframe>
+ ```
+
+---
+
+### 1.3 Dead Code: EXT_FILES Array
+- **File:** `module-loader.js`
+- **Lines:** 35-37
+- **Issue:** `EXT_FILES` array defined but never referenced (dead code, wrong count)
+- **Impact:** Maintenance confusion, wrong module count (6 vs actual 8)
+- **Fix:** Remove entirely — ext loading uses separate `extKeys` array on line 159
+
+---
+
+### 1.4 Packages.json URL Errors
+- **File:** `packages.json`
+- **Lines:** 7-167
+- **Issues:**
+ - All 18 package URLs point to `modules/<name>.js` (wrong path)
+ - Actual files are in `modules/ext/<name>.js`
+ - 12 packages reference files that don't exist anywhere
+- **Impact:** WPM installs fail with 404 errors
+- **Fix Options:**
+ 1. **Clean:** Remove 12 phantom packages, fix 6 real URLs to `modules/ext/`
+ 2. **Stub:** Create missing files in `modules/ext/`
+- **Recommendation:** Option 1 (clean) — reduces bloat
+
+---
+
+### 1.5 Search Uses Intrusive Alert()
+- **File:** `help.html`
+- **Line:** 1149
+- **Issue:** `alert('No results found')` blocks UI
+- **Impact:** Poor UX, modal interruption
+- **Fix:**
+ ```javascript
+ // Before
+ if (!found) alert('No results found for: ' + query);
+
+ // After
+ if (!found) {
+ const msg = document.getElementById('search-result-msg');
+ if (msg) {
+ msg.textContent = 'No results found for: ' + query;
+ msg.style.display = 'block';
+ setTimeout(() => { msg.style.display = 'none'; }, 3000);
+ }
+ }
+ ```
+
+---
+
+## 2. Agent System Issues
+
+### 2.1 Agent Loop Caps at 1 Turn (Not 5)
+- **File:** `module-aiassist.js`
+- **Lines:** 537-547
+- **Issue:** When model returns text + tool_calls, agent executes tools then `return`s (exits loop)
+- **Impact:** Agent stops after 1 tool turn instead of advertised `MAX_TOOL_TURNS = 5`
+- **Root Cause:** Follow-up text-response block returns instead of continuing loop
+- **Fix:**
+ ```javascript
+ // Before (lines 537-548)
+ if (pendingText) {
+ const followUp = await this.terminal.ollamaManager.chatWithTools(messages, []);
+ if (followUp.message && followUp.message.content) {
+ this.terminal.printAIResponse(followUp.message.content);
+ return { finalAnswer: followUp.message.content, toolTurns }; // ← exits loop
+ }
+ }
+ continue;
+
+ // After
+ if (pendingText) {
+ messages.push({ role: 'assistant', content: pendingText });
+ }
+ continue; // ← loop continues, allows up to 5 turns
+ ```
+
+---
+
+### 2.2 edit_module Not Checked as Destructive
+- **File:** `module-aiassist.js`
+- **Line:** 730
+- **Issue:** `isDestructive()` doesn't include `edit_module` but system prompt (line 227) says it's `(DESTRUCTIVE)`
+- **Impact:** AI told to ask permission but code never confirms
+- **Fix:**
+ ```javascript
+ // Before
+ isDestructive(name) {
+ return name === 'write_file' || name === 'delete_file' ||
+ name === 'run_command' || name === 'make_directory';
+ }
+
+ // After
+ isDestructive(name) {
+ return name === 'write_file' || name === 'delete_file' ||
+ name === 'run_command' || name === 'make_directory' ||
+ name === 'edit_module';
+ }
+ ```
+
+---
+
+### 2.3 Empty tools:[] Sent to Ollama
+- **File:** `module-aiassist.js`
+- **Line:** 539
+- **Issue:** `chatWithTools(messages, [])` sends empty tools array
+- **Impact:** Some Ollama models reject `tools: []`, triggers unnecessary fallback request
+- **Fix:** Remove follow-up call; push text as `assistant` message then `continue` loop (see 2.1)
+
+---
+
+### 2.4 Default Model Blocks Auto-Selection
+- **File:** `ollama-manager.js`
+- **Lines:** 8, 42-45
+- **Issue:** Default model `'gemma4:31b-cloud'` is truthy, so auto-selection skipped even if model doesn't exist
+- **Impact:** Users get silent model errors until manually selecting
+- **Fix:**
+ ```javascript
+ // Before (lines 42-45)
+ if (!this.currentModel && this.models.length > 0) {
+ this.currentModel = this.models[0].name;
+ // ...
+ }
+
+ // After
+ if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
+ if (this.models.length > 0) {
+ this.currentModel = this.models[0].name;
+ // ...
+ }
+ }
+ ```
+
+---
+
+### 2.5 abort() Misses chat() and chatStream()
+- **File:** `ollama-manager.js`
+- **Lines:** 70, 140, 176-183
+- **Issue:** `_abortController` only set in `chatWithTools()` (line 195), not in `chat()` or `chatStream()`
+- **Impact:** `ai-stop` command only cancels tool-calling requests, not direct chat or streaming
+- **Fix:**
+ ```javascript
+ // Add to chat() method (around line 70)
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ const response = await fetch(`${this.endpoint}/api/generate`, {
+ // ...
+ signal
+ });
+ this._abortController = null;
+
+ // Add to chatStream() method (around line 140) - same pattern
+ ```
+
+---
+
+## 3. AI Routing Issues
+
+### 3.1 Unknown Commands Not Awaited (executeCommand)
+- **File:** `terminal.js`
+- **Line:** 629
+- **Issue:** `aiMod.chat([rawInput.trim()])` called without `await`
+- **Impact:** Fire-and-forget execution, errors uncaught, no sequencing
+- **Fix:**
+ ```javascript
+ // Before
+ aiMod.chat([rawInput.trim()]);
+ return;
+
+ // After
+ this.print('→ Routing to AI assistant...', 'info');
+ await aiMod.chat([rawInput.trim()]);
+ return;
+ ```
+
+---
+
+### 3.2 Unknown Commands Not Awaited (dispatchCommand)
+- **File:** `terminal.js`
+- **Line:** 691
+- **Issue:** Same as 3.1 but in `dispatchCommand()` (used for pipe chains)
+- **Fix:** Same as 3.1
+
+**Combined Impact:** Unknown commands like `hello` route to AI but without proper error handling or user feedback
+
+---
+
+## 4. Cache Invalidation
+
+### 4.1 Cache Version Stale
+- **File:** `module-loader.js`
+- **Line:** 102
+- **Issue:** `CACHE = '?v=17'` serves old cached modules
+- **Impact:** Browser doesn't re-fetch updated module files
+- **Fix:**
+ ```javascript
+ // Before
+ const CACHE = '?v=17';
+
+ // After
+ const CACHE = '?v=18';
+ ```
+- **Note:** Requires users to hard refresh (Ctrl+F5 / Cmd+Shift+R)
+
+---
+
+## Implementation Priority
+
+### High Priority (Critical Bugs)
+1. **2.1** Agent loop caps at 1 turn — breaks multi-step AI tasks
+2. **1.4** Packages.json 404 errors — WPM installs broken
+3. **2.4** Default model blocks auto-selection — invisible failures
+4. **2.5** abort() incomplete — `ai-stop` doesn't work for chat/stream
+
+### Medium Priority (Functional Issues)
+5. **1.1** Splash fallback incomplete — minor UX issue
+6. **2.2** edit_module destructive check — security inconsistency
+7. **3.1 + 3.2** AI routing without await — error handling gaps
+8. **1.2** Preview iframe sandbox — JS previews broken
+
+### Low Priority (Cleanup)
+9. **1.3** Dead EXT_FILES code — maintenance clarity
+10. **1.5** Search alert() → inline — UX polish
+11. **4.1** Cache version — required after applying fixes
+
+---
+
+## Tradeoffs & Considerations
+
+### Packages.json Cleanup (1.4)
+- **Option A (Recommended):** Remove 12 phantom packages
+ - ✅ Reduces bloat, cleaner codebase
+ - ❌ Breaks backward compatibility if anyone installed them
+- **Option B:** Create stub files for missing packages
+ - ✅ Preserves compatibility
+ - ❌ Adds dead weight, maintenance burden
+
+### Agent Loop Change (2.1)
+- Removing follow-up call changes message structure slightly
+- Text+tool hybrid responses now push text as `assistant` message, then loop continues
+- This is correct behavior but differs from original design
+
+### AI Routing (3.1 + 3.2)
+- Routing all unknown commands to AI may increase token usage
+- Consider adding `ai-mode` toggle or `--no-ai` flag for opt-out
+
+### Cache Version (4.1)
+- Bumping version requires all users to hard refresh
+- Consider adding release notes or announcement
+
+---
+
+## Testing Checklist
+
+After applying fixes:
+
+- [ ] Hard refresh page (Ctrl+F5)
+- [ ] Test splash screen shows all 8 ext modules
+- [ ] Test HTML preview with JavaScript executes
+- [ ] Test WPM package installs succeed
+- [ ] Test search shows inline message (no alert)
+- [ ] Test multi-step AI task (5 tool turns)
+- [ ] Test `edit_module` prompts for confirmation
+- [ ] Test auto-select model on connect
+- [ ] Test `ai-stop` cancels chat/stream
+- [ ] Test unknown command routes to AI (`hello`)
+
+---
+
+## Files to Modify
+
+```
+spaces/chatstudio/newos4/
+├── index.html (lines 684, 770)
+├── help.html (line 1149)
+├── packages.json (lines 7-167)
+└── modules/core/
+ ├── module-loader.js (lines 35-37, 102)
+ ├── module-aiassist.js (lines 537-547, 730)
+ ├── ollama-manager.js (lines 8, 42-45, 70, 140)
+ └── terminal.js (lines 629, 691)
+```
+
+---
+
+## Estimated Effort
+
+| Category | Time | Complexity |
+|----------|------|------------|
+| File fixes | 15 min | Low |
+| Agent fixes | 30 min | Medium |
+| AI routing | 10 min | Low |
+| Testing | 20 min | Medium |
+| **Total** | **~75 min** | |
+
+---
+
+## Approval Required
+
+**Ready to execute?** Reply with:
+- ✅ "Execute all" — apply all 13 fixes
+- ✅ "Execute high priority" — apply top 4 fixes only
+- ✅ "Skip #X" — exclude specific fixes
+- ❓ "Discuss #X" — more details on specific issue
diff --git a/download-newos4.html b/download-newos4.html
new file mode 100644
index 0000000..af4cf73
--- /dev/null
+++ b/download-newos4.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1.0">
+<title>Download newOS4 v4.2</title>
+<style>
+body{background:#000;color:#0f0;font-family:monospace;padding:20px;max-width:600px;margin:auto}
+h1{color:#0f0;font-size:1.3em}
+a{color:#0af;text-decoration:none}
+a:hover{text-decoration:underline}
+.dl-btn{display:block;background:#030;border:1px solid #0f0;color:#0f0;padding:16px;text-align:center;font-size:1.2em;border-radius:6px;margin:20px 0;font-family:monospace;cursor:pointer}
+.dl-btn:hover{background:#050}
+.info{color:#888;font-size:0.85em;line-height:1.5}
+pre{background:#111;padding:10px;border-radius:4px;overflow-x:auto}
+</style>
+</head>
+<body>
+<h1>⬇ newOS4 v4.2 — Download</h1>
+
+<a class="dl-btn" href="newos4.tar.gz" download>📦 Download newOS4.tar.gz (101 KB)</a>
+
+<div class="info">
+<p><strong>Includes:</strong> index.html, help.html, packages.json, BUGFIX-SUGGESTIONS.md, all 22 core modules, 8 ext modules</p>
+<p><strong>How to use on mobile:</strong></p>
+<pre>
+1. Tap download button above
+2. Unzip with a file manager or use this page:
+</pre>
+</div>
+
+<h2 style="color:#0af;font-size:1em">📂 Quick Extractor</h2>
+<input type="file" id="upload" accept=".tar.gz,.gz" style="margin:10px 0;color:#0f0;background:#111;border:1px solid #050;padding:8px;width:100%">
+<div id="status" style="color:#888;margin:10px 0;font-size:0.85em">Select the .tar.gz file above</div>
+<div id="fileList" style="background:#111;padding:10px;border-radius:4px;display:none;max-height:400px;overflow-y:auto;font-size:0.8em"></div>
+
+<script>
+document.getElementById('upload').addEventListener('change', async function(e) {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const status = document.getElementById('status');
+ const list = document.getElementById('fileList');
+ status.textContent = '📦 Extracting ' + file.name + ' (' + (file.size / 1024).toFixed(0) + ' KB)...';
+ list.style.display = 'none';
+
+ try {
+ const buffer = await file.arrayBuffer();
+ const bytes = new Uint8Array(buffer);
+
+ // Use a simple tar.gz reader via jsdelivr gzip library
+ // Fallback: show the raw structure from the tar
+ let html = '<div style="color:#888;padding:4px">Extracted files (tap to view):</div>';
+
+ // Basic TAR parser (no gzip in browser, but we can read the gzip metadata)
+ const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b;
+ status.textContent = isGzip ? '📦 GZIP archive detected. For full extraction, use a PC.' : 'Unknown format.';
+
+ // List what should be in the archive
+ const expectedFiles = [
+ 'index.html', 'help.html', 'packages.json', 'BUGFIX-SUGGESTIONS.md',
+ 'modules/core/ (22 files)', 'modules/ext/ (8 files)'
+ ];
+
+ html += expectedFiles.map(f => {
+ const isDir = f.includes('(');
+ const name = isDir ? f.split(' (')[0] : f;
+ return '<div style="padding:2px 0;color:' + (isDir ? '#0af' : '#0f0') + '">' + (isDir ? '📁' : '📄') + ' ' + f + '</div>';
+ }).join('');
+
+ list.innerHTML = html;
+ list.style.display = 'block';
+ status.textContent = '✅ Download successful! ' + file.size + ' bytes. Archive contains 4 files + 2 directories.';
+ } catch(err) {
+ status.textContent = '❌ Error: ' + err.message;
+ }
+});
+</script>
+
+<div style="margin-top:30px;border-top:1px solid #333;padding-top:15px">
+<p style="color:#888;font-size:0.85em">
+<strong>🔗 Direct link:</strong> <a href="newos4.tar.gz">newos4.tar.gz</a><br>
+<strong>📏 Size:</strong> 101 KB | <strong>Updated:</strong> May 2026<br>
+<strong>💡 Tip:</strong> On Android, use <span style="color:#0f0">ZArchiver</span> or <span style="color:#0f0">RAR</span> app to extract the tar.gz.
+</p>
+</div>
+</body>
+</html>
diff --git a/help.html b/help.html
new file mode 100644
index 0000000..80c2e9c
--- /dev/null
+++ b/help.html
@@ -0,0 +1,926 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+<meta name="theme-color" content="#0a0a14">
+<title>WebX11 v4.2 — Help</title>
+<style>
+/* ── Reset & Base ── */
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
+:root{
+ --bg:#0a0a14;
+ --bg2:#111122;
+ --bg3:#16162a;
+ --bg4:#1c1c36;
+ --text:#d0d0e8;
+ --text2:#8888aa;
+ --accent:#7c5cfc;
+ --accent2:#a78bfa;
+ --green:#34d399;
+ --cyan:#22d3ee;
+ --amber:#fbbf24;
+ --rose:#fb7185;
+ --pink:#e879f9;
+ --border:#2a2a4a;
+ --radius:12px;
+ --font-mono:'JetBrains Mono','Fira Code','Cascadia Code','Courier New',monospace;
+ --font-sans:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter','SF Pro',Roboto,sans-serif
+}
+html{scroll-behavior:smooth}
+body{background:var(--bg);color:var(--text);font-family:var(--font-sans);line-height:1.7;font-size:15px;-webkit-font-smoothing:antialiased}
+@media(max-width:600px){body{font-size:13.5px}}
+
+/* ── Header ── */
+header{
+ position:sticky;top:0;z-index:100;
+ background:rgba(10,10,20,.85);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
+ border-bottom:1px solid var(--border);padding:0
+}
+header::after{
+ content:'';position:absolute;bottom:-1px;left:10%;right:10%;height:1px;
+ background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.5
+}
+.header-inner{max-width:960px;margin:0 auto;padding:20px 24px 16px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}
+.header-left{display:flex;align-items:center;gap:14px}
+.header-logo{width:36px;height:36px;border-radius:8px;background:linear-gradient(135deg,var(--accent),var(--pink));display:flex;align-items:center;justify-content:center;font-size:18px;color:#fff;flex-shrink:0}
+h1{font-size:1.35em;font-weight:700;letter-spacing:-.02em;color:#fff}
+h1 span{color:var(--accent2)}
+.header-meta{display:flex;align-items:center;gap:10px;font-size:.8em;color:var(--text2)}
+.badge{
+ display:inline-block;padding:3px 10px;border-radius:20px;font-size:.7em;font-weight:600;
+ letter-spacing:.03em;text-transform:uppercase
+}
+.badge-v{background:rgba(124,92,252,.2);color:var(--accent2);border:1px solid rgba(124,92,252,.3)}
+.badge-new{background:rgba(52,211,153,.15);color:var(--green);border:1px solid rgba(52,211,153,.25)}
+
+/* ── Nav ── */
+nav{
+ position:sticky;top:74px;z-index:99;
+ background:rgba(10,10,20,.7);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
+ border-bottom:1px solid var(--border);padding:0;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none
+}
+nav::-webkit-scrollbar{display:none}
+.nav-inner{max-width:960px;margin:0 auto;padding:8px 24px;display:flex;gap:4px;flex-wrap:nowrap}
+.nav-link{
+ flex-shrink:0;color:var(--text2);text-decoration:none;padding:6px 14px;border-radius:8px;
+ font-size:.78em;font-weight:500;transition:all .15s;white-space:nowrap
+}
+.nav-link:hover{color:#fff;background:rgba(124,92,252,.12)}
+
+/* ── Main ── */
+main{max-width:960px;margin:0 auto;padding:32px 24px 80px}
+
+/* ── Search ── */
+.search-box{
+ display:flex;gap:8px;margin-bottom:40px;
+ background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);
+ padding:4px;transition:border-color .2s
+}
+.search-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,252,.15)}
+.search-box input{
+ flex:1;background:transparent;border:none;color:var(--text);font-family:var(--font-mono);
+ font-size:.9em;outline:none;padding:10px 14px
+}
+.search-box input::placeholder{color:var(--text2);opacity:.6}
+.search-box button{
+ background:var(--accent);color:#fff;border:none;padding:8px 20px;border-radius:8px;
+ font-size:.8em;font-weight:600;cursor:pointer;transition:opacity .15s;font-family:inherit
+}
+.search-box button:hover{opacity:.85}
+.search-result-msg{color:var(--amber);font-size:.85em;margin-top:8px;display:none}
+
+/* ── Sections ── */
+section{margin-bottom:56px;scroll-margin-top:130px}
+h2{
+ font-size:1.35em;font-weight:700;color:#fff;margin-bottom:20px;letter-spacing:-.01em;
+ display:flex;align-items:center;gap:10px
+}
+h2::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,var(--border),transparent)}
+h3{
+ font-size:1.05em;font-weight:600;color:var(--text);margin:28px 0 12px;
+ display:flex;align-items:center;gap:8px
+}
+h4{font-size:.92em;font-weight:600;color:var(--accent2);margin:20px 0 8px}
+p{margin-bottom:14px;color:var(--text);line-height:1.8}
+ul,ol{margin-bottom:14px;padding-left:22px;color:var(--text)}
+li{margin-bottom:6px}
+a{color:var(--accent2);text-decoration:none;transition:color .15s}
+a:hover{color:#fff}
+
+/* ── Tables ── */
+.table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:16px 0;border-radius:var(--radius);border:1px solid var(--border)}
+table{width:100%;border-collapse:collapse;font-size:.88em}
+th,td{padding:10px 14px;text-align:left;border-bottom:1px solid var(--border)}
+th{background:var(--bg3);color:var(--accent2);font-weight:600;font-size:.82em;text-transform:uppercase;letter-spacing:.04em}
+tr:last-child td{border-bottom:none}
+tr:hover td{background:rgba(124,92,252,.04)}
+code{
+ font-family:var(--font-mono);font-size:.88em;padding:2px 7px;border-radius:5px;
+ background:rgba(124,92,252,.12);color:var(--accent2);border:1px solid rgba(124,92,252,.15)
+}
+pre code{background:transparent;border:none;padding:0;color:var(--text);font-size:.88em}
+
+/* ── Pre / Code Blocks ── */
+pre{
+ background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);
+ padding:16px 20px;overflow-x:auto;margin:16px 0;font-family:var(--font-mono);font-size:.85em;line-height:1.6
+}
+pre .cmt{color:var(--text2);opacity:.6} /* comment style */
+
+/* ── Callouts ── */
+.callout{
+ padding:16px 20px;border-radius:var(--radius);margin:16px 0;
+ border-left:3px solid;position:relative
+}
+.callout::before{content:'';position:absolute;inset:0;border-radius:var(--radius);opacity:.06;pointer-events:none}
+.callout-success{border-color:var(--green)}.callout-success::before{background:var(--green)}
+.callout-info{border-color:var(--cyan)}.callout-info::before{background:var(--cyan)}
+.callout-warning{border-color:var(--amber)}.callout-warning::before{background:var(--amber)}
+.callout-error{border-color:var(--rose)}.callout-error::before{background:var(--rose)}
+.callout-ollama{border-color:var(--pink)}.callout-ollama::before{background:var(--pink)}
+.callout-title{font-weight:700;margin-bottom:6px;display:block;font-size:.92em}
+
+/* ── Feature Grid ── */
+.feature-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;margin:16px 0}
+.feature-card{
+ background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);
+ padding:16px;transition:border-color .2s,transform .15s
+}
+.feature-card:hover{border-color:rgba(124,92,252,.4);transform:translateY(-1px)}
+.feature-card h4{margin:0 0 6px;font-size:.88em}
+.feature-card p{font-size:.82em;color:var(--text2);margin:0;line-height:1.5}
+
+/* ── Quick Ref ── */
+.quick-ref{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin:16px 0}
+.quick-ref-title{color:var(--accent2);font-weight:700;text-align:center;margin-bottom:16px;font-size:.95em;letter-spacing:.02em;text-transform:uppercase}
+.quick-ref-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px}
+.quick-ref-item{background:var(--bg3);padding:10px 12px;border-radius:8px;border:1px solid var(--border)}
+.quick-ref-item .cmd{color:var(--accent2);font-family:var(--font-mono);font-weight:600;font-size:.85em;display:block}
+.quick-ref-item .desc{color:var(--text2);font-size:.78em;margin-top:3px}
+
+/* ── Version Items ── */
+.version-item{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin:12px 0;transition:border-color .2s}
+.version-item:hover{border-color:rgba(124,92,252,.3)}
+.version-item .v-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:6px}
+.version-item .v-num{color:var(--accent2);font-weight:700;font-size:.95em}
+.version-item .v-date{color:var(--text2);font-size:.82em}
+.version-item ul{margin:0;padding-left:18px}
+.version-item li{margin-bottom:4px;font-size:.88em;color:var(--text)}
+
+/* ── Footer ── */
+footer{
+ text-align:center;padding:32px 24px;border-top:1px solid var(--border);
+ color:var(--text2);font-size:.82em
+}
+footer .links{display:flex;justify-content:center;gap:20px;margin-bottom:14px;flex-wrap:wrap}
+footer .links a{color:var(--text);font-weight:500}
+footer .links a:hover{color:var(--accent2)}
+footer .tagline{color:var(--accent2);margin-top:10px;font-weight:500}
+
+/* ── Back to Top ── */
+.back-top{
+ position:fixed;bottom:24px;right:24px;
+ background:var(--bg3);border:1px solid var(--border);color:var(--text2);
+ width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;
+ font-size:18px;cursor:pointer;opacity:0;pointer-events:none;transition:all .2s;z-index:50;
+ backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);text-decoration:none
+}
+.back-top.visible{opacity:1;pointer-events:auto}
+.back-top:hover{background:var(--accent);color:#fff;border-color:var(--accent);transform:translateY(-2px)}
+
+/* ── Responsive ── */
+@media(max-width:600px){
+ .header-inner{padding:16px 16px 12px}
+ .header-meta{display:none}
+ .nav-inner{padding:6px 12px;gap:2px}
+ .nav-link{padding:5px 10px;font-size:.74em}
+ main{padding:20px 16px 60px}
+ h2{font-size:1.15em}
+ .feature-grid{grid-template-columns:1fr}
+ .quick-ref-grid{grid-template-columns:1fr 1fr}
+ pre{font-size:.8em;padding:12px 14px}
+ th,td{padding:8px 10px;font-size:.82em}
+}
+@media(max-width:400px){
+ .quick-ref-grid{grid-template-columns:1fr}
+ .badge-new{display:none}
+}
+</style>
+</head>
+<body>
+
+<header>
+ <div class="header-inner">
+ <div class="header-left">
+ <div class="header-logo">⚡</div>
+ <h1>WebX11 <span>Docs</span></h1>
+ </div>
+ <div class="header-meta">
+ <span class="badge badge-v">v4.2</span>
+ <span class="badge badge-new">24 core · 12 ext</span>
+ <span style="color:var(--text2)">110+ commands</span>
+ </div>
+ </div>
+</header>
+
+<nav>
+ <div class="nav-inner">
+ <a href="#overview" class="nav-link">Overview</a>
+ <a href="#start" class="nav-link">Quick Start</a>
+ <a href="#commands" class="nav-link">Commands</a>
+ <a href="#filesystem" class="nav-link">Filesystem</a>
+ <a href="#ai" class="nav-link">AI Agent</a>
+ <a href="#skills" class="nav-link">Skills</a>
+ <a href="#ollama" class="nav-link">Ollama</a>
+ <a href="#web" class="nav-link">Search & Web</a>
+ <a href="#voice" class="nav-link">Voice & Sound</a>
+ <a href="#config" class="nav-link">Config</a>
+ <a href="#modules" class="nav-link">Modules</a>
+ <a href="#extras" class="nav-link">Extras</a>
+ <a href="#sessions" class="nav-link">Sessions</a>
+ <a href="#storage" class="nav-link">Storage</a>
+ <a href="#security" class="nav-link">Security</a>
+ <a href="#trouble" class="nav-link">Troubleshoot</a>
+ <a href="#ref" class="nav-link">Quick Ref</a>
+ <a href="#history" class="nav-link">Changelog</a>
+ </div>
+</nav>
+
+<main>
+ <div class="search-box">
+ <input type="text" id="search-input" placeholder="Search docs…" spellcheck="false">
+ <button onclick="searchDocs()">Search</button>
+ </div>
+ <div class="search-result-msg" id="search-result-msg"></div>
+
+ <!-- ── Overview ── -->
+ <section id="overview">
+ <h2>⛅ Overview</h2>
+ <p>WebX11 v4.2 is a browser-based Unix-like terminal — dynamic modules, AI assistant (Ollama), persistent virtual filesystem, web search, voice input, code editor, and a full skill system. Everything runs client-side in your browser.</p>
+ <div class="feature-grid">
+ <div class="feature-card"><h4>🧩 24 Core + 12 Ext</h4><p>Modules loaded at startup. Load new ones from any URL at runtime.</p></div>
+ <div class="feature-card"><h4>🤖 Agent with Tools</h4><p>15+ tools — file ops, web search, skills, modules. Destructive action confirmations with inline bar + countdown.</p></div>
+ <div class="feature-card"><h4>📚 Skills System</h4><p>Persistent procedural memory. Create, patch, search, export skills for repeatable workflows.</p></div>
+ <div class="feature-card"><h4>💾 VFS + Sessions</h4><p>Virtual filesystem persists in localStorage. Save/load/export sessions.</p></div>
+ <div class="feature-card"><h4>🎤 Voice Input</h4><p>Speech recognition in the terminal. Works in Chrome, Edge, Safari.</p></div>
+ <div class="feature-card"><h4>🌐 Web Search</h4><p>SearXNG + DuckDuckGo fallback + URL fetch/extract. All built-in.</p></div>
+ <div class="feature-card"><h4>☁️ WebDAV Sync</h4><p>Push, pull, backup VFS to any WebDAV server.</p></div>
+ <div class="feature-card"><h4>📱 Mobile First</h4><p>Touch-optimized. <code>srcdoc</code> HTML preview works on mobile.</p></div>
+ <div class="feature-card"><h4>✏️ Text Editor</h4><p>Inline VFS editor — <code>edit &lt;path&gt;</code> opens a modal with save/cancel.</p></div>
+ <div class="feature-card"><h4>🔗 Aliases</h4><p><code>ll</code>, <code>h</code>, <code>cls</code> built-in. Define custom aliases with <code>alias x=y</code>.</p></div>
+ </div>
+ </section>
+
+ <!-- ── Quick Start ── -->
+ <section id="start">
+ <h2>🚀 Quick Start</h2>
+ <ol style="margin-bottom:8px">
+ <li><strong>Open</strong> <code>index.html</code> in Chrome/Edge</li>
+ <li>Watch the splash screen load modules</li>
+ <li>Type <code>help</code> + Enter</li>
+ <li>Use the quick-action buttons above the input area</li>
+ </ol>
+
+ <h3>First Commands</h3>
+ <pre><span class="cmt"># List VFS files</span>
+ls
+
+<span class="cmt"># List files faster</span>
+ll
+
+<span class="cmt"># Connect to Ollama AI</span>
+ollamaconnect
+
+<span class="cmt"># Chat with AI</span>
+ai what's in this directory?
+
+<span class="cmt"># Create a file</span>
+put hello.txt Hello World
+
+<span class="cmt"># Edit a file</span>
+edit hello.txt
+
+<span class="cmt"># Search the web</span>
+search quantum computing
+
+<span class="cmt"># Find files</span>
+find hello
+
+<span class="cmt"># Compare two files</span>
+diff a.txt b.txt</pre>
+
+ <div class="callout callout-info">
+ <span class="callout-title">⌨️ Keyboard Shortcuts</span>
+ <ul style="margin:0">
+ <li><strong>Enter</strong> — Execute</li>
+ <li><strong>Tab</strong> — Cycle autocomplete</li>
+ <li><strong>↑/↓</strong> — Command history</li>
+ <li><strong>Esc</strong> — Hide autocomplete / stop AI</li>
+ <li><strong>|</strong> — Pipe: <code>ls | grep hello</code></li>
+ </ul>
+ </div>
+ </section>
+
+ <!-- ── Commands ── -->
+ <section id="commands">
+ <h2>📖 Full Command Reference</h2>
+
+ <h3>System</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>help</code></td><td>Show all commands</td><td><code>help</code></td></tr>
+ <tr><td><code>clear</code> / <code>cls</code></td><td>Clear terminal</td><td><code>cls</code></td></tr>
+ <tr><td><code>echo</code></td><td>Print text</td><td><code>echo Hello</code></td></tr>
+ <tr><td><code>mem</code></td><td>Show memory usage</td><td><code>mem</code></td></tr>
+ <tr><td><code>status</code></td><td>System status</td><td><code>status</code></td></tr>
+ <tr><td><code>font-size</code></td><td>Change font size (8–32)</td><td><code>font-size 16</code></td></tr>
+ <tr><td><code>load-local</code></td><td>Load module from VFS or URL</td><td><code>load-local modules/ext/calc.js</code></td></tr>
+ <tr><td><code>startup-modules</code></td><td>Open startup module selector</td><td><code>startup-modules</code></td></tr>
+ <tr><td><code>preview</code></td><td>Preview HTML in iframe</td><td><code>preview test.html</code></td></tr>
+ <tr><td><code>factory-reset</code></td><td>Reset config</td><td><code>factory-reset</code></td></tr>
+ <tr><td><code>reset-vfs</code></td><td>Reset VFS</td><td><code>reset-vfs</code></td></tr>
+ </table></div>
+
+ <h3>AI Assistant</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>ai</code> / <code>ask</code></td><td>Chat with AI (tool-calling)</td><td><code>ai create a notes file</code></td></tr>
+ <tr><td><code>ai-clear</code></td><td>Reset conversation context</td><td><code>ai-clear</code></td></tr>
+ <tr><td><code>ai-system</code></td><td>View/set system prompt</td><td><code>ai-system</code></td></tr>
+ <tr><td><code>ai-context</code></td><td>Show conversation memory</td><td><code>ai-context</code></td></tr>
+ <tr><td><code>ai-allow</code></td><td>Allow pending destructive action</td><td><code>ai-allow</code></td></tr>
+ <tr><td><code>ai-deny</code></td><td>Deny pending destructive action</td><td><code>ai-deny</code></td></tr>
+ <tr><td><code>ai-stop</code></td><td>Cancel running AI request</td><td><code>ai-stop</code></td></tr>
+ </table></div>
+
+ <h3>Smart Context</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>context</code></td><td>Set active profile</td><td><code>context expert</code></td></tr>
+ <tr><td><code>contexts</code></td><td>List profiles</td><td><code>contexts</code></td></tr>
+ <tr><td><code>context-new</code></td><td>Create profile</td><td><code>context-new myprofile</code></td></tr>
+ <tr><td><code>context-delete</code></td><td>Delete profile</td><td><code>context-delete old</code></td></tr>
+ <tr><td><code>smart-ask</code></td><td>Ask with current profile</td><td><code>smart-ask explain this</code></td></tr>
+ </table></div>
+
+ <h3>Dynamic Prompts</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>prompt</code></td><td>Use saved template</td><td><code>prompt summarize</code></td></tr>
+ <tr><td><code>prompt-save</code></td><td>Save template</td><td><code>prompt-save myprompt</code></td></tr>
+ <tr><td><code>prompt-list</code></td><td>List saved prompts</td><td><code>prompt-list</code></td></tr>
+ <tr><td><code>prompt-delete</code></td><td>Delete prompt</td><td><code>prompt-delete old</code></td></tr>
+ <tr><td><code>prompt-var</code></td><td>Set template variable</td><td><code>prompt-var name Alice</code></td></tr>
+ <tr><td><code>prompt-vars</code></td><td>List variables</td><td><code>prompt-vars</code></td></tr>
+ </table></div>
+
+ <h3>Skills</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>skill-list</code></td><td>List all skills</td><td><code>skill-list</code></td></tr>
+ <tr><td><code>skill-view</code></td><td>View skill content</td><td><code>skill-view web-scraper</code></td></tr>
+ <tr><td><code>skill-create</code></td><td>Create a skill</td><td><code>skill-create name desc "steps" tags</code></td></tr>
+ <tr><td><code>skill-patch</code></td><td>Update a skill</td><td><code>skill-patch name "new content"</code></td></tr>
+ <tr><td><code>skill-delete</code></td><td>Delete a skill</td><td><code>skill-delete oldskill</code></td></tr>
+ <tr><td><code>skill-export</code></td><td>Export skill to JSON</td><td><code>skill-export myskill</code></td></tr>
+ <tr><td><code>skill-import</code></td><td>Import skill from JSON</td><td><code>skill-import</code></td></tr>
+ <tr><td><code>skill-sync</code></td><td>Rebuild skill index</td><td><code>skill-sync</code></td></tr>
+ </table></div>
+
+ <h3>Config</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>config</code></td><td>Get a config value</td><td><code>config</code></td></tr>
+ <tr><td><code>config-set</code></td><td>Set a config value</td><td><code>config-set key value</code></td></tr>
+ <tr><td><code>config-list</code></td><td>List all config</td><td><code>config-list</code></td></tr>
+ <tr><td><code>config-delete</code></td><td>Delete a config entry</td><td><code>config-delete key</code></td></tr>
+ <tr><td><code>config-export</code></td><td>Export config as JSON</td><td><code>config-export</code></td></tr>
+ <tr><td><code>config-reset</code></td><td>Reset all config</td><td><code>config-reset</code></td></tr>
+ </table></div>
+ </section>
+
+ <!-- ── Filesystem ── -->
+ <section id="filesystem">
+ <h2>📁 Filesystem</h2>
+ <p>All VFS files persist in <code>localStorage</code>. <code>ls</code>, <code>cd</code>, <code>pwd</code> work like a real terminal. Clickable output — tap a file to <code>cat</code>, tap a dir to <code>cd</code>.</p>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>ls</code> / <code>ll</code></td><td>List files</td><td><code>ll</code></td></tr>
+ <tr><td><code>cd</code></td><td>Change directory</td><td><code>cd notes</code></td></tr>
+ <tr><td><code>pwd</code></td><td>Print working directory</td><td><code>pwd</code></td></tr>
+ <tr><td><code>cat</code></td><td>View file</td><td><code>cat readme.txt</code></td></tr>
+ <tr><td><code>put</code></td><td>Create file with content</td><td><code>put hello.txt Hello World</code></td></tr>
+ <tr><td><code>edit</code></td><td>Open in modal editor</td><td><code>edit notes.md</code></td></tr>
+ <tr><td><code>rm</code></td><td>Delete file</td><td><code>rm temp.txt</code></td></tr>
+ <tr><td><code>mkdir</code></td><td>Create directory</td><td><code>mkdir projects</code></td></tr>
+ <tr><td><code>rmdir</code></td><td>Remove directory</td><td><code>rmdir empty</code></td></tr>
+ <tr><td><code>mv</code></td><td>Move/rename</td><td><code>mv old.txt new.txt</code></td></tr>
+ <tr><td><code>cp</code></td><td>Copy file</td><td><code>cp a.txt b.txt</code></td></tr>
+ <tr><td><code>touch</code></td><td>Create empty file</td><td><code>touch newfile.txt</code></td></tr>
+ <tr><td><code>tree</code></td><td>Directory tree</td><td><code>tree</code></td></tr>
+ <tr><td><code>find</code></td><td>Recursive file search</td><td><code>find .txt</code></td></tr>
+ <tr><td><code>diff</code></td><td>Compare two files</td><td><code>diff a.txt b.txt</code></td></tr>
+ <tr><td><code>grep</code></td><td>Search file contents</td><td><code>grep hello *.txt</code></td></tr>
+ <tr><td><code>head</code></td><td>First N lines</td><td><code>head file.txt 10</code></td></tr>
+ <tr><td><code>tail</code></td><td>Last N lines</td><td><code>tail file.txt 10</code></td></tr>
+ <tr><td><code>sort</code></td><td>Sort file lines</td><td><code>sort file.txt</code></td></tr>
+ <tr><td><code>wc</code></td><td>Count lines/words/chars</td><td><code>wc file.txt</code></td></tr>
+ <tr><td><code>du</code></td><td>Disk usage</td><td><code>du</code></td></tr>
+ <tr><td><code>upload</code></td><td>Upload file from device</td><td><code>upload</code></td></tr>
+ <tr><td><code>download</code></td><td>Download file</td><td><code>download file.txt</code></td></tr>
+ <tr><td><code>vfs-export</code></td><td>Download VFS as JSON</td><td><code>vfs-export</code></td></tr>
+ <tr><td><code>vfs-import</code></td><td>Import VFS from JSON</td><td><code>vfs-import</code></td></tr>
+ </table></div>
+ </section>
+
+ <!-- ── AI Agent ── -->
+ <section id="ai">
+ <h2>🤖 AI Assistant</h2>
+ <div class="callout callout-success">
+ <span class="callout-title">Tool-Calling Agent</span>
+ The AI runs a full agent loop with 15+ tools: filesystem, web search, URL fetch, skills, WPM, session memory, user profile, module introspection.
+ </div>
+
+ <h3>How It Works</h3>
+ <ol>
+ <li>Type <code>ai &lt;request&gt;</code></li>
+ <li>AI loads relevant skills + recent session memory</li>
+ <li>Decides which tools to call (up to 5 turns)</li>
+ <li><strong>Destructive tools</strong> trigger an inline confirm bar — no dark overlay</li>
+ <li>120s countdown: yellow → bold red at 30s → pulsing at 10s</li>
+ <li>Type <code>ai-allow</code> / <code>ai-deny</code> or tap the buttons</li>
+ <li>After complex tasks, AI suggests creating a skill</li>
+ </ol>
+
+ <div class="callout callout-warning">
+ <span class="callout-title">⚠️ Destructive Tools</span>
+ <code>write_file</code>, <code>delete_file</code>, <code>run_command</code>, <code>make_directory</code>, <code>edit_module</code> all require confirmation before executing.
+ </div>
+
+ <h3>Available Tools</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Tool</th><th>Description</th><th>Destructive?</th></tr>
+ <tr><td><code>list_files</code></td><td>List VFS directory</td><td>❌</td></tr>
+ <tr><td><code>read_file</code></td><td>Read file (8k trunc)</td><td>❌</td></tr>
+ <tr><td><code>write_file</code></td><td>Write to file</td><td>✅</td></tr>
+ <tr><td><code>make_directory</code></td><td>Create directory</td><td>✅</td></tr>
+ <tr><td><code>delete_file</code></td><td>Delete file/dir</td><td>✅</td></tr>
+ <tr><td><code>get_status</code></td><td>System status</td><td>❌</td></tr>
+ <tr><td><code>get_help</code></td><td>List commands</td><td>❌</td></tr>
+ <tr><td><code>web_search</code></td><td>SearXNG + DDG</td><td>❌</td></tr>
+ <tr><td><code>web_fetch</code></td><td>Extract URL content</td><td>❌</td></tr>
+ <tr><td><code>run_command</code></td><td>Execute terminal cmd</td><td>✅</td></tr>
+ <tr><td><code>load_skills</code></td><td>Search relevant skills</td><td>❌</td></tr>
+ <tr><td><code>create/patch/list_skills</code></td><td>Skill management</td><td>❌</td></tr>
+ <tr><td><code>view/edit/test/reload</code></td><td>Module operations</td><td>edit ✅</td></tr>
+ <tr><td><code>user_get/set/list/forget</code></td><td>User profile</td><td>❌</td></tr>
+ <tr><td><code>session_save/load/recent/stats</code></td><td>Session memory</td><td>❌</td></tr>
+ </table></div>
+
+ <h3>Sound Notification</h3>
+ <p>A short <strong>"ding"</strong> plays via WebAudio when AI finishes. Toggle with the <strong>🔊 Sound</strong> button in quick-actions. Persists in localStorage.</p>
+ </section>
+
+ <!-- ── Skills ── -->
+ <section id="skills">
+ <h2>📚 Skills System</h2>
+ <div class="callout callout-success">
+ <span class="callout-title">Procedural Memory</span>
+ Skills are reusable markdown procedures stored in VFS <code>/skills/</code>. The AI auto-loads relevant skills at task start. Create skills after complex workflows (3+ tool calls) to save the approach.
+ </div>
+
+ <h3>Format</h3>
+ <pre>---
+name: web-scrape-and-summarize
+description: Scrape a URL and summarize
+tags: [web, research]
+category: general
+---
+## Steps
+1. Use web_fetch(url) to get content
+2. Present a concise summary
+3. Save with create_skill</pre>
+
+ <h3>Commands</h3>
+ <pre><span class="cmt"># List all skills</span>
+skill-list
+
+<span class="cmt"># Create a skill</span>
+skill-create my-skill "Does X" "Steps to do X" "tag1,tag2"
+
+<span class="cmt"># View / patch / delete</span>
+skill-view my-skill
+skill-patch my-skill "Updated steps"
+skill-delete my-skill
+
+<span class="cmt"># Export / Import</span>
+skill-export my-skill
+skill-import</pre>
+
+ <h3>Auto-Creation</h3>
+ <p>When the AI encounters the same tool error 3+ times, a workaround skill is auto-created. Every 20 messages, skills are audited for staleness.</p>
+ </section>
+
+ <!-- ── Ollama ── -->
+ <section id="ollama">
+ <h2>🤖 Ollama</h2>
+ <div class="callout callout-ollama">
+ <span class="callout-title">Local or Remote AI</span>
+ WebX11 connects to any Ollama instance. All tool-calling and chat features require a connection.
+ </div>
+
+ <h3>Setup</h3>
+ <pre><span class="cmt"># Install & pull a model</span>
+curl -fsSL https://ollama.com/install.sh | sh
+ollama pull llama2
+
+<span class="cmt"># Start Ollama</span>
+ollama serve
+
+<span class="cmt"># In WebX11</span>
+ollamaconnect</pre>
+
+ <h3>Commands</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th></tr>
+ <tr><td><code>ollamaconnect</code></td><td>Connect to Ollama</td></tr>
+ <tr><td><code>ollamasetproxy</code></td><td>Set remote endpoint</td></tr>
+ <tr><td><code>ollamamodels</code></td><td>List models</td></tr>
+ <tr><td><code>ollamasetmodel</code></td><td>Set active model</td></tr>
+ <tr><td><code>ollamadisconnect</code></td><td>Disconnect</td></tr>
+ <tr><td><code>ollama</code></td><td>Open Ollama Manager UI</td></tr>
+ <tr><td><code>models</code></td><td>List all models</td></tr>
+ <tr><td><code>model-info</code></td><td>Model details</td></tr>
+ <tr><td><code>model-pull</code></td><td>Pull a model</td></tr>
+ <tr><td><code>model-delete</code></td><td>Delete a model</td></tr>
+ <tr><td><code>model-run</code></td><td>Run model with prompt</td></tr>
+ </table></div>
+
+ <div class="callout callout-warning">
+ <span class="callout-title">🔌 CORS for Remote</span>
+ <code>OLLAMA_ORIGINS="*" ollama serve</code>
+ </div>
+ </section>
+
+ <!-- ── Search & Web ── -->
+ <section id="web">
+ <h2>🌐 Search & Web</h2>
+ <div class="callout callout-info">
+ <span class="callout-title">SearXNG + DDG + URL Fetcher</span>
+ Three modules provide comprehensive web capabilities via the proxy API.
+ </div>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Module</th><th>Description</th><th>Example</th></tr>
+ <tr><td><code>search</code> / <code>s</code></td><td>SearXNG</td><td>Web search</td><td><code>search raspberry pi</code></td></tr>
+ <tr><td><code>ddg</code> / <code>duck</code></td><td>DDG</td><td>DuckDuckGo fallback</td><td><code>ddg news today</code></td></tr>
+ <tr><td><code>fetch</code> / <code>web</code></td><td>Fetcher</td><td>Fetch URL content</td><td><code>fetch https://example.com</code></td></tr>
+ <tr><td><code>extract</code></td><td>Fetcher</td><td>Clean readable text</td><td><code>extract https://news.article</code></td></tr>
+ </table></div>
+ <p>The AI agent also has <code>web_search</code> and <code>web_fetch</code> tools using the same APIs.</p>
+ </section>
+
+ <!-- ── Voice & Sound ── -->
+ <section id="voice">
+ <h2>🎤 Voice & Sound</h2>
+ <div class="callout callout-success">
+ <span class="callout-title">Speech Recognition</span>
+ A <strong>🎤 button</strong> in the input area activates SpeechRecognition. Chrome, Edge, Safari 14.1+. Not Firefox.
+ </div>
+ <ul>
+ <li>Tap 🎤 to start listening — button pulses green</li>
+ <li>Say your command — recognized text fills the input</li>
+ <li>Tap again or wait to stop</li>
+ <li>Red flash indicates failure</li>
+ </ul>
+ <h3>Sound</h3>
+ <p>Toggle AI response "ding" with the <strong>🔊 Sound</strong> button. Persists in localStorage.</p>
+ </section>
+
+ <!-- ── Config ── -->
+ <section id="config">
+ <h2>⚙️ Configuration</h2>
+ <p>All config values stored as <code>webx11_config_*</code> in localStorage. Use the ⚙ Config button or CLI commands.</p>
+ <h3>Notable Keys</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Key</th><th>Description</th><th>Values</th></tr>
+ <tr><td><code>webx11_config_ai_sound</code></td><td>AI sound toggle</td><td><code>on</code>, <code>off</code></td></tr>
+ <tr><td><code>webx11_config_voice_visible</code></td><td>Mic button visibility</td><td><code>on</code>, <code>off</code></td></tr>
+ </table></div>
+ <h3>Persisted State</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Key</th><th>Description</th></tr>
+ <tr><td><code>webx11_filesystem</code></td><td>VFS JSON</td></tr>
+ <tr><td><code>webx11_modules</code></td><td>Loaded module URLs</td></tr>
+ <tr><td><code>webx11_ollama_endpoint</code></td><td>Ollama API URL</td></tr>
+ <tr><td><code>webx11_ollama_model</code></td><td>Selected model</td></tr>
+ <tr><td><code>webx11_startup_modules</code></td><td>Startup selection</td></tr>
+ <tr><td><code>webx11_font_size</code></td><td>Font size (8–32)</td></tr>
+ <tr><td><code>webx11_command_history</code></td><td>Command history</td></tr>
+ <tr><td><code>webx11_terminal_output</code></td><td>Persisted output</td></tr>
+ <tr><td><code>webx11_ai_context</code></td><td>AI conversation history</td></tr>
+ <tr><td><code>webx11_user_profile</code></td><td>AI user profile</td></tr>
+ <tr><td><code>webx11_aliases</code></td><td>User-defined aliases</td></tr>
+ </table></div>
+ </section>
+
+ <!-- ── Modules ── -->
+ <section id="modules">
+ <h2>🧩 Module System</h2>
+ <div class="callout callout-info">
+ <span class="callout-title">24 Core + 12 Ext</span>
+ Modules loaded in dependency order at startup. Toggle ext modules with ⚙ gear on splash.
+ </div>
+ <h3>Commands</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th></tr>
+ <tr><td><code>modules</code></td><td>Open Module Manager UI</td></tr>
+ <tr><td><code>loadmod &lt;url&gt;</code></td><td>Load module from URL</td></tr>
+ <tr><td><code>removemod &lt;name&gt;</code></td><td>Remove a module</td></tr>
+ <tr><td><code>listmods</code></td><td>List all loaded modules</td></tr>
+ </table></div>
+
+ <h3>Boot Order</h3>
+ <ol>
+ <li>Splash screen with ⚙ gear</li>
+ <li>Foundation: <code>virtual-fs.js</code> → <code>module-manager.js</code> → <code>ollama-manager.js</code> → <code>terminal.js</code></li>
+ <li>Built-in modules loaded async</li>
+ <li>Ext modules loaded if selected</li>
+ <li>Splash hides, terminal ready</li>
+ </ol>
+
+ <h3>Code Editor</h3>
+ <p>The AI can <code>edit_module(name)</code> to copy source into the Code Editor modal. Edit inline, then <strong>💾 Save & Test</strong> to load live from <code>/_mods/</code>. Use <code>test_module</code> without reloading the page.</p>
+ </section>
+
+ <!-- ── Extras ── -->
+ <section id="extras">
+ <h2>🧰 Extended Modules</h2>
+
+ <h3>WebDAV Sync</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th></tr>
+ <tr><td><code>webdav-config</code></td><td>Configure server</td></tr>
+ <tr><td><code>webdav-status</code></td><td>Connection status</td></tr>
+ <tr><td><code>webdav-ls</code></td><td>List remote dir</td></tr>
+ <tr><td><code>webdav-push</code></td><td>Push file</td></tr>
+ <tr><td><code>webdav-pull</code></td><td>Pull file</td></tr>
+ <tr><td><code>webdav-backup</code></td><td>Backup VFS</td></tr>
+ <tr><td><code>webdav-restore</code></td><td>Restore VFS</td></tr>
+ <tr><td><code>webdav-backup-list</code></td><td>List backups</td></tr>
+ </table></div>
+
+ <h3>Calculator & Cron</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Module</th><th>Description</th></tr>
+ <tr><td><code>calc</code></td><td>calc.js</td><td>Evaluate math / unit conversion</td></tr>
+ <tr><td><code>cron-every</code></td><td>cron.js</td><td>Schedule a command</td></tr>
+ <tr><td><code>cron-list</code></td><td>cron.js</td><td>List scheduled jobs</td></tr>
+ <tr><td><code>cron-stop</code></td><td>cron.js</td><td>Stop a job</td></tr>
+ <tr><td><code>cron-clear</code></td><td>cron.js</td><td>Clear all jobs</td></tr>
+ </table></div>
+
+ <h3>Theme, WPM & Mindmap</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Module</th><th>Description</th></tr>
+ <tr><td><code>theme</code></td><td>theme.js</td><td>Set color theme (dark, light, amber, cyber, …)</td></tr>
+ <tr><td><code>wpm</code></td><td>wpm.js</td><td>Package manager</td></tr>
+ <tr><td><code>mindmap</code></td><td>mindmap.js</td><td>Show file structure as mindmap</td></tr>
+ </table></div>
+
+ <h3>Server Monitor</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th></tr>
+ <tr><td><code>server</code></td><td>Add server to monitor</td></tr>
+ <tr><td><code>servers</code></td><td>List monitored servers</td></tr>
+ <tr><td><code>server-check</code></td><td>Ping a server</td></tr>
+ <tr><td><code>server-remove</code></td><td>Remove server</td></tr>
+ <tr><td><code>monitor</code></td><td>Toggle auto-monitoring</td></tr>
+ </table></div>
+
+ <h3>IO / Backup</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Command</th><th>Description</th></tr>
+ <tr><td><code>export-mods</code></td><td>Export module configs</td></tr>
+ <tr><td><code>import-mods</code></td><td>Import module configs</td></tr>
+ <tr><td><code>backup</code></td><td>Full system backup</td></tr>
+ <tr><td><code>restore</code></td><td>Restore from backup</td></tr>
+ <tr><td><code>share-mod</code></td><td>Share module as text</td></tr>
+ </table></div>
+
+ <h3>Aliases</h3>
+ <p>Built-in: <code>ll</code> = <code>ls</code>, <code>h</code> = <code>help</code>, <code>cls</code> = <code>clear</code>. Define custom: <code>alias x=ls -la</code>. Remove: <code>unalias x</code>. Persists in localStorage.</p>
+ </section>
+
+ <!-- ── Sessions ── -->
+ <section id="sessions">
+ <h2>💬 Sessions</h2>
+ <p>Sessions persist AI conversation logs in VFS <code>/memory/</code>. The AI auto-loads recent sessions at start for continuity.</p>
+ <h3>AI Tools</h3>
+ <div class="table-wrap"><table>
+ <tr><th>Tool</th><th>Description</th></tr>
+ <tr><td><code>session_save</code></td><td>Save summary</td></tr>
+ <tr><td><code>session_load</code></td><td>Load by date</td></tr>
+ <tr><td><code>session_recent</code></td><td>Show last N</td></tr>
+ <tr><td><code>session_stats</code></td><td>Aggregate statistics</td></tr>
+ </table></div>
+ <p>The terminal auto-saves session state every 60s and auto-restores on page reload.</p>
+ </section>
+
+ <!-- ── Storage ── -->
+ <section id="storage">
+ <h2>💾 Storage</h2>
+ <div class="callout callout-success">
+ <span class="callout-title">localStorage Persistence</span>
+ Everything survives hard refreshes via 15+ localStorage keys.
+ </div>
+ <pre><span class="cmt"># Clear all WebX11 data</span>
+localStorage.clear()
+location.reload()
+
+<span class="cmt"># Back up / restore VFS</span>
+vfs-export
+vfs-import</pre>
+ <p>~5–10MB per domain. Only URLs (not code) are stored for modules.</p>
+ </section>
+
+ <!-- ── Security ── -->
+ <section id="security">
+ <h2>🔒 Security</h2>
+ <div class="table-wrap"><table>
+ <tr><th>Feature</th><th>Status</th></tr>
+ <tr><td>Input Validation</td><td>✅</td></tr>
+ <tr><td>Output Escaping</td><td>✅</td></tr>
+ <tr><td>No eval() on User Input</td><td>✅</td></tr>
+ <tr><td>Path Traversal Prevention</td><td>✅</td></tr>
+ <tr><td>Sandboxed File System</td><td>✅</td></tr>
+ <tr><td>AI Destructive Confirmations</td><td>✅ Required + timeout</td></tr>
+ <tr><td>Iframe Preview Sandbox</td><td>✅ allow-same-origin allow-scripts</td></tr>
+ </table></div>
+ <div class="callout callout-warning">
+ <span class="callout-title">⚠️ Module Loading</span>
+ Only load modules from trusted sources — they have full terminal access.
+ </div>
+ </section>
+
+ <!-- ── Troubleshooting ── -->
+ <section id="trouble">
+ <h2>🔧 Troubleshooting</h2>
+ <div class="table-wrap"><table>
+ <tr><th>Issue</th><th>Solution</th></tr>
+ <tr><td>AI not responding</td><td>Run <code>ollamaconnect</code> first, ensure Ollama is running</td></tr>
+ <tr><td>Voice not working</td><td>Chrome/Edge/Safari only. Grant mic permission</td></tr>
+ <tr><td>Module fails to load</td><td>Check CORS on the hosting server</td></tr>
+ <tr><td>Data lost after refresh</td><td>Check localStorage is enabled. Private mode clears data</td></tr>
+ <tr><td>AI actions not confirming</td><td>Confirm bar auto-denies after 120s. Type <code>ai-allow</code></td></tr>
+ <tr><td>Sound not playing</td><td>Browser may need first user gesture. Toggle off/on</td></tr>
+ <tr><td>Ollama CORS error</td><td><code>OLLAMA_ORIGINS="*" ollama serve</code></td></tr>
+ </table></div>
+ <h3>Debug</h3>
+ <pre><span class="cmt"># Console</span>
+F12 → Console
+
+<span class="cmt"># View AI state</span>
+ai-system
+ai-context
+
+<span class="cmt"># Module status</span>
+listmods</pre>
+ </section>
+
+ <!-- ── Quick Ref ── -->
+ <section id="ref">
+ <h2>⚡ Quick Reference</h2>
+ <div class="quick-ref">
+ <div class="quick-ref-title">WebX11 v4.2</div>
+ <div class="quick-ref-grid">
+ <div class="quick-ref-item"><span class="cmd">help</span><span class="desc">Show all commands</span></div>
+ <div class="quick-ref-item"><span class="cmd">cls</span><span class="desc">Clear terminal</span></div>
+ <div class="quick-ref-item"><span class="cmd">ll</span><span class="desc">List files</span></div>
+ <div class="quick-ref-item"><span class="cmd">cd &lt;dir&gt;</span><span class="desc">Change directory</span></div>
+ <div class="quick-ref-item"><span class="cmd">cat &lt;f&gt;</span><span class="desc">View file</span></div>
+ <div class="quick-ref-item"><span class="cmd">edit &lt;f&gt;</span><span class="desc">Edit file</span></div>
+ <div class="quick-ref-item"><span class="cmd">ai &lt;msg&gt;</span><span class="desc">Chat with AI</span></div>
+ <div class="quick-ref-item"><span class="cmd">search &lt;q&gt;</span><span class="desc">Web search</span></div>
+ <div class="quick-ref-item"><span class="cmd">find &lt;q&gt;</span><span class="desc">File search</span></div>
+ <div class="quick-ref-item"><span class="cmd">diff a b</span><span class="desc">Compare files</span></div>
+ <div class="quick-ref-item"><span class="cmd">ollamaconnect</span><span class="desc">Connect AI</span></div>
+ <div class="quick-ref-item"><span class="cmd">ai-allow</span><span class="desc">Allow destructive</span></div>
+ <div class="quick-ref-item"><span class="cmd">ai-deny</span><span class="desc">Deny destructive</span></div>
+ <div class="quick-ref-item"><span class="cmd">modules</span><span class="desc">Module Manager</span></div>
+ <div class="quick-ref-item"><span class="cmd">skill-list</span><span class="desc">List skills</span></div>
+ <div class="quick-ref-item"><span class="cmd">alias</span><span class="desc">List aliases</span></div>
+ <div class="quick-ref-item"><span class="cmd">preview</span><span class="desc">HTML preview</span></div>
+ <div class="quick-ref-item"><span class="cmd">upload</span><span class="desc">Upload file</span></div>
+ </div>
+ </div>
+ </section>
+
+ <!-- ── Changelog ── -->
+ <section id="history">
+ <h2>📋 Changelog</h2>
+
+ <div class="version-item">
+ <div class="v-header"><span class="v-num">v4.2 — May 2026</span><span class="v-date">2026-05-25</span></div>
+ <ul>
+ <li><strong>Editor</strong> — <code>edit &lt;path&gt;</code> opens inline VFS editor modal</li>
+ <li><strong>Diff</strong> — <code>diff a b</code> LCS-based comparison + <code>diff --module</code></li>
+ <li><strong>Aliases</strong> — <code>ll</code>, <code>h</code>, <code>cls</code> defaults + custom alias definition</li>
+ <li><strong>File Finder</strong> — <code>find &lt;query&gt;</code> recursive VFS search</li>
+ <li>Agent loop: capped at 5 turns (was 1), <code>edit_module</code> destructive confirmation, model auto-validation</li>
+ <li><code>ai-stop</code> now cancels chat + stream, not just tool calls</li>
+ <li>Unknown commands awaited, "Command not found" suppressed on AI route</li>
+ <li><code>loopResult</code> scope fix — no more undefined error on AI fail</li>
+ <li>Preview iframe allows JS (<code>allow-scripts</code>), cache bump <code>v=18</code></li>
+ <li>Splash fallback + startup modal list all 12 ext modules</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
+ <div class="v-header"><span class="v-num">v4.1</span><span class="v-date">2026-05-23</span></div>
+ <ul>
+ <li>AI Agent with full tool-calling loop (15+ tools)</li>
+ <li>Inline destructive confirmation bar with 120s countdown timer</li>
+ <li>Skills system (8 CLI commands + UI modal)</li>
+ <li>Voice input (🎤 button, SpeechRecognition, show/hide toggle)</li>
+ <li>Sound notification (🔊 toggle, WebAudio ding)</li>
+ <li>AI spinner in status bar</li>
+ <li>Session system (save/load/recent/stats)</li>
+ <li>WebDAV sync (8 commands)</li>
+ <li>Web search (SearXNG + DDG + URL fetch/extract)</li>
+ <li>Config manager (6 commands + UI)</li>
+ <li>Startup modules selector (⚙ gear on splash)</li>
+ <li>Code editor modal (module editing, save & test live)</li>
+ <li>HTML preview via srcdoc (mobile)</li>
+ <li>File/dir autocomplete, pipe support</li>
+ <li>Smart context profiles, dynamic prompts with variables</li>
+ <li>Server monitor, ChatStudio overlay</li>
+ <li>Modular architecture: 23 JS modules, async loader</li>
+ </ul>
+ </div>
+
+ <div class="version-item">
+ <div class="v-header"><span class="v-num">v4.0</span><span class="v-date">2026-05-20</span></div>
+ <ul>
+ <li>Monolith refactored into core/ext modules</li>
+ <li>Async module loader with splash screen</li>
+ <li>Config manager modal</li>
+ <li>Bootloader fix for disabled modules</li>
+ </ul>
+ </div>
+ </section>
+</main>
+
+<footer>
+ <div class="links">
+ <a href="./index.html">Launch WebX11</a>
+ <a href="https://ollama.com" target="_blank">Ollama</a>
+ </div>
+ <p>WebX11 v4.2 · 2026</p>
+ <p class="tagline">Happy Hacking 🖥️🤖🎤</p>
+</footer>
+
+<a href="#" class="back-top" id="backTop">↑</a>
+
+<script>
+/* ── Back to Top ── */
+const bt = document.getElementById('backTop');
+window.addEventListener('scroll',()=>{bt.classList.toggle('visible',window.scrollY>300)});
+
+/* ── Search ── */
+function searchDocs(){
+ const q=document.getElementById('search-input').value.toLowerCase().trim();
+ const msg=document.getElementById('search-result-msg');
+ if(!q){msg.style.display='none';return}
+ const sections=document.querySelectorAll('section');
+ let found=false;
+ sections.forEach(s=>{
+ if(s.textContent.toLowerCase().includes(q)){
+ s.scrollIntoView({behavior:'smooth'});
+ s.style.border='2px solid var(--accent)';s.style.borderRadius='12px';
+ s.style.padding='0 20px';s.style.transition='border .3s';
+ setTimeout(()=>{s.style.border='none';s.style.padding='0'},2000);
+ found=true;
+ }
+ });
+ if(!found){
+ msg.textContent='No results for "'+q+'"';
+ msg.style.display='block';
+ setTimeout(()=>{msg.style.display='none'},3000);
+ }else{msg.style.display='none'}
+}
+document.getElementById('search-input').addEventListener('keypress',e=>{if(e.key==='Enter')searchDocs()});
+
+/* ── Smooth scroll nav ── */
+document.querySelectorAll('.nav-link').forEach(l=>{
+ l.addEventListener('click',e=>{
+ e.preventDefault();
+ const t=document.querySelector(l.getAttribute('href'));
+ if(t)t.scrollIntoView({behavior:'smooth'});
+ });
+});
+
+console.log('WebX11 v4.2 Docs — Modern');
+</script>
+</body>
+</html>
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..f1f315e
--- /dev/null
+++ b/index.html
@@ -0,0 +1,810 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+ <meta name="theme-color" content="#000000">
+ <title>WebX11 Module System v4.2</title>
+ <style>
+ :root {
+ --bg-color: #000000;
+ --bg-secondary: #0a0a0a;
+ --bg-tertiary: #111111;
+ --text-color: #00ff00;
+ --text-muted: #888888;
+ --accent-color: #00aa00;
+ --border-color: #005500;
+ --error-color: #ff5555;
+ --warning-color: #ffaa00;
+ --info-color: #00aaff;
+ --success-color: #00ff00;
+ --ai-text-color: #aaddff;
+ --ai-bg-color: #001a26;
+ --font-primary: 'Courier New', Courier, monospace;
+ }
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
+ body {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ font-family: var(--font-primary);
+ font-size: 16px;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ @media (max-width: 600px) { body { font-size: 12px; } }
+ header {
+ border-bottom: 1px solid var(--border-color);
+ padding: 10px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--bg-secondary);
+ flex-shrink: 0;
+ }
+ .header-title { font-weight: bold; font-size: 1.1em; }
+ .header-version { font-size: 0.7em; color: var(--text-muted); }
+ .status-pill {
+ font-size: 0.8em;
+ padding: 2px 6px;
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ background: var(--bg-tertiary);
+ }
+ #terminal-container {
+ flex-grow: 1;
+ overflow-y: auto;
+ padding: 10px;
+ scroll-behavior: smooth;
+ -webkit-overflow-scrolling: touch;
+ }
+ #ai-confirm-area {
+ display: none;
+ flex-shrink: 0;
+ padding: 10px;
+ margin: 0 8px 4px;
+ border: 1px solid var(--warning-color);
+ border-radius: 6px;
+ background: var(--bg-secondary);
+ z-index: 200;
+ font-size: 0.95em;
+ min-height: 48px;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: center;
+ pointer-events: auto;
+ }
+ #ai-confirm-area.visible { display: flex; }
+ #ai-confirm-area.hidden { display: none !important; }
+ #ai-confirm-area .confirm-desc {
+ flex: 1;
+ min-width: 120px;
+ color: var(--warning-color);
+ word-break: break-word;
+ }
+ #ai-confirm-area .confirm-btn {
+ padding: 12px 24px;
+ border: 1px solid var(--success-color);
+ color: var(--success-color);
+ background: transparent;
+ border-radius: 6px;
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 1.1em;
+ font-weight: bold;
+ touch-action: manipulation;
+ min-height: 48px;
+ min-width: 90px;
+ user-select: none;
+ -webkit-user-select: none;
+ transition: background 0.15s;
+ }
+ #ai-confirm-area .confirm-btn:active {
+ background: rgba(0, 255, 0, 0.15);
+ }
+ #ai-confirm-area .confirm-btn.deny {
+ border-color: var(--error-color);
+ color: var(--error-color);
+ }
+ #ai-confirm-area .confirm-timer {
+ font-size: 0.8em;
+ color: var(--warning-color);
+ flex-shrink: 0;
+ }
+ #ai-confirm-area .confirm-timer.urgent {
+ color: var(--error-color);
+ font-weight: bold;
+ }
+ #ai-confirm-area .confirm-timer.critical {
+ color: #ff0000;
+ font-weight: bold;
+ animation: pulse 1s infinite;
+ }
+ /* ── Startup Modules Modal ── */
+ .output-line { white-space: pre-wrap; word-break: break-word; line-height: 1.4; }
+ .output-line.command-prompt { color: var(--text-color); font-weight: bold; }
+ .output-line.error { color: var(--error-color); }
+ .output-line.warning { color: var(--warning-color); }
+ .output-line.info { color: var(--info-color); }
+ .output-line.success { color: var(--success-color); }
+ .output-line.text-muted { color: var(--text-muted); }
+ .output-line.ollama {
+ color: var(--ai-text-color);
+ background: var(--ai-bg-color);
+ padding: 8px;
+ border-radius: 4px;
+ border: 1px solid #003344;
+ margin-bottom: 10px;
+ position: relative;
+ }
+ #input-area {
+ border-top: 1px solid var(--border-color);
+ padding: 8px 10px;
+ background: var(--bg-secondary);
+ display: flex;
+ align-items: flex-start;
+ flex-shrink: 0;
+ position: relative;
+ }
+ .prompt-label { margin-right: 8px; color: var(--accent-color); white-space: nowrap; margin-top: 6px; }
+ #command-input {
+ flex-grow: 1;
+ background: transparent;
+ border: none;
+ color: var(--text-color);
+ font-family: var(--font-primary);
+ font-size: inherit;
+ outline: none;
+ resize: none;
+ overflow-y: auto;
+ max-height: 120px;
+ line-height: 1.4;
+ padding: 4px 0;
+ }
+ #send-btn {
+ flex-shrink: 0;
+ min-width: 44px;
+ touch-action: manipulation;
+ margin-top: 2px;
+ }
+ #quick-actions { scrollbar-width: none; -ms-overflow-style: none; }
+ #quick-actions::-webkit-scrollbar { display: none; }
+ @media (max-width: 480px) {
+ #input-area { padding: 6px; }
+ #command-input { font-size: 0.9em; }
+ .prompt-label { font-size: 0.8em; max-width: 80px; overflow: hidden; text-overflow: ellipsis; }
+ }
+ #autocomplete-dropdown {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ right: 0;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-bottom: none;
+ max-height: 200px;
+ overflow-y: auto;
+ display: none;
+ z-index: 100;
+ }
+ .autocomplete-item {
+ padding: 8px 12px;
+ cursor: pointer;
+ border-bottom: 1px solid #111;
+ }
+ .autocomplete-item:hover, .autocomplete-item.selected {
+ background: var(--bg-tertiary);
+ color: var(--success-color);
+ }
+ .autocomplete-item .cmd-name { color: var(--text-color); font-weight: bold; }
+ .autocomplete-item .cmd-desc { color: var(--text-muted); font-size: 0.85em; }
+ #quick-actions {
+ display: flex;
+ gap: 8px;
+ padding: 8px 10px;
+ overflow-x: auto;
+ border-top: 1px solid #111;
+ background: var(--bg-tertiary);
+ flex-shrink: 0;
+ }
+ .qa-btn {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ color: var(--text-color);
+ padding: 8px 12px;
+ border-radius: 4px;
+ font-family: inherit;
+ font-size: 0.9em;
+ cursor: pointer;
+ white-space: nowrap;
+ touch-action: manipulation;
+ }
+ .qa-btn:active { background: var(--border-color); }
+ #status-bar {
+ border-top: 1px solid var(--border-color);
+ padding: 4px 10px;
+ font-size: 0.8em;
+ display: flex;
+ justify-content: space-between;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ }
+ .modal-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.85);
+ display: none;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ }
+ .modal-content {
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ width: 90%;
+ max-width: 500px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ border-radius: 4px;
+ }
+ .modal-header {
+ padding: 10px;
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ font-weight: bold;
+ }
+ .modal-body { padding: 10px; overflow-y: auto; flex-grow: 1; }
+ .modal-footer {
+ padding: 10px;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ }
+ .btn {
+ padding: 6px 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ color: var(--text-color);
+ cursor: pointer;
+ border-radius: 3px;
+ }
+ .btn:hover { background: var(--bg-tertiary); }
+ .btn-danger { border-color: var(--error-color); color: var(--error-color); }
+ .file-item, .module-item, .model-item {
+ padding: 8px;
+ border-bottom: 1px solid #111;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ .file-item:hover, .module-item:hover, .model-item:hover { background: var(--bg-tertiary); }
+ .file-item.dir { color: var(--info-color); }
+ .module-item.active { border-left: 3px solid var(--success-color); }
+ .module-item.disabled { opacity: 0.5; }
+ .model-item.connected { color: var(--success-color); }
+ .hidden { display: none !important; }
+ .text-center { text-align: center; }
+ .clickable-file, .clickable-dir {
+ cursor: pointer;
+ display: inline-block;
+ padding: 1px 3px;
+ border: 1px solid transparent;
+ border-radius: 2px;
+ margin: 1px 0;
+ }
+ .clickable-file:hover { background: rgba(0,255,0,0.1); border-color: var(--border-color); }
+ .module-url-input {
+ width: 100%;
+ padding: 8px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ color: var(--text-color);
+ font-family: var(--font-primary);
+ margin-bottom: 10px;
+ }
+ .ollama-status {
+ padding: 10px;
+ margin-bottom: 10px;
+ border-radius: 4px;
+ }
+ .ollama-status.connected { background: rgba(0, 255, 0, 0.1); border: 1px solid var(--success-color); }
+ .ollama-status.disconnected { background: rgba(255, 85, 85, 0.1); border: 1px solid var(--error-color); }
+ .copy-btn {
+ position: absolute;
+ top: 5px;
+ right: 5px;
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid var(--border-color);
+ color: var(--text-muted);
+ font-size: 0.7em;
+ padding: 2px 6px;
+ cursor: pointer;
+ border-radius: 3px;
+ font-family: sans-serif;
+ }
+ .copy-btn:hover {
+ color: var(--text-color);
+ background: rgba(0, 255, 0, 0.1);
+ }
+ .module-badge {
+ display: inline-block;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 0.75em;
+ margin-left: 5px;
+ }
+ .module-badge.builtin { background: var(--bg-tertiary); border: 1px solid var(--border-color); }
+ .fetch-content {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ padding: 10px;
+ margin: 5px 0;
+ border-radius: 4px;
+ max-height: 300px;
+ overflow-y: auto;
+ font-size: 0.85em;
+ }
+ .search-result {
+ margin-bottom: 10px;
+ padding: 8px;
+ background: var(--bg-tertiary);
+ border-radius: 4px;
+ border-left: 3px solid var(--info-color);
+ }
+ .search-result a {
+ color: var(--info-color);
+ text-decoration: none;
+ }
+ .search-result a:hover {
+ text-decoration: underline;
+ }
+
+ /* ── Splash Screen ── */
+ #splash-overlay {
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: var(--bg-color);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ z-index: 2000;
+ transition: opacity 0.3s ease;
+ }
+ #splash-overlay.hidden {
+ opacity: 0;
+ pointer-events: none;
+ }
+ #splash-title {
+ font-size: 1.8em;
+ font-weight: bold;
+ margin-bottom: 4px;
+ }
+ #splash-version {
+ font-size: 0.8em;
+ color: var(--text-muted);
+ margin-bottom: 24px;
+ }
+ #splash-progress-track {
+ width: 260px;
+ max-width: 70vw;
+ height: 8px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 12px;
+ }
+ #splash-progress-fill {
+ height: 100%;
+ width: 0%;
+ background: var(--accent-color);
+ transition: width 0.15s ease;
+ border-radius: 3px;
+ }
+ #splash-phase {
+ font-size: 0.75em;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+ }
+ #splash-filename {
+ font-size: 0.85em;
+ color: var(--info-color);
+ margin-bottom: 16px;
+ }
+ #splash-cursor {
+ display: inline-block;
+ width: 8px;
+ height: 14px;
+ background: var(--text-color);
+ animation: blink 1s step-end infinite;
+ }
+ @keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+ }
+ #splash-gear {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ font-size: 1.4em;
+ cursor: pointer;
+ opacity: 0.5;
+ transition: opacity 0.2s;
+ touch-action: manipulation;
+ padding: 8px;
+ }
+ #splash-gear:hover { opacity: 1; }
+ #splash-error {
+ color: var(--error-color);
+ font-size: 0.85em;
+ margin-top: 12px;
+ text-align: center;
+ display: none;
+ }
+ #splash-error.show { display: block; }
+
+ /* ── Startup Modules Modal ── */
+ .mod-card {
+ display: flex;
+ align-items: center;
+ padding: 10px 8px;
+ border-bottom: 1px solid #111;
+ min-height: 44px;
+ touch-action: manipulation;
+ }
+ .mod-card:last-child { border-bottom: none; }
+ .mod-card.disabled { opacity: 0.4; pointer-events: none; }
+ .mod-card label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-grow: 1;
+ cursor: pointer;
+ }
+ .mod-card input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--accent-color);
+ flex-shrink: 0;
+ }
+ .mod-card .mod-name {
+ font-size: 0.9em;
+ flex-shrink: 0;
+ }
+ .mod-card .mod-badge {
+ font-size: 0.7em;
+ padding: 2px 6px;
+ border-radius: 3px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ color: var(--text-muted);
+ margin-left: auto;
+ }
+ .mod-card .mod-badge.foundation {
+ background: #001a00;
+ border-color: var(--accent-color);
+ color: var(--accent-color);
+ }
+ .mod-card .mod-badge.ext {
+ background: #1a001a;
+ border-color: #550055;
+ color: #aa55ff;
+ }
+ .mod-section-header {
+ padding: 8px 8px 4px;
+ font-size: 0.75em;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ }
+ @media (max-width: 400px) {
+ .mod-card { padding: 10px 6px; }
+ .mod-card .mod-name { font-size: 0.82em; }
+ }
+ </style>
+</head>
+<body>
+ <header>
+ <div>
+ <span class="header-title">WebX11</span>
+ <span class="header-version">v4.2</span>
+ </div>
+ <div id="header-status" class="status-pill">Mem: --</div>
+ </header>
+
+ <div id="terminal-container">
+<div class="output-line info">WebX11 Module System v4.2 - Unix-like Terminal</div>
+ <div class="output-line info">Commands: ls, cd, pwd, cat, echo, rm, mkdir, mv, cp, touch, tree, find, grep</div>
+ <div class="output-line info">Type 'help' for all commands</div>
+ <div class="output-line"></div>
+ <div class="output-line info loading" id="loading-indicator">Loading modules...</div>
+ </div>
+
+ <!-- AI Confirm Area — fixed outside scrollable output, always visible -->
+ <div id="ai-confirm-area" class="hidden"></div>
+
+ <div id="quick-actions">
+ <button class="qa-btn" onclick="webx11.executeCommand('ls')">📁 Files</button>
+ <button class="qa-btn" onclick="webx11.executeCommand('help')">📖 Help</button>
+ <button class="qa-btn" onclick="webx11.executeCommand('modules')">🧩 Modules</button>
+ <button class="qa-btn" onclick="webx11.openOllamaManager()">🤖 Ollama</button>
+ <button class="qa-btn" onclick="webx11.openSkillsManager()">📚 Skills</button>
+ <button class="qa-btn" onclick="webx11.openConfigManager()">⚙ Config</button>
+ <button class="qa-btn" onclick="webx11.executeCommand('clear')">🧹 Clear</button>
+ <button class="qa-btn" id="voice-toggle-btn" onclick="webx11.toggleMicVisibility()">🎤 Mic</button>
+ <button class="qa-btn" id="sound-toggle-btn">🔊 Sound</button>
+ </div>
+
+ <div id="input-area">
+ <span class="prompt-label">$</span>
+ <textarea id="command-input" rows="1" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" placeholder="Type a command..." style="resize:none; overflow-y:auto; max-height:120px;"></textarea>
+ <button id="send-btn" onclick="var i=document.getElementById('command-input');webx11.executeCommand(i.value);i.value='';i.style.height='auto'" style="background:var(--bg-tertiary); border:1px solid var(--border-color); color:var(--success-color); padding:6px 12px; border-radius:4px; cursor:pointer; font-family:inherit; font-size:0.9em; margin-left:6px; white-space:nowrap; flex-shrink:0;">⏎ Send</button>
+ <div id="autocomplete-dropdown"></div>
+ </div>
+
+ <div id="status-bar">
+ <span id="status-text">Ready</span>
+ <span id="network-status">📶 Online</span>
+ <button id="ai-cancel-btn" style="display:none; background:var(--error-color); color:#fff; border:none; border-radius:4px; padding:2px 10px; cursor:pointer; font-family:inherit; font-size:0.8em; touch-action:manipulation; min-height:28px;" onclick="webx11.executeCommand('ai-stop')">⏹ Stop</button>
+ </div>
+
+ <!-- File Browser Modal -->
+ <div id="file-browser" class="modal-overlay">
+ <div class="modal-content">
+ <div class="modal-header">
+ <span>File Browser</span>
+ <span style="cursor:pointer" onclick="webx11.closeFileBrowser()">✕</span>
+ </div>
+ <div class="modal-body" id="fb-content"></div>
+ <div class="modal-footer">
+ <button class="btn" onclick="document.getElementById('file-upload-input').click()">⬆️ Upload</button>
+ <button class="btn" onclick="webx11.closeFileBrowser()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Module Manager Modal -->
+ <div id="module-manager" class="modal-overlay">
+ <div class="modal-content" style="max-width: 600px;">
+ <div class="modal-header">
+ <span>Module Manager</span>
+ <span style="cursor:pointer" onclick="webx11.closeModuleManager()">✕</span>
+ </div>
+ <div class="modal-body">
+ <div style="margin-bottom: 15px;">
+ <input type="text" id="module-url-input" class="module-url-input" placeholder="Enter module URL (https://example.com/module.js)">
+ <button class="btn" onclick="webx11.loadModuleFromURL()" style="width: 100%;">Load Module from URL</button>
+ </div>
+ <h4 style="color: var(--info-color); margin-bottom: 10px;">Loaded Modules</h4>
+ <div id="module-list"></div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-danger" onclick="webx11.clearAllModules()">Clear All</button>
+ <button class="btn" onclick="webx11.closeModuleManager()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Ollama Manager Modal -->
+ <div id="ollama-manager" class="modal-overlay">
+ <div class="modal-content" style="max-width: 600px;">
+ <div class="modal-header">
+ <span>Ollama AI Manager</span>
+ <span style="cursor:pointer" onclick="webx11.closeOllamaManager()">✕</span>
+ </div>
+ <div class="modal-body">
+ <div id="ollama-status" class="ollama-status disconnected">
+ <strong>Status:</strong> <span id="ollama-status-text">Disconnected</span>
+ </div>
+ <div style="margin-bottom: 15px;">
+ <label style="display: block; margin-bottom: 5px; color: var(--text-muted);">Ollama Endpoint:</label>
+ <input type="text" id="ollama-endpoint" class="module-url-input" value="http://localhost:11434" placeholder="http://localhost:11434">
+ <button class="btn" onclick="webx11.connectOllama()" style="width: 100%;">Connect</button>
+ </div>
+ <h4 style="color: var(--info-color); margin-bottom: 10px;">Available Models</h4>
+ <div id="ollama-models"></div>
+ <div style="margin-top: 15px;">
+ <label style="display: block; margin-bottom: 5px; color: var(--text-muted);">Current Model:</label>
+ <select id="ollama-current-model" class="module-url-input">
+ <option value="">Select a model</option>
+ </select>
+ <button class="btn" onclick="webx11.setOllamaModel()" style="width: 100%;">Set Model</button>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn" onclick="webx11.closeOllamaManager()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Splash Screen -->
+ <div id="splash-overlay">
+ <span id="splash-gear" onclick="openStartupModulesSplash(event)" title="Select startup modules">⚙</span>
+ <div id="splash-title">⚡ WebX11</div>
+ <div id="splash-version">v4.2</div>
+ <div id="splash-progress-track"><div id="splash-progress-fill"></div></div>
+ <div id="splash-phase">Initializing...</div>
+ <div id="splash-filename"></div>
+ <div><span id="splash-cursor"></span></div>
+ <div id="splash-error"></div>
+ </div>
+
+ <!-- Skills Manager Modal -->
+ <div id="skills-manager" class="modal-overlay">
+ <div class="modal-content" style="max-width: 700px; max-height: 85vh;">
+ <div class="modal-header">
+ <span>📚 Skills Manager</span>
+ <span style="cursor:pointer" onclick="webx11.closeSkillsManager()">✕</span>
+ </div>
+ <div class="modal-body" style="display:flex; flex-direction:column; gap:10px;">
+ <div style="flex-shrink:0;">
+ <h4 style="color: var(--info-color); margin-bottom: 8px;">Installed Skills</h4>
+ <div id="skills-list"></div>
+ </div>
+ <div id="skill-preview" style="display:none; flex-shrink:0; background:var(--bg-tertiary); border:1px solid var(--border-color); border-radius:4px; padding:10px; max-height:250px; overflow-y:auto; white-space:pre-wrap; font-family:var(--font-primary); font-size:0.85em; color:var(--text-color);">
+ <div style="display:flex; justify-content:space-between; margin-bottom:6px;">
+ <span id="skill-preview-title" style="color:var(--success-color); font-weight:bold;"></span>
+ <span style="cursor:pointer; color:var(--text-muted); font-size:1.2em;" onclick="webx11.closeSkillPreview()">✕</span>
+ </div>
+ <div id="skill-preview-content"></div>
+ </div>
+ <div style="flex-shrink:0;">
+ <h4 style="color: var(--info-color); margin-bottom: 8px;">Create New Skill</h4>
+ <input type="text" id="skill-name-input" class="module-url-input" placeholder="Skill name (e.g. web-scrape-and-summarize)">
+ <input type="text" id="skill-desc-input" class="module-url-input" style="margin-top:6px" placeholder="One-line description">
+ <input type="text" id="skill-tags-input" class="module-url-input" style="margin-top:6px" placeholder="Tags (comma-separated, e.g. web,research)">
+ <textarea id="skill-content-input" class="module-url-input" style="margin-top:6px; height:100px; resize:vertical; font-family:var(--font-primary)" placeholder="Markdown skill content (optional — will use template)"></textarea>
+ <button class="btn" onclick="webx11.createSkillFromUI()" style="width:100%; margin-top:6px; border-color:var(--success-color);color:var(--success-color)">Create Skill</button>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn" onclick="webx11.refreshSkillsList()">🔄 Refresh</button>
+ <button class="btn" onclick="webx11.closeSkillsManager()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Preview Modal -->
+ <div id="preview-modal" class="modal-overlay">
+ <div class="modal-content" style="max-width: 95%; max-height: 90vh; width: 95%;">
+ <div class="modal-header">
+ <span id="preview-title">📄 Preview</span>
+ <span style="cursor:pointer" onclick="webx11.closePreview()">✕</span>
+ </div>
+ <div class="modal-body" style="padding:0; overflow:hidden; height:75vh;">
+ <iframe id="preview-iframe" style="width:100%; height:100%; border:none; background:var(--bg-color);" sandbox="allow-same-origin allow-scripts"></iframe>
+ </div>
+ <div class="modal-footer">
+ <button class="btn" onclick="webx11.closePreview()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Config Manager Modal -->
+ <div id="config-manager" class="modal-overlay">
+ <div class="modal-content" style="max-width: 550px; max-height: 80vh;">
+ <div class="modal-header">
+ <span>⚙ Configuration</span>
+ <span style="cursor:pointer" onclick="webx11.closeConfigManager()">✕</span>
+ </div>
+ <div class="modal-body" id="config-manager-body">
+ <div id="config-list" style="margin-bottom:16px;"></div>
+ <div style="border-top:1px solid var(--border-color); padding-top:12px;">
+ <h4 style="color:var(--info-color); margin-bottom:8px;">Add / Update Config</h4>
+ <div style="display:flex; gap:6px; flex-wrap:wrap;">
+ <input type="text" id="config-key-input" class="module-url-input" placeholder="Key" style="flex:1; min-width:100px;">
+ <input type="text" id="config-value-input" class="module-url-input" placeholder="Value" style="flex:3; min-width:150px;">
+ <button class="btn" onclick="webx11.addConfigFromUI()" style="border-color:var(--success-color);color:var(--success-color); padding:6px 14px;">Add</button>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button class="btn" onclick="webx11.exportConfigFromUI()">📤 Export</button>
+ <button class="btn btn-danger" onclick="webx11.resetConfigFromUI()">🗑 Reset All</button>
+ <button class="btn" onclick="webx11.closeConfigManager()">Close</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Startup Modules Modal -->
+ <div id="startup-modules" class="modal-overlay">
+ <div class="modal-content" style="max-width: 500px;">
+ <div class="modal-header">
+ <span>⚙ Startup Modules</span>
+ <span style="cursor:pointer" onclick="var w=window.webx11;if(w&&typeof w.closeStartupModules==='function')w.closeStartupModules();else document.getElementById('startup-modules').style.display='none'">✕</span>
+ </div>
+ <div class="modal-body" id="startup-modules-body"></div>
+ <div class="modal-footer">
+ <button class="btn btn-danger" onclick="webx11.closeStartupModules()">Cancel</button>
+ <button class="btn" onclick="webx11.saveStartupModules()" style="border-color:var(--info-color);color:var(--info-color)">Save & Reload</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Code Editor Modal -->
+ <div id="code-editor" class="modal-overlay">
+ <div class="modal-content" style="max-width: 90vw; max-height: 90vh; width: 900px; height: 80vh;">
+ <div class="modal-header">
+ <span id="code-editor-title">📝 Edit Module</span>
+ <span style="cursor:pointer" onclick="if(webx11) webx11.closeCodeEditor()">✕</span>
+ </div>
+ <div class="modal-body" style="padding:0; display:flex; flex-direction:column; height:calc(100% - 100px);">
+ <div id="code-editor-info" style="padding:6px 10px; font-size:0.8em; color:var(--text-muted); border-bottom:1px solid var(--border-color); flex-shrink:0;"></div>
+ <textarea id="code-editor-textarea" style="flex:1; width:100%; border:none; background:var(--bg-tertiary); color:var(--text-color); font-family:monospace; font-size:13px; padding:10px; resize:none; outline:none; tab-size:2; white-space:pre; overflow:auto;"></textarea>
+ </div>
+ <div class="modal-footer">
+ <button class="btn btn-danger" onclick="if(webx11) webx11.closeCodeEditor()">Cancel</button>
+ <button class="btn" onclick="(async()=>{if(webx11)await webx11.saveCodeEditor()})()" style="border-color:var(--info-color);color:var(--info-color)">💾 Save & Test</button>
+ </div>
+ </div>
+ </div>
+
+ <input type="file" id="file-upload-input" style="display:none">
+
+
+ <!-- Gear splash function — works before webx11 initializes -->
+ <script>
+ function openStartupModulesSplash(event) {
+ event.stopPropagation();
+ var w = window.webx11;
+ if (w && typeof w.openStartupModules === 'function') {
+ w.openStartupModules();
+ } else {
+ var overlay = document.getElementById('startup-modules');
+ if (!overlay) return;
+ // Bare-bones render for when webx11 isn't ready yet
+ var body = document.getElementById('startup-modules-body');
+ if (body && !body.children.length) {
+ var mods = [
+ ['Foundation (always loaded)', ['virtual-fs.js','module-manager.js','ollama-manager.js','terminal.js'], true],
+ ['Built-in modules', ['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'], false],
+ ['Extensions', ['calc.js','voice.js','cron.js','theme.js','wpm.js','mindmap.js','webdav.js','skill-pack-core.js','editor.js','diff.js','alias.js','finder.js','filemgr.js'], false]
+ ];
+ var isSel = function(name) { return true; }; // default: all selected
+ mods.forEach(function(section) {
+ var h = document.createElement('div');
+ h.className = 'mod-section-header';
+ h.textContent = section[0];
+ body.appendChild(h);
+ section[1].forEach(function(name) {
+ var card = document.createElement('div');
+ card.className = 'mod-card' + (section[2] ? ' disabled' : '');
+ var label = document.createElement('label');
+ var cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = section[2] || isSel(name);
+ cb.disabled = section[2];
+ cb.dataset.name = name;
+ var nameSpan = document.createElement('span');
+ nameSpan.className = 'mod-name';
+ nameSpan.textContent = name;
+ var badgeSpan = document.createElement('span');
+ badgeSpan.className = 'mod-badge' + (section[2] ? ' foundation' : '');
+ badgeSpan.textContent = section[2] ? 'foundation' : 'built-in';
+ label.appendChild(cb);
+ label.appendChild(nameSpan);
+ card.appendChild(label);
+ card.appendChild(badgeSpan);
+ body.appendChild(card);
+ });
+ });
+ }
+ overlay.style.display = 'flex';
+ }
+ }
+ </script>
+
+ <!-- Async dependency-ordered module loader (replaces 26 synchronous script tags) -->
+ <script src="modules/core/module-loader.js?v=19"></script>
+
+</body>
+</html>
diff --git a/index.html.pre-refactor b/index.html.pre-refactor
new file mode 100644
index 0000000..c9779c9
--- /dev/null
+++ b/index.html.pre-refactor
@@ -0,0 +1,35 @@
+
+ // Initialize the modular system
+ const webx11 = new WebX11Terminal();
+ window.webx11 = webx11;
+ </script>
+
+ <!-- Core infrastructure modules -->
+ <script src="modules/core/virtual-fs.js?v=3"></script>
+ <script src="modules/core/module-manager.js?v=3"></script>
+ <script src="modules/core/ollama-manager.js?v=3"></script>
+ <script src="modules/core/terminal.js?v=3"></script>
+ <script src="modules/core/module-system.js?v=3"></script>
+ <script src="modules/core/module-fs.js?v=3"></script>
+ <script src="modules/core/module-modmgr.js?v=3"></script>
+ <script src="modules/core/module-ollama.js?v=3"></script>
+ <script src="modules/core/module-my.js?v=3"></script>
+ <script src="modules/core/module-searxng.js?v=3"></script>
+ <script src="modules/core/module-servermon.js?v=3"></script>
+ <script src="modules/core/module-ollamamodels.js?v=3"></script>
+ <script src="modules/core/module-config.js?v=3"></script>
+ <script src="modules/core/module-simplegui.js?v=3"></script>
+ <script src="modules/core/module-io.js?v=3"></script>
+ <script src="modules/core/module-dynamicprompts.js?v=3"></script>
+ <script src="modules/core/module-aiassist.js?v=3"></script>
+ <script src="modules/core/module-aiasm.js?v=3"></script>
+ <script src="modules/core/module-smartctx.js?v=3"></script>
+ <script src="modules/core/module-duckduckgo.js?v=3"></script>
+ <script src="modules/core/module-fetcher.js?v=3"></script>
+ <script src="modules/ext/calc.js?v=3"></script>
+ <script src="modules/ext/voice.js?v=3"></script>
+ <script src="modules/ext/cron.js?v=3"></script>
+ <script src="modules/ext/theme.js?v=3"></script>
+ <script src="modules/ext/wpm.js?v=3"></script>
+</body>
+</html> \ No newline at end of file
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');
+ }
+}
diff --git a/modules/ext/alias.js b/modules/ext/alias.js
new file mode 100644
index 0000000..4ffab72
--- /dev/null
+++ b/modules/ext/alias.js
@@ -0,0 +1,108 @@
+class AliasModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.key = 'webx11_aliases';
+ this.defaults = { 'll': 'ls', 'h': 'help', 'cls': 'clear' };
+ this.aliases = this.load();
+ }
+
+ getHelpInfo() {
+ return {
+ 'alias': 'List, set, or show aliases (usage: alias [name=value])',
+ 'unalias': 'Remove a user-defined alias (usage: unalias <name>)',
+ };
+ }
+
+ load() {
+ try {
+ const data = localStorage.getItem(this.key);
+ return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults };
+ } catch (e) { return { ...this.defaults }; }
+ }
+ save() {
+ const custom = {};
+ Object.keys(this.aliases).forEach(k => {
+ if (!this.defaults[k]) custom[k] = this.aliases[k];
+ });
+ localStorage.setItem(this.key, JSON.stringify(custom));
+ }
+ registerCommands() {
+ const self = this;
+ // Register management commands
+ this.commands['alias'] = (args) => self.handleAlias(args);
+ this.commands['unalias'] = (args) => self.handleUnalias(args);
+ // Register each alias as a delegating command
+ Object.keys(this.aliases).forEach(name => {
+ if (name === 'alias' || name === 'unalias') return;
+ const target = this.aliases[name];
+ this.commands[name] = (args) => {
+ this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : ''));
+ };
+ });
+ }
+ handleAlias(args) {
+ if (!args[0]) {
+ // List all aliases
+ this.terminal.print('=== Aliases ===', 'info');
+ const keys = Object.keys(this.aliases);
+ if (keys.length === 0) {
+ this.terminal.print(' No aliases defined', 'warning');
+ } else {
+ keys.forEach(name => {
+ const mark = this.defaults[name] ? ' (built-in)' : '';
+ this.terminal.print(` ${name}=${this.aliases[name]}${mark}`);
+ });
+ }
+ this.terminal.print('Usage: alias &lt;name&gt;=&lt;value&gt;', 'info');
+ return;
+ }
+ // Parse name=value
+ const input = args.join(' ');
+ const eqIdx = input.indexOf('=');
+ if (eqIdx === -1) {
+ // Show specific alias value
+ const name = args[0];
+ if (this.aliases[name]) {
+ this.terminal.print(`${name}=${this.aliases[name]}`, 'info');
+ } else {
+ this.terminal.print(`Alias '${name}' not found`, 'error');
+ }
+ return;
+ }
+ const name = input.substring(0, eqIdx).trim();
+ const value = input.substring(eqIdx + 1).trim();
+ if (!name) return this.terminal.print('Invalid alias name', 'error');
+ if (!value) return this.terminal.print('Invalid alias value', 'error');
+ if (name === 'alias' || name === 'unalias') {
+ return this.terminal.print('Cannot alias reserved command name', 'error');
+ }
+ this.aliases[name] = value;
+ this.save();
+ // Register the new alias as a command immediately
+ if (!this.defaults[name]) {
+ const target = value;
+ this.commands[name] = (args) => {
+ this.terminal.executeCommand(target + (args.length ? ' ' + args.join(' ') : ''));
+ };
+ }
+ this.terminal.print(`Alias set: ${name}=${value}`, 'success');
+ }
+ handleUnalias(args) {
+ if (!args[0]) return this.terminal.print('Usage: unalias &lt;name&gt;', 'warning');
+ const name = args[0];
+ if (this.defaults[name]) {
+ return this.terminal.print(`Cannot unalias built-in alias '${name}'`, 'warning');
+ }
+ if (!this.aliases[name]) {
+ return this.terminal.print(`Alias '${name}' not found`, 'error');
+ }
+ delete this.aliases[name];
+ delete this.commands[name];
+ this.save();
+ this.terminal.print(`Alias removed: ${name}`, 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new AliasModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/ext/alias.js');
+}
diff --git a/modules/ext/calc.js b/modules/ext/calc.js
new file mode 100644
index 0000000..e28079e
--- /dev/null
+++ b/modules/ext/calc.js
@@ -0,0 +1,77 @@
+class CalcModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.units = {
+ 'km': { to: { 'miles': 0.621371, 'meters': 1000, 'feet': 3280.84 } },
+ 'miles': { to: { 'km': 1.60934, 'meters': 1609.34, 'feet': 5280 } },
+ 'meters': { to: { 'km': 0.001, 'miles': 0.000621371, 'feet': 3.28084 } },
+ 'feet': { to: { 'km': 0.0003048, 'miles': 0.000189394, 'meters': 0.3048 } },
+ 'kg': { to: { 'pounds': 2.20462, 'grams': 1000, 'ounces': 35.274 } },
+ 'pounds': { to: { 'kg': 0.453592, 'grams': 453.592, 'ounces': 16 } },
+ 'grams': { to: { 'kg': 0.001, 'pounds': 0.00220462, 'ounces': 0.035274 } },
+ 'ounces': { to: { 'kg': 0.0283495, 'pounds': 0.0625, 'grams': 28.3495 } },
+ 'celsius': { to: { 'fahrenheit': 'c2f', 'kelvin': 'c2k' } },
+ 'fahrenheit': { to: { 'celsius': 'f2c', 'kelvin': 'f2k' } },
+ 'kelvin': { to: { 'celsius': 'k2c', 'fahrenheit': 'k2f' } },
+ 'inch': { to: { 'cm': 2.54, 'mm': 25.4, 'feet': 0.0833333 } },
+ 'cm': { to: { 'inch': 0.393701, 'mm': 10, 'm': 0.01 } },
+ 'mm': { to: { 'cm': 0.1, 'inch': 0.0393701, 'm': 0.001 } },
+ 'liters': { to: { 'gallons': 0.264172, 'ml': 1000, 'cups': 4.22675 } },
+ 'gallons': { to: { 'liters': 3.78541, 'ml': 3785.41, 'cups': 16 } }
+ };
+ }
+ getHelpInfo() {
+ return { 'calc': 'calc <expression> or calc <value> <from-unit> to <to-unit> — Evaluate expressions or convert between units' };
+ }
+ registerCommands() {
+ this.commands['calc'] = (args) => this.calc(args);
+ }
+ calc(args) {
+ if (!args[0]) return this.terminal.print('Usage: calc <expression> or calc <value> <from-unit> to <to-unit>', 'warning');
+ const input = args.join(' ');
+ const convertMatch = input.match(/^([+-]?\d*\.?\d+)\s*(\w+)\s+(?:to|in|->)\s+(\w+)$/i);
+ if (convertMatch) {
+ return this.convert(parseFloat(convertMatch[1]), convertMatch[2].toLowerCase(), convertMatch[3].toLowerCase());
+ }
+ this.evaluate(input);
+ }
+ evaluate(expr) {
+ const sanitized = expr.replace(/[^0-9+\-*/.()%eE\s]/g, '');
+ if (!sanitized) return this.terminal.print('Invalid expression', 'error');
+ try {
+ const result = new Function('return (' + sanitized + ')')();
+ if (typeof result === 'number' && !isNaN(result)) {
+ this.terminal.print(`${sanitized} = ${result}`, 'success');
+ } else {
+ this.terminal.print('Invalid result', 'error');
+ }
+ } catch (e) {
+ this.terminal.print(`Error: ${e.message}`, 'error');
+ }
+ }
+ convert(value, from, to) {
+ if (!this.units[from]) return this.terminal.print(`Unknown unit: ${from}`, 'error');
+ if (!this.units[from].to[to]) {
+ const avail = Object.keys(this.units[from].to).join(', ');
+ return this.terminal.print(`Cannot convert ${from} to ${to}. Available: ${avail}`, 'warning');
+ }
+ const factor = this.units[from].to[to];
+ if (typeof factor === 'string') {
+ let result;
+ if (factor === 'c2f') result = value * 9/5 + 32;
+ else if (factor === 'c2k') result = value + 273.15;
+ else if (factor === 'f2c') result = (value - 32) * 5/9;
+ else if (factor === 'f2k') result = (value - 32) * 5/9 + 273.15;
+ else if (factor === 'k2c') result = value - 273.15;
+ else if (factor === 'k2f') result = (value - 273.15) * 9/5 + 32;
+ else return this.terminal.print('Conversion not implemented', 'error');
+ this.terminal.print(`${value} ${from} = ${result.toFixed(4)} ${to}`, 'success');
+ } else {
+ this.terminal.print(`${value} ${from} = ${(value * factor).toFixed(4)} ${to}`, 'success');
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new CalcModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/calc.js');
+}
diff --git a/modules/ext/cron.js b/modules/ext/cron.js
new file mode 100644
index 0000000..66cd18e
--- /dev/null
+++ b/modules/ext/cron.js
@@ -0,0 +1,110 @@
+class CronModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.jobs = {};
+ this.counter = 0;
+ this.MIN_INTERVAL = 5;
+ this.STORAGE_KEY = 'webx11_cron_jobs';
+ this._restoreJobs();
+ }
+ requirements() {
+ return { met: true, missing: [] };
+ }
+ getHelpInfo() {
+ return {
+ 'cron-every': 'cron-every <seconds> <command> — Schedule a command to run every N seconds',
+ 'cron-list': 'cron-list — List all active cron jobs',
+ 'cron-stop': 'cron-stop <id> — Stop a specific cron job',
+ 'cron-clear': 'cron-clear — Stop all cron jobs'
+ };
+ }
+ registerCommands() {
+ this.commands['cron-every'] = (args) => this.every(args);
+ this.commands['cron-list'] = () => this.list();
+ this.commands['cron-stop'] = (args) => this.stop(args);
+ this.commands['cron-clear'] = () => this.clear();
+ }
+ _restoreJobs() {
+ try {
+ const saved = localStorage.getItem(this.STORAGE_KEY);
+ if (!saved) return;
+ const data = JSON.parse(saved);
+ if (!Array.isArray(data) || data.length === 0) return;
+ let maxId = 0;
+ data.forEach(j => {
+ if (j.id > maxId) maxId = j.id;
+ if (this.counter < j.id) this.counter = j.id;
+ this.jobs[j.id] = {
+ cmd: j.cmd,
+ interval: j.interval,
+ runs: j.runs || 0,
+ timer: setInterval(() => {
+ if (!this.jobs[j.id]) return;
+ this.jobs[j.id].runs++;
+ this.terminal.print(`[cron #${j.id}] Running: ${j.cmd}`, 'info');
+ this.terminal.executeCommand(j.cmd);
+ }, j.interval)
+ };
+ });
+ this.counter = maxId;
+ if (data.length > 0) this.terminal.print(`↻ Restored ${data.length} cron job(s) from storage`, 'info');
+ } catch (e) { /* silently ignore corrupt data */ }
+ }
+ _saveJobs() {
+ try {
+ const data = Object.entries(this.jobs).map(([id, j]) => ({
+ id: parseInt(id),
+ cmd: j.cmd,
+ interval: j.interval,
+ runs: j.runs
+ }));
+ localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data));
+ } catch (e) { /* quota exceeded */ }
+ }
+ every(args) {
+ if (args.length < 2) return this.terminal.print('Usage: cron-every <seconds> <command>', 'warning');
+ const secs = parseInt(args[0]);
+ if (isNaN(secs) || secs < this.MIN_INTERVAL) return this.terminal.print(`Interval must be >= ${this.MIN_INTERVAL} seconds`, 'warning');
+ const cmd = args.slice(1).join(' ');
+ this.counter++;
+ const id = this.counter;
+ this.jobs[id] = {
+ cmd, interval: secs * 1000, runs: 0,
+ timer: setInterval(() => {
+ this.jobs[id].runs++;
+ this.terminal.print(`[cron #${id}] Running: ${cmd}`, 'info');
+ this.terminal.executeCommand(cmd);
+ }, secs * 1000)
+ };
+ this._saveJobs();
+ this.terminal.print(`Cron job #${id} started: "${cmd}" every ${secs}s`, 'success');
+ }
+ list() {
+ const ids = Object.keys(this.jobs);
+ if (ids.length === 0) return this.terminal.print('No active cron jobs', 'info');
+ this.terminal.print('=== Cron Jobs ===', 'info');
+ ids.forEach(id => {
+ const j = this.jobs[id];
+ this.terminal.print(` #${id}: "${j.cmd}" every ${j.interval / 1000}s (${j.runs} runs)`);
+ });
+ }
+ stop(args) {
+ if (!args[0]) return this.terminal.print('Usage: cron-stop <id>', 'warning');
+ const id = parseInt(args[0]);
+ if (!this.jobs[id]) return this.terminal.print(`Cron job #${id} not found`, 'error');
+ clearInterval(this.jobs[id].timer);
+ delete this.jobs[id];
+ this._saveJobs();
+ this.terminal.print(`Cron job #${id} stopped`, 'success');
+ }
+ clear() {
+ Object.keys(this.jobs).forEach(id => { clearInterval(this.jobs[id].timer); });
+ this.jobs = {};
+ localStorage.removeItem(this.STORAGE_KEY);
+ this.terminal.print('All cron jobs stopped', 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new CronModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/cron.js');
+}
diff --git a/modules/ext/diff.js b/modules/ext/diff.js
new file mode 100644
index 0000000..8b79b6b
--- /dev/null
+++ b/modules/ext/diff.js
@@ -0,0 +1,287 @@
+class DiffModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'diff': 'Show differences between two VFS files or compare module source (usage: diff <file1> <file2> or diff --module <name>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['diff'] = (args) => this.diff(args);
+ }
+ async diff(args) {
+ if (!args.length) return this.terminal.print('Usage: diff <file1> <file2> or diff --module <ModuleName>', 'warning');
+
+ if (args[0] === '--module') {
+ if (!args[1]) return this.terminal.print('Usage: diff --module <ModuleName>', 'warning');
+ return this._diffModule(args[1]);
+ }
+
+ return this._diffFiles(args[0], args[1]);
+ }
+ _diffFiles(path1, path2) {
+ let content1, content2;
+ try {
+ content1 = this.terminal.fs.readFile(path1);
+ content2 = this.terminal.fs.readFile(path2);
+ } catch (e) {
+ return this.terminal.print(`Error reading files: ${e.message}`, 'error');
+ }
+ if (content1 === null || content1 === undefined) {
+ return this.terminal.print(`File not found: ${path1}`, 'error');
+ }
+ if (content2 === null || content2 === undefined) {
+ return this.terminal.print(`File not found: ${path2}`, 'error');
+ }
+ const lines1 = String(content1).split('\n');
+ const lines2 = String(content2).split('\n');
+
+ // Strip trailing empty line if file ends with newline
+ if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop();
+ if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop();
+
+ if (lines1.length === 0 && lines2.length === 0) {
+ return this.terminal.print('Both files are empty — no differences', 'info');
+ }
+
+ // Compute LCS-based diff
+ const result = this._computeDiff(lines1, lines2);
+
+ // Render output
+ this.terminal.print(`--- ${path1}`, 'info');
+ this.terminal.print(`+++ ${path2}`, 'info');
+
+ if (result.length === 0) {
+ return this.terminal.print('Files are identical', 'success');
+ }
+
+ const maxLineNum = Math.max(lines1.length, lines2.length);
+ const lineNumWidth = String(maxLineNum).length;
+
+ result.forEach(part => {
+ if (part.type === 'same') {
+ // Print up to 3 unchanged lines for context, then collapse
+ if (part.lines.length <= 3) {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${line}`, 'dim');
+ });
+ } else {
+ // Show first line
+ this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim');
+ this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim');
+ // Show last line
+ const lastIdx = part.lines.length - 1;
+ const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim');
+ }
+ } else if (part.type === 'removed') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`-${ln} ${line}`, 'error');
+ });
+ } else if (part.type === 'added') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.bStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`+${ln} ${line}`, 'success');
+ });
+ }
+ });
+ }
+ _computeDiff(a, b) {
+ // Build LCS table
+ const m = a.length;
+ const n = b.length;
+ const dp = Array.from({ length: m + 1 }, () => new Int32Array(n + 1));
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (a[i - 1] === b[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // Backtrack to build diff parts
+ const parts = [];
+ let i = m, j = n;
+ let removed = [], added = [], same = [];
+ const aStart = 0, bStart = 0;
+
+ const flush = () => {
+ if (same.length > 0) {
+ parts.push({
+ type: 'same',
+ aStart: aStart + (parts.length > 0 ? parts.reduce((sum, p) => {
+ if (p.type === 'same') return sum + p.lines.length;
+ if (p.type === 'removed') return sum + p.lines.length;
+ return sum;
+ }, 0) : 0),
+ lines: same.splice(0)
+ });
+ }
+ if (removed.length > 0 || added.length > 0) {
+ if (removed.length > 0) {
+ const aIdx = parts.reduce((sum, p) => {
+ if (p.type === 'same') return sum + p.lines.length;
+ if (p.type === 'removed') return sum + p.lines.length;
+ return sum;
+ }, 0);
+ parts.push({ type: 'removed', aStart: aIdx, lines: removed.splice(0) });
+ }
+ if (added.length > 0) {
+ const bIdx = parts.reduce((sum, p) => {
+ if (p.type === 'same') return sum;
+ if (p.type === 'added') return sum + p.lines.length;
+ return sum;
+ }, 0);
+ parts.push({ type: 'added', bStart: bIdx, lines: added.splice(0) });
+ }
+ }
+ };
+
+ // Trace back through DP table
+ const trace = [];
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
+ trace.push({ type: 'same', lineA: a[i - 1], lineB: b[j - 1] });
+ i--; j--;
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ trace.push({ type: 'added', lineB: b[j - 1] });
+ j--;
+ } else {
+ trace.push({ type: 'removed', lineA: a[i - 1] });
+ i--;
+ }
+ }
+
+ // Reverse trace to get forward order
+ trace.reverse();
+
+ // Build parts from trace
+ let aIdx = 0, bIdx = 0;
+ for (const entry of trace) {
+ if (entry.type === 'same') {
+ same.push(entry.lineA);
+ aIdx++;
+ bIdx++;
+ } else if (entry.type === 'removed') {
+ flush();
+ removed.push(entry.lineA);
+ aIdx++;
+ } else if (entry.type === 'added') {
+ flush();
+ added.push(entry.lineB);
+ bIdx++;
+ }
+ }
+ flush();
+
+ return parts;
+ }
+ async _diffModule(modName) {
+ // Find module in registry
+ const entry = this.terminal.moduleManager.moduleRegistry.find(m => m.name === modName);
+ if (!entry) {
+ return this.terminal.print(`Module "${modName}" not found in registry`, 'error');
+ }
+
+ const url = entry.url;
+ if (!url) {
+ return this.terminal.print(`No source URL for module "${modName}"`, 'warning');
+ }
+
+ // Fetch original source
+ let originalSource;
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ originalSource = await resp.text();
+ } catch (e) {
+ return this.terminal.print(`Failed to fetch original source: ${e.message}`, 'error');
+ }
+
+ // Read local edited version
+ const localPath = `_mods/${modName}.js`;
+ let localSource;
+ let hasLocalEdit = false;
+ try {
+ localSource = this.terminal.fs.readFile(localPath);
+ if (localSource === null || localSource === undefined) {
+ hasLocalEdit = false;
+ } else {
+ hasLocalEdit = true;
+ }
+ } catch (e) {
+ hasLocalEdit = false;
+ }
+
+ if (!hasLocalEdit) {
+ // Try to read the module's currently loaded source
+ const loadedModule = this.terminal.moduleManager.modules[modName];
+ if (!loadedModule) {
+ return this.terminal.print(`Module "${modName}" is not loaded and has no local edits`, 'info');
+ }
+ return this.terminal.print(`Module "${modName}" has no local edits — source matches original`, 'success');
+ }
+
+ // Compare original source vs local edit
+ const lines1 = String(originalSource).split('\n');
+ const lines2 = String(localSource).split('\n');
+
+ // Strip trailing empty line if file ends with newline
+ if (lines1.length > 0 && lines1[lines1.length - 1] === '') lines1.pop();
+ if (lines2.length > 0 && lines2[lines2.length - 1] === '') lines2.pop();
+
+ this.terminal.print(`--- ${url}`, 'info');
+ this.terminal.print(`+++ ${localPath}`, 'info');
+
+ if (lines1.length === lines2.length && lines1.every((l, i) => l === lines2[i])) {
+ return this.terminal.print('No differences — local edit matches original', 'success');
+ }
+
+ const result = this._computeDiff(lines1, lines2);
+ const maxLineNum = Math.max(lines1.length, lines2.length);
+ const lineNumWidth = String(maxLineNum).length;
+
+ result.forEach(part => {
+ if (part.type === 'same') {
+ if (part.lines.length <= 3) {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${line}`, 'dim');
+ });
+ } else {
+ this.terminal.print(` ${String(part.aStart + 1).padStart(lineNumWidth)} ${part.lines[0]}`, 'dim');
+ this.terminal.print(` ${' '.repeat(lineNumWidth)} ... ${part.lines.length - 2} more unchanged lines`, 'dim');
+ const lastIdx = part.lines.length - 1;
+ const ln = String(part.aStart + lastIdx + 1).padStart(lineNumWidth);
+ this.terminal.print(` ${ln} ${part.lines[lastIdx]}`, 'dim');
+ }
+ } else if (part.type === 'removed') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.aStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`-${ln} ${line}`, 'error');
+ });
+ } else if (part.type === 'added') {
+ part.lines.forEach((line, i) => {
+ const ln = String(part.bStart + i + 1).padStart(lineNumWidth);
+ this.terminal.print(`+${ln} ${line}`, 'success');
+ });
+ }
+ });
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new DiffModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/diff.js');
+}
diff --git a/modules/ext/editor.js b/modules/ext/editor.js
new file mode 100644
index 0000000..571a535
--- /dev/null
+++ b/modules/ext/editor.js
@@ -0,0 +1,70 @@
+class EditorModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._vfsPath = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'edit': 'Open the code editor for a VFS file (usage: edit <path>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['edit'] = (args) => this.edit(args);
+ }
+ edit(args) {
+ if (!args[0]) return this.terminal.print('Usage: edit <path>', 'warning');
+ const path = args.join(' ');
+ const modal = document.getElementById('code-editor');
+ const title = document.getElementById('code-editor-title');
+ const textarea = document.getElementById('code-editor-textarea');
+ if (!modal || !textarea) return this.terminal.print('Editor modal not found', 'error');
+ // Read existing file content or use empty for new files
+ const content = this.terminal.fs.readFile(path);
+ const fileContent = (content !== null && content !== false) ? content : '';
+ // Set up modal
+ this._vfsPath = path;
+ title.textContent = '📝 Editing: ' + path;
+ textarea.value = fileContent;
+ modal.style.display = 'flex';
+ textarea.focus();
+ // Override save button to write back to VFS
+ const saveBtn = modal.querySelector('.modal-footer .btn:last-child');
+ const cancelBtn = modal.querySelector('.modal-footer .btn-danger');
+ if (saveBtn) {
+ saveBtn._origOnclick = saveBtn.onclick;
+ saveBtn.onclick = async () => {
+ const code = textarea.value;
+ const ok = this.terminal.fs.writeFile(this._vfsPath, code);
+ if (ok) {
+ this.terminal.print('✅ Saved to ' + this._vfsPath, 'success');
+ } else {
+ this.terminal.print('❌ Failed to save ' + this._vfsPath, 'error');
+ }
+ modal.style.display = 'none';
+ this._vfsPath = null;
+ // Restore original onclick handler
+ if (saveBtn._origOnclick) saveBtn.onclick = saveBtn._origOnclick;
+ delete saveBtn._origOnclick;
+ };
+ }
+ if (cancelBtn) {
+ cancelBtn._origOnclick = cancelBtn.onclick;
+ cancelBtn.onclick = () => {
+ modal.style.display = 'none';
+ this._vfsPath = null;
+ if (cancelBtn._origOnclick) cancelBtn.onclick = cancelBtn._origOnclick;
+ delete cancelBtn._origOnclick;
+ };
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new EditorModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/editor.js');
+}
diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js
new file mode 100644
index 0000000..5532cee
--- /dev/null
+++ b/modules/ext/filemgr.js
@@ -0,0 +1,755 @@
+class FileManagerModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.currentPath = [];
+ this._overlay = null;
+ this._selectedFile = null;
+ this._searchFilter = '';
+ this._rawMode = false;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'files': 'Open the file manager overlay with VFS directory listing and preview',
+ 'preview': 'Preview a specific file (usage: preview <path>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['files'] = () => this._openOverlay();
+ this.commands['preview'] = (args) => this._previewCommand(args);
+ }
+
+ _previewCommand(args) {
+ if (!args[0]) return this.terminal.print('Usage: preview <path>', 'warning');
+ const path = args.join(' ');
+ this._openOverlay();
+ this._navigateToPathFromParts(path);
+ const content = this.terminal.fs.readFile(path);
+ if (content === null || content === false) {
+ this._renderPreviewError('File not found: ' + path);
+ return;
+ }
+ this._selectedFile = path;
+ this._renderPreview(path, content);
+ this._highlightSelected();
+ }
+
+ _navigateToPathFromParts(path) {
+ const parts = path.split('/').filter(p => p);
+ if (parts.length <= 1) {
+ this.currentPath = [];
+ return;
+ }
+ this.currentPath = parts.slice(0, -1);
+ }
+
+ _detectType(filePath) {
+ const ext = filePath.split('.').pop().toLowerCase();
+ if (!ext || ext === filePath) return 'other';
+ const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
+ const videoExts = ['mp4', 'webm'];
+ const audioExts = ['mp3', 'wav', 'ogg'];
+ const codeExts = ['js', 'py', 'html', 'css', 'yaml', 'yml', 'toml'];
+ if (ext === 'md') return 'md';
+ if (imageExts.includes(ext)) return 'image';
+ if (videoExts.includes(ext)) return 'video';
+ if (audioExts.includes(ext)) return 'audio';
+ if (codeExts.includes(ext)) return 'code';
+ if (ext === 'json') return 'json';
+ if (ext === 'csv') return 'csv';
+ if (ext === 'pdf') return 'pdf';
+ if (ext === 'svg') return 'svg';
+ if (ext === 'txt') return 'text';
+ return 'other';
+ }
+
+ _renderMarkdown(text) {
+ if (!text) return '';
+ try {
+ let html = text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+ // code blocks first
+ html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
+ // inline code
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
+ // headers
+ html = html.replace(/^### (.+)$/gm, '<h3 style="color:var(--accent-color);margin:8px 0 4px">$1</h3>');
+ html = html.replace(/^## (.+)$/gm, '<h2 style="color:var(--accent-color);margin:10px 0 4px">$1</h2>');
+ html = html.replace(/^# (.+)$/gm, '<h1 style="color:var(--accent-color);margin:12px 0 6px">$1</h1>');
+ // bold
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+ // italic
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
+ // links
+ html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:var(--info-color)">$1</a>');
+ // horizontal rules
+ html = html.replace(/^---$/gm, '<hr style="border-color:var(--border-color);margin:8px 0">');
+ // unordered lists
+ html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li style="margin-left:16px;list-style:disc">$1</li>');
+ // paragraphs - double newlines
+ html = html.replace(/\n\n/g, '</p><p style="margin:6px 0">');
+ html = '<p style="margin:6px 0">' + html + '</p>';
+ return html;
+ } catch (e) {
+ return '<pre style="white-space:pre-wrap">' + text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>';
+ }
+ }
+
+ _getPreviewHtml(path, content) {
+ if (content === null || content === false) {
+ return '<div style="color:var(--error-color);padding:20px">File not found: ' + this._escapeHtml(path) + '</div>';
+ }
+
+ const type = this._detectType(path);
+ const ext = path.split('.').pop().toLowerCase();
+ const fileName = path.split('/').pop() || path;
+
+ // Handle binary detection for media types
+ const isLikelyBinary = typeof content === 'string' && content.length > 0 && !content.startsWith('\n') && !content.startsWith('\r') && !content.startsWith(' ') && !content.startsWith('<') && !content.startsWith('{') && !content.startsWith('[') && content.charCodeAt(0) < 32 && content.charCodeAt(0) !== 9 && content.charCodeAt(0) !== 10 && content.charCodeAt(0) !== 13;
+
+ const isTextContent = typeof content === 'string' && (content.startsWith('{') || content.startsWith('[') || content.startsWith('<') || content.startsWith('#') || /^[\w\s\n\r\t.,;:!?()\-=+*@#$%^&"'\/\\|`~]/.test(content.substring(0, 100)));
+
+ if (type === 'md' && isTextContent) {
+ return '<div class="markdown-preview" style="padding:12px;font-family:var(--font-primary);line-height:1.5">' + this._renderMarkdown(content) + '</div>';
+ }
+
+ if (type === 'text') {
+ const truncated = content.substring(0, 2000);
+ const extra = content.length > 2000 ? '<div style="color:var(--text-muted);margin-top:8px">(truncated to 2000 chars)</div>' : '';
+ return '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(truncated) + extra + '</pre>';
+ }
+
+ if (type === 'json') {
+ try {
+ const parsed = JSON.parse(content);
+ const formatted = JSON.stringify(parsed, null, 2);
+ const html = this._syntaxHighlightJson(formatted);
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto;line-height:1.4">' + html + '</pre>';
+ } catch (e) {
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>';
+ }
+ }
+
+ if (type === 'code') {
+ const lines = content.split('\n');
+ const totalLines = lines.length;
+ const maxLines = 150;
+ const displayLines = lines.slice(0, maxLines);
+ let html = '<table style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0"><tbody>';
+ displayLines.forEach((line, i) => {
+ const lineNum = i + 1;
+ const escaped = this._escapeHtml(line);
+ html += '<tr><td style="color:var(--text-muted);padding:0 8px 0 4px;text-align:right;user-select:none;border-right:1px solid var(--border-color);vertical-align:top;width:40px">' + lineNum + '</td>';
+ html += '<td style="padding:0 8px;white-space:pre;width:100%">' + (escaped || '&nbsp;') + '</td></tr>';
+ });
+ html += '</tbody></table>';
+ if (totalLines > maxLines) {
+ html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + totalLines + ' lines)</div>';
+ }
+ return html;
+ }
+
+ if (type === 'image') {
+ if (isLikelyBinary && !isTextContent) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Image — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="' + this._escapeHtml("var a=document.createElement('a');a.href='data:image/" + ext + ";base64," + btoa(content) + "';a.download='" + fileName + "';a.click()") + '">download to view</span></div>';
+ }
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center"><img src="data:image/' + ext + ';base64,' + base64 + '" style="max-width:80%;max-height:60vh;border-radius:4px;border:1px solid var(--border-color)" alt="' + this._escapeHtml(fileName) + '"></div>';
+ }
+
+ if (type === 'svg') {
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(content, 'text/html');
+ const scripts = doc.querySelectorAll('script');
+ scripts.forEach(s => s.remove());
+ // Remove event handlers from svg elements
+ const allEls = doc.querySelectorAll('*');
+ allEls.forEach(el => {
+ for (let i = el.attributes.length - 1; i >= 0; i--) {
+ const attr = el.attributes[i].name;
+ if (attr.startsWith('on')) el.removeAttribute(attr);
+ }
+ });
+ const cleanSvg = doc.body.innerHTML;
+ return '<div style="padding:12px;text-align:center;max-width:100%;overflow:auto">' + cleanSvg + '</div>';
+ } catch (e) {
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>';
+ }
+ }
+
+ if (type === 'video') {
+ if (isLikelyBinary && !isTextContent) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📷 Video — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:video/' + ext + ';base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to view</span></div>';
+ }
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center"><video controls style="max-width:80%;max-height:60vh;border-radius:4px"><source src="data:video/' + ext + ';base64,' + base64 + '" type="video/' + ext + '"></video></div>';
+ }
+
+ if (type === 'audio') {
+ if (isLikelyBinary && !isTextContent) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">🎵 Audio — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:audio/' + ext + ';base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to play</span></div>';
+ }
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center"><audio controls style="max-width:80%"><source src="data:audio/' + ext + ';base64,' + base64 + '" type="audio/' + ext + '"></audio></div>';
+ }
+
+ if (type === 'pdf') {
+ try {
+ const base64 = btoa(unescape(encodeURIComponent(content)));
+ return '<div style="padding:12px;text-align:center;height:70vh"><embed src="data:application/pdf;base64,' + base64 + '" type="application/pdf" style="width:100%;height:100%;border:1px solid var(--border-color);border-radius:4px"></div>';
+ } catch (e) {
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📄 PDF — <span style="color:var(--info-color);cursor:pointer;text-decoration:underline" onclick="var a=document.createElement(\'a\');a.href=\'data:application/pdf;base64,' + btoa(content) + '\';a.download=\'' + fileName + '\';a.click()">download to view</span></div>';
+ }
+ }
+
+ if (type === 'csv') {
+ try {
+ const lines = content.split('\n').filter(l => l.trim());
+ const maxRows = 50;
+ const rows = lines.slice(0, maxRows).map(l => l.split(',').map(c => c.trim().replace(/^"|"$/g, '')));
+ let html = '<table class="csv-table" style="border-collapse:collapse;width:100%;font-family:var(--font-primary);font-size:0.85em;margin:0;background:var(--bg-secondary)">';
+ if (rows.length > 0) {
+ html += '<thead><tr>';
+ rows[0].forEach(cell => {
+ html += '<th style="border:1px solid var(--border-color);padding:6px 8px;text-align:left;background:var(--bg-tertiary);color:var(--accent-color);font-weight:bold">' + this._escapeHtml(cell) + '</th>';
+ });
+ html += '</tr></thead><tbody>';
+ rows.slice(1).forEach(row => {
+ html += '<tr>';
+ row.forEach(cell => {
+ html += '<td style="border:1px solid var(--border-color);padding:4px 8px">' + this._escapeHtml(cell) + '</td>';
+ });
+ html += '</tr>';
+ });
+ html += '</tbody></table>';
+ if (lines.length > maxRows) {
+ html += '<div style="color:var(--text-muted);padding:8px">(showing ' + maxRows + ' of ' + lines.length + ' rows)</div>';
+ }
+ }
+ return html;
+ } catch (e) {
+ return '<pre style="padding:12px;font-family:var(--font-primary);font-size:0.9em;overflow-x:auto">' + this._escapeHtml(content.substring(0, 5000)) + '</pre>';
+ }
+ }
+
+ // 'other' — raw text fallback
+ if (typeof content === 'string' && isTextContent) {
+ const lines = content.split('\n');
+ const maxLines = 80;
+ const displayLines = lines.slice(0, maxLines);
+ const truncated = lines.length > maxLines;
+ let html = '<pre style="white-space:pre-wrap;padding:12px;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">';
+ displayLines.forEach((line, i) => {
+ html += this._escapeHtml(line) + '\n';
+ });
+ html += '</pre>';
+ if (truncated) {
+ html += '<div style="color:var(--text-muted);padding:8px;border-top:1px solid var(--border-color)">(showing ' + maxLines + ' of ' + lines.length + ' lines)</div>';
+ }
+ return html;
+ }
+
+ return '<div style="padding:20px;text-align:center;color:var(--text-muted)">📁 Preview not available for this file type</div>';
+ }
+
+ _syntaxHighlightJson(jsonStr) {
+ return jsonStr.replace(/("(?:\\.|[^"\\])*")\s*:/g, '<span style="color:var(--info-color)">$1</span>:')
+ .replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span style="color:var(--success-color)">$1</span>')
+ .replace(/:\s*(\d+\.?\d*)/g, ': <span style="color:var(--warning-color)">$1</span>')
+ .replace(/:\s*(true|false|null)/g, ': <span style="color:var(--error-color)">$1</span>');
+ }
+
+ _escapeHtml(str) {
+ if (typeof str !== 'string') return '';
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
+ }
+
+ _formatSize(size) {
+ if (!size && size !== 0) return '';
+ if (size < 1024) return size + ' B';
+ if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
+ return (size / (1024 * 1024)).toFixed(1) + ' MB';
+ }
+
+ _createOverlay() {
+ if (this._overlay) return;
+
+ const overlay = document.createElement('div');
+ overlay.id = 'filemgr-overlay';
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:1800;display:flex;flex-direction:column;background:var(--bg-color);color:var(--text-color);font-family:var(--font-primary);font-size:14px;';
+
+ // Inject responsive styles for the overlay
+ const styleId = 'filemgr-styles';
+ if (!document.getElementById(styleId)) {
+ const style = document.createElement('style');
+ style.id = styleId;
+ style.textContent = `
+ #filemgr-overlay.responsive .filemgr-main {
+ flex-direction: column !important;
+ }
+ #filemgr-overlay.responsive #filemgr-sidebar {
+ width: 100% !important;
+ max-height: 40vh;
+ }
+ #filemgr-overlay.responsive #filemgr-preview {
+ width: 100% !important;
+ }
+ #filemgr-overlay .filemgr-main {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ }
+ @media (max-width: 600px) {
+ #filemgr-overlay .filemgr-main {
+ flex-direction: column !important;
+ }
+ #filemgr-overlay #filemgr-sidebar {
+ width: 100% !important;
+ max-height: 40vh;
+ border-right: none !important;
+ border-bottom: 1px solid var(--border-color);
+ }
+ #filemgr-overlay #filemgr-preview {
+ width: 100% !important;
+ min-height: 40vh;
+ }
+ #filemgr-action-bar {
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ #filemgr-action-bar button {
+ min-width: 44px;
+ min-height: 44px;
+ font-size: 0.9em;
+ }
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ // Toolbar
+ const toolbar = document.createElement('div');
+ toolbar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;';
+
+ const closeBtn = document.createElement('button');
+ closeBtn.textContent = '✕';
+ closeBtn.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:1em;min-height:44px;touch-action:manipulation;';
+ closeBtn.onclick = () => this._closeOverlay();
+ toolbar.appendChild(closeBtn);
+
+ const titleSpan = document.createElement('span');
+ titleSpan.textContent = '📁 File Manager';
+ titleSpan.style.cssText = 'font-weight:bold;margin-right:8px;';
+ toolbar.appendChild(titleSpan);
+
+ // Breadcrumbs
+ const breadcrumbs = document.createElement('div');
+ breadcrumbs.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:0.9em;';
+ breadcrumbs.id = 'filemgr-breadcrumbs';
+
+ // Root breadcrumb
+ const rootSeg = document.createElement('span');
+ rootSeg.textContent = '/';
+ rootSeg.style.cssText = 'color:var(--accent-color);cursor:pointer;padding:2px 4px;border-radius:3px;';
+ rootSeg.onmouseenter = () => { rootSeg.style.background = 'var(--bg-tertiary)'; };
+ rootSeg.onmouseleave = () => { rootSeg.style.background = 'transparent'; };
+ rootSeg.onclick = () => this._navigateTo([]);
+ breadcrumbs.appendChild(rootSeg);
+
+ toolbar.appendChild(breadcrumbs);
+
+ // Search input
+ const searchInput = document.createElement('input');
+ searchInput.type = 'text';
+ searchInput.placeholder = '🔍 Filter files...';
+ searchInput.style.cssText = 'flex:1;min-width:120px;max-width:300px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-family:var(--font-primary);font-size:0.9em;outline:none;';
+ searchInput.oninput = (e) => {
+ this._searchFilter = e.target.value.toLowerCase();
+ this._refreshFileList();
+ };
+ toolbar.appendChild(searchInput);
+
+ overlay.appendChild(toolbar);
+
+ // Main area — wraps sidebar + preview in a responsive flex container
+ const mainArea = document.createElement('div');
+ mainArea.id = 'filemgr-main-container';
+ mainArea.className = 'filemgr-main';
+ mainArea.style.cssText = 'overflow:hidden;';
+
+ // Sidebar
+ const sidebar = document.createElement('div');
+ sidebar.id = 'filemgr-sidebar';
+ sidebar.style.cssText = 'width:30%;min-width:200px;overflow-y:auto;background:var(--bg-secondary);border-right:1px solid var(--border-color);';
+
+ mainArea.appendChild(sidebar);
+
+ // Preview pane
+ const preview = document.createElement('div');
+ preview.id = 'filemgr-preview';
+ preview.style.cssText = 'flex:1;overflow-y:auto;padding:0;';
+
+ mainArea.appendChild(preview);
+
+ overlay.appendChild(mainArea);
+
+ // Action bar
+ const actionBar = document.createElement('div');
+ actionBar.id = 'filemgr-action-bar';
+ actionBar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-top:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;';
+
+ const statusSpan = document.createElement('span');
+ statusSpan.id = 'filemgr-status';
+ statusSpan.style.cssText = 'flex:1;color:var(--text-muted);font-size:0.85em;';
+ statusSpan.textContent = 'No file selected';
+ actionBar.appendChild(statusSpan);
+
+ const deleteBtn = document.createElement('button');
+ deleteBtn.textContent = '🗑 Delete';
+ deleteBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--error-color);color:var(--error-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ deleteBtn.onclick = () => this._actionDelete();
+ actionBar.appendChild(deleteBtn);
+
+ const downloadBtn = document.createElement('button');
+ downloadBtn.textContent = '⬇ Download';
+ downloadBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ downloadBtn.onclick = () => this._actionDownload();
+ actionBar.appendChild(downloadBtn);
+
+ const editBtn = document.createElement('button');
+ editBtn.textContent = '✏ Edit';
+ editBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--accent-color);color:var(--accent-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ editBtn.onclick = () => this._actionEdit();
+ actionBar.appendChild(editBtn);
+
+ const previewToggleBtn = document.createElement('button');
+ previewToggleBtn.id = 'filemgr-toggle-raw';
+ previewToggleBtn.textContent = '📄 Raw';
+ previewToggleBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-muted);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;';
+ previewToggleBtn.onclick = () => this._togglePreviewMode();
+ actionBar.appendChild(previewToggleBtn);
+
+ overlay.appendChild(actionBar);
+
+ this._overlay = overlay;
+ return overlay;
+ }
+
+ _openOverlay() {
+ if (this._overlay) {
+ this._overlay.style.display = 'flex';
+ this._refreshFileList();
+ return;
+ }
+ const overlay = this._createOverlay();
+ document.body.appendChild(overlay);
+ this._navigateTo(this.currentPath);
+ }
+
+ _closeOverlay() {
+ if (this._overlay) {
+ this._overlay.style.display = 'none';
+ }
+ }
+
+ _clearOverlay() {
+ if (this._overlay && this._overlay.parentNode) {
+ this._overlay.parentNode.removeChild(this._overlay);
+ }
+ this._overlay = null;
+ this._selectedFile = null;
+ this._searchFilter = '';
+ this._rawMode = false;
+ }
+
+ _navigateTo(pathParts) {
+ this.currentPath = pathParts || [];
+ this._selectedFile = null;
+ this._searchFilter = '';
+ const searchInput = this._overlay?.querySelector('input[type="text"]');
+ if (searchInput) searchInput.value = '';
+ this._updateBreadcrumbs();
+ this._refreshFileList();
+ this._clearPreview();
+ this._updateStatus('No file selected');
+ }
+
+ _updateBreadcrumbs() {
+ const breadcrumbs = document.getElementById('filemgr-breadcrumbs');
+ if (!breadcrumbs) return;
+
+ // Keep root segment, remove rest
+ while (breadcrumbs.children.length > 1) {
+ breadcrumbs.removeChild(breadcrumbs.lastChild);
+ }
+
+ let accumulated = '';
+ this.currentPath.forEach((part, i) => {
+ const sep = document.createElement('span');
+ sep.textContent = ' › ';
+ sep.style.cssText = 'color:var(--text-muted);margin:0 2px;';
+ breadcrumbs.appendChild(sep);
+
+ const seg = document.createElement('span');
+ seg.textContent = part;
+ seg.style.cssText = 'cursor:pointer;padding:2px 4px;border-radius:3px;color:var(--text-color);';
+ if (i === this.currentPath.length - 1) {
+ seg.style.color = 'var(--accent-color)';
+ seg.style.fontWeight = 'bold';
+ }
+ seg.onmouseenter = () => { seg.style.background = 'var(--bg-tertiary)'; };
+ seg.onmouseleave = () => { seg.style.background = 'transparent'; };
+ const pathUpTo = this.currentPath.slice(0, i + 1);
+ seg.onclick = () => this._navigateTo(pathUpTo);
+ breadcrumbs.appendChild(seg);
+ });
+ }
+
+ _refreshFileList() {
+ const sidebar = document.getElementById('filemgr-sidebar');
+ if (!sidebar) return;
+
+ // Read directory
+ const pathStr = '/' + this.currentPath.join('/');
+ const entries = this.terminal.fs.ls(pathStr) || [];
+
+ // Separate dirs and files
+ const dirs = entries.filter(e => e.type === 'dir');
+ const files = entries.filter(e => e.type !== 'dir');
+
+ // Sort alphabetically
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
+ files.sort((a, b) => a.name.localeCompare(b.name));
+
+ // Apply search filter
+ let filteredDirs = dirs;
+ let filteredFiles = files;
+ if (this._searchFilter) {
+ const q = this._searchFilter;
+ filteredDirs = dirs.filter(e => e.name.toLowerCase().includes(q));
+ filteredFiles = files.filter(e => e.name.toLowerCase().includes(q));
+ }
+
+ sidebar.innerHTML = '';
+
+ if (filteredDirs.length === 0 && filteredFiles.length === 0) {
+ const emptyMsg = document.createElement('div');
+ emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);';
+ emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)';
+ sidebar.appendChild(emptyMsg);
+ this._updateStatus('0 files');
+ return;
+ }
+
+ // Add directory items
+ filteredDirs.forEach(entry => {
+ const item = document.createElement('div');
+ const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name;
+ item.dataset.path = fullPath;
+ item.dataset.type = 'dir';
+ item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;';
+ item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; };
+ item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; };
+ item.onclick = () => {
+ const newPath = [...this.currentPath, entry.name];
+ this._navigateTo(newPath);
+ };
+
+ const icon = document.createElement('span');
+ icon.textContent = '📁';
+ item.appendChild(icon);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.textContent = entry.name + '/';
+ nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ item.appendChild(nameSpan);
+
+ sidebar.appendChild(item);
+ });
+
+ // Add file items
+ filteredFiles.forEach(entry => {
+ const item = document.createElement('div');
+ const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name;
+ const isSelected = this._selectedFile === fullPath;
+ item.dataset.path = fullPath;
+ item.dataset.type = 'file';
+ item.dataset.selected = isSelected ? 'true' : 'false';
+ const bgColor = isSelected ? 'var(--bg-tertiary)' : 'transparent';
+ item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';';
+ if (isSelected) {
+ item.style.borderLeft = '3px solid var(--accent-color)';
+ }
+ item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; };
+ item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; };
+ item.onclick = () => {
+ this._selectedFile = fullPath;
+ this._highlightSelected();
+ const content = this.terminal.fs.readFile(fullPath);
+ this._renderPreview(fullPath, content);
+ };
+
+ const icon = document.createElement('span');
+ icon.textContent = '📄';
+ item.appendChild(icon);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.textContent = entry.name;
+ nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
+ item.appendChild(nameSpan);
+
+ const sizeSpan = document.createElement('span');
+ sizeSpan.textContent = this._formatSize(entry.size);
+ sizeSpan.style.cssText = 'color:var(--text-muted);font-size:0.8em;white-space:nowrap;margin-left:4px;';
+ item.appendChild(sizeSpan);
+
+ sidebar.appendChild(item);
+ });
+
+ const totalCount = entries.length;
+ this._updateStatus(totalCount + ' item' + (totalCount !== 1 ? 's' : ''));
+ }
+
+ _highlightSelected() {
+ const sidebar = document.getElementById('filemgr-sidebar');
+ if (!sidebar) return;
+ const items = sidebar.querySelectorAll('[data-type="file"]');
+ items.forEach(item => {
+ const selected = item.dataset.path === this._selectedFile;
+ item.dataset.selected = selected ? 'true' : 'false';
+ item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent';
+ item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent';
+ });
+ }
+
+ _renderPreview(path, content) {
+ const preview = document.getElementById('filemgr-preview');
+ if (!preview) return;
+
+ // Re-read content on each preview call
+ if (content === undefined) {
+ content = this.terminal.fs.readFile(path);
+ }
+
+ // For raw mode, show raw text regardless of type
+ if (this._rawMode && typeof content === 'string') {
+ preview.innerHTML = '<div style="padding:12px"><div style="color:var(--text-muted);font-size:0.85em;margin-bottom:8px">RAW: ' + this._escapeHtml(path) + '</div><pre style="white-space:pre-wrap;font-family:var(--font-primary);font-size:0.9em;margin:0;overflow-x:auto">' + this._escapeHtml(content.substring(0, 10000)) + '</pre></div>';
+ this._updateStatus('Preview: ' + path);
+ return;
+ }
+
+ // Check file size for text files
+ const type = this._detectType(path);
+ const textTypes = ['md', 'text', 'json', 'code', 'csv', 'other'];
+ if (textTypes.includes(type) && typeof content === 'string' && content.length > 51200) {
+ content = content.substring(0, 51200);
+ preview.innerHTML = this._getPreviewHtml(path, content);
+ const truncatedMsg = document.createElement('div');
+ truncatedMsg.style.cssText = 'color:var(--warning-color);padding:8px 12px;border-top:1px solid var(--border-color);font-size:0.85em;';
+ truncatedMsg.textContent = '(truncated — file exceeds 50KB)';
+ preview.appendChild(truncatedMsg);
+ this._updateStatus('Preview: ' + path);
+ return;
+ }
+
+ preview.innerHTML = this._getPreviewHtml(path, content);
+ this._updateStatus('Preview: ' + path);
+ }
+
+ _renderPreviewError(msg) {
+ const preview = document.getElementById('filemgr-preview');
+ if (!preview) return;
+ preview.innerHTML = '<div style="color:var(--error-color);padding:20px;font-size:1em">' + this._escapeHtml(msg) + '</div>';
+ this._updateStatus('Error');
+ }
+
+ _clearPreview() {
+ const preview = document.getElementById('filemgr-preview');
+ if (preview) preview.innerHTML = '';
+ }
+
+ _updateStatus(msg) {
+ const status = document.getElementById('filemgr-status');
+ if (status) status.textContent = msg;
+ }
+
+ _actionDelete() {
+ if (!this._selectedFile) {
+ this._updateStatus('No file selected to delete');
+ return;
+ }
+ if (!confirm('Delete ' + this._selectedFile + '?')) return;
+ const ok = this.terminal.fs.deleteFile(this._selectedFile);
+ if (ok) {
+ this._selectedFile = null;
+ this._refreshFileList();
+ this._clearPreview();
+ this._updateStatus('✅ Deleted');
+ } else {
+ this._updateStatus('❌ Failed to delete');
+ }
+ }
+
+ _actionDownload() {
+ if (!this._selectedFile) {
+ this._updateStatus('No file selected to download');
+ return;
+ }
+ const content = this.terminal.fs.readFile(this._selectedFile);
+ if (content === null || content === false) {
+ this._updateStatus('❌ File not found');
+ return;
+ }
+ const fileName = this._selectedFile.split('/').pop() || 'file';
+ const blob = new Blob([content], { type: 'application/octet-stream' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ this._updateStatus('⬇ Downloaded: ' + fileName);
+ }
+
+ _actionEdit() {
+ if (!this._selectedFile) {
+ this._updateStatus('No file selected to edit');
+ return;
+ }
+ if (typeof webx11 !== 'undefined' && webx11 && webx11.executeCommand) {
+ webx11.executeCommand('edit ' + this._selectedFile);
+ this._updateStatus('✏ Opened in editor: ' + this._selectedFile);
+ } else {
+ this._updateStatus('❌ Editor not available');
+ }
+ }
+
+ _togglePreviewMode() {
+ this._rawMode = !this._rawMode;
+ const btn = document.getElementById('filemgr-toggle-raw');
+ if (btn) {
+ btn.textContent = this._rawMode ? '📄 Rendered' : '📄 Raw';
+ btn.style.borderColor = this._rawMode ? 'var(--accent-color)' : 'var(--border-color)';
+ btn.style.color = this._rawMode ? 'var(--accent-color)' : 'var(--text-muted)';
+ }
+ if (this._selectedFile) {
+ const content = this.terminal.fs.readFile(this._selectedFile);
+ this._renderPreview(this._selectedFile, content);
+ }
+ }
+}
+
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/ext/filemgr.js');
+}
diff --git a/modules/ext/finder.js b/modules/ext/finder.js
new file mode 100644
index 0000000..1ca8cf7
--- /dev/null
+++ b/modules/ext/finder.js
@@ -0,0 +1,47 @@
+class FinderModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ }
+
+ requirements() {
+ return { met: true, missing: [] };
+ }
+
+ getHelpInfo() {
+ return {
+ 'find': 'Recursively search VFS files/directories by name (usage: find <query>)',
+ };
+ }
+
+ registerCommands() {
+ this.commands['find'] = (args) => this.find(args);
+ }
+ find(args) {
+ if (!args[0]) return this.terminal.print('Usage: find <query> — recursively searches VFS files/dirs matching query', 'warning');
+ const query = args.join(' ').toLowerCase();
+ const results = [];
+ this._recurse(this.terminal.fs.root, [], query, results);
+ if (results.length === 0) return this.terminal.print(`No matches for "${query}"`, 'warning');
+ this.terminal.print(`=== Found ${results.length} match(es) for "${query}" ===`, 'info');
+ results.forEach(r => {
+ const icon = r.type === 'dir' ? '📁' : '📄';
+ this.terminal.print(` ${icon} ${r.path}`);
+ });
+ }
+ _recurse(node, pathParts, query, results) {
+ if (!node || !node.content) return;
+ for (const [name, child] of Object.entries(node.content)) {
+ const fullPath = '/' + [...pathParts, name].join('/');
+ if (name.toLowerCase().includes(query)) {
+ results.push({ name, path: fullPath, type: child.type || (child.content ? 'dir' : 'file') });
+ }
+ if (child.content && typeof child.content === 'object') {
+ this._recurse(child, [...pathParts, name], query, results);
+ }
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new FinderModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/finder.js');
+}
diff --git a/modules/ext/mindmap.js b/modules/ext/mindmap.js
new file mode 100644
index 0000000..012bae7
--- /dev/null
+++ b/modules/ext/mindmap.js
@@ -0,0 +1,217 @@
+class MindmapModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.root = null;
+ this.colors = ['red', 'green', 'yellow', 'blue', 'cyan', 'magenta', 'white'];
+ this._colorIdx = 0;
+ }
+ getHelpInfo() {
+ return { 'mindmap': 'mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...] — Create and manage interactive mindmaps' };
+ }
+ _nextColor() {
+ const c = this.colors[this._colorIdx % this.colors.length];
+ this._colorIdx++;
+ return c;
+ }
+ _genId() {
+ return Math.random().toString(36).slice(2, 6);
+ }
+ _findNode(id, node) {
+ if (node.id === id) return node;
+ for (const child of (node.children || [])) {
+ const found = this._findNode(id, child);
+ if (found) return found;
+ }
+ return null;
+ }
+ _treeLines(node, prefix, isLast) {
+ const lines = [];
+ const connector = isLast ? '\u2514\u2500\u2500 ' : '\u251c\u2500\u2500 ';
+ const tagStr = (node.tags && node.tags.length) ? ' [' + node.tags.join(', ') + ']' : '';
+ const colorTag = node.color ? '[' + node.color + ']' : '';
+ lines.push(prefix + connector + colorTag + node.text + ' (' + node.id + ')' + tagStr);
+ if (node.children) {
+ const sorted = node.children;
+ sorted.forEach((child, i) => {
+ const subPrefix = prefix + (isLast ? ' ' : '\u2502 ');
+ this._treeLines(child, subPrefix, i === sorted.length - 1).forEach(l => lines.push(l));
+ });
+ }
+ return lines;
+ }
+ registerCommands() {
+ const self = this;
+ this.commands['mindmap'] = (args) => {
+ if (!args[0]) return this.terminal.print('Usage: mindmap <new|add|show|export|save|load|list|delete|rename|color|tag> [...]', 'warning');
+ const sub = args[0];
+ const rest = args.slice(1);
+ switch (sub) {
+ case 'new': return this._new(rest);
+ case 'add': return this._add(rest);
+ case 'show': return this._show(rest);
+ case 'export': return this._export(rest);
+ case 'save': return this._save(rest);
+ case 'load': return this._load(rest);
+ case 'list': return this._list();
+ case 'delete': return this._delete(rest);
+ case 'rename': return this._rename(rest);
+ case 'color': return this._color(rest);
+ case 'tag': return this._tag(rest);
+ default: this.terminal.print('Unknown subcommand: ' + sub + '. Try: new, add, show, export, save, load, list, delete, rename, color, tag', 'warning');
+ }
+ };
+ }
+ _parseTags(args) {
+ const tagIdx = args.indexOf('-t');
+ let tags = [];
+ if (tagIdx >= 0) {
+ tags = args[tagIdx + 1] ? args[tagIdx + 1].split(',').map(t => t.trim()).filter(Boolean) : [];
+ args.splice(tagIdx, 2);
+ }
+ return { args, tags };
+ }
+ _new(args) {
+ const { args: clean, tags } = this._parseTags([...args]);
+ if (!clean.length) return this.terminal.print('Usage: mindmap new <topic> [-t tag1,tag2]', 'warning');
+ if (this.root) return this.terminal.print('A mindmap already exists. Use mindmap add to extend it.', 'warning');
+ this.root = {
+ id: this._genId(),
+ text: clean.join(' '),
+ color: this._nextColor(),
+ tags: tags,
+ children: []
+ };
+ this.terminal.print('Created root: [' + this.root.color + '] ' + this.root.text + ' (' + this.root.id + ')', 'success');
+ }
+ _add(args) {
+ const { args: clean, tags } = this._parseTags([...args]);
+ if (clean.length < 2) return this.terminal.print('Usage: mindmap add <parent-id> <text> [-t tag1,tag2]', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ const parentId = clean[0];
+ const text = clean.slice(1).join(' ');
+ const parent = this._findNode(parentId, this.root);
+ if (!parent) return this.terminal.print('Node not found: ' + parentId, 'error');
+ const node = {
+ id: this._genId(),
+ text: text,
+ color: this._nextColor(),
+ tags: tags,
+ children: []
+ };
+ parent.children.push(node);
+ this.terminal.print('Added [' + node.color + '] ' + text + ' (' + node.id + ')', 'success');
+ }
+ _show(args) {
+ if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ let node = this.root;
+ if (args.length && args[0]) {
+ const found = this._findNode(args[0], this.root);
+ if (!found) return this.terminal.print('Node not found: ' + args[0], 'error');
+ node = found;
+ }
+ const lines = this._treeLines(node, '', true);
+ lines.forEach(l => this.terminal.print(l));
+ this.terminal.print('(' + node.text + ' — ' + this._countNodes(node) + ' nodes total)', 'info');
+ }
+ _countNodes(node) {
+ let c = 1;
+ for (const child of (node.children || [])) c += this._countNodes(child);
+ return c;
+ }
+ _export(args) {
+ if (!this.root) return this.terminal.print('No mindmap. Create one with mindmap new first.', 'warning');
+ let node = this.root;
+ if (args.length && args[0]) {
+ const found = this._findNode(args[0], this.root);
+ if (!found) return this.terminal.print('Node not found: ' + args[0], 'error');
+ node = found;
+ }
+ const md = this._toMarkdown(node, 0);
+ this.terminal.print(md);
+ this.terminal.print('(Markdown copied to clipboard)', 'info');
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(md);
+ }
+ }
+ _toMarkdown(node, depth) {
+ const prefix = ' '.repeat(depth) + '- ';
+ const tagStr = (node.tags && node.tags.length) ? ' *[' + node.tags.join(', ') + ']*' : '';
+ let out = prefix + '**' + node.text + '**' + tagStr + '\n';
+ if (node.children) {
+ node.children.forEach(c => { out += this._toMarkdown(c, depth + 1); });
+ }
+ return out;
+ }
+ _save(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap save <name>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap to save.', 'warning');
+ try {
+ localStorage.setItem('webx11_mindmap_' + args[0], JSON.stringify(this.root));
+ this.terminal.print('Mindmap saved as: ' + args[0], 'success');
+ } catch (e) {
+ this.terminal.print('Failed to save: ' + e.message, 'error');
+ }
+ }
+ _load(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap load <name>', 'warning');
+ try {
+ const data = localStorage.getItem('webx11_mindmap_' + args[0]);
+ if (!data) return this.terminal.print('No saved mindmap: ' + args[0], 'error');
+ this.root = JSON.parse(data);
+ this.terminal.print('Loaded mindmap: ' + args[0] + ' (' + this._countNodes(this.root) + ' nodes)', 'success');
+ } catch (e) {
+ this.terminal.print('Failed to load: ' + e.message, 'error');
+ }
+ }
+ _list() {
+ const keys = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k && k.startsWith('webx11_mindmap_')) keys.push(k.replace('webx11_mindmap_', ''));
+ }
+ if (!keys.length) return this.terminal.print('No saved mindmaps.', 'info');
+ this.terminal.print('=== Saved Mindmaps ===', 'info');
+ keys.forEach(k => this.terminal.print(' ' + k));
+ }
+ _delete(args) {
+ if (!args[0]) return this.terminal.print('Usage: mindmap delete <name>', 'warning');
+ localStorage.removeItem('webx11_mindmap_' + args[0]);
+ this.terminal.print('Deleted mindmap: ' + args[0], 'success');
+ }
+ _rename(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap rename <id> <text>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap.', 'warning');
+ const id = args[0];
+ const text = args.slice(1).join(' ');
+ const node = this._findNode(id, this.root);
+ if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ node.text = text;
+ this.terminal.print('Renamed ' + id + ' to: ' + text, 'success');
+ }
+ _color(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap color <id> <color>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap.', 'warning');
+ const id = args[0];
+ const color = args[1].toLowerCase();
+ if (!this.colors.includes(color)) return this.terminal.print('Available colors: ' + this.colors.join(', '), 'warning');
+ const node = this._findNode(id, this.root);
+ if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ node.color = color;
+ this.terminal.print('Set color for ' + id + ' to ' + color, 'success');
+ }
+ _tag(args) {
+ if (args.length < 2) return this.terminal.print('Usage: mindmap tag <id> <tag>', 'warning');
+ if (!this.root) return this.terminal.print('No mindmap.', 'warning');
+ const id = args[0];
+ const tag = args.slice(1).join(' ');
+ const node = this._findNode(id, this.root);
+ if (!node) return this.terminal.print('Node not found: ' + id, 'error');
+ if (!node.tags) node.tags = [];
+ node.tags.push(tag);
+ this.terminal.print('Added tag "' + tag + '" to ' + id, 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new MindmapModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/mindmap.js');
+}
diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js
new file mode 100644
index 0000000..95da385
--- /dev/null
+++ b/modules/ext/skill-pack-core.js
@@ -0,0 +1,285 @@
+class SkillPackCoreModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this._seeded = false;
+ this._skills = this._buildSkills();
+ }
+
+ requirements() {
+ try {
+ const fs = this.terminal && this.terminal.fs;
+ if (fs && fs.readFile('/skills/_seeded')) {
+ return { met: true, missing: [] };
+ }
+ return { met: false, missing: ['Skills directory not initialized. Will be created on first use.'] };
+ } catch (e) {
+ return { met: true, missing: [] };
+ }
+ }
+
+ getHelpInfo() {
+ return {
+ 'skill-pack-core': 'Seed and manage built-in core skills (navigation, memory, search, modules, backup, mindmap, etc.)',
+ };
+ }
+
+ _buildSkills() {
+ const s = (name, desc, tags, cat, steps) => ({
+ name, description: desc, tags, category: cat,
+ content: `---\nname: ${name}\ndescription: ${desc}\ntags: [${tags.join(', ')}]\ncategory: ${cat}\n---\n## Steps\n${steps}`
+ });
+
+ return [
+ s('navigating-the-terminal', 'How to explore the WebX11 terminal, find commands, check status, and navigate the filesystem', ['terminal', 'navigation', 'help', 'filesystem'], 'system',
+`1. **Get an overview of available commands:**
+ - Call get_help() to get a full command list sorted by category
+ - Call get_status() to see filesystem state, loaded modules, Ollama status
+ - For a specific command, use help <command> or check the help map
+
+2. **Explore the filesystem:**
+ - Use list_files(path?) to browse directories (omit path for current dir)
+ - Use read_file(path) to view file contents
+ - Use get_current_dir to see your working directory
+ - Use run_command("ls") or run_command("tree") for visual output
+
+3. **Understand system state:**
+ - run_command("mem") — memory usage
+ - run_command("status") — network, files, modules, Ollama
+ - run_command("modules") — module manager GUI
+ - get_module_status() — loaded modules, URLs, local edits
+
+4. **When user asks a vague question:**
+ - First call get_status() and get_help() to understand the current state
+ - Then ask clarifying questions based on what you found`),
+
+ s('memory-and-profile', 'How to use the user profile system, session memory, and pattern tracking for persistent context', ['memory', 'profile', 'session', 'persistence'], 'system',
+`1. **User Profile (persists across sessions):**
+ - user_get(key) — read any profile field (e.g. "preferredModel", "fontSize")
+ - user_set(key, value) — write to profile, auto-persists to localStorage
+ - user_list — show all stored profile keys
+ - user_forget(key) — delete a profile key
+ - Dot notation supported: user_set knownFacts.name "Alex"
+
+2. **Session Memory (VFS-based daily logs):**
+ - session_save(summary) — save a summary of what was accomplished
+ - session_load(date) — read a specific day's log (format: YYYY-MM-DD)
+ - session_recent(count) — show last N session summaries
+ - session_stats — aggregate totals across all sessions
+ - Recent sessions auto-load into context on first message
+
+3. **Conversation Patterns:**
+ - Pattern tracking runs automatically — frequent topics surface as hints
+ - my-set, my-get, my-list store additional personal data
+ - AI conversation context persists within a session (use ai-clear to reset)
+
+4. **When to use:**
+ - At start: call session_recent(3) to see what was worked on before
+ - After identifying user preferences: call user_set to store them
+ - After complex tasks: call session_save with a brief summary`),
+
+ s('web-search-and-fetch', 'How to search the web and fetch URL content using the WebX11 proxy API', ['search', 'fetch', 'web', 'proxy', 'api'], 'research',
+`1. **Search the web:**
+ - Call web_search(query) — uses /api/search which queries SearXNG + DDG fallback
+ - Results include: title, url, content snippet, source engine
+ - Always available (same-origin, no CORS issues)
+ - 5-minute server-side cache for repeated queries
+
+2. **Fetch URL content:**
+ - Call web_fetch(url) — uses /api/extract which strips HTML, extracts clean text
+ - Returns: title, content (up to 4000 chars), word_count, extraction method
+ - SSRF protection blocks internal network addresses
+ - 15-minute server-side cache
+
+3. **Search flags (via terminal search command):**
+ - search <query> --summarize — adds AI-generated overview via Ollama
+ - search <query> --fetch — fetches content snippets for top 3 results
+
+4. **When to use:**
+ - User asks about current events, facts, research → call web_search
+ - User says "read this article" or provides a URL → call web_fetch
+ - For in-depth analysis: search first, then fetch top results`),
+
+ s('module-management', 'How to view, edit, test, reload modules and manage startup configuration', ['modules', 'editing', 'code', 'self-improvement'], 'dev',
+`1. **View module source:**
+ - view_module(name) — fetches original source or reads from _mods/ if edited
+ - Supports all loaded modules: SystemModule, FileSystemModule, SearXNGModule, etc.
+
+2. **Edit a module:**
+ - edit_module(name) — copies source to _mods/<name>.js and opens code editor
+ - Editor shows the full source in a textarea
+ - Click "Save & Test" to save and auto-load the edited version
+
+3. **Test and reload:**
+ - test_module(name) — loads edited module from _mods/ for live testing
+ - reload_module(name) — discards edits and reloads from original URL
+ - list_modified_modules — shows all modules with pending edits
+
+4. **Check for updates:**
+ - check_updates — HEAD-requests all loaded module URLs to verify reachability
+
+5. **Manage startup:**
+ - startup-modules — opens the Startup Modules UI to select which modules load
+ - load-local <path> — load a module from VFS or URL without editing
+
+6. **When to use:**
+ - User reports a bug in a module → view_module, edit_module, test_module
+ - User asks "can you add X feature?" → same workflow
+ - Always save edits before testing. If test fails, the original module stays loaded.`),
+
+ s('system-maintenance', 'How to reset, troubleshoot, and maintain the WebX11 terminal', ['system', 'maintenance', 'reset', 'troubleshoot', 'config'], 'system',
+`1. **Factory reset (wipes everything):**
+ - factory-reset --confirm — clears all webx11_ localStorage keys, resets VFS, reloads
+ - Use only when the system is in an unrecoverable state
+
+2. **Soft VFS reset:**
+ - reset-vfs --confirm — clears only the filesystem, preserves settings/profile/modules
+
+3. **Config management:**
+ - config-list — show all config keys and values
+ - config-set <key> <value> — set a config value
+ - config-delete <key> — remove a config key
+ - config-export — dump all config as JSON
+ - config-reset — restore defaults (use before factory-reset if you want to save data)
+
+4. **Health checks (run automatically after complex tasks):**
+ - Check module loading status: loaded vs total
+ - Check for pending module edits
+ - Check memory usage (high if >100MB)
+ - Check skill count (0 skills after 2+ tool calls → suggest create_skill)
+
+5. **Font size and display:**
+ - font-size <8-32> — set terminal font size, persists across sessions
+ - setfont — alias for font-size
+
+6. **When to use:**
+ - System is slow or modules fail to load → check check_updates, memory
+ - User says "reset everything" → factory-reset --confirm
+ - User says "clear my files but keep settings" → reset-vfs --confirm`),
+
+ s('backup-and-restore', 'How to backup, restore, and export WebX11 data', ['backup', 'restore', 'export', 'import', 'persistence'], 'system',
+`1. **Backup VFS:**
+ - vfs-export — downloads the entire virtual filesystem as a dated JSON file
+ - backup — creates a full backup including VFS, modules, config (saves to _backups/)
+
+2. **Export/Import personal data:**
+ - my-export — prints all my-set data as formatted JSON
+ - my-import <json> — bulk-import personal data from JSON string
+
+3. **Export/Import modules:**
+ - export-mods — exports module registry as JSON
+ - import-mods <json> — imports modules from JSON backup
+
+4. **Restore:**
+ - restore <json> — restores terminal state from a backup JSON
+ - Session logs are saved to /memory/ automatically on page unload
+
+5. **When to use:**
+ - Before factory-reset: call my-export, vfs-export, export-mods to save data
+ - When switching browsers or devices: export all, then import
+ - User says "backup my data" or "save my files"`),
+
+ s('mindmap-workflow', 'How to create, view, edit, and manage brainstorming mindmaps', ['mindmap', 'brainstorming', 'planning', 'organization'], 'productivity',
+`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 from 7-color palette
+
+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:**
+ - 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, auto-copies to clipboard
+
+7. **When to use:**
+ - Map out project structure, brainstorm features, organize ideas
+ - Break down complex tasks into subtrees
+ - User says "brainstorm", "diagram", "organize thoughts", "plan"`),
+
+ s('skills-system-guide', 'How to use the skills system: load, create, patch, list, and audit skills for persistent procedural memory', ['skills', 'system', 'meta'], 'system',
+`1. **Always load skills at task start:**
+ - At the START of every task, call load_skills(query) to check for stored procedures
+ - Example: load_skills("search the web") returns matching skill content
+ - Example: load_skills("edit module") returns the module editing workflow
+
+2. **Create skills after complex tasks:**
+ - After any task with 3+ tool calls, call create_skill to save the approach
+ - Required fields: name (kebab-case), description, content (markdown with YAML frontmatter)
+ - Optional: tags (comma-separated), category (e.g. research, system, dev)
+ - Skills persist in /skills/ as .md files with /skills/index.json for fast lookup
+
+3. **Patch skills when they're outdated:**
+ - If a stored procedure has wrong steps, call patch_skill(name, newContent)
+ - This replaces the entire skill content and updates the index
+ - Do NOT create a duplicate — always patch the existing skill
+
+4. **Auto-created error skills:**
+ - If the same tool fails 3+ times, a workaround skill is created automatically
+ - Named tool-<name>-workaround (e.g. tool-write_file-workaround)
+
+5. **Audit and maintenance:**
+ - list_skills(category?) — browse available skills, optionally filter
+ - Skills are audited every 20 messages for staleness (empty content, >30 days old)
+
+6. **YAML frontmatter format:**
+ - Each skill file must start with: ---\\nname: <name>\\ndescription: <text>\\ntags: [...]\\ncategory: <cat>\\n---\\n
+ - Steps follow under ## Steps heading
+ - Use numbered lists for sequential steps`),
+ ];
+ }
+ registerCommands() {
+ this.commands['skill-pack-core'] = () => {
+ const count = this._skills.length;
+ this.terminal.print(`Skill Pack: Core — ${count} skills available`, 'info');
+ this.terminal.print('Skills will auto-seed on load', 'info');
+ };
+ }
+ init() {
+ this.seed();
+ }
+ async seed() {
+ console.log('[SkillPackCore] seed() called, _seeded=' + this._seeded);
+ if (this._seeded) return;
+ const fs = this.terminal.fs;
+ if (!fs) { console.warn('[SkillPackCore] this.terminal.fs not available'); return; }
+ fs.mkdirRecursive('/skills');
+ let index = fs.fsReadJSON('/skills/index.json') || {};
+ console.log('[SkillPackCore] existing index keys:', Object.keys(index));
+ let seeded = 0;
+ for (const skill of this._skills) {
+ if (!index[skill.name]) {
+ fs.fsWrite(`/skills/${skill.name}.md`, skill.content);
+ index[skill.name] = {
+ name: skill.name,
+ description: skill.description,
+ tags: skill.tags,
+ category: skill.category,
+ created: new Date().toISOString(),
+ updated: new Date().toISOString()
+ };
+ seeded++;
+ }
+ }
+ if (seeded > 0) {
+ fs.fsWriteJSON('/skills/index.json', index);
+ }
+ this._seeded = true;
+ }
+}
diff --git a/modules/ext/theme.js b/modules/ext/theme.js
new file mode 100644
index 0000000..9a1dab5
--- /dev/null
+++ b/modules/ext/theme.js
@@ -0,0 +1,172 @@
+class ThemeModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.key = 'webx11_themes';
+ this._previewTimer = null;
+ this.defaults = {
+ 'dark': { '--bg-color': '#000000', '--bg-secondary': '#0a0a0a', '--bg-tertiary': '#111111', '--text-color': '#00ff00', '--text-muted': '#888888', '--accent-color': '#00aa00', '--border-color': '#005500' },
+ 'light': { '--bg-color': '#ffffff', '--bg-secondary': '#f5f5f5', '--bg-tertiary': '#eeeeee', '--text-color': '#222222', '--text-muted': '#888888', '--accent-color': '#0066cc', '--border-color': '#cccccc' },
+ 'amber': { '--bg-color': '#1a0f00', '--bg-secondary': '#2a1a00', '--bg-tertiary': '#332200', '--text-color': '#ffaa00', '--text-muted': '#886600', '--accent-color': '#ff8800', '--border-color': '#663300' },
+ 'cyber': { '--bg-color': '#000a14', '--bg-secondary': '#001a2e', '--bg-tertiary': '#002240', '--text-color': '#00ffff', '--text-muted': '#0088aa', '--accent-color': '#ff00ff', '--border-color': '#004466' },
+ 'ocean': { '--bg-color': '#0a1628', '--bg-secondary': '#0f2240', '--bg-tertiary': '#142e54', '--text-color': '#88ddff', '--text-muted': '#5599bb', '--accent-color': '#00aaff', '--border-color': '#1a4470' },
+ 'forest': { '--bg-color': '#0a1a0e', '--bg-secondary': '#0f2814', '--bg-tertiary': '#14361c', '--text-color': '#88dd88', '--text-muted': '#559955', '--accent-color': '#33cc33', '--border-color': '#1a4a22' },
+ 'monochrome': { '--bg-color': '#0a0a0a', '--bg-secondary': '#181818', '--bg-tertiary': '#222222', '--text-color': '#cccccc', '--text-muted': '#666666', '--accent-color': '#999999', '--border-color': '#333333' },
+ 'sunset': { '--bg-color': '#1a0a14', '--bg-secondary': '#2a1220', '--bg-tertiary': '#3a1a2c', '--text-color': '#ffbb99', '--text-muted': '#cc7755', '--accent-color': '#ff6644', '--border-color': '#552233' },
+ 'midnight': { '--bg-color': '#050510', '--bg-secondary': '#0a0a20', '--bg-tertiary': '#101030', '--text-color': '#aaaaee', '--text-muted': '#555588', '--accent-color': '#6666ff', '--border-color': '#1a1a3a' }
+ };
+ this.themes = this.load();
+ }
+ requirements() {
+ return { met: true, missing: [] };
+ }
+ getHelpInfo() {
+ return { 'theme': 'theme <set|list|new|delete|export|preview|keep> [...] — Manage and preview visual themes' };
+ }
+ load() {
+ try {
+ const data = localStorage.getItem(this.key);
+ return data ? { ...this.defaults, ...JSON.parse(data) } : { ...this.defaults };
+ } catch (e) { return { ...this.defaults }; }
+ }
+ save() {
+ const custom = {};
+ Object.keys(this.themes).forEach(k => {
+ if (!this.defaults[k]) custom[k] = this.themes[k];
+ });
+ localStorage.setItem(this.key, JSON.stringify(custom));
+ }
+ registerCommands() {
+ this.commands['theme'] = (args) => this.handle(args);
+ }
+ handle(args) {
+ if (!args[0]) return this.terminal.print('Usage: theme set <name>, theme list, theme new, theme delete <name>, theme export <name>, theme preview <name>, theme keep', 'warning');
+ const sub = args[0].toLowerCase();
+ if (sub === 'set') this.setTheme(args[1]);
+ else if (sub === 'list') this.listThemes();
+ else if (sub === 'new') this.newTheme(args.slice(1));
+ else if (sub === 'delete') this.deleteTheme(args[1]);
+ else if (sub === 'export') this.exportTheme(args[1]);
+ else if (sub === 'preview') this.previewTheme(args[1]);
+ else if (sub === 'keep') this.keepPreview();
+ else this.terminal.print(`Unknown: ${sub}`, 'error');
+ }
+ _applyVars(name) {
+ if (!this.themes[name]) return false;
+ const vars = this.themes[name];
+ const root = document.documentElement;
+ Object.entries(vars).forEach(([key, val]) => root.style.setProperty(key, val));
+ return true;
+ }
+ setTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme set <name>', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ // Cancel any active preview
+ if (this._previewTimer) {
+ clearTimeout(this._previewTimer);
+ this._previewTimer = null;
+ }
+ this._applyVars(name);
+ localStorage.setItem('webx11_active_theme', name);
+ this.terminal.print(`Theme set to: ${name}`, 'success');
+ }
+ previewTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme preview <name>', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ // Cancel any existing preview
+ if (this._previewTimer) {
+ clearTimeout(this._previewTimer);
+ this._previewTimer = null;
+ }
+ const previous = localStorage.getItem('webx11_active_theme') || 'dark';
+ this._applyVars(name);
+ this.terminal.print(`🎨 Previewing: ${name} — will revert in 5s`, 'info');
+ this.terminal.print(' Type "theme keep" to keep this theme permanently', 'info');
+ this._previewTimer = setTimeout(() => {
+ this._previewTimer = null;
+ this._applyVars(previous);
+ this.terminal.print(`⏱ Preview ended — reverted to: ${previous}`, 'info');
+ }, 5000);
+ }
+ keepPreview() {
+ if (!this._previewTimer) return this.terminal.print('No active preview to keep', 'warning');
+ clearTimeout(this._previewTimer);
+ this._previewTimer = null;
+ const root = document.documentElement;
+ const cssVars = ['--bg-color', '--bg-secondary', '--bg-tertiary', '--text-color', '--text-muted', '--accent-color', '--border-color'];
+ const currentVars = {};
+ cssVars.forEach(v => { currentVars[v] = getComputedStyle(root).getPropertyValue(v).trim() || '#000'; });
+ let matchName = null;
+ for (const [name, vars] of Object.entries(this.themes)) {
+ const match = cssVars.every(v => vars[v] === currentVars[v]);
+ if (match) { matchName = name; break; }
+ }
+ if (matchName) {
+ localStorage.setItem('webx11_active_theme', matchName);
+ this.terminal.print(`✅ Preview kept — theme saved as: ${matchName}`, 'success');
+ } else {
+ // Custom vars — save current state as active
+ const active = localStorage.getItem('webx11_active_theme') || 'dark';
+ this.terminal.print(`✅ Preview kept (${active} theme)`, 'success');
+ }
+ }
+ listThemes() {
+ const active = localStorage.getItem('webx11_active_theme') || 'dark';
+ this.terminal.print('=== Themes ===', 'info');
+ Object.keys(this.themes).forEach(name => {
+ const mark = name === active ? ' ✅' : '';
+ this.terminal.print(` ${name}${mark}`);
+ });
+ this.terminal.print('Use: theme set <name> to apply', 'info');
+ }
+ newTheme(args) {
+ if (args.length < 1) return this.terminal.print('Usage: theme new <name> --bg #000 --text #0f0', 'warning');
+ const name = args[0].toLowerCase();
+ if (this.themes[name]) return this.terminal.print(`Theme '${name}' already exists`, 'warning');
+ const vars = {};
+ const root = document.documentElement;
+ const cssVars = ['--bg-color', '--bg-secondary', '--bg-tertiary', '--text-color', '--text-muted', '--accent-color', '--border-color', '--error-color', '--warning-color', '--info-color', '--success-color'];
+ for (let i = 1; i < args.length; i++) {
+ if (args[i].startsWith('--')) {
+ const val = args[i + 1];
+ if (val && !val.startsWith('--')) {
+ vars[args[i]] = val;
+ i++;
+ }
+ }
+ }
+ if (Object.keys(vars).length === 0) {
+ const preview = {};
+ cssVars.forEach(v => { preview[v] = getComputedStyle(root).getPropertyValue(v).trim() || '#000'; });
+ this.themes[name] = preview;
+ } else {
+ this.themes[name] = vars;
+ }
+ this.save();
+ this.terminal.print(`Theme '${name}' created with ${Object.keys(this.themes[name]).length} variables`, 'success');
+ }
+ deleteTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme delete <name>', 'warning');
+ if (this.defaults[name]) return this.terminal.print('Cannot delete built-in theme', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ delete this.themes[name];
+ this.save();
+ const active = localStorage.getItem('webx11_active_theme');
+ if (active === name) { localStorage.removeItem('webx11_active_theme'); }
+ this.terminal.print(`Theme '${name}' deleted`, 'success');
+ }
+ exportTheme(name) {
+ if (!name) return this.terminal.print('Usage: theme export <name>', 'warning');
+ if (!this.themes[name]) return this.terminal.print(`Theme '${name}' not found`, 'error');
+ const data = JSON.stringify(this.themes[name], null, 2);
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = `theme-${name}.json`; a.click();
+ URL.revokeObjectURL(url);
+ this.terminal.print(`Theme '${name}' exported`, 'success');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new ThemeModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/theme.js');
+}
diff --git a/modules/ext/voice.js b/modules/ext/voice.js
new file mode 100644
index 0000000..0fd96cd
--- /dev/null
+++ b/modules/ext/voice.js
@@ -0,0 +1,102 @@
+class VoiceModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.recognition = null;
+ this.listening = false;
+ this._lastError = '';
+ }
+ requirements() {
+ if (typeof SpeechRecognition !== 'undefined' || typeof webkitSpeechRecognition !== 'undefined') {
+ return { met: true, missing: [] };
+ }
+ return { met: false, missing: ['SpeechRecognition not available in this browser'] };
+ }
+ getHelpInfo() {
+ return { 'voice': 'voice [stop] — Start voice recognition, or type "voice stop" to cancel' };
+ }
+ registerCommands() {
+ this.commands['voice'] = (args) => this.handle(args);
+ }
+ handle(args) {
+ const sub = args[0] && args[0].toLowerCase();
+ if (sub === 'stop') return this.stopListening();
+ if (this.listening) return this.terminal.print('Already listening. Say something or type "voice stop"', 'warning');
+ this.startListening();
+ }
+ _updateButton(listening, error) {
+ const btn = document.getElementById('voice-toggle-btn');
+ if (!btn) return;
+ if (listening) {
+ btn.style.borderColor = 'var(--success-color)';
+ btn.style.color = 'var(--success-color)';
+ btn.style.animation = 'pulse 1.5s ease-in-out infinite';
+ btn.textContent = '🔴 Listening';
+ } else {
+ btn.style.borderColor = '';
+ btn.style.color = '';
+ btn.style.animation = '';
+ if (error) {
+ btn.style.borderColor = 'var(--error-color)';
+ btn.style.color = 'var(--error-color)';
+ } else {
+ btn.style.borderColor = '';
+ btn.style.color = '';
+ }
+ btn.textContent = '🎤 Mic';
+ }
+ }
+ startListening() {
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+ if (!SpeechRecognition) {
+ this._updateButton(false, true);
+ return this.terminal.print('Speech recognition not supported in this browser', 'error');
+ }
+ this.recognition = new SpeechRecognition();
+ this.recognition.lang = 'en-US';
+ this.recognition.interimResults = false;
+ this.recognition.maxAlternatives = 1;
+ this.recognition.continuous = false;
+ this.listening = true;
+ this._updateButton(true);
+ this.terminal.print('🎤 Listening... (say a command, or tap mic button to cancel)', 'info');
+ this.recognition.onresult = (event) => {
+ const transcript = event.results[0][0].transcript;
+ this.terminal.print(`🎤 Recognized: "${transcript}"`, 'success');
+ const input = this.terminal.inputField;
+ input.value = transcript;
+ input.focus();
+ this.listening = false;
+ this._updateButton(false);
+ };
+ this.recognition.onerror = (event) => {
+ this.terminal.print(`🎤 Error: ${event.error}`, 'error');
+ this.listening = false;
+ this._updateButton(false, true);
+ };
+ this.recognition.onend = () => {
+ if (this.listening) {
+ this.listening = false;
+ this._updateButton(false);
+ this.terminal.print('🎤 Voice input ended', 'info');
+ }
+ };
+ try { this.recognition.start(); }
+ catch (e) {
+ this.terminal.print(`🎤 Failed to start: ${e.message}`, 'error');
+ this.listening = false;
+ this._updateButton(false, true);
+ }
+ }
+ stopListening() {
+ if (this.recognition && this.listening) {
+ this.recognition.stop();
+ this.listening = false;
+ this._updateButton(false);
+ this.terminal.print('🎤 Listening stopped', 'info');
+ }
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new VoiceModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/voice.js');
+}
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
new file mode 100644
index 0000000..63dfb05
--- /dev/null
+++ b/modules/ext/webdav.js
@@ -0,0 +1,532 @@
+class WebDAVModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.config = null; // { url, username, password }
+ this._browsingPath = '/';
+ this._STORAGE_KEY = 'webx11_webdav_config';
+ // Auto-discover space name from page URL
+ this._spaceName = this._detectSpaceName();
+ this._autoConfigured = false;
+ this._loadConfig();
+ }
+
+ _detectSpaceName() {
+ try {
+ const path = window.location.pathname;
+ // /spaces/chatstudio/newos4/ → chatstudio
+ const parts = path.split('/').filter(p => p);
+ const spacesIdx = parts.indexOf('spaces');
+ if (spacesIdx >= 0 && spacesIdx + 1 < parts.length) {
+ return parts[spacesIdx + 1];
+ }
+ } catch (e) {}
+ return '';
+ }
+
+ requirements() {
+ const cfg = localStorage.getItem('webx11_webdav_config');
+ if (!cfg) return { met: false, missing: ['WebDAV endpoint not configured. Use webdav-config to set it up.'] };
+ try {
+ const parsed = JSON.parse(cfg);
+ if (!parsed.url) return { met: false, missing: ['WebDAV URL not set'] };
+ return { met: true, missing: [] };
+ } catch (e) {
+ return { met: false, missing: ['WebDAV config corrupted'] };
+ }
+ }
+
+ getHelpInfo() {
+ return {
+ 'webdav-config': 'webdav-config [url user pass] — Configure WebDAV. No args = auto-detect from current page URL',
+ 'webdav-status': 'Show current WebDAV configuration status',
+ 'webdav-ls': 'List files/directories on the WebDAV remote',
+ 'webdav-push': 'Upload a VFS file to WebDAV',
+ 'webdav-pull': 'Download a WebDAV file to VFS',
+ 'webdav-backup': 'Backup entire VFS to WebDAV as a dated JSON snapshot',
+ 'webdav-restore': 'Restore VFS from a WebDAV backup file',
+ 'webdav-backup-list': 'List available backup files on WebDAV',
+ 'webdav-explorer': 'webdav-explorer [path] — Interactive remote file browser',
+ };
+ }
+
+ _loadConfig() {
+ try {
+ const saved = localStorage.getItem(this._STORAGE_KEY);
+ if (saved) {
+ this.config = JSON.parse(saved);
+ // Try to get password from SecretVault
+ if (window.webx11_secretVault && this.config && this.config.username) {
+ const encryptedPass = window.webx11_secretVault.decrypt('webdav_pass_' + this.config.username);
+ if (encryptedPass !== null && encryptedPass !== undefined) {
+ this.config.password = encryptedPass;
+ }
+ }
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ _saveConfig() {
+ try {
+ if (this.config) {
+ const toStore = {
+ url: this.config.url,
+ username: this.config.username
+ };
+ // Try SecretVault for encrypted password storage
+ if (window.webx11_secretVault && this.config.password) {
+ window.webx11_secretVault.encrypt('webdav_pass_' + this.config.username, this.config.password);
+ // Also store space token
+ if (this._spaceName) {
+ window.webx11_secretVault.encrypt('webdav_token_' + this._spaceName, this.config.password);
+ }
+ }
+ // Also store in localStorage for backward compat (SecretVault decrypts later)
+ // Store only url and username hint in plaintext lS, encrypted password handled above
+ localStorage.setItem(this._STORAGE_KEY, JSON.stringify(toStore));
+ } else {
+ localStorage.removeItem(this._STORAGE_KEY);
+ }
+ } catch (e) { console.error('WebDAV config save error:', e); }
+ }
+
+ _getAuthHeader() {
+ if (!this.config || !this.config.password) return {};
+ const token = btoa(this.config.username + ':' + this.config.password);
+ return { 'Authorization': 'Basic ' + token };
+ }
+
+ async _webdavRequest(method, path, options = {}) {
+ if (!this.config || !this.config.url) {
+ throw new Error('WebDAV not configured. Use webdav-config first.');
+ }
+ const baseUrl = this.config.url.replace(/\/+$/, '');
+ const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
+ const url = baseUrl + urlPath;
+
+ const headers = {
+ ...this._getAuthHeader(),
+ ...(options.headers || {})
+ };
+
+ const fetchOpts = {
+ method: method,
+ headers: headers,
+ body: options.body || undefined
+ };
+
+ const response = await fetch(url, fetchOpts);
+ return response;
+ }
+
+ async _listDir(path) {
+ // Try PROPFIND first (works with WebDAV servers), fallback to nginx autoindex
+ try {
+ const response = await this._webdavRequest('PROPFIND', path, {
+ headers: { 'Depth': '1' }
+ });
+ if (response.ok) {
+ const text = await response.text();
+ return this._parsePropfind(text);
+ }
+ } catch (e) { /* PROPFIND failed, try autoindex */ }
+ // Fallback: use nginx __list__ endpoint
+ return this._listDirViaAutoIndex(path);
+ }
+
+ async _listDirViaAutoIndex(path) {
+ const baseUrl = this.config.url.replace(/\/+$/, '');
+ const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
+ // Use __list__ for directory listing
+ const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, '');
+ try {
+ const resp = await fetch(listUrl, { method: 'GET' });
+ if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status);
+ const html = await resp.text();
+ return this._parseAutoindex(html);
+ } catch (e) {
+ throw new Error('Cannot list directory: ' + e.message);
+ }
+ }
+
+ _parseAutoindex(html) {
+ const entries = [];
+ // Parse nginx autoindex HTML format
+ const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g;
+ let match;
+ while ((match = linkRegex.exec(html)) !== null) {
+ const href = match[1];
+ const name = match[2].replace(/\/$/, '');
+ const sizeStr = match[4];
+ if (name === '..' || name === '../') continue;
+ if (name === '__list__') continue;
+ const isDir = href.endsWith('/');
+ let size = 0;
+ if (!isDir && sizeStr !== '-') {
+ size = parseInt(sizeStr) || 0;
+ }
+ entries.push({
+ name: name,
+ type: isDir ? 'dir' : 'file',
+ size: size,
+ href: href
+ });
+ }
+ // Fallback: if regex failed, try simpler parse
+ if (entries.length === 0) {
+ const simpleRegex = /<a href="([^"]+)">/g;
+ const names = [];
+ while ((match = simpleRegex.exec(html)) !== null) {
+ const href = match[1];
+ if (href === '../' || href === '/' || href === './') continue;
+ if (href.startsWith('?')) continue;
+ names.push(href);
+ }
+ for (const href of names) {
+ const isDir = href.endsWith('/');
+ entries.push({
+ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
+ type: isDir ? 'dir' : 'file',
+ size: 0,
+ href: href
+ });
+ }
+ }
+ return entries;
+ }
+
+ _parsePropfind(xml) {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xml, 'text/xml');
+ const responses = doc.querySelectorAll('D\\:response, response');
+ const entries = [];
+ responses.forEach((resp) => {
+ const href = (resp.querySelector('D\\:href, href') || {}).textContent || '';
+ const isDir = !!((resp.querySelector('D\\:collection, collection') || {}).textContent);
+ const getContentLength = (resp.querySelector('D\\:getcontentlength, getcontentlength, lp1\\:getcontentlength') || {}).textContent;
+ const size = getContentLength ? parseInt(getContentLength) : 0;
+ const displayName = href.split('/').filter(Boolean).pop() || '/';
+ if (displayName) {
+ entries.push({
+ name: decodeURIComponent(displayName),
+ type: isDir ? 'dir' : 'file',
+ size: size,
+ href: href
+ });
+ }
+ });
+ // Remove the root entry (same as first child's dir)
+ return entries.filter((e, i) => i > 0 || e.type !== 'dir' || entries.length <= 1);
+ }
+
+ async _ensureDir(remotePath) {
+ // Try MKCOL — ignore 405 (already exists) and 409 (parent missing)
+ const response = await this._webdavRequest('MKCOL', remotePath);
+ if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true;
+ if (response.status === 409) {
+ // Parent missing — try creating parent recursively
+ const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean);
+ if (parts.length > 1) {
+ const parent = parts.slice(0, -1).join('/');
+ await this._ensureDir(parent);
+ // Retry MKCOL
+ const retry = await this._webdavRequest('MKCOL', remotePath);
+ if (retry.ok || retry.status === 405) return true;
+ }
+ // Try creating path component by component as a fallback
+ let current = '';
+ for (const part of parts) {
+ current += '/' + part;
+ try {
+ const attempt = await this._webdavRequest('MKCOL', current);
+ if (!attempt.ok && attempt.status !== 405) {
+ // Ignore — it likely exists already or we can't create it
+ }
+ } catch (e) { /* ignore */ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ registerCommands() {
+ const self = this;
+
+ // --- webdav-config: set WebDAV endpoint and credentials ---
+ this.commands['webdav-config'] = async (args) => {
+ // No args: auto-detect space name and try to configure from token
+ if (!args || args.length === 0) {
+ const spaceName = self._spaceName;
+ if (!spaceName) {
+ self.terminal.print('Could not detect space name from URL.', 'warning');
+ self.terminal.print('Usage: webdav-config <url> [username] [password]', 'info');
+ return;
+ }
+
+ // Auto-configure URL from space name
+ const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/';
+ let token = null;
+
+ // Check SecretVault first
+ if (window.webx11_secretVault) {
+ token = window.webx11_secretVault.decrypt('webdav_token_' + spaceName);
+ }
+
+ // Fallback to localStorage
+ if (!token) {
+ token = localStorage.getItem('webx11_spaces_' + spaceName);
+ }
+
+ if (token) {
+ self.config = {
+ url: autoUrl,
+ username: spaceName,
+ password: token
+ };
+ self._saveConfig();
+ self._autoConfigured = true;
+ self.terminal.print('Auto-configured WebDAV for space: ' + spaceName, 'success');
+ try {
+ const resp = await self._webdavRequest('OPTIONS', '/');
+ self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success');
+ self.terminal.print('WebDAV configured: ' + self.config.url, 'success');
+ } catch (e) {
+ self.terminal.print('Connection test failed: ' + e.message, 'warning');
+ self.terminal.print('Config saved but connection may need CORS or network access.', 'warning');
+ }
+ } else {
+ self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning');
+ self.terminal.print('Get your token from /spaces/' + spaceName + '/.token or the dashboard.', 'info');
+ self.terminal.print('Auto-setting URL: ' + autoUrl, 'info');
+ self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'info');
+ // At minimum set the URL
+ self.config = {
+ url: autoUrl,
+ username: spaceName,
+ password: ''
+ };
+ self._saveConfig();
+ self._autoConfigured = true;
+ }
+ return;
+ }
+
+ // Legacy 1-3 arg behavior
+ self.config = {
+ url: args[0],
+ username: args[1] || (self.config ? self.config.username : ''),
+ password: args[2] || (self.config ? self.config.password : '')
+ };
+ self._saveConfig();
+ self.terminal.print('Testing WebDAV connection...', 'info');
+ try {
+ const resp = await self._webdavRequest('OPTIONS', '/');
+ self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success');
+ self.terminal.print('WebDAV configured: ' + self.config.url, 'success');
+ } catch (e) {
+ self.terminal.print('Connection test failed: ' + e.message, 'warning');
+ self.terminal.print('Config saved but connection may need CORS or network access.', 'warning');
+ }
+ };
+
+ // --- webdav-status: show current WebDAV config ---
+ this.commands['webdav-status'] = () => {
+ if (!self.config || !self.config.url) {
+ self.terminal.print('WebDAV not configured. Use webdav-config <url> [username] [password]', 'warning');
+ return;
+ }
+ self.terminal.print('WebDAV Status:', 'text-muted');
+ self.terminal.print(' URL: ' + self.config.url);
+ self.terminal.print(' User: ' + (self.config.username || '(none)'));
+ self.terminal.print(' Password: ' + (self.config.password ? '**** (' + self.config.password.length + ' chars)' : '(none)'));
+ self.terminal.print(' Space detected: ' + (self._spaceName || '(none)'));
+ if (self._autoConfigured) {
+ self.terminal.print(' Auto-configured: yes', 'success');
+ }
+ };
+
+ // --- webdav-ls: list remote directory ---
+ this.commands['webdav-ls'] = async (args) => {
+ const remotePath = args[0] || '/';
+ try {
+ const entries = await self._listDir(remotePath);
+ if (entries.length === 0) {
+ self.terminal.print('(empty)', 'info');
+ return;
+ }
+ entries.forEach(e => {
+ const icon = e.type === 'dir' ? '📁' : '📄';
+ const sizeStr = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/';
+ self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + (e.type === 'file' ? sizeStr : ''));
+ });
+ self.terminal.print(entries.length + ' item(s)', 'text-muted');
+ } catch (e) {
+ self.terminal.print('Failed to list: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-push: upload a VFS file to WebDAV ---
+ this.commands['webdav-push'] = async (args) => {
+ if (!args[0]) {
+ self.terminal.print('Usage: webdav-push <vfs-path> [remote-path]', 'warning');
+ return;
+ }
+ const vfsPath = args[0];
+ const remotePath = args[1] || vfsPath;
+ const content = self.terminal.fs.readFile(vfsPath);
+ if (content === null) {
+ self.terminal.print('File not found in VFS: ' + vfsPath, 'error');
+ return;
+ }
+ try {
+ // Ensure remote dir exists
+ const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean);
+ if (parts.length > 1) {
+ const parent = parts.slice(0, -1).join('/');
+ await self._ensureDir(parent);
+ }
+ const resp = await self._webdavRequest('PUT', remotePath, {
+ body: content,
+ headers: { 'Content-Type': 'text/plain' }
+ });
+ if (resp.ok || resp.status === 201 || resp.status === 204) {
+ self.terminal.print('Pushed: ' + vfsPath + ' → ' + remotePath, 'success');
+ } else {
+ self.terminal.print('Push failed: HTTP ' + resp.status, 'error');
+ }
+ } catch (e) {
+ self.terminal.print('Push error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-pull: download a WebDAV file to VFS ---
+ this.commands['webdav-pull'] = async (args) => {
+ if (!args[0]) {
+ self.terminal.print('Usage: webdav-pull <remote-path> [vfs-path]', 'warning');
+ return;
+ }
+ const remotePath = args[0];
+ const vfsPath = args[1] || remotePath.split('/').pop();
+ try {
+ const resp = await self._webdavRequest('GET', remotePath);
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ const content = await resp.text();
+ self.terminal.fs.writeFile(vfsPath, content);
+ self.terminal.print('Pulled: ' + remotePath + ' → ' + vfsPath + ' (' + content.length + ' bytes)', 'success');
+ } catch (e) {
+ self.terminal.print('Pull error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-backup: backup entire VFS to WebDAV ---
+ this.commands['webdav-backup'] = async (args) => {
+ const name = (args[0] || 'backup').replace(/[^a-zA-Z0-9_-]/g, '_');
+ const now = new Date();
+ const dateStr = now.getFullYear() + '-' +
+ String(now.getMonth() + 1).padStart(2, '0') + '-' +
+ String(now.getDate()).padStart(2, '0') + '_' +
+ String(now.getHours()).padStart(2, '0') +
+ String(now.getMinutes()).padStart(2, '0') +
+ String(now.getSeconds()).padStart(2, '0');
+ const filename = name + '-' + dateStr + '.json';
+ const remotePath = 'backups/' + filename;
+
+ try {
+ await self._ensureDir('backups');
+ const serialized = JSON.stringify(self.terminal.fs.root, null, 2);
+ const resp = await self._webdavRequest('PUT', remotePath, {
+ body: serialized,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ if (resp.ok || resp.status === 201 || resp.status === 204) {
+ self.terminal.print('Backup saved: ' + remotePath + ' (' + self._formatSize(serialized.length) + ')', 'success');
+ } else {
+ self.terminal.print('Backup failed: HTTP ' + resp.status, 'error');
+ }
+ } catch (e) {
+ self.terminal.print('Backup error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-restore: restore VFS from a WebDAV backup ---
+ this.commands['webdav-restore'] = async (args) => {
+ if (!args[0]) {
+ self.terminal.print('Usage: webdav-restore <backup-filename>', 'warning');
+ self.terminal.print(' Use webdav-ls backups/ to see available backups', 'info');
+ return;
+ }
+ let remotePath = args[0];
+ if (!remotePath.startsWith('backups/') && !remotePath.startsWith('/backups/')) {
+ remotePath = 'backups/' + remotePath;
+ }
+ try {
+ const resp = await self._webdavRequest('GET', remotePath);
+ if (!resp.ok) throw new Error('HTTP ' + resp.status);
+ const content = await resp.text();
+ const data = JSON.parse(content);
+ if (!data || !data.type) {
+ self.terminal.print('Invalid backup file (not a valid VFS export)', 'error');
+ return;
+ }
+ if (self.terminal.fs.restoreFromExport(data)) {
+ self.terminal.updatePrompt();
+ self.terminal.print('VFS restored from: ' + remotePath, 'success');
+ self.terminal.executeCommand('ls');
+ } else {
+ self.terminal.print('Failed to restore VFS from backup', 'error');
+ }
+ } catch (e) {
+ self.terminal.print('Restore error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-backup-list: list available backups ---
+ this.commands['webdav-backup-list'] = async () => {
+ try {
+ const entries = await self._listDir('backups');
+ if (entries.length === 0) {
+ self.terminal.print('No backups found', 'info');
+ return;
+ }
+ self.terminal.print('Available backups:', 'text-muted');
+ entries.forEach(e => {
+ if (e.type === 'file') {
+ self.terminal.print(' ' + e.name + ' (' + self._formatSize(e.size) + ')');
+ }
+ });
+ } catch (e) {
+ self.terminal.print('Failed to list backups: ' + e.message, 'info');
+ self.terminal.print(' (backups directory may not exist yet — run webdav-backup first)', 'info');
+ }
+ };
+
+ // --- webdav-explorer: interactive file browser via WebDAV ---
+ this.commands['webdav-explorer'] = async (args) => {
+ const startPath = args[0] || '/';
+ try {
+ const entries = await self._listDir(startPath);
+ self.terminal.print('📁 WebDAV: ' + startPath, 'info');
+ if (entries.length === 0) {
+ self.terminal.print('(empty)', 'info');
+ return;
+ }
+ entries.forEach(e => {
+ const icon = e.type === 'dir' ? '📁' : '📄';
+ const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/';
+ self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size);
+ });
+ self.terminal.print('---');
+ self.terminal.print('↳ Navigate: webdav-explorer <dir> | Pull: webdav-pull <file>', 'text-muted');
+ self.terminal.print(entries.length + ' item(s)', 'text-muted');
+ } catch (e) {
+ self.terminal.print('WebDAV error: ' + e.message, 'error');
+ }
+ };
+ }
+
+ _formatSize(bytes) {
+ if (bytes < 1024) return bytes + ' B';
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
+ }
+}
diff --git a/modules/ext/wpm.js b/modules/ext/wpm.js
new file mode 100644
index 0000000..9c8d820
--- /dev/null
+++ b/modules/ext/wpm.js
@@ -0,0 +1,149 @@
+class WpmModule {
+ constructor() {
+ this.commands = {};
+ this.terminal = null;
+ this.indexUrl = 'https://webx11.duckdns.org/spaces/chatstudio/newos4/packages.json';
+ this.index = null;
+ this.installed = this.loadInstalled();
+ }
+ getHelpInfo() {
+ return { 'wpm': 'wpm <install|list|search|update|remove|info> [...] — Web Package Manager for installing and managing modules' };
+ }
+ loadInstalled() {
+ try {
+ const data = localStorage.getItem('webx11_wpm_installed');
+ return data ? JSON.parse(data) : {};
+ } catch (e) { return {}; }
+ }
+ saveInstalled() {
+ localStorage.setItem('webx11_wpm_installed', JSON.stringify(this.installed));
+ }
+ registerCommands() {
+ this.commands['wpm'] = (args) => this.handle(args);
+ }
+ handle(args) {
+ if (!args[0]) return this.terminal.print('Usage: wpm install <pkg>, wpm list, wpm search <kw>, wpm update, wpm remove <pkg>, wpm info <pkg>', 'warning');
+ const sub = args[0].toLowerCase();
+ if (sub === 'install') this.install(args[1]);
+ else if (sub === 'list') this.list();
+ else if (sub === 'search') this.search(args.slice(1).join(' '));
+ else if (sub === 'update') this.update();
+ else if (sub === 'remove') this.remove(args[1]);
+ else if (sub === 'info') this.info(args[1]);
+ else this.terminal.print(`Unknown: ${sub}`, 'error');
+ }
+ async loadIndex() {
+ if (this.index) return this.index;
+ try {
+ this.terminal.print('Fetching package index...', 'info');
+ const res = await fetch(this.indexUrl);
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ this.index = await res.json();
+ return this.index;
+ } catch (e) {
+ this.terminal.print(`Failed to load index: ${e.message}`, 'error');
+ return null;
+ }
+ }
+ async install(name) {
+ if (!name) return this.terminal.print('Usage: wpm install <package>', 'warning');
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const pkg = idx.packages[name];
+ if (!pkg) return this.terminal.print(`Package "${name}" not found. Try: wpm search <keyword>`, 'error');
+ if (this.installed[name]) return this.terminal.print(`Package "${name}" already installed. Use wpm update to reinstall.`, 'warning');
+ this.terminal.print(`Installing ${pkg.name} v${pkg.version}...`, 'info');
+ this.terminal.print(` ${pkg.desc}`, 'info');
+ const success = await this.terminal.moduleManager.loadModuleFromURL(pkg.url);
+ if (success) {
+ this.installed[name] = { version: pkg.version, installed: new Date().toISOString() };
+ this.saveInstalled();
+ this.terminal.print(`"${name}" installed successfully!`, 'success');
+ }
+ }
+ async list() {
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const names = Object.keys(this.installed);
+ this.terminal.print('=== Installed Packages ===', 'info');
+ if (names.length === 0) {
+ this.terminal.print(' None. Use: wpm install <pkg>', 'info');
+ } else {
+ names.forEach(name => {
+ const inst = this.installed[name];
+ this.terminal.print(` ${name} v${inst.version} (${inst.installed.split('T')[0]})`);
+ });
+ }
+ this.terminal.print('\n=== Available Packages ===', 'info');
+ Object.keys(idx.packages).sort().forEach(name => {
+ const pkg = idx.packages[name];
+ const installed = this.installed[name] ? ' ✅' : '';
+ this.terminal.print(` ${name} - ${pkg.desc}${installed}`);
+ });
+ }
+ async search(keyword) {
+ if (!keyword) return this.terminal.print('Usage: wpm search <keyword>', 'warning');
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const kw = keyword.toLowerCase();
+ const results = [];
+ Object.entries(idx.packages).forEach(([name, pkg]) => {
+ if (name.includes(kw) || pkg.desc.toLowerCase().includes(kw) || pkg.category.toLowerCase().includes(kw)) {
+ results.push({ name, ...pkg });
+ }
+ });
+ if (results.length === 0) return this.terminal.print(`No packages matching "${keyword}"`, 'info');
+ this.terminal.print(`=== Search: "${keyword}" (${results.length} results) ===`, 'info');
+ results.forEach(r => {
+ const installed = this.installed[r.name] ? ' [installed]' : '';
+ this.terminal.print(` ${r.name} [${r.category}] - ${r.desc}${installed}`);
+ });
+ this.terminal.print('Use: wpm install <name> to install', 'info');
+ }
+ async update() {
+ this.index = null;
+ await this.loadIndex();
+ if (!this.index) return;
+ this.terminal.print('Index refreshed.', 'success');
+ const outdated = [];
+ Object.keys(this.installed).forEach(name => {
+ const pkg = this.index.packages[name];
+ if (pkg && pkg.version !== this.installed[name].version) {
+ outdated.push(name);
+ }
+ });
+ if (outdated.length > 0) {
+ this.terminal.print(`Outdated packages: ${outdated.join(', ')}. Reinstall with "wpm install <name>"`, 'warning');
+ }
+ }
+ async remove(name) {
+ if (!name) return this.terminal.print('Usage: wpm remove <package>', 'warning');
+ if (!this.installed[name]) return this.terminal.print(`Package "${name}" is not installed via wpm`, 'warning');
+ const idx = await this.loadIndex();
+ const className = idx && idx.packages[name] ? idx.packages[name].name.replace(/\s+/g, '') + 'Module' : null;
+ if (className && this.terminal.moduleManager.unregisterModule(className)) {
+ this.terminal.print(`Module ${className} unregistered`, 'info');
+ }
+ delete this.installed[name];
+ this.saveInstalled();
+ this.terminal.print(`Package "${name}" removed. If module persists, use: removemod <ClassName>`, 'success');
+ }
+ async info(name) {
+ if (!name) return this.terminal.print('Usage: wpm info <package>', 'warning');
+ const idx = await this.loadIndex();
+ if (!idx) return;
+ const pkg = idx.packages[name];
+ if (!pkg) return this.terminal.print(`Package "${name}" not found`, 'error');
+ this.terminal.print(`=== ${pkg.name} v${pkg.version} ===`, 'info');
+ this.terminal.print(`Description: ${pkg.desc}`);
+ this.terminal.print(`Category: ${pkg.category}`);
+ this.terminal.print(`Commands: ${pkg.commands.join(', ')}`);
+ this.terminal.print(`URL: ${pkg.url}`);
+ const installed = this.installed[name];
+ if (installed) this.terminal.print(`Installed: ${installed.installed}`, 'success');
+ else this.terminal.print('Not installed. Use: wpm install ' + name, 'info');
+ }
+}
+if (typeof window.webx11 !== 'undefined' && window.webx11) {
+ webx11.moduleManager.registerModule(new WpmModule(), 'https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wpm.js');
+}
diff --git a/packages.json b/packages.json
new file mode 100644
index 0000000..759dc0a
--- /dev/null
+++ b/packages.json
@@ -0,0 +1,112 @@
+{
+ "name": "WebX11 Package Index",
+ "version": "1.0.0",
+ "updated": "2026-05-24T00:00:00Z",
+ "base_url": "https://webx11.duckdns.org/spaces/newos4/modules/ext/",
+ "packages": {
+ "voice": {
+ "name": "Voice",
+ "version": "1.0.0",
+ "desc": "Speech-to-text input using Web Speech API",
+ "url": "modules/ext/voice.js",
+ "commands": ["voice"],
+ "category": "input"
+ },
+ "calc": {
+ "name": "Calculator",
+ "version": "1.0.0",
+ "desc": "Math evaluator and unit converter (km to miles, kg to pounds, etc.)",
+ "url": "modules/ext/calc.js",
+ "commands": ["calc"],
+ "category": "utility"
+ },
+ "cron": {
+ "name": "Cron Scheduler",
+ "version": "1.0.0",
+ "desc": "Schedule commands to run at intervals (min 5s)",
+ "url": "modules/ext/cron.js",
+ "commands": ["cron-every", "cron-list", "cron-stop", "cron-clear"],
+ "category": "automation"
+ },
+ "theme": {
+ "name": "Theme Switcher",
+ "version": "1.0.0",
+ "desc": "CSS theme manager — dark, light, amber, cyber + custom",
+ "url": "modules/ext/theme.js",
+ "commands": ["theme"],
+ "category": "ui"
+ },
+ "wpm": {
+ "name": "Package Manager",
+ "version": "1.0.0",
+ "desc": "Install modules from the WebX11 package index (like npm for the terminal)",
+ "url": "modules/ext/wpm.js",
+ "commands": ["wpm"],
+ "category": "system"
+ },
+ "webdav": {
+ "name": "WebDAV Sync",
+ "version": "1.0.0",
+ "desc": "Cloud sync for VFS — backup, push, pull, and restore via WebDAV",
+ "url": "modules/ext/webdav.js",
+ "commands": ["webdav-config", "webdav-status", "webdav-ls", "webdav-push", "webdav-pull", "webdav-backup", "webdav-restore", "webdav-backup-list"],
+ "category": "system"
+ },
+ "mindmap": {
+ "name": "Mindmap",
+ "version": "1.0.0",
+ "desc": "Brainstorming mindmap tool — new, add, show, export, save, load, color, tag",
+ "url": "modules/ext/mindmap.js",
+ "commands": ["mindmap"],
+ "category": "productivity"
+ },
+ "skill-pack-core": {
+ "name": "Skill Pack: Core",
+ "version": "1.0.0",
+ "desc": "Comprehensive skill set: terminal navigation, memory/profile, web search, module editing, system maintenance, backup, mindmap workflow, skills system guide",
+ "url": "modules/ext/skill-pack-core.js",
+ "commands": ["skill-pack-core"],
+ "category": "system"
+ },
+ "editor": {
+ "name": "Text Editor",
+ "version": "1.0.0",
+ "desc": "Inline text editor for VFS files — edit, save, cancel via modal",
+ "url": "modules/ext/editor.js",
+ "commands": ["edit"],
+ "category": "utility"
+ },
+ "diff": {
+ "name": "File Diff",
+ "version": "1.0.0",
+ "desc": "Compare two files or module versions with unified diff output",
+ "url": "modules/ext/diff.js",
+ "commands": ["diff"],
+ "category": "utility"
+ },
+ "alias": {
+ "name": "Command Aliases",
+ "version": "1.0.0",
+ "desc": "Define and manage command aliases (ll, h, cls defaults) with localStorage persistence",
+ "url": "modules/ext/alias.js",
+ "commands": ["alias", "unalias", "ll", "h", "cls"],
+ "category": "system"
+ },
+ "finder": {
+ "name": "File Finder",
+ "version": "1.0.0",
+ "desc": "Recursive VFS file/directory search with fuzzy matching",
+ "url": "modules/ext/finder.js",
+ "commands": ["find"],
+ "category": "utility"
+ },
+ "filemgr": {
+ "name": "File Manager",
+ "version": "1.0.0",
+ "desc": "Modern file browser with preview for Markdown, images, video, audio, code, PDF, CSV",
+ "url": "modules/ext/filemgr.js",
+ "commands": ["files", "preview"],
+ "category": "utility"
+ }
+ }
+}
diff --git a/packages.json.orig b/packages.json.orig
new file mode 100644
index 0000000..9d13824
--- /dev/null
+++ b/packages.json.orig
@@ -0,0 +1,168 @@
+{
+ "name": "WebX11 Package Index",
+ "version": "1.0.0",
+ "updated": "2026-05-19T00:00:00Z",
+ "base_url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/",
+ "packages": {
+ "widget": {
+ "name": "Widget",
+ "version": "1.0.0",
+ "desc": "Live dashboard widgets — clock, weather, random quote",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/widget.js",
+ "commands": ["widget"],
+ "category": "ui"
+ },
+ "voice": {
+ "name": "Voice",
+ "version": "1.0.0",
+ "desc": "Speech-to-text input using Web Speech API",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/voice.js",
+ "commands": ["voice"],
+ "category": "input"
+ },
+ "calc": {
+ "name": "Calculator",
+ "version": "1.0.0",
+ "desc": "Math evaluator and unit converter (km to miles, kg to pounds, etc.)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/calc.js",
+ "commands": ["calc"],
+ "category": "utility"
+ },
+ "bookmarks": {
+ "name": "Bookmarks",
+ "version": "1.0.0",
+ "desc": "Bookmark manager with tags, import/export",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/bookmarks.js",
+ "commands": ["bm-add", "bm-list", "bm-open", "bm-remove", "bm-export", "bm-import"],
+ "category": "utility"
+ },
+ "snippets": {
+ "name": "Snippets",
+ "version": "1.0.0",
+ "desc": "Code/text snippet library — save, insert, list, delete",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/snippets.js",
+ "commands": ["snippet"],
+ "category": "utility"
+ },
+ "notes": {
+ "name": "Notes",
+ "version": "1.0.0",
+ "desc": "Markdown note-taking in the virtual filesystem",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/notes.js",
+ "commands": ["note"],
+ "category": "productivity"
+ },
+ "cron": {
+ "name": "Cron Scheduler",
+ "version": "1.0.0",
+ "desc": "Schedule commands to run at intervals (min 5s)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/cron.js",
+ "commands": ["cron-every", "cron-list", "cron-stop", "cron-clear"],
+ "category": "automation"
+ },
+ "tour": {
+ "name": "Tour",
+ "version": "1.0.0",
+ "desc": "Interactive guided walkthrough of WebX11 features",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/tour.js",
+ "commands": ["tour"],
+ "category": "docs"
+ },
+ "theme": {
+ "name": "Theme Switcher",
+ "version": "1.0.0",
+ "desc": "CSS theme manager — dark, light, amber, cyber + custom",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/theme.js",
+ "commands": ["theme"],
+ "category": "ui"
+ },
+ "history-ai": {
+ "name": "AI History",
+ "version": "1.0.0",
+ "desc": "Log and replay Ollama AI conversations",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/history-ai.js",
+ "commands": ["ai-history", "ai-replay", "ai-export", "ai-save"],
+ "category": "ai"
+ },
+ "grep": {
+ "name": "Grep",
+ "version": "1.0.0",
+ "desc": "Filter lines matching a regex pattern (pipe-compatible)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/grep.js",
+ "commands": ["grep"],
+ "category": "pipe"
+ },
+ "wc": {
+ "name": "Word Count",
+ "version": "1.0.0",
+ "desc": "Count lines, words, and characters (pipe-compatible)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wc.js",
+ "commands": ["wc"],
+ "category": "pipe"
+ },
+ "head": {
+ "name": "Head",
+ "version": "1.0.0",
+ "desc": "Show first N lines of output (pipe-compatible)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/head.js",
+ "commands": ["head"],
+ "category": "pipe"
+ },
+ "tail": {
+ "name": "Tail",
+ "version": "1.0.0",
+ "desc": "Show last N lines of output (pipe-compatible)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/tail.js",
+ "commands": ["tail"],
+ "category": "pipe"
+ },
+ "wpm": {
+ "name": "Package Manager",
+ "version": "1.0.0",
+ "desc": "Install modules from the WebX11 package index (like npm for the terminal)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/wpm.js",
+ "commands": ["wpm"],
+ "category": "system"
+ },
+ "git4webx": {
+ "name": "Git Integration",
+ "version": "1.0.0",
+ "desc": "Clone GitHub repos into the virtual filesystem",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/git4webx.js",
+ "commands": ["git-clone", "git-status"],
+ "category": "dev"
+ },
+ "workspace": {
+ "name": "Workspaces",
+ "version": "1.0.0",
+ "desc": "Switch between isolated workspaces (separate filesystem + modules)",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/workspace.js",
+ "commands": ["ws"],
+ "category": "system"
+ },
+ "webdav": {
+ "name": "WebDAV Sync",
+ "version": "1.0.0",
+ "desc": "Cloud sync for VFS — backup, push, pull, and restore via WebDAV",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/webdav.js",
+ "commands": ["webdav-config", "webdav-status", "webdav-ls", "webdav-push", "webdav-pull", "webdav-backup", "webdav-restore", "webdav-backup-list"],
+ "category": "system"
+ },
+ "mindmap": {
+ "name": "Mindmap",
+ "version": "1.0.0",
+ "desc": "Brainstorming mindmap tool — new, add, show, export, save, load, color, tag",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/mindmap.js",
+ "commands": ["mindmap"],
+ "category": "productivity"
+ },
+ "skill-pack-core": {
+ "name": "Skill Pack: Core",
+ "version": "1.0.0",
+ "desc": "Comprehensive skill set: terminal navigation, memory/profile, web search, module editing, system maintenance, backup, mindmap workflow, skills system guide",
+ "url": "https://webx11.duckdns.org/spaces/chatstudio/newos4/modules/skill-pack-core.js",
+ "commands": ["skill-pack-core"],
+ "category": "system"
+ }
+ }
+}
diff --git a/space.yaml b/space.yaml
new file mode 100644
index 0000000..9f24e70
--- /dev/null
+++ b/space.yaml
@@ -0,0 +1,14 @@
+# WebX11 Space Configuration
+
+space:
+ name: newos4
+ owner: newos4
+ created: 2026-05-25T16:40:00.000Z
+
+settings:
+ public: true
+ max_size_mb: 100
+
+features:
+ webdav: true
+ static_hosting: true
diff --git a/summary.txt b/summary.txt
new file mode 100644
index 0000000..b89a9a5
--- /dev/null
+++ b/summary.txt
@@ -0,0 +1,14 @@
+session_summary: project: WebX11 Module System v4.2
+type: Mobile-Optimized Web Terminal
+
+status: Complete
+
+key_features: - Virtual File System with localStorage persistence - Dynamic Module Loading from URLs - Ollama AI Integration for local LLM access - Mobile-responsive design with touch optimizations - Command-line interface with history support - Quick action buttons for common tasks
+
+components: virtual_file_system:
+description: In-browser file storage using localStorage commands: [ls, cat, put, rm, upload, download]
+
+module_manager: description: Dynamic module loading and registry system commands: [modules, loadmod, removemod, listmods] persistence: localStorage
+
+ollama_manager:
+description: Local AI model integration commands: [ollamaconnect, ollamadisconnect, ollamamodels, ollamasetmodel, ai, ask] default_endpoint: http://localhost:11434 terminal_core: description: Main interface controller features: [command_history, network_status, memory_monitor]built_in_modules: - SystemModule - FileSystemModule - ModuleManagerModule - OllamaModuleui_elements: - Header with version and memory status - Terminal output container - Quick action button bar - Command input area - Status bar (network/ready status) - Modals: File Browser, Module Manager, Ollama Managerdecisions: - Single-file HTML implementation for portability - localStorage for data persistence across sessions - Mobile-first responsive design - Modular architecture for extensibilityaction_items: - Deploy HTML file to web server or open locally - Configure Ollama endpoint if using AI features - Load custom modules via URL if needed - Use 'help' command to view all available commandstechnical_notes: - Requires modern browser with localStorage support - Ollama integration needs local Ollama server running - Module loading requires CORS-friendly URLs - All data stored client-side only \ No newline at end of file