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

3330 lines
141 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 + auto-submit, 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==
/* global GM_notification */
/* global GM_setClipboard */
/* global GM_xmlhttpRequest */
(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: 600,
SUBMIT_MODE: 'button_first',
MAX_COMPOSER_WAIT_MS: 15 * 60 * 1000, // 15 minutes
SUBMIT_MAX_RETRIES: 12,
// 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,
CLUSTER_RESCAN_MS: 1000, // time window to rescan adjacent messages
CLUSTER_MAX_LOOKAHEAD: 3, // how many adjacent assistant messages to check
// Queue management
QUEUE_MIN_DELAY_MS: 1500,
QUEUE_MAX_PER_MINUTE: 15,
QUEUE_MAX_PER_MESSAGE: 5,
QUEUE_WAIT_FOR_COMPOSER_MS: 12000,
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);
// Always use current script's VERSION and RUNTIME, not stale values from storage
delete saved.VERSION;
delete saved.RUNTIME;
return { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } };
} catch {
return structuredClone(DEFAULT_CONFIG);
}
}
function saveConfig(cfg) {
try {
const { VERSION, RUNTIME, ...persistable } = cfg;
localStorage.setItem(STORAGE_KEYS.cfg, JSON.stringify(persistable));
} 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 {
// Show a minimal manual copy UI (no deprecated execCommand)
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;';
const panel = document.createElement('div');
panel.style.cssText = 'background:#1f2937;color:#e5e7eb;padding:12px 12px 8px;border-radius:8px;width:min(760px,90vw);max-height:70vh;display:flex;flex-direction:column;gap:8px;box-shadow:0 10px 30px rgba(0,0,0,0.5)';
const title = document.createElement('div');
title.textContent = 'Copy to clipboard';
title.style.cssText = 'font:600 14px system-ui,sans-serif;';
const hint = document.createElement('div');
hint.textContent = 'Press Ctrl+C (Windows/Linux) or ⌘+C (macOS) to copy the selected text.';
hint.style.cssText = 'font:12px system-ui,sans-serif;opacity:0.85;';
const ta = document.createElement('textarea');
ta.value = text;
ta.readOnly = true;
ta.style.cssText = 'width:100%;height:40vh;font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;';
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
const close = document.createElement('button');
close.textContent = 'Close';
close.style.cssText = 'padding:6px 10px;background:#374151;color:#e5e7eb;border:1px solid #4b5563;border-radius:6px;cursor:pointer;';
close.onclick = () => overlay.remove();
panel.append(title, hint, ta, row);
row.append(close);
overlay.append(panel);
document.body.appendChild(overlay);
// Focus and select
ta.focus();
ta.select();
this.warn('Clipboard API unavailable; showing manual copy UI', { error: originalError?.message });
} 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);
dump.VERSION = DEFAULT_CONFIG.VERSION;
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;
}
// Prevent overriding ephemeral fields from pasted JSON
delete parsed.VERSION;
delete parsed.RUNTIME;
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 _commandLikeText(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 = _commandLikeText(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));
}
// Hash the text within this message that appears BEFORE the first command block
function _hashIntraMessagePrefix(el) {
const t = (el.textContent || '');
// Find the first complete @bridge@ block
const m = t.match(/@bridge@[\s\S]*?@end@/m);
const endIdx = m ? t.indexOf(m[0]) : t.length;
// Hash the last 2000 chars before the command block
return _hash(_norm(t.slice(Math.max(0, endIdx - 2000), endIdx)));
}
// Ordinal among messages that share the same (commandHash, prevCtxHash, intraHash)
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);
const ih = _hashIntraMessagePrefix(node);
return `ch:${ch}|ph:${ph}|ih:${ih}`;
})();
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 ih = _hashIntraMessagePrefix(el);
const dh = _hash(_domHint(el));
const key = `ch:${ch}|ph:${ph}|ih:${ih}`;
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,
intraMessageHash: ih,
domHint: dh,
ordinal: n
});
return fingerprint;
}
// Stable fingerprint: computed once per element, then cached on dataset.
// Prevents drift when the DOM/text changes later.
function getStableFingerprint(el) {
if (el?.dataset?.aiRcStableFp) return el.dataset.aiRcStableFp;
const fp = fingerprintElement(el);
try { if (el && el.dataset) el.dataset.aiRcStableFp = fp; } catch {}
return fp;
}
// ---------------------- 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 hits = [];
const seen = new Set();
// 1) First scan code elements (pre code, pre, code)
const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) {
const txt = (b.textContent || '').trim();
const parts = extractAllCompleteBlocks(txt);
for (const part of parts) {
const normalized = _norm(part);
seen.add(normalized);
hits.push({ blockElement: b, text: `@bridge@\n${part}\n@end@` });
}
}
// 2) Also scan the entire element's textContent for plain-text blocks
const wholeText = _norm(el.textContent || '');
const plainParts = extractAllCompleteBlocks(wholeText);
for (const part of plainParts) {
const normalized = _norm(part);
if (!seen.has(normalized)) {
seen.add(normalized);
hits.push({ blockElement: null, text: `@bridge@\n${part}\n@end@` });
}
}
return hits;
}
// Chainable actions that may trigger cluster rescan
const chainableActions = ['create_repo', 'create_file', 'create_branch', 'update_file', 'delete_file', 'create_pr'];
// 1) Check if we should trigger a cluster rescan after executing an action
function shouldTriggerClusterRescan(anchorEl, justExecutedAction) {
if (!chainableActions.includes(justExecutedAction)) return false;
// Check if next sibling is an unprocessed assistant message
let nextSibling = anchorEl?.nextElementSibling;
while (nextSibling) {
// Stop at user messages
if (commandMonitor && !commandMonitor.isAssistantMessage(nextSibling)) return false;
// Check if it's an assistant message
if (commandMonitor && commandMonitor.isAssistantMessage(nextSibling)) {
// Check if unprocessed (no processed marker)
const hasMarker = nextSibling?.dataset?.aiRcProcessed === '1' || !!nextSibling.querySelector('.ai-rc-queue-badge');
return !hasMarker;
}
nextSibling = nextSibling.nextElementSibling;
}
return false;
}
// 2) Schedule a cluster rescan to check adjacent assistant messages
async function scheduleClusterRescan(anchorEl) {
if (!anchorEl) return;
RC_DEBUG?.info('Scheduling cluster rescan', { anchor: anchorEl });
const deadline = Date.now() + CONFIG.CLUSTER_RESCAN_MS;
let scanned = 0;
let currentEl = anchorEl.nextElementSibling;
while (currentEl && scanned < CONFIG.CLUSTER_MAX_LOOKAHEAD && Date.now() < deadline) {
// Stop at user message boundaries
if (commandMonitor && !commandMonitor.isAssistantMessage(currentEl)) {
RC_DEBUG?.verbose('Cluster rescan hit user message boundary');
break;
}
// Only process assistant messages
if (commandMonitor && commandMonitor.isAssistantMessage(currentEl)) {
// Check if already processed
const hasMarker = currentEl?.dataset?.aiRcProcessed === '1' || !!currentEl.querySelector('.ai-rc-queue-badge');
if (!hasMarker) {
// Look for new @bridge@ blocks
const hits = findAllCommandsInMessage(currentEl);
if (hits.length > 0) {
RC_DEBUG?.info('Cluster rescan found commands in adjacent message', { count: hits.length });
// 1) Set dataset marker
currentEl.dataset.aiRcProcessed = '1';
// 2) Slice hits to CONFIG.QUEUE_MAX_PER_MESSAGE
const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
// 3) Mark and enqueue each command
capped.forEach((h, idx) => {
if (commandMonitor) {
commandMonitor.history.markElement(currentEl, idx + 1);
commandMonitor.enqueueCommand(currentEl, h, idx);
}
});
// 4) Add queue badge with capped count
attachQueueBadge(currentEl, capped.length);
}
}
scanned++;
}
currentEl = currentEl.nextElementSibling;
// Small delay between checks
if (currentEl && Date.now() < deadline) {
await ExecutionManager.delay(100);
}
}
RC_DEBUG?.verbose('Cluster rescan completed', { scanned, deadline: Date.now() >= deadline });
}
// Helper functions for per-subcommand dataset flags
function subDoneKey(i) { return `aiRcSubDone_${i}`; } // i is 1-based
function subEnqKey(i) { return `aiRcSubEnq_${i}`; } // i is 1-based
// Tiny badge on the message showing how many got queued
function attachQueueBadge(el, count) {
let badge = el.querySelector('.ai-rc-queue-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'ai-rc-queue-badge';
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);
}
badge.textContent = `${count} command${count>1?'s':''} queued`;
}
// 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();
// Pre-paste: the send button can be disabled. Don't block on it here.
const btn = findSendButton(el);
// Narrow scope to composer's local container
const scope = el?.closest('form, [data-testid="composer"], [data-testid="composer-container"], main, body') || document;
// 1) Narrow "busy" detection to the scope and ignore hidden spinners
const busy = scope.querySelector('[aria-busy="true"], [data-state="loading"], .typing-indicator');
// Debug logging when composer or button not found
if (!el || !btn) {
RC_DEBUG?.verbose('Composer probe', {
foundEl: !!el,
elTag: el?.tagName,
elClasses: el ? Array.from(el.classList || []).join(' ') : null,
hasBtn: !!btn,
busyFound: !!busy
});
}
// 2) Only block if there is real (non-whitespace) unsent content already present
let hasUnsent = false;
if (el) {
try {
const currentText = (el.textContent || el.value || '').trim();
hasUnsent = currentText.length > 0 && !/^\s*$/.test(currentText);
} catch (e) {
RC_DEBUG?.verbose('Failed to check composer content', { error: String(e) });
}
}
// Ready to paste as soon as composer exists, not busy, and no unsent text.
if (el && !busy && !hasUnsent) 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']
};
// noinspection JSUnusedGlobalSymbols
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');
}
}
/**
* @param {Element} el
* @param {string|number} [suffix]
*/
hasElement(el, suffix = '') {
let fp = getStableFingerprint(el);
if (suffix !== '' && suffix != null) fp += `#${String(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;
}
/**
* @param {Element} el
* @param {string|number} [suffix]
*/
markElement(el, suffix = '') {
let fp = getStableFingerprint(el);
if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`;
this.session.add(fp);
this.cache[fp] = Date.now();
this._save();
// Also set hard per-subcommand flag on element (bullet-proof local dedupe)
try { if (el && el.dataset && suffix) el.dataset[subDoneKey(Number(suffix))] = '1'; } catch {}
RC_DEBUG?.verbose('Marked element as processed', {
fingerprint: fp.slice(0, 60) + '...'
});
if (CONFIG.SHOW_EXECUTED_MARKER && el instanceof HTMLElement) {
try {
el.style.borderLeft = '3px solid #10B981';
el.title = 'Command executed — use "Run again" to re-run';
} catch {}
}
}
resetAll() {
this.session.clear();
localStorage.removeItem(this.key);
this.cache = {};
RC_DEBUG?.info('All conversation history cleared');
}
}
// Global helpers (stable)
// noinspection JSUnusedGlobalSymbols
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 = [
// ChatGPT / GPT
'#prompt-textarea',
'.ProseMirror#prompt-textarea',
'.ProseMirror[role="textbox"][contenteditable="true"]',
'[data-testid="composer"] [contenteditable="true"][role="textbox"]',
'main [contenteditable="true"][role="textbox"]',
// Claude
'.chat-message + [contenteditable="true"]',
'[contenteditable="true"][data-testid="chat-input"]',
// Gemini
'textarea[data-testid="input-area"]',
'[contenteditable="true"][aria-label*="Message"]',
// Generic fallbacks
'textarea',
'[contenteditable="true"]'
];
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) {
// Try multiple scope strategies
const formScope = scopeEl?.closest('form');
const composerScope = scopeEl?.closest('[data-testid="composer"]');
const mainScope = scopeEl?.closest('main');
const scope = formScope || composerScope || mainScope || document;
// Debug: Log scoping information
RC_DEBUG?.verbose('findSendButton: scope resolution', {
hasScopeEl: !!scopeEl,
scopeElTag: scopeEl?.tagName,
formScope: !!formScope,
composerScope: !!composerScope,
mainScope: !!mainScope,
usingDocument: scope === document
});
const selectors = [
'button[data-testid="send-button"]',
'button#composer-submit-button',
'[id="composer-submit-button"]',
'button.composer-submit-btn',
'button[data-testid="composer-send-button"]',
'button[aria-label="Send"]',
'button[aria-label*="Send prompt"]',
'button[aria-label*="Send message"]',
'button[aria-label*="Send"]',
'button[aria-label*="send"]',
'button[aria-label*="Submit"]',
'button[aria-label*="submit"]',
// Some pages omit type=submit; keep generic button-in-form last
'form button'
];
// First pass: Try scoped search
for (const s of selectors) {
const btn = scope.querySelector(s);
if (btn) {
const style = window.getComputedStyle(btn);
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true';
const hidden = style.display === 'none' || style.visibility === 'hidden';
const notRendered = btn.offsetParent === null && style.position !== 'fixed';
RC_DEBUG?.verbose('findSendButton: found candidate (scoped)', {
selector: s,
id: btn.id,
disabled,
hidden,
notRendered,
willReturn: !disabled && !hidden && !notRendered
});
if (!disabled && !hidden && !notRendered) return btn;
}
}
// Second pass: Fallback to global search with detailed logging
RC_DEBUG?.verbose('findSendButton: no button found in scope, trying global search');
for (const s of selectors) {
const btn = document.querySelector(s);
if (btn) {
const style = window.getComputedStyle(btn);
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true';
const hidden = style.display === 'none' || style.visibility === 'hidden';
const notRendered = btn.offsetParent === null && style.position !== 'fixed';
RC_DEBUG?.verbose('findSendButton: found candidate (global)', {
selector: s,
id: btn.id,
disabled,
hidden,
notRendered,
inScope: scope.contains(btn),
willReturn: !disabled && !hidden && !notRendered
});
if (!disabled && !hidden && !notRendered) return btn;
}
}
// Final fallback: XPath for exact button location (works if structure hasn't drifted)
try {
const xp = '/html/body/div[1]/div/div/div[2]/main/div/div/div[2]/div[1]/div/div[2]/form/div[2]/div/div[3]/div/button';
const node = document.evaluate(xp, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (node instanceof HTMLButtonElement) {
RC_DEBUG?.verbose('findSendButton: found via XPath fallback', { id: node.id });
return node;
}
} catch (e) {
RC_DEBUG?.verbose('findSendButton: XPath fallback failed', { error: String(e) });
}
RC_DEBUG?.warn('findSendButton: no valid button found anywhere');
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 {
// 1) Get composer element first
const el = getVisibleInputCandidate();
if (!el) return false;
// 2) Find send button scoped to composer
const btn = findSendButton(el);
// 3) Check SUBMIT_MODE and click or press Enter
if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) {
btn.click();
return true;
}
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`;
// Use text node to preserve code fences better
const textNode = document.createTextNode(payload2);
el.innerHTML = '';
el.appendChild(textNode);
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: Selection API insertion (non-deprecated)
try {
const sel = window.getSelection && window.getSelection();
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
// Ensure there's a caret; if not, place it at the end
if (sel && sel.rangeCount === 0) {
const r = document.createRange();
r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r);
RC_DEBUG?.verbose('Selection range set for contentEditable');
}
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const node = document.createTextNode(payload);
range.insertNode(node);
// Move caret after inserted node
range.setStartAfter(node);
range.setEndAfter(node);
sel.removeAllRanges(); sel.addRange(range);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: Selection API (contentEditable)');
return true;
}
} else if (typeof el.setRangeText === 'function') {
// For inputs/text-areas supporting setRangeText
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? start;
el.setRangeText(payload, start, end, 'end');
el.dispatchEvent(new Event('input', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: setRangeText');
return true;
}
} catch (e) {
RC_DEBUG?.verbose('Selection API insertion 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, attempt = 0, startedAt = Date.now(), submitRetry = 0) {
// 1) Check if elapsed time exceeds MAX_COMPOSER_WAIT_MS
const elapsed = Date.now() - startedAt;
if (elapsed > CONFIG.MAX_COMPOSER_WAIT_MS) {
RC_DEBUG?.error('pasteAndMaybeSubmit gave up after max wait time', {
elapsed,
maxWait: CONFIG.MAX_COMPOSER_WAIT_MS,
attempt,
submitRetry
});
GM_notification({
title: 'AI Repo Commander',
text: `Paste/submit failed: composer not ready after ${Math.floor(elapsed / 1000)}s`,
timeout: 6000
});
return false;
}
// 2) Quick readiness probe with 1200ms timeout
const ready = await waitForComposerReady({ timeoutMs: 1200 });
if (!ready) {
// 3) Not ready, requeue with exponential backoff (600ms base, cap at 30s)
const backoffMs = Math.min(30_000, Math.floor(600 * Math.pow(1.6, attempt)));
RC_DEBUG?.warn('Composer not ready; re-queueing paste with backoff', {
attempt,
backoffMs,
elapsed
});
setTimeout(() => {
execQueue.push(async () => {
await pasteAndMaybeSubmit(text, attempt + 1, startedAt, submitRetry);
});
}, backoffMs);
return false;
}
// 4) Only paste if text is non-empty (enables submit-only retries)
if (text && text.length > 0) {
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;
// 5) After paste, wait POST_PASTE_DELAY_MS
await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS);
// 6) Try submitComposer()
const ok = await submitComposer();
if (!ok) {
// 7) If submit fails, and we haven't hit SUBMIT_MAX_RETRIES, requeue submit-only retry
if (submitRetry < CONFIG.SUBMIT_MAX_RETRIES) {
const submitBackoffMs = Math.min(30_000, Math.floor(500 * Math.pow(1.6, submitRetry)));
RC_DEBUG?.warn('Submit failed; re-queueing submit-only retry with backoff', {
submitRetry,
submitBackoffMs
});
setTimeout(() => {
execQueue.push(async () => {
// Empty text for submit-only retry, increment submitRetry
await pasteAndMaybeSubmit('', attempt, startedAt, submitRetry + 1);
});
}, submitBackoffMs);
return false;
} else {
RC_DEBUG?.error('Submit failed after max retries', { submitRetry, maxRetries: CONFIG.SUBMIT_MAX_RETRIES });
GM_notification({
title: 'AI Repo Commander',
text: `Pasted content, but auto-submit failed after ${CONFIG.SUBMIT_MAX_RETRIES} retries.`,
timeout: 6000
});
}
}
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 };
}
}
// ---------------------- ResponseBuffer (helper functions and class) ----------------------
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') + '```');
}
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;
}
}
}
// Initialize singleton
window.AI_REPO_RESPONSES = new ResponseBuffer();
// ---------------------- 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 });
return await this.mockExecution(command, sourceElement, renderKey, label);
}
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) {
window.AI_REPO_RESPONSES.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);
window.AI_REPO_RESPONSES.push({ label, content: listing });
} else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
window.AI_REPO_RESPONSES.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
});
}
}
// Trigger cluster rescan for chainable commands
try {
if (shouldTriggerClusterRescan(sourceElement, command.action)) {
if (!sourceElement.dataset.aiRcClusterCoolUntil || Date.now() > Number(sourceElement.dataset.aiRcClusterCoolUntil)) {
sourceElement.dataset.aiRcClusterCoolUntil = String(Date.now() + 1500);
await scheduleClusterRescan(sourceElement);
} else {
RC_DEBUG?.verbose('Cluster rescan suppressed by cooldown');
}
}
} catch (e) {
RC_DEBUG?.verbose('Cluster rescan failed', { error: String(e) });
}
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) void 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();
// noinspection JSUnusedGlobalSymbols
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');
}
};
// Helper function to find command text in an element (code blocks or plain text)
function findCommandTextInElement(el) {
// Helper to check if text is a complete command
const isComplete = (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);
};
// 1) First try to find in code blocks
const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) {
const txt = (b.textContent || '').trim();
if (isComplete(txt)) {
return { blockElement: b, text: txt };
}
}
// 2) If not found in code blocks, check raw message text
const wholeText = _norm(el.textContent || '');
const parts = extractAllCompleteBlocks(wholeText);
if (parts.length > 0) {
const part = parts[0];
return { blockElement: null, text: `@bridge@\n${part}\n@end@` };
}
// 3) No complete command found
return null;
}
// ---------------------- 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 adjacentToProcessed = 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;
}
// D) Check for new assistant messages adjacent to already-processed ones (split messages)
if (!adjacentToProcessed) {
for (const m of mutations) {
if (m.type === 'childList') {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
// Check if it's an assistant message
const isAssistantMsg = node.matches?.(this.currentPlatform.messages) &&
this.isAssistantMessage(node);
if (isAssistantMsg) {
// Check if previous sibling is a processed assistant message
const prev = node.previousElementSibling;
if (prev && prev.dataset?.aiRcProcessed === '1' && this.isAssistantMessage(prev)) {
reasons.add('split message detected');
adjacentToProcessed = true;
break;
}
}
}
if (adjacentToProcessed) break;
}
}
}
if (shouldScan || adjacentToProcessed) {
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) {
return findCommandTextInElement(el);
}
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;
// Allow re-scan of already-processed messages to catch *new* blocks appended later
const hits = findAllCommandsInMessage(el);
if (!hits.length) return;
const capped = hits.slice(0, CONFIG.QUEUE_MAX_PER_MESSAGE);
// Count how many sub-commands we have already marked for this element
// Prefer element flags (local), fallback to history (persistent)
let already = 0;
for (let i = 0; i < capped.length; i++) {
const idx1 = i + 1;
const done = el?.dataset?.[subDoneKey(idx1)] === '1' || this.history.hasElement(el, idx1);
if (done) already++;
}
// Case A: first time seeing this message (no aiRcProcessed yet)
if (!el.dataset.aiRcProcessed) {
el.dataset.aiRcProcessed = '1';
// If only one block, keep fast path
if (capped.length === 1) {
if (already > 0) {
// Already executed, add Run Again button
attachRunAgainUI(el, () => this.trackMessage(el, capped[0].text, this.getReadableMessageId(el)));
skipped++;
return;
}
this.history.markElement(el, 1);
this.trackMessage(el, capped[0].text, this.getReadableMessageId(el));
found++;
return;
}
// Check if within cold start or all already executed
const withinColdStart = Date.now() < this.coldStartUntil;
const alreadyAll = (already === capped.length);
if (withinColdStart || alreadyAll) {
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, capped, (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 += capped.length;
return;
}
// Multi-block: mark & enqueue all we see now
attachQueueBadge(el, capped.length);
capped.forEach((hit, idx) => {
const subIdx = idx + 1;
const enqKey = subEnqKey(subIdx);
if (el?.dataset?.[enqKey] === '1' || el?.dataset?.[subDoneKey(subIdx)] === '1') return;
try { if (el && el.dataset) el.dataset[enqKey] = '1'; } catch {}
this.history.markElement(el, subIdx);
this.enqueueCommand(el, hit, idx);
});
found += capped.length;
return;
}
// Case B: message was already processed; enqueue only the *new* ones
if (already < capped.length) {
const newlyAdded = capped.slice(already);
RC_DEBUG?.info('Detected new command blocks in already-processed message', {
alreadyCount: already,
newCount: newlyAdded.length,
totalCount: capped.length
});
const existingQueued = parseInt(el.dataset.aiRcQueued || '0', 10) || 0;
const total = existingQueued + newlyAdded.length;
attachQueueBadge(el, total);
newlyAdded.forEach((hit, idx) => {
const subIdx = already + idx + 1; // 1-based
const enqKey = subEnqKey(subIdx);
if (el?.dataset?.[enqKey] === '1' || el?.dataset?.[subDoneKey(subIdx)] === '1') return;
try { if (el && el.dataset) el.dataset[enqKey] = '1'; } catch {}
this.history.markElement(el, subIdx); // also sets SubDone via patch #2
this.enqueueCommand(el, hit, subIdx - 1);
});
el.dataset.aiRcQueued = String(total);
found += newlyAdded.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 subIndex1 = (idx + 1);
const subId = `${messageId}#${subIndex1}`;
// Hard guard: never enqueue twice
const enqKey = subEnqKey(subIndex1);
if (element?.dataset?.[enqKey] === '1' && element?.dataset?.[subDoneKey(subIndex1)] === '1') {
RC_DEBUG?.verbose('Skip enqueue (already done)', { subIndex1 });
return;
}
try { if (element && element.dataset) element.dataset[enqKey] = '1'; } catch {}
execQueue.push(async () => {
// Micro-settle: wait for text to stabilize before parsing
try {
const blockElement = hit.blockElement;
if (blockElement) {
let lastText = blockElement.textContent || '';
const maxWait = 700;
const checkInterval = 70;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
await new Promise(r => setTimeout(r, checkInterval));
const currentText = blockElement.textContent || '';
if (currentText === lastText) break; // Text stabilized
lastText = currentText;
}
}
} catch (e) {
RC_DEBUG?.verbose('Micro-settle failed, continuing anyway', { error: String(e) });
}
const finalTxt = hit.text;
let parsed;
try {
parsed = CommandParser.parseYAMLCommand(finalTxt);
} 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;
}
const val = CommandParser.validateStructure(parsed);
if (!val.isValid) {
UIFeedback.appendStatus(element, 'ERROR', {
action: 'Command',
details: `Validation failed: ${val.errors.join(', ')}`,
key: subId,
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);
void 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;
RC_DEBUG?.info('🔵 SETTLE: Starting stability check', {
messageId,
initialLength: initialText.length,
initialPreview: initialText.substring(0, 100),
settleWindow: CONFIG.SETTLE_CHECK_MS
});
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 : '';
RC_DEBUG?.trace('🔵 SETTLE: Poll iteration', {
messageId,
foundCommand: !!hit,
textLength: txt.length,
textPreview: txt.substring(0, 80),
isComplete: this.isCompleteCommandText(txt),
unchanged: txt === last,
timeRemaining: deadline - Date.now()
});
if (!txt || !this.isCompleteCommandText(txt)) {
RC_DEBUG?.verbose('🟡 SETTLE: Command not complete yet', {
messageId,
hasText: !!txt,
isComplete: this.isCompleteCommandText(txt)
});
continue;
}
if (txt === last) {
RC_DEBUG?.trace('🟢 SETTLE: Text stable, continuing wait', { messageId });
} else {
RC_DEBUG?.info('🟡 SETTLE: Text changed, resetting deadline', {
messageId,
oldLength: last.length,
newLength: txt.length,
newDeadline: CONFIG.SETTLE_CHECK_MS
});
last = txt;
deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
}
}
// 🔧 FIX: Return the stable text we verified, not a new DOM lookup
RC_DEBUG?.info('🔵 SETTLE: Deadline reached, returning stable text', {
messageId,
stableTextLength: last.length,
stablePreview: last.substring(0, 100)
});
return last; // ← FIXED: Return what we verified as stable
}
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 buttons = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+]/.test(b.textContent || ''));
const b = buttons[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);
// 🔍 LOG: Parsed successfully
RC_DEBUG?.verbose('✅ PARSE: Success', {
messageId,
action: parsed.action,
textLength: message.originalText.length
});
} 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.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', {
action: 'Command',
details: `Validation failed: ${validation.errors.join(', ')}`
});
return;
}
// 3) Debounce
this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
const before = message.originalText;
// 🔍 LOG: Before debounce
RC_DEBUG?.info('⏳ DEBOUNCE: Starting wait', {
messageId,
delay: CONFIG.DEBOUNCE_DELAY,
textLength: before.length,
isAlreadyComplete: this.isCompleteCommandText(before)
});
await this.debounceWithCancel(messageId);
if (message.cancelToken?.cancelled) {
RC_DEBUG?.warn('Operation cancelled after debounce', { messageId });
return;
}
// 🔍 LOG: Before settle
RC_DEBUG?.info('🔵 Starting settle check', {
messageId,
beforeTextLength: before.length,
beforePreview: before.substring(0, 100)
});
const stable = await this.waitForStableCompleteBlock(message.element, before, messageId);
// 🔍 LOG: After settle - THIS IS THE KEY ONE
RC_DEBUG?.info('🔵 SETTLE: Returned from stability check', {
messageId,
beforeTextLength: before.length,
stableTextLength: stable.length,
stablePreview: stable.substring(0, 100),
isEmpty: !stable,
textChanged: stable !== before
});
if (!stable) {
// 🔍 LOG: This is where your error happens
RC_DEBUG?.error('❌ SETTLE: Returned empty string - FAILING', {
messageId,
originalTextLength: before.length,
originalPreview: before.substring(0, 100),
// This will help us understand WHY it's empty
elementStillExists: !!message.element,
elementHasCodeBlocks: message.element?.querySelectorAll('pre code, pre, code').length
});
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.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', {
action: 'Command',
details: `Final validation failed: ${reVal.errors.join(', ')}`
});
return;
}
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);
// Mark as done on element (belt-and-suspenders against fingerprint drift)
try {
const m = /#(\d+)$/.exec(messageId);
const subIndex1 = m ? Number(m[1]) : 1;
if (message?.element?.dataset) message.element.dataset[subDoneKey(subIndex1)] = '1';
} catch {}
} 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);
void 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();
// noinspection JSUnusedGlobalSymbols
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();
}
})();