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/client.py ADDED
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SquidBot Client
4
+
5
+ Terminal-based chat client that connects to the local SquidBot server.
6
+ Features: async operations, input history, loading animation, notifications.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import readline
12
+ import signal
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from .config import DATA_DIR, SQUID_PORT
18
+
19
+ # Server configuration
20
+ SERVER_HOST = "127.0.0.1"
21
+ SERVER_PORT = SQUID_PORT
22
+
23
+ # History file
24
+ HISTORY_FILE = DATA_DIR / "client_history"
25
+
26
+
27
+ class Spinner:
28
+ """Animated spinner for loading indication with elapsed time."""
29
+
30
+ FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
31
+
32
+ def __init__(self, message: str = "Thinking"):
33
+ self.message = message
34
+ self.running = False
35
+ self.task: asyncio.Task | None = None
36
+ self.start_time: float = 0
37
+ self.elapsed: float = 0
38
+
39
+ async def _animate(self):
40
+ """Run the animation loop."""
41
+ idx = 0
42
+ while self.running:
43
+ frame = self.FRAMES[idx % len(self.FRAMES)]
44
+ self.elapsed = time.time() - self.start_time
45
+ print(
46
+ f"\r{frame} {self.message}... ({self.elapsed:.1f}s)", end="", flush=True
47
+ )
48
+ idx += 1
49
+ await asyncio.sleep(0.1)
50
+
51
+ async def start(self):
52
+ """Start the spinner."""
53
+ self.start_time = time.time()
54
+ self.running = True
55
+ self.task = asyncio.create_task(self._animate())
56
+
57
+ async def stop(self) -> float:
58
+ """Stop the spinner and clear the line. Returns elapsed time."""
59
+ self.running = False
60
+ self.elapsed = time.time() - self.start_time
61
+ if self.task:
62
+ self.task.cancel()
63
+ try:
64
+ await self.task
65
+ except asyncio.CancelledError:
66
+ pass
67
+ # Clear the spinner line
68
+ print("\r" + " " * 50 + "\r", end="", flush=True)
69
+ return self.elapsed
70
+
71
+
72
+ class InputHistory:
73
+ """Manage input history with readline."""
74
+
75
+ def __init__(self, history_file: Path):
76
+ self.history_file = history_file
77
+
78
+ async def load(self):
79
+ """Load history from file."""
80
+ try:
81
+ if self.history_file.exists():
82
+ readline.read_history_file(str(self.history_file))
83
+ except Exception:
84
+ pass
85
+
86
+ async def save(self):
87
+ """Save history to file."""
88
+ try:
89
+ self.history_file.parent.mkdir(parents=True, exist_ok=True)
90
+ readline.set_history_length(1000)
91
+ readline.write_history_file(str(self.history_file))
92
+ except Exception:
93
+ pass
94
+
95
+ async def setup(self):
96
+ """Setup readline for history navigation."""
97
+ readline.parse_and_bind(r'"\e[A": previous-history')
98
+ readline.parse_and_bind(r'"\e[B": next-history')
99
+ readline.parse_and_bind(r'"\e[C": forward-char')
100
+ readline.parse_and_bind(r'"\e[D": backward-char')
101
+ readline.parse_and_bind(r'"\e[1~": beginning-of-line')
102
+ readline.parse_and_bind(r'"\e[4~": end-of-line')
103
+ readline.parse_and_bind(r'"\e[3~": delete-char')
104
+ await self.load()
105
+
106
+
107
+ class SquidBotClient:
108
+ """Async chat client for SquidBot."""
109
+
110
+ def __init__(self):
111
+ self.reader: asyncio.StreamReader | None = None
112
+ self.writer: asyncio.StreamWriter | None = None
113
+ self.running = True
114
+ self.history = InputHistory(HISTORY_FILE)
115
+ self.spinner = Spinner("Thinking")
116
+ self.pending_notifications: list[str] = []
117
+ self.response_queue: asyncio.Queue = asyncio.Queue()
118
+ self.reader_task: asyncio.Task | None = None
119
+
120
+ async def connect(self) -> bool:
121
+ """Connect to the server."""
122
+ try:
123
+ self.reader, self.writer = await asyncio.open_connection(
124
+ SERVER_HOST, SERVER_PORT
125
+ )
126
+ return True
127
+ except ConnectionRefusedError:
128
+ print(f"Error: Cannot connect to server at {SERVER_HOST}:{SERVER_PORT}")
129
+ print("Make sure the server is running: make start")
130
+ print(f"(Port configured via SQUID_PORT in .env)")
131
+ return False
132
+ except Exception as e:
133
+ print(f"Error connecting: {e}")
134
+ return False
135
+
136
+ async def send_request(self, message: str, command: str = "chat") -> None:
137
+ """Send a request to the server."""
138
+ if not self.writer:
139
+ return
140
+
141
+ request = {"command": command, "message": message}
142
+ data = json.dumps(request) + "\n"
143
+ self.writer.write(data.encode())
144
+ await self.writer.drain()
145
+
146
+ async def read_responses(self):
147
+ """Single reader task that dispatches responses to the right handler."""
148
+ while self.running and self.reader:
149
+ try:
150
+ data = await self.reader.readline()
151
+ if not data:
152
+ # Connection closed
153
+ await self.response_queue.put(None)
154
+ break
155
+
156
+ response = json.loads(data.decode().strip())
157
+
158
+ if response.get("status") == "notification":
159
+ # Handle notification immediately
160
+ msg = response.get("response", "")
161
+ print(f"\n\n📢 [Scheduled Task]\n{msg}\n")
162
+ print("You: ", end="", flush=True)
163
+ else:
164
+ # Queue response for chat handler
165
+ await self.response_queue.put(response)
166
+
167
+ except asyncio.CancelledError:
168
+ break
169
+ except json.JSONDecodeError:
170
+ continue
171
+ except Exception:
172
+ await self.response_queue.put(None)
173
+ break
174
+
175
+ async def get_response(self, timeout: float = 120.0) -> dict | None:
176
+ """Get a response from the queue with timeout."""
177
+ try:
178
+ return await asyncio.wait_for(self.response_queue.get(), timeout=timeout)
179
+ except asyncio.TimeoutError:
180
+ return None
181
+
182
+ async def chat(self, user_input: str) -> tuple[str | None, float]:
183
+ """Send chat message with loading animation."""
184
+ await self.spinner.start()
185
+
186
+ try:
187
+ await self.send_request(user_input, "chat")
188
+
189
+ # Wait for response from queue
190
+ response = await self.get_response(timeout=120.0)
191
+ if response is None:
192
+ return None, self.spinner.elapsed
193
+
194
+ return response.get("response", ""), self.spinner.elapsed
195
+ finally:
196
+ await self.spinner.stop()
197
+
198
+ async def close(self):
199
+ """Close the connection."""
200
+ if self.reader_task:
201
+ self.reader_task.cancel()
202
+ try:
203
+ await self.reader_task
204
+ except asyncio.CancelledError:
205
+ pass
206
+
207
+ if self.writer:
208
+ self.writer.close()
209
+ await self.writer.wait_closed()
210
+
211
+ async def get_input(self, prompt: str) -> str:
212
+ """Get user input asynchronously."""
213
+ loop = asyncio.get_event_loop()
214
+ return await loop.run_in_executor(None, lambda: input(prompt))
215
+
216
+ async def run(self):
217
+ """Run the interactive chat loop."""
218
+ print("=" * 60)
219
+ print(" SquidBot Client")
220
+ print("=" * 60)
221
+ print()
222
+
223
+ if not await self.connect():
224
+ return
225
+
226
+ # Start the single reader task
227
+ self.reader_task = asyncio.create_task(self.read_responses())
228
+
229
+ # Verify connection
230
+ await self.send_request("", "ping")
231
+ response = await self.get_response(timeout=5.0)
232
+ if not response or response.get("response") != "pong":
233
+ print("Error: Server not responding correctly")
234
+ return
235
+
236
+ # Setup history
237
+ await self.history.setup()
238
+
239
+ print(f"Connected to SquidBot at {SERVER_HOST}:{SERVER_PORT}")
240
+ print()
241
+ print("Commands:")
242
+ print(" /clear - Clear conversation history")
243
+ print(" /quit - Exit the client")
244
+ print()
245
+ print("Tips:")
246
+ print(" - Use ↑/↓ arrows to navigate input history")
247
+ print(" - Scheduled tasks will appear automatically")
248
+ print()
249
+ print("-" * 60)
250
+
251
+ while self.running:
252
+ try:
253
+ # Get user input
254
+ user_input = await self.get_input("\nYou: ")
255
+
256
+ if not user_input.strip():
257
+ continue
258
+
259
+ # Handle commands
260
+ cmd = user_input.strip().lower()
261
+ if cmd in ["/quit", "/exit", "/q"]:
262
+ print("Goodbye!")
263
+ break
264
+
265
+ if cmd == "/clear":
266
+ await self.send_request("", "clear")
267
+ response = await self.get_response(timeout=5.0)
268
+ if response:
269
+ print(f"\n{response.get('response', '')}")
270
+ continue
271
+
272
+ # Send chat message with animation
273
+ response, elapsed = await self.chat(user_input)
274
+
275
+ if response is None:
276
+ print("Error: Lost connection to server")
277
+ break
278
+
279
+ print(f"SquidBot: {response}")
280
+ print(f" ({elapsed:.1f}s)")
281
+
282
+ except EOFError:
283
+ print("\nGoodbye!")
284
+ break
285
+ except KeyboardInterrupt:
286
+ print("\nGoodbye!")
287
+ break
288
+
289
+ # Cleanup
290
+ self.running = False
291
+ await self.history.save()
292
+ await self.close()
293
+
294
+
295
+ async def async_main():
296
+ """Async main entry point."""
297
+ client = SquidBotClient()
298
+
299
+ def signal_handler():
300
+ client.running = False
301
+
302
+ loop = asyncio.get_event_loop()
303
+ for sig in (signal.SIGINT, signal.SIGTERM):
304
+ try:
305
+ loop.add_signal_handler(sig, signal_handler)
306
+ except NotImplementedError:
307
+ pass
308
+
309
+ await client.run()
310
+
311
+
312
+ def main():
313
+ """Main entry point."""
314
+ asyncio.run(async_main())
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
squidbot/config.py ADDED
@@ -0,0 +1,148 @@
1
+ """Configuration loader from environment variables."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv(override=True) # Override existing env vars with .env values
9
+
10
+ # Required
11
+ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
12
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
13
+
14
+ # Optional
15
+ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o")
16
+ HEARTBEAT_INTERVAL_MINUTES = int(os.environ.get("HEARTBEAT_INTERVAL_MINUTES", "30"))
17
+ SQUID_PORT = int(os.environ.get("SQUID_PORT", "7777"))
18
+
19
+ # Paths - Use SQUIDBOT_HOME env var or default to ~/.squidbot
20
+ DATA_DIR = Path(os.environ.get("SQUIDBOT_HOME", Path.home() / ".squidbot"))
21
+ MEMORY_FILE = DATA_DIR / "memory.json"
22
+ CRON_FILE = DATA_DIR / "cron_jobs.json"
23
+ CHARACTER_FILE = DATA_DIR / "CHARACTER.md"
24
+ SKILLS_DIR = DATA_DIR / "skills"
25
+ CODING_DIR = DATA_DIR / "coding"
26
+ SESSIONS_DIR = DATA_DIR / "sessions"
27
+
28
+ # Default CHARACTER.md content
29
+ DEFAULT_CHARACTER = """# Character Definition
30
+
31
+ You are a helpful AI assistant with the following traits:
32
+
33
+ ## Personality
34
+ - Friendly and approachable
35
+ - Patient and thorough
36
+ - Honest about limitations
37
+
38
+ ## Communication Style
39
+ - Clear and concise responses
40
+ - Use examples when helpful
41
+ - Ask clarifying questions when needed
42
+
43
+ ## Guidelines
44
+ - Always be helpful and respectful
45
+ - Admit when you don't know something
46
+ - Provide accurate information
47
+ """
48
+
49
+ # Default skill template
50
+ DEFAULT_SKILL_SEARCH = """---
51
+ name: search
52
+ description: Search the web for information
53
+ ---
54
+ When user asks a question requiring current information:
55
+
56
+ 1. Use `web_search` tool with a clear query
57
+ 2. Review the results
58
+ 3. Synthesize a helpful answer with sources
59
+
60
+ Always cite sources when providing factual information.
61
+ """
62
+
63
+ DEFAULT_SKILL_REMINDER = """---
64
+ name: reminder
65
+ description: Set reminders and scheduled tasks
66
+ ---
67
+ When user wants to set a reminder:
68
+
69
+ 1. Extract the message and time
70
+ 2. Use `cron_create` tool with appropriate delay_minutes or cron_expression
71
+ 3. Confirm the reminder was set
72
+
73
+ Examples:
74
+ - "Remind me in 10 minutes" → delay_minutes=10
75
+ - "Remind me daily at 9am" → cron_expression="0 9 * * *"
76
+ """
77
+
78
+
79
+ def ensure_data_dirs():
80
+ """Ensure all data directories exist."""
81
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
82
+ SKILLS_DIR.mkdir(parents=True, exist_ok=True)
83
+ CODING_DIR.mkdir(parents=True, exist_ok=True)
84
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
85
+
86
+
87
+ def init_default_files():
88
+ """Create default files if they don't exist."""
89
+ ensure_data_dirs()
90
+
91
+ # Create default CHARACTER.md
92
+ if not CHARACTER_FILE.exists():
93
+ CHARACTER_FILE.write_text(DEFAULT_CHARACTER, encoding="utf-8")
94
+ print(f" Created: {CHARACTER_FILE}")
95
+
96
+ # Create default skills
97
+ search_skill_dir = SKILLS_DIR / "search"
98
+ if not search_skill_dir.exists():
99
+ search_skill_dir.mkdir(parents=True, exist_ok=True)
100
+ (search_skill_dir / "SKILL.md").write_text(
101
+ DEFAULT_SKILL_SEARCH, encoding="utf-8"
102
+ )
103
+ print(f" Created: {search_skill_dir / 'SKILL.md'}")
104
+
105
+ reminder_skill_dir = SKILLS_DIR / "reminder"
106
+ if not reminder_skill_dir.exists():
107
+ reminder_skill_dir.mkdir(parents=True, exist_ok=True)
108
+ (reminder_skill_dir / "SKILL.md").write_text(
109
+ DEFAULT_SKILL_REMINDER, encoding="utf-8"
110
+ )
111
+ print(f" Created: {reminder_skill_dir / 'SKILL.md'}")
112
+
113
+
114
+ def show_startup_info():
115
+ """Display startup configuration info."""
116
+ print("\n" + "=" * 60)
117
+ print(" SquidBot Configuration")
118
+ print("=" * 60)
119
+ print(f" Home Directory : {DATA_DIR}")
120
+ print(f" Server Port : {SQUID_PORT}")
121
+ print(f" Model : {OPENAI_MODEL}")
122
+ print(f" Heartbeat : {HEARTBEAT_INTERVAL_MINUTES} minutes")
123
+ print("-" * 60)
124
+ print(f" Character File : {CHARACTER_FILE}")
125
+ print(f" Skills Dir : {SKILLS_DIR}")
126
+ print(f" Coding Dir : {CODING_DIR}")
127
+ print(f" Sessions Dir : {SESSIONS_DIR}")
128
+ print("=" * 60)
129
+ print("\n To customize, set environment variables:")
130
+ print(" SQUIDBOT_HOME=/path/to/home")
131
+ print(" SQUID_PORT=7777")
132
+ print(" OPENAI_MODEL=gpt-4o")
133
+ print("=" * 60 + "\n")
134
+
135
+
136
+ def validate_config():
137
+ """Validate required configuration."""
138
+ errors = []
139
+ if not TELEGRAM_BOT_TOKEN:
140
+ errors.append("TELEGRAM_BOT_TOKEN not set")
141
+ if not OPENAI_API_KEY:
142
+ errors.append("OPENAI_API_KEY not set")
143
+ if errors:
144
+ raise ValueError("Missing required config:\n" + "\n".join(errors))
145
+
146
+
147
+ # Ensure data directory exists on import
148
+ ensure_data_dirs()