todo-agent 0.2.9__py3-none-any.whl → 0.3.2__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/conversation_manager.py +1 -1
- todo_agent/core/exceptions.py +54 -3
- todo_agent/core/todo_manager.py +142 -44
- todo_agent/infrastructure/calendar_utils.py +2 -4
- todo_agent/infrastructure/inference.py +99 -53
- todo_agent/infrastructure/llm_client.py +224 -1
- todo_agent/infrastructure/ollama_client.py +68 -77
- todo_agent/infrastructure/openrouter_client.py +75 -78
- todo_agent/infrastructure/prompts/system_prompt.txt +429 -401
- todo_agent/infrastructure/todo_shell.py +28 -28
- todo_agent/interface/cli.py +110 -18
- todo_agent/interface/formatters.py +22 -0
- todo_agent/interface/progress.py +58 -0
- todo_agent/interface/tools.py +211 -139
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/METADATA +3 -3
- todo_agent-0.3.2.dist-info/RECORD +30 -0
- todo_agent-0.2.9.dist-info/RECORD +0 -29
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/WHEEL +0 -0
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,7 @@ Subprocess wrapper for todo.sh operations.
|
|
4
4
|
|
5
5
|
import os
|
6
6
|
import subprocess # nosec B404
|
7
|
-
from typing import Any, List, Optional
|
7
|
+
from typing import Any, List, Optional, TypedDict
|
8
8
|
|
9
9
|
try:
|
10
10
|
from todo_agent.core.exceptions import TodoShellError
|
@@ -12,6 +12,18 @@ except ImportError:
|
|
12
12
|
from core.exceptions import TodoShellError # type: ignore[no-redef]
|
13
13
|
|
14
14
|
|
15
|
+
class TaskComponents(TypedDict):
|
16
|
+
"""Type definition for task components."""
|
17
|
+
|
18
|
+
priority: str | None
|
19
|
+
description: str
|
20
|
+
projects: list[str]
|
21
|
+
contexts: list[str]
|
22
|
+
due: str | None
|
23
|
+
|
24
|
+
other_tags: list[str]
|
25
|
+
|
26
|
+
|
15
27
|
class TodoShell:
|
16
28
|
"""Subprocess execution wrapper with error management."""
|
17
29
|
|
@@ -243,19 +255,19 @@ class TodoShell:
|
|
243
255
|
def _extract_task_number(self, line: str) -> Optional[int]:
|
244
256
|
"""
|
245
257
|
Extract task number from a line that may contain ANSI color codes.
|
246
|
-
|
258
|
+
|
247
259
|
Args:
|
248
260
|
line: Task line that may contain ANSI color codes
|
249
|
-
|
261
|
+
|
250
262
|
Returns:
|
251
263
|
Task number if found, None otherwise
|
252
264
|
"""
|
253
265
|
from rich.text import Text
|
254
|
-
|
266
|
+
|
255
267
|
# Use rich to properly handle ANSI color codes
|
256
268
|
text = Text.from_ansi(line)
|
257
269
|
clean_line = text.plain
|
258
|
-
|
270
|
+
|
259
271
|
# Split on first space and check if first part is a number
|
260
272
|
parts = clean_line.split(" ", 1)
|
261
273
|
if parts and parts[0].isdigit():
|
@@ -319,7 +331,8 @@ class TodoShell:
|
|
319
331
|
# Remove the project if it exists (with or without + prefix)
|
320
332
|
project_to_remove = f"+{clean_project}"
|
321
333
|
components["projects"] = [
|
322
|
-
p
|
334
|
+
p
|
335
|
+
for p in components["projects"]
|
323
336
|
if p != project_to_remove and p != clean_project
|
324
337
|
]
|
325
338
|
else:
|
@@ -345,7 +358,7 @@ class TodoShell:
|
|
345
358
|
# Replace the task with the new description
|
346
359
|
return self.replace(task_number, new_description)
|
347
360
|
|
348
|
-
def _parse_task_components(self, task_line: str) ->
|
361
|
+
def _parse_task_components(self, task_line: str) -> TaskComponents:
|
349
362
|
"""
|
350
363
|
Parse a todo.txt task line into its components.
|
351
364
|
|
@@ -357,9 +370,10 @@ class TodoShell:
|
|
357
370
|
"""
|
358
371
|
# Remove ANSI color codes first using rich
|
359
372
|
from rich.text import Text
|
373
|
+
|
360
374
|
text = Text.from_ansi(task_line)
|
361
375
|
task_line = text.plain
|
362
|
-
|
376
|
+
|
363
377
|
# Remove task number prefix if present (e.g., "1 " or "1. ")
|
364
378
|
# First try the format without dot (standard todo.sh format)
|
365
379
|
if " " in task_line and task_line.split(" ")[0].isdigit():
|
@@ -368,13 +382,12 @@ class TodoShell:
|
|
368
382
|
elif ". " in task_line:
|
369
383
|
task_line = task_line.split(". ", 1)[1]
|
370
384
|
|
371
|
-
components = {
|
385
|
+
components: TaskComponents = {
|
372
386
|
"priority": None,
|
373
387
|
"description": "",
|
374
388
|
"projects": [],
|
375
389
|
"contexts": [],
|
376
390
|
"due": None,
|
377
|
-
"recurring": None,
|
378
391
|
"other_tags": [],
|
379
392
|
}
|
380
393
|
|
@@ -409,17 +422,8 @@ class TodoShell:
|
|
409
422
|
components["due"] = word[4:] # Remove 'due:' prefix
|
410
423
|
continue
|
411
424
|
|
412
|
-
# Recurring: rec:frequency[:interval]
|
413
|
-
if word.startswith("rec:"):
|
414
|
-
components["recurring"] = word
|
415
|
-
continue
|
416
|
-
|
417
425
|
# Other tags (like custom tags)
|
418
|
-
if (
|
419
|
-
":" in word
|
420
|
-
and not word.startswith("due:")
|
421
|
-
and not word.startswith("rec:")
|
422
|
-
):
|
426
|
+
if ":" in word and not word.startswith("due:"):
|
423
427
|
other_tags_set.add(word)
|
424
428
|
continue
|
425
429
|
|
@@ -430,13 +434,13 @@ class TodoShell:
|
|
430
434
|
components["description"] = word
|
431
435
|
|
432
436
|
# Convert sets back to sorted lists for consistent ordering
|
433
|
-
components["projects"] = sorted(
|
434
|
-
components["contexts"] = sorted(
|
435
|
-
components["other_tags"] = sorted(
|
437
|
+
components["projects"] = sorted(projects_set)
|
438
|
+
components["contexts"] = sorted(contexts_set)
|
439
|
+
components["other_tags"] = sorted(other_tags_set)
|
436
440
|
|
437
441
|
return components
|
438
442
|
|
439
|
-
def _reconstruct_task(self, components:
|
443
|
+
def _reconstruct_task(self, components: TaskComponents) -> str:
|
440
444
|
"""
|
441
445
|
Reconstruct a task description from parsed components.
|
442
446
|
|
@@ -466,10 +470,6 @@ class TodoShell:
|
|
466
470
|
if components["due"]:
|
467
471
|
parts.append(f"due:{components['due']}")
|
468
472
|
|
469
|
-
# Add recurring pattern
|
470
|
-
if components["recurring"]:
|
471
|
-
parts.append(components["recurring"])
|
472
|
-
|
473
473
|
# Add other tags
|
474
474
|
parts.extend(components["other_tags"])
|
475
475
|
|
todo_agent/interface/cli.py
CHANGED
@@ -3,16 +3,14 @@ Command-line interface for todo.sh LLM agent.
|
|
3
3
|
"""
|
4
4
|
|
5
5
|
from typing import Optional
|
6
|
+
import readline
|
7
|
+
from rich.align import Align
|
8
|
+
from rich.console import Console, Group
|
9
|
+
from rich.live import Live
|
10
|
+
from rich.spinner import Spinner
|
11
|
+
from rich.text import Text
|
6
12
|
|
7
13
|
try:
|
8
|
-
import readline
|
9
|
-
|
10
|
-
from rich.align import Align
|
11
|
-
from rich.console import Console
|
12
|
-
from rich.live import Live
|
13
|
-
from rich.spinner import Spinner
|
14
|
-
from rich.text import Text
|
15
|
-
|
16
14
|
from todo_agent.core.todo_manager import TodoManager
|
17
15
|
from todo_agent.infrastructure.config import Config
|
18
16
|
from todo_agent.infrastructure.inference import Inference
|
@@ -26,6 +24,7 @@ try:
|
|
26
24
|
TaskFormatter,
|
27
25
|
)
|
28
26
|
from todo_agent.interface.tools import ToolCallHandler
|
27
|
+
from todo_agent.interface.progress import ToolCallProgress
|
29
28
|
except ImportError:
|
30
29
|
from core.todo_manager import TodoManager # type: ignore[no-redef]
|
31
30
|
from infrastructure.config import Config # type: ignore[no-redef]
|
@@ -40,11 +39,7 @@ except ImportError:
|
|
40
39
|
TaskFormatter,
|
41
40
|
)
|
42
41
|
from interface.tools import ToolCallHandler # type: ignore[no-redef]
|
43
|
-
from
|
44
|
-
from rich.console import Console
|
45
|
-
from rich.live import Live
|
46
|
-
from rich.spinner import Spinner
|
47
|
-
from rich.text import Text
|
42
|
+
from interface.progress import ToolCallProgress # type: ignore[no-redef]
|
48
43
|
|
49
44
|
|
50
45
|
class CLI:
|
@@ -105,6 +100,100 @@ class CLI:
|
|
105
100
|
initial_spinner = self._create_thinking_spinner("Thinking...")
|
106
101
|
return Live(initial_spinner, console=self.console, refresh_per_second=10)
|
107
102
|
|
103
|
+
def _create_tool_call_spinner(self, progress_description: str,
|
104
|
+
sequence: int = 0, total_sequences: int = 0) -> Group:
|
105
|
+
"""
|
106
|
+
Create a multi-line spinner showing tool call progress.
|
107
|
+
|
108
|
+
Args:
|
109
|
+
progress_description: User-friendly description of what the tool is doing
|
110
|
+
sequence: Current sequence number
|
111
|
+
total_sequences: Total number of sequences
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
Group object with spinner and optional sequence info
|
115
|
+
"""
|
116
|
+
# Line 1: Main progress with spinner
|
117
|
+
main_line = Spinner("dots", text=Text(progress_description, style="cyan"))
|
118
|
+
|
119
|
+
# Line 2: Sequence progress (show current sequence even if we don't know total)
|
120
|
+
# if sequence > 0:
|
121
|
+
# if total_sequences > 0:
|
122
|
+
# sequence_text = Text(f"Sequence {sequence}/{total_sequences}", style="dim")
|
123
|
+
# else:
|
124
|
+
# sequence_text = Text(f"Sequence {sequence}", style="dim")
|
125
|
+
# return Group(main_line, sequence_text)
|
126
|
+
|
127
|
+
return main_line
|
128
|
+
|
129
|
+
def _create_completion_spinner(self, thinking_time: float) -> Spinner:
|
130
|
+
"""
|
131
|
+
Create completion spinner with timing.
|
132
|
+
|
133
|
+
Args:
|
134
|
+
thinking_time: Total thinking time in seconds
|
135
|
+
|
136
|
+
Returns:
|
137
|
+
Spinner object showing completion
|
138
|
+
"""
|
139
|
+
return Spinner("dots", text=Text(f"✅ Complete ({thinking_time:.1f}s)", style="green"))
|
140
|
+
|
141
|
+
def _create_cli_progress_callback(self, live_display: Live) -> ToolCallProgress:
|
142
|
+
"""
|
143
|
+
Create a CLI-specific progress callback for tool call tracking.
|
144
|
+
|
145
|
+
Args:
|
146
|
+
live_display: The live display to update
|
147
|
+
|
148
|
+
Returns:
|
149
|
+
ToolCallProgress implementation for CLI
|
150
|
+
"""
|
151
|
+
class CLIProgressCallback(ToolCallProgress):
|
152
|
+
def __init__(self, cli: CLI, live: Live):
|
153
|
+
self.cli = cli
|
154
|
+
self.live = live
|
155
|
+
self.current_sequence = 0
|
156
|
+
self.total_sequences = 0
|
157
|
+
|
158
|
+
def on_thinking_start(self) -> None:
|
159
|
+
"""Show initial thinking spinner."""
|
160
|
+
spinner = self.cli._create_thinking_spinner("🤔 Analyzing your request...")
|
161
|
+
self.live.update(spinner)
|
162
|
+
|
163
|
+
def on_tool_call_start(self, tool_name: str, progress_description: str,
|
164
|
+
sequence: int, total_sequences: int) -> None:
|
165
|
+
"""Show tool execution progress."""
|
166
|
+
self.current_sequence = sequence
|
167
|
+
self.total_sequences = total_sequences
|
168
|
+
|
169
|
+
# Create multi-line spinner
|
170
|
+
spinner = self.cli._create_tool_call_spinner(
|
171
|
+
progress_description=progress_description,
|
172
|
+
sequence=sequence,
|
173
|
+
total_sequences=total_sequences
|
174
|
+
)
|
175
|
+
self.live.update(spinner)
|
176
|
+
|
177
|
+
def on_tool_call_complete(self, tool_name: str, success: bool, duration: float) -> None:
|
178
|
+
"""Tool completion - no action needed."""
|
179
|
+
pass
|
180
|
+
|
181
|
+
def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
|
182
|
+
"""Show sequence completion."""
|
183
|
+
spinner = self.cli._create_tool_call_spinner(
|
184
|
+
progress_description=f"🔄 Sequence {sequence} complete",
|
185
|
+
sequence=sequence,
|
186
|
+
total_sequences=total_sequences
|
187
|
+
)
|
188
|
+
self.live.update(spinner)
|
189
|
+
|
190
|
+
def on_thinking_complete(self, total_time: float) -> None:
|
191
|
+
"""Show completion spinner."""
|
192
|
+
spinner = self.cli._create_completion_spinner(total_time)
|
193
|
+
self.live.update(spinner)
|
194
|
+
|
195
|
+
return CLIProgressCallback(self, live_display)
|
196
|
+
|
108
197
|
def _print_header(self) -> None:
|
109
198
|
"""Print the application header with unicode borders."""
|
110
199
|
header_panel = PanelFormatter.create_header_panel()
|
@@ -244,10 +333,10 @@ class CLI:
|
|
244
333
|
# Format the response and create a panel
|
245
334
|
formatted_response = ResponseFormatter.format_response(response)
|
246
335
|
|
247
|
-
# Get memory usage
|
336
|
+
# Get memory usage
|
248
337
|
# DISABLED FOR NOW
|
249
|
-
|
250
|
-
memory_usage = None
|
338
|
+
memory_usage = self._get_memory_usage()
|
339
|
+
#memory_usage = None
|
251
340
|
|
252
341
|
# Create response panel with memory usage
|
253
342
|
response_panel = PanelFormatter.create_response_panel(
|
@@ -277,8 +366,11 @@ class CLI:
|
|
277
366
|
# Show thinking spinner during LLM processing
|
278
367
|
with self._get_thinking_live() as live:
|
279
368
|
try:
|
280
|
-
#
|
281
|
-
|
369
|
+
# Create progress callback for tool call tracking
|
370
|
+
progress_callback = self._create_cli_progress_callback(live)
|
371
|
+
|
372
|
+
# Process request through inference engine with progress tracking
|
373
|
+
response, thinking_time = self.inference.process_request(user_input, progress_callback)
|
282
374
|
|
283
375
|
# Update spinner with completion message and thinking time
|
284
376
|
live.update(
|
@@ -14,6 +14,28 @@ from rich.text import Text
|
|
14
14
|
CLI_WIDTH = 100
|
15
15
|
PANEL_WIDTH = CLI_WIDTH - 2 # Leave 2 characters for borders
|
16
16
|
|
17
|
+
# Provider error message mapping
|
18
|
+
PROVIDER_ERROR_MESSAGES = {
|
19
|
+
"malformed_response": "I got a confusing response from my AI service. Please try again, or type 'clear' to reset our conversation.",
|
20
|
+
"malformed_tool_call": "I received a malformed request. Please try again, or type 'clear' to reset our conversation.",
|
21
|
+
"rate_limit": "I'm a bit overwhelmed right now. Please wait a moment and try again, or type 'clear' to start fresh.",
|
22
|
+
"auth_error": "I can't connect to my AI service. Please check your configuration, or type 'clear' to reset.",
|
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."
|
25
|
+
}
|
26
|
+
|
27
|
+
def get_provider_error_message(error_type: str) -> str:
|
28
|
+
"""
|
29
|
+
Get user-friendly error message for provider errors.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
error_type: The type of provider error
|
33
|
+
|
34
|
+
Returns:
|
35
|
+
User-friendly error message with recovery suggestion
|
36
|
+
"""
|
37
|
+
return PROVIDER_ERROR_MESSAGES.get(error_type, PROVIDER_ERROR_MESSAGES["general_error"])
|
38
|
+
|
17
39
|
|
18
40
|
class TaskFormatter:
|
19
41
|
"""Formats task-related output with unicode characters and consistent styling."""
|
@@ -0,0 +1,58 @@
|
|
1
|
+
"""
|
2
|
+
Progress tracking interface for tool call execution.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from abc import ABC, abstractmethod
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
|
9
|
+
class ToolCallProgress(ABC):
|
10
|
+
"""Abstract interface for tool call progress tracking."""
|
11
|
+
|
12
|
+
@abstractmethod
|
13
|
+
def on_thinking_start(self) -> None:
|
14
|
+
"""Called when LLM starts thinking."""
|
15
|
+
pass
|
16
|
+
|
17
|
+
@abstractmethod
|
18
|
+
def on_tool_call_start(self, tool_name: str, progress_description: str,
|
19
|
+
sequence: int, total_sequences: int) -> None:
|
20
|
+
"""Called when a tool call starts."""
|
21
|
+
pass
|
22
|
+
|
23
|
+
@abstractmethod
|
24
|
+
def on_tool_call_complete(self, tool_name: str, success: bool,
|
25
|
+
duration: float) -> None:
|
26
|
+
"""Called when a tool call completes (optional - no action needed)."""
|
27
|
+
pass
|
28
|
+
|
29
|
+
@abstractmethod
|
30
|
+
def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
|
31
|
+
"""Called when a tool call sequence completes."""
|
32
|
+
pass
|
33
|
+
|
34
|
+
@abstractmethod
|
35
|
+
def on_thinking_complete(self, total_time: float) -> None:
|
36
|
+
"""Called when thinking is complete."""
|
37
|
+
pass
|
38
|
+
|
39
|
+
|
40
|
+
class NoOpProgress(ToolCallProgress):
|
41
|
+
"""No-operation implementation for when progress tracking is not needed."""
|
42
|
+
|
43
|
+
def on_thinking_start(self) -> None:
|
44
|
+
pass
|
45
|
+
|
46
|
+
def on_tool_call_start(self, tool_name: str, progress_description: str,
|
47
|
+
sequence: int, total_sequences: int) -> None:
|
48
|
+
pass
|
49
|
+
|
50
|
+
def on_tool_call_complete(self, tool_name: str, success: bool,
|
51
|
+
duration: float) -> None:
|
52
|
+
pass
|
53
|
+
|
54
|
+
def on_sequence_complete(self, sequence: int, total_sequences: int) -> None:
|
55
|
+
pass
|
56
|
+
|
57
|
+
def on_thinking_complete(self, total_time: float) -> None:
|
58
|
+
pass
|