From 205c2561feefa9a8a54c86b4e90ecbd520e512e6 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 15 Oct 2025 20:09:08 -0300 Subject: [PATCH] initial skeleton --- src/command-executor.js | 112 ++++++++++++++++++++++ src/command-parser.js | 98 +++++++++++++++++++ src/config.js | 99 +++++++++++++++++++ src/logger.js | 57 +++++++++++ src/main.js | 159 +++++++++++++++++++++++++++++++ src/storage.js | 64 +++++++++++++ src/userscript-bootstrap.user.js | 20 ++++ 7 files changed, 609 insertions(+) create mode 100644 src/command-executor.js create mode 100644 src/command-parser.js create mode 100644 src/config.js create mode 100644 src/logger.js create mode 100644 src/main.js create mode 100644 src/storage.js create mode 100644 src/userscript-bootstrap.user.js diff --git a/src/command-executor.js b/src/command-executor.js new file mode 100644 index 0000000..826372d --- /dev/null +++ b/src/command-executor.js @@ -0,0 +1,112 @@ +// ==COMMAND EXECUTOR START== +/* global GM_xmlhttpRequest */ +(function () { + class CommandExecutor { + static async execute(command, sourceElement, label = '') { + const log = window.AI_REPO_LOGGER; + const cfg = window.AI_REPO_CONFIG; + + try { + if (['update_file', 'create_file'].includes(command.action) && !command.commit_message) { + command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; + } + + if (!cfg.get('api.enabled')) { + log.info('Mock executing', { action: command.action, label }); + await this.delay(300); + return this._success({ status: 200, responseText: JSON.stringify({ success: true, message: 'Mock execution completed' }) }, command, sourceElement, true, label); + } + + log.info('Executing via API', { action: command.action, label }); + const res = await this._api(command); + return this._success(res, command, sourceElement, false, label); + + } catch (err) { + window.AI_REPO_LOGGER.error('Execution failed', { action: command.action, error: err.message }); + return this._error(err, command, sourceElement, label); + } + } + + static _api(command, attempt = 0) { + const cfg = window.AI_REPO_CONFIG; + const maxRetries = cfg.get('api.maxRetries') ?? 2; + const timeout = cfg.get('api.timeout') ?? 60000; + const bridgeKey = this._getBridgeKey(); + + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'POST', + url: command.url, + headers: { 'X-Bridge-Key': bridgeKey, 'Content-Type': 'application/json' }, + data: JSON.stringify(command), + timeout, + onload: (r) => (r.status >= 200 && r.status < 300) ? resolve(r) : reject(new Error(`API Error ${r.status}: ${r.statusText}`)), + onerror: (e) => { + if (attempt < maxRetries) { + setTimeout(() => this._api(command, attempt + 1).then(resolve).catch(reject), 1000 * (attempt + 1)); + } else reject(new Error(`Network error after ${attempt + 1} attempts: ${e?.error || 'unknown'}`)); + }, + ontimeout: () => reject(new Error(`API timeout after ${timeout}ms`)) + }); + }); + } + + static _getBridgeKey() { + const cfg = window.AI_REPO_CONFIG; + let key = cfg.get('api.bridgeKey'); + if (!key) { + key = prompt('[AI Repo Commander] Enter your bridge key for this session:') || ''; + if (!key) throw new Error('Bridge key required when API is enabled'); + if (confirm('Save this bridge key to avoid future prompts?')) cfg.set('api.bridgeKey', key); + } + return key; + } + + static _success(response, command, el, isMock = false, label = '') { + let data; try { data = JSON.parse(response.responseText || '{}'); } catch { data = { message: 'Operation completed' }; } + this._status(el, isMock ? 'MOCK' : 'SUCCESS', { action: command.action, details: data.message || 'Completed successfully', label }); + + if (command.action === 'get_file') this._handleGetFile(data, label); + if (command.action === 'list_files') this._handleListFiles(data, label); + + return { success: true, data, isMock }; + } + + static _error(error, command, el, label = '') { + this._status(el, 'ERROR', { action: command.action, details: error.message, label }); + return { success: false, error: error.message }; + } + + static _status(el, type, data) { + const div = document.createElement('div'); + div.style.cssText = ` + padding:8px 12px;margin:8px 0;border-radius:4px; + border-left:4px solid ${this._color(type)}; + background:rgba(255,255,255,.05);font-family:monospace;font-size:13px;white-space:pre-wrap; + `; + div.textContent = `${data.label || data.action} — ${type}${data.details ? ': ' + data.details : ''}`; + el.appendChild(div); + } + static _color(t){ return ({SUCCESS:'#10B981', ERROR:'#EF4444', MOCK:'#8B5CF6'})[t] || '#6B7280'; } + + static _handleGetFile(data, label) { + const content = data?.content?.data ?? data?.content ?? data?.result?.content?.data ?? data?.result?.content; + if (!content) return; + window.AI_REPO_RESPONSES = window.AI_REPO_RESPONSES || []; + window.AI_REPO_RESPONSES.push({ label, content }); + } + + static _handleListFiles(data, label) { + const files = data?.files ?? data?.result?.files; + if (!Array.isArray(files)) return; + const listing = '```text\n' + files.map(f => (typeof f === 'string' ? f : (f?.path || f?.name || JSON.stringify(f)))).join('\n') + '\n```'; + window.AI_REPO_RESPONSES = window.AI_REPO_RESPONSES || []; + window.AI_REPO_RESPONSES.push({ label, content: listing }); + } + + static delay(ms) { return new Promise(r => setTimeout(r, ms)); } + } + + window.AI_REPO_EXECUTOR = CommandExecutor; +})(); +// ==COMMAND EXECUTOR END== diff --git a/src/command-parser.js b/src/command-parser.js new file mode 100644 index 0000000..f35b25a --- /dev/null +++ b/src/command-parser.js @@ -0,0 +1,98 @@ +// ==COMMAND PARSER START== +(function () { + class CommandParser { + static REQUIRED = { + get_file: ['action', 'repo', 'path'], + update_file: ['action', 'repo', 'path', 'content'], + create_file: ['action', 'repo', 'path', 'content'], + create_repo: ['action', 'repo'], + create_branch:['action', 'repo', 'branch'], + create_pr: ['action', 'repo', 'title', 'head', 'base'], + list_files: ['action', 'repo', 'path'] + }; + + static parse(text) { + const block = this.extractBlock(text); + if (!block) throw new Error('No complete @bridge@ command found (missing @end@)'); + const parsed = this.parseKV(block); + this.applyDefaults(parsed); + return parsed; + } + + static extractBlock(text) { + const m = /^\s*@bridge@[ \t]*\n([\s\S]*?)\n@end@[ \t]*(?:\n|$)/m.exec(text); + return m?.[1]?.trim() || null; + } + + // Simple YAML-like parser (supports "key: value" & "key: |" multiline) + static parseKV(block) { + const out = {}; + const lines = block.split('\n'); + let curKey = null, multi = false, buf = []; + + const flush = () => { if (multi && curKey) out[curKey] = buf.join('\n').replace(/\s+$/,''); curKey = null; buf = []; multi = false; }; + + for (const raw of lines) { + const line = raw.replace(/\r$/, ''); + + if (multi) { + // End multiline if we see an unindented key pattern + if (/^[A-Za-z_][\w\-]*\s*:/.test(line) && !/^\s/.test(line)) { + flush(); + } 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 === '|') { + curKey = key; multi = true; buf = []; + } else { + out[key] = value; + curKey = key; + } + } else if (multi) { + buf.push(line); + } + } + flush(); + return out; + } + + static applyDefaults(p) { + p.url = p.url || 'https://n8n.brrd.tech/webhook/ai-gitea-bridge'; + p.owner = p.owner || 'rob'; + if (p.action === 'create_branch' && !p.source_branch) p.source_branch = 'main'; + if (typeof p.repo === 'string' && p.repo.includes('/')) { + const [owner, repo] = p.repo.split('/', 2); + if (!p.owner) p.owner = owner; + p.repo = repo; + } + } + + static validate(p) { + const errors = []; + + // explicit example flag + if (p.example === true || String(p.example).toLowerCase() === 'true' || String(p.example).toLowerCase() === 'yes') { + return { isValid: true, errors: [], example: true }; + } + + const action = p.action; + if (!action) return { isValid: false, errors: ['Missing required field: action'] }; + const req = this.REQUIRED[action]; + if (!req) return { isValid: false, errors: [`Unknown action: ${action}`] }; + + for (const f of req) if (!(f in p) || p[f] === '') errors.push(`Missing required field: ${f}`); + + return { isValid: errors.length === 0, errors }; + } + } + + window.AI_REPO_PARSER = CommandParser; +})(); +// ==COMMAND PARSER END== diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..52afefe --- /dev/null +++ b/src/config.js @@ -0,0 +1,99 @@ +// ==CONFIG START== +(function () { + const STORAGE_KEYS = { + history: 'ai_repo_commander_executed', + cfg: 'ai_repo_commander_cfg', + panel: 'ai_repo_commander_panel_state' + }; + + const DEFAULT_CONFIG = { + meta: { version: '1.6.2' }, + + api: { + enabled: true, + timeout: 60000, + maxRetries: 2, + bridgeKey: '' + }, + + debug: { + enabled: true, + level: 2, // 0=off, 1=errors, 2=info, 3=verbose, 4=trace + maxLines: 400, + showPanel: true + }, + + execution: { + debounceDelay: 6500, + settleCheckMs: 1300, + settlePollMs: 250, + requireTerminator: true + }, + + queue: { + minDelayMs: 1500, + maxPerMinute: 15, + maxPerMessage: 5 + }, + + ui: { + autoSubmit: true, + appendTrailingNewline: true, + postPasteDelayMs: 600, + showExecutedMarker: true, + processExisting: false // used by main.js + }, + + // Runtime state (not persisted) + runtime: { + paused: false + } + }; + + class ConfigManager { + constructor() { this.config = this.load(); } + load() { + try { + const raw = localStorage.getItem(STORAGE_KEYS.cfg); + if (!raw) return this.deepClone(DEFAULT_CONFIG); + const saved = JSON.parse(raw); + return this.mergeConfigs(DEFAULT_CONFIG, saved); + } catch { + return this.deepClone(DEFAULT_CONFIG); + } + } + save() { + try { + const { runtime, ...persistable } = this.config; // do not persist runtime + localStorage.setItem('ai_repo_commander_cfg', JSON.stringify(persistable)); + } catch (e) { console.warn('Failed to save config:', e); } + } + get(keyPath) { + return keyPath.split('.').reduce((obj, key) => obj?.[key], this.config); + } + set(keyPath, value) { + const keys = keyPath.split('.'); + const last = keys.pop(); + const tgt = keys.reduce((o, k) => (o[k] = o[k] || {}), this.config); + tgt[last] = value; + this.save(); + } + mergeConfigs(defaults, saved) { + const out = this.deepClone(defaults); + for (const k of Object.keys(saved)) { + if (k === 'runtime') continue; // never restore runtime + if (typeof out[k] === 'object' && !Array.isArray(out[k])) { + out[k] = { ...out[k], ...saved[k] }; + } else { + out[k] = saved[k]; + } + } + return out; + } + deepClone(o) { return JSON.parse(JSON.stringify(o)); } + } + + window.AI_REPO_CONFIG = new ConfigManager(); + window.AI_REPO_STORAGE_KEYS = STORAGE_KEYS; +})(); +// ==CONFIG END== diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..4234085 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,57 @@ +// ==LOGGER START== +(function () { + class Logger { + constructor() { + this.config = window.AI_REPO_CONFIG; + this.buffer = []; + } + 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, 'VERBOSE', msg, data); } + trace(msg, data) { this._log(5, 'TRACE', msg, data); } + + _log(levelNum, levelName, msg, data) { + const enabled = !!this.config.get('debug.enabled'); + const level = this.config.get('debug.level') ?? 0; + if (!enabled || levelNum > level) return; + + const entry = { + timestamp: new Date().toISOString(), + level: levelName, + message: String(msg), + data: this._sanitize(data) + }; + this.buffer.push(entry); + const maxLines = this.config.get('debug.maxLines') || 400; + if (this.buffer.length > maxLines) this.buffer.splice(0, this.buffer.length - maxLines); + + const prefix = `[AI RC ${levelName}]`; + entry.data ? console.log(prefix, msg, entry.data) : console.log(prefix, msg); + } + + _sanitize(data) { + if (!data) return null; + if (data instanceof HTMLElement) return `HTMLElement<${data.tagName}>`; + if (typeof data === 'string' && data.length > 200) return data.slice(0, 200) + '…'; + if (typeof data === 'object') { + const out = {}; + for (const [k, v] of Object.entries(data)) { + out[k] = v instanceof HTMLElement ? `HTMLElement<${v.tagName}>` : + (typeof v === 'string' && v.length > 200 ? v.slice(0, 200) + '…' : v); + } + return out; + } + return data; + } + + getRecentLogs(n = 50) { + return this.buffer.slice(-n).map(e => + `${e.timestamp} ${e.level.padEnd(7)} ${e.message}${e.data ? ' ' + JSON.stringify(e.data) : ''}` + ).join('\n'); + } + } + + window.AI_REPO_LOGGER = new Logger(); +})(); +// ==LOGGER END== diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..878925c --- /dev/null +++ b/src/main.js @@ -0,0 +1,159 @@ +// ==MAIN START== +(function () { + 'use strict'; + + if (!window.AI_REPO_CONFIG || !window.AI_REPO_LOGGER || !window.AI_REPO_HISTORY || !window.AI_REPO_PARSER || !window.AI_REPO_EXECUTOR) { + console.error('AI Repo Commander: Core modules not loaded'); + return; + } + + const logger = window.AI_REPO_LOGGER; + const config = window.AI_REPO_CONFIG; + const history = window.AI_REPO_HISTORY; + + class AIRepoCommander { + constructor() { + this.isInitialized = false; + this.observer = null; + this.processed = new WeakSet(); + this.messageSelectors = [ + '[data-message-author-role="assistant"]', + '.chat-message:not([data-message-author-role="user"])', + '.message-content' + ]; + } + + initialize() { + if (this.isInitialized) return; + logger.info('AI Repo Commander initializing', { + version: config.get('meta.version'), + debugLevel: config.get('debug.level'), + apiEnabled: config.get('api.enabled') + }); + + this.startObserver(); + if (config.get('ui.processExisting')) this.scanExisting(); + this.exposeAPI(); + + this.isInitialized = true; + logger.info('AI Repo Commander initialized'); + } + + startObserver() { + this.observer = new MutationObserver((mutations) => { + if (config.get('runtime.paused')) return; + for (const m of mutations) { + if (m.type !== 'childList') continue; + for (const n of m.addedNodes) { + if (n.nodeType !== 1) continue; + if (this.isAssistantMessage(n)) this.processMessage(n); + const inner = n.querySelectorAll?.(this.messageSelectors.join(',')) || []; + inner.forEach(el => this.isAssistantMessage(el) && this.processMessage(el)); + } + } + }); + this.observer.observe(document.body, { childList: true, subtree: true }); + } + + isAssistantMessage(el) { + return this.messageSelectors.some(sel => el.matches?.(sel)); + } + + processMessage(el) { + if (this.processed.has(el)) return; + const commands = this.extractCommands(el); + if (!commands.length) return; + + this.processed.add(el); + commands.slice(0, config.get('queue.maxPerMessage')).forEach((cmdText, idx) => { + if (history.isProcessed(el, idx)) { + this.addRetryButton(el, cmdText, idx); + } else { + this.run(el, cmdText, idx); + } + }); + } + + extractCommands(el) { + const text = el.textContent || ''; + const out = []; + const re = /@bridge@[\s\S]*?@end@/g; + let m; + while ((m = re.exec(text)) !== null) out.push(m[0]); + return out; + } + + async run(el, commandText, index) { + try { + history.markProcessed(el, index); + + const parsed = window.AI_REPO_PARSER.parse(commandText); + const validation = window.AI_REPO_PARSER.validate(parsed); + if (!validation.isValid) { + logger.error('Command validation failed', { errors: validation.errors, command: parsed.action }); + this.addRetryButton(el, commandText, index); + return; + } + if (validation.example) { + logger.info('Skipping example command'); + return; + } + + await this.delay(config.get('execution.debounceDelay') || 0); + const label = `Command ${index + 1}`; + await window.AI_REPO_EXECUTOR.execute(parsed, el, label); + + } catch (e) { + logger.error('Command execution failed', { error: e.message, commandIndex: index }); + this.addRetryButton(el, commandText, index); + } + } + + addRetryButton(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.run(el, commandText, idx)); + el.appendChild(btn); + } + + scanExisting() { + const nodes = document.querySelectorAll(this.messageSelectors.join(',')); + nodes.forEach(el => this.isAssistantMessage(el) && this.processMessage(el)); + } + + exposeAPI() { + window.AI_REPO_COMMANDER = { + version: config.get('meta.version'), + config, + logger, + history, + pause: () => { config.set('runtime.paused', true); logger.info('Paused'); }, + resume: () => { config.set('runtime.paused', false); logger.info('Resumed'); }, + clearHistory: () => { history.clear(); logger.info('History cleared'); } + }; + } + + delay(ms) { return new Promise(r => setTimeout(r, ms)); } + destroy() { + this.observer?.disconnect(); + this.processed = new WeakSet(); + this.isInitialized = false; + logger.info('AI Repo Commander destroyed'); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.AI_REPO_MAIN = new AIRepoCommander(); + window.AI_REPO_MAIN.initialize(); + }); + } else { + window.AI_REPO_MAIN = new AIRepoCommander(); + window.AI_REPO_MAIN.initialize(); + } +})(); +// ==MAIN END== diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..96873d9 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,64 @@ +// ==STORAGE START== +(function () { + class ConversationHistory { + constructor() { + this.conversationId = this._getConversationId(); + this.key = `ai_rc:conv:${this.conversationId}:processed`; + this.cache = this._load(); + this._cleanupExpired(); + } + + _getConversationId() { + const host = location.hostname.replace('chat.openai.com', 'chatgpt.com'); + return `${host}:${location.pathname || '/'}`; + } + + _load() { + try { return JSON.parse(localStorage.getItem(this.key) || '{}'); } + catch { return {}; } + } + _save() { + try { localStorage.setItem(this.key, JSON.stringify(this.cache)); } + catch (e) { window.AI_REPO_LOGGER?.warn('Failed to save history cache', { error: e.message }); } + } + + isProcessed(el, commandIndex = 0) { + const fp = this._fingerprint(el, commandIndex); + return Object.prototype.hasOwnProperty.call(this.cache, fp); + } + markProcessed(el, commandIndex = 0) { + const fp = this._fingerprint(el, commandIndex); + this.cache[fp] = Date.now(); + this._save(); + if (window.AI_REPO_CONFIG.get('ui.showExecutedMarker')) this._mark(el); + } + + _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)}`; + } + _hash(str) { + let h = 5381; + for (let i = 0; i < Math.min(str.length, 1000); i++) h = ((h << 5) + h) ^ str.charCodeAt(i); + return (h >>> 0).toString(36); + } + _mark(el) { try { el.style.borderLeft = '3px solid #10B981'; } catch {} } + + _cleanupExpired() { + const ttl = 30 * 24 * 60 * 60 * 1000; + const now = Date.now(); + let dirty = false; + for (const [k, ts] of Object.entries(this.cache)) { + if (!ts || now - ts > ttl) { delete this.cache[k]; dirty = true; } + } + if (dirty) this._save(); + } + + clear() { this.cache = {}; this._save(); } + } + + window.AI_REPO_HISTORY = new ConversationHistory(); +})(); +// ==STORAGE END== diff --git a/src/userscript-bootstrap.user.js b/src/userscript-bootstrap.user.js new file mode 100644 index 0000000..35d455f --- /dev/null +++ b/src/userscript-bootstrap.user.js @@ -0,0 +1,20 @@ +// ==UserScript== +// @name AI Repo Commander (Modular Refactor) +// @namespace http://tampermonkey.net/ +// @version 2.0.0-mod +// @description Modularized: config, logger, storage, parser, executor, main +// @author You +// @match https://chat.openai.com/* +// @match https://chatgpt.com/* +// @match https://claude.ai/* +// @match https://gemini.google.com/* +// @grant GM_xmlhttpRequest +// @connect n8n.brrd.tech +// @connect * +// @require https://YOUR_HOST/config.js +// @require https://YOUR_HOST/logger.js +// @require https://YOUR_HOST/storage.js +// @require https://YOUR_HOST/command-parser.js +// @require https://YOUR_HOST/command-executor.js +// @require https://YOUR_HOST/main.js +// ==/UserScript==