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
dulus_mcp/tools.py ADDED
@@ -0,0 +1,131 @@
1
+ """Register MCP tools into the central tool_registry.
2
+
3
+ Importing this module:
4
+ 1. Loads .mcp.json config files
5
+ 2. Connects to each configured MCP server
6
+ 3. Discovers tools from each server
7
+ 4. Registers each tool into tool_registry so Claude can use them
8
+
9
+ MCP tool qualified names follow the pattern:
10
+ mcp__<server_name>__<tool_name>
11
+
12
+ This matches the Claude Code convention (mcp__serverName__toolName).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import threading
17
+ from typing import Dict, List, Optional
18
+
19
+ from tool_registry import ToolDef, register_tool
20
+ from .client import MCPClient, MCPManager, get_mcp_manager
21
+ from .config import load_mcp_configs
22
+ from .types import MCPServerConfig, MCPTool
23
+
24
+
25
+ # ── Global state ──────────────────────────────────────────────────────────────
26
+
27
+ _initialized = False
28
+ _init_lock = threading.Lock()
29
+ _connect_errors: Dict[str, Optional[str]] = {} # server → error or None
30
+
31
+
32
+ # ── Tool wrapper ──────────────────────────────────────────────────────────────
33
+
34
+ def _make_mcp_func(qualified_name: str):
35
+ """Return a tool func that calls the MCP server for a given qualified name."""
36
+ def _mcp_tool(params: dict, config: dict) -> str:
37
+ mgr = get_mcp_manager()
38
+ try:
39
+ return mgr.call_tool(qualified_name, params)
40
+ except Exception as e:
41
+ return f"Error calling MCP tool '{qualified_name}': {e}"
42
+ return _mcp_tool
43
+
44
+
45
+ def _register_tool(tool: MCPTool) -> None:
46
+ td = ToolDef(
47
+ name=tool.qualified_name,
48
+ schema=tool.to_tool_schema(),
49
+ func=_make_mcp_func(tool.qualified_name),
50
+ read_only=tool.read_only,
51
+ concurrent_safe=False,
52
+ )
53
+ register_tool(td)
54
+
55
+
56
+ # ── Initialization ────────────────────────────────────────────────────────────
57
+
58
+ def initialize_mcp(verbose: bool = False) -> Dict[str, Optional[str]]:
59
+ """Load configs, connect servers, register tools. Idempotent.
60
+
61
+ Returns a dict of {server_name: error_message_or_None}.
62
+ """
63
+ global _initialized, _connect_errors
64
+
65
+ with _init_lock:
66
+ if _initialized:
67
+ return _connect_errors
68
+
69
+ configs = load_mcp_configs()
70
+ if not configs:
71
+ _initialized = True
72
+ return {}
73
+
74
+ mgr = get_mcp_manager()
75
+ for cfg in configs.values():
76
+ mgr.add_server(cfg)
77
+
78
+ errors = mgr.connect_all()
79
+ _connect_errors = errors
80
+
81
+ # Register tools from all successfully connected servers
82
+ for client in mgr.list_servers():
83
+ if client.state.value == "connected":
84
+ for tool in client._tools:
85
+ _register_tool(tool)
86
+ if verbose:
87
+ print(f"[MCP] {client.config.name}: {len(client._tools)} tool(s) registered")
88
+
89
+ _initialized = True
90
+ return errors
91
+
92
+
93
+ def reload_mcp() -> Dict[str, Optional[str]]:
94
+ """Force a full reload: re-read configs, reconnect, re-register all tools."""
95
+ global _initialized
96
+ with _init_lock:
97
+ _initialized = False
98
+ return initialize_mcp()
99
+
100
+
101
+ def refresh_server(server_name: str) -> Optional[str]:
102
+ """Reconnect a single server and re-register its tools. Returns error or None."""
103
+ mgr = get_mcp_manager()
104
+ client = next((c for c in mgr.list_servers() if c.config.name == server_name), None)
105
+ if client is None:
106
+ return f"Server '{server_name}' not configured"
107
+ try:
108
+ mgr.reload_server(server_name)
109
+ for tool in client._tools:
110
+ _register_tool(tool)
111
+ return None
112
+ except Exception as e:
113
+ return str(e)
114
+
115
+
116
+ def get_connect_errors() -> Dict[str, Optional[str]]:
117
+ return dict(_connect_errors)
118
+
119
+
120
+ # ── Auto-initialize on import ─────────────────────────────────────────────────
121
+ # Connect in a background thread so startup is not blocked.
122
+
123
+ def _background_init():
124
+ try:
125
+ initialize_mcp()
126
+ except Exception:
127
+ pass
128
+
129
+
130
+ _bg_thread = threading.Thread(target=_background_init, daemon=True)
131
+ _bg_thread.start()
dulus_mcp/types.py ADDED
@@ -0,0 +1,124 @@
1
+ """MCP type definitions: server configs, tool descriptors, connection state."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from enum import Enum
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ # ── Server config ─────────────────────────────────────────────────────────────
10
+
11
+ class MCPTransport(str, Enum):
12
+ STDIO = "stdio"
13
+ SSE = "sse"
14
+ HTTP = "http"
15
+ WS = "ws"
16
+
17
+
18
+ @dataclass
19
+ class MCPServerConfig:
20
+ """Configuration for a single MCP server.
21
+
22
+ Mirrors the Claude Code schema (types.ts) for the two most useful transports.
23
+
24
+ Stdio example:
25
+ {"type": "stdio", "command": "uvx", "args": ["mcp-server-git"]}
26
+
27
+ SSE/HTTP example:
28
+ {"type": "sse", "url": "http://localhost:8080/sse",
29
+ "headers": {"Authorization": "Bearer token"}}
30
+ """
31
+ name: str # logical name in mcpServers dict
32
+ transport: MCPTransport = MCPTransport.STDIO
33
+ # stdio fields
34
+ command: str = ""
35
+ args: List[str] = field(default_factory=list)
36
+ env: Dict[str, str] = field(default_factory=dict)
37
+ # sse / http / ws fields
38
+ url: str = ""
39
+ headers: Dict[str, str] = field(default_factory=dict)
40
+ # optional
41
+ timeout: int = 30 # seconds per request
42
+ disabled: bool = False
43
+
44
+ @classmethod
45
+ def from_dict(cls, name: str, d: dict) -> "MCPServerConfig":
46
+ transport_str = d.get("type", "stdio").lower()
47
+ try:
48
+ transport = MCPTransport(transport_str)
49
+ except ValueError:
50
+ transport = MCPTransport.STDIO
51
+ return cls(
52
+ name=name,
53
+ transport=transport,
54
+ command=d.get("command", ""),
55
+ args=d.get("args", []),
56
+ env=d.get("env", {}),
57
+ url=d.get("url", ""),
58
+ headers=d.get("headers", {}),
59
+ timeout=int(d.get("timeout", 30)),
60
+ disabled=bool(d.get("disabled", False)),
61
+ )
62
+
63
+
64
+ # ── Connection state ──────────────────────────────────────────────────────────
65
+
66
+ class MCPServerState(str, Enum):
67
+ DISCONNECTED = "disconnected"
68
+ CONNECTING = "connecting"
69
+ CONNECTED = "connected"
70
+ ERROR = "error"
71
+
72
+
73
+ # ── Tool descriptor ───────────────────────────────────────────────────────────
74
+
75
+ @dataclass
76
+ class MCPTool:
77
+ """A tool provided by an MCP server, ready to register in tool_registry."""
78
+ server_name: str
79
+ tool_name: str # original name from server
80
+ qualified_name: str # mcp__<server>__<tool>
81
+ description: str
82
+ input_schema: Dict[str, Any] # JSON Schema object
83
+ read_only: bool = False # from annotations.readOnlyHint
84
+
85
+ def to_tool_schema(self) -> dict:
86
+ """Convert to the schema format expected by the Claude API."""
87
+ return {
88
+ "name": self.qualified_name,
89
+ "description": f"[MCP:{self.server_name}] {self.description}",
90
+ "input_schema": self.input_schema or {"type": "object", "properties": {}},
91
+ }
92
+
93
+
94
+ # ── JSON-RPC helpers ──────────────────────────────────────────────────────────
95
+
96
+ def make_request(method: str, params: Optional[dict], req_id: int) -> dict:
97
+ msg: dict = {"jsonrpc": "2.0", "id": req_id, "method": method}
98
+ if params is not None:
99
+ msg["params"] = params
100
+ return msg
101
+
102
+
103
+ def make_notification(method: str, params: Optional[dict] = None) -> dict:
104
+ msg: dict = {"jsonrpc": "2.0", "method": method}
105
+ if params is not None:
106
+ msg["params"] = params
107
+ return msg
108
+
109
+
110
+ MCP_PROTOCOL_VERSION = "2024-11-05"
111
+
112
+ CLIENT_INFO = {
113
+ "name": "dulus",
114
+ "version": "1.0.0",
115
+ }
116
+
117
+ INIT_PARAMS = {
118
+ "protocolVersion": MCP_PROTOCOL_VERSION,
119
+ "capabilities": {
120
+ "tools": {},
121
+ "roots": {"listChanged": False},
122
+ },
123
+ "clientInfo": CLIENT_INFO,
124
+ }
gui/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Dulus GUI package — professional desktop interface."""
2
+ from gui.main_window import DulusMainWindow
3
+ from gui.chat_widget import ChatWidget
4
+ from gui.agent_bridge import DulusBridge
5
+ from gui.sidebar import DulusSidebar
6
+ from gui.settings_dialog import SettingsDialog
7
+ from gui.tool_panel import ToolPanel
8
+ from gui.tasks_view import TasksView
9
+
10
+ __all__ = [
11
+ "DulusMainWindow",
12
+ "ChatWidget",
13
+ "DulusBridge",
14
+ "DulusSidebar",
15
+ "SettingsDialog",
16
+ "ToolPanel",
17
+ "TasksView",
18
+ ]
gui/agent_bridge.py ADDED
@@ -0,0 +1,283 @@
1
+ """Bridge between the GUI and Dulus's core agent engine.
2
+
3
+ Handles AgentState, config, threaded execution, MemPalace injection,
4
+ skill injection, and permission requests. Based on Nayeli's design.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import queue
9
+ import threading
10
+ from pathlib import Path
11
+
12
+ from agent import (
13
+ AgentState,
14
+ run,
15
+ TextChunk,
16
+ ThinkingChunk,
17
+ ToolStart,
18
+ ToolEnd,
19
+ TurnDone,
20
+ PermissionRequest,
21
+ )
22
+ from config import load_config
23
+ from context import build_system_prompt
24
+ from common import sanitize_text
25
+ from gui.session_utils import save_session
26
+
27
+ # Ensure all tool modules are loaded so registration side-effects run
28
+ import tools as _tools_init
29
+ import memory.tools as _mem_tools_init
30
+ import multi_agent.tools as _ma_tools_init
31
+ import skill.tools as _sk_tools_init
32
+ import dulus_mcp.tools as _mcp_tools_init
33
+ import task.tools as _task_tools_init
34
+
35
+ try:
36
+ import tmux_tools as _tmux_tools_init
37
+ except Exception:
38
+ pass
39
+
40
+ try:
41
+ from plugin.loader import register_plugin_tools
42
+ register_plugin_tools()
43
+ except Exception:
44
+ pass
45
+
46
+
47
+ class DulusBridge:
48
+ """Thread-safe bridge between GUI and Dulus core.
49
+
50
+ Runs the agent loop in a background thread and streams events
51
+ back to the UI via an internal event queue (poll from GUI thread).
52
+ """
53
+
54
+ def __init__(self, config: dict | None = None):
55
+ self.config = config or load_config()
56
+ self.state = AgentState()
57
+ self._cancelled = threading.Event()
58
+ self._running = True
59
+ self._worker_thread: threading.Thread | None = None
60
+ self._input_queue: queue.Queue[str | None] = queue.Queue()
61
+ self.event_queue: queue.Queue[dict] = queue.Queue()
62
+
63
+ # Permission handling
64
+ self._permission_queue: queue.Queue[bool] = queue.Queue()
65
+
66
+
67
+ # Session ID tracking
68
+ self.session_id: str | None = None
69
+ self.pending_history: list[dict] = []
70
+
71
+ # Skill injection buffer (one-shot, consumed on next message)
72
+ self._skill_inject: str = ""
73
+
74
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
75
+
76
+ def start(self) -> None:
77
+ """Start the background worker thread."""
78
+ if self._worker_thread is None or not self._worker_thread.is_alive():
79
+ self._running = True
80
+ self._worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
81
+ self._worker_thread.start()
82
+
83
+ def stop(self) -> None:
84
+ """Clean shutdown of the bridge worker thread."""
85
+ self._running = False
86
+ self._cancelled.set()
87
+ self._input_queue.put(None)
88
+ if self._worker_thread:
89
+ self._worker_thread.join(timeout=3.0)
90
+
91
+ # ── Public API ────────────────────────────────────────────────────────────
92
+
93
+ def send_message(self, text: str) -> None:
94
+ """Enqueue a user message. Pre-loads pending history if needed."""
95
+ if self.pending_history:
96
+ self.load_session(self.pending_history, self.session_id)
97
+ self.pending_history = []
98
+ self._input_queue.put(text)
99
+
100
+ def stop_generation(self) -> None:
101
+ """Signal the current generation to stop as soon as possible."""
102
+ self._cancelled.set()
103
+
104
+ def grant_permission(self, granted: bool) -> None:
105
+ """Respond to a pending permission request."""
106
+ self._permission_queue.put(granted)
107
+
108
+ def get_context_usage(self) -> tuple[int, int]:
109
+ """Return (tokens_used, token_limit)."""
110
+ used = self.state.total_input_tokens + self.state.total_output_tokens
111
+ limit = self.config.get("max_tokens", 250000)
112
+ return used, limit
113
+
114
+ def save_current_session(self) -> str | None:
115
+ """Manually save the current active state to disk. Returns session_id."""
116
+ if self.state and self.state.messages:
117
+ self.session_id = save_session(self.state, self.config, self.session_id)
118
+ return self.session_id
119
+ return None
120
+
121
+ def clear_session(self) -> None:
122
+ """Reset the agent state (new conversation)."""
123
+ self.state = AgentState()
124
+ self.session_id = None
125
+ self.pending_history = []
126
+
127
+ def load_session(self, messages: list[dict], session_id: str | None = None) -> None:
128
+ """Load a previous session's messages into the current state."""
129
+ self.state = AgentState()
130
+ self.session_id = session_id
131
+ self.pending_history = []
132
+ for m in messages:
133
+ # Preserve all fields (role, content, tool_calls, tool_call_id, etc.)
134
+ self.state.messages.append(m.copy())
135
+
136
+ def inject_skill(self, skill_body: str) -> None:
137
+ """Inject skill context into the next user message (one-shot)."""
138
+ self._skill_inject = skill_body
139
+
140
+ def set_model(self, model: str) -> None:
141
+ """Change the active model."""
142
+ self.config["model"] = model
143
+
144
+ # ── Worker loop ───────────────────────────────────────────────────────────
145
+
146
+ def _worker_loop(self) -> None:
147
+ while self._running:
148
+ try:
149
+ user_message = self._input_queue.get(timeout=0.5)
150
+ if user_message is None:
151
+ continue
152
+ if not isinstance(user_message, str):
153
+ continue
154
+
155
+ self._cancelled.clear()
156
+ self._process_turn(user_message)
157
+
158
+ except queue.Empty:
159
+ continue
160
+ except Exception as exc:
161
+ self._emit("error", message=str(exc))
162
+
163
+ def _process_turn(self, user_message: str) -> None:
164
+ # ── Skill inject (one-shot) ────────────────────────────────────────
165
+ skill_body = self._skill_inject
166
+ self._skill_inject = ""
167
+ if skill_body:
168
+ user_message = (
169
+ "[SKILL CONTEXT — follow these instructions for this turn]\n\n"
170
+ + skill_body
171
+ + "\n\n---\n\n[USER MESSAGE]\n"
172
+ + user_message
173
+ )
174
+
175
+ # ── MemPalace: per-turn memory injection ───────────────────────────
176
+ user_message = self._apply_mempalace(user_message)
177
+
178
+ # Sanitize input
179
+ user_message = sanitize_text(user_message)
180
+
181
+ # Rebuild system prompt each turn (picks up cwd changes, etc.)
182
+ system_prompt = build_system_prompt(self.config)
183
+
184
+ for event in run(
185
+ user_message=user_message,
186
+ state=self.state,
187
+ config=self.config,
188
+ system_prompt=system_prompt,
189
+ cancel_check=lambda: self._cancelled.is_set(),
190
+ ):
191
+ if isinstance(event, TextChunk):
192
+ self._emit("text", text=event.text)
193
+
194
+ elif isinstance(event, ThinkingChunk):
195
+ self._emit("thinking", text=event.text)
196
+
197
+ elif isinstance(event, ToolStart):
198
+ self._emit("tool_start", name=event.name, inputs=event.inputs)
199
+
200
+ elif isinstance(event, ToolEnd):
201
+ self._emit(
202
+ "tool_end",
203
+ name=event.name,
204
+ result=event.result,
205
+ permitted=event.permitted,
206
+ )
207
+
208
+ elif isinstance(event, TurnDone):
209
+ # Auto-save session to disk
210
+ self.session_id = save_session(self.state, self.config, self.session_id)
211
+ self._emit(
212
+ "turn_done",
213
+ input_tokens=event.input_tokens,
214
+ output_tokens=event.output_tokens,
215
+ session_id=self.session_id
216
+ )
217
+
218
+ elif isinstance(event, PermissionRequest):
219
+ self._emit("permission", description=event.description)
220
+ try:
221
+ granted = self._permission_queue.get(timeout=300.0)
222
+ event.granted = bool(granted)
223
+ except queue.Empty:
224
+ event.granted = False
225
+
226
+ def _apply_mempalace(self, user_input: str) -> str:
227
+ """Copy of dulus.py MemPalace injection logic."""
228
+ if not self.config.get("mem_palace", True):
229
+ return user_input
230
+
231
+ # Skip trivial messages so we don't burn tokens on "klk"
232
+ if not user_input or len(user_input.strip()) < 12:
233
+ return user_input
234
+
235
+ _trivial = {
236
+ "hola", "klk", "gracias", "ok", "si", "no", "dale",
237
+ "exit", "quit", "help", "thanks", "bien",
238
+ }
239
+ _first = user_input.strip().lower().split()[0]
240
+ if _first in _trivial:
241
+ return user_input
242
+
243
+ try:
244
+ from memory import find_relevant_memories
245
+
246
+ _q = user_input.strip()[:200]
247
+ _raw_hits = find_relevant_memories(_q, max_results=3)
248
+ if not _raw_hits:
249
+ return user_input
250
+
251
+ _parts = []
252
+ for _i, _h in enumerate(_raw_hits, 1):
253
+ _name = _h.get("name", f"hit_{_i}")
254
+ _desc = _h.get("description", "")
255
+ _body = _h.get("content", "").strip()
256
+ _snip = _body[:300] + ("..." if len(_body) > 300 else "")
257
+ if _desc:
258
+ _parts.append(f"### {_name}\n_{_desc}_\n{_snip}")
259
+ else:
260
+ _parts.append(f"### {_name}\n{_snip}")
261
+
262
+ _hits_str = "\n\n".join(_parts)
263
+ if len(_hits_str) > 2000:
264
+ _hits_str = _hits_str[:2000] + "\n[...truncated]"
265
+
266
+ _inject = (
267
+ "[MemPalace — relevant memories pre-loaded for this turn. "
268
+ "Do NOT re-query unless the user explicitly asks for more. "
269
+ "The answer to the user's question is very likely already "
270
+ "below — read it BEFORE reaching for any tool.]\n\n"
271
+ + _hits_str
272
+ )
273
+ return (
274
+ _inject
275
+ + "\n\n---\n\n[USER MESSAGE]\n"
276
+ + user_input
277
+ )
278
+ except Exception:
279
+ return user_input
280
+
281
+ def _emit(self, event_type: str, **kwargs) -> None:
282
+ """Put an event into the public event queue."""
283
+ self.event_queue.put({"type": event_type, **kwargs})