personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add date range support to todo_list MCP tool

Extend todo_list() with optional day_to parameter for querying todos
across a date range. Single-day behavior unchanged when day_to is None.

- Range queries return markdown grouped by day with ### headers
- Validates date formats and range order with helpful error messages
- Same-day range (day == day_to) returns normal single-day format
- Add get_todo_days_in_range() helper to find todo files in range

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+233 -5
+123
apps/todos/tests/test_tools.py
··· 240 240 assert len(lines) == 2 241 241 assert json.loads(lines[0])["cancelled"] is True 242 242 assert json.loads(lines[1])["cancelled"] is True 243 + 244 + 245 + # ----------------------------------------------------------------------------- 246 + # todo_list range tests 247 + # ----------------------------------------------------------------------------- 248 + 249 + 250 + def test_todo_list_range_multiple_days(tmp_path, monkeypatch): 251 + """todo_list with day_to should return todos grouped by day.""" 252 + facet = "work" 253 + todos_dir = tmp_path / "facets" / facet / "todos" 254 + todos_dir.mkdir(parents=True) 255 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 256 + 257 + # Create todos for multiple days 258 + (todos_dir / "20250101.jsonl").write_text('{"text": "New Year task"}\n') 259 + (todos_dir / "20250103.jsonl").write_text( 260 + '{"text": "Task A"}\n{"text": "Task B", "completed": true}\n' 261 + ) 262 + (todos_dir / "20250105.jsonl").write_text('{"text": "Weekend task"}\n') 263 + 264 + result = call_tool(todo_tools.todo_list, "20250101", facet, "20250105") 265 + 266 + assert result["day"] == "20250101" 267 + assert result["day_to"] == "20250105" 268 + assert result["facet"] == facet 269 + assert "### 20250101" in result["markdown"] 270 + assert "New Year task" in result["markdown"] 271 + assert "### 20250103" in result["markdown"] 272 + assert "Task A" in result["markdown"] 273 + assert "Task B" in result["markdown"] 274 + assert "### 20250105" in result["markdown"] 275 + assert "Weekend task" in result["markdown"] 276 + 277 + 278 + def test_todo_list_range_empty(tmp_path, monkeypatch): 279 + """todo_list with day_to should return empty message when no todos in range.""" 280 + facet = "work" 281 + todos_dir = tmp_path / "facets" / facet / "todos" 282 + todos_dir.mkdir(parents=True) 283 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 284 + 285 + result = call_tool(todo_tools.todo_list, "20250101", facet, "20250107") 286 + 287 + assert result["day"] == "20250101" 288 + assert result["day_to"] == "20250107" 289 + assert result["markdown"] == "No todos in range." 290 + 291 + 292 + def test_todo_list_range_swapped_error(tmp_path, monkeypatch): 293 + """todo_list should error when day > day_to with helpful suggestion.""" 294 + facet = "work" 295 + todos_dir = tmp_path / "facets" / facet / "todos" 296 + todos_dir.mkdir(parents=True) 297 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 298 + 299 + result = call_tool(todo_tools.todo_list, "20250107", facet, "20250101") 300 + 301 + assert "error" in result 302 + assert "must be before or equal to" in result["error"] 303 + assert "suggestion" in result 304 + assert "swap" in result["suggestion"] 305 + 306 + 307 + def test_todo_list_range_same_day_no_headers(todo_env): 308 + """todo_list with day == day_to should behave like single day (no headers).""" 309 + day, facet, _ = todo_env([{"text": "Single day task"}]) 310 + 311 + result = call_tool(todo_tools.todo_list, day, facet, day) 312 + 313 + # Should be same as single-day format (no day_to in response, no headers) 314 + assert result == { 315 + "day": day, 316 + "facet": facet, 317 + "markdown": "1: [ ] Single day task", 318 + } 319 + assert "day_to" not in result 320 + assert "###" not in result["markdown"] 321 + 322 + 323 + def test_todo_list_range_includes_cancelled(tmp_path, monkeypatch): 324 + """todo_list range should include cancelled items with strikethrough.""" 325 + facet = "work" 326 + todos_dir = tmp_path / "facets" / facet / "todos" 327 + todos_dir.mkdir(parents=True) 328 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 329 + 330 + (todos_dir / "20250101.jsonl").write_text( 331 + '{"text": "Active"}\n{"text": "Cancelled", "cancelled": true}\n' 332 + ) 333 + 334 + result = call_tool(todo_tools.todo_list, "20250101", facet, "20250103") 335 + 336 + assert "1: [ ] Active" in result["markdown"] 337 + assert "2: ~~[cancelled] Cancelled~~" in result["markdown"] 338 + 339 + 340 + def test_todo_list_invalid_day_format(tmp_path, monkeypatch): 341 + """todo_list should error on invalid day format.""" 342 + facet = "work" 343 + todos_dir = tmp_path / "facets" / facet / "todos" 344 + todos_dir.mkdir(parents=True) 345 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 346 + 347 + result = call_tool(todo_tools.todo_list, "not-a-date", facet) 348 + 349 + assert "error" in result 350 + assert "Invalid day format" in result["error"] 351 + assert "suggestion" in result 352 + 353 + 354 + def test_todo_list_invalid_day_to_format(tmp_path, monkeypatch): 355 + """todo_list should error on invalid day_to format.""" 356 + facet = "work" 357 + todos_dir = tmp_path / "facets" / facet / "todos" 358 + todos_dir.mkdir(parents=True) 359 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 360 + 361 + result = call_tool(todo_tools.todo_list, "20250101", facet, "bad-date") 362 + 363 + assert "error" in result 364 + assert "Invalid day_to format" in result["error"] 365 + assert "suggestion" in result
+36
apps/todos/todo.py
··· 34 34 "validate_line_number", 35 35 "upcoming", 36 36 "get_facets_with_todos", 37 + "get_todo_days_in_range", 37 38 ] 38 39 39 40 # Regex for extracting time annotation from text ··· 563 564 return [] 564 565 565 566 return sorted(facets_with_todos) 567 + 568 + 569 + def get_todo_days_in_range(facet: str, day_from: str, day_to: str) -> list[str]: 570 + """Return a sorted list of days with todos in the given range for a facet. 571 + 572 + Args: 573 + facet: Facet name (e.g., "personal", "work"). 574 + day_from: Start day in ``YYYYMMDD`` format (inclusive). 575 + day_to: End day in ``YYYYMMDD`` format (inclusive). 576 + 577 + Returns: 578 + Sorted list of day strings (YYYYMMDD) that have todo files within the range. 579 + Returns empty list if no todos exist or journal path is invalid. 580 + """ 581 + journal = os.getenv("JOURNAL_PATH", "journal") 582 + todos_dir = Path(journal) / "facets" / facet / "todos" 583 + 584 + if not todos_dir.is_dir(): 585 + return [] 586 + 587 + days: list[str] = [] 588 + 589 + try: 590 + for f in todos_dir.iterdir(): 591 + if not f.is_file() or f.suffix != ".jsonl": 592 + continue 593 + stem = f.stem 594 + if len(stem) != 8 or not stem.isdigit(): 595 + continue 596 + if day_from <= stem <= day_to: 597 + days.append(stem) 598 + except OSError: # pragma: no cover - filesystem failure 599 + return [] 600 + 601 + return sorted(days) 566 602 567 603 568 604 def format_todos(
+74 -5
apps/todos/tools.py
··· 30 30 31 31 32 32 @register_tool(annotations=HINTS) 33 - def todo_list(day: str, facet: str) -> dict[str, Any]: 34 - """Return the numbered todo checklist for ``day``'s todos in a specific facet. 33 + def todo_list(day: str, facet: str, day_to: str | None = None) -> dict[str, Any]: 34 + """Return the numbered todo checklist for a day or date range in a specific facet. 35 35 36 36 Args: 37 - day: Journal day in ``YYYYMMDD`` format. 37 + day: Journal day in ``YYYYMMDD`` format (start of range if day_to provided). 38 38 facet: Facet name (e.g., "personal", "work"). 39 + day_to: Optional end day in ``YYYYMMDD`` format for range queries (inclusive). 39 40 40 41 Returns: 41 42 Dictionary containing the formatted ``markdown`` view with ``N:`` line 42 43 prefixes. Cancelled items are shown with strikethrough to maintain 43 44 sequential line numbering for ``todo_add`` operations. 45 + 46 + For range queries, includes ``day_to`` in response and markdown is grouped 47 + by day with ``### YYYYMMDD`` headers. Days with no todos are omitted. 48 + 49 + Examples: 50 + - todo_list("20250101", "work") # Single day 51 + - todo_list("20250101", "work", "20250107") # Week range 44 52 """ 45 53 try: 46 - checklist = todo.TodoChecklist.load(day, facet) 47 - return {"day": day, "facet": facet, "markdown": checklist.display()} 54 + # Validate day format 55 + try: 56 + datetime.strptime(day, "%Y%m%d") 57 + except ValueError: 58 + return { 59 + "error": f"Invalid day format '{day}'", 60 + "suggestion": "use YYYYMMDD format (e.g., 20250104)", 61 + } 62 + 63 + # Single day mode (no day_to) 64 + if day_to is None: 65 + checklist = todo.TodoChecklist.load(day, facet) 66 + return {"day": day, "facet": facet, "markdown": checklist.display()} 67 + 68 + # Validate day_to format 69 + try: 70 + datetime.strptime(day_to, "%Y%m%d") 71 + except ValueError: 72 + return { 73 + "error": f"Invalid day_to format '{day_to}'", 74 + "suggestion": "use YYYYMMDD format (e.g., 20250107)", 75 + } 76 + 77 + # Validate range order 78 + if day > day_to: 79 + return { 80 + "error": f"day '{day}' must be before or equal to day_to '{day_to}'", 81 + "suggestion": f"swap the values: todo_list('{day_to}', '{facet}', '{day}')", 82 + } 83 + 84 + # Same day treated as single day (no headers) 85 + if day == day_to: 86 + checklist = todo.TodoChecklist.load(day, facet) 87 + return {"day": day, "facet": facet, "markdown": checklist.display()} 88 + 89 + # Range mode: find all todo files in range 90 + days_with_todos = todo.get_todo_days_in_range(facet, day, day_to) 91 + 92 + if not days_with_todos: 93 + return { 94 + "day": day, 95 + "day_to": day_to, 96 + "facet": facet, 97 + "markdown": "No todos in range.", 98 + } 99 + 100 + # Build markdown with day headers 101 + sections: list[str] = [] 102 + for day_str in days_with_todos: 103 + checklist = todo.TodoChecklist.load(day_str, facet) 104 + if checklist.items: 105 + section = f"### {day_str}\n{checklist.display()}" 106 + sections.append(section) 107 + 108 + markdown = "\n\n".join(sections) if sections else "No todos in range." 109 + 110 + return { 111 + "day": day, 112 + "day_to": day_to, 113 + "facet": facet, 114 + "markdown": markdown, 115 + } 116 + 48 117 except FileNotFoundError: 49 118 return {"error": f"No todos found for facet '{facet}' on day '{day}'"} 50 119 except Exception as exc: # pragma: no cover - unexpected failure