diff options
| author | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-05-25 18:02:02 +0200 |
| commit | 4baa484b84c5184286cac2743575251454a5ee2b (patch) | |
| tree | cb9002f93644f363d0f35831dc97e6492bbb0ed6 /modules/core/module-aiassist.js | |
auto: 2026-05-25_18:02 changes
Diffstat (limited to 'modules/core/module-aiassist.js')
| -rw-r--r-- | modules/core/module-aiassist.js | 1435 |
1 files changed, 1435 insertions, 0 deletions
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'); + }); + } + } |