← All source files

V16 / 6df58cd3ed86

ReleaseRuntime.cs

Interfaces/Gui/LilithGui.Worker/ReleaseRuntime.cs

Changed in this version
CS
using System.Collections;
using System.Reflection;
using System.Text.Json;
using System.Xml.Linq;
using static Lilith.Gui.Worker.ReflectionApi;

namespace Lilith.Gui.Worker;

internal sealed partial class ReleaseRuntime(Assembly core,Assembly agentAssembly,string backend,string state,Action<string,object?> emit) : IAsyncDisposable
{
    private object? _agent,_provider;
    private bool _attached,_speech,_kanbanAttached;
    private bool _desktopResearchAttached;
    private readonly List<object> _trace=[];
    private readonly HashSet<string> _reportedTools=new(StringComparer.Ordinal);
    private void CaptureToolResults(bool publish=true)
    {
        if(Get(_agent,"History") is not IEnumerable history)return;
        int index=0;
        foreach(var message in history.Cast<object>())
        {
            index++;
            if(Get(message,"Role")?.ToString()!="tool")continue;
            string? id=Get(message,"ToolCallId")?.ToString();
            if(string.IsNullOrEmpty(id))id="legacy-"+index+"-"+Get(message,"ToolName");
            if(!_reportedTools.Add(id))continue;
            if(!publish)continue; // Loading saved evidence is not a new execution.
            string output=Get(message,"Content")?.ToString()??"";
            emit("tool",new{id,name=Get(message,"ToolName")?.ToString()??"tool",kind="Result",text=output[..Math.Min(6000,output.Length)],truncated=output.Length>6000});
        }
    }
    private readonly object _traceLock=new();
    private static string Text(JsonElement p,string key,string fallback="")=>p.TryGetProperty(key,out var v)?v.ToString():fallback;
    private Type Type(string name)=>core.GetType(name)??throw new NotSupportedException("Feature unavailable in this release: "+name);
    private void Trace(object value){lock(_traceLock){_trace.Add(value);if(_trace.Count>1000)_trace.RemoveAt(0);}emit("trace",value);}
    private void Log(object value)
    {
        AttachProvider();
        string sender=Get(value,"Sender")?.ToString()??"System",text=Get(value,"Description")?.ToString()??"";
        if(text.StartsWith("Speech unavailable:",StringComparison.Ordinal)){_speech=false;emit("speech",new{enabled=false,message=text});}
        // Legacy console descriptions contain their own labels. Keep raw text for
        // legacy message detection, but expose one sender and an unlabelled body.
        string traceSender=sender.Equals("System",StringComparison.OrdinalIgnoreCase)?"System":sender;
        string traceText=text;
        while(traceText.StartsWith(traceSender+": ",StringComparison.OrdinalIgnoreCase))
            traceText=traceText[(traceSender.Length+2)..];
        if(traceSender.StartsWith("Subagent ",StringComparison.Ordinal)&&traceText.StartsWith(traceSender+" ",StringComparison.Ordinal))
            traceText=traceText[(traceSender.Length+1)..];
        Trace(new{sender=traceSender,text=traceText,at=Get(value,"Timestamp")});
        string name=Get(_provider,"AgentName")?.ToString()??"Lilith";
        if(sender is "Scheduler" or "Calendar")emit("message",new{role="notification",name=sender,text,at=DateTimeOffset.UtcNow});
        if(sender==name&&text.StartsWith(name+": ",StringComparison.Ordinal))
        {
            emit("message",new{role="assistant",name,text=text[(name.Length+2)..],at=DateTimeOffset.UtcNow});
            emit("status",new{state=_speech?"speaking":"idle",text=_speech?"Speaking":"Ready"});
        }
    }
    private void AttachProvider()
    {
        if(_agent is null)return;
        _provider??=_agent.GetType().GetField("_llmclient",BindingFlags.Instance|BindingFlags.NonPublic)?.GetValue(_agent);
        if(_provider is null)return;
        if(!_desktopResearchAttached&&Environment.GetEnvironmentVariable("LILITH_DESKTOP_BROWSER")=="1"&&core.GetType("Lilith.Core.WebResearch.IResearchBrowser") is {} browserInterface)
        {
            _desktopResearchAttached=true;
            var browser=DispatchProxy.Create(browserInterface,typeof(DesktopResearchBridge));
            var bridge=(DesktopResearchBridge)browser;
            bridge.Emit=emit;
            bridge.Options=core.GetType("Lilith.Core.WebResearch.BrowserResearchOptions")!.GetMethod("Load")!.Invoke(null,null);
            _provider.GetType().GetMethod("ConfigureResearchBrowser")!.Invoke(_provider,[browser]);
        }
        if(!_attached)
        {
            _attached=true;
            Attach(_provider,"ScenarioProgress",progress=>emit("scenario",progress));
            Attach(_provider,"SubagentOutput",output=>emit("subagent",output));
            Attach(_provider,"WebsiteScreenshotsReady",images=>emit("images",images));
        }
        var kanban=Get(_provider,"Kanban");var store=Get(kanban,"Store");
        if(store is not null&&!_kanbanAttached){_kanbanAttached=true;Attach(store,"Changed",board=>{emit("kanban",board);if(Get(board,"State")?.ToString() is "waiting_plan" or "waiting_test" or "waiting_publication")emit("approvals_invalidated",new{});});}
    }
    internal async Task<object?> DispatchAsync(string action,JsonElement payload,CancellationToken ct)
    {
        if(action=="boot")return await Boot(payload);
        if(_agent is null||Get(_agent,"IsBooted") is not true)throw new InvalidOperationException("Start Lilith first.");
        AttachProvider();
        return action switch
        {
            "chat"=>await Chat(Text(payload,"text"),Text(payload,"clientId"),ct),
            "command"=>await Command(Text(payload,"text"),ct),
            "panel"=>await Panel(Text(payload,"panel"),payload,ct),
            "scenario"=>await Scenario(Text(payload,"name"),payload,ct),
            "history"=>await Call(_agent,"ListConversations"),
            "approvals"=>await Approvals(ct),
            "approval_decide"=>await DecideApproval(payload,ct),
            _=>throw new ArgumentException("Unknown GUI action.")
        };
    }
    private async Task<object> Boot(JsonElement p)
    {
        if(_agent is not null)throw new InvalidOperationException("Restart the worker to change boot settings.");
        Directory.CreateDirectory(state);Directory.CreateDirectory(Path.Combine(state,"Workspace"));
        string voiceDirectory=Path.Combine(backend,"voices");
        if(Directory.Exists(voiceDirectory))
        {
            // Resolve the selected release's installed voice assets, not the GUI worker's folder.
            var voices=Assembly.Load("KokoroSharp").GetType("KokoroSharp.KokoroVoiceManager");
            voices?.GetMethod("LoadVoicesFromPath",BindingFlags.Public|BindingFlags.Static)?.Invoke(null,[voiceDirectory]);
        }
        string model=Text(p,"model","gemma4"),provider=Text(p,"provider","Ollama"),name=Text(p,"name","Lilith");
        if(model.Length is <1 or >150||name.Length is <1 or >60||provider is not("Ollama" or "LMStudio"))throw new ArgumentException("Choose a provider, model and name.");
        _speech=p.TryGetProperty("speech",out var speech)&&speech.GetBoolean();
        object tts=Create(Type("Lilith.Core.Kokoro.KokoroTtsOptions"),new Dictionary<string,object?>{{"Enabled",_speech},{"WaitForTts",true}});
        var agentType=agentAssembly.GetType("Lilith.Agent.Lilith")!;
        string prompt=agentType.GetField("DefaultSystemPrompt")?.GetRawConstantValue()?.ToString()??"You are Lilith, a helpful local assistant.";
        prompt=prompt.Replace("You are Lilith","You are "+name,StringComparison.Ordinal);
        object credentials=Create(agentAssembly.GetType("Lilith.Agent.LilithCredentials")!,new Dictionary<string,object?>{{"Model",model},{"SystemPrompt",prompt},{"AgentName",name},{"Provider",provider},{"Tts",tts}});
        var constructor=agentType.GetConstructors().Single(c=>c.GetParameters().Length==2);
        var logger=new EventForwarder(Log).Create(constructor.GetParameters()[0].ParameterType);
        _agent=constructor.Invoke([logger,credentials]);
        var workspaceProperty=agentType.GetProperty("WorkspaceOptions");
        if(workspaceProperty?.CanWrite==true)
        {
            string mode=Text(p,"mode","Ask");if(mode is not("Ask" or "Yolo"))throw new ArgumentException("Unknown access mode.");
            workspaceProperty.SetValue(_agent,Create(workspaceProperty.PropertyType,new Dictionary<string,object?>{{"RootDirectory",Path.Combine(state,"Workspace")},{"Mode",mode}}));
        }
        emit("status",new{state="starting",text="Starting "+provider+" / "+model});
        if(core.GetType("Lilith.Core.Scenarios.ScenarioExecutor") is not null)emit("catalog",ScenarioLibrary());
        await Call(_agent,"BootAsync",new Dictionary<string,object?>{{"startServerIfNotRunning",false}});
        CaptureToolResults();
        AttachProvider();var capabilities=Capabilities();emit("ready",new{capabilities,model,provider,name,speech=_speech,mode=Get(Get(Get(_provider,"Workspace"),"Options"),"Mode")?.ToString(),context=Get(_agent,"ContextMax"),profile=Get(Get(_provider,"Personality"),"Current"),backendVersion=core.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion});
        return new{capabilities,message="Ready"};
    }
    private async Task<object?> Chat(string text,string clientId,CancellationToken ct)
    {
        if(string.IsNullOrWhiteSpace(text)||text.Length>16000)throw new ArgumentException("Message must contain 1–16000 characters.");
        emit("message",new{role="user",name="You",text,id=clientId,at=DateTimeOffset.UtcNow});
        if(text.StartsWith('/'))return await Command(text,ct);
        emit("status",new{state="thinking",text="Thinking"});
        try{await Call(_agent!,"ChatAsync",new Dictionary<string,object?>{{"input",text},{"cancellationToken",ct}});}
        finally{CaptureToolResults();}
        return new{message="Reply complete.",profile=Get(Get(_provider,"Personality"),"Current")};
    }
    private async Task<object?> Command(string text,CancellationToken ct)
    {
        if(!text.StartsWith('/')||text.Contains('\n')||text.Length>1000)throw new ArgumentException("Use one registered chat command.");
        emit("status",new{state="working",text="Running host command"});
        var commands=Get(_agent,"ChatCommands")??throw new NotSupportedException("Chat commands are unavailable in this snapshot.");
        var result=await Call(commands,"ExecuteAsync",new Dictionary<string,object?>{{"input",text},{"cancellationToken",ct}});
        if(text.Equals("/new",StringComparison.OrdinalIgnoreCase))_reportedTools.Clear();
        if(text.StartsWith("/history",StringComparison.OrdinalIgnoreCase))CaptureToolResults(false);
        emit("profile",Get(Get(_provider,"Personality"),"Current"));
        emit("command",result);return result;
    }
    private async Task<object?> Scenario(string name,JsonElement p,CancellationToken ct)
    {
        emit("status",new{state="working",text="Scenario · "+name});
        var allowed=new[]{"memory_save","memory_read","memory_search","memory_forget","calendar_create","calendar_update","calendar_delete","calendar_read","calendar_search","calendar_agenda","workspace_list","workspace_read","workspace_save","workspace_mkdir","workspace_search","workspace_bin","workspace_restore","schedule_list","schedule_peek","schedule_create","schedule_cancel","schedule_delete","profile_read","voices_list","name_change","voice_change","sex_change","personality_change","email_accounts","email_check","email_read","email_briefing","email_sections","message_channels","message_check","message_status","message_send","home_devices","home_state","home_on","home_off","home_brightness","home_color","kanban_status"};
        if(!allowed.Contains(name,StringComparer.Ordinal)&&(!System.Text.RegularExpressions.Regex.IsMatch(name,"^[A-Za-z][A-Za-z0-9_-]{0,63}$")||!File.Exists(Path.Combine(backend,"Scenarios",name+".xml"))))
            throw new ArgumentException("Only actual saved scenarios from this release may run. Browser-supplied executable XML is not accepted.");
        var inputs=new Dictionary<string,string>();
        if(p.TryGetProperty("inputs",out var values))foreach(var item in values.EnumerateObject())
        {if(item.Value.ValueKind!=JsonValueKind.String)throw new ArgumentException("Scenario inputs must be strings.");inputs.Add(item.Name,item.Value.GetString()!);}
        try
        {
            var result=await Call(_provider!,"RunScenarioAsync",new Dictionary<string,object?>{{"scenarioName",name},{"inputs",inputs},{"cancellationToken",ct}});
            emit("profile",Get(Get(_provider,"Personality"),"Current"));
            emit("receipt",new{name,result});return result;
        }
        catch(Exception ex) when(ex.GetBaseException() is InvalidDataException failure&&failure.Message.StartsWith("Configured scenario did not complete: ",StringComparison.Ordinal))
        {
            // Older application-dispatch APIs reject a legitimate approval stop as
            // incomplete. Recognize only a receipt verified against its owning service.
            const string prefix="Configured scenario did not complete: ";
            using var response=JsonDocument.Parse(ex.GetBaseException().Message[prefix.Length..]);
            var data=response.RootElement;
            if(data.GetProperty("status").GetString()!="needs_approval"||!data.TryGetProperty("result",out var receipt))throw;
            string? proposal=receipt.TryGetProperty("ProposalId",out var id)?id.GetString():null;
            if(string.IsNullOrWhiteSpace(proposal))throw;
            string service;
            if(name.StartsWith("calendar_",StringComparison.Ordinal))
            {
                var pending=await Service("Calendar","Lilith.Core.Calendar.CalendarRequest","pending",ct);
                if(Get(pending,"Data") is not IEnumerable list||!list.Cast<object>().Any(item=>Get(item,"Id")?.ToString()==proposal))throw;
                service="calendar";
            }
            else if(name.StartsWith("schedule_",StringComparison.Ordinal))
            {
                var pending=await Service("Schedules","Lilith.Core.Scheduling.ScheduleRequest","pending",ct);
                if(Get(pending,"Data") is not IEnumerable list||!list.Cast<object>().Any(item=>Get(item,"Id")?.ToString()==proposal))throw;
                service="schedules";
            }
            else if(name.StartsWith("workspace_",StringComparison.Ordinal)||name.StartsWith("memory_",StringComparison.Ordinal))
            {
                var actual=await Call(Get(_provider,"Workspace")!,"Review",new Dictionary<string,object?>{{"id",proposal}});
                if(Get(actual,"Id")?.ToString()!=proposal)throw;
                service="workspace";
            }
            else throw;
            var result=new{status="needs_approval",service,proposalId=proposal,receipt=receipt.Clone()};
            emit("receipt",new{name,result});return result;
        }
    }
    private object[] Capabilities()
    {
        var list=new List<object>();
        void Add(string id,string label,int version,bool available){if(available)list.Add(new{id,label,introduced=version});}
        Add("tools","Tools",2,core.GetType("Lilith.Core.Tools.ILilithTool") is not null);
        Add("scenarios","Scenarios",3,core.GetType("Lilith.Core.Scenarios.ScenarioExecutor") is not null);
        Add("workspace","Workspace",4,Get(_provider,"Workspace") is not null);
        Add("schedules","Timers & cron",5,Get(_provider,"Schedules") is not null);
        Add("memory","Memory",7,File.Exists(Path.Combine(backend,"Scenarios","memory_read.xml")));
        Add("research","Web research",8,core.GetType("Lilith.Core.WebResearch.SeleniumResearchBrowser") is not null);
        Add("personality","Personality",9,Get(_provider,"Personality") is not null);
        Add("calendar","Calendar",10,Get(_provider,"Calendar") is not null);
        Add("projects","Projects",11,core.GetType("Lilith.Core.Projects.ProjectGeneration") is not null);
        Add("email","Email",13,core.GetType("Lilith.Core.Email.EmailClient") is not null);
        Add("messaging","Channels",14,core.GetType("Lilith.Core.Messaging.MessageBridgeClient") is not null);
        Add("home","Home",15,Get(_provider,"Home") is not null);
        Add("kanban","Kanban",16,Get(_provider,"Kanban") is not null);
        return list.ToArray();
    }
    private async Task<object?> Panel(string panel,JsonElement p,CancellationToken ct)
    {
        Trace(new{sender="GUI",text="Read "+panel+" view from the selected backend.",at=DateTimeOffset.UtcNow});
        switch(panel)
        {
            case "history":return await Call(_agent!,"ListConversations");
            case "conversation":return Get(_agent,"History");
            case "traces":lock(_traceLock)return _trace.ToArray();
            case "personality":
                var catalog=Type("Lilith.Core.Personality.PersonalityCatalog");
                return new{profile=Get(Get(_provider,"Personality"),"Current"),styles=catalog.GetProperty("Styles",BindingFlags.Public|BindingFlags.Static)?.GetValue(null),
                    voices=catalog.GetMethod("Voices",BindingFlags.Public|BindingFlags.Static)?.Invoke(null,[Get(Get(Get(_provider,"Personality"),"Default"),"Voice")?.ToString()??"af_heart"])};
            case "calendar":
                var calendar=ReadOwnedState("Calendar","calendar.json");
                return calendar is {} saved?new{entries=new{Data=saved.GetProperty("Entries")},pending=new{Data=saved.GetProperty("Pending")}}
                    :new{entries=await Service("Calendar","Lilith.Core.Calendar.CalendarRequest","search",ct),pending=await Service("Calendar","Lilith.Core.Calendar.CalendarRequest","pending",ct)};
            case "schedules":return await Service("Schedules","Lilith.Core.Scheduling.ScheduleRequest","list",ct);
            case "workspace":return await Workspace("list",Text(p,"path"),ct);
            case "memory":return await Memory(ct);
            case "scenarios":return ScenarioLibrary();
            case "kanban":return await Call(Get(Get(_provider,"Kanban"),"Store")??throw new NotSupportedException("Kanban unavailable."),"List");
            case "projects":
                var workspace=Get(_provider,"Workspace")??throw new NotSupportedException("Workspace unavailable.");
                var drafts=core.GetType("Lilith.Core.Projects.ProjectDraftStore");
                if(drafts is not null)return await Call(Create(drafts,new Dictionary<string,object?>{{"workspace",workspace}}),"List");
                string projectRoot=Path.Combine(state,"Workspace");
                return Directory.Exists(projectRoot)?Directory.EnumerateDirectories(projectRoot).Where(d=>!Path.GetFileName(d).StartsWith('.')&&File.Exists(Path.Combine(d,"lilith-project.json"))).Take(100).Select(d=>(object)new{Name=Path.GetFileName(d),State="published",Kind="managed project"}).ToArray():[];
            case "tools":
                var registered=_provider?.GetType().GetProperty("RegisteredTools",BindingFlags.Instance|BindingFlags.NonPublic)?.GetValue(_provider) as IEnumerable;
                return registered?.Cast<object>().Select(pair=>{var tool=Get(pair,"Value");return new{name=Get(tool,"Name"),description=Get(tool,"Description")?.ToString()?[..Math.Min(250,Get(tool,"Description")!.ToString()!.Length)]};}).ToArray()??[];
            case "home":return await Call(Get(_provider,"Home")??throw new NotSupportedException("Home unavailable."),"ExecuteAsync",new Dictionary<string,object?>{{"action","devices"},{"ct",ct}});
            case "email":return new{configured=!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("LILITH_EMAIL_API_TOKEN")),mode=Get(_provider,"EmailMode"),message="Checking a mailbox is an explicit action. Credentials stay in the email service."};
            case "messaging":return new{configured=!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("LILITH_MESSAGES_API_TOKEN")),message="Connections and incoming messages are fetched only when you request them."};
            case "research":return new{message="Ask a research question in chat. Search and reading traces appear here as actual scenario events."};
            default:throw new NotSupportedException("Panel unavailable in this release.");
        }
    }
    private JsonElement? ReadOwnedState(string service,string filename)
    {
        string? root=Get(Get(Get(_provider,service),"Options"),"RootDirectory")?.ToString();
        if(root is null)throw new NotSupportedException(service+" unavailable.");
        string path=Path.GetFullPath(Path.Combine(root,filename)),scope=Path.GetFullPath(state)+Path.DirectorySeparatorChar;
        if(!path.StartsWith(scope,StringComparison.OrdinalIgnoreCase))throw new IOException("GUI state is outside the isolated version directory.");
        string? check=path;
        while(check is not null&&check.StartsWith(scope,StringComparison.OrdinalIgnoreCase))
        {
            if((File.Exists(check)||Directory.Exists(check))&&(File.GetAttributes(check)&FileAttributes.ReparsePoint)!=0)throw new IOException("Linked state paths are not read by the GUI.");
            check=Path.GetDirectoryName(check);
        }
        if(!File.Exists(path))return null;if(new FileInfo(path).Length>2000000)throw new IOException("Oversized state snapshot.");
        using var doc=JsonDocument.Parse(File.ReadAllText(path));return doc.RootElement.Clone();
    }
    private async Task<object?> Service(string property,string requestType,string operation,CancellationToken ct)
    {
        object service=Get(_provider,property)??throw new NotSupportedException(property+" unavailable.");
        object request=Create(Type(requestType),new Dictionary<string,object?>{{"Operation",operation}});
        return await Call(service,"Execute",new Dictionary<string,object?>{{"request",request}});
    }
    private async Task<object?> Workspace(string operation,string path,CancellationToken ct)
    {
        object request=Create(Type("Lilith.Core.Workspace.WorkspaceRequest"),new Dictionary<string,object?>{{"Operation",operation},{"Path",path}});
        return await Call(Get(_provider,"Workspace")??throw new NotSupportedException("Workspace unavailable."),"ExecuteAsync",new Dictionary<string,object?>{{"request",request},{"ct",ct}});
    }
    private async Task<object> Memory(CancellationToken ct)
    {
        string directory=Path.Combine(state,"Workspace","memory");
        object? result=Directory.Exists(directory)?await Workspace("list","memory",ct):null;
        var facts=new List<object>();
        if(Directory.Exists(directory))foreach(string file in Directory.EnumerateFiles(directory,"*.txt").Take(100))
        {
            if((File.GetAttributes(file)&FileAttributes.ReparsePoint)!=0)continue;
            if(new FileInfo(file).Length>32000)continue;string value=await File.ReadAllTextAsync(file,ct);
            facts.Add(new{key=Path.GetFileNameWithoutExtension(file),value=value[..Math.Min(6000,value.Length)]});
        }
        return new{facts,listing=result};
    }
    private object[] ScenarioLibrary()
    {
        string root=Path.Combine(backend,"Scenarios");if(!Directory.Exists(root))return [];
        return Directory.EnumerateFiles(root,"*.xml").Order().Select(path=>
        {
            string xml=File.ReadAllText(path);var doc=XDocument.Parse(xml);var scenario=doc.Root!;
            return (object)new{id=(string?)scenario.Attribute("id")??Path.GetFileNameWithoutExtension(path),title=(string?)scenario.Attribute("title"),xml,
                inputs=scenario.Element("inputs")?.Elements("input").Select(i=>new{name=(string?)i.Attribute("name"),required=(string?)i.Attribute("required"),value=(string?)i.Attribute("default")}).ToArray(),
                steps=scenario.Element("steps")?.Descendants().Where(e=>e.Name.LocalName is "step" or "run" or "tool" or "if" or "foreach" or "result").Select((e,index)=>new{index=index+1,kind=e.Name.LocalName,target=(string?)e.Attribute("scenario")??(string?)e.Attribute("name"),text=e.Value[..Math.Min(400,e.Value.Length)]}).ToArray()};
        }).ToArray();
    }
    public async ValueTask DisposeAsync(){if(_agent is IAsyncDisposable disposable)await disposable.DisposeAsync();}
}