dulus 0.2.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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
gui/chat_widget.py ADDED
@@ -0,0 +1,448 @@
1
+ """Chat display widget for Dulus GUI.
2
+
3
+ Provides a scrollable chat view with message bubbles, markdown-like rendering,
4
+ code blocks with copy buttons, tool execution pills, and a typing indicator.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import tkinter as tk
10
+ from datetime import datetime
11
+ from typing import Callable
12
+
13
+ try:
14
+ import customtkinter as ctk
15
+ except ImportError:
16
+ raise ImportError("customtkinter is required. Install: pip install customtkinter")
17
+
18
+ from gui.themes import get_theme
19
+
20
+ # ── Theme constants (loaded from active theme) ──────────────────────────────
21
+ _t = get_theme()
22
+ BG_COLOR = _t["bg"]
23
+ CARD_COLOR = _t["card"]
24
+ ACCENT_COLOR = _t["accent"]
25
+ ACCENT_HOVER = _t["accent_hover"]
26
+ TEXT_COLOR = _t["text"]
27
+ TEXT_DIM = _t["dim"]
28
+ USER_BUBBLE = _t["user_bubble"]
29
+ ASSISTANT_BUBBLE = _t["assistant_bubble"]
30
+ CODE_BG = _t["code_bg"]
31
+ BORDER_COLOR = _t["border"]
32
+
33
+ # Tag colors (updated by apply_theme)
34
+ TAG_BOLD_COLOR = _t.get("text", "#ffffff")
35
+ TAG_CODE_COLOR = _t.get("dim", "#c9d1d9")
36
+ TAG_ITALIC_COLOR = _t.get("dim", "#bbbbbb")
37
+
38
+ FONT_FAMILY = "Segoe UI"
39
+ FONT_NORMAL = (FONT_FAMILY, 13)
40
+ FONT_BOLD = (FONT_FAMILY, 13, "bold")
41
+ FONT_SMALL = (FONT_FAMILY, 11)
42
+ FONT_CODE = ("Consolas", 12)
43
+ FONT_TIMESTAMP = (FONT_FAMILY, 10)
44
+
45
+
46
+ def _sanitize_markdown(text: str) -> str:
47
+ """Escape HTML-like chars so tkinter Text widget stays safe."""
48
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
49
+
50
+
51
+ class ChatWidget(ctk.CTkFrame):
52
+ """Scrollable chat widget with message bubbles and rich formatting."""
53
+
54
+ def __init__(
55
+ self,
56
+ master,
57
+ on_copy_callback: Callable | None = None,
58
+ **kwargs,
59
+ ):
60
+ super().__init__(master, fg_color="transparent", **kwargs)
61
+
62
+ self.on_copy = on_copy_callback
63
+ self._message_frames: list[ctk.CTkFrame] = []
64
+ self._current_bubble_text: ctk.CTkTextbox | None = None
65
+ self._current_bubble_is_user: bool = False
66
+ self._current_bubble_outer: ctk.CTkFrame | None = None
67
+
68
+ # Grid layout
69
+ self.grid_columnconfigure(0, weight=1)
70
+ self.grid_rowconfigure(0, weight=1)
71
+
72
+ # Scrollable container
73
+ self._scroll = ctk.CTkScrollableFrame(
74
+ self,
75
+ fg_color="transparent",
76
+ scrollbar_fg_color=BORDER_COLOR,
77
+ scrollbar_button_color=ACCENT_COLOR,
78
+ scrollbar_button_hover_color=ACCENT_HOVER,
79
+ )
80
+ self._scroll.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
81
+ self._scroll.grid_columnconfigure(0, weight=1)
82
+
83
+ # Inner frame where messages live
84
+ self._container = ctk.CTkFrame(self._scroll, fg_color="transparent")
85
+ self._container.pack(fill="both", expand=True)
86
+ self._container.grid_columnconfigure(0, weight=1)
87
+
88
+ # Thinking indicator (hidden by default)
89
+ self._thinking_frame = ctk.CTkFrame(
90
+ self._container, fg_color=ASSISTANT_BUBBLE, corner_radius=12
91
+ )
92
+ self._thinking_label = ctk.CTkLabel(
93
+ self._thinking_frame,
94
+ text="🌀 Dulus ta pensando…",
95
+ font=FONT_NORMAL,
96
+ text_color=ACCENT_COLOR,
97
+ )
98
+ self._thinking_label.pack(padx=16, pady=10)
99
+
100
+ # ── Public API ──────────────────────────────────────────────────────────
101
+
102
+ def add_user_message(self, text: str) -> None:
103
+ """Add a user message bubble on the right."""
104
+ self._hide_thinking()
105
+ self._finish_current_stream()
106
+
107
+ bubble, txt = self._create_bubble(
108
+ text=text,
109
+ is_user=True,
110
+ timestamp=datetime.now().strftime("%H:%M"),
111
+ )
112
+ bubble.pack(anchor="e", padx=12, pady=(6, 2), fill="x")
113
+ self._message_frames.append(bubble)
114
+ self._scroll_to_bottom()
115
+
116
+ def add_assistant_message(self, text: str) -> None:
117
+ """Start a new assistant message bubble on the left."""
118
+ self._hide_thinking()
119
+ self._finish_current_stream()
120
+
121
+ bubble, txt = self._create_bubble(
122
+ text=text,
123
+ is_user=False,
124
+ timestamp=datetime.now().strftime("%H:%M"),
125
+ )
126
+ bubble.pack(anchor="w", padx=12, pady=(6, 2), fill="x")
127
+ self._message_frames.append(bubble)
128
+ self._current_bubble_text = txt
129
+ self._current_bubble_is_user = False
130
+ self._current_bubble_outer = bubble
131
+ self._scroll_to_bottom()
132
+
133
+ def append_to_last_message(self, text: str) -> None:
134
+ """Append text to the current assistant bubble (streaming)."""
135
+ if self._current_bubble_text is None or self._current_bubble_is_user:
136
+ self.add_assistant_message(text)
137
+ return
138
+
139
+ widget = self._current_bubble_text
140
+ widget.configure(state="normal")
141
+ current = widget.get("1.0", "end-1c")
142
+ widget.delete("1.0", "end")
143
+ self._render_formatted(widget, current + text)
144
+ self._adjust_text_height(widget)
145
+ widget.configure(state="disabled")
146
+ self._scroll_to_bottom()
147
+
148
+ def add_tool_indicator(self, name: str, status: str = "running") -> None:
149
+ """Add a small inline pill showing a tool execution."""
150
+ self._hide_thinking()
151
+
152
+ pill = ctk.CTkFrame(
153
+ self._container,
154
+ fg_color=CODE_BG if status == "running" else "#1b3a1b",
155
+ corner_radius=8,
156
+ border_width=1,
157
+ border_color=ACCENT_COLOR if status == "running" else "#4caf50",
158
+ )
159
+ icon = "⚙" if status == "running" else "✓"
160
+ lbl = ctk.CTkLabel(
161
+ pill,
162
+ text=f"{icon} {name}",
163
+ font=FONT_SMALL,
164
+ text_color=ACCENT_COLOR if status == "running" else "#4caf50",
165
+ )
166
+ lbl.pack(padx=10, pady=4)
167
+
168
+ # Stack tools above the current assistant message bubble
169
+ if self._current_bubble_outer is not None:
170
+ pill.pack(
171
+ before=self._current_bubble_outer,
172
+ anchor="w",
173
+ padx=20,
174
+ pady=(2, 4),
175
+ )
176
+ else:
177
+ pill.pack(anchor="w", padx=20, pady=(2, 4))
178
+
179
+ self._message_frames.append(pill)
180
+ self._scroll_to_bottom()
181
+
182
+ def show_thinking(self) -> None:
183
+ """Show the 'thinking' indicator at the bottom."""
184
+ self._thinking_frame.pack(anchor="w", padx=12, pady=6, fill="x")
185
+ self._scroll_to_bottom()
186
+
187
+ def hide_thinking(self) -> None:
188
+ """Hide the thinking indicator."""
189
+ self._hide_thinking()
190
+
191
+ def clear_chat(self) -> None:
192
+ """Remove all messages and reset state."""
193
+ self._finish_current_stream()
194
+ for w in self._message_frames:
195
+ w.destroy()
196
+ self._message_frames.clear()
197
+ self._current_bubble_text = None
198
+ self._current_bubble_is_user = False
199
+ self._hide_thinking()
200
+
201
+ def load_messages(self, messages: list[dict]) -> None:
202
+ """Bulk load messages into the chat view without repetitive scrolling."""
203
+ self.clear_chat()
204
+
205
+ for m in messages:
206
+ role = m.get("role", "")
207
+ content = m.get("content", "")
208
+
209
+ # Skip system/soul messages and empty ones
210
+ if role == "system" or not content:
211
+ continue
212
+
213
+ is_user = (role == "user")
214
+ display_text = content
215
+ if role == "assistant" and m.get("thinking"):
216
+ display_text = f"*[Pensamiento]*\n{m['thinking']}\n\n{content}"
217
+ elif role == "thinking":
218
+ display_text = f"*[Pensamiento]*\n{content}"
219
+ is_user = False
220
+
221
+ bubble, txt = self._create_bubble(
222
+ text=display_text,
223
+ is_user=is_user,
224
+ timestamp=m.get("timestamp", datetime.now().strftime("%H:%M")),
225
+ )
226
+ anchor = "e" if is_user else "w"
227
+ bubble.pack(anchor=anchor, padx=12, pady=(6, 2), fill="x")
228
+ self._message_frames.append(bubble)
229
+
230
+ # Only one scroll at the end (safe access)
231
+ def _final_scroll():
232
+ try:
233
+ canvas = getattr(self._scroll, "_parent_canvas", None)
234
+ if canvas:
235
+ canvas.update_idletasks()
236
+ canvas.yview_moveto(1.0)
237
+ except: pass
238
+ self.after(100, _final_scroll)
239
+
240
+ def apply_theme(self) -> None:
241
+ """Re-apply current theme colors to existing widgets."""
242
+ t = get_theme()
243
+ global BG_COLOR, CARD_COLOR, ACCENT_COLOR, ACCENT_HOVER, TEXT_COLOR, TEXT_DIM
244
+ global USER_BUBBLE, ASSISTANT_BUBBLE, CODE_BG, BORDER_COLOR
245
+ global TAG_BOLD_COLOR, TAG_CODE_COLOR, TAG_ITALIC_COLOR
246
+ BG_COLOR = t["bg"]
247
+ CARD_COLOR = t["card"]
248
+ ACCENT_COLOR = t["accent"]
249
+ ACCENT_HOVER = t["accent_hover"]
250
+ TEXT_COLOR = t["text"]
251
+ TEXT_DIM = t["dim"]
252
+ USER_BUBBLE = t["user_bubble"]
253
+ ASSISTANT_BUBBLE = t["assistant_bubble"]
254
+ CODE_BG = t["code_bg"]
255
+ BORDER_COLOR = t["border"]
256
+ TAG_BOLD_COLOR = t["text"]
257
+ TAG_CODE_COLOR = t["dim"]
258
+ TAG_ITALIC_COLOR = t["dim"]
259
+
260
+ self.configure(fg_color="transparent")
261
+ self._scroll.configure(
262
+ fg_color=t["bg"],
263
+ scrollbar_fg_color=t["border"],
264
+ scrollbar_button_color=t["accent"],
265
+ scrollbar_button_hover_color=t["accent_hover"],
266
+ )
267
+ self._container.configure(fg_color=t["bg"])
268
+ self._thinking_frame.configure(fg_color=t["assistant_bubble"])
269
+ self._thinking_label.configure(text_color=t["accent"])
270
+ # Recolor existing message bubbles
271
+ for outer in self._message_frames:
272
+ if hasattr(outer, "_bubble"):
273
+ new_fg = t["user_bubble"] if outer._is_user else t["assistant_bubble"]
274
+ outer._bubble.configure(fg_color=new_fg)
275
+ for child in outer._bubble.winfo_children():
276
+ if isinstance(child, ctk.CTkTextbox):
277
+ child.configure(text_color=t["text"])
278
+ elif isinstance(child, ctk.CTkLabel):
279
+ child.configure(text_color=t["dim"])
280
+
281
+ self.update_idletasks()
282
+
283
+ # ── Internal helpers ────────────────────────────────────────────────────
284
+
285
+ def _hide_thinking(self) -> None:
286
+ self._thinking_frame.pack_forget()
287
+
288
+ def _finish_current_stream(self) -> None:
289
+ """Lock the current bubble so future appends start a new one."""
290
+ if self._current_bubble_text is not None:
291
+ self._current_bubble_text = None
292
+ self._current_bubble_is_user = False
293
+ self._current_bubble_outer = None
294
+
295
+ def _scroll_to_bottom(self) -> None:
296
+ """Auto-scroll to the latest message."""
297
+ def _do_scroll():
298
+ try:
299
+ canvas = getattr(self._scroll, "_parent_canvas", None)
300
+ if canvas:
301
+ canvas.yview_moveto(1.0)
302
+ else:
303
+ # fallback for different customtkinter versions
304
+ self._scroll._scrollbar._command("moveto", 1.0)
305
+ except Exception:
306
+ pass
307
+ self.after(50, _do_scroll)
308
+
309
+ def _create_bubble(
310
+ self, text: str, is_user: bool, timestamp: str
311
+ ) -> tuple[ctk.CTkFrame, ctk.CTkTextbox]:
312
+ """Create a message bubble frame with formatted text widget inside."""
313
+ fg = USER_BUBBLE if is_user else ASSISTANT_BUBBLE
314
+ anchor = "e" if is_user else "w"
315
+
316
+ # Outer frame for alignment
317
+ outer = ctk.CTkFrame(self._container, fg_color="transparent")
318
+ outer.grid_columnconfigure(0, weight=1 if not is_user else 0)
319
+ outer.grid_columnconfigure(1, weight=0 if not is_user else 1)
320
+
321
+ # Bubble frame
322
+ bubble = ctk.CTkFrame(outer, fg_color=fg, corner_radius=14)
323
+ outer._bubble = bubble
324
+ outer._is_user = is_user
325
+ if is_user:
326
+ bubble.grid(row=0, column=1, sticky="e", padx=(80, 0))
327
+ else:
328
+ bubble.grid(row=0, column=0, sticky="w", padx=(0, 80))
329
+
330
+ # Timestamp label
331
+ ts_label = ctk.CTkLabel(
332
+ bubble,
333
+ text=timestamp,
334
+ font=FONT_TIMESTAMP,
335
+ text_color=TEXT_DIM,
336
+ )
337
+ ts_label.pack(anchor="nw" if not is_user else "ne", padx=12, pady=(6, 0))
338
+
339
+ # Text widget for formatted content
340
+ txt = ctk.CTkTextbox(
341
+ bubble,
342
+ fg_color="transparent",
343
+ text_color=TEXT_COLOR,
344
+ font=FONT_NORMAL,
345
+ wrap="word",
346
+ state="disabled",
347
+ activate_scrollbars=False,
348
+ height=20,
349
+ width=500, # Initial width
350
+ corner_radius=0,
351
+ )
352
+ txt.pack(fill="both", expand=True, padx=12, pady=(2, 10))
353
+ txt.configure(state="normal")
354
+ self._render_formatted(txt, text)
355
+ txt.configure(state="disabled")
356
+
357
+ # Dynamic height adjustment
358
+ self._adjust_text_height(txt)
359
+
360
+ return outer, txt
361
+
362
+ def _adjust_text_height(self, txt: ctk.CTkTextbox) -> None:
363
+ """Dynamic height based on content lines."""
364
+ content = txt.get("1.0", "end-1c")
365
+ if not content:
366
+ txt.configure(height=40)
367
+ return
368
+ # Improved line counting: detect actual text lines
369
+ # and factor in wrapping (approx match to bubble width)
370
+ # We increase the chars-per-line to 65 since we made it wider
371
+ wrapped = sum((len(line) // 65) + 1 for line in content.split("\n"))
372
+ # Add a small buffer to prevent scrollbars (26px per line is safer than 24)
373
+ height = max(40, min(1200, wrapped * 26 + 10))
374
+ txt.configure(height=height)
375
+
376
+ def _render_formatted(self, txt: ctk.CTkTextbox, text: str) -> None:
377
+ """Parse and insert markdown-like formatting into a CTkTextbox.
378
+
379
+ NOTE: CTkTextbox forbids 'font' in tag_config, so we use colors only.
380
+ """
381
+ txt.delete("1.0", "end")
382
+ try:
383
+ # CTkTextbox does not allow 'font' in tag_config — use foreground only
384
+ txt.tag_config("bold", foreground=TAG_BOLD_COLOR)
385
+ txt.tag_config("code", foreground=TAG_CODE_COLOR)
386
+ txt.tag_config("code_block", foreground=TAG_CODE_COLOR)
387
+ txt.tag_config("italic", foreground=TAG_ITALIC_COLOR)
388
+ except Exception:
389
+ # Fallback: tags unsupported, render plain text
390
+ txt.insert("end", text)
391
+ return
392
+
393
+ # Simple regex-based parsing
394
+ # Process code blocks first (```...```)
395
+ parts = re.split(r"(```(?:[\w]*\n)?[\s\S]*?```)", text)
396
+
397
+ idx = 0
398
+ for part in parts:
399
+ if part.startswith("```") and part.endswith("```"):
400
+ # Extract language and code
401
+ inner = part[3:-3]
402
+ lang = ""
403
+ if "\n" in inner:
404
+ first, rest = inner.split("\n", 1)
405
+ first = first.strip()
406
+ if first and " " not in first:
407
+ lang = first
408
+ inner = rest
409
+ else:
410
+ inner = first + "\n" + rest if rest else first
411
+ self._insert_code_block(txt, inner.strip(), lang)
412
+ else:
413
+ self._insert_inline_formatted(txt, part)
414
+ idx += 1
415
+
416
+ def _insert_code_block(self, txt: ctk.CTkTextbox, code: str, lang: str = "") -> None:
417
+ """Insert a code block with a dark background and copy button."""
418
+ # Code block frame (we use text widget bg color simulation via tag)
419
+ txt.insert("end", "\n")
420
+
421
+ if lang:
422
+ txt.insert("end", f" {lang}\n", "code_block")
423
+ txt.insert("end", code, "code_block")
424
+ txt.insert("end", "\n")
425
+
426
+ # We can't easily add a real button inside CTkTextbox,
427
+ # so we append a small copy hint at the end of the block
428
+ txt.insert("end", " [📋 copiar]\n", "code_block")
429
+
430
+ def _insert_inline_formatted(self, txt: ctk.CTkTextbox, text: str) -> None:
431
+ """Process inline bold, italic, and inline code within a text segment."""
432
+ # Pattern order: bold **text**, italic *text*, inline `code`
433
+ pattern = re.compile(r"(\*\*.*?\*\*|\*.*?\*|`.+?`)")
434
+ pos = 0
435
+ for m in pattern.finditer(text):
436
+ # Text before match
437
+ if m.start() > pos:
438
+ txt.insert("end", text[pos:m.start()])
439
+ token = m.group(0)
440
+ if token.startswith("**") and token.endswith("**"):
441
+ txt.insert("end", token[2:-2], "bold")
442
+ elif token.startswith("*") and token.endswith("*"):
443
+ txt.insert("end", token[1:-1], "italic")
444
+ elif token.startswith("`") and token.endswith("`"):
445
+ txt.insert("end", token[1:-1], "code")
446
+ pos = m.end()
447
+ if pos < len(text):
448
+ txt.insert("end", text[pos:])