2146 lines
77 KiB
JavaScript
2146 lines
77 KiB
JavaScript
// ==UserScript==
|
|
// @name AI Repo Commander
|
|
// @namespace http://tampermonkey.net/
|
|
// @version 1.5.1
|
|
// @description Execute @bridge@ YAML commands from AI assistants (safe & robust): complete-block detection, streaming-settle, persistent dedupe, paste+autosubmit, debug console with Tools/Settings, draggable/collapsible panel
|
|
// @author Your Name
|
|
// @match https://chat.openai.com/*
|
|
// @match https://chatgpt.com/*
|
|
// @match https://claude.ai/*
|
|
// @match https://gemini.google.com/*
|
|
// @grant GM_xmlhttpRequest
|
|
// @grant GM_notification
|
|
// @grant GM_setClipboard
|
|
// @connect n8n.brrd.tech
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// ---------------------- Storage keys ----------------------
|
|
const STORAGE_KEYS = {
|
|
history: 'ai_repo_commander_executed',
|
|
cfg: 'ai_repo_commander_cfg',
|
|
panel: 'ai_repo_commander_panel_state'
|
|
};
|
|
|
|
// ---------------------- Config (with persistence) ----------------------
|
|
const DEFAULT_CONFIG = {
|
|
ENABLE_API: true,
|
|
DEBUG_MODE: true,
|
|
DEBUG_LEVEL: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace
|
|
DEBUG_WATCH_MS: 120000,
|
|
DEBUG_MAX_LINES: 400,
|
|
DEBUG_SHOW_PANEL: true,
|
|
|
|
// Timing & API
|
|
DEBOUNCE_DELAY: 3000,
|
|
MAX_RETRIES: 2,
|
|
VERSION: '1.5.1',
|
|
API_TIMEOUT_MS: 60000,
|
|
|
|
PROCESS_EXISTING: false,
|
|
ASSISTANT_ONLY: true,
|
|
BRIDGE_KEY: '',
|
|
|
|
// Persistent dedupe window
|
|
DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
|
|
COLD_START_MS: 2000,
|
|
SHOW_EXECUTED_MARKER: true,
|
|
|
|
// Housekeeping
|
|
CLEANUP_AFTER_MS: 30000,
|
|
CLEANUP_INTERVAL_MS: 60000,
|
|
|
|
// Paste + submit behavior
|
|
APPEND_TRAILING_NEWLINE: true,
|
|
AUTO_SUBMIT: true,
|
|
POST_PASTE_DELAY_MS: 250,
|
|
SUBMIT_MODE: 'button_first',
|
|
|
|
// Streaming-complete hardening
|
|
REQUIRE_TERMINATOR: true,
|
|
SETTLE_CHECK_MS: 800,
|
|
SETTLE_POLL_MS: 200,
|
|
|
|
// Runtime toggles
|
|
RUNTIME: { PAUSED: false },
|
|
|
|
// New additions for hardening
|
|
STUCK_AFTER_MS: 10 * 60 * 1000,
|
|
SCAN_DEBOUNCE_MS: 250,
|
|
FAST_WARN_MS: 50,
|
|
SLOW_WARN_MS: 60_000,
|
|
};
|
|
|
|
function loadSavedConfig() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEYS.cfg);
|
|
if (!raw) return structuredClone(DEFAULT_CONFIG);
|
|
const saved = JSON.parse(raw);
|
|
const merged = { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } };
|
|
return merged;
|
|
} catch {
|
|
return structuredClone(DEFAULT_CONFIG);
|
|
}
|
|
}
|
|
function saveConfig(cfg) {
|
|
try { localStorage.setItem(STORAGE_KEYS.cfg, JSON.stringify(cfg)); } catch {}
|
|
}
|
|
|
|
const CONFIG = loadSavedConfig();
|
|
|
|
// ---------------------- Debug Console ----------------------
|
|
let RC_DEBUG = null;
|
|
|
|
class DebugConsole {
|
|
constructor(cfg) {
|
|
this.cfg = cfg;
|
|
this.buf = [];
|
|
this.loopCounts = new Map();
|
|
this.startedAt = Date.now();
|
|
this.panel = null;
|
|
this.bodyLogs = null;
|
|
this.bodyTools = null;
|
|
this.collapsed = false;
|
|
this.drag = { active: false, dx: 0, dy: 0 };
|
|
this.panelState = this._loadPanelState();
|
|
|
|
this.loopCleanupInterval = setInterval(() => {
|
|
if (Date.now() - this.startedAt > this.cfg.DEBUG_WATCH_MS * 2) {
|
|
this.loopCounts.clear();
|
|
this.startedAt = Date.now();
|
|
this.verbose('Cleaned loop counters');
|
|
}
|
|
}, this.cfg.DEBUG_WATCH_MS);
|
|
|
|
if (cfg.DEBUG_SHOW_PANEL) this.mount();
|
|
this.info(`Debug console ready (level=${cfg.DEBUG_LEVEL})`);
|
|
}
|
|
|
|
_loadPanelState() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(STORAGE_KEYS.panel) || '{}');
|
|
} catch { return {}; }
|
|
}
|
|
_savePanelState(partial) {
|
|
try {
|
|
const merged = { ...(this.panelState || {}), ...(partial || {}) };
|
|
this.panelState = merged;
|
|
localStorage.setItem(STORAGE_KEYS.panel, JSON.stringify(merged));
|
|
} catch {}
|
|
}
|
|
|
|
// Levels
|
|
error(msg, data) { this._log(1, 'ERROR', msg, data); }
|
|
warn(msg, data) { this._log(2, 'WARN', msg, data); }
|
|
info(msg, data) { this._log(3, 'INFO', msg, data); }
|
|
verbose(msg, data){ this._log(4, 'VERB', msg, data); }
|
|
trace(msg, data) { this._log(5, 'TRACE', msg, data); }
|
|
|
|
command(action, status, extra={}) {
|
|
const icon = { detected:'👁️', parsing:'📝', validating:'✓', debouncing:'⏳', executing:'⚙️', complete:'✅', error:'❌' }[status] || '•';
|
|
this.info(`${icon} ${action} [${status}]`, extra);
|
|
}
|
|
|
|
nowIso() { return new Date().toISOString(); }
|
|
withinWatch() { return Date.now() - this.startedAt <= this.cfg.DEBUG_WATCH_MS; }
|
|
|
|
logLoop(kind, msg) {
|
|
const k = `${kind}:${msg}`;
|
|
const cur = this.loopCounts.get(k) || 0;
|
|
if (!this.withinWatch() && kind !== 'WARN') return;
|
|
if (cur >= 10) return;
|
|
this.loopCounts.set(k, cur + 1);
|
|
const suffix = (cur + 1) > 1 ? ` (${cur + 1}x)` : '';
|
|
if (kind === 'ERROR') this.error(`${msg}${suffix}`);
|
|
else if (kind === 'WARN') this.warn(`${msg}${suffix}`);
|
|
else this.info(`${msg}${suffix}`);
|
|
}
|
|
|
|
copyLast(n=50) {
|
|
const lines = this.buf.slice(-n).map(e => `${e.ts} ${e.level.padEnd(5)} ${e.msg}${e.data? ' ' + JSON.stringify(e.data): ''}`).join('\n');
|
|
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(lines).then(() => {
|
|
this.info(`Copied last ${Math.min(n, this.buf.length)} lines to clipboard`);
|
|
}).catch(e => this._fallbackCopy(lines, e));
|
|
} else {
|
|
this._fallbackCopy(lines);
|
|
}
|
|
}
|
|
|
|
_fallbackCopy(text, originalError = null) {
|
|
try {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.focus();
|
|
ta.select();
|
|
const ok = document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
if (ok) this.info(`Copied last ${text.split('\n').length} lines to clipboard (fallback)`);
|
|
else this.warn('Clipboard copy failed (fallback)');
|
|
} catch (e) {
|
|
this.warn('Clipboard copy failed', { error: originalError?.message || e.message });
|
|
}
|
|
}
|
|
|
|
setLevel(n) {
|
|
const lv = Math.max(0, Math.min(4, n));
|
|
this.cfg.DEBUG_LEVEL = lv;
|
|
saveConfig(this.cfg);
|
|
this.info(`Log level => ${lv}`);
|
|
}
|
|
|
|
_sanitize(data) {
|
|
if (!data) return null;
|
|
try {
|
|
if (data instanceof HTMLElement) return '[HTMLElement]';
|
|
if (typeof data === 'string' && data.length > 400) return data.slice(0,400)+'…';
|
|
if (typeof data === 'object') {
|
|
const clone = { ...data };
|
|
if (clone.element instanceof HTMLElement) clone.element = '[HTMLElement]';
|
|
return clone;
|
|
}
|
|
} catch {}
|
|
return data;
|
|
}
|
|
|
|
_log(numericLevel, levelName, msg, data) {
|
|
if (!this.cfg.DEBUG_MODE) return;
|
|
const thresholdMap = { 0: 0, 1: 1, 2: 3, 3: 4, 4: 5 };
|
|
const threshold = thresholdMap[this.cfg.DEBUG_LEVEL] ?? 0;
|
|
if (numericLevel > threshold) return;
|
|
|
|
const entry = { ts: this.nowIso(), level: levelName, msg: String(msg), data: this._sanitize(data) };
|
|
this.buf.push(entry);
|
|
if (this.buf.length > this.cfg.DEBUG_MAX_LINES) this.buf.splice(0, this.buf.length - this.cfg.DEBUG_MAX_LINES);
|
|
if (this.cfg.DEBUG_LEVEL >= 3) {
|
|
const prefix = `[AI RC]`;
|
|
if (entry.data != null) console.log(prefix, entry.level, entry.msg, entry.data);
|
|
else console.log(prefix, entry.level, entry.msg);
|
|
}
|
|
if (this.panel) this._renderRow(entry);
|
|
}
|
|
|
|
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-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;">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', () => this.copyLast(50));
|
|
root.querySelector('.rc-copy-200').addEventListener('click', () => this.copyLast(200));
|
|
|
|
const pauseBtn = root.querySelector('.rc-pause');
|
|
pauseBtn.addEventListener('click', () => {
|
|
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.info(`Runtime ${this.cfg.RUNTIME.PAUSED ? 'paused' : 'resumed'}`);
|
|
});
|
|
|
|
root.querySelector('.rc-stop').addEventListener('click', () => {
|
|
window.AI_REPO_STOP?.();
|
|
this.warn('Emergency STOP activated');
|
|
});
|
|
|
|
// Tabs
|
|
const tabLogs = root.querySelector('.rc-tab-logs');
|
|
const tabTools = root.querySelector('.rc-tab-tools');
|
|
const selectTab = (tools=false) => {
|
|
this.bodyLogs.style.display = tools ? 'none' : 'block';
|
|
this.bodyTools.style.display = tools ? 'block' : 'none';
|
|
tabLogs.style.background = tools ? '#111827' : '#1f2937';
|
|
tabTools.style.background = tools ? '#1f2937' : '#111827';
|
|
};
|
|
tabLogs.addEventListener('click', () => selectTab(false));
|
|
|
|
tabTools.addEventListener('click', () => {
|
|
selectTab(true);
|
|
root.querySelectorAll('.rc-toggle').forEach(inp => {
|
|
const key = inp.dataset.key;
|
|
inp.checked = !!this.cfg[key];
|
|
});
|
|
root.querySelectorAll('.rc-num').forEach(inp => {
|
|
inp.value = String(this.cfg[inp.dataset.key] ?? '');
|
|
});
|
|
|
|
const dump = JSON.parse(JSON.stringify(this.cfg));
|
|
if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8);
|
|
root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2);
|
|
|
|
const bridgeKeyInput = root.querySelector('.rc-bridge-key');
|
|
if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : '';
|
|
});
|
|
|
|
// Collapse
|
|
const collapseBtn = root.querySelector('.rc-collapse');
|
|
const setCollapsed = (c) => {
|
|
this.collapsed = c;
|
|
this.bodyLogs.style.display = c ? 'none' : 'block';
|
|
this.bodyTools.style.display = 'none';
|
|
collapseBtn.textContent = c ? '▸' : '▾';
|
|
this._savePanelState({ collapsed: c });
|
|
};
|
|
setCollapsed(!!this.panelState.collapsed);
|
|
collapseBtn.addEventListener('click', () => setCollapsed(!this.collapsed));
|
|
|
|
// Dragging
|
|
const header = root.querySelector('.rc-header');
|
|
header.addEventListener('mousedown', (e) => {
|
|
if ((e.target).closest('button,select,input,textarea,label')) return;
|
|
this.drag.active = true;
|
|
const rect = root.getBoundingClientRect();
|
|
this.drag.dx = e.clientX - rect.left;
|
|
this.drag.dy = e.clientY - rect.top;
|
|
root.style.right = 'auto'; root.style.bottom = 'auto';
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
});
|
|
const onMove = (e) => {
|
|
if (!this.drag.active) return;
|
|
const x = Math.max(0, Math.min(window.innerWidth - this.panel.offsetWidth, e.clientX - this.drag.dx));
|
|
const y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - this.drag.dy));
|
|
this.panel.style.left = `${x}px`;
|
|
this.panel.style.top = `${y}px`;
|
|
};
|
|
const onUp = () => {
|
|
if (!this.drag.active) return;
|
|
this.drag.active = false;
|
|
this._savePanelState({ left: parseInt(this.panel.style.left||'0',10), top: parseInt(this.panel.style.top||'0',10) });
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
|
|
// Tools: Clear History
|
|
root.querySelector('.rc-clear-history').addEventListener('click', () => {
|
|
try {
|
|
commandMonitor?.history?.resetAll?.();
|
|
RC_DEBUG?.info('Conversation history cleared');
|
|
GM_notification({ title: 'AI Repo Commander', text: 'This conversation\'s execution marks cleared', timeout: 2500 });
|
|
} catch {
|
|
localStorage.removeItem(STORAGE_KEYS.history);
|
|
RC_DEBUG?.info('Legacy history key cleared');
|
|
}
|
|
});
|
|
|
|
// Tools: toggles & numbers
|
|
root.querySelectorAll('.rc-toggle').forEach(inp => {
|
|
const key = inp.dataset.key;
|
|
inp.checked = !!this.cfg[key];
|
|
inp.addEventListener('change', () => {
|
|
this.cfg[key] = !!inp.checked;
|
|
saveConfig(this.cfg);
|
|
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.info(`Config ${inp.dataset.key} => ${v}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Tools: JSON input
|
|
root.querySelector('.rc-save-json').addEventListener('click', () => {
|
|
try {
|
|
const raw = root.querySelector('.rc-json').value;
|
|
const parsed = JSON.parse(raw);
|
|
|
|
if (Object.prototype.hasOwnProperty.call(parsed, 'BRIDGE_KEY')) {
|
|
const v = (parsed.BRIDGE_KEY ?? '').toString().trim();
|
|
if (v && !/^•+$/.test(v)) {
|
|
this.cfg.BRIDGE_KEY = v;
|
|
BRIDGE_KEY = v;
|
|
}
|
|
delete parsed.BRIDGE_KEY;
|
|
}
|
|
|
|
Object.assign(this.cfg, parsed);
|
|
saveConfig(this.cfg);
|
|
|
|
const dump = JSON.parse(JSON.stringify(this.cfg));
|
|
if (dump.BRIDGE_KEY) dump.BRIDGE_KEY = '•'.repeat(8);
|
|
root.querySelector('.rc-json').value = JSON.stringify(dump, null, 2);
|
|
|
|
this.info('Config JSON saved');
|
|
} catch (e) {
|
|
this.warn('Invalid JSON in config textarea', { error: String(e) });
|
|
}
|
|
});
|
|
|
|
root.querySelector('.rc-reset-defaults').addEventListener('click', () => {
|
|
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.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', () => {
|
|
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.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', () => {
|
|
this.cfg.BRIDGE_KEY = '';
|
|
bridgeKeyInput.value = '';
|
|
saveConfig(this.cfg);
|
|
BRIDGE_KEY = null;
|
|
this.info('Bridge key cleared');
|
|
GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 });
|
|
});
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
destroy() {
|
|
try { clearInterval(this.loopCleanupInterval); } catch {}
|
|
if (this.panel && this.panel.parentNode) this.panel.parentNode.removeChild(this.panel);
|
|
}
|
|
}
|
|
|
|
// ---------------------- Platform selectors ----------------------
|
|
const PLATFORM_SELECTORS = {
|
|
'chat.openai.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' },
|
|
'chatgpt.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' },
|
|
'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' },
|
|
'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' }
|
|
};
|
|
|
|
// ---------------------- Fingerprinting helpers (portable) ----------------------
|
|
const MSG_SELECTORS = [
|
|
'[data-message-author-role]', // ChatGPT/OpenAI
|
|
'.chat-message', // Claude
|
|
'.message-content' // Gemini
|
|
];
|
|
|
|
// Consistent DJB2 variant
|
|
function _hash(s) {
|
|
let h = 5381;
|
|
for (let i = 0; i < s.length; i++) h = ((h << 5) + h) ^ s.charCodeAt(i);
|
|
return (h >>> 0).toString(36);
|
|
}
|
|
|
|
// Normalize text a bit to reduce spurious diffs
|
|
function _norm(s) {
|
|
return (s || '')
|
|
.replace(/\r/g, '')
|
|
.replace(/\u200b/g, '') // zero-width
|
|
.replace(/[ \t]+\n/g, '\n') // trailing ws
|
|
.trim();
|
|
}
|
|
|
|
// Extract the *command block* if present; else fall back to element text
|
|
function _commandishText(el) {
|
|
// Mirror parser's detector: require header, action, and '@end@'
|
|
const blocks = el.querySelectorAll('pre code, pre, code');
|
|
for (const b of blocks) {
|
|
const t = _norm(b.textContent || '');
|
|
if (/@end@\s*$/m.test(t) && /(^|\n)\s*@bridge@\b/m.test(t) && /(^|\n)\s*action\s*:/m.test(t)) {
|
|
return t;
|
|
}
|
|
}
|
|
// No command block found; use element text (capped)
|
|
return _norm((el.textContent || '').slice(0, 2000));
|
|
}
|
|
|
|
// Hash of the command (or element text) capped to 2000 chars
|
|
function _hashCommand(el) {
|
|
const t = _commandishText(el);
|
|
return _hash(t.slice(0, 2000));
|
|
}
|
|
|
|
// Hash of the *preceding context*: concatenate previous messages' text until ~2000 chars
|
|
function _hashPrevContext(el) {
|
|
const all = Array.from(document.querySelectorAll(MSG_SELECTORS.join(',')));
|
|
const idx = all.indexOf(el);
|
|
if (idx <= 0) return '0';
|
|
|
|
let remaining = 2000;
|
|
let buf = '';
|
|
for (let i = idx - 1; i >= 0 && remaining > 0; i--) {
|
|
const t = _norm(all[i].textContent || '');
|
|
if (!t) continue;
|
|
// Prepend last slice so the nearest history weighs most
|
|
const take = t.slice(-remaining);
|
|
buf = take + buf;
|
|
remaining -= take.length;
|
|
}
|
|
// Hash only the *last* 2000 chars of the collected buffer (stable, compact)
|
|
return _hash(buf.slice(-2000));
|
|
}
|
|
|
|
// Ordinal among messages that share the same (commandHash, prevCtxHash)
|
|
function _ordinalForKey(el, key) {
|
|
const list = Array.from(document.querySelectorAll(MSG_SELECTORS.join(',')));
|
|
let n = 0;
|
|
for (const node of list) {
|
|
const nodeKey = node === el
|
|
? key
|
|
: (() => {
|
|
// Compute on the fly only if needed
|
|
const ch = _hashCommand(node);
|
|
const ph = _hashPrevContext(node);
|
|
return `ch:${ch}|ph:${ph}`;
|
|
})();
|
|
if (nodeKey === key) n++;
|
|
if (node === el) return n; // 1-based ordinal
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
// DOM path hint for extra fingerprint stability
|
|
function _domHint(node) {
|
|
const p = [];
|
|
let n = node;
|
|
for (let i = 0; n && i < 4; i++) { // last 4 ancestors
|
|
const tag = (n.tagName || '').toLowerCase();
|
|
const cls = (n.className || '').toString().split(/\s+/).slice(0, 2).join('.');
|
|
p.push(tag + (cls ? '.' + cls : ''));
|
|
n = n.parentElement;
|
|
}
|
|
return p.join('>');
|
|
}
|
|
|
|
// Main fingerprint function
|
|
function fingerprintElement(el) {
|
|
// Always use content-based fingerprinting for reliability across reloads
|
|
const ch = _hashCommand(el);
|
|
const ph = _hashPrevContext(el);
|
|
const dh = _hash(_domHint(el));
|
|
const key = `ch:${ch}|ph:${ph}`;
|
|
const n = _ordinalForKey(el, key);
|
|
const fingerprint = `${key}|hint:${dh}|n:${n}`;
|
|
|
|
RC_DEBUG?.trace('Generated fingerprint', {
|
|
fingerprint: fingerprint.slice(0, 60) + '...',
|
|
commandHash: ch,
|
|
prevContextHash: ph,
|
|
domHint: dh,
|
|
ordinal: n
|
|
});
|
|
|
|
return fingerprint;
|
|
}
|
|
|
|
// ---------------------- Conversation-Aware Element History ----------------------
|
|
function getConversationId() {
|
|
const host = location.hostname.replace('chat.openai.com', 'chatgpt.com'); // normalize
|
|
|
|
// ChatGPT
|
|
if (/chatgpt\.com/.test(host)) {
|
|
// Try GPT-specific conversation path first: /g/[gpt-id]/c/[conv-id]
|
|
const gptMatch = location.pathname.match(/\/g\/[^/]+\/c\/([a-f0-9-]+)/i);
|
|
if (gptMatch?.[1]) {
|
|
RC_DEBUG?.verbose('Conversation ID from GPT URL', { id: gptMatch[1].slice(0, 12) + '...' });
|
|
return `chatgpt:${gptMatch[1]}`;
|
|
}
|
|
|
|
// Regular conversation path: /c/[conv-id]
|
|
const m = location.pathname.match(/\/c\/([a-f0-9-]+)/i);
|
|
if (m?.[1]) {
|
|
RC_DEBUG?.verbose('Conversation ID from URL path', { id: m[1].slice(0, 12) + '...' });
|
|
return `chatgpt:${m[1]}`;
|
|
}
|
|
|
|
// Try embedded page state (best-effort)
|
|
try {
|
|
const scripts = document.querySelectorAll('script[type="application/json"]');
|
|
for (const s of scripts) {
|
|
const t = s.textContent || '';
|
|
const j = /"conversationId":"([a-f0-9-]+)"/i.exec(t);
|
|
if (j?.[1]) {
|
|
RC_DEBUG?.verbose('Conversation ID from page state', { id: j[1].slice(0, 12) + '...' });
|
|
return `chatgpt:${j[1]}`;
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return `chatgpt:${location.pathname || '/'}`;
|
|
}
|
|
|
|
// Claude
|
|
if (/claude\.ai/.test(host)) {
|
|
const m1 = location.pathname.match(/\/chat\/([^/]+)/i);
|
|
const m2 = location.pathname.match(/\/thread\/([^/]+)/i);
|
|
const id = (m1?.[1] || m2?.[1]);
|
|
if (id) {
|
|
RC_DEBUG?.verbose('Conversation ID (Claude)', { id: id.slice(0, 12) + '...' });
|
|
}
|
|
return `claude:${id || (location.pathname || '/')}`;
|
|
}
|
|
|
|
// Gemini / others
|
|
const generic = `${host}:${location.pathname || '/'}`;
|
|
RC_DEBUG?.verbose('Conversation ID (generic)', { id: generic });
|
|
return generic;
|
|
}
|
|
|
|
// ---------------------- Command requirements ----------------------
|
|
const REQUIRED_FIELDS = {
|
|
'get_file': ['action', 'repo', 'path'],
|
|
'update_file': ['action', 'repo', 'path', 'content'],
|
|
'create_file': ['action', 'repo', 'path', 'content'],
|
|
'delete_file': ['action', 'repo', 'path'],
|
|
'list_files': ['action', 'repo', 'path'],
|
|
'create_repo': ['action', 'repo'],
|
|
'create_branch': ['action', 'repo', 'branch'],
|
|
'create_pr': ['action', 'repo', 'title', 'head', 'base'],
|
|
'merge_pr': ['action', 'repo', 'pr_number'],
|
|
'close_pr': ['action', 'repo', 'pr_number'],
|
|
'create_issue': ['action', 'repo', 'title'],
|
|
'comment_issue': ['action', 'repo', 'issue_number', 'body'],
|
|
'close_issue': ['action', 'repo', 'issue_number'],
|
|
'rollback': ['action', 'repo', 'commit_sha'],
|
|
'create_tag': ['action', 'repo', 'tag', 'target'],
|
|
'create_release': ['action', 'repo', 'tag_name', 'name']
|
|
};
|
|
|
|
const FIELD_VALIDATORS = {
|
|
repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v),
|
|
path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'),
|
|
action: (v) => Object.keys(REQUIRED_FIELDS).includes(v),
|
|
owner: (v) => !v || /^[\w\-]+$/.test(v),
|
|
url: (v) => !v || /^https?:\/\/.+\..+/.test(v),
|
|
branch: (v) => v && v.length > 0 && !v.includes('..'),
|
|
source_branch:(v) => !v || (v.length > 0 && !v.includes('..')),
|
|
head: (v) => v && v.length > 0,
|
|
base: (v) => v && v.length > 0,
|
|
pr_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0,
|
|
issue_number: (v) => !isNaN(parseInt(v)) && parseInt(v) > 0,
|
|
commit_sha: (v) => /^[a-f0-9]{7,40}$/i.test(v),
|
|
tag: (v) => v && v.length > 0 && !v.includes(' '),
|
|
tag_name: (v) => v && v.length > 0 && !v.includes(' '),
|
|
target: (v) => v && v.length > 0,
|
|
title: (v) => v && v.length > 0,
|
|
name: (v) => v && v.length > 0,
|
|
body: (v) => typeof v === 'string',
|
|
message: (v) => typeof v === 'string'
|
|
};
|
|
|
|
const STATUS_TEMPLATES = {
|
|
SUCCESS: '[{action}: Success] {details}',
|
|
ERROR: '[{action}: Error] {details}',
|
|
VALIDATION_ERROR: '[{action}: Invalid] {details}',
|
|
EXECUTING: '[{action}: Processing...]',
|
|
MOCK: '[{action}: Mock] {details}'
|
|
};
|
|
|
|
const COMMAND_STATES = {
|
|
DETECTED: 'detected',
|
|
PARSING: 'parsing',
|
|
VALIDATING: 'validating',
|
|
DEBOUNCING: 'debouncing',
|
|
EXECUTING: 'executing',
|
|
COMPLETE: 'complete',
|
|
ERROR: 'error'
|
|
};
|
|
|
|
// ---------------------- Persistent Command History ----------------------
|
|
class ConvHistory {
|
|
constructor() {
|
|
this.convId = getConversationId();
|
|
this.key = `ai_rc:conv:${this.convId}:processed`;
|
|
this.session = new Set();
|
|
this.cache = this._load();
|
|
this._cleanupTTL();
|
|
|
|
RC_DEBUG?.info('ConvHistory initialized', {
|
|
convId: this.convId.slice(0, 50) + (this.convId.length > 50 ? '...' : ''),
|
|
cachedCount: Object.keys(this.cache).length
|
|
});
|
|
}
|
|
|
|
_load() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(this.key) || '{}');
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
_save() {
|
|
try {
|
|
localStorage.setItem(this.key, JSON.stringify(this.cache));
|
|
} catch {}
|
|
}
|
|
|
|
_cleanupTTL() {
|
|
const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000);
|
|
const now = Date.now();
|
|
let dirty = false;
|
|
|
|
for (const [fp, ts] of Object.entries(this.cache)) {
|
|
if (!ts || (now - ts) > ttl) {
|
|
delete this.cache[fp];
|
|
dirty = true;
|
|
}
|
|
}
|
|
|
|
if (dirty) {
|
|
this._save();
|
|
RC_DEBUG?.verbose('Cleaned expired fingerprints from cache');
|
|
}
|
|
}
|
|
|
|
hasElement(el) {
|
|
const fp = fingerprintElement(el);
|
|
const result = this.session.has(fp) || (fp in this.cache);
|
|
|
|
if (result && CONFIG.DEBUG_LEVEL >= 4) {
|
|
RC_DEBUG?.trace('Element already processed', {
|
|
fingerprint: fp.slice(0, 60) + '...',
|
|
inSession: this.session.has(fp),
|
|
inCache: fp in this.cache
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
markElement(el) {
|
|
const fp = fingerprintElement(el);
|
|
this.session.add(fp);
|
|
this.cache[fp] = Date.now();
|
|
this._save();
|
|
|
|
RC_DEBUG?.verbose('Marked element as processed', {
|
|
fingerprint: fp.slice(0, 60) + '...'
|
|
});
|
|
|
|
if (CONFIG.SHOW_EXECUTED_MARKER) {
|
|
try {
|
|
el.style.borderLeft = '3px solid #10B981';
|
|
el.title = 'Command executed — use "Run again" to re-run';
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
unmarkElement(el) {
|
|
const fp = fingerprintElement(el);
|
|
this.session.delete(fp);
|
|
if (fp in this.cache) {
|
|
delete this.cache[fp];
|
|
this._save();
|
|
}
|
|
|
|
RC_DEBUG?.verbose('Unmarked element', {
|
|
fingerprint: fp.slice(0, 60) + '...'
|
|
});
|
|
}
|
|
|
|
resetAll() {
|
|
this.session.clear();
|
|
localStorage.removeItem(this.key);
|
|
this.cache = {};
|
|
RC_DEBUG?.info('All conversation history cleared');
|
|
}
|
|
}
|
|
|
|
// Global helpers (stable)
|
|
window.AI_REPO = {
|
|
clearHistory: () => {
|
|
try { commandMonitor?.history?.resetAll?.(); } catch {}
|
|
localStorage.removeItem(STORAGE_KEYS.history); // legacy
|
|
},
|
|
getConfig: () => structuredClone(CONFIG),
|
|
setConfig: (patch) => { Object.assign(CONFIG, patch||{}); saveConfig(CONFIG); },
|
|
};
|
|
|
|
function attachRunAgainUI(containerEl, onRun) {
|
|
if (containerEl.querySelector('.ai-rc-rerun')) return;
|
|
const bar = document.createElement('div');
|
|
bar.className = 'ai-rc-rerun';
|
|
bar.style.cssText = 'margin:8px 0; display:flex; gap:8px; align-items:center;';
|
|
const msg = document.createElement('span');
|
|
msg.textContent = 'Already executed.';
|
|
msg.style.cssText = 'flex:1; font-size:13px; opacity:.9;';
|
|
const run = document.createElement('button');
|
|
run.textContent = 'Run again';
|
|
run.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#1f2937; color:#e5e7eb;';
|
|
const dismiss = document.createElement('button');
|
|
dismiss.textContent = 'Dismiss';
|
|
dismiss.style.cssText = 'padding:4px 10px; border:1px solid #374151; border-radius:4px; background:#111827; color:#9ca3af;';
|
|
run.onclick = onRun;
|
|
dismiss.onclick = () => bar.remove();
|
|
bar.append(msg, run, dismiss);
|
|
containerEl.appendChild(bar);
|
|
}
|
|
|
|
// ---------------------- UI feedback ----------------------
|
|
class UIFeedback {
|
|
static appendStatus(sourceElement, templateType, data) {
|
|
const statusElement = this.createStatusElement(templateType, data);
|
|
const existing = sourceElement.querySelector('.ai-repo-commander-status');
|
|
if (existing) existing.remove();
|
|
sourceElement.appendChild(statusElement);
|
|
}
|
|
static createStatusElement(templateType, data) {
|
|
const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR;
|
|
const message = template.replace('{action}', data.action).replace('{details}', data.details);
|
|
const el = document.createElement('div');
|
|
el.className = 'ai-repo-commander-status';
|
|
el.textContent = message;
|
|
el.style.cssText = `
|
|
padding: 8px 12px; margin: 10px 0; border-radius: 4px;
|
|
border-left: 4px solid ${this.color(templateType)};
|
|
background-color: rgba(255,255,255,0.08); font-family: monospace; font-size: 14px;
|
|
white-space: pre-wrap; word-wrap: break-word; border: 1px solid rgba(255,255,255,0.15);
|
|
`;
|
|
return el;
|
|
}
|
|
static color(t) {
|
|
const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' };
|
|
return c[t] || '#6B7280';
|
|
}
|
|
}
|
|
|
|
// ---------------------- Paste + Submit helpers ----------------------
|
|
function getVisibleInputCandidate() {
|
|
const candidates = [
|
|
'.ProseMirror#prompt-textarea',
|
|
'#prompt-textarea.ProseMirror',
|
|
'#prompt-textarea',
|
|
'.ProseMirror',
|
|
'[contenteditable="true"]',
|
|
'textarea'
|
|
];
|
|
for (const sel of candidates) {
|
|
const el = document.querySelector(sel);
|
|
if (!el) continue;
|
|
const style = window.getComputedStyle(el);
|
|
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
if (el.offsetParent === null && style.position !== 'fixed') continue;
|
|
return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findSendButton() {
|
|
const selectors = [
|
|
'button[data-testid="send-button"]',
|
|
'button[aria-label*="Send"]',
|
|
'button[aria-label*="send"]',
|
|
'button[aria-label*="Submit"]',
|
|
'button[aria-label*="submit"]',
|
|
'form button[type="submit"]'
|
|
];
|
|
for (const s of selectors) {
|
|
const btn = document.querySelector(s);
|
|
if (!btn) continue;
|
|
const style = window.getComputedStyle(btn);
|
|
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true';
|
|
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
if (btn.offsetParent === null && style.position !== 'fixed') continue;
|
|
if (!disabled) return btn;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function pressEnterOn(el) {
|
|
const events = ['keydown','keypress','keyup'];
|
|
for (const type of events) {
|
|
const ok = el.dispatchEvent(new KeyboardEvent(type, {
|
|
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true
|
|
}));
|
|
if (!ok) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function submitComposer() {
|
|
try {
|
|
const btn = findSendButton();
|
|
if (CONFIG.SUBMIT_MODE !== 'enter_only' && btn) { btn.click(); return true; }
|
|
const el = getVisibleInputCandidate();
|
|
if (!el) return false;
|
|
return pressEnterOn(el);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pasteToComposer(text) {
|
|
RC_DEBUG?.info('🔵 pasteToComposer CALLED', { textLength: text.length, preview: text.substring(0, 100) });
|
|
|
|
try {
|
|
const el = getVisibleInputCandidate();
|
|
if (!el) {
|
|
RC_DEBUG?.warn('❌ No input element found');
|
|
GM_notification({ title: 'AI Repo Commander', text: 'No input box found to paste file content.', timeout: 4000 });
|
|
return false;
|
|
}
|
|
|
|
RC_DEBUG?.verbose('Found input element', {
|
|
tagName: el.tagName,
|
|
classList: Array.from(el.classList || []).join(' '),
|
|
contentEditable: el.getAttribute('contenteditable')
|
|
});
|
|
|
|
const payload = CONFIG.APPEND_TRAILING_NEWLINE ? (text.endsWith('\n') ? text : text + '\n') : text;
|
|
el.focus();
|
|
|
|
// Method 1: ClipboardEvent
|
|
try {
|
|
const dt = new DataTransfer();
|
|
dt.setData('text/plain', payload);
|
|
const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
|
|
const dispatched = el.dispatchEvent(pasteEvt);
|
|
const notPrevented = !pasteEvt.defaultPrevented;
|
|
|
|
RC_DEBUG?.verbose('ClipboardEvent attempt', { dispatched, notPrevented });
|
|
|
|
if (dispatched && notPrevented) {
|
|
RC_DEBUG?.info('✅ Paste method succeeded: ClipboardEvent');
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
RC_DEBUG?.verbose('ClipboardEvent failed', { error: String(e) });
|
|
}
|
|
|
|
// Method 2: ProseMirror
|
|
const isPM = el.classList && el.classList.contains('ProseMirror');
|
|
if (isPM) {
|
|
RC_DEBUG?.verbose('Attempting ProseMirror paste');
|
|
const escape = (s) => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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 }));
|
|
RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror');
|
|
return true;
|
|
}
|
|
|
|
// Method 3: execCommand
|
|
try {
|
|
const sel = window.getSelection && window.getSelection();
|
|
if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) {
|
|
const r = document.createRange();
|
|
r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r);
|
|
RC_DEBUG?.verbose('Selection range set for execCommand');
|
|
}
|
|
|
|
const success = document.execCommand && document.execCommand('insertText', false, payload);
|
|
RC_DEBUG?.verbose('execCommand attempt', { success });
|
|
|
|
if (success) {
|
|
RC_DEBUG?.info('✅ Paste method succeeded: execCommand');
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
RC_DEBUG?.verbose('execCommand failed', { error: String(e) });
|
|
}
|
|
|
|
// Method 4: TEXTAREA/INPUT
|
|
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
RC_DEBUG?.verbose('Attempting TEXTAREA/INPUT paste');
|
|
el.value = payload;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
RC_DEBUG?.info('✅ Paste method succeeded: TEXTAREA/INPUT');
|
|
return true;
|
|
}
|
|
|
|
// Method 5: contentEditable
|
|
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') {
|
|
RC_DEBUG?.verbose('Attempting contentEditable paste');
|
|
el.textContent = payload;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
RC_DEBUG?.info('✅ Paste method succeeded: contentEditable');
|
|
return true;
|
|
}
|
|
|
|
// Fallback: GM_setClipboard
|
|
RC_DEBUG?.warn('All paste methods failed, trying GM_setClipboard fallback');
|
|
try {
|
|
if (typeof GM_setClipboard === 'function') {
|
|
GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' });
|
|
GM_notification({ title: 'AI Repo Commander', text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', timeout: 5000 });
|
|
RC_DEBUG?.info('✅ Paste method succeeded: GM_setClipboard (manual paste required)');
|
|
}
|
|
} catch (e) {
|
|
RC_DEBUG?.warn('GM_setClipboard failed', { error: String(e) });
|
|
}
|
|
|
|
return false;
|
|
|
|
} catch (e) {
|
|
RC_DEBUG?.warn('pasteToComposer fatal error', { error: String(e) });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function pasteAndMaybeSubmit(text) {
|
|
const pasted = pasteToComposer(text);
|
|
if (!pasted) return false;
|
|
|
|
try {
|
|
const el = getVisibleInputCandidate();
|
|
const actualContent = el?.textContent || el?.value || '[no content found]';
|
|
RC_DEBUG?.info('📋 Content in composer after paste', {
|
|
expectedLength: text.length,
|
|
actualLength: actualContent.length,
|
|
actualPreview: actualContent.substring(0, 200)
|
|
});
|
|
} catch (e) {
|
|
RC_DEBUG?.warn('Could not read composer content', { error: String(e) });
|
|
}
|
|
|
|
if (!CONFIG.AUTO_SUBMIT) return true;
|
|
await ExecutionManager.delay(CONFIG.POST_PASTE_DELAY_MS);
|
|
const ok = await submitComposer();
|
|
if (!ok) {
|
|
GM_notification({ title: 'AI Repo Commander', text: 'Pasted content, but auto-submit did not trigger.', timeout: 4000 });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ---------------------- Parser (strict, require @end@) ----------------------
|
|
class CommandParser {
|
|
static parseYAMLCommand(codeBlockText) {
|
|
const block = this.extractCompleteBlock(codeBlockText);
|
|
if (!block) throw new Error('No complete @bridge@ command found (missing @end@ terminator).');
|
|
const parsed = this.parseKeyValuePairs(block);
|
|
|
|
// Defaults
|
|
parsed.url = parsed.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge';
|
|
parsed.owner = parsed.owner || 'rob';
|
|
|
|
// Helpful default: create_branch without source_branch defaults to 'main'
|
|
if (parsed.action === 'create_branch' && !parsed.source_branch) {
|
|
parsed.source_branch = 'main';
|
|
}
|
|
|
|
// Expand owner/repo shorthand
|
|
if (parsed.repo && typeof parsed.repo === 'string' && parsed.repo.includes('/')) {
|
|
const [owner, repo] = parsed.repo.split('/', 2);
|
|
if (!parsed.owner) parsed.owner = owner;
|
|
parsed.repo = repo;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
static extractCompleteBlock(text) {
|
|
// Require terminator @end@ (clearer than --- which appears in markdown)
|
|
const pattern = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/m;
|
|
const m = text.match(pattern);
|
|
if (!m) return null;
|
|
const inner = m[1]?.trimEnd();
|
|
if (!inner) return null;
|
|
return inner;
|
|
}
|
|
|
|
static parseKeyValuePairs(block) {
|
|
const lines = block.split('\n');
|
|
const result = {};
|
|
let currentKey = null;
|
|
let collecting = false;
|
|
let buf = [];
|
|
// Kept for reference, but no longer used for collection termination
|
|
const TOP = ['action','repo','path','content','owner','url','commit_message','branch','ref'];
|
|
|
|
for (const raw of lines) {
|
|
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 = [];
|
|
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));
|
|
return this.makeAPICallWithRetry(command, attempt + 1);
|
|
}
|
|
const totalAttempts = attempt + 1;
|
|
throw new Error(`${err.message} (failed after ${totalAttempts} attempts; max ${CONFIG.MAX_RETRIES + 1})`);
|
|
}
|
|
}
|
|
|
|
static makeAPICall(command) {
|
|
return new Promise((resolve, reject) => {
|
|
const bridgeKey = requireBridgeKeyIfNeeded();
|
|
GM_xmlhttpRequest({
|
|
method: 'POST',
|
|
url: command.url,
|
|
headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' },
|
|
data: JSON.stringify(command),
|
|
timeout: CONFIG.API_TIMEOUT_MS || 60000,
|
|
|
|
onload: (response) => {
|
|
if (response.status >= 200 && response.status < 300) {
|
|
return resolve(response);
|
|
}
|
|
const body = response.responseText ? ` body=${response.responseText.slice(0,300)}` : '';
|
|
reject(new Error(`API Error ${response.status}: ${response.statusText}${body}`));
|
|
},
|
|
|
|
onerror: (error) => {
|
|
const msg = (error && (error.error || error.message))
|
|
? (error.error || error.message)
|
|
: JSON.stringify(error ?? {});
|
|
reject(new Error(`Network error: ${msg}`));
|
|
},
|
|
|
|
ontimeout: () => reject(new Error(`API request timeout after ${CONFIG.API_TIMEOUT_MS}ms`))
|
|
});
|
|
});
|
|
}
|
|
|
|
static async mockExecution(command, sourceElement) {
|
|
await this.delay(500);
|
|
const mock = {
|
|
status: 200,
|
|
responseText: JSON.stringify({
|
|
success: true,
|
|
message: `Mock execution completed for ${command.action}`,
|
|
data: { command: command.action, repo: command.repo, path: command.path, commit_message: command.commit_message }
|
|
})
|
|
};
|
|
return this.handleSuccess(mock, command, sourceElement, true);
|
|
}
|
|
|
|
static _extractGetFileBody(payload) {
|
|
const item = Array.isArray(payload) ? payload[0] : payload;
|
|
return (
|
|
item?.result?.content?.data ??
|
|
item?.content?.data ??
|
|
payload?.result?.content?.data ??
|
|
null
|
|
);
|
|
}
|
|
|
|
static _extractFilesArray(payload) {
|
|
const obj = Array.isArray(payload) ? payload[0] : payload;
|
|
let files = obj?.result?.files ?? obj?.files ?? null;
|
|
if (!files) {
|
|
const res = obj?.result;
|
|
if (res) {
|
|
for (const [k, v] of Object.entries(res)) {
|
|
if (Array.isArray(v) && v.length && (k.toLowerCase().includes('file') || typeof v[0] === 'string' || v[0]?.path || v[0]?.name)) {
|
|
files = v; break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return Array.isArray(files) ? files : null;
|
|
}
|
|
|
|
static _formatFilesListing(files) {
|
|
const pickPath = (f) => {
|
|
if (typeof f === 'string') return f;
|
|
if (typeof f?.path === 'string') return f.path;
|
|
if (f?.dir && f?.name) return `${f.dir.replace(/\/+$/,'')}/${f.name}`;
|
|
if (f?.name) return f.name;
|
|
try { return JSON.stringify(f); } catch { return String(f); }
|
|
};
|
|
|
|
const lines = files.map(pickPath).filter(Boolean).sort();
|
|
return '```text\n' + lines.join('\n') + '\n```';
|
|
}
|
|
|
|
static async handleSuccess(response, command, sourceElement, isMock = false) {
|
|
let data;
|
|
try { data = JSON.parse(response.responseText || '{}'); }
|
|
catch { data = { message: 'Operation completed (no JSON body)' }; }
|
|
|
|
UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', {
|
|
action: command.action,
|
|
details: data.message || 'Operation completed successfully'
|
|
});
|
|
|
|
if (command.action === 'get_file') {
|
|
const body = this._extractGetFileBody(data);
|
|
if (typeof body === 'string' && body.length) {
|
|
await pasteAndMaybeSubmit(body);
|
|
} else {
|
|
GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 });
|
|
}
|
|
}
|
|
|
|
if (command.action === 'list_files') {
|
|
const files = this._extractFilesArray(data);
|
|
if (files && files.length) {
|
|
const listing = this._formatFilesListing(files);
|
|
await pasteAndMaybeSubmit(listing);
|
|
} else {
|
|
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
|
|
await pasteAndMaybeSubmit(fallback);
|
|
GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', timeout: 5000 });
|
|
}
|
|
}
|
|
|
|
return { success: true, data, isMock };
|
|
}
|
|
|
|
static handleError(error, command, sourceElement) {
|
|
UIFeedback.appendStatus(sourceElement, 'ERROR', {
|
|
action: command.action || 'Command',
|
|
details: error.message
|
|
});
|
|
return { success: false, error: error.message };
|
|
}
|
|
|
|
static delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
}
|
|
|
|
// ---------------------- Bridge Key ----------------------
|
|
let BRIDGE_KEY = null;
|
|
|
|
function requireBridgeKeyIfNeeded() {
|
|
if (!CONFIG.ENABLE_API) return BRIDGE_KEY;
|
|
|
|
// 1) Try runtime
|
|
if (BRIDGE_KEY && typeof BRIDGE_KEY === 'string' && BRIDGE_KEY.length) {
|
|
return BRIDGE_KEY;
|
|
}
|
|
|
|
// 2) Try saved config
|
|
if (CONFIG.BRIDGE_KEY && typeof CONFIG.BRIDGE_KEY === 'string' && CONFIG.BRIDGE_KEY.length) {
|
|
BRIDGE_KEY = CONFIG.BRIDGE_KEY;
|
|
RC_DEBUG?.info('Using saved bridge key from config');
|
|
return BRIDGE_KEY;
|
|
}
|
|
|
|
// 3) Prompt fallback
|
|
const entered = prompt(
|
|
'[AI Repo Commander] Enter your bridge key for this session (or set it in Tools → Bridge Configuration to avoid this prompt):'
|
|
);
|
|
if (!entered) throw new Error('Bridge key required when API is enabled.');
|
|
|
|
BRIDGE_KEY = entered;
|
|
|
|
// Offer to save for next time
|
|
try {
|
|
if (confirm('Save this bridge key in Settings → Bridge Configuration to avoid future prompts?')) {
|
|
CONFIG.BRIDGE_KEY = BRIDGE_KEY;
|
|
saveConfig(CONFIG);
|
|
RC_DEBUG?.info('Bridge key saved to config');
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
return BRIDGE_KEY;
|
|
}
|
|
|
|
// Optional: expose a safe setter for console use (won't log the key)
|
|
window.AI_REPO_SET_KEY = function setBridgeKey(k) {
|
|
BRIDGE_KEY = (k || '').trim() || null;
|
|
if (BRIDGE_KEY) {
|
|
RC_DEBUG?.info('Bridge key set for this session');
|
|
} else {
|
|
RC_DEBUG?.info('Bridge key cleared for this session');
|
|
}
|
|
};
|
|
|
|
// ---------------------- Monitor (with streaming "settle" & complete-block check) ----------------------
|
|
class CommandMonitor {
|
|
constructor() {
|
|
this.trackedMessages = new Map();
|
|
this.history = new ConvHistory();
|
|
this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0);
|
|
this.observer = null;
|
|
this.currentPlatform = null;
|
|
this._idCounter = 0;
|
|
this.cleanupIntervalId = null;
|
|
this.initialize();
|
|
}
|
|
|
|
getReadableMessageId(element) {
|
|
this._idCounter += 1;
|
|
const id = `cmd-${this._idCounter}-${Math.random().toString(36).slice(2,6)}`;
|
|
if (element?.dataset) element.dataset.aiRcId = id;
|
|
return id;
|
|
}
|
|
|
|
extractAction(text) {
|
|
const m = /(^|\n)\s*action\s*:\s*([A-Za-z_][\w\-]*)/m.exec(text || '');
|
|
return m ? m[2] : 'unknown';
|
|
}
|
|
|
|
initialize() {
|
|
this.detectPlatform();
|
|
this.startObservation();
|
|
this.setupEmergencyStop();
|
|
RC_DEBUG?.info('AI Repo Commander initialized', {
|
|
ENABLE_API: CONFIG.ENABLE_API,
|
|
DEBUG_MODE: CONFIG.DEBUG_MODE,
|
|
DEBOUNCE_DELAY: CONFIG.DEBOUNCE_DELAY,
|
|
MAX_RETRIES: CONFIG.MAX_RETRIES,
|
|
VERSION: CONFIG.VERSION
|
|
});
|
|
if (CONFIG.ENABLE_API) {
|
|
if (CONFIG.BRIDGE_KEY) {
|
|
RC_DEBUG?.info('API is enabled — using saved bridge key from config');
|
|
} else {
|
|
RC_DEBUG?.warn('API is enabled — you will be prompted for your bridge key on first command.');
|
|
}
|
|
}
|
|
this.cleanupIntervalId = setInterval(() => this.cleanupProcessedCommands(), CONFIG.CLEANUP_INTERVAL_MS);
|
|
}
|
|
|
|
detectPlatform() {
|
|
const host = window.location.hostname;
|
|
this.currentPlatform = PLATFORM_SELECTORS[host] || PLATFORM_SELECTORS['chat.openai.com'];
|
|
}
|
|
|
|
startObservation() {
|
|
let scanPending = false;
|
|
let lastScan = 0;
|
|
let lastMessageCount = 0;
|
|
|
|
const scheduleScan = () => {
|
|
if (scanPending) return;
|
|
scanPending = true;
|
|
const delay = Math.max(0, CONFIG.SCAN_DEBOUNCE_MS - (Date.now() - lastScan));
|
|
setTimeout(() => {
|
|
scanPending = false;
|
|
lastScan = Date.now();
|
|
this.scanMessages();
|
|
}, delay);
|
|
};
|
|
|
|
// MutationObserver for immediate detection - watching edits AND additions
|
|
this.observer = new MutationObserver((mutations) => {
|
|
let shouldScan = false;
|
|
let reasons = new Set();
|
|
|
|
for (const m of mutations) {
|
|
// A) New nodes under the DOM (previous fast path)
|
|
if (m.type === 'childList') {
|
|
for (const node of m.addedNodes) {
|
|
if (node.nodeType !== 1) continue;
|
|
if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) {
|
|
reasons.add('code block added');
|
|
shouldScan = true;
|
|
break;
|
|
}
|
|
// Also consider new assistant messages appearing
|
|
if (node.matches?.(this.currentPlatform.messages) ||
|
|
node.querySelector?.(this.currentPlatform.messages)) {
|
|
reasons.add('message added');
|
|
shouldScan = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// B) Text *inside* existing nodes changed (critical for streaming)
|
|
if (m.type === 'characterData') {
|
|
const el = m.target?.parentElement;
|
|
if (!el) continue;
|
|
if (el.closest?.('pre, code') || el.closest?.(this.currentPlatform.messages)) {
|
|
reasons.add('text changed');
|
|
shouldScan = true;
|
|
}
|
|
}
|
|
|
|
// C) Attribute toggles that show/hide or "finalize" code
|
|
if (m.type === 'attributes') {
|
|
const target = m.target;
|
|
if (target?.matches?.('pre, code') ||
|
|
target?.closest?.(this.currentPlatform.messages)) {
|
|
reasons.add('attribute changed');
|
|
shouldScan = true;
|
|
}
|
|
}
|
|
|
|
if (shouldScan) break;
|
|
}
|
|
|
|
if (shouldScan) {
|
|
RC_DEBUG?.trace('MO: scan triggered', { reasons: Array.from(reasons).join(', ') });
|
|
scheduleScan();
|
|
}
|
|
});
|
|
|
|
// Observe all changes - no attributeFilter to catch any streaming-related attrs
|
|
this.observer.observe(document.body, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
attributes: true
|
|
});
|
|
|
|
// Polling fallback - scan every 2 seconds if message count changed
|
|
this.pollingInterval = setInterval(() => {
|
|
if (CONFIG.RUNTIME.PAUSED) return;
|
|
|
|
const messages = document.querySelectorAll(this.currentPlatform.messages);
|
|
const currentCount = messages.length;
|
|
|
|
if (currentCount !== lastMessageCount) {
|
|
RC_DEBUG?.trace('Polling detected message count change', {
|
|
old: lastMessageCount,
|
|
new: currentCount
|
|
});
|
|
lastMessageCount = currentCount;
|
|
scheduleScan();
|
|
}
|
|
}, 2000);
|
|
|
|
if (CONFIG.PROCESS_EXISTING) {
|
|
setTimeout(() => {
|
|
RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)');
|
|
this.scanMessages();
|
|
}, 600);
|
|
} else {
|
|
RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)');
|
|
}
|
|
}
|
|
|
|
isCompleteCommandText(txt) {
|
|
if (!CONFIG.REQUIRE_TERMINATOR) return /(^|\n)\s*@bridge@\b/m.test(txt) && /(^|\n)\s*action\s*:/m.test(txt);
|
|
return /(^|\n)\s*@bridge@\b/m.test(txt)
|
|
&& /(^|\n)\s*action\s*:/m.test(txt)
|
|
&& /@end@\s*$/m.test(txt);
|
|
}
|
|
|
|
findCommandInCodeBlock(el) {
|
|
const blocks = el.querySelectorAll('pre code, pre, code');
|
|
for (const b of blocks) {
|
|
const txt = (b.textContent || '').trim();
|
|
if (this.isCompleteCommandText(txt)) {
|
|
return { blockElement: b, text: txt };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
scanMessages() {
|
|
if (CONFIG.RUNTIME.PAUSED) {
|
|
RC_DEBUG?.logLoop('loop', 'scan paused');
|
|
return;
|
|
}
|
|
|
|
const messages = document.querySelectorAll(this.currentPlatform.messages);
|
|
let skipped = 0, found = 0;
|
|
|
|
messages.forEach((el) => {
|
|
if (!this.isAssistantMessage(el)) return;
|
|
if (el.dataset.aiRcProcessed) return;
|
|
|
|
const hit = this.findCommandInCodeBlock(el);
|
|
if (!hit) return;
|
|
|
|
const cmdText = hit.text;
|
|
const withinColdStart = Date.now() < this.coldStartUntil;
|
|
const alreadyProcessed = this.history.hasElement(el);
|
|
|
|
RC_DEBUG?.trace('Evaluating message', {
|
|
withinColdStart,
|
|
alreadyProcessed,
|
|
preview: cmdText.slice(0, 60)
|
|
});
|
|
|
|
// Skip if cold start (but DON'T mark in history)
|
|
if (withinColdStart) {
|
|
el.dataset.aiRcProcessed = '1';
|
|
|
|
RC_DEBUG?.verbose('Skipping command - page load (cold start)', {
|
|
fingerprint: fingerprintElement(el).slice(0, 40) + '...',
|
|
preview: cmdText.slice(0, 80)
|
|
});
|
|
|
|
// DO NOT markElement() here — only mark when we actually execute
|
|
attachRunAgainUI(el, () => {
|
|
el.dataset.aiRcProcessed = '1';
|
|
// Clear any accidental mark just in case
|
|
this.history.unmarkElement(el);
|
|
|
|
const id = this.getReadableMessageId(el);
|
|
const hit2 = this.findCommandInCodeBlock(el);
|
|
if (hit2) {
|
|
this.trackMessage(el, hit2.text, id);
|
|
}
|
|
});
|
|
|
|
skipped++;
|
|
return;
|
|
}
|
|
|
|
// Skip if already processed in this conversation
|
|
if (alreadyProcessed) {
|
|
el.dataset.aiRcProcessed = '1';
|
|
|
|
RC_DEBUG?.verbose('Skipping command - already executed in this conversation', {
|
|
fingerprint: fingerprintElement(el).slice(0, 40) + '...',
|
|
preview: cmdText.slice(0, 80)
|
|
});
|
|
|
|
attachRunAgainUI(el, () => {
|
|
el.dataset.aiRcProcessed = '1';
|
|
// Clear from history so it can run again
|
|
this.history.unmarkElement(el);
|
|
|
|
const id = this.getReadableMessageId(el);
|
|
const hit2 = this.findCommandInCodeBlock(el);
|
|
if (hit2) {
|
|
this.trackMessage(el, hit2.text, id);
|
|
}
|
|
});
|
|
|
|
skipped++;
|
|
return;
|
|
}
|
|
|
|
// New message that hasn't been executed → auto-execute once
|
|
el.dataset.aiRcProcessed = '1';
|
|
this.history.markElement(el);
|
|
|
|
const id = this.getReadableMessageId(el);
|
|
this.trackMessage(el, cmdText, id);
|
|
found++;
|
|
});
|
|
|
|
if (skipped) RC_DEBUG?.info(`Skipped ${skipped} command(s) - Run Again buttons added`);
|
|
if (found) RC_DEBUG?.info(`Auto-executing ${found} new command(s)`);
|
|
}
|
|
|
|
isAssistantMessage(el) {
|
|
if (!CONFIG.ASSISTANT_ONLY) return true;
|
|
const host = location.hostname;
|
|
if (/chat\.openai\.com|chatgpt\.com/.test(host)) {
|
|
const roleEl = el.closest?.('[data-message-author-role]') || el;
|
|
return roleEl?.getAttribute?.('data-message-author-role') === 'assistant';
|
|
}
|
|
if (/claude\.ai/.test(host)) {
|
|
const isUser = !!el.closest?.('[data-message-author-role="user"]');
|
|
return !isUser;
|
|
}
|
|
if (/gemini\.google\.com/.test(host)) return true;
|
|
return true;
|
|
}
|
|
|
|
trackMessage(element, text, messageId) {
|
|
RC_DEBUG?.info('New command detected', { messageId, preview: text.substring(0, 120) });
|
|
this.trackedMessages.set(messageId, {
|
|
element,
|
|
originalText: text,
|
|
state: COMMAND_STATES.DETECTED,
|
|
startTime: Date.now(),
|
|
lastUpdate: Date.now(),
|
|
cancelToken: { cancelled: false }
|
|
});
|
|
this.updateState(messageId, COMMAND_STATES.PARSING);
|
|
this.processCommand(messageId);
|
|
}
|
|
|
|
async debounceWithCancel(messageId) {
|
|
const start = Date.now();
|
|
const delay = CONFIG.DEBOUNCE_DELAY;
|
|
const checkInterval = 100;
|
|
|
|
while (Date.now() - start < delay) {
|
|
const msg = this.trackedMessages.get(messageId);
|
|
if (!msg || msg.cancelToken?.cancelled) return;
|
|
|
|
msg.lastUpdate = Date.now();
|
|
this.trackedMessages.set(messageId, msg);
|
|
|
|
await ExecutionManager.delay(Math.min(checkInterval, delay - (Date.now() - start)));
|
|
}
|
|
}
|
|
|
|
async waitForStableCompleteBlock(element, initialText, messageId) {
|
|
let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
|
|
let last = initialText;
|
|
|
|
while (Date.now() < deadline) {
|
|
const rec = this.trackedMessages.get(messageId);
|
|
if (!rec || rec.cancelToken?.cancelled) {
|
|
RC_DEBUG?.warn('Settle cancelled', { messageId });
|
|
return '';
|
|
}
|
|
|
|
rec.lastUpdate = Date.now();
|
|
this.trackedMessages.set(messageId, rec);
|
|
|
|
await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS);
|
|
const hit = this.findCommandInCodeBlock(element);
|
|
const txt = hit ? hit.text : '';
|
|
|
|
if (!txt || !this.isCompleteCommandText(txt)) {
|
|
continue;
|
|
}
|
|
|
|
if (txt === last) {
|
|
// stable; keep waiting
|
|
} else {
|
|
last = txt;
|
|
deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
|
|
}
|
|
}
|
|
|
|
const finalHit = this.findCommandInCodeBlock(element);
|
|
return finalHit ? finalHit.text : '';
|
|
}
|
|
|
|
attachRetryUI(element, messageId) {
|
|
if (element.querySelector('.ai-rc-rerun')) return;
|
|
|
|
attachRunAgainUI(element, () => {
|
|
element.dataset.aiRcProcessed = '1';
|
|
const hit = this.findCommandInCodeBlock(element);
|
|
if (hit) {
|
|
this.trackedMessages.delete(messageId);
|
|
const newId = this.getReadableMessageId(element);
|
|
this.trackMessage(element, hit.text, newId);
|
|
}
|
|
});
|
|
}
|
|
|
|
updateState(messageId, state) {
|
|
const msg = this.trackedMessages.get(messageId);
|
|
if (!msg) return;
|
|
const old = msg.state;
|
|
msg.state = state;
|
|
msg.lastUpdate = Date.now();
|
|
this.trackedMessages.set(messageId, msg);
|
|
RC_DEBUG?.command(this.extractAction(msg.originalText), state, {
|
|
messageId, transition: `${old} -> ${state}`
|
|
});
|
|
}
|
|
|
|
async processCommand(messageId) {
|
|
if (CONFIG.RUNTIME.PAUSED) {
|
|
RC_DEBUG?.info('process paused, skipping', { messageId });
|
|
return;
|
|
}
|
|
|
|
const started = Date.now();
|
|
try {
|
|
const message = this.trackedMessages.get(messageId);
|
|
if (!message) {
|
|
RC_DEBUG?.error('Message not found', { messageId });
|
|
return;
|
|
}
|
|
|
|
if (message.cancelToken?.cancelled) {
|
|
RC_DEBUG?.warn('Operation cancelled', { messageId });
|
|
return;
|
|
}
|
|
|
|
// 1) Parse
|
|
let parsed;
|
|
try {
|
|
parsed = CommandParser.parseYAMLCommand(message.originalText);
|
|
} catch (err) {
|
|
RC_DEBUG?.error(`Command parsing failed: ${err.message}`, { messageId });
|
|
this.updateState(messageId, COMMAND_STATES.ERROR);
|
|
if (/No complete @bridge@/.test(err.message)) return;
|
|
|
|
this.attachRetryUI(message.element, messageId);
|
|
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message });
|
|
return;
|
|
}
|
|
|
|
if (message.cancelToken?.cancelled) {
|
|
RC_DEBUG?.warn('Operation cancelled after parse', { messageId });
|
|
return;
|
|
}
|
|
|
|
// 2) Validate
|
|
this.updateState(messageId, COMMAND_STATES.VALIDATING);
|
|
let validation = CommandParser.validateStructure(parsed);
|
|
if (!validation.isValid) {
|
|
this.attachRetryUI(message.element, messageId);
|
|
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
|
}
|
|
|
|
// 3) Debounce
|
|
this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
|
|
const before = message.originalText;
|
|
await this.debounceWithCancel(messageId);
|
|
|
|
if (message.cancelToken?.cancelled) {
|
|
RC_DEBUG?.warn('Operation cancelled after debounce', { messageId });
|
|
return;
|
|
}
|
|
|
|
const stable = await this.waitForStableCompleteBlock(message.element, before, messageId);
|
|
if (!stable) {
|
|
this.updateState(messageId, COMMAND_STATES.ERROR);
|
|
return;
|
|
}
|
|
|
|
if (stable !== before) {
|
|
RC_DEBUG?.info('Command changed after settle (re-validate)', { messageId });
|
|
message.originalText = stable;
|
|
const reParsed = CommandParser.parseYAMLCommand(stable);
|
|
const reVal = CommandParser.validateStructure(reParsed);
|
|
if (!reVal.isValid) {
|
|
this.attachRetryUI(message.element, messageId);
|
|
throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`);
|
|
}
|
|
parsed = reParsed;
|
|
}
|
|
|
|
// 4) Execute
|
|
this.updateState(messageId, COMMAND_STATES.EXECUTING);
|
|
const result = await ExecutionManager.executeCommand(parsed, message.element);
|
|
|
|
if (!result || result.success === false) {
|
|
RC_DEBUG?.warn('Execution reported failure', { messageId });
|
|
this.updateState(messageId, COMMAND_STATES.ERROR);
|
|
this.attachRetryUI(message.element, messageId);
|
|
return;
|
|
}
|
|
|
|
const duration = Date.now() - started;
|
|
if (duration < CONFIG.FAST_WARN_MS) RC_DEBUG?.warn('Command completed very fast', { messageId, duration });
|
|
if (duration > CONFIG.SLOW_WARN_MS) RC_DEBUG?.warn('Command took very long', { messageId, duration });
|
|
|
|
this.updateState(messageId, COMMAND_STATES.COMPLETE);
|
|
|
|
} catch (error) {
|
|
const duration = Date.now() - started;
|
|
RC_DEBUG?.error(`Command processing error: ${error.message}`, { messageId, duration });
|
|
this.updateState(messageId, COMMAND_STATES.ERROR);
|
|
const message = this.trackedMessages.get(messageId);
|
|
if (/No complete @bridge@/.test(error.message)) return;
|
|
if (message) {
|
|
this.attachRetryUI(message.element, messageId);
|
|
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: error.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
cleanupProcessedCommands() {
|
|
const now = Date.now();
|
|
let count = 0;
|
|
|
|
for (const [id, msg] of this.trackedMessages.entries()) {
|
|
const age = now - (msg.lastUpdate || msg.startTime || now);
|
|
const finished = (msg.state === COMMAND_STATES.COMPLETE || msg.state === COMMAND_STATES.ERROR);
|
|
|
|
const shouldCleanup =
|
|
(finished && age > CONFIG.CLEANUP_AFTER_MS) ||
|
|
(age > CONFIG.STUCK_AFTER_MS);
|
|
|
|
if (shouldCleanup) {
|
|
if (age > CONFIG.STUCK_AFTER_MS && !finished) {
|
|
RC_DEBUG?.warn('Cleaning stuck entry', { messageId: id, state: msg.state, age });
|
|
}
|
|
this.trackedMessages.delete(id);
|
|
count++;
|
|
}
|
|
}
|
|
|
|
if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entries`);
|
|
}
|
|
|
|
stopAllProcessing() {
|
|
this.trackedMessages.clear();
|
|
if (this.observer) this.observer.disconnect();
|
|
if (this.pollingInterval) {
|
|
clearInterval(this.pollingInterval);
|
|
this.pollingInterval = null;
|
|
}
|
|
if (this.cleanupIntervalId) {
|
|
clearInterval(this.cleanupIntervalId);
|
|
this.cleanupIntervalId = null;
|
|
}
|
|
RC_DEBUG?.destroy?.();
|
|
}
|
|
|
|
setupEmergencyStop() {
|
|
window.AI_REPO_STOP = () => {
|
|
CONFIG.ENABLE_API = false;
|
|
CONFIG.RUNTIME.PAUSED = true;
|
|
saveConfig(CONFIG);
|
|
|
|
for (const [id, msg] of this.trackedMessages.entries()) {
|
|
if (msg.cancelToken) msg.cancelToken.cancelled = true;
|
|
|
|
if (msg.state === COMMAND_STATES.EXECUTING || msg.state === COMMAND_STATES.DEBOUNCING) {
|
|
RC_DEBUG?.error('Emergency stop - cancelling command', { messageId: id });
|
|
this.updateState(id, COMMAND_STATES.ERROR);
|
|
}
|
|
}
|
|
|
|
this.stopAllProcessing();
|
|
RC_DEBUG?.error('🚨 EMERGENCY STOP ACTIVATED 🚨');
|
|
GM_notification({ text: 'All command processing stopped', title: 'Emergency Stop', timeout: 5000 });
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---------------------- Manual retry helpers ----------------------
|
|
let commandMonitor;
|
|
|
|
window.AI_REPO_RETRY_COMMAND_TEXT = () => {
|
|
RC_DEBUG?.warn('Retry by text is deprecated. Use AI_REPO_RETRY_MESSAGE(messageId) or click "Run again".');
|
|
};
|
|
|
|
window.AI_REPO_RETRY_MESSAGE = (messageId) => {
|
|
try {
|
|
const msg = commandMonitor?.trackedMessages?.get(messageId);
|
|
if (!msg) {
|
|
RC_DEBUG?.warn('Message not found for retry', { messageId });
|
|
return;
|
|
}
|
|
|
|
msg.element.dataset.aiRcProcessed = '1';
|
|
|
|
RC_DEBUG?.info('Retrying message now', { messageId });
|
|
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
|
|
commandMonitor.processCommand(messageId);
|
|
} catch (e) {
|
|
RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) });
|
|
}
|
|
};
|
|
|
|
// ---------------------- Test commands ----------------------
|
|
const TEST_COMMANDS = {
|
|
validUpdate:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: update_file
|
|
repo: test-repo
|
|
path: TEST.md
|
|
content: |
|
|
Test content
|
|
Multiple lines
|
|
---
|
|
Even markdown horizontal rules work!
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
getFile:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: get_file
|
|
repo: test-repo
|
|
path: README.md
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
listFiles:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: list_files
|
|
repo: test-repo
|
|
path: .
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
createBranch:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: create_branch
|
|
repo: test-repo
|
|
branch: feature/new-feature
|
|
source_branch: main
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
createPR:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: create_pr
|
|
repo: test-repo
|
|
title: Add new feature
|
|
head: feature/new-feature
|
|
base: main
|
|
body: |
|
|
This PR adds a new feature
|
|
- Item 1
|
|
- Item 2
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
createIssue:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: create_issue
|
|
repo: test-repo
|
|
title: Bug report
|
|
body: |
|
|
Description of the bug
|
|
Steps to reproduce:
|
|
1. Step one
|
|
2. Step two
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
createTag:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: create_tag
|
|
repo: test-repo
|
|
tag: v1.0.0
|
|
target: main
|
|
message: Release version 1.0.0
|
|
@end@
|
|
\`\`\`
|
|
`,
|
|
createRelease:
|
|
`\
|
|
\`\`\`yaml
|
|
@bridge@
|
|
action: create_release
|
|
repo: test-repo
|
|
tag_name: v1.0.0
|
|
name: Version 1.0.0
|
|
body: |
|
|
## What's New
|
|
- Feature A
|
|
- Feature B
|
|
|
|
## Bug Fixes
|
|
- Fix X
|
|
- Fix Y
|
|
@end@
|
|
\`\`\`
|
|
`
|
|
};
|
|
|
|
// ---------------------- 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
|
|
};
|
|
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()');
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initializeRepoCommander);
|
|
} else {
|
|
initializeRepoCommander();
|
|
}
|
|
})(); |