Skill: lmanchu/omni-research (version 1.0.0)

--- name: omni-research description: Multi-source deep research using your own browser. Queries Perplexity, Grok, and Gemini in parallel via CDP — zero API keys, uses your existing subscriptions. metadata: {"clawdbot":{"emoji":"🔬","requires":{"bins":["python3"],"pip":["httpx","websockets"]}}} --- # omni-research Research any topic by querying multiple AI services through your own browser. No API keys — uses your existing Perplexity Pro, X Premium, Gemini Advanced subscriptions. ## How It Works 1. Connects to your running browser via CDP (Chrome DevTools Protocol) 2. Opens parallel tabs to Perplexity, Grok, and Gemini (you're already logged in) 3. Submits your query, waits for response, extracts answer from each 4. Synthesizes all results into a unified summary ## Prerequisites - Python 3.10+ with `httpx` and `websockets` - Chrome, Edge, or any Chromium browser running with CDP: ```bash # Add to your browser shortcut or launch command (one-time) --remote-debugging-port=9222 ``` - Logged into your AI services in the browser ## Usage ```bash # All browser sources (Perplexity + Grok + Gemini) python3 research.py "AIPC market trends 2026" # Specific sources python3 research.py --sources perplexity,grok "topic" # API-only mode (no browser needed) python3 research.py --sources gemini-api "quick question" # JSON output python3 research.py --json "query" ``` ## Available Sources | Source | Type | Requires | |--------|------|----------| | `perplexity` | Browser | Perplexity Pro login | | `grok` | Browser | X Premium / Grok login | | `gemini` | Browser | Google account login | | `gemini-api` | API | OpenAI-compatible endpoint | ## Configuration Optional `~/.config/omni-research/config.json`: ```json { "cdp_port": 9222, "cliproxy_url": "http://127.0.0.1:8317/v1", "cliproxy_key": "your-key", "synthesis_model": "glm-4.7", "gemini_api_model": "gemini-2.5-flash" } ``` ## Architecture ``` User's Browser (Chrome/Edge/Comet/Arc, CDP :9222) ├── Tab: perplexity.ai → user's Pro session ├── Tab: grok.com → user's Premium session └── Tab: gemini.google.com → user's Google session ↓ WebSocket (CDP Input.insertText + dispatchKeyEvent) BrowserBridge (browser.py — httpx + websockets) ↓ ← IrisGo runtime replaces this layer omni-research skill (parallel query + extract + synthesize) ↓ Markdown output with per-source sections + synthesis ``` ## For IrisGo The `BrowserBridge` in `browser.py` is an abstract interface. Current implementation uses direct CDP via WebSocket. IrisGo runtime provides native browser APIs — just swap the bridge layer. ```python # skill.json requirement { "requirements": { "tools": ["browser"] } } ```

path: browser.py
content: """Browser Bridge — abstract interface for skill ↔ user's browser.

Current implementation: CDP (Chrome DevTools Protocol) via httpx + websockets.
Connects directly to the user's running browser — no Playwright, no agent-browser.
Future: IrisGo runtime replaces this with native browser API.
"""

import asyncio
import json
import time

import httpx


class BrowserBridge:
    """Connect to the user's running browser via CDP."""

    def __init__(self, cdp_port: int = 9222):
        self.cdp_port = cdp_port
        self.base_url = f"http://127.0.0.1:{cdp_port}"
        self._ws_connections: dict[str, object] = {}
        self._input_lock = asyncio.Lock()

    def verify_connection(self) -> bool:
        """Check if the user's browser is reachable."""
        try:
            resp = httpx.get(f"{self.base_url}/json/version", timeout=3)
            return resp.status_code == 200
        except Exception:
            return False

    def get_browser_info(self) -> dict:
        resp = httpx.get(f"{self.base_url}/json/version", timeout=3)
        return resp.json()

    def list_tabs(self) -> list[dict]:
        resp = httpx.get(f"{self.base_url}/json/list", timeout=5)
        return [t for t in resp.json() if t.get("type") == "page"]

    def new_tab(self, url: str = "") -> dict:
        """Open a new tab, return tab info with webSocketDebuggerUrl."""
        target = f"{self.base_url}/json/new?{url}" if url else f"{self.base_url}/json/new"
        # Try PUT first (Comet/newer Chrome), fall back to GET
        resp = httpx.put(target, timeout=10)
        if resp.status_code == 405:
            resp = httpx.get(target, timeout=10)
        return resp.json()

    def close_tab(self, tab_id: str) -> bool:
        try:
            resp = httpx.get(f"{self.base_url}/json/close/{tab_id}", timeout=5)
            return resp.status_code == 200
        except Exception:
            return False

    async def connect_tab(self, ws_url: str):
        """Create a persistent WebSocket connection to a tab."""
        import websockets
        ws = await websockets.connect(ws_url, max_size=10 * 1024 * 1024)
        self._ws_connections[ws_url] = {"ws": ws, "msg_id": 1}
        return ws

    async def _send(self, ws_url: str, method: str, params: dict | None = None) -> dict:
        """Send CDP command on an existing connection."""
        conn = self._ws_connections.get(ws_url)
        if not conn:
            raise RuntimeError("Not connected. Call connect_tab first.")
        ws = conn["ws"]
        msg_id = conn["msg_id"]
        conn["msg_id"] += 1

        cmd = {"id": msg_id, "method": method}
        if params:
            cmd["params"] = params
        await ws.send(json.dumps(cmd))

        while True:
            resp = json.loads(await asyncio.wait_for(ws.recv(), timeout=30))
            if resp.get("id") == msg_id:
                return resp.get("result", {})

    async def eval_js(self, ws_url: str, expression: str) -> str:
        """Evaluate JavaScript and return string result."""
        result = await self._send(ws_url, "Runtime.evaluate",
                                  {"expression": expression, "returnByValue": True, "awaitPromise": True})
        val = result.get("result", {}).get("value", "")
        return str(val) if val is not None else ""

    async def insert_text(self, ws_url: str, text: str):
        """Type text using CDP Input.insertText (works with React/Vue)."""
        await self._send(ws_url, "Input.insertText", {"text": text})

    async def press_enter(self, ws_url: str):
        """Press Enter key via CDP."""
        await self._send(ws_url, "Input.dispatchKeyEvent", {
            "type": "keyDown", "key": "Enter", "code": "Enter",
            "windowsVirtualKeyCode": 13, "nativeVirtualKeyCode": 13,
        })
        await self._send(ws_url, "Input.dispatchKeyEvent", {
            "type": "keyUp", "key": "Enter", "code": "Enter",
            "windowsVirtualKeyCode": 13, "nativeVirtualKeyCode": 13,
        })

    async def close_ws(self, ws_url: str):
        """Close WebSocket connection."""
        conn = self._ws_connections.pop(ws_url, None)
        if conn:
            await conn["ws"].close()

    async def bring_to_front(self, ws_url: str):
        """Activate this tab (required before Input.insertText in multi-tab)."""
        await self._send(ws_url, "Page.bringToFront")

    async def submit_query(self, ws_url: str, focus_js: str, query: str):
        """Phase 1: Focus → type → Enter. Must hold _input_lock for multi-tab safety."""
        async with self._input_lock:
            await self.bring_to_front(ws_url)
            await asyncio.sleep(0.3)
            await self.eval_js(ws_url, focus_js)
            await asyncio.sleep(0.5)
            await self.insert_text(ws_url, query)
            await asyncio.sleep(0.8)
            await self.press_enter(ws_url)

    async def poll_response(self, ws_url: str, extract_js: str,
                            wait_after: float, poll_interval: float,
                            poll_max: int, min_length: int) -> str:
        """Phase 2: Wait → poll until stable. Safe to run in parallel."""
        await asyncio.sleep(wait_after)
        prev_text = ""
        stable_count = 0
        for _ in range(poll_max):
            await asyncio.sleep(poll_interval)
            text = await self.eval_js(ws_url, extract_js)
            if text == prev_text and len(text) > min_length:
                stable_count += 1
                if stable_count >= 2:
                    break
            else:
                stable_count = 0
            prev_text = text
        return prev_text

    async def query_in_tab(self, url: str, focus_js: str, query: str,
                           extract_js: str, wait_before: float = 8.0,
                           wait_after: float = 10.0, poll_interval: float = 3.0,
                           poll_max: int = 30, min_length: int = 200) -> str:
        """Full flow: open tab → submit query (serialized) → poll (parallel-safe) → close."""
        tab = self.new_tab(url)
        tab_id = tab.get("id", "")
        ws_url = tab.get("webSocketDebuggerUrl", "")

        if not ws_url:
            self.close_tab(tab_id)
            return "[Error] No WebSocket URL for tab"

        try:
            await self.connect_tab(ws_url)
            await asyncio.sleep(wait_before)

            # Phase 1: submit (serialized via _input_lock)
            await self.submit_query(ws_url, focus_js, query)

            # Phase 2: poll (parallel-safe, uses tab-specific WebSocket)
            text = await self.poll_response(
                ws_url, extract_js, wait_after, poll_interval, poll_max, min_length
            )
            return text or "[No response]"

        finally:
            await self.close_ws(ws_url)
            self.close_tab(tab_id)


def detect_cdp_port() -> int | None:
    for port in [9222, 9229, 9221, 18800]:
        try:
            resp = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=1)
            if resp.status_code == 200:
                return port
        except Exception:
            continue
    return None


def ensure_browser_bridge(config: dict | None = None) -> BrowserBridge:
    port = (config or {}).get("cdp_port")
    if port is None:
        port = detect_cdp_port()
    if port is None:
        raise RuntimeError(
            "Cannot connect to browser. Start your browser with CDP:\n"
            "  --remote-debugging-port=9222\n"
            "Or add this flag to your browser shortcut."
        )
    bridge = BrowserBridge(cdp_port=port)
    if not bridge.verify_connection():
        raise RuntimeError(f"CDP port {port} found but connection failed.")
    return bridge


path: requirements.txt
content: httpx>=0.27
websockets>=12.0


path: research.py
content: #!/usr/bin/env python3
"""omni-research: Multi-source deep research using the user's own browser sessions.

Connects to the user's running browser via CDP. No API keys needed —
uses whatever services the user is already logged into.

Usage:
    python3 research.py "your research query"
    python3 research.py --sources perplexity,gemini "query"
    python3 research.py --sources gemini-api "quick query"
"""

import asyncio
import json
import sys
import time
from pathlib import Path

import httpx

from browser import BrowserBridge, ensure_browser_bridge

CONFIG_PATH = Path.home() / ".config" / "omni-research" / "config.json"

DEFAULT_CONFIG = {
    "cdp_port": None,
    "cliproxy_url": "http://127.0.0.1:8317/v1",
    "cliproxy_key": "magi-proxy-key-2026",
    "synthesis_model": "glm-4.7",
    "gemini_api_model": "gemini-2.5-flash",
    "timeout_browser": 180,
    "timeout_api": 30,
}


def load_config() -> dict:
    config = dict(DEFAULT_CONFIG)
    if CONFIG_PATH.exists():
        with open(CONFIG_PATH) as f:
            config.update(json.load(f))
    return config


# ── Browser sources (CDP) ────────────────────────────────────────────────


async def query_perplexity_browser(browser: BrowserBridge, query: str) -> str:
    """Query Perplexity via user's logged-in browser."""
    try:
        return await browser.query_in_tab(
            url="https://www.perplexity.ai/",
            focus_js='document.querySelector("div[contenteditable=true]").focus()',
            query=query,
            extract_js="""(() => {
                const bodyText = document.body.innerText;
                // Check if still loading
                if (bodyText.includes('思考中') || bodyText.includes('搜尋中') || bodyText.includes('Searching')) return '';
                // Strategy 1: find .prose or markdown containers
                const prose = document.querySelectorAll('.prose, [class*="prose"], [class*="markdown"]');
                if (prose.length > 0) {
                    const last = prose[prose.length-1].innerText;
                    if (last.length > 100) return last;
                }
                // Strategy 2: find answer text from body content
                const lines = bodyText.split('\\n').filter(l => l.trim().length > 40);
                if (lines.length > 3) return lines.join('\\n');
                return '';
            })()""",
            wait_before=8.0,
            wait_after=15.0,
            poll_interval=3.0,
            poll_max=35,
            min_length=300,
        )
    except Exception as e:
        return f"[Perplexity] Error: {e}"


async def query_grok_browser(browser: BrowserBridge, query: str) -> str:
    """Query Grok via user's logged-in session on grok.com."""
    try:
        return await browser.query_in_tab(
            url="https://grok.com/",
            focus_js="""(() => {
                const pm = document.querySelector('.ProseMirror');
                if (pm) { pm.focus(); return 'prosemirror'; }
                const ta = document.querySelector('textarea');
                if (ta) { ta.focus(); return 'textarea'; }
                const ce = document.querySelector('[contenteditable="true"]');
                if (ce) { ce.focus(); return 'ce'; }
                return 'none';
            })()""",
            query=query,
            extract_js="""(() => {
                // Strategy 1: response-content-markdown (exact Grok selector)
                const md = document.querySelectorAll('.response-content-markdown, [class*="response-content-markdown"]');
                if (md.length > 0) {
                    const last = md[md.length-1].innerText;
                    if (last.length > 30) return last;
                }
                // Strategy 2: message-bubble (Grok chat bubble)
                const bubbles = document.querySelectorAll('[class*="message-bubble"]');
                if (bubbles.length > 0) {
                    const last = bubbles[bubbles.length-1].innerText;
                    if (last.length > 30) return last;
                }
                // Strategy 3: chat scroll area (contains all messages minus sidebar)
                const scroll = document.querySelector('[class*="overflow-y-auto"][class*="scrollbar-gutter"]');
                if (scroll && scroll.innerText.length > 50) {
                    // Strip the user query (first line) to get just the response
                    const lines = scroll.innerText.split('\\n').filter(l => l.trim());
                    const responseStart = lines.findIndex((l, i) => i > 0 && l.length > 20);
                    if (responseStart > 0) return lines.slice(responseStart).join('\\n');
                }
                return '';
            })()""",
            wait_before=8.0,
            wait_after=12.0,
            poll_interval=3.0,
            poll_max=30,
            min_length=50,
        )
    except Exception as e:
        return f"[Grok] Error: {e}"


async def query_gemini_browser(browser: BrowserBridge, query: str) -> str:
    """Query Gemini via user's logged-in Google session."""
    try:
        return await browser.query_in_tab(
            url="https://gemini.google.com/app",
            focus_js="""(() => {
                const tb = document.querySelector('[role="textbox"]');
                if (tb) { tb.focus(); return 'textbox'; }
                const ce = document.querySelector('[contenteditable="true"]');
                if (ce) { ce.focus(); return 'ce'; }
                return 'none';
            })()""",
            query=query,
            extract_js="""(() => {
                // Gemini response containers
                const sels = ['.model-response-text', '.response-content', '[class*="markdown"]', '.message-content'];
                for (const s of sels) {
                    const els = document.querySelectorAll(s);
                    if (els.length > 0 && els[els.length-1].innerText.length > 50) return els[els.length-1].innerText;
                }
                // Fallback: largest content div
                const allDivs = document.querySelectorAll('div');
                let best = null, bestLen = 0;
                for (const d of allDivs) {
                    const t = d.innerText;
                    if (t.length > 200 && t.length < 15000 && !d.querySelector('nav, header, aside')) {
                        if (t.length > bestLen) { bestLen = t.length; best = d; }
                    }
                }
                if (best) return best.innerText;
                return '';
            })()""",
            wait_before=5.0,
            wait_after=15.0,
            poll_interval=3.0,
            poll_max=30,
            min_length=200,
        )
    except Exception as e:
        return f"[Gemini] Error: {e}"


# ── API sources ──────────────────────────────────────────────────────────


async def _cliproxy_chat(config: dict, model: str, messages: list[dict]) -> str:
    url = config["cliproxy_url"]
    key = config["cliproxy_key"]
    async with httpx.AsyncClient(timeout=config["timeout_api"]) as client:
        for attempt in range(3):
            resp = await client.post(
                f"{url}/chat/completions",
                headers={"Authorization": f"Bearer {key}"},
                json={"model": model, "messages": messages, "max_tokens": 4096},
            )
            if resp.status_code == 429:
                wait = 3 * (attempt + 1)
                print(f"  {model}: 429, retry in {wait}s...", file=sys.stderr)
                await asyncio.sleep(wait)
                continue
            resp.raise_for_status()
            return resp.json()["choices"][0]["message"]["content"].strip()
        return f"[{model}] Rate limited after 3 retries"


async def query_gemini_api(config: dict, query: str) -> str:
    try:
        return await _cliproxy_chat(
            config, config["gemini_api_model"],
            [
                {"role": "system", "content": "You are a research assistant. Provide detailed, sourced answers."},
                {"role": "user", "content": query},
            ],
        )
    except Exception as e:
        return f"[Gemini API] Error: {e}"


# ── Synthesis ────────────────────────────────────────────────────────────


async def synthesize(config: dict, query: str, results: dict[str, str]) -> str:
    sources_text = ""
    for name, text in results.items():
        truncated = text[:3000] if len(text) > 3000 else text
        sources_text += f"\n\n### {name}\n{truncated}"
    # Try synthesis model, fall back to gemini if rate limited
    for model in [config["synthesis_model"], config["gemini_api_model"]]:
        try:
            return await _cliproxy_chat(
                config, model,
                [
                    {"role": "system", "content": "Synthesize these research results into 3-5 bullet points. Note agreements, disagreements, unique insights. Write in the query's language."},
                    {"role": "user", "content": f"Query: {query}\n\nSources:{sources_text}"},
                ],
            )
        except Exception:
            continue
    return "[Synthesis] All models rate limited"


# ── Source registry ──────────────────────────────────────────────────────

BROWSER_SOURCES = {
    "perplexity": query_perplexity_browser,
    "grok": query_grok_browser,
    "gemini": query_gemini_browser,
}
API_SOURCES = {"gemini-api": query_gemini_api}
ALL_SOURCE_NAMES = list(BROWSER_SOURCES.keys()) + list(API_SOURCES.keys())


# ── Main ─────────────────────────────────────────────────────────────────


async def research(query: str, sources: list[str] | None = None) -> str:
    config = load_config()
    if sources is None:
        sources = list(BROWSER_SOURCES.keys())

    print(f"Researching: {query}", file=sys.stderr)
    print(f"Sources: {', '.join(sources)}", file=sys.stderr)

    need_browser = any(s in BROWSER_SOURCES for s in sources)
    browser = None

    if need_browser:
        try:
            browser = ensure_browser_bridge(config)
            info = browser.get_browser_info()
            print(f"  Browser: {info.get('Browser', '?')} on port {browser.cdp_port}", file=sys.stderr)
        except RuntimeError as e:
            print(f"  {e}", file=sys.stderr)
            sources = [s for s in sources if s in API_SOURCES] or ["gemini-api"]
            print(f"  Falling back to: {', '.join(sources)}", file=sys.stderr)

    start = time.time()
    results = {}

    # Browser sources — run in parallel (each opens its own tab)
    browser_tasks = {}
    for name in sources:
        if name in BROWSER_SOURCES and browser:
            browser_tasks[name] = asyncio.create_task(BROWSER_SOURCES[name](browser, query))

    # API sources — also parallel
    api_tasks = {}
    for name in sources:
        if name in API_SOURCES:
            api_tasks[name] = asyncio.create_task(API_SOURCES[name](config, query))

    # Gather all
    for name, task in {**browser_tasks, **api_tasks}.items():
        try:
            results[name] = await asyncio.wait_for(task, timeout=config["timeout_browser"])
            print(f"  {name}: done ({time.time() - start:.1f}s)", file=sys.stderr)
        except asyncio.TimeoutError:
            results[name] = f"[{name}] Timed out"
            print(f"  {name}: timeout", file=sys.stderr)

    # Synthesize
    if len(results) > 1:
        print("  Synthesizing...", file=sys.stderr)
        summary = await synthesize(config, query, results)
    else:
        summary = None

    elapsed = time.time() - start
    print(f"  Total: {elapsed:.1f}s", file=sys.stderr)

    output = f"## Research: {query}\n"
    for name, text in results.items():
        output += f"\n### {name.title()}\n{text}\n"
    if summary:
        output += f"\n---\n### Summary\n{summary}\n"
    return output


def main():
    import argparse
    parser = argparse.ArgumentParser(description="omni-research")
    parser.add_argument("query", help="Research query")
    parser.add_argument("--sources", "-s", default=None)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()
    sources = args.sources.split(",") if args.sources else None
    result = asyncio.run(research(args.query, sources))
    if args.json:
        print(json.dumps({"query": args.query, "result": result}))
    else:
        print(result)


if __name__ == "__main__":
    main()


path: setup.py
content: #!/usr/bin/env python3
"""Setup helper for omni-research.

Checks prerequisites and creates default config.
Browser login is not needed — the skill uses your existing browser sessions.
"""

import json
import shutil
import subprocess
import sys
from pathlib import Path

CONFIG_DIR = Path.home() / ".config" / "omni-research"
CONFIG_FILE = CONFIG_DIR / "config.json"

DEFAULT_CONFIG = {
    "cdp_port": 9222,
    "cliproxy_url": "http://127.0.0.1:8317/v1",
    "cliproxy_key": "",
    "synthesis_model": "glm-4.7",
    "gemini_api_model": "gemini-2.5-flash",
}


def check_agent_browser() -> bool:
    return shutil.which("agent-browser") is not None


def check_cdp(port: int = 9222) -> bool:
    try:
        import httpx
        resp = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2)
        return resp.status_code == 200
    except Exception:
        return False


def main():
    print("omni-research setup")
    print("=" * 40)

    # Check agent-browser
    if check_agent_browser():
        print("[OK] agent-browser installed")
    else:
        print("[!!] agent-browser not found")
        print("     Install: npm install -g agent-browser")

    # Check CDP
    if check_cdp():
        print("[OK] Browser CDP available on port 9222")
    else:
        print("[!!] Browser CDP not available")
        print("     Start Chrome/Edge with: --remote-debugging-port=9222")

    # Create config
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    if not CONFIG_FILE.exists():
        with open(CONFIG_FILE, "w") as f:
            json.dump(DEFAULT_CONFIG, f, indent=2)
        print(f"[OK] Config created: {CONFIG_FILE}")
    else:
        print(f"[OK] Config exists: {CONFIG_FILE}")

    print()
    print("To use:")
    print("  python3 research.py 'your query here'")
    print()
    print("For API-only mode (no browser):")
    print("  python3 research.py --sources gemini-api 'query'")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)