V5 / CHANGESET

Only the delta.

The exact changes from V4 to V5. Unchanged files are excluded. Nothing from a later version is included.

V4→V5f52bca255516

Green + / red - count added and removed lines; signed bytes show the file size change. UTF-8 file sizes and total lines in this snapshot; deleted files use the previous snapshot. Sorting stays within each folder.

Repository root 1 files

ModifiedVersion.props205 B / 4 lines+2-2+1 B
PROPSStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project>
  <!-- V5 scheduling development. V4 alpha is preserved separately. -->
  <PropertyGroup><VersionPrefix>5.0.0</VersionPrefix><VersionSuffix>alpha.2</VersionSuffix></PropertyGroup>
</Project>

Interfaces/ConsoleApp/LilithConsole 1 files

ModifiedProgram.cs4,857 B / 90 lines+2-1+68 B
C#UnchangedRemoved / replacedAdded
using System;
using System.Text;
using System.Threading.Tasks;
using Lilith.Core.Logging;
using Lilith.Agent;
using LilithAgent = Lilith.Agent;
using LilithConsole.Setup;
using Lilith.Core.Kokoro;

namespace LilithConsole
{
    partial class Program
    {
        static async Task Main(string[] args)
        {
            Console.OutputEncoding = Encoding.UTF8;
            var consoleSink = new TextLogSink(Console.Out, overwriteProgress: !Console.IsOutputRedirected,
                width: () => Math.Max(Console.WindowWidth - 1, 1));
            Action<LogEntry> onLog = consoleSink.Handle;

            var logger = new Logger(onLog);
            try
            {
                string settingsPath = Environment.GetEnvironmentVariable("LILITH_CONSOLE_SETTINGS")
                    ?? System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lilith", "console-settings.json");
                var credentials = await ProviderSetup.RunAsync(Console.In, Console.Out, logger, settingsPath);
                if (credentials is null) { logger.SystemWriteLine("Setup stopped. Relaunch to continue."); return; }
                if (!Console.IsInputRedirected || Environment.GetEnvironmentVariable("LILITH_TTS") == "1")
                {
                    try
                    {
                        credentials = credentials with { Tts = await KokoroSetup.RunAsync(Console.In, Console.Out,
                            logger, settingsPath + ".kokoro.json") };
                    }
                    catch (Exception ex) { logger.SystemWriteLine($"Speech setup failed: {ex.Message}. Relaunch to retry; continuing with text chat.", LogColors.Orange); }
                }
                Console.Title = $"{credentials.AgentName} — {credentials.Model} Chat";
                await using var lilith = new LilithAgent.Lilith(onLog, credentials);
                string mode = "ask";
                if (!Console.IsInputRedirected)
                {
                    Console.Write("Workspace and scheduling mode for this boot [ask/yolo, default ask]: ");
                    mode = Console.ReadLine()?.Trim().ToLowerInvariant() ?? "ask";
                    if (mode.Length == 0) mode = "ask";
                    if (mode is not ("ask" or "yolo")) throw new ArgumentException("Choose ask or yolo at boot.");
                }
                else if (args.Contains("--workspace-yolo")) mode = "yolo";
                var workspace = Lilith.Core.Workspace.WorkspaceOptions.Default;
                string? workspaceRoot = Environment.GetEnvironmentVariable("LILITH_WORKSPACE_ROOT");
                lilith.WorkspaceOptions = workspace with { RootDirectory = workspaceRoot ?? workspace.RootDirectory,
                    Mode = mode == "yolo" ? Lilith.Core.Workspace.WorkspaceAccessMode.Yolo : Lilith.Core.Workspace.WorkspaceAccessMode.Ask };
                Console.WriteLine($"Workspace: {lilith.WorkspaceOptions.RootDirectory} ({mode}, fixed until restart). Use /workspace, /workspace review <id>, /approve <id>, /reject <id>.");
                await lilith.BootAsync(startServerIfNotRunning: true);

                while (true)
                {
                    string? input;
                    using (consoleSink.BeginInput())
                    {
                        lilith.Logger.Write("Input", "You: ", LogColors.Green);
                        input = Console.ReadLine()?.Trim();
                    }
                    if (input is null) break;
                    if (string.IsNullOrEmpty(input)) continue;
                    if (input.Equals("exit", StringComparison.OrdinalIgnoreCase)) break;
                    try
                    {
                        var command = await lilith.ChatCommands.ExecuteAsync(input);
                        if (command.Output is { Length: > 0 }) Console.WriteLine(command.Output);
                        if (command.ExitRequested) break;
                        if (!command.Handled) await lilith.ChatAsync(input);
                    }
                    catch (Exception ex)
                    {
                        lilith.Logger.SystemWriteLine($"Request failed: {ex.Message}. Please try again.", LogColors.Red);
                    }
                }
                await lilith.StopSchedulingAsync();
                lilith.SaveConversation();
                lilith.Logger.SystemWriteLine("Goodbye.");
            }
            catch (Exception ex)
            {
                logger.SystemWriteLine($"Setup/chat failed: {ex.Message}. Relaunch to retry; completed setup steps are retained.", LogColors.Red);
                Environment.ExitCode = 1;
            }
            finally { consoleSink.Flush(); }
        }
    }
}

Lilith/Agent 5 files

ModifiedLilith.Boot.cs3,105 B / 78 lines+1-0+144 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;

namespace Lilith.Agent;

public partial class Lilith
{
    public bool IsBooted { get; private set; }

    public async Task BootAsync(bool startServerIfNotRunning = true)
    {
        using var operation = EnterOperation();
        try
        {
            if (IsBooted) return;
            if (string.IsNullOrWhiteSpace(Credentials.Model) || string.IsNullOrWhiteSpace(Credentials.SystemPrompt) ||
                string.IsNullOrWhiteSpace(Credentials.AgentName)) throw new ArgumentException("Model, system prompt, and agent name are required.");
            await Logger.TimeStageAsync("Total agent boot", () => BootCoreAsync(startServerIfNotRunning));
        }
        catch
        {
            _llmclient?.Dispose();
            _llmclient = null!;
            IsBooted = false;
            throw;
        }
    }

    private async Task BootCoreAsync(bool startServerIfNotRunning)
    {
        IsBooted = false;
        await Logger.TimeStageAsync("Client initialization", () => InitializeProviderAsync(startServerIfNotRunning));
        await Logger.TimeStageAsync("Chat readiness validation", () => { EnsureChatReadiness(); return Task.CompletedTask; });
        if (Credentials.Tts is { Enabled: true } options && _tts is null)
        {
            try
            {
                _tts = await Logger.TimeStageAsync("Kokoro initialization", () =>
                    global::Lilith.Core.Kokoro.KokoroTts.CreateAsync(Logger, options));
            }
            catch (Exception ex) { Logger.SystemWriteLine($"Speech unavailable: {ex.Message}. Text chat remains available.", LogColors.Orange); }
        }
        Logger.SystemWriteLine($"=== {Credentials.AgentName} AI Chat ({Credentials.Model} via {Credentials.Provider}) ===");
        Logger.SystemWriteLine("Type your message and press Enter. Type 'exit' to quit.\n");
        Logger.SystemPromptWriteLine($"SYSTEM: {Credentials.SystemPrompt}");
        await Logger.TimeStageAsync("Greeting generation", GreetingCoreAsync);
        Logger.SystemWriteLine("", LogColors.White);
        IsBooted = true;
        if (EnableScenarios) _schedulePump = Task.WhenAll(DetectSchedulesAsync(_scheduleStop.Token), PumpSchedulesAsync(_scheduleStop.Token));
    }

    public async Task GreetAsync()
    {
        using var operation = EnterOperation("Boot the agent before greeting.");
        await GreetingCoreAsync();
    }

    private async Task GreetingCoreAsync()
    {
        if (!EnableScenarios)
        {
            await ChatCoreAsync("Please greet the user.");
            return;
        }
        string greeting = await Client.RunScenarioAsync("greeting", new Dictionary<string, string>
        {
            ["agent_name"] = Credentials.AgentName
        });
        if (_tts is not null) await _tts.SpeakAsync(greeting);
    }

    private void EnsureChatReadiness()
    {
        _llmclient.IsNull();
        this.EnsureSystemPromptContent();
        this.EnsureContextInitialized();
    }
}
ModifiedLilith.Chat.cs4,114 B / 100 lines+3-1+152 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using System.Diagnostics;

namespace Lilith.Agent;

public partial class Lilith
{
    public async Task ChatAsync(string input, List<string>? images = null, CancellationToken cancellationToken = default)
    {
        using var operation = await _operation.EnterAsync(cancellationToken);
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (!IsBooted) throw new InvalidOperationException("Boot the agent before chatting.");
        await ChatCoreAsync(input, images, cancellationToken);
    }

    private async Task ChatCoreAsync(string input, List<string>? images = null, CancellationToken cancellationToken = default)
    {
        var timer = Stopwatch.StartNew();
        string reply;
        try { reply = await Client.StreamReply(input, images, cancellationToken); }
        finally { timer.Stop(); }
        this.LogTokenCounts(timer.Elapsed);
        if (_tts is not null) await _tts.SpeakAsync(reply, cancellationToken);
    }

    private T WithConversation<T>(Func<ProviderClient, T> action)
    {
        using var operation = EnterOperation("Boot the agent before using its conversation.");
        return action(Client);
    }

    private void WithConversation(Action<ProviderClient> action)
    {
        using var operation = EnterOperation("Boot the agent before using its conversation.");
        action(Client);
    }

    public void NewConversation() => WithConversation(client => client.NewConversation());
    public async Task ApproveWorkspaceAsync(string id, CancellationToken cancellationToken = default)
    {
        using var operation = EnterOperation("Boot before approving workspace changes.");
        string reply = await Client.ApproveWorkspaceAsync(id, cancellationToken);
        if (_tts is not null) await _tts.SpeakAsync(reply, cancellationToken);
    }
    public async Task RejectWorkspaceAsync(string id, CancellationToken cancellationToken = default)
    {
        using var operation = EnterOperation("Boot before rejecting workspace changes.");
        var result = await Workspace.ExecuteAsync(new("reject", BinId: id), cancellationToken);
        Logger.SystemWriteLine(result.Message);
    }

    public void SaveConversation() => WithConversation(client => client.SaveConversation());

    public IReadOnlyList<ChatSessionSummary> ListConversations() => WithConversation(client => client.ListConversations());

    public bool LoadConversation(int displayIndex) => WithConversation(client => client.LoadConversation(displayIndex));

    public bool LoadConversation(string id) => WithConversation(client => client.LoadConversation(id));

    public void HandleHistoryCommand(string input)
    {
        var parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (parts.Length == 1)
        {
            var sessions = ListConversations();
            if (sessions.Count == 0)
            {
                Logger.HistoryWriteLine("No saved chats found.");
                return;
            }

            Logger.HistoryWriteLine("Saved chats:", LogColors.Cyan);
            for (int i = 0; i < sessions.Count; i++)
            {
                var session = sessions[i];
                Logger.HistoryWriteLine($"{i + 1}. {session.Title} ({session.UpdatedAt.LocalDateTime:g}, {session.MessageCount} messages)");
            }

            Logger.HistoryWriteLine("Use /history <number> to load a chat.");
            return;
        }

        if (!int.TryParse(parts[1], out int index))
        {
            Logger.HistoryWriteLine("Usage: /history or /history <number>", LogColors.Orange);
            return;
        }

        if (LoadConversation(index))
        {
            Logger.HistoryWriteLine($"Loaded saved chat #{index}.", LogColors.Cyan);
        }
        else
        {
            Logger.HistoryWriteLine($"Saved chat #{index} was not found.", LogColors.Orange);
        }
    }
}
NEWLilith.Commands.Schedules.cs1,207 B / 20 lines+20-0+1,207 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Commands;

namespace Lilith.Agent;

public partial class Lilith
{
    partial void RegisterScheduleCommands(ChatCommandRegistry commands)
    {
        commands.Register("schedules", "List schedules; use pending or review <id> for proposals.", context => context.Arguments switch
        {
            "" => JsonSerializer.Serialize(Schedules.Execute(new("list"))),
            "pending" => JsonSerializer.Serialize(Schedules.Execute(new("pending"))),
            var args when args.StartsWith("review ", StringComparison.OrdinalIgnoreCase) => JsonSerializer.Serialize(Schedules.Review(args[7..].Trim()), new JsonSerializerOptions { WriteIndented = true }),
            _ => "Use /schedules, /schedules pending or /schedules review <id>."
        });
        commands.RegisterAsync("schedule-approve", "Approve a schedule proposal: /schedule-approve <id>.", async context => { await ApproveScheduleAsync(context.Arguments, context.CancellationToken); return new(true); });
        commands.Register("schedule-reject", "Reject a schedule proposal: /schedule-reject <id>.", context => { RejectSchedule(context.Arguments); return null; });
    }
}
NEWLilith.Schedules.cs2,535 B / 62 lines+62-0+2,535 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Agent;

public partial class Lilith
{
    public async Task StopSchedulingAsync()
    {
        _scheduleStop.Cancel();
        if (_schedulePump is not null) await _schedulePump;
    }

    private async Task DetectSchedulesAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
        try
        {
            while (await timer.WaitForNextTickAsync(ct))
                try { Schedules.Tick(); }
                catch (Exception ex) { Logger.SystemWriteLine("Schedule detection failed: " + ex.Message, global::Lilith.Core.Logging.LogColors.Red); }
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
    }

    private async Task PumpSchedulesAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
        try
        {
            while (await timer.WaitForNextTickAsync(ct))
            {
                try
                {
                    // Detection runs separately; delivery must own conversation and narration access.
                    if (!await _operation.WaitAsync(0, ct)) continue;
                    try { await DeliverScheduleEventsAsync(ct); }
                    finally { _operation.Release(); }
                }
                catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
                catch (Exception ex) { Logger.SystemWriteLine("Scheduler: " + ex.Message, global::Lilith.Core.Logging.LogColors.Red); }
            }
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
    }

    private Task DeliverScheduleEventsAsync(CancellationToken ct) =>
        global::Lilith.Core.Scheduling.ScheduleDelivery.DrainAsync(Client,
            (text, token) => _tts is null ? Task.CompletedTask : _tts.SpeakAsync(text, token), ct);

    public async Task ApproveScheduleAsync(string id, CancellationToken ct = default)
    {
        using var operation = EnterOperation("Boot before approving schedule changes.");
        string reply = Schedules.Approve(id).ToReply();
        Logger.SystemWriteLine(reply);
        if (_tts is not null) await _tts.SpeakAsync(reply, ct);
    }

    public void RejectSchedule(string id)
    {
        using var operation = EnterOperation("Boot before rejecting schedule changes.");
        Schedules.Reject(id);
        Logger.SystemWriteLine("Schedule proposal rejected.");
    }
}
ModifiedLilith.cs3,952 B / 81 lines+5-0+471 B
C#UnchangedRemoved / replacedAdded
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Providers;
using Lilith.Core.Kokoro;

namespace Lilith.Agent;

public partial class Lilith(Action<LogEntry> onLog, LilithCredentials? credentials = null) : IAsyncDisposable
{
    private KokoroTts? _tts;
    private readonly SemaphoreSlim _operation = new(1, 1);
    private bool _disposed;
    private readonly CancellationTokenSource _scheduleStop = new();
    private Task? _schedulePump;
    private ProviderClient _llmclient = null!;
    private bool _workspaceLocked;
    private global::Lilith.Core.Workspace.WorkspaceOptions _workspaceOptions = global::Lilith.Core.Workspace.WorkspaceOptions.Default;
    public global::Lilith.Core.Workspace.WorkspaceOptions WorkspaceOptions
    {
        get => _workspaceOptions;
        set { if (_workspaceLocked) throw new InvalidOperationException("Workspace configuration can only be chosen before boot."); _workspaceOptions = value ?? throw new ArgumentNullException(nameof(value)); }
    }
    public global::Lilith.Core.Workspace.WorkspaceSession Workspace => Client.Workspace ?? throw new InvalidOperationException("Workspace unavailable.");
    public global::Lilith.Core.Scheduling.ScheduleSession Schedules => Client.Schedules ?? throw new InvalidOperationException("Schedules unavailable.");
    private readonly LilithCredentials _credentials = credentials ?? new();

    public LilithCredentials Credentials => _credentials;
    public Logger Logger { get; } = new(onLog);
    internal Func<bool, Task<ProviderClient>>? ProviderFactory { get; set; }

    private ProviderClient Client
    {
        get
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            return _llmclient ?? throw new InvalidOperationException("Boot the agent before using its conversation.");
        }
    }
    public ChatHistory History => Client.History;
    public int ContextMax => Client.ContextMax;
    public int CurrentContextTokens => Client.CurrentContextTokens;

    private async Task InitializeProviderAsync(bool startServerIfNotRunning)
    {
        _workspaceLocked = true;
        _llmclient = ProviderFactory is { } factory ? await factory(startServerIfNotRunning) : await this.CreateClient(startServerIfNotRunning);
        _llmclient.ShowSubagentOutput = _showSubagentOutput;
        _llmclient.ConfigureWorkspace(_workspaceOptions);
        if (EnableScenarios) _llmclient.ConfigureSchedules(new(Path.Combine(_workspaceOptions.RootDirectory, ".lilith", "schedules"), _workspaceOptions.Mode), ScenarioRoot);
        foreach (var tool in Tools) _llmclient.RegisterTool(tool);
        if (EnableScenarios) global::Lilith.Core.Scenarios.ScenarioTools.Register(_llmclient, ScenarioRoot);
    }

    public bool EnableScenarios { get; set; } = true;
    public string? ScenarioRoot { get; set; }

    /// <summary>Configure before boot. The template is deliberately not enabled.</summary>
    public List<global::Lilith.Core.Tools.ILilithTool> Tools { get; } = [new global::Lilith.Core.Tools.CurrentTimeTool()];

    private IDisposable EnterOperation(string? bootRequiredMessage = null)
    {
        var operation = _operation.EnterOrThrow("An agent operation is already in progress.");
        try
        {
            ObjectDisposedException.ThrowIf(_disposed, this);
            if (bootRequiredMessage is not null && !IsBooted) throw new InvalidOperationException(bootRequiredMessage);
            return operation;
        }
        catch { operation.Dispose(); throw; }
    }

    public async ValueTask DisposeAsync()
    {
        await StopSchedulingAsync();
        using var operation = await _operation.EnterAsync();
        if (_disposed) return;
        _disposed = true;
        try { if (_tts is not null) await _tts.DisposeAsync(); }
        finally { _llmclient?.Dispose(); IsBooted = false; }
    }
}

Lilith/Core 1 files

ModifiedCore.csproj597 B / 15 lines+2-2+46 B
CSPROJStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>Lilith.Core</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Cronos" Version="0.13.0" />
    <PackageReference Include="KokoroSharp.CPU" Version="0.8.4" />
    <Content Include="Scenarios/Definitions/*.xml" Link="Scenarios/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
  </ItemGroup>
</Project>

Lilith/Core/Kokoro 2 files

DeletedKokoroIdentifiers.cs591 B / 11 lines+0-11-591 B
This file was removed in this version. Metrics describe its previous snapshot.
C#UnchangedRemoved / replacedAdded
using System.Text.RegularExpressions;

namespace Lilith.Core.Kokoro;

internal static class KokoroIdentifiers
{
    private static readonly string[] Digits = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"];
    // Approval/bin hashes are identifiers, not large integers. Keep display/history untouched.
    internal static string Expand(string text) => Regex.Replace(text, @"(?<![A-Za-z0-9])[A-Fa-f0-9]{24,64}(?![A-Za-z0-9])",
        match => string.Join(" ", match.Value.Select(c => c is >= '0' and <= '9' ? Digits[c - '0'] : c.ToString())));
}
ModifiedKokoroTtsSanitizer.cs3,774 B / 68 lines+0-1-48 B
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text;

namespace Lilith.Core.Kokoro;

/// <summary>Conservative speech cleanup; leaves pronunciation of numbers/abbreviations to the phonemizer.</summary>
public static class KokoroTtsSanitizer
{
    public static string Sanitize(string text)
    {
        ArgumentNullException.ThrowIfNull(text);
        // Remove fenced code before interpreting its contents as prose, including an unclosed fence.
        text = KokoroRegex.CodeFence().Replace(text, " ");
        text = KokoroRegex.MarkdownLink().Replace(text, "${label}");
        text = KokoroRegex.WebUrl().Replace(text, match =>
        {
            // Discard the address but retain punctuation ending the surrounding sentence.
            string url = match.Value.TrimEnd(')', ']', '}', '"', '\'');
            int end = url.Length;
            while (end > 0 && ".!?;:".Contains(url[end - 1])) end--;
            return " " + url[end..];
        });
        for (int pass = 0; pass < 3; pass++) text = KokoroRegex.Emphasis().Replace(text, "${value}");
        text = KokoroRegex.StrikeThrough().Replace(text, "${value}").Replace("`", "");

        // Misaki can split surrogate pairs before normalization; spoken input excludes pictographs.
        // Grapheme rendering and the original chat/history text remain untouched.
        var spoken = new StringBuilder(text.Length);
        foreach (var rune in text.EnumerateRunes())
        {
            if (rune.Value is 0x200B or 0x2060 or 0xFEFF or 0x00AD) continue;
            bool unsupported = rune.Utf16SequenceLength == 2 || Rune.GetUnicodeCategory(rune) == UnicodeCategory.OtherSymbol ||
                rune.Value is 0x200D or 0xFE0E or 0xFE0F or 0xFFFD ||
                (Rune.GetUnicodeCategory(rune) == UnicodeCategory.Control && rune.Value is not (9 or 10 or 13));
            spoken.Append(unsupported ? " " : rune.ToString());
        }
        text = spoken.ToString().Normalize(NormalizationForm.FormC).Replace("\r\n", "\n").Replace('\r', '\n');
        var result = new StringBuilder(text.Length);
        bool paragraphBreak = false, previousList = false;
        foreach (string raw in text.Split('\n'))
        {
            string line = raw.Trim();
            if (line.Length == 0) { paragraphBreak = result.Length > 0; continue; }
            bool listItem = KokoroRegex.ListMarker().IsMatch(line);
            bool heading = KokoroRegex.HeadingMarker().IsMatch(line);
            line = KokoroRegex.ListMarker().Replace(line, "");
            line = KokoroRegex.HeadingMarker().Replace(line, "");
            line = KokoroRegex.QuoteMarker().Replace(line, "");
            line = KokoroRegex.Whitespace().Replace(line, " ").Trim();
            if (line.Length == 0) continue;
            if (result.Length > 0)
            {
                // Wrapped prose is joined without new pauses; structural boundaries retain a sentence break.
                int lastIndex = result.Length - 1;
                while (lastIndex > 0 && " )]\"'".Contains(result[lastIndex])) lastIndex--;
                char last = result[lastIndex];
                if ((paragraphBreak || listItem || heading || (previousList && !char.IsWhiteSpace(raw[0]))) && !".!?;:…".Contains(last)) result.Append('.');
                result.Append(' ');
            }
            result.Append(line);
            paragraphBreak = false;
            previousList = listItem || (previousList && char.IsWhiteSpace(raw[0]));
        }
        text = KokoroRegex.Whitespace().Replace(result.ToString(), " ").Trim();
        text = KokoroRegex.RepeatedPunctuation().Replace(text, "$1");
        return KokoroRegex.SpaceBeforePunctuation().Replace(text, "$1");
    }
}

Lilith/Core/Logging 1 files

ModifiedTextLogSink.cs3,325 B / 95 lines+9-0+415 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Logging;

/// <summary>Renders log types consistently; files retain the last update of each progress phase.</summary>
public sealed class TextLogSink(TextWriter writer, bool overwriteProgress = false, Func<int>? width = null)
{
    private readonly object _sync = new();
    private string? _progress;
    private int _drawnLength;
    private bool _inputActive;
    private readonly List<LogEntry> _deferred = [];

    /// <summary>Defer background output until the current console input has finished.</summary>
    public IDisposable BeginInput()
    {
        lock (_sync)
        {
            if (_inputActive) throw new InvalidOperationException("Console input is already active.");
            FinishProgress();
            _inputActive = true;
            return new TextLogInputLease(EndInput);
        }
    }

    public void Handle(LogEntry entry)
    {
        lock (_sync)
        {
            if (_inputActive && entry.Sender == "Scheduler" && entry.Type == LogType.WriteLine)
            {
                // Idle timer notices must be visible without requiring the user to submit input.
                writer.WriteLine();
                writer.WriteLine(entry.Description);
                writer.Write("You: ");
                writer.Flush();
                return;
            }
            if (_inputActive && !(entry.Sender == "Input" && entry.Type == LogType.Write))
            {
                if (entry.Type == LogType.Progress && _deferred.Count > 0 &&
                    _deferred[^1].Type == LogType.Progress && _deferred[^1].Sender == entry.Sender)
                    _deferred[^1] = entry;
                else _deferred.Add(entry);
                return;
            }
            if (entry.Type == LogType.Progress)
            {
                _progress = entry.Description;
                if (overwriteProgress)
                {
                    int available = Math.Max(1, width?.Invoke() ?? 120);
                    string text = entry.Description.Replace('\r', ' ').Replace('\n', ' ');
                    if (text.Length > available) text = text[..available];
                    writer.Write("\r" + text.PadRight(Math.Min(available, Math.Max(_drawnLength, text.Length))));
                    _drawnLength = text.Length;
                    writer.Flush();
                }
                return;
            }

            FinishProgress();
            if (entry.Type == LogType.Write) writer.Write(entry.Description);
            else writer.WriteLine(entry.Description);
            writer.Flush();
        }
    }

    public void Flush()
    {
        lock (_sync)
        {
            FinishProgress();
            writer.Flush();
        }
    }

    private void FinishProgress()
    {
        if (_progress is null) return;
        if (overwriteProgress) writer.WriteLine();
        else writer.WriteLine(_progress);
        _progress = null;
        _drawnLength = 0;
    }

    private void EndInput()
    {
        lock (_sync)
        {
            _inputActive = false;
            if (_deferred.Count > 0) writer.WriteLine();
            foreach (var entry in _deferred) Handle(entry);
            _deferred.Clear();
        }
    }
}

Lilith/Core/Providers 1 files

ModifiedProviderClient.cs18,805 B / 333 lines+28-1+1,558 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Providers;

using System.Text.Json;
using Lilith.Core.Tools;

/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract partial class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
    private readonly bool _ownsHttp = httpClient is null;
    private readonly SemaphoreSlim _operation = new(1, 1);
    private bool _disposed;
    private readonly Dictionary<string, ILilithTool> _tools = new(StringComparer.Ordinal);
    internal IReadOnlyDictionary<string, ILilithTool> RegisteredTools => _tools;
    public Lilith.Core.Workspace.WorkspaceSession? Workspace { get; private set; }
    public Lilith.Core.Scheduling.ScheduleSession? Schedules { get; private set; }
    internal bool ExecutingScheduledJob { get; private set; }
    public void ConfigureSchedules(Lilith.Core.Scheduling.ScheduleOptions options, string? scenarioRoot = null, TimeProvider? clock = null)
    {
        using var operation = EnterOperation();
        if (Schedules is not null) throw new InvalidOperationException("Schedules are fixed for this provider session.");
        Schedules = new(options, scenarioRoot, clock);
    }

    public void RecordScheduleEvent(Lilith.Core.Scheduling.ScheduleEvent item)
    {
        using var operation = EnterOperation();
        // Trusted application envelope; reminder text is data, never a new user instruction.
        History.Add("system", "Scheduler event (data): " + JsonSerializer.Serialize(new { item.Id, item.Name, item.Kind, item.AtUtc, item.Message }));
        SaveCore();
        Logger.WriteLine("Scheduler", item.Message, LogColors.Yellow);
    }

    public async Task<string> RunScheduledScenarioAsync(string name, IReadOnlyDictionary<string, string> inputs, CancellationToken ct = default)
    {
        using var operation = EnterOperation();
        ExecutingScheduledJob = true;
        try { return await RunScenarioCoreAsync(name, inputs, ct); }
        finally { ExecutingScheduledJob = false; }
    }
    public void ConfigureWorkspace(Lilith.Core.Workspace.WorkspaceOptions options)
    {
        using var operation = EnterOperation();
        if (Workspace is not null) throw new InvalidOperationException("Workspace mode and root are fixed for this provider session.");
        Workspace = new(options);
    }
    public async Task<string> ApproveWorkspaceAsync(string id, CancellationToken cancellationToken = default)
    {
        using var operation = EnterOperation();
        using var grant = (Workspace ?? throw new InvalidOperationException("No workspace configured.")).Authorize(id);
        return await RunScenarioCoreAsync("workspace_approve", new Dictionary<string, string> { ["bin_id"] = id }, cancellationToken);
    }
    public event Action<Lilith.Core.Scenarios.ScenarioProgress>? ScenarioProgress;
    internal void ReportScenario(Lilith.Core.Scenarios.ScenarioProgress progress)
    {
        ReportSubagent(progress);
        if (ScenarioProgress is not { } handlers) return;
        foreach (var handler in handlers.GetInvocationList().Cast<Action<Lilith.Core.Scenarios.ScenarioProgress>>())
            try { handler(progress); }
            catch (Exception ex) { Logger.WriteWarning("Scenario", "Progress observer failed: " + ex.Message); }
    }

    /// <summary>Fresh context with borrowed transport; never boots or reenters the parent.</summary>
    public virtual ProviderClient CreateScenarioBranch(string prompt, Logger branchLogger, ChatHistoryStore store) =>
        throw new NotSupportedException("This provider does not support scenario branches.");
    protected object[]? ToolDefinitions => _tools.Count == 0 ? null : _tools.Values.Select(t => (object)new
    {
        type = "function", function = new { name = t.Name, description = t.Description, parameters = t.Parameters }
    }).ToArray();

    public void RegisterTool(ILilithTool tool)
    {
        using var operation = EnterOperation();
        ArgumentNullException.ThrowIfNull(tool);
        if (string.IsNullOrWhiteSpace(tool.Name) || tool.Name.Length > 64 ||
            tool.Name.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '_' or '-')) ||
            tool.Parameters.ValueKind != JsonValueKind.Object)
            throw new ArgumentException("A tool requires a valid function name and JSON parameter schema.", nameof(tool));
        if (!_tools.TryAdd(tool.Name, tool)) throw new ArgumentException($"Tool '{tool.Name}' is already registered.", nameof(tool));
    }
    public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
    public string Model { get; set; } = "gemma4";
    protected int NumCtx { get; set; } = 131072;
    public int ContextMax => NumCtx;
    public string SystemPrompt { get; protected set; } = string.Empty;
    /// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
    public ChatHistory History { get; } = [];
    public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
    public Logger Logger { get; } = logger;
    public string AgentName { get; set; } = "Lilith";
    public int ContextUsagePercent { get; private set; }
    public int CurrentContextTokens { get; private set; }
    public int ContextWarningThreshold { get; set; } = 60;
    public event Action<int>? OnContextHighUsage;

    public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
        OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);

    protected void InitializeConversation()
    {
        History.Add("system", SystemPrompt);
        ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
    }

    private IDisposable EnterOperation()
    {
        var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
        if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
        return operation;
    }

    public void NewConversation()
    {
        using var operation = EnterOperation();
        SaveCore();
        HistoryStore.StartNewSession();
        ClearCore();
        Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
    }

    public void SaveConversation() => SaveCurrentConversation();
    public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
    public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
    public bool LoadConversation(string id) => LoadSavedConversation(id);
    public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }

    private void ClearCore()
    {
        History.Clear();
        ResetUsage();
        if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
    }

    private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
    public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
    public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }

    private void SaveCore()
    {
        try
        {
            if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
                HistoryStore.SaveSession(Model, History);
        }
        catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
    }

    public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
    {
        using var operation = EnterOperation();
        return HistoryStore.ListSessions();
    }

    public bool LoadSavedConversation(int displayIndex)
    {
        using var operation = EnterOperation();
        var sessions = HistoryStore.ListSessions();
        return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
    }

    public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }

    private bool LoadCore(string id)
    {
        string previousId = HistoryStore.CurrentSessionId;
        try
        {
            var session = HistoryStore.LoadSession(id);
            if (session is null) return false;
            var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
            if (messages[0].Role != "system")
            {
                if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
                messages.Insert(0, new ChatMessage("system", SystemPrompt));
            }
            History.Clear();
            History.AddRange(messages);
            ResetUsage();
            return true;
        }
        catch (Exception ex)
        {
            HistoryStore.UseSession(previousId);
            Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
            return false;
        }
    }

    protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);
    protected virtual Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages, CancellationToken cancellationToken) =>
        GenerateReplyAsync(messages);

    public async Task<string> StreamReply(string userInput, List<string>? images = null, CancellationToken cancellationToken = default)
    {
        using var operation = EnterOperation();
        return await StreamReplyCoreAsync(userInput, images, cancellationToken);
    }

    /// <summary>Application-requested scenario dispatch; no model decision is needed to start it.</summary>
    public async Task<string> RunScenarioAsync(string scenarioName, IReadOnlyDictionary<string, string>? inputs = null,
        CancellationToken cancellationToken = default)
    {
        using var operation = EnterOperation();
        return await RunScenarioCoreAsync(scenarioName, inputs, cancellationToken);
    }

    private async Task<string> RunScenarioCoreAsync(string scenarioName, IReadOnlyDictionary<string, string>? inputs,
        CancellationToken cancellationToken)
    {
        if (!_tools.TryGetValue("run_scenario", out var tool) || tool is not Lilith.Core.Scenarios.RunScenarioTool)
            throw new InvalidOperationException("Register scenario support before running a scenario.");
        var arguments = JsonSerializer.SerializeToElement(new { scenario_name = scenarioName, inputs = inputs ?? new Dictionary<string, string>() });
        var call = new ToolCall(new ToolFunction("run_scenario", arguments), "application-" + Guid.NewGuid().ToString("N"));
        var dispatch = new ChatChunk(new ChatMessage("assistant", "", ToolCalls: [call]), true, null, null);
        Logger.WriteLine("System", "Application requested scenario: " + scenarioName, LogColors.Cyan);
        return await StreamReplyCoreAsync(scenarioName == "greeting" ? "Please greet the user using the greeting scenario." : "Run " + scenarioName + " scenario.",
            null, cancellationToken, dispatch);
    }

    private async Task<string> StreamReplyCoreAsync(string userInput, List<string>? images, CancellationToken cancellationToken,
        ChatChunk? applicationDispatch = null)
    {
        bool requireScenarioSuccess = applicationDispatch is not null;
        if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
        var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
        var requestHistory = History.Select(m => m.Snapshot()).ToList();
        requestHistory.Add(userMessage);
        Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
        ChatChunk? chunk;
        int originalCount = requestHistory.Count - 1;
        int rounds = 0;
        while (true)
        {
            cancellationToken.ThrowIfCancellationRequested();
            chunk = applicationDispatch ?? await GenerateReplyAsync(requestHistory, cancellationToken);
            applicationDispatch = null;
            cancellationToken.ThrowIfCancellationRequested();
            if (chunk is not { Done: true, Message.Role: "assistant" } ||
                chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
                throw new InvalidDataException("The model provider returned an invalid assistant response.");
            if (chunk.Message.ToolCalls is not { Count: > 0 } calls) break;
            if (++rounds > 8 || calls.Count > 16)
                throw new InvalidDataException("The model exceeded the tool execution limit.");
            foreach (var call in calls)
                if (call?.Function is null || string.IsNullOrWhiteSpace(call.Function.Name) ||
                    call.Function.Arguments.ValueKind != JsonValueKind.Object)
                    throw new InvalidDataException("The model returned a malformed tool call.");
            if (calls.Count != 1 && calls.Any(c => _tools.TryGetValue(c.Function.Name, out var candidate) && candidate is ITurnResultTool))
                throw new InvalidDataException("Run a scenario in its own tool batch so its final output is unambiguous.");
            requestHistory.Add(chunk.Message with { Content = chunk.Message.Content ?? string.Empty });
            string? exactReply = null;
            foreach (var call in calls)
            {
                cancellationToken.ThrowIfCancellationRequested();
                string result;
                if (!_tools.TryGetValue(call.Function.Name, out var tool))
                    result = JsonSerializer.Serialize(new { error = "Unknown tool: " + call.Function.Name });
                else
                {
                    using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                    deadline.CancelAfter(tool is ITurnResultTool timed ? timed.ExecutionTimeout : TimeSpan.FromSeconds(30));
                    try
                    {
                        // Await actual completion: custom tools must cooperate with cancellation.
                        if (tool is ITurnResultTool turnTool)
                        {
                            var outcome = await turnTool.ExecuteResultAsync(call.Function.Arguments, deadline.Token);
                            deadline.Token.ThrowIfCancellationRequested();
                            if (requireScenarioSuccess && call.Function.Name == "run_scenario")
                            {
                                using var status = JsonDocument.Parse(outcome.Content);
                                string? scenarioStatus = status.RootElement.GetProperty("status").GetString();
                                if (scenarioStatus != "completed" && !(ExecutingScheduledJob && scenarioStatus == "needs_approval"))
                                    throw new InvalidDataException("Configured scenario did not complete: " + outcome.Content);
                            }
                            result = outcome.Content;
                            if (outcome.ExactReply is { } exact)
                            {
                                if (exactReply is not null) throw new InvalidDataException("Only one exact scenario output is allowed per tool batch.");
                                exactReply = exact;
                            }
                        }
                        else result = await tool.ExecuteAsync(call.Function.Arguments, deadline.Token);
                        deadline.Token.ThrowIfCancellationRequested();
                    }
                    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
                    catch (Exception ex) when (!requireScenarioSuccess && ex is not OutOfMemoryException)
                    {
                        result = JsonSerializer.Serialize(new { error = ex is OperationCanceledException ? "Tool execution canceled or timed out." : ex.Message });
                    }
                }
                requestHistory.Add(new ChatMessage("tool", result ?? string.Empty, ToolName: call.Function.Name, ToolCallId: call.Id));
            }
            if (exactReply is not null)
            {
                chunk = new ChatChunk(new ChatMessage("assistant", exactReply), true, null, null);
                break;
            }
        }
        cancellationToken.ThrowIfCancellationRequested();
        if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
            string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
            throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
        string reply = chunk.Message.Content;
        // Commit only after a successful final answer.
        requestHistory[originalCount] = userMessage with { Tokens = chunk.PromptTokens };
        History.AddRange(requestHistory.Skip(originalCount).Select(m => m.Snapshot()));
        History.Add("assistant", reply, chunk.CompletionTokens);
        SaveCore();
        Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
        ResetUsage();
        if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
        {
            CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
            ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
            if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
                foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
                    try { handler(ContextUsagePercent); }
                    catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
        }
        return reply;
    }

    public void Dispose()
    {
        using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
        if (_disposed) return;
        _disposed = true;
        Schedules?.Dispose();
        if (_ownsHttp) Http.Dispose();
        GC.SuppressFinalize(this);
    }

}

Lilith/Core/Scenarios 2 files

ModifiedRunScenarioTool.cs6,543 B / 87 lines+5-0+878 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using System.Xml.Linq;
using Lilith.Core.Tools;

namespace Lilith.Core.Scenarios;

/// <summary>Loads trusted saved XML, fills values as data, and passes the complete document to the executor.</summary>
public sealed class RunScenarioTool(ProviderClient parent, ScenarioFileService files) : ITurnResultTool
{
    public string Name => "run_scenario";
    public string Description => "Run a named saved scenario in a fresh sub-agent conversation. " +
        "Use this when the user says run X scenario. Supply input values in inputs. " +
        "If status is needs_input, ask the user for the missing values before retrying. " +
        "Do not retry a failed scenario without a new user request. " +
        "For exit mode respond, follow exit_request when composing the final answer. " +
        "For workspace data management, always delegate to a workspace scenario. Search with workspace_search and read only selected excerpts. " +
        "Never approve a workspace proposal yourself or change access mode. " +
        "For timers and cron jobs use schedule_create/update/cancel/delete scenarios. Use schedule_peek(name) for remaining time and schedule_list for all schedules. Never approve schedule proposals yourself. " +
        "Schedule create/update take name, kind (timer, interval or cron), seconds (whole seconds for timer/interval), cron (five fields), timezone (zone ID), message (reminder), scenario (optional saved action) and inputs_json (JSON string object for its inputs). Updates replace the complete definition. " +
        "Available scenarios and input names: " + string.Join("; ", files.ListNames().Select(name =>
        { var scenario = ScenarioXmlParser.Parse(files.LoadScenarioXml(name)); return name + "(" + string.Join(", ", scenario.Inputs.Select(i => i.Name + (i.Required ? " required" : ""))) + ")"; }));
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new
    {
        type = "object", properties = new
        {
            scenario_name = new { type = "string", description = "Saved scenario name, without .xml." },
            inputs = new { type = "object", additionalProperties = new { type = "string" }, description = "Named scenario input values." }
        }, required = new[] { "scenario_name" }, additionalProperties = false
    });
    public TimeSpan ExecutionTimeout => TimeSpan.FromMinutes(10);
    public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
        (await ExecuteResultAsync(arguments, cancellationToken)).Content;

    public async Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
    {
        if (arguments.ValueKind != JsonValueKind.Object || !arguments.TryGetProperty("scenario_name", out var name) || name.ValueKind != JsonValueKind.String ||
            arguments.EnumerateObject().Any(p => p.Name is not ("scenario_name" or "inputs")))
            throw new ArgumentException("Expected scenario_name and optional inputs.");
        string xml = files.LoadScenarioXml(name.GetString()!);
        var definition = ScenarioXmlParser.Parse(xml);
        var supplied = new Dictionary<string, string>(StringComparer.Ordinal);
        if (arguments.TryGetProperty("inputs", out var inputValues))
        {
            if (inputValues.ValueKind != JsonValueKind.Object) throw new ArgumentException("inputs must be an object of string values.");
            foreach (var property in inputValues.EnumerateObject())
            {
                if (property.Value.ValueKind != JsonValueKind.String || !definition.Inputs.Any(i => i.Name == property.Name) ||
                    !supplied.TryAdd(property.Name, property.Value.GetString()!) || supplied[property.Name].Length > 20000)
                    throw new ArgumentException("Unknown, duplicate, or invalid scenario input: " + property.Name);
            }
        }
        var document = ScenarioXmlParser.Read(xml);
        foreach (var input in document.Root!.Element("inputs")?.Elements("input") ?? [])
            if (supplied.TryGetValue((string)input.Attribute("name")!, out var value)) input.SetAttributeValue("value", value);
        string fullXml = document.ToString(SaveOptions.DisableFormatting);
        var filled = ScenarioXmlParser.Parse(fullXml);
        string[] missing = filled.Inputs.Where(i => i.Required && string.IsNullOrWhiteSpace(i.Value)).Select(i => i.Name).ToArray();
        if (missing.Length > 0)
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "needs_input", scenario = definition.Id, missing,
                request = "Ask the user for these values. No scenario steps have run." }),
                "Please provide the following values for the " + definition.Title + " scenario: " + string.Join(", ", missing) + ".");
        ScenarioRunResult run;
        try { run = await new ScenarioExecutor(parent).RunXmlAsync(fullXml, cancellationToken); }
        catch (ScenarioExecutionException ex)
        {
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = "failed", run_id = ex.RunId, error = ex.Message }));
        }
        if (run.Schedule is { } schedule)
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = schedule.Status, run_id = run.RunId, result = schedule,
                exit_mode = run.ExitMode, exit_request = run.ExitRequest }), schedule.Status == "needs_approval" || run.ExitMode == "copy" ? schedule.ToReply() : null);
        if (run.Workspace is { } storage)
            return new ToolExecutionResult(JsonSerializer.Serialize(new { status = storage.Status, run_id = run.RunId, result = storage,
                exit_mode = run.ExitMode, exit_request = run.ExitRequest }),
                storage.Status == "needs_approval" || run.ExitMode == "copy" ? storage.ToReply() : null);
        return new ToolExecutionResult(JsonSerializer.Serialize(new
        {
            status = "completed", run_id = run.RunId, scenario = run.ScenarioId, output = run.Output,
            exit_mode = run.ExitMode, exit_request = run.ExitRequest
        }), run.ExitMode == "copy" ? run.Output : null);
    }
}

public static class ScenarioTools
{
    public static void Register(ProviderClient provider, string? scenarioRoot = null) =>
        provider.RegisterTool(new RunScenarioTool(provider, new ScenarioFileService(scenarioRoot)));
}
ModifiedScenarioExecutor.cs7,351 B / 95 lines+10-4+1,173 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Tools;

namespace Lilith.Core.Scenarios;

public sealed record ScenarioRunResult(string RunId, string ScenarioId, string Output, string ExitMode, string ExitRequest, Lilith.Core.Workspace.WorkspaceResult? Workspace = null, Lilith.Core.Scheduling.ScheduleResult? Schedule = null);
public sealed class ScenarioExecutionException(string runId, Exception inner)
    : Exception(inner.Message, inner)
{
    public string RunId { get; } = runId;
}

public sealed class ScenarioExecutor(ProviderClient parent)
{
    /// <summary>Application API for trusted full XML. Model calls should use the saved-file scenario tool.</summary>
    public async Task<ScenarioRunResult> RunXmlAsync(string completeXml, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var scenario = ScenarioXmlParser.Parse(completeXml);
        string[] missing = scenario.Inputs.Where(i => i.Required && string.IsNullOrWhiteSpace(i.Value)).Select(i => i.Name).ToArray();
        if (missing.Length > 0) throw new ArgumentException("Missing required scenario inputs: " + string.Join(", ", missing));
        var values = scenario.Inputs.ToDictionary(i => i.Name, i => i.Value ?? string.Empty, StringComparer.Ordinal);
        // Validate every substitution/tool reference before compiling code or running a chat turn.
        string prompt = ScenarioXmlParser.Expand(scenario.SystemPrompt, values);
        string[] steps = scenario.Steps.Select(s => ScenarioXmlParser.Expand(s, values)).ToArray();
        string exit = ScenarioXmlParser.Expand(scenario.ExitRequest, values);
        var workspaceNames = scenario.AllowedTools.Where(n => Lilith.Core.Workspace.WorkspaceTool.Operation(n) is not null).ToArray();
        var scheduleNames = scenario.AllowedTools.Where(n => Lilith.Core.Scheduling.ScheduleTool.Operation(n) is not null).ToArray();
        if (scheduleNames.Length > 0 && (parent.Schedules is null || parent.ExecutingScheduledJob || scenario.Steps.Length != 1 || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0))
            throw new InvalidDataException("Schedule scenarios require a configured scheduler, one step, one schedule operation and no embedded code; scheduled jobs cannot manage schedules.");
        if (workspaceNames.Length > 0 && (parent.Workspace is null || scenario.Steps.Length != 1 || scenario.AllowedTools.Length != 1 || scenario.CodeTools.Length != 0))
            throw new InvalidDataException("Workspace scenarios require a configured workspace, one step, one workspace operation, and no embedded code.");
        foreach (string tool in scenario.AllowedTools)
            if (!workspaceNames.Contains(tool) && !scheduleNames.Contains(tool) && (!parent.RegisteredTools.ContainsKey(tool) || parent.RegisteredTools[tool] is ITurnResultTool))
                throw new InvalidDataException("Unavailable or nested scenario tool: " + tool);
        string runId = Guid.NewGuid().ToString("N");
        string folder = Path.Combine(parent.HistoryStore.RootDirectory, "Scenarios", runId);
        Directory.CreateDirectory(folder);
        using var transcript = new StreamWriter(Path.Combine(folder, "session.log")) { AutoFlush = true };
        await File.WriteAllTextAsync(Path.Combine(folder, "scenario.xml"), completeXml, cancellationToken);
        void Report(string kind, string text, int step)
        {
            transcript.WriteLine($"{DateTimeOffset.UtcNow:O} [{kind}] {text}");
            parent.ReportScenario(new ScenarioProgress(runId, scenario.Id, kind, text, step, steps.Length));
        }
        Report("Started", scenario.Title, 0);
        using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        deadline.CancelAfter(TimeSpan.FromMinutes(10));
        var ct = deadline.Token;
        try
        {
            using var compiler = await ScenarioCodeCompiler.CompileAsync(scenario.CodeTools, values, ct);
            using var child = parent.CreateScenarioBranch(prompt, new Logger(), new ChatHistoryStore(Path.Combine(folder, "History")));
            child.AgentName = "Scenario:" + scenario.Id;
            Lilith.Core.Workspace.WorkspaceResult? storageResult = null;
            Lilith.Core.Scheduling.ScheduleResult? scheduleResult = null;
            foreach (string name in scenario.AllowedTools)
            {
                if (workspaceNames.Contains(name)) child.RegisterTool(new Lilith.Core.Workspace.WorkspaceTool(parent.Workspace!, Lilith.Core.Workspace.WorkspaceTool.Operation(name)!, values, result => storageResult = result));
                else if (scheduleNames.Contains(name)) child.RegisterTool(new Lilith.Core.Scheduling.ScheduleTool(parent.Schedules!, Lilith.Core.Scheduling.ScheduleTool.Operation(name)!, values, result => scheduleResult = result));
                else child.RegisterTool(parent.RegisteredTools[name]);
            }
            foreach (var tool in compiler.Tools) child.RegisterTool(tool);
            string? last = null;
            for (int i = 0; i < steps.Length; i++)
            {
                ct.ThrowIfCancellationRequested();
                Report("Step", $"{i + 1}/{steps.Length}: {steps[i]}", i + 1);
                int historyCount = child.History.Count;
                last = await child.StreamReply(steps[i], cancellationToken: ct);
                foreach (var toolResult in child.History.Skip(historyCount).Where(m => m.Role == "tool"))
                    Report("Tool", toolResult.ToolName + ": " + toolResult.Content, i + 1);
                Report("Reply", last, i + 1);
            }
            ct.ThrowIfCancellationRequested();
            if (string.IsNullOrWhiteSpace(last)) throw new InvalidDataException("Scenario produced no final response.");
            if (workspaceNames.Length > 0 && storageResult is null) throw new InvalidDataException("The scenario did not perform its workspace operation. No completion is claimed.");
            if (scheduleNames.Length > 0 && scheduleResult is null) throw new InvalidDataException("The scenario did not perform its schedule operation. No completion is claimed.");
            var result = new ScenarioRunResult(runId, scenario.Id, last, scenario.ExitMode, exit, storageResult, scheduleResult);
            await File.WriteAllTextAsync(Path.Combine(folder, "result.json"), JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }), ct);
            Report(storageResult?.Status == "needs_approval" || scheduleResult?.Status == "needs_approval" ? "NeedsApproval" : "Completed", "Result returned to caller.", steps.Length);
            return result;
        }
        catch (Exception ex)
        {
            await File.WriteAllTextAsync(Path.Combine(folder, "failure.json"), JsonSerializer.Serialize(new
            {
                RunId = runId, ScenarioId = scenario.Id, Status = ex is OperationCanceledException ? "canceled" : "failed", Error = ex.Message
            }));
            Report(ex is OperationCanceledException ? "Canceled" : "Failed", ex.Message, 0);
            if (ex is OperationCanceledException) throw;
            throw new ScenarioExecutionException(runId, ex);
        }
    }
}

Lilith/Core/Scenarios/Definitions 7 files

NEWschedule_cancel.xml498 B / 7 lines+7-0+498 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_cancel" title="Schedule cancel">
  <inputs><input name="name" required="true" /></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_cancel_operation" /></tools>
  <steps><step>Call schedule_cancel_operation with {} now.</step></steps>
  <exit mode="copy">Return the verified application result.</exit>
</scenario>
NEWschedule_create.xml751 B / 7 lines+7-0+751 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_create" title="Schedule create">
  <inputs><input name="name" required="true" /><input name="kind" default="timer" /><input name="seconds" default="0" /><input name="cron" default="" /><input name="timezone" default="" /><input name="message" default="" /><input name="scenario" default="" /><input name="inputs_json" default="{}" /></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_create_operation" /></tools>
  <steps><step>Call schedule_create_operation with {} now.</step></steps>
  <exit mode="copy">Return the verified application result.</exit>
</scenario>
NEWschedule_delete.xml498 B / 7 lines+7-0+498 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_delete" title="Schedule delete">
  <inputs><input name="name" required="true" /></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_delete_operation" /></tools>
  <steps><step>Call schedule_delete_operation with {} now.</step></steps>
  <exit mode="copy">Return the verified application result.</exit>
</scenario>
NEWschedule_list.xml559 B / 7 lines+7-0+559 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_list" title="Schedule list">
  <inputs></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_list_operation" /></tools>
  <steps><step>Call schedule_list_operation with {} now.</step></steps>
  <exit mode="respond">Answer the user using the verified schedule status, including remaining_seconds or next UTC time when relevant. Do not invent an active timer.</exit>
</scenario>
NEWschedule_peek.xml596 B / 7 lines+7-0+596 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_peek" title="Schedule peek">
  <inputs><input name="name" required="true" /></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_peek_operation" /></tools>
  <steps><step>Call schedule_peek_operation with {} now.</step></steps>
  <exit mode="respond">Answer the user using the verified schedule status, including remaining_seconds or next UTC time when relevant. Do not invent an active timer.</exit>
</scenario>
NEWschedule_pending.xml571 B / 7 lines+7-0+571 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_pending" title="Schedule pending">
  <inputs></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_pending_operation" /></tools>
  <steps><step>Call schedule_pending_operation with {} now.</step></steps>
  <exit mode="respond">Answer the user using the verified schedule status, including remaining_seconds or next UTC time when relevant. Do not invent an active timer.</exit>
</scenario>
NEWschedule_update.xml751 B / 7 lines+7-0+751 B
XMLStructureInputsTools / C#StepsExitUnchangedRemoved / replacedAdded
<scenario schemaVersion="1" id="schedule_update" title="Schedule update">
  <inputs><input name="name" required="true" /><input name="kind" default="timer" /><input name="seconds" default="0" /><input name="cron" default="" /><input name="timezone" default="" /><input name="message" default="" /><input name="scenario" default="" /><input name="inputs_json" default="{}" /></inputs>
  <systemPrompt>Manage the schedule using the bound inputs. Call the provided operation exactly once with {}. Never invent success.</systemPrompt>
  <tools><allow name="schedule_update_operation" /></tools>
  <steps><step>Call schedule_update_operation with {} now.</step></steps>
  <exit mode="copy">Return the verified application result.</exit>
</scenario>

Lilith/Core/Scheduling 4 files

NEWScheduleDelivery.cs1,794 B / 35 lines+35-0+1,794 B
C#UnchangedRemoved / replacedAdded
namespace Lilith.Core.Scheduling;

/// <summary>The host calls this only while it owns conversation and narration access.</summary>
public static class ScheduleDelivery
{
    public static async Task DrainAsync(ProviderClient provider, Func<string, CancellationToken, Task> narrate,
        CancellationToken ct = default)
    {
        var schedules = provider.Schedules ?? throw new InvalidOperationException("Configure schedules first.");
        for (int i = 0; i < 10 && schedules.PeekEvent() is { } item; i++)
        {
            ct.ThrowIfCancellationRequested();
            provider.RecordScheduleEvent(item);
            string? failure = null;
            Exception? speechFailure = null;
            try
            {
                // A speech failure must not prevent the scheduled action from running.
                try { await narrate(item.Message, ct); }
                catch (Exception ex) when (ex is not OperationCanceledException)
                { speechFailure = ex; }
                if (item.Job is { Scenario.Length: > 0 } job)
                {
                    string reply = await provider.RunScheduledScenarioAsync(job.Scenario, job.Inputs, ct);
                    try { await narrate(reply, ct); }
                    catch (Exception ex) when (ex is not OperationCanceledException) { speechFailure = ex; }
                }
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
            catch (Exception ex) { failure = ex.Message; }
            schedules.CompleteEvent(item.Id, failure);
            if (speechFailure is not null) throw new InvalidOperationException("Schedule speech failed; the action outcome was retained.", speechFailure);
        }
    }
}
NEWScheduleModels.cs1,600 B / 27 lines+27-0+1,600 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Workspace;

namespace Lilith.Core.Scheduling;

public sealed record ScheduleOptions(string RootDirectory, WorkspaceAccessMode Mode = WorkspaceAccessMode.Ask);
public sealed record ScheduleRequest(string Operation, string Name = "", string Kind = "timer", int Seconds = 0,
    string Cron = "", string TimeZone = "", string Message = "", string Scenario = "", string InputsJson = "{}");
public sealed record ScheduleEntry(string Name, string Kind, int Seconds, string Cron, string TimeZone,
    string Message, string Scenario, Dictionary<string, string> Inputs, DateTimeOffset? NextUtc, string Status, int Revision,
    string? LastOutcome = null, DateTimeOffset? LastFinishedUtc = null);
public sealed record ScheduleProposal(string Id, ScheduleRequest Request, int? ExpectedRevision);
public sealed record ScheduleEvent(string Id, string Name, string Kind, DateTimeOffset AtUtc, string Message,
    ScheduleEntry? Job = null);
public sealed record ScheduleResult(string Status, string Message, string? ProposalId = null, object? Data = null)
{
    public string ToReply() => Status == "needs_approval"
        ? $"{Message}\nReview with /schedules review {ProposalId}; then /schedule-approve {ProposalId} or /schedule-reject {ProposalId}."
        : Data is null ? Message : JsonSerializer.Serialize(this);
}

internal sealed class ScheduleState
{
    public List<ScheduleEntry> Entries { get; set; } = [];
    public List<ScheduleProposal> Pending { get; set; } = [];
    public List<ScheduleEvent> Events { get; set; } = [];
}
NEWScheduleSession.cs11,259 B / 225 lines+225-0+11,259 B
C#UnchangedRemoved / replacedAdded
using System.Text.Json;
using Lilith.Core.Scenarios;
using Lilith.Core.Scheduling.Extensions;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;

namespace Lilith.Core.Scheduling;

/// <summary>Persistent, single-host schedule state. Due detection never calls the conversation.</summary>
public sealed class ScheduleSession : IDisposable
{
    private readonly object _sync = new();
    private readonly FileStream _lease;
    private readonly string _file;
    private readonly ScenarioFileService _files;
    private readonly TimeProvider _clock;
    private ScheduleState _state;
    private bool _disposed;
    private bool _faulted;
    public ScheduleOptions Options { get; }

    public ScheduleSession(ScheduleOptions options, string? scenarioRoot = null, TimeProvider? clock = null)
    {
        if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown schedule access mode.");
        Options = options with { RootDirectory = Path.GetFullPath(options.RootDirectory) };
        _clock = clock ?? TimeProvider.System;
        _files = new(scenarioRoot);
        Options.RootDirectory.EnsureNoLinks();
        Directory.CreateDirectory(Options.RootDirectory);
        _lease = new(Path.Combine(Options.RootDirectory, "host.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
        _file = Path.Combine(Options.RootDirectory, "schedules.json");
        try
        {
            _state = File.Exists(_file) ? JsonSerializer.Deserialize<ScheduleState>(File.ReadAllText(_file))
                ?? throw new InvalidDataException("Invalid schedule state.") : new();
            Recover();
        }
        catch { _lease.Dispose(); throw; }
    }

    public ScheduleResult Execute(ScheduleRequest request)
    {
        lock (_sync)
        {
            ThrowIfDisposed();
            return request.Operation switch
            {
                "list" => new("completed", "Schedule status.", Data: _state.Entries.Take(100).Select(e => View(e, false)).ToArray()),
                "peek" => new("completed", "Schedule status.", Data: View(Find(request.Name))),
                "pending" => new("completed", "Pending schedule changes.", Data: _state.Pending.Select(p => new { p.Id, p.Request.Operation, p.Request.Name }).ToArray()),
                "create" or "update" or "cancel" or "delete" => PrepareOrCommit(request),
                _ => throw new ArgumentException("Unknown schedule operation.")
            };
        }
    }

    public ScheduleProposal Review(string id)
    {
        lock (_sync) { ThrowIfDisposed(); return _state.Pending.SingleOrDefault(p => p.Id == id) ?? throw new ArgumentException("Unknown proposal."); }
    }

    // Host-only APIs: neither approval nor rejection is registered as a model tool.
    public ScheduleResult Approve(string id)
    {
        lock (_sync)
        {
            var proposal = Review(id);
            var existing = _state.Entries.SingleOrDefault(e => e.Name.Equals(proposal.Request.Name, StringComparison.OrdinalIgnoreCase));
            if (existing?.Revision != proposal.ExpectedRevision) throw new InvalidOperationException("Schedule changed. Reject this proposal and prepare a new one.");
            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(); }
    }

    public void Tick()
    {
        lock (_sync)
        {
            ThrowIfDisposed();
            var now = _clock.GetUtcNow();
            var due = _state.Entries.Where(e => e.Status == "active" && e.NextUtc <= now).ToArray();
            foreach (var entry in due)
            {
                // One outstanding run per named job; coalesce late intervals rather than replaying a backlog.
                bool outstanding = _state.Events.Any(e => e.Job?.Name == entry.Name);
                Replace(entry with
                {
                    NextUtc = entry.Kind == "timer" ? null : entry.NextAfter(now),
                    Status = entry.Kind == "timer" ? "fired" : "active",
                    Revision = entry.Revision + 1
                });
                if (!outstanding) AddEvent(entry, "fired", $"{entry.Name} fired. {entry.Message}", entry);
            }
            if (due.Length > 0) Save();
        }
    }

    public ScheduleEvent? PeekEvent()
    {
        lock (_sync) { ThrowIfDisposed(); return _state.Events.FirstOrDefault(); }
    }

    public void CompleteEvent(string id, string? failure = null)
    {
        lock (_sync)
        {
            ThrowIfDisposed();
            var item = _state.Events.Single(e => e.Id == id);
            _state.Events.Remove(item);
            if (item.Job is { } job)
            {
                var entry = Find(job.Name);
                Replace(entry with { Status = entry.Kind == "timer" ? (failure is null ? "completed" : "failed") : entry.Status,
                    LastOutcome = failure is null ? "completed" : failure[..Math.Min(2000, failure.Length)], LastFinishedUtc = _clock.GetUtcNow() });
                AddEvent(job, failure is null ? "completed" : "failed",
                    failure is null ? $"{job.Name} finished." : $"{job.Name} failed: {failure}");
            }
            Save();
        }
    }

    private void Recover()
    {
        var now = _clock.GetUtcNow();
        var interrupted = _state.Events.Where(e => e.Job is not null).Select(e => e.Job!.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
        _state.Events.RemoveAll(e => e.Job is not null);
        foreach (var entry in _state.Entries.ToArray())
        {
            bool overdue = entry.Status == "active" && entry.NextUtc <= now;
            if (!overdue && !interrupted.Contains(entry.Name)) continue;
            if (!_state.Events.Any(e => e.Name == entry.Name && e.Kind == "missed"))
                AddEvent(entry, "missed", $"{entry.Name}: missed or interrupted occurrence while Lilith was closed. Its action was not replayed.");
            if (overdue || (interrupted.Contains(entry.Name) && entry.Kind == "timer")) Replace(entry with { NextUtc = entry.Kind == "timer" ? null : entry.NextAfter(now),
                Status = entry.Kind == "timer" ? "missed" : "active", Revision = entry.Revision + 1 });
        }
        Save();
    }

    private ScheduleResult PrepareOrCommit(ScheduleRequest request)
    {
        Validate(request);
        if (Options.Mode == WorkspaceAccessMode.Yolo) { var result = Commit(request); Save(); return result; }
        if (_state.Pending.Count >= 100) throw new InvalidOperationException("Review existing pending changes first.");
        var existing = _state.Entries.SingleOrDefault(e => e.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
        string id = Guid.NewGuid().ToString("N");
        _state.Pending.Add(new(id, request, existing?.Revision));
        Save();
        return new("needs_approval", $"Approve schedule {request.Operation}: {request.Name}.", id, request);
    }

    private void Validate(ScheduleRequest request)
    {
        switch (request.Operation)
        {
            case "create":
                if (_state.Entries.Count >= 100 || _state.Entries.Any(e => e.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)))
                    throw new ArgumentException("Schedule name exists or the 100-schedule limit was reached.");
                _ = request.CreateEntry(_clock.GetUtcNow(), 1, _files);
                break;
            case "update":
                _ = request.CreateEntry(_clock.GetUtcNow(), Find(request.Name).Revision + 1, _files);
                goto case "cancel";
            case "cancel":
            case "delete":
                _ = Find(request.Name);
                if (_state.Events.Any(e => e.Job?.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase) == true))
                    throw new InvalidOperationException("This occurrence is queued or running; wait for its completion before changing it.");
                break;
            default: throw new ArgumentException("Unknown mutation.");
        }
    }

    private ScheduleResult Commit(ScheduleRequest request)
    {
        switch (request.Operation)
        {
            case "create": _state.Entries.Add(request.CreateEntry(_clock.GetUtcNow(), 1, _files)); break;
            case "update": Replace(request.CreateEntry(_clock.GetUtcNow(), Find(request.Name).Revision + 1, _files)); break;
            case "cancel": var entry = Find(request.Name); Replace(entry with { Status = "canceled", NextUtc = null, Revision = entry.Revision + 1 }); break;
            case "delete": _state.Entries.Remove(Find(request.Name)); break;
            default: throw new ArgumentException("Unknown mutation.");
        }
        return new("completed", $"Schedule {request.Operation} completed: {request.Name}.");
    }

    private object View(ScheduleEntry entry) => View(entry, true);
    private object View(ScheduleEntry entry, bool detail)
    {
        double? remaining = entry.NextUtc is { } next ? Math.Max(0, (next - _clock.GetUtcNow()).TotalSeconds) : null;
        bool queued = _state.Events.Any(e => e.Job?.Name == entry.Name);
        return detail
            ? new { entry.Name, entry.Kind, entry.Status, entry.NextUtc, remaining_seconds = remaining,
                entry.TimeZone, entry.Cron, entry.Seconds, entry.Message, entry.Scenario, entry.LastOutcome, entry.LastFinishedUtc, queued_or_running = queued }
            : new { entry.Name, entry.Kind, entry.Status, entry.NextUtc, remaining_seconds = remaining, queued_or_running = queued };
    }
    private ScheduleEntry Find(string name) => _state.Entries.SingleOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
        ?? throw new ArgumentException("Unknown schedule: " + name);
    private void Replace(ScheduleEntry entry) { _state.Entries.Remove(Find(entry.Name)); _state.Entries.Add(entry); }
    private void AddEvent(ScheduleEntry entry, string kind, string message, ScheduleEntry? job = null) =>
        _state.Events.Add(new(Guid.NewGuid().ToString("N"), entry.Name, kind, _clock.GetUtcNow(), message, job));
    private void Save()
    {
        string temp = _file + "." + Guid.NewGuid().ToString("N") + ".tmp";
        try
        {
            File.WriteAllText(temp, JsonSerializer.Serialize(_state, new JsonSerializerOptions { WriteIndented = true }));
            File.Move(temp, _file, true);
        }
        catch { _faulted = true; throw; }
    }
    private void ThrowIfDisposed()
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_faulted) throw new IOException("Schedule storage failed. Fix storage and restart before continuing.");
    }
    public void Dispose() { lock (_sync) { if (_disposed) return; _disposed = true; _lease.Dispose(); } }
}
NEWScheduleTool.cs2,134 B / 29 lines+29-0+2,134 B
C#UnchangedRemoved / replacedAdded
using System.Globalization;
using System.Text.Json;
using Lilith.Core.Tools;

namespace Lilith.Core.Scheduling;

internal sealed class ScheduleTool(ScheduleSession session, string operation, IReadOnlyDictionary<string, string> inputs,
    Action<ScheduleResult> completed) : ITurnResultTool
{
    public string Name => "schedule_" + operation + "_operation";
    public string Description => "Perform the scenario's schedule operation once using its bound inputs. Call with {}. Never invent success.";
    public TimeSpan ExecutionTimeout => TimeSpan.FromSeconds(30);
    public JsonElement Parameters => JsonSerializer.SerializeToElement(new { type = "object", properties = new { }, additionalProperties = false });
    public async Task<string> ExecuteAsync(JsonElement arguments, CancellationToken cancellationToken = default) =>
        (await ExecuteResultAsync(arguments, cancellationToken)).Content;
    public Task<ToolExecutionResult> ExecuteResultAsync(JsonElement arguments, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (arguments.ValueKind != JsonValueKind.Object || arguments.EnumerateObject().Any()) throw new ArgumentException("Expected an empty argument object.");
        string Get(string name, string fallback = "") => inputs.GetValueOrDefault(name, fallback);
        if (!int.TryParse(Get("seconds", "0"), NumberStyles.None, CultureInfo.InvariantCulture, out int seconds)) throw new ArgumentException("Invalid seconds.");
        var result = session.Execute(new(operation, Get("name"), Get("kind", "timer"), seconds, Get("cron"), Get("timezone"), Get("message"), Get("scenario"), Get("inputs_json", "{}")));
        completed(result);
        string json = JsonSerializer.Serialize(result);
        return Task.FromResult(new ToolExecutionResult(json, json));
    }
    internal static readonly string[] Operations = ["create", "update", "cancel", "delete", "list", "peek", "pending"];
    internal static string? Operation(string name) => Operations.FirstOrDefault(op => name == "schedule_" + op + "_operation");
}

Lilith/Core/Scheduling/Extensions 1 files

NEWScheduleExtensions.cs3,282 B / 44 lines+44-0+3,282 B
C#UnchangedRemoved / replacedAdded
using Cronos;
using Lilith.Core.Scenarios;
using System.Text.Json;

namespace Lilith.Core.Scheduling.Extensions;

internal static class ScheduleExtensions
{
    internal static ScheduleEntry CreateEntry(this ScheduleRequest request, DateTimeOffset now, int revision, ScenarioFileService files)
    {
        if (!ScenarioXmlParser.ValidName(request.Name) || request.Name.Length > 64)
            throw new ArgumentException("Use a schedule name of up to 64 letters, digits, underscores or hyphens.");
        if (request.Message.Length > 2000 || request.InputsJson.Length > 20000 || request.Cron.Length > 128 || request.TimeZone.Length > 128)
            throw new ArgumentException("Schedule input is too large.");
        var inputs = JsonSerializer.Deserialize<Dictionary<string, string>>(request.InputsJson) ?? throw new ArgumentException("Expected scenario input object.");
        if (inputs.Any(p => p.Value is null)) throw new ArgumentException("Scenario inputs must be strings.");
        if (request.Scenario.Length > 0)
        {
            if (request.Scenario.StartsWith("schedule_", StringComparison.Ordinal)) throw new ArgumentException("Scheduled jobs cannot manage schedules.");
            var definition = ScenarioXmlParser.Parse(files.LoadScenarioXml(request.Scenario));
            if (definition.AllowedTools.Any(n => ScheduleTool.Operation(n) is not null)) throw new ArgumentException("Scheduled jobs cannot manage schedules.");
            if (inputs.Keys.Any(k => !definition.Inputs.Any(i => i.Name == k)) ||
                definition.Inputs.Any(i => i.Required && string.IsNullOrWhiteSpace(inputs.TryGetValue(i.Name, out var value) ? value : i.Value)))
                throw new ArgumentException("Unknown or missing scheduled scenario inputs.");
        }
        else if (string.IsNullOrWhiteSpace(request.Message)) throw new ArgumentException("A reminder message or saved scenario is required.");
        string zone = string.IsNullOrWhiteSpace(request.TimeZone) ? TimeZoneInfo.Local.Id : request.TimeZone;
        _ = TimeZoneInfo.FindSystemTimeZoneById(zone);
        var entry = new ScheduleEntry(request.Name, request.Kind, request.Seconds, request.Cron, zone,
            request.Message, request.Scenario, inputs, null, "active", revision);
        return entry with { NextUtc = entry.NextAfter(now) ?? throw new ArgumentException("Schedule has no future occurrence.") };
    }

    internal static DateTimeOffset? NextAfter(this ScheduleEntry entry, DateTimeOffset now) => entry.Kind switch
    {
        "timer" or "interval" when entry.Seconds is >= 1 and <= 31536000 => now.AddSeconds(entry.Seconds),
        "cron" => ParseCron(entry.Cron).GetNextOccurrence(now, TimeZoneInfo.FindSystemTimeZoneById(entry.TimeZone))?.ToUniversalTime(),
        _ => throw new ArgumentException("Choose timer or interval with 1–31536000 seconds, or a five-field cron expression.")
    };

    private static CronExpression ParseCron(string expression) => expression.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 5
        ? CronExpression.Parse(expression, CronFormat.Standard)
        : throw new ArgumentException("Cron requires exactly five fields: minute hour day month weekday.");
}