todo-agent 0.3.2__py3-none-any.whl → 0.3.5__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.
- todo_agent/_version.py +2 -2
- todo_agent/core/exceptions.py +6 -6
- todo_agent/core/todo_manager.py +315 -182
- todo_agent/infrastructure/inference.py +120 -52
- todo_agent/infrastructure/llm_client.py +56 -22
- todo_agent/infrastructure/ollama_client.py +23 -13
- todo_agent/infrastructure/openrouter_client.py +20 -12
- todo_agent/infrastructure/prompts/system_prompt.txt +190 -438
- todo_agent/infrastructure/todo_shell.py +94 -11
- todo_agent/interface/cli.py +51 -33
- todo_agent/interface/formatters.py +7 -4
- todo_agent/interface/progress.py +30 -19
- todo_agent/interface/tools.py +73 -30
- todo_agent/main.py +17 -1
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/METADATA +1 -1
- todo_agent-0.3.5.dist-info/RECORD +30 -0
- todo_agent-0.3.2.dist-info/RECORD +0 -30
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/WHEEL +0 -0
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/top_level.txt +0 -0
@@ -32,13 +32,19 @@ class TodoShell:
|
|
32
32
|
self.todo_dir = os.path.dirname(todo_file_path) or os.getcwd()
|
33
33
|
self.logger = logger
|
34
34
|
|
35
|
-
def execute(
|
35
|
+
def execute(
|
36
|
+
self,
|
37
|
+
command: List[str],
|
38
|
+
cwd: Optional[str] = None,
|
39
|
+
suppress_color: bool = False,
|
40
|
+
) -> str:
|
36
41
|
"""
|
37
|
-
Execute todo.sh command.
|
42
|
+
Execute a todo.sh command and return the output.
|
38
43
|
|
39
44
|
Args:
|
40
45
|
command: List of command arguments
|
41
46
|
cwd: Working directory (defaults to todo.sh directory)
|
47
|
+
suppress_color: If True, strip ANSI color codes from output (for LLM consumption)
|
42
48
|
|
43
49
|
Returns:
|
44
50
|
Command output as string
|
@@ -52,6 +58,7 @@ class TodoShell:
|
|
52
58
|
self.logger.debug(f"=== RAW COMMAND EXECUTION ===")
|
53
59
|
self.logger.debug(f"Raw command: {raw_command}")
|
54
60
|
self.logger.debug(f"Working directory: {cwd or self.todo_dir}")
|
61
|
+
self.logger.debug(f"Suppress color: {suppress_color}")
|
55
62
|
|
56
63
|
try:
|
57
64
|
working_dir = cwd or self.todo_dir
|
@@ -67,7 +74,20 @@ class TodoShell:
|
|
67
74
|
self.logger.debug(f"Raw stderr: {result.stderr}")
|
68
75
|
self.logger.debug(f"Return code: {result.returncode}")
|
69
76
|
|
70
|
-
|
77
|
+
output = result.stdout.strip()
|
78
|
+
|
79
|
+
# Strip ANSI color codes if requested (for LLM consumption)
|
80
|
+
if suppress_color:
|
81
|
+
from rich.text import Text
|
82
|
+
|
83
|
+
# Use Rich's Text.from_ansi to parse and then get plain text
|
84
|
+
output = Text.from_ansi(output).plain
|
85
|
+
if self.logger:
|
86
|
+
self.logger.debug(
|
87
|
+
f"Stripped ANSI codes from output for LLM consumption"
|
88
|
+
)
|
89
|
+
|
90
|
+
return output
|
71
91
|
except subprocess.CalledProcessError as e:
|
72
92
|
# Log error details
|
73
93
|
if self.logger:
|
@@ -88,12 +108,18 @@ class TodoShell:
|
|
88
108
|
"""Add new task."""
|
89
109
|
return self.execute(["todo.sh", "add", description])
|
90
110
|
|
91
|
-
def
|
111
|
+
def addto(self, destination: str, text: str) -> str:
|
112
|
+
"""Add text to a specific file in the todo.txt directory."""
|
113
|
+
return self.execute(["todo.sh", "addto", destination, text])
|
114
|
+
|
115
|
+
def list_tasks(
|
116
|
+
self, filter_str: Optional[str] = None, suppress_color: bool = True
|
117
|
+
) -> str:
|
92
118
|
"""List tasks with optional filtering."""
|
93
119
|
command = ["todo.sh", "ls"]
|
94
120
|
if filter_str:
|
95
121
|
command.append(filter_str)
|
96
|
-
return self.execute(command)
|
122
|
+
return self.execute(command, suppress_color=suppress_color)
|
97
123
|
|
98
124
|
def complete(self, task_number: int) -> str:
|
99
125
|
"""Mark task complete."""
|
@@ -135,20 +161,22 @@ class TodoShell:
|
|
135
161
|
"""Remove task priority."""
|
136
162
|
return self.execute(["todo.sh", "depri", str(task_number)])
|
137
163
|
|
138
|
-
def list_projects(self) -> str:
|
164
|
+
def list_projects(self, suppress_color: bool = True) -> str:
|
139
165
|
"""List projects."""
|
140
|
-
return self.execute(["todo.sh", "lsp"])
|
166
|
+
return self.execute(["todo.sh", "lsp"], suppress_color=suppress_color)
|
141
167
|
|
142
|
-
def list_contexts(self) -> str:
|
168
|
+
def list_contexts(self, suppress_color: bool = True) -> str:
|
143
169
|
"""List contexts."""
|
144
|
-
return self.execute(["todo.sh", "lsc"])
|
170
|
+
return self.execute(["todo.sh", "lsc"], suppress_color=suppress_color)
|
145
171
|
|
146
|
-
def list_completed(
|
172
|
+
def list_completed(
|
173
|
+
self, filter_str: Optional[str] = None, suppress_color: bool = True
|
174
|
+
) -> str:
|
147
175
|
"""List completed tasks with optional filtering."""
|
148
176
|
command = ["todo.sh", "listfile", "done.txt"]
|
149
177
|
if filter_str:
|
150
178
|
command.append(filter_str)
|
151
|
-
return self.execute(command)
|
179
|
+
return self.execute(command, suppress_color=suppress_color)
|
152
180
|
|
153
181
|
def archive(self) -> str:
|
154
182
|
"""Archive completed tasks."""
|
@@ -252,6 +280,61 @@ class TodoShell:
|
|
252
280
|
# Replace the task with the new description
|
253
281
|
return self.replace(task_number, new_description)
|
254
282
|
|
283
|
+
def set_parent(self, task_number: int, parent_number: Optional[int]) -> str:
|
284
|
+
"""
|
285
|
+
Set or update parent task number for a task by intelligently rewriting it.
|
286
|
+
|
287
|
+
Args:
|
288
|
+
task_number: The task number to modify
|
289
|
+
parent_number: Parent task number, or None to remove parent
|
290
|
+
|
291
|
+
Returns:
|
292
|
+
The updated task description
|
293
|
+
"""
|
294
|
+
# First, get the current task to parse its components
|
295
|
+
tasks_output = self.list_tasks()
|
296
|
+
task_lines = tasks_output.strip().split("\n")
|
297
|
+
|
298
|
+
# Find the task by its actual number (not array index)
|
299
|
+
current_task = None
|
300
|
+
for line in task_lines:
|
301
|
+
if line.strip():
|
302
|
+
# Extract task number from the beginning of the line (handling ANSI codes)
|
303
|
+
extracted_number = self._extract_task_number(line)
|
304
|
+
if extracted_number == task_number:
|
305
|
+
current_task = line
|
306
|
+
break
|
307
|
+
|
308
|
+
if not current_task:
|
309
|
+
raise TodoShellError(f"Task number {task_number} not found")
|
310
|
+
|
311
|
+
# Parse the current task components
|
312
|
+
components = self._parse_task_components(current_task)
|
313
|
+
|
314
|
+
# Update the parent (None removes it)
|
315
|
+
if parent_number is not None:
|
316
|
+
if not isinstance(parent_number, int) or parent_number <= 0:
|
317
|
+
raise TodoShellError(
|
318
|
+
f"Invalid parent_number '{parent_number}'. Must be a positive integer."
|
319
|
+
)
|
320
|
+
parent_tag = f"parent:{parent_number}"
|
321
|
+
# Remove any existing parent tag and add the new one
|
322
|
+
components["other_tags"] = [
|
323
|
+
tag for tag in components["other_tags"] if not tag.startswith("parent:")
|
324
|
+
]
|
325
|
+
components["other_tags"].append(parent_tag)
|
326
|
+
else:
|
327
|
+
# Remove parent tag
|
328
|
+
components["other_tags"] = [
|
329
|
+
tag for tag in components["other_tags"] if not tag.startswith("parent:")
|
330
|
+
]
|
331
|
+
|
332
|
+
# Reconstruct the task
|
333
|
+
new_description = self._reconstruct_task(components)
|
334
|
+
|
335
|
+
# Replace the task with the new description
|
336
|
+
return self.replace(task_number, new_description)
|
337
|
+
|
255
338
|
def _extract_task_number(self, line: str) -> Optional[int]:
|
256
339
|
"""
|
257
340
|
Extract task number from a line that may contain ANSI color codes.
|
todo_agent/interface/cli.py
CHANGED
@@ -2,8 +2,9 @@
|
|
2
2
|
Command-line interface for todo.sh LLM agent.
|
3
3
|
"""
|
4
4
|
|
5
|
-
from typing import Optional
|
6
5
|
import readline
|
6
|
+
from typing import Optional
|
7
|
+
|
7
8
|
from rich.align import Align
|
8
9
|
from rich.console import Console, Group
|
9
10
|
from rich.live import Live
|
@@ -23,8 +24,8 @@ try:
|
|
23
24
|
TableFormatter,
|
24
25
|
TaskFormatter,
|
25
26
|
)
|
26
|
-
from todo_agent.interface.tools import ToolCallHandler
|
27
27
|
from todo_agent.interface.progress import ToolCallProgress
|
28
|
+
from todo_agent.interface.tools import ToolCallHandler
|
28
29
|
except ImportError:
|
29
30
|
from core.todo_manager import TodoManager # type: ignore[no-redef]
|
30
31
|
from infrastructure.config import Config # type: ignore[no-redef]
|
@@ -38,8 +39,8 @@ except ImportError:
|
|
38
39
|
TableFormatter,
|
39
40
|
TaskFormatter,
|
40
41
|
)
|
41
|
-
from interface.tools import ToolCallHandler # type: ignore[no-redef]
|
42
42
|
from interface.progress import ToolCallProgress # type: ignore[no-redef]
|
43
|
+
from interface.tools import ToolCallHandler # type: ignore[no-redef]
|
43
44
|
|
44
45
|
|
45
46
|
class CLI:
|
@@ -100,22 +101,23 @@ class CLI:
|
|
100
101
|
initial_spinner = self._create_thinking_spinner("Thinking...")
|
101
102
|
return Live(initial_spinner, console=self.console, refresh_per_second=10)
|
102
103
|
|
103
|
-
def _create_tool_call_spinner(
|
104
|
-
|
104
|
+
def _create_tool_call_spinner(
|
105
|
+
self, progress_description: str, sequence: int = 0, total_sequences: int = 0
|
106
|
+
) -> Group:
|
105
107
|
"""
|
106
108
|
Create a multi-line spinner showing tool call progress.
|
107
|
-
|
109
|
+
|
108
110
|
Args:
|
109
111
|
progress_description: User-friendly description of what the tool is doing
|
110
112
|
sequence: Current sequence number
|
111
113
|
total_sequences: Total number of sequences
|
112
|
-
|
114
|
+
|
113
115
|
Returns:
|
114
116
|
Group object with spinner and optional sequence info
|
115
117
|
"""
|
116
118
|
# Line 1: Main progress with spinner
|
117
119
|
main_line = Spinner("dots", text=Text(progress_description, style="cyan"))
|
118
|
-
|
120
|
+
|
119
121
|
# Line 2: Sequence progress (show current sequence even if we don't know total)
|
120
122
|
# if sequence > 0:
|
121
123
|
# if total_sequences > 0:
|
@@ -123,75 +125,87 @@ class CLI:
|
|
123
125
|
# else:
|
124
126
|
# sequence_text = Text(f"Sequence {sequence}", style="dim")
|
125
127
|
# return Group(main_line, sequence_text)
|
126
|
-
|
127
|
-
return main_line
|
128
|
+
|
129
|
+
return Group(main_line)
|
128
130
|
|
129
131
|
def _create_completion_spinner(self, thinking_time: float) -> Spinner:
|
130
132
|
"""
|
131
133
|
Create completion spinner with timing.
|
132
|
-
|
134
|
+
|
133
135
|
Args:
|
134
136
|
thinking_time: Total thinking time in seconds
|
135
|
-
|
137
|
+
|
136
138
|
Returns:
|
137
139
|
Spinner object showing completion
|
138
140
|
"""
|
139
|
-
return Spinner(
|
141
|
+
return Spinner(
|
142
|
+
"dots", text=Text(f"✅ Complete ({thinking_time:.1f}s)", style="green")
|
143
|
+
)
|
140
144
|
|
141
145
|
def _create_cli_progress_callback(self, live_display: Live) -> ToolCallProgress:
|
142
146
|
"""
|
143
147
|
Create a CLI-specific progress callback for tool call tracking.
|
144
|
-
|
148
|
+
|
145
149
|
Args:
|
146
150
|
live_display: The live display to update
|
147
|
-
|
151
|
+
|
148
152
|
Returns:
|
149
153
|
ToolCallProgress implementation for CLI
|
150
154
|
"""
|
155
|
+
|
151
156
|
class CLIProgressCallback(ToolCallProgress):
|
152
157
|
def __init__(self, cli: CLI, live: Live):
|
153
158
|
self.cli = cli
|
154
159
|
self.live = live
|
155
160
|
self.current_sequence = 0
|
156
161
|
self.total_sequences = 0
|
157
|
-
|
162
|
+
|
158
163
|
def on_thinking_start(self) -> None:
|
159
164
|
"""Show initial thinking spinner."""
|
160
|
-
spinner = self.cli._create_thinking_spinner(
|
165
|
+
spinner = self.cli._create_thinking_spinner(
|
166
|
+
"🤔 Analyzing your request..."
|
167
|
+
)
|
161
168
|
self.live.update(spinner)
|
162
|
-
|
163
|
-
def on_tool_call_start(
|
164
|
-
|
169
|
+
|
170
|
+
def on_tool_call_start(
|
171
|
+
self,
|
172
|
+
tool_name: str,
|
173
|
+
progress_description: str,
|
174
|
+
sequence: int,
|
175
|
+
total_sequences: int,
|
176
|
+
) -> None:
|
165
177
|
"""Show tool execution progress."""
|
166
178
|
self.current_sequence = sequence
|
167
179
|
self.total_sequences = total_sequences
|
168
|
-
|
180
|
+
|
169
181
|
# Create multi-line spinner
|
170
182
|
spinner = self.cli._create_tool_call_spinner(
|
171
183
|
progress_description=progress_description,
|
172
184
|
sequence=sequence,
|
173
|
-
total_sequences=total_sequences
|
185
|
+
total_sequences=total_sequences,
|
174
186
|
)
|
175
187
|
self.live.update(spinner)
|
176
|
-
|
177
|
-
def on_tool_call_complete(
|
188
|
+
|
189
|
+
def on_tool_call_complete(
|
190
|
+
self, tool_name: str, success: bool, duration: float
|
191
|
+
) -> None:
|
178
192
|
"""Tool completion - no action needed."""
|
179
193
|
pass
|
180
|
-
|
194
|
+
|
181
195
|
def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
|
182
196
|
"""Show sequence completion."""
|
183
197
|
spinner = self.cli._create_tool_call_spinner(
|
184
198
|
progress_description=f"🔄 Sequence {sequence} complete",
|
185
199
|
sequence=sequence,
|
186
|
-
total_sequences=total_sequences
|
200
|
+
total_sequences=total_sequences,
|
187
201
|
)
|
188
202
|
self.live.update(spinner)
|
189
|
-
|
203
|
+
|
190
204
|
def on_thinking_complete(self, total_time: float) -> None:
|
191
205
|
"""Show completion spinner."""
|
192
206
|
spinner = self.cli._create_completion_spinner(total_time)
|
193
207
|
self.live.update(spinner)
|
194
|
-
|
208
|
+
|
195
209
|
return CLIProgressCallback(self, live_display)
|
196
210
|
|
197
211
|
def _print_header(self) -> None:
|
@@ -296,7 +310,8 @@ class CLI:
|
|
296
310
|
if user_input.lower() == "list":
|
297
311
|
self.logger.debug("User requested task list")
|
298
312
|
try:
|
299
|
-
|
313
|
+
# Use suppress_color=False for interactive display to preserve colors
|
314
|
+
output = self.todo_shell.list_tasks(suppress_color=False)
|
300
315
|
formatted_output = TaskFormatter.format_task_list(output)
|
301
316
|
task_panel = PanelFormatter.create_task_panel(formatted_output)
|
302
317
|
self.console.print(task_panel)
|
@@ -311,7 +326,8 @@ class CLI:
|
|
311
326
|
if user_input.lower() == "done":
|
312
327
|
self.logger.debug("User requested completed task list")
|
313
328
|
try:
|
314
|
-
|
329
|
+
# Use suppress_color=False for interactive display to preserve colors
|
330
|
+
output = self.todo_shell.list_completed(suppress_color=False)
|
315
331
|
formatted_output = TaskFormatter.format_completed_tasks(output)
|
316
332
|
task_panel = PanelFormatter.create_task_panel(
|
317
333
|
formatted_output, title="✅ Completed Tasks"
|
@@ -336,7 +352,7 @@ class CLI:
|
|
336
352
|
# Get memory usage
|
337
353
|
# DISABLED FOR NOW
|
338
354
|
memory_usage = self._get_memory_usage()
|
339
|
-
#memory_usage = None
|
355
|
+
# memory_usage = None
|
340
356
|
|
341
357
|
# Create response panel with memory usage
|
342
358
|
response_panel = PanelFormatter.create_response_panel(
|
@@ -368,9 +384,11 @@ class CLI:
|
|
368
384
|
try:
|
369
385
|
# Create progress callback for tool call tracking
|
370
386
|
progress_callback = self._create_cli_progress_callback(live)
|
371
|
-
|
387
|
+
|
372
388
|
# Process request through inference engine with progress tracking
|
373
|
-
response, thinking_time = self.inference.process_request(
|
389
|
+
response, thinking_time = self.inference.process_request(
|
390
|
+
user_input, progress_callback
|
391
|
+
)
|
374
392
|
|
375
393
|
# Update spinner with completion message and thinking time
|
376
394
|
live.update(
|
@@ -21,20 +21,23 @@ PROVIDER_ERROR_MESSAGES = {
|
|
21
21
|
"rate_limit": "I'm a bit overwhelmed right now. Please wait a moment and try again, or type 'clear' to start fresh.",
|
22
22
|
"auth_error": "I can't connect to my AI service. Please check your configuration, or type 'clear' to reset.",
|
23
23
|
"timeout": "The request took too long. Please try again, or type 'clear' to reset our conversation.",
|
24
|
-
"general_error": "Something went wrong with my AI service. Please try again, or type 'clear' to reset our conversation."
|
24
|
+
"general_error": "Something went wrong with my AI service. Please try again, or type 'clear' to reset our conversation.",
|
25
25
|
}
|
26
26
|
|
27
|
+
|
27
28
|
def get_provider_error_message(error_type: str) -> str:
|
28
29
|
"""
|
29
30
|
Get user-friendly error message for provider errors.
|
30
|
-
|
31
|
+
|
31
32
|
Args:
|
32
33
|
error_type: The type of provider error
|
33
|
-
|
34
|
+
|
34
35
|
Returns:
|
35
36
|
User-friendly error message with recovery suggestion
|
36
37
|
"""
|
37
|
-
return PROVIDER_ERROR_MESSAGES.get(
|
38
|
+
return PROVIDER_ERROR_MESSAGES.get(
|
39
|
+
error_type, PROVIDER_ERROR_MESSAGES["general_error"]
|
40
|
+
)
|
38
41
|
|
39
42
|
|
40
43
|
class TaskFormatter:
|
todo_agent/interface/progress.py
CHANGED
@@ -3,34 +3,39 @@ Progress tracking interface for tool call execution.
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
from abc import ABC, abstractmethod
|
6
|
-
from typing import Optional
|
7
6
|
|
8
7
|
|
9
8
|
class ToolCallProgress(ABC):
|
10
9
|
"""Abstract interface for tool call progress tracking."""
|
11
|
-
|
10
|
+
|
12
11
|
@abstractmethod
|
13
12
|
def on_thinking_start(self) -> None:
|
14
13
|
"""Called when LLM starts thinking."""
|
15
14
|
pass
|
16
|
-
|
15
|
+
|
17
16
|
@abstractmethod
|
18
|
-
def on_tool_call_start(
|
19
|
-
|
17
|
+
def on_tool_call_start(
|
18
|
+
self,
|
19
|
+
tool_name: str,
|
20
|
+
progress_description: str,
|
21
|
+
sequence: int,
|
22
|
+
total_sequences: int,
|
23
|
+
) -> None:
|
20
24
|
"""Called when a tool call starts."""
|
21
25
|
pass
|
22
|
-
|
26
|
+
|
23
27
|
@abstractmethod
|
24
|
-
def on_tool_call_complete(
|
25
|
-
|
28
|
+
def on_tool_call_complete(
|
29
|
+
self, tool_name: str, success: bool, duration: float
|
30
|
+
) -> None:
|
26
31
|
"""Called when a tool call completes (optional - no action needed)."""
|
27
32
|
pass
|
28
|
-
|
33
|
+
|
29
34
|
@abstractmethod
|
30
35
|
def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
|
31
36
|
"""Called when a tool call sequence completes."""
|
32
37
|
pass
|
33
|
-
|
38
|
+
|
34
39
|
@abstractmethod
|
35
40
|
def on_thinking_complete(self, total_time: float) -> None:
|
36
41
|
"""Called when thinking is complete."""
|
@@ -39,20 +44,26 @@ class ToolCallProgress(ABC):
|
|
39
44
|
|
40
45
|
class NoOpProgress(ToolCallProgress):
|
41
46
|
"""No-operation implementation for when progress tracking is not needed."""
|
42
|
-
|
47
|
+
|
43
48
|
def on_thinking_start(self) -> None:
|
44
49
|
pass
|
45
|
-
|
46
|
-
def on_tool_call_start(
|
47
|
-
|
50
|
+
|
51
|
+
def on_tool_call_start(
|
52
|
+
self,
|
53
|
+
tool_name: str,
|
54
|
+
progress_description: str,
|
55
|
+
sequence: int,
|
56
|
+
total_sequences: int,
|
57
|
+
) -> None:
|
48
58
|
pass
|
49
|
-
|
50
|
-
def on_tool_call_complete(
|
51
|
-
|
59
|
+
|
60
|
+
def on_tool_call_complete(
|
61
|
+
self, tool_name: str, success: bool, duration: float
|
62
|
+
) -> None:
|
52
63
|
pass
|
53
|
-
|
64
|
+
|
54
65
|
def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
|
55
66
|
pass
|
56
|
-
|
67
|
+
|
57
68
|
def on_thinking_complete(self, total_time: float) -> None:
|
58
69
|
pass
|