diff --git a/src/debug-panel.js b/src/debug-panel.js new file mode 100644 index 0000000..f4ab468 --- /dev/null +++ b/src/debug-panel.js @@ -0,0 +1,70 @@ +// ==DEBUG PANEL START== +// Depends on: config.js, logger.js, queue.js +(function () { + const cfg = () => window.AI_REPO_CONFIG; + const log = () => window.AI_REPO_LOGGER; + + class DebugPanel { + constructor() { this.root = null; } + mount() { + if (!cfg().get('debug.showPanel')) return; + if (this.root) return; + const root = document.createElement('div'); + root.style.cssText = ` + position:fixed; right:16px; bottom:16px; z-index:2147483647; width:460px; max-height:55vh; + display:flex; flex-direction:column; background:rgba(20,20,24,.92); border:1px solid #3b3b46; border-radius:8px; + color:#e5e7eb; font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; box-shadow:0 16px 40px rgba(0,0,0,.55); + `; + root.innerHTML = ` +
+ AI Repo Commander + + + +
+
+ `; + document.body.appendChild(root); + this.root = root; + this.body = root.querySelector('[data-body]'); + this._wire(); + this._tick(); + } + _wire() { + this.root.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-act]'); if (!btn) return; + const act = btn.getAttribute('data-act'); + if (act === 'copy') navigator.clipboard?.writeText(log().getRecentLogs(200)); + if (act === 'pause') { + const paused = !cfg().get('runtime.paused'); cfg().set('runtime.paused', paused); + btn.textContent = paused ? 'Resume' : 'Pause'; + log().info(paused ? 'Paused' : 'Resumed'); + } + if (act === 'clearq') window.AI_REPO_QUEUE.clear(); + }); + // queue badge + const q = window.AI_REPO_QUEUE; + if (q) q.onSizeChange = (n) => this._toast(`Queue: ${n}`); + } + _toast(msg) { + if (!this.root) return; + const t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'position:absolute; right:12px; bottom:12px; padding:6px 10px; background:#111827; color:#e5e7eb; border:1px solid #374151; border-radius:6px;'; + this.root.appendChild(t); setTimeout(() => t.remove(), 1000); + } + _tick() { + if (!this.body) return; + const rows = window.AI_REPO_LOGGER?.buffer?.slice(-80) || []; + this.body.innerHTML = rows.map(e => `
${e.timestamp} ${e.level} ${e.message}
`).join(''); + requestAnimationFrame(() => this._tick()); + } + } + + const panel = new DebugPanel(); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => panel.mount()); + else panel.mount(); + + window.AI_REPO_DEBUG_PANEL = panel; +})(); +// ==DEBUG PANEL END== diff --git a/src/detector.js b/src/detector.js new file mode 100644 index 0000000..2231944 --- /dev/null +++ b/src/detector.js @@ -0,0 +1,131 @@ +// ==DETECTOR START== +// Depends on: config.js, logger.js, queue.js, command-parser.js, command-executor.js, storage.js +(function () { + const cfg = () => window.AI_REPO_CONFIG; + const log = () => window.AI_REPO_LOGGER; + + function extractAllBlocks(text) { + const out = []; const re = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/gm; + let m; while ((m = re.exec(text)) !== null) out.push(m[0]); + return out; + } + + function isAssistantMsg(el) { + const sels = [ + '[data-message-author-role="assistant"]', + '.chat-message:not([data-message-author-role="user"])', + '.message-content' + ]; + return sels.some(s => el.matches?.(s) || el.querySelector?.(s)); + } + + async function settleText(el, initial, windowMs, pollMs) { + let deadline = Date.now() + windowMs; + let last = initial; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, pollMs)); + const fresh = el.textContent || ''; + const blocks = extractAllBlocks(fresh); + const pick = blocks.join('\n'); // if multiple, concatenate for stability check (we’ll split later) + if (pick === last && pick) continue; // stable—keep waiting out the window + if (pick && pick !== last) { last = pick; deadline = Date.now() + windowMs; } + } + return last; + } + + class Detector { + constructor() { + this.observer = null; + this.processed = new WeakSet(); + this.clusterLookahead = 3; + this.clusterWindowMs = 1000; + } + start() { + this.observer = new MutationObserver((mutations) => { + if (cfg().get('runtime.paused')) return; + let should = false; + for (const m of mutations) { + if (m.type === 'childList') { + for (const n of m.addedNodes) { + if (n.nodeType === 1 && isAssistantMsg(n)) { this._handle(n); should = true; } + } + } + if (m.type === 'characterData') { + const el = m.target?.parentElement; + if (el && isAssistantMsg(el)) should = true; + } + } + if (should) {/* no-op: _handle already queued */} + }); + this.observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true }); + if (cfg().get('ui.processExisting')) { + document.querySelectorAll('[data-message-author-role], .chat-message, .message-content') + .forEach(el => isAssistantMsg(el) && this._handle(el)); + } + log().info('Detector started'); + } + + async _handle(el) { + if (this.processed.has(el)) return; + this.processed.add(el); + + // Debounce complete generation + const debounce = cfg().get('execution.debounceDelay') || 0; + if (debounce > 0) await new Promise(r => setTimeout(r, debounce)); + + // Settle + const baseText = el.textContent || ''; + const stable = await settleText(el, baseText, cfg().get('execution.settleCheckMs') || 1200, cfg().get('execution.settlePollMs') || 250); + const blocks = extractAllBlocks(stable); + if (!blocks.length) { this.processed.delete(el); return; } // not a command after all + + const maxPerMsg = cfg().get('queue.maxPerMessage') || 5; + blocks.slice(0, maxPerMsg).forEach((cmdText, idx) => this._enqueueOne(el, cmdText, idx)); + + // Cluster rescan: look ahead a few assistant messages for chained blocks + setTimeout(() => this._clusterRescan(el), this.clusterWindowMs); + } + + _enqueueOne(el, commandText, idx) { + const history = window.AI_REPO_HISTORY; + if (history.isProcessed(el, idx)) { + this._addRunAgain(el, commandText, idx); + return; + } + history.markProcessed(el, idx); + + window.AI_REPO_QUEUE.push(async () => { + try { + const parsed = window.AI_REPO_PARSER.parse(commandText); + const v = window.AI_REPO_PARSER.validate(parsed); + if (!v.isValid) throw new Error(`Validation failed: ${v.errors.join(', ')}`); + if (v.example) { log().info('Example command skipped'); return; } + await window.AI_REPO_EXECUTOR.execute(parsed, el, `[${idx + 1}] ${parsed.action}`); + } catch (e) { + log().error('Command failed', { error: e.message }); + this._addRunAgain(el, commandText, idx); + } + }); + } + + _addRunAgain(el, commandText, idx) { + const btn = document.createElement('button'); + btn.textContent = `Run Again #${idx + 1}`; + btn.style.cssText = 'padding:4px 8px;margin:4px;border:1px solid #374151;border-radius:4px;background:#1f2937;color:#e5e7eb;cursor:pointer;'; + btn.addEventListener('click', () => this._enqueueOne(el, commandText, idx)); + el.appendChild(btn); + } + + _clusterRescan(anchor) { + let scanned = 0; let cur = anchor.nextElementSibling; + while (cur && scanned < this.clusterLookahead) { + if (!isAssistantMsg(cur)) break; + if (!this.processed.has(cur)) this._handle(cur); + scanned++; cur = cur.nextElementSibling; + } + } + } + + window.AI_REPO_DETECTOR = new Detector(); +})(); +// ==DETECTOR END== diff --git a/src/fingerprint-strong.js b/src/fingerprint-strong.js new file mode 100644 index 0000000..185aa21 --- /dev/null +++ b/src/fingerprint-strong.js @@ -0,0 +1,35 @@ +// ==FINGERPRINT (drop-in utility) == +(function(){ + function norm(s){ return (s||'').replace(/\r/g,'').replace(/\u200b/g,'').replace(/[ \t]+\n/g,'\n').trim(); } + function hash(s){ let h=5381; for(let i=0;i>>0).toString(36); } + function commandLikeText(el){ + const blocks = el.querySelectorAll('pre code, pre, code'); + for (const b of blocks) { + const t = norm(b.textContent || ''); + if (/@end@\s*$/m.test(t) && /(^|\n)\s*@bridge@\b/m.test(t) && /(^|\n)\s*action\s*:/m.test(t)) return t; + } + return norm((el.textContent || '').slice(0, 2000)); + } + function prevContextHash(el) { + const list = Array.from(document.querySelectorAll('[data-message-author-role], .chat-message, .message-content')); + const idx = list.indexOf(el); if (idx <= 0) return '0'; + let rem = 2000, buf = ''; + for (let i=idx-1; i>=0 && rem>0; i--){ + const t = norm(list[i].textContent || ''); if (!t) continue; + const take = t.slice(-rem); buf = take + buf; rem -= take.length; + } + return hash(buf.slice(-2000)); + } + function intraPrefixHash(el){ + const t = el.textContent || ''; + const m = t.match(/@bridge@[\s\S]*?@end@/m); + const endIdx = m ? t.indexOf(m[0]) : t.length; + return hash(norm(t.slice(Math.max(0, endIdx - 2000), endIdx))); + } + window.AI_REPO_FINGERPRINT = function(el){ + const ch = hash(commandLikeText(el).slice(0, 2000)); + const ph = prevContextHash(el); + const ih = intraPrefixHash(el); + return `ch:${ch}|ph:${ph}|ih:${ih}`; + }; +})(); diff --git a/src/main.js b/src/main.js index 878925c..9d102e9 100644 --- a/src/main.js +++ b/src/main.js @@ -154,6 +154,8 @@ } else { window.AI_REPO_MAIN = new AIRepoCommander(); window.AI_REPO_MAIN.initialize(); + // Kick off the advanced detector (restores settle/debounce, multi-block, cluster rescan) + window.AI_REPO_DETECTOR?.start(); } })(); // ==MAIN END== diff --git a/src/paste-submit.js b/src/paste-submit.js new file mode 100644 index 0000000..eea8af8 --- /dev/null +++ b/src/paste-submit.js @@ -0,0 +1,139 @@ +// ==PASTE SUBMIT START== +// Depends on: config.js, logger.js +/* global GM_setClipboard */ +(function () { + const cfg = () => window.AI_REPO_CONFIG; + const log = () => window.AI_REPO_LOGGER; + + function findComposer() { + const sels = [ + '#prompt-textarea', + '.ProseMirror#prompt-textarea', + '.ProseMirror[role="textbox"][contenteditable="true"]', + '[data-testid="composer"] [contenteditable="true"][role="textbox"]', + 'main [contenteditable="true"][role="textbox"]', + 'textarea[data-testid="input-area"]', + '[contenteditable="true"][aria-label*="Message"]', + 'textarea', + '[contenteditable="true"]' + ]; + for (const s of sels) { + const el = document.querySelector(s); + if (!el) continue; + const st = window.getComputedStyle(el); + if (st.display === 'none' || st.visibility === 'hidden') continue; + if (el.offsetParent === null && st.position !== 'fixed') continue; + return el; + } + return null; + } + + function findSendButton(scopeEl) { + const scope = scopeEl?.closest('form, [data-testid="composer"], main, body') || document; + const sels = [ + 'button[data-testid="send-button"]', + '#composer-submit-button', + 'button[aria-label*="Send prompt"]', + 'button[aria-label*="Send message"]', + 'button[aria-label="Send"]', + 'button[aria-label*="Send"]', + 'form button' + ]; + for (const s of sels) { + const b = scope.querySelector(s) || document.querySelector(s); + if (!b) continue; + const st = window.getComputedStyle(b); + const disabled = b.disabled || b.getAttribute('aria-disabled') === 'true'; + const hidden = st.display === 'none' || st.visibility === 'hidden'; + const notRendered = b.offsetParent === null && st.position !== 'fixed'; + if (!disabled && !hidden && !notRendered) return b; + } + return null; + } + + function pressEnter(el) { + for (const t of ['keydown','keypress','keyup']) { + const ok = el.dispatchEvent(new KeyboardEvent(t, { key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true, cancelable:true })); + if (!ok) return false; + } + return true; + } + + async function waitReady(timeoutMs = 12000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const el = findComposer(); + if (el) { + const current = (el.textContent || el.value || '').trim(); + const busy = el.closest('form, [data-testid="composer"], main, body')?.querySelector('[aria-busy="true"], [data-state="loading"], .typing-indicator'); + if (!busy && current.length === 0) return true; + } + await new Promise(r => setTimeout(r, 200)); + } + return false; + } + + function pasteInto(el, text) { + const payload = cfg().get('ui.appendTrailingNewline') ? (text.endsWith('\n') ? text : text + '\n') : text; + try { + // ClipboardEvent path + const dt = new DataTransfer(); dt.setData('text/plain', payload); + const evt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }); + if (el.dispatchEvent(evt) && !evt.defaultPrevented) return true; + } catch {} + // ProseMirror path + if (el.classList?.contains('ProseMirror')) { + const node = document.createTextNode('\n' + payload.replace(/\n?$/,'\n') + '\n'); + el.innerHTML = ''; el.appendChild(node); + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + // Selection/contentEditable path + try { + if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { + const sel = window.getSelection(); if (sel && sel.rangeCount === 0) { + const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); + } + const range = window.getSelection()?.getRangeAt(0); + if (range) { + range.deleteContents(); const node = document.createTextNode(payload); + range.insertNode(node); range.setStartAfter(node); range.setEndAfter(node); + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + } + } catch {} + // Textarea path + if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { + el.value = payload; el.dispatchEvent(new Event('input', { bubbles: true })); return true; + } + // Fallback to clipboard + try { + if (typeof GM_setClipboard === 'function') { + GM_setClipboard(payload, { type:'text', mimetype:'text/plain' }); + alert('Content copied to clipboard. Press Ctrl/Cmd+V to paste.'); + return true; + } + } catch {} + return false; + } + + async function submitToComposer(text) { + const auto = !!cfg().get('ui.autoSubmit'); + const ok = await waitReady(cfg().get('execution.settleCheckMs') || 1200); + if (!ok) { log()?.warn('Composer not ready'); return false; } + + const el = findComposer(); if (!el) { log()?.warn('Composer not found'); return false; } + if (text && !pasteInto(el, text)) { log()?.warn('Paste failed'); return false; } + + await new Promise(r => setTimeout(r, cfg().get('ui.postPasteDelayMs') || 600)); + if (!auto) return true; + + const btn = findSendButton(el); + if (btn) { btn.click(); return true; } + return pressEnter(el); + } + + window.AI_REPO_PASTE = { submitToComposer }; +})(); +// ==PASTE SUBMIT END== diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000..f543374 --- /dev/null +++ b/src/queue.js @@ -0,0 +1,43 @@ +// ==QUEUE START== +// Depends on: config.js, logger.js +(function () { + class ExecutionQueue { + constructor(opts = {}) { + const cfg = window.AI_REPO_CONFIG; + this.minDelayMs = opts.minDelayMs ?? cfg.get('queue.minDelayMs') ?? 1500; + this.maxPerMinute = opts.maxPerMinute ?? cfg.get('queue.maxPerMinute') ?? 15; + this.q = []; + this.running = false; + this.timestamps = []; + this.onSizeChange = null; + } + push(task) { + this.q.push(task); + this.onSizeChange?.(this.q.length); + if (!this.running) void this._drain(); + } + clear() { this.q.length = 0; this.onSizeChange?.(0); } + size() { return this.q.length; } + _withinBudget() { + const now = Date.now(); + this.timestamps = this.timestamps.filter(t => now - t < 60_000); + return this.timestamps.length < this.maxPerMinute; + } + async _drain() { + if (this.running) return; + this.running = true; + while (this.q.length) { + while (!this._withinBudget()) await this._delay(400); + const fn = this.q.shift(); + this.onSizeChange?.(this.q.length); + try { await fn(); } catch (e) { window.AI_REPO_LOGGER?.warn('Queue task error', { error: String(e) }); } + this.timestamps.push(Date.now()); + await this._delay(this.minDelayMs); + } + this.running = false; + } + _delay(ms){ return new Promise(r => setTimeout(r, ms)); } + } + window.AI_REPO_QUEUE = new ExecutionQueue(); +})(); +// ==QUEUE END== diff --git a/src/response-buffer.js b/src/response-buffer.js new file mode 100644 index 0000000..1cc049f --- /dev/null +++ b/src/response-buffer.js @@ -0,0 +1,71 @@ +// ==RESPONSE BUFFER START== +// Depends on: config.js, logger.js, queue.js, paste-submit.js +(function () { + function chunkByLines(s, limit) { + const out = []; let start = 0; + while (start < s.length) { + const soft = s.lastIndexOf('\n', Math.min(start + limit, s.length)); + const end = soft > start ? soft + 1 : Math.min(start + limit, s.length); + out.push(s.slice(start, end)); start = end; + } + return out; + } + function isSingleFence(s){ return /^```[^\n]*\n[\s\S]*\n```$/.test(s.trim()); } + function splitRespectingFence(text, limit) { + const t = text.trim(); if (!isSingleFence(t)) return chunkByLines(text, limit); + const m = /^```([^\n]*)\n([\s\S]*)\n```$/.exec(t); + const lang = (m?.[1] || 'text').trim(); const inner = m?.[2] ?? ''; + const chunks = chunkByLines(inner, limit - 16 - lang.length); + return chunks.map(c => '```' + lang + '\n' + c.replace(/\n?$/, '\n') + '```'); + } + + class ResponseBuffer { + constructor() { + this.pending = []; this.timer = null; this.flushing = false; + } + push({ label, content }) { + if (!content) return; + this.pending.push({ label, content }); + this._schedule(); + } + _schedule() { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.flush(), 500); + } + _build() { + const showHeadings = true; // readable by default + const parts = []; + for (const { label, content } of this.pending) { + if (showHeadings && label) parts.push(`### ${label}\n`); + parts.push(String(content).trimEnd(), ''); + } + return parts.join('\n'); + } + async flush() { + if (this.flushing || !this.pending.length) return; + this.flushing = true; + const toPaste = this._build(); this.pending.length = 0; + try { + const limit = 250_000; + if (toPaste.length > limit) { + const chunks = splitRespectingFence(toPaste, limit); + chunks.forEach((c, i) => { + const header = `### Part ${i+1}/${chunks.length}\n`; + const payload = header + c; + window.AI_REPO_QUEUE.push(async () => { + await window.AI_REPO_PASTE.submitToComposer(payload); + }); + }); + } else { + window.AI_REPO_QUEUE.push(async () => { + await window.AI_REPO_PASTE.submitToComposer(toPaste); + }); + } + } finally { + this.flushing = false; + } + } + } + window.AI_REPO_RESPONSES = new ResponseBuffer(); +})(); +// ==RESPONSE BUFFER END== diff --git a/src/storage.js b/src/storage.js index 96873d9..2905aed 100644 --- a/src/storage.js +++ b/src/storage.js @@ -34,11 +34,10 @@ } _fingerprint(el, idx) { - const text = (el.textContent || '').slice(0, 1000); - const list = Array.from(document.querySelectorAll('[data-message-author-role], .chat-message, .message-content')); - const pos = list.indexOf(el); - return `conv:${this.conversationId}|pos:${pos}|idx:${idx}|hash:${this._hash(text)}`; + const base = window.AI_REPO_FINGERPRINT ? window.AI_REPO_FINGERPRINT(el) : this._hash((el.textContent||'').slice(0,1000)); + return `${base}|idx:${idx}`; } + _hash(str) { let h = 5381; for (let i = 0; i < Math.min(str.length, 1000); i++) h = ((h << 5) + h) ^ str.charCodeAt(i); diff --git a/src/userscript-bootstrap.user.js b/src/userscript-bootstrap.user.js index 0663cd9..afbdb2b 100644 --- a/src/userscript-bootstrap.user.js +++ b/src/userscript-bootstrap.user.js @@ -1,13 +1,15 @@ // ==UserScript== -// @name AI Repo Commander (Modular) +// @name AI Repo Commander (Full Features) // @namespace http://tampermonkey.net/ -// @version 2.0.0 -// @description Modularized AI Repo Commander +// @version 2.1.0 +// @description Full modular AI Repo Commander with all features +// @author Robert Dickson // @match https://chat.openai.com/* // @match https://chatgpt.com/* // @match https://claude.ai/* // @match https://gemini.google.com/* // @grant GM_xmlhttpRequest +// @grant GM_setClipboard // @connect n8n.brrd.tech // @connect * // @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/config.js @@ -16,4 +18,9 @@ // @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/command-parser.js // @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/command-executor.js // @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/main.js -// ==/UserScript== +// @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/queue.js +// @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/response-buffer.js +// @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/paste-submit.js +// @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/detector.js +// @require https://gitea.brrd.tech/rob/AI-Repo-Commander/raw/branch/refactor-structure/src/debug-panel.js +// ==/UserScript== \ No newline at end of file