todo-agent 0.3.3__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/todo_manager.py +299 -171
- todo_agent/infrastructure/inference.py +16 -9
- todo_agent/infrastructure/prompts/system_prompt.txt +117 -15
- todo_agent/infrastructure/todo_shell.py +59 -0
- todo_agent/interface/tools.py +48 -5
- todo_agent/main.py +17 -1
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.5.dist-info}/METADATA +1 -1
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.5.dist-info}/RECORD +13 -13
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.5.dist-info}/WHEEL +0 -0
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.5.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.5.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.3.
|
32
|
-
__version_tuple__ = version_tuple = (0, 3,
|
31
|
+
__version__ = version = '0.3.5'
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 5)
|
33
33
|
|
34
34
|
__commit_id__ = commit_id = None
|
todo_agent/core/todo_manager.py
CHANGED
@@ -12,86 +12,126 @@ class TodoManager:
|
|
12
12
|
def __init__(self, todo_shell: Any) -> None:
|
13
13
|
self.todo_shell = todo_shell
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
15
|
+
# ============================================================================
|
16
|
+
# VALIDATION METHODS
|
17
|
+
# ============================================================================
|
18
|
+
|
19
|
+
def _normalize_empty_to_none(self, value: Optional[str]) -> Optional[str]:
|
20
|
+
"""Convert empty strings to None for consistent handling."""
|
21
|
+
if value is None:
|
22
|
+
return None
|
23
|
+
if isinstance(value, str) and not value.strip():
|
24
|
+
return None
|
25
|
+
return value
|
26
|
+
|
27
|
+
def _validate_date_format(
|
28
|
+
self, date_str: Optional[str], field_name: str = "date"
|
29
|
+
) -> None:
|
30
|
+
"""Validate date format (YYYY-MM-DD)."""
|
31
|
+
if date_str is None or not date_str.strip():
|
32
|
+
return
|
33
|
+
try:
|
34
|
+
datetime.strptime(date_str, "%Y-%m-%d")
|
35
|
+
except ValueError:
|
36
|
+
raise ValueError(
|
37
|
+
f"Invalid {field_name} format '{date_str}'. Must be YYYY-MM-DD."
|
38
|
+
)
|
39
|
+
|
40
|
+
def _validate_priority(self, priority: Optional[str]) -> None:
|
41
|
+
"""Validate priority format (single uppercase letter A-Z)."""
|
42
|
+
if priority is None:
|
43
|
+
return
|
44
|
+
if not (len(priority) == 1 and priority.isalpha() and priority.isupper()):
|
29
45
|
raise ValueError(
|
30
46
|
f"Invalid priority '{priority}'. Must be a single uppercase letter (A-Z)."
|
31
47
|
)
|
32
48
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
49
|
+
def _validate_parent_number(self, parent_number: Optional[int]) -> None:
|
50
|
+
"""Validate parent task number."""
|
51
|
+
if parent_number is not None and (
|
52
|
+
not isinstance(parent_number, int) or parent_number <= 0
|
53
|
+
):
|
54
|
+
raise ValueError(
|
55
|
+
f"Invalid parent_number '{parent_number}'. Must be a positive integer."
|
56
|
+
)
|
40
57
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
raise ValueError(
|
46
|
-
"Context name cannot be empty after removing @ symbol."
|
47
|
-
)
|
58
|
+
def _validate_duration(self, duration: Optional[str]) -> None:
|
59
|
+
"""Validate duration format (e.g., '30m', '2h', '1d')."""
|
60
|
+
if duration is None:
|
61
|
+
return
|
48
62
|
|
49
|
-
if
|
50
|
-
|
51
|
-
try:
|
52
|
-
datetime.strptime(due, "%Y-%m-%d")
|
53
|
-
except ValueError:
|
54
|
-
raise ValueError(
|
55
|
-
f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
|
56
|
-
)
|
63
|
+
if not isinstance(duration, str) or not duration.strip():
|
64
|
+
raise ValueError("Duration must be a non-empty string.")
|
57
65
|
|
58
|
-
if duration
|
59
|
-
|
60
|
-
|
61
|
-
|
66
|
+
if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
|
67
|
+
raise ValueError(
|
68
|
+
f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
|
69
|
+
)
|
62
70
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
|
67
|
-
)
|
71
|
+
value = duration[:-1]
|
72
|
+
if not value:
|
73
|
+
raise ValueError("Duration value cannot be empty.")
|
68
74
|
|
69
|
-
|
70
|
-
|
71
|
-
if
|
72
|
-
raise ValueError("Duration value
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
if numeric_value <= 0:
|
78
|
-
raise ValueError("Duration value must be positive.")
|
79
|
-
except ValueError:
|
80
|
-
raise ValueError(
|
81
|
-
f"Invalid duration value '{value}'. Must be a positive number."
|
82
|
-
)
|
75
|
+
try:
|
76
|
+
numeric_value = float(value)
|
77
|
+
if numeric_value <= 0:
|
78
|
+
raise ValueError("Duration value must be positive.")
|
79
|
+
except ValueError:
|
80
|
+
raise ValueError(
|
81
|
+
f"Invalid duration value '{value}'. Must be a positive number."
|
82
|
+
)
|
83
83
|
|
84
|
-
|
84
|
+
def _clean_project_name(self, project: Optional[str]) -> Optional[str]:
|
85
|
+
"""Clean and validate project name."""
|
86
|
+
if project is None:
|
87
|
+
return None
|
88
|
+
if not project.strip():
|
89
|
+
return None
|
90
|
+
clean_project = project.strip().lstrip("+")
|
91
|
+
if not clean_project:
|
92
|
+
raise ValueError("Project name cannot be empty after removing + symbol.")
|
93
|
+
return clean_project
|
94
|
+
|
95
|
+
def _clean_context_name(self, context: Optional[str]) -> Optional[str]:
|
96
|
+
"""Clean and validate context name."""
|
97
|
+
if context is None:
|
98
|
+
return None
|
99
|
+
if not context.strip():
|
100
|
+
return "" # Return empty string for empty input (used by set_context for removal)
|
101
|
+
clean_context = context.strip().lstrip("@")
|
102
|
+
if not clean_context:
|
103
|
+
raise ValueError("Context name cannot be empty after removing @ symbol.")
|
104
|
+
return clean_context
|
105
|
+
|
106
|
+
# ============================================================================
|
107
|
+
# TASK BUILDING AND UTILITY METHODS
|
108
|
+
# ============================================================================
|
109
|
+
|
110
|
+
def _build_task_description(
|
111
|
+
self,
|
112
|
+
description: str,
|
113
|
+
priority: Optional[str] = None,
|
114
|
+
project: Optional[str] = None,
|
115
|
+
context: Optional[str] = None,
|
116
|
+
due: Optional[str] = None,
|
117
|
+
duration: Optional[str] = None,
|
118
|
+
parent_number: Optional[int] = None,
|
119
|
+
) -> str:
|
120
|
+
"""Build full task description with all components."""
|
85
121
|
full_description = description
|
86
122
|
|
87
123
|
if priority:
|
88
124
|
full_description = f"({priority}) {full_description}"
|
89
125
|
|
90
126
|
if project:
|
91
|
-
|
127
|
+
clean_project = self._clean_project_name(project)
|
128
|
+
if clean_project: # Only add if not None/empty after cleaning
|
129
|
+
full_description = f"{full_description} +{clean_project}"
|
92
130
|
|
93
131
|
if context:
|
94
|
-
|
132
|
+
clean_context = self._clean_context_name(context)
|
133
|
+
if clean_context: # Only add if not None/empty after cleaning
|
134
|
+
full_description = f"{full_description} @{clean_context}"
|
95
135
|
|
96
136
|
if due:
|
97
137
|
full_description = f"{full_description} due:{due}"
|
@@ -99,6 +139,86 @@ class TodoManager:
|
|
99
139
|
if duration:
|
100
140
|
full_description = f"{full_description} duration:{duration}"
|
101
141
|
|
142
|
+
if parent_number:
|
143
|
+
full_description = f"{full_description} parent:{parent_number}"
|
144
|
+
|
145
|
+
return full_description
|
146
|
+
|
147
|
+
def _format_task_operation_response(
|
148
|
+
self, operation: str, task_number: int, result: str, additional_info: str = ""
|
149
|
+
) -> str:
|
150
|
+
"""Format consistent task operation response messages."""
|
151
|
+
# Handle special cases for backward compatibility with tests
|
152
|
+
if operation == "Set due date" and additional_info:
|
153
|
+
return f"Set due date {additional_info} for task {task_number}: {result}"
|
154
|
+
elif operation == "Set context" and additional_info:
|
155
|
+
return f"Set context {additional_info} for task {task_number}: {result}"
|
156
|
+
elif operation == "Set priority" and additional_info:
|
157
|
+
return f"Set priority {additional_info} for task {task_number}: {result}"
|
158
|
+
else:
|
159
|
+
base_message = f"{operation} task {task_number}"
|
160
|
+
if additional_info:
|
161
|
+
base_message += f" ({additional_info})"
|
162
|
+
return f"{base_message}: {result}"
|
163
|
+
|
164
|
+
def _handle_empty_result(self, result: str, empty_message: str) -> str:
|
165
|
+
"""Handle empty results with consistent messaging."""
|
166
|
+
return empty_message if not result.strip() else result
|
167
|
+
|
168
|
+
def _find_task_number_by_description(self, description: str) -> int:
|
169
|
+
"""Find task number by description in the current task list."""
|
170
|
+
tasks = self.todo_shell.list_tasks()
|
171
|
+
task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
|
172
|
+
|
173
|
+
if not task_lines:
|
174
|
+
raise RuntimeError("Failed to add task - no tasks found after addition")
|
175
|
+
|
176
|
+
import re
|
177
|
+
|
178
|
+
for line in reversed(task_lines):
|
179
|
+
if description in line:
|
180
|
+
match = re.match(r"^(\d+)", line)
|
181
|
+
if match:
|
182
|
+
return int(match.group(1))
|
183
|
+
|
184
|
+
raise RuntimeError(
|
185
|
+
f"Could not find task with description '{description}' after adding it. "
|
186
|
+
f"This indicates a serious issue with task matching."
|
187
|
+
)
|
188
|
+
|
189
|
+
# ============================================================================
|
190
|
+
# CORE CRUD OPERATIONS
|
191
|
+
# ============================================================================
|
192
|
+
|
193
|
+
def add_task(
|
194
|
+
self,
|
195
|
+
description: str,
|
196
|
+
priority: Optional[str] = None,
|
197
|
+
project: Optional[str] = None,
|
198
|
+
context: Optional[str] = None,
|
199
|
+
due: Optional[str] = None,
|
200
|
+
duration: Optional[str] = None,
|
201
|
+
parent_number: Optional[int] = None,
|
202
|
+
) -> str:
|
203
|
+
"""Add new task with explicit project/context parameters."""
|
204
|
+
# Normalize empty strings to None for optional parameters
|
205
|
+
priority = self._normalize_empty_to_none(priority)
|
206
|
+
project = self._normalize_empty_to_none(project)
|
207
|
+
context = self._normalize_empty_to_none(context)
|
208
|
+
due = self._normalize_empty_to_none(due)
|
209
|
+
# Note: duration is not normalized - empty strings should raise validation errors
|
210
|
+
|
211
|
+
# Validate inputs
|
212
|
+
self._validate_priority(priority)
|
213
|
+
self._validate_date_format(due, "due date")
|
214
|
+
self._validate_duration(duration)
|
215
|
+
self._validate_parent_number(parent_number)
|
216
|
+
|
217
|
+
# Build the full task description
|
218
|
+
full_description = self._build_task_description(
|
219
|
+
description, priority, project, context, due, duration, parent_number
|
220
|
+
)
|
221
|
+
|
102
222
|
self.todo_shell.add(full_description)
|
103
223
|
return f"Added task: {full_description}"
|
104
224
|
|
@@ -107,50 +227,53 @@ class TodoManager:
|
|
107
227
|
) -> str:
|
108
228
|
"""List tasks with optional filtering."""
|
109
229
|
result = self.todo_shell.list_tasks(filter, suppress_color=suppress_color)
|
110
|
-
|
111
|
-
return "No tasks found."
|
112
|
-
|
113
|
-
# Return the raw todo.txt format for the LLM to format conversationally
|
114
|
-
# The LLM will convert this into natural language in its response
|
115
|
-
return result
|
230
|
+
return self._handle_empty_result(result, "No tasks found.")
|
116
231
|
|
117
232
|
def complete_task(self, task_number: int) -> str:
|
118
233
|
"""Mark task complete by line number."""
|
119
234
|
result = self.todo_shell.complete(task_number)
|
120
|
-
return
|
235
|
+
return self._format_task_operation_response("Completed", task_number, result)
|
121
236
|
|
122
237
|
def replace_task(self, task_number: int, new_description: str) -> str:
|
123
238
|
"""Replace entire task content."""
|
124
239
|
result = self.todo_shell.replace(task_number, new_description)
|
125
|
-
return
|
240
|
+
return self._format_task_operation_response("Replaced", task_number, result)
|
126
241
|
|
127
242
|
def append_to_task(self, task_number: int, text: str) -> str:
|
128
243
|
"""Add text to end of existing task."""
|
129
244
|
result = self.todo_shell.append(task_number, text)
|
130
|
-
return
|
245
|
+
return self._format_task_operation_response("Appended to", task_number, result)
|
131
246
|
|
132
247
|
def prepend_to_task(self, task_number: int, text: str) -> str:
|
133
248
|
"""Add text to beginning of existing task."""
|
134
249
|
result = self.todo_shell.prepend(task_number, text)
|
135
|
-
return
|
250
|
+
return self._format_task_operation_response("Prepended to", task_number, result)
|
136
251
|
|
137
252
|
def delete_task(self, task_number: int, term: Optional[str] = None) -> str:
|
138
253
|
"""Delete entire task or specific term from task."""
|
139
254
|
result = self.todo_shell.delete(task_number, term)
|
140
|
-
if term:
|
141
|
-
return
|
255
|
+
if term is not None:
|
256
|
+
return self._format_task_operation_response(
|
257
|
+
"Removed", task_number, result, f"'{term}'"
|
258
|
+
)
|
142
259
|
else:
|
143
|
-
return
|
260
|
+
return self._format_task_operation_response("Deleted", task_number, result)
|
144
261
|
|
145
262
|
def set_priority(self, task_number: int, priority: str) -> str:
|
146
263
|
"""Set or change task priority (A-Z)."""
|
264
|
+
priority = self._normalize_empty_to_none(priority)
|
265
|
+
self._validate_priority(priority)
|
147
266
|
result = self.todo_shell.set_priority(task_number, priority)
|
148
|
-
return
|
267
|
+
return self._format_task_operation_response(
|
268
|
+
"Set priority", task_number, result, priority or ""
|
269
|
+
)
|
149
270
|
|
150
271
|
def remove_priority(self, task_number: int) -> str:
|
151
272
|
"""Remove priority from task."""
|
152
273
|
result = self.todo_shell.remove_priority(task_number)
|
153
|
-
return
|
274
|
+
return self._format_task_operation_response(
|
275
|
+
"Removed priority from", task_number, result
|
276
|
+
)
|
154
277
|
|
155
278
|
def set_due_date(self, task_number: int, due_date: str) -> str:
|
156
279
|
"""
|
@@ -164,19 +287,17 @@ class TodoManager:
|
|
164
287
|
Confirmation message with the updated task
|
165
288
|
"""
|
166
289
|
# Validate due date format only if not empty
|
167
|
-
|
168
|
-
try:
|
169
|
-
datetime.strptime(due_date, "%Y-%m-%d")
|
170
|
-
except ValueError:
|
171
|
-
raise ValueError(
|
172
|
-
f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
|
173
|
-
)
|
290
|
+
self._validate_date_format(due_date, "due date")
|
174
291
|
|
175
292
|
result = self.todo_shell.set_due_date(task_number, due_date)
|
176
293
|
if due_date.strip():
|
177
|
-
return
|
294
|
+
return self._format_task_operation_response(
|
295
|
+
"Set due date", task_number, result, due_date
|
296
|
+
)
|
178
297
|
else:
|
179
|
-
return
|
298
|
+
return self._format_task_operation_response(
|
299
|
+
"Removed due date from", task_number, result
|
300
|
+
)
|
180
301
|
|
181
302
|
def set_context(self, task_number: int, context: str) -> str:
|
182
303
|
"""
|
@@ -190,20 +311,17 @@ class TodoManager:
|
|
190
311
|
Confirmation message with the updated task
|
191
312
|
"""
|
192
313
|
# Validate context name if not empty
|
193
|
-
if context.strip()
|
194
|
-
# Remove any existing @ symbols to prevent duplication
|
195
|
-
clean_context = context.strip().lstrip("@")
|
196
|
-
if not clean_context:
|
197
|
-
raise ValueError(
|
198
|
-
"Context name cannot be empty after removing @ symbol."
|
199
|
-
)
|
314
|
+
clean_context = self._clean_context_name(context) if context.strip() else ""
|
200
315
|
|
201
316
|
result = self.todo_shell.set_context(task_number, context)
|
202
317
|
if context.strip():
|
203
|
-
|
204
|
-
|
318
|
+
return self._format_task_operation_response(
|
319
|
+
"Set context", task_number, result, f"@{clean_context}"
|
320
|
+
)
|
205
321
|
else:
|
206
|
-
return
|
322
|
+
return self._format_task_operation_response(
|
323
|
+
"Removed context from", task_number, result
|
324
|
+
)
|
207
325
|
|
208
326
|
def set_project(self, task_number: int, projects: list) -> str:
|
209
327
|
"""
|
@@ -223,12 +341,7 @@ class TodoManager:
|
|
223
341
|
if projects:
|
224
342
|
for project in projects:
|
225
343
|
if project.strip() and not project.startswith("-"):
|
226
|
-
|
227
|
-
clean_project = project.strip().lstrip("+")
|
228
|
-
if not clean_project:
|
229
|
-
raise ValueError(
|
230
|
-
"Project name cannot be empty after removing + symbol."
|
231
|
-
)
|
344
|
+
self._clean_project_name(project)
|
232
345
|
elif project.startswith("-"):
|
233
346
|
clean_project = project[1:].strip().lstrip("+")
|
234
347
|
if not clean_project:
|
@@ -239,7 +352,9 @@ class TodoManager:
|
|
239
352
|
result = self.todo_shell.set_project(task_number, projects)
|
240
353
|
|
241
354
|
if not projects:
|
242
|
-
return
|
355
|
+
return self._format_task_operation_response(
|
356
|
+
"No project changes made to", task_number, result
|
357
|
+
)
|
243
358
|
else:
|
244
359
|
# Build operation description
|
245
360
|
operations = []
|
@@ -255,24 +370,52 @@ class TodoManager:
|
|
255
370
|
operations.append(f"added +{clean_project}")
|
256
371
|
|
257
372
|
if not operations:
|
258
|
-
return
|
373
|
+
return self._format_task_operation_response(
|
374
|
+
"No project changes made to", task_number, result
|
375
|
+
)
|
259
376
|
else:
|
260
377
|
operation_desc = ", ".join(operations)
|
261
|
-
return
|
378
|
+
return self._format_task_operation_response(
|
379
|
+
"Updated projects for", task_number, result, operation_desc
|
380
|
+
)
|
381
|
+
|
382
|
+
def set_parent(self, task_number: int, parent_number: Optional[int]) -> str:
|
383
|
+
"""
|
384
|
+
Set or update parent task number for a task by intelligently rewriting it.
|
385
|
+
|
386
|
+
Args:
|
387
|
+
task_number: The task number to modify
|
388
|
+
parent_number: Parent task number, or None to remove parent
|
389
|
+
|
390
|
+
Returns:
|
391
|
+
Confirmation message with the updated task
|
392
|
+
"""
|
393
|
+
# Validate parent_number if provided
|
394
|
+
self._validate_parent_number(parent_number)
|
395
|
+
|
396
|
+
result = self.todo_shell.set_parent(task_number, parent_number)
|
397
|
+
if parent_number is not None:
|
398
|
+
return self._format_task_operation_response(
|
399
|
+
"Set parent task", task_number, result, str(parent_number)
|
400
|
+
)
|
401
|
+
else:
|
402
|
+
return self._format_task_operation_response(
|
403
|
+
"Removed parent from", task_number, result
|
404
|
+
)
|
405
|
+
|
406
|
+
# ============================================================================
|
407
|
+
# LISTING AND QUERY METHODS
|
408
|
+
# ============================================================================
|
262
409
|
|
263
410
|
def list_projects(self, suppress_color: bool = True, **kwargs: Any) -> str:
|
264
411
|
"""List all available projects in todo.txt."""
|
265
412
|
result = self.todo_shell.list_projects(suppress_color=suppress_color)
|
266
|
-
|
267
|
-
return "No projects found."
|
268
|
-
return result
|
413
|
+
return self._handle_empty_result(result, "No projects found.")
|
269
414
|
|
270
415
|
def list_contexts(self, suppress_color: bool = True, **kwargs: Any) -> str:
|
271
416
|
"""List all available contexts in todo.txt."""
|
272
417
|
result = self.todo_shell.list_contexts(suppress_color=suppress_color)
|
273
|
-
|
274
|
-
return "No contexts found."
|
275
|
-
return result
|
418
|
+
return self._handle_empty_result(result, "No contexts found.")
|
276
419
|
|
277
420
|
def list_completed_tasks(
|
278
421
|
self,
|
@@ -335,16 +478,22 @@ class TodoManager:
|
|
335
478
|
result = self.todo_shell.list_completed(
|
336
479
|
combined_filter, suppress_color=suppress_color
|
337
480
|
)
|
338
|
-
|
339
|
-
|
340
|
-
|
481
|
+
return self._handle_empty_result(
|
482
|
+
result, "No completed tasks found matching the criteria."
|
483
|
+
)
|
484
|
+
|
485
|
+
# ============================================================================
|
486
|
+
# UTILITY OPERATIONS
|
487
|
+
# ============================================================================
|
341
488
|
|
342
489
|
def move_task(
|
343
490
|
self, task_number: int, destination: str, source: Optional[str] = None
|
344
491
|
) -> str:
|
345
492
|
"""Move task from source to destination file."""
|
346
493
|
result = self.todo_shell.move(task_number, destination, source)
|
347
|
-
return
|
494
|
+
return self._format_task_operation_response(
|
495
|
+
"Moved", task_number, result, f"to {destination}"
|
496
|
+
)
|
348
497
|
|
349
498
|
def archive_tasks(self, **kwargs: Any) -> str:
|
350
499
|
"""Archive completed tasks."""
|
@@ -363,12 +512,17 @@ class TodoManager:
|
|
363
512
|
timezone = now.astimezone().tzinfo
|
364
513
|
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}"
|
365
514
|
|
366
|
-
|
515
|
+
# ============================================================================
|
516
|
+
# ADVANCED OPERATIONS
|
517
|
+
# ============================================================================
|
518
|
+
|
519
|
+
def create_completed_task(
|
367
520
|
self,
|
368
521
|
description: str,
|
369
522
|
completion_date: Optional[str] = None,
|
370
523
|
project: Optional[str] = None,
|
371
524
|
context: Optional[str] = None,
|
525
|
+
parent_number: Optional[int] = None,
|
372
526
|
) -> str:
|
373
527
|
"""
|
374
528
|
Create a task and immediately mark it as completed.
|
@@ -381,74 +535,48 @@ class TodoManager:
|
|
381
535
|
completion_date: Completion date in YYYY-MM-DD format (defaults to today)
|
382
536
|
project: Optional project name (without the + symbol)
|
383
537
|
context: Optional context name (without the @ symbol)
|
538
|
+
parent_number: Optional parent task number (required for subtasks)
|
384
539
|
|
385
540
|
Returns:
|
386
541
|
Confirmation message with the completed task details
|
387
542
|
"""
|
543
|
+
# Normalize empty strings to None
|
544
|
+
project = self._normalize_empty_to_none(project)
|
545
|
+
context = self._normalize_empty_to_none(context)
|
546
|
+
|
388
547
|
# Set default completion date to today if not provided
|
389
548
|
if not completion_date:
|
390
549
|
completion_date = datetime.now().strftime("%Y-%m-%d")
|
391
550
|
|
392
|
-
# Validate
|
393
|
-
|
394
|
-
|
395
|
-
except ValueError:
|
396
|
-
raise ValueError(
|
397
|
-
f"Invalid completion date format '{completion_date}'. Must be YYYY-MM-DD."
|
398
|
-
)
|
399
|
-
|
400
|
-
# Build the task description with project and context
|
401
|
-
full_description = description
|
551
|
+
# Validate inputs
|
552
|
+
self._validate_date_format(completion_date, "completion date")
|
553
|
+
self._validate_parent_number(parent_number)
|
402
554
|
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
raise ValueError(
|
408
|
-
"Project name cannot be empty after removing + symbol."
|
409
|
-
)
|
410
|
-
full_description = f"{full_description} +{clean_project}"
|
411
|
-
|
412
|
-
if context:
|
413
|
-
# Remove any existing @ symbols to prevent duplication
|
414
|
-
clean_context = context.strip().lstrip("@")
|
415
|
-
if not clean_context:
|
416
|
-
raise ValueError(
|
417
|
-
"Context name cannot be empty after removing @ symbol."
|
418
|
-
)
|
419
|
-
full_description = f"{full_description} @{clean_context}"
|
420
|
-
|
421
|
-
# Add the task first
|
422
|
-
self.todo_shell.add(full_description)
|
423
|
-
|
424
|
-
# Get the task number by finding the newly added task
|
425
|
-
tasks = self.todo_shell.list_tasks()
|
426
|
-
task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
|
427
|
-
if not task_lines:
|
428
|
-
raise RuntimeError("Failed to add task - no tasks found after addition")
|
429
|
-
|
430
|
-
# Find the task that matches our description (it should be the last one added)
|
431
|
-
# Look for the task that contains our description
|
432
|
-
task_number = None
|
433
|
-
for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
|
434
|
-
if description in line:
|
435
|
-
task_number = i
|
436
|
-
break
|
555
|
+
# Build the task description
|
556
|
+
full_description = self._build_task_description(
|
557
|
+
description, project=project, context=context, parent_number=parent_number
|
558
|
+
)
|
437
559
|
|
438
|
-
if
|
439
|
-
|
440
|
-
|
441
|
-
# Log a warning that we're using fallback logic
|
442
|
-
import logging
|
560
|
+
# Check if we need to use a specific completion date
|
561
|
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
562
|
+
use_specific_date = completion_date != current_date
|
443
563
|
|
444
|
-
|
445
|
-
|
446
|
-
|
564
|
+
if use_specific_date:
|
565
|
+
# When using a specific completion date, add directly to done.txt
|
566
|
+
# Format: "x YYYY-MM-DD [task description]"
|
567
|
+
completed_task_line = f"x {completion_date} {full_description}"
|
568
|
+
self.todo_shell.addto("done.txt", completed_task_line)
|
569
|
+
return f"Created and completed task: {full_description} (completed on {completion_date})"
|
570
|
+
else:
|
571
|
+
# When using current date, use the standard add + complete workflow
|
572
|
+
# Add the task first
|
573
|
+
self.todo_shell.add(full_description)
|
447
574
|
|
448
|
-
|
449
|
-
|
575
|
+
# Find the task number and mark it complete
|
576
|
+
task_number = self._find_task_number_by_description(description)
|
577
|
+
self.todo_shell.complete(task_number)
|
450
578
|
|
451
|
-
|
579
|
+
return f"Created and completed task: {full_description} (completed on {completion_date})"
|
452
580
|
|
453
581
|
def restore_completed_task(self, task_number: int) -> str:
|
454
582
|
"""
|
@@ -80,20 +80,20 @@ class Inference:
|
|
80
80
|
def current_tasks(self) -> str:
|
81
81
|
"""
|
82
82
|
Get current tasks from the todo manager.
|
83
|
-
|
83
|
+
|
84
84
|
Returns:
|
85
85
|
Formatted string of current tasks or error message
|
86
86
|
"""
|
87
87
|
try:
|
88
88
|
# Use the todo manager from the tool handler to get current tasks
|
89
89
|
tasks = self.tool_handler.todo_manager.list_tasks(suppress_color=True)
|
90
|
-
|
90
|
+
|
91
91
|
# If no tasks found, return a clear message
|
92
92
|
if not tasks.strip() or tasks == "No tasks found.":
|
93
93
|
return "No current tasks found."
|
94
|
-
|
94
|
+
|
95
95
|
return tasks
|
96
|
-
|
96
|
+
|
97
97
|
except Exception as e:
|
98
98
|
self.logger.warning(f"Failed to get current tasks: {e!s}")
|
99
99
|
return f"Error retrieving current tasks: {e!s}"
|
@@ -355,7 +355,9 @@ class Inference:
|
|
355
355
|
self.tool_handler.tools
|
356
356
|
)
|
357
357
|
|
358
|
-
def _get_tool_progress_description(
|
358
|
+
def _get_tool_progress_description(
|
359
|
+
self, tool_name: str, tool_call: Dict[str, Any]
|
360
|
+
) -> str:
|
359
361
|
"""
|
360
362
|
Get user-friendly progress description for a tool with parameter interpolation.
|
361
363
|
|
@@ -377,25 +379,30 @@ class Inference:
|
|
377
379
|
|
378
380
|
if tool_def and "progress_description" in tool_def:
|
379
381
|
template = tool_def["progress_description"]
|
380
|
-
|
382
|
+
|
381
383
|
# Extract arguments from tool call
|
382
384
|
arguments = tool_call.get("function", {}).get("arguments", {})
|
383
385
|
if isinstance(arguments, str):
|
384
386
|
import json
|
387
|
+
|
385
388
|
try:
|
386
389
|
arguments = json.loads(arguments)
|
387
390
|
except json.JSONDecodeError:
|
388
391
|
arguments = {}
|
389
|
-
|
392
|
+
|
390
393
|
# Use .format() like the system prompt does
|
391
394
|
try:
|
392
395
|
return template.format(**arguments)
|
393
396
|
except KeyError as e:
|
394
397
|
# If a required parameter is missing, fall back to template
|
395
|
-
self.logger.warning(
|
398
|
+
self.logger.warning(
|
399
|
+
f"Missing parameter {e} for progress description of {tool_name}"
|
400
|
+
)
|
396
401
|
return template
|
397
402
|
except Exception as e:
|
398
|
-
self.logger.warning(
|
403
|
+
self.logger.warning(
|
404
|
+
f"Failed to interpolate progress description for {tool_name}: {e}"
|
405
|
+
)
|
399
406
|
return template
|
400
407
|
|
401
408
|
# Fallback to generic description
|
@@ -12,48 +12,130 @@ You are an AI interface to the user's todo.sh task management system with direct
|
|
12
12
|
|
13
13
|
## Decision Flow
|
14
14
|
1. **Data Discovery** → `list_tasks()` and `list_completed_tasks()` to fetch current and completed tasks
|
15
|
+
2. **Date Discovery**: → `parse_date()` to fetch the calendar dates of any user-specified days
|
15
16
|
2. **Planning Phase** → Analyze tasks and plan operations in logical order:
|
16
17
|
- Multiple distinct goals may be indicated by the user
|
17
|
-
-
|
18
|
+
- "I did X" → Search existing tasks first, then complete or create_completed_task()
|
19
|
+
- Identify dependencies/parents and blocking relationships
|
20
|
+
- Identify the due date of recurring tasks using parse_date()
|
18
21
|
- Determine priority sequence (overdue → due today → due soon → others)
|
19
22
|
- Plan context-specific operations if needed
|
20
23
|
- Map out required tool calls in execution order
|
21
24
|
- Detail the execution plan in the response content
|
22
25
|
3. **Execution Phase** → Execute planned operations in sequence:
|
23
26
|
- Task operations: discover → analyze → execute
|
24
|
-
- "I did X" → Search existing tasks first, then complete or create_completed_task()
|
25
|
-
- Context filtering → Use exact matching: `list_tasks("@context")` only returns tasks with that specific context
|
26
27
|
4. **Validation** → Verify all planned operations completed successfully
|
27
|
-
5. **Respond**: Generate a conversational, context-aware reply that summarizes the actions taken, explains
|
28
|
+
5. **Respond**: Generate a conversational, context-aware reply that summarizes the actions taken, explains
|
29
|
+
reasoning (especially for due dates, priorities, or suggestions), and presents results in a natural, engaging tone.
|
30
|
+
Always reference real data and operations performed. If no action was taken, clearly state why. Ensure the response
|
31
|
+
is logically consistent, matches the user's style, and highlights any important next steps or recommendations.
|
28
32
|
|
29
33
|
## Todo.txt Format
|
30
34
|
```
|
31
|
-
(A) Task description +project @context due:YYYY-MM-DD duration:
|
35
|
+
(A) Task description +project @context due:YYYY-MM-DD duration:2h parent:12
|
36
|
+
(B) Put out trash and recycling +weekly +thursday @home duration:5m
|
37
|
+
(C) Standup meeting at 9:00AM +daily +weekdays @office duration:10m
|
38
|
+
(D) Another task (weekly on Friday) +project @context duration:10m
|
39
|
+
(E) Yet another task (daily) +project @context duration:2h
|
32
40
|
x YYYY-MM-DD Completed task description
|
33
41
|
```
|
34
42
|
|
43
|
+
**Key Format Rules:**
|
44
|
+
- **Recurrence Tags**: Use `+weekly`, `+daily`, `+monthly` for recurrence frequency
|
45
|
+
- **Day Specificity**: Recurring tasks use `+monday`, `+tuesday`, `+wednesday`, `+thursday`, `+friday`, `+saturday`, `+sunday` for specific days
|
46
|
+
- **Weekday Patterns**: Recurring tasks use `+weekdays` for Monday-Friday, `+weekends` for Saturday-Sunday
|
47
|
+
- **Multiple Recurrence**: Combine tags like `+weekly +thursday` or `+daily +weekdays`
|
48
|
+
- **Time Specification**: Include time directly in description (e.g., "at 9:00AM", "at 2:00PM")
|
49
|
+
- **Duration**: Always specify `duration:Xm` or `duration:Xh` for time estimates
|
50
|
+
|
51
|
+
Example: add_task(description='Put out trash and recycling', project='weekly', context='home', due='YYYY-MM-DD', duration='5m')
|
52
|
+
|
35
53
|
## Key Intelligence Engines
|
36
54
|
|
55
|
+
### Recurring Tasks
|
56
|
+
- **Daily Tasks**: If the task is indicated as 'daily', it is considered due today, regardless if a due date is specified.
|
57
|
+
- **Daily Weekday Tasks**: If the task is indicated as 'daily +weekdays', it is due on the next weekday (Monday-Friday).
|
58
|
+
- **Weekly Tasks**: If the task is indicated as 'weekly', it is due on the day of the week mentioned in the task description THIS WEEK.
|
59
|
+
- **Weekly Day-Specific Tasks**: If the task contains both '+weekly' and a specific day (e.g., '+thursday'), it is due on that specific day THIS WEEK.
|
60
|
+
- **Time-Specific Tasks**: If the task contains a specific time (e.g., 'at 9:00AM'), use parse_date() to determine the next occurrence of that time.
|
61
|
+
- **Work in Progress Tasks**: If the task has a 'wip' project tag (work in progress), it is considered due today, regardless if a due date is specified.
|
62
|
+
- **Due Date Inference**: Issue parse_date() to determine the due date of recurring tasks, especially for complex patterns like "weekly on Thursday" or "daily on weekdays at 9:00AM".
|
63
|
+
|
64
|
+
### Complex Recurrence Pattern Examples
|
65
|
+
- `+weekly +thursday` → Due on the next Thursday
|
66
|
+
- `+daily +weekdays` → Due on the next weekday (Monday-Friday)
|
67
|
+
- `at 9:00AM +daily +weekdays` → Due on the next weekday at 9:00AM
|
68
|
+
- `+weekly +friday duration:2h` → Due on the next Friday with 2-hour duration
|
69
|
+
- `+monthly +first` → Due on the first day of next month
|
70
|
+
|
37
71
|
### Task Creation Protocol
|
38
72
|
1. Get current + completed tasks to check duplicates
|
39
73
|
2. Infer project/context/duration from description and patterns
|
40
|
-
3.
|
41
|
-
4.
|
74
|
+
3. **Parse complex recurrence patterns**: Handle multiple recurrence indicators (e.g., '+weekly +thursday', '+daily +weekdays')
|
75
|
+
4. **Handle time specifications**: Extract and process time-based scheduling (e.g., 'at 9:00AM')
|
76
|
+
5. **Parse parent task relationships**: Identify when user indicates task dependencies or hierarchies
|
77
|
+
6. Apply completion date intelligence (work tasks by week end, bills 3-5 days early, etc.)
|
78
|
+
7. Create with full metadata including proper due date calculation and parent relationships
|
79
|
+
|
80
|
+
### Parent Task Relationships
|
81
|
+
**Natural Language Indicators for Parent Tasks**:
|
82
|
+
- **Explicit Dependencies**: "after [task]", "once [task] is done", "following [task]", "depends on [task]"
|
83
|
+
- **Hierarchical Language**: "subtask of [task]", "part of [task]", "under [task]", "child of [task]"
|
84
|
+
- **Sequential Language**: "next step after [task]", "continue with [task]", "build on [task]"
|
85
|
+
- **Conditional Language**: "if [task] then [new task]", "when [task] is complete, do [new task]"
|
86
|
+
- **Update Commands**: "make [task] depend on [task]", "update [task] to depend on [task]", "put [task] under [task]", "move [task] under [task]"
|
87
|
+
|
88
|
+
**Parent Task Resolution Protocol**:
|
89
|
+
1. **Identify Parent Indicators**: Look for natural language cues indicating task relationships (creation or updates)
|
90
|
+
2. **Find Tasks**: Use `list_tasks()` to locate both the child task and parent task
|
91
|
+
3. **Validate Relationship**: Ensure the parent-child relationship makes logical sense
|
92
|
+
4. **Establish Relationship**:
|
93
|
+
- **For new tasks**: Create child task with `parent:XX` reference
|
94
|
+
- **For existing tasks**: Use `set_parent()` to update target task with parent relationship
|
95
|
+
5. **Maintain Hierarchy**: Preserve task dependencies in the todo.txt format with `parent:XX`
|
96
|
+
|
97
|
+
**Parent Task Examples**:
|
98
|
+
- **Creating new tasks**: "Add a subtask to review the quarterly report" → Find parent task "Review quarterly report" and create child task with `parent:XX`
|
99
|
+
- **Creating new tasks**: "After I finish the presentation, I need to schedule a follow-up meeting" → Find "presentation" task and create "schedule follow-up meeting" with `parent:XX`
|
100
|
+
- **Creating new tasks**: "Once the code review is done, I'll deploy to staging" → Find "code review" task and create "deploy to staging" with `parent:XX`
|
101
|
+
- **Updating existing tasks**: "Update the listing tasks to depend on the pictures task" → Find "listing tasks" and "pictures task", then use `set_parent()` to update listing tasks
|
102
|
+
- **Updating existing tasks**: "Make the deployment depend on the testing task" → Find "deployment" and "testing" tasks, then use `set_parent()` to update deployment
|
103
|
+
- **Updating existing tasks**: "Put the documentation under the code review task" → Find "documentation" and "code review" tasks, then use `set_parent()` to update documentation
|
104
|
+
|
105
|
+
**Parent Task Format**: `(A) Child task description +project @context due:YYYY-MM-DD duration:2h parent:12`
|
42
106
|
|
43
107
|
### Task Completion Protocol
|
44
|
-
1.
|
45
|
-
2.
|
46
|
-
|
47
|
-
|
108
|
+
1. **Discovery**: Use `list_tasks()` to search semantically in active tasks
|
109
|
+
2. **For Recurring Tasks** (containing +daily, +weekly, +monthly, +weekdays, etc.):
|
110
|
+
- **USE** `create_completed_task()` with the original task number as parent_number
|
111
|
+
- **PRESERVE** the original recurring task for future occurrences. **IMPORTANT** DO NOT MARK COMPLETE! DO NOT MODIFY!
|
112
|
+
- **TOOL CALL**: `create_completed_task(description='Task description', parent_number='XX', completion_date='YYYY-MM-DD', context='context', project='project')`
|
113
|
+
3. **For Non-Recurring Tasks**:
|
114
|
+
- Single match → use `complete_task(task_number='XX')`
|
115
|
+
- Multiple/fuzzy → show options
|
116
|
+
- No match → suggest alternatives
|
117
|
+
|
118
|
+
### Recurring Task Completion Examples
|
119
|
+
- **User says**: "I put out the trash" → Find `(B) Put out trash and recycling +weekly +thursday @home duration:5m`
|
120
|
+
- **Tool Call**: `create_completed_task(description='Put out trash and recycling', parent_number='B', completion_date='YYYY-MM-DD', context='home', project='weekly')`
|
121
|
+
- **Result**: Original task remains active for next Thursday
|
122
|
+
- **User says**: "Done with standup" → Find `Standup meeting at 9:00AM +daily +weekdays @office duration:10m`
|
123
|
+
- **Tool Call**: `create_completed_task(description='Standup meeting at 9:00AM', parent_number='XX', completion_date='YYYY-MM-DD', context='office', project='daily')`
|
124
|
+
- **Result**: Original task remains active for next weekday
|
48
125
|
|
49
126
|
### Task Suggestions
|
50
127
|
**Trigger**: User asks, seems stuck, or after completions
|
51
|
-
**Method**:
|
128
|
+
**Method**:
|
129
|
+
- Identify tasks within the user's implied temporal scope
|
130
|
+
- Consider that today is {current_datetime}, and match any recurring tasks
|
131
|
+
- Pay careful attention to due dates and their relation to the current date
|
132
|
+
- @office and work tasks are always the highest priority
|
133
|
+
- +wip tasks are considered high priority and due today
|
52
134
|
- Balance urgency and priority. Use your best judgment.
|
53
|
-
- Logical dependencies
|
135
|
+
- Logical and explicit dependencies should be suggested first (tasks that unblock others get priority)
|
54
136
|
- Then urgency (overdue → due today → due soon)
|
55
|
-
-
|
56
|
-
-
|
137
|
+
- Be exhaustive in your search and mention everything relevant
|
138
|
+
- Always state days of the week
|
57
139
|
|
58
140
|
### Context Patterns
|
59
141
|
- `@phone`: calls, appointments
|
@@ -64,6 +146,23 @@ x YYYY-MM-DD Completed task description
|
|
64
146
|
|
65
147
|
### Project Patterns
|
66
148
|
- Health → `+health`, Work → `+work`, Bills → `+bills`, etc.
|
149
|
+
- Recurring tasks:
|
150
|
+
- `+daily`: ALWAYS consider due today
|
151
|
+
- `+weekly`: ALWAYS consider due on the specific day THIS WEEK
|
152
|
+
|
153
|
+
## Notes Protocol
|
154
|
+
**When the user wants to create a note**:
|
155
|
+
**Triggers**: 'note:', or 'Create a note', or 'progress:', etc
|
156
|
+
**Action**: create_completed_task()
|
157
|
+
**Method**:
|
158
|
+
1. ALWAYS Identify semantically similar tasks that are the likely parent task
|
159
|
+
2. IF there is LIKELY match, that is the parent and parent_number
|
160
|
+
4. Create a completed task with create_completed_task():
|
161
|
+
- **IMPORTANT**: ALWAYS assign the parent_number if a match was found
|
162
|
+
- with inferred parent_number, completion_date, context, and project
|
163
|
+
- Task description should never include date of completion
|
164
|
+
- NO priority
|
165
|
+
**Response**: ALWAYS refer to your note actions as creating a note, NOT creating a task
|
67
166
|
|
68
167
|
## Critical Rules
|
69
168
|
- **Overdue definition**: A task is overdue IF AND _ONLY IF_ due < {current_datetime}. None is an acceptable answer!
|
@@ -71,11 +170,14 @@ x YYYY-MM-DD Completed task description
|
|
71
170
|
- **Task ordering**: Always dependencies first, then urgency
|
72
171
|
- **Data integrity**: Only use real tool data, never fabricate
|
73
172
|
- **Completion date reasoning**: Always explain date suggestions briefly
|
173
|
+
- **Parent Tasks**: Task dependencies are explicitly indicated by `parent:XX` tags
|
174
|
+
- **Parent Task Detection**: Always identify and establish parent-child relationships when users indicate task dependencies through natural language
|
74
175
|
|
75
176
|
## Tool Selection Strategy
|
76
177
|
- Project tags: use `set_project()`
|
77
178
|
- Context tags: use `set_context()`
|
78
179
|
- Due dates: use `set_due_date()`
|
180
|
+
- Parent relationships: use `set_parent()`
|
79
181
|
- Discovery: `list_tasks()` once for full context
|
80
182
|
- Completion: `list_tasks()` + `complete_task()`
|
81
183
|
- Addition: `list_tasks()` + `add_task()` with full metadata
|
@@ -108,6 +108,10 @@ class TodoShell:
|
|
108
108
|
"""Add new task."""
|
109
109
|
return self.execute(["todo.sh", "add", description])
|
110
110
|
|
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
|
+
|
111
115
|
def list_tasks(
|
112
116
|
self, filter_str: Optional[str] = None, suppress_color: bool = True
|
113
117
|
) -> str:
|
@@ -276,6 +280,61 @@ class TodoShell:
|
|
276
280
|
# Replace the task with the new description
|
277
281
|
return self.replace(task_number, new_description)
|
278
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
|
+
|
279
338
|
def _extract_task_number(self, line: str) -> Optional[int]:
|
280
339
|
"""
|
281
340
|
Extract task number from a line that may contain ANSI color codes.
|
todo_agent/interface/tools.py
CHANGED
@@ -16,7 +16,7 @@ Task Management Tools:
|
|
16
16
|
- append_to_task(task_number, text) - Add text to end of existing task
|
17
17
|
- prepend_to_task(task_number, text) - Add text to beginning of existing task
|
18
18
|
- delete_task(task_number, term?) - Delete entire task or remove specific term
|
19
|
-
-
|
19
|
+
- create_completed_task(description, completion_date?, project?, context?) - Create task and immediately mark as completed
|
20
20
|
|
21
21
|
Priority Management Tools:
|
22
22
|
- set_priority(task_number, priority) - Set or change task priority (A-Z)
|
@@ -26,6 +26,7 @@ Task Modification Tools:
|
|
26
26
|
- set_due_date(task_number, due_date) - Set or update due date for a task by intelligently rewriting it (use empty string to remove due date)
|
27
27
|
- set_context(task_number, context) - Set or update context for a task by intelligently rewriting it (use empty string to remove context)
|
28
28
|
- set_project(task_number, projects) - Set or update projects for a task by intelligently rewriting it (handles array of projects with add/remove operations)
|
29
|
+
- set_parent(task_number, parent_number) - Set or update parent task number for a task by intelligently rewriting it (use None to remove parent)
|
29
30
|
|
30
31
|
Utility Tools:
|
31
32
|
- move_task(task_number, destination, source?) - Move task between files
|
@@ -176,6 +177,10 @@ class ToolCallHandler:
|
|
176
177
|
"type": "string",
|
177
178
|
"description": "Optional duration estimate in format: minutes (e.g., '30m'), hours (e.g., '2h'), or days (e.g., '1d'). Use for time planning and task prioritization.",
|
178
179
|
},
|
180
|
+
"parent_number": {
|
181
|
+
"type": "integer",
|
182
|
+
"description": "Optional parent task number (required for subtasks)",
|
183
|
+
},
|
179
184
|
},
|
180
185
|
"required": ["description"],
|
181
186
|
},
|
@@ -254,12 +259,12 @@ class ToolCallHandler:
|
|
254
259
|
"type": "integer",
|
255
260
|
"description": "The line number of the task to modify (required)",
|
256
261
|
},
|
257
|
-
"
|
262
|
+
"text": {
|
258
263
|
"type": "string",
|
259
264
|
"description": "Text to add to the end of the task (required)",
|
260
265
|
},
|
261
266
|
},
|
262
|
-
"required": ["task_number", "
|
267
|
+
"required": ["task_number", "text"],
|
263
268
|
},
|
264
269
|
},
|
265
270
|
"progress_description": "📝 Adding notes to task #{task_number}...",
|
@@ -474,6 +479,39 @@ class ToolCallHandler:
|
|
474
479
|
},
|
475
480
|
"progress_description": "🏷️ Setting project tags for task #{task_number}...",
|
476
481
|
},
|
482
|
+
{
|
483
|
+
"type": "function",
|
484
|
+
"function": {
|
485
|
+
"name": "set_parent",
|
486
|
+
"description": (
|
487
|
+
"Set or update the parent task number for an EXISTING task by intelligently rewriting it. "
|
488
|
+
"USE CASE: Call this when user wants to add, change, or remove a parent task relationship on an existing task. "
|
489
|
+
"NOT FOR: Creating new tasks, completing tasks, or any other task operations. "
|
490
|
+
"This preserves all existing task components (priority, projects, contexts, due date, etc.) "
|
491
|
+
"while updating or adding the parent relationship. Use None to remove the parent. "
|
492
|
+
"PREFERRED METHOD: Use this instead of append_to_task() when adding parent tags (parent:XX). "
|
493
|
+
"This tool properly manages parent relationships and prevents formatting issues. "
|
494
|
+
"IMPORTANT: Use list_tasks() first "
|
495
|
+
"to find the correct task number if user doesn't specify it. "
|
496
|
+
"Parent task numbers should be provided as integers (e.g., 12 not 'parent:12')."
|
497
|
+
),
|
498
|
+
"parameters": {
|
499
|
+
"type": "object",
|
500
|
+
"properties": {
|
501
|
+
"task_number": {
|
502
|
+
"type": "integer",
|
503
|
+
"description": "The line number of the task to modify (required)",
|
504
|
+
},
|
505
|
+
"parent_number": {
|
506
|
+
"type": "integer",
|
507
|
+
"description": "Parent task number to set, or null to remove parent (required)",
|
508
|
+
},
|
509
|
+
},
|
510
|
+
"required": ["task_number", "parent_number"],
|
511
|
+
},
|
512
|
+
},
|
513
|
+
"progress_description": "🔗 Setting parent task {parent_number} for task #{task_number}...",
|
514
|
+
},
|
477
515
|
{
|
478
516
|
"type": "function",
|
479
517
|
"function": {
|
@@ -585,7 +623,7 @@ class ToolCallHandler:
|
|
585
623
|
{
|
586
624
|
"type": "function",
|
587
625
|
"function": {
|
588
|
-
"name": "
|
626
|
+
"name": "create_completed_task",
|
589
627
|
"description": (
|
590
628
|
"Create a task and immediately mark it as completed. "
|
591
629
|
"USE CASE: Call this when user says they completed something on a specific date (e.g., 'I did the laundry today', 'I finished the report yesterday', 'I cleaned the garage last week') "
|
@@ -615,6 +653,10 @@ class ToolCallHandler:
|
|
615
653
|
"type": "string",
|
616
654
|
"description": "Optional context name (without the @ symbol) for new task creation",
|
617
655
|
},
|
656
|
+
"parent_number": {
|
657
|
+
"type": "integer",
|
658
|
+
"description": "Optional parent task number (required for subtasks)",
|
659
|
+
},
|
618
660
|
},
|
619
661
|
"required": ["description"],
|
620
662
|
},
|
@@ -897,11 +939,12 @@ class ToolCallHandler:
|
|
897
939
|
"set_due_date": self.todo_manager.set_due_date,
|
898
940
|
"set_context": self.todo_manager.set_context,
|
899
941
|
"set_project": self.todo_manager.set_project,
|
942
|
+
"set_parent": self.todo_manager.set_parent,
|
900
943
|
"move_task": self.todo_manager.move_task,
|
901
944
|
"archive_tasks": self.todo_manager.archive_tasks,
|
902
945
|
"parse_date": self._parse_date,
|
903
946
|
"get_calendar": self._get_calendar,
|
904
|
-
"
|
947
|
+
"create_completed_task": self.todo_manager.create_completed_task,
|
905
948
|
"restore_completed_task": self.todo_manager.restore_completed_task,
|
906
949
|
}
|
907
950
|
|
todo_agent/main.py
CHANGED
@@ -11,9 +11,12 @@ from .interface.cli import CLI
|
|
11
11
|
|
12
12
|
def main() -> None:
|
13
13
|
"""Main application entry point."""
|
14
|
+
from ._version import __version__
|
15
|
+
|
14
16
|
parser = argparse.ArgumentParser(
|
15
|
-
description="Todo.sh LLM Agent - Natural language task management",
|
17
|
+
description=f"Todo.sh LLM Agent - Natural language task management (v{__version__})",
|
16
18
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
19
|
+
add_help=False,
|
17
20
|
epilog="""
|
18
21
|
Examples:
|
19
22
|
todo-agent # Interactive mode
|
@@ -23,6 +26,19 @@ Examples:
|
|
23
26
|
""",
|
24
27
|
)
|
25
28
|
|
29
|
+
parser.add_argument(
|
30
|
+
"--version", "-v",
|
31
|
+
action="version",
|
32
|
+
version=f"%(prog)s {__version__}",
|
33
|
+
help="Show version information and exit",
|
34
|
+
)
|
35
|
+
|
36
|
+
parser.add_argument(
|
37
|
+
"--help", "-h",
|
38
|
+
action="help",
|
39
|
+
help="Show this help message and exit",
|
40
|
+
)
|
41
|
+
|
26
42
|
parser.add_argument(
|
27
43
|
"command",
|
28
44
|
nargs="?",
|
@@ -1,30 +1,30 @@
|
|
1
1
|
todo_agent/__init__.py,sha256=RUowhd14r3tqB_7rl83unGV8oBjra3UOIl7jix-33fk,254
|
2
|
-
todo_agent/_version.py,sha256=
|
3
|
-
todo_agent/main.py,sha256
|
2
|
+
todo_agent/_version.py,sha256=UAb2Toi6SAdScDfq1uKRRv5QpMUuRtJqqwNxTMGe5Q4,704
|
3
|
+
todo_agent/main.py,sha256=DfcZuTsHkmpm6kw0coB0-B677K3hyRwutWIuO4noQ3w,2026
|
4
4
|
todo_agent/core/__init__.py,sha256=QAZ4it63pXv5-DxtNcuSAmg7ZnCY5ackI5yycvKHr9I,365
|
5
5
|
todo_agent/core/conversation_manager.py,sha256=9aAWogswZe9Cs7wKT47RG-cLh1LQ5D9RbhUHJVUyTS8,13549
|
6
6
|
todo_agent/core/exceptions.py,sha256=ilVL5hyPHGYQXsLm0pYivMbhbamupG77r8TbiQr2tAU,2034
|
7
|
-
todo_agent/core/todo_manager.py,sha256=
|
7
|
+
todo_agent/core/todo_manager.py,sha256=qnMT6TOhxvh-e_2IkC8rA87T56iJQ9mHT1AyffrAFr8,25134
|
8
8
|
todo_agent/infrastructure/__init__.py,sha256=SGbHXgzq6U1DMgOfWPMsWEK99zjPSF-6gzy7xqc5fsI,284
|
9
9
|
todo_agent/infrastructure/calendar_utils.py,sha256=8tOZHN5CNrRHpTQHYzskmsZNJKWuUFjrjyvHQ76AhzU,1837
|
10
10
|
todo_agent/infrastructure/config.py,sha256=zyp6qOlg1nN_awphivlgGNBE6fL0Hf66YgvWxR8ldyQ,2117
|
11
|
-
todo_agent/infrastructure/inference.py,sha256=
|
11
|
+
todo_agent/infrastructure/inference.py,sha256=nv0ZbYHiJtWOwsN1VoXY1f0Zlf9SOIOVQZDhnAeC1YU,16212
|
12
12
|
todo_agent/infrastructure/llm_client.py,sha256=Knb6yvQt0q8mobnwVH08mGCd8xBHK_g1S66DOdp7cO8,9807
|
13
13
|
todo_agent/infrastructure/llm_client_factory.py,sha256=-tktnVOIF7B45WR7AuLoi7MKnEyuM8lgg1jjc4T1FhM,1929
|
14
14
|
todo_agent/infrastructure/logger.py,sha256=2ykG_0lyzmEGxDF6ZRl1qiTUGDuFeQgzv4Na6vRmXcM,4110
|
15
15
|
todo_agent/infrastructure/ollama_client.py,sha256=V_zAeBjIEzB8PZXyzFeiLMLA5qf3y4WV2_6Vqxn1ujc,5629
|
16
16
|
todo_agent/infrastructure/openrouter_client.py,sha256=0pxIKvHItwVijFz8l4loOeCa4HUpMvTYROcwYJh3iyI,6748
|
17
|
-
todo_agent/infrastructure/todo_shell.py,sha256=
|
17
|
+
todo_agent/infrastructure/todo_shell.py,sha256=2iPPhsLsTcjVx8Wv2mJJBu11kLJOK9Tk5KBAyVQ3Yjs,20596
|
18
18
|
todo_agent/infrastructure/token_counter.py,sha256=PCKheOVJbp1s89yhh_i6iKgURMt9mVoYkwjQJCc2xCE,4958
|
19
|
-
todo_agent/infrastructure/prompts/system_prompt.txt,sha256=
|
19
|
+
todo_agent/infrastructure/prompts/system_prompt.txt,sha256=ZHGYgd7lCuWaSX5io3IYWFhdjcGbdqKXf2uhEWdbzQ8,12380
|
20
20
|
todo_agent/interface/__init__.py,sha256=vDD3rQu4qDkpvVwGVtkDzE1M4IiSHYzTif4GbYSFWaI,457
|
21
21
|
todo_agent/interface/cli.py,sha256=Ikh5AYoOZnNUVXzHjfv2bsE33yK3VhCdzza83HOzFp8,16460
|
22
22
|
todo_agent/interface/formatters.py,sha256=a9PW-5DbY8K5QYZjBXFZSdzlCmy263kwBI9nXgP8LXI,20081
|
23
23
|
todo_agent/interface/progress.py,sha256=EpPa20Hrnjv_TBIp0tzViVciThqsOvAHuYO4v2rc8eI,1751
|
24
|
-
todo_agent/interface/tools.py,sha256
|
25
|
-
todo_agent-0.3.
|
26
|
-
todo_agent-0.3.
|
27
|
-
todo_agent-0.3.
|
28
|
-
todo_agent-0.3.
|
29
|
-
todo_agent-0.3.
|
30
|
-
todo_agent-0.3.
|
24
|
+
todo_agent/interface/tools.py,sha256=Q6h2zpe1yRUwJOZZFArjXgBN6WPDI56CDQQJcdF1I5M,54448
|
25
|
+
todo_agent-0.3.5.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
26
|
+
todo_agent-0.3.5.dist-info/METADATA,sha256=eP-pVo55MQvK1G9b9vaoOhir6fQnjOr4CD-D8n29Ha0,10056
|
27
|
+
todo_agent-0.3.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
28
|
+
todo_agent-0.3.5.dist-info/entry_points.txt,sha256=4W7LrCib6AXP5IZDwWRht8S5gutLu5oNfTJHGbt4oHs,52
|
29
|
+
todo_agent-0.3.5.dist-info/top_level.txt,sha256=a65mlPIhPZHuq2bRIi_sCMAIJsUddvXt171OBF6r6co,11
|
30
|
+
todo_agent-0.3.5.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|