Size: 32.5 KB Modified: 6/07/2026 1:25 PM
using KaiChat.Hubs;
using KaiChat.Models;
using KaiChat.Services;
using Microsoft.AspNetCore.HttpOverrides;

// Determine the project root before creating the builder:
// walk up from the DLL location to find the KaiChat.csproj file
var assemblyDir = AppContext.BaseDirectory;
var projectRoot = assemblyDir;
while (projectRoot != null && !File.Exists(Path.Combine(projectRoot, "KaiChat.csproj")))
    projectRoot = Path.GetDirectoryName(projectRoot);
if (projectRoot == null) projectRoot = assemblyDir;

var wwwroot = Path.Combine(projectRoot, "wwwroot");
var webRoot = Directory.Exists(wwwroot) ? wwwroot : null;
var appVersion = GetAppVersion();

var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
    Args = args,
    ContentRootPath = projectRoot,
    WebRootPath = webRoot
});

// Resolve base folder relative to project root
var configuredBase = builder.Configuration.GetValue<string>("KaiChat:BaseFolder") ?? "..";
var resolvedBase = Path.GetFullPath(Path.Combine(projectRoot, configuredBase));
builder.Configuration["KaiChat:BaseFolder"] = resolvedBase;

// Load user config (saved from Settings page) if it exists
var baseFolder = builder.Configuration.GetValue<string>("KaiChat:BaseFolder") ?? "";
var userConfigPath = Path.Combine(baseFolder, ".kaichatconfig.json");
if (File.Exists(userConfigPath))
{
    var userConfig = System.Text.Json.JsonSerializer
        .Deserialize<Dictionary<string, string>>(File.ReadAllText(userConfigPath));
    if (userConfig != null)
    {
        foreach (var (key, value) in userConfig)
        {
            if (!string.IsNullOrEmpty(value))
                builder.Configuration[key] = value;
        }
    }
}

var httpPort = builder.Configuration.GetValue<int?>("KaiChat:HttpPort") ?? 5200;
var httpsPort = builder.Configuration.GetValue<int?>("KaiChat:HttpsPort") ?? 5201;
var httpsCertPathSetting = builder.Configuration.GetValue<string>("KaiChat:HttpsCertificatePath");
var httpsCertPassword = builder.Configuration.GetValue<string>("KaiChat:HttpsCertificatePassword") ?? "";
var httpsCertPath = string.IsNullOrWhiteSpace(httpsCertPathSetting)
    ? Path.Combine(baseFolder, ".kaichat-lan.pfx")
    : Path.GetFullPath(Path.Combine(baseFolder, httpsCertPathSetting));
var httpsEnabled = File.Exists(httpsCertPath);
var explicitUrls = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"))
    || args.Any(arg => arg.Equals("--urls", StringComparison.OrdinalIgnoreCase)
        || arg.StartsWith("--urls=", StringComparison.OrdinalIgnoreCase));

// Listen on all interfaces for LAN access unless the host environment provides URLs.
if (!explicitUrls)
{
    builder.WebHost.ConfigureKestrel(options =>
    {
        options.ListenAnyIP(httpPort);
        if (httpsEnabled)
            options.ListenAnyIP(httpsPort, listen => listen.UseHttps(httpsCertPath, httpsCertPassword));
    });
}

// Services
builder.Services.AddRazorPages();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSignalR();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedHost |
        ForwardedHeaders.XForwardedProto;

    // KaiChat is commonly exposed through ad-hoc tunnels/reverse proxies where
    // the proxy network is not known ahead of time.
    options.KnownIPNetworks.Clear();
    options.KnownProxies.Clear();
});

var config = builder.Configuration.GetSection("KaiChat").Get<KaiChatConfig>()
    ?? throw new InvalidOperationException("KaiChat configuration section is required");
config.AppVersion = appVersion;
builder.Services.AddSingleton(config);

builder.Services.AddHttpClient<IChatService, ChatService>(client =>
{
    // Memory updates use the chat client too; keep this above MemoryUpdateService's
    // own timeout so the updater, not HttpClient's default 100s timeout, decides.
    client.Timeout = TimeSpan.FromMinutes(11);
});
builder.Services.AddSingleton<IGitService, GitService>();
builder.Services.AddSingleton<IFileService, FileService>();
builder.Services.AddSingleton<IConversationService, ConversationService>();
builder.Services.AddSingleton<IMemoryService, MemoryService>();
builder.Services.AddSingleton<IMemoryManagementService, MemoryManagementService>();
builder.Services.AddSingleton<IMemorySweepService, MemorySweepService>();
builder.Services.AddSingleton<IMemoryRebuildService, MemoryRebuildService>();
builder.Services.AddSingleton<ICompactionService, CompactionService>();
builder.Services.AddSingleton<ISearchService, SearchService>();
builder.Services.AddSingleton<IInstructionRewriteService, InstructionRewriteService>();
builder.Services.AddSingleton<IKaiTimerService, KaiTimerService>();
builder.Services.AddSingleton<IActiveConversationService, ActiveConversationService>();
builder.Services.AddSingleton<IKaiAutonomousConversationService, KaiAutonomousConversationService>();
builder.Services.AddHostedService<KaiTimerMilestoneService>();
builder.Services.AddSingleton<KaiAutonomyService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<KaiAutonomyService>());
builder.Services.AddHttpClient<INightscoutService, NightscoutService>();
builder.Services.AddSingleton<ICgmBridgeService, CgmBridgeService>();
builder.Services.AddSingleton<MemoryUpdateService>();
builder.Services.AddSingleton<IToolCallLogService>(sp =>
{
    var baseFolder = sp.GetRequiredService<IConfiguration>().GetValue<string>("KaiChat:BaseFolder") ?? ".";
    var logPath = Path.Combine(baseFolder, ".kaichat-tool-logs.json");
    return new ToolCallLogService(logPath);
});
builder.Services.AddHostedService(sp => sp.GetRequiredService<MemoryUpdateService>());

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}

app.UseForwardedHeaders();
app.UseStaticFiles();
app.UseRouting();

app.MapRazorPages();
app.MapHub<ChatHub>("/chatHub");

// API: App version used by the browser to detect when a no-cache refresh is needed.
app.MapGet("/api/app/version", (HttpContext context, KaiChatConfig config) =>
{
    context.Response.Headers.CacheControl = "no-store, no-cache, max-age=0";
    context.Response.Headers.Pragma = "no-cache";
    context.Response.Headers.Expires = "0";
    return Results.Ok(new { version = config.AppVersion });
});

// API: Get test mode status (reads from the live KaiChatConfig singleton, not restart-gated IConfiguration)
app.MapGet("/api/settings/testmode", (KaiChatConfig config) =>
{
    return Results.Ok(new { testMode = config.TestMode });
});

app.MapGet("/api/personas", (KaiChatConfig config) =>
{
    return Results.Ok(BuildPersonaCatalog(config.BaseFolder));
});

// API endpoints for conversations
app.MapGet("/api/conversations", (string? persona, IConversationService svc) =>
{
    return svc.ListConversations(persona).Select(c => new
    {
        c.Id, c.Title, c.CreatedAt, c.UpdatedAt,
        MessageCount = c.Messages.Count
    });
});

// API: Get tool call log
app.MapGet("/api/tool-logs", (IToolCallLogService svc) =>
{
    var logs = svc.GetRecent(200);
    var total = svc.GetTotalCount();
    return Results.Ok(new { logs, total });
});

// API: Log a new tool call entry (used by frontend rerun to record results)
app.MapPost("/api/tool-logs", async (ToolCallLog entry, IToolCallLogService svc, IGitService git) =>
{
    svc.Add(entry);
    await git.AutoCommitAsync("tool call log update", ".kaichat-tool-logs.json");
    return Results.Ok(new { logged = true });
});

// API: Clear tool call log
app.MapDelete("/api/tool-logs", async (IToolCallLogService svc, IGitService git) =>
{
    svc.Clear();
    await git.AutoCommitAsync("tool call log cleared", ".kaichat-tool-logs.json");
    return Results.Ok();
});

// API: Single local count-up timer
app.MapGet("/api/kaitimer/status", async (IKaiTimerService timer, CancellationToken ct) =>
{
    return Results.Ok(await timer.GetStateAsync(ct));
});

app.MapGet("/api/kaitimer/dashboard", async (IKaiTimerService timer, CancellationToken ct) =>
{
    return Results.Ok(await timer.GetStateAsync(ct));
});

app.MapPost("/api/kaitimer/start", async (IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    var result = await timer.StartAsync(ct);
    if (result.Success)
        await git.AutoCommitAsync("KaiTimer start", ".kaichat-timer.json");
    return Results.Ok(result);
});

app.MapPost("/api/kaitimer/pause", async (IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    var result = await timer.PauseAsync(ct);
    if (result.Success)
        await git.AutoCommitAsync("KaiTimer pause", ".kaichat-timer.json");
    return Results.Ok(result);
});

app.MapPost("/api/kaitimer/reset", async (IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    var result = await timer.ResetAsync(ct);
    if (result.Success)
        await git.AutoCommitAsync("KaiTimer reset", ".kaichat-timer.json");
    return Results.Ok(result);
});

app.MapPost("/api/kaitimer/wheel/segments", async (KaiWheelSegmentCreateRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.CreateWheelSegmentAsync(req, ct), git, "KaiTimer wheel segment added");
});

app.MapPost("/api/kaitimer/wheel/segments/update", async (KaiWheelSegmentUpdateRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.UpdateWheelSegmentAsync(req, ct), git, "KaiTimer wheel segment updated");
});

app.MapPost("/api/kaitimer/wheel/segments/delete", async (KaiWheelSegmentDeleteRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.DeleteWheelSegmentAsync(req, ct), git, "KaiTimer wheel segment removed");
});

app.MapPost("/api/kaitimer/wheel/spin", async (IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.SpinWheelAsync(ct), git, "KaiTimer wheel spun");
});

app.MapPost("/api/kaitimer/counters", async (KaiCounterCreateRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.CreateCounterAsync(req, ct), git, "KaiTimer counter created");
});

app.MapPost("/api/kaitimer/counters/update", async (KaiCounterUpdateRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.UpdateCounterAsync(req, ct), git, "KaiTimer counter updated");
});

app.MapPost("/api/kaitimer/counters/increment", async (KaiCounterChangeRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.IncrementCounterAsync(req, ct), git, "KaiTimer counter incremented");
});

app.MapPost("/api/kaitimer/counters/decrement", async (KaiCounterChangeRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.DecrementCounterAsync(req, ct), git, "KaiTimer counter decremented");
});

app.MapPost("/api/kaitimer/counters/delete", async (KaiCounterDeleteRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.DeleteCounterAsync(req, ct), git, "KaiTimer counter removed");
});

app.MapPost("/api/kaitimer/milestones", async (KaiMilestoneCreateRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.CreateMilestoneAsync(req, ct), git, "KaiTimer milestone created");
});

app.MapPost("/api/kaitimer/milestones/update", async (KaiMilestoneUpdateRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.UpdateMilestoneAsync(req, ct), git, "KaiTimer milestone updated");
});

app.MapPost("/api/kaitimer/milestones/delete", async (KaiMilestoneDeleteRequest req, IKaiTimerService timer, IGitService git, CancellationToken ct) =>
{
    return await CommitKaiTimerResultAsync(await timer.DeleteMilestoneAsync(req, ct), git, "KaiTimer milestone removed");
});

app.MapGet("/api/autonomy/status", async (KaiAutonomyService autonomy, CancellationToken ct) =>
{
    return Results.Ok(await autonomy.GetStatusAsync(ct));
});

app.MapGet("/api/autonomy/personas", async (bool? includeDisabled, KaiAutonomyService autonomy, CancellationToken ct) =>
{
    return Results.Ok(await autonomy.GetPersonaStatusesAsync(includeDisabled ?? false, ct));
});

app.MapPost("/api/autonomy/settings", async (KaiAutonomySettingsUpdate req, KaiAutonomyService autonomy, CancellationToken ct) =>
{
    return Results.Ok(await autonomy.UpdateSettingsAsync(req, ct));
});

app.MapPost("/api/autonomy/step", async (KaiAutonomyStepRequest? req, KaiAutonomyService autonomy, CancellationToken ct) =>
{
    var result = await autonomy.ForceStepAsync(req, ct);
    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

app.MapPost("/api/autonomy/sandbox/reset", async (KaiAutonomyService autonomy, CancellationToken ct) =>
{
    return Results.Ok(await autonomy.ResetSandboxAsync(ct));
});

app.MapPost("/api/autonomy/guidance/reflect", async (KaiAutonomyService autonomy, CancellationToken ct) =>
{
    var result = await autonomy.ReflectGuidanceAsync(ct);
    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

app.MapPost("/api/autonomy/guidance/propose", async (KaiAutonomyGuidanceProposalRequest req, KaiAutonomyService autonomy, CancellationToken ct) =>
{
    var result = await autonomy.ProposeGuidanceChangeAsync(req, ct);
    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

app.MapPost("/api/autonomy/guidance/approve", async (KaiAutonomyGuidanceDecisionRequest? req, KaiAutonomyService autonomy, CancellationToken ct) =>
{
    var result = await autonomy.ApproveGuidanceProposalAsync(req, ct);
    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

app.MapPost("/api/autonomy/guidance/reject", async (KaiAutonomyGuidanceDecisionRequest? req, KaiAutonomyService autonomy, CancellationToken ct) =>
{
    var result = await autonomy.RejectGuidanceProposalAsync(req, ct);
    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
});

app.MapGet("/api/conversations/{id}", (string id, string? persona, IConversationService svc) =>
{
    var conversationsFolder = svc.ResolveConversationFolder(persona);
    var conv = svc.GetConversation(id, conversationsFolder);
    return conv is null ? Results.NotFound() : Results.Ok(conv);
});

// API: Preview a compaction plan without calling the model or changing files.
app.MapGet("/api/compaction/preview/{id}", async (
    string id,
    string? persona,
    bool? force,
    IConversationService conversations,
    ICompactionService compaction) =>
{
    var conversationsFolder = conversations.ResolveConversationFolder(persona);
    var conv = conversations.GetConversation(id, conversationsFolder);
    if (conv is null)
        return Results.NotFound();

    var systemPrompt = await conversations.BuildSystemPromptAsync();
    var preview = await compaction.PreviewCompactionAsync(conv, systemPrompt, force ?? false);
    return Results.Ok(preview);
});

// API: Exercise compaction against an in-memory copy. This never trims the real
// conversation and never writes archive files. Set generateSummary=true to also
// test the chunk summarization calls against the configured model.
app.MapPost("/api/compaction/test/{id}", async (
    string id,
    string? persona,
    CompactionTestRequest req,
    IConversationService conversations,
    ICompactionService compaction,
    HttpContext context) =>
{
    var conversationsFolder = conversations.ResolveConversationFolder(persona);
    var conv = conversations.GetConversation(id, conversationsFolder);
    if (conv is null)
        return Results.NotFound();

    var systemPrompt = await conversations.BuildSystemPromptAsync();
    var result = await compaction.TestCompactionAsync(
        conv,
        systemPrompt,
        req.Force,
        req.GenerateSummary,
        ct: context.RequestAborted);
    return Results.Ok(result);
});

app.MapDelete("/api/conversations/{id}", async (string id, string? persona, IConversationService svc, IGitService git) =>
{
    var conversationsFolder = svc.ResolveConversationFolder(persona);
    var conv = svc.GetConversation(id, conversationsFolder);
    if (conv is null)
        return Results.NotFound();

    var relPath = svc.GetConversationRelativePath(id, conversationsFolder);
    await svc.DeleteConversationAsync(id, conversationsFolder);

    // Commit the deletion
    var stage = await git.StagePathAsync(relPath);
    if (stage.Success)
        await git.CommitAsync($"[{id}] delete conversation");

    return Results.Ok();
});

// API: Stage and commit all changes
app.MapPost("/api/git/commit", async (IGitService git) =>
{
    var stage = await git.StageAllAsync();
    if (!stage.Success)
        return Results.Problem(stage.Error, statusCode: 500);

    var status = await git.StatusAsync();
    if (string.IsNullOrEmpty(status.Output))
        return Results.Ok(new { message = "Nothing to commit.", staged = false });

    var commit = await git.CommitAsync("KaiChat: auto-commit");
    if (!commit.Success)
        return Results.Problem(commit.Error, statusCode: 500);

    return Results.Ok(new { message = commit.Output, staged = true });
});

// API: Get git status
app.MapGet("/api/git/status", async (IGitService git) =>
{
    var result = await git.StatusAsync();
    return Results.Ok(new { output = result.Output, success = result.Success });
});

// API: Read a file's content
app.MapGet("/api/files/read", (string path, IFileService file) =>
{
    return Results.Ok(new { content = file.ReadFile(path) });
});

// API: List directory contents
app.MapGet("/api/files/list", (string? path, IFileService file) =>
{
    return Results.Ok(file.GetDirectoryContents(path ?? ""));
});

// API: Delete a file
app.MapDelete("/api/files/delete", async (string path, IFileService file) =>
{
    try
    {
        await file.DeleteFileAsync(path);
        return Results.Ok(new { deleted = true });
    }
    catch (FileNotFoundException)
    {
        return Results.NotFound(new { error = "File not found" });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
    {
        return Results.BadRequest(new { error = "Path is outside the workspace" });
    }
});

// API: Rename a file
app.MapPost("/api/files/rename", async (FileRenameRequest req, IFileService file) =>
{
    try
    {
        await file.RenameFileAsync(req.Source, req.Dest);
        return Results.Ok(new { renamed = true });
    }
    catch (FileNotFoundException)
    {
        return Results.NotFound(new { error = "Source file not found" });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
    {
        return Results.BadRequest(new { error = "Path is outside the workspace" });
    }
});

// API: Get persistent memory
app.MapGet("/api/memory", async (IMemoryService mem) =>
{
    var content = await mem.GetPersistentMemoryAsync();
    return Results.Ok(new { content });
});

// API: Inspect the automatic memory update scheduler.
app.MapGet("/api/memory/update/status", async (MemoryUpdateService updater, CancellationToken ct) =>
{
    return Results.Ok(await updater.GetStatusAsync(ct));
});

// API: Force the automatic memory updater to run immediately.
app.MapPost("/api/memory/update/force", async (MemoryUpdateService updater, CancellationToken ct) =>
{
    return Results.Ok(await updater.ForceUpdateAsync(ct));
});

// API: Reset the memory updater fuse and force an immediate run.
app.MapPost("/api/memory/update/reset-and-run", async (MemoryUpdateService updater, CancellationToken ct) =>
{
    return Results.Ok(await updater.ResetFuseAndRunAsync(ct));
});

// API: List memory files
app.MapGet("/api/memory/files", async (bool? includeTrash, IMemoryService mem) =>
{
    var files = await mem.ListMemoryFilesAsync(includeTrash ?? false);
    return Results.Ok(files);
});

// API: Read a memory file
app.MapGet("/api/memory/file", async (string path, IMemoryService mem) =>
{
    try
    {
        var content = await mem.ReadMemoryFileAsync(path);
        return Results.Ok(new { path, content });
    }
    catch (FileNotFoundException)
    {
        return Results.NotFound(new { error = "Memory file not found" });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

app.MapGet("/api/memory/file/read", async (string path, IMemoryService mem) =>
{
    try
    {
        var content = await mem.ReadMemoryFileAsync(path);
        return Results.Ok(new { path, content });
    }
    catch (FileNotFoundException)
    {
        return Results.NotFound(new { error = "Memory file not found" });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

// API: Save/create a memory file
app.MapPost("/api/memory/file/save", async (MemoryFileSaveRequest req, IMemoryService mem, IGitService git) =>
{
    try
    {
        await mem.SaveMemoryFileAsync(req.Path, req.Content);
        await git.AutoCommitAsync($"memory file save: {req.Path}", mem.MemoryDirectoryRelativePath);
        return Results.Ok(new { saved = true, path = req.Path });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

// API: Soft-delete a memory file
app.MapPost("/api/memory/file/delete", async (MemoryFileDeleteRequest req, IMemoryService mem, IGitService git) =>
{
    try
    {
        await mem.SoftDeleteMemoryFileAsync(req.Path, req.Reason);
        await git.AutoCommitAsync($"memory file soft-delete: {req.Path}", mem.MemoryDirectoryRelativePath);
        return Results.Ok(new { deleted = true, path = req.Path });
    }
    catch (FileNotFoundException)
    {
        return Results.NotFound(new { error = "Memory file not found" });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException or InvalidOperationException)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

// API: Save to persistent memory
app.MapPost("/api/memory/save", async (MemorySaveRequest req, IMemoryService mem, IGitService git) =>
{
    await mem.SaveMemoryAsync(req.Section, req.Content);
    await git.AutoCommitAsync($"memory update: {req.Section}", mem.MemoryDirectoryRelativePath);
    return Results.Ok(new { saved = true });
});

// API: Save full memory content (replace entire file)
app.MapPost("/api/memory/save-full", async (MemoryFullSaveRequest req, IMemoryService mem, IGitService git) =>
{
    await mem.SaveFullAsync(req.Content);
    await git.AutoCommitAsync("memory full update", mem.MemoryDirectoryRelativePath);
    return Results.Ok(new { saved = true });
});

// API: Get a specific section from memory
app.MapGet("/api/memory/section", async (string name, IMemoryService mem) =>
{
    var content = await mem.GetPersistentMemoryAsync();
    var marker = $"## {name}";
    var idx = content.IndexOf(marker);
    if (idx == -1) return Results.NotFound();
    var end = content.IndexOf("\n## ", idx + marker.Length);
    if (end == -1) end = content.Length;
    return Results.Ok(new { section = content[idx..end] });
});

// API: Delete a memory section
app.MapPost("/api/memory/delete-section", async (MemoryDeleteSectionRequest req, IMemoryService mem, IGitService git) =>
{
    await mem.DeleteSectionAsync(req.Section);
    await git.AutoCommitAsync($"memory delete-section: {req.Section}", mem.MemoryDirectoryRelativePath);
    return Results.Ok(new { deleted = true });
});

// API: Tell AI to manage memory (parse an instruction string)
app.MapPost("/api/memory/ai-manage", async (MemoryAiManageRequest req, IMemoryManagementService manager, IMemoryService mem, IGitService git) =>
{
    var result = await manager.ManageAsync(req.Instruction);
    if (result.Changed)
        await git.AutoCommitAsync("memory ai-manage", mem.MemoryDirectoryRelativePath);

    return Results.Ok(new
    {
        saved = result.Changed,
        operations = result.Operations,
        raw = result.RawResponse
    });
});

// API: Historical memory rebuild source list
app.MapGet("/api/memory/rebuild/sources", async (int? chunkTokens, IMemoryRebuildService rebuild) =>
{
    var sources = await rebuild.ListSourcesAsync(chunkTokens ?? 600_000);
    return Results.Ok(sources);
});

// API: Historical memory rebuild status
app.MapGet("/api/memory/rebuild/status", (IMemoryRebuildService rebuild) =>
{
    return Results.Ok(rebuild.GetStatus());
});

// API: Start a historical memory rebuild dry-run or apply run
app.MapPost("/api/memory/rebuild/start", async (MemoryRebuildStartRequest req, IMemoryRebuildService rebuild) =>
{
    try
    {
        var status = await rebuild.StartAsync(new MemoryRebuildStartOptions(
            SourceKey: req.SourceKey,
            ProcessRemaining: req.ProcessRemaining,
            Apply: req.Apply,
            ChunkTokens: req.ChunkTokens <= 0 ? 600_000 : req.ChunkTokens,
            MaxOutputTokens: req.MaxOutputTokens <= 0 ? 384_000 : req.MaxOutputTokens,
            MaxChunks: req.MaxChunks,
            Resume: req.Resume,
            CommitAfterSource: req.CommitAfterSource,
            RetentionAudit: req.RetentionAudit,
            ChangeAudit: req.ChangeAudit));
        return Results.Ok(status);
    }
    catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

// API: Cancel the current historical memory rebuild process
app.MapPost("/api/memory/rebuild/cancel", async (IMemoryRebuildService rebuild) =>
{
    var status = await rebuild.CancelAsync();
    return Results.Ok(status);
});

// API: List files in the rebuilt latest memory directory
app.MapGet("/api/memory/rebuild/latest/files", async (IMemoryRebuildService rebuild) =>
{
    var files = await rebuild.ListLatestFilesAsync();
    return Results.Ok(files);
});

// API: Read a file from the rebuilt latest memory directory
app.MapGet("/api/memory/rebuild/latest/file", async (string path, IMemoryRebuildService rebuild) =>
{
    try
    {
        var content = await rebuild.ReadLatestFileAsync(path);
        return Results.Ok(new { path, content });
    }
    catch (FileNotFoundException)
    {
        return Results.NotFound(new { error = "Rebuilt memory file not found" });
    }
    catch (Exception ex) when (ex is ArgumentException or UnauthorizedAccessException)
    {
        return Results.BadRequest(new { error = ex.Message });
    }
});

// API: Update the Story Bible
app.MapPost("/api/bible/update", async (BibleUpdateRequest req, IFileService files, IGitService git) =>
{
    var biblePath = "Story Bible.md";
    var currentBible = files.ReadFile(biblePath);
    // Append a new entry rather than rewriting
    var entry = $"\n\n---\n\n*Update {DateTime.UtcNow:g}* \u2014 {req.Note}";
    await files.WriteFileAsync(biblePath, currentBible + entry);
    await git.AutoCommitAsync($"bible: {req.Note}", biblePath);
    return Results.Ok(new { updated = true });
});

// API: Rewrite an instruction file (used by Tool Log rerun button)
app.MapPost("/api/instructions/rewrite", async (InstructionRewriteRequest req, IInstructionRewriteService rewriter) =>
{
    var result = await rewriter.RewriteAsync(req.Path, req.Instruction);
    return result.IsError
        ? Results.BadRequest(result)
        : Results.Ok(result);
});

// API: Search past conversations
app.MapGet("/api/search", async (string q, ISearchService search) =>
{
    var results = await search.SearchAsync(q);
    return Results.Ok(results);
});

// API: Get search index stats
app.MapGet("/api/search/stats", (ISearchService search) =>
{
    return Results.Ok(new { indexedConversations = search.IndexedConversations });
});

// API: Nightscout read-only CGM integration
app.MapGet("/api/nightscout/status", async (INightscoutService nightscout, CancellationToken ct) =>
{
    return Results.Ok(await nightscout.GetStatusAsync(ct));
});

app.MapGet("/api/nightscout/latest", async (int? count, INightscoutService nightscout, CancellationToken ct) =>
{
    return Results.Ok(await nightscout.GetLatestAsync(count ?? 6, ct));
});

app.MapGet("/api/nightscout/history", async (
    string? start,
    string? end,
    int? count,
    INightscoutService nightscout,
    CancellationToken ct) =>
{
    return Results.Ok(await nightscout.GetHistoryAsync(start, end, count ?? 288, ct));
});

// Build search index on startup
var searchService = app.Services.GetRequiredService<ISearchService>();
await searchService.BuildIndexAsync();

var localUrl = explicitUrls ? "configured by --urls/ASPNETCORE_URLS" : $"http://localhost:{httpPort}";
var lanUrl = explicitUrls ? "configured by --urls/ASPNETCORE_URLS" : $"http://<your-ip>:{httpPort}";
var lanHttpsUrl = explicitUrls
    ? "configured by --urls/ASPNETCORE_URLS"
    : httpsEnabled
        ? $"https://<your-ip>:{httpsPort}"
        : "disabled (add .kaichat-lan.pfx)";

Console.WriteLine(@$"
  ╔══════════════════════════════════════════╗
  ║              KaiChat v1                  ║
  ║                                          ║
  ║  {config.Model} @ {config.ApiEndpoint}  ║
  ║  Base: {config.BaseFolder}               ║
  ║                                          ║
  ║  Local:  {localUrl}            ║
  ║  LAN:    {lanUrl}           ║
  ║  HTTPS:  {lanHttpsUrl}           ║
  ╚══════════════════════════════════════════╝
");

app.Run();

static string GetAppVersion()
{
    var assemblyPath = typeof(Program).Assembly.Location;
    if (!string.IsNullOrWhiteSpace(assemblyPath) && File.Exists(assemblyPath))
        return File.GetLastWriteTimeUtc(assemblyPath).Ticks.ToString();

    return DateTime.UtcNow.Ticks.ToString();
}

static IEnumerable<object> BuildPersonaCatalog(string? baseFolder)
{
    var root = Path.GetFullPath(baseFolder ?? ".");
    var personasRoot = Path.Combine(root, "KaiChat", "Personas");
    if (!Directory.Exists(personasRoot))
        return Array.Empty<object>();

    return Directory.GetDirectories(personasRoot)
        .Select(directory =>
        {
            var name = Path.GetFileName(directory);
            var conversationsDirectory = Path.Combine(directory, "Conversations");
            var conversationCount = Directory.Exists(conversationsDirectory)
                ? Directory.GetFiles(conversationsDirectory, "*.json").Length
                : 0;
            var hasConversations = conversationCount > 0;
            return new
            {
                name,
                key = name,
                hasConversations,
                conversationCount,
                hasAutonomy = File.Exists(Path.Combine(directory, ".kaichat-autonomy.json")),
                hasMemory = Directory.Exists(Path.Combine(directory, "Memory"))
            };
        })
        .Where(p => !string.IsNullOrWhiteSpace(p.name))
        .OrderBy(p => p.name)
        .ToArray();
}

static async Task<IResult> CommitKaiTimerResultAsync(KaiTimerOperationResult result, IGitService git, string summary)
{
    if (result.Success)
        await git.AutoCommitAsync(summary, ".kaichat-timer.json");

    return result.Success
        ? Results.Ok(result)
        : Results.BadRequest(result);
}

// Request types
record MemorySaveRequest(string Section, string Content);
record MemoryFullSaveRequest(string Content);
record MemoryDeleteSectionRequest(string Section);
record MemoryAiManageRequest(string Instruction);
record MemoryFileSaveRequest(string Path, string Content);
record MemoryFileDeleteRequest(string Path, string Reason);
record MemoryRebuildStartRequest(
    string? SourceKey = null,
    bool ProcessRemaining = false,
    bool Apply = false,
    int ChunkTokens = 600_000,
    int MaxOutputTokens = 384_000,
    int MaxChunks = 0,
    bool Resume = true,
    bool CommitAfterSource = true,
    bool RetentionAudit = true,
    bool ChangeAudit = true);
record BibleUpdateRequest(string Note);
record FileRenameRequest(string Source, string Dest);
record InstructionRewriteRequest(string Path, string Instruction);
record CompactionTestRequest(bool Force = false, bool GenerateSummary = false);
Offline