// ==UserScript== // @name Page Agent - LLM in the page // @namespace https://github.com/example/page-agent // @version 0.1.0 // @description Turn any web page into an LLM agent that can run JS in the page to inspect, extract, fetch and act - then bake what it learned into a standalone, LLM-free userscript. // @match *://*/* // @run-at document-idle // @grant GM.xmlHttpRequest // @grant GM.setValue // @grant GM.getValue // @connect api.openai.com // @connect * // ==/UserScript== // SECURITY NOTE: `@connect *` allows GM.xmlHttpRequest to POST to ANY host. That is // handy while you experiment with self-hosted / proxied endpoints, but it is broad. // Once you know your endpoint's host, DELETE the `@connect *` line above and keep // only the specific host(s) you use (e.g. just `@connect api.openai.com`). (async () => { 'use strict'; // Built at runtime so this file contains no literal triple-backticks and stays paste-safe. const BT3 = '`'.repeat(3); // ============================================================ // Configuration // ============================================================ const DEFAULTS = { endpoint: 'https://api.openai.com/v1/chat/completions', apiKey: '', model: 'gpt-4o-mini', }; const CONFIG = { endpoint: await GM.getValue('endpoint', DEFAULTS.endpoint), apiKey: await GM.getValue('apiKey', DEFAULTS.apiKey), model: await GM.getValue('model', DEFAULTS.model), }; const maxSteps = 8; // upper bound on the agent's tool-use loop // The panel starts fully hidden. Reveal it with: // Desktop - Ctrl+Shift+Space (see HOTKEY) // Mobile - three-finger tap anywhere on the page const HOTKEY = { ctrl: true, shift: true, alt: false, code: 'Space', label: 'Ctrl+Shift+Space' }; // ============================================================ // safeStringify - serialize arbitrary values without blowing up // ============================================================ function safeStringify(value, maxLen = 20000) { const seen = new WeakSet(); let out; try { out = JSON.stringify(value, function (key, val) { // DOM nodes -> "[NODENAME] " if (typeof Node !== 'undefined' && val instanceof Node) { return '[' + val.nodeName + '] ' + String(val.textContent || '').slice(0, 200); } if (typeof val === 'function') return '[Function]'; if (val !== null && typeof val === 'object') { if (seen.has(val)) return String(val); // circular / repeated ref seen.add(val); } return val; }); } catch (e) { out = String(value); } if (out === undefined) out = String(value); // undefined coercion if (out.length > maxLen) { const total = out.length; out = out.slice(0, maxLen) + '...[truncated, ' + total + ' chars]'; } return out; } // ============================================================ // The single tool: run_js // ============================================================ const TOOLS = [{ type: 'function', function: { name: 'run_js', description: 'Execute JavaScript in the context of the current web page. Your code runs ' + 'as the BODY of an async function, so you can use `await` and you MUST `return` ' + 'a value to see any result. The returned value is JSON-stringified and sent back ' + 'to you, so keep it SMALL and RELEVANT - return only the specific fields/text you ' + 'need, never whole DOM trees or huge arrays. Use it to query selectors, extract ' + 'content, call fetch(), read/modify the page, click, fill and navigate. Take ' + 'several small targeted steps rather than one giant snippet.', parameters: { type: 'object', properties: { code: { type: 'string', description: 'JavaScript run as the body of an async function. `return` the result you want back.', }, }, required: ['code'], }, }, }]; async function runJs(code) { try { // Wrapped so the model can `await` and `return` naturally. const fn = new Function('return (async () => { ' + code + ' })()'); const result = await fn(); return safeStringify(result); } catch (e) { return 'ERROR: ' + (e && e.stack ? e.stack : String(e)); } } // ============================================================ // OpenAI-compatible request via GM.xmlHttpRequest // ============================================================ function chat(messages) { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: 'POST', url: CONFIG.endpoint, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + CONFIG.apiKey, }, data: JSON.stringify({ model: CONFIG.model, messages: messages, tools: TOOLS, tool_choice: 'auto', temperature: 0.2, }), timeout: 60000, onload: (res) => { if (res.status < 200 || res.status >= 300) { reject(new Error('HTTP ' + res.status + ': ' + res.responseText)); return; } try { resolve(JSON.parse(res.responseText)); } catch (e) { reject(new Error('Bad JSON from endpoint: ' + res.responseText)); } }, onerror: () => reject(new Error('Network error contacting ' + CONFIG.endpoint)), ontimeout: () => reject(new Error('Request timed out after 60s')), }); }); } // ============================================================ // Agentic loop // ============================================================ const SYSTEM_PROMPT = 'You are an agent embedded directly in a live web page in the user\'s browser. ' + 'You have exactly one tool, run_js, which executes JavaScript in this page and ' + 'returns its JSON-stringified return value. Use it to inspect the DOM, query ' + 'selectors, extract content, call fetch(), and act on the page (click, fill, ' + 'navigate) to satisfy the request. Prefer small, targeted snippets that return ' + 'only the specific data you need; do not dump whole DOM trees or large arrays. ' + 'Take several small steps rather than one giant one. When the request is complete, ' + 'stop calling tools and reply to the user in plain text.'; // Returns { answer, messages } so the conversation can be reused for later turns. async function agent(userRequest, log = console.log, history = null) { const messages = history ? history : [{ role: 'system', content: SYSTEM_PROMPT }]; if (userRequest) messages.push({ role: 'user', content: userRequest }); for (let step = 0; step < maxSteps; step++) { const data = await chat(messages); const msg = data && data.choices && data.choices[0] && data.choices[0].message; if (!msg) { return { answer: 'ERROR: no message in response: ' + safeStringify(data), messages }; } messages.push(msg); const toolCalls = msg.tool_calls; if (!toolCalls || toolCalls.length === 0) { return { answer: msg.content || '', messages }; // final plain-text answer } for (const tc of toolCalls) { let args = {}; try { args = JSON.parse(tc.function.arguments || '{}'); } catch (e) { args = { code: '' }; } const code = args.code || ''; log('>> run_js:\n' + code); const result = await runJs(code); log('<< result: ' + result.slice(0, 500)); messages.push({ role: 'tool', tool_call_id: tc.id, content: result }); } } return { answer: '(Stopped after ' + maxSteps + ' steps without a final answer.)', messages }; } // ============================================================ // UI - floating panel (bottom-right, dark), hidden by default // ============================================================ const PANEL_ID = 'llm-page-agent-panel'; if (document.getElementById(PANEL_ID)) return; // avoid double-inject const style = document.createElement('style'); style.textContent = ` #${PANEL_ID}{position:fixed;bottom:16px;right:16px;z-index:2147483647;width:340px; font-family:-apple-system,system-ui,Segoe UI,Roboto,sans-serif;font-size:13px; background:#1e1e22;color:#e6e6e6;border:1px solid #3a3a40;border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,.5);overflow:hidden; transition:opacity .12s ease, transform .12s ease;} #${PANEL_ID}.pa-hidden{opacity:0;transform:translateY(8px) scale(.98);pointer-events:none; visibility:hidden;} #${PANEL_ID} .pa-header{display:flex;align-items:center;gap:8px;padding:8px 10px; background:#27272c;user-select:none;} #${PANEL_ID} .pa-title{font-weight:600;flex:1;} #${PANEL_ID} .pa-iconbtn{background:transparent;border:none;color:#bdbdbd;cursor:pointer; font-size:14px;line-height:1;padding:3px 7px;border-radius:6px;} #${PANEL_ID} .pa-iconbtn:hover{background:#3a3a40;color:#fff;} #${PANEL_ID} .pa-body{padding:8px 10px;} #${PANEL_ID} .pa-log{max-height:240px;overflow-y:auto;white-space:pre-wrap; word-break:break-word;background:#131316;border:1px solid #2c2c31;border-radius:6px; padding:8px;line-height:1.35;margin-bottom:8px;font-family:ui-monospace,Menlo,Consolas,monospace; font-size:12px;} #${PANEL_ID} .pa-row{display:flex;gap:6px;margin-bottom:6px;} #${PANEL_ID} input.pa-input{flex:1;min-width:0;background:#131316;color:#e6e6e6; border:1px solid #3a3a40;border-radius:6px;padding:6px 8px;outline:none;} #${PANEL_ID} input.pa-input:disabled{opacity:.5;} #${PANEL_ID} button.pa-btn{background:#3b82f6;color:#fff;border:none;border-radius:6px; padding:6px 10px;cursor:pointer;font-size:12px;white-space:nowrap;} #${PANEL_ID} button.pa-btn.pa-secondary{background:#3a3a40;} #${PANEL_ID} button.pa-btn:disabled{opacity:.5;cursor:not-allowed;} #${PANEL_ID} .pa-spinner{display:inline-block;width:12px;height:12px; border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%; animation:pa-rotation .7s linear infinite;vertical-align:middle;} #${PANEL_ID}.pa-collapsed .pa-body{display:none;} @keyframes pa-rotation{from{transform:rotate(0deg);}to{transform:rotate(360deg);}} #pa-agent-toast{position:fixed;bottom:16px;right:16px;z-index:2147483647; font-family:-apple-system,system-ui,Segoe UI,Roboto,sans-serif;font-size:12px; background:#1e1e22;color:#e6e6e6;border:1px solid #3a3a40;border-radius:8px; padding:8px 12px;box-shadow:0 6px 20px rgba(0,0,0,.45);max-width:280px; transition:opacity .3s ease;pointer-events:none;} #pa-agent-toast.pa-fade{opacity:0;} `; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = PANEL_ID; panel.className = 'pa-hidden'; // hidden until revealed panel.innerHTML = '
' + '[AI] Page Agent' + '' + '' + '' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '
'; document.body.appendChild(panel); const logEl = panel.querySelector('.pa-log'); const inputEl = panel.querySelector('.pa-input'); const sendBtn = panel.querySelector('[data-act="send"]'); const convertBtn = panel.querySelector('[data-act="convert"]'); const downloadBtn = panel.querySelector('[data-act="download"]'); const settingsBtn = panel.querySelector('[data-act="settings"]'); const collapseBtn = panel.querySelector('[data-act="collapse"]'); const hideBtn = panel.querySelector('[data-act="hide"]'); const allButtons = [sendBtn, convertBtn, downloadBtn]; function append(text) { const line = document.createElement('div'); line.textContent = text; logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; // autoscroll } // ============================================================ // Visibility - hidden by default; toggle via hotkey / gesture // ============================================================ let hintShown = false; function isHidden() { return panel.classList.contains('pa-hidden'); } function showPanel() { panel.classList.remove('pa-hidden'); if (!hintShown) { append('Reveal/hide me anytime: ' + HOTKEY.label + ' (desktop) or a three-finger tap (mobile).'); hintShown = true; } setTimeout(() => inputEl.focus(), 0); } function hidePanel() { panel.classList.add('pa-hidden'); } function togglePanel() { if (isHidden()) showPanel(); else hidePanel(); } // Transient toast so the reveal shortcut is discoverable on page load. function showToast(text, ms = 4000) { const existing = document.getElementById('pa-agent-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.id = 'pa-agent-toast'; toast.textContent = text; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('pa-fade'); setTimeout(() => toast.remove(), 320); }, ms); } // ============================================================ // Busy / loading state // ============================================================ let busy = false; let statusEl = null; function setBusy(on, label) { busy = on; inputEl.disabled = on; allButtons.forEach((b) => { b.disabled = on; }); if (on) { sendBtn.dataset.label = sendBtn.textContent; sendBtn.innerHTML = ''; statusEl = document.createElement('div'); statusEl.innerHTML = ' ' + (label || 'thinking...'); logEl.appendChild(statusEl); logEl.scrollTop = logEl.scrollHeight; } else { if (sendBtn.dataset.label) sendBtn.textContent = sendBtn.dataset.label; if (statusEl && statusEl.parentNode) statusEl.parentNode.removeChild(statusEl); statusEl = null; } } // ============================================================ // Conversation state + runners // ============================================================ let conversation = null; // running messages array, reused across turns let lastCode = ''; // last generated userscript async function run(request) { if (busy) return; if (!CONFIG.apiKey) { append('! No API key set. Click [cfg] to configure.'); return; } append('> ' + request); setBusy(true, 'thinking...'); try { const { answer, messages } = await agent(request, append, conversation); conversation = messages; // persist for later turns append('[AI] ' + answer); } catch (e) { append('ERROR: ' + (e && e.message ? e.message : String(e))); } finally { setBusy(false); // always reset } } function send() { const v = inputEl.value.trim(); if (!v) return; inputEl.value = ''; run(v); } // ============================================================ // Convert / Download - bake the session into a standalone userscript // ============================================================ const CONVERT_PROMPT = 'Now convert EVERYTHING you learned and did in this session into a single, ' + 'standalone, repeatable Tampermonkey/Violentmonkey userscript that performs the ' + 'same task automatically with NO LLM calls and no dependence on this agent. Bake ' + 'in the concrete selectors, fetch() URLs, and data transforms you discovered. ' + 'Include a complete ==UserScript== metadata header (@name, @namespace, @version, ' + '@description, an appropriate @match, @run-at and any @grant/@connect you need). ' + 'The script must be self-contained and idempotent. Output ONLY the finished ' + 'userscript inside a single ' + BT3 + 'javascript code block - no explanation ' + 'before or after.'; function extractCode(text) { if (!text) return ''; const re = new RegExp(BT3 + 'javascript\\s*([\\s\\S]*?)' + BT3, 'i'); const m = text.match(re); if (m) return m[1].trim(); return text.trim(); // fallback: raw text } async function copyToClipboard(text) { try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(text); return; } } catch (e) { /* fall through to fallback */ } const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.focus(); ta.select(); try { document.execCommand('copy'); } catch (e) { /* ignore */ } document.body.removeChild(ta); } function saveFile(text, filename) { const blob = new Blob([text], { type: 'text/javascript' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // Runs the convert prompt against a COPY of the conversation so the live one stays clean. async function generateUserscript() { const copy = JSON.parse(JSON.stringify(conversation)); const { answer } = await agent(CONVERT_PROMPT, append, copy); lastCode = extractCode(answer); return lastCode; } async function convert() { if (busy) return; if (!conversation) { append('! Nothing to convert yet - run the agent first.'); return; } setBusy(true, 'converting...'); try { const code = await generateUserscript(); await copyToClipboard(code); append('OK Userscript generated & copied to clipboard (' + code.length + ' chars). Use "Download .user.js" to save it.'); } catch (e) { append('ERROR: ' + (e && e.message ? e.message : String(e))); } finally { setBusy(false); } } async function download() { if (busy) return; if (!conversation) { append('! Nothing to convert yet - run the agent first.'); return; } setBusy(true, 'preparing download...'); try { const code = lastCode || await generateUserscript(); saveFile(code, 'page-agent-generated.user.js'); append('v Saved page-agent-generated.user.js (' + code.length + ' chars).'); } catch (e) { append('ERROR: ' + (e && e.message ? e.message : String(e))); } finally { setBusy(false); } } // ============================================================ // Settings // ============================================================ async function openSettings() { const ep = prompt('Endpoint (chat-completions URL):', CONFIG.endpoint); if (ep !== null) { CONFIG.endpoint = ep.trim() || DEFAULTS.endpoint; await GM.setValue('endpoint', CONFIG.endpoint); } const key = prompt('API key (Bearer token):', CONFIG.apiKey); if (key !== null) { CONFIG.apiKey = key.trim(); await GM.setValue('apiKey', CONFIG.apiKey); } const mdl = prompt('Model:', CONFIG.model); if (mdl !== null) { CONFIG.model = mdl.trim() || DEFAULTS.model; await GM.setValue('model', CONFIG.model); } append('[cfg] Settings saved - endpoint=' + CONFIG.endpoint + ' model=' + CONFIG.model + ' key=' + (CONFIG.apiKey ? '(set)' : '(empty)')); } // ============================================================ // Wiring // ============================================================ sendBtn.addEventListener('click', send); inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }); convertBtn.addEventListener('click', convert); downloadBtn.addEventListener('click', download); settingsBtn.addEventListener('click', openSettings); hideBtn.addEventListener('click', hidePanel); collapseBtn.addEventListener('click', () => { const collapsed = panel.classList.toggle('pa-collapsed'); collapseBtn.textContent = collapsed ? '[ ]' : '-'; collapseBtn.title = collapsed ? 'Expand' : 'Collapse'; }); // Desktop reveal/hide - global hotkey (Ctrl+Shift+Space by default). window.addEventListener('keydown', (e) => { if (!!e.ctrlKey === HOTKEY.ctrl && !!e.shiftKey === HOTKEY.shift && !!e.altKey === HOTKEY.alt && e.code === HOTKEY.code) { e.preventDefault(); togglePanel(); } else if (e.key === 'Escape' && !isHidden() && document.activeElement !== inputEl) { hidePanel(); } }, true); // Mobile reveal/hide - three-finger tap anywhere on the page. window.addEventListener('touchstart', (e) => { if (e.touches && e.touches.length === 3) { e.preventDefault(); togglePanel(); } }, { capture: true, passive: false }); append('Ready. ' + (CONFIG.apiKey ? '' : 'Set your API key via [cfg] first. ') + 'Ask me to inspect or act on this page.'); showToast('[AI] Page Agent loaded - ' + HOTKEY.label + ' (desktop) or three-finger tap (mobile) to open.'); })();