V10 / CALENDAR

calendar_read_operation

Read a selected calendar event, including its date, time zone, notes, source and reminder.

Scenario onlyIntroduced in V10V10

What it does

Read operations do not modify the event or require approval.

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