diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 64f284c..89bd6aa 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ -// @version 1.4.0 +// @version 1.4.1 // @description Execute ^%$bridge YAML commands from AI assistants (safe & robust): complete-block detection, streaming-settle, persistent dedupe, paste+autosubmit, debug console with Tools/Settings, draggable/collapsible panel // @author Your Name // @match https://chat.openai.com/* @@ -34,20 +34,20 @@ DEBUG_SHOW_PANEL: true, // Timing & API - DEBOUNCE_DELAY: 3000, // was 5000 + DEBOUNCE_DELAY: 3000, MAX_RETRIES: 2, - VERSION: '1.4.0', - API_TIMEOUT_MS: 60000, // NEW: configurable API timeout + VERSION: '1.4.1', + API_TIMEOUT_MS: 60000, PROCESS_EXISTING: false, ASSISTANT_ONLY: true, - BRIDGE_KEY: '', // Var to store the bridge key + BRIDGE_KEY: '', // Persistent dedupe window DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days - COLD_START_MS: 2000, // Optional: during first 2s after load, don't auto-run pre-existing messages - SHOW_EXECUTED_MARKER: true, // Add a green border on messages that executed + COLD_START_MS: 2000, + SHOW_EXECUTED_MARKER: true, // Housekeeping CLEANUP_AFTER_MS: 30000, @@ -57,22 +57,21 @@ APPEND_TRAILING_NEWLINE: true, AUTO_SUBMIT: true, POST_PASTE_DELAY_MS: 250, - SUBMIT_MODE: 'button_first', // 'button_first' | 'enter_only' | 'smart' + SUBMIT_MODE: 'button_first', // Streaming-complete hardening - REQUIRE_TERMINATOR: true, // require trailing '---' line - SETTLE_CHECK_MS: 800, // was 1500 - SETTLE_POLL_MS: 200, // was 300 + REQUIRE_TERMINATOR: true, + SETTLE_CHECK_MS: 800, + SETTLE_POLL_MS: 200, // Runtime toggles RUNTIME: { PAUSED: false }, // New additions for hardening - STUCK_AFTER_MS: 10 * 60 * 1000, // 10min: force cleanup stuck entries - SCAN_DEBOUNCE_MS: 250, // throttle MutationObserver scans - FAST_WARN_MS: 50, // warn if command completes suspiciously fast - SLOW_WARN_MS: 60_000, // warn if command takes >1min - + STUCK_AFTER_MS: 10 * 60 * 1000, + SCAN_DEBOUNCE_MS: 250, + FAST_WARN_MS: 50, + SLOW_WARN_MS: 60_000, }; function loadSavedConfig() { @@ -80,7 +79,6 @@ const raw = localStorage.getItem(STORAGE_KEYS.cfg); if (!raw) return structuredClone(DEFAULT_CONFIG); const saved = JSON.parse(raw); - // shallow merge; keep nested RUNTIME const merged = { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; return merged; } catch { @@ -374,7 +372,6 @@ tabTools.addEventListener('click', () => { selectTab(true); - // refresh toggles/nums root.querySelectorAll('.rc-toggle').forEach(inp => { const key = inp.dataset.key; inp.checked = !!this.cfg[key]; @@ -383,12 +380,10 @@ inp.value = String(this.cfg[inp.dataset.key] ?? ''); }); - // Mask BRIDGE_KEY in JSON dump const dump = JSON.parse(JSON.stringify(this.cfg)); if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8); root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2); - // Mask the bridge key input (never show the real key) const bridgeKeyInput = root.querySelector('.rc-bridge-key'); if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; }); @@ -408,7 +403,7 @@ // Dragging const header = root.querySelector('.rc-header'); header.addEventListener('mousedown', (e) => { - if ((e.target).closest('button,select,input,textarea,label')) return; // let controls work + if ((e.target).closest('button,select,input,textarea,label')) return; this.drag.active = true; const rect = root.getBoundingClientRect(); this.drag.dx = e.clientX - rect.left; @@ -437,15 +432,13 @@ try { commandMonitor?.history?.resetAll?.(); RC_DEBUG?.info('Conversation history cleared'); - GM_notification({ title: 'AI Repo Commander', text: 'This conversation’s execution marks cleared', timeout: 2500 }); + GM_notification({ title: 'AI Repo Commander', text: 'This conversation\'s execution marks cleared', timeout: 2500 }); } catch { - // fallback: remove legacy localStorage.removeItem(STORAGE_KEYS.history); RC_DEBUG?.info('Legacy history key cleared'); } }); - // Tools: toggles & numbers root.querySelectorAll('.rc-toggle').forEach(inp => { const key = inp.dataset.key; @@ -474,9 +467,6 @@ const raw = root.querySelector('.rc-json').value; const parsed = JSON.parse(raw); - // Handle BRIDGE_KEY specially: ignore masked or empty strings, - // accept a real value, then remove it from parsed so Object.assign - // doesn’t stomp it later. if (Object.prototype.hasOwnProperty.call(parsed, 'BRIDGE_KEY')) { const v = (parsed.BRIDGE_KEY ?? '').toString().trim(); if (v && !/^•+$/.test(v)) { @@ -489,7 +479,6 @@ Object.assign(this.cfg, parsed); saveConfig(this.cfg); - // Re-mask JSON view after save const dump = JSON.parse(JSON.stringify(this.cfg)); if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8); root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2); @@ -503,9 +492,9 @@ root.querySelector('.rc-reset-defaults').addEventListener('click', () => { Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG)); saveConfig(this.cfg); - BRIDGE_KEY = null; // <— add - const bridgeKeyInput = root.querySelector('.rc-bridge-key'); // <— add - if (bridgeKeyInput) bridgeKeyInput.value = ''; // <— add + BRIDGE_KEY = null; + const bridgeKeyInput = root.querySelector('.rc-bridge-key'); + if (bridgeKeyInput) bridgeKeyInput.value = ''; this.info('Config reset to defaults'); }); @@ -522,7 +511,6 @@ root.querySelector('.rc-save-bridge-key').addEventListener('click', () => { const raw = (bridgeKeyInput.value || '').trim(); - // If user clicked into a masked field and didn't change it, do nothing if (/^•+$/.test(raw)) { this.info('Bridge key unchanged'); GM_notification({ title: 'AI Repo Commander', text: 'Bridge key unchanged', timeout: 2000 }); @@ -530,8 +518,7 @@ } this.cfg.BRIDGE_KEY = raw; saveConfig(this.cfg); - BRIDGE_KEY = raw || null; // set runtime immediately - // re-mask UI + BRIDGE_KEY = raw || null; bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : ''; this.info('Bridge key saved (masked)'); GM_notification({ title: 'AI Repo Commander', text: 'Bridge key saved', timeout: 2500 }); @@ -541,11 +528,10 @@ this.cfg.BRIDGE_KEY = ''; bridgeKeyInput.value = ''; saveConfig(this.cfg); - BRIDGE_KEY = null; // Clear the runtime key too + BRIDGE_KEY = null; this.info('Bridge key cleared'); GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 }); }); - } _renderRow(e) { @@ -571,55 +557,216 @@ 'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, 'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } }; - // ---------------------- Conversation-Aware Element History ---------------------- - function getConversationId() { - const host = location.hostname; - // ChatGPT / OpenAI - if (/chatgpt\.com|chat\.openai\.com/.test(host)) { - const m = location.pathname.match(/\/c\/([^/]+)/); - return `chatgpt:${m ? m[1] : location.pathname}`; - } - // Claude - if (/claude\.ai/.test(host)) { - const m = location.pathname.match(/\/thread\/([^/]+)/); - return `claude:${m ? m[1] : location.pathname}`; - } - // Gemini / others - return `${host}:${location.pathname || '/'}`; + // ---------------------- Fingerprinting helpers (portable) ---------------------- + const MSG_SELECTORS = [ + '[data-message-author-role]', // ChatGPT/OpenAI + '.chat-message', // Claude + '.message-content' // Gemini + ]; + + // Consistent DJB2 variant + function _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); } - function fingerprintElement(el) { - // Prefer platform IDs if available - const attrs = ['data-message-id','data-id','data-testid','id']; - for (const a of attrs) { - const v = el.getAttribute?.(a) || el.closest?.(`[${a}]`)?.getAttribute?.(a); - if (v) return `id:${a}:${v}`; + // Normalize text a bit to reduce spurious diffs + function _norm(s) { + return (s || '') + .replace(/\r/g, '') + .replace(/\u200b/g, '') // zero-width + .replace(/[ \t]+\n/g, '\n') // trailing ws + .trim(); + } + + // Extract the *command block* if present; else fall back to element text + function _commandishText(el) { + // Mirror parser's detector: require header, action, and '---' + const blocks = el.querySelectorAll('pre code, pre, code'); + for (const b of blocks) { + const t = _norm(b.textContent || ''); + if (/\n---\s*$/.test(t) && /(^|\n)\s*\^%\$bridge\b/m.test(t) && /(^|\n)\s*action\s*:/m.test(t)) { + return t; + } } - // Fallback: DOM position + short content hash - const nodes = Array.from(document.querySelectorAll('[data-message-author-role], .chat-message, .message-content')); - const idx = Math.max(0, nodes.indexOf(el)); - const s = (el.textContent || '').slice(0, 256); - let h = 5381; for (let i=0;i>>0).toString(36)}`; + // No command block found; use element text (capped) + return _norm((el.textContent || '').slice(0, 2000)); + } + + // Hash of the command (or element text) capped to 2000 chars + function _hashCommand(el) { + const t = _commandishText(el); + return _hash(t.slice(0, 2000)); + } + + // Hash of the *preceding context*: concatenate previous messages' text until ~2000 chars + function _hashPrevContext(el) { + const all = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); + const idx = all.indexOf(el); + if (idx <= 0) return '0'; + + let remaining = 2000; + let buf = ''; + for (let i = idx - 1; i >= 0 && remaining > 0; i--) { + const t = _norm(all[i].textContent || ''); + if (!t) continue; + // Prepend last slice so the nearest history weighs most + const take = t.slice(-remaining); + buf = take + buf; + remaining -= take.length; + } + // Hash only the *last* 2000 chars of the collected buffer (stable, compact) + return _hash(buf.slice(-2000)); + } + + // Ordinal among messages that share the same (commandHash, prevCtxHash) + function _ordinalForKey(el, key) { + const list = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))); + let n = 0; + for (const node of list) { + const nodeKey = node === el + ? key + : (() => { + // Compute on the fly only if needed + const ch = _hashCommand(node); + const ph = _hashPrevContext(node); + return `ch:${ch}|ph:${ph}`; + })(); + if (nodeKey === key) n++; + if (node === el) return n; // 1-based ordinal + } + return 1; + } + + // DOM path hint for extra fingerprint stability + function _domHint(node) { + const p = []; + let n = node; + for (let i = 0; n && i < 4; i++) { // last 4 ancestors + const tag = (n.tagName || '').toLowerCase(); + const cls = (n.className || '').toString().split(/\s+/).slice(0, 2).join('.'); + p.push(tag + (cls ? '.' + cls : '')); + n = n.parentElement; + } + return p.join('>'); + } + + // Main fingerprint function + function fingerprintElement(el) { + // Always use content-based fingerprinting for reliability across reloads + const ch = _hashCommand(el); + const ph = _hashPrevContext(el); + const dh = _hash(_domHint(el)); + const key = `ch:${ch}|ph:${ph}`; + const n = _ordinalForKey(el, key); + const fingerprint = `${key}|hint:${dh}|n:${n}`; + + RC_DEBUG?.trace('Generated fingerprint', { + fingerprint: fingerprint.slice(0, 60) + '...', + commandHash: ch, + prevContextHash: ph, + domHint: dh, + ordinal: n + }); + + return fingerprint; + } + + // ---------------------- Conversation-Aware Element History ---------------------- + function getConversationId() { + const host = location.hostname.replace('chat.openai.com', 'chatgpt.com'); // normalize + + // ChatGPT + if (/chatgpt\.com/.test(host)) { + // Try GPT-specific conversation path first: /g/[gpt-id]/c/[conv-id] + const gptMatch = location.pathname.match(/\/g\/[^/]+\/c\/([a-f0-9-]+)/i); + if (gptMatch?.[1]) { + RC_DEBUG?.verbose('Conversation ID from GPT URL', { id: gptMatch[1].slice(0, 12) + '...' }); + return `chatgpt:${gptMatch[1]}`; + } + + // Regular conversation path: /c/[conv-id] + const m = location.pathname.match(/\/c\/([a-f0-9-]+)/i); + if (m?.[1]) { + RC_DEBUG?.verbose('Conversation ID from URL path', { id: m[1].slice(0, 12) + '...' }); + return `chatgpt:${m[1]}`; + } + + // Try embedded page state (best-effort) + try { + const scripts = document.querySelectorAll('script[type="application/json"]'); + for (const s of scripts) { + const t = s.textContent || ''; + const j = /"conversationId":"([a-f0-9-]+)"/i.exec(t); + if (j?.[1]) { + RC_DEBUG?.verbose('Conversation ID from page state', { id: j[1].slice(0, 12) + '...' }); + return `chatgpt:${j[1]}`; + } + } + } catch {} + + return `chatgpt:${location.pathname || '/'}`; + } + + // Claude + if (/claude\.ai/.test(host)) { + const m1 = location.pathname.match(/\/chat\/([^/]+)/i); + const m2 = location.pathname.match(/\/thread\/([^/]+)/i); + const id = (m1?.[1] || m2?.[1]); + if (id) { + RC_DEBUG?.verbose('Conversation ID (Claude)', { id: id.slice(0, 12) + '...' }); + } + return `claude:${id || (location.pathname || '/')}`; + } + + // Gemini / others + const generic = `${host}:${location.pathname || '/'}`; + RC_DEBUG?.verbose('Conversation ID (generic)', { id: generic }); + return generic; } // ---------------------- 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'] + 'get_file': ['action', 'repo', 'path'], + 'update_file': ['action', 'repo', 'path', 'content'], + 'create_file': ['action', 'repo', 'path', 'content'], + 'delete_file': ['action', 'repo', 'path'], + 'list_files': ['action', 'repo', 'path'], + 'create_repo': ['action', 'repo'], + 'create_branch': ['action', 'repo', 'branch'], + 'create_pr': ['action', 'repo', 'title', 'head', 'base'], + 'merge_pr': ['action', 'repo', 'pr_number'], + 'close_pr': ['action', 'repo', 'pr_number'], + 'create_issue': ['action', 'repo', 'title'], + 'comment_issue': ['action', 'repo', 'issue_number', 'body'], + 'close_issue': ['action', 'repo', 'issue_number'], + 'rollback': ['action', 'repo', 'commit_sha'], + 'create_tag': ['action', 'repo', 'tag', 'target'], + 'create_release': ['action', 'repo', 'tag_name', 'name'] }; 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) + 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), + branch: (v) => v && v.length > 0 && !v.includes('..'), + source_branch:(v) => !v || (v.length > 0 && !v.includes('..')), + head: (v) => v && v.length > 0, + base: (v) => v && v.length > 0, + pr_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0, + issue_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0, + commit_sha: (v) => /^[a-f0-9]{7,40}$/i.test(v), + tag: (v) => v && v.length > 0 && !v.includes(' '), + tag_name: (v) => v && v.length > 0 && !v.includes(' '), + target: (v) => v && v.length > 0, + title: (v) => v && v.length > 0, + name: (v) => v && v.length > 0, + body: (v) => typeof v === 'string', + message: (v) => typeof v === 'string' }; const STATUS_TEMPLATES = { @@ -647,7 +794,12 @@ this.key = `ai_rc:conv:${this.convId}:processed`; this.session = new Set(); this.cache = this._load(); - this._cleanupTTL(); // ← Add cleanup on init + this._cleanupTTL(); + + RC_DEBUG?.info('ConvHistory initialized', { + convId: this.convId.slice(0, 50) + (this.convId.length > 50 ? '...' : ''), + cachedCount: Object.keys(this.cache).length + }); } _load() { @@ -676,12 +828,25 @@ } } - if (dirty) this._save(); + if (dirty) { + this._save(); + RC_DEBUG?.verbose('Cleaned expired fingerprints from cache'); + } } hasElement(el) { const fp = fingerprintElement(el); - return this.session.has(fp) || (fp in this.cache); + const result = this.session.has(fp) || (fp in this.cache); + + if (result && CONFIG.DEBUG_LEVEL >= 4) { + RC_DEBUG?.trace('Element already processed', { + fingerprint: fp.slice(0, 60) + '...', + inSession: this.session.has(fp), + inCache: fp in this.cache + }); + } + + return result; } markElement(el) { @@ -690,6 +855,10 @@ this.cache[fp] = Date.now(); this._save(); + RC_DEBUG?.verbose('Marked element as processed', { + fingerprint: fp.slice(0, 60) + '...' + }); + if (CONFIG.SHOW_EXECUTED_MARKER) { try { el.style.borderLeft = '3px solid #10B981'; @@ -705,12 +874,17 @@ delete this.cache[fp]; this._save(); } + + RC_DEBUG?.verbose('Unmarked element', { + fingerprint: fp.slice(0, 60) + '...' + }); } resetAll() { this.session.clear(); localStorage.removeItem(this.key); this.cache = {}; + RC_DEBUG?.info('All conversation history cleared'); } } @@ -838,58 +1012,110 @@ } function pasteToComposer(text) { + RC_DEBUG?.info('🔵 pasteToComposer CALLED', { textLength: text.length, preview: text.substring(0, 100) }); + try { const el = getVisibleInputCandidate(); if (!el) { + RC_DEBUG?.warn('❌ No input element found'); GM_notification({ title: 'AI Repo Commander', text: 'No input box found to paste file content.', timeout: 4000 }); return false; } + + RC_DEBUG?.verbose('Found input element', { + tagName: el.tagName, + classList: Array.from(el.classList || []).join(' '), + contentEditable: el.getAttribute('contenteditable') + }); + const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text; el.focus(); + // Method 1: ClipboardEvent 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 (_) {} + const dispatched = el.dispatchEvent(pasteEvt); + const notPrevented = !pasteEvt.defaultPrevented; - 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); + RC_DEBUG?.verbose('ClipboardEvent attempt', { dispatched, notPrevented }); + + if (dispatched && notPrevented) { + RC_DEBUG?.info('✅ Paste method succeeded: ClipboardEvent'); + return true; } - if (document.execCommand && document.execCommand('insertText', false, payload)) return true; - } catch (_) {} + } catch (e) { + RC_DEBUG?.verbose('ClipboardEvent failed', { error: String(e) }); + } + // Method 2: ProseMirror const isPM = el.classList && el.classList.contains('ProseMirror'); if (isPM) { + RC_DEBUG?.verbose('Attempting ProseMirror paste'); 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 })); + RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); return true; } - 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; + // Method 3: execCommand + 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); + RC_DEBUG?.verbose('Selection range set for execCommand'); + } + + const success = document.execCommand && document.execCommand('insertText', false, payload); + RC_DEBUG?.verbose('execCommand attempt', { success }); + + if (success) { + RC_DEBUG?.info('✅ Paste method succeeded: execCommand'); + return true; + } + } catch (e) { + RC_DEBUG?.verbose('execCommand failed', { error: String(e) }); } + // Method 4: TEXTAREA/INPUT + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + RC_DEBUG?.verbose('Attempting TEXTAREA/INPUT paste'); + el.value = payload; + el.dispatchEvent(new Event('input', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: TEXTAREA/INPUT'); + return true; + } + + // Method 5: contentEditable + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + RC_DEBUG?.verbose('Attempting contentEditable paste'); + el.textContent = payload; + el.dispatchEvent(new Event('input', { bubbles: true })); + RC_DEBUG?.info('✅ Paste method succeeded: contentEditable'); + return true; + } + + // Fallback: GM_setClipboard + RC_DEBUG?.warn('All paste methods failed, trying GM_setClipboard 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 }); + RC_DEBUG?.info('✅ Paste method succeeded: GM_setClipboard (manual paste required)'); } - } catch (_) {} + } catch (e) { + RC_DEBUG?.warn('GM_setClipboard failed', { error: String(e) }); + } + return false; } catch (e) { - RC_DEBUG?.warn('pasteToComposer failed', { error: String(e) }); + RC_DEBUG?.warn('pasteToComposer fatal error', { error: String(e) }); return false; } } @@ -897,6 +1123,19 @@ async function pasteAndMaybeSubmit(text) { const pasted = pasteToComposer(text); if (!pasted) return false; + + try { + const el = getVisibleInputCandidate(); + const actualContent = el?.textContent || el?.value || '[no content found]'; + RC_DEBUG?.info('📋 Content in composer after paste', { + expectedLength: text.length, + actualLength: actualContent.length, + actualPreview: actualContent.substring(0, 200) + }); + } catch (e) { + RC_DEBUG?.warn('Could not read composer content', { error: String(e) }); + } + if (!CONFIG.AUTO_SUBMIT) return true; await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS); const ok = await submitComposer(); @@ -913,11 +1152,16 @@ if (!block) throw new Error('No complete ^%$bridge command found (missing --- terminator).'); const parsed = this.parseKeyValuePairs(block); - // defaults + // Defaults parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; parsed.owner = parsed.owner || 'rob'; - // expand owner/repo shorthand + // Helpful default: create_branch without source_branch defaults to 'main' + if (parsed.action === 'create_branch' && !parsed.source_branch) { + parsed.source_branch = 'main'; + } + + // 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; @@ -927,7 +1171,6 @@ } static extractCompleteBlock(text) { - // Require terminator line --- (DeepSeek #2) const pattern = /^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m; const m = text.match(pattern); if (!m) return null; @@ -942,6 +1185,7 @@ let currentKey = null; let collecting = false; let buf = []; + // Kept for reference, but no longer used for collection termination const TOP = ['action','repo','path','content','owner','url','commit_message','branch','ref']; for (const raw of lines) { @@ -950,8 +1194,8 @@ 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) { + // End the current '|' block on ANY unindented key, not just a small whitelist + if (looksKey && unindented) { result[currentKey] = buf.join('\n').trimEnd(); collecting = false; buf = []; } else { @@ -1019,7 +1263,7 @@ return await this.makeAPICall(command); } catch (err) { if (attempt < CONFIG.MAX_RETRIES) { - await this.delay(1000 * (attempt + 1)); // 1s, 2s, ... + await this.delay(1000 * (attempt + 1)); return this.makeAPICallWithRetry(command, attempt + 1); } const totalAttempts = attempt + 1; @@ -1202,12 +1446,12 @@ } }; - // ---------------------- Monitor (with streaming “settle” & complete-block check) ---------------------- + // ---------------------- Monitor (with streaming "settle" & complete-block check) ---------------------- class CommandMonitor { constructor() { this.trackedMessages = new Map(); this.history = new ConvHistory(); - this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0); // optional + this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0); this.observer = null; this.currentPlatform = null; this._idCounter = 0; @@ -1256,6 +1500,7 @@ startObservation() { let scanPending = false; let lastScan = 0; + let lastMessageCount = 0; const scheduleScan = () => { if (scanPending) return; @@ -1268,19 +1513,84 @@ }, delay); }; + // MutationObserver for immediate detection - watching edits AND additions this.observer = new MutationObserver((mutations) => { + let shouldScan = false; + let reasons = new Set(); + 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(); - return; // Early exit after scheduling + // A) New nodes under the DOM (previous fast path) + if (m.type === 'childList') { + for (const node of m.addedNodes) { + if (node.nodeType !== 1) continue; + if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) { + reasons.add('code block added'); + shouldScan = true; + break; + } + // Also consider new assistant messages appearing + if (node.matches?.(this.currentPlatform.messages) || + node.querySelector?.(this.currentPlatform.messages)) { + reasons.add('message added'); + shouldScan = true; + break; + } } } + + // B) Text *inside* existing nodes changed (critical for streaming) + if (m.type === 'characterData') { + const el = m.target?.parentElement; + if (!el) continue; + if (el.closest?.('pre, code') || el.closest?.(this.currentPlatform.messages)) { + reasons.add('text changed'); + shouldScan = true; + } + } + + // C) Attribute toggles that show/hide or "finalize" code + if (m.type === 'attributes') { + const target = m.target; + if (target?.matches?.('pre, code') || + target?.closest?.(this.currentPlatform.messages)) { + reasons.add('attribute changed'); + shouldScan = true; + } + } + + if (shouldScan) break; + } + + if (shouldScan) { + RC_DEBUG?.trace('MO: scan triggered', { reasons: Array.from(reasons).join(', ') }); + scheduleScan(); } }); - this.observer.observe(document.body, { childList: true, subtree: true }); + // Observe all changes - no attributeFilter to catch any streaming-related attrs + this.observer.observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true + }); + + // Polling fallback - scan every 2 seconds if message count changed + this.pollingInterval = setInterval(() => { + if (CONFIG.RUNTIME.PAUSED) return; + + const messages = document.querySelectorAll(this.currentPlatform.messages); + const currentCount = messages.length; + + if (currentCount !== lastMessageCount) { + RC_DEBUG?.trace('Polling detected message count change', { + old: lastMessageCount, + new: currentCount + }); + lastMessageCount = currentCount; + scheduleScan(); + } + }, 2000); if (CONFIG.PROCESS_EXISTING) { setTimeout(() => { @@ -1292,7 +1602,6 @@ } } - // helper: must contain header, action, and final '---' on a line by itself isCompleteCommandText(txt) { if (!CONFIG.REQUIRE_TERMINATOR) return /(^|\n)\s*\^%\$bridge\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt); return /(^|\n)\s*\^%\$bridge\b/m.test(txt) @@ -1304,7 +1613,6 @@ const blocks = el.querySelectorAll('pre code, pre, code'); for (const b of blocks) { const txt = (b.textContent || '').trim(); - // Only treat as candidate if complete (DeepSeek #2) if (this.isCompleteCommandText(txt)) { return { blockElement: b, text: txt }; } @@ -1329,25 +1637,54 @@ if (!hit) return; const cmdText = hit.text; - - // Check if we're in cold start OR if this specific message element was already executed const withinColdStart = Date.now() < this.coldStartUntil; const alreadyProcessed = this.history.hasElement(el); - // If cold start OR already processed → show "Run Again" button (don't auto-execute) - if (withinColdStart || alreadyProcessed) { + RC_DEBUG?.trace('Evaluating message', { + withinColdStart, + alreadyProcessed, + preview: cmdText.slice(0, 60) + }); + + // Skip if cold start (but DON'T mark in history) + if (withinColdStart) { el.dataset.aiRcProcessed = '1'; + + RC_DEBUG?.verbose('Skipping command - page load (cold start)', { + fingerprint: fingerprintElement(el).slice(0, 40) + '...', + preview: cmdText.slice(0, 80) + }); - const reason = withinColdStart - ? 'page load (cold start)' - : 'already executed in this conversation'; + // DO NOT markElement() here — only mark when we actually execute + attachRunAgainUI(el, () => { + el.dataset.aiRcProcessed = '1'; + // Clear any accidental mark just in case + this.history.unmarkElement(el); - RC_DEBUG?.verbose(`Skipping command - ${reason}`, { + const id = this.getReadableMessageId(el); + const hit2 = this.findCommandInCodeBlock(el); + if (hit2) { + this.trackMessage(el, hit2.text, id); + } + }); + + skipped++; + return; + } + + // Skip if already processed in this conversation + if (alreadyProcessed) { + el.dataset.aiRcProcessed = '1'; + + RC_DEBUG?.verbose('Skipping command - already executed in this conversation', { + fingerprint: fingerprintElement(el).slice(0, 40) + '...', preview: cmdText.slice(0, 80) }); attachRunAgainUI(el, () => { - el.dataset.aiRcProcessed = '1'; // ← Prevents scan loop double-enqueue + el.dataset.aiRcProcessed = '1'; + // Clear from history so it can run again + this.history.unmarkElement(el); const id = this.getReadableMessageId(el); const hit2 = this.findCommandInCodeBlock(el); @@ -1377,7 +1714,8 @@ 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"]'); + const roleEl = el.closest?.('[data-message-author-role]') || el; + return roleEl?.getAttribute?.('data-message-author-role') === 'assistant'; } if (/claude\.ai/.test(host)) { const isUser = !!el.closest?.('[data-message-author-role="user"]'); @@ -1395,22 +1733,21 @@ state: COMMAND_STATES.DETECTED, startTime: Date.now(), lastUpdate: Date.now(), - cancelToken: { cancelled: false } // ← ADD THIS + cancelToken: { cancelled: false } }); this.updateState(messageId, COMMAND_STATES.PARSING); this.processCommand(messageId); } - // Debounce that checks cancellation + async debounceWithCancel(messageId) { const start = Date.now(); const delay = CONFIG.DEBOUNCE_DELAY; - const checkInterval = 100; // check every 100ms + const checkInterval = 100; while (Date.now() - start < delay) { const msg = this.trackedMessages.get(messageId); if (!msg || msg.cancelToken?.cancelled) return; - // Update lastUpdate to prevent premature cleanup msg.lastUpdate = Date.now(); this.trackedMessages.set(messageId, msg); @@ -1418,20 +1755,17 @@ } } - // Updated settle check with cancellation and lastUpdate bumps async waitForStableCompleteBlock(element, initialText, messageId) { let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS); let last = initialText; while (Date.now() < deadline) { - // Check cancellation const rec = this.trackedMessages.get(messageId); if (!rec || rec.cancelToken?.cancelled) { RC_DEBUG?.warn('Settle cancelled', { messageId }); return ''; } - // Update lastUpdate to prevent cleanup during long wait rec.lastUpdate = Date.now(); this.trackedMessages.set(messageId, rec); @@ -1455,7 +1789,6 @@ return finalHit ? finalHit.text : ''; } - // Retry UI helper attachRetryUI(element, messageId) { if (element.querySelector('.ai-rc-rerun')) return; @@ -1463,13 +1796,13 @@ element.dataset.aiRcProcessed = '1'; const hit = this.findCommandInCodeBlock(element); if (hit) { - // Clear old entry and create new one this.trackedMessages.delete(messageId); const newId = this.getReadableMessageId(element); this.trackMessage(element, hit.text, newId); } }); } + updateState(messageId, state) { const msg = this.trackedMessages.get(messageId); if (!msg) return; @@ -1496,7 +1829,6 @@ return; } - // ← CHECK CANCELLATION if (message.cancelToken?.cancelled) { RC_DEBUG?.warn('Operation cancelled', { messageId }); return; @@ -1511,14 +1843,11 @@ this.updateState(messageId, COMMAND_STATES.ERROR); if (/No complete \^%\$bridge/.test(err.message)) return; - // ← ADD RUN AGAIN ON ERRORS this.attachRetryUI(message.element, messageId); - UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message }); return; } - // ← CHECK CANCELLATION if (message.cancelToken?.cancelled) { RC_DEBUG?.warn('Operation cancelled after parse', { messageId }); return; @@ -1537,7 +1866,6 @@ const before = message.originalText; await this.debounceWithCancel(messageId); - // ← CHECK CANCELLATION if (message.cancelToken?.cancelled) { RC_DEBUG?.warn('Operation cancelled after debounce', { messageId }); return; @@ -1601,7 +1929,7 @@ const shouldCleanup = (finished && age > CONFIG.CLEANUP_AFTER_MS) || - (age > CONFIG.STUCK_AFTER_MS); // Force cleanup for stuck items + (age > CONFIG.STUCK_AFTER_MS); if (shouldCleanup) { if (age > CONFIG.STUCK_AFTER_MS && !finished) { @@ -1618,6 +1946,10 @@ stopAllProcessing() { this.trackedMessages.clear(); if (this.observer) this.observer.disconnect(); + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } if (this.cleanupIntervalId) { clearInterval(this.cleanupIntervalId); this.cleanupIntervalId = null; @@ -1631,7 +1963,6 @@ CONFIG.RUNTIME.PAUSED = true; saveConfig(CONFIG); - // Cancel all pending operations for (const [id, msg] of this.trackedMessages.entries()) { if (msg.cancelToken) msg.cancelToken.cancelled = true; @@ -1649,7 +1980,7 @@ } // ---------------------- Manual retry helpers ---------------------- - let commandMonitor; // forward ref + let commandMonitor; window.AI_REPO_RETRY_COMMAND_TEXT = () => { RC_DEBUG?.warn('Retry by text is deprecated. Use AI_REPO_RETRY_MESSAGE(messageId) or click "Run again".'); @@ -1663,7 +1994,7 @@ return; } - msg.element.dataset.aiRcProcessed = '1'; // ← Block scan loop + msg.element.dataset.aiRcProcessed = '1'; RC_DEBUG?.info('Message unmarked; reprocessing now', { messageId }); commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); @@ -1673,7 +2004,6 @@ } }; - // ---------------------- Test commands ---------------------- const TEST_COMMANDS = { validUpdate: @@ -1708,6 +2038,79 @@ repo: test-repo path: . --- \`\`\` +`, + createBranch: +`\ +\`\`\`yaml +^%$bridge +action: create_branch +repo: test-repo +branch: feature/new-feature +source_branch: main +--- +\`\`\` +`, + createPR: +`\ +\`\`\`yaml +^%$bridge +action: create_pr +repo: test-repo +title: Add new feature +head: feature/new-feature +base: main +body: | + This PR adds a new feature + - Item 1 + - Item 2 +--- +\`\`\` +`, + createIssue: +`\ +\`\`\`yaml +^%$bridge +action: create_issue +repo: test-repo +title: Bug report +body: | + Description of the bug + Steps to reproduce: + 1. Step one + 2. Step two +--- +\`\`\` +`, + createTag: +`\ +\`\`\`yaml +^%$bridge +action: create_tag +repo: test-repo +tag: v1.0.0 +target: main +message: Release version 1.0.0 +--- +\`\`\` +`, + createRelease: +`\ +\`\`\`yaml +^%$bridge +action: create_release +repo: test-repo +tag_name: v1.0.0 +name: Version 1.0.0 +body: | + ## What's New + - Feature A + - Feature B + + ## Bug Fixes + - Fix X + - Fix Y +--- +\`\`\` ` }; @@ -1723,7 +2126,7 @@ path: . test: TEST_COMMANDS, version: CONFIG.VERSION, history: commandMonitor.history, - submitComposer // expose for quick testing + submitComposer }; RC_DEBUG?.info('AI Repo Commander fully initialized'); RC_DEBUG?.info('API Enabled:', { value: CONFIG.ENABLE_API }); @@ -1737,4 +2140,4 @@ path: . } else { initializeRepoCommander(); } -})(); +})(); \ No newline at end of file