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
@@ -4,7 +4,7 @@ Subprocess wrapper for todo.sh operations.
|
|
4
4
|
|
5
5
|
import os
|
6
6
|
import subprocess # nosec B404
|
7
|
-
from typing import Any, List, Optional
|
7
|
+
from typing import Any, List, Optional, TypedDict
|
8
8
|
|
9
9
|
try:
|
10
10
|
from todo_agent.core.exceptions import TodoShellError
|
@@ -12,6 +12,18 @@ except ImportError:
|
|
12
12
|
from core.exceptions import TodoShellError # type: ignore[no-redef]
|
13
13
|
|
14
14
|
|
15
|
+
class TaskComponents(TypedDict):
|
16
|
+
"""Type definition for task components."""
|
17
|
+
|
18
|
+
priority: str | None
|
19
|
+
description: str
|
20
|
+
projects: list[str]
|
21
|
+
contexts: list[str]
|
22
|
+
due: str | None
|
23
|
+
recurring: str | None
|
24
|
+
other_tags: list[str]
|
25
|
+
|
26
|
+
|
15
27
|
class TodoShell:
|
16
28
|
"""Subprocess execution wrapper with error management."""
|
17
29
|
|
@@ -142,12 +154,337 @@ class TodoShell:
|
|
142
154
|
"""Archive completed tasks."""
|
143
155
|
return self.execute(["todo.sh", "-f", "archive"])
|
144
156
|
|
145
|
-
def
|
146
|
-
"""
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
157
|
+
def set_due_date(self, task_number: int, due_date: str) -> str:
|
158
|
+
"""
|
159
|
+
Set or update due date for a task by intelligently rewriting it.
|
160
|
+
|
161
|
+
Args:
|
162
|
+
task_number: The task number to modify
|
163
|
+
due_date: Due date in YYYY-MM-DD format, or empty string to remove due date
|
164
|
+
|
165
|
+
Returns:
|
166
|
+
The updated task description
|
167
|
+
"""
|
168
|
+
# First, get the current task to parse its components
|
169
|
+
tasks_output = self.list_tasks()
|
170
|
+
task_lines = tasks_output.strip().split("\n")
|
171
|
+
|
172
|
+
# Find the task by its actual number (not array index)
|
173
|
+
current_task = None
|
174
|
+
for line in task_lines:
|
175
|
+
if line.strip():
|
176
|
+
# Extract task number from the beginning of the line (handling ANSI codes)
|
177
|
+
extracted_number = self._extract_task_number(line)
|
178
|
+
if extracted_number == task_number:
|
179
|
+
current_task = line
|
180
|
+
break
|
181
|
+
|
182
|
+
if not current_task:
|
183
|
+
raise TodoShellError(f"Task number {task_number} not found")
|
184
|
+
|
185
|
+
# Parse the current task components
|
186
|
+
components = self._parse_task_components(current_task)
|
187
|
+
|
188
|
+
# Update the due date (empty string removes it)
|
189
|
+
if due_date.strip():
|
190
|
+
components["due"] = due_date
|
191
|
+
else:
|
192
|
+
components["due"] = None
|
193
|
+
|
194
|
+
# Reconstruct the task
|
195
|
+
new_description = self._reconstruct_task(components)
|
196
|
+
|
197
|
+
# Replace the task with the new description
|
198
|
+
return self.replace(task_number, new_description)
|
199
|
+
|
200
|
+
def set_context(self, task_number: int, context: str) -> str:
|
201
|
+
"""
|
202
|
+
Set or update context for a task by intelligently rewriting it.
|
203
|
+
|
204
|
+
Args:
|
205
|
+
task_number: The task number to modify
|
206
|
+
context: Context name (without @ symbol), or empty string to remove context
|
207
|
+
|
208
|
+
Returns:
|
209
|
+
The updated task description
|
210
|
+
"""
|
211
|
+
# First, get the current task to parse its components
|
212
|
+
tasks_output = self.list_tasks()
|
213
|
+
task_lines = tasks_output.strip().split("\n")
|
214
|
+
|
215
|
+
# Find the task by its actual number (not array index)
|
216
|
+
current_task = None
|
217
|
+
for line in task_lines:
|
218
|
+
if line.strip():
|
219
|
+
# Extract task number from the beginning of the line (handling ANSI codes)
|
220
|
+
extracted_number = self._extract_task_number(line)
|
221
|
+
if extracted_number == task_number:
|
222
|
+
current_task = line
|
223
|
+
break
|
224
|
+
|
225
|
+
if not current_task:
|
226
|
+
raise TodoShellError(f"Task number {task_number} not found")
|
227
|
+
|
228
|
+
# Parse the current task components
|
229
|
+
components = self._parse_task_components(current_task)
|
230
|
+
|
231
|
+
# Update the context (empty string removes it)
|
232
|
+
if context.strip():
|
233
|
+
# Remove any existing @ symbols to prevent duplication
|
234
|
+
clean_context = context.strip().lstrip("@")
|
235
|
+
if not clean_context:
|
236
|
+
raise TodoShellError(
|
237
|
+
"Context name cannot be empty after removing @ symbol."
|
238
|
+
)
|
239
|
+
context_tag = f"@{clean_context}"
|
240
|
+
# Only add if not already present (deduplication)
|
241
|
+
if context_tag not in components["contexts"]:
|
242
|
+
components["contexts"] = [context_tag]
|
243
|
+
else:
|
244
|
+
# Context already exists, no change needed
|
245
|
+
return self._reconstruct_task(components)
|
246
|
+
else:
|
247
|
+
components["contexts"] = []
|
248
|
+
|
249
|
+
# Reconstruct the task
|
250
|
+
new_description = self._reconstruct_task(components)
|
251
|
+
|
252
|
+
# Replace the task with the new description
|
253
|
+
return self.replace(task_number, new_description)
|
254
|
+
|
255
|
+
def _extract_task_number(self, line: str) -> Optional[int]:
|
256
|
+
"""
|
257
|
+
Extract task number from a line that may contain ANSI color codes.
|
258
|
+
|
259
|
+
Args:
|
260
|
+
line: Task line that may contain ANSI color codes
|
261
|
+
|
262
|
+
Returns:
|
263
|
+
Task number if found, None otherwise
|
264
|
+
"""
|
265
|
+
from rich.text import Text
|
266
|
+
|
267
|
+
# Use rich to properly handle ANSI color codes
|
268
|
+
text = Text.from_ansi(line)
|
269
|
+
clean_line = text.plain
|
270
|
+
|
271
|
+
# Split on first space and check if first part is a number
|
272
|
+
parts = clean_line.split(" ", 1)
|
273
|
+
if parts and parts[0].isdigit():
|
274
|
+
return int(parts[0])
|
275
|
+
return None
|
276
|
+
|
277
|
+
def set_project(self, task_number: int, projects: list) -> str:
|
278
|
+
"""
|
279
|
+
Set or update projects for a task by intelligently rewriting it.
|
280
|
+
|
281
|
+
Args:
|
282
|
+
task_number: The task number to modify
|
283
|
+
projects: List of project operations. Each item can be:
|
284
|
+
- "project" (add project)
|
285
|
+
- "-project" (remove project)
|
286
|
+
- Empty string is a NOOP
|
287
|
+
|
288
|
+
Returns:
|
289
|
+
The updated task description
|
290
|
+
"""
|
291
|
+
# First, get the current task to parse its components
|
292
|
+
tasks_output = self.list_tasks()
|
293
|
+
task_lines = tasks_output.strip().split("\n")
|
294
|
+
|
295
|
+
# Find the task by its actual number (not array index)
|
296
|
+
current_task = None
|
297
|
+
for line in task_lines:
|
298
|
+
if line.strip():
|
299
|
+
# Extract task number from the beginning of the line (handling ANSI codes)
|
300
|
+
extracted_number = self._extract_task_number(line)
|
301
|
+
if extracted_number == task_number:
|
302
|
+
current_task = line
|
303
|
+
break
|
304
|
+
|
305
|
+
if not current_task:
|
306
|
+
raise TodoShellError(f"Task number {task_number} not found")
|
307
|
+
|
308
|
+
# Parse the current task components
|
309
|
+
components = self._parse_task_components(current_task)
|
310
|
+
|
311
|
+
# Store original projects to check if any changes were made
|
312
|
+
original_projects = components["projects"].copy()
|
313
|
+
|
314
|
+
# Handle project operations
|
315
|
+
if not projects:
|
316
|
+
# Empty list is a NOOP - return original task unchanged
|
317
|
+
return self._reconstruct_task(components)
|
318
|
+
else:
|
319
|
+
# Process each project operation
|
320
|
+
for project in projects:
|
321
|
+
if not project.strip():
|
322
|
+
# Empty string is a NOOP - skip this operation
|
323
|
+
continue
|
324
|
+
elif project.startswith("-"):
|
325
|
+
# Remove project
|
326
|
+
clean_project = project[1:].strip().lstrip("+")
|
327
|
+
if not clean_project:
|
328
|
+
raise TodoShellError(
|
329
|
+
"Project name cannot be empty after removing - and + symbols."
|
330
|
+
)
|
331
|
+
# Remove the project if it exists (with or without + prefix)
|
332
|
+
project_to_remove = f"+{clean_project}"
|
333
|
+
components["projects"] = [
|
334
|
+
p
|
335
|
+
for p in components["projects"]
|
336
|
+
if p != project_to_remove and p != clean_project
|
337
|
+
]
|
338
|
+
else:
|
339
|
+
# Add project
|
340
|
+
clean_project = project.strip().lstrip("+")
|
341
|
+
if not clean_project:
|
342
|
+
raise TodoShellError(
|
343
|
+
"Project name cannot be empty after removing + symbol."
|
344
|
+
)
|
345
|
+
project_tag = f"+{clean_project}"
|
346
|
+
# Only add if not already present (deduplication)
|
347
|
+
if project_tag not in components["projects"]:
|
348
|
+
components["projects"].append(project_tag)
|
349
|
+
|
350
|
+
# Check if any changes were actually made
|
351
|
+
if components["projects"] == original_projects:
|
352
|
+
# No changes made - return original task unchanged
|
353
|
+
return self._reconstruct_task(components)
|
354
|
+
|
355
|
+
# Reconstruct the task
|
356
|
+
new_description = self._reconstruct_task(components)
|
357
|
+
|
358
|
+
# Replace the task with the new description
|
359
|
+
return self.replace(task_number, new_description)
|
360
|
+
|
361
|
+
def _parse_task_components(self, task_line: str) -> TaskComponents:
|
362
|
+
"""
|
363
|
+
Parse a todo.txt task line into its components.
|
364
|
+
|
365
|
+
Args:
|
366
|
+
task_line: Raw task line from todo.txt
|
367
|
+
|
368
|
+
Returns:
|
369
|
+
Dictionary with parsed components
|
370
|
+
"""
|
371
|
+
# Remove ANSI color codes first using rich
|
372
|
+
from rich.text import Text
|
373
|
+
|
374
|
+
text = Text.from_ansi(task_line)
|
375
|
+
task_line = text.plain
|
376
|
+
|
377
|
+
# Remove task number prefix if present (e.g., "1 " or "1. ")
|
378
|
+
# First try the format without dot (standard todo.sh format)
|
379
|
+
if " " in task_line and task_line.split(" ")[0].isdigit():
|
380
|
+
task_line = task_line.split(" ", 1)[1]
|
381
|
+
# Fallback to dot format if present
|
382
|
+
elif ". " in task_line:
|
383
|
+
task_line = task_line.split(". ", 1)[1]
|
384
|
+
|
385
|
+
components: TaskComponents = {
|
386
|
+
"priority": None,
|
387
|
+
"description": "",
|
388
|
+
"projects": [],
|
389
|
+
"contexts": [],
|
390
|
+
"due": None,
|
391
|
+
"recurring": None,
|
392
|
+
"other_tags": [],
|
393
|
+
}
|
394
|
+
|
395
|
+
# Use sets to automatically deduplicate projects and contexts
|
396
|
+
projects_set = set()
|
397
|
+
contexts_set = set()
|
398
|
+
other_tags_set = set()
|
399
|
+
|
400
|
+
# Split by spaces to process each word
|
401
|
+
words = task_line.split()
|
402
|
+
|
403
|
+
for word in words:
|
404
|
+
# Priority: (A), (B), etc.
|
405
|
+
if word.startswith("(") and word.endswith(")") and len(word) == 3:
|
406
|
+
priority = word[1]
|
407
|
+
if priority.isalpha() and priority.isupper():
|
408
|
+
components["priority"] = priority
|
409
|
+
continue
|
410
|
+
|
411
|
+
# Projects: +project
|
412
|
+
if word.startswith("+"):
|
413
|
+
projects_set.add(word)
|
414
|
+
continue
|
415
|
+
|
416
|
+
# Contexts: @context
|
417
|
+
if word.startswith("@"):
|
418
|
+
contexts_set.add(word)
|
419
|
+
continue
|
420
|
+
|
421
|
+
# Due date: due:YYYY-MM-DD
|
422
|
+
if word.startswith("due:"):
|
423
|
+
components["due"] = word[4:] # Remove 'due:' prefix
|
424
|
+
continue
|
425
|
+
|
426
|
+
# Recurring: rec:frequency[:interval]
|
427
|
+
if word.startswith("rec:"):
|
428
|
+
components["recurring"] = word
|
429
|
+
continue
|
430
|
+
|
431
|
+
# Other tags (like custom tags)
|
432
|
+
if (
|
433
|
+
":" in word
|
434
|
+
and not word.startswith("due:")
|
435
|
+
and not word.startswith("rec:")
|
436
|
+
):
|
437
|
+
other_tags_set.add(word)
|
438
|
+
continue
|
439
|
+
|
440
|
+
# Regular description text
|
441
|
+
if components["description"]:
|
442
|
+
components["description"] += " " + word
|
443
|
+
else:
|
444
|
+
components["description"] = word
|
445
|
+
|
446
|
+
# Convert sets back to sorted lists for consistent ordering
|
447
|
+
components["projects"] = sorted(projects_set)
|
448
|
+
components["contexts"] = sorted(contexts_set)
|
449
|
+
components["other_tags"] = sorted(other_tags_set)
|
450
|
+
|
451
|
+
return components
|
452
|
+
|
453
|
+
def _reconstruct_task(self, components: TaskComponents) -> str:
|
454
|
+
"""
|
455
|
+
Reconstruct a task description from parsed components.
|
456
|
+
|
457
|
+
Args:
|
458
|
+
components: Dictionary with task components
|
459
|
+
|
460
|
+
Returns:
|
461
|
+
Reconstructed task description
|
462
|
+
"""
|
463
|
+
parts = []
|
464
|
+
|
465
|
+
# Add priority if present
|
466
|
+
if components["priority"]:
|
467
|
+
parts.append(f"({components['priority']})")
|
468
|
+
|
469
|
+
# Add description
|
470
|
+
if components["description"]:
|
471
|
+
parts.append(components["description"])
|
472
|
+
|
473
|
+
# Add projects
|
474
|
+
parts.extend(components["projects"])
|
475
|
+
|
476
|
+
# Add contexts
|
477
|
+
parts.extend(components["contexts"])
|
478
|
+
|
479
|
+
# Add due date
|
480
|
+
if components["due"]:
|
481
|
+
parts.append(f"due:{components['due']}")
|
482
|
+
|
483
|
+
# Add recurring pattern
|
484
|
+
if components["recurring"]:
|
485
|
+
parts.append(components["recurring"])
|
486
|
+
|
487
|
+
# Add other tags
|
488
|
+
parts.extend(components["other_tags"])
|
489
|
+
|
490
|
+
return " ".join(parts)
|
todo_agent/interface/cli.py
CHANGED
@@ -166,7 +166,7 @@ class CLI:
|
|
166
166
|
while True:
|
167
167
|
try:
|
168
168
|
# Print prompt character on separate line to prevent deletion
|
169
|
-
self.console.print("\n[bold cyan]▶[/bold cyan]", end="
|
169
|
+
self.console.print("\n[bold cyan]▶[/bold cyan]", end=" ")
|
170
170
|
user_input = self.console.input().strip()
|
171
171
|
|
172
172
|
if user_input.lower() in ["quit", "exit", "q"]:
|
@@ -245,7 +245,9 @@ class CLI:
|
|
245
245
|
formatted_response = ResponseFormatter.format_response(response)
|
246
246
|
|
247
247
|
# Get memory usage
|
248
|
-
|
248
|
+
# DISABLED FOR NOW
|
249
|
+
# memory_usage = self._get_memory_usage()
|
250
|
+
memory_usage = None
|
249
251
|
|
250
252
|
# Create response panel with memory usage
|
251
253
|
response_panel = PanelFormatter.create_response_panel(
|