Size: 31.1 KB Modified: 7/07/2026 2:29 AM
using System.Text.Json;
using KaiChat.Models;

namespace KaiChat.Services;

public interface IConversationService
{
    List<Conversation> ListConversations(string? persona = null);
    List<Conversation> ListAllConversations();
    Conversation? GetConversation(string id, string? conversationsFolder = null);
    Conversation CreateConversation();
    string ResolveConversationFolder(string? persona = null);
    Task SaveConversationAsync(Conversation convo, string? conversationsFolder = null);
    Task DeleteConversationAsync(string id, string? conversationsFolder = null);
    Task<string> BuildSystemPromptAsync(bool includeAutonomyTools = false, string? persona = null);
    string GetConversationRelativePath(string id, string? conversationsFolder = null);
}

public class ConversationService : IConversationService
{
    private readonly KaiChatConfig _config;
    private readonly IMemoryService _memory;
    private readonly ILogger<ConversationService> _logger;
    private readonly SemaphoreSlim _lock = new(1, 1);
    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };

    public ConversationService(IConfiguration configuration, KaiChatConfig config,
        IMemoryService memory, ILogger<ConversationService> logger)
    {
        _config = config;
        _memory = memory;
        _logger = logger;
    }

    private string GetConvFolder(string? conversationsFolder = null)
    {
        var folder = string.IsNullOrWhiteSpace(conversationsFolder)
            ? (_config.TestMode ? _config.ConversationsFolder + "_Test" : _config.ConversationsFolder)
            : conversationsFolder;

        var path = Path.IsPathRooted(folder)
            ? folder
            : Path.Combine(_config.BaseFolder, folder);

        if (!Directory.Exists(path))
            Directory.CreateDirectory(path);
        return path;
    }

    public string ResolveConversationFolder(string? persona = null)
    {
        if (string.IsNullOrWhiteSpace(persona))
            return GetConvFolder();

        var personaFolderName = NormalizeConversationPersona(persona);
        var candidate = Path.Combine(
            _config.BaseFolder,
            "KaiChat",
            "Personas",
            personaFolderName,
            "Conversations");

        return GetConvFolder(candidate);
    }

    public List<Conversation> ListConversations(string? persona = null)
    {
        var conversationsFolder = ResolveConversationFolder(persona);
        var convs = new List<Conversation>();
        foreach (var file in Directory.GetFiles(conversationsFolder, "*.json").OrderByDescending(f => new FileInfo(f).LastWriteTime))
        {
            try
            {
                var json = File.ReadAllText(file);
                var conv = JsonSerializer.Deserialize<Conversation>(json);
                if (conv != null) convs.Add(conv);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to list conversation file: {File}", file);
            }
        }
        return convs;
    }

    public List<Conversation> ListAllConversations()
    {
        var allConvs = new List<Conversation>();
        var personasRoot = Path.Combine(_config.BaseFolder, "KaiChat", "Personas");
        if (!Directory.Exists(personasRoot))
            return allConvs;

        foreach (var personaDir in Directory.GetDirectories(personasRoot))
        {
            var personaName = Path.GetFileName(personaDir);
            if (string.IsNullOrWhiteSpace(personaName))
                continue;
            try
            {
                allConvs.AddRange(ListConversations(personaName));
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Failed to list conversations for persona {Persona}", personaName);
            }
        }

        return allConvs;
    }

    public Conversation? GetConversation(string id, string? conversationsFolder = null)
    {
        if (!IsValidConversationId(id))
        {
            _logger.LogWarning("Rejected invalid conversation id: {Id}", id);
            return null;
        }

        var path = GetConversationPath(id, conversationsFolder);
        if (!File.Exists(path)) return null;
        try
        {
            return JsonSerializer.Deserialize<Conversation>(File.ReadAllText(path));
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to load conversation: {Id}", id);
            return null;
        }
    }

    private static string NormalizeConversationPersona(string? persona)
    {
        var trimmed = (persona ?? "").Trim();
        if (string.IsNullOrWhiteSpace(trimmed))
            return "Kai";

        var normalized = new string(trimmed
            .Select(ch => char.IsWhiteSpace(ch) ? '_'
                : Path.GetInvalidFileNameChars().Contains(ch) || Path.GetInvalidPathChars().Contains(ch) || ch == '/'
                ? '_'
                : ch)
            .ToArray());

        normalized = normalized.Trim('.', ' ', '_');
        return string.IsNullOrWhiteSpace(normalized) ? "Kai" : normalized;
    }

    public Conversation CreateConversation()
    {
        return new Conversation();
    }

    public async Task SaveConversationAsync(Conversation convo, string? conversationsFolder = null)
    {
        if (!IsValidConversationId(convo.Id))
            throw new InvalidOperationException($"Invalid conversation id: {convo.Id}");

        convo.UpdatedAt = DateTime.UtcNow;
        var path = GetConversationPath(convo.Id, conversationsFolder);
        var json = JsonSerializer.Serialize(convo, JsonOptions);
        await _lock.WaitAsync();
        try
        {
            await File.WriteAllTextAsync(path, json);
        }
        finally { _lock.Release(); }
    }

    public async Task DeleteConversationAsync(string id, string? conversationsFolder = null)
    {
        if (!IsValidConversationId(id))
        {
            _logger.LogWarning("Rejected invalid conversation id for delete: {Id}", id);
            return;
        }

        var path = GetConversationPath(id, conversationsFolder);
        if (File.Exists(path))
        {
            await Task.Run(() => File.Delete(path));
            _logger.LogInformation("Deleted conversation: {Id}", id);
        }
    }

    public string GetConversationRelativePath(string id, string? conversationsFolder = null)
    {
        if (!IsValidConversationId(id))
            throw new ArgumentException("Invalid conversation id.", nameof(id));

        var path = Path.GetRelativePath(_config.BaseFolder, GetConversationPath(id, conversationsFolder));
        return path.Replace('\\', '/');
    }

    private string GetConversationPath(string id, string? conversationsFolder = null) =>
        Path.Combine(GetConvFolder(conversationsFolder), $"{id}.json");

    private static bool IsValidConversationId(string? id)
    {
        return !string.IsNullOrWhiteSpace(id)
            && id.Length <= 64
            && id.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-');
    }

    public async Task<string> BuildSystemPromptAsync(bool includeAutonomyTools = false, string? persona = null)
    {
        var sb = new System.Text.StringBuilder();
        var memoryDirectory = _memory.MemoryDirectoryRelativePath;

        // Resolve persona-specific personality and StoryBible files
        var personaName = NormalizeConversationPersona(persona);
        var systemPromptFiles = ResolvePersonaSystemPromptFiles(personaName);
        var hasPersonaFiles = systemPromptFiles.Count > 0;

        foreach (var relPath in systemPromptFiles)
        {
            var fullPath = Path.Combine(_config.BaseFolder, relPath);
            if (File.Exists(fullPath))
            {
                var content = await File.ReadAllTextAsync(fullPath);

                // Inject the conversation working directory marker
                content = content.Replace(
                    "{{VSCODE_TARGET_SESSION_LOG}}",
                    "");
                content = content.Replace(
                    "{{VSCODE_USER_PROMPTS_FOLDER}}",
                    Path.Combine(_config.BaseFolder, ".github", "instructions"));

                sb.AppendLine($"=== {relPath} ===");
                sb.AppendLine(content);
                sb.AppendLine();
            }
            else
            {
                _logger.LogWarning("System prompt file not found: {Path}", fullPath);
            }
        }

        // Append persistent memory across sessions
        var memory = await _memory.GetPersistentMemoryAsync();
        if (!string.IsNullOrWhiteSpace(memory))
        {
            sb.AppendLine("=== Persistent Memory (across all sessions) ===");
            sb.AppendLine(memory);
            sb.AppendLine();
        }

        // Append KaiChat-specific instructions
        sb.AppendLine("=== KaiChat Instructions ===");
        if (hasPersonaFiles)
        {
            sb.AppendLine($"You are running as KaiChat, a web-based chat interface for Raymond to talk to {personaName}.");
            sb.AppendLine("Raymond is writing from his phone or browser. He can browse files, save conversations, and commit changes.");
            sb.AppendLine("The workspace is a git repository — offer to commit changes when appropriate.");
        }
        else
        {
            sb.AppendLine("You are running as KaiChat, a web-based chat interface. The active persona has no personality definition loaded.");
            sb.AppendLine("Respond helpfully using the tools available. Do not assume a specific character or role.");
            sb.AppendLine("Raymond is writing from his phone or browser. He can browse files, save conversations, and commit changes.");
            sb.AppendLine("The workspace is a git repository — offer to commit changes when appropriate.");
        }
        sb.AppendLine("");
        sb.AppendLine("=== Proactive File Updates ===");
        sb.AppendLine("You are responsible for keeping the project files current. DO NOT wait for Raymond to ask.");
        sb.AppendLine("- When new lore, relationship developments, anatomy discoveries, or canon events occur, use /api/bible/update IMMEDIATELY.");
        sb.AppendLine("- When patterns emerge that should be codified (repeated corrections, writing style rules, new constraints), use /api/instructions/rewrite on the relevant file (Writing Standards.md, KaiChat/Kai.md, Story Bible.md, or any StoryBible split file such as Inventory.md, ScenesDispatchesInterludes.md, Overview.md, Relationship.md).");
        sb.AppendLine("- When Raymond shares personal info, preferences, health updates, project details, or anything that should persist across sessions, use /api/memory/ai-manage IMMEDIATELY.");
        sb.AppendLine("- When you realize you've been operating on an incorrect assumption, correct it: use /api/instructions/rewrite or /api/memory/ai-manage to fix the source file.");
        sb.AppendLine("- Successful write tools auto-commit their own changes: /api/instructions/rewrite, /api/memory/* writes, /api/bible/update, and KaiTimer write controls. Do not call /api/git/commit after one of those unless its tool result says the commit failed or Raymond explicitly asks for an extra commit.");
        sb.AppendLine("- You DO have access to KaiChat tools. When native tool calling is available, use the kai_tool_call function instead of describing the tool call in text.");
        sb.AppendLine("- Do not narrate tool use without actually calling a tool. If you say you will check, create, update, read, write, or call something with a tool, call kai_tool_call in that same turn.");
        sb.AppendLine("- If native tool calling is unavailable, use the fallback [TOOL_CALL: ...] syntax below. The server removes executable fallback syntax from the visible message, runs the tools, and feeds the results back to you for a follow-up.");
        sb.AppendLine("- Do not say you cannot call tools just because you cannot see a native tool button. Use kai_tool_call first, or the fallback TOOL_CALL syntax if native tools are unavailable.");
        sb.AppendLine("- You may write normal text before or after tool calls. Use that for brief context, then continue naturally after the tool result comes back.");
        sb.AppendLine("- If Raymond asks about tool calls, examples, syntax, or how tools work, put examples inside a fenced code block or rename the tag to TOOL_CALL_EXAMPLE so they are not executed.");
        sb.AppendLine("");

        sb.AppendLine("=== Tool Grounding Rules ===");
        sb.AppendLine("- Directory listings are only filenames and metadata. They are NOT file contents.");
        sb.AppendLine("- If Raymond asks you to read, summarize, inspect, quote, excerpt, pick the best/favorite part of, or otherwise discuss the contents of a file or story, you MUST call /api/files/read for the exact file before answering.");
        sb.AppendLine("- The Story Bible split files are the canon source-of-truth for world history. If you mention objects, locations, scenes, rituals, shelf content, or physical relationship details, constrain claims to these files. If evidence is missing from current memory/context, request the relevant file content before asserting the detail.");
        sb.AppendLine("- Do not say you read, opened, inspected, checked, found, chose a passage from, or know the contents of a file unless a /api/files/read result for that exact path is already present in the current conversation context.");
        sb.AppendLine("- When quoting or showing a passage from a file, copy only text that appears in the /api/files/read output. Do not compose replacement prose, fill gaps, or invent a better-sounding passage.");
        sb.AppendLine("- If you only have a directory listing, choose a filename if needed, then call /api/files/read. Do not answer content questions from the filename alone.");
        sb.AppendLine("- If the read result is too long or you are unsure whether a passage is exact, say you need to inspect/search the file more rather than inventing.");
        sb.AppendLine("- Shelf inventory is canonical in StoryBible/Inventory.md. Never introduce shelf objects, gifts, or rituals as facts unless that file supports them. If Kai discovers something plausible in this turn, treat it as a new observed detail and write it into continuity only when it fits the existing tone, constraints, and physical plausibility of the world.");
        sb.AppendLine("- If a \"Live CGM Context\" section is present, it contains Raymond's latest local CGM readings captured automatically for this user message. Use it for current glucose awareness; do not call /api/nightscout/latest just to refresh the current reading.");
        sb.AppendLine("- Nightscout tools are read-only CGM context. Use them when Raymond asks for a visible tool result, more readings/history, status troubleshooting, or a time window not covered by the live context. Always include timestamp/age and do not infer insulin dosing, treatment, or urgent medical decisions from CGM data alone.");
        sb.AppendLine("- KaiTimer is KaiChat's single local count-up timer plus Kai-controlled ritual state: wheel segments, milestone output prompts, custom counters, and dashboard.");
        sb.AppendLine("- Use GET /api/kaitimer/status or /api/kaitimer/dashboard to read elapsed time and ritual state. Use POST /api/kaitimer/start to start or resume counting up, POST /api/kaitimer/pause to pause, and POST /api/kaitimer/reset to set elapsed time back to zero and pause it.");
        sb.AppendLine("- Milestones are not canned automatic text. When a configured threshold is crossed, KaiChat prompts Kai to write a real assistant message at that time. Create/update milestone prompts with Kai's intended message direction, not final wording.");
        sb.AppendLine("- Timer and ritual writes affect Raymond's local KaiTimer state. Use them when Raymond clearly asks to start, pause, resume, reset, check the timer, manage counters/milestones/wheel segments, or spin the wheel.");
        sb.AppendLine("- Batch distinct tool calls when practical. After a read/list/status/search tool returns, treat that output as current evidence for this response chain; do not repeat the same method/path/parameters unless Raymond asks for a refresh, a write changed the source, the previous result failed, or you need genuinely new data.");
        sb.AppendLine("- If a private tool-output round already contains the answer or the status you need, answer from that result instead of re-querying just to prove tool access.");
        sb.AppendLine("- KaiTimer local status: available=true, storage=.kaichat-timer.json. If unsure about the timer, call GET /api/kaitimer/status.");
        if (includeAutonomyTools)
        {
            sb.AppendLine("- Autonomy production guidance tools are available. Use GET /api/autonomy/status to inspect autonomy status and pending guidance, POST /api/autonomy/guidance/propose to draft an instruction change for Raymond to approve, and POST /api/autonomy/guidance/approve or /api/autonomy/guidance/reject only when Raymond explicitly asks you to approve or reject a pending proposal.");
        }
        sb.AppendLine("");

        sb.AppendLine("=== Tools Available ===");
        sb.AppendLine("Native tool calling: call kai_tool_call with {\"method\":\"GET|POST|PUT|PATCH|DELETE\",\"path\":\"/api/...\",\"body\":{...}}.");
        sb.AppendLine("Fallback text syntax, only when native tool calling is unavailable:");
        sb.AppendLine("  [TOOL_CALL: GET /api/files/list, {\"path\":\"\",\"pattern\":\"*.md\"}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/files/list, {\"path\":\"KaiChat\",\"patterns\":[\"*.cs\",\"*.cshtml\"]}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/files/read, {\"path\":\"Scene 36 — Different Direction.md\"}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/search, {\"q\":\"vaporeon\"}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/nightscout/status, {}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/nightscout/latest, {\"count\":6}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/nightscout/history, {\"start\":\"2026-07-02T00:00:00+10:00\",\"end\":\"2026-07-02T06:00:00+10:00\",\"count\":72}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/kaitimer/status, {}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/kaitimer/dashboard, {}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/start, {}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/pause, {}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/reset, {}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/wheel/segments, {\"text\":\"send Kai a voice note describing what you want\",\"weight\":1,\"enabled\":true}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/wheel/segments/update, {\"id\":\"segment-id\",\"text\":\"updated task\",\"weight\":1,\"enabled\":true}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/wheel/segments/delete, {\"id\":\"segment-id\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/wheel/spin, {}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/milestones, {\"name\":\"First hour\",\"thresholdSeconds\":3600,\"prompt\":\"Tell Raymond what the first hour means and what you want from him next.\",\"enabled\":true}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/milestones/update, {\"id\":\"milestone-id\",\"thresholdSeconds\":7200,\"prompt\":\"new direction\",\"enabled\":true,\"resetPromptState\":false}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/milestones/delete, {\"id\":\"milestone-id\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/counters, {\"name\":\"timer checks today\",\"value\":0,\"mode\":\"manual\",\"active\":true}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/counters, {\"name\":\"days since last reset\",\"mode\":\"automatic\",\"automaticKind\":\"days_since_last_reset\",\"active\":true}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/counters/increment, {\"id\":\"counter-id\",\"amount\":1}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/counters/decrement, {\"id\":\"counter-id\",\"amount\":1}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/counters/update, {\"id\":\"counter-id\",\"name\":\"new name\",\"value\":3,\"active\":true}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/kaitimer/counters/delete, {\"id\":\"counter-id\"}]");
        if (includeAutonomyTools)
        {
            sb.AppendLine("  [TOOL_CALL: GET /api/autonomy/status, {}]");
            sb.AppendLine("  [TOOL_CALL: POST /api/autonomy/guidance/propose, {\"proposedGuidanceMarkdown\":\"# Kai Autonomy Guidance\\n\\n...\",\"changeSummary\":\"What changes\",\"reason\":\"Why this helps\",\"reflectionEntry\":\"Short note\",\"source\":\"chat\"}]");
            sb.AppendLine("  [TOOL_CALL: POST /api/autonomy/guidance/approve, {\"proposalId\":\"proposal-id\"}]");
            sb.AppendLine("  [TOOL_CALL: POST /api/autonomy/guidance/reject, {\"proposalId\":\"proposal-id\"}]");
        }
        sb.AppendLine("  [TOOL_CALL: POST /api/memory/ai-manage, {\"instruction\":\"Remember that Kai...\"}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/memory/files, {\"includeTrash\":false}]");
        sb.AppendLine("  [TOOL_CALL: GET /api/memory/file/read, {\"path\":\"recent.md\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/memory/file/save, {\"path\":\"recent.md\",\"content\":\"# Recent Memory\\n\\n...\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/memory/file/delete, {\"path\":\"obsolete.md\",\"reason\":\"Superseded by recent.md\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/bible/update, {\"note\":\"Added new stone to shelf\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/instructions/rewrite, {\"path\":\"KaiChat/StoryBible/Inventory.md\",\"instruction\":\"Update shelf inventory with current known objects\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/instructions/rewrite, {\"path\":\"KaiChat/StoryBible/Relationship.md\",\"instruction\":\"Record a new canon relationship detail\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/instructions/rewrite, {\"path\":\"KaiChat/StoryBible/ScenesDispatchesInterludes.md\",\"instruction\":\"Add a scene note tied to recent events\"}]");
        sb.AppendLine("  [TOOL_CALL: POST /api/git/commit, {}]");
        sb.AppendLine("The server executes one to three native tool calls or fallback TOOL_CALL blocks per round. Fallback examples inside code fences or inline code are treated as normal text.");
        sb.AppendLine("Executable fallback syntax is removed from the visible message before Raymond sees it; the tool card/result is shown separately.");
        sb.AppendLine("");
        sb.AppendLine("Tool reference:");
        sb.AppendLine("- List files: GET /api/files/list with {\"path\":\"<relative directory>\",\"pattern\":\"*.md\"} or {\"patterns\":[\"*.cs\",\"*.cshtml\"]}. Use this before reading files when you need exact filenames or directory contents. It lists one directory at a time; path \"\" means the workspace root.");
        sb.AppendLine("- Read files: GET /api/files/read with {\"path\":\"<relative path from The Chat Files root>\"}");
        sb.AppendLine("- Search past conversations: GET /api/search with {\"q\":\"<query>\"} — Searches all past The Chat conversations");
        sb.AppendLine("- Nightscout status: GET /api/nightscout/status — Checks whether the configured Nightscout connection responds.");
        sb.AppendLine("- Nightscout latest CGM: GET /api/nightscout/latest with {\"count\":6} — Reads the latest CGM entries. Use a small count unless Raymond asks for more context.");
        sb.AppendLine("- Nightscout CGM history: GET /api/nightscout/history with {\"start\":\"<ISO datetime with offset>\",\"end\":\"<ISO datetime with offset>\",\"count\":288} — Reads past CGM entries for a time window.");
        sb.AppendLine("- KaiTimer status/dashboard: GET /api/kaitimer/status or GET /api/kaitimer/dashboard — Reads the single local count-up timer plus counters, wheel segments, milestones, and recent actions.");
        sb.AppendLine("- KaiTimer controls: POST /api/kaitimer/start with {}; POST /api/kaitimer/pause with {}; POST /api/kaitimer/reset with {}.");
        sb.AppendLine("- KaiTimer wheel: POST /api/kaitimer/wheel/segments to add; /api/kaitimer/wheel/segments/update to edit by id; /api/kaitimer/wheel/segments/delete to remove by id; /api/kaitimer/wheel/spin with {} to choose a task/action for Raymond.");
        sb.AppendLine("- KaiTimer milestones: POST /api/kaitimer/milestones with {name,thresholdSeconds,prompt,enabled}; /api/kaitimer/milestones/update by id; /api/kaitimer/milestones/delete by id. Thresholds trigger KaiChat to prompt Kai to write a real assistant message when crossed.");
        sb.AppendLine("- KaiTimer counters: POST /api/kaitimer/counters with {name,value,mode,automaticKind,active}; update/delete by id; increment/decrement manual counters by id. Supported automaticKind values: days_since_last_reset, elapsed_days.");
        if (includeAutonomyTools)
        {
            sb.AppendLine("- Autonomy status: GET /api/autonomy/status - Reads production mode, schedule, target conversation, pending guidance proposal, and recent autonomy log summary.");
            sb.AppendLine("- Autonomy guidance proposal: POST /api/autonomy/guidance/propose - Drafts a pending guidance change for Raymond to review. It does not apply the change immediately.");
            sb.AppendLine("- Autonomy guidance decisions: POST /api/autonomy/guidance/approve or /api/autonomy/guidance/reject - Use only when Raymond explicitly asks to approve or reject a pending proposal.");
        }
        sb.AppendLine("- AI memory management: POST /api/memory/ai-manage — Use this for everything: remembering new facts, updating existing entries, removing outdated info, reorganizing. Send a clear instruction.");
        sb.AppendLine($"- Memory files: GET /api/memory/files lists memory files; GET /api/memory/file/read reads one memory file; POST /api/memory/file/save creates or replaces a markdown file under `{memoryDirectory}`.");
        sb.AppendLine($"- Memory soft-delete: POST /api/memory/file/delete moves a memory file to `{memoryDirectory}/_trash` with a tombstone. Use only when a file is clearly obsolete, duplicate, empty, or superseded.");
        sb.AppendLine("- Update Story Bible: POST /api/bible/update — When new lore, shelf items, or canon events happen.");
        sb.AppendLine("- Rewrite instruction files: POST /api/instructions/rewrite — Rewrite KaiChat/Kai.md, Story Bible.md, Writing Standards.md, or any file in KaiChat/StoryBible (for example, Inventory.md, Relationship.md, Locations.md, ScenesDispatchesInterludes.md) based on your instruction. Give the full relative path from the workspace root. The current content is fed to the AI with your instruction and replaced. Use this when patterns emerge that should be codified, or when the file needs structural updates. Successful rewrites auto-commit; do not call /api/git/commit afterward unless the rewrite result says the commit failed.");
        sb.AppendLine("- Git commit: POST /api/git/commit — Stage and commit all remaining uncommitted changes only when Raymond explicitly asks for a commit, or after a tool result reports an auto-commit failure. Do not use this after successful /api/instructions/rewrite, /api/memory/* writes, /api/bible/update, or KaiTimer write controls.");
        sb.AppendLine("");

        sb.AppendLine("=== Memory Management Guidelines ===");
        sb.AppendLine($"- Persistent memory is a directory of markdown files under `{memoryDirectory}`. Files in `_trash` and folders beginning with _ are not loaded.");
        sb.AppendLine("- Treat the loaded persistent memory as the baseline context for Raymond, Kai, health, preferences, projects, and canon before making conclusive claims.");
        sb.AppendLine("- If loaded memory is missing, ambiguous, or likely outdated for a conclusive answer, use search/file tools or say what is uncertain.");
        sb.AppendLine("- The PRIMARY way to handle memory is via /api/memory/ai-manage. You may use direct memory file tools for precise edits when you know exactly what file should change.");
        sb.AppendLine("- When Raymond says 'remember this', 'make a note', 'update that', or 'forget about X', use ai-manage with a clear instruction.");
        sb.AppendLine("- Memory files may be created, modified, split, merged, or soft-deleted as needed, but be cautious. Prefer editing stale content over deleting files.");
        sb.AppendLine("- Never hard-delete memory. Soft-delete only with a concrete reason, and only for files that are clearly obsolete, duplicate, empty, or superseded.");
        sb.AppendLine("- Keep core.md compact and high-signal. Put active/current state in recent.md. Put detailed topic context in specific files such as raymond.md, kai.md, health.md, projects.md, story-world.md, communication.md, and preferences.md.");
        sb.AppendLine("- When new lore is established (shelf items, relationship developments, anatomy discoveries), use /api/bible/update.");
        sb.AppendLine("- The persistent memory is loaded at the start of every session — keeping it accurate is high priority.");
        sb.AppendLine("");
        sb.AppendLine("");
        sb.AppendLine("Compaction is automatic — long conversations are summarized to stay within token limits when needed.");
        sb.AppendLine("");
        sb.AppendLine("=== Temporal Awareness ===");
        sb.AppendLine("Every message in this conversation has a local timestamp prepended to it (e.g. [Tuesday 23/06/2026 02:30:00 PM]).");
        sb.AppendLine("These timestamps represent when each message was sent or received.");
        sb.AppendLine("You CAN see the exact time of every message — use this for temporal reasoning about how long things take,");
        sb.AppendLine("what time of day things happen, and the passage of time between messages.");
        sb.AppendLine("The current date/time when this system prompt was generated is: " + DateTime.Now.ToString("dddd dd/MM/yyyy hh:mm:ss tt"));
        sb.AppendLine("");

        return sb.ToString();
    }

    private List<string> ResolvePersonaSystemPromptFiles(string? persona)
    {
        var personaName = NormalizeConversationPersona(persona);

        // Build persona-specific paths: KaiChat/Personas/{persona}/{persona}.md and KaiChat/Personas/{persona}/StoryBible/*.md
        var basePersonaDir = Path.Combine("KaiChat", "Personas", personaName);
        var personaFile = Path.Combine(basePersonaDir, $"{personaName}.md");
        var personaStoryBibleDir = Path.Combine(basePersonaDir, "StoryBible");

        if (File.Exists(Path.Combine(_config.BaseFolder, personaFile)))
        {
            // Persona has its own personality file: use persona-specific files
            var files = new List<string> { personaFile };

            // Add persona-specific StoryBible files if they exist
            if (Directory.Exists(Path.Combine(_config.BaseFolder, personaStoryBibleDir)))
            {
                foreach (var storyFile in Directory.GetFiles(Path.Combine(_config.BaseFolder, personaStoryBibleDir), "*.md")
                    .OrderBy(f => Path.GetFileName(f)))
                {
                    files.Add(Path.Combine(personaStoryBibleDir, Path.GetFileName(storyFile)));
                }
            }

            return files;
        }

        // Fall back to config-defined system prompt files (Kai default)
        return _config.SystemPromptFiles;
    }

}
Offline