todo-agent 0.2.6__py3-none-any.whl → 0.2.9__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.
@@ -142,12 +142,335 @@ class TodoShell:
142
142
  """Archive completed tasks."""
143
143
  return self.execute(["todo.sh", "-f", "archive"])
144
144
 
145
- def deduplicate(self) -> str:
146
- """Remove duplicate tasks."""
147
- try:
148
- return self.execute(["todo.sh", "-f", "deduplicate"])
149
- except TodoShellError as e:
150
- # Handle the case where no duplicates are found (not really an error)
151
- if "No duplicate tasks found" in str(e):
152
- return "No duplicate tasks found"
153
- raise
145
+ def set_due_date(self, task_number: int, due_date: str) -> str:
146
+ """
147
+ Set or update due date for a task by intelligently rewriting it.
148
+
149
+ Args:
150
+ task_number: The task number to modify
151
+ due_date: Due date in YYYY-MM-DD format, or empty string to remove due date
152
+
153
+ Returns:
154
+ The updated task description
155
+ """
156
+ # First, get the current task to parse its components
157
+ tasks_output = self.list_tasks()
158
+ task_lines = tasks_output.strip().split("\n")
159
+
160
+ # Find the task by its actual number (not array index)
161
+ current_task = None
162
+ for line in task_lines:
163
+ if line.strip():
164
+ # Extract task number from the beginning of the line (handling ANSI codes)
165
+ extracted_number = self._extract_task_number(line)
166
+ if extracted_number == task_number:
167
+ current_task = line
168
+ break
169
+
170
+ if not current_task:
171
+ raise TodoShellError(f"Task number {task_number} not found")
172
+
173
+ # Parse the current task components
174
+ components = self._parse_task_components(current_task)
175
+
176
+ # Update the due date (empty string removes it)
177
+ if due_date.strip():
178
+ components["due"] = due_date
179
+ else:
180
+ components["due"] = None
181
+
182
+ # Reconstruct the task
183
+ new_description = self._reconstruct_task(components)
184
+
185
+ # Replace the task with the new description
186
+ return self.replace(task_number, new_description)
187
+
188
+ def set_context(self, task_number: int, context: str) -> str:
189
+ """
190
+ Set or update context for a task by intelligently rewriting it.
191
+
192
+ Args:
193
+ task_number: The task number to modify
194
+ context: Context name (without @ symbol), or empty string to remove context
195
+
196
+ Returns:
197
+ The updated task description
198
+ """
199
+ # First, get the current task to parse its components
200
+ tasks_output = self.list_tasks()
201
+ task_lines = tasks_output.strip().split("\n")
202
+
203
+ # Find the task by its actual number (not array index)
204
+ current_task = None
205
+ for line in task_lines:
206
+ if line.strip():
207
+ # Extract task number from the beginning of the line (handling ANSI codes)
208
+ extracted_number = self._extract_task_number(line)
209
+ if extracted_number == task_number:
210
+ current_task = line
211
+ break
212
+
213
+ if not current_task:
214
+ raise TodoShellError(f"Task number {task_number} not found")
215
+
216
+ # Parse the current task components
217
+ components = self._parse_task_components(current_task)
218
+
219
+ # Update the context (empty string removes it)
220
+ if context.strip():
221
+ # Remove any existing @ symbols to prevent duplication
222
+ clean_context = context.strip().lstrip("@")
223
+ if not clean_context:
224
+ raise TodoShellError(
225
+ "Context name cannot be empty after removing @ symbol."
226
+ )
227
+ context_tag = f"@{clean_context}"
228
+ # Only add if not already present (deduplication)
229
+ if context_tag not in components["contexts"]:
230
+ components["contexts"] = [context_tag]
231
+ else:
232
+ # Context already exists, no change needed
233
+ return self._reconstruct_task(components)
234
+ else:
235
+ components["contexts"] = []
236
+
237
+ # Reconstruct the task
238
+ new_description = self._reconstruct_task(components)
239
+
240
+ # Replace the task with the new description
241
+ return self.replace(task_number, new_description)
242
+
243
+ def _extract_task_number(self, line: str) -> Optional[int]:
244
+ """
245
+ Extract task number from a line that may contain ANSI color codes.
246
+
247
+ Args:
248
+ line: Task line that may contain ANSI color codes
249
+
250
+ Returns:
251
+ Task number if found, None otherwise
252
+ """
253
+ from rich.text import Text
254
+
255
+ # Use rich to properly handle ANSI color codes
256
+ text = Text.from_ansi(line)
257
+ clean_line = text.plain
258
+
259
+ # Split on first space and check if first part is a number
260
+ parts = clean_line.split(" ", 1)
261
+ if parts and parts[0].isdigit():
262
+ return int(parts[0])
263
+ return None
264
+
265
+ def set_project(self, task_number: int, projects: list) -> str:
266
+ """
267
+ Set or update projects for a task by intelligently rewriting it.
268
+
269
+ Args:
270
+ task_number: The task number to modify
271
+ projects: List of project operations. Each item can be:
272
+ - "project" (add project)
273
+ - "-project" (remove project)
274
+ - Empty string is a NOOP
275
+
276
+ Returns:
277
+ The updated task description
278
+ """
279
+ # First, get the current task to parse its components
280
+ tasks_output = self.list_tasks()
281
+ task_lines = tasks_output.strip().split("\n")
282
+
283
+ # Find the task by its actual number (not array index)
284
+ current_task = None
285
+ for line in task_lines:
286
+ if line.strip():
287
+ # Extract task number from the beginning of the line (handling ANSI codes)
288
+ extracted_number = self._extract_task_number(line)
289
+ if extracted_number == task_number:
290
+ current_task = line
291
+ break
292
+
293
+ if not current_task:
294
+ raise TodoShellError(f"Task number {task_number} not found")
295
+
296
+ # Parse the current task components
297
+ components = self._parse_task_components(current_task)
298
+
299
+ # Store original projects to check if any changes were made
300
+ original_projects = components["projects"].copy()
301
+
302
+ # Handle project operations
303
+ if not projects:
304
+ # Empty list is a NOOP - return original task unchanged
305
+ return self._reconstruct_task(components)
306
+ else:
307
+ # Process each project operation
308
+ for project in projects:
309
+ if not project.strip():
310
+ # Empty string is a NOOP - skip this operation
311
+ continue
312
+ elif project.startswith("-"):
313
+ # Remove project
314
+ clean_project = project[1:].strip().lstrip("+")
315
+ if not clean_project:
316
+ raise TodoShellError(
317
+ "Project name cannot be empty after removing - and + symbols."
318
+ )
319
+ # Remove the project if it exists (with or without + prefix)
320
+ project_to_remove = f"+{clean_project}"
321
+ components["projects"] = [
322
+ p for p in components["projects"]
323
+ if p != project_to_remove and p != clean_project
324
+ ]
325
+ else:
326
+ # Add project
327
+ clean_project = project.strip().lstrip("+")
328
+ if not clean_project:
329
+ raise TodoShellError(
330
+ "Project name cannot be empty after removing + symbol."
331
+ )
332
+ project_tag = f"+{clean_project}"
333
+ # Only add if not already present (deduplication)
334
+ if project_tag not in components["projects"]:
335
+ components["projects"].append(project_tag)
336
+
337
+ # Check if any changes were actually made
338
+ if components["projects"] == original_projects:
339
+ # No changes made - return original task unchanged
340
+ return self._reconstruct_task(components)
341
+
342
+ # Reconstruct the task
343
+ new_description = self._reconstruct_task(components)
344
+
345
+ # Replace the task with the new description
346
+ return self.replace(task_number, new_description)
347
+
348
+ def _parse_task_components(self, task_line: str) -> dict:
349
+ """
350
+ Parse a todo.txt task line into its components.
351
+
352
+ Args:
353
+ task_line: Raw task line from todo.txt
354
+
355
+ Returns:
356
+ Dictionary with parsed components
357
+ """
358
+ # Remove ANSI color codes first using rich
359
+ from rich.text import Text
360
+ text = Text.from_ansi(task_line)
361
+ task_line = text.plain
362
+
363
+ # Remove task number prefix if present (e.g., "1 " or "1. ")
364
+ # First try the format without dot (standard todo.sh format)
365
+ if " " in task_line and task_line.split(" ")[0].isdigit():
366
+ task_line = task_line.split(" ", 1)[1]
367
+ # Fallback to dot format if present
368
+ elif ". " in task_line:
369
+ task_line = task_line.split(". ", 1)[1]
370
+
371
+ components = {
372
+ "priority": None,
373
+ "description": "",
374
+ "projects": [],
375
+ "contexts": [],
376
+ "due": None,
377
+ "recurring": None,
378
+ "other_tags": [],
379
+ }
380
+
381
+ # Use sets to automatically deduplicate projects and contexts
382
+ projects_set = set()
383
+ contexts_set = set()
384
+ other_tags_set = set()
385
+
386
+ # Split by spaces to process each word
387
+ words = task_line.split()
388
+
389
+ for word in words:
390
+ # Priority: (A), (B), etc.
391
+ if word.startswith("(") and word.endswith(")") and len(word) == 3:
392
+ priority = word[1]
393
+ if priority.isalpha() and priority.isupper():
394
+ components["priority"] = priority
395
+ continue
396
+
397
+ # Projects: +project
398
+ if word.startswith("+"):
399
+ projects_set.add(word)
400
+ continue
401
+
402
+ # Contexts: @context
403
+ if word.startswith("@"):
404
+ contexts_set.add(word)
405
+ continue
406
+
407
+ # Due date: due:YYYY-MM-DD
408
+ if word.startswith("due:"):
409
+ components["due"] = word[4:] # Remove 'due:' prefix
410
+ continue
411
+
412
+ # Recurring: rec:frequency[:interval]
413
+ if word.startswith("rec:"):
414
+ components["recurring"] = word
415
+ continue
416
+
417
+ # Other tags (like custom tags)
418
+ if (
419
+ ":" in word
420
+ and not word.startswith("due:")
421
+ and not word.startswith("rec:")
422
+ ):
423
+ other_tags_set.add(word)
424
+ continue
425
+
426
+ # Regular description text
427
+ if components["description"]:
428
+ components["description"] += " " + word
429
+ else:
430
+ components["description"] = word
431
+
432
+ # Convert sets back to sorted lists for consistent ordering
433
+ components["projects"] = sorted(list(projects_set))
434
+ components["contexts"] = sorted(list(contexts_set))
435
+ components["other_tags"] = sorted(list(other_tags_set))
436
+
437
+ return components
438
+
439
+ def _reconstruct_task(self, components: dict) -> str:
440
+ """
441
+ Reconstruct a task description from parsed components.
442
+
443
+ Args:
444
+ components: Dictionary with task components
445
+
446
+ Returns:
447
+ Reconstructed task description
448
+ """
449
+ parts = []
450
+
451
+ # Add priority if present
452
+ if components["priority"]:
453
+ parts.append(f"({components['priority']})")
454
+
455
+ # Add description
456
+ if components["description"]:
457
+ parts.append(components["description"])
458
+
459
+ # Add projects
460
+ parts.extend(components["projects"])
461
+
462
+ # Add contexts
463
+ parts.extend(components["contexts"])
464
+
465
+ # Add due date
466
+ if components["due"]:
467
+ parts.append(f"due:{components['due']}")
468
+
469
+ # Add recurring pattern
470
+ if components["recurring"]:
471
+ parts.append(components["recurring"])
472
+
473
+ # Add other tags
474
+ parts.extend(components["other_tags"])
475
+
476
+ return " ".join(parts)
@@ -138,8 +138,8 @@ class CLI:
138
138
  conversation_manager = self.inference.get_conversation_manager()
139
139
 
140
140
  # Get current usage from conversation summary
141
- summary = conversation_manager.get_conversation_summary()
142
- current_tokens = summary.get("estimated_tokens", 0)
141
+ summary = self.inference.get_conversation_summary()
142
+ current_tokens = summary.get("request_tokens", 0) # Use request_tokens
143
143
  current_messages = summary.get("total_messages", 0)
144
144
 
145
145
  # Get limits from conversation manager
@@ -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="\n")
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"]:
@@ -209,9 +209,7 @@ class CLI:
209
209
  try:
210
210
  output = self.todo_shell.list_tasks()
211
211
  formatted_output = TaskFormatter.format_task_list(output)
212
- task_panel = PanelFormatter.create_task_panel(
213
- formatted_output
214
- )
212
+ task_panel = PanelFormatter.create_task_panel(formatted_output)
215
213
  self.console.print(task_panel)
216
214
  except Exception as e:
217
215
  self.logger.error(f"Error listing tasks: {e!s}")
@@ -246,8 +244,10 @@ class CLI:
246
244
  # Format the response and create a panel
247
245
  formatted_response = ResponseFormatter.format_response(response)
248
246
 
249
- # Get memory usage
250
- memory_usage = self._get_memory_usage()
247
+ # Get memory usage
248
+ # DISABLED FOR NOW
249
+ # memory_usage = self._get_memory_usage()
250
+ memory_usage = None
251
251
 
252
252
  # Create response panel with memory usage
253
253
  response_panel = PanelFormatter.create_response_panel(
@@ -36,8 +36,6 @@ class TaskFormatter:
36
36
  formatted_text = Text()
37
37
  task_count = 0
38
38
 
39
-
40
-
41
39
  for line in lines:
42
40
  line = line.strip()
43
41
  # Skip empty lines, separators, and todo.sh's own summary line
@@ -57,8 +55,6 @@ class TaskFormatter:
57
55
 
58
56
  return formatted_text
59
57
 
60
-
61
-
62
58
  @staticmethod
63
59
  def format_completed_tasks(raw_tasks: str) -> Text:
64
60
  """
@@ -77,8 +73,6 @@ class TaskFormatter:
77
73
  formatted_text = Text()
78
74
  task_count = 0
79
75
 
80
-
81
-
82
76
  for line in lines:
83
77
  line = line.strip()
84
78
  # Skip empty lines, separators, and todo.sh's own summary line
@@ -97,8 +91,6 @@ class TaskFormatter:
97
91
 
98
92
  return formatted_text
99
93
 
100
-
101
-
102
94
  @staticmethod
103
95
  def _format_single_task(task_line: str, task_number: int) -> str:
104
96
  """
@@ -404,7 +396,9 @@ class PanelFormatter:
404
396
  )
405
397
 
406
398
  @staticmethod
407
- def create_task_panel(content: str | Text, title: str = "📋 Current Tasks") -> Panel:
399
+ def create_task_panel(
400
+ content: str | Text, title: str = "📋 Current Tasks"
401
+ ) -> Panel:
408
402
  """Create a panel for displaying task lists."""
409
403
  return Panel(
410
404
  content, title=title, border_style="dim", box=ROUNDED, width=PANEL_WIDTH