filename:
assistant/tools/calendar.go
branch:
main
back to repo
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"assistant/memory"
)
var calendarListParams = json.RawMessage(`{
"type": "object",
"properties": {
"from": { "type": "string", "description": "Range start (ISO8601 date YYYY-MM-DD or datetime)" },
"to": { "type": "string", "description": "Range end exclusive (ISO8601)" }
},
"required": ["from", "to"],
"additionalProperties": false
}`)
var calendarProposeParams = json.RawMessage(`{
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["create", "update", "delete", "complete"] },
"id": { "type": "integer", "description": "Item id for update, delete, or complete" },
"title": { "type": "string" },
"notes": { "type": "string" },
"status": { "type": "string", "enum": ["open", "done", "cancelled"] },
"due_at": { "type": "string" },
"timezone": { "type": "string", "description": "Ignored for date-only calendar; accepted for backward compatibility." }
},
"required": ["action"],
"additionalProperties": false
}`)
type calendarListArgs struct {
From string `json:"from"`
To string `json:"to"`
}
type calendarProposeArgs struct {
Action string `json:"action"`
ID int64 `json:"id"`
Title string `json:"title"`
Notes string `json:"notes"`
Status string `json:"status"`
DueAt string `json:"due_at"`
TZ string `json:"timezone"`
}
func calendarTools(store *memory.Store) []Tool {
return []Tool{
{
Name: "calendar_list_range",
Description: "List calendar items and date-todos overlapping a time range (read-only). Use from/to as ISO8601 dates or datetimes. " +
"Some items may be undated (missing due_at); treat those as undated and do not infer dates.",
Parameters: calendarListParams,
Run: func(ctx context.Context, args json.RawMessage) (string, error) {
var a calendarListArgs
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
items, err := store.ListCalendarRange(strings.TrimSpace(a.From), strings.TrimSpace(a.To))
if err != nil {
return "", err
}
if len(items) == 0 {
return "No calendar items in that range.", nil
}
b, err := json.MarshalIndent(items, "", " ")
if err != nil {
return "", err
}
return string(b), nil
},
},
{
Name: "calendar_propose_change",
Description: "Propose a calendar change (create, update, delete, or mark complete). " +
"Does NOT apply immediately - the user must confirm in the web UI. " +
"For create, provide title and due_at (YYYY-MM-DD) when date is known (especially for 'today'). " +
"If date is unknown, omit due_at to keep the item undated. " +
"For update/delete/complete, provide id.",
Parameters: calendarProposeParams,
Run: func(ctx context.Context, args json.RawMessage) (string, error) {
var a calendarProposeArgs
if err := json.Unmarshal(args, &a); err != nil {
return "", err
}
pl, err := buildProposePayload(store, &a)
if err != nil {
return "", err
}
pid, err := store.AddCalendarPending(pl, 48*time.Hour)
if err != nil {
return "", err
}
summary := summarizeProposal(&pl)
return fmt.Sprintf(
"Proposal #%d queued for your approval (expires in 48h).\n%s\nOpen the assistant web UI → calendar panel → pending approvals → Confirm or Reject.\n"+
"You cannot apply this change via chat; only the user can confirm.",
pid, summary,
), nil
},
},
}
}
func summarizeProposal(pl *memory.CalendarProposePayload) string {
switch pl.Action {
case "create":
return fmt.Sprintf("create: %q", pl.Item.Title)
case "update":
return fmt.Sprintf("update #%d: %q", pl.ID, pl.Item.Title)
case "delete":
return fmt.Sprintf("delete item #%d", pl.ID)
case "complete":
return fmt.Sprintf("mark item #%d done", pl.ID)
default:
return pl.Action
}
}
func buildProposePayload(store *memory.Store, a *calendarProposeArgs) (memory.CalendarProposePayload, error) {
if a == nil {
return memory.CalendarProposePayload{}, fmt.Errorf("args required")
}
action := strings.TrimSpace(strings.ToLower(a.Action))
var pl memory.CalendarProposePayload
pl.Action = action
switch action {
case "create":
it := memory.CalendarInput{
Title: strings.TrimSpace(a.Title),
Notes: strings.TrimSpace(a.Notes),
Status: firstNonEmpty(a.Status, "open"),
DueAt: strings.TrimSpace(a.DueAt),
Source: "agent",
}
if it.Title == "" {
return pl, fmt.Errorf("title required for create")
}
pl.Item = it
return pl, nil
case "update":
if a.ID <= 0 {
return pl, fmt.Errorf("id required for update")
}
ex, err := store.GetCalendarItem(a.ID)
if err != nil {
return pl, err
}
pl.ID = a.ID
pl.Item = mergeCalendarUpdate(ex, a)
return pl, nil
case "delete", "complete":
if a.ID <= 0 {
return pl, fmt.Errorf("id required for %s", action)
}
pl.ID = a.ID
return pl, nil
default:
return pl, fmt.Errorf("unknown action %q", a.Action)
}
}
func firstNonEmpty(a, b string) string {
a = strings.TrimSpace(a)
if a != "" {
return a
}
return b
}
func mergeCalendarUpdate(ex *memory.CalendarItem, a *calendarProposeArgs) memory.CalendarInput {
if ex == nil {
return memory.CalendarInput{}
}
it := memory.CalendarInput{
Title: ex.Title,
Notes: ex.Notes,
Status: ex.Status,
DueAt: ex.DueAt,
Source: "agent",
}
if strings.TrimSpace(a.Title) != "" {
it.Title = strings.TrimSpace(a.Title)
}
if strings.TrimSpace(a.Notes) != "" {
it.Notes = strings.TrimSpace(a.Notes)
}
if strings.TrimSpace(a.Status) != "" {
it.Status = strings.TrimSpace(a.Status)
}
if strings.TrimSpace(a.DueAt) != "" {
it.DueAt = strings.TrimSpace(a.DueAt)
}
return it
}