devduck 0.4.1__py3-none-any.whl → 0.5.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.

Potentially problematic release.


This version of devduck might be problematic. Click here for more details.

@@ -0,0 +1,522 @@
1
+ """
2
+ DevDuck Tray - Modern tray app with server controls & agent capabilities
3
+ """
4
+
5
+ import rumps
6
+ import socket
7
+ import threading
8
+ import json
9
+ import os
10
+ import tempfile
11
+ import webbrowser
12
+ from queue import Queue
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ import uuid
16
+
17
+
18
+ class DevDuckTray(rumps.App):
19
+ def __init__(self):
20
+ super().__init__("🦆", quit_button=None)
21
+
22
+ # State
23
+ self.command_queue = Queue()
24
+ self.command_responses = {}
25
+ self.ui_update_queue = Queue()
26
+ self.state = {
27
+ "status": "idle",
28
+ "tcp_enabled": True,
29
+ "ws_enabled": True,
30
+ "mcp_enabled": True,
31
+ }
32
+
33
+ self.base_icon = "🦆"
34
+
35
+ # Multi-stream state
36
+ self.active_streams = {}
37
+ self.recent_results = []
38
+ self.max_recent = 5
39
+
40
+ # Persistent menu items
41
+ self.saved_menu_items = []
42
+
43
+ # Build initial menu
44
+ self._build_menu()
45
+
46
+ # Start IPC listener
47
+ self.socket_path = os.path.join(tempfile.gettempdir(), "devduck_tray.sock")
48
+ self._start_ipc_listener()
49
+
50
+ # Start command processor
51
+ self.command_timer = rumps.Timer(self._process_commands, 0.1)
52
+ self.command_timer.start()
53
+
54
+ # Start UI update processor (main thread only)
55
+ self.ui_timer = rumps.Timer(self._process_ui_updates, 0.2)
56
+ self.ui_timer.start()
57
+
58
+ # Import devduck
59
+ try:
60
+ os.environ["DEVDUCK_AUTO_START_SERVERS"] = "false"
61
+ import devduck
62
+
63
+ self.devduck = devduck
64
+ except ImportError:
65
+ self.devduck = None
66
+
67
+ def _process_ui_updates(self, _):
68
+ """Process UI updates on main thread only"""
69
+ updated = False
70
+ while not self.ui_update_queue.empty():
71
+ update_type = self.ui_update_queue.get()
72
+ if update_type == "menu":
73
+ updated = True
74
+
75
+ if updated:
76
+ self._build_menu()
77
+
78
+ def _request_menu_update(self):
79
+ """Request menu update (thread-safe)"""
80
+ self.ui_update_queue.put("menu")
81
+
82
+ def _build_menu(self):
83
+ """Build menu with server controls and agent capabilities"""
84
+ self.menu.clear()
85
+
86
+ # Active Streams Section
87
+ if self.active_streams:
88
+ self.menu.add(rumps.MenuItem("🌊 Active Streams", callback=None))
89
+ for stream_id, stream_data in list(self.active_streams.items()):
90
+ icon = {
91
+ "thinking": "🤔",
92
+ "processing": "💡",
93
+ "complete": "✅",
94
+ "error": "❌",
95
+ }.get(stream_data["status"], "💭")
96
+ query_short = (
97
+ stream_data["query"][:30] + "..."
98
+ if len(stream_data["query"]) > 30
99
+ else stream_data["query"]
100
+ )
101
+ text_short = (
102
+ stream_data["text"][:40] + "..."
103
+ if len(stream_data["text"]) > 40
104
+ else stream_data["text"]
105
+ )
106
+ menu_text = f" {icon} {query_short}: {text_short}"
107
+ self.menu.add(rumps.MenuItem(menu_text, callback=None))
108
+ self.menu.add(rumps.separator)
109
+
110
+ # User-defined menu items
111
+ if self.saved_menu_items:
112
+ for item in self.saved_menu_items:
113
+ if item.get("type") == "separator":
114
+ self.menu.add(rumps.separator)
115
+ else:
116
+ label = item.get("title") or item.get("label", "Item")
117
+ query = item.get("query") or item.get("action", label)
118
+ self.menu.add(
119
+ rumps.MenuItem(label, callback=self._create_callback(query))
120
+ )
121
+ self.menu.add(rumps.separator)
122
+
123
+ # Recent Results Section
124
+ if self.recent_results:
125
+ self.menu.add(rumps.MenuItem("📝 Recent Results", callback=None))
126
+ for query, result, timestamp in self.recent_results[: self.max_recent]:
127
+ query_short = query[:25] + "..." if len(query) > 25 else query
128
+ result_short = result[:35] + "..." if len(result) > 35 else result
129
+ time_str = timestamp.strftime("%H:%M")
130
+ menu_text = f" [{time_str}] {query_short} → {result_short}"
131
+ self.menu.add(
132
+ rumps.MenuItem(
133
+ menu_text,
134
+ callback=self._create_show_result_callback(query, result),
135
+ )
136
+ )
137
+ self.menu.add(rumps.separator)
138
+
139
+ # Status
140
+ self.menu.add(rumps.MenuItem(f"Status: {self.state['status']}", callback=None))
141
+ self.menu.add(rumps.separator)
142
+
143
+ # Server controls
144
+ self.menu.add(rumps.MenuItem("🌐 Servers", callback=None))
145
+
146
+ tcp_status = "✅" if self.state["tcp_enabled"] else "❌"
147
+ self.menu.add(
148
+ rumps.MenuItem(f" {tcp_status} TCP (9999)", callback=self.toggle_tcp)
149
+ )
150
+
151
+ ws_status = "✅" if self.state["ws_enabled"] else "❌"
152
+ self.menu.add(
153
+ rumps.MenuItem(f" {ws_status} WebSocket (8080)", callback=self.toggle_ws)
154
+ )
155
+
156
+ mcp_status = "✅" if self.state["mcp_enabled"] else "❌"
157
+ self.menu.add(
158
+ rumps.MenuItem(f" {mcp_status} MCP (8000)", callback=self.toggle_mcp)
159
+ )
160
+
161
+ self.menu.add(rumps.separator)
162
+
163
+ # Agent Capabilities
164
+ self.menu.add(rumps.MenuItem("🤖 Agent Capabilities", callback=None))
165
+ self.menu.add(
166
+ rumps.MenuItem(
167
+ " 👂 Start Clipboard Listening",
168
+ callback=self._create_callback(
169
+ "start clipboard monitoring in background"
170
+ ),
171
+ )
172
+ )
173
+ self.menu.add(
174
+ rumps.MenuItem(
175
+ " 🎤 Start Background Listening",
176
+ callback=self._create_callback("start background audio listening"),
177
+ )
178
+ )
179
+ self.menu.add(
180
+ rumps.MenuItem(
181
+ " 📺 Start Screen Reader",
182
+ callback=self._create_callback("start screen reader monitoring"),
183
+ )
184
+ )
185
+ self.menu.add(
186
+ rumps.MenuItem(
187
+ " 👁️ Start YOLO Vision",
188
+ callback=self._create_callback("start yolo vision detection"),
189
+ )
190
+ )
191
+
192
+ self.menu.add(rumps.separator)
193
+
194
+ # Actions
195
+ self.menu.add(rumps.MenuItem("💻 Show Input", callback=self.show_input))
196
+ self.menu.add(rumps.MenuItem("🌐 Web Dashboard", callback=self.open_dashboard))
197
+
198
+ self.menu.add(rumps.separator)
199
+ self.menu.add(rumps.MenuItem("Quit", callback=self.quit_app))
200
+
201
+ def _create_show_result_callback(self, query, result):
202
+ """Create callback to show full result in notification"""
203
+
204
+ def callback(sender):
205
+ rumps.notification("DevDuck Result", query, result)
206
+
207
+ return callback
208
+
209
+ def toggle_tcp(self, sender):
210
+ """Toggle TCP server"""
211
+ if self.devduck and hasattr(self.devduck.devduck.agent, "tool"):
212
+ try:
213
+ action = "stop_server" if self.state["tcp_enabled"] else "start_server"
214
+ self.devduck.devduck.agent.tool.tcp(action=action, port=9999)
215
+ self.state["tcp_enabled"] = not self.state["tcp_enabled"]
216
+ self._request_menu_update()
217
+ rumps.notification(
218
+ "DevDuck",
219
+ "",
220
+ f"TCP: {'ON' if self.state['tcp_enabled'] else 'OFF'}",
221
+ )
222
+ except Exception as e:
223
+ rumps.notification("DevDuck Error", "", str(e))
224
+
225
+ def toggle_ws(self, sender):
226
+ """Toggle WebSocket server"""
227
+ if self.devduck and hasattr(self.devduck.devduck.agent, "tool"):
228
+ try:
229
+ action = "stop_server" if self.state["ws_enabled"] else "start_server"
230
+ self.devduck.devduck.agent.tool.websocket(action=action, port=8080)
231
+ self.state["ws_enabled"] = not self.state["ws_enabled"]
232
+ self._request_menu_update()
233
+ rumps.notification(
234
+ "DevDuck",
235
+ "",
236
+ f"WebSocket: {'ON' if self.state['ws_enabled'] else 'OFF'}",
237
+ )
238
+ except Exception as e:
239
+ rumps.notification("DevDuck Error", "", str(e))
240
+
241
+ def toggle_mcp(self, sender):
242
+ """Toggle MCP server"""
243
+ if self.devduck and hasattr(self.devduck.devduck.agent, "tool"):
244
+ try:
245
+ action = "stop" if self.state["mcp_enabled"] else "start"
246
+ self.devduck.devduck.agent.tool.mcp_server(action=action, port=8000)
247
+ self.state["mcp_enabled"] = not self.state["mcp_enabled"]
248
+ self._request_menu_update()
249
+ rumps.notification(
250
+ "DevDuck",
251
+ "",
252
+ f"MCP: {'ON' if self.state['mcp_enabled'] else 'OFF'}",
253
+ )
254
+ except Exception as e:
255
+ rumps.notification("DevDuck Error", "", str(e))
256
+
257
+ def show_input(self, sender):
258
+ """Show ambient input overlay"""
259
+ ambient_socket = os.path.join(tempfile.gettempdir(), "devduck_ambient.sock")
260
+
261
+ try:
262
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
263
+ client.settimeout(2.0)
264
+ client.connect(ambient_socket)
265
+ client.send(json.dumps({"action": "show"}).encode("utf-8"))
266
+ client.close()
267
+ except:
268
+ # Ambient not running - try to start via devduck
269
+ if self.devduck and hasattr(self.devduck.devduck.agent, "tool"):
270
+ try:
271
+ self.devduck.devduck.agent.tool.ambient(action="start")
272
+ rumps.notification("DevDuck", "", "Input overlay started! 💻")
273
+ except Exception as e:
274
+ rumps.notification("DevDuck Error", "", f"Failed: {e}")
275
+
276
+ def open_dashboard(self, sender):
277
+ """Open web dashboard"""
278
+ webbrowser.open("https://cagataycali.github.io/devduck")
279
+
280
+ def quit_app(self, sender):
281
+ """Quit application"""
282
+ rumps.quit_application()
283
+
284
+ def _start_ipc_listener(self):
285
+ """Start Unix socket listener"""
286
+
287
+ def listener():
288
+ try:
289
+ if os.path.exists(self.socket_path):
290
+ os.unlink(self.socket_path)
291
+
292
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
293
+ server.bind(self.socket_path)
294
+ server.listen(5)
295
+
296
+ while True:
297
+ conn, _ = server.accept()
298
+ threading.Thread(
299
+ target=self._handle_connection, args=(conn,), daemon=True
300
+ ).start()
301
+ except Exception as e:
302
+ print(f"IPC error: {e}")
303
+
304
+ threading.Thread(target=listener, daemon=True).start()
305
+
306
+ def _handle_connection(self, conn):
307
+ """Handle IPC connection"""
308
+ try:
309
+ data = conn.recv(4096)
310
+ if data:
311
+ command = json.loads(data.decode("utf-8"))
312
+ request_id = command.get("request_id", id(command))
313
+
314
+ self.command_queue.put((request_id, command))
315
+
316
+ # Wait for response
317
+ import time
318
+
319
+ timeout = 5.0
320
+ start = time.time()
321
+ while time.time() - start < timeout:
322
+ if request_id in self.command_responses:
323
+ response = self.command_responses.pop(request_id)
324
+ conn.send(json.dumps(response).encode("utf-8"))
325
+ break
326
+ time.sleep(0.05)
327
+ else:
328
+ conn.send(
329
+ json.dumps({"status": "error", "message": "timeout"}).encode(
330
+ "utf-8"
331
+ )
332
+ )
333
+ except Exception as e:
334
+ try:
335
+ conn.send(
336
+ json.dumps({"status": "error", "message": str(e)}).encode("utf-8")
337
+ )
338
+ except:
339
+ pass
340
+ finally:
341
+ conn.close()
342
+
343
+ def _process_commands(self, _):
344
+ """Process queued commands on main thread"""
345
+ while not self.command_queue.empty():
346
+ request_id, cmd = self.command_queue.get()
347
+ response = self._handle_command(cmd)
348
+ self.command_responses[request_id] = response
349
+
350
+ def _handle_command(self, cmd):
351
+ """Handle IPC commands"""
352
+ try:
353
+ action = cmd.get("action")
354
+
355
+ if action == "update_title":
356
+ new_icon = cmd.get("title", "🦆")
357
+ self.base_icon = new_icon
358
+ self.title = self.base_icon
359
+ return {"status": "success"}
360
+
361
+ elif action == "set_progress":
362
+ progress = cmd.get("progress", "idle")
363
+ icons = {
364
+ "idle": "🦆",
365
+ "thinking": "🤔",
366
+ "processing": "💡",
367
+ "complete": "✅",
368
+ "error": "❌",
369
+ }
370
+ self.base_icon = icons.get(progress, "🦆")
371
+ self.title = self.base_icon
372
+ return {"status": "success"}
373
+
374
+ elif action == "update_menu":
375
+ items = cmd.get("items", [])
376
+ self.saved_menu_items = items
377
+ self._request_menu_update()
378
+ return {"status": "success"}
379
+
380
+ elif action == "notify":
381
+ msg = cmd.get("message", {})
382
+ rumps.notification(
383
+ msg.get("title", "DevDuck"),
384
+ msg.get("subtitle", ""),
385
+ msg.get("message", ""),
386
+ )
387
+ return {"status": "success"}
388
+
389
+ elif action == "stream_text":
390
+ text = cmd.get("text", "")
391
+ stream_id = cmd.get("stream_id", "default")
392
+ status = cmd.get("status", "processing")
393
+ query = cmd.get("query", "")
394
+
395
+ self.active_streams[stream_id] = {
396
+ "query": query,
397
+ "status": status,
398
+ "text": text,
399
+ }
400
+
401
+ self._request_menu_update()
402
+
403
+ return {"status": "success"}
404
+
405
+ elif action == "stream_complete":
406
+ stream_id = cmd.get("stream_id", "default")
407
+ if stream_id in self.active_streams:
408
+ stream_data = self.active_streams.pop(stream_id)
409
+ self.recent_results.insert(
410
+ 0, (stream_data["query"], stream_data["text"], datetime.now())
411
+ )
412
+ self.recent_results = self.recent_results[: self.max_recent]
413
+ self._request_menu_update()
414
+ return {"status": "success"}
415
+
416
+ elif action == "show_input":
417
+ self.show_input(None)
418
+ return {"status": "success"}
419
+
420
+ elif action in ["toggle_tcp", "toggle_ws", "toggle_mcp"]:
421
+ if action == "toggle_tcp":
422
+ self.toggle_tcp(None)
423
+ elif action == "toggle_ws":
424
+ self.toggle_ws(None)
425
+ elif action == "toggle_mcp":
426
+ self.toggle_mcp(None)
427
+ return {"status": "success"}
428
+
429
+ return {"status": "error", "message": "Unknown action"}
430
+ except Exception as e:
431
+ return {"status": "error", "message": str(e)}
432
+
433
+ def _create_callback(self, query):
434
+ """Create callback for menu item"""
435
+
436
+ def callback(sender):
437
+ if self.devduck:
438
+ stream_id = str(uuid.uuid4())[:8]
439
+
440
+ self.active_streams[stream_id] = {
441
+ "query": query,
442
+ "status": "thinking",
443
+ "text": "Starting...",
444
+ }
445
+ self._request_menu_update()
446
+
447
+ self.base_icon = "🤔"
448
+ self.title = self.base_icon
449
+
450
+ def run():
451
+ try:
452
+ self.active_streams[stream_id]["status"] = "processing"
453
+ self.active_streams[stream_id]["text"] = "Processing query..."
454
+ self._request_menu_update()
455
+
456
+ self.base_icon = "💡"
457
+ self.title = self.base_icon
458
+
459
+ result = self.devduck.ask(query)
460
+ result_str = str(result)
461
+
462
+ self.active_streams[stream_id]["status"] = "complete"
463
+ self.active_streams[stream_id]["text"] = result_str
464
+ self._request_menu_update()
465
+
466
+ self.base_icon = "✅"
467
+ self.title = self.base_icon
468
+
469
+ rumps.notification("DevDuck Result", query, result_str)
470
+
471
+ def move_to_recent():
472
+ if stream_id in self.active_streams:
473
+ stream_data = self.active_streams.pop(stream_id)
474
+ self.recent_results.insert(
475
+ 0,
476
+ (
477
+ stream_data["query"],
478
+ stream_data["text"],
479
+ datetime.now(),
480
+ ),
481
+ )
482
+ self.recent_results = self.recent_results[
483
+ : self.max_recent
484
+ ]
485
+ self._request_menu_update()
486
+ self._reset_icon()
487
+
488
+ threading.Timer(3.0, move_to_recent).start()
489
+
490
+ except Exception as e:
491
+ self.active_streams[stream_id]["status"] = "error"
492
+ self.active_streams[stream_id]["text"] = str(e)
493
+ self._request_menu_update()
494
+
495
+ self.base_icon = "❌"
496
+ self.title = self.base_icon
497
+ rumps.notification("DevDuck Error", query, str(e))
498
+
499
+ threading.Timer(
500
+ 3.0, lambda: self._cleanup_stream(stream_id)
501
+ ).start()
502
+
503
+ threading.Thread(target=run, daemon=True).start()
504
+
505
+ return callback
506
+
507
+ def _cleanup_stream(self, stream_id):
508
+ """Remove stream and reset icon"""
509
+ if stream_id in self.active_streams:
510
+ self.active_streams.pop(stream_id)
511
+ self._request_menu_update()
512
+ self._reset_icon()
513
+
514
+ def _reset_icon(self):
515
+ """Reset icon to default"""
516
+ self.base_icon = "🦆"
517
+ self.title = self.base_icon
518
+
519
+
520
+ if __name__ == "__main__":
521
+ app = DevDuckTray()
522
+ app.run()
@@ -0,0 +1,157 @@
1
+ """
2
+ Ambient input overlay control tool - integrated with devduck
3
+ """
4
+
5
+ from strands import tool
6
+ from typing import Dict, Any
7
+ import subprocess
8
+ import socket
9
+ import json
10
+ import tempfile
11
+ import os
12
+ import time
13
+ import signal
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # Global state
18
+ _ambient_process = None
19
+
20
+
21
+ def _send_command(command: Dict) -> Dict:
22
+ """Send command to ambient input overlay"""
23
+ socket_path = os.path.join(tempfile.gettempdir(), "devduck_ambient.sock")
24
+
25
+ try:
26
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
27
+ client.settimeout(2.0)
28
+ client.connect(socket_path)
29
+ client.sendall(json.dumps(command).encode("utf-8"))
30
+
31
+ response_data = client.recv(4096)
32
+ client.close()
33
+
34
+ if not response_data:
35
+ return {"status": "error", "message": "Empty response"}
36
+
37
+ return json.loads(response_data.decode("utf-8"))
38
+ except socket.timeout:
39
+ return {"status": "error", "message": "Timeout"}
40
+ except Exception as e:
41
+ return {"status": "error", "message": str(e)}
42
+
43
+
44
+ @tool
45
+ def ambient(
46
+ action: str,
47
+ text: str = None,
48
+ ) -> Dict[str, Any]:
49
+ """Control ambient AI input overlay.
50
+
51
+ Args:
52
+ action: Action to perform
53
+ - "start": Start ambient overlay
54
+ - "stop": Stop overlay
55
+ - "show": Show overlay
56
+ - "hide": Hide overlay
57
+ - "status": Check if running
58
+ - "set_text": Pre-fill text
59
+ text: Text to pre-fill (for set_text action)
60
+
61
+ Returns:
62
+ Dict with status and content
63
+
64
+ Features:
65
+ 🎨 Modern glassmorphism UI
66
+ ⚡ Blinking cursor with auto-focus
67
+ 🌊 Real-time IPC streaming from devduck
68
+ 📦 Structured message handling
69
+ ⌨️ ESC to hide, Enter to send
70
+ """
71
+ global _ambient_process
72
+
73
+ if action == "start":
74
+ if _ambient_process and _ambient_process.poll() is None:
75
+ return {"status": "success", "content": [{"text": "✓ Already running"}]}
76
+
77
+ # Get ambient script path (in same directory as this file)
78
+ tools_dir = Path(__file__).parent
79
+ ambient_script = tools_dir / "_ambient_input.py"
80
+
81
+ if not ambient_script.exists():
82
+ return {
83
+ "status": "error",
84
+ "content": [{"text": f"❌ Ambient script not found: {ambient_script}"}],
85
+ }
86
+
87
+ _ambient_process = subprocess.Popen(
88
+ [sys.executable, str(ambient_script)],
89
+ stdout=subprocess.DEVNULL,
90
+ stderr=subprocess.DEVNULL,
91
+ )
92
+
93
+ time.sleep(1.5)
94
+
95
+ return {
96
+ "status": "success",
97
+ "content": [
98
+ {"text": f"✓ Ambient overlay started (PID: {_ambient_process.pid})"}
99
+ ],
100
+ }
101
+
102
+ elif action == "stop":
103
+ if _ambient_process:
104
+ try:
105
+ os.kill(_ambient_process.pid, signal.SIGTERM)
106
+ _ambient_process.wait(timeout=3)
107
+ except:
108
+ pass
109
+ _ambient_process = None
110
+
111
+ return {"status": "success", "content": [{"text": "✓ Stopped"}]}
112
+
113
+ elif action == "status":
114
+ is_running = _ambient_process and _ambient_process.poll() is None
115
+ return {"status": "success", "content": [{"text": f"Running: {is_running}"}]}
116
+
117
+ elif action == "show":
118
+ result = _send_command({"action": "show"})
119
+ if result.get("status") == "success":
120
+ return {"status": "success", "content": [{"text": "✓ Overlay shown"}]}
121
+ else:
122
+ return {
123
+ "status": "error",
124
+ "content": [
125
+ {"text": f"Failed: {result.get('message', 'Unknown error')}"}
126
+ ],
127
+ }
128
+
129
+ elif action == "hide":
130
+ result = _send_command({"action": "hide"})
131
+ if result.get("status") == "success":
132
+ return {"status": "success", "content": [{"text": "✓ Overlay hidden"}]}
133
+ else:
134
+ return {
135
+ "status": "error",
136
+ "content": [
137
+ {"text": f"Failed: {result.get('message', 'Unknown error')}"}
138
+ ],
139
+ }
140
+
141
+ elif action == "set_text":
142
+ if not text:
143
+ return {"status": "error", "content": [{"text": "text parameter required"}]}
144
+
145
+ result = _send_command({"action": "set_text", "text": text})
146
+ if result.get("status") == "success":
147
+ return {"status": "success", "content": [{"text": f"✓ Text set: {text}"}]}
148
+ else:
149
+ return {
150
+ "status": "error",
151
+ "content": [
152
+ {"text": f"Failed: {result.get('message', 'Unknown error')}"}
153
+ ],
154
+ }
155
+
156
+ else:
157
+ return {"status": "error", "content": [{"text": f"Unknown action: {action}"}]}