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 CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.9'
32
- __version_tuple__ = version_tuple = (0, 2, 9)
31
+ __version__ = version = '0.3.2'
32
+ __version_tuple__ = version_tuple = (0, 3, 2)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -35,7 +35,7 @@ class ConversationManager:
35
35
  """Manages conversation state and memory for LLM interactions."""
36
36
 
37
37
  def __init__(
38
- self, max_tokens: int = 32000, max_messages: int = 50, model: str = "gpt-4"
38
+ self, max_tokens: int = 64000, max_messages: int = 100, model: str = "gpt-4"
39
39
  ):
40
40
  self.history: List[ConversationMessage] = []
41
41
  self.max_tokens = max_tokens
@@ -12,16 +12,67 @@ class TodoError(Exception):
12
12
  class TaskNotFoundError(TodoError):
13
13
  """Task not found in todo file."""
14
14
 
15
- pass
15
+ def __init__(self, message: str = "Task not found"):
16
+ super().__init__(message)
17
+ self.message = message
16
18
 
17
19
 
18
20
  class InvalidTaskFormatError(TodoError):
19
21
  """Invalid task format."""
20
22
 
21
- pass
23
+ def __init__(self, message: str = "Invalid task format"):
24
+ super().__init__(message)
25
+ self.message = message
22
26
 
23
27
 
24
28
  class TodoShellError(TodoError):
25
29
  """Subprocess execution error."""
26
30
 
27
- pass
31
+ def __init__(self, message: str = "Todo.sh command failed"):
32
+ super().__init__(message)
33
+ self.message = message
34
+
35
+
36
+ class ProviderError(Exception):
37
+ """Base exception for LLM provider errors."""
38
+
39
+ def __init__(self, message: str, error_type: str, provider: str):
40
+ super().__init__(message)
41
+ self.message = message
42
+ self.error_type = error_type
43
+ self.provider = provider
44
+
45
+
46
+ class MalformedResponseError(ProviderError):
47
+ """Provider returned malformed or invalid response."""
48
+
49
+ def __init__(self, message: str, provider: str):
50
+ super().__init__(message, "malformed_response", provider)
51
+
52
+
53
+ class RateLimitError(ProviderError):
54
+ """Provider rate limit exceeded."""
55
+
56
+ def __init__(self, message: str, provider: str):
57
+ super().__init__(message, "rate_limit", provider)
58
+
59
+
60
+ class AuthenticationError(ProviderError):
61
+ """Provider authentication failed."""
62
+
63
+ def __init__(self, message: str, provider: str):
64
+ super().__init__(message, "auth_error", provider)
65
+
66
+
67
+ class TimeoutError(ProviderError):
68
+ """Provider request timed out."""
69
+
70
+ def __init__(self, message: str, provider: str):
71
+ super().__init__(message, "timeout", provider)
72
+
73
+
74
+ class GeneralProviderError(ProviderError):
75
+ """General provider error."""
76
+
77
+ def __init__(self, message: str, provider: str):
78
+ super().__init__(message, "general_error", provider)
@@ -19,7 +19,7 @@ class TodoManager:
19
19
  project: Optional[str] = None,
20
20
  context: Optional[str] = None,
21
21
  due: Optional[str] = None,
22
- recurring: Optional[str] = None,
22
+ duration: Optional[str] = None,
23
23
  ) -> str:
24
24
  """Add new task with explicit project/context parameters."""
25
25
  # Validate and sanitize inputs
@@ -55,32 +55,31 @@ class TodoManager:
55
55
  f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
56
56
  )
57
57
 
58
- if recurring:
59
- # Validate recurring format
60
- if not recurring.startswith("rec:"):
61
- raise ValueError(
62
- f"Invalid recurring format '{recurring}'. Must start with 'rec:'."
63
- )
64
- # Basic validation of recurring syntax
65
- parts = recurring.split(":")
66
- if len(parts) < 2 or len(parts) > 3:
58
+ if duration is not None:
59
+ # Validate duration format (e.g., "30m", "2h", "1d")
60
+ if not duration or not isinstance(duration, str):
61
+ raise ValueError("Duration must be a non-empty string.")
62
+
63
+ # Check if duration ends with a valid unit
64
+ if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
67
65
  raise ValueError(
68
- f"Invalid recurring format '{recurring}'. Expected 'rec:frequency' or 'rec:frequency:interval'."
66
+ f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
69
67
  )
70
- frequency = parts[1]
71
- if frequency not in ["daily", "weekly", "monthly", "yearly"]:
68
+
69
+ # Extract the numeric part and validate it
70
+ value = duration[:-1]
71
+ if not value:
72
+ raise ValueError("Duration value cannot be empty.")
73
+
74
+ try:
75
+ # Check if the value is a valid positive number
76
+ numeric_value = float(value)
77
+ if numeric_value <= 0:
78
+ raise ValueError("Duration value must be positive.")
79
+ except ValueError:
72
80
  raise ValueError(
73
- f"Invalid frequency '{frequency}'. Must be one of: daily, weekly, monthly, yearly."
81
+ f"Invalid duration value '{value}'. Must be a positive number."
74
82
  )
75
- if len(parts) == 3:
76
- try:
77
- interval = int(parts[2])
78
- if interval < 1:
79
- raise ValueError("Interval must be at least 1.")
80
- except ValueError:
81
- raise ValueError(
82
- f"Invalid interval '{parts[2]}'. Must be a positive integer."
83
- )
84
83
 
85
84
  # Build the full task description with priority, project, and context
86
85
  full_description = description
@@ -97,8 +96,8 @@ class TodoManager:
97
96
  if due:
98
97
  full_description = f"{full_description} due:{due}"
99
98
 
100
- if recurring:
101
- full_description = f"{full_description} {recurring}"
99
+ if duration:
100
+ full_description = f"{full_description} duration:{duration}"
102
101
 
103
102
  self.todo_shell.add(full_description)
104
103
  return f"Added task: {full_description}"
@@ -118,24 +117,6 @@ class TodoManager:
118
117
  result = self.todo_shell.complete(task_number)
119
118
  return f"Completed task {task_number}: {result}"
120
119
 
121
- def get_overview(self, **kwargs: Any) -> str:
122
- """Show current task statistics."""
123
- tasks = self.todo_shell.list_tasks()
124
- completed = self.todo_shell.list_completed()
125
-
126
- task_count = (
127
- len([line for line in tasks.split("\n") if line.strip()])
128
- if tasks.strip()
129
- else 0
130
- )
131
- completed_count = (
132
- len([line for line in completed.split("\n") if line.strip()])
133
- if completed.strip()
134
- else 0
135
- )
136
-
137
- return f"Task Overview:\n- Active tasks: {task_count}\n- Completed tasks: {completed_count}"
138
-
139
120
  def replace_task(self, task_number: int, new_description: str) -> str:
140
121
  """Replace entire task content."""
141
122
  result = self.todo_shell.replace(task_number, new_description)
@@ -373,4 +354,121 @@ class TodoManager:
373
354
  def get_current_datetime(self, **kwargs: Any) -> str:
374
355
  """Get the current date and time."""
375
356
  now = datetime.now()
376
- return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} ({now.strftime('%A, %B %d, %Y at %I:%M %p')})"
357
+ week_number = now.isocalendar()[1]
358
+ timezone = now.astimezone().tzinfo
359
+ return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} {timezone} ({now.strftime('%A, %B %d, %Y at %I:%M %p')}) - Week {week_number}"
360
+
361
+ def created_completed_task(
362
+ self,
363
+ description: str,
364
+ completion_date: Optional[str] = None,
365
+ project: Optional[str] = None,
366
+ context: Optional[str] = None,
367
+ ) -> str:
368
+ """
369
+ Create a task and immediately mark it as completed.
370
+
371
+ This is a convenience method for handling "I did X on [date]" statements.
372
+ The task is created with the specified completion date and immediately marked complete.
373
+
374
+ Args:
375
+ description: The task description of what was completed
376
+ completion_date: Completion date in YYYY-MM-DD format (defaults to today)
377
+ project: Optional project name (without the + symbol)
378
+ context: Optional context name (without the @ symbol)
379
+
380
+ Returns:
381
+ Confirmation message with the completed task details
382
+ """
383
+ # Set default completion date to today if not provided
384
+ if not completion_date:
385
+ completion_date = datetime.now().strftime("%Y-%m-%d")
386
+
387
+ # Validate completion date format
388
+ try:
389
+ datetime.strptime(completion_date, "%Y-%m-%d")
390
+ except ValueError:
391
+ raise ValueError(
392
+ f"Invalid completion date format '{completion_date}'. Must be YYYY-MM-DD."
393
+ )
394
+
395
+ # Build the task description with project and context
396
+ full_description = description
397
+
398
+ if project:
399
+ # Remove any existing + symbols to prevent duplication
400
+ clean_project = project.strip().lstrip("+")
401
+ if not clean_project:
402
+ raise ValueError(
403
+ "Project name cannot be empty after removing + symbol."
404
+ )
405
+ full_description = f"{full_description} +{clean_project}"
406
+
407
+ if context:
408
+ # Remove any existing @ symbols to prevent duplication
409
+ clean_context = context.strip().lstrip("@")
410
+ if not clean_context:
411
+ raise ValueError(
412
+ "Context name cannot be empty after removing @ symbol."
413
+ )
414
+ full_description = f"{full_description} @{clean_context}"
415
+
416
+ # Add the task first
417
+ self.todo_shell.add(full_description)
418
+
419
+ # Get the task number by finding the newly added task
420
+ tasks = self.todo_shell.list_tasks()
421
+ task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
422
+ if not task_lines:
423
+ raise RuntimeError("Failed to add task - no tasks found after addition")
424
+
425
+ # Find the task that matches our description (it should be the last one added)
426
+ # Look for the task that contains our description
427
+ task_number = None
428
+ for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
429
+ if description in line:
430
+ task_number = i
431
+ break
432
+
433
+ if task_number is None:
434
+ # Fallback: use the last task number if we can't find a match
435
+ task_number = len(task_lines)
436
+ # Log a warning that we're using fallback logic
437
+ import logging
438
+
439
+ logging.warning(
440
+ f"Could not find exact match for '{description}', using fallback task number {task_number}"
441
+ )
442
+
443
+ # Mark it as complete
444
+ self.todo_shell.complete(task_number)
445
+
446
+ return f"Created and completed task: {full_description} (completed on {completion_date})"
447
+
448
+ def restore_completed_task(self, task_number: int) -> str:
449
+ """
450
+ Restore a completed task from done.txt back to todo.txt.
451
+
452
+ This method moves a completed task from done.txt back to todo.txt,
453
+ effectively restoring it to active status.
454
+
455
+ Args:
456
+ task_number: The line number of the completed task in done.txt to restore
457
+
458
+ Returns:
459
+ Confirmation message with the restored task details
460
+ """
461
+ # Validate task number
462
+ if task_number <= 0:
463
+ raise ValueError("Task number must be a positive integer")
464
+
465
+ # Use the move command to restore the task from done.txt to todo.txt
466
+ result = self.todo_shell.move(task_number, "todo.txt", "done.txt")
467
+
468
+ # Extract the task description from the result for confirmation
469
+ # The result format is typically: "TODO: X moved from '.../done.txt' to '.../todo.txt'."
470
+ if "moved from" in result and "to" in result:
471
+ # Try to extract the task description if possible
472
+ return f"Restored completed task {task_number} to active status: {result}"
473
+ else:
474
+ return f"Restored completed task {task_number} to active status"
@@ -15,10 +15,8 @@ def get_calendar_output() -> str:
15
15
  Formatted calendar string showing three months side by side
16
16
  """
17
17
  try:
18
- # Use cal -3 to get three months side by side
19
- result = subprocess.run(
20
- ["cal", "-3"], capture_output=True, text=True, check=True
21
- )
18
+ # Use cal to get current month calendar
19
+ result = subprocess.run(["cal"], capture_output=True, text=True, check=True)
22
20
  return result.stdout.strip()
23
21
  except (subprocess.SubprocessError, FileNotFoundError):
24
22
  # Fallback to Python calendar module
@@ -4,6 +4,7 @@ LLM inference engine for todo.sh agent.
4
4
 
5
5
  import os
6
6
  import time
7
+ from datetime import datetime
7
8
  from typing import Any, Dict, Optional
8
9
 
9
10
  try:
@@ -12,6 +13,7 @@ try:
12
13
  from todo_agent.infrastructure.llm_client_factory import LLMClientFactory
13
14
  from todo_agent.infrastructure.logger import Logger
14
15
  from todo_agent.interface.tools import ToolCallHandler
16
+ from todo_agent.interface.progress import ToolCallProgress, NoOpProgress
15
17
  except ImportError:
16
18
  from core.conversation_manager import ( # type: ignore[no-redef]
17
19
  ConversationManager,
@@ -23,6 +25,7 @@ except ImportError:
23
25
  )
24
26
  from infrastructure.logger import Logger # type: ignore[no-redef]
25
27
  from interface.tools import ToolCallHandler # type: ignore[no-redef]
28
+ from interface.progress import ToolCallProgress, NoOpProgress # type: ignore[no-redef]
26
29
 
27
30
 
28
31
  class Inference:
@@ -67,13 +70,10 @@ class Inference:
67
70
 
68
71
  def _load_system_prompt(self) -> str:
69
72
  """Load and format the system prompt from file."""
70
- # Generate tools section programmatically
71
- tools_section = self._generate_tools_section()
72
-
73
73
  # Get current datetime for interpolation
74
- from datetime import datetime
75
-
76
- current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
74
+ now = datetime.now()
75
+ timezone_info = time.tzname[time.daylight]
76
+ current_datetime = f"{now.strftime('%Y-%m-%d %H:%M:%S')} {timezone_info}"
77
77
 
78
78
  # Get calendar output
79
79
  from .calendar_utils import get_calendar_output
@@ -93,9 +93,8 @@ class Inference:
93
93
  with open(prompt_file_path, encoding="utf-8") as f:
94
94
  system_prompt_template = f.read()
95
95
 
96
- # Format the template with the tools section, current datetime, and calendar
96
+ # Format the template with current datetime and calendar
97
97
  return system_prompt_template.format(
98
- tools_section=tools_section,
99
98
  current_datetime=current_datetime,
100
99
  calendar_output=calendar_output,
101
100
  )
@@ -107,64 +106,28 @@ class Inference:
107
106
  self.logger.error(f"Error loading system prompt: {e!s}")
108
107
  raise
109
108
 
110
- def _generate_tools_section(self) -> str:
111
- """Generate the AVAILABLE TOOLS section with strategic categorization."""
112
- tool_categories = {
113
- "Discovery Tools": [
114
- "list_projects",
115
- "list_contexts",
116
- "list_tasks",
117
- "list_completed_tasks",
118
- ],
119
- "Modification Tools": [
120
- "add_task",
121
- "complete_task",
122
- "replace_task",
123
- "append_to_task",
124
- "prepend_to_task",
125
- ],
126
- "Management Tools": [
127
- "delete_task",
128
- "set_priority",
129
- "remove_priority",
130
- "move_task",
131
- ],
132
- "Maintenance Tools": ["archive_tasks", "deduplicate_tasks", "get_overview"],
133
- }
134
-
135
- tools_section = []
136
- for category, tool_names in tool_categories.items():
137
- tools_section.append(f"\n**{category}:**")
138
- for tool_name in tool_names:
139
- tool_info = next(
140
- (
141
- t
142
- for t in self.tool_handler.tools
143
- if t["function"]["name"] == tool_name
144
- ),
145
- None,
146
- )
147
- if tool_info:
148
- # Get first sentence of description for concise overview
149
- first_sentence = (
150
- tool_info["function"]["description"].split(".")[0] + "."
151
- )
152
- tools_section.append(f"- {tool_name}(): {first_sentence}")
153
109
 
154
- return "\n".join(tools_section)
155
110
 
156
- def process_request(self, user_input: str) -> tuple[str, float]:
111
+ def process_request(self, user_input: str, progress_callback: Optional[ToolCallProgress] = None) -> tuple[str, float]:
157
112
  """
158
113
  Process a user request through the LLM with tool orchestration.
159
114
 
160
115
  Args:
161
116
  user_input: Natural language user request
117
+ progress_callback: Optional progress callback for tool call tracking
162
118
 
163
119
  Returns:
164
120
  Tuple of (formatted response for user, thinking time in seconds)
165
121
  """
166
122
  # Start timing the request
167
123
  start_time = time.time()
124
+
125
+ # Initialize progress callback if not provided
126
+ if progress_callback is None:
127
+ progress_callback = NoOpProgress()
128
+
129
+ # Notify progress callback that thinking has started
130
+ progress_callback.on_thinking_start()
168
131
 
169
132
  try:
170
133
  self.logger.debug(
@@ -187,6 +150,30 @@ class Inference:
187
150
  messages=messages, tools=self.tool_handler.tools
188
151
  )
189
152
 
153
+ # Check for provider errors
154
+ if response.get("error", False):
155
+ error_type = response.get("error_type", "general_error")
156
+ provider = response.get("provider", "unknown")
157
+ self.logger.error(f"Provider error from {provider}: {error_type}")
158
+
159
+ # Import here to avoid circular imports
160
+ try:
161
+ from todo_agent.interface.formatters import get_provider_error_message
162
+ error_message = get_provider_error_message(error_type)
163
+ except ImportError:
164
+ from interface.formatters import get_provider_error_message
165
+ error_message = get_provider_error_message(error_type)
166
+
167
+ # Add error message to conversation
168
+ self.conversation_manager.add_message(MessageRole.ASSISTANT, error_message)
169
+
170
+ # Calculate thinking time and return
171
+ end_time = time.time()
172
+ thinking_time = end_time - start_time
173
+ progress_callback.on_thinking_complete(thinking_time)
174
+
175
+ return error_message, thinking_time
176
+
190
177
  # Extract actual token usage from API response
191
178
  usage = response.get("usage", {})
192
179
  actual_prompt_tokens = usage.get("prompt_tokens", 0)
@@ -201,6 +188,8 @@ class Inference:
201
188
 
202
189
  # Handle multiple tool calls in sequence
203
190
  tool_call_count = 0
191
+ total_sequences = 0 # We'll track this as we go
192
+
204
193
  while True:
205
194
  tool_calls = self.llm_client.extract_tool_calls(response)
206
195
 
@@ -211,6 +200,9 @@ class Inference:
211
200
  self.logger.debug(
212
201
  f"Executing tool call sequence #{tool_call_count} with {len(tool_calls)} tools"
213
202
  )
203
+
204
+ # Notify progress callback of sequence start
205
+ progress_callback.on_sequence_complete(tool_call_count, 0) # We don't know total yet
214
206
 
215
207
  # Execute all tool calls and collect results
216
208
  tool_results = []
@@ -224,6 +216,14 @@ class Inference:
224
216
  self.logger.debug(f"Tool Call ID: {tool_call_id}")
225
217
  self.logger.debug(f"Raw tool call: {tool_call}")
226
218
 
219
+ # Get progress description for the tool
220
+ progress_description = self._get_tool_progress_description(tool_name)
221
+
222
+ # Notify progress callback of tool call start
223
+ progress_callback.on_tool_call_start(
224
+ tool_name, progress_description, tool_call_count, 0 # We don't know total yet
225
+ )
226
+
227
227
  result = self.tool_handler.execute_tool(tool_call)
228
228
 
229
229
  # Log tool execution result (success or error)
@@ -249,6 +249,30 @@ class Inference:
249
249
  messages=messages, tools=self.tool_handler.tools
250
250
  )
251
251
 
252
+ # Check for provider errors in continuation
253
+ if response.get("error", False):
254
+ error_type = response.get("error_type", "general_error")
255
+ provider = response.get("provider", "unknown")
256
+ self.logger.error(f"Provider error in continuation from {provider}: {error_type}")
257
+
258
+ # Import here to avoid circular imports
259
+ try:
260
+ from todo_agent.interface.formatters import get_provider_error_message
261
+ error_message = get_provider_error_message(error_type)
262
+ except ImportError:
263
+ from interface.formatters import get_provider_error_message
264
+ error_message = get_provider_error_message(error_type)
265
+
266
+ # Add error message to conversation
267
+ self.conversation_manager.add_message(MessageRole.ASSISTANT, error_message)
268
+
269
+ # Calculate thinking time and return
270
+ end_time = time.time()
271
+ thinking_time = end_time - start_time
272
+ progress_callback.on_thinking_complete(thinking_time)
273
+
274
+ return error_message, thinking_time
275
+
252
276
  # Update with actual tokens from subsequent API calls
253
277
  usage = response.get("usage", {})
254
278
  actual_prompt_tokens = usage.get("prompt_tokens", 0)
@@ -260,6 +284,9 @@ class Inference:
260
284
  # Calculate and log total thinking time
261
285
  end_time = time.time()
262
286
  thinking_time = end_time - start_time
287
+
288
+ # Notify progress callback that thinking is complete
289
+ progress_callback.on_thinking_complete(thinking_time)
263
290
 
264
291
  # Add final assistant response to conversation with thinking time
265
292
  final_content = self.llm_client.extract_content(response)
@@ -294,6 +321,25 @@ class Inference:
294
321
  self.tool_handler.tools
295
322
  )
296
323
 
324
+ def _get_tool_progress_description(self, tool_name: str) -> str:
325
+ """
326
+ Get user-friendly progress description for a tool.
327
+
328
+ Args:
329
+ tool_name: Name of the tool
330
+
331
+ Returns:
332
+ Progress description string
333
+ """
334
+ tool_def = next((t for t in self.tool_handler.tools
335
+ if t.get("function", {}).get("name") == tool_name), None)
336
+
337
+ if tool_def and "progress_description" in tool_def:
338
+ return tool_def["progress_description"]
339
+
340
+ # Fallback to generic description
341
+ return f"🔧 Executing {tool_name}..."
342
+
297
343
  def clear_conversation(self) -> None:
298
344
  """Clear conversation history."""
299
345
  self.conversation_manager.clear_conversation()