diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index cdd2d7c..9df4a81 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name AI Repo Commander // @namespace http://tampermonkey.net/ -// @version 1.1.2 -// @description Safely execute ^%$bridge YAML commands in chat UIs with dedupe, debounce, and clear feedback — minimal and focused. +// @version 1.2.1 +// @description Execute ^%$bridge YAML commands from AI assistants in code blocks with persistent dedupe and robust paste // @author Your Name // @match https://chat.openai.com/* // @match https://chatgpt.com/* @@ -10,6 +10,7 @@ // @match https://gemini.google.com/* // @grant GM_xmlhttpRequest // @grant GM_notification +// @grant GM_setClipboard // @connect n8n.brrd.tech // ==/UserScript== @@ -18,95 +19,109 @@ // ---------------------- Config ---------------------- const CONFIG = { - ENABLE_API: true, // Master kill switch - DEBUG_MODE: true, // Console logs - DEBOUNCE_DELAY: 5000, // Bot typing protection - MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries) - VERSION: '1.1.2', + ENABLE_API: true, // Master kill switch + DEBUG_MODE: true, // Console logs + DEBOUNCE_DELAY: 5000, // Bot typing protection + MAX_RETRIES: 2, // Retry attempts (=> up to MAX_RETRIES+1 total tries) + VERSION: '1.2.1', - // Lean dedupe + cleanup (keep it simple) - SEEN_TTL_MS: 60000, // Deduplicate identical blocks for 60s - CLEANUP_AFTER_MS: 30000, // Drop COMPLETE/ERROR entries after 30s - CLEANUP_INTERVAL_MS: 60000, // Sweep cadence + PROCESS_EXISTING: false, // If false, only process messages added after init (but see initial delayed scan) + ASSISTANT_ONLY: true, // Process assistant messages by default (your core use case) - // Optional safety: when true, only user-authored messages are processed - SKIP_AI_MESSAGES: false + // 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 }; // ---------------------- 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' } + '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'], + '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'] + 'list_files': ['action', 'repo', 'path'] }; const FIELD_VALIDATORS = { // allow "owner/repo" or just "repo" - 'repo': (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), + repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v), // minimal traversal guard: no absolute paths, no backslashes, no ".." - '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) + 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}', + SUCCESS: '[{action}: Success] {details}', + ERROR: '[{action}: Error] {details}', VALIDATION_ERROR: '[{action}: Invalid] {details}', - EXECUTING: '[{action}: Processing...]', - MOCK: '[{action}: Mock] {details}' + EXECUTING: '[{action}: Processing...]', + MOCK: '[{action}: Mock] {details}' }; const COMMAND_STATES = { - DETECTED: 'detected', - PARSING: 'parsing', + DETECTED: 'detected', + PARSING: 'parsing', VALIDATING: 'validating', DEBOUNCING: 'debouncing', - EXECUTING: 'executing', - COMPLETE: 'complete', - ERROR: 'error' + EXECUTING: 'executing', + COMPLETE: 'complete', + ERROR: 'error' }; - // ---------------------- Ephemeral 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.'); + // ---------------------- Persistent Command History ---------------------- + class CommandHistory { + constructor() { + this.key = 'ai_repo_commander_executed'; + this.ttl = CONFIG.DEDUPE_TTL_MS; + this.cleanup(); } - return BRIDGE_KEY; - } - - // ---------------------- Dedupe store (hash -> timestamp) ---------------------- - const SEEN_MAP = new Map(); - function hashBlock(str) { - // djb2/xor variant -> base36 - let h = 5381; - for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i); - return (h >>> 0).toString(36); - } - function alreadySeenBlock(blockText) { - const now = Date.now(); - const key = hashBlock(blockText); - const ts = SEEN_MAP.get(key); - if (ts && (now - ts) < CONFIG.SEEN_TTL_MS) return true; - SEEN_MAP.set(key, now); - // prune occasionally - for (const [k, t] of SEEN_MAP) if ((now - t) >= CONFIG.SEEN_TTL_MS) SEEN_MAP.delete(k); - return false; + _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); + } + 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 { @@ -118,7 +133,7 @@ } static createStatusElement(templateType, data) { const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; - const message = template.replace('{action}', data.action).replace('{details}', data.details); + 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; @@ -136,15 +151,105 @@ } } + // ---------------------- Paste helper ---------------------- + 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 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; + } + + el.focus(); + + // 1) Try a real-ish paste event with DataTransfer + try { + const dt = new DataTransfer(); + dt.setData('text/plain', text); + const pasteEvt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + if (el.dispatchEvent(pasteEvt) && !pasteEvt.defaultPrevented) return true; + } catch (_) { /* continue */ } + + // 2) Try execCommand insertText (still widely supported) + 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, text)) return true; + } catch (_) { /* continue */ } + + // 3) ProseMirror: inject paragraph HTML + notify input/change + const isPM = el.classList && el.classList.contains('ProseMirror'); + if (isPM) { + const escape = (s) => s.replace(/&/g,'&').replace(//g,'>'); + const html = String(text).split('\n').map(line => line.length ? `
${escape(line)}
` : '. We still want to execute.
- const isAssistant = !!element.closest?.('[data-message-author-role="assistant"]');
- if (isAssistant) return false;
-
- const nodes = element.querySelectorAll('pre, code');
- for (const el of nodes) {
- if ((el.textContent || '').includes('^%$bridge')) return true;
- }
- return false;
+ reextractCommandText(element) {
+ const hit = this.findCommandInCodeBlock(element);
+ return hit ? hit.text : '';
}
scanMessages() {
const messages = document.querySelectorAll(this.currentPlatform.messages);
messages.forEach((el) => {
- if (!this.isUserMessage(el)) return; // optional safety (disabled by default)
+ if (!this.isAssistantMessage(el)) return;
+ if (el.dataset.aiRcProcessed) return;
- const id = this.getMessageId(el);
- if (this.trackedMessages.has(id)) return;
+ const hit = this.findCommandInCodeBlock(el);
+ if (!hit) return;
- if (this.hasBridgeInCodeBlock(el)) return; // discussing examples → ignore
+ const cmdText = hit.text;
- const text = this.extractText(el);
- if (!text) return;
+ // Persistent dedupe: skip if we've already executed this exact block recently
+ if (this.history.has(cmdText)) {
+ this.log('Skipping already-executed command (persistent history)');
+ return;
+ }
- // Require a full ^%$bridge ... --- block to avoid false positives
- const m = text.match(/^\s*\^%\$bridge[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/m);
- if (!m) return;
-
- const wholeBlock = m[0];
- if (alreadySeenBlock(wholeBlock)) return; // dedupe across multi-wrapper renders
-
- this.trackMessage(el, text, id);
+ el.dataset.aiRcProcessed = '1';
+ this.trackMessage(el, cmdText, this.getMessageId(el));
});
}
trackMessage(element, text, messageId) {
- this.log('New command detected:', { messageId, text: text.substring(0, 120) });
+ this.log('New command detected in code block:', { messageId, preview: text.substring(0, 120) });
this.trackedMessages.set(messageId, {
element, originalText: text, state: COMMAND_STATES.DETECTED, startTime: Date.now(), lastUpdate: Date.now()
});
@@ -434,14 +600,6 @@
msg.lastUpdate = Date.now();
this.trackedMessages.set(messageId, msg);
this.log(`Message ${messageId} state updated to: ${state}`);
-
- // Terminal cleanup after grace period (kept shortly for debugging)
- if (state === COMMAND_STATES.COMPLETE || state === COMMAND_STATES.ERROR) {
- setTimeout(() => {
- this.trackedMessages.delete(messageId);
- this.log(`Cleaned up message ${messageId}`);
- }, CONFIG.CLEANUP_AFTER_MS);
- }
}
async processCommand(messageId) {
@@ -449,23 +607,50 @@
const message = this.trackedMessages.get(messageId);
if (!message) return;
- const parsed = CommandParser.parseYAMLCommand(message.originalText);
+ let parsed = CommandParser.parseYAMLCommand(message.originalText);
this.updateState(messageId, COMMAND_STATES.VALIDATING);
- const validation = CommandParser.validateStructure(parsed);
+ 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.extractText(message.element);
- if (after && after !== before) {
- this.log('Content changed during debounce; restarting debounce.');
+
+ // Re-extract after debounce in case the AI finished streaming or edited
+ const after = this.reextractCommandText(message.element);
+
+ // If command disappeared, abort gracefully
+ if (!after) {
+ this.log('Command removed during debounce - aborting');
+ this.updateState(messageId, COMMAND_STATES.ERROR);
+ return;
+ }
+
+ // If changed, re-debounce once and re-parse
+ if (after !== before) {
+ this.log('Command changed during debounce - updating and re-debouncing once');
+ message.originalText = after;
await this.debounce();
+
+ const finalTxt = this.reextractCommandText(message.element);
+ if (!finalTxt) {
+ this.log('Command removed after re-debounce - aborting');
+ 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(', ')}`);
}
this.updateState(messageId, COMMAND_STATES.EXECUTING);
await ExecutionManager.executeCommand(parsed, message.element);
+
+ // Mark the FINAL executed version in history
+ this.history.mark(message.originalText);
+
this.updateState(messageId, COMMAND_STATES.COMPLETE);
} catch (error) {
@@ -507,9 +692,12 @@
log(...args) { if (CONFIG.DEBUG_MODE) console.log('[AI Repo Commander]', ...args); }
}
- // ---------------------- Test commands (unchanged) ----------------------
+ // ---------------------- Test commands ----------------------
const TEST_COMMANDS = {
- validUpdate: `^%$bridge
+ validUpdate:
+`\
+\`\`\`yaml
+^%$bridge
action: update_file
repo: test-repo
path: TEST.md
@@ -517,17 +705,17 @@ content: |
Test content
Multiple lines
---
+\`\`\`
`,
- invalidCommand: `^%$bridge
-action: update_file
-repo: test-repo
----
-`,
- getFile: `^%$bridge
+ getFile:
+`\
+\`\`\`yaml
+^%$bridge
action: get_file
repo: test-repo
path: README.md
---
+\`\`\`
`
};
@@ -536,10 +724,17 @@ path: README.md
function initializeRepoCommander() {
if (!commandMonitor) {
commandMonitor = new CommandMonitor();
- window.AI_REPO_COMMANDER = { monitor: commandMonitor, config: CONFIG, test: TEST_COMMANDS, version: CONFIG.VERSION };
+ window.AI_REPO_COMMANDER = {
+ monitor: commandMonitor,
+ config: CONFIG,
+ test: TEST_COMMANDS,
+ version: CONFIG.VERSION,
+ history: commandMonitor.history
+ };
console.log('AI Repo Commander fully initialized');
console.log('API Enabled:', CONFIG.ENABLE_API);
console.log('Test commands available in window.AI_REPO_COMMANDER.test');
+ console.log('Reset history: window.AI_REPO_COMMANDER.history.reset() or AI_REPO_CLEAR_HISTORY()');
}
}