// ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ // @version 1.3.2 // @description Execute ^%$bridge YAML commands from AI assistants in code blocks with persistent dedupe, robust paste, optional auto-submit, and a built-in debug console // @author Your Name // @match https://chat.openai.com/* // @match https://chatgpt.com/* // @match https://claude.ai/* // @match https://gemini.google.com/* // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_setClipboard // @connect n8n.brrd.tech // ==/UserScript== (function () { 'use strict'; // ---------------------- Config ---------------------- const CONFIG = { ENABLE_API: true, // Master kill switch (STOP API flips this to false) DEBUG_MODE: true, // Global on/off for debug logging DEBUG_LEVEL: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace DEBUG_WATCH_MS: 120000, // Only log tight loop spam for the first 2 minutes DEBUG_MAX_LINES: 400, // In-memory + panel lines DEBUG_SHOW_PANEL: true, // Show floating debug console UI DEBOUNCE_DELAY: 5000, // Bot typing protection MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries) VERSION: '1.3.2', PROCESS_EXISTING: false, // If false, only process *new* messages (no initial rescan) ASSISTANT_ONLY: true, // Process assistant messages by default (core use case) // 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 // Paste + submit behavior APPEND_TRAILING_NEWLINE: true, // Add '\n' after pasted text AUTO_SUBMIT: true, // Try to submit after pasting content POST_PASTE_DELAY_MS: 250, // Delay before submit to let editors settle SUBMIT_MODE: 'button_first', // 'button_first' | 'enter_only' | 'smart' // Runtime toggles (live-updated by the debug panel) RUNTIME: { PAUSED: false, // Pause scanning + execution via panel } }; // ---------------------- Debug Console ---------------------- let RC_DEBUG = null; class DebugConsole { constructor(cfg) { this.cfg = cfg; this.buf = []; this.loopCounts = new Map(); this.startedAt = Date.now(); this.panel = null; if (cfg.DEBUG_SHOW_PANEL) this.mount(); this.info(`Debug console ready (level=${cfg.DEBUG_LEVEL})`); } // Levels: 1=ERROR, 2=WARN, 3=INFO, 4=VERB, 5=TRACE error(msg, data) { this._log(1, 'ERROR', msg, data); } warn(msg, data) { this._log(2, 'WARN', msg, data); } info(msg, data) { this._log(3, 'INFO', msg, data); } verbose(msg, data){ this._log(4, 'VERB', msg, data); } trace(msg, data) { this._log(5, 'TRACE', msg, data); } command(action, status, extra={}) { const icon = { detected:'👁️', parsing:'📝', validating:'✓', debouncing:'⏳', executing:'⚙️', complete:'✅', error:'❌' }[status] || '•'; this.info(`${icon} ${action} [${status}]`, extra); } nowIso() { return new Date().toISOString(); } withinWatch() { return Date.now() - this.startedAt <= this.cfg.DEBUG_WATCH_MS; } // Loop/ticker messages → suppress after 10 repeats or after WATCH window logLoop(kind, msg) { const k = `${kind}:${msg}`; const cur = this.loopCounts.get(k) || 0; if (!this.withinWatch() && kind !== 'WARN') return; if (cur >= 10) return; this.loopCounts.set(k, cur + 1); const suffix = (cur + 1) > 1 ? ` (${cur + 1}x)` : ''; // default to INFO (visible at level 2+) if (kind === 'ERROR') this.error(`${msg}${suffix}`); else if (kind === 'WARN') this.warn(`${msg}${suffix}`); else this.info(`${msg}${suffix}`); } copyLast(n=50) { const lines = this.buf.slice(-n).map(e => `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`).join('\n'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(lines).then(() => { this.info(`Copied last ${Math.min(n, this.buf.length)} lines to clipboard`); }).catch(e => this._fallbackCopy(lines, e)); } else { this._fallbackCopy(lines); } } _fallbackCopy(text, originalError = null) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); if (ok) this.info(`Copied last ${text.split('\n').length} lines to clipboard (fallback)`); else this.warn('Clipboard copy failed (fallback)'); } catch (e) { this.warn('Clipboard copy failed', { error: originalError?.message || e.message }); } } setLevel(n) { const lv = Math.max(0, Math.min(4, n)); // clamp 0..4 this.cfg.DEBUG_LEVEL = lv; this.info(`Log level => ${lv}`); } _sanitize(data) { if (!data) return null; try { if (data instanceof HTMLElement) return '[HTMLElement]'; if (typeof data === 'string' && data.length > 400) return data.slice(0,400)+'…'; if (typeof data === 'object') { const clone = { ...data }; if (clone.element instanceof HTMLElement) clone.element = '[HTMLElement]'; return clone; } } catch {} return data; } _log(numericLevel, levelName, msg, data) { if (!this.cfg.DEBUG_MODE) return; // Threshold map: 0=off, 1=ERROR, 2=+WARN+INFO, 3=+VERB, 4=+TRACE const thresholdMap = { 0: 0, 1: 1, 2: 3, 3: 4, 4: 5 }; const threshold = thresholdMap[this.cfg.DEBUG_LEVEL] ?? 0; if (numericLevel > threshold) return; const entry = { ts: this.nowIso(), level: levelName, msg: String(msg), data: this._sanitize(data) }; this.buf.push(entry); if (this.buf.length > this.cfg.DEBUG_MAX_LINES) this.buf.splice(0, this.buf.length - this.cfg.DEBUG_MAX_LINES); // Keep console quiet unless verbose+ is enabled if (this.cfg.DEBUG_LEVEL >= 3) { const prefix = `[AI RC]`; if (entry.data != null) console.log(prefix, entry.level, entry.msg, entry.data); else console.log(prefix, entry.level, entry.msg); } if (this.panel) this._renderRow(entry); } mount() { if (!document.body) { setTimeout(() => this.mount(), 100); return; } const root = document.createElement('div'); root.style.cssText = ` position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; width: 420px; max-height: 45vh; display: flex; flex-direction: column; background: rgba(20,20,24,0.92); border:1px solid #3b3b46; border-radius: 8px; color:#e5e7eb; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; box-shadow: 0 16px 40px rgba(0,0,0,0.55); backdrop-filter: blur(4px); `; root.innerHTML = `
AI Repo Commander — Debug
`; document.body.appendChild(root); this.panel = root; const sel = root.querySelector('.rc-level'); sel.value = String(this.cfg.DEBUG_LEVEL); sel.addEventListener('change', () => this.setLevel(parseInt(sel.value,10))); root.querySelector('.rc-copy').addEventListener('click', () => this.copyLast(50)); const pauseBtn = root.querySelector('.rc-pause'); pauseBtn.addEventListener('click', () => { this.cfg.RUNTIME.PAUSED = !this.cfg.RUNTIME.PAUSED; pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause'; pauseBtn.style.background = this.cfg.RUNTIME.PAUSED ? '#f59e0b' : ''; pauseBtn.style.color = this.cfg.RUNTIME.PAUSED ? '#111827' : ''; this.info(`Runtime ${this.cfg.RUNTIME.PAUSED ? 'paused' : 'resumed'}`); }); root.querySelector('.rc-stop').addEventListener('click', () => { window.AI_REPO_STOP?.(); this.warn('Emergency STOP activated'); }); } _renderRow(e) { const body = this.panel.querySelector('.rc-body'); const row = document.createElement('div'); row.style.cssText = 'padding:4px 0;border-bottom:1px dashed #2a2a34;white-space:pre-wrap;word-break:break-word;'; row.textContent = `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`; body.appendChild(row); while (body.children.length > this.cfg.DEBUG_MAX_LINES) body.firstChild.remove(); body.scrollTop = body.scrollHeight; } } // ---------------------- 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' } }; // ---------------------- 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'] }; const FIELD_VALIDATORS = { repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.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}', 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' }; // ---------------------- Persistent Command History ---------------------- class CommandHistory { constructor() { this.key = 'ai_repo_commander_executed'; this.ttl = CONFIG.DEDUPE_TTL_MS; this.cleanup(); } _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 { 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'; } } // ---------------------- Paste + Submit helpers ---------------------- 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 findSendButton() { const selectors = [ 'button[data-testid="send-button"]', 'button[aria-label*="Send"]', 'button[aria-label*="send"]', 'button[aria-label*="Submit"]', 'button[aria-label*="submit"]', 'form button[type="submit"]' ]; for (const s of selectors) { const btn = document.querySelector(s); if (!btn) continue; const style = window.getComputedStyle(btn); const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; if (style.display === 'none' || style.visibility === 'hidden') continue; if (btn.offsetParent === null && style.position !== 'fixed') continue; if (!disabled) return btn; } return null; } function pressEnterOn(el) { const events = ['keydown','keypress','keyup']; for (const type of events) { const ok = el.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true })); if (!ok) return false; } return true; } async function submitComposer() { try { const btn = findSendButton(); if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { btn.click(); return true; } const el = getVisibleInputCandidate(); if (!el) return false; return pressEnterOn(el); } catch { return false; } } 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; } const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text; el.focus(); // 1) ClipboardEvent paste try { const dt = new DataTransfer(); dt.setData('text/plain', payload); const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); if (el.dispatchEvent(pasteEvt) && !pasteEvt.defaultPrevented) return true; } catch (_) { /* continue */ } // 2) execCommand insertText 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, payload)) return true; } catch (_) { /* continue */ } // 3) ProseMirror innerHTML const isPM = el.classList && el.classList.contains('ProseMirror'); if (isPM) { const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); const html = String(payload).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 = payload; el.dispatchEvent(new Event('input', { bubbles: true })); return true; } if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { el.textContent = payload; el.dispatchEvent(new Event('input', { bubbles: true })); return true; } // 5) Clipboard fallback try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(payload, { 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) { RC_DEBUG?.warn('pasteToComposer failed', { error: String(e) }); return false; } } async function pasteAndMaybeSubmit(text) { const pasted = pasteToComposer(text); if (!pasted) return false; if (!CONFIG.AUTO_SUBMIT) return true; await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS); const ok = await submitComposer(); if (!ok) { GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 }); } return true; } // ---------------------- Parser ---------------------- class CommandParser { 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.owner = parsed.owner || 'rob'; // 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; } static extractCommandBlock(text) { const patterns = [ /^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m, /^\s*\^%\$bridge[ \t]*\n([\s\S]*?)(?=\n\s*$|\n---|\n```|$)/m, /^\s*\^%\$bridge[ \t]*\n([\s\S]*)/m ]; for (const pattern of patterns) { const match = text.match(pattern); if (match && match[1]?.trim()) return match[1].trimEnd(); } return null; } 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']; for (const raw of lines) { const line = raw.replace(/\r$/, ''); 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 = []; } 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 = []; const action = parsed.action; if (!action) { errors.push('Missing required field: action'); return { isValid:false, errors }; } const req = REQUIRED_FIELDS[action]; if (!req) { errors.push(`Unknown action: ${action}`); return { isValid:false, errors }; } for (const f of req) if (!parsed[f] && parsed[f] !== '') errors.push(`Missing required field: ${f}`); 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 { if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; } if (!CONFIG.ENABLE_API) return this.mockExecution(command, sourceElement); UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...' }); const res = await this.makeAPICallWithRetry(command); return this.handleSuccess(res, command, sourceElement); } catch (error) { return this.handleError(error, command, sourceElement); } } 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})`); } } 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); 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 _extractGetFileBody(payload) { const item = Array.isArray(payload) ? payload[0] : payload; return ( item?.result?.content?.data ?? item?.content?.data ?? payload?.result?.content?.data ?? null ); } static _extractFilesArray(payload) { const obj = Array.isArray(payload) ? payload[0] : payload; let files = obj?.result?.files ?? obj?.files ?? null; if (!files) { const res = obj?.result; if (res) { for (const [k, v] of Object.entries(res)) { if (Array.isArray(v) && v.length && (k.toLowerCase().includes('file') || typeof v[0] === 'string' || v[0]?.path || v[0]?.name)) { files = v; break; } } } } return Array.isArray(files) ? files : null; } static _formatFilesListing(files) { const pickPath = (f) => { if (typeof f === 'string') return f; if (typeof f?.path === 'string') return f.path; if (f?.dir && f?.name) return `${f.dir.replace(/\/+$/,'')}/${f.name}`; if (f?.name) return f.name; try { return JSON.stringify(f); } catch { return String(f); } }; const lines = files.map(pickPath).filter(Boolean).sort(); return '```text\n' + lines.join('\n') + '\n```'; } static async 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' }); if (command.action === 'get_file') { const body = this._extractGetFileBody(data); if (typeof body === 'string' && body.length) { await pasteAndMaybeSubmit(body); } else { GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); } } if (command.action === 'list_files') { const files = this._extractFilesArray(data); if (files && files.length) { const listing = this._formatFilesListing(files); await pasteAndMaybeSubmit(listing); } else { const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; await pasteAndMaybeSubmit(fallback); GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', timeout: 5000 }); } } 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)); } } // ---------------------- Bridge 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; } // ---------------------- Monitor ---------------------- class CommandMonitor { constructor() { this.trackedMessages = new Map(); // id -> { element, originalText, state, lastUpdate, startTime } this.history = new CommandHistory(); this.observer = null; this.currentPlatform = null; this._idCounter = 0; this.cleanupIntervalId = null; this.initialize(); } getReadableMessageId(element) { this._idCounter += 1; const id = `cmd-${this._idCounter}-${Math.random().toString(36).slice(2,6)}`; if (element?.dataset) element.dataset.aiRcId = id; return id; } extractAction(text) { const m = /(^|\n)\s*action\s*:\s*([A-Za-z_][\w\-]*)/m.exec(text || ''); return m ? m[2] : 'unknown'; } initialize() { this.detectPlatform(); this.startObservation(); this.setupEmergencyStop(); RC_DEBUG?.info('AI Repo Commander initialized', { ENABLE_API: CONFIG.ENABLE_API, DEBUG_MODE: CONFIG.DEBUG_MODE, DEBOUNCE_DELAY: CONFIG.DEBOUNCE_DELAY, MAX_RETRIES: CONFIG.MAX_RETRIES, VERSION: CONFIG.VERSION }); if (CONFIG.ENABLE_API) { RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.'); } // store interval id so STOP can clear it this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS); } detectPlatform() { const host = window.location.hostname; this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com']; } startObservation() { // Throttled observer; only rescan if code blocks likely appeared let scanPending = false; const scheduleScan = () => { if (scanPending) return; scanPending = true; setTimeout(() => { scanPending = false; this.scanMessages(); }, 100); }; this.observer = new MutationObserver((mutations) => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) { scheduleScan(); break; } } } }); this.observer.observe(document.body, { childList: true, subtree: true }); // Respect PROCESS_EXISTING on initial scan if (CONFIG.PROCESS_EXISTING) { setTimeout(() => { RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)'); this.scanMessages(); }, 1000); } else { RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)'); } } scanMessages() { if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.logLoop('loop', 'scan paused'); return; } const messages = document.querySelectorAll(this.currentPlatform.messages); let skipped = 0, found = 0; messages.forEach((el) => { if (!this.isAssistantMessage(el)) return; if (el.dataset.aiRcProcessed) return; const hit = this.findCommandInCodeBlock(el); if (!hit) return; const cmdText = hit.text; if (this.history.has(cmdText)) { el.dataset.aiRcProcessed = '1'; skipped++; return; } el.dataset.aiRcProcessed = '1'; const id = this.getReadableMessageId(el); this.trackMessage(el, cmdText, id); found++; }); if (skipped) RC_DEBUG?.logLoop('loop', `skipped already-executed (${skipped})`); if (found) RC_DEBUG?.info(`Found ${found} new command(s)`); } isAssistantMessage(el) { if (!CONFIG.ASSISTANT_ONLY) return true; const host = location.hostname; if (/chat\.openai\.com|chatgpt\.com/.test(host)) { return !!el.closest?.('[data-message-author-role="assistant"]'); } if (/claude\.ai/.test(host)) { const isUser = !!el.closest?.('[data-message-author-role="user"]'); return !isUser; } if (/gemini\.google\.com/.test(host)) return true; return true; } // **HARDENED**: require header + action: to avoid partials findCommandInCodeBlock(el) { const blocks = el.querySelectorAll('pre code, pre, code'); for (const b of blocks) { const txt = (b.textContent || '').trim(); if (/(^|\n)\s*\^%\$bridge\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt)) { return { blockElement: b, text: txt }; } } return null; } getMessageId(element) { // kept for compatibility return this.getReadableMessageId(element); } reextractCommandText(element) { const hit = this.findCommandInCodeBlock(element); return hit ? hit.text : ''; } trackMessage(element, text, messageId) { RC_DEBUG?.info('New command detected', { messageId, preview: 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; const old = msg.state; msg.state = state; msg.lastUpdate = Date.now(); this.trackedMessages.set(messageId, msg); RC_DEBUG?.command(this.extractAction(msg.originalText), state, { messageId, transition: `${old} -> ${state}` }); } async processCommand(messageId) { if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.info('process paused, skipping', { messageId }); return; } const started = Date.now(); try { const message = this.trackedMessages.get(messageId); if (!message) { RC_DEBUG?.error('Message not found', { messageId }); return; } let parsed = CommandParser.parseYAMLCommand(message.originalText); this.updateState(messageId, COMMAND_STATES.VALIDATING); 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.reextractCommandText(message.element); if (!after) { this.updateState(messageId, COMMAND_STATES.ERROR); return; } if (after !== before) { RC_DEBUG?.info('Command changed during debounce (re-validate)', { messageId }); message.originalText = after; await this.debounce(); const finalTxt = this.reextractCommandText(message.element); if (!finalTxt) { 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(', ')}`); } // **HARDENED**: pre-mark to avoid duplicate runs if DOM churns this.history.mark(message.originalText); this.updateState(messageId, COMMAND_STATES.EXECUTING); await ExecutionManager.executeCommand(parsed, message.element); const duration = Date.now() - started; if (duration < 100) RC_DEBUG?.warn('Command completed suspiciously fast', { messageId, duration }); if (duration > 30000) RC_DEBUG?.warn('Command took unusually long', { messageId, duration }); this.updateState(messageId, COMMAND_STATES.COMPLETE); } catch (error) { const duration = Date.now() - started; RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration }); this.updateState(messageId, COMMAND_STATES.ERROR); const message = this.trackedMessages.get(messageId); // Silent ignore for non-commands/partials to avoid noisy inline errors if (/No valid command block|Missing required field:\s*action/i.test(error.message)) return; 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(); let count = 0; 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); count++; } } if (count) RC_DEBUG?.info(`Cleaned ${count} processed entries`); } stopAllProcessing() { this.trackedMessages.clear(); if (this.observer) this.observer.disconnect(); if (this.cleanupIntervalId) { clearInterval(this.cleanupIntervalId); this.cleanupIntervalId = null; } } setupEmergencyStop() { window.AI_REPO_STOP = () => { // Critical: stop API + pause runtime + cancel inflight + clear interval CONFIG.ENABLE_API = false; CONFIG.RUNTIME.PAUSED = true; for (const [id, msg] of this.trackedMessages.entries()) { if (msg.state === COMMAND_STATES.EXECUTING || msg.state === COMMAND_STATES.DEBOUNCING) { RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id }); this.updateState(id, COMMAND_STATES.ERROR); } } this.stopAllProcessing(); RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨'); GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 }); }; } log(...args) { const [msg, data] = (typeof args[0] === 'string') ? [args[0], args[1]] : ['(log)', args]; RC_DEBUG?.verbose(msg, data); } } // ---------------------- Test commands (unchanged) ---------------------- const TEST_COMMANDS = { validUpdate: `\ \`\`\`yaml ^%$bridge action: update_file repo: test-repo path: TEST.md content: | Test content Multiple lines --- \`\`\` `, getFile: `\ \`\`\`yaml ^%$bridge action: get_file repo: test-repo path: README.md --- \`\`\` `, listFiles: `\ \`\`\`yaml ^%$bridge action: list_files repo: test-repo path: . --- \`\`\` ` }; // ---------------------- Init ---------------------- let commandMonitor; function initializeRepoCommander() { if (!RC_DEBUG) RC_DEBUG = new DebugConsole(CONFIG); if (!commandMonitor) { commandMonitor = new CommandMonitor(); window.AI_REPO_COMMANDER = { monitor: commandMonitor, config: CONFIG, test: TEST_COMMANDS, version: CONFIG.VERSION, history: commandMonitor.history, submitComposer // expose for quick testing }; RC_DEBUG?.info('AI Repo Commander fully initialized'); RC_DEBUG?.info('API Enabled:', { value: CONFIG.ENABLE_API }); RC_DEBUG?.info('Test commands available in window.AI_REPO_COMMANDER.test'); RC_DEBUG?.info('Reset history with window.AI_REPO_COMMANDER.history.reset() or AI_REPO_CLEAR_HISTORY()'); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeRepoCommander); } else { initializeRepoCommander(); } })();