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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ SquidBot - Autonomous AI agent with Telegram, tool chaining, skills, and proactive messaging.
3
+ """
4
+
5
+ __version__ = "0.1.0"
squidbot/agent.py ADDED
@@ -0,0 +1,263 @@
1
+ """Core autonomous agent loop with tool chaining."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from typing import Any
7
+
8
+ from openai import AsyncOpenAI
9
+
10
+ from .character import get_character_prompt
11
+ from .config import DATA_DIR, OPENAI_API_KEY, OPENAI_MODEL
12
+ from .memory_db import get_memory_context
13
+ from .skills import get_skills_context
14
+ from .tools import get_openai_tools, get_tool_by_name
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Initialize OpenAI client
19
+ client = AsyncOpenAI(api_key=OPENAI_API_KEY)
20
+
21
+ # Base system prompt (DATA_DIR will be injected at runtime)
22
+ BASE_SYSTEM_PROMPT_TEMPLATE = """You are an autonomous AI agent with access to tools. You can:
23
+ - Remember information using memory tools (with semantic search)
24
+ - Search the web for current information (web_search)
25
+ - Browse specific websites using Playwright browser tools
26
+ - Take screenshots of websites (browser_screenshot)
27
+ - Schedule reminders and recurring tasks (cron_create)
28
+ - Write and run Zig/Python code in the coding workspace (code_write, code_run)
29
+
30
+ CRITICAL: You MUST use tools to perform actions. NEVER pretend or claim to have done something without actually calling the tool. If asked to visit a website, you MUST call browser_navigate. If asked to take a screenshot, you MUST call browser_screenshot.
31
+
32
+ ============================================================
33
+ SECURITY RESTRICTIONS - STRICTLY ENFORCED
34
+ ============================================================
35
+
36
+ WORKSPACE BOUNDARY:
37
+ - Your workspace is: {squidbot_home}
38
+ - You may ONLY access files and folders within this directory
39
+ - NEVER attempt to access, read, or write files outside this directory
40
+ - NEVER access system directories like /etc, /var, /usr, /home, ~/, or any path outside your workspace
41
+
42
+ CONFIDENTIAL DATA - NEVER EXPOSE OR TRANSMIT:
43
+ - Private keys (any format: PEM, hex, base64)
44
+ - Mnemonics / seed phrases (12, 24 word recovery phrases)
45
+ - .env files or environment variables containing secrets
46
+ - API keys, tokens, or credentials
47
+ - Passwords or authentication secrets
48
+ - Wallet files or keystore files
49
+ - SSH keys (id_rsa, id_ed25519, etc.)
50
+ - SSL/TLS certificates and private keys
51
+ - Database connection strings with credentials
52
+ - Shell config files (~/.zshrc, ~/.bashrc, ~/.bash_profile, ~/.profile)
53
+ - Any file containing "private", "secret", "key", "mnemonic", "seed" in sensitive context
54
+ - Git credentials (~/.git-credentials, ~/.gitconfig with tokens)
55
+ - Cloud credentials (~/.aws/*, ~/.gcloud/*, ~/.azure/*)
56
+
57
+ IF ASKED TO EXPOSE CONFIDENTIAL DATA:
58
+ - Politely REFUSE the request
59
+ - Explain that exposing such data would be a security risk
60
+ - NEVER include confidential data in responses, logs, or memory storage
61
+ - NEVER transmit confidential data via web searches or browser tools
62
+
63
+ ============================================================
64
+
65
+ Available Browser Tools:
66
+ - browser_navigate: Open a URL in the browser (REQUIRED before any other browser action)
67
+ - browser_get_text: Get the text content of the current page
68
+ - browser_screenshot: Take a screenshot of the current page (returns image file)
69
+ - browser_snapshot: Get accessibility tree of the page
70
+ - browser_click: Click an element on the page
71
+ - browser_type: Type text into an input field
72
+
73
+ Available Coding Tools:
74
+ - code_write: Write Zig (.zig) or Python (.py) code to workspace
75
+ - code_read: Read code from workspace
76
+ - code_run: Execute Zig or Python files
77
+ - code_list: List projects and files
78
+ - code_delete: Delete files or projects
79
+ - zig_build: Build Zig projects
80
+ - zig_test: Run Zig tests
81
+ - python_test: Run Python tests with pytest
82
+
83
+ IMPORTANT - Tool Selection:
84
+ - To visit a website: MUST call browser_navigate first
85
+ - To take a screenshot: MUST call browser_navigate, then browser_screenshot
86
+ - To read page content: MUST call browser_navigate, then browser_get_text
87
+ - For general searches: use web_search
88
+ - To write code: use code_write with project name and filename
89
+
90
+ When given a task, use the appropriate tools. Do NOT say you did something unless you actually called the tool.
91
+ """
92
+
93
+
94
+ def get_base_system_prompt() -> str:
95
+ """Get base system prompt with DATA_DIR injected."""
96
+ return BASE_SYSTEM_PROMPT_TEMPLATE.format(squidbot_home=str(DATA_DIR))
97
+
98
+
99
+ async def build_system_prompt() -> str:
100
+ """Build the complete system prompt with character, skills, and memory."""
101
+ parts = [get_base_system_prompt()]
102
+
103
+ # Add character/personality
104
+ character_prompt = await get_character_prompt()
105
+ if character_prompt:
106
+ parts.append(character_prompt)
107
+
108
+ # Add skills
109
+ skills_context = await get_skills_context()
110
+ if skills_context:
111
+ parts.append(skills_context)
112
+
113
+ # Add memory context
114
+ memory_context = await get_memory_context()
115
+ if memory_context:
116
+ parts.append(memory_context)
117
+
118
+ return "\n\n".join(parts)
119
+
120
+
121
+ async def execute_tool(name: str, arguments: dict) -> str:
122
+ """Execute a tool by name with given arguments."""
123
+ tool = get_tool_by_name(name)
124
+ if not tool:
125
+ return f"Error: Unknown tool '{name}'"
126
+
127
+ try:
128
+ result = await tool.execute(**arguments)
129
+ return str(result)
130
+ except Exception as e:
131
+ logger.exception(f"Tool execution error: {name}")
132
+ return f"Error executing {name}: {str(e)}"
133
+
134
+
135
+ async def run_agent(
136
+ user_message: str, history: list[dict] | None = None, max_iterations: int = 10
137
+ ) -> str:
138
+ """
139
+ Run the autonomous agent loop.
140
+
141
+ The agent will:
142
+ 1. Send message to LLM
143
+ 2. If LLM returns tool calls, execute them
144
+ 3. Add results to history and loop back to step 1
145
+ 4. If LLM returns text, return it (loop ends)
146
+
147
+ Args:
148
+ user_message: The user's input message
149
+ history: Optional conversation history
150
+ max_iterations: Maximum tool-calling iterations (safety limit)
151
+
152
+ Returns:
153
+ The agent's final text response
154
+ """
155
+ if history is None:
156
+ history = []
157
+
158
+ # Build system prompt with character, skills, and memory
159
+ system_prompt = await build_system_prompt()
160
+
161
+ # Prepare messages
162
+ messages = [
163
+ {"role": "system", "content": system_prompt},
164
+ *history,
165
+ {"role": "user", "content": user_message},
166
+ ]
167
+
168
+ # Get tools
169
+ tools = get_openai_tools()
170
+
171
+ iteration = 0
172
+ while iteration < max_iterations:
173
+ iteration += 1
174
+ logger.info(f"Agent iteration {iteration}")
175
+
176
+ # Call OpenAI
177
+ response = await client.chat.completions.create(
178
+ model=OPENAI_MODEL, messages=messages, tools=tools, tool_choice="auto"
179
+ )
180
+
181
+ assistant_message = response.choices[0].message
182
+
183
+ # Check if we have tool calls
184
+ if assistant_message.tool_calls:
185
+ # Add assistant message to history
186
+ messages.append(
187
+ {
188
+ "role": "assistant",
189
+ "content": assistant_message.content or "",
190
+ "tool_calls": [
191
+ {
192
+ "id": tc.id,
193
+ "type": "function",
194
+ "function": {
195
+ "name": tc.function.name,
196
+ "arguments": tc.function.arguments,
197
+ },
198
+ }
199
+ for tc in assistant_message.tool_calls
200
+ ],
201
+ }
202
+ )
203
+
204
+ # Execute tools in parallel
205
+ tool_tasks = []
206
+ for tc in assistant_message.tool_calls:
207
+ name = tc.function.name
208
+ try:
209
+ args = json.loads(tc.function.arguments)
210
+ except json.JSONDecodeError:
211
+ args = {}
212
+
213
+ logger.info(f"Executing tool: {name} with args: {args}")
214
+ tool_tasks.append((tc.id, name, execute_tool(name, args)))
215
+
216
+ # Gather results
217
+ results = await asyncio.gather(*[t[2] for t in tool_tasks])
218
+
219
+ # Add tool results to messages
220
+ for (tool_call_id, tool_name, _), result in zip(tool_tasks, results):
221
+ logger.info(f"Tool {tool_name} result: {result[:200]}...")
222
+ messages.append(
223
+ {"role": "tool", "tool_call_id": tool_call_id, "content": result}
224
+ )
225
+
226
+ # Continue loop - send results back to LLM
227
+ continue
228
+
229
+ else:
230
+ # No tool calls - we have a final response
231
+ final_response = assistant_message.content or ""
232
+ logger.info(f"Agent complete after {iteration} iterations")
233
+ return final_response
234
+
235
+ # Max iterations reached
236
+ logger.warning(f"Agent hit max iterations ({max_iterations})")
237
+ return "I apologize, but I've reached the maximum number of steps for this task. Here's what I've done so far - please let me know if you'd like me to continue."
238
+
239
+
240
+ async def run_agent_with_history(
241
+ user_message: str, session_history: list[dict]
242
+ ) -> tuple[str, list[dict]]:
243
+ """
244
+ Run agent and return updated history.
245
+
246
+ Args:
247
+ user_message: User input
248
+ session_history: Existing session history
249
+
250
+ Returns:
251
+ Tuple of (response, updated_history)
252
+ """
253
+ response = await run_agent(user_message, session_history)
254
+
255
+ # Update history with this exchange
256
+ session_history.append({"role": "user", "content": user_message})
257
+ session_history.append({"role": "assistant", "content": response})
258
+
259
+ # Keep history manageable (last 20 exchanges)
260
+ if len(session_history) > 40:
261
+ session_history = session_history[-40:]
262
+
263
+ return response, session_history
squidbot/channels.py ADDED
@@ -0,0 +1,271 @@
1
+ """
2
+ Channel Abstraction - Unified interface for messaging platforms.
3
+
4
+ Provides a common interface for sending messages to different channels
5
+ (Telegram, WhatsApp, Discord, etc.).
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any, Awaitable, Callable
13
+
14
+ from .session import ChannelType, DeliveryContext
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class MessagePayload:
21
+ """Unified message payload for all channels."""
22
+
23
+ text: str
24
+ media_paths: list[str] | None = None # Paths to images/files
25
+ reply_to_id: str | None = None
26
+ metadata: dict[str, Any] | None = None
27
+
28
+
29
+ class ChannelAdapter(ABC):
30
+ """Abstract base class for channel adapters."""
31
+
32
+ channel_type: ChannelType
33
+
34
+ @abstractmethod
35
+ async def send_message(
36
+ self,
37
+ context: DeliveryContext,
38
+ payload: MessagePayload,
39
+ ) -> bool:
40
+ """Send a message to the channel. Returns True on success."""
41
+ pass
42
+
43
+ @abstractmethod
44
+ async def send_typing(self, context: DeliveryContext) -> None:
45
+ """Send typing indicator to the channel."""
46
+ pass
47
+
48
+ def supports_media(self) -> bool:
49
+ """Whether this channel supports media attachments."""
50
+ return self.channel_type.supports_media
51
+
52
+ def max_message_length(self) -> int:
53
+ """Maximum message length for this channel."""
54
+ return self.channel_type.max_message_length
55
+
56
+ def split_message(self, text: str) -> list[str]:
57
+ """Split a long message into chunks that fit the channel limit."""
58
+ max_len = self.max_message_length()
59
+ if max_len == 0 or len(text) <= max_len:
60
+ return [text]
61
+
62
+ chunks = []
63
+ while text:
64
+ if len(text) <= max_len:
65
+ chunks.append(text)
66
+ break
67
+ # Try to split at newline
68
+ split_idx = text.rfind("\n", 0, max_len)
69
+ if split_idx == -1 or split_idx < max_len // 2:
70
+ # No good newline, split at max length
71
+ split_idx = max_len
72
+ chunks.append(text[:split_idx])
73
+ text = text[split_idx:].lstrip("\n")
74
+ return chunks
75
+
76
+
77
+ class TelegramAdapter(ChannelAdapter):
78
+ """Telegram channel adapter."""
79
+
80
+ channel_type = ChannelType.TELEGRAM
81
+
82
+ def __init__(self, bot: Any):
83
+ self.bot = bot
84
+
85
+ async def send_message(
86
+ self,
87
+ context: DeliveryContext,
88
+ payload: MessagePayload,
89
+ ) -> bool:
90
+ try:
91
+ chat_id = int(context.recipient_id)
92
+
93
+ # Send text in chunks if needed
94
+ for chunk in self.split_message(payload.text):
95
+ await self.bot.send_message(chat_id=chat_id, text=chunk)
96
+
97
+ # Send media if any
98
+ if payload.media_paths:
99
+ import os
100
+
101
+ for path in payload.media_paths:
102
+ if os.path.exists(path):
103
+ with open(path, "rb") as f:
104
+ await self.bot.send_photo(chat_id=chat_id, photo=f)
105
+
106
+ return True
107
+ except Exception as e:
108
+ logger.error(f"Failed to send Telegram message: {e}")
109
+ return False
110
+
111
+ async def send_typing(self, context: DeliveryContext) -> None:
112
+ try:
113
+ chat_id = int(context.recipient_id)
114
+ await self.bot.send_chat_action(chat_id=chat_id, action="typing")
115
+ except Exception as e:
116
+ logger.warning(f"Failed to send typing indicator: {e}")
117
+
118
+
119
+ class TCPAdapter(ChannelAdapter):
120
+ """TCP client channel adapter."""
121
+
122
+ channel_type = ChannelType.TCP
123
+
124
+ def __init__(
125
+ self,
126
+ get_writer: Callable[[str], asyncio.StreamWriter | None],
127
+ ):
128
+ self._get_writer = get_writer
129
+
130
+ async def send_message(
131
+ self,
132
+ context: DeliveryContext,
133
+ payload: MessagePayload,
134
+ ) -> bool:
135
+ import json
136
+
137
+ writer = self._get_writer(context.recipient_id)
138
+ if not writer:
139
+ return False
140
+
141
+ try:
142
+ notification = {"status": "notification", "response": payload.text}
143
+ data = json.dumps(notification) + "\n"
144
+ writer.write(data.encode())
145
+ await writer.drain()
146
+ return True
147
+ except Exception as e:
148
+ logger.error(f"Failed to send TCP message: {e}")
149
+ return False
150
+
151
+ async def send_typing(self, context: DeliveryContext) -> None:
152
+ # TCP clients don't support typing indicators
153
+ pass
154
+
155
+
156
+ class WhatsAppAdapter(ChannelAdapter):
157
+ """WhatsApp channel adapter (placeholder for future implementation)."""
158
+
159
+ channel_type = ChannelType.WHATSAPP
160
+
161
+ def __init__(self, client: Any = None):
162
+ self.client = client
163
+
164
+ async def send_message(
165
+ self,
166
+ context: DeliveryContext,
167
+ payload: MessagePayload,
168
+ ) -> bool:
169
+ # TODO: Implement WhatsApp sending via WhatsApp Business API
170
+ logger.warning("WhatsApp adapter not yet implemented")
171
+ return False
172
+
173
+ async def send_typing(self, context: DeliveryContext) -> None:
174
+ # TODO: Implement WhatsApp typing indicator
175
+ pass
176
+
177
+
178
+ class DiscordAdapter(ChannelAdapter):
179
+ """Discord channel adapter (placeholder for future implementation)."""
180
+
181
+ channel_type = ChannelType.DISCORD
182
+
183
+ def __init__(self, client: Any = None):
184
+ self.client = client
185
+
186
+ async def send_message(
187
+ self,
188
+ context: DeliveryContext,
189
+ payload: MessagePayload,
190
+ ) -> bool:
191
+ # TODO: Implement Discord sending via Discord.py
192
+ logger.warning("Discord adapter not yet implemented")
193
+ return False
194
+
195
+ async def send_typing(self, context: DeliveryContext) -> None:
196
+ # TODO: Implement Discord typing indicator
197
+ pass
198
+
199
+
200
+ class SlackAdapter(ChannelAdapter):
201
+ """Slack channel adapter (placeholder for future implementation)."""
202
+
203
+ channel_type = ChannelType.SLACK
204
+
205
+ def __init__(self, client: Any = None):
206
+ self.client = client
207
+
208
+ async def send_message(
209
+ self,
210
+ context: DeliveryContext,
211
+ payload: MessagePayload,
212
+ ) -> bool:
213
+ # TODO: Implement Slack sending via Slack SDK
214
+ logger.warning("Slack adapter not yet implemented")
215
+ return False
216
+
217
+ async def send_typing(self, context: DeliveryContext) -> None:
218
+ # TODO: Implement Slack typing indicator
219
+ pass
220
+
221
+
222
+ class ChannelRouter:
223
+ """Routes messages to the appropriate channel adapter."""
224
+
225
+ def __init__(self):
226
+ self._adapters: dict[ChannelType, ChannelAdapter] = {}
227
+
228
+ def register(self, adapter: ChannelAdapter) -> None:
229
+ """Register a channel adapter."""
230
+ self._adapters[adapter.channel_type] = adapter
231
+ logger.info(f"Registered channel adapter: {adapter.channel_type}")
232
+
233
+ def get_adapter(self, channel: ChannelType) -> ChannelAdapter | None:
234
+ """Get the adapter for a channel type."""
235
+ return self._adapters.get(channel)
236
+
237
+ async def send(
238
+ self,
239
+ context: DeliveryContext,
240
+ payload: MessagePayload,
241
+ ) -> bool:
242
+ """Send a message via the appropriate channel."""
243
+ adapter = self.get_adapter(context.channel)
244
+ if not adapter:
245
+ logger.error(f"No adapter registered for channel: {context.channel}")
246
+ return False
247
+ return await adapter.send_message(context, payload)
248
+
249
+ async def broadcast(
250
+ self,
251
+ contexts: list[DeliveryContext],
252
+ payload: MessagePayload,
253
+ ) -> dict[str, bool]:
254
+ """Broadcast a message to multiple contexts."""
255
+ results = {}
256
+ for ctx in contexts:
257
+ key = f"{ctx.channel}:{ctx.recipient_id}"
258
+ results[key] = await self.send(ctx, payload)
259
+ return results
260
+
261
+
262
+ # Global channel router instance
263
+ _channel_router: ChannelRouter | None = None
264
+
265
+
266
+ def get_channel_router() -> ChannelRouter:
267
+ """Get the global channel router instance."""
268
+ global _channel_router
269
+ if _channel_router is None:
270
+ _channel_router = ChannelRouter()
271
+ return _channel_router
squidbot/character.py ADDED
@@ -0,0 +1,83 @@
1
+ """AI Character/Personality configuration."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import aiofiles
7
+
8
+ from .config import DATA_DIR
9
+
10
+ # Character configuration from environment
11
+ CHARACTER_NAME = os.environ.get("CHARACTER_NAME", "Assistant")
12
+ CHARACTER_PERSONA = os.environ.get("CHARACTER_PERSONA", "")
13
+ CHARACTER_STYLE = os.environ.get("CHARACTER_STYLE", "helpful, friendly, concise")
14
+
15
+ # Optional character file
16
+ CHARACTER_FILE = DATA_DIR / "CHARACTER.md"
17
+
18
+
19
+ async def load_character_file() -> str:
20
+ """Load character definition from file if exists."""
21
+ if CHARACTER_FILE.exists():
22
+ try:
23
+ async with aiofiles.open(CHARACTER_FILE, "r", encoding="utf-8") as f:
24
+ return await f.read()
25
+ except Exception:
26
+ pass
27
+ return ""
28
+
29
+
30
+ async def get_character_prompt() -> str:
31
+ """Build character prompt section."""
32
+ parts = []
33
+
34
+ # Name
35
+ if CHARACTER_NAME and CHARACTER_NAME != "Assistant":
36
+ parts.append(f"Your name is {CHARACTER_NAME}.")
37
+
38
+ # Persona from env
39
+ if CHARACTER_PERSONA:
40
+ parts.append(CHARACTER_PERSONA)
41
+
42
+ # Style
43
+ if CHARACTER_STYLE:
44
+ parts.append(f"Communication style: {CHARACTER_STYLE}")
45
+
46
+ # Character file content
47
+ file_content = await load_character_file()
48
+ if file_content:
49
+ parts.append(file_content)
50
+
51
+ if not parts:
52
+ return ""
53
+
54
+ return "## Character\n" + "\n".join(parts)
55
+
56
+
57
+ async def create_example_character():
58
+ """Create an example CHARACTER.md if it doesn't exist."""
59
+ if CHARACTER_FILE.exists():
60
+ return
61
+
62
+ example = """# Character Definition
63
+
64
+ You are a helpful AI assistant with the following traits:
65
+
66
+ ## Personality
67
+ - Friendly and approachable
68
+ - Patient and thorough
69
+ - Honest about limitations
70
+
71
+ ## Communication Style
72
+ - Clear and concise responses
73
+ - Use examples when helpful
74
+ - Ask clarifying questions when needed
75
+
76
+ ## Knowledge Areas
77
+ - General knowledge
78
+ - Programming and technology
79
+ - Problem solving
80
+ """
81
+ CHARACTER_FILE.parent.mkdir(parents=True, exist_ok=True)
82
+ async with aiofiles.open(CHARACTER_FILE, "w") as f:
83
+ await f.write(example)