filename:
assistant/memory/calendar.go
branch:
main
back to repo
package memory
import (
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
)
// CalendarItem is a date-attached todo/event row.
type CalendarItem struct {
ID int64 `json:"id"`
Title string `json:"title"`
Notes string `json:"notes"`
Status string `json:"status"` // open, done, cancelled
DueAt string `json:"due_at,omitempty"`
Source string `json:"source"` // manual, agent
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// CalendarPendingProposal is a proposed change awaiting user confirmation.
type CalendarPendingProposal struct {
ID int64 `json:"id"`
Payload json.RawMessage `json:"payload"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
SummaryText string `json:"summary,omitempty"`
}
// CalendarProposePayload is stored in calendar_pending.payload_json.
type CalendarProposePayload struct {
Action string `json:"action"` // create, update, delete, complete
ID int64 `json:"id,omitempty"`
Item CalendarInput `json:"item,omitempty"`
}
// CalendarPendingEditInput updates the item portion of a pending create/update proposal.
type CalendarPendingEditInput struct {
Title string `json:"title"`
Notes string `json:"notes"`
Status string `json:"status"`
DueAt string `json:"due_at"`
}
// CalendarInput is used for create/update from API or proposals.
type CalendarInput struct {
Title string `json:"title"`
Notes string `json:"notes"`
Status string `json:"status"`
DueAt string `json:"due_at"`
Source string `json:"source"`
}
func (s *Store) bumpCalendarRevision() error {
if s == nil || s.db == nil {
return fmt.Errorf("store not available")
}
_, err := s.db.Exec(`UPDATE calendar_meta SET v = v + 1 WHERE k = 'revision'`)
return err
}
// CalendarRevision returns the monotonic revision counter for realtime sync.
func (s *Store) CalendarRevision() (int64, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("store not available")
}
var v int64
err := s.db.QueryRow(`SELECT v FROM calendar_meta WHERE k = 'revision'`).Scan(&v)
if err == sql.ErrNoRows {
return 0, nil
}
return v, err
}
// ListCalendarRange returns items overlapping [from, to) (RFC3339 or date strings).
func (s *Store) ListCalendarRange(from, to string) ([]CalendarItem, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("store not available")
}
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
if from == "" || to == "" {
return nil, fmt.Errorf("from and to are required")
}
fromDate, toDateExclusive, err := normalizedDateWindow(from, to)
if err != nil {
return nil, err
}
// Items are date-only: due_at in [from, to) OR undated.
// Use normalized date bounds so:
// - to at midnight/date-only stays exclusive (existing behavior)
// - to with non-midnight time includes that calendar day.
rows, err := s.db.Query(`
SELECT id, title, notes, status, due_at, source, created_at, updated_at
FROM calendar_items
WHERE status != 'cancelled' AND (
(due_at IS NOT NULL AND due_at != '' AND date(due_at) >= date(?) AND date(due_at) < date(?))
OR (due_at IS NULL OR due_at = '')
)
ORDER BY
CASE
WHEN (due_at IS NULL OR due_at = '') THEN 0
ELSE 1
END,
COALESCE(due_at, created_at), id
`, fromDate, toDateExclusive)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCalendarItems(rows)
}
var datePrefixRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
func datePartString(v string) string {
v = strings.TrimSpace(v)
if len(v) < 10 {
return ""
}
p := v[:10]
if datePrefixRE.MatchString(p) {
return p
}
return ""
}
func normalizedDateWindow(from, to string) (fromDate string, toDateExclusive string, err error) {
fromDate = datePartString(from)
toDate := datePartString(to)
if fromDate == "" || toDate == "" {
return "", "", fmt.Errorf("from/to must start with YYYY-MM-DD")
}
// If to includes a time and it's not midnight, include that day by bumping exclusive end +1 day.
// Examples:
// - to=2026-03-26T00:00:00 -> exclusive 2026-03-26
// - to=2026-03-25T23:59:59 -> exclusive 2026-03-26
toDateExclusive = toDate
toTrim := strings.TrimSpace(to)
if len(toTrim) >= 19 && strings.Contains(toTrim, "T") {
tm := toTrim[11:19]
if tm != "00:00:00" {
d, parseErr := time.Parse("2006-01-02", toDate)
if parseErr != nil {
return "", "", parseErr
}
toDateExclusive = d.AddDate(0, 0, 1).Format("2006-01-02")
}
}
return fromDate, toDateExclusive, nil
}
func scanCalendarItems(rows *sql.Rows) ([]CalendarItem, error) {
var out []CalendarItem
for rows.Next() {
var it CalendarItem
var dueAt sql.NullString
if err := rows.Scan(
&it.ID, &it.Title, &it.Notes, &it.Status,
&dueAt, &it.Source, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
if dueAt.Valid {
it.DueAt = dueAt.String
}
out = append(out, it)
}
return out, rows.Err()
}
// GetCalendarItem returns one item by id.
func (s *Store) GetCalendarItem(id int64) (*CalendarItem, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("store not available")
}
row := s.db.QueryRow(`
SELECT id, title, notes, status, due_at, source, created_at, updated_at
FROM calendar_items WHERE id = ?
`, id)
var it CalendarItem
var dueAt sql.NullString
if err := row.Scan(
&it.ID, &it.Title, &it.Notes, &it.Status,
&dueAt, &it.Source, &it.CreatedAt, &it.UpdatedAt,
); err != nil {
return nil, err
}
if dueAt.Valid {
it.DueAt = dueAt.String
}
return &it, nil
}
func optionalDuePtr(dueAt string) (due interface{}) {
dueAt = strings.TrimSpace(dueAt)
if dueAt != "" {
due = dueAt
}
return due
}
func normalizeCalendarInput(in *CalendarInput, defaultSource string) error {
if in == nil {
return fmt.Errorf("item required")
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" {
return fmt.Errorf("title required")
}
in.Notes = strings.TrimSpace(in.Notes)
if in.Status == "" {
in.Status = "open"
}
switch in.Status {
case "open", "done", "cancelled":
default:
return fmt.Errorf("invalid status %q", in.Status)
}
if in.Source == "" {
in.Source = defaultSource
}
if in.Source != "manual" && in.Source != "agent" {
return fmt.Errorf("invalid source")
}
return nil
}
// AddCalendarItem inserts a calendar row (manual or from applied proposal).
func (s *Store) AddCalendarItem(in CalendarInput) (int64, error) {
if err := normalizeCalendarInput(&in, "manual"); err != nil {
return 0, err
}
now := time.Now().UTC().Format(time.RFC3339)
due := optionalDuePtr(in.DueAt)
res, err := s.db.Exec(`
INSERT INTO calendar_items (title, notes, status, due_at, source, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, in.Title, in.Notes, in.Status, due, in.Source, now, now)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
_ = s.bumpCalendarRevision()
return id, nil
}
// UpdateCalendarItem updates fields (empty strings clear optional times if passed explicitly - we use pointers in API; here merge from existing).
func (s *Store) UpdateCalendarItem(id int64, in CalendarInput) error {
if err := normalizeCalendarInput(&in, "manual"); err != nil {
return err
}
now := time.Now().UTC().Format(time.RFC3339)
due := optionalDuePtr(in.DueAt)
res, err := s.db.Exec(`
UPDATE calendar_items SET title=?, notes=?, status=?, due_at=?, updated_at=?
WHERE id=?
`, in.Title, in.Notes, in.Status, due, now, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("no calendar item with id %d", id)
}
_ = s.bumpCalendarRevision()
return nil
}
// DeleteCalendarItem removes a row.
func (s *Store) DeleteCalendarItem(id int64) error {
res, err := s.db.Exec(`DELETE FROM calendar_items WHERE id = ?`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("no calendar item with id %d", id)
}
_ = s.bumpCalendarRevision()
return nil
}
// AddCalendarPending inserts a proposal; returns id.
func (s *Store) AddCalendarPending(payload CalendarProposePayload, ttl time.Duration) (int64, error) {
if s == nil || s.db == nil {
return 0, fmt.Errorf("store not available")
}
if ttl <= 0 {
ttl = 24 * time.Hour
}
b, err := json.Marshal(payload)
if err != nil {
return 0, err
}
exp := time.Now().UTC().Add(ttl).Format(time.RFC3339)
res, err := s.db.Exec(`INSERT INTO calendar_pending (payload_json, status, expires_at) VALUES (?, 'pending', ?)`, string(b), exp)
if err != nil {
return 0, err
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
_ = s.bumpCalendarRevision()
return id, nil
}
// ListCalendarPending returns pending proposals not expired.
func (s *Store) ListCalendarPending() ([]CalendarPendingProposal, error) {
if s == nil || s.db == nil {
return nil, fmt.Errorf("store not available")
}
now := time.Now().UTC().Format(time.RFC3339)
rows, err := s.db.Query(`
SELECT id, payload_json, status, created_at, expires_at FROM calendar_pending
WHERE status = 'pending' AND expires_at > ?
ORDER BY id DESC
`, now)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CalendarPendingProposal
for rows.Next() {
var p CalendarPendingProposal
var raw string
if err := rows.Scan(&p.ID, &raw, &p.Status, &p.CreatedAt, &p.ExpiresAt); err != nil {
return nil, err
}
p.Payload = json.RawMessage(raw)
p.SummaryText = summarizePendingPayload(raw)
out = append(out, p)
}
return out, rows.Err()
}
func summarizePendingPayload(raw string) string {
var pl CalendarProposePayload
if err := json.Unmarshal([]byte(raw), &pl); err != nil {
return raw
}
switch pl.Action {
case "create":
return fmt.Sprintf("create: %s", pl.Item.Title)
case "update":
return fmt.Sprintf("update #%d: %s", pl.ID, pl.Item.Title)
case "delete":
return fmt.Sprintf("delete #%d", pl.ID)
case "complete":
return fmt.Sprintf("complete #%d", pl.ID)
default:
return pl.Action
}
}
// ApplyCalendarPending applies a pending proposal and marks it applied.
func (s *Store) ApplyCalendarPending(id int64) error {
if s == nil || s.db == nil {
return fmt.Errorf("store not available")
}
var raw string
var status string
err := s.db.QueryRow(`SELECT payload_json, status FROM calendar_pending WHERE id = ?`, id).Scan(&raw, &status)
if err != nil {
return err
}
if status != "pending" {
return fmt.Errorf("proposal %d is not pending", id)
}
var pl CalendarProposePayload
if err := json.Unmarshal([]byte(raw), &pl); err != nil {
return err
}
if err := s.applyPayload(&pl); err != nil {
return err
}
_, err = s.db.Exec(`UPDATE calendar_pending SET status = 'applied' WHERE id = ?`, id)
if err != nil {
return err
}
// Item mutations already bumped revision via applyPayload.
return nil
}
func (s *Store) applyPayload(pl *CalendarProposePayload) error {
if pl == nil {
return fmt.Errorf("nil payload")
}
switch pl.Action {
case "create":
pl.Item.Source = "agent"
_, err := s.AddCalendarItem(pl.Item)
return err
case "update":
if pl.ID <= 0 {
return fmt.Errorf("id required")
}
pl.Item.Source = "agent"
return s.UpdateCalendarItem(pl.ID, pl.Item)
case "delete":
if pl.ID <= 0 {
return fmt.Errorf("id required")
}
return s.DeleteCalendarItem(pl.ID)
case "complete":
if pl.ID <= 0 {
return fmt.Errorf("id required")
}
ex, err := s.GetCalendarItem(pl.ID)
if err != nil {
return err
}
in := CalendarInput{
Title: ex.Title,
Notes: ex.Notes,
Status: "done",
DueAt: ex.DueAt,
Source: "agent",
}
return s.UpdateCalendarItem(pl.ID, in)
default:
return fmt.Errorf("unknown action %q", pl.Action)
}
}
// RejectCalendarPending marks a proposal rejected.
func (s *Store) RejectCalendarPending(id int64) error {
if s == nil || s.db == nil {
return fmt.Errorf("store not available")
}
res, err := s.db.Exec(`UPDATE calendar_pending SET status = 'rejected' WHERE id = ? AND status = 'pending'`, id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("no pending proposal %d", id)
}
_ = s.bumpCalendarRevision()
return nil
}
// UpdateCalendarPendingItem edits a pending create/update proposal.
func (s *Store) UpdateCalendarPendingItem(id int64, in CalendarPendingEditInput) error {
if s == nil || s.db == nil {
return fmt.Errorf("store not available")
}
var raw string
var status string
if err := s.db.QueryRow(`SELECT payload_json, status FROM calendar_pending WHERE id = ?`, id).Scan(&raw, &status); err != nil {
return err
}
if status != "pending" {
return fmt.Errorf("proposal %d is not pending", id)
}
var pl CalendarProposePayload
if err := json.Unmarshal([]byte(raw), &pl); err != nil {
return err
}
if pl.Action != "create" && pl.Action != "update" {
return fmt.Errorf("proposal %d is %s; only create/update can be edited", id, pl.Action)
}
item := pl.Item
item.Title = strings.TrimSpace(in.Title)
item.Notes = strings.TrimSpace(in.Notes)
if strings.TrimSpace(in.Status) != "" {
item.Status = strings.TrimSpace(in.Status)
}
item.DueAt = strings.TrimSpace(in.DueAt)
item.Source = "agent"
if err := normalizeCalendarInput(&item, "agent"); err != nil {
return err
}
pl.Item = item
b, err := json.Marshal(pl)
if err != nil {
return err
}
_, err = s.db.Exec(`UPDATE calendar_pending SET payload_json = ? WHERE id = ? AND status = 'pending'`, string(b), id)
if err != nil {
return err
}
_ = s.bumpCalendarRevision()
return nil
}
// ExpireOldCalendarPending marks expired rows.
func (s *Store) ExpireOldCalendarPending() error {
if s == nil || s.db == nil {
return nil
}
now := time.Now().UTC().Format(time.RFC3339)
_, _ = s.db.Exec(`UPDATE calendar_pending SET status = 'expired' WHERE status = 'pending' AND expires_at <= ?`, now)
return nil
}