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.

devduck/__init__.py CHANGED
@@ -18,6 +18,7 @@ from logging.handlers import RotatingFileHandler
18
18
 
19
19
  os.environ["BYPASS_TOOL_CONSENT"] = "true"
20
20
  os.environ["STRANDS_TOOL_CONSOLE_MODE"] = "enabled"
21
+ os.environ["EDITOR_DISABLE_BACKUP"] = "true"
21
22
 
22
23
  # 📝 Setup logging system
23
24
  LOG_DIR = Path(tempfile.gettempdir()) / "devduck" / "logs"
@@ -126,6 +127,7 @@ def get_own_source_code():
126
127
  except Exception as e:
127
128
  return f"Error reading own source code: {e}"
128
129
 
130
+
129
131
  def view_logs_tool(
130
132
  action: str = "view",
131
133
  lines: int = 100,
@@ -450,9 +452,11 @@ class DevDuck:
450
452
  tcp_port=9999,
451
453
  ws_port=8080,
452
454
  mcp_port=8000,
455
+ ipc_socket=None,
453
456
  enable_tcp=True,
454
457
  enable_ws=True,
455
458
  enable_mcp=True,
459
+ enable_ipc=True,
456
460
  ):
457
461
  """Initialize the minimalist adaptive agent"""
458
462
  logger.info("Initializing DevDuck agent...")
@@ -494,24 +498,30 @@ class DevDuck:
494
498
  from .tools import (
495
499
  tcp,
496
500
  websocket,
501
+ ipc,
497
502
  mcp_server,
498
503
  install_tools,
499
504
  use_github,
500
505
  create_subagent,
501
506
  store_in_kb,
502
507
  system_prompt,
508
+ tray,
509
+ ambient,
503
510
  )
504
511
 
505
512
  core_tools.extend(
506
513
  [
507
514
  tcp,
508
515
  websocket,
516
+ ipc,
509
517
  mcp_server,
510
518
  install_tools,
511
519
  use_github,
512
520
  create_subagent,
513
521
  store_in_kb,
514
- system_prompt
522
+ system_prompt,
523
+ tray,
524
+ ambient,
515
525
  ]
516
526
  )
517
527
  except ImportError as e:
@@ -542,6 +552,8 @@ class DevDuck:
542
552
  from strands_tools import (
543
553
  shell,
544
554
  editor,
555
+ file_read,
556
+ file_write,
545
557
  calculator,
546
558
  # python_repl,
547
559
  image_reader,
@@ -556,6 +568,8 @@ class DevDuck:
556
568
  [
557
569
  shell,
558
570
  editor,
571
+ file_read,
572
+ file_write,
559
573
  calculator,
560
574
  # python_repl,
561
575
  image_reader,
@@ -670,6 +684,22 @@ class DevDuck:
670
684
  logger.error(f"Failed to start MCP server: {e}")
671
685
  print(f"🦆 ⚠ MCP server failed: {e}")
672
686
 
687
+ if enable_ipc:
688
+ try:
689
+ # Start IPC server for local process communication
690
+ ipc_socket_path = ipc_socket or "/tmp/devduck_main.sock"
691
+ ipc_result = self.agent.tool.ipc(
692
+ action="start_server", socket_path=ipc_socket_path
693
+ )
694
+ if ipc_result.get("status") == "success":
695
+ logger.info(f"✓ IPC server started on {ipc_socket_path}")
696
+ print(f"🦆 ✓ IPC server: {ipc_socket_path}")
697
+ else:
698
+ logger.warning(f"IPC server start issue: {ipc_result}")
699
+ except Exception as e:
700
+ logger.error(f"Failed to start IPC server: {e}")
701
+ print(f"🦆 ⚠ IPC server failed: {e}")
702
+
673
703
  # Start file watcher for auto hot-reload
674
704
  self._start_file_watcher()
675
705
 
@@ -1121,18 +1151,22 @@ if "--mcp" in sys.argv:
1121
1151
  _tcp_port = int(os.getenv("DEVDUCK_TCP_PORT", "9999"))
1122
1152
  _ws_port = int(os.getenv("DEVDUCK_WS_PORT", "8080"))
1123
1153
  _mcp_port = int(os.getenv("DEVDUCK_MCP_PORT", "8000"))
1154
+ _ipc_socket = os.getenv("DEVDUCK_IPC_SOCKET", None)
1124
1155
  _enable_tcp = os.getenv("DEVDUCK_ENABLE_TCP", "true").lower() == "true"
1125
1156
  _enable_ws = os.getenv("DEVDUCK_ENABLE_WS", "true").lower() == "true"
1126
1157
  _enable_mcp = os.getenv("DEVDUCK_ENABLE_MCP", "true").lower() == "true"
1158
+ _enable_ipc = os.getenv("DEVDUCK_ENABLE_IPC", "true").lower() == "true"
1127
1159
 
1128
1160
  devduck = DevDuck(
1129
1161
  auto_start_servers=_auto_start,
1130
1162
  tcp_port=_tcp_port,
1131
1163
  ws_port=_ws_port,
1132
1164
  mcp_port=_mcp_port,
1165
+ ipc_socket=_ipc_socket,
1133
1166
  enable_tcp=_enable_tcp,
1134
1167
  enable_ws=_enable_ws,
1135
1168
  enable_mcp=_enable_mcp,
1169
+ enable_ipc=_enable_ipc,
1136
1170
  )
1137
1171
 
1138
1172
 
devduck/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.4.1'
32
- __version_tuple__ = version_tuple = (0, 4, 1)
31
+ __version__ = version = '0.5.0'
32
+ __version_tuple__ = version_tuple = (0, 5, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
devduck/tools/__init__.py CHANGED
@@ -3,5 +3,7 @@
3
3
  from .tcp import tcp
4
4
  from .mcp_server import mcp_server
5
5
  from .install_tools import install_tools
6
+ from .tray import tray
7
+ from .ambient import ambient
6
8
 
7
- __all__ = ["tcp", "mcp_server", "install_tools"]
9
+ __all__ = ["tcp", "mcp_server", "install_tools", "tray", "ambient"]
@@ -0,0 +1,423 @@
1
+ """
2
+ 🦆 DevDuck Ambient Input - Preserves scroll position
3
+ """
4
+
5
+ import tkinter as tk
6
+ from tkinter import font as tkfont, scrolledtext
7
+ import threading
8
+ import json
9
+ import tempfile
10
+ import queue
11
+ import socket as unix_socket
12
+ import os
13
+ import uuid
14
+ import subprocess
15
+
16
+
17
+ class IPCClient:
18
+ """IPC client for connecting to devduck main server"""
19
+
20
+ def __init__(self, socket_path="/tmp/devduck_main.sock"):
21
+ self.socket_path = socket_path
22
+ self.sock = None
23
+ self.connected = False
24
+ self.message_queue = queue.Queue()
25
+
26
+ def connect(self):
27
+ try:
28
+ self.sock = unix_socket.socket(unix_socket.AF_UNIX, unix_socket.SOCK_STREAM)
29
+ self.sock.connect(self.socket_path)
30
+ self.connected = True
31
+ threading.Thread(target=self._listen, daemon=True).start()
32
+ return True
33
+ except Exception as e:
34
+ print(f"Failed to connect: {e}")
35
+ return False
36
+
37
+ def send_message(self, message, turn_id=None):
38
+ if not self.connected:
39
+ print("Not connected!")
40
+ return False
41
+ try:
42
+ data = {"message": message, "turn_id": turn_id or str(uuid.uuid4())}
43
+ msg = json.dumps(data).encode() + b"\n"
44
+ self.sock.sendall(msg)
45
+ print(f"✓ Sent: {message[:50]} [turn: {data['turn_id'][:8]}]")
46
+ return True
47
+ except Exception as e:
48
+ print(f"Send failed: {e}")
49
+ self.connected = False
50
+ return False
51
+
52
+ def _listen(self):
53
+ buffer = b""
54
+ while self.connected:
55
+ try:
56
+ chunk = self.sock.recv(4096)
57
+ if not chunk:
58
+ self.connected = False
59
+ break
60
+ buffer += chunk
61
+ while b"\n" in buffer:
62
+ line, buffer = buffer.split(b"\n", 1)
63
+ try:
64
+ msg = json.loads(line.decode())
65
+ self.message_queue.put(msg)
66
+ except json.JSONDecodeError:
67
+ continue
68
+ except Exception as e:
69
+ print(f"Listen error: {e}")
70
+ self.connected = False
71
+ break
72
+
73
+ def disconnect(self):
74
+ self.connected = False
75
+ if self.sock:
76
+ try:
77
+ self.sock.close()
78
+ except:
79
+ pass
80
+
81
+
82
+ class MinimalAmbientInput:
83
+ def __init__(self):
84
+ self.root = tk.Tk()
85
+ self.root.title("🦆 devduck")
86
+
87
+ self.root.attributes("-topmost", True)
88
+ self.root.attributes("-alpha", 0.85)
89
+
90
+ screen_width = self.root.winfo_screenwidth()
91
+ screen_height = self.root.winfo_screenheight()
92
+
93
+ # Better size - bigger and more usable
94
+ window_width = 900
95
+ window_height = 650
96
+
97
+ # CENTER on screen
98
+ x = (screen_width - window_width) // 2
99
+ y = (screen_height - window_height) // 2
100
+
101
+ self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
102
+ self.root.configure(bg="#00ff88")
103
+
104
+ # Inner frame
105
+ inner_frame = tk.Frame(self.root, bg="#000000")
106
+ inner_frame.pack(fill=tk.BOTH, expand=True, padx=3, pady=3)
107
+
108
+ # Output area - NORMAL state for copying, but read-only via bindings
109
+ self.text = scrolledtext.ScrolledText(
110
+ inner_frame,
111
+ font=tkfont.Font(family="-apple-system", size=14),
112
+ bg="#000000",
113
+ fg="#ffffff",
114
+ insertbackground="#00ff88",
115
+ bd=0,
116
+ highlightthickness=0,
117
+ wrap=tk.WORD,
118
+ padx=10,
119
+ pady=10,
120
+ state="normal", # Keep normal for selection/copying
121
+ height=20,
122
+ )
123
+ self.text.pack(fill=tk.BOTH, expand=True)
124
+
125
+ # Make text read-only by blocking all modification events
126
+ self.text.bind("<Key>", lambda e: "break") # Block keyboard input
127
+ self.text.bind(
128
+ "<Button-2>", lambda e: "break"
129
+ ) # Block middle-click paste (Linux)
130
+ self.text.bind("<Button-3>", lambda e: None) # Allow right-click
131
+
132
+ # Input frame at bottom - bigger and more prominent
133
+ input_frame = tk.Frame(inner_frame, bg="#000000", height=90)
134
+ input_frame.pack(fill=tk.X, side=tk.BOTTOM, pady=(10, 0))
135
+ input_frame.pack_propagate(False)
136
+
137
+ # Container for prompt + input
138
+ input_container = tk.Frame(input_frame, bg="#000000")
139
+ input_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
140
+
141
+ # Green prompt label - bigger
142
+ prompt_label = tk.Label(
143
+ input_container,
144
+ text=">",
145
+ font=tkfont.Font(family="-apple-system", size=20, weight="bold"),
146
+ fg="#00ff88",
147
+ bg="#000000",
148
+ )
149
+ prompt_label.pack(side=tk.LEFT, padx=(0, 10))
150
+
151
+ # Input entry - bigger, more visible
152
+ self.input_entry = tk.Entry(
153
+ input_container,
154
+ font=tkfont.Font(family="-apple-system", size=18),
155
+ bg="#1a1a1a",
156
+ fg="#ffffff",
157
+ insertbackground="#00ff88",
158
+ bd=0,
159
+ highlightthickness=2,
160
+ highlightbackground="#00ff88",
161
+ highlightcolor="#00ff88",
162
+ )
163
+ self.input_entry.pack(fill=tk.BOTH, expand=True, side=tk.LEFT, ipady=10)
164
+
165
+ # Tags for output - Green user text, no ugly background
166
+ self.text.tag_config(
167
+ "user",
168
+ foreground="#00ff88",
169
+ background="#000000",
170
+ font=tkfont.Font(family="-apple-system", size=14, weight="bold"),
171
+ spacing1=5,
172
+ spacing3=5,
173
+ lmargin1=0,
174
+ lmargin2=0,
175
+ rmargin=0,
176
+ )
177
+ self.text.tag_config(
178
+ "assistant",
179
+ foreground="#ffffff",
180
+ background="#000000",
181
+ spacing1=0,
182
+ spacing3=0,
183
+ lmargin1=0,
184
+ lmargin2=0,
185
+ rmargin=0,
186
+ )
187
+ self.text.tag_config(
188
+ "tool",
189
+ foreground="#00ff88",
190
+ font=tkfont.Font(family="-apple-system", size=12),
191
+ )
192
+ self.text.tag_config("error", foreground="#ef4444")
193
+
194
+ # Bindings
195
+ self.input_entry.bind("<Return>", self.on_enter)
196
+ self.input_entry.bind("<Escape>", lambda e: self.root.withdraw())
197
+
198
+ # State - Turn tracking
199
+ self.ipc_client = IPCClient()
200
+ self.turns = {}
201
+ self.turn_order = []
202
+
203
+ # Command listener
204
+ self.command_socket_path = os.path.join(
205
+ tempfile.gettempdir(), "devduck_ambient.sock"
206
+ )
207
+ self._start_command_listener()
208
+
209
+ # Start
210
+ self.root.after(50, self.process_messages)
211
+ self.root.after(1000, self._try_connect)
212
+
213
+ # Force layout update before showing
214
+ self.root.update_idletasks()
215
+
216
+ self.root.deiconify()
217
+ self._force_focus()
218
+
219
+ def _try_connect(self):
220
+ if self.ipc_client.connect():
221
+ print("✓ Connected to IPC server")
222
+ self._append_text("✓ Connected to DevDuck\n\n", "tool")
223
+ else:
224
+ print("✗ Connection failed, retrying...")
225
+ self.root.after(5000, self._try_connect)
226
+
227
+ def _append_text(self, text, tag=None):
228
+ """Append text - NO auto-scroll, but stays in normal state"""
229
+ if tag:
230
+ self.text.insert(tk.END, text, tag)
231
+ else:
232
+ self.text.insert(tk.END, text)
233
+ # NO self.text.see(tk.END) - user controls scroll
234
+
235
+ def _render_all_turns(self):
236
+ """Render all turns - PRESERVES scroll position"""
237
+ # Save current scroll position BEFORE deleting
238
+ try:
239
+ yview = self.text.yview()
240
+ scroll_position = yview[0] # Top of visible area
241
+ except:
242
+ scroll_position = 0.0
243
+
244
+ # Delete and re-render
245
+ self.text.delete("1.0", tk.END)
246
+
247
+ for turn_id in self.turn_order:
248
+ if turn_id in self.turns:
249
+ turn = self.turns[turn_id]
250
+
251
+ # User message with "> " prefix
252
+ query = turn.get("query", "")
253
+ if query:
254
+ self.text.insert(tk.END, f"> {query}\n\n", "user")
255
+
256
+ # Assistant response
257
+ buffer = turn.get("buffer", "")
258
+ if buffer:
259
+ self.text.insert(tk.END, buffer, "assistant")
260
+
261
+ # Add spacing between turns
262
+ self.text.insert(tk.END, "\n")
263
+
264
+ # Restore scroll position AFTER re-rendering
265
+ try:
266
+ self.text.yview_moveto(scroll_position)
267
+ except:
268
+ pass
269
+
270
+ def _start_command_listener(self):
271
+ def server_thread():
272
+ if os.path.exists(self.command_socket_path):
273
+ os.unlink(self.command_socket_path)
274
+
275
+ server = unix_socket.socket(unix_socket.AF_UNIX, unix_socket.SOCK_STREAM)
276
+ server.bind(self.command_socket_path)
277
+ server.listen(1)
278
+
279
+ while True:
280
+ try:
281
+ conn, _ = server.accept()
282
+ data = conn.recv(4096)
283
+ if data:
284
+ cmd = json.loads(data.decode("utf-8"))
285
+ action = cmd.get("action")
286
+ if action == "show":
287
+ self.root.after(0, self._force_focus)
288
+ elif action == "hide":
289
+ self.root.after(0, self.root.withdraw)
290
+ elif action == "set_text":
291
+ text = cmd.get("text", "")
292
+ self.root.after(0, lambda: self._set_input_text(text))
293
+ conn.send(json.dumps({"status": "success"}).encode("utf-8"))
294
+ conn.close()
295
+ except Exception as e:
296
+ print(f"Command error: {e}")
297
+
298
+ threading.Thread(target=server_thread, daemon=True).start()
299
+
300
+ def _set_input_text(self, text):
301
+ """Set input text"""
302
+ self.input_entry.delete(0, tk.END)
303
+ self.input_entry.insert(0, text)
304
+
305
+ def _force_focus(self):
306
+ self.root.deiconify()
307
+ self.root.lift()
308
+ self.root.attributes("-topmost", True)
309
+
310
+ try:
311
+ subprocess.run(
312
+ [
313
+ "osascript",
314
+ "-e",
315
+ f'tell application "System Events" to set frontmost of the first process whose unix id is {os.getpid()} to true',
316
+ ],
317
+ check=False,
318
+ capture_output=True,
319
+ timeout=1,
320
+ )
321
+ except:
322
+ pass
323
+
324
+ self.root.focus_force()
325
+ self.input_entry.focus_set()
326
+
327
+ def process_messages(self):
328
+ """Process messages with turn-based buffering"""
329
+ needs_render = False
330
+
331
+ while not self.ipc_client.message_queue.empty():
332
+ msg = self.ipc_client.message_queue.get()
333
+ msg_type = msg.get("type")
334
+ turn_id = msg.get("turn_id")
335
+
336
+ if msg_type == "turn_start":
337
+ if turn_id not in self.turns:
338
+ self.turns[turn_id] = {
339
+ "query": msg.get("data", ""),
340
+ "buffer": "",
341
+ "tools": [],
342
+ }
343
+ self.turn_order.append(turn_id)
344
+ needs_render = True
345
+
346
+ elif msg_type == "chunk":
347
+ if turn_id in self.turns:
348
+ chunk = msg.get("data", "")
349
+ self.turns[turn_id]["buffer"] += chunk
350
+ needs_render = True
351
+
352
+ elif msg_type == "tool_start":
353
+ if turn_id in self.turns:
354
+ tool_name = msg.get("data", "")
355
+ tool_num = msg.get("tool_number", 0)
356
+ tool_text = f"\n🛠️ #{tool_num}: {tool_name} "
357
+ self.turns[turn_id]["buffer"] += tool_text
358
+ needs_render = True
359
+
360
+ elif msg_type == "tool_end":
361
+ if turn_id in self.turns:
362
+ success = msg.get("success", False)
363
+ icon = "✅" if success else "❌"
364
+ self.turns[turn_id]["buffer"] += f"{icon}\n"
365
+ needs_render = True
366
+
367
+ elif msg_type == "turn_end":
368
+ if turn_id in self.turns:
369
+ self.turns[turn_id]["buffer"] += "\n"
370
+ needs_render = True
371
+
372
+ elif msg_type == "error":
373
+ error_msg = msg.get("data", "Unknown error")
374
+ if turn_id and turn_id in self.turns:
375
+ self.turns[turn_id]["buffer"] += f"\n❌ Error: {error_msg}\n"
376
+ needs_render = True
377
+
378
+ if needs_render:
379
+ self._render_all_turns()
380
+
381
+ self.root.after(50, self.process_messages)
382
+
383
+ def on_enter(self, event):
384
+ """Handle Enter key in input field"""
385
+ query = self.input_entry.get().strip()
386
+
387
+ if not query:
388
+ return "break"
389
+
390
+ if not self.ipc_client.connected:
391
+ self._append_text("\n❌ Not connected\n", "error")
392
+ return "break"
393
+
394
+ try:
395
+ turn_id = str(uuid.uuid4())
396
+ self.input_entry.delete(0, tk.END)
397
+
398
+ if not self.ipc_client.send_message(query, turn_id=turn_id):
399
+ self._append_text("❌ Send failed\n", "error")
400
+
401
+ except Exception as e:
402
+ print(f"❌ Error: {e}")
403
+ import traceback
404
+
405
+ traceback.print_exc()
406
+
407
+ return "break"
408
+
409
+ def run(self):
410
+ self.root.mainloop()
411
+
412
+ def cleanup(self):
413
+ self.ipc_client.disconnect()
414
+ if os.path.exists(self.command_socket_path):
415
+ os.unlink(self.command_socket_path)
416
+
417
+
418
+ if __name__ == "__main__":
419
+ app = MinimalAmbientInput()
420
+ try:
421
+ app.run()
422
+ finally:
423
+ app.cleanup()