Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

feat: 循环任务

Lyric 7c31f77e 34f68559

+1401 -85
+1
.gitignore
··· 6 6 .ai 7 7 .cursor/ 8 8 .codex 9 + .bin 9 10 go.work 10 11 go.work.sum 11 12 dump
+17
assets/config/TODO.RECUR.md
··· 1 + --- 2 + created_at: "1970-01-01T00:00:00Z" 3 + updated_at: "1970-01-01T00:00:00Z" 4 + recurring_count: 0 5 + --- 6 + 7 + # TODO Recurring 8 + 9 + <!-- 10 + ==Example Begin== 11 + 12 + - [ ] [Next](2026-02-12 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-1001981343441) | Remind [John](tg:@johnwick) to submit the report. 13 + - [ ] [Next](2026-02-16 10:00), [Repeat](weekly) | Review open invoices. 14 + - [ ] [Next](2026-02-14 18:00), [Repeat](every 3 days) | Back up notes. 15 + 16 + ==Example End== 17 + -->
+8 -1
cmd/mistermorph/consolecmd/local_runtime.go
··· 44 44 "github.com/quailyquaily/mistermorph/internal/skillsutil" 45 45 "github.com/quailyquaily/mistermorph/internal/statepaths" 46 46 "github.com/quailyquaily/mistermorph/internal/streaming" 47 + "github.com/quailyquaily/mistermorph/internal/todo" 47 48 "github.com/quailyquaily/mistermorph/internal/toolsutil" 48 49 "github.com/quailyquaily/mistermorph/internal/workspace" 49 50 "github.com/quailyquaily/mistermorph/llm" ··· 1316 1317 if pokeMeta := job.WakeSignal.MetaValue(); pokeMeta != nil { 1317 1318 meta["poke"] = pokeMeta 1318 1319 } 1319 - promptAugment := func(spec *agent.PromptSpec, _ *tools.Registry) { 1320 + promptAugment := func(spec *agent.PromptSpec, reg *tools.Registry) { 1321 + toolsutil.SetTodoUpdateToolAddContext(reg, todo.AddResolveContext{ 1322 + Channel: "console", 1323 + ChatType: "topic", 1324 + SpeakerUsername: consoleParticipantKey, 1325 + UserInputRaw: job.Task, 1326 + }) 1320 1327 prefixBlocks := make([]agent.PromptBlock, 0, 2) 1321 1328 if block := workspace.PromptBlock(job.WorkspaceDir); strings.TrimSpace(block.Content) != "" { 1322 1329 prefixBlocks = append(prefixBlocks, block)
+10
cmd/mistermorph/consolecmd/local_runtime_history.go
··· 22 22 ) 23 23 24 24 func (r *consoleLocalRuntime) buildConsolePromptMessages(job consoleLocalTaskJob) ([]llm.Message, *llm.Message, error) { 25 + if isHeartbeatTaskJob(job) { 26 + task := strings.TrimSpace(job.Task) 27 + if task == "" { 28 + return nil, nil, nil 29 + } 30 + return nil, &llm.Message{ 31 + Role: "user", 32 + Content: task, 33 + }, nil 34 + } 25 35 history := r.loadConsoleTopicHistory(job) 26 36 return renderConsolePromptMessages(history, job) 27 37 }
+26
cmd/mistermorph/consolecmd/local_runtime_test.go
··· 309 309 } 310 310 } 311 311 312 + func TestBuildConsolePromptMessagesOmitsHistoryForHeartbeat(t *testing.T) { 313 + rt := &consoleLocalRuntime{} 314 + history, current, err := rt.buildConsolePromptMessages(consoleLocalTaskJob{ 315 + TaskID: "heartbeat_1", 316 + TopicID: "_heartbeat", 317 + Task: "# Heartbeat Checklist\n\n## Check TODO.md", 318 + CreatedAt: time.Date(2026, time.May, 1, 12, 0, 0, 0, time.UTC), 319 + Trigger: daemonruntime.TaskTrigger{Source: "heartbeat"}, 320 + }) 321 + if err != nil { 322 + t.Fatalf("buildConsolePromptMessages() error = %v", err) 323 + } 324 + if len(history) != 0 { 325 + t.Fatalf("history messages = %d, want 0", len(history)) 326 + } 327 + if current == nil { 328 + t.Fatal("current message is nil") 329 + } 330 + if current.Content != "# Heartbeat Checklist\n\n## Check TODO.md" { 331 + t.Fatalf("current content = %q", current.Content) 332 + } 333 + if strings.Contains(current.Content, "chat_history_messages") { 334 + t.Fatalf("heartbeat prompt should not mention chat_history_messages: %q", current.Content) 335 + } 336 + } 337 + 312 338 func TestBuildConsoleTopicHistoryUsesRecentPriorTasks(t *testing.T) { 313 339 base := time.Date(2026, time.March, 23, 10, 0, 0, 0, time.UTC) 314 340 tasks := make([]daemonruntime.TaskInfo, 0, 10)
+4
internal/daemonruntime/server.go
··· 610 610 diagnoseFileReadable("contacts_inactive", paths.contactsInactive), 611 611 diagnoseFileReadable("todo_wip", paths.todoWIP), 612 612 diagnoseFileReadable("todo_done", paths.todoDone), 613 + diagnoseFileReadable("todo_recurring", paths.todoRecurring), 613 614 diagnoseFileReadable("persona_identity", paths.identityPath), 614 615 diagnoseFileReadable("persona_soul", paths.soulPath), 615 616 diagnoseFileReadable("heartbeat_checklist", paths.heartbeatPath), ··· 1607 1608 scriptsPath string 1608 1609 todoWIP string 1609 1610 todoDone string 1611 + todoRecurring string 1610 1612 auditPath string 1611 1613 } 1612 1614 ··· 1627 1629 scriptsPath: statepaths.ScriptsNotesPath(), 1628 1630 todoWIP: statepaths.TODOWIPPath(), 1629 1631 todoDone: statepaths.TODODONEPath(), 1632 + todoRecurring: statepaths.TODORECURPath(), 1630 1633 auditPath: resolveGuardAuditPath(stateDir), 1631 1634 } 1632 1635 } ··· 1684 1687 return []stateFileSpec{ 1685 1688 {Name: "TODO.md", Group: "todo", Path: paths.todoWIP}, 1686 1689 {Name: "TODO.DONE.md", Group: "todo", Path: paths.todoDone}, 1690 + {Name: "TODO.RECUR.md", Group: "todo", Path: paths.todoRecurring}, 1687 1691 {Name: "ACTIVE.md", Group: "contacts", Path: paths.contactsActive}, 1688 1692 {Name: "INACTIVE.md", Group: "contacts", Path: paths.contactsInactive}, 1689 1693 {Name: "IDENTITY.md", Group: "persona", Path: paths.identityPath},
+6 -4
internal/daemonruntime/server_state_files_test.go
··· 16 16 paths := runtimeStatePaths{ 17 17 todoWIP: "/tmp/TODO.md", 18 18 todoDone: "/tmp/TODO.DONE.md", 19 + todoRecurring: "/tmp/TODO.RECUR.md", 19 20 contactsActive: "/tmp/ACTIVE.md", 20 21 contactsInactive: "/tmp/INACTIVE.md", 21 22 identityPath: "/tmp/IDENTITY.md", ··· 25 26 } 26 27 27 28 items := describeStateFiles(paths, "") 28 - if len(items) != 8 { 29 - t.Fatalf("len(items) = %d, want 8", len(items)) 29 + if len(items) != 9 { 30 + t.Fatalf("len(items) = %d, want 9", len(items)) 30 31 } 31 32 32 33 foundHeartbeat := false ··· 45 46 paths := runtimeStatePaths{ 46 47 todoWIP: "/tmp/TODO.md", 47 48 todoDone: "/tmp/TODO.DONE.md", 49 + todoRecurring: "/tmp/TODO.RECUR.md", 48 50 contactsActive: "/tmp/ACTIVE.md", 49 51 contactsInactive: "/tmp/INACTIVE.md", 50 52 identityPath: "/tmp/IDENTITY.md", ··· 99 101 if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { 100 102 t.Fatalf("invalid json: %v", err) 101 103 } 102 - if len(payload.Items) != 8 { 103 - t.Fatalf("len(items) = %d, want 8", len(payload.Items)) 104 + if len(payload.Items) != 9 { 105 + t.Fatalf("len(items) = %d, want 9", len(payload.Items)) 104 106 } 105 107 } 106 108
+88
internal/heartbeatutil/heartbeat.go
··· 3 3 import ( 4 4 "fmt" 5 5 "os" 6 + "path/filepath" 6 7 "regexp" 7 8 "strings" 8 9 "sync" 9 10 "time" 11 + 12 + "github.com/quailyquaily/mistermorph/internal/entryutil" 13 + "github.com/quailyquaily/mistermorph/internal/todo" 10 14 ) 11 15 12 16 const ( ··· 16 20 var heartbeatHTMLComment = regexp.MustCompile(`(?s)<!--.*?-->`) 17 21 18 22 func BuildHeartbeatTask(checklistPath string) (string, bool, error) { 23 + if err := materializeDueRecurringTodos(checklistPath); err != nil { 24 + return "", true, err 25 + } 19 26 checklist, empty, err := readHeartbeatChecklist(checklistPath) 20 27 if err != nil { 21 28 return "", true, err 22 29 } 23 30 if empty { 24 31 return "", true, nil 32 + } 33 + checklist, err = appendOpenTodos(checklistPath, checklist) 34 + if err != nil { 35 + return "", true, err 25 36 } 26 37 return checklist, false, nil 38 + } 39 + 40 + func appendOpenTodos(checklistPath string, task string) (string, error) { 41 + task = strings.TrimSpace(task) 42 + todoPath := todoWIPPathForChecklist(checklistPath) 43 + if todoPath == "" { 44 + return task, nil 45 + } 46 + raw, err := os.ReadFile(todoPath) 47 + if err != nil { 48 + if os.IsNotExist(err) { 49 + return task, nil 50 + } 51 + return "", err 52 + } 53 + wip, err := todo.ParseWIP(string(raw)) 54 + if err != nil { 55 + return "", err 56 + } 57 + if len(wip.Entries) == 0 { 58 + return task, nil 59 + } 60 + var b strings.Builder 61 + if task != "" { 62 + b.WriteString(task) 63 + b.WriteString("\n\n") 64 + } 65 + b.WriteString("## Current TODO.md Open Items\n\n") 66 + for _, item := range wip.Entries { 67 + line := heartbeatTODOEntryLine(item) 68 + if line == "" { 69 + continue 70 + } 71 + b.WriteString(line) 72 + b.WriteString("\n") 73 + } 74 + return strings.TrimSpace(b.String()), nil 75 + } 76 + 77 + func heartbeatTODOEntryLine(item todo.Entry) string { 78 + content := strings.TrimSpace(item.Content) 79 + createdAt := strings.TrimSpace(item.CreatedAt) 80 + if content == "" || !entryutil.IsValidTimestamp(createdAt) { 81 + return "" 82 + } 83 + meta := []string{entryutil.FormatMetadataTuple("Created", createdAt)} 84 + if chatID := strings.TrimSpace(item.ChatID); chatID != "" { 85 + meta = append(meta, entryutil.FormatMetadataTuple("ChatID", chatID)) 86 + } 87 + return "- [ ] " + strings.Join(meta, ", ") + " | " + content 88 + } 89 + 90 + func materializeDueRecurringTodos(checklistPath string) error { 91 + checklistPath = strings.TrimSpace(checklistPath) 92 + todoPath := todoWIPPathForChecklist(checklistPath) 93 + if todoPath == "" { 94 + return nil 95 + } 96 + store := todo.NewStore( 97 + todoPath, 98 + filepath.Join(filepath.Dir(todoPath), todo.DefaultDONEFilename), 99 + ) 100 + store.RecurringPath = filepath.Join(filepath.Dir(todoPath), todo.DefaultRECURFilename) 101 + _, err := store.MaterializeDueRecurring() 102 + return err 103 + } 104 + 105 + func todoWIPPathForChecklist(checklistPath string) string { 106 + checklistPath = strings.TrimSpace(checklistPath) 107 + if checklistPath == "" { 108 + return "" 109 + } 110 + stateDir := filepath.Dir(checklistPath) 111 + if strings.TrimSpace(stateDir) == "" || stateDir == "." { 112 + return "" 113 + } 114 + return filepath.Join(stateDir, todo.DefaultWIPFilename) 27 115 } 28 116 29 117 func BuildHeartbeatMeta(source string, interval time.Duration, checklistPath string, checklistEmpty bool, state *State, extra map[string]any) map[string]any {
+59
internal/heartbeatutil/heartbeat_recurring_test.go
··· 1 + package heartbeatutil 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + func TestBuildHeartbeatTaskMaterializesRecurringTodos(t *testing.T) { 12 + root := t.TempDir() 13 + checklistPath := filepath.Join(root, "HEARTBEAT.md") 14 + if err := os.WriteFile(checklistPath, []byte("## Check TODO.md\n\n- Check TODO.md\n"), 0o600); err != nil { 15 + t.Fatalf("write heartbeat checklist: %v", err) 16 + } 17 + dueAt := time.Now().UTC().Add(-1 * time.Hour).Format("2006-01-02 15:04") 18 + recurPath := filepath.Join(root, "TODO.RECUR.md") 19 + recur := `--- 20 + created_at: "1970-01-01T00:00:00Z" 21 + updated_at: "1970-01-01T00:00:00Z" 22 + recurring_count: 1 23 + --- 24 + 25 + # TODO Recurring 26 + 27 + - [ ] [Next](` + dueAt + `), [Repeat](daily) | Review open invoices. 28 + ` 29 + if err := os.WriteFile(recurPath, []byte(recur), 0o600); err != nil { 30 + t.Fatalf("write recurring todo: %v", err) 31 + } 32 + 33 + task, empty, err := BuildHeartbeatTask(checklistPath) 34 + if err != nil { 35 + t.Fatalf("BuildHeartbeatTask() error = %v", err) 36 + } 37 + if empty || !strings.Contains(task, "Check TODO.md") { 38 + t.Fatalf("heartbeat task mismatch: task=%q empty=%v", task, empty) 39 + } 40 + if !strings.Contains(task, "## Current TODO.md Open Items") || !strings.Contains(task, dueAt+" Review open invoices.") { 41 + t.Fatalf("heartbeat task missing current TODO snapshot:\n%s", task) 42 + } 43 + 44 + todoRaw, err := os.ReadFile(filepath.Join(root, "TODO.md")) 45 + if err != nil { 46 + t.Fatalf("read materialized TODO.md: %v", err) 47 + } 48 + if !strings.Contains(string(todoRaw), dueAt+" Review open invoices.") { 49 + t.Fatalf("TODO.md missing materialized recurring task:\n%s", string(todoRaw)) 50 + } 51 + 52 + recurRaw, err := os.ReadFile(recurPath) 53 + if err != nil { 54 + t.Fatalf("read updated TODO.RECUR.md: %v", err) 55 + } 56 + if strings.Contains(string(recurRaw), "[Next]("+dueAt+")") { 57 + t.Fatalf("TODO.RECUR.md did not advance Next:\n%s", string(recurRaw)) 58 + } 59 + }
+10
internal/promptprofile/prompts/block_todo_workflow.md
··· 1 1 [[ TODO Workflow ]] 2 2 Use this workflow only when you need to remember something for future work, or mark an existing todo item as completed. 3 3 Maintain `TODO.md` and `TODO.DONE.md` under `file_state_dir`. 4 + Recurring tasks live in `TODO.RECUR.md` under `file_state_dir`. 4 5 5 6 `TODO.md` entry format examples: 6 7 ```text ··· 14 15 - [x] [Created](2026-02-11 09:30), [Done](2026-02-11 10:00), [ChatID](tg:-1001981343441) | 2026-02-11 10:00 Had lunch with [John](tg:@johnwick), Miss Louis and [Sarah](tg:29930) at the Italian restaurant. 15 16 ``` 16 17 18 + `TODO.RECUR.md` entry format examples: 19 + ```text 20 + - [ ] [Next](2026-02-12 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-1001981343441) | Remind [John](tg:@johnwick) to submit the report. 21 + - [ ] [Next](2026-02-16 10:00), [Repeat](weekly) | Review open invoices. 22 + - [ ] [Next](2026-02-14 18:00), [Repeat](every 3 days) | Back up notes. 23 + ``` 24 + 17 25 - If a new task is identified, use `todo_update` to add it to `TODO.md`. 26 + - If a new recurring task is identified, use `todo_update` with action `add_recurring`. Pass `content`, `next` (`YYYY-MM-DD HH:mm`), `repeat`, optional `tz`, and optional `chat_id`; supported repeat values are `daily`, `weekly`, and `every N days`. 27 + - If the user states a timezone, write it as an IANA timezone in `TZ` (for example `Asia/Tokyo`). If no timezone is stated, omit `TZ`; the runtime local timezone is used. 18 28 - If a task is expired, notify mentioned contacts via `contacts_send` with a concise reminder. Do not mention TODO files, pending counts, or delivery status. Then use `todo_update` to complete the task. 19 29 - If a task is not due, do nothing.
+5
internal/statepaths/statepaths.go
··· 13 13 ScriptsNotesFilename = "SCRIPTS.md" 14 14 TODOWIPFilename = "TODO.md" 15 15 TODODONEFilename = "TODO.DONE.md" 16 + TODORECURFilename = "TODO.RECUR.md" 16 17 ) 17 18 18 19 func FileStateDir() string { ··· 85 86 86 87 func TODODONEPath() string { 87 88 return pathutil.ResolveStateFile(viper.GetString("file_state_dir"), TODODONEFilename) 89 + } 90 + 91 + func TODORECURPath() string { 92 + return pathutil.ResolveStateFile(viper.GetString("file_state_dir"), TODORECURFilename) 88 93 } 89 94 90 95 func WorkspaceAttachmentsPath() string {
+165 -4
internal/todo/parser.go
··· 3 3 import ( 4 4 "bufio" 5 5 "fmt" 6 + "regexp" 6 7 "strconv" 7 8 "strings" 8 9 ··· 10 11 "gopkg.in/yaml.v3" 11 12 ) 12 13 14 + var todoHTMLComment = regexp.MustCompile(`(?s)<!--.*?-->`) 15 + 13 16 type fileFrontmatter struct { 14 - CreatedAt string `yaml:"created_at"` 15 - UpdatedAt string `yaml:"updated_at"` 16 - OpenCount *int `yaml:"open_count,omitempty"` 17 - DoneCount *int `yaml:"done_count,omitempty"` 17 + CreatedAt string `yaml:"created_at"` 18 + UpdatedAt string `yaml:"updated_at"` 19 + OpenCount *int `yaml:"open_count,omitempty"` 20 + DoneCount *int `yaml:"done_count,omitempty"` 21 + RecurringCount *int `yaml:"recurring_count,omitempty"` 18 22 } 19 23 20 24 func ParseWIP(raw string) (WIPFile, error) { ··· 53 57 return out, nil 54 58 } 55 59 60 + func ParseRECUR(raw string) (RECURFile, error) { 61 + fm, body, _, err := parseFrontmatter(raw) 62 + if err != nil { 63 + return RECURFile{}, err 64 + } 65 + lines := parseRecurringEntryLines(body) 66 + out := RECURFile{ 67 + CreatedAt: strings.TrimSpace(fm.CreatedAt), 68 + UpdatedAt: strings.TrimSpace(fm.UpdatedAt), 69 + Entries: lines, 70 + } 71 + if fm.RecurringCount != nil { 72 + out.RecurringCount = *fm.RecurringCount 73 + } 74 + out.RecurringCount = len(out.Entries) 75 + return out, nil 76 + } 77 + 56 78 func RenderWIP(file WIPFile) string { 57 79 file.OpenCount = len(file.Entries) 58 80 var b strings.Builder ··· 107 129 return b.String() 108 130 } 109 131 132 + func RenderRECUR(file RECURFile) string { 133 + file.RecurringCount = len(file.Entries) 134 + var b strings.Builder 135 + b.WriteString("---\n") 136 + b.WriteString(`created_at: "`) 137 + b.WriteString(strings.TrimSpace(file.CreatedAt)) 138 + b.WriteString("\"\n") 139 + b.WriteString(`updated_at: "`) 140 + b.WriteString(strings.TrimSpace(file.UpdatedAt)) 141 + b.WriteString("\"\n") 142 + b.WriteString("recurring_count: ") 143 + b.WriteString(strconv.Itoa(file.RecurringCount)) 144 + b.WriteString("\n") 145 + b.WriteString("---\n\n") 146 + b.WriteString(HeaderRECUR) 147 + b.WriteString("\n\n") 148 + for _, item := range file.Entries { 149 + line := renderRecurringEntryLine(item) 150 + if strings.TrimSpace(line) == "" { 151 + continue 152 + } 153 + b.WriteString(line) 154 + b.WriteString("\n") 155 + } 156 + return b.String() 157 + } 158 + 110 159 func ParseEntryFromInput(raw string, now string) (Entry, error) { 111 160 raw = strings.TrimSpace(raw) 112 161 if raw == "" { ··· 173 222 } 174 223 175 224 func parseEntryLines(body string, done bool) []Entry { 225 + body = todoHTMLComment.ReplaceAllString(body, "") 176 226 if strings.TrimSpace(body) == "" { 177 227 return nil 178 228 } ··· 199 249 return out 200 250 } 201 251 252 + func parseRecurringEntryLines(body string) []RecurringEntry { 253 + body = todoHTMLComment.ReplaceAllString(body, "") 254 + if strings.TrimSpace(body) == "" { 255 + return nil 256 + } 257 + lines := strings.Split(body, "\n") 258 + out := make([]RecurringEntry, 0, len(lines)) 259 + for _, raw := range lines { 260 + line := strings.TrimSpace(raw) 261 + if line == "" { 262 + continue 263 + } 264 + if strings.HasPrefix(line, "#") { 265 + continue 266 + } 267 + if item, ok := parseRecurringEntryLine(line); ok { 268 + out = append(out, item) 269 + } 270 + } 271 + return out 272 + } 273 + 202 274 func parseWIPEntryLine(line string) (Entry, bool) { 203 275 line = strings.TrimSpace(line) 204 276 if !strings.HasPrefix(line, "- [ ] ") { ··· 309 381 meta = append(meta, entryutil.FormatMetadataTuple("ChatID", chatID)) 310 382 } 311 383 return "- [x] " + strings.Join(meta, ", ") + " | " + content 384 + } 385 + 386 + func parseRecurringEntryLine(line string) (RecurringEntry, bool) { 387 + line = strings.TrimSpace(line) 388 + if !strings.HasPrefix(line, "- [ ] ") { 389 + return RecurringEntry{}, false 390 + } 391 + rest := strings.TrimSpace(strings.TrimPrefix(line, "- [ ] ")) 392 + metaRaw, content, ok := entryutil.SplitMetadataAndContent(rest) 393 + if !ok { 394 + return RecurringEntry{}, false 395 + } 396 + meta, ok := parseRecurringEntryMetadata(metaRaw) 397 + if !ok || content == "" { 398 + return RecurringEntry{}, false 399 + } 400 + nextAt, ok := meta["Next"] 401 + if !ok || !validTimestamp(nextAt) { 402 + return RecurringEntry{}, false 403 + } 404 + repeat, ok := meta["Repeat"] 405 + if !ok || !validRecurringRepeat(repeat) { 406 + return RecurringEntry{}, false 407 + } 408 + tz := "" 409 + if rawTZ, exists := meta["TZ"]; exists { 410 + tz = normalizeRecurringTZ(rawTZ) 411 + if !validRecurringTZ(tz) { 412 + return RecurringEntry{}, false 413 + } 414 + } 415 + chatID := "" 416 + if rawChatID, exists := meta["ChatID"]; exists { 417 + chatID = normalizeEntryChatID(rawChatID) 418 + if !isValidTODOChatID(chatID) { 419 + return RecurringEntry{}, false 420 + } 421 + } 422 + return RecurringEntry{ 423 + NextAt: nextAt, 424 + Repeat: normalizeRecurringRepeat(repeat), 425 + TZ: tz, 426 + ChatID: chatID, 427 + Content: content, 428 + }, true 429 + } 430 + 431 + func renderRecurringEntryLine(item RecurringEntry) string { 432 + content := strings.TrimSpace(item.Content) 433 + nextAt := strings.TrimSpace(item.NextAt) 434 + repeat := normalizeRecurringRepeat(item.Repeat) 435 + if content == "" || !validTimestamp(nextAt) || !validRecurringRepeat(repeat) { 436 + return "" 437 + } 438 + meta := []string{ 439 + entryutil.FormatMetadataTuple("Next", nextAt), 440 + entryutil.FormatMetadataTuple("Repeat", repeat), 441 + } 442 + tz := normalizeRecurringTZ(item.TZ) 443 + if tz != "" { 444 + if !validRecurringTZ(tz) { 445 + return "" 446 + } 447 + meta = append(meta, entryutil.FormatMetadataTuple("TZ", tz)) 448 + } 449 + chatID := normalizeEntryChatID(item.ChatID) 450 + if chatID != "" { 451 + if !isValidTODOChatID(chatID) { 452 + return "" 453 + } 454 + meta = append(meta, entryutil.FormatMetadataTuple("ChatID", chatID)) 455 + } 456 + return "- [ ] " + strings.Join(meta, ", ") + " | " + content 457 + } 458 + 459 + func parseRecurringEntryMetadata(raw string) (map[string]string, bool) { 460 + out, ok := entryutil.ParseMetadataTuples(raw) 461 + if !ok { 462 + return nil, false 463 + } 464 + for key := range out { 465 + switch key { 466 + case "Next", "Repeat", "TZ", "ChatID": 467 + continue 468 + default: 469 + return nil, false 470 + } 471 + } 472 + return out, true 312 473 } 313 474 314 475 func parseEntryMetadata(raw string) (map[string]string, bool) {
+282
internal/todo/recurring.go
··· 1 + package todo 2 + 3 + import ( 4 + "fmt" 5 + "regexp" 6 + "strconv" 7 + "strings" 8 + "time" 9 + ) 10 + 11 + var everyNDaysPattern = regexp.MustCompile(`^every\s+([1-9][0-9]*)\s+days?$`) 12 + 13 + type RecurringMaterializeResult struct { 14 + Generated int `json:"generated"` 15 + Advanced int `json:"advanced"` 16 + } 17 + 18 + func (s *Store) AddRecurringWithChatID(raw string, nextAt string, repeat string, tz string, chatID string) (RecurringUpdateResult, error) { 19 + recur, _, err := s.readRECUR(s.nowUTC()) 20 + if err != nil { 21 + return RecurringUpdateResult{}, err 22 + } 23 + entry, err := ParseRecurringEntryFromInput(raw, nextAt, repeat, tz) 24 + if err != nil { 25 + return RecurringUpdateResult{}, err 26 + } 27 + if parsedChatID := normalizeEntryChatID(chatID); parsedChatID != "" { 28 + entry.ChatID = parsedChatID 29 + } 30 + if err := validateRecurringEntry(entry); err != nil { 31 + return RecurringUpdateResult{}, err 32 + } 33 + recur.Entries = append([]RecurringEntry{entry}, recur.Entries...) 34 + if err := s.writeRECUR(recur); err != nil { 35 + return RecurringUpdateResult{}, err 36 + } 37 + return RecurringUpdateResult{ 38 + OK: true, 39 + Action: "add_recurring", 40 + RecurringCount: len(recur.Entries), 41 + Changed: Changed{ 42 + WIPAdded: 0, 43 + WIPRemoved: 0, 44 + DONEAdded: 0, 45 + }, 46 + Entry: &entry, 47 + }, nil 48 + } 49 + 50 + func ParseRecurringEntryFromInput(raw string, nextAt string, repeat string, tz string) (RecurringEntry, error) { 51 + raw = strings.TrimSpace(raw) 52 + if raw == "" { 53 + return RecurringEntry{}, fmt.Errorf("content is required") 54 + } 55 + if item, ok := parseRecurringEntryLine(raw); ok { 56 + return item, nil 57 + } 58 + nextAt = strings.TrimSpace(nextAt) 59 + repeat = normalizeRecurringRepeat(repeat) 60 + tz = normalizeRecurringTZ(tz) 61 + entry := RecurringEntry{ 62 + NextAt: nextAt, 63 + Repeat: repeat, 64 + TZ: tz, 65 + Content: raw, 66 + } 67 + if err := validateRecurringEntry(entry); err != nil { 68 + return RecurringEntry{}, err 69 + } 70 + return entry, nil 71 + } 72 + 73 + func (s *Store) MaterializeDueRecurring() (RecurringMaterializeResult, error) { 74 + if s == nil { 75 + return RecurringMaterializeResult{}, fmt.Errorf("todo store is nil") 76 + } 77 + now := s.nowUTC() 78 + recur, exists, err := s.readRECUR(now) 79 + if err != nil { 80 + return RecurringMaterializeResult{}, err 81 + } 82 + if !exists || len(recur.Entries) == 0 { 83 + return RecurringMaterializeResult{}, nil 84 + } 85 + 86 + wip, done, err := s.readFiles() 87 + if err != nil { 88 + return RecurringMaterializeResult{}, err 89 + } 90 + 91 + generated := make([]Entry, 0, len(recur.Entries)) 92 + for idx, item := range recur.Entries { 93 + if err := validateRecurringEntry(item); err != nil { 94 + return RecurringMaterializeResult{}, err 95 + } 96 + loc, err := recurringLocation(item.TZ) 97 + if err != nil { 98 + return RecurringMaterializeResult{}, err 99 + } 100 + nextAt, err := parseRecurringTime(item.NextAt, loc) 101 + if err != nil { 102 + return RecurringMaterializeResult{}, err 103 + } 104 + if nextAt.After(now) { 105 + continue 106 + } 107 + generated = append(generated, Entry{ 108 + Done: false, 109 + CreatedAt: now.Format(TimestampLayout), 110 + ChatID: normalizeEntryChatID(item.ChatID), 111 + Content: materializedRecurringContent(nextAt, loc, item.TZ, item.Content), 112 + }) 113 + advanced, err := nextRecurringTimeAfterInLocation(nextAt, item.Repeat, now, loc) 114 + if err != nil { 115 + return RecurringMaterializeResult{}, err 116 + } 117 + recur.Entries[idx].NextAt = advanced.Format(TimestampLayout) 118 + recur.Entries[idx].Repeat = normalizeRecurringRepeat(item.Repeat) 119 + recur.Entries[idx].TZ = normalizeRecurringTZ(item.TZ) 120 + } 121 + if len(generated) == 0 { 122 + return RecurringMaterializeResult{}, nil 123 + } 124 + 125 + wip.Entries = append(generated, wip.Entries...) 126 + if err := validateWIPEntries(wip.Entries); err != nil { 127 + return RecurringMaterializeResult{}, err 128 + } 129 + if err := s.writeFiles(wip, done); err != nil { 130 + return RecurringMaterializeResult{}, err 131 + } 132 + if err := s.writeRECUR(recur); err != nil { 133 + return RecurringMaterializeResult{}, err 134 + } 135 + return RecurringMaterializeResult{ 136 + Generated: len(generated), 137 + Advanced: len(generated), 138 + }, nil 139 + } 140 + 141 + func validateRecurringEntry(item RecurringEntry) error { 142 + loc, err := recurringLocation(item.TZ) 143 + if err != nil { 144 + return err 145 + } 146 + if _, err := parseRecurringTime(item.NextAt, loc); err != nil { 147 + return err 148 + } 149 + if !validRecurringRepeat(item.Repeat) { 150 + return fmt.Errorf("invalid Repeat: %s", strings.TrimSpace(item.Repeat)) 151 + } 152 + if err := validateEntryChatID(item.ChatID); err != nil { 153 + return err 154 + } 155 + if err := validateEntryReferences(item.Content); err != nil { 156 + return err 157 + } 158 + if strings.TrimSpace(item.Content) == "" { 159 + return fmt.Errorf("content is required") 160 + } 161 + return nil 162 + } 163 + 164 + func materializedRecurringContent(nextAt time.Time, loc *time.Location, tz string, content string) string { 165 + if loc == nil { 166 + loc = time.UTC 167 + } 168 + prefix := nextAt.In(loc).Format(TimestampLayout) 169 + tz = normalizeRecurringTZ(tz) 170 + if tz != "" { 171 + prefix += " (" + tz + ")" 172 + } 173 + return strings.TrimSpace(prefix + " " + strings.TrimSpace(content)) 174 + } 175 + 176 + func parseRecurringTime(raw string, loc *time.Location) (time.Time, error) { 177 + value := strings.TrimSpace(raw) 178 + if value == "" { 179 + return time.Time{}, fmt.Errorf("Next is required") 180 + } 181 + if loc == nil { 182 + loc = time.UTC 183 + } 184 + t, err := time.ParseInLocation(TimestampLayout, value, loc) 185 + if err != nil { 186 + return time.Time{}, fmt.Errorf("invalid Next: %s", value) 187 + } 188 + return t, nil 189 + } 190 + 191 + func nextRecurringTimeAfter(from time.Time, repeat string, after time.Time) (time.Time, error) { 192 + return nextRecurringTimeAfterInLocation(from, repeat, after, time.UTC) 193 + } 194 + 195 + func nextRecurringTimeAfterInLocation(from time.Time, repeat string, after time.Time, loc *time.Location) (time.Time, error) { 196 + repeat = normalizeRecurringRepeat(repeat) 197 + days, err := recurringRepeatDays(repeat) 198 + if err != nil { 199 + return time.Time{}, err 200 + } 201 + if loc == nil { 202 + loc = time.UTC 203 + } 204 + next := from.In(loc) 205 + after = after.In(loc) 206 + if next.After(after) { 207 + return next, nil 208 + } 209 + 210 + elapsedDays := int(after.Sub(next).Hours() / 24) 211 + steps := elapsedDays/days + 1 212 + next = next.AddDate(0, 0, steps*days) 213 + for !next.After(after) { 214 + next = next.AddDate(0, 0, days) 215 + } 216 + return next, nil 217 + } 218 + 219 + func recurringRepeatDays(repeat string) (int, error) { 220 + repeat = normalizeRecurringRepeat(repeat) 221 + switch { 222 + case repeat == "daily": 223 + return 1, nil 224 + case repeat == "weekly": 225 + return 7, nil 226 + default: 227 + parsed, ok := parseEveryNDays(repeat) 228 + if !ok { 229 + return 0, fmt.Errorf("invalid Repeat: %s", strings.TrimSpace(repeat)) 230 + } 231 + return parsed, nil 232 + } 233 + } 234 + 235 + func validRecurringRepeat(raw string) bool { 236 + raw = normalizeRecurringRepeat(raw) 237 + if raw == "daily" || raw == "weekly" { 238 + return true 239 + } 240 + _, ok := parseEveryNDays(raw) 241 + return ok 242 + } 243 + 244 + func normalizeRecurringRepeat(raw string) string { 245 + return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(raw))), " ") 246 + } 247 + 248 + func parseEveryNDays(raw string) (int, bool) { 249 + matches := everyNDaysPattern.FindStringSubmatch(normalizeRecurringRepeat(raw)) 250 + if len(matches) != 2 { 251 + return 0, false 252 + } 253 + days, err := strconv.Atoi(matches[1]) 254 + if err != nil || days <= 0 { 255 + return 0, false 256 + } 257 + return days, true 258 + } 259 + 260 + func recurringLocation(tz string) (*time.Location, error) { 261 + tz = normalizeRecurringTZ(tz) 262 + if tz == "" { 263 + return time.Local, nil 264 + } 265 + if strings.EqualFold(tz, "UTC") { 266 + return time.UTC, nil 267 + } 268 + loc, err := time.LoadLocation(tz) 269 + if err != nil { 270 + return nil, fmt.Errorf("invalid TZ: %s", tz) 271 + } 272 + return loc, nil 273 + } 274 + 275 + func validRecurringTZ(raw string) bool { 276 + _, err := recurringLocation(raw) 277 + return err == nil 278 + } 279 + 280 + func normalizeRecurringTZ(raw string) string { 281 + return strings.TrimSpace(raw) 282 + }
+257
internal/todo/recurring_test.go
··· 1 + package todo 2 + 3 + import ( 4 + "path/filepath" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestParseAndRenderRECUR(t *testing.T) { 11 + raw := `--- 12 + created_at: "1970-01-01T00:00:00Z" 13 + updated_at: "1970-01-01T00:00:00Z" 14 + recurring_count: 1 15 + --- 16 + 17 + # TODO Recurring 18 + 19 + - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 20 + ` 21 + file, err := ParseRECUR(raw) 22 + if err != nil { 23 + t.Fatalf("ParseRECUR() error = %v", err) 24 + } 25 + if len(file.Entries) != 1 { 26 + t.Fatalf("entries = %d, want 1", len(file.Entries)) 27 + } 28 + entry := file.Entries[0] 29 + if entry.NextAt != "2026-05-02 09:00" || entry.Repeat != "daily" || entry.TZ != "Asia/Tokyo" || entry.ChatID != "tg:-100123" { 30 + t.Fatalf("entry metadata mismatch: %#v", entry) 31 + } 32 + rendered := RenderRECUR(file) 33 + if !strings.Contains(rendered, "[Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123)") { 34 + t.Fatalf("rendered recurring entry missing metadata:\n%s", rendered) 35 + } 36 + } 37 + 38 + func TestParseRECURIgnoresHTMLCommentExamples(t *testing.T) { 39 + raw := `--- 40 + created_at: "1970-01-01T00:00:00Z" 41 + updated_at: "1970-01-01T00:00:00Z" 42 + recurring_count: 0 43 + --- 44 + 45 + # TODO Recurring 46 + 47 + <!-- 48 + - [ ] [Next](2026-05-02 09:00), [Repeat](daily) | Example only. 49 + --> 50 + ` 51 + file, err := ParseRECUR(raw) 52 + if err != nil { 53 + t.Fatalf("ParseRECUR() error = %v", err) 54 + } 55 + if len(file.Entries) != 0 { 56 + t.Fatalf("entries = %d, want 0", len(file.Entries)) 57 + } 58 + } 59 + 60 + func TestMaterializeDueRecurring(t *testing.T) { 61 + root := t.TempDir() 62 + store := NewStore(filepath.Join(root, "TODO.md"), filepath.Join(root, "TODO.DONE.md")) 63 + store.Now = func() time.Time { 64 + return time.Date(2026, 5, 2, 9, 30, 0, 0, time.UTC) 65 + } 66 + recur := RECURFile{ 67 + CreatedAt: "1970-01-01T00:00:00Z", 68 + UpdatedAt: "1970-01-01T00:00:00Z", 69 + Entries: []RecurringEntry{ 70 + { 71 + NextAt: "2026-05-02 09:00", 72 + Repeat: "daily", 73 + ChatID: "tg:-100123", 74 + Content: "Remind [John](tg:@john) to submit report.", 75 + }, 76 + { 77 + NextAt: "2026-05-03 09:00", 78 + Repeat: "weekly", 79 + Content: "Future task.", 80 + }, 81 + }, 82 + } 83 + if err := store.writeRECUR(recur); err != nil { 84 + t.Fatalf("writeRECUR() error = %v", err) 85 + } 86 + 87 + result, err := store.MaterializeDueRecurring() 88 + if err != nil { 89 + t.Fatalf("MaterializeDueRecurring() error = %v", err) 90 + } 91 + if result.Generated != 1 || result.Advanced != 1 { 92 + t.Fatalf("result = %#v, want one generated and advanced", result) 93 + } 94 + 95 + wip, _, err := store.readFiles() 96 + if err != nil { 97 + t.Fatalf("readFiles() error = %v", err) 98 + } 99 + if len(wip.Entries) != 1 { 100 + t.Fatalf("wip entries = %d, want 1", len(wip.Entries)) 101 + } 102 + if got := wip.Entries[0].Content; got != "2026-05-02 09:00 Remind [John](tg:@john) to submit report." { 103 + t.Fatalf("materialized content = %q", got) 104 + } 105 + if wip.Entries[0].ChatID != "tg:-100123" { 106 + t.Fatalf("chat id = %q, want tg:-100123", wip.Entries[0].ChatID) 107 + } 108 + 109 + updated, _, err := store.readRECUR(store.nowUTC()) 110 + if err != nil { 111 + t.Fatalf("readRECUR() error = %v", err) 112 + } 113 + if got := updated.Entries[0].NextAt; got != "2026-05-03 09:00" { 114 + t.Fatalf("advanced next = %q, want 2026-05-03 09:00", got) 115 + } 116 + if got := updated.Entries[1].NextAt; got != "2026-05-03 09:00" { 117 + t.Fatalf("future next = %q, want unchanged", got) 118 + } 119 + } 120 + 121 + func TestMaterializeDueRecurringAdvancesPastNow(t *testing.T) { 122 + next, err := nextRecurringTimeAfter( 123 + time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC), 124 + "every 3 days", 125 + time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC), 126 + ) 127 + if err != nil { 128 + t.Fatalf("nextRecurringTimeAfter() error = %v", err) 129 + } 130 + if got := next.Format(TimestampLayout); got != "2026-05-10 09:00" { 131 + t.Fatalf("next = %q, want 2026-05-10 09:00", got) 132 + } 133 + } 134 + 135 + func TestMaterializeDueRecurringWithTimezone(t *testing.T) { 136 + root := t.TempDir() 137 + store := NewStore(filepath.Join(root, "TODO.md"), filepath.Join(root, "TODO.DONE.md")) 138 + store.Now = func() time.Time { 139 + return time.Date(2026, 5, 7, 6, 30, 0, 0, time.UTC) 140 + } 141 + recur := RECURFile{ 142 + CreatedAt: "1970-01-01T00:00:00Z", 143 + UpdatedAt: "1970-01-01T00:00:00Z", 144 + Entries: []RecurringEntry{ 145 + { 146 + NextAt: "2026-05-07 15:00", 147 + Repeat: "weekly", 148 + TZ: "Asia/Tokyo", 149 + Content: "去打网球。", 150 + }, 151 + }, 152 + } 153 + if err := store.writeRECUR(recur); err != nil { 154 + t.Fatalf("writeRECUR() error = %v", err) 155 + } 156 + 157 + result, err := store.MaterializeDueRecurring() 158 + if err != nil { 159 + t.Fatalf("MaterializeDueRecurring() error = %v", err) 160 + } 161 + if result.Generated != 1 { 162 + t.Fatalf("generated = %d, want 1", result.Generated) 163 + } 164 + 165 + wip, _, err := store.readFiles() 166 + if err != nil { 167 + t.Fatalf("readFiles() error = %v", err) 168 + } 169 + if len(wip.Entries) != 1 { 170 + t.Fatalf("wip entries = %d, want 1", len(wip.Entries)) 171 + } 172 + if got := wip.Entries[0].Content; got != "2026-05-07 15:00 (Asia/Tokyo) 去打网球。" { 173 + t.Fatalf("materialized content = %q", got) 174 + } 175 + 176 + updated, _, err := store.readRECUR(store.nowUTC()) 177 + if err != nil { 178 + t.Fatalf("readRECUR() error = %v", err) 179 + } 180 + if got := updated.Entries[0].NextAt; got != "2026-05-14 15:00" { 181 + t.Fatalf("advanced next = %q, want 2026-05-14 15:00", got) 182 + } 183 + if got := updated.Entries[0].TZ; got != "Asia/Tokyo" { 184 + t.Fatalf("advanced tz = %q, want Asia/Tokyo", got) 185 + } 186 + } 187 + 188 + func TestMaterializeDueRecurringWithTimezoneNotDueYet(t *testing.T) { 189 + root := t.TempDir() 190 + store := NewStore(filepath.Join(root, "TODO.md"), filepath.Join(root, "TODO.DONE.md")) 191 + store.Now = func() time.Time { 192 + return time.Date(2026, 5, 7, 5, 59, 0, 0, time.UTC) 193 + } 194 + if err := store.writeRECUR(RECURFile{ 195 + CreatedAt: "1970-01-01T00:00:00Z", 196 + UpdatedAt: "1970-01-01T00:00:00Z", 197 + Entries: []RecurringEntry{ 198 + { 199 + NextAt: "2026-05-07 15:00", 200 + Repeat: "weekly", 201 + TZ: "Asia/Tokyo", 202 + Content: "去打网球。", 203 + }, 204 + }, 205 + }); err != nil { 206 + t.Fatalf("writeRECUR() error = %v", err) 207 + } 208 + 209 + result, err := store.MaterializeDueRecurring() 210 + if err != nil { 211 + t.Fatalf("MaterializeDueRecurring() error = %v", err) 212 + } 213 + if result.Generated != 0 { 214 + t.Fatalf("generated = %d, want 0", result.Generated) 215 + } 216 + } 217 + 218 + func TestMaterializeDueRecurringDefaultsToLocalTimezone(t *testing.T) { 219 + oldLocal := time.Local 220 + time.Local = time.FixedZone("JST", 9*60*60) 221 + t.Cleanup(func() { time.Local = oldLocal }) 222 + 223 + root := t.TempDir() 224 + store := NewStore(filepath.Join(root, "TODO.md"), filepath.Join(root, "TODO.DONE.md")) 225 + store.Now = func() time.Time { 226 + return time.Date(2026, 5, 7, 6, 0, 0, 0, time.UTC) 227 + } 228 + if err := store.writeRECUR(RECURFile{ 229 + CreatedAt: "1970-01-01T00:00:00Z", 230 + UpdatedAt: "1970-01-01T00:00:00Z", 231 + Entries: []RecurringEntry{ 232 + { 233 + NextAt: "2026-05-07 15:00", 234 + Repeat: "weekly", 235 + Content: "去打网球。", 236 + }, 237 + }, 238 + }); err != nil { 239 + t.Fatalf("writeRECUR() error = %v", err) 240 + } 241 + 242 + result, err := store.MaterializeDueRecurring() 243 + if err != nil { 244 + t.Fatalf("MaterializeDueRecurring() error = %v", err) 245 + } 246 + if result.Generated != 1 { 247 + t.Fatalf("generated = %d, want 1", result.Generated) 248 + } 249 + 250 + wip, _, err := store.readFiles() 251 + if err != nil { 252 + t.Fatalf("readFiles() error = %v", err) 253 + } 254 + if got := wip.Entries[0].Content; got != "2026-05-07 15:00 去打网球。" { 255 + t.Fatalf("materialized content = %q", got) 256 + } 257 + }
+2 -4
internal/todo/reference_llm.go
··· 122 122 "If `input_raw` mentions a time (explicit or relative), resolve it with current time context in `runtime` (now_local/now_utc) and rewrite it as exact `YYYY-MM-DD hh:mm`.", 123 123 "Must consider first-person references with speaker context: ", 124 124 "- if the speaker mention themselves (like 'I', 'me', '$SPEAKER'), resolve to their own contact if available; similarly, resolve mentions of the speaker's direct interlocutors to those contacts if available;", 125 + "- if runtime.speaker_username is already a valid `protocol:id`, use it as the speaker reference id;", 125 126 "Attach IDs as `[Name](protocol:id)`, where id may be from reachable_ids, example input:", 126 127 "Notice $SPEAKER to tell Alice invites Bob to the meeting of Lucy.", 127 128 "and rewritten content (assume the $SPEAKER is 'Lyric'): ", ··· 205 206 UserInputRaw: strings.TrimSpace(in.UserInputRaw), 206 207 SpeakerUsername: func() string { 207 208 v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(in.SpeakerUsername), "@")) 208 - if v == "" { 209 - return "" 210 - } 211 - return strings.ToLower(v) 209 + return v 212 210 }(), 213 211 } 214 212 if len(in.MentionUsernames) > 0 {
+60 -3
internal/todo/store.go
··· 1 1 package todo 2 2 3 3 import ( 4 + "path/filepath" 4 5 "strings" 5 6 "time" 6 7 ··· 11 12 func NewStore(wipPath string, donePath string) *Store { 12 13 wipPath = pathutil.ExpandHomePath(strings.TrimSpace(wipPath)) 13 14 donePath = pathutil.ExpandHomePath(strings.TrimSpace(donePath)) 15 + recurringPath := "" 16 + if wipPath != "" { 17 + recurringPath = filepath.Join(filepath.Dir(wipPath), DefaultRECURFilename) 18 + } 14 19 return &Store{ 15 - WIPPath: wipPath, 16 - DONEPath: donePath, 17 - Now: time.Now, 20 + WIPPath: wipPath, 21 + DONEPath: donePath, 22 + RecurringPath: recurringPath, 23 + Now: time.Now, 18 24 } 19 25 } 20 26 ··· 106 112 } 107 113 done.DoneCount = len(done.Entries) 108 114 return done, nil 115 + } 116 + 117 + func (s *Store) readRECUR(now time.Time) (RECURFile, bool, error) { 118 + nowRFC3339 := now.UTC().Format(time.RFC3339) 119 + path := strings.TrimSpace(s.RecurringPath) 120 + if path == "" { 121 + return RECURFile{ 122 + CreatedAt: nowRFC3339, 123 + UpdatedAt: nowRFC3339, 124 + RecurringCount: 0, 125 + Entries: nil, 126 + }, false, nil 127 + } 128 + text, exists, err := fsstore.ReadText(path) 129 + if err != nil { 130 + return RECURFile{}, false, err 131 + } 132 + if !exists || strings.TrimSpace(text) == "" { 133 + return RECURFile{ 134 + CreatedAt: nowRFC3339, 135 + UpdatedAt: nowRFC3339, 136 + RecurringCount: 0, 137 + Entries: nil, 138 + }, exists, nil 139 + } 140 + recur, err := ParseRECUR(text) 141 + if err != nil { 142 + return RECURFile{}, exists, err 143 + } 144 + if strings.TrimSpace(recur.CreatedAt) == "" { 145 + recur.CreatedAt = nowRFC3339 146 + } 147 + if strings.TrimSpace(recur.UpdatedAt) == "" { 148 + recur.UpdatedAt = nowRFC3339 149 + } 150 + recur.RecurringCount = len(recur.Entries) 151 + return recur, exists, nil 152 + } 153 + 154 + func (s *Store) writeRECUR(file RECURFile) error { 155 + path := strings.TrimSpace(s.RecurringPath) 156 + if path == "" { 157 + return nil 158 + } 159 + now := s.nowUTC().Format(time.RFC3339) 160 + if strings.TrimSpace(file.CreatedAt) == "" { 161 + file.CreatedAt = now 162 + } 163 + file.UpdatedAt = now 164 + file.RecurringCount = len(file.Entries) 165 + return fsstore.WriteTextAtomic(path, RenderRECUR(file), fsstore.FileOptions{DirPerm: 0o700, FilePerm: 0o600}) 109 166 } 110 167 111 168 func (s *Store) nowUTC() time.Time {
+36 -9
internal/todo/types.go
··· 8 8 ) 9 9 10 10 const ( 11 - TimestampLayout = entryutil.TimestampLayout 12 - HeaderWIP = "# TODO Work In Progress (WIP)" 13 - HeaderDONE = "# TODO Done" 14 - DefaultWIPFilename = "TODO.md" 15 - DefaultDONEFilename = "TODO.DONE.md" 11 + TimestampLayout = entryutil.TimestampLayout 12 + HeaderWIP = "# TODO Work In Progress (WIP)" 13 + HeaderDONE = "# TODO Done" 14 + HeaderRECUR = "# TODO Recurring" 15 + DefaultWIPFilename = "TODO.md" 16 + DefaultDONEFilename = "TODO.DONE.md" 17 + DefaultRECURFilename = "TODO.RECUR.md" 16 18 ) 17 19 18 20 type Entry struct { ··· 23 25 Content string `json:"content"` 24 26 } 25 27 28 + type RecurringEntry struct { 29 + NextAt string `json:"next_at"` 30 + Repeat string `json:"repeat"` 31 + TZ string `json:"tz,omitempty"` 32 + ChatID string `json:"chat_id,omitempty"` 33 + Content string `json:"content"` 34 + } 35 + 26 36 type WIPFile struct { 27 37 CreatedAt string `json:"created_at"` 28 38 UpdatedAt string `json:"updated_at"` ··· 37 47 Entries []Entry `json:"entries"` 38 48 } 39 49 50 + type RECURFile struct { 51 + CreatedAt string `json:"created_at"` 52 + UpdatedAt string `json:"updated_at"` 53 + RecurringCount int `json:"recurring_count"` 54 + Entries []RecurringEntry `json:"entries"` 55 + } 56 + 40 57 type Changed struct { 41 58 WIPAdded int `json:"wip_added"` 42 59 WIPRemoved int `json:"wip_removed"` ··· 52 69 Warnings []string `json:"warnings,omitempty"` 53 70 } 54 71 72 + type RecurringUpdateResult struct { 73 + OK bool `json:"ok"` 74 + Action string `json:"action"` 75 + RecurringCount int `json:"recurring_count"` 76 + Changed Changed `json:"changed"` 77 + Entry *RecurringEntry `json:"entry,omitempty"` 78 + Warnings []string `json:"warnings,omitempty"` 79 + } 80 + 55 81 type Counts struct { 56 82 OpenCount int `json:"open_count"` 57 83 DoneCount int `json:"done_count"` ··· 74 100 } 75 101 76 102 type Store struct { 77 - WIPPath string 78 - DONEPath string 79 - Now func() time.Time 80 - Semantics SemanticResolver 103 + WIPPath string 104 + DONEPath string 105 + RecurringPath string 106 + Now func() time.Time 107 + Semantics SemanticResolver 81 108 }
+125
tools/builtin/todo_tools_test.go
··· 157 157 } 158 158 } 159 159 160 + func TestTodoUpdateToolAddRecurring(t *testing.T) { 161 + root := t.TempDir() 162 + wip := filepath.Join(root, "TODO.md") 163 + done := filepath.Join(root, "TODO.DONE.md") 164 + contactsDir := filepath.Join(root, "contacts") 165 + client := &stubTodoToolLLMClient{} 166 + update := NewTodoUpdateToolWithLLM(true, wip, done, contactsDir, client, "gpt-5.2") 167 + out, err := update.Execute(context.Background(), map[string]any{ 168 + "action": "add_recurring", 169 + "content": "去打网球。", 170 + "next": "2026-05-07 15:00", 171 + "repeat": "weekly", 172 + "tz": "Asia/Tokyo", 173 + }) 174 + if err != nil { 175 + t.Fatalf("todo_update add_recurring error = %v", err) 176 + } 177 + var parsed struct { 178 + OK bool `json:"ok"` 179 + RecurringCount int `json:"recurring_count"` 180 + Entry struct { 181 + NextAt string `json:"next_at"` 182 + Repeat string `json:"repeat"` 183 + TZ string `json:"tz"` 184 + Content string `json:"content"` 185 + } `json:"entry"` 186 + } 187 + if err := json.Unmarshal([]byte(out), &parsed); err != nil { 188 + t.Fatalf("todo_update add_recurring json parse error = %v", err) 189 + } 190 + if !parsed.OK || parsed.RecurringCount != 1 { 191 + t.Fatalf("unexpected add_recurring result: %s", out) 192 + } 193 + if parsed.Entry.NextAt != "2026-05-07 15:00" || parsed.Entry.Repeat != "weekly" || parsed.Entry.TZ != "Asia/Tokyo" || parsed.Entry.Content != "去打网球。" { 194 + t.Fatalf("entry mismatch: %#v", parsed.Entry) 195 + } 196 + if len(client.calls) != 0 { 197 + t.Fatalf("expected no llm calls for recurring task without people, got %d", len(client.calls)) 198 + } 199 + 200 + if _, err := os.Stat(wip); !os.IsNotExist(err) { 201 + t.Fatalf("TODO.md should not be created by add_recurring, stat err=%v", err) 202 + } 203 + recurRaw, err := os.ReadFile(filepath.Join(root, "TODO.RECUR.md")) 204 + if err != nil { 205 + t.Fatalf("ReadFile(TODO.RECUR.md) error = %v", err) 206 + } 207 + recurFile, err := todo.ParseRECUR(string(recurRaw)) 208 + if err != nil { 209 + t.Fatalf("ParseRECUR() error = %v", err) 210 + } 211 + if len(recurFile.Entries) != 1 { 212 + t.Fatalf("recurring entries = %d, want 1", len(recurFile.Entries)) 213 + } 214 + } 215 + 216 + func TestTodoUpdateToolAddRecurringResolvesConsoleSpeakerPlaceholder(t *testing.T) { 217 + root := t.TempDir() 218 + wip := filepath.Join(root, "TODO.md") 219 + done := filepath.Join(root, "TODO.DONE.md") 220 + contactsDir := filepath.Join(root, "contacts") 221 + seedTodoContacts(t, contactsDir) 222 + 223 + client := &stubTodoToolLLMClient{ 224 + replies: []string{ 225 + `{"status":"ok","rewritten_content":"提醒$SPEAKER去打网球。"}`, 226 + }, 227 + } 228 + update := NewTodoUpdateToolWithLLM(true, wip, done, contactsDir, client, "gpt-5.2") 229 + update.SetAddContext(todo.AddResolveContext{ 230 + Channel: "console", 231 + ChatType: "topic", 232 + SpeakerUsername: "console:user", 233 + UserInputRaw: "每周四东京时间下午 3 点,提醒我去打网球。", 234 + }) 235 + out, err := update.Execute(context.Background(), map[string]any{ 236 + "action": "add_recurring", 237 + "content": "提醒我去打网球。", 238 + "people": []any{"$SPEAKER"}, 239 + "next": "2026-05-07 15:00", 240 + "repeat": "weekly", 241 + "tz": "Asia/Tokyo", 242 + }) 243 + if err != nil { 244 + t.Fatalf("todo_update add_recurring error = %v", err) 245 + } 246 + var parsed struct { 247 + OK bool `json:"ok"` 248 + Entry struct { 249 + Content string `json:"content"` 250 + } `json:"entry"` 251 + } 252 + if err := json.Unmarshal([]byte(out), &parsed); err != nil { 253 + t.Fatalf("todo_update add_recurring json parse error = %v", err) 254 + } 255 + if !parsed.OK || parsed.Entry.Content != "提醒[我](console:user)去打网球。" { 256 + t.Fatalf("unexpected add_recurring result: %s", out) 257 + } 258 + recurRaw, err := os.ReadFile(filepath.Join(root, "TODO.RECUR.md")) 259 + if err != nil { 260 + t.Fatalf("ReadFile(TODO.RECUR.md) error = %v", err) 261 + } 262 + recurText := string(recurRaw) 263 + if strings.Contains(recurText, "$SPEAKER") { 264 + t.Fatalf("TODO.RECUR.md should not contain raw $SPEAKER: %s", recurText) 265 + } 266 + if !strings.Contains(recurText, "[我](console:user)") { 267 + t.Fatalf("TODO.RECUR.md missing console speaker ref: %s", recurText) 268 + } 269 + } 270 + 271 + func TestTodoUpdateToolAddRecurringRejectsUnresolvedPlaceholder(t *testing.T) { 272 + root := t.TempDir() 273 + update := NewTodoUpdateToolWithLLM(true, filepath.Join(root, "TODO.md"), filepath.Join(root, "TODO.DONE.md"), filepath.Join(root, "contacts"), &stubTodoToolLLMClient{}, "gpt-5.2") 274 + _, err := update.Execute(context.Background(), map[string]any{ 275 + "action": "add_recurring", 276 + "content": "提醒$SPEAKER去打网球。", 277 + "next": "2026-05-07 15:00", 278 + "repeat": "weekly", 279 + }) 280 + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unresolved reference placeholder") { 281 + t.Fatalf("expected unresolved placeholder error, got %v", err) 282 + } 283 + } 284 + 160 285 func TestTodoUpdateToolAddRejectsInvalidChatID(t *testing.T) { 161 286 root := t.TempDir() 162 287 wip := filepath.Join(root, "TODO.md")
+222 -60
tools/builtin/todo_update.go
··· 7 7 "fmt" 8 8 "log/slog" 9 9 "strings" 10 + "unicode" 10 11 12 + refid "github.com/quailyquaily/mistermorph/internal/entryutil/refid" 11 13 "github.com/quailyquaily/mistermorph/internal/pathutil" 12 14 "github.com/quailyquaily/mistermorph/internal/todo" 13 15 "github.com/quailyquaily/mistermorph/llm" ··· 70 72 func (t *TodoUpdateTool) Name() string { return "todo_update" } 71 73 72 74 func (t *TodoUpdateTool) Description() string { 73 - return "Updates TODO files under file_state_dir. Supports add and complete actions, keeps counts in TODO.md/TODO.DONE.md consistent." 75 + return "Updates TODO files under file_state_dir. Supports add, complete, and add_recurring actions, keeps counts in TODO.md/TODO.DONE.md/TODO.RECUR.md consistent." 74 76 } 75 77 76 78 func (t *TodoUpdateTool) ParameterSchema() string { ··· 79 81 "properties": map[string]any{ 80 82 "action": map[string]any{ 81 83 "type": "string", 82 - "description": "Action: add|complete.", 84 + "description": "Action: add|complete|add_recurring.", 83 85 }, 84 86 "content": map[string]any{ 85 87 "type": "string", 86 - "description": "Todo content. Required for add and complete.", 88 + "description": "Todo content. Required for add, complete, and add_recurring.", 89 + }, 90 + "next": map[string]any{ 91 + "type": "string", 92 + "description": "Next scheduled time for add_recurring, in YYYY-MM-DD HH:mm.", 93 + }, 94 + "repeat": map[string]any{ 95 + "type": "string", 96 + "description": "Repeat rule for add_recurring: daily|weekly|every N days.", 97 + }, 98 + "tz": map[string]any{ 99 + "type": "string", 100 + "description": "Optional IANA timezone for add_recurring, for example Asia/Tokyo. Omit to use runtime local timezone.", 87 101 }, 88 102 "people": map[string]any{ 89 103 "type": "array", ··· 139 153 store := todo.NewStore(wipPath, donePath) 140 154 store.Semantics = todo.NewLLMSemanticResolver(t.Client, t.Model) 141 155 var ( 142 - result todo.UpdateResult 156 + result any 143 157 err error 144 158 ) 145 159 switch action { ··· 164 178 "context_mentions_count", len(t.AddContext.MentionUsernames), 165 179 "context_user_input_raw_len", len(t.AddContext.UserInputRaw), 166 180 ) 167 - if _, preErr := todo.ExtractReferenceIDs(content); preErr != nil { 168 - return "", preErr 181 + rewritten, warnings, resolveErr := t.resolveAddContent(ctx, content, people, contactsDir, true) 182 + if resolveErr != nil { 183 + return "", resolveErr 184 + } 185 + result, err = store.AddWithChatID(ctx, rewritten, chatID) 186 + if err == nil && len(warnings) > 0 { 187 + addResult := result.(todo.UpdateResult) 188 + addResult.Warnings = append(addResult.Warnings, warnings...) 189 + result = addResult 190 + } 191 + case "add_recurring": 192 + chatID, chatIDErr := parseTodoUpdateChatID(params) 193 + if chatIDErr != nil { 194 + return "", chatIDErr 195 + } 196 + nextAt, nextErr := parseTodoUpdateString(params, "next", "next_at") 197 + if nextErr != nil { 198 + return "", nextErr 169 199 } 170 - snapshot, snapErr := todo.LoadContactSnapshot(ctx, contactsDir) 171 - if snapErr != nil { 172 - return "", snapErr 200 + repeat, repeatErr := parseTodoUpdateString(params, "repeat") 201 + if repeatErr != nil { 202 + return "", repeatErr 203 + } 204 + tz, tzErr := parseTodoUpdateOptionalString(params, "tz") 205 + if tzErr != nil { 206 + return "", tzErr 207 + } 208 + people, peopleErr := parseTodoUpdatePeopleOptional(params) 209 + if peopleErr != nil { 210 + return "", peopleErr 173 211 } 174 - resolver := todo.NewLLMReferenceResolver(t.Client, t.Model) 175 - rewritten, warnings, resolveErr := resolver.ResolveAddContent(ctx, content, people, snapshot, t.AddContext) 176 - fallbackRawWrite := false 212 + rewritten, warnings, resolveErr := t.resolveAddContent(ctx, content, people, contactsDir, false) 177 213 if resolveErr != nil { 178 - var missingErr *todo.MissingReferenceIDError 179 - if errors.As(resolveErr, &missingErr) { 180 - fallbackRawWrite = true 181 - rewritten = content 182 - warnings = appendIfMissingWarning(warnings, "reference_unresolved_write_raw") 183 - slog.Default().Debug("todo_update_add_reference_unresolved_fallback", 184 - "missing_count", len(missingErr.Items), 185 - ) 186 - } else { 187 - return "", resolveErr 188 - } 214 + return "", resolveErr 189 215 } 190 - slog.Default().Debug("todo_update_add_resolved", 191 - "rewritten", rewritten, 192 - "warnings_count", len(warnings), 193 - "fallback_raw_write", fallbackRawWrite, 194 - ) 195 - if !fallbackRawWrite { 196 - if requiredErr := todo.ValidateRequiredReferenceMentions(rewritten, snapshot); requiredErr != nil { 197 - var missingErr *todo.MissingReferenceIDError 198 - if errors.As(requiredErr, &missingErr) { 199 - firstMention := "" 200 - firstSuggestion := "" 201 - firstReason := "" 202 - if len(missingErr.Items) > 0 { 203 - firstMention = strings.TrimSpace(missingErr.Items[0].Mention) 204 - firstSuggestion = strings.TrimSpace(missingErr.Items[0].Suggestion) 205 - firstReason = strings.TrimSpace(missingErr.Items[0].Reason) 206 - } 207 - slog.Default().Debug("todo_update_add_required_reference_fallback_detail", 208 - "rewritten_before_fallback", rewritten, 209 - "fallback_target_content", content, 210 - "first_missing_mention", firstMention, 211 - "first_missing_suggestion", firstSuggestion, 212 - "first_missing_reason", firstReason, 213 - ) 214 - fallbackRawWrite = true 215 - rewritten = content 216 - warnings = appendIfMissingWarning(warnings, "reference_unresolved_write_raw") 217 - slog.Default().Debug("todo_update_add_required_reference_fallback", 218 - "missing_count", len(missingErr.Items), 219 - ) 220 - } else { 221 - return "", requiredErr 222 - } 223 - } 224 - } 225 - result, err = store.AddWithChatID(ctx, rewritten, chatID) 216 + result, err = store.AddRecurringWithChatID(rewritten, nextAt, repeat, tz, chatID) 226 217 if err == nil && len(warnings) > 0 { 227 - result.Warnings = append(result.Warnings, warnings...) 218 + recurringResult := result.(todo.RecurringUpdateResult) 219 + recurringResult.Warnings = append(recurringResult.Warnings, warnings...) 220 + result = recurringResult 228 221 } 229 222 case "complete": 230 223 result, err = store.Complete(ctx, content) ··· 238 231 return string(out), nil 239 232 } 240 233 234 + func (t *TodoUpdateTool) resolveAddContent(ctx context.Context, content string, people []string, contactsDir string, enforceRequiredReferences bool) (string, []string, error) { 235 + if _, preErr := todo.ExtractReferenceIDs(content); preErr != nil { 236 + return "", nil, preErr 237 + } 238 + if len(people) == 0 { 239 + return t.resolveAddPlaceholders(content, content, nil) 240 + } 241 + snapshot, snapErr := todo.LoadContactSnapshot(ctx, contactsDir) 242 + if snapErr != nil { 243 + return "", nil, snapErr 244 + } 245 + resolver := todo.NewLLMReferenceResolver(t.Client, t.Model) 246 + rewritten, warnings, resolveErr := resolver.ResolveAddContent(ctx, content, people, snapshot, t.AddContext) 247 + fallbackRawWrite := false 248 + if resolveErr != nil { 249 + var missingErr *todo.MissingReferenceIDError 250 + if errors.As(resolveErr, &missingErr) { 251 + fallbackRawWrite = true 252 + rewritten = content 253 + warnings = appendIfMissingWarning(warnings, "reference_unresolved_write_raw") 254 + slog.Default().Debug("todo_update_add_reference_unresolved_fallback", 255 + "missing_count", len(missingErr.Items), 256 + ) 257 + } else { 258 + return "", nil, resolveErr 259 + } 260 + } 261 + slog.Default().Debug("todo_update_add_resolved", 262 + "rewritten", rewritten, 263 + "warnings_count", len(warnings), 264 + "fallback_raw_write", fallbackRawWrite, 265 + ) 266 + if enforceRequiredReferences && !fallbackRawWrite { 267 + if requiredErr := todo.ValidateRequiredReferenceMentions(rewritten, snapshot); requiredErr != nil { 268 + var missingErr *todo.MissingReferenceIDError 269 + if errors.As(requiredErr, &missingErr) { 270 + firstMention := "" 271 + firstSuggestion := "" 272 + firstReason := "" 273 + if len(missingErr.Items) > 0 { 274 + firstMention = strings.TrimSpace(missingErr.Items[0].Mention) 275 + firstSuggestion = strings.TrimSpace(missingErr.Items[0].Suggestion) 276 + firstReason = strings.TrimSpace(missingErr.Items[0].Reason) 277 + } 278 + slog.Default().Debug("todo_update_add_required_reference_fallback_detail", 279 + "rewritten_before_fallback", rewritten, 280 + "fallback_target_content", content, 281 + "first_missing_mention", firstMention, 282 + "first_missing_suggestion", firstSuggestion, 283 + "first_missing_reason", firstReason, 284 + ) 285 + rewritten = content 286 + warnings = appendIfMissingWarning(warnings, "reference_unresolved_write_raw") 287 + slog.Default().Debug("todo_update_add_required_reference_fallback", 288 + "missing_count", len(missingErr.Items), 289 + ) 290 + } else { 291 + return "", nil, requiredErr 292 + } 293 + } 294 + } 295 + rewritten, warnings, placeholderErr := t.resolveAddPlaceholders(content, rewritten, warnings) 296 + if placeholderErr != nil { 297 + return "", nil, placeholderErr 298 + } 299 + return rewritten, warnings, nil 300 + } 301 + 302 + func (t *TodoUpdateTool) resolveAddPlaceholders(original string, rewritten string, warnings []string) (string, []string, error) { 303 + rewritten = strings.TrimSpace(rewritten) 304 + if rewritten == "" { 305 + return "", nil, fmt.Errorf("content is required") 306 + } 307 + if strings.Contains(rewritten, "$SPEAKER") { 308 + speakerRef := todoUpdateSpeakerReferenceID(t.AddContext) 309 + if speakerRef != "" { 310 + label := todoUpdateSpeakerReferenceLabel(rewritten) 311 + formatted, err := refid.FormatMarkdownReference(label, speakerRef) 312 + if err != nil { 313 + return "", nil, err 314 + } 315 + rewritten = strings.ReplaceAll(rewritten, "$SPEAKER", formatted) 316 + } 317 + } 318 + if containsTodoUpdateReferencePlaceholder(rewritten) { 319 + original = strings.TrimSpace(original) 320 + if containsTodoUpdateReferencePlaceholder(original) { 321 + return "", nil, fmt.Errorf("unresolved reference placeholder in content") 322 + } 323 + warnings = appendIfMissingWarning(warnings, "reference_placeholder_unresolved_write_raw") 324 + return original, warnings, nil 325 + } 326 + return rewritten, warnings, nil 327 + } 328 + 329 + func todoUpdateSpeakerReferenceID(ctx todo.AddResolveContext) string { 330 + if ref, ok := refid.Normalize(ctx.SpeakerUsername); ok { 331 + return ref 332 + } 333 + switch strings.ToLower(strings.TrimSpace(ctx.Channel)) { 334 + case "console": 335 + return "console:user" 336 + case "telegram": 337 + if ctx.SpeakerUserID != 0 { 338 + return fmt.Sprintf("tg:%d", ctx.SpeakerUserID) 339 + } 340 + username := strings.TrimPrefix(strings.TrimSpace(ctx.SpeakerUsername), "@") 341 + if username != "" { 342 + return "tg:@" + username 343 + } 344 + } 345 + return "" 346 + } 347 + 348 + func todoUpdateSpeakerReferenceLabel(content string) string { 349 + for _, r := range content { 350 + if unicode.In(r, unicode.Han) { 351 + return "我" 352 + } 353 + } 354 + return "me" 355 + } 356 + 357 + func containsTodoUpdateReferencePlaceholder(content string) bool { 358 + return strings.Contains(content, "$SPEAKER") || strings.Contains(content, "$AGENT") 359 + } 360 + 241 361 func normalizeTodoUpdateUsernames(input []string) []string { 242 362 if len(input) == 0 { 243 363 return nil ··· 264 384 if !exists { 265 385 return nil, fmt.Errorf("people is required for add action") 266 386 } 387 + return parseTodoUpdatePeopleValue(raw) 388 + } 389 + 390 + func parseTodoUpdatePeopleOptional(params map[string]any) ([]string, error) { 391 + raw, exists := params["people"] 392 + if !exists || raw == nil { 393 + return nil, nil 394 + } 395 + return parseTodoUpdatePeopleValue(raw) 396 + } 397 + 398 + func parseTodoUpdatePeopleValue(raw any) ([]string, error) { 267 399 switch v := raw.(type) { 268 400 case []string: 269 401 return normalizeTodoUpdateUsernames(v), nil ··· 280 412 default: 281 413 return nil, fmt.Errorf("people must be an array of strings") 282 414 } 415 + } 416 + 417 + func parseTodoUpdateString(params map[string]any, names ...string) (string, error) { 418 + value, err := parseTodoUpdateOptionalString(params, names...) 419 + if err != nil { 420 + return "", err 421 + } 422 + if value == "" { 423 + return "", fmt.Errorf("%s is required", strings.TrimSpace(names[0])) 424 + } 425 + return value, nil 426 + } 427 + 428 + func parseTodoUpdateOptionalString(params map[string]any, names ...string) (string, error) { 429 + for _, name := range names { 430 + name = strings.TrimSpace(name) 431 + if name == "" { 432 + continue 433 + } 434 + raw, exists := params[name] 435 + if !exists || raw == nil { 436 + continue 437 + } 438 + value, ok := raw.(string) 439 + if !ok { 440 + return "", fmt.Errorf("%s must be a string", name) 441 + } 442 + return strings.TrimSpace(value), nil 443 + } 444 + return "", nil 283 445 } 284 446 285 447 func parseTodoUpdateChatID(params map[string]any) (string, error) {
+5
web/vitepress/docs/guide/built-in-tools.md
··· 113 113 Maintains `TODO.md` / `TODO.DONE.md` under `file_state_dir`, including add and complete operations. 114 114 115 115 - Key limits: `add` requires `people`; `complete` uses semantic matching and will error on no-match or ambiguous match. 116 + - Recurring todos are added with `action=add_recurring` and stored in `TODO.RECUR.md` under `file_state_dir`: 117 + ```text 118 + - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 119 + ``` 120 + Supported repeat values are `daily`, `weekly`, and `every N days`. `TZ` is optional; when omitted, the runtime local timezone is used. Heartbeat expands due recurring records into ordinary `TODO.md` items, advances `Next`, then includes the current open `TODO.md` items in the heartbeat task. 116 121 117 122 ## Dedicated Tools 118 123
+5
web/vitepress/docs/ja/guide/built-in-tools.md
··· 113 113 `file_state_dir` 配下の `TODO.md` / `TODO.DONE.md` を更新し、TODO の追加と完了を扱います。 114 114 115 115 - 主な制約: `add` では `people` が必要です。`complete` は意味的マッチングを使うため、候補がない場合や曖昧な場合はエラーになります。 116 + - 繰り返し TODO は `action=add_recurring` で追加し、`file_state_dir` 配下の `TODO.RECUR.md` に保存されます。 117 + ```text 118 + - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 119 + ``` 120 + `Repeat` は `daily`、`weekly`、`every N days` に対応しています。`TZ` は任意で、省略時は runtime のローカルタイムゾーンを使います。heartbeat は期限が来た繰り返しレコードを通常の `TODO.md` 項目へ展開し、`Next` を進めたうえで、現在の open な `TODO.md` 項目を heartbeat task に含めます。 116 121 117 122 ## 専用ツール 118 123
+8
web/vitepress/docs/zh/guide/built-in-tools.md
··· 116 116 117 117 关键限制:`add` 需要 `people`;`complete` 依赖语义匹配,找不到或匹配过多都会报错。 118 118 119 + 循环待办通过 `action=add_recurring` 新增,写入 `file_state_dir` 下的 `TODO.RECUR.md`: 120 + 121 + ```text 122 + - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 123 + ``` 124 + 125 + 当前支持的 `Repeat` 值是 `daily`、`weekly`、`every N days`。`TZ` 可选;省略时使用运行进程的本地时区。heartbeat 会把到期的循环记录展开成普通 `TODO.md` 待办,推进 `Next`,然后把当前 `TODO.md` 的 open items 一起放进 heartbeat task。 126 + 119 127 ## 专属工具 120 128 121 129 这些工具不会在普通 CLI / 通用 embedding 场景中出现,只有对应通道 runtime 具备上下文时才会注入。