AI-Repo-Commander/src/ai-repo-commander.user.js

2496 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name AI Repo Commander
// @namespace http://tampermonkey.net/
// @version 1.6.2
// @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, multi-command queue
// @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';
// ---------------------- Storage keys ----------------------
const STORAGE_KEYS = {
history: 'ai_repo_commander_executed',
cfg: 'ai_repo_commander_cfg',
panel: 'ai_repo_commander_panel_state'
};
// ---------------------- Config (with persistence) ----------------------
const DEFAULT_CONFIG = {
ENABLE_API: true,
DEBUG_MODE: true,
DEBUG_LEVEL: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace
DEBUG_WATCH_MS: 120000,
DEBUG_MAX_LINES: 400,
DEBUG_SHOW_PANEL: true,
// Timing & API
// If you see "debouncing → error" in logs (assistant streams very slowly),
// try bumping DEBOUNCE_DELAY by +10002000 and/or SETTLE_CHECK_MS by +400800.
DEBOUNCE_DELAY: 3000,
MAX_RETRIES: 2,
VERSION: '1.6.2',
API_TIMEOUT_MS: 60000,
PROCESS_EXISTING: false,
ASSISTANT_ONLY: true,
BRIDGE_KEY: '',
// Persistent dedupe window
DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days
COLD_START_MS: 2000,
SHOW_EXECUTED_MARKER: true,
// Housekeeping
CLEANUP_AFTER_MS: 30000,
CLEANUP_INTERVAL_MS: 60000,
// Paste + submit behavior
APPEND_TRAILING_NEWLINE: true,
AUTO_SUBMIT: true,
POST_PASTE_DELAY_MS: 250,
SUBMIT_MODE: 'button_first',
// Streaming-complete hardening
// SETTLE_CHECK_MS is the "stable window" after last text change;
// SETTLE_POLL_MS is how often we re-check the code block.
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,
SCAN_DEBOUNCE_MS: 250,
FAST_WARN_MS: 50,
SLOW_WARN_MS: 60_000,
// Queue management
QUEUE_MIN_DELAY_MS: 800,
QUEUE_MAX_PER_MINUTE: 15,
QUEUE_MAX_PER_MESSAGE: 5,
QUEUE_WAIT_FOR_COMPOSER_MS: 6000,
};
function loadSavedConfig() {
try {
const raw = localStorage.getItem(STORAGE_KEYS.cfg);
if (!raw) return structuredClone(DEFAULT_CONFIG);
const saved = JSON.parse(raw);
const merged = { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } };
return merged;
} catch {
return structuredClone(DEFAULT_CONFIG);
}
}
function saveConfig(cfg) {
try { localStorage.setItem(STORAGE_KEYS.cfg, JSON.stringify(cfg)); } catch {}
}
const CONFIG = loadSavedConfig();
// ---------------------- 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;
this.bodyLogs = null;
this.bodyTools = null;
this.collapsed = false;
this.drag = { active: false, dx: 0, dy: 0 };
this.panelState = this._loadPanelState();
this.loopCleanupInterval = setInterval(() => {
if (Date.now() - this.startedAt > this.cfg.DEBUG_WATCH_MS * 2) {
this.loopCounts.clear();
this.startedAt = Date.now();
this.verbose('Cleaned loop counters');
}
}, this.cfg.DEBUG_WATCH_MS);
if (cfg.DEBUG_SHOW_PANEL) this.mount();
this.info(`Debug console ready (level=${cfg.DEBUG_LEVEL})`);
}
_loadPanelState() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEYS.panel) || '{}');
} catch { return {}; }
}
_savePanelState(partial) {
try {
const merged = { ...(this.panelState || {}), ...(partial || {}) };
this.panelState = merged;
localStorage.setItem(STORAGE_KEYS.panel, JSON.stringify(merged));
} catch {}
}
// Levels
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; }
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)` : '';
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));
this.cfg.DEBUG_LEVEL = lv;
saveConfig(this.cfg);
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;
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);
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);
}
_renderRow(e) {
if (!this.bodyLogs) return;
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): ''}`;
this.bodyLogs.appendChild(row);
while (this.bodyLogs.children.length > this.cfg.DEBUG_MAX_LINES) this.bodyLogs.firstChild.remove();
this.bodyLogs.scrollTop = this.bodyLogs.scrollHeight;
}
flashBtn(btn, label = 'Done', ms = 900) {
if (!btn) return;
const old = btn.textContent;
btn.disabled = true;
btn.textContent = `${label}`;
btn.style.opacity = '0.7';
setTimeout(() => {
btn.disabled = false;
btn.textContent = old;
btn.style.opacity = '';
}, ms);
}
toast(msg, ms = 1200) {
if (!this.panel) return;
const t = document.createElement('div');
t.textContent = msg;
t.style.cssText = `
position:absolute; right:12px; bottom:12px; padding:6px 10px;
background:#111827; color:#e5e7eb; border:1px solid #374151;
border-radius:6px; font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
opacity:.98; pointer-events:none; box-shadow:0 6px 20px rgba(0,0,0,.35)
`;
this.panel.appendChild(t);
setTimeout(() => t.remove(), ms);
}
mount() {
if (!document.body) { setTimeout(() => this.mount(), 100); return; }
const root = document.createElement('div');
root.style.cssText = `
position: fixed; ${this.panelState.left!==undefined ? `left:${this.panelState.left}px; top:${this.panelState.top}px;` : 'right:16px; bottom:16px;'}
z-index: 2147483647;
width: 460px; max-height: 55vh; 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 = `
<div class="rc-header" style="display:flex; gap:8px; align-items:center; padding:8px; border-bottom:1px solid #2c2c33; cursor:move; user-select:none">
<strong style="flex:1">AI Repo Commander</strong>
<div class="rc-tabs" style="display:flex; gap:6px;">
<button class="rc-tab rc-tab-logs" style="padding:4px 6px;border:1px solid #374151;border-radius:4px;background:#1f2937;color:#e5e7eb;">Logs</button>
<button class="rc-tab rc-tab-tools" style="padding:4px 6px;border:1px solid #374151;border-radius:4px;background:#111827;color:#e5e7eb;">Tools & Settings</button>
</div>
<label style="display:flex;align-items:center;gap:4px;">Lvl
<select class="rc-level" style="background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
<option value="0">off</option>
<option value="1">errors</option>
<option value="2" selected>info</option>
<option value="3">verbose</option>
<option value="4">trace</option>
</select>
</label>
<button class="rc-copy" title="Copy last 50 lines" style="padding:4px 6px;">Copy</button>
<button class="rc-pause" title="Pause/resume scanning" style="padding:4px 6px;">Pause</button>
<button class="rc-collapse" title="Collapse/expand" style="padding:4px 6px;">▾</button>
<button class="rc-queue-clear" title="Clear command queue" style="padding:4px 6px;background:#7c2d12;color:#fff;border:1px solid #991b1b">Clear Queue (0)</button>
<button class="rc-stop" title="Stop API calls" style="padding:4px 6px;background:#7f1d1d;color:#fff;border:1px solid #991b1b">STOP API</button>
</div>
<div class="rc-body rc-body-logs" style="overflow:auto; padding:8px; display:block; flex:1"></div>
<div class="rc-body rc-body-tools" style="overflow:auto; padding:8px; display:none; flex:1">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div style="grid-column:1 / -1;">
<h4 style="margin:0 0 6px 0;">Quick Actions</h4>
<button class="rc-clear-history" style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;">Clear History</button>
<button class="rc-copy-200" style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;margin-left:8px;">Copy last 200 logs</button>
</div>
<div>
<h4 style="margin:8px 0 6px 0;">Toggles</h4>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<input class="rc-toggle" data-key="ENABLE_API" type="checkbox"> ENABLE_API
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<input class="rc-toggle" data-key="AUTO_SUBMIT" type="checkbox"> AUTO_SUBMIT
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<input class="rc-toggle" data-key="APPEND_TRAILING_NEWLINE" type="checkbox"> APPEND_NEWLINE
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<input class="rc-toggle" data-key="ASSISTANT_ONLY" type="checkbox"> ASSISTANT_ONLY
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<input class="rc-toggle" data-key="PROCESS_EXISTING" type="checkbox"> PROCESS_EXISTING
</label>
</div>
<div>
<h4 style="margin:8px 0 6px 0;">Delays</h4>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
DEBOUNCE_DELAY (ms) <input class="rc-num" data-key="DEBOUNCE_DELAY" type="number" min="0" step="100" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
SETTLE_CHECK_MS <input class="rc-num" data-key="SETTLE_CHECK_MS" type="number" min="0" step="100" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
SETTLE_POLL_MS <input class="rc-num" data-key="SETTLE_POLL_MS" type="number" min="50" step="50" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
API_TIMEOUT_MS <input class="rc-num" data-key="API_TIMEOUT_MS" type="number" min="10000" step="5000" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
</div>
<div style="grid-column:1 / -1;">
<h4 style="margin:8px 0 6px 0;">Queue Settings</h4>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
QUEUE_MIN_DELAY_MS <input class="rc-num" data-key="QUEUE_MIN_DELAY_MS" type="number" min="0" step="100" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
QUEUE_MAX_PER_MINUTE <input class="rc-num" data-key="QUEUE_MAX_PER_MINUTE" type="number" min="1" step="1" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
QUEUE_MAX_PER_MESSAGE <input class="rc-num" data-key="QUEUE_MAX_PER_MESSAGE" type="number" min="1" step="1" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
QUEUE_WAIT_FOR_COMPOSER_MS <input class="rc-num" data-key="QUEUE_WAIT_FOR_COMPOSER_MS" type="number" min="1000" step="500" style="width:120px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:2px 6px;">
</label>
</div>
<div style="grid-column:1 / -1;">
<h4 style="margin:8px 0 6px 0;">Bridge Configuration</h4>
<label style="display:flex;align-items:center;gap:8px;margin:4px 0;">
Bridge Key:
<input class="rc-bridge-key" type="password"
style="flex:1;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:4px;padding:4px 8px;"
placeholder="Enter your bridge key here">
</label>
<div style="margin-top:6px;">
<button class="rc-save-bridge-key"
style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;">
Save Bridge Key
</button>
<button class="rc-clear-bridge-key"
style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;margin-left:8px;">
Clear Bridge Key
</button>
</div>
</div>
<div style="grid-column:1 / -1;">
<h4 style="margin:8px 0 6px 0;">Config JSON</h4>
<textarea class="rc-json" style="width:100%;height:140px;background:#0b1220;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px;"></textarea>
<div style="margin-top:6px;">
<button class="rc-save-json" style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;">Save Config</button>
<button class="rc-reset-defaults" style="padding:6px 8px;border:1px solid #374151;border-radius:6px;background:#1f2937;color:#e5e7eb;margin-left:8px;">Reset to Defaults</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(root);
this.panel = root;
this.bodyLogs = root.querySelector('.rc-body-logs');
this.bodyTools = root.querySelector('.rc-body-tools');
// Controls
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', (e) => {
this.copyLast(50);
this.flashBtn(e.currentTarget, 'Copied');
this.toast('Copied last 50 logs');
});
root.querySelector('.rc-copy-200').addEventListener('click', (e) => {
this.copyLast(200);
this.flashBtn(e.currentTarget, 'Copied');
this.toast('Copied last 200 logs');
});
const pauseBtn = root.querySelector('.rc-pause');
pauseBtn.addEventListener('click', () => {
const wasPaused = this.cfg.RUNTIME.PAUSED;
this.cfg.RUNTIME.PAUSED = !this.cfg.RUNTIME.PAUSED;
saveConfig(this.cfg);
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.flashBtn(pauseBtn, this.cfg.RUNTIME.PAUSED ? 'Paused' : 'Resumed');
this.toast(this.cfg.RUNTIME.PAUSED ? 'Paused scanning' : 'Resumed scanning');
this.info(`Runtime ${this.cfg.RUNTIME.PAUSED ? 'paused' : 'resumed'}`);
// When RESUMING: start a short cold-start window and mark current hits as processed.
if (wasPaused && !this.cfg.RUNTIME.PAUSED) {
if (commandMonitor) {
commandMonitor.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 2000);
}
markExistingHitsAsProcessed();
}
});
// Queue clear button
const queueBtn = root.querySelector('.rc-queue-clear');
queueBtn.addEventListener('click', (e) => {
window.AI_REPO_QUEUE?.clear?.();
this.flashBtn(e.currentTarget, 'Cleared');
this.toast('Queue cleared');
this.warn('Command queue cleared');
});
root.querySelector('.rc-stop').addEventListener('click', (e) => {
window.AI_REPO_STOP?.();
this.flashBtn(e.currentTarget, 'Stopped');
this.toast('Emergency STOP activated');
this.warn('Emergency STOP activated');
});
// Tabs
const tabLogs = root.querySelector('.rc-tab-logs');
const tabTools = root.querySelector('.rc-tab-tools');
const selectTab = (tools=false) => {
this.bodyLogs.style.display = tools ? 'none' : 'block';
this.bodyTools.style.display = tools ? 'block' : 'none';
tabLogs.style.background = tools ? '#111827' : '#1f2937';
tabTools.style.background = tools ? '#1f2937' : '#111827';
};
tabLogs.addEventListener('click', () => selectTab(false));
tabTools.addEventListener('click', () => {
selectTab(true);
root.querySelectorAll('.rc-toggle').forEach(inp => {
const key = inp.dataset.key;
inp.checked = !!this.cfg[key];
});
root.querySelectorAll('.rc-num').forEach(inp => {
inp.value = String(this.cfg[inp.dataset.key] ?? '');
});
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);
const bridgeKeyInput = root.querySelector('.rc-bridge-key');
if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : '';
});
// Collapse
const collapseBtn = root.querySelector('.rc-collapse');
const setCollapsed = (c) => {
this.collapsed = c;
this.bodyLogs.style.display = c ? 'none' : 'block';
this.bodyTools.style.display = 'none';
collapseBtn.textContent = c ? '▸' : '▾';
this._savePanelState({ collapsed: c });
};
setCollapsed(!!this.panelState.collapsed);
collapseBtn.addEventListener('click', () => setCollapsed(!this.collapsed));
// Dragging
const header = root.querySelector('.rc-header');
header.addEventListener('mousedown', (e) => {
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;
this.drag.dy = e.clientY - rect.top;
root.style.right = 'auto'; root.style.bottom = 'auto';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
const onMove = (e) => {
if (!this.drag.active) return;
const x = Math.max(0, Math.min(window.innerWidth - this.panel.offsetWidth, e.clientX - this.drag.dx));
const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - this.drag.dy));
this.panel.style.left = `${x}px`;
this.panel.style.top = `${y}px`;
};
const onUp = () => {
if (!this.drag.active) return;
this.drag.active = false;
this._savePanelState({ left: parseInt(this.panel.style.left||'0',10), top: parseInt(this.panel.style.top||'0',10) });
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
// Tools: Clear History
root.querySelector('.rc-clear-history').addEventListener('click', (e) => {
try {
commandMonitor?.history?.resetAll?.();
RC_DEBUG?.info('Conversation history cleared');
GM_notification({ title: 'AI Repo Commander', text: 'Execution marks cleared', timeout: 2500 });
} catch {
localStorage.removeItem(STORAGE_KEYS.history);
RC_DEBUG?.info('Legacy history key cleared');
}
this.flashBtn(e.currentTarget, 'Cleared');
this.toast('Conversation marks cleared');
});
// Tools: toggles & numbers
root.querySelectorAll('.rc-toggle').forEach(inp => {
const key = inp.dataset.key;
inp.checked = !!this.cfg[key];
inp.addEventListener('change', () => {
this.cfg[key] = !!inp.checked;
saveConfig(this.cfg);
this.toast(`${key} = ${this.cfg[key] ? 'on' : 'off'}`);
this.info(`Config ${key} => ${this.cfg[key]}`);
});
});
root.querySelectorAll('.rc-num').forEach(inp => {
inp.value = String(this.cfg[inp.dataset.key] ?? '');
inp.addEventListener('change', () => {
const v = parseInt(inp.value, 10);
if (!Number.isNaN(v)) {
this.cfg[inp.dataset.key] = v;
saveConfig(this.cfg);
this.toast(`${inp.dataset.key} = ${v}`);
this.info(`Config ${inp.dataset.key} => ${v}`);
}
});
});
// Tools: JSON input
root.querySelector('.rc-save-json').addEventListener('click', (e) => {
try {
const raw = root.querySelector('.rc-json').value;
const parsed = JSON.parse(raw);
if (Object.prototype.hasOwnProperty.call(parsed, 'BRIDGE_KEY')) {
const v = (parsed.BRIDGE_KEY ?? '').toString().trim();
if (v && !/^•+$/.test(v)) {
this.cfg.BRIDGE_KEY = v;
BRIDGE_KEY = v;
}
delete parsed.BRIDGE_KEY;
}
Object.assign(this.cfg, parsed);
saveConfig(this.cfg);
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);
this.flashBtn(e.currentTarget, 'Saved');
this.toast('Config saved');
this.info('Config JSON saved');
} catch (err) {
this.toast('Invalid JSON', 1500);
this.warn('Invalid JSON in config textarea', { error: String(err) });
}
});
root.querySelector('.rc-reset-defaults').addEventListener('click', (e) => {
Object.assign(this.cfg, structuredClone(DEFAULT_CONFIG));
saveConfig(this.cfg);
BRIDGE_KEY = null;
const bridgeKeyInput = root.querySelector('.rc-bridge-key');
if (bridgeKeyInput) bridgeKeyInput.value = '';
this.flashBtn(e.currentTarget, 'Reset');
this.toast('Defaults restored');
this.info('Config reset to defaults');
});
// Set initial UI states
pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause';
if (this.cfg.RUNTIME.PAUSED) {
pauseBtn.style.background = '#f59e0b';
pauseBtn.style.color = '#111827';
}
// Bridge Key handlers
const bridgeKeyInput = root.querySelector('.rc-bridge-key');
bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : '';
root.querySelector('.rc-save-bridge-key').addEventListener('click', (e) => {
const raw = (bridgeKeyInput.value || '').trim();
if (/^•+$/.test(raw)) {
this.info('Bridge key unchanged');
GM_notification({ title: 'AI Repo Commander', text: 'Bridge key unchanged', timeout: 2000 });
return;
}
this.cfg.BRIDGE_KEY = raw;
saveConfig(this.cfg);
BRIDGE_KEY = raw || null;
bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : '';
this.flashBtn(e.currentTarget, 'Saved');
this.toast('Bridge key saved');
this.info('Bridge key saved (masked)');
GM_notification({ title: 'AI Repo Commander', text: 'Bridge key saved', timeout: 2500 });
});
root.querySelector('.rc-clear-bridge-key').addEventListener('click', (e) => {
this.cfg.BRIDGE_KEY = '';
bridgeKeyInput.value = '';
saveConfig(this.cfg);
BRIDGE_KEY = null;
this.flashBtn(e.currentTarget, 'Cleared');
this.toast('Bridge key cleared');
this.info('Bridge key cleared');
GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 });
});
}
destroy() {
try { clearInterval(this.loopCleanupInterval); } catch {}
if (this.panel && this.panel.parentNode) this.panel.parentNode.removeChild(this.panel);
}
}
// ---------------------- 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' }
};
// ---------------------- 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);
}
// 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 '@end@'
const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) {
const t = _norm(b.textContent || '');
if (/@end@\s*$/m.test(t) && /(^|\n)\s*@bridge@\b/m.test(t) && /(^|\n)\s*action\s*:/m.test(t)) {
return t;
}
}
// 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;
}
// ---------------------- Multi-block extraction helpers ----------------------
function extractAllCompleteBlocks(text) {
const out = [];
const re = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/gm;
let m;
while ((m = re.exec(text)) !== null) {
const inner = (m[1] || '').trimEnd();
if (inner && /(^|\n)\s*action\s*:/m.test(inner)) out.push(inner);
}
return out; // array of inner texts (without @bridge@/@end@)
}
function findAllCommandsInMessage(el) {
const blocks = el.querySelectorAll('pre code, pre, code');
const hits = [];
for (const b of blocks) {
const txt = (b.textContent || '').trim();
const parts = extractAllCompleteBlocks(txt);
for (const part of parts) hits.push({ blockElement: b, text: `@bridge@\n${part}\n@end@` });
}
return hits;
}
// Tiny badge on the message showing how many got queued
function attachQueueBadge(el, count) {
if (el.querySelector('.ai-rc-queue-badge')) return;
const badge = document.createElement('span');
badge.className = 'ai-rc-queue-badge';
badge.textContent = `${count} command${count>1?'s':''} queued`;
badge.style.cssText = `
display:inline-block; padding:2px 6px; margin:4px 0;
background:#3b82f6; color:#fff; border-radius:4px;
font:11px ui-monospace, monospace;`;
el.insertBefore(badge, el.firstChild);
}
// Wait until it's safe to paste/submit
async function waitForComposerReady({ timeoutMs = CONFIG.QUEUE_WAIT_FOR_COMPOSER_MS, pollMs = 200 } = {}) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
// basic "assistant still typing" check
const lastMsg = Array.from(document.querySelectorAll(MSG_SELECTORS.join(','))).pop();
if (lastMsg?.querySelector?.('[aria-busy="true"], .typing-indicator')) {
await ExecutionManager.delay(400);
continue;
}
const el = getVisibleInputCandidate();
const btn = findSendButton();
const btnReady = !btn || (!btn.disabled && btn.getAttribute('aria-disabled') !== 'true');
const busy = document.querySelector(
'[aria-busy="true"], [data-state="loading"], ' +
'button[disabled], button[aria-disabled="true"]'
);
if (el && btnReady && !busy) return true;
await ExecutionManager.delay(pollMs);
}
RC_DEBUG?.warn('Composer not ready within timeout');
return false;
}
// ---------------------- 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 = {
'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),
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 = {
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 ConvHistory {
constructor() {
this.convId = getConversationId();
this.key = `ai_rc:conv:${this.convId}:processed`;
this.session = new Set();
this.cache = this._load();
this._cleanupTTL();
RC_DEBUG?.info('ConvHistory initialized', {
convId: this.convId.slice(0, 50) + (this.convId.length > 50 ? '...' : ''),
cachedCount: Object.keys(this.cache).length
});
}
_load() {
try {
return JSON.parse(localStorage.getItem(this.key) || '{}');
} catch {
return {};
}
}
_save() {
try {
localStorage.setItem(this.key, JSON.stringify(this.cache));
} catch {}
}
_cleanupTTL() {
const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000);
const now = Date.now();
let dirty = false;
for (const [fp, ts] of Object.entries(this.cache)) {
if (!ts || (now - ts) > ttl) {
delete this.cache[fp];
dirty = true;
}
}
if (dirty) {
this._save();
RC_DEBUG?.verbose('Cleaned expired fingerprints from cache');
}
}
hasElement(el, suffix = '') {
let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`;
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, suffix = '') {
let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`;
this.session.add(fp);
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';
el.title = 'Command executed — use "Run again" to re-run';
} catch {}
}
}
unmarkElement(el, suffix = '') {
let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`;
this.session.delete(fp);
if (fp in this.cache) {
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');
}
}
// Global helpers (stable)
window.AI_REPO = {
clearHistory: () => {
try { commandMonitor?.history?.resetAll?.(); } catch {}
localStorage.removeItem(STORAGE_KEYS.history); // legacy
},
getConfig: () => structuredClone(CONFIG),
setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); },
};
function attachRunAgainUI(containerEl, onRun) {
if (containerEl.querySelector('.ai-rc-rerun')) return;
const bar = document.createElement('div');
bar.className = 'ai-rc-rerun';
bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center;';
const msg = document.createElement('span');
msg.textContent = 'Already executed.';
msg.style.cssText = 'flex:1; font-size:13px; opacity:.9;';
const run = document.createElement('button');
run.textContent = 'Run again';
run.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;';
const dismiss = document.createElement('button');
dismiss.textContent = 'Dismiss';
dismiss.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#111827; color:#9ca3af;';
run.onclick = (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running'); onRun(); };
dismiss.onclick = (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Dismissed'); bar.remove(); };
bar.append(msg, run, dismiss);
containerEl.appendChild(bar);
}
// When resuming from pause, treat like a cold start & mark all currently-visible commands as processed.
// Adds "Run again" buttons so nothing auto-executes.
function markExistingHitsAsProcessed() {
try {
const messages = document.querySelectorAll(MSG_SELECTORS.join(','));
messages.forEach((el) => {
const hits = findAllCommandsInMessage(el);
if (!hits.length) return;
el.dataset.aiRcProcessed = '1';
const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
capped.forEach((_, idx) => {
commandMonitor?.history?.markElement?.(el, idx + 1);
});
attachRunAgainUI(el, () => {
const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
nowHits.forEach((h, i) => commandMonitor.enqueueCommand(el, h, i));
});
});
RC_DEBUG?.info('Resume-safe guard: marked visible commands as processed & attached Run again buttons');
} catch (e) {
RC_DEBUG?.warn('Resume-safe guard failed', { error: String(e) });
}
}
// ---------------------- 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) {
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 });
const dispatched = el.dispatchEvent(pasteEvt);
const notPrevented = !pasteEvt.defaultPrevented;
RC_DEBUG?.verbose('ClipboardEvent attempt', { dispatched, notPrevented });
if (dispatched && notPrevented) {
RC_DEBUG?.info('✅ Paste method succeeded: ClipboardEvent');
return true;
}
} 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');
// Pad with blank lines before/after to preserve ``` fences visually.
const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`;
const escape = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const html = String(payload2)
.split('\n')
.map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>')
.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;
}
// 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 (e) {
RC_DEBUG?.warn('GM_setClipboard failed', { error: String(e) });
}
return false;
} catch (e) {
RC_DEBUG?.warn('pasteToComposer fatal error', { error: String(e) });
return false;
}
}
async function pasteAndMaybeSubmit(text) {
const ready = await waitForComposerReady({ timeoutMs: CONFIG.QUEUE_WAIT_FOR_COMPOSER_MS });
if (!ready) {
RC_DEBUG?.warn('Composer not ready; re-queueing paste');
execQueue.push(async () => { await pasteAndMaybeSubmit(text); });
return false;
}
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();
if (!ok) {
GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 });
}
return true;
}
// ---------------------- Parser (strict, require @end@) ----------------------
class CommandParser {
static parseYAMLCommand(codeBlockText) {
const block = this.extractCompleteBlock(codeBlockText);
if (!block) throw new Error('No complete @bridge@ command found (missing @end@ terminator).');
const parsed = this.parseKeyValuePairs(block);
// Defaults
parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge';
parsed.owner = parsed.owner || 'rob';
// 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;
parsed.repo = repo;
}
return parsed;
}
static extractCompleteBlock(text) {
// Require terminator @end@ (clearer than --- which appears in markdown)
const pattern = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/m;
const m = text.match(pattern);
if (!m) return null;
const inner = m[1]?.trimEnd();
if (!inner) return null;
return inner;
}
static parseKeyValuePairs(block) {
const lines = block.split('\n');
const result = {};
let currentKey = null;
let collecting = false;
let buf = [];
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);
// 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 {
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 = [];
// Example commands are treated as valid but inert
const isExample =
parsed.example === true ||
parsed.example === 'true' ||
String(parsed.example || '').toLowerCase() === 'yes';
const action = parsed.action;
if (isExample) {
return { isValid: true, errors, example: true };
}
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));
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: CONFIG.API_TIMEOUT_MS || 60000,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
return resolve(response);
}
const body = response.responseText ? ` body=${response.responseText.slice(0,300)}` : '';
reject(new Error(`API Error ${response.status}: ${response.statusText}${body}`));
},
onerror: (error) => {
const msg = (error && (error.error || error.message))
? (error.error || error.message)
: JSON.stringify(error ?? {});
reject(new Error(`Network error: ${msg}`));
},
ontimeout: () => reject(new Error(`API request timeout after ${CONFIG.API_TIMEOUT_MS}ms`))
});
});
}
static async mockExecution(command, sourceElement) {
await this.delay(500);
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)); }
}
// ---------------------- Execution Queue ----------------------
class ExecutionQueue {
constructor({ minDelayMs = CONFIG.QUEUE_MIN_DELAY_MS, maxPerMinute = CONFIG.QUEUE_MAX_PER_MINUTE } = {}) {
this.q = [];
this.running = false;
this.minDelayMs = minDelayMs;
this.maxPerMinute = maxPerMinute;
this.timestamps = [];
this.onSizeChange = null;
}
push(task) {
this.q.push(task);
this.onSizeChange?.(this.q.length);
if (!this.running) this._drain();
}
clear() {
this.q.length = 0;
this.onSizeChange?.(0);
}
cancelOne(predicate) {
const i = this.q.findIndex(predicate);
if (i >= 0) this.q.splice(i, 1);
this.onSizeChange?.(this.q.length);
}
_withinBudget() {
const now = Date.now();
this.timestamps = this.timestamps.filter(t => now - t < 60_000);
return this.timestamps.length < this.maxPerMinute;
}
async _drain() {
if (this.running) return;
this.running = true;
const origLen = this.q.length;
while (this.q.length) {
// rate cap
while (!this._withinBudget()) await ExecutionManager.delay(500);
const fn = this.q.shift();
this.onSizeChange?.(this.q.length);
RC_DEBUG?.toast?.(`Executing command ${origLen - this.q.length}/${origLen}`, 800);
try { await fn(); } catch { /* error already surfaced */ }
this.timestamps.push(Date.now());
await ExecutionManager.delay(this.minDelayMs);
}
this.running = false;
}
}
const execQueue = new ExecutionQueue();
window.AI_REPO_QUEUE = {
clear: () => execQueue.clear(),
size: () => execQueue.q.length,
cancelOne: (cb) => execQueue.cancelOne(cb),
};
// ---------------------- Bridge Key ----------------------
let BRIDGE_KEY = null;
function requireBridgeKeyIfNeeded() {
if (!CONFIG.ENABLE_API) return BRIDGE_KEY;
// 1) Try runtime
if (BRIDGE_KEY && typeof BRIDGE_KEY === 'string' && BRIDGE_KEY.length) {
return BRIDGE_KEY;
}
// 2) Try saved config
if (CONFIG.BRIDGE_KEY && typeof CONFIG.BRIDGE_KEY === 'string' && CONFIG.BRIDGE_KEY.length) {
BRIDGE_KEY = CONFIG.BRIDGE_KEY;
RC_DEBUG?.info('Using saved bridge key from config');
return BRIDGE_KEY;
}
// 3) Prompt fallback
const entered = prompt(
'[AI Repo Commander] Enter your bridge key for this session (or set it in Tools → Bridge Configuration to avoid this prompt):'
);
if (!entered) throw new Error('Bridge key required when API is enabled.');
BRIDGE_KEY = entered;
// Offer to save for next time
try {
if (confirm('Save this bridge key in Settings → Bridge Configuration to avoid future prompts?')) {
CONFIG.BRIDGE_KEY = BRIDGE_KEY;
saveConfig(CONFIG);
RC_DEBUG?.info('Bridge key saved to config');
}
} catch { /* ignore */ }
return BRIDGE_KEY;
}
// Optional: expose a safe setter for console use (won't log the key)
window.AI_REPO_SET_KEY = function setBridgeKey(k) {
BRIDGE_KEY = (k || '').trim() || null;
if (BRIDGE_KEY) {
RC_DEBUG?.info('Bridge key set for this session');
} else {
RC_DEBUG?.info('Bridge key cleared for this session');
}
};
// ---------------------- 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);
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) {
if (CONFIG.BRIDGE_KEY) {
RC_DEBUG?.info('API is enabled — using saved bridge key from config');
} else {
RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.');
}
}
this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS);
// Wire up queue size updates to UI
if (execQueue) {
execQueue.onSizeChange = (n) => {
const queueBtn = document.querySelector('.rc-queue-clear');
if (queueBtn) queueBtn.textContent = `Clear Queue (${n})`;
};
}
}
detectPlatform() {
const host = window.location.hostname;
this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com'];
}
startObservation() {
let scanPending = false;
let lastScan = 0;
let lastMessageCount = 0;
const scheduleScan = () => {
if (scanPending) return;
scanPending = true;
const delay = Math.max(0, CONFIG.SCAN_DEBOUNCE_MS - (Date.now() - lastScan));
setTimeout(() => {
scanPending = false;
lastScan = Date.now();
this.scanMessages();
}, 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) {
// 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();
}
});
// 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(() => {
RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)');
this.scanMessages();
}, 600);
} else {
RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)');
}
}
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)
&& /(^|\n)\s*action\s*:/m.test(txt)
&& /@end@\s*$/m.test(txt);
}
findCommandInCodeBlock(el) {
const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) {
const txt = (b.textContent || '').trim();
if (this.isCompleteCommandText(txt)) {
return { blockElement: b, text: txt };
}
}
return null;
}
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 hits = findAllCommandsInMessage(el);
if (!hits.length) return;
if (hits.length === 1) {
el.dataset.aiRcProcessed = '1';
if (this.history.hasElement(el, 1)) {
attachRunAgainUI(el, () => this.trackMessage(el, hits[0].text, this.getReadableMessageId(el)));
return;
}
this.history.markElement(el, 1);
this.trackMessage(el, hits[0].text, this.getReadableMessageId(el));
return;
}
const withinColdStart = Date.now() < this.coldStartUntil;
const alreadyAll = hits.every((_, i) => this.history.hasElement(el, i + 1));
RC_DEBUG?.trace('Evaluating message', {
withinColdStart,
alreadyAll,
commandCount: hits.length
});
// Skip if cold start or already processed (but DON'T mark new ones in history during cold start)
if (withinColdStart || alreadyAll) {
el.dataset.aiRcProcessed = '1';
RC_DEBUG?.verbose('Skipping command(s) - ' + (withinColdStart ? 'page load (cold start)' : 'already executed in this conversation'), {
fingerprint: fingerprintElement(el).slice(0, 40) + '...',
commandCount: hits.length
});
attachRunAgainUI(el, () => {
el.dataset.aiRcProcessed = '1';
const hit2 = findAllCommandsInMessage(el);
if (hit2.length) {
const capped = hit2.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
capped.forEach((h, i) => this.enqueueCommand(el, h, i));
}
});
skipped += hits.length;
return;
}
// New message that hasn't been executed → auto-execute once
el.dataset.aiRcProcessed = '1';
const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
attachQueueBadge(el, capped.length);
capped.forEach((hit, idx) => {
// mark each sub-command immediately to avoid re-exec on reloads
this.history.markElement(el, idx + 1);
this.enqueueCommand(el, hit, idx);
});
found += capped.length;
});
if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`);
if (found) RC_DEBUG?.info(`Auto-executing ${found} new command(s)`);
}
enqueueCommand(element, hit, idx) {
const messageId = this.getReadableMessageId(element);
const subId = `${messageId}#${idx + 1}`;
// track this sub-command so updateState/attachRetryUI can work
this.trackedMessages.set(subId, {
element,
originalText: hit.text,
state: COMMAND_STATES.DETECTED,
startTime: Date.now(),
lastUpdate: Date.now(),
cancelToken: { cancelled: false },
});
execQueue.push(async () => {
// optional tiny settle for streaming
await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS);
const allNow = findAllCommandsInMessage(element);
const liveForIdx = allNow[idx]?.text;
const finalTxt = (liveForIdx && this.isCompleteCommandText(liveForIdx)) ? liveForIdx : hit.text;
let parsed;
try {
parsed = CommandParser.parseYAMLCommand(finalTxt);
const val = CommandParser.validateStructure(parsed);
if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`);
} catch (err) {
UIFeedback.appendStatus(element, 'ERROR', { action: 'Command', details: err.message });
this.attachRetryUI(element, subId);
return;
}
this.updateState(subId, COMMAND_STATES.EXECUTING);
const res = await ExecutionManager.executeCommand(parsed, element);
if (!res || res.success === false) {
this.updateState(subId, COMMAND_STATES.ERROR);
this.attachRetryUI(element, subId);
return;
}
this.updateState(subId, COMMAND_STATES.COMPLETE);
});
}
isAssistantMessage(el) {
if (!CONFIG.ASSISTANT_ONLY) return true;
const host = location.hostname;
if (/chat\.openai\.com|chatgpt\.com/.test(host)) {
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"]');
return !isUser;
}
if (/gemini\.google\.com/.test(host)) return true;
return true;
}
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(),
cancelToken: { cancelled: false }
});
this.updateState(messageId, COMMAND_STATES.PARSING);
this.processCommand(messageId);
}
async debounceWithCancel(messageId) {
const start = Date.now();
const delay = CONFIG.DEBOUNCE_DELAY;
const checkInterval = 100;
while (Date.now() - start < delay) {
const msg = this.trackedMessages.get(messageId);
if (!msg || msg.cancelToken?.cancelled) return;
msg.lastUpdate = Date.now();
this.trackedMessages.set(messageId, msg);
await ExecutionManager.delay(Math.min(checkInterval, delay - (Date.now() - start)));
}
}
async waitForStableCompleteBlock(element, initialText, messageId) {
let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
let last = initialText;
while (Date.now() < deadline) {
const rec = this.trackedMessages.get(messageId);
if (!rec || rec.cancelToken?.cancelled) {
RC_DEBUG?.warn('Settle cancelled', { messageId });
return '';
}
rec.lastUpdate = Date.now();
this.trackedMessages.set(messageId, rec);
await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS);
const hit = this.findCommandInCodeBlock(element);
const txt = hit ? hit.text : '';
if (!txt || !this.isCompleteCommandText(txt)) {
continue;
}
if (txt === last) {
// stable; keep waiting
} else {
last = txt;
deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
}
}
const finalHit = this.findCommandInCodeBlock(element);
return finalHit ? finalHit.text : '';
}
attachRetryUI(element, messageId) {
if (element.querySelector('.ai-rc-rerun')) return;
attachRunAgainUI(element, () => {
element.dataset.aiRcProcessed = '1';
// Parse sub-index from messageId like "...#3"
const m = /#(\d+)$/.exec(messageId);
const wantIdx = m ? Math.max(0, parseInt(m[1], 10) - 1) : 0;
const all = findAllCommandsInMessage(element);
const selected = all[wantIdx]?.text || all[0]?.text;
if (selected) {
this.trackedMessages.delete(messageId);
const newId = this.getReadableMessageId(element);
this.trackMessage(element, selected, newId);
}
});
}
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;
}
if (message.cancelToken?.cancelled) {
RC_DEBUG?.warn('Operation cancelled', { messageId });
return;
}
// 1) Parse
let parsed;
try {
parsed = CommandParser.parseYAMLCommand(message.originalText);
} catch (err) {
RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId });
this.updateState(messageId, COMMAND_STATES.ERROR);
if (/No complete @bridge@/.test(err.message)) return;
this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message });
return;
}
if (message.cancelToken?.cancelled) {
RC_DEBUG?.warn('Operation cancelled after parse', { messageId });
return;
}
// 2) Validate
this.updateState(messageId, COMMAND_STATES.VALIDATING);
let validation = CommandParser.validateStructure(parsed);
// Silently skip examples (already marked in history by the scanner)
if (validation.example) {
RC_DEBUG?.info('Example command detected — skipping execution');
this.updateState(messageId, COMMAND_STATES.COMPLETE);
return;
}
if (!validation.isValid) {
this.attachRetryUI(message.element, messageId);
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
}
// 3) Debounce
this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
const before = message.originalText;
await this.debounceWithCancel(messageId);
if (message.cancelToken?.cancelled) {
RC_DEBUG?.warn('Operation cancelled after debounce', { messageId });
return;
}
const stable = await this.waitForStableCompleteBlock(message.element, before, messageId);
if (!stable) {
this.updateState(messageId, COMMAND_STATES.ERROR);
return;
}
if (stable !== before) {
RC_DEBUG?.info('Command changed after settle (re-validate)', { messageId });
message.originalText = stable;
const reParsed = CommandParser.parseYAMLCommand(stable);
const reVal = CommandParser.validateStructure(reParsed);
if (!reVal.isValid) {
this.attachRetryUI(message.element, messageId);
throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`);
}
parsed = reParsed;
}
// 4) Execute
this.updateState(messageId, COMMAND_STATES.EXECUTING);
const result = await ExecutionManager.executeCommand(parsed, message.element);
if (!result || result.success === false) {
RC_DEBUG?.warn('Execution reported failure', { messageId });
this.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId);
return;
}
const duration = Date.now() - started;
if (duration < CONFIG.FAST_WARN_MS) RC_DEBUG?.warn('Command completed very fast', { messageId, duration });
if (duration > CONFIG.SLOW_WARN_MS) RC_DEBUG?.warn('Command took very 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);
if (/No complete @bridge@/.test(error.message)) return;
if (message) {
this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message });
}
}
}
cleanupProcessedCommands() {
const now = Date.now();
let count = 0;
for (const [id, msg] of this.trackedMessages.entries()) {
const age = now - (msg.lastUpdate || msg.startTime || now);
const finished = (msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR);
const shouldCleanup =
(finished && age > CONFIG.CLEANUP_AFTER_MS) ||
(age > CONFIG.STUCK_AFTER_MS);
if (shouldCleanup) {
if (age > CONFIG.STUCK_AFTER_MS && !finished) {
RC_DEBUG?.warn('Cleaning stuck entry', { messageId: id, state: msg.state, age });
}
this.trackedMessages.delete(id);
count++;
}
}
if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entries`);
}
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;
}
RC_DEBUG?.destroy?.();
}
setupEmergencyStop() {
window.AI_REPO_STOP = () => {
CONFIG.ENABLE_API = false;
CONFIG.RUNTIME.PAUSED = true;
saveConfig(CONFIG);
const queuedCount = execQueue.q.length;
execQueue.clear();
RC_DEBUG?.error(`🚨 EMERGENCY STOP: cancelled ${queuedCount} queued command(s)`);
for (const [id, msg] of this.trackedMessages.entries()) {
if (msg.cancelToken) msg.cancelToken.cancelled = true;
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 });
};
}
}
// ---------------------- Manual retry helpers ----------------------
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".');
};
window.AI_REPO_RETRY_MESSAGE = (messageId) => {
try {
const msg = commandMonitor?.trackedMessages?.get(messageId);
if (!msg) {
RC_DEBUG?.warn('Message not found for retry', { messageId });
return;
}
msg.element.dataset.aiRcProcessed = '1';
RC_DEBUG?.info('Retrying message now', { messageId });
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
commandMonitor.processCommand(messageId);
} catch (e) {
RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) });
}
};
// ---------------------- Test commands ----------------------
const TEST_COMMANDS = {
validUpdate:
`\
\`\`\`yaml
@bridge@
action: update_file
repo: test-repo
path: TEST.md
content: |
Test content
Multiple lines
---
Even markdown horizontal rules work!
@end@
\`\`\`
`,
getFile:
`\
\`\`\`yaml
@bridge@
action: get_file
repo: test-repo
path: README.md
@end@
\`\`\`
`,
listFiles:
`\
\`\`\`yaml
@bridge@
action: list_files
repo: test-repo
path: .
@end@
\`\`\`
`,
createBranch:
`\
\`\`\`yaml
@bridge@
action: create_branch
repo: test-repo
branch: feature/new-feature
source_branch: main
@end@
\`\`\`
`,
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
@end@
\`\`\`
`,
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
@end@
\`\`\`
`,
createTag:
`\
\`\`\`yaml
@bridge@
action: create_tag
repo: test-repo
tag: v1.0.0
target: main
message: Release version 1.0.0
@end@
\`\`\`
`,
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
@end@
\`\`\`
`,
multiCommand:
`\
\`\`\`yaml
@bridge@
action: get_file
repo: test-repo
path: file1.txt
@end@
@bridge@
action: get_file
repo: test-repo
path: file2.txt
@end@
@bridge@
action: list_files
repo: test-repo
path: .
@end@
\`\`\`
`
};
// ---------------------- Init ----------------------
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,
queue: execQueue
};
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 Tools → Clear History or window.AI_REPO.clearHistory()');
RC_DEBUG?.info('Queue management: window.AI_REPO_QUEUE.clear() / .size() / .cancelOne()');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeRepoCommander);
} else {
initializeRepoCommander();
}
})();