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/__init__.py
ADDED
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)
|