diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index cdd2d7c..9df4a81 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ -// @version 1.1.2 -// @description Safely execute ^%$bridge YAML commands in chat UIs with dedupe, debounce, and clear feedback — minimal and focused. +// @version 1.2.1 +// @description Execute ^%$bridge YAML commands from AI assistants in code blocks with persistent dedupe and robust paste // @author Your Name // @match https://chat.openai.com/* // @match https://chatgpt.com/* @@ -10,6 +10,7 @@ // @match https://gemini.google.com/* // @grant GM_xmlhttpRequest // @grant GM_notification +// @grant GM_setClipboard // @connect n8n.brrd.tech // ==/UserScript== @@ -18,95 +19,109 @@ // ---------------------- Config ---------------------- const CONFIG = { - ENABLE_API: true, // Master kill switch - DEBUG_MODE: true, // Console logs - DEBOUNCE_DELAY: 5000, // Bot typing protection - MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries) - VERSION: '1.1.2', + ENABLE_API: true, // Master kill switch + DEBUG_MODE: true, // Console logs + DEBOUNCE_DELAY: 5000, // Bot typing protection + MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries) + VERSION: '1.2.1', - // Lean dedupe + cleanup (keep it simple) - SEEN_TTL_MS: 60000, // Deduplicate identical blocks for 60s - CLEANUP_AFTER_MS: 30000, // Drop COMPLETE/ERROR entries after 30s - CLEANUP_INTERVAL_MS: 60000, // Sweep cadence + PROCESS_EXISTING: false, // If false, only process messages added after init (but see initial delayed scan) + ASSISTANT_ONLY: true, // Process assistant messages by default (your core use case) - // Optional safety: when true, only user-authored messages are processed - SKIP_AI_MESSAGES: false + // Persistent dedupe window + DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days + + // Housekeeping + CLEANUP_AFTER_MS: 30000, // Drop COMPLETE/ERROR entries after 30s + CLEANUP_INTERVAL_MS: 60000 // Sweep cadence }; // ---------------------- Platform selectors ---------------------- const PLATFORM_SELECTORS = { 'chat.openai.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, 'chatgpt.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, - 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, - 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } + 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, + 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } }; // ---------------------- Command requirements ---------------------- const REQUIRED_FIELDS = { 'update_file': ['action', 'repo', 'path', 'content'], - 'get_file': ['action', 'repo', 'path'], + 'get_file': ['action', 'repo', 'path'], 'create_repo': ['action', 'repo'], 'create_file': ['action', 'repo', 'path', 'content'], 'delete_file': ['action', 'repo', 'path'], - 'list_files': ['action', 'repo', 'path'] + 'list_files': ['action', 'repo', 'path'] }; const FIELD_VALIDATORS = { // allow "owner/repo" or just "repo" - 'repo': (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), + repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), // minimal traversal guard: no absolute paths, no backslashes, no ".." - 'path': (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), - 'action': (v) => Object.keys(REQUIRED_FIELDS).includes(v), - 'owner': (v) => !v || /^[\w\-]+$/.test(v), - 'url': (v) => !v || /^https?:\/\/.+\..+/.test(v) + path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), + action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), + owner: (v) => !v || /^[\w\-]+$/.test(v), + url: (v) => !v || /^https?:\/\/.+\..+/.test(v) }; const STATUS_TEMPLATES = { - SUCCESS: '[{action}: Success] {details}', - ERROR: '[{action}: Error] {details}', + SUCCESS: '[{action}: Success] {details}', + ERROR: '[{action}: Error] {details}', VALIDATION_ERROR: '[{action}: Invalid] {details}', - EXECUTING: '[{action}: Processing...]', - MOCK: '[{action}: Mock] {details}' + EXECUTING: '[{action}: Processing...]', + MOCK: '[{action}: Mock] {details}' }; const COMMAND_STATES = { - DETECTED: 'detected', - PARSING: 'parsing', + DETECTED: 'detected', + PARSING: 'parsing', VALIDATING: 'validating', DEBOUNCING: 'debouncing', - EXECUTING: 'executing', - COMPLETE: 'complete', - ERROR: 'error' + EXECUTING: 'executing', + COMPLETE: 'complete', + ERROR: 'error' }; - // ---------------------- Ephemeral key ---------------------- - let BRIDGE_KEY = null; - function requireBridgeKeyIfNeeded() { - if (CONFIG.ENABLE_API && !BRIDGE_KEY) { - BRIDGE_KEY = prompt('[AI Repo Commander] Enter your bridge key for this session:'); - if (!BRIDGE_KEY) throw new Error('Bridge key required when API is enabled.'); + // ---------------------- Persistent Command History ---------------------- + class CommandHistory { + constructor() { + this.key = 'ai_repo_commander_executed'; + this.ttl = CONFIG.DEDUPE_TTL_MS; + this.cleanup(); } - return BRIDGE_KEY; - } - - // ---------------------- Dedupe store (hash -> timestamp) ---------------------- - const SEEN_MAP = new Map(); - function hashBlock(str) { - // djb2/xor variant -> base36 - let h = 5381; - for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i); - return (h >>> 0).toString(36); - } - function alreadySeenBlock(blockText) { - const now = Date.now(); - const key = hashBlock(blockText); - const ts = SEEN_MAP.get(key); - if (ts && (now - ts) < CONFIG.SEEN_TTL_MS) return true; - SEEN_MAP.set(key, now); - // prune occasionally - for (const [k, t] of SEEN_MAP) if ((now - t) >= CONFIG.SEEN_TTL_MS) SEEN_MAP.delete(k); - return false; + _load() { + try { return JSON.parse(localStorage.getItem(this.key) || '{}'); } + catch { return {}; } + } + _save(db) { localStorage.setItem(this.key, JSON.stringify(db)); } + _hash(s) { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i); + return (h >>> 0).toString(36); + } + has(text) { + const db = this._load(); + const k = this._hash(text); + const ts = db[k]; + return !!ts && (Date.now() - ts) < this.ttl; + } + mark(text) { + const db = this._load(); + db[this._hash(text)] = Date.now(); + this._save(db); + } + cleanup() { + const db = this._load(); + const now = Date.now(); + let dirty = false; + for (const [k, ts] of Object.entries(db)) { + if (!ts || (now - ts) >= this.ttl) { delete db[k]; dirty = true; } + } + if (dirty) this._save(db); + } + reset() { localStorage.removeItem(this.key); } } + window.AI_REPO_CLEAR_HISTORY = () => localStorage.removeItem('ai_repo_commander_executed'); // ---------------------- UI feedback ---------------------- class UIFeedback { @@ -118,7 +133,7 @@ } static createStatusElement(templateType, data) { const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; - const message = template.replace('{action}', data.action).replace('{details}', data.details); + const message = template.replace('{action}', data.action).replace('{details}', data.details); const el = document.createElement('div'); el.className = 'ai-repo-commander-status'; el.textContent = message; @@ -136,15 +151,105 @@ } } + // ---------------------- Paste helper ---------------------- + function getVisibleInputCandidate() { + const candidates = [ + '.ProseMirror#prompt-textarea', + '#prompt-textarea.ProseMirror', + '#prompt-textarea', + '.ProseMirror', + '[contenteditable="true"]', + 'textarea' + ]; + for (const sel of candidates) { + const el = document.querySelector(sel); + if (!el) continue; + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + if (el.offsetParent === null && style.position !== 'fixed') continue; + return el; + } + return null; + } + + function pasteToComposer(text) { + try { + const el = getVisibleInputCandidate(); + if (!el) { + GM_notification({ title: 'AI Repo Commander', text: 'No input box found to paste file content.', timeout: 4000 }); + return false; + } + + el.focus(); + + // 1) Try a real-ish paste event with DataTransfer + try { + const dt = new DataTransfer(); + dt.setData('text/plain', text); + const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + if (el.dispatchEvent(pasteEvt) && !pasteEvt.defaultPrevented) return true; + } catch (_) { /* continue */ } + + // 2) Try execCommand insertText (still widely supported) + try { + const sel = window.getSelection && window.getSelection(); + if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) { + const r = document.createRange(); + r.selectNodeContents(el); + r.collapse(false); + sel.removeAllRanges(); + sel.addRange(r); + } + if (document.execCommand && document.execCommand('insertText', false, text)) return true; + } catch (_) { /* continue */ } + + // 3) ProseMirror: inject paragraph HTML + notify input/change + const isPM = el.classList && el.classList.contains('ProseMirror'); + if (isPM) { + const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); + const html = String(text).split('\n').map(line => line.length ? `

${escape(line)}

` : '


').join(''); + el.innerHTML = html; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return true; + } + + // 4) contenteditable/textarea fallback + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + el.value = text; + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + el.textContent = text; + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + + // 5) Clipboard fallback for manual paste + try { + if (typeof GM_setClipboard === 'function') { + GM_setClipboard(text, { type: 'text', mimetype: 'text/plain' }); + GM_notification({ title: 'AI Repo Commander', text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', timeout: 5000 }); + } + } catch (_) {} + return false; + + } catch (e) { + console.warn('[AI Repo Commander] pasteToComposer failed:', e); + return false; + } + } + // ---------------------- Parser ---------------------- class CommandParser { - static parseYAMLCommand(text) { - const block = this.extractCommandBlock(text); + static parseYAMLCommand(codeBlockText) { + const block = this.extractCommandBlock(codeBlockText); if (!block) throw new Error('No valid command block found'); const parsed = this.parseKeyValuePairs(block); // defaults - parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; + parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; parsed.owner = parsed.owner || 'rob'; // expand owner/repo shorthand @@ -157,8 +262,13 @@ } static extractCommandBlock(text) { - // require ^%$bridge ... --- (tolerate trailing spaces and EOF) - const m = text.match(/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m); + // We are passed the *code block text*. Accept ^%$bridge until explicit '---' or end-of-code-block (EOF). + // More flexible: allow ^%$bridge anywhere on its own line (with optional leading spaces). + const start = text.search(/(^|\n)\s*\^%\$bridge\b/m); + if (start === -1) return null; + + const after = text.slice(start); + const m = after.match(/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)(?:\n---[ \t]*(?:\n|$)|$)/m); return m ? m[1].trimEnd() : null; } @@ -180,7 +290,6 @@ if (isTopKey) { result[currentKey] = buf.join('\n').trimEnd(); collecting = false; buf = []; - // fallthrough to parse this line as a new key } else { buf.push(line); continue; } @@ -228,7 +337,7 @@ class ExecutionManager { static async executeCommand(command, sourceElement) { try { - // FIX: synthesize commit_message first so mock and real behave the same + // synthesize commit_message for file writes if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; } @@ -294,10 +403,34 @@ let data; try { data = JSON.parse(response.responseText || '{}'); } catch { data = { message: 'Operation completed (no JSON body)' }; } + UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', { action: command.action, details: data.message || 'Operation completed successfully' }); + + // If this was a get_file, try to paste the returned content into the composer + if (command.action === 'get_file') { + const payload = data; + const item = Array.isArray(payload) ? payload[0] : payload; + + // Try common shapes (n8n variations) + const body = + item?.result?.content?.data ?? + item?.content?.data ?? + payload?.result?.content?.data ?? + null; + + if (typeof body === 'string' && body.length) { + const pasted = pasteToComposer(body); + if (!pasted) { + GM_notification({ title: 'AI Repo Commander', text: 'Fetched file (get_file), but could not paste into input.', timeout: 4000 }); + } + } else { + console.log('[AI Repo Commander] get_file: no content.data in response', data); + GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but response had no content.data', timeout: 4000 }); + } + } return { success: true, data, isMock }; } @@ -312,10 +445,21 @@ static delay(ms) { return new Promise(r => setTimeout(r, ms)); } } + // ---------------------- Bridge Key ---------------------- + let BRIDGE_KEY = null; // (Declared near top for clarity) + function requireBridgeKeyIfNeeded() { + if (CONFIG.ENABLE_API && !BRIDGE_KEY) { + BRIDGE_KEY = prompt('[AI Repo Commander] Enter your bridge key for this session:'); + if (!BRIDGE_KEY) throw new Error('Bridge key required when API is enabled.'); + } + return BRIDGE_KEY; + } + // ---------------------- Monitor ---------------------- class CommandMonitor { constructor() { this.trackedMessages = new Map(); // id -> { element, originalText, state, lastUpdate } + this.history = new CommandHistory(); this.observer = null; this.currentPlatform = null; this.initialize(); @@ -346,7 +490,20 @@ }); }); this.observer.observe(document.body, { childList: true, subtree: true }); - this.scanExistingMessages(); + + // Always do one delayed scan to catch initial render, even with PROCESS_EXISTING=false + setTimeout(() => { + this.log('Initial scan after page load'); + this.scanMessages(); + }, 2000); + + // Optional deeper scan if explicitly enabled + if (CONFIG.PROCESS_EXISTING) { + setTimeout(() => { + this.log('Deep scan of existing messages (PROCESS_EXISTING=true)'); + this.scanMessages(); + }, 5000); + } } scanNode(node) { @@ -355,71 +512,80 @@ scanExistingMessages() { setTimeout(() => this.scanMessages(), 1000); } - // Optional safety: when SKIP_AI_MESSAGES=true, only process user-authored messages - isUserMessage(element) { - if (!CONFIG.SKIP_AI_MESSAGES) return true; // default: process both user + assistant - const host = window.location.hostname; - if (host === 'chat.openai.com' || host === 'chatgpt.com') { - return !!element.closest?.('[data-message-author-role="user"]'); + isAssistantMessage(el) { + if (!CONFIG.ASSISTANT_ONLY) return true; // Process all messages + + const host = location.hostname; + + // OpenAI/ChatGPT: reliable role attribute + if (/chat\.openai\.com|chatgpt\.com/.test(host)) { + return !!el.closest?.('[data-message-author-role="assistant"]'); } - // For unknown platforms, be permissive (treat as user) + + // Claude: prefer explicit role if present; else allow (Claude DOM varies) + if (/claude\.ai/.test(host)) { + const isUser = !!el.closest?.('[data-message-author-role="user"]'); + return !isUser; + } + + // Gemini: DOM varies; allow by default (can refine later) + if (/gemini\.google\.com/.test(host)) { + return true; + } + + // Unknown platforms: allow return true; } + // Require: command must be in a code block (pre/code) + findCommandInCodeBlock(el) { + const blocks = el.querySelectorAll('pre code, pre, code'); + for (const b of blocks) { + const txt = (b.textContent || '').trim(); + // Flexible: allow ^%$bridge to appear anywhere on its own line + if (/(^|\n)\s*\^%\$bridge\b/m.test(txt)) { + return { blockElement: b, text: txt }; + } + } + return null; + } + getMessageId(element) { if (element.dataset && element.dataset.aiRcId) return element.dataset.aiRcId; - // prefer DOM id; otherwise add a tiny salt to reduce collisions const id = element.id || `${element.className}-${element.childElementCount}-${Date.now()}-${Math.random().toString(36).slice(2,6)}`; if (element.dataset) element.dataset.aiRcId = id; return id; } - extractText(element) { - const c = element.querySelector(this.currentPlatform.content); - const target = c || element; - return (target.textContent || '').trim(); - } - - // Always ignore commands shown inside code/pre blocks so you can discuss examples safely - hasBridgeInCodeBlock(element) { - // If this is an assistant message, allow execution even when rendered as code. - // Rationale: assistant YAML often renders as
. We still want to execute.
-      const isAssistant = !!element.closest?.('[data-message-author-role="assistant"]');
-      if (isAssistant) return false;
-
-      const nodes = element.querySelectorAll('pre, code');
-      for (const el of nodes) {
-        if ((el.textContent || '').includes('^%$bridge')) return true;
-      }
-      return false;
+    reextractCommandText(element) {
+      const hit = this.findCommandInCodeBlock(element);
+      return hit ? hit.text : '';
     }
 
     scanMessages() {
       const messages = document.querySelectorAll(this.currentPlatform.messages);
       messages.forEach((el) => {
-        if (!this.isUserMessage(el)) return; // optional safety (disabled by default)
+        if (!this.isAssistantMessage(el)) return;
+        if (el.dataset.aiRcProcessed) return;
 
-        const id = this.getMessageId(el);
-        if (this.trackedMessages.has(id)) return;
+        const hit = this.findCommandInCodeBlock(el);
+        if (!hit) return;
 
-        if (this.hasBridgeInCodeBlock(el)) return; // discussing examples → ignore
+        const cmdText = hit.text;
 
-        const text = this.extractText(el);
-        if (!text) return;
+        // Persistent dedupe: skip if we've already executed this exact block recently
+        if (this.history.has(cmdText)) {
+          this.log('Skipping already-executed command (persistent history)');
+          return;
+        }
 
-        // Require a full ^%$bridge ... --- block to avoid false positives
-        const m = text.match(/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m);
-        if (!m) return;
-
-        const wholeBlock = m[0];
-        if (alreadySeenBlock(wholeBlock)) return; // dedupe across multi-wrapper renders
-
-        this.trackMessage(el, text, id);
+        el.dataset.aiRcProcessed = '1';
+        this.trackMessage(el, cmdText, this.getMessageId(el));
       });
     }
 
     trackMessage(element, text, messageId) {
-      this.log('New command detected:', { messageId, text: text.substring(0, 120) });
+      this.log('New command detected in code block:', { messageId, preview: text.substring(0, 120) });
       this.trackedMessages.set(messageId, {
         element, originalText: text, state: COMMAND_STATES.DETECTED, startTime: Date.now(), lastUpdate: Date.now()
       });
@@ -434,14 +600,6 @@
       msg.lastUpdate = Date.now();
       this.trackedMessages.set(messageId, msg);
       this.log(`Message ${messageId} state updated to: ${state}`);
-
-      // Terminal cleanup after grace period (kept shortly for debugging)
-      if (state === COMMAND_STATES.COMPLETE || state === COMMAND_STATES.ERROR) {
-        setTimeout(() => {
-          this.trackedMessages.delete(messageId);
-          this.log(`Cleaned up message ${messageId}`);
-        }, CONFIG.CLEANUP_AFTER_MS);
-      }
     }
 
     async processCommand(messageId) {
@@ -449,23 +607,50 @@
         const message = this.trackedMessages.get(messageId);
         if (!message) return;
 
-        const parsed = CommandParser.parseYAMLCommand(message.originalText);
+        let parsed = CommandParser.parseYAMLCommand(message.originalText);
         this.updateState(messageId, COMMAND_STATES.VALIDATING);
 
-        const validation = CommandParser.validateStructure(parsed);
+        let validation = CommandParser.validateStructure(parsed);
         if (!validation.isValid) throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
 
         this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
         const before = message.originalText;
         await this.debounce();
-        const after = this.extractText(message.element);
-        if (after && after !== before) {
-          this.log('Content changed during debounce; restarting debounce.');
+
+        // Re-extract after debounce in case the AI finished streaming or edited
+        const after = this.reextractCommandText(message.element);
+
+        // If command disappeared, abort gracefully
+        if (!after) {
+          this.log('Command removed during debounce - aborting');
+          this.updateState(messageId, COMMAND_STATES.ERROR);
+          return;
+        }
+
+        // If changed, re-debounce once and re-parse
+        if (after !== before) {
+          this.log('Command changed during debounce - updating and re-debouncing once');
+          message.originalText = after;
           await this.debounce();
+
+          const finalTxt = this.reextractCommandText(message.element);
+          if (!finalTxt) {
+            this.log('Command removed after re-debounce - aborting');
+            this.updateState(messageId, COMMAND_STATES.ERROR);
+            return;
+          }
+          message.originalText = finalTxt;
+          parsed = CommandParser.parseYAMLCommand(finalTxt);
+          validation = CommandParser.validateStructure(parsed);
+          if (!validation.isValid) throw new Error(`Final validation failed: ${validation.errors.join(', ')}`);
         }
 
         this.updateState(messageId, COMMAND_STATES.EXECUTING);
         await ExecutionManager.executeCommand(parsed, message.element);
+
+        // Mark the FINAL executed version in history
+        this.history.mark(message.originalText);
+
         this.updateState(messageId, COMMAND_STATES.COMPLETE);
 
       } catch (error) {
@@ -507,9 +692,12 @@
     log(...args) { if (CONFIG.DEBUG_MODE) console.log('[AI Repo Commander]', ...args); }
   }
 
-  // ---------------------- Test commands (unchanged) ----------------------
+  // ---------------------- Test commands ----------------------
   const TEST_COMMANDS = {
-    validUpdate: `^%$bridge
+    validUpdate:
+`\
+\`\`\`yaml
+^%$bridge
 action: update_file
 repo: test-repo
 path: TEST.md
@@ -517,17 +705,17 @@ content: |
   Test content
   Multiple lines
 ---
+\`\`\`
 `,
-    invalidCommand: `^%$bridge
-action: update_file
-repo: test-repo
----
-`,
-    getFile: `^%$bridge
+    getFile:
+`\
+\`\`\`yaml
+^%$bridge
 action: get_file
 repo: test-repo
 path: README.md
 ---
+\`\`\`
 `
   };
 
@@ -536,10 +724,17 @@ path: README.md
   function initializeRepoCommander() {
     if (!commandMonitor) {
       commandMonitor = new CommandMonitor();
-      window.AI_REPO_COMMANDER = { monitor: commandMonitor, config: CONFIG, test: TEST_COMMANDS, version: CONFIG.VERSION };
+      window.AI_REPO_COMMANDER = {
+        monitor: commandMonitor,
+        config: CONFIG,
+        test: TEST_COMMANDS,
+        version: CONFIG.VERSION,
+        history: commandMonitor.history
+      };
       console.log('AI Repo Commander fully initialized');
       console.log('API Enabled:', CONFIG.ENABLE_API);
       console.log('Test commands available in window.AI_REPO_COMMANDER.test');
+      console.log('Reset history: window.AI_REPO_COMMANDER.history.reset() or AI_REPO_CLEAR_HISTORY()');
     }
   }