← Back to Files
Program.cs
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);