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/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)}"