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 +2 -2
- todo_agent/core/todo_manager.py +144 -2
- todo_agent/infrastructure/inference.py +4 -3
- todo_agent/infrastructure/openrouter_client.py +26 -20
- todo_agent/infrastructure/prompts/system_prompt.txt +389 -324
- todo_agent/infrastructure/todo_shell.py +347 -10
- todo_agent/interface/cli.py +4 -2
- todo_agent/interface/tools.py +170 -116
- {todo_agent-0.2.8.dist-info → todo_agent-0.3.1.dist-info}/METADATA +33 -65
- {todo_agent-0.2.8.dist-info → todo_agent-0.3.1.dist-info}/RECORD +14 -14
- {todo_agent-0.2.8.dist-info → todo_agent-0.3.1.dist-info}/WHEEL +0 -0
- {todo_agent-0.2.8.dist-info → todo_agent-0.3.1.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.2.8.dist-info → todo_agent-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.2.8.dist-info → todo_agent-0.3.1.dist-info}/top_level.txt +0 -0
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.
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
31
|
+
__version__ = version = '0.3.1'
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 1)
|
33
33
|
|
34
34
|
__commit_id__ = commit_id = None
|
todo_agent/core/todo_manager.py
CHANGED
@@ -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(
|
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
|
-
|
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
|
-
|
75
|
-
|
76
|
-
current_datetime =
|
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
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
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
|
|