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
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()
|