diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 582cb89..c108747 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -1,9 +1,10 @@ // ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ -// @version 1.0.2 -// @description Enable AI assistants to securely interact with git repositories via YAML commands, with safety-first execution and clear inline feedback. +// @version 1.1.2 +// @description Safely execute ^%$bridge YAML commands in chat UIs with dedupe, debounce, and clear feedback — minimal and focused. // @author Your Name +// @match https://chat.openai.com/* // @match https://chatgpt.com/* // @match https://claude.ai/* // @match https://gemini.google.com/* @@ -12,541 +13,497 @@ // @connect n8n.brrd.tech // ==/UserScript== -(function() { - 'use strict'; +(function () { + 'use strict'; - // Configuration - MUST be manually enabled for production - const CONFIG = { - ENABLE_API: false, // Master kill switch - DEBUG_MODE: true, // Development logging - DEBOUNCE_DELAY: 5000, // 5-second bot typing protection - MAX_RETRIES: 2, // API retry attempts (2 retries => up to 3 total attempts) - VERSION: '1.0.2' - }; + // ---------------------- Config ---------------------- + const CONFIG = { + ENABLE_API: false, // 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', - // Platform-specific DOM selectors - const PLATFORM_SELECTORS = { - 'chat.openai.com': { - messages: '[class*="message"]', - input: '#prompt-textarea', - content: '[class*="markdown"]' - }, - 'claude.ai': { - messages: '.chat-message', - input: '[contenteditable="true"]', - content: '.content' - }, - 'gemini.google.com': { - messages: '.message-content', - input: 'textarea, [contenteditable="true"]', - content: '.message-text' - } - }; + // 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 - // Required fields matrix - const REQUIRED_FIELDS = { - 'update_file': ['action', 'repo', 'path', 'content'], - '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'] - }; + // Optional safety: when true, only user-authored messages are processed + SKIP_AI_MESSAGES: false + }; - // Enhanced field validators (minimal safety) - const FIELD_VALIDATORS = { - // allow "owner/repo" or just "repo" - 'repo': (value) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(value), - // basic traversal guard: no absolute paths, no backslashes, no ".." - 'path': (value) => !value.includes('..') && !value.startsWith('/') && !value.includes('\\'), - 'action': (value) => Object.keys(REQUIRED_FIELDS).includes(value), - 'owner': (value) => !value || /^[\w\-]+$/.test(value), - 'url': (value) => !value || /^https?:\/\/.+\..+/.test(value) - }; + // ---------------------- Platform selectors ---------------------- + const PLATFORM_SELECTORS = { + 'chat.openai.com': { messages: '[class*="message"]', input: '#prompt-textarea', content: '[class*="markdown"]' }, + 'chatgpt.com': { messages: '[class*="message"]', input: '#prompt-textarea', content: '[class*="markdown"]' }, + 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, + 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } + }; - // Status message templates - const STATUS_TEMPLATES = { - SUCCESS: '[{action}: Success] {details}', - ERROR: '[{action}: Error] {details}', - VALIDATION_ERROR: '[{action}: Invalid] {details}', - EXECUTING: '[{action}: Processing...]', - MOCK: '[{action}: Mock] {details}' - }; + // ---------------------- Command requirements ---------------------- + const REQUIRED_FIELDS = { + 'update_file': ['action', 'repo', 'path', 'content'], + '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'] + }; - // Command states - const COMMAND_STATES = { - DETECTED: 'detected', - PARSING: 'parsing', - VALIDATING: 'validating', - DEBOUNCING: 'debouncing', - EXECUTING: 'executing', - COMPLETE: 'complete', - ERROR: 'error' - }; + const FIELD_VALIDATORS = { + // allow "owner/repo" or just "repo" + '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) + }; - // Ephemeral secret (memory only; prompted when API enabled) - 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.'); - } - return BRIDGE_KEY; + const STATUS_TEMPLATES = { + SUCCESS: '[{action}: Success] {details}', + ERROR: '[{action}: Error] {details}', + VALIDATION_ERROR: '[{action}: Invalid] {details}', + EXECUTING: '[{action}: Processing...]', + MOCK: '[{action}: Mock] {details}' + }; + + const COMMAND_STATES = { + DETECTED: 'detected', + PARSING: 'parsing', + VALIDATING: 'validating', + DEBOUNCING: 'debouncing', + 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.'); + } + 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; + } + + // ---------------------- UI feedback ---------------------- + class UIFeedback { + static appendStatus(sourceElement, templateType, data) { + const statusElement = this.createStatusElement(templateType, data); + const existing = sourceElement.querySelector('.ai-repo-commander-status'); + if (existing) existing.remove(); + sourceElement.appendChild(statusElement); + } + static createStatusElement(templateType, data) { + const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; + 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; + el.style.cssText = ` + padding: 8px 12px; margin: 10px 0; border-radius: 4px; + border-left: 4px solid ${this.color(templateType)}; + background-color: rgba(255,255,255,0.08); font-family: monospace; font-size: 14px; + white-space: pre-wrap; word-wrap: break-word; border: 1px solid rgba(255,255,255,0.15); + `; + return el; + } + static color(t) { + const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' }; + return c[t] || '#6B7280'; + } + } + + // ---------------------- Parser ---------------------- + class CommandParser { + static parseYAMLCommand(text) { + const block = this.extractCommandBlock(text); + 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.owner = parsed.owner || 'brrd'; + + // expand owner/repo shorthand + if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) { + const [owner, repo] = parsed.repo.split('/', 2); + if (!parsed.owner) parsed.owner = owner; + parsed.repo = repo; + } + return parsed; } - // Core Monitor Class - class CommandMonitor { - constructor() { - this.trackedMessages = new Map(); // id -> { element, originalText, state, ... } - this.observer = null; - this.currentPlatform = null; - this.initialize(); - } - - initialize() { - this.detectPlatform(); - this.startObservation(); - this.setupEmergencyStop(); - this.log('AI Repo Commander initialized', CONFIG); - if (CONFIG.ENABLE_API) { - console.warn('[AI Repo Commander] API is enabled — you will be prompted for your bridge key on first command.'); - } - } - - detectPlatform() { - const hostname = window.location.hostname; - this.currentPlatform = PLATFORM_SELECTORS[hostname] || PLATFORM_SELECTORS['chat.openai.com']; - } - - startObservation() { - this.observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === 1) { // Element - this.scanNode(node); - } - }); - }); - }); - - this.observer.observe(document.body, { childList: true, subtree: true }); - - // Initial scan - this.scanExistingMessages(); - } - - scanNode(node) { - if (node.querySelector && node.querySelector(this.currentPlatform.messages)) { - this.scanMessages(); - } - } - - scanExistingMessages() { - setTimeout(() => this.scanMessages(), 1000); - } - - scanMessages() { - const messages = document.querySelectorAll(this.currentPlatform.messages); - messages.forEach((messageElement) => { - const messageId = this.getMessageId(messageElement); - if (this.trackedMessages.has(messageId)) return; - - const text = this.extractText(messageElement); - if (text && text.includes('^%$bridge')) { - this.trackMessage(messageElement, text, messageId); - } - }); - } - - // Stable id: persist a data attribute to avoid duplicate processing on rescans - getMessageId(element) { - if (element.dataset && element.dataset.aiRcId) return element.dataset.aiRcId; - 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 contentElement = element.querySelector(this.currentPlatform.content); - return contentElement ? contentElement.textContent : element.textContent; - } - - trackMessage(element, text, messageId) { - this.log('New command detected:', { messageId, text: text.substring(0, 100) }); - this.trackedMessages.set(messageId, { - element, - originalText: text, - state: COMMAND_STATES.DETECTED, - startTime: Date.now() - }); - this.updateState(messageId, COMMAND_STATES.PARSING); - this.processCommand(messageId); - } - - updateState(messageId, state) { - const message = this.trackedMessages.get(messageId); - if (message) { - message.state = state; - message.lastUpdate = Date.now(); - this.trackedMessages.set(messageId, message); - this.log(`Message ${messageId} state updated to: ${state}`); - } - } - - async processCommand(messageId) { - try { - const message = this.trackedMessages.get(messageId); - if (!message) return; - - // Step 1: Parse command - const parsedCommand = CommandParser.parseYAMLCommand(message.originalText); - this.updateState(messageId, COMMAND_STATES.VALIDATING); - - // Step 2: Validate command - const validation = CommandParser.validateStructure(parsedCommand); - if (!validation.isValid) { - throw new Error(`Validation failed: ${validation.errors.join(', ')}`); - } - - // Step 3: Debounce with content change detection (handles streaming edits) - this.updateState(messageId, COMMAND_STATES.DEBOUNCING); - const initialText = message.originalText; - await this.debounce(); - const currentText = this.extractText(message.element); - if (currentText && currentText !== initialText) { - this.log('Content changed during debounce; restarting debounce.'); - await this.debounce(); - } - - // Step 4: Optional commit message synthesis - if ((parsedCommand.action === 'update_file' || parsedCommand.action === 'create_file') && - !parsedCommand.commit_message) { - parsedCommand.commit_message = `AI Repo Commander: ${parsedCommand.path} (${new Date().toISOString()})`; - } - - // Step 5: Execute command - this.updateState(messageId, COMMAND_STATES.EXECUTING); - await ExecutionManager.executeCommand(parsedCommand, message.element); - - // Step 6: Complete - this.updateState(messageId, COMMAND_STATES.COMPLETE); - - } catch (error) { - this.log(`Command processing error: ${error.message}`); - this.updateState(messageId, COMMAND_STATES.ERROR); - const message = this.trackedMessages.get(messageId); - if (message) { - UIFeedback.appendStatus(message.element, 'ERROR', { - action: 'Command', - details: error.message - }); - } - } - } - - debounce() { - return new Promise(resolve => setTimeout(resolve, CONFIG.DEBOUNCE_DELAY)); - } - - stopAllProcessing() { - this.trackedMessages.clear(); - if (this.observer) this.observer.disconnect(); - } - - setupEmergencyStop() { - window.AI_REPO_STOP = () => { - CONFIG.ENABLE_API = false; - this.stopAllProcessing(); - this.log('EMERGENCY STOP ACTIVATED'); - GM_notification({ - text: 'AI Repo Commander Emergency Stop Activated', - title: 'Safety System', - timeout: 5000 - }); - }; - } - - log(...args) { - if (CONFIG.DEBUG_MODE) console.log('[AI Repo Commander]', ...args); - } + static extractCommandBlock(text) { + // require ^%$bridge ... --- (tolerate trailing spaces and EOF) + const m = text.match(/^\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m); + return m ? m[1].trimEnd() : null; } - // Command Parser Class (sturdier block extraction + simple content: | handling) - class CommandParser { - static parseYAMLCommand(text) { - try { - const commandBlock = this.extractCommandBlock(text); - if (!commandBlock) throw new Error('No valid command block found'); - const parsed = this.parseKeyValuePairs(commandBlock); + static parseKeyValuePairs(block) { + const lines = block.split('\n'); + const result = {}; + let currentKey = null; + let collecting = false; + let buf = []; + const TOP = ['action','repo','path','content','owner','url','commit_message','branch','ref']; - // Defaults - parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; - parsed.owner = parsed.owner || 'brrd'; + for (const raw of lines) { + const line = raw.replace(/\r$/, ''); - // Expand owner/repo shorthand here (not in validate) - if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) { - const [owner, repo] = parsed.repo.split('/', 2); - if (!parsed.owner) parsed.owner = owner; - parsed.repo = repo; - } - - return parsed; - } catch (error) { - throw new Error(`YAML parsing failed: ${error.message}`); - } + if (collecting) { + const looksKey = /^[A-Za-z_][\w\-]*\s*:/.test(line); + const unindented = !/^[ \t]/.test(line); + const isTopKey = looksKey && unindented && TOP.some(k => line.startsWith(k + ':')); + 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; + } } - static extractCommandBlock(text) { - // Find ^%$bridge … up to a line with --- (allow trailing spaces) then newline OR end of string - const m = text.match(/^\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m); - return m ? m[1].trimEnd() : null; - } - - static parseKeyValuePairs(block) { - const lines = block.split('\n'); - const result = {}; - let currentKey = null; - let collecting = false; - let buf = []; - const TOP_LEVEL_KEYS = ['action','repo','path','content','owner','url','commit_message','branch','ref']; - - for (const raw of lines) { - const line = raw.replace(/\r$/, ''); - - if (collecting) { - // Only stop if UNINDENTED and a KNOWN top-level key - const looksKey = /^[A-Za-z_][\w\-]*\s*:/.test(line); - const unindented = !/^[ \t]/.test(line); - const isTopKey = looksKey && unindented && - TOP_LEVEL_KEYS.some(k => line.startsWith(k + ':')); - if (isTopKey) { - result[currentKey] = buf.join('\n').trimEnd(); - collecting = false; - buf = []; - // fall through to parse this line as a new key - } else { - buf.push(line); - continue; - } - } - - const idx = line.indexOf(':'); - if (idx !== -1) { - const key = line.slice(0, idx).trim(); - let value = line.slice(idx + 1).trim(); - - if (value === '|') { - currentKey = key; - collecting = true; - buf = []; - } else if (value === '') { - currentKey = key; - result[key] = ''; - } else { - result[key] = value; - currentKey = null; - } - } else if (currentKey && result[currentKey] === '') { - result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd(); - } - } - - if (collecting && currentKey) { - result[currentKey] = buf.join('\n').trimEnd(); - } - - return result; - } - - static validateStructure(parsed) { - const errors = []; - - // Required fields per action - const action = parsed.action; - if (!action) { - errors.push('Missing required field: action'); - return { isValid: false, errors }; - } - - const requiredFields = REQUIRED_FIELDS[action]; - if (!requiredFields) { - errors.push(`Unknown action: ${action}`); - return { isValid: false, errors }; - } - - for (const field of requiredFields) { - if (!parsed[field] && parsed[field] !== '') { - errors.push(`Missing required field: ${field}`); - } - } - - // Field format validators (includes path traversal guard) - for (const [field, value] of Object.entries(parsed)) { - const validator = FIELD_VALIDATORS[field]; - if (validator && !validator(value)) { - errors.push(`Invalid format for field: ${field}`); - } - } - - return { isValid: errors.length === 0, errors }; + const idx = line.indexOf(':'); + if (idx !== -1) { + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + + if (value === '|') { + currentKey = key; collecting = true; buf = []; + } else if (value === '') { + currentKey = key; result[key] = ''; + } else { + result[key] = value; currentKey = null; + } + } else if (currentKey && result[currentKey] === '') { + result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd(); } + } + if (collecting && currentKey) result[currentKey] = buf.join('\n').trimEnd(); + return result; } - // Execution Manager (append status, clearer retry errors, ephemeral key) - class ExecutionManager { - static async executeCommand(command, sourceElement) { - try { - if (!CONFIG.ENABLE_API) { - return this.mockExecution(command, sourceElement); - } + static validateStructure(parsed) { + const errors = []; + const action = parsed.action; + if (!action) { errors.push('Missing required field: action'); return { isValid:false, errors }; } - UIFeedback.appendStatus(sourceElement, 'EXECUTING', { - action: command.action, - details: 'Making API request...' - }); + const req = REQUIRED_FIELDS[action]; + if (!req) { errors.push(`Unknown action: ${action}`); return { isValid:false, errors }; } - const response = await this.makeAPICallWithRetry(command); - return this.handleSuccess(response, command, sourceElement); + for (const f of req) if (!parsed[f] && parsed[f] !== '') errors.push(`Missing required field: ${f}`); - } catch (error) { - return this.handleError(error, command, sourceElement); - } + for (const [field, value] of Object.entries(parsed)) { + const validator = FIELD_VALIDATORS[field]; + if (validator && !validator(value)) errors.push(`Invalid format for field: ${field}`); + } + return { isValid: errors.length === 0, errors }; + } + } + + // ---------------------- Execution ---------------------- + class ExecutionManager { + static async executeCommand(command, sourceElement) { + try { + // FIX: synthesize commit_message first so mock and real behave the same + if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { + command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; } - static async makeAPICallWithRetry(command, attempt = 0) { - try { - requireBridgeKeyIfNeeded(); - return await this.makeAPICall(command); - } catch (error) { - if (attempt < CONFIG.MAX_RETRIES) { - await this.delay(1000 * (attempt + 1)); // simple backoff: 1s, 2s, ... - return this.makeAPICallWithRetry(command, attempt + 1); - } - const totalAttempts = attempt + 1; // attempts actually made - throw new Error(`${error.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`); - } - } + if (!CONFIG.ENABLE_API) return this.mockExecution(command, sourceElement); - static makeAPICall(command) { - return new Promise((resolve, reject) => { - const bridgeKey = requireBridgeKeyIfNeeded(); + UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...' }); - GM_xmlhttpRequest({ - method: 'POST', - url: command.url, - headers: { - 'X-Bridge-Key': bridgeKey, - 'Content-Type': 'application/json' - }, - data: JSON.stringify(command), - timeout: 30000, - onload: (response) => { - if (response.status >= 200 && response.status < 300) { - resolve(response); - } else { - reject(new Error(`API Error ${response.status}: ${response.statusText}`)); - } - }, - onerror: (error) => { - reject(new Error(`Network error: ${error}`)); - }, - ontimeout: () => { - reject(new Error('API request timeout')); - } - }); - }); - } + const res = await this.makeAPICallWithRetry(command); + return this.handleSuccess(res, command, sourceElement); - static async mockExecution(command, sourceElement) { - await this.delay(1000); // Simulate API delay - const mockResponse = { - status: 200, - responseText: JSON.stringify({ - success: true, - message: `Mock execution completed for ${command.action}`, - data: { command: command.action, repo: command.repo, path: command.path } - }) - }; - return this.handleSuccess(mockResponse, command, sourceElement, true); - } - - static handleSuccess(response, command, sourceElement, isMock = false) { - let responseData; - try { - responseData = JSON.parse(response.responseText || '{}'); - } catch { - responseData = { message: 'Operation completed (no JSON body)' }; - } - const templateType = isMock ? 'MOCK' : 'SUCCESS'; - - UIFeedback.appendStatus(sourceElement, templateType, { - action: command.action, - details: responseData.message || 'Operation completed successfully' - }); - - return { success: true, data: responseData, isMock }; - } - - static handleError(error, command, sourceElement) { - UIFeedback.appendStatus(sourceElement, 'ERROR', { - action: command.action || 'Command', - details: error.message - }); - - return { success: false, error: error.message }; - } - - static delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } + } catch (error) { + return this.handleError(error, command, sourceElement); + } } - // UI Feedback System (APPENDS instead of replacing) - class UIFeedback { - static appendStatus(sourceElement, templateType, data) { - const statusElement = this.createStatusElement(templateType, data); - const existingStatus = sourceElement.querySelector('.ai-repo-commander-status'); - if (existingStatus) existingStatus.remove(); - sourceElement.appendChild(statusElement); - } - - static createStatusElement(templateType, data) { - const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; - const message = template.replace('{action}', data.action).replace('{details}', data.details); - - const statusElement = document.createElement('div'); - statusElement.className = 'ai-repo-commander-status'; - statusElement.textContent = message; - statusElement.style.cssText = ` - padding: 8px 12px; - margin: 10px 0; - border-radius: 4px; - border-left: 4px solid ${this.colorCodeStatus(templateType)}; - background-color: rgba(255, 255, 255, 0.08); - font-family: monospace; - font-size: 14px; - white-space: pre-wrap; - word-wrap: break-word; - border: 1px solid rgba(255, 255, 255, 0.15); - `; - return statusElement; - } - - static colorCodeStatus(type) { - const colors = { - 'SUCCESS': '#10B981', // Green - 'ERROR': '#EF4444', // Red - 'VALIDATION_ERROR': '#F59E0B', // Yellow - 'EXECUTING': '#3B82F6', // Blue - 'MOCK': '#8B5CF6' // Purple - }; - return colors[type] || '#6B7280'; // Default gray + static async makeAPICallWithRetry(command, attempt = 0) { + try { + requireBridgeKeyIfNeeded(); + return await this.makeAPICall(command); + } catch (err) { + if (attempt < CONFIG.MAX_RETRIES) { + await this.delay(1000 * (attempt + 1)); // 1s, 2s, ... + return this.makeAPICallWithRetry(command, attempt + 1); } + const totalAttempts = attempt + 1; + throw new Error(`${err.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`); + } } - // Test command generator (unchanged) - const TEST_COMMANDS = { - validUpdate: `^%$bridge + static makeAPICall(command) { + return new Promise((resolve, reject) => { + const bridgeKey = requireBridgeKeyIfNeeded(); + GM_xmlhttpRequest({ + method: 'POST', + url: command.url, + headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, + data: JSON.stringify(command), + timeout: 30000, + onload: (response) => (response.status >= 200 && response.status < 300) + ? resolve(response) + : reject(new Error(`API Error ${response.status}: ${response.statusText}`)), + onerror: (error) => reject(new Error(`Network error: ${error}`)), + ontimeout: () => reject(new Error('API request timeout')) + }); + }); + } + + static async mockExecution(command, sourceElement) { + await this.delay(1000); // consistency + const mock = { + status: 200, + responseText: JSON.stringify({ + success: true, + message: `Mock execution completed for ${command.action}`, + data: { command: command.action, repo: command.repo, path: command.path, commit_message: command.commit_message } + }) + }; + return this.handleSuccess(mock, command, sourceElement, true); + } + + static handleSuccess(response, command, sourceElement, isMock = false) { + 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' + }); + return { success: true, data, isMock }; + } + + static handleError(error, command, sourceElement) { + UIFeedback.appendStatus(sourceElement, 'ERROR', { + action: command.action || 'Command', + details: error.message + }); + return { success: false, error: error.message }; + } + + static delay(ms) { return new Promise(r => setTimeout(r, ms)); } + } + + // ---------------------- Monitor ---------------------- + class CommandMonitor { + constructor() { + this.trackedMessages = new Map(); // id -> { element, originalText, state, lastUpdate } + this.observer = null; + this.currentPlatform = null; + this.initialize(); + } + + initialize() { + this.detectPlatform(); + this.startObservation(); + this.setupEmergencyStop(); + this.log('AI Repo Commander initialized', CONFIG); + if (CONFIG.ENABLE_API) { + console.warn('[AI Repo Commander] API is enabled — you will be prompted for your bridge key on first command.'); + } + setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS); + } + + detectPlatform() { + const host = window.location.hostname; + this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com']; + } + + startObservation() { + this.observer = new MutationObserver((mutations) => { + mutations.forEach((m) => { + m.addedNodes.forEach((node) => { + if (node.nodeType === 1) this.scanNode(node); + }); + }); + }); + this.observer.observe(document.body, { childList: true, subtree: true }); + this.scanExistingMessages(); + } + + scanNode(node) { + if (node.querySelector && node.querySelector(this.currentPlatform.messages)) this.scanMessages(); + } + + 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"]'); + } + // For unknown platforms, be permissive (treat as user) + return true; + } + + 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); + return (c ? c.textContent : element.textContent) || ''; + } + + // Always ignore commands shown inside code/pre blocks so you can discuss examples safely + hasBridgeInCodeBlock(element) { + const nodes = element.querySelectorAll('pre, code'); + for (const el of nodes) { + if ((el.textContent || '').includes('^%$bridge')) return true; + } + return false; + } + + scanMessages() { + const messages = document.querySelectorAll(this.currentPlatform.messages); + messages.forEach((el) => { + if (!this.isUserMessage(el)) return; // optional safety (disabled by default) + + const id = this.getMessageId(el); + if (this.trackedMessages.has(id)) return; + + if (this.hasBridgeInCodeBlock(el)) return; // discussing examples → ignore + + const text = this.extractText(el); + if (!text) return; + + // Require a full ^%$bridge ... --- block to avoid false positives + const m = text.match(/^\^%\$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); + }); + } + + trackMessage(element, text, messageId) { + this.log('New command detected:', { messageId, text: text.substring(0, 120) }); + this.trackedMessages.set(messageId, { + element, originalText: text, state: COMMAND_STATES.DETECTED, startTime: Date.now(), lastUpdate: Date.now() + }); + this.updateState(messageId, COMMAND_STATES.PARSING); + this.processCommand(messageId); + } + + updateState(messageId, state) { + const msg = this.trackedMessages.get(messageId); + if (!msg) return; + msg.state = state; + 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) { + try { + const message = this.trackedMessages.get(messageId); + if (!message) return; + + const parsed = CommandParser.parseYAMLCommand(message.originalText); + this.updateState(messageId, COMMAND_STATES.VALIDATING); + + const 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.'); + await this.debounce(); + } + + this.updateState(messageId, COMMAND_STATES.EXECUTING); + await ExecutionManager.executeCommand(parsed, message.element); + this.updateState(messageId, COMMAND_STATES.COMPLETE); + + } catch (error) { + this.log(`Command processing error: ${error.message}`); + this.updateState(messageId, COMMAND_STATES.ERROR); + const message = this.trackedMessages.get(messageId); + if (message) { + UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message }); + } + } + } + + debounce() { return new Promise((r) => setTimeout(r, CONFIG.DEBOUNCE_DELAY)); } + + cleanupProcessedCommands() { + const now = Date.now(); + for (const [id, msg] of this.trackedMessages.entries()) { + if ((msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR) && + now - (msg.lastUpdate || now) > CONFIG.CLEANUP_AFTER_MS) { + this.trackedMessages.delete(id); + } + } + } + + stopAllProcessing() { + this.trackedMessages.clear(); + if (this.observer) this.observer.disconnect(); + } + + setupEmergencyStop() { + window.AI_REPO_STOP = () => { + CONFIG.ENABLE_API = false; + this.stopAllProcessing(); + this.log('EMERGENCY STOP ACTIVATED'); + GM_notification({ text: 'AI Repo Commander Emergency Stop Activated', title: 'Safety System', timeout: 5000 }); + }; + } + + log(...args) { if (CONFIG.DEBUG_MODE) console.log('[AI Repo Commander]', ...args); } + } + + // ---------------------- Test commands (unchanged) ---------------------- + const TEST_COMMANDS = { + validUpdate: `^%$bridge action: update_file repo: test-repo path: TEST.md @@ -555,41 +512,34 @@ content: | Multiple lines --- `, - invalidCommand: `^%$bridge + invalidCommand: `^%$bridge action: update_file repo: test-repo --- `, - getFile: `^%$bridge + getFile: `^%$bridge action: get_file repo: test-repo path: README.md --- ` - }; + }; - // Initialize the system - let commandMonitor; - - function initializeRepoCommander() { - if (!commandMonitor) { - commandMonitor = new CommandMonitor(); - // Expose for debugging - window.AI_REPO_COMMANDER = { - monitor: commandMonitor, - config: CONFIG, - test: TEST_COMMANDS, - version: CONFIG.VERSION - }; - 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'); - } + // ---------------------- Init ---------------------- + let commandMonitor; + function initializeRepoCommander() { + if (!commandMonitor) { + commandMonitor = new CommandMonitor(); + window.AI_REPO_COMMANDER = { monitor: commandMonitor, config: CONFIG, test: TEST_COMMANDS, version: CONFIG.VERSION }; + 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'); } + } - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeRepoCommander); - } else { - initializeRepoCommander(); - } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeRepoCommander); + } else { + initializeRepoCommander(); + } })();