blob: 5c23457ce8530fee98bdb01ba3061bd59d624816 [file] [log] [blame]
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001package claudetool
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9
10 "sketch.dev/llm"
11)
12
13var 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
20var TodoWrite = &llm.Tool{
21 Name: "todo_write",
22 Description: todoWriteDescription,
23 InputSchema: llm.MustSchema(todoWriteInputSchema),
24 Run: todoWriteRun,
25}
26
27const (
28 todoWriteDescription = `todo_write: Creates and manages a structured task list for tracking work and communicating progress to users. Use early and often.
29
30Use 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
37Skip for:
38- trivial single-step tasks
39- purely conversational exchanges
40
41Update dynamically as work evolves - conversations can spawn tasks, simple tasks can become complex, and new discoveries may require additional work.
42
43Rules:
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
86type TodoItem struct {
87 ID string `json:"id"`
88 Task string `json:"task"`
89 Status string `json:"status"`
90}
91
92type TodoList struct {
93 Items []TodoItem `json:"items"`
94}
95
96type TodoWriteInput struct {
97 Tasks []TodoItem `json:"tasks"`
98}
99
100// TodoFilePath returns the path to the todo file for the given session ID.
101func TodoFilePath(sessionID string) string {
102 if sessionID == "" {
103 return "/tmp/sketch_todos.json"
104 }
105 return filepath.Join("/tmp", sessionID, "todos.json")
106}
107
108func todoFilePathForContext(ctx context.Context) string {
109 return TodoFilePath(SessionID(ctx))
110}
111
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700112func todoReadRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700113 todoPath := todoFilePathForContext(ctx)
114 content, err := os.ReadFile(todoPath)
115 if os.IsNotExist(err) {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700116 return llm.ToolOut{LLMContent: llm.TextContent("No todo list found. Use todo_write to create one.")}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700117 }
118 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700119 return llm.ErrorfToolOut("failed to read todo file: %w", err)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700120 }
121
122 var todoList TodoList
123 if err := json.Unmarshal(content, &todoList); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700124 return llm.ErrorfToolOut("failed to parse todo file: %w", err)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700125 }
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 Snyder43b60b92025-07-21 14:57:10 -0700133 return llm.ToolOut{LLMContent: llm.TextContent(result)}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700134}
135
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700136func todoWriteRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700137 var input TodoWriteInput
138 if err := json.Unmarshal(m, &input); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700139 return llm.ErrorfToolOut("invalid input: %w", err)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700140 }
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 Snyder43b60b92025-07-21 14:57:10 -0700151 return llm.ErrorfToolOut("only one task can be 'in-progress' at a time, found %d", inProgressCount)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700152 }
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 Snyder43b60b92025-07-21 14:57:10 -0700161 return llm.ErrorfToolOut("failed to create todo directory: %w", err)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700162 }
163
164 content, err := json.Marshal(todoList)
165 if err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700166 return llm.ErrorfToolOut("failed to marshal todo list: %w", err)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700167 }
168
169 if err := os.WriteFile(todoPath, content, 0o600); err != nil {
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700170 return llm.ErrorfToolOut("failed to write todo file: %w", err)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700171 }
172
173 result := fmt.Sprintf("Updated todo list with %d items.", len(input.Tasks))
174
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700175 return llm.ToolOut{LLMContent: llm.TextContent(result)}
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700176}