Ryanhub - file viewer
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
}