1159 lines
43 KiB
JavaScript
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,'&').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 }));
|
|
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();
|
|
}
|
|
})();
|