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

2743 lines
99 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
// @connect *
// ==/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: 6500,
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: 1300,
SETTLE_POLL_MS: 250,
// Runtime toggles
RUNTIME: { PAUSED: false },
// New additions for hardening
STUCK_AFTER_MS: 10 * 60 * 1000,
SCAN_DEBOUNCE_MS: 400,
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,
RESPONSE_BUFFER_FLUSH_DELAY_MS: 500, // wait for siblings to finish
RESPONSE_BUFFER_SECTION_HEADINGS: true,
MAX_PASTE_CHARS: 250_000, // hard cap per message
SPLIT_LONG_RESPONSES: true, // enable multi-message split
};
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) => {
const tgt = e.target instanceof Element ? e.target : e.target?.parentElement;
if (tgt?.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(el);
const btnReady = !btn || (!btn.disabled && btn.getAttribute('aria-disabled') !== 'true');
const scope = el?.closest('form, [data-testid="composer"], main, body') || document;
const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"]');
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),
url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v),
owner: (v) => !v || /^[\w\-.]+$/.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); },
};
// Replace the whole attachRunAgainUI with this per-command version (and keep a thin wrapper for back-compat)
function attachRunAgainPerCommand(containerEl, hits, onRunOneIdx, onRunAll) {
// Rebuild if an old single-button bar exists
const old = containerEl.querySelector('.ai-rc-rerun');
if (old) old.remove();
const bar = document.createElement('div');
bar.className = 'ai-rc-rerun';
bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap;';
const msg = document.createElement('span');
msg.textContent = `Already executed. Re-run:`;
msg.style.cssText = 'font-size:13px; opacity:.9; margin-right:6px;';
bar.appendChild(msg);
// "Run all again" button (optional legacy support)
const runAllBtn = document.createElement('button');
runAllBtn.textContent = 'Run all again';
runAllBtn.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;';
runAllBtn.addEventListener('click', (ev) => {
RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running');
try {
if (typeof onRunAll === 'function') {
onRunAll();
} else {
// Fallback: run each per-command callback in order
hits.forEach((_, idx) => {
try { onRunOneIdx?.(idx); } catch (e) {
RC_DEBUG?.warn('Run-all fallback failed for index', { idx, error: String(e) });
}
});
}
} catch (e) {
RC_DEBUG?.warn('Run-all handler failed', { error: String(e) });
}
});
bar.appendChild(runAllBtn);
hits.forEach((_, idx) => {
const btn = document.createElement('button');
btn.textContent = `Run again [#${idx + 1}]`;
btn.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;';
btn.addEventListener('click', (ev) => {
RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Running');
try { onRunOneIdx(idx); } catch (e) {
RC_DEBUG?.warn('Run-again handler failed', { error: String(e) });
}
});
bar.appendChild(btn);
});
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;';
dismiss.addEventListener('click', (ev) => { RC_DEBUG?.flashBtn?.(ev.currentTarget, 'Dismissed'); bar.remove(); });
bar.appendChild(dismiss);
containerEl.appendChild(bar);
}
// Back-compat thin wrapper used elsewhere; now renders per-command for whatever is currently in the message.
function attachRunAgainUI(containerEl, onRunAllLegacy) {
const hitsNow = findAllCommandsInMessage(containerEl).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
attachRunAgainPerCommand(containerEl, hitsNow, (idx) => {
// Preserve legacy behavior if a caller passed a single callback:
// default to re-enqueue just the selected index.
const h = hitsNow[idx];
if (!h) return;
commandMonitor.enqueueCommand(containerEl, h, idx);
}, () => {
// Legacy "run all" behavior for old callers
if (typeof onRunAllLegacy === 'function') {
onRunAllLegacy();
return;
}
hitsNow.forEach((h, i) => commandMonitor.enqueueCommand(containerEl, h, i));
});
}
// 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);
});
attachRunAgainPerCommand(el, capped, (idx) => {
const nowHits = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
const h = nowHits[idx];
if (h) commandMonitor.enqueueCommand(el, h, idx);
}, () => {
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 ensureBoard(containerEl) {
let board = containerEl.querySelector('.ai-rc-status-board');
if (!board) {
board = document.createElement('div');
board.className = 'ai-rc-status-board';
board.style.cssText = `
margin:10px 0;padding:8px;border:1px solid rgba(255,255,255,0.15);
border-radius:6px;background:rgba(255,255,255,0.06);font-family:monospace;
`;
containerEl.appendChild(board);
}
return board;
}
static appendStatus(containerEl, templateType, data) {
// Back-compat: when no key provided, fall through to single-line behavior
if (!data || !data.key) {
const statusElement = this.createStatusElement(templateType, data);
const existing = containerEl.querySelector('.ai-repo-commander-status');
if (existing) existing.remove();
statusElement.classList.add('ai-repo-commander-status');
containerEl.appendChild(statusElement);
return;
}
// Multi-line board (preferred)
const board = this.ensureBoard(containerEl);
const entry = this.upsertEntry(board, data.key);
entry.textContent = this.renderLine(templateType, data);
entry.dataset.state = templateType;
entry.style.borderLeft = `4px solid ${this.color(templateType)}`;
}
static upsertEntry(board, key) {
let el = board.querySelector(`[data-entry-key="${key}"]`);
if (!el) {
el = document.createElement('div');
el.dataset.entryKey = key;
el.style.cssText = `
padding:6px 8px;margin:4px 0;border-left:4px solid transparent;
background:rgba(0,0,0,0.15);border-radius:4px;
white-space:pre-wrap;word-break:break-word;
`;
board.appendChild(el);
}
return el;
}
static renderLine(templateType, data) {
const { action, details, label } = data || {};
const state = ({
SUCCESS:'Success', ERROR:'Error', VALIDATION_ERROR:'Invalid',
EXECUTING:'Processing...', MOCK:'Mock'
})[templateType] || templateType;
const left = label || action || 'Command';
return `${left}${state}${details ? `: ${details}` : ''}`;
}
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.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(scopeEl) {
const scope = scopeEl?.closest('form, [data-testid="composer"], main') || document;
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' });
RC_DEBUG?.warn('📋 Clipboard fallback used — manual paste may be required', {
length: payload.length
});
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, renderKey = '', label = '') {
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) {
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label });
const res = await this.mockExecution(command, sourceElement, renderKey, label);
return res;
}
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label });
const res = await this.makeAPICallWithRetry(command);
return this.handleSuccess(res, command, sourceElement, false, renderKey, label);
} catch (error) {
return this.handleError(error, command, sourceElement, renderKey, label);
}
}
static async mockExecution(command, sourceElement, renderKey = '', label = '') {
await this.delay(500);
const mock = { status: 200, responseText: JSON.stringify({ success: true, message: `Mock execution completed for ${command.action}` }) };
return this.handleSuccess(mock, command, sourceElement, true, renderKey, label);
}
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 _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, renderKey = '', label = '') {
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',
key: renderKey,
label
});
if (command.action === 'get_file') {
const body = this._extractGetFileBody(data);
if (typeof body === 'string' && body.length) {
RESP_BUFFER.push({ label, content: 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);
RESP_BUFFER.push({ label, content: listing });
} else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
RESP_BUFFER.push({ label, content: 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, renderKey = '', label = '') {
UIFeedback.appendStatus(sourceElement, 'ERROR', {
action: command.action || 'Command',
details: error.message,
key: renderKey,
label
});
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),
};
function chunkByLines(s, limit) {
const out = [];
let start = 0;
while (start < s.length) {
const endSoft = s.lastIndexOf('\n', Math.min(start + limit, s.length));
const end = endSoft > start ? endSoft + 1 : Math.min(start + limit, s.length);
out.push(s.slice(start, end));
start = end;
}
return out;
}
function isSingleFencedBlock(s) {
return /^```[^\n]*\n[\s\S]*\n```$/.test(s.trim());
}
function splitRespectingCodeFence(text, limit) {
const trimmed = text.trim();
if (!isSingleFencedBlock(trimmed)) {
// Not a single fence → just line-friendly chunking
return chunkByLines(text, limit);
}
// Extract inner payload & language hint
const m = /^```([^\n]*)\n([\s\S]*)\n```$/.exec(trimmed);
const lang = (m?.[1] || 'text').trim();
const inner = m?.[2] ?? '';
const chunks = chunkByLines(inner, limit - 16 - lang.length); // budget for fences
return chunks.map(c => '```' + lang + '\n' + c.replace(/\n?$/, '\n') + '```');
}
// ---------------------- ResponseBuffer ----------------------
class ResponseBuffer {
constructor() {
this.pending = []; // { label, content }
this.timer = null;
this.flushing = false;
}
push(item) {
if (!item || !item.content) return;
this.pending.push(item);
this.scheduleFlush();
}
scheduleFlush() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.flush(), CONFIG.RESPONSE_BUFFER_FLUSH_DELAY_MS || 500);
}
buildCombined() {
const parts = [];
for (const { label, content } of this.pending) {
if (CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS && label) {
parts.push(`### ${label}\n`);
}
parts.push(String(content).trimEnd());
parts.push(''); // blank line between sections
}
return parts.join('\n');
}
async flush() {
if (this.flushing) return;
if (!this.pending.length) return;
this.flushing = true;
const toPaste = this.buildCombined();
this.pending.length = 0; // clear
try {
const limit = CONFIG.MAX_PASTE_CHARS || 250_000;
if (CONFIG.SPLIT_LONG_RESPONSES && toPaste.length > limit) {
const chunks = splitRespectingCodeFence(toPaste, limit);
RC_DEBUG?.warn(`Splitting long response into ${chunks.length} message(s)`, {
totalChars: toPaste.length, perChunkLimit: limit
});
chunks.forEach((chunk, i) => {
const header = CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS
? `### Part ${i+1}/${chunks.length}\n`
: '';
const payload = header + chunk;
execQueue.push(async () => {
await pasteAndMaybeSubmit(payload);
});
});
return; // done: queued as multiple messages
}
// Normal single-message path
execQueue.push(async () => {
await pasteAndMaybeSubmit(toPaste);
});
} finally {
this.flushing = false;
}
}
}
const RESP_BUFFER = new ResponseBuffer();
window.AI_REPO_RESPONSES = RESP_BUFFER; // optional debug handle
// ---------------------- 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 }
);
attachRunAgainPerCommand(el, hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE), (idx) => {
el.dataset.aiRcProcessed = '1';
const hit2 = findAllCommandsInMessage(el).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
const h = hit2[idx];
if (h) this.enqueueCommand(el, h, idx);
});
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}`;
execQueue.push(async () => {
const finalTxt = hit.text; // <<< ADD THIS
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,
key: subId, // <<< key per sub-command
label: `[${idx+1}] parse`
});
this.attachRetryUI(element, subId);
return;
}
this.updateState(subId, COMMAND_STATES.EXECUTING);
const res = await ExecutionManager.executeCommand(
parsed,
element,
/* renderKey: */ subId, // <<< pass key down
/* label: */ `[${idx+1}] ${this.extractAction(finalTxt)}`
);
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) {
const all = findAllCommandsInMessage(element).slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
if (!all.length) return;
// Parse failing index
const m = /#(\d+)$/.exec(messageId);
const failedIdx = m ? Math.max(0, parseInt(m[1], 10) - 1) : 0;
attachRunAgainPerCommand(element, all, (idx) => {
element.dataset.aiRcProcessed = '1';
const pick = all[idx]?.text;
if (!pick) return;
this.trackedMessages.delete(messageId);
const newId = this.getReadableMessageId(element);
this.trackMessage(element, pick, newId);
});
// Highlight failed one
try {
const bar = element.querySelector('.ai-rc-rerun');
const btns = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+\]/.test(b.textContent || ''));
const b = btns[failedIdx];
if (b) b.style.outline = '2px solid #ef4444';
} catch {}
}
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 action = parsed?.action || 'unknown';
const renderKey = `${messageId}#1`;
const label = `[1] ${action}`;
const result = await ExecutionManager.executeCommand(parsed, message.element, renderKey, label);
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();
}
})();