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
skill/tools.py ADDED
@@ -0,0 +1,110 @@
1
+ """Skill tool: lets the model invoke skills by name via tool call."""
2
+ from __future__ import annotations
3
+
4
+ from tool_registry import ToolDef, register_tool
5
+ from .loader import find_skill, load_skills, substitute_arguments
6
+
7
+
8
+ _SKILL_SCHEMA = {
9
+ "name": "Skill",
10
+ "description": (
11
+ "Invoke a named skill (reusable prompt template). "
12
+ "Use SkillList to see available skills and their triggers."
13
+ ),
14
+ "input_schema": {
15
+ "type": "object",
16
+ "properties": {
17
+ "name": {
18
+ "type": "string",
19
+ "description": "Skill name (e.g. 'commit', 'review')",
20
+ },
21
+ "args": {
22
+ "type": "string",
23
+ "description": "Arguments to pass to the skill (replaces $ARGUMENTS)",
24
+ "default": "",
25
+ },
26
+ },
27
+ "required": ["name"],
28
+ },
29
+ }
30
+
31
+ _SKILL_LIST_SCHEMA = {
32
+ "name": "SkillList",
33
+ "description": "List all available skills with their names, triggers, and descriptions.",
34
+ "input_schema": {
35
+ "type": "object",
36
+ "properties": {},
37
+ "required": [],
38
+ },
39
+ }
40
+
41
+
42
+ def _skill_tool(params: dict, config: dict) -> str:
43
+ """Execute a skill by name and return its output."""
44
+ skill_name = params.get("name", "").strip()
45
+ args = params.get("args", "")
46
+
47
+ # Look up by name first, then by trigger
48
+ skill = None
49
+ for s in load_skills():
50
+ if s.name == skill_name:
51
+ skill = s
52
+ break
53
+ if skill is None:
54
+ skill = find_skill(skill_name)
55
+ if skill is None:
56
+ names = [s.name for s in load_skills()]
57
+ return f"Error: skill '{skill_name}' not found. Available: {', '.join(names)}"
58
+
59
+ rendered = substitute_arguments(skill.prompt, args, skill.arguments)
60
+ message = f"[Skill: {skill.name}]\n\n{rendered}"
61
+
62
+ # Run inline via agent and collect text output
63
+ import agent as _agent
64
+ system_prompt = config.get("_system_prompt", "")
65
+
66
+ # Collect output text
67
+ output_parts: list[str] = []
68
+ sub_state = _agent.AgentState()
69
+ sub_config = {**config, "_depth": config.get("_depth", 0) + 1}
70
+ try:
71
+ for event in _agent.run(message, sub_state, sub_config, system_prompt):
72
+ if hasattr(event, "text"):
73
+ output_parts.append(event.text)
74
+ except Exception as e:
75
+ return f"Skill execution error: {e}"
76
+
77
+ return "".join(output_parts) or "(skill completed with no text output)"
78
+
79
+
80
+ def _skill_list_tool(params: dict, config: dict) -> str:
81
+ skills = load_skills()
82
+ if not skills:
83
+ return "No skills available."
84
+ lines = ["Available skills:\n"]
85
+ for s in skills:
86
+ triggers = ", ".join(s.triggers)
87
+ hint = f" args: {s.argument_hint}" if s.argument_hint else ""
88
+ when = f"\n when: {s.when_to_use}" if s.when_to_use else ""
89
+ lines.append(f"- **{s.name}** [{triggers}]{hint}\n {s.description}{when}")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _register() -> None:
94
+ register_tool(ToolDef(
95
+ name="Skill",
96
+ schema=_SKILL_SCHEMA,
97
+ func=_skill_tool,
98
+ read_only=False,
99
+ concurrent_safe=False,
100
+ ))
101
+ register_tool(ToolDef(
102
+ name="SkillList",
103
+ schema=_SKILL_LIST_SCHEMA,
104
+ func=_skill_list_tool,
105
+ read_only=True,
106
+ concurrent_safe=True,
107
+ ))
108
+
109
+
110
+ _register()
skills.py ADDED
@@ -0,0 +1,14 @@
1
+ """Backward-compatibility shim — real implementation is in skill/ package."""
2
+ from skill.loader import ( # noqa: F401
3
+ SkillDef,
4
+ load_skills,
5
+ find_skill,
6
+ substitute_arguments,
7
+ _parse_skill_file,
8
+ _parse_list_field,
9
+ )
10
+ from skill.executor import execute_skill # noqa: F401
11
+
12
+ # Legacy constant — kept for tests that patch it
13
+ from skill.loader import _get_skill_paths as _gsp
14
+ SKILL_PATHS = _gsp()
spinner.py ADDED
@@ -0,0 +1,42 @@
1
+ """Shared spinner phrases for Dulus's tool/debate spinners.
2
+
3
+ Centralized so dulus.py and ui/render.py stay in sync.
4
+ """
5
+
6
+ TOOL_SPINNER_PHRASES = [
7
+ "⚡ Rewriting light speed...",
8
+ "🏁 Winning a race against light...",
9
+ "🤔 Who is Barry Allen?...",
10
+ "🤔 Who is KevRojo?...",
11
+ "🦅 Dropping from the stratosphere...",
12
+ "💨 Leaving electrons behind...",
13
+ "🌍 Orbiting the codebase...",
14
+ "⏱️ Breaking the sound barrier...",
15
+ "🔥 Faster than a hot reload...",
16
+ "🚀 Terminal velocity reached...",
17
+ "🦅 Sharpening talons on the AST...",
18
+ "🏎️ Shifting to 6th gear...",
19
+ "⚡ Speed force activated...",
20
+ "🌪️ Blitzing through the bytecode...",
21
+ "💫 Bending spacetime...",
22
+ "🦅 Preying on bugs from above...",
23
+ "👁️ Dulus vision engaged...",
24
+ "🍗 Hunting for memory leaks...",
25
+ "🪶 Shedding legacy code...",
26
+ "🕹️ Try-catching mid-flight...",
27
+ "🥚 Hatching a master plan...",
28
+ "⚡ I-I-I'm... I-I'm... I'm fast...",
29
+ "🔮 Looking at your code from the future...",
30
+ "☕ If I'm taking so long, don't worry, I'm just talking to your mom...",
31
+ ]
32
+
33
+ DEBATE_SPINNER_PHRASES = [
34
+ "⚔️ Experts taking their positions...",
35
+ "🧠 Experts formulating arguments...",
36
+ "🗣️ Debate in progress...",
37
+ "⚖️ Weighing the evidence...",
38
+ "💡 Building counter-arguments...",
39
+ "🔥 Debate heating up...",
40
+ "📜 Drafting the consensus...",
41
+ "🎯 Finding common ground...",
42
+ ]
string_utils.py ADDED
@@ -0,0 +1,42 @@
1
+ """String utilities — adapted from kimi-cli."""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ import re
6
+ import string
7
+
8
+ _NEWLINE_RE = re.compile(r"[\r\n]+")
9
+
10
+
11
+ def shorten(text: str, *, width: int, placeholder: str = "…") -> str:
12
+ """Shorten text to at most *width* characters.
13
+
14
+ Normalises whitespace, then truncates — preferring a word boundary
15
+ when one exists near the cut point, but falling back to a hard cut
16
+ so that CJK text without spaces won't collapse to just the placeholder.
17
+ """
18
+ text = " ".join(text.split())
19
+ if len(text) <= width:
20
+ return text
21
+ cut = width - len(placeholder)
22
+ if cut <= 0:
23
+ return text[:width]
24
+ space = text.rfind(" ", 0, cut + 1)
25
+ if space > 0:
26
+ cut = space
27
+ return text[:cut].rstrip() + placeholder
28
+
29
+
30
+ def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str:
31
+ """Shorten the text by inserting ellipsis in the middle."""
32
+ if len(text) <= width:
33
+ return text
34
+ if remove_newline:
35
+ text = _NEWLINE_RE.sub(" ", text)
36
+ return text[: width // 2] + "..." + text[-width // 2 :]
37
+
38
+
39
+ def random_string(length: int = 8) -> str:
40
+ """Generate a random string of fixed length."""
41
+ letters = string.ascii_lowercase
42
+ return "".join(random.choice(letters) for _ in range(length))
subagent.py ADDED
@@ -0,0 +1,11 @@
1
+ """Backward-compatibility shim — real implementation is in multi_agent/subagent.py."""
2
+ from multi_agent.subagent import ( # noqa: F401
3
+ AgentDefinition,
4
+ SubAgentTask,
5
+ SubAgentManager,
6
+ load_agent_definitions,
7
+ get_agent_definition,
8
+ _extract_final_text,
9
+ _agent_run,
10
+ _BUILTIN_AGENTS,
11
+ )
task/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Task system for dulus."""
2
+ from .types import Task, TaskStatus
3
+ from .store import (
4
+ create_task, get_task, list_tasks, update_task,
5
+ delete_task, clear_all_tasks, reload_from_disk,
6
+ )
7
+
8
+ __all__ = [
9
+ "Task", "TaskStatus",
10
+ "create_task", "get_task", "list_tasks", "update_task",
11
+ "delete_task", "clear_all_tasks", "reload_from_disk",
12
+ ]
task/store.py ADDED
@@ -0,0 +1,199 @@
1
+ """Thread-safe task store: in-memory dict persisted to .dulus/tasks.json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ import uuid
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .types import Task, TaskStatus
12
+
13
+ _lock = threading.Lock()
14
+
15
+ # Tasks are keyed by ID, stored per session in <cwd>/.dulus/tasks.json
16
+ # The store is kept in memory; we reload from disk on first access.
17
+
18
+ _tasks: dict[str, Task] = {}
19
+ _loaded = False
20
+
21
+
22
+ # ── persistence ───────────────────────────────────────────────────────────────
23
+
24
+ def _tasks_file() -> Path:
25
+ return Path.cwd() / ".dulus-context" / "tasks.json"
26
+
27
+
28
+ def _load() -> None:
29
+ global _loaded
30
+ if _loaded:
31
+ return
32
+ f = _tasks_file()
33
+ if f.exists():
34
+ try:
35
+ data = json.loads(f.read_text())
36
+ for item in data.get("tasks", []):
37
+ t = Task.from_dict(item)
38
+ _tasks[t.id] = t
39
+ except Exception:
40
+ pass
41
+ _loaded = True
42
+
43
+
44
+ def _save() -> None:
45
+ f = _tasks_file()
46
+ f.parent.mkdir(parents=True, exist_ok=True)
47
+ data = {"tasks": [t.to_dict() for t in _tasks.values()]}
48
+ f.write_text(json.dumps(data, indent=2))
49
+
50
+
51
+ def _next_id() -> str:
52
+ """Generate a short sequential numeric ID."""
53
+ if not _tasks:
54
+ return "1"
55
+ max_id = max((int(k) for k in _tasks if k.isdigit()), default=0)
56
+ return str(max_id + 1)
57
+
58
+
59
+ # ── public API ────────────────────────────────────────────────────────────────
60
+
61
+ def create_task(
62
+ subject: str,
63
+ description: str,
64
+ active_form: str = "",
65
+ metadata: dict[str, Any] | None = None,
66
+ ) -> Task:
67
+ with _lock:
68
+ _load()
69
+ task = Task(
70
+ id=_next_id(),
71
+ subject=subject,
72
+ description=description,
73
+ active_form=active_form,
74
+ metadata=metadata or {},
75
+ )
76
+ _tasks[task.id] = task
77
+ _save()
78
+ return task
79
+
80
+
81
+ def get_task(task_id: str) -> Task | None:
82
+ with _lock:
83
+ _load()
84
+ return _tasks.get(str(task_id))
85
+
86
+
87
+ def list_tasks() -> list[Task]:
88
+ with _lock:
89
+ _load()
90
+ return list(_tasks.values())
91
+
92
+
93
+ def update_task(
94
+ task_id: str,
95
+ subject: str | None = None,
96
+ description: str | None = None,
97
+ status: str | None = None,
98
+ active_form: str | None = None,
99
+ owner: str | None = None,
100
+ add_blocks: list[str] | None = None,
101
+ add_blocked_by: list[str] | None = None,
102
+ metadata: dict[str, Any] | None = None,
103
+ ) -> tuple[Task | None, list[str]]:
104
+ """Update a task. Returns (updated_task, list_of_updated_fields)."""
105
+ with _lock:
106
+ _load()
107
+ task = _tasks.get(str(task_id))
108
+ if task is None:
109
+ return None, []
110
+
111
+ updated_fields: list[str] = []
112
+
113
+ if subject is not None and subject != task.subject:
114
+ task.subject = subject
115
+ updated_fields.append("subject")
116
+
117
+ if description is not None and description != task.description:
118
+ task.description = description
119
+ updated_fields.append("description")
120
+
121
+ if active_form is not None and active_form != task.active_form:
122
+ task.active_form = active_form
123
+ updated_fields.append("active_form")
124
+
125
+ if owner is not None and owner != task.owner:
126
+ task.owner = owner
127
+ updated_fields.append("owner")
128
+
129
+ if status is not None:
130
+ try:
131
+ new_status = TaskStatus(status)
132
+ except ValueError:
133
+ new_status = None
134
+ if new_status is not None and new_status != task.status:
135
+ task.status = new_status
136
+ updated_fields.append("status")
137
+
138
+ if metadata is not None:
139
+ for k, v in metadata.items():
140
+ if v is None:
141
+ task.metadata.pop(k, None)
142
+ else:
143
+ task.metadata[k] = v
144
+ updated_fields.append("metadata")
145
+
146
+ if add_blocks:
147
+ new_blocks = [b for b in add_blocks if b not in task.blocks]
148
+ if new_blocks:
149
+ task.blocks.extend(new_blocks)
150
+ # Also register the reverse edge on the target tasks
151
+ for b_id in new_blocks:
152
+ target = _tasks.get(str(b_id))
153
+ if target and str(task_id) not in target.blocked_by:
154
+ target.blocked_by.append(str(task_id))
155
+ updated_fields.append("blocks")
156
+
157
+ if add_blocked_by:
158
+ new_bb = [b for b in add_blocked_by if b not in task.blocked_by]
159
+ if new_bb:
160
+ task.blocked_by.extend(new_bb)
161
+ # Also register the reverse edge
162
+ for blocker_id in new_bb:
163
+ blocker = _tasks.get(str(blocker_id))
164
+ if blocker and str(task_id) not in blocker.blocks:
165
+ blocker.blocks.append(str(task_id))
166
+ updated_fields.append("blocked_by")
167
+
168
+ if updated_fields:
169
+ task.updated_at = datetime.now().isoformat()
170
+ _save()
171
+
172
+ return task, updated_fields
173
+
174
+
175
+ def delete_task(task_id: str) -> bool:
176
+ with _lock:
177
+ _load()
178
+ task_id = str(task_id)
179
+ if task_id not in _tasks:
180
+ return False
181
+ del _tasks[task_id]
182
+ _save()
183
+ return True
184
+
185
+
186
+ def clear_all_tasks() -> None:
187
+ """Remove all tasks (used in tests)."""
188
+ with _lock:
189
+ _tasks.clear()
190
+ _save()
191
+
192
+
193
+ def reload_from_disk() -> None:
194
+ """Force reload from disk (used in tests)."""
195
+ global _loaded
196
+ with _lock:
197
+ _tasks.clear()
198
+ _loaded = False
199
+ _load()
task/tools.py ADDED
@@ -0,0 +1,265 @@
1
+ """Task tools: TaskCreate, TaskUpdate, TaskGet, TaskList — registered into tool_registry."""
2
+ from __future__ import annotations
3
+
4
+ from tool_registry import ToolDef, register_tool
5
+ from .store import create_task, get_task, list_tasks, update_task, delete_task
6
+ from .types import TaskStatus
7
+
8
+
9
+ # ── Schemas ───────────────────────────────────────────────────────────────────
10
+
11
+ _TASK_CREATE_SCHEMA = {
12
+ "name": "TaskCreate",
13
+ "description": (
14
+ "Create a new task in the task list. "
15
+ "Use this to track work items, to-dos, and multi-step plans. "
16
+ "Returns the new task's ID and subject."
17
+ ),
18
+ "input_schema": {
19
+ "type": "object",
20
+ "properties": {
21
+ "subject": {
22
+ "type": "string",
23
+ "description": "A brief title for the task",
24
+ },
25
+ "description": {
26
+ "type": "string",
27
+ "description": "What needs to be done",
28
+ },
29
+ "active_form": {
30
+ "type": "string",
31
+ "description": (
32
+ "Present-continuous label shown while in_progress "
33
+ "(e.g. 'Running tests', 'Writing docs')"
34
+ ),
35
+ },
36
+ "metadata": {
37
+ "type": "object",
38
+ "description": "Arbitrary key-value metadata to attach to the task",
39
+ },
40
+ },
41
+ "required": ["subject", "description"],
42
+ },
43
+ }
44
+
45
+ _TASK_UPDATE_SCHEMA = {
46
+ "name": "TaskUpdate",
47
+ "description": (
48
+ "Update an existing task. Can change subject, description, status, owner, "
49
+ "dependency edges (blocks / blocked_by), and metadata. "
50
+ "Set status='deleted' to remove the task. "
51
+ "Valid statuses: pending, in_progress, completed, cancelled, deleted."
52
+ ),
53
+ "input_schema": {
54
+ "type": "object",
55
+ "properties": {
56
+ "task_id": {
57
+ "type": "string",
58
+ "description": "The ID of the task to update",
59
+ },
60
+ "subject": {
61
+ "type": "string",
62
+ "description": "New title for the task",
63
+ },
64
+ "description": {
65
+ "type": "string",
66
+ "description": "New description for the task",
67
+ },
68
+ "status": {
69
+ "type": "string",
70
+ "enum": ["pending", "in_progress", "completed", "cancelled", "deleted"],
71
+ "description": "New status ('deleted' removes the task)",
72
+ },
73
+ "active_form": {
74
+ "type": "string",
75
+ "description": "Present-continuous label while in_progress",
76
+ },
77
+ "owner": {
78
+ "type": "string",
79
+ "description": "Agent/user responsible for this task",
80
+ },
81
+ "add_blocks": {
82
+ "type": "array",
83
+ "items": {"type": "string"},
84
+ "description": "Task IDs that this task now blocks",
85
+ },
86
+ "add_blocked_by": {
87
+ "type": "array",
88
+ "items": {"type": "string"},
89
+ "description": "Task IDs that block this task",
90
+ },
91
+ "metadata": {
92
+ "type": "object",
93
+ "description": "Keys to merge into task metadata (null value = delete key)",
94
+ },
95
+ },
96
+ "required": ["task_id"],
97
+ },
98
+ }
99
+
100
+ _TASK_GET_SCHEMA = {
101
+ "name": "TaskGet",
102
+ "description": "Retrieve a single task by ID. Returns full task details.",
103
+ "input_schema": {
104
+ "type": "object",
105
+ "properties": {
106
+ "task_id": {
107
+ "type": "string",
108
+ "description": "The ID of the task to retrieve",
109
+ },
110
+ },
111
+ "required": ["task_id"],
112
+ },
113
+ }
114
+
115
+ _TASK_LIST_SCHEMA = {
116
+ "name": "TaskList",
117
+ "description": (
118
+ "List all tasks. Returns id, subject, status, owner, and pending blockers. "
119
+ "Use this to review the current plan or find the next available task."
120
+ ),
121
+ "input_schema": {
122
+ "type": "object",
123
+ "properties": {},
124
+ "required": [],
125
+ },
126
+ }
127
+
128
+
129
+ # ── Implementations ────────────────────────────────────────────────────────────
130
+
131
+ def _task_create(subject: str, description: str, active_form: str = "", metadata: dict = None) -> str:
132
+ task = create_task(subject, description, active_form=active_form, metadata=metadata)
133
+ return f"Task #{task.id} created: {task.subject}"
134
+
135
+
136
+ def _task_update(
137
+ task_id: str,
138
+ subject: str = None,
139
+ description: str = None,
140
+ status: str = None,
141
+ active_form: str = None,
142
+ owner: str = None,
143
+ add_blocks: list = None,
144
+ add_blocked_by: list = None,
145
+ metadata: dict = None,
146
+ ) -> str:
147
+ # Handle deletion
148
+ if status == "deleted":
149
+ ok = delete_task(task_id)
150
+ if ok:
151
+ return f"Task #{task_id} deleted."
152
+ return f"Error: task #{task_id} not found."
153
+
154
+ task, updated_fields = update_task(
155
+ task_id,
156
+ subject=subject,
157
+ description=description,
158
+ status=status,
159
+ active_form=active_form,
160
+ owner=owner,
161
+ add_blocks=add_blocks or [],
162
+ add_blocked_by=add_blocked_by or [],
163
+ metadata=metadata,
164
+ )
165
+ if task is None:
166
+ return f"Error: task #{task_id} not found."
167
+ if not updated_fields:
168
+ return f"Task #{task_id}: no changes (fields already match)."
169
+ return f"Task #{task_id} updated — changed: {', '.join(updated_fields)}."
170
+
171
+
172
+ def _task_get(task_id: str) -> str:
173
+ task = get_task(task_id)
174
+ if task is None:
175
+ return f"Task #{task_id} not found."
176
+ lines = [
177
+ f"Task #{task.id}: {task.subject}",
178
+ f"Status: {task.status.value}",
179
+ f"Description: {task.description}",
180
+ ]
181
+ if task.owner:
182
+ lines.append(f"Owner: {task.owner}")
183
+ if task.active_form:
184
+ lines.append(f"Active form: {task.active_form}")
185
+ if task.blocked_by:
186
+ lines.append(f"Blocked by: #{', #'.join(task.blocked_by)}")
187
+ if task.blocks:
188
+ lines.append(f"Blocks: #{', #'.join(task.blocks)}")
189
+ if task.metadata:
190
+ lines.append(f"Metadata: {task.metadata}")
191
+ lines.append(f"Created: {task.created_at[:19]}")
192
+ lines.append(f"Updated: {task.updated_at[:19]}")
193
+ return "\n".join(lines)
194
+
195
+
196
+ def _task_list() -> str:
197
+ tasks = list_tasks()
198
+ if not tasks:
199
+ return "No tasks."
200
+ resolved = {t.id for t in tasks if t.status == TaskStatus.COMPLETED}
201
+ lines = []
202
+ for task in tasks:
203
+ pending_blockers = [b for b in task.blocked_by if b not in resolved]
204
+ owner_str = f" ({task.owner})" if task.owner else ""
205
+ blocked_str = f" [blocked by #{', #'.join(pending_blockers)}]" if pending_blockers else ""
206
+ lines.append(
207
+ f"#{task.id} [{task.status.value}] {task.status_icon()} "
208
+ f"{task.subject}{owner_str}{blocked_str}"
209
+ )
210
+ return "\n".join(lines)
211
+
212
+
213
+ # ── Registration ───────────────────────────────────────────────────────────────
214
+
215
+ def _register() -> None:
216
+ defs = [
217
+ ToolDef(
218
+ name="TaskCreate",
219
+ schema=_TASK_CREATE_SCHEMA,
220
+ func=lambda p, c: _task_create(
221
+ p["subject"],
222
+ p["description"],
223
+ p.get("active_form", ""),
224
+ p.get("metadata"),
225
+ ),
226
+ read_only=False,
227
+ concurrent_safe=True,
228
+ ),
229
+ ToolDef(
230
+ name="TaskUpdate",
231
+ schema=_TASK_UPDATE_SCHEMA,
232
+ func=lambda p, c: _task_update(
233
+ p["task_id"],
234
+ subject=p.get("subject"),
235
+ description=p.get("description"),
236
+ status=p.get("status"),
237
+ active_form=p.get("active_form"),
238
+ owner=p.get("owner"),
239
+ add_blocks=p.get("add_blocks"),
240
+ add_blocked_by=p.get("add_blocked_by"),
241
+ metadata=p.get("metadata"),
242
+ ),
243
+ read_only=False,
244
+ concurrent_safe=True,
245
+ ),
246
+ ToolDef(
247
+ name="TaskGet",
248
+ schema=_TASK_GET_SCHEMA,
249
+ func=lambda p, c: _task_get(p["task_id"]),
250
+ read_only=True,
251
+ concurrent_safe=True,
252
+ ),
253
+ ToolDef(
254
+ name="TaskList",
255
+ schema=_TASK_LIST_SCHEMA,
256
+ func=lambda p, c: _task_list(),
257
+ read_only=True,
258
+ concurrent_safe=True,
259
+ ),
260
+ ]
261
+ for td in defs:
262
+ register_tool(td)
263
+
264
+
265
+ _register()