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
@@ -0,0 +1,501 @@
1
+ """Threaded sub-agent system for spawning nested agent loops."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import uuid
6
+ import queue
7
+ import subprocess
8
+ import tempfile
9
+ from concurrent.futures import ThreadPoolExecutor, Future
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Any
13
+
14
+
15
+ # ── Agent definition ───────────────────────────────────────────────────────
16
+
17
+ @dataclass
18
+ class AgentDefinition:
19
+ """Definition for a specialized agent type."""
20
+ name: str
21
+ description: str = ""
22
+ system_prompt: str = "" # extra instructions prepended to the base system prompt
23
+ model: str = "" # model override; "" = inherit from parent
24
+ tools: list = field(default_factory=list) # empty list = all tools
25
+ source: str = "user" # "built-in" | "user" | "project"
26
+
27
+
28
+ # ── Built-in agent definitions ─────────────────────────────────────────────
29
+
30
+ _BUILTIN_AGENTS: Dict[str, AgentDefinition] = {
31
+ "general-purpose": AgentDefinition(
32
+ name="general-purpose",
33
+ description=(
34
+ "General-purpose agent for researching complex questions, "
35
+ "searching for code, and executing multi-step tasks."
36
+ ),
37
+ system_prompt="",
38
+ source="built-in",
39
+ ),
40
+ "coder": AgentDefinition(
41
+ name="coder",
42
+ description="Specialized coding agent for writing, reading, and modifying code.",
43
+ system_prompt=(
44
+ "You are a specialized coding assistant. Focus on:\n"
45
+ "- Writing clean, idiomatic code\n"
46
+ "- Reading and understanding existing code before modifying\n"
47
+ "- Making minimal targeted changes\n"
48
+ "- Never adding unnecessary features, comments, or error handling\n"
49
+ ),
50
+ source="built-in",
51
+ ),
52
+ "reviewer": AgentDefinition(
53
+ name="reviewer",
54
+ description="Code review agent analyzing quality, security, and correctness.",
55
+ system_prompt=(
56
+ "You are a code reviewer. Analyze code for:\n"
57
+ "- Correctness and logic errors\n"
58
+ "- Security vulnerabilities (injection, XSS, auth bypass, etc.)\n"
59
+ "- Performance issues\n"
60
+ "- Code quality and maintainability\n"
61
+ "Be concise and specific. Categorize findings as: Critical | Warning | Suggestion.\n"
62
+ ),
63
+ tools=["Read", "Glob", "Grep"],
64
+ source="built-in",
65
+ ),
66
+ "researcher": AgentDefinition(
67
+ name="researcher",
68
+ description="Research agent for exploring codebases and answering questions.",
69
+ system_prompt=(
70
+ "You are a research assistant focused on understanding codebases.\n"
71
+ "- Read and analyze code thoroughly before answering\n"
72
+ "- Provide factual, evidence-based answers\n"
73
+ "- Cite specific file paths and line numbers\n"
74
+ "- Be concise and focused\n"
75
+ ),
76
+ tools=["Read", "Glob", "Grep", "WebFetch", "WebSearch"],
77
+ source="built-in",
78
+ ),
79
+ "tester": AgentDefinition(
80
+ name="tester",
81
+ description="Testing agent that writes and runs tests.",
82
+ system_prompt=(
83
+ "You are a testing specialist. Your job:\n"
84
+ "- Write comprehensive tests for the given code\n"
85
+ "- Run existing tests and diagnose failures\n"
86
+ "- Focus on edge cases and error conditions\n"
87
+ "- Keep tests simple, readable, and fast\n"
88
+ ),
89
+ source="built-in",
90
+ ),
91
+ }
92
+
93
+
94
+ # ── Loading agent definitions from .md files ──────────────────────────────
95
+
96
+ def _parse_agent_md(path: Path, source: str = "user") -> AgentDefinition:
97
+ """Parse a .md file with optional YAML frontmatter into an AgentDefinition.
98
+
99
+ File format:
100
+ ---
101
+ description: "Short description"
102
+ model: claude-haiku-4-5-20251001
103
+ tools: [Read, Write, Edit, Bash]
104
+ ---
105
+
106
+ System prompt body goes here...
107
+ """
108
+ content = path.read_text()
109
+ name = path.stem
110
+ description = ""
111
+ model = ""
112
+ tools: list = []
113
+ system_prompt_body = content
114
+
115
+ if content.startswith("---"):
116
+ end = content.find("---", 3)
117
+ if end != -1:
118
+ fm_text = content[3:end].strip()
119
+ system_prompt_body = content[end + 3:].strip()
120
+ try:
121
+ import yaml as _yaml
122
+ fm = _yaml.safe_load(fm_text) or {}
123
+ except ImportError:
124
+ # Manual key: value parse (no yaml dependency required)
125
+ fm: dict = {}
126
+ for line in fm_text.splitlines():
127
+ if ":" in line:
128
+ k, _, v = line.partition(":")
129
+ fm[k.strip()] = v.strip()
130
+ description = str(fm.get("description", ""))
131
+ model = str(fm.get("model", ""))
132
+ raw_tools = fm.get("tools", [])
133
+ if isinstance(raw_tools, list):
134
+ tools = [str(t) for t in raw_tools]
135
+ elif isinstance(raw_tools, str):
136
+ # Handle "[Read, Write]" or "Read, Write" format
137
+ s = raw_tools.strip("[]")
138
+ tools = [t.strip() for t in s.split(",") if t.strip()]
139
+
140
+ return AgentDefinition(
141
+ name=name,
142
+ description=description,
143
+ system_prompt=system_prompt_body,
144
+ model=model,
145
+ tools=tools,
146
+ source=source,
147
+ )
148
+
149
+
150
+ def load_agent_definitions() -> Dict[str, AgentDefinition]:
151
+ """Load all agent definitions: built-ins → user-level → project-level.
152
+
153
+ Search paths:
154
+ ~/.dulus/agents/*.md (user-level)
155
+ .dulus/agents/*.md (project-level, overrides user)
156
+ """
157
+ defs: Dict[str, AgentDefinition] = dict(_BUILTIN_AGENTS)
158
+
159
+ # User-level
160
+ user_dir = Path.home() / ".dulus" / "agents"
161
+ if user_dir.is_dir():
162
+ for p in sorted(user_dir.glob("*.md")):
163
+ try:
164
+ d = _parse_agent_md(p, source="user")
165
+ defs[d.name] = d
166
+ except Exception:
167
+ pass
168
+
169
+ # Project-level (overrides user)
170
+ proj_dir = Path.cwd() / ".dulus-context" / "agents"
171
+ if proj_dir.is_dir():
172
+ for p in sorted(proj_dir.glob("*.md")):
173
+ try:
174
+ d = _parse_agent_md(p, source="project")
175
+ defs[d.name] = d
176
+ except Exception:
177
+ pass
178
+
179
+ return defs
180
+
181
+
182
+ def get_agent_definition(name: str) -> Optional[AgentDefinition]:
183
+ """Look up an agent definition by name. Returns None if not found."""
184
+ return load_agent_definitions().get(name)
185
+
186
+
187
+ # ── SubAgentTask ───────────────────────────────────────────────────────────
188
+
189
+ @dataclass
190
+ class SubAgentTask:
191
+ """Represents a sub-agent task with lifecycle tracking."""
192
+ id: str
193
+ prompt: str
194
+ status: str = "pending" # pending | running | completed | failed | cancelled
195
+ result: Optional[str] = None
196
+ depth: int = 0
197
+ name: str = "" # optional human-readable name (addressable by SendMessage)
198
+ worktree_path: str = "" # set if isolation="worktree"
199
+ worktree_branch: str = "" # set if isolation="worktree"
200
+ _cancel_flag: bool = False
201
+ _future: Optional[Future] = field(default=None, repr=False)
202
+ _inbox: Any = field(default_factory=queue.Queue, repr=False) # for send_message
203
+ # When the sub-agent calls AskMainAgentQuestion it registers a pending question
204
+ # here and blocks on the event. SendMessage from the main agent resolves it.
205
+ # Shape: {"question": str, "event": threading.Event, "result": list[str]}
206
+ _pending_question: Optional[dict] = field(default=None, repr=False)
207
+
208
+
209
+ # ── Worktree helpers ───────────────────────────────────────────────────────
210
+
211
+ def _git_root(cwd: str) -> Optional[str]:
212
+ """Return the git root directory for cwd, or None if not in a git repo."""
213
+ try:
214
+ r = subprocess.run(
215
+ ["git", "rev-parse", "--show-toplevel"],
216
+ cwd=cwd, capture_output=True, text=True, check=True,
217
+ )
218
+ return r.stdout.strip()
219
+ except Exception:
220
+ return None
221
+
222
+
223
+ def _create_worktree(base_dir: str) -> tuple:
224
+ """Create a temporary git worktree.
225
+
226
+ Returns:
227
+ (worktree_path, branch_name)
228
+ Raises:
229
+ subprocess.CalledProcessError or OSError on failure.
230
+ """
231
+ branch = f"nano-agent-{uuid.uuid4().hex[:8]}"
232
+ # mkdtemp gives us a path; remove the empty dir so git can create it
233
+ wt_path = tempfile.mkdtemp(prefix="nano-agent-wt-")
234
+ os.rmdir(wt_path)
235
+ subprocess.run(
236
+ ["git", "worktree", "add", "-b", branch, wt_path],
237
+ cwd=base_dir, check=True, capture_output=True, text=True,
238
+ )
239
+ return wt_path, branch
240
+
241
+
242
+ def _remove_worktree(wt_path: str, branch: str, base_dir: str) -> None:
243
+ """Remove a git worktree and delete its branch (best-effort)."""
244
+ try:
245
+ subprocess.run(
246
+ ["git", "worktree", "remove", "--force", wt_path],
247
+ cwd=base_dir, capture_output=True,
248
+ )
249
+ except Exception:
250
+ pass
251
+ try:
252
+ subprocess.run(
253
+ ["git", "branch", "-D", branch],
254
+ cwd=base_dir, capture_output=True,
255
+ )
256
+ except Exception:
257
+ pass
258
+
259
+
260
+ # ── Internal helpers ───────────────────────────────────────────────────────
261
+
262
+ def _agent_run(prompt, state, config, system_prompt, depth=0, cancel_check=None):
263
+ """Lazy-import wrapper to avoid circular dependency with agent module.
264
+
265
+ Uses absolute import so this works whether called from inside or outside
266
+ the multi_agent package (sys.path includes the project root).
267
+ """
268
+ import agent as _agent_mod
269
+ return _agent_mod.run(prompt, state, config, system_prompt, depth=depth, cancel_check=cancel_check)
270
+
271
+
272
+ def _extract_final_text(messages):
273
+ """Walk backwards through messages, return first assistant content string."""
274
+ for msg in reversed(messages):
275
+ if msg.get("role") == "assistant" and msg.get("content"):
276
+ return msg["content"]
277
+ return None
278
+
279
+
280
+ # ── SubAgentManager ────────────────────────────────────────────────────────
281
+
282
+ class SubAgentManager:
283
+ """Manages concurrent sub-agent tasks using a thread pool."""
284
+
285
+ def __init__(self, max_concurrent: int = 5, max_depth: int = 5):
286
+ self.tasks: Dict[str, SubAgentTask] = {}
287
+ self._by_name: Dict[str, str] = {} # name → task_id
288
+ self.max_concurrent = max_concurrent
289
+ self.max_depth = max_depth
290
+ self._pool = ThreadPoolExecutor(max_workers=max_concurrent)
291
+
292
+ def spawn(
293
+ self,
294
+ prompt: str,
295
+ config: dict,
296
+ system_prompt: str,
297
+ depth: int = 0,
298
+ agent_def: Optional[AgentDefinition] = None,
299
+ isolation: str = "", # "" | "worktree"
300
+ name: str = "",
301
+ ) -> SubAgentTask:
302
+ """Spawn a new sub-agent task.
303
+
304
+ Args:
305
+ prompt: user message for the sub-agent
306
+ config: agent configuration dict (copied before modification)
307
+ system_prompt: base system prompt
308
+ depth: current nesting depth (prevents infinite recursion)
309
+ agent_def: optional AgentDefinition with model/system_prompt/tools overrides
310
+ isolation: "" for normal, "worktree" for isolated git worktree
311
+ name: optional human-readable name (addressable via SendMessage)
312
+
313
+ Returns:
314
+ SubAgentTask tracking the spawned work.
315
+ """
316
+ task_id = uuid.uuid4().hex[:12]
317
+ short_name = name or task_id[:8]
318
+ task = SubAgentTask(id=task_id, prompt=prompt, depth=depth, name=short_name)
319
+ self.tasks[task_id] = task
320
+ if name:
321
+ self._by_name[name] = task_id
322
+
323
+ if depth >= self.max_depth:
324
+ task.status = "failed"
325
+ task.result = f"Max depth ({self.max_depth}) exceeded"
326
+ return task
327
+
328
+ # Build effective config and system prompt for this sub-agent
329
+ eff_config = dict(config)
330
+ # Stash task id so tools invoked inside the sub-agent (e.g.
331
+ # AskMainAgentQuestion) can locate their own task via the singleton
332
+ # manager.
333
+ eff_config["_subagent_task_id"] = task_id
334
+ eff_system = system_prompt
335
+
336
+ if agent_def:
337
+ if agent_def.model:
338
+ eff_config["model"] = agent_def.model
339
+ if agent_def.system_prompt:
340
+ eff_system = agent_def.system_prompt.rstrip() + "\n\n" + system_prompt
341
+
342
+ # Handle worktree isolation
343
+ worktree_path = ""
344
+ worktree_branch = ""
345
+ base_dir = os.getcwd()
346
+
347
+ if isolation == "worktree":
348
+ git_root = _git_root(base_dir)
349
+ if not git_root:
350
+ task.status = "failed"
351
+ task.result = "isolation='worktree' requires a git repository"
352
+ return task
353
+ try:
354
+ worktree_path, worktree_branch = _create_worktree(git_root)
355
+ task.worktree_path = worktree_path
356
+ task.worktree_branch = worktree_branch
357
+ notice = (
358
+ f"\n\n[Note: You are working in an isolated git worktree at "
359
+ f"{worktree_path} (branch: {worktree_branch}). "
360
+ f"Your changes are isolated from the main workspace at {git_root}. "
361
+ f"Commit your changes before finishing so they can be reviewed/merged.]"
362
+ )
363
+ prompt = prompt + notice
364
+ except Exception as e:
365
+ task.status = "failed"
366
+ task.result = f"Failed to create worktree: {e}"
367
+ return task
368
+
369
+ def _run():
370
+ import agent as _agent_mod; AgentState = _agent_mod.AgentState
371
+ task.status = "running"
372
+ old_cwd = os.getcwd()
373
+ try:
374
+ if worktree_path:
375
+ os.chdir(worktree_path)
376
+
377
+ state = AgentState()
378
+ gen = _agent_run(
379
+ prompt, state, eff_config, eff_system,
380
+ depth=depth + 1,
381
+ cancel_check=lambda: task._cancel_flag,
382
+ )
383
+ for _event in gen:
384
+ if task._cancel_flag:
385
+ break
386
+
387
+ if task._cancel_flag:
388
+ task.status = "cancelled"
389
+ task.result = None
390
+ else:
391
+ task.result = _extract_final_text(state.messages)
392
+ task.status = "completed"
393
+
394
+ # Drain inbox: process any messages sent via SendMessage
395
+ while not task._inbox.empty() and not task._cancel_flag:
396
+ inbox_msg = task._inbox.get_nowait()
397
+ task.status = "running"
398
+ gen2 = _agent_run(
399
+ inbox_msg, state, eff_config, eff_system,
400
+ depth=depth + 1,
401
+ cancel_check=lambda: task._cancel_flag,
402
+ )
403
+ for _ev in gen2:
404
+ if task._cancel_flag:
405
+ break
406
+ if not task._cancel_flag:
407
+ task.result = _extract_final_text(state.messages)
408
+ task.status = "completed"
409
+
410
+ except Exception as e:
411
+ task.status = "failed"
412
+ task.result = f"Error: {e}"
413
+ finally:
414
+ if worktree_path:
415
+ os.chdir(old_cwd)
416
+ _remove_worktree(worktree_path, worktree_branch, old_cwd)
417
+
418
+ task._future = self._pool.submit(_run)
419
+ return task
420
+
421
+ def wait(self, task_id: str, timeout: float = None) -> Optional[SubAgentTask]:
422
+ """Block until a task completes or timeout expires.
423
+
424
+ Returns:
425
+ The task, or None if task_id is unknown.
426
+ """
427
+ task = self.tasks.get(task_id)
428
+ if task is None:
429
+ return None
430
+ if task._future is not None:
431
+ try:
432
+ task._future.result(timeout=timeout)
433
+ except Exception:
434
+ pass
435
+ return task
436
+
437
+ def get_result(self, task_id: str) -> Optional[str]:
438
+ """Return the result string for a completed task, or None."""
439
+ task = self.tasks.get(task_id)
440
+ return task.result if task else None
441
+
442
+ def list_tasks(self) -> List[SubAgentTask]:
443
+ """Return all tracked tasks."""
444
+ return list(self.tasks.values())
445
+
446
+ def send_message(self, task_id_or_name: str, message: str) -> bool:
447
+ """Send a message to a running background agent.
448
+
449
+ If the agent is currently blocked on an AskMainAgentQuestion call, the
450
+ message is delivered immediately as the answer and the agent resumes
451
+ the SAME turn (preserving full context).
452
+
453
+ Otherwise the message is queued in the inbox and the agent processes
454
+ it after completing its current turn.
455
+
456
+ Args:
457
+ task_id_or_name: task ID or the human-readable name passed to spawn()
458
+ message: message text to send
459
+
460
+ Returns:
461
+ True if the message was delivered or queued, False if task not
462
+ found or already done.
463
+ """
464
+ # Resolve name → task_id
465
+ task_id = self._by_name.get(task_id_or_name, task_id_or_name)
466
+ task = self.tasks.get(task_id)
467
+ if task is None:
468
+ return False
469
+ if task.status not in ("running", "pending"):
470
+ return False
471
+ # If the sub-agent is waiting on AskMainAgentQuestion, fulfill it now
472
+ # instead of queuing to the inbox.
473
+ pq = task._pending_question
474
+ if pq is not None:
475
+ pq["result"].append(message)
476
+ task._pending_question = None
477
+ pq["event"].set()
478
+ return True
479
+ task._inbox.put(message)
480
+ return True
481
+
482
+ def cancel(self, task_id: str) -> bool:
483
+ """Request cancellation of a running task.
484
+
485
+ Returns:
486
+ True if the cancel flag was set, False if task not found or not running.
487
+ """
488
+ task = self.tasks.get(task_id)
489
+ if task is None:
490
+ return False
491
+ if task.status == "running":
492
+ task._cancel_flag = True
493
+ return True
494
+ return False
495
+
496
+ def shutdown(self) -> None:
497
+ """Cancel all running tasks and shut down the thread pool."""
498
+ for task in self.tasks.values():
499
+ if task.status == "running":
500
+ task._cancel_flag = True
501
+ self._pool.shutdown(wait=True)