Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

feat: support hourly recurring todos

Lyric 1314722f cb55900f

+134 -24
+1
assets/config/TODO.RECUR.md
··· 12 12 - [ ] [Next](2026-02-12 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-1001981343441) | Remind [John](tg:@johnwick) to submit the report. 13 13 - [ ] [Next](2026-02-16 10:00), [Repeat](weekly) | Review open invoices. 14 14 - [ ] [Next](2026-02-14 18:00), [Repeat](every 3 days) | Back up notes. 15 + - [ ] [Next](2026-02-14 18:00), [Repeat](every 6 hours) | Check the feeder. 15 16 16 17 ==Example End== 17 18 -->
+2 -1
internal/promptprofile/prompts/block_todo_workflow.md
··· 20 20 - [ ] [Next](2026-02-12 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-1001981343441) | Remind [John](tg:@johnwick) to submit the report. 21 21 - [ ] [Next](2026-02-16 10:00), [Repeat](weekly) | Review open invoices. 22 22 - [ ] [Next](2026-02-14 18:00), [Repeat](every 3 days) | Back up notes. 23 + - [ ] [Next](2026-02-14 18:00), [Repeat](every 6 hours) | Check the feeder. 23 24 ``` 24 25 25 26 - 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 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`, `every N days`, and `every N hours`. 27 28 - 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. 28 29 - 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. 29 30 - If a task is not due, do nothing.
+48 -19
internal/todo/recurring.go
··· 8 8 "time" 9 9 ) 10 10 11 - var everyNDaysPattern = regexp.MustCompile(`^every\s+([1-9][0-9]*)\s+days?$`) 11 + var ( 12 + everyNDaysPattern = regexp.MustCompile(`^every\s+([1-9][0-9]*)\s+days?$`) 13 + everyNHoursPattern = regexp.MustCompile(`^every\s+([1-9][0-9]*)\s+hours?$`) 14 + ) 15 + 16 + type recurringInterval struct { 17 + Days int 18 + Hours int 19 + } 12 20 13 21 type RecurringMaterializeResult struct { 14 22 Generated int `json:"generated"` ··· 189 197 190 198 func nextRecurringTimeAfterInLocation(from time.Time, repeat string, after time.Time, loc *time.Location) (time.Time, error) { 191 199 repeat = normalizeRecurringRepeat(repeat) 192 - days, err := recurringRepeatDays(repeat) 200 + interval, err := recurringRepeatInterval(repeat) 193 201 if err != nil { 194 202 return time.Time{}, err 195 203 } ··· 202 210 return next, nil 203 211 } 204 212 205 - elapsedDays := int(after.Sub(next).Hours() / 24) 206 - steps := elapsedDays/days + 1 207 - next = next.AddDate(0, 0, steps*days) 213 + if interval.Days > 0 { 214 + elapsedDays := int(after.Sub(next).Hours() / 24) 215 + steps := elapsedDays/interval.Days + 1 216 + next = next.AddDate(0, 0, steps*interval.Days) 217 + for !next.After(after) { 218 + next = next.AddDate(0, 0, interval.Days) 219 + } 220 + return next, nil 221 + } 222 + 223 + step := time.Duration(interval.Hours) * time.Hour 224 + elapsedHours := int(after.Sub(next).Hours()) 225 + steps := elapsedHours/interval.Hours + 1 226 + next = next.Add(time.Duration(steps) * step) 208 227 for !next.After(after) { 209 - next = next.AddDate(0, 0, days) 228 + next = next.Add(step) 210 229 } 211 230 return next, nil 212 231 } 213 232 214 - func recurringRepeatDays(repeat string) (int, error) { 233 + func recurringRepeatInterval(repeat string) (recurringInterval, error) { 215 234 repeat = normalizeRecurringRepeat(repeat) 216 235 switch { 217 236 case repeat == "daily": 218 - return 1, nil 237 + return recurringInterval{Days: 1}, nil 219 238 case repeat == "weekly": 220 - return 7, nil 239 + return recurringInterval{Days: 7}, nil 221 240 default: 222 - parsed, ok := parseEveryNDays(repeat) 223 - if !ok { 224 - return 0, fmt.Errorf("invalid Repeat: %s", strings.TrimSpace(repeat)) 241 + if days, ok := parseEveryNDays(repeat); ok { 242 + return recurringInterval{Days: days}, nil 243 + } 244 + if hours, ok := parseEveryNHours(repeat); ok { 245 + return recurringInterval{Hours: hours}, nil 225 246 } 226 - return parsed, nil 247 + return recurringInterval{}, fmt.Errorf("invalid Repeat: %s", strings.TrimSpace(repeat)) 227 248 } 228 249 } 229 250 230 251 func validRecurringRepeat(raw string) bool { 231 - raw = normalizeRecurringRepeat(raw) 232 - if raw == "daily" || raw == "weekly" { 233 - return true 234 - } 235 - _, ok := parseEveryNDays(raw) 236 - return ok 252 + _, err := recurringRepeatInterval(raw) 253 + return err == nil 237 254 } 238 255 239 256 func normalizeRecurringRepeat(raw string) string { ··· 250 267 return 0, false 251 268 } 252 269 return days, true 270 + } 271 + 272 + func parseEveryNHours(raw string) (int, bool) { 273 + matches := everyNHoursPattern.FindStringSubmatch(normalizeRecurringRepeat(raw)) 274 + if len(matches) != 2 { 275 + return 0, false 276 + } 277 + hours, err := strconv.Atoi(matches[1]) 278 + if err != nil || hours <= 0 { 279 + return 0, false 280 + } 281 + return hours, true 253 282 } 254 283 255 284 func recurringLocation(tz string) (*time.Location, error) {
+79
internal/todo/recurring_test.go
··· 36 36 } 37 37 } 38 38 39 + func TestParseAndRenderRECURWithHourlyRepeat(t *testing.T) { 40 + raw := `--- 41 + created_at: "1970-01-01T00:00:00Z" 42 + updated_at: "1970-01-01T00:00:00Z" 43 + recurring_count: 1 44 + --- 45 + 46 + # TODO Recurring 47 + 48 + - [ ] [Next](2026-05-02 09:00), [Repeat](every 6 hours), [TZ](Asia/Tokyo) | Check the feeder. 49 + ` 50 + file, err := ParseRECUR(raw) 51 + if err != nil { 52 + t.Fatalf("ParseRECUR() error = %v", err) 53 + } 54 + if len(file.Entries) != 1 { 55 + t.Fatalf("entries = %d, want 1", len(file.Entries)) 56 + } 57 + entry := file.Entries[0] 58 + if entry.Repeat != "every 6 hours" { 59 + t.Fatalf("repeat = %q, want every 6 hours", entry.Repeat) 60 + } 61 + rendered := RenderRECUR(file) 62 + if !strings.Contains(rendered, "[Repeat](every 6 hours)") { 63 + t.Fatalf("rendered recurring entry missing hourly repeat:\n%s", rendered) 64 + } 65 + } 66 + 39 67 func TestParseRECURIgnoresHTMLCommentExamples(t *testing.T) { 40 68 raw := `--- 41 69 created_at: "1970-01-01T00:00:00Z" ··· 133 161 } 134 162 if got := next.Format(TimestampLayout); got != "2026-05-10 09:00" { 135 163 t.Fatalf("next = %q, want 2026-05-10 09:00", got) 164 + } 165 + } 166 + 167 + func TestMaterializeDueRecurringAdvancesHourlyPastNow(t *testing.T) { 168 + next, err := nextRecurringTimeAfter( 169 + time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC), 170 + "every 6 hours", 171 + time.Date(2026, 5, 1, 20, 0, 0, 0, time.UTC), 172 + ) 173 + if err != nil { 174 + t.Fatalf("nextRecurringTimeAfter() error = %v", err) 175 + } 176 + if got := next.Format(TimestampLayout); got != "2026-05-01 21:00" { 177 + t.Fatalf("next = %q, want 2026-05-01 21:00", got) 178 + } 179 + } 180 + 181 + func TestMaterializeDueRecurringWithHourlyTimezone(t *testing.T) { 182 + root := t.TempDir() 183 + store := NewStore(filepath.Join(root, "TODO.md"), filepath.Join(root, "TODO.DONE.md")) 184 + store.Now = func() time.Time { 185 + return time.Date(2026, 5, 7, 8, 30, 0, 0, time.UTC) 186 + } 187 + if err := store.writeRECUR(RECURFile{ 188 + CreatedAt: "1970-01-01T00:00:00Z", 189 + UpdatedAt: "1970-01-01T00:00:00Z", 190 + Entries: []RecurringEntry{ 191 + { 192 + NextAt: "2026-05-07 15:00", 193 + Repeat: "every 2 hours", 194 + TZ: "Asia/Tokyo", 195 + Content: "检查状态。", 196 + }, 197 + }, 198 + }); err != nil { 199 + t.Fatalf("writeRECUR() error = %v", err) 200 + } 201 + 202 + result, err := store.MaterializeDueRecurring() 203 + if err != nil { 204 + t.Fatalf("MaterializeDueRecurring() error = %v", err) 205 + } 206 + if result.Generated != 1 { 207 + t.Fatalf("generated = %d, want 1", result.Generated) 208 + } 209 + updated, _, err := store.readRECUR(store.nowUTC()) 210 + if err != nil { 211 + t.Fatalf("readRECUR() error = %v", err) 212 + } 213 + if got := updated.Entries[0].NextAt; got != "2026-05-07 19:00" { 214 + t.Fatalf("advanced next = %q, want 2026-05-07 19:00", got) 136 215 } 137 216 } 138 217
+1 -1
tools/builtin/todo_update.go
··· 93 93 }, 94 94 "repeat": map[string]any{ 95 95 "type": "string", 96 - "description": "Repeat rule for add_recurring: daily|weekly|every N days.", 96 + "description": "Repeat rule for add_recurring: daily|weekly|every N days|every N hours.", 97 97 }, 98 98 "tz": map[string]any{ 99 99 "type": "string",
+1 -1
web/vitepress/docs/guide/built-in-tools.md
··· 117 117 ```text 118 118 - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 119 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. 120 + Supported repeat values are `daily`, `weekly`, `every N days`, and `every N hours`. `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. 121 121 122 122 ## Dedicated Tools 123 123
+1 -1
web/vitepress/docs/ja/guide/built-in-tools.md
··· 117 117 ```text 118 118 - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 119 119 ``` 120 - `Repeat` は `daily`、`weekly`、`every N days` に対応しています。`TZ` は任意で、省略時は runtime のローカルタイムゾーンを使います。heartbeat は期限が来た繰り返しレコードを通常の `TODO.md` 項目へ展開し、`Next` を進めたうえで、現在の open な `TODO.md` 項目を heartbeat task に含めます。 120 + `Repeat` は `daily`、`weekly`、`every N days`、`every N hours` に対応しています。`TZ` は任意で、省略時は runtime のローカルタイムゾーンを使います。heartbeat は期限が来た繰り返しレコードを通常の `TODO.md` 項目へ展開し、`Next` を進めたうえで、現在の open な `TODO.md` 項目を heartbeat task に含めます。 121 121 122 122 ## 専用ツール 123 123
+1 -1
web/vitepress/docs/zh/guide/built-in-tools.md
··· 122 122 - [ ] [Next](2026-05-02 09:00), [Repeat](daily), [TZ](Asia/Tokyo), [ChatID](tg:-100123) | Remind [John](tg:@john) to submit report. 123 123 ``` 124 124 125 - 当前支持的 `Repeat` 值是 `daily`、`weekly`、`every N days`。`TZ` 可选;省略时使用运行进程的本地时区。heartbeat 会把到期的循环记录展开成普通 `TODO.md` 待办,推进 `Next`,然后把当前 `TODO.md` 的 open items 一起放进 heartbeat task。 125 + 当前支持的 `Repeat` 值是 `daily`、`weekly`、`every N days`、`every N hours`。`TZ` 可选;省略时使用运行进程的本地时区。heartbeat 会把到期的循环记录展开成普通 `TODO.md` 待办,推进 `Next`,然后把当前 `TODO.md` 的 open items 一起放进 heartbeat task。 126 126 127 127 ## 专属工具 128 128