| package claudetool |
| |
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "os" |
| "path/filepath" |
| |
| "sketch.dev/llm" |
| ) |
| |
| var TodoRead = &llm.Tool{ |
| Name: "todo_read", |
| Description: `Reads the current todo list. Use frequently to track progress and understand what's pending.`, |
| InputSchema: llm.EmptySchema(), |
| Run: todoReadRun, |
| } |
| |
| var TodoWrite = &llm.Tool{ |
| Name: "todo_write", |
| Description: todoWriteDescription, |
| InputSchema: llm.MustSchema(todoWriteInputSchema), |
| Run: todoWriteRun, |
| } |
| |
| const ( |
| todoWriteDescription = `todo_write: Creates and manages a structured task list for tracking work and communicating progress to users. Use early and often. |
| |
| Use for: |
| - multi-step tasks |
| - complex work |
| - when users provide multiple requests |
| - conversations that start trivial but grow in scope |
| - when users request additional work (directly or via feedback) |
| |
| Skip for: |
| - trivial single-step tasks |
| - purely conversational exchanges |
| |
| Update dynamically as work evolves - conversations can spawn tasks, simple tasks can become complex, and new discoveries may require additional work. |
| |
| Rules: |
| - Update immediately when task states or task list changes |
| - Only one task "in-progress" at any time |
| - Each update completely replaces the task list - include all tasks (past and present) |
| - Never modify or delete completed tasks |
| - Queued and in-progress tasks may be restructured as understanding evolves |
| - Tasks should be atomic, clear, precise, and actionable |
| - If the user adds new tasks: append, don't replace |
| ` |
| |
| todoWriteInputSchema = ` |
| { |
| "type": "object", |
| "required": ["tasks"], |
| "properties": { |
| "tasks": { |
| "type": "array", |
| "description": "Array of tasks to write", |
| "items": { |
| "type": "object", |
| "required": ["id", "task", "status"], |
| "properties": { |
| "id": { |
| "type": "string", |
| "description": "stable, unique hyphenated slug" |
| }, |
| "task": { |
| "type": "string", |
| "description": "actionable step in active tense, sentence case, plain text only, displayed to user" |
| }, |
| "status": { |
| "type": "string", |
| "enum": ["queued", "in-progress", "completed"], |
| "description": "current task status" |
| } |
| } |
| } |
| } |
| } |
| } |
| ` |
| ) |
| |
| type TodoItem struct { |
| ID string `json:"id"` |
| Task string `json:"task"` |
| Status string `json:"status"` |
| } |
| |
| type TodoList struct { |
| Items []TodoItem `json:"items"` |
| } |
| |
| type TodoWriteInput struct { |
| Tasks []TodoItem `json:"tasks"` |
| } |
| |
| // TodoFilePath returns the path to the todo file for the given session ID. |
| func TodoFilePath(sessionID string) string { |
| if sessionID == "" { |
| return "/tmp/sketch_todos.json" |
| } |
| return filepath.Join("/tmp", sessionID, "todos.json") |
| } |
| |
| func todoFilePathForContext(ctx context.Context) string { |
| return TodoFilePath(SessionID(ctx)) |
| } |
| |
| func todoReadRun(ctx context.Context, m json.RawMessage) llm.ToolOut { |
| todoPath := todoFilePathForContext(ctx) |
| content, err := os.ReadFile(todoPath) |
| if os.IsNotExist(err) { |
| return llm.ToolOut{LLMContent: llm.TextContent("No todo list found. Use todo_write to create one.")} |
| } |
| if err != nil { |
| return llm.ErrorfToolOut("failed to read todo file: %w", err) |
| } |
| |
| var todoList TodoList |
| if err := json.Unmarshal(content, &todoList); err != nil { |
| return llm.ErrorfToolOut("failed to parse todo file: %w", err) |
| } |
| |
| result := fmt.Sprintf(`<todo_list count="%d">%s`, len(todoList.Items), "\n") |
| for _, item := range todoList.Items { |
| result += fmt.Sprintf(` <task id="%s" status="%s">%s</task>%s`, item.ID, item.Status, item.Task, "\n") |
| } |
| result += "</todo_list>" |
| |
| return llm.ToolOut{LLMContent: llm.TextContent(result)} |
| } |
| |
| func todoWriteRun(ctx context.Context, m json.RawMessage) llm.ToolOut { |
| var input TodoWriteInput |
| if err := json.Unmarshal(m, &input); err != nil { |
| return llm.ErrorfToolOut("invalid input: %w", err) |
| } |
| |
| // Validate that only one task is in-progress |
| inProgressCount := 0 |
| for _, task := range input.Tasks { |
| if task.Status == "in-progress" { |
| inProgressCount++ |
| } |
| } |
| switch { |
| case inProgressCount > 1: |
| return llm.ErrorfToolOut("only one task can be 'in-progress' at a time, found %d", inProgressCount) |
| } |
| |
| todoList := TodoList{ |
| Items: input.Tasks, |
| } |
| |
| todoPath := todoFilePathForContext(ctx) |
| // Ensure directory exists |
| if err := os.MkdirAll(filepath.Dir(todoPath), 0o700); err != nil { |
| return llm.ErrorfToolOut("failed to create todo directory: %w", err) |
| } |
| |
| content, err := json.Marshal(todoList) |
| if err != nil { |
| return llm.ErrorfToolOut("failed to marshal todo list: %w", err) |
| } |
| |
| if err := os.WriteFile(todoPath, content, 0o600); err != nil { |
| return llm.ErrorfToolOut("failed to write todo file: %w", err) |
| } |
| |
| result := fmt.Sprintf("Updated todo list with %d items.", len(input.Tasks)) |
| |
| return llm.ToolOut{LLMContent: llm.TextContent(result)} |
| } |