todo-agent 0.2.8__py3-none-any.whl → 0.3.1__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.8'
32
- __version_tuple__ = version_tuple = (0, 2, 8)
31
+ __version__ = version = '0.3.1'
32
+ __version_tuple__ = version_tuple = (0, 3, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -20,6 +20,7 @@ class TodoManager:
20
20
  context: Optional[str] = None,
21
21
  due: Optional[str] = None,
22
22
  recurring: Optional[str] = None,
23
+ duration: Optional[str] = None,
23
24
  ) -> str:
24
25
  """Add new task with explicit project/context parameters."""
25
26
  # Validate and sanitize inputs
@@ -78,7 +79,35 @@ class TodoManager:
78
79
  if interval < 1:
79
80
  raise ValueError("Interval must be at least 1.")
80
81
  except ValueError:
81
- raise ValueError(f"Invalid interval '{parts[2]}'. Must be a positive integer.")
82
+ raise ValueError(
83
+ f"Invalid interval '{parts[2]}'. Must be a positive integer."
84
+ )
85
+
86
+ if duration is not None:
87
+ # Validate duration format (e.g., "30m", "2h", "1d")
88
+ if not duration or not isinstance(duration, str):
89
+ raise ValueError("Duration must be a non-empty string.")
90
+
91
+ # Check if duration ends with a valid unit
92
+ if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
93
+ raise ValueError(
94
+ f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
95
+ )
96
+
97
+ # Extract the numeric part and validate it
98
+ value = duration[:-1]
99
+ if not value:
100
+ raise ValueError("Duration value cannot be empty.")
101
+
102
+ try:
103
+ # Check if the value is a valid positive number
104
+ numeric_value = float(value)
105
+ if numeric_value <= 0:
106
+ raise ValueError("Duration value must be positive.")
107
+ except ValueError:
108
+ raise ValueError(
109
+ f"Invalid duration value '{value}'. Must be a positive number."
110
+ )
82
111
 
83
112
  # Build the full task description with priority, project, and context
84
113
  full_description = description
@@ -98,6 +127,9 @@ class TodoManager:
98
127
  if recurring:
99
128
  full_description = f"{full_description} {recurring}"
100
129
 
130
+ if duration:
131
+ full_description = f"{full_description} duration:{duration}"
132
+
101
133
  self.todo_shell.add(full_description)
102
134
  return f"Added task: {full_description}"
103
135
 
@@ -167,6 +199,114 @@ class TodoManager:
167
199
  result = self.todo_shell.remove_priority(task_number)
168
200
  return f"Removed priority from task {task_number}: {result}"
169
201
 
202
+ def set_due_date(self, task_number: int, due_date: str) -> str:
203
+ """
204
+ Set or update due date for a task by intelligently rewriting it.
205
+
206
+ Args:
207
+ task_number: The task number to modify
208
+ due_date: Due date in YYYY-MM-DD format, or empty string to remove due date
209
+
210
+ Returns:
211
+ Confirmation message with the updated task
212
+ """
213
+ # Validate due date format only if not empty
214
+ if due_date.strip():
215
+ try:
216
+ datetime.strptime(due_date, "%Y-%m-%d")
217
+ except ValueError:
218
+ raise ValueError(
219
+ f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
220
+ )
221
+
222
+ result = self.todo_shell.set_due_date(task_number, due_date)
223
+ if due_date.strip():
224
+ return f"Set due date {due_date} for task {task_number}: {result}"
225
+ else:
226
+ return f"Removed due date from task {task_number}: {result}"
227
+
228
+ def set_context(self, task_number: int, context: str) -> str:
229
+ """
230
+ Set or update context for a task by intelligently rewriting it.
231
+
232
+ Args:
233
+ task_number: The task number to modify
234
+ context: Context name (without @ symbol), or empty string to remove context
235
+
236
+ Returns:
237
+ Confirmation message with the updated task
238
+ """
239
+ # Validate context name if not empty
240
+ if context.strip():
241
+ # Remove any existing @ symbols to prevent duplication
242
+ clean_context = context.strip().lstrip("@")
243
+ if not clean_context:
244
+ raise ValueError(
245
+ "Context name cannot be empty after removing @ symbol."
246
+ )
247
+
248
+ result = self.todo_shell.set_context(task_number, context)
249
+ if context.strip():
250
+ clean_context = context.strip().lstrip("@")
251
+ return f"Set context @{clean_context} for task {task_number}: {result}"
252
+ else:
253
+ return f"Removed context from task {task_number}: {result}"
254
+
255
+ def set_project(self, task_number: int, projects: list) -> str:
256
+ """
257
+ Set or update projects for a task by intelligently rewriting it.
258
+
259
+ Args:
260
+ task_number: The task number to modify
261
+ projects: List of project operations. Each item can be:
262
+ - "project" (add project)
263
+ - "-project" (remove project)
264
+ - Empty string removes all projects
265
+
266
+ Returns:
267
+ Confirmation message with the updated task
268
+ """
269
+ # Validate project names if not empty
270
+ if projects:
271
+ for project in projects:
272
+ if project.strip() and not project.startswith("-"):
273
+ # Remove any existing + symbols to prevent duplication
274
+ clean_project = project.strip().lstrip("+")
275
+ if not clean_project:
276
+ raise ValueError(
277
+ "Project name cannot be empty after removing + symbol."
278
+ )
279
+ elif project.startswith("-"):
280
+ clean_project = project[1:].strip().lstrip("+")
281
+ if not clean_project:
282
+ raise ValueError(
283
+ "Project name cannot be empty after removing - and + symbols."
284
+ )
285
+
286
+ result = self.todo_shell.set_project(task_number, projects)
287
+
288
+ if not projects:
289
+ return f"No project changes made to task {task_number}: {result}"
290
+ else:
291
+ # Build operation description
292
+ operations = []
293
+ for project in projects:
294
+ if not project.strip():
295
+ # Empty string is a NOOP - skip
296
+ continue
297
+ elif project.startswith("-"):
298
+ clean_project = project[1:].strip().lstrip("+")
299
+ operations.append(f"removed +{clean_project}")
300
+ else:
301
+ clean_project = project.strip().lstrip("+")
302
+ operations.append(f"added +{clean_project}")
303
+
304
+ if not operations:
305
+ return f"No project changes made to task {task_number}: {result}"
306
+ else:
307
+ operation_desc = ", ".join(operations)
308
+ return f"Updated projects for task {task_number} ({operation_desc}): {result}"
309
+
170
310
  def list_projects(self, **kwargs: Any) -> str:
171
311
  """List all available projects in todo.txt."""
172
312
  result = self.todo_shell.list_projects()
@@ -263,4 +403,6 @@ class TodoManager:
263
403
  def get_current_datetime(self, **kwargs: Any) -> str:
264
404
  """Get the current date and time."""
265
405
  now = datetime.now()
266
- return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} ({now.strftime('%A, %B %d, %Y at %I:%M %p')})"
406
+ week_number = now.isocalendar()[1]
407
+ timezone = now.astimezone().tzinfo
408
+ 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}"
@@ -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:
@@ -71,9 +72,9 @@ class Inference:
71
72
  tools_section = self._generate_tools_section()
72
73
 
73
74
  # Get current datetime for interpolation
74
- from datetime import datetime
75
-
76
- current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
75
+ now = datetime.now()
76
+ timezone_info = time.tzname[time.daylight]
77
+ current_datetime = f"{now.strftime('%Y-%m-%d %H:%M:%S')} {timezone_info}"
77
78
 
78
79
  # Get calendar output
79
80
  from .calendar_utils import get_calendar_output
@@ -72,26 +72,32 @@ class OpenRouterClient(LLMClient):
72
72
  f"Token usage - Prompt: {prompt_tokens}, Completion: {completion_tokens}, Total: {total_tokens}"
73
73
  )
74
74
 
75
- # Log tool call details if present
76
- if response.get("choices"):
77
- choice = response["choices"][0]
78
- if "message" in choice and "tool_calls" in choice["message"]:
79
- tool_calls = choice["message"]["tool_calls"]
80
- self.logger.info(f"Response contains {len(tool_calls)} tool calls")
81
-
82
- # Log thinking content (response body) if present
83
- content = choice["message"].get("content", "")
84
- if content and content.strip():
85
- self.logger.info(f"LLM thinking before tool calls: {content}")
86
-
87
- for i, tool_call in enumerate(tool_calls):
88
- tool_name = tool_call.get("function", {}).get("name", "unknown")
89
- self.logger.info(f" Tool call {i + 1}: {tool_name}")
90
- elif "message" in choice and "content" in choice["message"]:
91
- content = choice["message"]["content"]
92
- self.logger.debug(
93
- f"Response contains content: {content[:100]}{'...' if len(content) > 100 else ''}"
94
- )
75
+ # Extract and log choice details
76
+ choices = response.get("choices", [])
77
+ if not choices:
78
+ return
79
+
80
+ choice = choices[0]
81
+ message = choice.get("message", {})
82
+
83
+ # Always log reasoning and content if present
84
+ reasoning = message.get("reasoning", "")
85
+ if reasoning:
86
+ self.logger.info(f"LLM reasoning: {reasoning}")
87
+
88
+ content = message.get("content", "")
89
+ if content:
90
+ self.logger.info(f"LLM content: {content}")
91
+
92
+ # Handle tool calls
93
+ tool_calls = message.get("tool_calls", [])
94
+ if tool_calls:
95
+ self.logger.info(f"Response contains {len(tool_calls)} tool calls")
96
+
97
+ # Log each tool call
98
+ for i, tool_call in enumerate(tool_calls, 1):
99
+ tool_name = tool_call.get("function", {}).get("name", "unknown")
100
+ self.logger.info(f" Tool call {i}: {tool_name}")
95
101
 
96
102
  self.logger.debug(f"Raw response: {json.dumps(response, indent=2)}")
97
103