Update src/ai-repo-commander.user.js
changed command history aproach. More resilient - Handles edge cases in long conversations and streaming responses Better cleanup - Won't leak memory from stuck operations Cancellable operations - Can be stopped mid-flight without errors Better debugging - Magic numbers replaced with named config values Improved UX - Error states now offer recovery options
This commit is contained in:
parent
3575dec2ee
commit
f4ebb492b7
|
|
@ -41,10 +41,14 @@
|
|||
|
||||
PROCESS_EXISTING: false,
|
||||
ASSISTANT_ONLY: true,
|
||||
BRIDGE_KEY: '', // Var to store the bridge key
|
||||
|
||||
// Persistent dedupe window
|
||||
DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
|
||||
COLD_START_MS: 2000, // Optional: during first 2s after load, don't auto-run pre-existing messages
|
||||
SHOW_EXECUTED_MARKER: true, // Add a green border on messages that executed
|
||||
|
||||
// Housekeeping
|
||||
CLEANUP_AFTER_MS: 30000,
|
||||
CLEANUP_INTERVAL_MS: 60000,
|
||||
|
|
@ -61,7 +65,14 @@
|
|||
SETTLE_POLL_MS: 200, // was 300
|
||||
|
||||
// Runtime toggles
|
||||
RUNTIME: { PAUSED: false }
|
||||
RUNTIME: { PAUSED: false },
|
||||
|
||||
// New additions for hardening
|
||||
STUCK_AFTER_MS: 10 * 60 * 1000, // 10min: force cleanup stuck entries
|
||||
SCAN_DEBOUNCE_MS: 250, // throttle MutationObserver scans
|
||||
FAST_WARN_MS: 50, // warn if command completes suspiciously fast
|
||||
SLOW_WARN_MS: 60_000, // warn if command takes >1min
|
||||
|
||||
};
|
||||
|
||||
function loadSavedConfig() {
|
||||
|
|
@ -292,6 +303,25 @@
|
|||
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>
|
||||
|
|
@ -341,9 +371,10 @@
|
|||
tabTools.style.background = tools ? '#1f2937' : '#111827';
|
||||
};
|
||||
tabLogs.addEventListener('click', () => selectTab(false));
|
||||
|
||||
tabTools.addEventListener('click', () => {
|
||||
selectTab(true);
|
||||
// refresh tools view values
|
||||
// refresh toggles/nums
|
||||
root.querySelectorAll('.rc-toggle').forEach(inp => {
|
||||
const key = inp.dataset.key;
|
||||
inp.checked = !!this.cfg[key];
|
||||
|
|
@ -351,7 +382,15 @@
|
|||
root.querySelectorAll('.rc-num').forEach(inp => {
|
||||
inp.value = String(this.cfg[inp.dataset.key] ?? '');
|
||||
});
|
||||
root.querySelector('.rc-json').value = JSON.stringify(this.cfg, null, 2);
|
||||
|
||||
// Mask BRIDGE_KEY in JSON dump
|
||||
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);
|
||||
|
||||
// Mask the bridge key input (never show the real key)
|
||||
const bridgeKeyInput = root.querySelector('.rc-bridge-key');
|
||||
if (bridgeKeyInput) bridgeKeyInput.value = this.cfg.BRIDGE_KEY ? '•'.repeat(8) : '';
|
||||
});
|
||||
|
||||
// Collapse
|
||||
|
|
@ -395,11 +434,18 @@
|
|||
|
||||
// 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 {
|
||||
// fallback: remove legacy
|
||||
localStorage.removeItem(STORAGE_KEYS.history);
|
||||
this.info('Command history cleared');
|
||||
GM_notification({ title: 'AI Repo Commander', text: 'Command history cleared', timeout: 2500 });
|
||||
RC_DEBUG?.info('Legacy history key cleared');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Tools: toggles & numbers
|
||||
root.querySelectorAll('.rc-toggle').forEach(inp => {
|
||||
const key = inp.dataset.key;
|
||||
|
|
@ -427,16 +473,39 @@
|
|||
try {
|
||||
const raw = root.querySelector('.rc-json').value;
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
// Handle BRIDGE_KEY specially: ignore masked or empty strings,
|
||||
// accept a real value, then remove it from parsed so Object.assign
|
||||
// doesn’t stomp it later.
|
||||
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);
|
||||
|
||||
// Re-mask JSON view after save
|
||||
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; // <— add
|
||||
const bridgeKeyInput = root.querySelector('.rc-bridge-key'); // <— add
|
||||
if (bridgeKeyInput) bridgeKeyInput.value = ''; // <— add
|
||||
this.info('Config reset to defaults');
|
||||
});
|
||||
|
||||
|
|
@ -447,6 +516,36 @@
|
|||
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 user clicked into a masked field and didn't change it, do nothing
|
||||
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; // set runtime immediately
|
||||
// re-mask UI
|
||||
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; // Clear the runtime key too
|
||||
this.info('Bridge key cleared');
|
||||
GM_notification({ title: 'AI Repo Commander', text: 'Bridge key cleared', timeout: 2500 });
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
_renderRow(e) {
|
||||
|
|
@ -472,6 +571,38 @@
|
|||
'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' },
|
||||
'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' }
|
||||
};
|
||||
// ---------------------- Conversation-Aware Element History ----------------------
|
||||
|
||||
function getConversationId() {
|
||||
const host = location.hostname;
|
||||
// ChatGPT / OpenAI
|
||||
if (/chatgpt\.com|chat\.openai\.com/.test(host)) {
|
||||
const m = location.pathname.match(/\/c\/([^/]+)/);
|
||||
return `chatgpt:${m ? m[1] : location.pathname}`;
|
||||
}
|
||||
// Claude
|
||||
if (/claude\.ai/.test(host)) {
|
||||
const m = location.pathname.match(/\/thread\/([^/]+)/);
|
||||
return `claude:${m ? m[1] : location.pathname}`;
|
||||
}
|
||||
// Gemini / others
|
||||
return `${host}:${location.pathname || '/'}`;
|
||||
}
|
||||
|
||||
function fingerprintElement(el) {
|
||||
// Prefer platform IDs if available
|
||||
const attrs = ['data-message-id','data-id','data-testid','id'];
|
||||
for (const a of attrs) {
|
||||
const v = el.getAttribute?.(a) || el.closest?.(`[${a}]`)?.getAttribute?.(a);
|
||||
if (v) return `id:${a}:${v}`;
|
||||
}
|
||||
// Fallback: DOM position + short content hash
|
||||
const nodes = Array.from(document.querySelectorAll('[data-message-author-role], .chat-message, .message-content'));
|
||||
const idx = Math.max(0, nodes.indexOf(el));
|
||||
const s = (el.textContent || '').slice(0, 256);
|
||||
let h = 5381; for (let i=0;i<s.length;i++) h=((h<<5)+h) ^ s.charCodeAt(i);
|
||||
return `pos:${idx}:h:${(h>>>0).toString(36)}`;
|
||||
}
|
||||
|
||||
// ---------------------- Command requirements ----------------------
|
||||
const REQUIRED_FIELDS = {
|
||||
|
|
@ -510,57 +641,109 @@
|
|||
};
|
||||
|
||||
// ---------------------- Persistent Command History ----------------------
|
||||
class CommandHistory {
|
||||
class ConvHistory {
|
||||
constructor() {
|
||||
this.key = STORAGE_KEYS.history;
|
||||
this.ttl = CONFIG.DEDUPE_TTL_MS;
|
||||
this.cleanup();
|
||||
this.convId = getConversationId();
|
||||
this.key = `ai_rc:conv:${this.convId}:processed`;
|
||||
this.session = new Set();
|
||||
this.cache = this._load();
|
||||
this._cleanupTTL(); // ← Add cleanup on init
|
||||
}
|
||||
|
||||
_load() {
|
||||
try { return JSON.parse(localStorage.getItem(this.key) || '{}'); }
|
||||
catch { return {}; }
|
||||
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;
|
||||
|
||||
_save() {
|
||||
try {
|
||||
localStorage.setItem(this.key, JSON.stringify(this.cache));
|
||||
} catch {}
|
||||
}
|
||||
mark(text) {
|
||||
const db = this._load();
|
||||
db[this._hash(text)] = Date.now();
|
||||
this._save(db);
|
||||
}
|
||||
unmark(text) {
|
||||
const db = this._load();
|
||||
const k = this._hash(text);
|
||||
if (k in db) { delete db[k]; this._save(db); }
|
||||
}
|
||||
cleanup() {
|
||||
const db = this._load();
|
||||
|
||||
_cleanupTTL() {
|
||||
const ttl = CONFIG.DEDUPE_TTL_MS || (30 * 24 * 60 * 60 * 1000);
|
||||
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; }
|
||||
|
||||
for (const [fp, ts] of Object.entries(this.cache)) {
|
||||
if (!ts || (now - ts) > ttl) {
|
||||
delete this.cache[fp];
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) this._save(db);
|
||||
}
|
||||
reset() { localStorage.removeItem(this.key); }
|
||||
|
||||
if (dirty) this._save();
|
||||
}
|
||||
|
||||
hasElement(el) {
|
||||
const fp = fingerprintElement(el);
|
||||
return this.session.has(fp) || (fp in this.cache);
|
||||
}
|
||||
|
||||
markElement(el) {
|
||||
const fp = fingerprintElement(el);
|
||||
this.session.add(fp);
|
||||
this.cache[fp] = Date.now();
|
||||
this._save();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
this.session.clear();
|
||||
localStorage.removeItem(this.key);
|
||||
this.cache = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Global helpers (stable)
|
||||
window.AI_REPO = {
|
||||
clearHistory: () => localStorage.removeItem(STORAGE_KEYS.history),
|
||||
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) {
|
||||
|
|
@ -853,11 +1036,23 @@
|
|||
headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' },
|
||||
data: JSON.stringify(command),
|
||||
timeout: CONFIG.API_TIMEOUT_MS || 60000,
|
||||
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'))
|
||||
|
||||
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`))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -961,19 +1156,58 @@
|
|||
|
||||
// ---------------------- 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.');
|
||||
}
|
||||
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 CommandHistory();
|
||||
this.history = new ConvHistory();
|
||||
this.coldStartUntil = Date.now() + (CONFIG.COLD_START_MS || 0); // optional
|
||||
this.observer = null;
|
||||
this.currentPlatform = null;
|
||||
this._idCounter = 0;
|
||||
|
|
@ -1005,8 +1239,12 @@
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
@ -1017,10 +1255,17 @@
|
|||
|
||||
startObservation() {
|
||||
let scanPending = false;
|
||||
let lastScan = 0;
|
||||
|
||||
const scheduleScan = () => {
|
||||
if (scanPending) return;
|
||||
scanPending = true;
|
||||
setTimeout(() => { scanPending = false; this.scanMessages(); }, 120);
|
||||
const delay = Math.max(0, CONFIG.SCAN_DEBOUNCE_MS - (Date.now() - lastScan));
|
||||
setTimeout(() => {
|
||||
scanPending = false;
|
||||
lastScan = Date.now();
|
||||
this.scanMessages();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
this.observer = new MutationObserver((mutations) => {
|
||||
|
|
@ -1029,18 +1274,19 @@
|
|||
if (node.nodeType !== 1) continue;
|
||||
if (node.matches?.('pre, code') || node.querySelector?.('pre, code')) {
|
||||
scheduleScan();
|
||||
break;
|
||||
return; // Early exit after scheduling
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
if (CONFIG.PROCESS_EXISTING) {
|
||||
setTimeout(() => {
|
||||
RC_DEBUG?.info('Initial scan after page load (PROCESS_EXISTING=true)');
|
||||
this.scanMessages();
|
||||
}, 1000);
|
||||
}, 600);
|
||||
} else {
|
||||
RC_DEBUG?.info('Initial scan skipped (PROCESS_EXISTING=false)');
|
||||
}
|
||||
|
|
@ -1066,31 +1312,11 @@
|
|||
return null;
|
||||
}
|
||||
|
||||
async waitForStableCompleteBlock(element, initialText) {
|
||||
// After debounce, wait until the block remains unchanged for SETTLE_CHECK_MS, with terminator present.
|
||||
const mustEnd = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
|
||||
let last = initialText;
|
||||
while (Date.now() < mustEnd) {
|
||||
await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS);
|
||||
const hit = this.findCommandInCodeBlock(element);
|
||||
const txt = hit ? hit.text : '';
|
||||
if (!txt || !this.isCompleteCommandText(txt)) {
|
||||
// streaming not done yet; extend window slightly
|
||||
continue;
|
||||
}
|
||||
if (txt === last) {
|
||||
// stable during this poll; keep looping until timeout to ensure stability window
|
||||
} else {
|
||||
last = txt; // changed; reset window by moving mustEnd forward a bit
|
||||
}
|
||||
}
|
||||
// final extract
|
||||
const finalHit = this.findCommandInCodeBlock(element);
|
||||
return finalHit ? finalHit.text : '';
|
||||
}
|
||||
|
||||
scanMessages() {
|
||||
if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.logLoop('loop', 'scan paused'); return; }
|
||||
if (CONFIG.RUNTIME.PAUSED) {
|
||||
RC_DEBUG?.logLoop('loop', 'scan paused');
|
||||
return;
|
||||
}
|
||||
|
||||
const messages = document.querySelectorAll(this.currentPlatform.messages);
|
||||
let skipped = 0, found = 0;
|
||||
|
|
@ -1104,20 +1330,47 @@
|
|||
|
||||
const cmdText = hit.text;
|
||||
|
||||
if (this.history.has(cmdText)) {
|
||||
// Check if we're in cold start OR if this specific message element was already executed
|
||||
const withinColdStart = Date.now() < this.coldStartUntil;
|
||||
const alreadyProcessed = this.history.hasElement(el);
|
||||
|
||||
// If cold start OR already processed → show "Run Again" button (don't auto-execute)
|
||||
if (withinColdStart || alreadyProcessed) {
|
||||
el.dataset.aiRcProcessed = '1';
|
||||
|
||||
const reason = withinColdStart
|
||||
? 'page load (cold start)'
|
||||
: 'already executed in this conversation';
|
||||
|
||||
RC_DEBUG?.verbose(`Skipping command - ${reason}`, {
|
||||
preview: cmdText.slice(0, 80)
|
||||
});
|
||||
|
||||
attachRunAgainUI(el, () => {
|
||||
el.dataset.aiRcProcessed = '1'; // ← Prevents scan loop double-enqueue
|
||||
|
||||
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?.logLoop('loop', `skipped already-executed (${skipped})`);
|
||||
if (found) RC_DEBUG?.info(`Found ${found} new command(s)`);
|
||||
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) {
|
||||
|
|
@ -1137,12 +1390,86 @@
|
|||
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()
|
||||
element,
|
||||
originalText: text,
|
||||
state: COMMAND_STATES.DETECTED,
|
||||
startTime: Date.now(),
|
||||
lastUpdate: Date.now(),
|
||||
cancelToken: { cancelled: false } // ← ADD THIS
|
||||
});
|
||||
this.updateState(messageId, COMMAND_STATES.PARSING);
|
||||
this.processCommand(messageId);
|
||||
}
|
||||
// Debounce that checks cancellation
|
||||
async debounceWithCancel(messageId) {
|
||||
const start = Date.now();
|
||||
const delay = CONFIG.DEBOUNCE_DELAY;
|
||||
const checkInterval = 100; // check every 100ms
|
||||
|
||||
while (Date.now() - start < delay) {
|
||||
const msg = this.trackedMessages.get(messageId);
|
||||
if (!msg || msg.cancelToken?.cancelled) return;
|
||||
|
||||
// Update lastUpdate to prevent premature cleanup
|
||||
msg.lastUpdate = Date.now();
|
||||
this.trackedMessages.set(messageId, msg);
|
||||
|
||||
await ExecutionManager.delay(Math.min(checkInterval, delay - (Date.now() - start)));
|
||||
}
|
||||
}
|
||||
|
||||
// Updated settle check with cancellation and lastUpdate bumps
|
||||
async waitForStableCompleteBlock(element, initialText, messageId) {
|
||||
let deadline = Date.now() + Math.max(0, CONFIG.SETTLE_CHECK_MS);
|
||||
let last = initialText;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
// Check cancellation
|
||||
const rec = this.trackedMessages.get(messageId);
|
||||
if (!rec || rec.cancelToken?.cancelled) {
|
||||
RC_DEBUG?.warn('Settle cancelled', { messageId });
|
||||
return '';
|
||||
}
|
||||
|
||||
// Update lastUpdate to prevent cleanup during long wait
|
||||
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 : '';
|
||||
}
|
||||
|
||||
// Retry UI helper
|
||||
attachRetryUI(element, messageId) {
|
||||
if (element.querySelector('.ai-rc-rerun')) return;
|
||||
|
||||
attachRunAgainUI(element, () => {
|
||||
element.dataset.aiRcProcessed = '1';
|
||||
const hit = this.findCommandInCodeBlock(element);
|
||||
if (hit) {
|
||||
// Clear old entry and create new one
|
||||
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;
|
||||
|
|
@ -1156,61 +1483,98 @@
|
|||
}
|
||||
|
||||
async processCommand(messageId) {
|
||||
if (CONFIG.RUNTIME.PAUSED) { RC_DEBUG?.info('process paused, skipping', { messageId }); return; }
|
||||
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) {
|
||||
RC_DEBUG?.error('Message not found', { messageId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Initial parse check (strict: must include ---)
|
||||
// ← CHECK CANCELLATION
|
||||
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; // silent for partials
|
||||
if (/No complete \^%\$bridge/.test(err.message)) return;
|
||||
|
||||
// ← ADD RUN AGAIN ON ERRORS
|
||||
this.attachRetryUI(message.element, messageId);
|
||||
|
||||
UIFeedback.appendStatus(message.element, 'ERROR', { action: 'Command', details: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// ← CHECK CANCELLATION
|
||||
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) throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
if (!validation.isValid) {
|
||||
this.attachRetryUI(message.element, messageId);
|
||||
throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 3) Debounce, then settle: ensure final text complete & stable
|
||||
// 3) Debounce
|
||||
this.updateState(messageId, COMMAND_STATES.DEBOUNCING);
|
||||
const before = message.originalText;
|
||||
await this.debounce();
|
||||
const stable = await this.waitForStableCompleteBlock(message.element, before);
|
||||
if (!stable) { this.updateState(messageId, COMMAND_STATES.ERROR); return; }
|
||||
await this.debounceWithCancel(messageId);
|
||||
|
||||
// ← CHECK CANCELLATION
|
||||
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) throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`);
|
||||
if (!reVal.isValid) {
|
||||
this.attachRetryUI(message.element, messageId);
|
||||
throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`);
|
||||
}
|
||||
parsed = reParsed;
|
||||
}
|
||||
|
||||
// 4) Pre-mark to avoid duplicate runs even if failure later (explicit design)
|
||||
this.history.mark(message.originalText);
|
||||
|
||||
// 5) Execute
|
||||
// 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; command remains marked (no auto-retry)', { messageId });
|
||||
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 < 50) RC_DEBUG?.warn('Command completed very fast', { messageId, duration });
|
||||
if (duration > 60000) RC_DEBUG?.warn('Command took very long', { messageId, duration });
|
||||
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);
|
||||
|
||||
|
|
@ -1219,26 +1583,36 @@
|
|||
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; // quiet for partials
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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); // Force cleanup for stuck items
|
||||
|
||||
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} processed entries`);
|
||||
|
||||
if (count) RC_DEBUG?.info(`Cleaned ${count} tracked entrie(s)`);
|
||||
}
|
||||
|
||||
stopAllProcessing() {
|
||||
|
|
@ -1257,12 +1631,16 @@
|
|||
CONFIG.RUNTIME.PAUSED = true;
|
||||
saveConfig(CONFIG);
|
||||
|
||||
// Cancel all pending operations
|
||||
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 });
|
||||
|
|
@ -1273,20 +1651,20 @@
|
|||
// ---------------------- 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_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; }
|
||||
commandMonitor.history.unmark(msg.originalText);
|
||||
if (!msg) {
|
||||
RC_DEBUG?.warn('Message not found for retry', { messageId });
|
||||
return;
|
||||
}
|
||||
|
||||
msg.element.dataset.aiRcProcessed = '1'; // ← Block scan loop
|
||||
|
||||
RC_DEBUG?.info('Message unmarked; reprocessing now', { messageId });
|
||||
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
|
||||
commandMonitor.processCommand(messageId);
|
||||
|
|
@ -1295,6 +1673,7 @@
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
// ---------------------- Test commands ----------------------
|
||||
const TEST_COMMANDS = {
|
||||
validUpdate:
|
||||
|
|
|
|||
Loading…
Reference in New Issue