V16 / CALENDAR

calendar_update_operation

Replace an existing calendar event and reminder after locating its stable event ID.

Scenario onlyIntroduced in V10V16

What it does

Updates replace the complete definition. Application-owned revisions reject stale approvals.

Read the implementation

This page describes the selected release. The source link points to the same exact commit; it cannot silently open a newer implementation.

Lilith/Core/Calendar/CalendarOperations.cs ↗

Implementation code

Read the implementation from this version's exact source snapshot. Helper links below stay on the same release.

Lilith/Core/Calendar/CalendarOperations.cs

C#
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Lilith.Core.Calendar;

internal static class CalendarOperations
{
    internal static readonly string[] Names = ["calendar_create_operation", "calendar_update_operation", "calendar_delete_operation", "calendar_read_operation", "calendar_search_operation", "calendar_agenda_operation", "calendar_offer_operation"];
    internal static bool Mutates(string name) => name is "calendar_create_operation" or "calendar_update_operation" or "calendar_delete_operation";
    internal static string Execute(ProviderClient owner, string name, IReadOnlyDictionary<string, string> inputs)
    {
        string Get(string key) => inputs.GetValueOrDefault(key) ?? "";
        if (name == "calendar_offer_operation")
        {
            string answer = Get("answer");
            return owner.Calendar is not null && Regex.IsMatch(Get("question"), @"\b(when|release|date|launch)\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)
                ? answer + "\n\nWould you like me to add this to your calendar? If so, what reminder date and time would you like, or would you prefer no reminder?" : answer;
        }
        var calendar = owner.Calendar ?? throw new InvalidOperationException("Calendar is not configured.");
        if (!Names.Contains(name)) throw new ArgumentException("Unknown calendar operation.");
        string operation = name[9..^10];
        return JsonSerializer.Serialize(calendar.Execute(new(operation, Get("id"), Get("title"), Get("date"), Get("time"), Get("timezone"), Get("reminder"), Get("notes"), Get("source"), Get("query"))));
    }
}

Lilith/Core/Calendar/CalendarSession.cs

Open this helper's source page ↗

C#
using System.Globalization;
using System.Text.Json;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Calendar;

/// <summary>Single-host calendar; detection persists notices without invoking a conversation.</summary>
public sealed class CalendarSession : IDisposable
{
    private readonly object _sync = new();
    private readonly FileStream _lease;
    private readonly string _file;
    private readonly TimeProvider _clock;
    private CalendarState _state;
    private bool _faulted, _disposed;
    public CalendarOptions Options { get; }

    public CalendarSession(CalendarOptions options, TimeProvider? clock = null)
    {
        if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown calendar access mode.");
        Options = options with { RootDirectory = Path.GetFullPath(options.RootDirectory) };
        Options.RootDirectory.EnsureNoLinks();
        Directory.CreateDirectory(Options.RootDirectory);
        _clock = clock ?? TimeProvider.System;
        _file = Path.Combine(Options.RootDirectory, "calendar.json");
        _lease = new(Path.Combine(Options.RootDirectory, "host.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
        try
        {
            _state = File.Exists(_file) ? JsonSerializer.Deserialize<CalendarState>(File.ReadAllText(_file))
                ?? throw new InvalidDataException("Invalid calendar state.") : new();
            if (_state.Entries.Count > 1000 || _state.Pending.Count > 100 || _state.Notices.Count > 1000)
                throw new InvalidDataException("Calendar limits exceeded.");
            // A crash may have left a queued notice. Retain one missed notice, never run actions.
            _state.Notices = _state.Notices.Select(n => n with { Missed = true, Message = MissedMessage(n.Title) }).ToList();
            Detect(true);
            Save();
        }
        catch { _lease.Dispose(); throw; }
    }

    public CalendarResult Execute(CalendarRequest request)
    {
        lock (_sync)
        {
            Check();
            return request.Operation switch
            {
                "create" or "update" or "delete" => Prepare(request),
                "read" => new("completed", "Calendar event.", Find(request.Id)),
                "search" => new("completed", "Calendar matches (up to 20).", _state.Entries
                    .Where(e => e.Title.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || e.Notes.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || e.Date == request.Query)
                    .OrderBy(e => e.Date).ThenBy(e => e.Time).Take(20).Select(Summary).ToArray()),
                "agenda" => Agenda(),
                "pending" => new("completed", "Pending calendar changes.", _state.Pending.ToArray()),
                _ => throw new ArgumentException("Unknown calendar operation.")
            };
        }
    }

    private CalendarResult Agenda()
    {
        var now = _clock.GetUtcNow();
        var events = _state.Entries.Where(e => e.Date == TimeZoneInfo.ConvertTime(now, Zone(e.TimeZone)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))
            .OrderBy(e => e.Time).ToArray();
        var overdue = _state.Notices.Where(n => n.DueUtc <= now).ToArray();
        return new("completed", "Today's events and overdue reminders.", new { has_items = events.Length + overdue.Length > 0,
            event_count = events.Length, overdue_count = overdue.Length, events = events.Take(20).Select(Summary).ToArray(), overdue = overdue.Take(20).ToArray() });
    }

    private CalendarResult Prepare(CalendarRequest request)
    {
        // Persist exact absolute times and stable ID at preparation, never reinterpret on approval.
        if (request.Operation == "create" && request.Id.Length == 0) request = request with { Id = Guid.NewGuid().ToString("N") };
        CalendarEntry? prepared = Validate(request);
        if (prepared is not null) request = request with { TimeZone = prepared.TimeZone };
        if (Options.Mode == WorkspaceAccessMode.Yolo)
        {
            var result = Commit(request); Save(); return result;
        }
        if (_state.Pending.Count >= 100) throw new InvalidOperationException("Review existing calendar proposals first.");
        string id = Guid.NewGuid().ToString("N");
        _state.Pending.Add(new(id, request, _state.Entries.SingleOrDefault(e => e.Id == request.Id)?.Revision));
        Save();
        return new("needs_approval", $"Approve calendar {request.Operation}: {request.Title switch { "" => Find(request.Id).Title, _ => request.Title }}.", request, id);
    }

    public CalendarProposal Review(string id)
    {
        lock (_sync) { Check(); return _state.Pending.SingleOrDefault(p => p.Id == id) ?? throw new ArgumentException("Unknown calendar proposal."); }
    }
    public CalendarResult Approve(string id)
    {
        lock (_sync)
        {
            var proposal = Review(id);
            if (_state.Entries.SingleOrDefault(e => e.Id == proposal.Request.Id)?.Revision != proposal.ExpectedRevision)
                throw new InvalidOperationException("Calendar event changed. Reject this proposal and prepare another.");
            Validate(proposal.Request);
            var result = Commit(proposal.Request);
            _state.Pending.Remove(proposal); Save(); return result;
        }
    }
    public void Reject(string id) { lock (_sync) { var proposal = Review(id); _state.Pending.Remove(proposal); Save(); } }

    private CalendarEntry? Validate(CalendarRequest request)
    {
        if (request.Id.Length is < 1 or > 64 || request.Id.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('_' or '-')))
            throw new ArgumentException("Event ID must contain 1-64 letters, digits, underscores or hyphens.");
        if (request.Operation == "create")
        {
            if (_state.Entries.Count >= 1000 || _state.Entries.Any(e => e.Id == request.Id)) throw new ArgumentException("Calendar ID exists or event limit reached.");
        }
        else
        {
            _ = Find(request.Id);
            if (_state.Notices.Any(n => n.EventId == request.Id)) throw new InvalidOperationException("Deliver this event's outstanding reminder before changing it.");
        }
        if (request.Operation == "delete") return null;
        if (request.Title.Length is < 1 or > 200 || string.IsNullOrWhiteSpace(request.Title) || request.Title.Any(char.IsControl) || request.Notes.Length > 2000 || request.Source.Length > 2000)
            throw new ArgumentException("Provide a title (1-200 visible characters), notes/source up to 2000 characters.");
        if (request.Source.Length > 0 && (!Uri.TryCreate(request.Source, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http")))
            throw new ArgumentException("Source must be an HTTP(S) URL.");
        if (!DateOnly.TryParseExact(request.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) throw new ArgumentException("Date must be yyyy-MM-dd.");
        if (_state.Entries.Any(e => e.Id != request.Id && e.Title.Equals(request.Title, StringComparison.OrdinalIgnoreCase) && e.Date == request.Date && e.Time == request.Time))
            throw new ArgumentException("This title/date/time is already in the calendar. Search and update the existing event instead.");
        var zone = Zone(request.TimeZone.Length == 0 ? TimeZoneInfo.Local.Id : request.TimeZone);
        DateTimeOffset? starts = request.Time.Length == 0 ? null : At(date, request.Time, zone);
        DateTimeOffset? reminder = request.Reminder switch
        {
            "none" => null,
            "" => throw new ArgumentException("Ask the user for a reminder time, or explicit none. Do not choose a default."),
            _ => ParseReminder(request.Reminder, zone)
        };
        if (reminder <= _clock.GetUtcNow()) throw new ArgumentException("Reminder must be in the future; ask for a new reminder time or none.");
        return new(request.Id, request.Title, request.Date, request.Time, zone.Id, starts, reminder, request.Notes, request.Source,
            request.Operation == "create" ? 1 : Find(request.Id).Revision + 1);
    }

    private static TimeZoneInfo Zone(string id) => TimeZoneInfo.FindSystemTimeZoneById(id);
    private static DateTimeOffset At(DateOnly date, string time, TimeZoneInfo zone)
    {
        if (!TimeOnly.TryParseExact(time, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) throw new ArgumentException("Time must be HH:mm.");
        var local = date.ToDateTime(parsed, DateTimeKind.Unspecified);
        if (zone.IsInvalidTime(local) || zone.IsAmbiguousTime(local)) throw new ArgumentException("Local time is invalid or ambiguous at a daylight-saving change. Choose an unambiguous local time.");
        return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, zone), TimeSpan.Zero);
    }
    private static DateTimeOffset ParseReminder(string value, TimeZoneInfo zone)
    {
        if (value.Length != 16 || value[10] != 'T' || !DateOnly.TryParseExact(value[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
            throw new ArgumentException("Reminder must be yyyy-MM-ddTHH:mm in the event time zone, or none.");
        return At(date, value[11..], zone);
    }
    private CalendarResult Commit(CalendarRequest request)
    {
        var entry = Validate(request);
        string title = entry?.Title ?? Find(request.Id).Title;
        _state.Entries.RemoveAll(e => e.Id == request.Id);
        if (entry is not null) _state.Entries.Add(entry);
        return new("completed", $"Calendar {request.Operation} completed{(entry is null ? ": " + title : "")}.", entry);
    }
    public void Tick() { lock (_sync) { Check(); if (Detect(false)) Save(); } }
    private bool Detect(bool missed)
    {
        var due = _state.Entries.Where(e => !e.ReminderQueued && e.ReminderUtc <= _clock.GetUtcNow()).ToArray();
        foreach (var entry in due)
        {
            _state.Entries[_state.Entries.IndexOf(entry)] = entry with { ReminderQueued = true, Revision = entry.Revision + 1 };
            _state.Notices.Add(new(Guid.NewGuid().ToString("N"), entry.Id, entry.Title, entry.ReminderUtc!.Value, missed,
                missed ? MissedMessage(entry.Title) : $"Calendar reminder: {entry.Title}, {entry.Date}{(entry.Time.Length == 0 ? " (all day)" : " at " + entry.Time + " " + entry.TimeZone)}."));
        }
        return due.Length > 0;
    }
    private static string MissedMessage(string title) => $"Missed calendar reminder while Lilith was closed: {title}. No action was replayed.";
    public CalendarNotice? PeekNotice() { lock (_sync) { Check(); return _state.Notices.FirstOrDefault(); } }
    public void CompleteNotice(string id) { lock (_sync) { Check(); _state.Notices.RemoveAll(n => n.Id == id); Save(); } }
    private CalendarEntry Find(string id) => _state.Entries.SingleOrDefault(e => e.Id == id) ?? throw new ArgumentException("Unknown calendar event ID. Search the calendar first.");
    private static object Summary(CalendarEntry entry) => new { entry.Id, entry.Title, entry.Date, entry.Time, entry.TimeZone, entry.ReminderUtc };
    private void Save()
    {
        string temp = _file + "." + Guid.NewGuid().ToString("N") + ".tmp";
        try { File.WriteAllText(temp, JsonSerializer.Serialize(_state)); File.Move(temp, _file, true); }
        catch { _faulted = true; throw; }
    }
    private void Check() { ObjectDisposedException.ThrowIf(_disposed, this); if (_faulted) throw new IOException("Calendar storage failed. Fix storage and restart."); }
    public void Dispose() { lock (_sync) { if (_disposed) return; _disposed = true; _lease.Dispose(); } }
}

Lilith/Core/Calendar/CalendarModels.cs

Open this helper's source page ↗

C#
using System.Text.Json;
using Lilith.Core.Workspace;

namespace Lilith.Core.Calendar;

public sealed record CalendarOptions(string RootDirectory, WorkspaceAccessMode Mode = WorkspaceAccessMode.Ask);
public sealed record CalendarRequest(string Operation, string Id = "", string Title = "", string Date = "",
    string Time = "", string TimeZone = "", string Reminder = "", string Notes = "", string Source = "", string Query = "");
public sealed record CalendarEntry(string Id, string Title, string Date, string Time, string TimeZone,
    DateTimeOffset? StartsUtc, DateTimeOffset? ReminderUtc, string Notes, string Source, int Revision, bool ReminderQueued = false);
public sealed record CalendarProposal(string Id, CalendarRequest Request, int? ExpectedRevision);
public sealed record CalendarNotice(string Id, string EventId, string Title, DateTimeOffset DueUtc, bool Missed, string Message);
public sealed record CalendarResult(string Status, string Message, object? Data = null, string? ProposalId = null)
{
    public string ToReply() => Status == "needs_approval"
        ? $"{Message}\nUse /calendar pending to review the prepared change, then /calendar-approve or /calendar-reject with its proposal ID."
        : Status == "completed" && EventData() is { } entry
            ? $"{Message} {entry.Title}: {entry.Date}{(entry.Time.Length == 0 ? " (all day)" : " at " + entry.Time + " " + entry.TimeZone)}. " +
              (entry.ReminderUtc is { } reminder ? $"Reminder: {TimeZoneInfo.ConvertTime(reminder, TimeZoneInfo.FindSystemTimeZoneById(entry.TimeZone)):yyyy-MM-dd HH:mm} {entry.TimeZone}." : "No reminder set.")
        : Status == "completed" && Data is null ? Message : JsonSerializer.Serialize(this);
    private CalendarEntry? EventData() => Data switch
    {
        CalendarEntry entry => entry,
        JsonElement { ValueKind: JsonValueKind.Object } element when element.TryGetProperty("Title", out _) => element.Deserialize<CalendarEntry>(),
        _ => null
    };
}
internal sealed class CalendarState
{
    public List<CalendarEntry> Entries { get; set; } = [];
    public List<CalendarProposal> Pending { get; set; } = [];
    public List<CalendarNotice> Notices { get; set; } = [];
}

Lilith/Core/Calendar/CalendarDelivery.cs

Open this helper's source page ↗

C#
namespace Lilith.Core.Calendar;

public static class CalendarDelivery
{
    // Caller owns the host's conversation/narration gate, shared with scheduler delivery.
    public static async Task DrainAsync(ProviderClient provider, Func<string, CancellationToken, Task> narrate, CancellationToken ct = default)
    {
        if (provider.Calendar is not { } calendar) return;
        for (int i = 0; i < 10 && calendar.PeekNotice() is { } notice; i++)
        {
            ct.ThrowIfCancellationRequested();
            provider.RecordCalendarNotice(notice);
            await narrate(notice.Message, ct);
            calendar.CompleteNotice(notice.Id);
        }
    }
}

Related source in this version

Scenarios using this tool