squidbot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- squidbot/__init__.py +5 -0
- squidbot/agent.py +263 -0
- squidbot/channels.py +271 -0
- squidbot/character.py +83 -0
- squidbot/client.py +318 -0
- squidbot/config.py +148 -0
- squidbot/daemon.py +310 -0
- squidbot/lanes.py +41 -0
- squidbot/main.py +157 -0
- squidbot/memory_db.py +706 -0
- squidbot/playwright_check.py +233 -0
- squidbot/plugins/__init__.py +47 -0
- squidbot/plugins/base.py +96 -0
- squidbot/plugins/hooks.py +416 -0
- squidbot/plugins/loader.py +248 -0
- squidbot/plugins/web3_plugin.py +407 -0
- squidbot/scheduler.py +214 -0
- squidbot/server.py +487 -0
- squidbot/session.py +609 -0
- squidbot/skills.py +141 -0
- squidbot/skills_template/reminder/SKILL.md +13 -0
- squidbot/skills_template/search/SKILL.md +11 -0
- squidbot/skills_template/summarize/SKILL.md +14 -0
- squidbot/tools/__init__.py +100 -0
- squidbot/tools/base.py +42 -0
- squidbot/tools/browser.py +311 -0
- squidbot/tools/coding.py +599 -0
- squidbot/tools/cron.py +218 -0
- squidbot/tools/memory_tool.py +152 -0
- squidbot/tools/web_search.py +50 -0
- squidbot-0.1.0.dist-info/METADATA +542 -0
- squidbot-0.1.0.dist-info/RECORD +34 -0
- squidbot-0.1.0.dist-info/WHEEL +4 -0
- squidbot-0.1.0.dist-info/entry_points.txt +4 -0
squidbot/skills.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Skills system - load markdown prompts to teach agent behaviors."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import aiofiles
|
|
9
|
+
|
|
10
|
+
from .config import DATA_DIR
|
|
11
|
+
|
|
12
|
+
SKILLS_DIR = DATA_DIR / "skills"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Skill:
|
|
17
|
+
"""A skill loaded from a markdown file."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
content: str
|
|
22
|
+
file_path: Path
|
|
23
|
+
metadata: dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
27
|
+
"""Parse YAML frontmatter from markdown content."""
|
|
28
|
+
metadata = {}
|
|
29
|
+
body = content
|
|
30
|
+
|
|
31
|
+
if content.startswith("---"):
|
|
32
|
+
parts = content.split("---", 2)
|
|
33
|
+
if len(parts) >= 3:
|
|
34
|
+
frontmatter = parts[1].strip()
|
|
35
|
+
body = parts[2].strip()
|
|
36
|
+
|
|
37
|
+
for line in frontmatter.split("\n"):
|
|
38
|
+
if ":" in line:
|
|
39
|
+
key, value = line.split(":", 1)
|
|
40
|
+
metadata[key.strip()] = value.strip()
|
|
41
|
+
|
|
42
|
+
return metadata, body
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def load_skill(skill_dir: Path) -> Optional[Skill]:
|
|
46
|
+
"""Load a skill from a directory containing SKILL.md."""
|
|
47
|
+
skill_file = skill_dir / "SKILL.md"
|
|
48
|
+
if not skill_file.exists():
|
|
49
|
+
skill_file = skill_dir / "skill.md"
|
|
50
|
+
if not skill_file.exists():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
async with aiofiles.open(skill_file, "r", encoding="utf-8") as f:
|
|
55
|
+
content = await f.read()
|
|
56
|
+
|
|
57
|
+
metadata, body = parse_frontmatter(content)
|
|
58
|
+
|
|
59
|
+
return Skill(
|
|
60
|
+
name=metadata.get("name", skill_dir.name),
|
|
61
|
+
description=metadata.get("description", ""),
|
|
62
|
+
content=body,
|
|
63
|
+
file_path=skill_file,
|
|
64
|
+
metadata=metadata,
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def load_all_skills(skills_dir: Path = None) -> list[Skill]:
|
|
71
|
+
"""Load all skills from the skills directory."""
|
|
72
|
+
if skills_dir is None:
|
|
73
|
+
skills_dir = SKILLS_DIR
|
|
74
|
+
|
|
75
|
+
if not skills_dir.exists():
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
# Load skills concurrently
|
|
79
|
+
tasks = []
|
|
80
|
+
for item in skills_dir.iterdir():
|
|
81
|
+
if item.is_dir():
|
|
82
|
+
tasks.append(load_skill(item))
|
|
83
|
+
|
|
84
|
+
results = await asyncio.gather(*tasks)
|
|
85
|
+
return [s for s in results if s is not None]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def format_skills_for_prompt(skills: list[Skill]) -> str:
|
|
89
|
+
"""Format skills as a prompt section for the LLM."""
|
|
90
|
+
if not skills:
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
lines = ["## Available Skills\n"]
|
|
94
|
+
for skill in skills:
|
|
95
|
+
lines.append(f"### {skill.name}")
|
|
96
|
+
if skill.description:
|
|
97
|
+
lines.append(f"*{skill.description}*\n")
|
|
98
|
+
lines.append(skill.content)
|
|
99
|
+
lines.append("")
|
|
100
|
+
|
|
101
|
+
return "\n".join(lines)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def get_skills_context() -> str:
|
|
105
|
+
"""Get all skills formatted for system prompt."""
|
|
106
|
+
skills = await load_all_skills()
|
|
107
|
+
return format_skills_for_prompt(skills)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def ensure_skills_dir():
|
|
111
|
+
"""Ensure skills directory exists."""
|
|
112
|
+
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def create_example_skill():
|
|
116
|
+
"""Create an example skill if none exist."""
|
|
117
|
+
await ensure_skills_dir()
|
|
118
|
+
|
|
119
|
+
example_dir = SKILLS_DIR / "weather"
|
|
120
|
+
if example_dir.exists():
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
example_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
skill_content = """---
|
|
125
|
+
name: weather
|
|
126
|
+
description: Get weather information for a location
|
|
127
|
+
---
|
|
128
|
+
When the user asks about weather:
|
|
129
|
+
|
|
130
|
+
1. Use the `web_search` tool to search for current weather
|
|
131
|
+
2. Search query format: "weather in {city} today"
|
|
132
|
+
3. Extract the temperature, conditions, and forecast
|
|
133
|
+
4. Present the information in a friendly format
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
User: "What's the weather in Tokyo?"
|
|
137
|
+
→ Search: "weather in Tokyo today"
|
|
138
|
+
→ Response: "It's currently 18°C in Tokyo with partly cloudy skies..."
|
|
139
|
+
"""
|
|
140
|
+
async with aiofiles.open(example_dir / "SKILL.md", "w") as f:
|
|
141
|
+
await f.write(skill_content)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reminder
|
|
3
|
+
description: Set reminders and scheduled tasks
|
|
4
|
+
---
|
|
5
|
+
When user wants to set a reminder:
|
|
6
|
+
|
|
7
|
+
1. Extract the message and time
|
|
8
|
+
2. Use `cron_create` tool with appropriate delay_minutes or cron_expression
|
|
9
|
+
3. Confirm the reminder was set
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
- "Remind me in 10 minutes" → delay_minutes=10
|
|
13
|
+
- "Remind me daily at 9am" → cron_expression="0 9 * * *"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: search
|
|
3
|
+
description: Search the web for information
|
|
4
|
+
---
|
|
5
|
+
When user asks a question requiring current information:
|
|
6
|
+
|
|
7
|
+
1. Use `web_search` tool with a clear query
|
|
8
|
+
2. Review the results
|
|
9
|
+
3. Synthesize a helpful answer with sources
|
|
10
|
+
|
|
11
|
+
Always cite sources when providing factual information.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: summarize
|
|
3
|
+
description: Summarize text or web content
|
|
4
|
+
---
|
|
5
|
+
When user asks to summarize something:
|
|
6
|
+
|
|
7
|
+
1. If given a URL, use `browser_navigate` to visit it
|
|
8
|
+
2. Use `browser_get_text` to extract content
|
|
9
|
+
3. Provide a concise summary with key points
|
|
10
|
+
|
|
11
|
+
Format:
|
|
12
|
+
- **TL;DR**: One sentence summary
|
|
13
|
+
- **Key Points**: 3-5 bullet points
|
|
14
|
+
- **Details**: Brief elaboration if needed
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Tool registry - all available tools for the agent."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .base import Tool
|
|
6
|
+
from .browser import (BrowserClickTool, BrowserGetTextTool,
|
|
7
|
+
BrowserNavigateTool, BrowserScreenshotTool,
|
|
8
|
+
BrowserSnapshotTool, BrowserTypeTool)
|
|
9
|
+
from .coding import get_coding_tools
|
|
10
|
+
from .cron import CronClearTool, CronCreateTool, CronDeleteTool, CronListTool
|
|
11
|
+
from .memory_tool import (MemoryAddTool, MemoryDeleteTool, MemoryListTool,
|
|
12
|
+
MemorySearchTool)
|
|
13
|
+
from .web_search import WebSearchTool
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Core tools (always available)
|
|
18
|
+
CORE_TOOLS: list[Tool] = [
|
|
19
|
+
# Memory (SQLite + vector search)
|
|
20
|
+
MemoryAddTool(),
|
|
21
|
+
MemorySearchTool(),
|
|
22
|
+
MemoryListTool(),
|
|
23
|
+
MemoryDeleteTool(),
|
|
24
|
+
# Web search
|
|
25
|
+
WebSearchTool(),
|
|
26
|
+
# Browser
|
|
27
|
+
BrowserNavigateTool(),
|
|
28
|
+
BrowserScreenshotTool(),
|
|
29
|
+
BrowserSnapshotTool(),
|
|
30
|
+
BrowserClickTool(),
|
|
31
|
+
BrowserTypeTool(),
|
|
32
|
+
BrowserGetTextTool(),
|
|
33
|
+
# Scheduling
|
|
34
|
+
CronCreateTool(),
|
|
35
|
+
CronListTool(),
|
|
36
|
+
CronDeleteTool(),
|
|
37
|
+
CronClearTool(),
|
|
38
|
+
# Coding (Zig + Python)
|
|
39
|
+
*get_coding_tools(),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Plugin tools (loaded dynamically)
|
|
43
|
+
_plugin_tools: list[Tool] = []
|
|
44
|
+
_plugins_loaded = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_plugins() -> None:
|
|
48
|
+
"""Load plugins and their tools."""
|
|
49
|
+
global _plugin_tools, _plugins_loaded
|
|
50
|
+
|
|
51
|
+
if _plugins_loaded:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from ..plugins import get_registry, load_builtin_plugins
|
|
56
|
+
|
|
57
|
+
# Load all built-in plugins
|
|
58
|
+
load_builtin_plugins()
|
|
59
|
+
|
|
60
|
+
# Get tools from all plugins
|
|
61
|
+
registry = get_registry()
|
|
62
|
+
_plugin_tools = registry.get_all_tools()
|
|
63
|
+
|
|
64
|
+
logger.info(f"Loaded {len(_plugin_tools)} tools from plugins")
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.warning(f"Failed to load plugins: {e}")
|
|
68
|
+
_plugin_tools = []
|
|
69
|
+
|
|
70
|
+
_plugins_loaded = True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_all_tools() -> list[Tool]:
|
|
74
|
+
"""Get all tools including plugin tools."""
|
|
75
|
+
_load_plugins()
|
|
76
|
+
return CORE_TOOLS + _plugin_tools
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Legacy alias for backward compatibility
|
|
80
|
+
ALL_TOOLS = CORE_TOOLS # Will be updated after first call to get_all_tools()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_tool_by_name(name: str) -> Tool | None:
|
|
84
|
+
"""Get a tool by its name."""
|
|
85
|
+
for tool in get_all_tools():
|
|
86
|
+
if tool.name == name:
|
|
87
|
+
return tool
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_openai_tools() -> list[dict]:
|
|
92
|
+
"""Get all tools in OpenAI format."""
|
|
93
|
+
return [tool.to_openai_tool() for tool in get_all_tools()]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reload_plugins() -> None:
|
|
97
|
+
"""Reload all plugins (useful for development)."""
|
|
98
|
+
global _plugins_loaded
|
|
99
|
+
_plugins_loaded = False
|
|
100
|
+
_load_plugins()
|
squidbot/tools/base.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Base tool class and types."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Tool(ABC):
|
|
8
|
+
"""Base class for all tools."""
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
"""Tool name for OpenAI function calling."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def description(self) -> str:
|
|
19
|
+
"""Tool description."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def parameters(self) -> dict:
|
|
25
|
+
"""JSON Schema for tool parameters."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def execute(self, **kwargs) -> Any:
|
|
30
|
+
"""Execute the tool with given parameters."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
def to_openai_tool(self) -> dict:
|
|
34
|
+
"""Convert to OpenAI tool format."""
|
|
35
|
+
return {
|
|
36
|
+
"type": "function",
|
|
37
|
+
"function": {
|
|
38
|
+
"name": self.name,
|
|
39
|
+
"description": self.description,
|
|
40
|
+
"parameters": self.parameters,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Browser automation tools using Playwright."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from playwright.async_api import Browser, Page, async_playwright
|
|
7
|
+
|
|
8
|
+
from .base import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrowserManager:
|
|
12
|
+
"""Manages browser instance and pages."""
|
|
13
|
+
|
|
14
|
+
_instance: Optional["BrowserManager"] = None
|
|
15
|
+
_browser: Optional[Browser] = None
|
|
16
|
+
_page: Optional[Page] = None
|
|
17
|
+
_playwright = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
async def get_instance(cls) -> "BrowserManager":
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
cls._instance = cls()
|
|
23
|
+
return cls._instance
|
|
24
|
+
|
|
25
|
+
async def get_page(self) -> Page:
|
|
26
|
+
"""Get or create browser page."""
|
|
27
|
+
if self._browser is None:
|
|
28
|
+
self._playwright = await async_playwright().start()
|
|
29
|
+
self._browser = await self._playwright.chromium.launch(headless=True)
|
|
30
|
+
|
|
31
|
+
if self._page is None or self._page.is_closed():
|
|
32
|
+
self._page = await self._browser.new_page()
|
|
33
|
+
|
|
34
|
+
return self._page
|
|
35
|
+
|
|
36
|
+
async def close(self):
|
|
37
|
+
"""Close browser."""
|
|
38
|
+
if self._browser:
|
|
39
|
+
await self._browser.close()
|
|
40
|
+
self._browser = None
|
|
41
|
+
self._page = None
|
|
42
|
+
if self._playwright:
|
|
43
|
+
await self._playwright.stop()
|
|
44
|
+
self._playwright = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BrowserNavigateTool(Tool):
|
|
48
|
+
"""Navigate to a URL."""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def name(self) -> str:
|
|
52
|
+
return "browser_navigate"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def description(self) -> str:
|
|
56
|
+
return "Navigate the browser to a specific website URL. USE THIS when you need to visit a specific website (like techcrunch.com, news sites, etc.) to read its actual current content. After navigating, use browser_get_text to read the page content."
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def parameters(self) -> dict:
|
|
60
|
+
return {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"url": {"type": "string", "description": "URL to navigate to"}
|
|
64
|
+
},
|
|
65
|
+
"required": ["url"],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async def execute(self, url: str) -> str:
|
|
69
|
+
try:
|
|
70
|
+
manager = await BrowserManager.get_instance()
|
|
71
|
+
page = await manager.get_page()
|
|
72
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
73
|
+
return f"Navigated to: {page.url}\nTitle: {await page.title()}"
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return f"Navigation error: {str(e)}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BrowserScreenshotTool(Tool):
|
|
79
|
+
"""Take a screenshot of the current page."""
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def name(self) -> str:
|
|
83
|
+
return "browser_screenshot"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def description(self) -> str:
|
|
87
|
+
return "Take a screenshot of the current browser page. Returns base64 encoded image."
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def parameters(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"full_page": {
|
|
95
|
+
"type": "boolean",
|
|
96
|
+
"description": "Capture full page (default false)",
|
|
97
|
+
"default": False,
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"required": [],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async def execute(self, full_page: bool = False) -> str:
|
|
104
|
+
try:
|
|
105
|
+
import tempfile
|
|
106
|
+
from datetime import datetime
|
|
107
|
+
|
|
108
|
+
manager = await BrowserManager.get_instance()
|
|
109
|
+
page = await manager.get_page()
|
|
110
|
+
screenshot = await page.screenshot(full_page=full_page)
|
|
111
|
+
|
|
112
|
+
# Save to temp file for Telegram
|
|
113
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
114
|
+
temp_path = tempfile.gettempdir() + f"/squidbot_screenshot_{timestamp}.png"
|
|
115
|
+
with open(temp_path, "wb") as f:
|
|
116
|
+
f.write(screenshot)
|
|
117
|
+
|
|
118
|
+
# Return special format that server can detect
|
|
119
|
+
return (
|
|
120
|
+
f"[SCREENSHOT:{temp_path}] Screenshot saved ({len(screenshot)} bytes)"
|
|
121
|
+
)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
return f"Screenshot error: {str(e)}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class BrowserSnapshotTool(Tool):
|
|
127
|
+
"""Get accessibility tree snapshot of the page."""
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def name(self) -> str:
|
|
131
|
+
return "browser_snapshot"
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def description(self) -> str:
|
|
135
|
+
return "Get a text snapshot of the current page content (accessibility tree). Use this to understand page structure before interacting."
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def parameters(self) -> dict:
|
|
139
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
140
|
+
|
|
141
|
+
async def execute(self) -> str:
|
|
142
|
+
try:
|
|
143
|
+
manager = await BrowserManager.get_instance()
|
|
144
|
+
page = await manager.get_page()
|
|
145
|
+
|
|
146
|
+
# Get simplified page content
|
|
147
|
+
content = await page.evaluate(
|
|
148
|
+
"""() => {
|
|
149
|
+
const walk = (node, depth = 0) => {
|
|
150
|
+
let result = [];
|
|
151
|
+
const indent = ' '.repeat(depth);
|
|
152
|
+
|
|
153
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
154
|
+
const text = node.textContent.trim();
|
|
155
|
+
if (text) result.push(indent + text);
|
|
156
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
157
|
+
const tag = node.tagName.toLowerCase();
|
|
158
|
+
const role = node.getAttribute('role') || '';
|
|
159
|
+
const name = node.getAttribute('aria-label') || node.getAttribute('name') || '';
|
|
160
|
+
const href = node.getAttribute('href') || '';
|
|
161
|
+
|
|
162
|
+
let info = tag;
|
|
163
|
+
if (role) info += ` [${role}]`;
|
|
164
|
+
if (name) info += ` "${name}"`;
|
|
165
|
+
if (href && tag === 'a') info += ` -> ${href}`;
|
|
166
|
+
|
|
167
|
+
if (['script', 'style', 'noscript'].includes(tag)) return result;
|
|
168
|
+
|
|
169
|
+
if (['button', 'a', 'input', 'select', 'textarea', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) {
|
|
170
|
+
result.push(indent + info);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const child of node.childNodes) {
|
|
174
|
+
result = result.concat(walk(child, depth + 1));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
};
|
|
179
|
+
return walk(document.body).slice(0, 100).join('\\n');
|
|
180
|
+
}"""
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
title = await page.title()
|
|
184
|
+
url = page.url
|
|
185
|
+
return f"Page: {title}\nURL: {url}\n\nContent:\n{content}"
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return f"Snapshot error: {str(e)}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class BrowserClickTool(Tool):
|
|
191
|
+
"""Click an element on the page."""
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def name(self) -> str:
|
|
195
|
+
return "browser_click"
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def description(self) -> str:
|
|
199
|
+
return "Click an element on the page by text content or CSS selector."
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def parameters(self) -> dict:
|
|
203
|
+
return {
|
|
204
|
+
"type": "object",
|
|
205
|
+
"properties": {
|
|
206
|
+
"selector": {
|
|
207
|
+
"type": "string",
|
|
208
|
+
"description": "CSS selector or text to click (e.g., 'button.submit' or 'text=Sign In')",
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"required": ["selector"],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async def execute(self, selector: str) -> str:
|
|
215
|
+
try:
|
|
216
|
+
manager = await BrowserManager.get_instance()
|
|
217
|
+
page = await manager.get_page()
|
|
218
|
+
|
|
219
|
+
# Try as text selector first if no CSS chars
|
|
220
|
+
if not any(c in selector for c in ".#[]>+~:"):
|
|
221
|
+
try:
|
|
222
|
+
await page.get_by_text(selector).first.click(timeout=5000)
|
|
223
|
+
return f"Clicked element with text: {selector}"
|
|
224
|
+
except:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
await page.click(selector, timeout=5000)
|
|
228
|
+
return f"Clicked: {selector}"
|
|
229
|
+
except Exception as e:
|
|
230
|
+
return f"Click error: {str(e)}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class BrowserTypeTool(Tool):
|
|
234
|
+
"""Type text into an input field."""
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def name(self) -> str:
|
|
238
|
+
return "browser_type"
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def description(self) -> str:
|
|
242
|
+
return "Type text into an input field."
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def parameters(self) -> dict:
|
|
246
|
+
return {
|
|
247
|
+
"type": "object",
|
|
248
|
+
"properties": {
|
|
249
|
+
"selector": {
|
|
250
|
+
"type": "string",
|
|
251
|
+
"description": "CSS selector for the input field",
|
|
252
|
+
},
|
|
253
|
+
"text": {"type": "string", "description": "Text to type"},
|
|
254
|
+
"submit": {
|
|
255
|
+
"type": "boolean",
|
|
256
|
+
"description": "Press Enter after typing (default false)",
|
|
257
|
+
"default": False,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
"required": ["selector", "text"],
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async def execute(self, selector: str, text: str, submit: bool = False) -> str:
|
|
264
|
+
try:
|
|
265
|
+
manager = await BrowserManager.get_instance()
|
|
266
|
+
page = await manager.get_page()
|
|
267
|
+
await page.fill(selector, text)
|
|
268
|
+
if submit:
|
|
269
|
+
await page.press(selector, "Enter")
|
|
270
|
+
return f"Typed '{text}' into {selector}" + (
|
|
271
|
+
" and submitted" if submit else ""
|
|
272
|
+
)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
return f"Type error: {str(e)}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class BrowserGetTextTool(Tool):
|
|
278
|
+
"""Get text content from the page."""
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def name(self) -> str:
|
|
282
|
+
return "browser_get_text"
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def description(self) -> str:
|
|
286
|
+
return "Get the actual text content from the current browser page. Use this after browser_navigate to read and extract information from the website. Returns the full page text for summarization or analysis."
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def parameters(self) -> dict:
|
|
290
|
+
return {
|
|
291
|
+
"type": "object",
|
|
292
|
+
"properties": {
|
|
293
|
+
"selector": {
|
|
294
|
+
"type": "string",
|
|
295
|
+
"description": "CSS selector (optional, defaults to body)",
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"required": [],
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async def execute(self, selector: str = "body") -> str:
|
|
302
|
+
try:
|
|
303
|
+
manager = await BrowserManager.get_instance()
|
|
304
|
+
page = await manager.get_page()
|
|
305
|
+
text = await page.inner_text(selector)
|
|
306
|
+
# Limit output
|
|
307
|
+
if len(text) > 5000:
|
|
308
|
+
text = text[:5000] + "\n...(truncated)"
|
|
309
|
+
return text
|
|
310
|
+
except Exception as e:
|
|
311
|
+
return f"Get text error: {str(e)}"
|