Compare commits

..

No commits in common. "main" and "changes" have entirely different histories.

12 changed files with 130 additions and 462 deletions

7
.gitignore vendored
View File

@ -1,7 +0,0 @@
# IDE project files
.idea/
*.iml
# OS-specific junk (harmless to keep ignored)
.DS_Store
Thumbs.db

8
.idea/.gitignore vendored
View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,7 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>testid</w>
</words>
</dictionary>
</component>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/AI-Repo-Commander.iml" filepath="$PROJECT_DIR$/.idea/AI-Repo-Commander.iml" />
</modules>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -4,79 +4,18 @@
A browser userscript that enables AI assistants to securely interact with git repositories via YAML-style commands, with comprehensive safety measures and real-time feedback. A browser userscript that enables AI assistants to securely interact with git repositories via YAML-style commands, with comprehensive safety measures and real-time feedback.
## 1.1 Diagrams (PlantUML)
- Architecture Overview: Docs/diagrams/architecture-overview.puml
- Command Execution Sequence: Docs/diagrams/sequence-command-execution.puml
- Command Processing State Machine: Docs/diagrams/state-machine.puml
How to view: open the .puml files with any PlantUML viewer (IDE plugin or web renderer) to generate images.
## 2. Core Architecture ## 2. Core Architecture
### 2.1 Safety-First Design ### 2.1 Safety-First Design
```javascript ```javascript
// Configuration (persisted via localStorage) // Configuration - MUST be manually enabled for production
const DEFAULT_CONFIG = { const CONFIG = {
ENABLE_API: true, ENABLE_API: false, // Master kill switch
DEBUG_MODE: true, DEBUG_MODE: true, // Development logging
DEBUG_LEVEL: 2, DEBOUNCE_DELAY: 5000, // 5-second bot typing protection
DEBUG_WATCH_MS: 120000, MAX_RETRIES: 2, // API retry attempts
DEBUG_MAX_LINES: 400, VERSION: '1.0.0'
DEBUG_SHOW_PANEL: true,
// Timing & API
DEBOUNCE_DELAY: 6500, // streaming debounce before settle
MAX_RETRIES: 2,
VERSION: '1.6.2',
API_TIMEOUT_MS: 60000,
PROCESS_EXISTING: false, // resume-safe guard
ASSISTANT_ONLY: true,
BRIDGE_KEY: '',
// Persistent dedupe window
DEDUPE_TTL_MS: 30 * 24 * 60 * 60 * 1000, // 30 days
COLD_START_MS: 2000, // avoid immediate re-exec on reload
SHOW_EXECUTED_MARKER: true,
// Housekeeping
CLEANUP_AFTER_MS: 30000,
CLEANUP_INTERVAL_MS: 60000,
// Paste + submit behavior
APPEND_TRAILING_NEWLINE: true,
AUTO_SUBMIT: true,
POST_PASTE_DELAY_MS: 250,
SUBMIT_MODE: 'button_first',
// Streaming complete hardening
REQUIRE_TERMINATOR: true, // requires @end@
SETTLE_CHECK_MS: 1300, // stable window after last change
SETTLE_POLL_MS: 250, // settle poll frequency
// Runtime toggles
RUNTIME: { PAUSED: false },
// Hardening & perf
STUCK_AFTER_MS: 10 * 60 * 1000,
SCAN_DEBOUNCE_MS: 400,
FAST_WARN_MS: 50,
SLOW_WARN_MS: 60_000,
// Queue management
QUEUE_MIN_DELAY_MS: 800,
QUEUE_MAX_PER_MINUTE: 15,
QUEUE_MAX_PER_MESSAGE: 5,
QUEUE_WAIT_FOR_COMPOSER_MS: 6000,
RESPONSE_BUFFER_FLUSH_DELAY_MS: 500,
RESPONSE_BUFFER_SECTION_HEADINGS: true,
MAX_PASTE_CHARS: 250_000,
SPLIT_LONG_RESPONSES: true,
}; };
``` ```
@ -105,7 +44,7 @@ class CommandMonitor {
// Message states // Message states
static STATES = { static STATES = {
DETECTED: 'detected', // @bridge@ found DETECTED: 'detected', // ^%$bridge found
PARSING: 'parsing', // YAML parsing in progress PARSING: 'parsing', // YAML parsing in progress
VALIDATING: 'validating', // Field validation VALIDATING: 'validating', // Field validation
DEBOUNCING: 'debouncing', // 5-second wait period DEBOUNCING: 'debouncing', // 5-second wait period
@ -114,9 +53,9 @@ class CommandMonitor {
ERROR: 'error' // Command failed ERROR: 'error' // Command failed
}; };
scanMessages(): void; // Find new command messages scanMessages(): void // Find new command messages
trackMessage(element, text): void; // Begin processing pipeline trackMessage(element, text): void // Begin processing pipeline
updateState(messageId, state): void; // State machine transitions updateState(messageId, state): void // State machine transitions
} }
``` ```
@ -127,14 +66,14 @@ class CommandMonitor {
**Input Format:** **Input Format:**
```yaml ```yaml
@bridge@ ^%$bridge
action: update_file action: update_file
repo: ai-workflow-test repo: ai-workflow-test
path: README.md path: README.md
content: | content: |
Multi-line content Multi-line content
with proper formatting with proper formatting
@end@ ---
``` ```
**Parsing Logic:** **Parsing Logic:**
@ -142,7 +81,7 @@ content: |
```javascript ```javascript
class CommandParser { class CommandParser {
parseYAMLCommand(text): ParsedCommand { parseYAMLCommand(text): ParsedCommand {
// 1. Extract command block (@bridge@ ... @end@ terminator required) // 1. Extract command block (^%$bridge to ---)
// 2. Parse key-value pairs // 2. Parse key-value pairs
// 3. Handle multi-line content with | syntax // 3. Handle multi-line content with | syntax
// 4. Set defaults (url, owner if missing) // 4. Set defaults (url, owner if missing)
@ -257,78 +196,19 @@ class UIFeedback {
} }
``` ```
### 3.6 Execution Queue
Purpose: Serialize command execution with rate limits and minimal spacing between actions.
Highlights:
- Min delay between tasks: CONFIG.QUEUE_MIN_DELAY_MS
- Rate cap: CONFIG.QUEUE_MAX_PER_MINUTE
- Fire-and-forget push; internal drain loop ensures sequential execution
```javascript
class ExecutionQueue {
push(task) { /* queue and begin drain */ }
async _drain() { /* rate-limit and run */ }
}
```
### 3.7 Response Buffer (paste pipeline)
Purpose: Collect response chunks (e.g., get_file content or list_files listing) and paste them into the composer once nearby tasks finish, respecting size limits.
Highlights:
- Delayed flush (CONFIG.RESPONSE_BUFFER_FLUSH_DELAY_MS) to batch sibling results
- Splitting long responses (CONFIG.SPLIT_LONG_RESPONSES, CONFIG.MAX_PASTE_CHARS)
- Uses pasteAndMaybeSubmit(text) which pastes and optionally auto-submits
### 3.8 Conversation-Aware History (dedupe)
Purpose: Avoid re-executing the same command in the same conversation across reloads.
Highlights:
- Fingerprint of message element (plus optional sub-index) stored in localStorage with TTL (CONFIG.DEDUPE_TTL_MS)
- Per-message, per-command "Run again" UI allows manual re-exec
- SHOW_EXECUTED_MARKER outlines executed messages in green
### 3.9 Paste + Submit Mechanics
Purpose: Robustly paste text into the site-specific composer and submit.
Paste flow (tries in order):
- Dispatch ClipboardEvent('paste', clipboardData)
- ProseMirror innerHTML paragraphs with input/change events
- Selection/Range insertion for contentEditable; setRangeText for inputs/textareas
- Direct value/textContent assignment as a fallback
- GM_setClipboard as last resort (manual paste)
Submit flow:
- Find send button scoped to nearest form/composer/main first
- If no button or enter-only mode, simulate Enter key events on the input
### 3.10 Notifications
Purpose: Present OS-level notifications via userscript APIs when available.
- Implementation: Direct calls to GM_notification for OS-level notifications.
- Userscript header includes: `@grant GM_notification` to enable the API in Tampermonkey/Violentmonkey.
- IDE hint: A top-of-file comment `/* global GM_notification */` is present to silence unresolved symbol warnings in WebStorm.
- Portability note: No wrapper is used. If the script is executed outside a userscript manager, GM_notification will not exist and notifications are skipped; the in-page debug panel/toasts provide fallback visibility.
## 4. Processing Pipeline ## 4. Processing Pipeline
### 4.1 Step-by-Step Flow ### 4.1 Step-by-Step Flow
``` ```
1. MONITOR: Detect @bridge@ command blocks in assistant messages; extract all complete blocks per message. 1. MONITOR: Detect ^%$bridge in new messages
2. TRACK: Assign readable message ID; mark per-command history to avoid re-exec; show queue badge. 2. TRACK: Assign unique ID and initial state
3. PARSE: Extract inner YAML-like key/values from @bridge@ ... @end@; apply defaults; expand owner/repo shorthand. 3. PARSE: Extract YAML into structured command
4. VALIDATE: Check required fields based on action; treat examples specially (skip execution). 4. VALIDATE: Check required fields and formats
5. DEBOUNCE: Wait DEBOUNCE_DELAY, then run SETTLE window to ensure the final, stable command text. 5. DEBOUNCE: Wait 5 seconds for bot typing completion
6. EXECUTE (queue): Serialize via ExecutionQueue with rate limits; GM_xmlhttpRequest to bridge with retries. 6. EXECUTE: Make API call (or mock if disabled)
7. FEEDBACK: Render per-command status lines; paste response bodies via ResponseBuffer into the composer. 7. FEEDBACK: Replace with status message
8. SUBMIT: Optionally auto-submit after paste using scoped send button or Enter key events. 8. CLEANUP: Update state to complete
9. COMPLETE: Update state and timestamps; maintain history TTL; attach per-command "Run again" buttons for manual control.
``` ```
### 4.2 Error Handling Flow ### 4.2 Error Handling Flow
@ -344,14 +224,12 @@ Network Error → [Action: Error] Cannot reach bridge
### 5.1 Multiple Protection Layers ### 5.1 Multiple Protection Layers
- API Master Switch: ENABLE_API gate and runtime PAUSED toggle - **API Master Switch:** ENABLE_API must be explicitly true
- Command Validation: required fields per action, example detection - **Command Validation:** Required fields and format checking
- Streaming Hardened: DEBOUNCE_DELAY + SETTLE_CHECK_MS window ensures complete blocks - **Bot Debouncing:** 5-second wait prevents partial command execution
- Rate Limiting: ExecutionQueue caps per-minute and enforces inter-task delay - **Network Timeouts:** 30-second API call limits
- Persistent Dedupe: conversation-aware history with TTL to prevent re-exec across reloads - **Error Boundaries:** Isolated error handling per command
- UI Safeguards: per-command "Run again" instead of auto-reexec; executed marker on messages - **State Tracking:** Prevents duplicate processing
- Network Limits: API_TIMEOUT_MS and bounded retries
- Error Isolation: per-command try/catch with inline validation handling (no throw/catch ping-pong)
### 5.2 Emergency Stop Options ### 5.2 Emergency Stop Options
@ -373,10 +251,18 @@ window.AI_REPO_STOP = emergencyStop;
```javascript ```javascript
const PLATFORM_SELECTORS = { const PLATFORM_SELECTORS = {
'chat.openai.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, 'chat.openai.com': {
'chatgpt.com': { messages: '[data-message-author-role]', input: '#prompt-textarea, textarea, [contenteditable="true"]', content: '.markdown' }, messages: '[class*="message"]',
'claude.ai': { messages: '.chat-message', input: '[contenteditable="true"]', content: '.content' }, input: '#prompt-textarea'
'gemini.google.com': { messages: '.message-content', input: 'textarea, [contenteditable="true"]', content: '.message-text' } },
'claude.ai': {
messages: '.chat-message',
input: '[contenteditable="true"]'
},
'gemini.google.com': {
messages: '.message-content',
input: 'textarea, [contenteditable="true"]'
}
}; };
``` ```
@ -387,9 +273,25 @@ const PLATFORM_SELECTORS = {
```javascript ```javascript
// Test command generator for safe testing // Test command generator for safe testing
const TEST_COMMANDS = { const TEST_COMMANDS = {
validUpdate: '@bridge@\naction: update_file\nrepo: test-repo\npath: TEST.md\ncontent: |\n Test content\n Multiple lines\n@end@', validUpdate: `^%$bridge
invalidCommand: '@bridge@\naction: update_file\nrepo: test-repo\n@end@', action: update_file
getFile: '@bridge@\naction: get_file\nrepo: test-repo\npath: README.md\n@end@' repo: test-repo
path: TEST.md
content: |
Test content
Multiple lines
---`,
invalidCommand: `^%$bridge
action: update_file
repo: test-repo
---`,
getFile: `^%$bridge
action: get_file
repo: test-repo
path: README.md
---`
}; };
``` ```

View File

@ -1,45 +0,0 @@
@startuml Architecture Overview
skinparam componentStyle rectangle
skinparam shadowing false
skinparam ArrowColor #555
skinparam component {
BackgroundColor<<core>> #E8F5E9
BackgroundColor<<ui>> #E3F2FD
BackgroundColor<<ext>> #FFF3E0
}
package "Browser (Chat Sites)" <<ext>> {
[ChatGPT DOM]
[Claude DOM]
[Gemini DOM]
}
node "Userscript: AI Repo Commander" as Userscript {
component "Core Monitor" <<core>> as Monitor
component "Command Parser" <<core>> as Parser
component "Validation Engine" <<core>> as Validator
component "Execution Manager" <<core>> as Executor
component "Dedupe Store" <<core>> as Dedupe
component "Config Manager" <<core>> as Config
component "UI Panel (Tools/Settings)" <<ui>> as Panel
component "Inline Status UI" <<ui>> as InlineUI
}
cloud "Bridge API" <<ext>> as Bridge
' Relationships
[ChatGPT DOM] -down-> Monitor : observe assistant messages
[Claude DOM] -down-> Monitor
[Gemini DOM] -down-> Monitor
Monitor -> Parser : extract YAML blocks
Parser -> Validator : validate fields/actions
Validator -> Executor : approved commands
Executor -> Bridge : HTTP requests (key, action)
Executor --> InlineUI : progress + results
Monitor --> InlineUI : markers (processed, run again)
Panel <--> Config : view/edit config
Executor <--> Config : timeouts, retries
Monitor <--> Dedupe : per-convo records
@enduml

View File

@ -1,37 +0,0 @@
@startuml Command Execution Sequence
skinparam shadowing false
skinparam ArrowColor #555
actor User as U
participant "Assistant Message\n(DOM)" as DOM
participant "Core Monitor" as Monitor
participant "Command Parser" as Parser
participant "Validation Engine" as Validator
participant "Dedupe Store" as Dedupe
participant "Execution Manager" as Executor
participant "Inline Status UI" as UI
participant "Bridge API" as Bridge
U -> DOM : Produces message with YAML block
DOM -> Monitor : observe new/updated message
Monitor -> Monitor : debounce + streaming settle
Monitor -> Parser : extract YAML block
Parser --> Monitor : ParsedCommand
Monitor -> Validator : validate fields and action
Validator --> Monitor : ValidationResult
Monitor -> Dedupe : check de-duplication
Dedupe --> Monitor : isNew?
alt example: true or duplicate
Monitor -> UI : mark as processed (skipped)
return
else valid and new
Monitor -> UI : show "Ready" (runnable)
Monitor -> Executor : enqueue/execute command
Executor -> UI : state = executing
Executor -> Bridge : POST action with bridge key
Bridge --> Executor : response (success/failure)
Executor -> UI : show result + Run Again button
Executor -> Dedupe : record execution (ttl 30d)
end
@enduml

View File

@ -1,42 +0,0 @@
@startuml Command Processing State Machine
skinparam shadowing false
skinparam ArrowColor #555
skinparam state {
StartColor #A5D6A7
BackgroundColor #FAFAFA
}
[*] --> DETECTED : YAML block found
state DETECTED {
}
DETECTED --> PARSING : debounce/settle passed
PARSING --> VALIDATING : YAML parsed
PARSING --> ERROR : parse failure
VALIDATING --> DEDUPE_CHECK : required fields ok
VALIDATING --> ERROR : validation failed
state DEDUPE_CHECK
DEDUPE_CHECK --> SKIPPED : duplicate or example:true
DEDUPE_CHECK --> READY : new and runnable
state READY
READY --> EXECUTING : user intent or auto-exec policy
READY --> [*] : STOP triggered
state EXECUTING
EXECUTING --> COMPLETE : success
EXECUTING --> ERROR : API/network failure
state SKIPPED
SKIPPED --> [*]
state COMPLETE
COMPLETE --> [*]
state ERROR
ERROR --> [*]
@enduml

View File

@ -74,17 +74,6 @@ SLOW_WARN_MS: 60000
5) Show inline status and store dedupe record 5) Show inline status and store dedupe record
6) Expose a **Run Again** button for intentional re-execution 6) Expose a **Run Again** button for intentional re-execution
## Diagrams (PlantUML)
The following PlantUML diagrams provide a high-level overview of the codebase:
- Architecture Overview: Docs/diagrams/architecture-overview.puml
- Command Execution Sequence: Docs/diagrams/sequence-command-execution.puml
- Command Processing State Machine: Docs/diagrams/state-machine.puml
How to view:
- Use any PlantUML renderer (IntelliJ/VSCode plugin, plantuml.com/plantuml, or local PlantUML jar)
- Copy the contents of a .puml file into your renderer to generate the diagram image
## New in v1.6.2 ## New in v1.6.2
- **Resume-Safe Guard:** On resume, treat like a cold start and mark visible commands as processed, preventing accidental re-execution. - **Resume-Safe Guard:** On resume, treat like a cold start and mark visible commands as processed, preventing accidental re-execution.
- **Example Field Support:** Add `example: true` to any command block to make it inert. Its silently skipped (no error UI). - **Example Field Support:** Add `example: true` to any command block to make it inert. Its silently skipped (no error UI).

View File

@ -2,7 +2,7 @@
// @name AI Repo Commander // @name AI Repo Commander
// @namespace http://tampermonkey.net/ // @namespace http://tampermonkey.net/
// @version 1.6.2 // @version 1.6.2
// @description Execute @bridge@ YAML commands from AI assistants (safe & robust): complete-block detection, streaming-settle, persistent dedupe, paste + auto-submit, debug console with Tools/Settings, draggable/collapsible panel, multi-command queue // @description Execute @bridge@ YAML commands from AI assistants (safe & robust): complete-block detection, streaming-settle, persistent dedupe, paste+autosubmit, debug console with Tools/Settings, draggable/collapsible panel, multi-command queue
// @author Your Name // @author Your Name
// @match https://chat.openai.com/* // @match https://chat.openai.com/*
// @match https://chatgpt.com/* // @match https://chatgpt.com/*
@ -14,9 +14,6 @@
// @connect n8n.brrd.tech // @connect n8n.brrd.tech
// @connect * // @connect *
// ==/UserScript== // ==/UserScript==
/* global GM_notification */
/* global GM_setClipboard */
/* global GM_xmlhttpRequest */
(function () { (function () {
'use strict'; 'use strict';
@ -101,7 +98,8 @@
const raw = localStorage.getItem(STORAGE_KEYS.cfg); const raw = localStorage.getItem(STORAGE_KEYS.cfg);
if (!raw) return structuredClone(DEFAULT_CONFIG); if (!raw) return structuredClone(DEFAULT_CONFIG);
const saved = JSON.parse(raw); const saved = JSON.parse(raw);
return { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } }; const merged = { ...DEFAULT_CONFIG, ...saved, RUNTIME: { ...DEFAULT_CONFIG.RUNTIME, ...(saved.RUNTIME || {}) } };
return merged;
} catch { } catch {
return structuredClone(DEFAULT_CONFIG); return structuredClone(DEFAULT_CONFIG);
} }
@ -194,35 +192,17 @@
_fallbackCopy(text, originalError = null) { _fallbackCopy(text, originalError = null) {
try { try {
// Show a minimal manual copy UI (no deprecated execCommand)
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;z-index:999999;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;';
const panel = document.createElement('div');
panel.style.cssText = 'background:#1f2937;color:#e5e7eb;padding:12px 12px 8px;border-radius:8px;width:min(760px,90vw);max-height:70vh;display:flex;flex-direction:column;gap:8px;box-shadow:0 10px 30px rgba(0,0,0,0.5)';
const title = document.createElement('div');
title.textContent = 'Copy to clipboard';
title.style.cssText = 'font:600 14px system-ui,sans-serif;';
const hint = document.createElement('div');
hint.textContent = 'Press Ctrl+C (Windows/Linux) or ⌘+C (macOS) to copy the selected text.';
hint.style.cssText = 'font:12px system-ui,sans-serif;opacity:0.85;';
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = text; ta.value = text;
ta.readOnly = true; ta.style.position = 'fixed';
ta.style.cssText = 'width:100%;height:40vh;font:12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;'; ta.style.opacity = '0';
const row = document.createElement('div'); document.body.appendChild(ta);
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';
const close = document.createElement('button');
close.textContent = 'Close';
close.style.cssText = 'padding:6px 10px;background:#374151;color:#e5e7eb;border:1px solid #4b5563;border-radius:6px;cursor:pointer;';
close.onclick = () => overlay.remove();
panel.append(title, hint, ta, row);
row.append(close);
overlay.append(panel);
document.body.appendChild(overlay);
// Focus and select
ta.focus(); ta.focus();
ta.select(); ta.select();
this.warn('Clipboard API unavailable; showing manual copy UI', { error: originalError?.message }); const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) this.info(`Copied last ${text.split('\n').length} lines to clipboard (fallback)`);
else this.warn('Clipboard copy failed (fallback)');
} catch (e) { } catch (e) {
this.warn('Clipboard copy failed', { error: originalError?.message || e.message }); this.warn('Clipboard copy failed', { error: originalError?.message || e.message });
} }
@ -711,7 +691,7 @@
} }
// Extract the *command block* if present; else fall back to element text // Extract the *command block* if present; else fall back to element text
function _commandLikeText(el) { function _commandishText(el) {
// Mirror parser's detector: require header, action, and '@end@' // Mirror parser's detector: require header, action, and '@end@'
const blocks = el.querySelectorAll('pre code, pre, code'); const blocks = el.querySelectorAll('pre code, pre, code');
for (const b of blocks) { for (const b of blocks) {
@ -726,7 +706,7 @@
// Hash of the command (or element text) capped to 2000 chars // Hash of the command (or element text) capped to 2000 chars
function _hashCommand(el) { function _hashCommand(el) {
const t = _commandLikeText(el); const t = _commandishText(el);
return _hash(t.slice(0, 2000)); return _hash(t.slice(0, 2000));
} }
@ -934,9 +914,8 @@
'create_release': ['action', 'repo', 'tag_name', 'name'] 'create_release': ['action', 'repo', 'tag_name', 'name']
}; };
// noinspection JSUnusedGlobalSymbols
const FIELD_VALIDATORS = { const FIELD_VALIDATORS = {
repo: (v) => /^[\w\-.]+(\/[\w\-.]+)?$/.test(v), repo: (v) => /^[\w\-\.]+(\/[\w\-\.]+)?$/.test(v),
path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'), path: (v) => !v.includes('..') && !v.startsWith('/') && !v.includes('\\'),
action: (v) => Object.keys(REQUIRED_FIELDS).includes(v), action: (v) => Object.keys(REQUIRED_FIELDS).includes(v),
url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v), url: (v) => !v || /^https?:\/\/[^/\s]+(?:\/|$)/i.test(v),
@ -1022,13 +1001,9 @@
} }
} }
/**
* @param {Element} el
* @param {string|number} [suffix]
*/
hasElement(el, suffix = '') { hasElement(el, suffix = '') {
let fp = fingerprintElement(el); let fp = fingerprintElement(el);
if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; if (suffix) fp += `#${suffix}`;
const result = this.session.has(fp) || (fp in this.cache); const result = this.session.has(fp) || (fp in this.cache);
if (result && CONFIG.DEBUG_LEVEL >= 4) { if (result && CONFIG.DEBUG_LEVEL >= 4) {
@ -1042,13 +1017,9 @@
return result; return result;
} }
/**
* @param {Element} el
* @param {string|number} [suffix]
*/
markElement(el, suffix = '') { markElement(el, suffix = '') {
let fp = fingerprintElement(el); let fp = fingerprintElement(el);
if (suffix !== '' && suffix != null) fp += `#${String(suffix)}`; if (suffix) fp += `#${suffix}`;
this.session.add(fp); this.session.add(fp);
this.cache[fp] = Date.now(); this.cache[fp] = Date.now();
this._save(); this._save();
@ -1057,7 +1028,7 @@
fingerprint: fp.slice(0, 60) + '...' fingerprint: fp.slice(0, 60) + '...'
}); });
if (CONFIG.SHOW_EXECUTED_MARKER && el instanceof HTMLElement) { if (CONFIG.SHOW_EXECUTED_MARKER) {
try { try {
el.style.borderLeft = '3px solid #10B981'; el.style.borderLeft = '3px solid #10B981';
el.title = 'Command executed — use "Run again" to re-run'; el.title = 'Command executed — use "Run again" to re-run';
@ -1065,6 +1036,20 @@
} }
} }
unmarkElement(el, suffix = '') {
let fp = fingerprintElement(el);
if (suffix) fp += `#${suffix}`;
this.session.delete(fp);
if (fp in this.cache) {
delete this.cache[fp];
this._save();
}
RC_DEBUG?.verbose('Unmarked element', {
fingerprint: fp.slice(0, 60) + '...'
});
}
resetAll() { resetAll() {
this.session.clear(); this.session.clear();
localStorage.removeItem(this.key); localStorage.removeItem(this.key);
@ -1074,7 +1059,6 @@
} }
// Global helpers (stable) // Global helpers (stable)
// noinspection JSUnusedGlobalSymbols
window.AI_REPO = { window.AI_REPO = {
clearHistory: () => { clearHistory: () => {
try { commandMonitor?.history?.resetAll?.(); } catch {} try { commandMonitor?.history?.resetAll?.(); } catch {}
@ -1305,7 +1289,7 @@
'form button[type="submit"]' 'form button[type="submit"]'
]; ];
for (const s of selectors) { for (const s of selectors) {
const btn = scope.querySelector(s); const btn = document.querySelector(s);
if (!btn) continue; if (!btn) continue;
const style = window.getComputedStyle(btn); const style = window.getComputedStyle(btn);
const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true'; const disabled = btn.disabled || btn.getAttribute('aria-disabled') === 'true';
@ -1386,51 +1370,36 @@
const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`; const payload2 = `\n${payload.replace(/\n?$/, '\n')}\n`;
const escape = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const escape = (s) => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
el.innerHTML = String(payload2) const html = String(payload2)
.split('\n') .split('\n')
.map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>') .map(line => line.length ? `<p>${escape(line)}</p>` : '<p><br></p>')
.join(''); .join('');
el.innerHTML = html;
el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror'); RC_DEBUG?.info('✅ Paste method succeeded: ProseMirror');
return true; return true;
} }
// Method 3: Selection API insertion (non-deprecated) // Method 3: execCommand
try { try {
const sel = window.getSelection && window.getSelection(); const sel = window.getSelection && window.getSelection();
if (el.isContentEditable || el.getAttribute('contenteditable') === 'true') { if (sel && sel.rangeCount === 0 && el instanceof HTMLElement) {
// Ensure there's a caret; if not, place it at the end
if (sel && sel.rangeCount === 0) {
const r = document.createRange(); const r = document.createRange();
r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r); r.selectNodeContents(el); r.collapse(false); sel.removeAllRanges(); sel.addRange(r);
RC_DEBUG?.verbose('Selection range set for contentEditable'); RC_DEBUG?.verbose('Selection range set for execCommand');
} }
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0); const success = document.execCommand && document.execCommand('insertText', false, payload);
range.deleteContents(); RC_DEBUG?.verbose('execCommand attempt', { success });
const node = document.createTextNode(payload);
range.insertNode(node); if (success) {
// Move caret after inserted node RC_DEBUG?.info('✅ Paste method succeeded: execCommand');
range.setStartAfter(node);
range.setEndAfter(node);
sel.removeAllRanges(); sel.addRange(range);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: Selection API (contentEditable)');
return true;
}
} else if (typeof el.setRangeText === 'function') {
// For inputs/text-areas supporting setRangeText
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? start;
el.setRangeText(payload, start, end, 'end');
el.dispatchEvent(new Event('input', { bubbles: true }));
RC_DEBUG?.info('✅ Paste method succeeded: setRangeText');
return true; return true;
} }
} catch (e) { } catch (e) {
RC_DEBUG?.verbose('Selection API insertion failed', { error: String(e) }); RC_DEBUG?.verbose('execCommand failed', { error: String(e) });
} }
// Method 4: TEXTAREA/INPUT // Method 4: TEXTAREA/INPUT
@ -1625,7 +1594,8 @@
if (!CONFIG.ENABLE_API) { if (!CONFIG.ENABLE_API) {
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label }); UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label });
return await this.mockExecution(command, sourceElement, renderKey, label); const res = await this.mockExecution(command, sourceElement, renderKey, label);
return res;
} }
UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label }); UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label });
@ -1743,7 +1713,7 @@
if (command.action === 'get_file') { if (command.action === 'get_file') {
const body = this._extractGetFileBody(data); const body = this._extractGetFileBody(data);
if (typeof body === 'string' && body.length) { if (typeof body === 'string' && body.length) {
new ResponseBuffer().push({ label, content: body }); RESP_BUFFER.push({ label, content: body });
} else { } else {
GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 });
} }
@ -1753,10 +1723,10 @@
const files = this._extractFilesArray(data); const files = this._extractFilesArray(data);
if (files && files.length) { if (files && files.length) {
const listing = this._formatFilesListing(files); const listing = this._formatFilesListing(files);
new ResponseBuffer().push({ label, content: listing }); RESP_BUFFER.push({ label, content: listing });
} else { } else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
new ResponseBuffer().push({ label, content: fallback }); RESP_BUFFER.push({ label, content: fallback });
GM_notification({ GM_notification({
title: 'AI Repo Commander', title: 'AI Repo Commander',
text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.',
@ -1794,7 +1764,7 @@
push(task) { push(task) {
this.q.push(task); this.q.push(task);
this.onSizeChange?.(this.q.length); this.onSizeChange?.(this.q.length);
if (!this.running) void this._drain(); if (!this.running) this._drain();
} }
clear() { clear() {
this.q.length = 0; this.q.length = 0;
@ -1829,7 +1799,6 @@
} }
const execQueue = new ExecutionQueue(); const execQueue = new ExecutionQueue();
// noinspection JSUnusedGlobalSymbols
window.AI_REPO_QUEUE = { window.AI_REPO_QUEUE = {
clear: () => execQueue.clear(), clear: () => execQueue.clear(),
size: () => execQueue.q.length, size: () => execQueue.q.length,
@ -1941,8 +1910,8 @@
} }
const RESP_BUFFER = new ResponseBuffer();
window.AI_REPO_RESPONSES = new ResponseBuffer(); // optional debug handle window.AI_REPO_RESPONSES = RESP_BUFFER; // optional debug handle
// ---------------------- Bridge Key ---------------------- // ---------------------- Bridge Key ----------------------
let BRIDGE_KEY = null; let BRIDGE_KEY = null;
@ -2260,6 +2229,8 @@
let parsed; let parsed;
try { try {
parsed = CommandParser.parseYAMLCommand(finalTxt); parsed = CommandParser.parseYAMLCommand(finalTxt);
const val = CommandParser.validateStructure(parsed);
if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`);
} catch (err) { } catch (err) {
UIFeedback.appendStatus(element, 'ERROR', { UIFeedback.appendStatus(element, 'ERROR', {
action: 'Command', action: 'Command',
@ -2270,17 +2241,6 @@
this.attachRetryUI(element, subId); this.attachRetryUI(element, subId);
return; return;
} }
const val = CommandParser.validateStructure(parsed);
if (!val.isValid) {
UIFeedback.appendStatus(element, 'ERROR', {
action: 'Command',
details: `Validation failed: ${val.errors.join(', ')}`,
key: subId,
label: `[${idx+1}] parse`
});
this.attachRetryUI(element, subId);
return;
}
this.updateState(subId, COMMAND_STATES.EXECUTING); this.updateState(subId, COMMAND_STATES.EXECUTING);
const res = await ExecutionManager.executeCommand( const res = await ExecutionManager.executeCommand(
@ -2325,7 +2285,7 @@
cancelToken: { cancelled: false } cancelToken: { cancelled: false }
}); });
this.updateState(messageId, COMMAND_STATES.PARSING); this.updateState(messageId, COMMAND_STATES.PARSING);
void this.processCommand(messageId); this.processCommand(messageId);
} }
async debounceWithCancel(messageId) { async debounceWithCancel(messageId) {
@ -2432,8 +2392,8 @@
// Highlight failed one // Highlight failed one
try { try {
const bar = element.querySelector('.ai-rc-rerun'); const bar = element.querySelector('.ai-rc-rerun');
const buttons = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+]/.test(b.textContent || '')); const btns = [...bar.querySelectorAll('button')].filter(b => /Run again \[#\d+\]/.test(b.textContent || ''));
const b = buttons[failedIdx]; const b = btns[failedIdx];
if (b) b.style.outline = '2px solid #ef4444'; if (b) b.style.outline = '2px solid #ef4444';
} catch {} } catch {}
} }
@ -2507,13 +2467,8 @@
} }
if (!validation.isValid) { if (!validation.isValid) {
this.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId); this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', { throw new Error(`Validation failed: ${validation.errors.join(', ')}`);
action: 'Command',
details: `Validation failed: ${validation.errors.join(', ')}`
});
return;
} }
// 3) Debounce // 3) Debounce
@ -2569,13 +2524,8 @@
const reParsed = CommandParser.parseYAMLCommand(stable); const reParsed = CommandParser.parseYAMLCommand(stable);
const reVal = CommandParser.validateStructure(reParsed); const reVal = CommandParser.validateStructure(reParsed);
if (!reVal.isValid) { if (!reVal.isValid) {
this.updateState(messageId, COMMAND_STATES.ERROR);
this.attachRetryUI(message.element, messageId); this.attachRetryUI(message.element, messageId);
UIFeedback.appendStatus(message.element, 'ERROR', { throw new Error(`Final validation failed: ${reVal.errors.join(', ')}`);
action: 'Command',
details: `Final validation failed: ${reVal.errors.join(', ')}`
});
return;
} }
parsed = reParsed; parsed = reParsed;
} }
@ -2697,7 +2647,7 @@
RC_DEBUG?.info('Retrying message now', { messageId }); RC_DEBUG?.info('Retrying message now', { messageId });
commandMonitor.updateState(messageId, COMMAND_STATES.PARSING); commandMonitor.updateState(messageId, COMMAND_STATES.PARSING);
void commandMonitor.processCommand(messageId); commandMonitor.processCommand(messageId);
} catch (e) { } catch (e) {
RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) }); RC_DEBUG?.error('Failed to retry message', { messageId, error: String(e) });
} }
@ -2843,7 +2793,6 @@ path: .
if (!commandMonitor) { if (!commandMonitor) {
commandMonitor = new CommandMonitor(); commandMonitor = new CommandMonitor();
// noinspection JSUnusedGlobalSymbols
window.AI_REPO_COMMANDER = { window.AI_REPO_COMMANDER = {
monitor: commandMonitor, monitor: commandMonitor,
config: CONFIG, config: CONFIG,