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

1159 lines
43 KiB
JavaScript

// ==UserScript==
// @name AI Repo Commander
// @namespace http://tampermonkey.net/
// @version 1.3.4
// @description Execute ^%$bridge YAML commands from AI assistants in code blocks with persistent dedupe, robust paste, optional auto-submit, and a built-in debug console (safe manual-retry only)
// @author Your Name
// @match https://chat.openai.com/*
// @match https://chatgpt.com/*
// @match https://claude.ai/*
// @match https://gemini.google.com/*
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @grant GM_setClipboard
// @connect n8n.brrd.tech
// ==/UserScript==
(function () {
'use strict';
// ---------------------- Config ----------------------
const CONFIG = {
ENABLE_API: true, // Master kill switch (STOP API flips this to false)
DEBUG_MODE: true, // Global on/off for debug logging
DEBUG_LEVEL: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace
DEBUG_WATCH_MS: 120000, // Only log tight loop spam for the first 2 minutes
DEBUG_MAX_LINES: 400, // In-memory + panel lines
DEBUG_SHOW_PANEL: true, // Show floating debug console UI
DEBOUNCE_DELAY: 5000, // Bot typing protection
MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries)
VERSION: '1.3.4',
PROCESS_EXISTING: false, // If false, only process *new* messages (no initial rescan)
ASSISTANT_ONLY: true, // Process assistant messages by default (core use case)
// Persistent dedupe window
DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days
// Housekeeping
CLEANUP_AFTER_MS: 30000, // Drop COMPLETE/ERROR entries after 30s
CLEANUP_INTERVAL_MS: 60000, // Sweep cadence
// Paste + submit behavior
APPEND_TRAILING_NEWLINE: true, // Add '\n' after pasted text
AUTO_SUBMIT: true, // Try to submit after pasting content
POST_PASTE_DELAY_MS: 250, // Delay before submit to let editors settle
SUBMIT_MODE: 'button_first', // 'button_first' | 'enter_only' | 'smart'
// Runtime toggles (live-updated by the debug panel)
RUNTIME: {
PAUSED: false, // Pause scanning + execution via panel
}
};
// ---------------------- Debug Console ----------------------
let RC_DEBUG = null;
class DebugConsole {
constructor(cfg) {
this.cfg = cfg;
this.buf = [];
this.loopCounts = new Map();
this.startedAt = Date.now();
this.panel = null;
// loop-counts periodic cleanup to avoid unbounded growth
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})`);
}
// Levels: 1=ERROR, 2=WARN, 3=INFO, 4=VERB, 5=TRACE
error(msg, data) { this._log(1, 'ERROR', msg, data); }
warn(msg, data) { this._log(2, 'WARN', msg, data); }
info(msg, data) { this._log(3, 'INFO', msg, data); }
verbose(msg, data){ this._log(4, 'VERB', msg, data); }
trace(msg, data) { this._log(5, 'TRACE', msg, data); }
command(action, status, extra={}) {
const icon = { detected:'👁️', parsing:'📝', validating:'✓', debouncing:'⏳', executing:'⚙️', complete:'✅', error:'❌' }[status] || '•';
this.info(`${icon} ${action} [${status}]`, extra);
}
nowIso() { return new Date().toISOString(); }
withinWatch() { return Date.now() - this.startedAt <= this.cfg.DEBUG_WATCH_MS; }
// Loop/ticker messages → suppress after 10 repeats or after WATCH window
logLoop(kind, msg) {
const k = `${kind}:${msg}`;
const cur = this.loopCounts.get(k) || 0;
if (!this.withinWatch() && kind !== 'WARN') return;
if (cur >= 10) return;
this.loopCounts.set(k, cur + 1);
const suffix = (cur + 1) > 1 ? ` (${cur + 1}x)` : '';
// default to INFO (visible at level 2+)
if (kind === 'ERROR') this.error(`${msg}${suffix}`);
else if (kind === 'WARN') this.warn(`${msg}${suffix}`);
else this.info(`${msg}${suffix}`);
}
copyLast(n=50) {
const lines = this.buf.slice(-n).map(e => `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(lines).then(() => {
this.info(`Copied last ${Math.min(n, this.buf.length)} lines to clipboard`);
}).catch(e => this._fallbackCopy(lines, e));
} else {
this._fallbackCopy(lines);
}
}
_fallbackCopy(text, originalError = null) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) this.info(`Copied last ${text.split('\n').length} lines to clipboard (fallback)`);
else this.warn('Clipboard copy failed (fallback)');
} catch (e) {
this.warn('Clipboard copy failed', { error: originalError?.message || e.message });
}
}
setLevel(n) {
const lv = Math.max(0, Math.min(4, n)); // clamp 0..4
this.cfg.DEBUG_LEVEL = lv;
this.info(`Log level => ${lv}`);
}
_sanitize(data) {
if (!data) return null;
try {
if (data instanceof HTMLElement) return '[HTMLElement]';
if (typeof data === 'string' && data.length > 400) return data.slice(0,400)+'…';
if (typeof data === 'object') {
const clone = { ...data };
if (clone.element instanceof HTMLElement) clone.element = '[HTMLElement]';
return clone;
}
} catch {}
return data;
}
_log(numericLevel, levelName, msg, data) {
if (!this.cfg.DEBUG_MODE) return;
// Threshold map: 0=off, 1=ERROR, 2=+WARN+INFO, 3=+VERB, 4=+TRACE
const thresholdMap = { 0: 0, 1: 1, 2: 3, 3: 4, 4: 5 };
const threshold = thresholdMap[this.cfg.DEBUG_LEVEL] ?? 0;
if (numericLevel > threshold) return;
const entry = { ts: this.nowIso(), level: levelName, msg: String(msg), data: this._sanitize(data) };
this.buf.push(entry);
if (this.buf.length > this.cfg.DEBUG_MAX_LINES) this.buf.splice(0, this.buf.length - this.cfg.DEBUG_MAX_LINES);
// Keep console quiet unless verbose+ is enabled
if (this.cfg.DEBUG_LEVEL >= 3) {
const prefix = `[AI RC]`;
if (entry.data != null) console.log(prefix, entry.level, entry.msg, entry.data);
else console.log(prefix, entry.level, entry.msg);
}
if (this.panel) this._renderRow(entry);
}
mount() {
if (!document.body) {
setTimeout(() => this.mount(), 100);
return;
}
const root = document.createElement('div');
root.style.cssText = `
position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;
width: 420px; max-height: 45vh; display: flex; flex-direction: column;
background: rgba(20,20,24,0.92); border:1px solid #3b3b46; border-radius: 8px;
color:#e5e7eb; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
box-shadow: 0 16px 40px rgba(0,0,0,0.55); backdrop-filter: blur(4px);
`;
root.innerHTML = `
<div style="display:flex; gap:8px; align-items:center; padding:8px; border-bottom:1px solid #2c2c33">
<strong style="flex:1">AI Repo Commander — Debug</strong>
<label style="display:flex;align-items:center;gap:4px;">Level
<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 50</button>
<button class="rc-pause" title="Pause/resume scanning" style="padding:4px 6px;">Pause</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" style="overflow:auto; padding:8px; display:block; flex:1"></div>
`;
document.body.appendChild(root);
this.panel = root;
const sel = root.querySelector('.rc-level');
sel.value = String(this.cfg.DEBUG_LEVEL);
sel.addEventListener('change', () => this.setLevel(parseInt(sel.value,10)));
root.querySelector('.rc-copy').addEventListener('click', () => this.copyLast(50));
const pauseBtn = root.querySelector('.rc-pause');
pauseBtn.addEventListener('click', () => {
this.cfg.RUNTIME.PAUSED = !this.cfg.RUNTIME.PAUSED;
pauseBtn.textContent = this.cfg.RUNTIME.PAUSED ? 'Resume' : 'Pause';
pauseBtn.style.background = this.cfg.RUNTIME.PAUSED ? '#f59e0b' : '';
pauseBtn.style.color = this.cfg.RUNTIME.PAUSED ? '#111827' : '';
this.info(`Runtime ${this.cfg.RUNTIME.PAUSED ? 'paused' : 'resumed'}`);
});
root.querySelector('.rc-stop').addEventListener('click', () => {
window.AI_REPO_STOP?.();
this.warn('Emergency STOP activated');
});
}
_renderRow(e) {
const body = this.panel.querySelector('.rc-body');
const row = document.createElement('div');
row.style.cssText = 'padding:4px 0;border-bottom:1px dashed #2a2a34;white-space:pre-wrap;word-break:break-word;';
row.textContent = `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`;
body.appendChild(row);
while (body.children.length > this.cfg.DEBUG_MAX_LINES) body.firstChild.remove();
body.scrollTop = body.scrollHeight;
}
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' }
};
// ---------------------- Command requirements ----------------------
const REQUIRED_FIELDS = {
'update_file': ['action', 'repo', 'path', 'content'],
'get_file': ['action', 'repo', 'path'],
'create_repo': ['action', 'repo'],
'create_file': ['action', 'repo', 'path', 'content'],
'delete_file': ['action', 'repo', 'path'],
'list_files': ['action', 'repo', 'path']
};
const FIELD_VALIDATORS = {
repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v),
path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'),
action: (v) => Object.keys(REQUIRED_FIELDS).includes(v),
owner: (v) => !v || /^[\w\-]+$/.test(v),
url: (v) => !v || /^https?:\/\/.+\..+/.test(v)
};
const STATUS_TEMPLATES = {
SUCCESS: '[{action}: Success] {details}',
ERROR: '[{action}: Error] {details}',
VALIDATION_ERROR: '[{action}: Invalid] {details}',
EXECUTING: '[{action}: Processing...]',
MOCK: '[{action}: Mock] {details}'
};
const COMMAND_STATES = {
DETECTED: 'detected',
PARSING: 'parsing',
VALIDATING: 'validating',
DEBOUNCING: 'debouncing',
EXECUTING: 'executing',
COMPLETE: 'complete',
ERROR: 'error'
};
// ---------------------- Persistent Command History ----------------------
class CommandHistory {
constructor() {
this.key = 'ai_repo_commander_executed';
this.ttl = CONFIG.DEDUPE_TTL_MS;
this.cleanup();
}
_load() {
try { return JSON.parse(localStorage.getItem(this.key) || '{}'); }
catch { return {}; }
}
_save(db) { localStorage.setItem(this.key, JSON.stringify(db)); }
_hash(s) {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i);
return (h >>> 0).toString(36);
}
has(text) {
const db = this._load();
const k = this._hash(text);
const ts = db[k];
return !!ts && (Date.now() - ts) < this.ttl;
}
mark(text) {
const db = this._load();
db[this._hash(text)] = Date.now();
this._save(db);
}
unmark(text) { // manual retry only
const db = this._load();
const k = this._hash(text);
if (k in db) { delete db[k]; this._save(db); }
}
cleanup() {
const db = this._load();
const now = Date.now();
let dirty = false;
for (const [k, ts] of Object.entries(db)) {
if (!ts || (now - ts) >= this.ttl) { delete db[k]; dirty = true; }
}
if (dirty) this._save(db);
}
reset() { localStorage.removeItem(this.key); }
}
window.AI_REPO_CLEAR_HISTORY = () => localStorage.removeItem('ai_repo_commander_executed');
// ---------------------- UI feedback ----------------------
class UIFeedback {
static appendStatus(sourceElement, templateType, data) {
const statusElement = this.createStatusElement(templateType, data);
const existing = sourceElement.querySelector('.ai-repo-commander-status');
if (existing) existing.remove();
sourceElement.appendChild(statusElement);
}
static createStatusElement(templateType, data) {
const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR;
const message = template.replace('{action}', data.action).replace('{details}', data.details);
const el = document.createElement('div');
el.className = 'ai-repo-commander-status';
el.textContent = message;
el.style.cssText = `
padding: 8px 12px; margin: 10px 0; border-radius: 4px;
border-left: 4px solid ${this.color(templateType)};
background-color: rgba(255,255,255,0.08); font-family: monospace; font-size: 14px;
white-space: pre-wrap; word-wrap: break-word; border: 1px solid rgba(255,255,255,0.15);
`;
return el;
}
static color(t) {
const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' };
return c[t] || '#6B7280';
}
}
// ---------------------- Paste + Submit helpers ----------------------
function getVisibleInputCandidate() {
const candidates = [
'.ProseMirror#prompt-textarea',
'#prompt-textarea.ProseMirror',
'#prompt-textarea',
'.ProseMirror',
'[contenteditable="true"]',
'textarea'
];
for (const sel of candidates) {
const el = document.querySelector(sel);
if (!el) continue;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') continue;
if (el.offsetParent === null && style.position !== 'fixed') continue;
return el;
}
return null;
}
function findSendButton() {
const selectors = [
'button[data-testid="send-button"]',
'button[aria-label*="Send"]',
'button[aria-label*="send"]',
'button[aria-label*="Submit"]',
'button[aria-label*="submit"]',
'form button[type="submit"]'
];
for (const s of selectors) {
const btn = document.querySelector(s);
if (!btn) continue;
const style = window.getComputedStyle(btn);
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true';
if (style.display === 'none' || style.visibility === 'hidden') continue;
if (btn.offsetParent === null && style.position !== 'fixed') continue;
if (!disabled) return btn;
}
return null;
}
function pressEnterOn(el) {
const events = ['keydown','keypress','keyup'];
for (const type of events) {
const ok = el.dispatchEvent(new KeyboardEvent(type, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
if (!ok) return false;
}
return true;
}
async function submitComposer() {
try {
const btn = findSendButton();
if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) {
btn.click();
return true;
}
const el = getVisibleInputCandidate();
if (!el) return false;
return pressEnterOn(el);
} catch {
return false;
}
}
function pasteToComposer(text) {
try {
const el = getVisibleInputCandidate();
if (!el) {
GM_notification({ title: 'AI Repo Commander', text: 'No input box found to paste file content.', timeout: 4000 });
return false;
}
const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text;
el.focus();
// 1) ClipboardEvent paste
try {
const dt = new DataTransfer();
dt.setData('text/plain', payload);
const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
if (el.dispatchEvent(pasteEvt) && !pasteEvt.defaultPrevented) return true;
} catch (_) { /* continue */ }
// 2) execCommand insertText
try {
const sel = window.getSelection && window.getSelection();
if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) {
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(false);
sel.removeAllRanges();
sel.addRange(r);
}
if (document.execCommand && document.execCommand('insertText', false, payload)) return true;
} catch (_) { /* continue */ }
// 3) ProseMirror innerHTML
const isPM = el.classList && el.classList.contains('ProseMirror');
if (isPM) {
const escape = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const html = String(payload).split('\n').map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>').join('');
el.innerHTML = html;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
// 4) contenteditable/textarea fallback
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
el.value = payload;
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
el.textContent = payload;
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
// 5) Clipboard fallback
try {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' });
GM_notification({ title: 'AI Repo Commander', text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', timeout: 5000 });
}
} catch (_) {}
return false;
} catch (e) {
RC_DEBUG?.warn('pasteToComposer failed', { error: String(e) });
return false;
}
}
async function pasteAndMaybeSubmit(text) {
const pasted = pasteToComposer(text);
if (!pasted) return false;
if (!CONFIG.AUTO_SUBMIT) return true;
await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS);
const ok = await submitComposer();
if (!ok) {
GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 });
}
return true;
}
// ---------------------- Parser ----------------------
class CommandParser {
static parseYAMLCommand(codeBlockText) {
const block = this.extractCommandBlock(codeBlockText);
if (!block) throw new Error('No valid command block found');
const parsed = this.parseKeyValuePairs(block);
// defaults
parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge';
parsed.owner = parsed.owner || 'rob';
// expand owner/repo shorthand
if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) {
const [owner, repo] = parsed.repo.split('/', 2);
if (!parsed.owner) parsed.owner = owner;
parsed.repo = repo;
}
return parsed;
}
static extractCommandBlock(text) {
const patterns = [
/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m,
/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)(?=\n\s*$|\n---|\n```|$)/m,
/^\s*\^%\$bridge[ \t]*\n([\s\S]*)/m
];
for (const pattern of patterns) {
const match = text.match(pattern);
if (match && match[1]?.trim()) return match[1].trimEnd();
}
return null;
}
static parseKeyValuePairs(block) {
const lines = block.split('\n');
const result = {};
let currentKey = null;
let collecting = false;
let buf = [];
const TOP = ['action','repo','path','content','owner','url','commit_message','branch','ref'];
for (const raw of lines) {
const line = raw.replace(/\r$/, '');
if (collecting) {
const looksKey = /^[A-Za-z_][\w\-]*\s*:/.test(line);
const unindented = !/^[ \t]/.test(line);
const isTopKey = looksKey && unindented && TOP.some(k => line.startsWith(k + ':'));
if (isTopKey) {
result[currentKey] = buf.join('\n').trimEnd();
collecting = false; buf = [];
} else {
buf.push(line); continue;
}
}
const idx = line.indexOf(':');
if (idx !== -1) {
const key = line.slice(0, idx).trim();
let value = line.slice(idx + 1).trim();
if (value === '|') {
currentKey = key; collecting = true; buf = [];
} else if (value === '') {
currentKey = key; result[key] = '';
} else {
result[key] = value; currentKey = null;
}
} else if (currentKey && result[currentKey] === '') {
result[currentKey] += (result[currentKey] ? '\n' : '') + line.trimEnd();
}
}
if (collecting && currentKey) result[currentKey] = buf.join('\n').trimEnd();
return result;
}
static validateStructure(parsed) {
const errors = [];
const action = parsed.action;
if (!action) { errors.push('Missing required field: action'); return { isValid:false, errors }; }
const req = REQUIRED_FIELDS[action];
if (!req) { errors.push(`Unknown action: ${action}`); return { isValid:false, errors }; }
for (const f of req) if (!parsed[f] && parsed[f] !== '') errors.push(`Missing required field: ${f}`);
for (const [field, value] of Object.entries(parsed)) {
const validator = FIELD_VALIDATORS[field];
if (validator && !validator(value)) errors.push(`Invalid format for field: ${field}`);
}
return { isValid: errors.length === 0, errors };
}
}
// ---------------------- Execution ----------------------
class ExecutionManager {
static async executeCommand(command, sourceElement) {
try {
if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) {
command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`;
}
if (!CONFIG.ENABLE_API) return this.mockExecution(command, sourceElement);
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...' });
const res = await this.makeAPICallWithRetry(command);
return this.handleSuccess(res, command, sourceElement);
} catch (error) {
return this.handleError(error, command, sourceElement);
}
}
static async makeAPICallWithRetry(command, attempt = 0) {
try {
requireBridgeKeyIfNeeded();
return await this.makeAPICall(command);
} catch (err) {
if (attempt < CONFIG.MAX_RETRIES) {
await this.delay(1000 * (attempt + 1)); // 1s, 2s, ...
return this.makeAPICallWithRetry(command, attempt + 1);
}
const totalAttempts = attempt + 1;
throw new Error(`${err.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`);
}
}
static makeAPICall(command) {
return new Promise((resolve, reject) => {
const bridgeKey = requireBridgeKeyIfNeeded();
GM_xmlhttpRequest({
method: 'POST',
url: command.url,
headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' },
data: JSON.stringify(command),
timeout: 30000,
onload: (response) => (response.status >= 200 && response.status < 300)
? resolve(response)
: reject(new Error(`API Error ${response.status}: ${response.statusText}`)),
onerror: (error) => reject(new Error(`Network error: ${error}`)),
ontimeout: () => reject(new Error('API request timeout'))
});
});
}
static async mockExecution(command, sourceElement) {
await this.delay(1000);
const mock = {
status: 200,
responseText: JSON.stringify({
success: true,
message: `Mock execution completed for ${command.action}`,
data: { command: command.action, repo: command.repo, path: command.path, commit_message: command.commit_message }
})
};
return this.handleSuccess(mock, command, sourceElement, true);
}
static _extractGetFileBody(payload) {
const item = Array.isArray(payload) ? payload[0] : payload;
return (
item?.result?.content?.data ??
item?.content?.data ??
payload?.result?.content?.data ??
null
);
}
static _extractFilesArray(payload) {
const obj = Array.isArray(payload) ? payload[0] : payload;
let files = obj?.result?.files ?? obj?.files ?? null;
if (!files) {
const res = obj?.result;
if (res) {
for (const [k, v] of Object.entries(res)) {
if (Array.isArray(v) && v.length && (k.toLowerCase().includes('file') || typeof v[0] === 'string' || v[0]?.path || v[0]?.name)) {
files = v; break;
}
}
}
}
return Array.isArray(files) ? files : null;
}
static _formatFilesListing(files) {
const pickPath = (f) => {
if (typeof f === 'string') return f;
if (typeof f?.path === 'string') return f.path;
if (f?.dir && f?.name) return `${f.dir.replace(/\/+$/,'')}/${f.name}`;
if (f?.name) return f.name;
try { return JSON.stringify(f); } catch { return String(f); }
};
const lines = files.map(pickPath).filter(Boolean).sort();
return '```text\n' + lines.join('\n') + '\n```';
}
static async handleSuccess(response, command, sourceElement, isMock = false) {
let data;
try { data = JSON.parse(response.responseText || '{}'); }
catch { data = { message: 'Operation completed (no JSON body)' }; }
UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', {
action: command.action,
details: data.message || 'Operation completed successfully'
});
if (command.action === 'get_file') {
const body = this._extractGetFileBody(data);
if (typeof body === 'string' && body.length) {
await pasteAndMaybeSubmit(body);
} else {
GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 });
}
}
if (command.action === 'list_files') {
const files = this._extractFilesArray(data);
if (files && files.length) {
const listing = this._formatFilesListing(files);
await pasteAndMaybeSubmit(listing);
} else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
await pasteAndMaybeSubmit(fallback);
GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', timeout: 5000 });
}
}
return { success: true, data, isMock };
}
static handleError(error, command, sourceElement) {
UIFeedback.appendStatus(sourceElement, 'ERROR', {
action: command.action || 'Command',
details: error.message
});
return { success: false, error: error.message };
}
static delay(ms) { return new Promise(r => setTimeout(r, ms)); }
}
// ---------------------- Bridge Key ----------------------
let BRIDGE_KEY = null;
function requireBridgeKeyIfNeeded() {
if (CONFIG.ENABLE_API && !BRIDGE_KEY) {
BRIDGE_KEY = prompt('[AI Repo Commander] Enter your bridge key for this session:');
if (!BRIDGE_KEY) throw new Error('Bridge key required when API is enabled.');
}
return BRIDGE_KEY;
}
// ---------------------- Monitor ----------------------
class CommandMonitor {
constructor() {
this.trackedMessages = new Map(); // id -> { element, originalText, state, lastUpdate, startTime }
this.history = new CommandHistory();
this.observer = null;
this.currentPlatform = null;
this._idCounter = 0;
this.cleanupIntervalId = null;
this.initialize();
}
getReadableMessageId(element) {
this._idCounter += 1;
const id = `cmd-${this._idCounter}-${Math.random().toString(36).slice(2,6)}`;
if (element?.dataset) element.dataset.aiRcId = id;
return id;
}
extractAction(text) {
const m = /(^|\n)\s*action\s*:\s*([A-Za-z_][\w\-]*)/m.exec(text || '');
return m ? m[2] : 'unknown';
}
initialize() {
this.detectPlatform();
this.startObservation();
this.setupEmergencyStop();
RC_DEBUG?.info('AI Repo Commander initialized', {
ENABLE_API: CONFIG.ENABLE_API,
DEBUG_MODE: CONFIG.DEBUG_MODE,
DEBOUNCE_DELAY: CONFIG.DEBOUNCE_DELAY,
MAX_RETRIES: CONFIG.MAX_RETRIES,
VERSION: CONFIG.VERSION
});
if (CONFIG.ENABLE_API) {
RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.');
}
// store interval id so STOP can clear it
this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS);
}
detectPlatform() {
const host = window.location.hostname;
this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com'];
}
startObservation() {
// Throttled observer; only rescan if code blocks likely appeared
let scanPending = false;
const scheduleScan = () => {
if (scanPending) return;
scanPending = true;
setTimeout(() => { scanPending = false; this.scanMessages(); }, 100);
};
this.observer = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) {
scheduleScan();
break;
}
}
}
});
this.observer.observe(document.body, { childList: true, subtree: true });
// Respect PROCESS_EXISTING on initial scan (explicitly log skip)
if (CONFIG.PROCESS_EXISTING) {
setTimeout(() => {
RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)');
this.scanMessages();
}, 1000);
} else {
RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)');
}
}
scanMessages() {
if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.logLoop('loop', 'scan paused'); return; }
const messages = document.querySelectorAll(this.currentPlatform.messages);
let skipped = 0, found = 0;
messages.forEach((el) => {
if (!this.isAssistantMessage(el)) return;
if (el.dataset.aiRcProcessed) return;
const hit = this.findCommandInCodeBlock(el);
if (!hit) return;
const cmdText = hit.text;
if (this.history.has(cmdText)) {
el.dataset.aiRcProcessed = '1';
skipped++;
return;
}
el.dataset.aiRcProcessed = '1';
const id = this.getReadableMessageId(el);
this.trackMessage(el, cmdText, id);
found++;
});
if (skipped) RC_DEBUG?.logLoop('loop', `skipped already-executed (${skipped})`);
if (found) RC_DEBUG?.info(`Found ${found} new command(s)`);
}
isAssistantMessage(el) {
if (!CONFIG.ASSISTANT_ONLY) return true;
const host = location.hostname;
if (/chat\.openai\.com|chatgpt\.com/.test(host)) {
return !!el.closest?.('[data-message-author-role="assistant"]');
}
if (/claude\.ai/.test(host)) {
const isUser = !!el.closest?.('[data-message-author-role="user"]');
return !isUser;
}
if (/gemini\.google\.com/.test(host)) return true;
return true;
}
// **HARDENED**: require header + action: to avoid partials
findCommandInCodeBlock(el) {
const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) {
const txt = (b.textContent || '').trim();
if (/(^|\n)\s*\^%\$bridge\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt)) {
return { blockElement: b, text: txt };
}
}
return null;
}
getMessageId(element) {
// kept for compatibility
return this.getReadableMessageId(element);
}
reextractCommandText(element) {
const hit = this.findCommandInCodeBlock(element);
return hit ? hit.text : '';
}
trackMessage(element, text, messageId) {
RC_DEBUG?.info('New command detected', { messageId, preview: text.substring(0, 120) });
this.trackedMessages.set(messageId, {
element, originalText: text, state: COMMAND_STATES.DETECTED, startTime: Date.now(), lastUpdate: Date.now()
});
this.updateState(messageId, COMMAND_STATES.PARSING);
this.processCommand(messageId);
}
updateState(messageId, state) {
const msg = this.trackedMessages.get(messageId);
if (!msg) return;
const old = msg.state;
msg.state = state;
msg.lastUpdate = Date.now();
this.trackedMessages.set(messageId, msg);
RC_DEBUG?.command(this.extractAction(msg.originalText), state, {
messageId, transition: `${old} -> ${state}`
});
}
async processCommand(messageId) {
if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.info('process paused, skipping', { messageId }); return; }
const started = Date.now();
try {
const message = this.trackedMessages.get(messageId);
if (!message) { RC_DEBUG?.error('Message not found', { messageId }); return; }
// Parsing wrapped for clearer error classification
let parsed;
try {
parsed = CommandParser.parseYAMLCommand(message.originalText);
} catch (err) {
RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId });
this.updateState(messageId, COMMAND_STATES.ERROR);
// Ignore UI error for common partial/invalid cases
if (/No valid command block|Missing required field:|YAML parsing error/i.test(err.message)) return;
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message });
return;
}
this.updateState(messageId, COMMAND_STATES.VALIDATING);
let validation = CommandParser.validateStructure(parsed);
if (!validation.isValid) throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
const before = message.originalText;
await this.debounce();
const after = this.reextractCommandText(message.element);
if (!after) { this.updateState(messageId, COMMAND_STATES.ERROR); return; }
if (after !== before) {
RC_DEBUG?.info('Command changed during debounce (re-validate)', { messageId });
message.originalText = after;
await this.debounce();
const finalTxt = this.reextractCommandText(message.element);
if (!finalTxt) { this.updateState(messageId, COMMAND_STATES.ERROR); return; }
message.originalText = finalTxt;
parsed = CommandParser.parseYAMLCommand(finalTxt);
validation = CommandParser.validateStructure(parsed);
if (!validation.isValid) throw new Error(`Final validation failed: ${validation.errors.join(', ')}`);
}
// Pre-mark to avoid duplicate runs if DOM churns — stays marked even on failure
this.history.mark(message.originalText);
this.updateState(messageId, COMMAND_STATES.EXECUTING);
const result = await ExecutionManager.executeCommand(parsed, message.element);
// If execution reported failure, mark ERROR but do not unmark (no auto-retries).
if (!result || result.success === false) {
RC_DEBUG?.warn('Execution reported failure; command remains marked (no auto-retry)', { messageId });
this.updateState(messageId, COMMAND_STATES.ERROR);
return;
}
const duration = Date.now() - started;
if (duration < 50) RC_DEBUG?.warn('Command completed very fast', { messageId, duration });
if (duration > 60000) RC_DEBUG?.warn('Command took very long', { messageId, duration });
this.updateState(messageId, COMMAND_STATES.COMPLETE);
} catch (error) {
const duration = Date.now() - started;
RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration });
// DO NOT unmark — failed commands remain marked to prevent surprise retries
this.updateState(messageId, COMMAND_STATES.ERROR);
const message = this.trackedMessages.get(messageId);
// Silent ignore for non-commands/partials to avoid noisy inline errors
if (/No valid command block|Missing required field:\s*action/i.test(error.message)) return;
if (message) {
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message });
}
}
}
debounce() { return new Promise((r) => setTimeout(r, CONFIG.DEBOUNCE_DELAY)); }
cleanupProcessedCommands() {
const now = Date.now();
let count = 0;
for (const [id, msg] of this.trackedMessages.entries()) {
if ((msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR) &&
now - (msg.lastUpdate || now) > CONFIG.CLEANUP_AFTER_MS) {
this.trackedMessages.delete(id);
count++;
}
}
if (count) RC_DEBUG?.info(`Cleaned ${count} processed entries`);
}
stopAllProcessing() {
this.trackedMessages.clear();
if (this.observer) this.observer.disconnect();
if (this.cleanupIntervalId) {
clearInterval(this.cleanupIntervalId);
this.cleanupIntervalId = null;
}
RC_DEBUG?.destroy?.();
}
setupEmergencyStop() {
window.AI_REPO_STOP = () => {
// Critical: stop API + pause runtime + cancel inflight + clear interval
CONFIG.ENABLE_API = false;
CONFIG.RUNTIME.PAUSED = true;
for (const [id, msg] of this.trackedMessages.entries()) {
if (msg.state === COMMAND_STATES.EXECUTING || msg.state === COMMAND_STATES.DEBOUNCING) {
RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id });
this.updateState(id, COMMAND_STATES.ERROR);
}
}
this.stopAllProcessing();
RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨');
GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 });
};
}
log(...args) {
const [msg, data] = (typeof args[0] === 'string') ? [args[0], args[1]] : ['(log)', args];
RC_DEBUG?.verbose(msg, data);
}
}
// ---------------------- Manual retry helpers ----------------------
let commandMonitor; // forward ref
window.AI_REPO_RETRY_COMMAND_TEXT = (text) => {
try {
commandMonitor?.history?.unmark?.(text);
RC_DEBUG?.info('Command unmarked for manual retry (by text)', { preview: String(text).slice(0,120) });
} catch (e) {
RC_DEBUG?.error('Failed to unmark command by text', { error: String(e) });
}
};
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; }
commandMonitor.history.unmark(msg.originalText);
RC_DEBUG?.info('Message unmarked; reprocessing now', { messageId });
// re-run directly (ignores PROCESS_EXISTING and aiRcProcessed flag)
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
commandMonitor.processCommand(messageId);
} catch (e) {
RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) });
}
};
// ---------------------- Test commands (unchanged) ----------------------
const TEST_COMMANDS = {
validUpdate:
`\
\`\`\`yaml
^%$bridge
action: update_file
repo: test-repo
path: TEST.md
content: |
Test content
Multiple lines
---
\`\`\`
`,
getFile:
`\
\`\`\`yaml
^%$bridge
action: get_file
repo: test-repo
path: README.md
---
\`\`\`
`,
listFiles:
`\
\`\`\`yaml
^%$bridge
action: list_files
repo: test-repo
path: .
---
\`\`\`
`
};
// ---------------------- Init ----------------------
function initializeRepoCommander() {
if (!RC_DEBUG) RC_DEBUG = new DebugConsole(CONFIG);
if (!commandMonitor) {
commandMonitor = new CommandMonitor();
window.AI_REPO_COMMANDER = {
monitor: commandMonitor,
config: CONFIG,
test: TEST_COMMANDS,
version: CONFIG.VERSION,
history: commandMonitor.history,
submitComposer // expose for quick testing
};
RC_DEBUG?.info('AI Repo Commander fully initialized');
RC_DEBUG?.info('API Enabled:', { value: CONFIG.ENABLE_API });
RC_DEBUG?.info('Test commands available in window.AI_REPO_COMMANDER.test');
RC_DEBUG?.info('Reset history with window.AI_REPO_COMMANDER.history.reset() or AI_REPO_CLEAR_HISTORY()');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeRepoCommander);
} else {
initializeRepoCommander();
}
})();