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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
dulus_gui.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Dulus GUI Entry Point — professional desktop interface.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python dulus_gui.py
|
|
5
|
+
python dulus.py --gui
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import queue
|
|
11
|
+
import sys
|
|
12
|
+
import traceback
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import customtkinter as ctk
|
|
20
|
+
except ImportError:
|
|
21
|
+
print("Error: customtkinter is required. Install: pip install customtkinter")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
from config import load_config
|
|
25
|
+
from gui import DulusMainWindow, DulusBridge
|
|
26
|
+
from gui.themes import get_theme, set_theme
|
|
27
|
+
from gui.session_utils import scan_sessions
|
|
28
|
+
|
|
29
|
+
# Session directories
|
|
30
|
+
from config import SESSIONS_DIR, DAILY_DIR, MR_SESSION_DIR
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _center_on_parent(dialog: ctk.CTkToplevel, parent: ctk.CTk) -> None:
|
|
37
|
+
"""Center a Toplevel over its parent window."""
|
|
38
|
+
dialog.update_idletasks()
|
|
39
|
+
pw, ph = parent.winfo_width(), parent.winfo_height()
|
|
40
|
+
px, py = parent.winfo_x(), parent.winfo_y()
|
|
41
|
+
dw, dh = dialog.winfo_width(), dialog.winfo_height()
|
|
42
|
+
x = px + (pw - dw) // 2
|
|
43
|
+
y = py + (ph - dh) // 2
|
|
44
|
+
dialog.geometry(f"+{x}+{y}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _PermissionDialog(ctk.CTkToplevel):
|
|
48
|
+
"""Modal permission request dialog centered on the parent."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, parent: ctk.CTk, description: str, on_resolve: Callable[[bool], None]):
|
|
51
|
+
super().__init__(parent)
|
|
52
|
+
self._on_resolve = on_resolve
|
|
53
|
+
self._create_ui(description)
|
|
54
|
+
self._setup_window(parent)
|
|
55
|
+
|
|
56
|
+
def _create_ui(self, description: str) -> None:
|
|
57
|
+
t = get_theme()
|
|
58
|
+
self.configure(fg_color=t["bg"])
|
|
59
|
+
|
|
60
|
+
ctk.CTkLabel(
|
|
61
|
+
self,
|
|
62
|
+
text="🔒 Permission Required",
|
|
63
|
+
font=("Segoe UI", 16, "bold"),
|
|
64
|
+
text_color=t["accent"],
|
|
65
|
+
).pack(pady=(20, 10))
|
|
66
|
+
|
|
67
|
+
ctk.CTkLabel(
|
|
68
|
+
self,
|
|
69
|
+
text=description,
|
|
70
|
+
font=("Segoe UI", 12),
|
|
71
|
+
text_color=t["text"],
|
|
72
|
+
wraplength=450,
|
|
73
|
+
).pack(pady=10, padx=20)
|
|
74
|
+
|
|
75
|
+
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
|
|
76
|
+
btn_frame.pack(pady=15)
|
|
77
|
+
|
|
78
|
+
ctk.CTkButton(
|
|
79
|
+
btn_frame,
|
|
80
|
+
text="Deny",
|
|
81
|
+
font=("Segoe UI", 12, "bold"),
|
|
82
|
+
fg_color=t["border"],
|
|
83
|
+
hover_color=t["error"],
|
|
84
|
+
width=100,
|
|
85
|
+
command=self._deny,
|
|
86
|
+
).pack(side="left", padx=10)
|
|
87
|
+
|
|
88
|
+
ctk.CTkButton(
|
|
89
|
+
btn_frame,
|
|
90
|
+
text="Allow",
|
|
91
|
+
font=("Segoe UI", 12, "bold"),
|
|
92
|
+
fg_color=t["accent"],
|
|
93
|
+
hover_color=t["accent_hover"],
|
|
94
|
+
width=100,
|
|
95
|
+
command=self._allow,
|
|
96
|
+
).pack(side="left", padx=10)
|
|
97
|
+
|
|
98
|
+
def _setup_window(self, parent: ctk.CTk) -> None:
|
|
99
|
+
self.title("Permission Required")
|
|
100
|
+
self.geometry("500x220")
|
|
101
|
+
self.transient(parent)
|
|
102
|
+
self.grab_set()
|
|
103
|
+
self.resizable(False, False)
|
|
104
|
+
_center_on_parent(self, parent)
|
|
105
|
+
|
|
106
|
+
def _allow(self) -> None:
|
|
107
|
+
self.destroy()
|
|
108
|
+
self._on_resolve(True)
|
|
109
|
+
|
|
110
|
+
def _deny(self) -> None:
|
|
111
|
+
self.destroy()
|
|
112
|
+
self._on_resolve(False)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Main launcher ─────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# _scan_sessions refactored to gui/session_utils.py
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def launch_gui(config: dict | None = None, initial_prompt: str | None = None) -> None:
|
|
122
|
+
"""Launch the Dulus desktop GUI.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
config: Dulus configuration dict (loaded from disk if None).
|
|
126
|
+
initial_prompt: Optional initial user message to send on startup.
|
|
127
|
+
"""
|
|
128
|
+
cfg = config or load_config()
|
|
129
|
+
|
|
130
|
+
# Theme
|
|
131
|
+
ctk.set_appearance_mode(cfg.get("appearance", "dark"))
|
|
132
|
+
ctk.set_default_color_theme("dark-blue")
|
|
133
|
+
set_theme(cfg.get("theme", "midnight"))
|
|
134
|
+
t = get_theme()
|
|
135
|
+
|
|
136
|
+
# Create GUI window FIRST so user sees something immediately
|
|
137
|
+
app = DulusMainWindow()
|
|
138
|
+
app.set_model(cfg.get("model", "default"))
|
|
139
|
+
|
|
140
|
+
# Create bridge (but don't start yet)
|
|
141
|
+
bridge = DulusBridge(config=cfg)
|
|
142
|
+
|
|
143
|
+
# Wire bridge into sidebar so context bar / model list work
|
|
144
|
+
app.sidebar.bridge = bridge
|
|
145
|
+
|
|
146
|
+
# ── Wire callbacks ────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def _on_send(text: str) -> None:
|
|
149
|
+
if text.strip():
|
|
150
|
+
# NOTE: message bubble is already added by main_window._on_send_click
|
|
151
|
+
app.show_thinking()
|
|
152
|
+
bridge.send_message(text)
|
|
153
|
+
|
|
154
|
+
def _on_new_chat() -> None:
|
|
155
|
+
# Save current session if active (it will return a new ID if it was new)
|
|
156
|
+
sid = bridge.save_current_session()
|
|
157
|
+
if sid:
|
|
158
|
+
# If a new session was created, refresh sidebar to show it
|
|
159
|
+
app.set_sessions(scan_sessions())
|
|
160
|
+
|
|
161
|
+
app.hide_thinking()
|
|
162
|
+
app.chat.clear_chat()
|
|
163
|
+
bridge.clear_session()
|
|
164
|
+
app.set_active_session(None)
|
|
165
|
+
app.sidebar.update_context_bar()
|
|
166
|
+
app.set_status("Listo", t["success"])
|
|
167
|
+
|
|
168
|
+
def _on_session_select(session_id: str) -> None:
|
|
169
|
+
# Save current session before switching to ensure no loss
|
|
170
|
+
sid = bridge.save_current_session()
|
|
171
|
+
|
|
172
|
+
# If we were in a new chat that just got saved, refresh sidebar to show it
|
|
173
|
+
if sid:
|
|
174
|
+
app.set_sessions(scan_sessions())
|
|
175
|
+
|
|
176
|
+
app.hide_thinking()
|
|
177
|
+
|
|
178
|
+
# 1. Use cached data from sidebar for instant switching
|
|
179
|
+
session_data = app.sidebar._session_cache.get(session_id)
|
|
180
|
+
if not session_data:
|
|
181
|
+
# Fallback to scanning if cache missed (rare)
|
|
182
|
+
for s in scan_sessions():
|
|
183
|
+
if s["id"] == session_id:
|
|
184
|
+
session_data = s
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
if not session_data:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# 2. Update UI instantly (fluid)
|
|
191
|
+
messages = session_data.get("messages", [])
|
|
192
|
+
app.chat.load_messages(messages)
|
|
193
|
+
|
|
194
|
+
# 3. Defer bridge loading until first message (user request)
|
|
195
|
+
bridge.pending_history = messages
|
|
196
|
+
bridge.session_id = session_id
|
|
197
|
+
# Important: clear actual AI state so it's fresh until sync
|
|
198
|
+
from agent import AgentState
|
|
199
|
+
bridge.state = AgentState()
|
|
200
|
+
|
|
201
|
+
app.set_active_session(session_id)
|
|
202
|
+
app.sidebar.update_context_bar()
|
|
203
|
+
app.set_status("Sesión lista (Contexto diferido)", t["success"])
|
|
204
|
+
|
|
205
|
+
def _on_settings() -> None:
|
|
206
|
+
from gui.settings_dialog import SettingsDialog
|
|
207
|
+
SettingsDialog(app, cfg)
|
|
208
|
+
|
|
209
|
+
def _on_model_change(model: str) -> None:
|
|
210
|
+
bridge.set_model(model)
|
|
211
|
+
app.set_model(model)
|
|
212
|
+
|
|
213
|
+
app.on_send = _on_send
|
|
214
|
+
app.on_new_chat = _on_new_chat
|
|
215
|
+
app.sidebar.on_settings = _on_settings
|
|
216
|
+
app.on_model_change = _on_model_change
|
|
217
|
+
app.on_session_select = _on_session_select
|
|
218
|
+
|
|
219
|
+
# Load existing sessions into sidebar
|
|
220
|
+
app.set_sessions(scan_sessions())
|
|
221
|
+
app.sidebar._refresh_model_list()
|
|
222
|
+
app.sidebar.update_context_bar()
|
|
223
|
+
|
|
224
|
+
# ── Permission dialog handling ────────────────────────────────────────────
|
|
225
|
+
_perm_dialog: _PermissionDialog | None = None
|
|
226
|
+
|
|
227
|
+
def _close_perm() -> None:
|
|
228
|
+
nonlocal _perm_dialog
|
|
229
|
+
if _perm_dialog is not None:
|
|
230
|
+
_perm_dialog.destroy()
|
|
231
|
+
_perm_dialog = None
|
|
232
|
+
|
|
233
|
+
def _resolve_perm(granted: bool) -> None:
|
|
234
|
+
_close_perm()
|
|
235
|
+
bridge.grant_permission(granted)
|
|
236
|
+
|
|
237
|
+
def _show_perm(description: str) -> None:
|
|
238
|
+
nonlocal _perm_dialog
|
|
239
|
+
_close_perm()
|
|
240
|
+
_perm_dialog = _PermissionDialog(app, description, _resolve_perm)
|
|
241
|
+
|
|
242
|
+
# ── Event polling loop ────────────────────────────────────────────────────
|
|
243
|
+
def _poll_events() -> None:
|
|
244
|
+
if not app.winfo_exists():
|
|
245
|
+
return # App destroyed, stop polling
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
while True:
|
|
249
|
+
event = bridge.event_queue.get_nowait()
|
|
250
|
+
etype = event.get("type")
|
|
251
|
+
|
|
252
|
+
if etype == "text":
|
|
253
|
+
app.add_assistant_chunk(event.get("text", ""))
|
|
254
|
+
|
|
255
|
+
elif etype == "thinking":
|
|
256
|
+
app.show_thinking()
|
|
257
|
+
|
|
258
|
+
elif etype == "tool_start":
|
|
259
|
+
app.add_tool_call(event.get("name", "tool"), "running")
|
|
260
|
+
|
|
261
|
+
elif etype == "tool_end":
|
|
262
|
+
app.add_tool_call(event.get("name", ""), "done")
|
|
263
|
+
|
|
264
|
+
elif etype == "turn_done":
|
|
265
|
+
app.hide_thinking()
|
|
266
|
+
itok = event.get("input_tokens", 0)
|
|
267
|
+
otok = event.get("output_tokens", 0)
|
|
268
|
+
app.set_status(f"Listo (+{itok}/{otok} tok)", t["success"])
|
|
269
|
+
|
|
270
|
+
# Refresh sessions list to show the newly saved session (with its title)
|
|
271
|
+
app.set_sessions(scan_sessions())
|
|
272
|
+
if event.get("session_id"):
|
|
273
|
+
app.set_active_session(event.get("session_id"))
|
|
274
|
+
|
|
275
|
+
elif etype == "permission":
|
|
276
|
+
_show_perm(event.get("description", ""))
|
|
277
|
+
|
|
278
|
+
elif etype == "error":
|
|
279
|
+
app.hide_thinking()
|
|
280
|
+
app.chat.add_assistant_message(
|
|
281
|
+
f"**Error:** {event.get('message', 'Unknown error')}"
|
|
282
|
+
)
|
|
283
|
+
app.set_status("Error", t["error"])
|
|
284
|
+
|
|
285
|
+
except queue.Empty:
|
|
286
|
+
pass
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
# Log to file so we know what crashed the UI
|
|
289
|
+
try:
|
|
290
|
+
with open("gui_error.log", "a", encoding="utf-8") as f:
|
|
291
|
+
f.write(f"\n[{datetime.datetime.now()}] POLL ERROR: {exc}\n")
|
|
292
|
+
traceback.print_exc(file=f)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
finally:
|
|
296
|
+
# ALWAYS reschedule — if we don't, the GUI stops responding
|
|
297
|
+
if app.winfo_exists():
|
|
298
|
+
app.after(50, _poll_events)
|
|
299
|
+
|
|
300
|
+
app.after(50, _poll_events)
|
|
301
|
+
|
|
302
|
+
# ── Start bridge AFTER UI is ready ────────────────────────────────────────
|
|
303
|
+
try:
|
|
304
|
+
bridge.start()
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
app.chat.add_assistant_message(f"**Fatal:** Could not start Dulus bridge: {exc}")
|
|
307
|
+
app.set_status("Fatal error", t["error"])
|
|
308
|
+
|
|
309
|
+
# ── Initial prompt ────────────────────────────────────────────────────────
|
|
310
|
+
if initial_prompt:
|
|
311
|
+
app.chat.add_user_message(initial_prompt)
|
|
312
|
+
bridge.send_message(initial_prompt)
|
|
313
|
+
app.show_thinking()
|
|
314
|
+
|
|
315
|
+
# ── Cleanup ───────────────────────────────────────────────────────────────
|
|
316
|
+
def _on_close() -> None:
|
|
317
|
+
bridge.stop()
|
|
318
|
+
app.destroy()
|
|
319
|
+
|
|
320
|
+
app.protocol("WM_DELETE_WINDOW", _on_close)
|
|
321
|
+
app.run()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def main() -> None:
|
|
325
|
+
"""CLI entry point."""
|
|
326
|
+
cfg = load_config()
|
|
327
|
+
launch_gui(config=cfg)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
main()
|
dulus_mcp/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""mcp package — Model Context Protocol client for dulus.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
MCP servers are configured in one of two JSON files:
|
|
6
|
+
|
|
7
|
+
~/.dulus/mcp.json (user-level, all projects)
|
|
8
|
+
.mcp.json (project-level, current dir, overrides user)
|
|
9
|
+
|
|
10
|
+
Format:
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"my-git-server": {
|
|
14
|
+
"type": "stdio",
|
|
15
|
+
"command": "uvx",
|
|
16
|
+
"args": ["mcp-server-git"]
|
|
17
|
+
},
|
|
18
|
+
"my-remote": {
|
|
19
|
+
"type": "sse",
|
|
20
|
+
"url": "http://localhost:8080/sse"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Supported transports:
|
|
26
|
+
stdio — spawn a local subprocess (most common)
|
|
27
|
+
sse — HTTP Server-Sent Events stream
|
|
28
|
+
http — plain HTTP POST (Streamable HTTP transport)
|
|
29
|
+
|
|
30
|
+
MCP tools are automatically discovered on startup and registered into the
|
|
31
|
+
tool_registry under the name mcp__<server>__<tool>.
|
|
32
|
+
Claude can invoke them just like built-in tools.
|
|
33
|
+
"""
|
|
34
|
+
from .types import MCPServerConfig, MCPTool, MCPServerState, MCPTransport # noqa: F401
|
|
35
|
+
from .client import MCPClient, MCPManager, get_mcp_manager # noqa: F401
|
|
36
|
+
from .config import ( # noqa: F401
|
|
37
|
+
load_mcp_configs,
|
|
38
|
+
save_user_mcp_config,
|
|
39
|
+
add_server_to_user_config,
|
|
40
|
+
remove_server_from_user_config,
|
|
41
|
+
list_config_files,
|
|
42
|
+
)
|
|
43
|
+
from .tools import initialize_mcp, reload_mcp, refresh_server # noqa: F401
|