| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 1 | package claudetool |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | |
| 10 | "sketch.dev/llm" |
| 11 | ) |
| 12 | |
| 13 | var TodoRead = &llm.Tool{ |
| 14 | Name: "todo_read", |
| 15 | Description: `Reads the current todo list. Use frequently to track progress and understand what's pending.`, |
| 16 | InputSchema: llm.EmptySchema(), |
| 17 | Run: todoReadRun, |
| 18 | } |
| 19 | |
| 20 | var TodoWrite = &llm.Tool{ |
| 21 | Name: "todo_write", |
| 22 | Description: todoWriteDescription, |
| 23 | InputSchema: llm.MustSchema(todoWriteInputSchema), |
| 24 | Run: todoWriteRun, |
| 25 | } |
| 26 | |
| 27 | const ( |
| 28 | todoWriteDescription = `todo_write: Creates and manages a structured task list for tracking work and communicating progress to users. Use early and often. |
| 29 | |
| 30 | Use for: |
| 31 | - multi-step tasks |
| 32 | - complex work |
| 33 | - when users provide multiple requests |
| 34 | - conversations that start trivial but grow in scope |
| 35 | - when users request additional work (directly or via feedback) |
| 36 | |
| 37 | Skip for: |
| 38 | - trivial single-step tasks |
| 39 | - purely conversational exchanges |
| 40 | |
| 41 | Update dynamically as work evolves - conversations can spawn tasks, simple tasks can become complex, and new discoveries may require additional work. |
| 42 | |
| 43 | Rules: |
| 44 | - Update immediately when task states or task list changes |
| 45 | - Only one task "in-progress" at any time |
| 46 | - Each update completely replaces the task list - include all tasks (past and present) |
| 47 | - Never modify or delete completed tasks |
| 48 | - Queued and in-progress tasks may be restructured as understanding evolves |
| 49 | - Tasks should be atomic, clear, precise, and actionable |
| 50 | - If the user adds new tasks: append, don't replace |
| 51 | ` |
| 52 | |
| 53 | todoWriteInputSchema = ` |
| 54 | { |
| 55 | "type": "object", |
| 56 | "required": ["tasks"], |
| 57 | "properties": { |
| 58 | "tasks": { |
| 59 | "type": "array", |
| 60 | "description": "Array of tasks to write", |
| 61 | "items": { |
| 62 | "type": "object", |
| 63 | "required": ["id", "task", "status"], |
| 64 | "properties": { |
| 65 | "id": { |
| 66 | "type": "string", |
| 67 | "description": "stable, unique hyphenated slug" |
| 68 | }, |
| 69 | "task": { |
| 70 | "type": "string", |
| 71 | "description": "actionable step in active tense, sentence case, plain text only, displayed to user" |
| 72 | }, |
| 73 | "status": { |
| 74 | "type": "string", |
| 75 | "enum": ["queued", "in-progress", "completed"], |
| 76 | "description": "current task status" |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | ` |
| 84 | ) |
| 85 | |
| 86 | type TodoItem struct { |
| 87 | ID string `json:"id"` |
| 88 | Task string `json:"task"` |
| 89 | Status string `json:"status"` |
| 90 | } |
| 91 | |
| 92 | type TodoList struct { |
| 93 | Items []TodoItem `json:"items"` |
| 94 | } |
| 95 | |
| 96 | type TodoWriteInput struct { |
| 97 | Tasks []TodoItem `json:"tasks"` |
| 98 | } |
| 99 | |
| 100 | // TodoFilePath returns the path to the todo file for the given session ID. |
| 101 | func TodoFilePath(sessionID string) string { |
| 102 | if sessionID == "" { |
| 103 | return "/tmp/sketch_todos.json" |
| 104 | } |
| 105 | return filepath.Join("/tmp", sessionID, "todos.json") |
| 106 | } |
| 107 | |
| 108 | func todoFilePathForContext(ctx context.Context) string { |
| 109 | return TodoFilePath(SessionID(ctx)) |
| 110 | } |
| 111 | |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 112 | func todoReadRun(ctx context.Context, m json.RawMessage) llm.ToolOut { |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 113 | todoPath := todoFilePathForContext(ctx) |
| 114 | content, err := os.ReadFile(todoPath) |
| 115 | if os.IsNotExist(err) { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 116 | return llm.ToolOut{LLMContent: llm.TextContent("No todo list found. Use todo_write to create one.")} |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 117 | } |
| 118 | if err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 119 | return llm.ErrorfToolOut("failed to read todo file: %w", err) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 120 | } |
| 121 | |
| 122 | var todoList TodoList |
| 123 | if err := json.Unmarshal(content, &todoList); err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 124 | return llm.ErrorfToolOut("failed to parse todo file: %w", err) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 125 | } |
| 126 | |
| 127 | result := fmt.Sprintf(`<todo_list count="%d">%s`, len(todoList.Items), "\n") |
| 128 | for _, item := range todoList.Items { |
| 129 | result += fmt.Sprintf(` <task id="%s" status="%s">%s</task>%s`, item.ID, item.Status, item.Task, "\n") |
| 130 | } |
| 131 | result += "</todo_list>" |
| 132 | |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 133 | return llm.ToolOut{LLMContent: llm.TextContent(result)} |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 134 | } |
| 135 | |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 136 | func todoWriteRun(ctx context.Context, m json.RawMessage) llm.ToolOut { |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 137 | var input TodoWriteInput |
| 138 | if err := json.Unmarshal(m, &input); err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 139 | return llm.ErrorfToolOut("invalid input: %w", err) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 140 | } |
| 141 | |
| 142 | // Validate that only one task is in-progress |
| 143 | inProgressCount := 0 |
| 144 | for _, task := range input.Tasks { |
| 145 | if task.Status == "in-progress" { |
| 146 | inProgressCount++ |
| 147 | } |
| 148 | } |
| 149 | switch { |
| 150 | case inProgressCount > 1: |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 151 | return llm.ErrorfToolOut("only one task can be 'in-progress' at a time, found %d", inProgressCount) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 152 | } |
| 153 | |
| 154 | todoList := TodoList{ |
| 155 | Items: input.Tasks, |
| 156 | } |
| 157 | |
| 158 | todoPath := todoFilePathForContext(ctx) |
| 159 | // Ensure directory exists |
| 160 | if err := os.MkdirAll(filepath.Dir(todoPath), 0o700); err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 161 | return llm.ErrorfToolOut("failed to create todo directory: %w", err) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 162 | } |
| 163 | |
| 164 | content, err := json.Marshal(todoList) |
| 165 | if err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 166 | return llm.ErrorfToolOut("failed to marshal todo list: %w", err) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 167 | } |
| 168 | |
| 169 | if err := os.WriteFile(todoPath, content, 0o600); err != nil { |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 170 | return llm.ErrorfToolOut("failed to write todo file: %w", err) |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 171 | } |
| 172 | |
| 173 | result := fmt.Sprintf("Updated todo list with %d items.", len(input.Tasks)) |
| 174 | |
| Josh Bleecher Snyder | 43b60b9 | 2025-07-21 14:57:10 -0700 | [diff] [blame] | 175 | return llm.ToolOut{LLMContent: llm.TextContent(result)} |
| Josh Bleecher Snyder | 112b923 | 2025-05-23 11:26:33 -0700 | [diff] [blame] | 176 | } |