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/daemon.py ADDED
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SquidBot Daemon Manager
4
+
5
+ Process supervisor for running SquidBot server and managing clients.
6
+ """
7
+
8
+ import os
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ # PID and log files
16
+ DATA_DIR = Path.home() / ".squidbot"
17
+ PID_FILE = DATA_DIR / "squidbot.pid"
18
+ LOG_FILE = DATA_DIR / "squidbot.log"
19
+
20
+ # Script directory
21
+ SCRIPT_DIR = Path(__file__).parent.absolute()
22
+
23
+
24
+ def get_pid() -> int | None:
25
+ """Get the PID of the running server."""
26
+ if not PID_FILE.exists():
27
+ return None
28
+ try:
29
+ pid = int(PID_FILE.read_text().strip())
30
+ # Check if process is actually running
31
+ os.kill(pid, 0)
32
+ return pid
33
+ except (ValueError, ProcessLookupError, PermissionError):
34
+ # PID file exists but process is not running
35
+ PID_FILE.unlink(missing_ok=True)
36
+ return None
37
+
38
+
39
+ def is_running() -> bool:
40
+ """Check if the server is running."""
41
+ return get_pid() is not None
42
+
43
+
44
+ def find_squidbot_processes() -> list[tuple[int, str]]:
45
+ """Find all SquidBot-related Python processes (server and client only)."""
46
+ processes = []
47
+ try:
48
+ # Use ps to find Python processes
49
+ result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
50
+
51
+ for line in result.stdout.split("\n"):
52
+ # Only look for server.py and client.py, not daemon.py
53
+ if "python" in line.lower() and any(
54
+ script in line for script in ["server.py", "client.py"]
55
+ ):
56
+ parts = line.split()
57
+ if len(parts) >= 2:
58
+ try:
59
+ pid = int(parts[1])
60
+ # Don't include ourselves
61
+ if pid != os.getpid():
62
+ processes.append((pid, line))
63
+ except ValueError:
64
+ pass
65
+ except Exception:
66
+ pass
67
+
68
+ return processes
69
+
70
+
71
+ def show_env_info():
72
+ """Display environment configuration to console."""
73
+ import os
74
+
75
+ from dotenv import load_dotenv
76
+
77
+ load_dotenv()
78
+
79
+ squid_port = int(os.environ.get("SQUID_PORT", "7777"))
80
+ openai_model = os.environ.get("OPENAI_MODEL", "gpt-4o")
81
+ heartbeat = int(os.environ.get("HEARTBEAT_INTERVAL_MINUTES", "30"))
82
+ telegram_token = os.environ.get("TELEGRAM_BOT_TOKEN", "")
83
+ openai_key = os.environ.get("OPENAI_API_KEY", "")
84
+
85
+ print("")
86
+ print("=" * 60)
87
+ print(" SquidBot Configuration")
88
+ print("=" * 60)
89
+ print(f" Home Directory : {DATA_DIR}")
90
+ print(f" Server Port : 127.0.0.1:{squid_port}")
91
+ print(f" Model : {openai_model}")
92
+ print(f" Heartbeat : {heartbeat} minutes")
93
+ print("-" * 60)
94
+ print(f" OPENAI_API_KEY : {'[SET]' if openai_key else '[NOT SET]'}")
95
+ print(f" Telegram Bot : {'[ENABLED]' if telegram_token else '[DISABLED]'}")
96
+ print("=" * 60)
97
+ print("")
98
+
99
+
100
+ def start():
101
+ """Start the server."""
102
+ if is_running():
103
+ print(f"SquidBot server is already running (PID: {get_pid()})")
104
+ return False
105
+
106
+ # Ensure data directory exists
107
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
108
+
109
+ # Show environment info to console
110
+ show_env_info()
111
+
112
+ # Check Playwright before starting (so user sees errors immediately)
113
+ from .playwright_check import require_playwright_or_exit
114
+
115
+ require_playwright_or_exit()
116
+
117
+ print(f"Starting SquidBot server...")
118
+ print(f"Log file: {LOG_FILE}")
119
+
120
+ # Open log file
121
+ log_fd = open(LOG_FILE, "a")
122
+
123
+ # Start the process using module execution
124
+ # cwd must be parent of squidbot package (app directory)
125
+ process = subprocess.Popen(
126
+ [sys.executable, "-m", "squidbot.server"],
127
+ stdout=log_fd,
128
+ stderr=log_fd,
129
+ cwd=str(SCRIPT_DIR.parent),
130
+ start_new_session=True, # Detach from terminal
131
+ )
132
+
133
+ # Write PID file
134
+ PID_FILE.write_text(str(process.pid))
135
+
136
+ # Wait a moment to check if it started successfully
137
+ time.sleep(1)
138
+
139
+ if process.poll() is None:
140
+ print(f"SquidBot server started (PID: {process.pid})")
141
+ return True
142
+ else:
143
+ print("SquidBot failed to start. Check logs:")
144
+ print(f" tail -f {LOG_FILE}")
145
+ PID_FILE.unlink(missing_ok=True)
146
+ return False
147
+
148
+
149
+ def stop():
150
+ """Stop the server."""
151
+ pid = get_pid()
152
+ if pid is None:
153
+ print("SquidBot server is not running")
154
+ return False
155
+
156
+ print(f"Stopping SquidBot server (PID: {pid})...")
157
+
158
+ try:
159
+ # Send SIGTERM for graceful shutdown
160
+ os.kill(pid, signal.SIGTERM)
161
+
162
+ # Wait for process to terminate
163
+ for _ in range(10): # Wait up to 10 seconds
164
+ time.sleep(1)
165
+ try:
166
+ os.kill(pid, 0)
167
+ except ProcessLookupError:
168
+ print("SquidBot server stopped")
169
+ PID_FILE.unlink(missing_ok=True)
170
+ return True
171
+
172
+ # Force kill if still running
173
+ print("Process not responding, sending SIGKILL...")
174
+ os.kill(pid, signal.SIGKILL)
175
+ time.sleep(1)
176
+ PID_FILE.unlink(missing_ok=True)
177
+ print("SquidBot server forcefully terminated")
178
+ return True
179
+
180
+ except ProcessLookupError:
181
+ print("Process already terminated")
182
+ PID_FILE.unlink(missing_ok=True)
183
+ return True
184
+ except PermissionError:
185
+ print(f"Permission denied to stop process {pid}")
186
+ return False
187
+
188
+
189
+ def stopall():
190
+ """Stop server and kill all clients."""
191
+ print("Stopping all SquidBot processes...")
192
+
193
+ killed = 0
194
+
195
+ # First, stop the server gracefully
196
+ if is_running():
197
+ stop()
198
+ killed += 1
199
+
200
+ # Find and kill any remaining processes
201
+ processes = find_squidbot_processes()
202
+
203
+ for pid, cmdline in processes:
204
+ try:
205
+ print(f" Killing PID {pid}...")
206
+ os.kill(pid, signal.SIGTERM)
207
+ killed += 1
208
+ except (ProcessLookupError, PermissionError):
209
+ pass
210
+
211
+ # Wait a moment
212
+ if processes:
213
+ time.sleep(1)
214
+
215
+ # Force kill any remaining
216
+ for pid, cmdline in processes:
217
+ try:
218
+ os.kill(pid, 0) # Check if still running
219
+ print(f" Force killing PID {pid}...")
220
+ os.kill(pid, signal.SIGKILL)
221
+ except (ProcessLookupError, PermissionError):
222
+ pass
223
+
224
+ # Clean up PID file
225
+ PID_FILE.unlink(missing_ok=True)
226
+
227
+ print(f"Stopped {killed} process(es)")
228
+ return True
229
+
230
+
231
+ def restart():
232
+ """Restart the server."""
233
+ if is_running():
234
+ stop()
235
+ time.sleep(1)
236
+ start()
237
+
238
+
239
+ def status():
240
+ """Show server status."""
241
+ pid = get_pid()
242
+ if pid:
243
+ print(f"SquidBot server is running (PID: {pid})")
244
+ print(f"Log file: {LOG_FILE}")
245
+
246
+ # Show any client processes
247
+ processes = find_squidbot_processes()
248
+ clients = [(p, c) for p, c in processes if "client.py" in c]
249
+ if clients:
250
+ print(f"Active clients: {len(clients)}")
251
+
252
+ return True
253
+ else:
254
+ print("SquidBot server is not running")
255
+ return False
256
+
257
+
258
+ def logs(follow: bool = False, lines: int = 50):
259
+ """Show server logs."""
260
+ if not LOG_FILE.exists():
261
+ print("No log file found")
262
+ return
263
+
264
+ if follow:
265
+ os.execvp("tail", ["tail", "-f", str(LOG_FILE)])
266
+ else:
267
+ os.execvp("tail", ["tail", "-n", str(lines), str(LOG_FILE)])
268
+
269
+
270
+ def main():
271
+ """Main entry point."""
272
+ if len(sys.argv) < 2:
273
+ print("Usage: python daemon.py <command>")
274
+ print("")
275
+ print("Commands:")
276
+ print(" start Start the server")
277
+ print(" stop Stop the server")
278
+ print(" stopall Stop server and kill all clients")
279
+ print(" restart Restart the server")
280
+ print(" status Show server status")
281
+ print(" logs Show recent logs")
282
+ print(" logs -f Follow logs in real-time")
283
+ sys.exit(1)
284
+
285
+ command = sys.argv[1]
286
+
287
+ if command == "start":
288
+ success = start()
289
+ sys.exit(0 if success else 1)
290
+ elif command == "stop":
291
+ success = stop()
292
+ sys.exit(0 if success else 1)
293
+ elif command == "stopall":
294
+ success = stopall()
295
+ sys.exit(0 if success else 1)
296
+ elif command == "restart":
297
+ restart()
298
+ elif command == "status":
299
+ running = status()
300
+ sys.exit(0 if running else 1)
301
+ elif command == "logs":
302
+ follow = "-f" in sys.argv or "--follow" in sys.argv
303
+ logs(follow=follow)
304
+ else:
305
+ print(f"Unknown command: {command}")
306
+ sys.exit(1)
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
squidbot/lanes.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ Command Lanes - Execution context categorization.
3
+
4
+ Lanes categorize the context in which commands are executed,
5
+ enabling different behaviors for different execution paths.
6
+ """
7
+
8
+ from enum import Enum
9
+
10
+
11
+ class CommandLane(str, Enum):
12
+ """Command execution lane types."""
13
+
14
+ MAIN = "main" # Primary user interaction
15
+ CRON = "cron" # Scheduled/cron jobs
16
+ SUBAGENT = "subagent" # Sub-agent execution
17
+ NESTED = "nested" # Nested command execution
18
+ WEBHOOK = "webhook" # Webhook-triggered execution
19
+ PROACTIVE = "proactive" # Proactive/autonomous messages
20
+
21
+ def __str__(self) -> str:
22
+ return self.value
23
+
24
+ @property
25
+ def is_user_initiated(self) -> bool:
26
+ """Whether this lane represents user-initiated actions."""
27
+ return self in (CommandLane.MAIN, CommandLane.NESTED)
28
+
29
+ @property
30
+ def is_automated(self) -> bool:
31
+ """Whether this lane represents automated actions."""
32
+ return self in (CommandLane.CRON, CommandLane.WEBHOOK, CommandLane.PROACTIVE)
33
+
34
+
35
+ # Convenience exports
36
+ LANE_MAIN = CommandLane.MAIN
37
+ LANE_CRON = CommandLane.CRON
38
+ LANE_SUBAGENT = CommandLane.SUBAGENT
39
+ LANE_NESTED = CommandLane.NESTED
40
+ LANE_WEBHOOK = CommandLane.WEBHOOK
41
+ LANE_PROACTIVE = CommandLane.PROACTIVE
squidbot/main.py ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SquidBot - Autonomous AI Agent
4
+
5
+ A Telegram bot with:
6
+ - Telegram bot interface
7
+ - OpenAI LLM with tool calling
8
+ - Autonomous tool chaining loop
9
+ - Persistent memory
10
+ - Web search
11
+ - Browser automation (Playwright)
12
+ - Proactive messaging (cron/heartbeat)
13
+ """
14
+
15
+ import asyncio
16
+ import logging
17
+
18
+ from telegram import Update
19
+ from telegram.ext import (Application, CommandHandler, ContextTypes,
20
+ MessageHandler, filters)
21
+
22
+ from .agent import run_agent_with_history
23
+ from .config import TELEGRAM_BOT_TOKEN, validate_config
24
+ from .scheduler import Scheduler
25
+
26
+ # Setup logging
27
+ logging.basicConfig(
28
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
29
+ )
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Session storage (in-memory for simplicity)
33
+ sessions: dict[int, list[dict]] = {}
34
+
35
+ # Scheduler instance (initialized later)
36
+ scheduler: Scheduler | None = None
37
+
38
+
39
+ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
40
+ """Handle /start command."""
41
+ await update.message.reply_text(
42
+ "Hello! I'm your autonomous AI assistant.\n\n"
43
+ "I can:\n"
44
+ "- Remember things you tell me\n"
45
+ "- Search the web for information\n"
46
+ "- Browse websites\n"
47
+ "- Set reminders and scheduled tasks\n\n"
48
+ "Just send me a message!"
49
+ )
50
+
51
+
52
+ async def clear_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
53
+ """Handle /clear command - clear session history."""
54
+ chat_id = update.effective_chat.id
55
+ sessions[chat_id] = []
56
+ await update.message.reply_text("Conversation history cleared.")
57
+
58
+
59
+ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
60
+ """Handle incoming messages."""
61
+ chat_id = update.effective_chat.id
62
+ user_message = update.message.text
63
+
64
+ # Update scheduler's chat_id for proactive messages
65
+ global scheduler
66
+ if scheduler:
67
+ scheduler.set_chat_id(chat_id)
68
+
69
+ # Get or create session history
70
+ if chat_id not in sessions:
71
+ sessions[chat_id] = []
72
+
73
+ # Send typing indicator
74
+ await context.bot.send_chat_action(chat_id=chat_id, action="typing")
75
+
76
+ try:
77
+ # Run the agent
78
+ response, updated_history = await run_agent_with_history(
79
+ user_message, sessions[chat_id]
80
+ )
81
+ sessions[chat_id] = updated_history
82
+
83
+ # Send response (split if too long)
84
+ max_length = 4096
85
+ if len(response) <= max_length:
86
+ await update.message.reply_text(response)
87
+ else:
88
+ # Split into chunks
89
+ for i in range(0, len(response), max_length):
90
+ chunk = response[i : i + max_length]
91
+ await update.message.reply_text(chunk)
92
+
93
+ # Reload scheduler jobs in case new ones were created
94
+ if scheduler:
95
+ scheduler.reload_jobs()
96
+
97
+ except Exception as e:
98
+ logger.exception("Error handling message")
99
+ await update.message.reply_text(f"Sorry, an error occurred: {str(e)}")
100
+
101
+
102
+ async def send_proactive_message(app: Application, chat_id: int, message: str):
103
+ """Send a proactive message to a chat."""
104
+ try:
105
+ await app.bot.send_message(chat_id=chat_id, text=message)
106
+ except Exception as e:
107
+ logger.error(f"Failed to send proactive message: {e}")
108
+
109
+
110
+ def main():
111
+ """Main entry point."""
112
+ # Validate configuration
113
+ validate_config()
114
+
115
+ # Create application
116
+ app = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
117
+
118
+ # Add handlers
119
+ app.add_handler(CommandHandler("start", start_command))
120
+ app.add_handler(CommandHandler("clear", clear_command))
121
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
122
+
123
+ # Setup scheduler
124
+ global scheduler
125
+
126
+ async def send_message(message: str):
127
+ """Wrapper to send messages from scheduler."""
128
+ # Get the most recent chat_id
129
+ if scheduler and scheduler.chat_id:
130
+ await send_proactive_message(app, scheduler.chat_id, message)
131
+
132
+ async def run_agent_for_scheduler(prompt: str) -> str:
133
+ """Run agent from scheduler context."""
134
+ response, _ = await run_agent_with_history(prompt, [])
135
+ return response
136
+
137
+ scheduler = Scheduler(send_message=send_message, run_agent=run_agent_for_scheduler)
138
+
139
+ # Start scheduler when bot starts
140
+ async def post_init(application: Application):
141
+ scheduler.start()
142
+ logger.info("Bot and scheduler started")
143
+
144
+ async def post_shutdown(application: Application):
145
+ scheduler.stop()
146
+ logger.info("Scheduler stopped")
147
+
148
+ app.post_init = post_init
149
+ app.post_shutdown = post_shutdown
150
+
151
+ # Run the bot
152
+ logger.info("Starting SquidBot...")
153
+ app.run_polling(allowed_updates=Update.ALL_TYPES)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()