Size: 116.0 KB Modified: 6/07/2026 7:33 PM
using Microsoft.AspNetCore.SignalR;
using KaiChat.Models;
using KaiChat.Services;
using System.Collections.Concurrent;
using System.Text;
using System.Text.RegularExpressions;

namespace KaiChat.Hubs;

public class ChatHub : Hub
{
    private readonly IChatService _chat;
    private readonly IConversationService _conversation;
    private readonly ICompactionService _compaction;
    private readonly IMemoryService _memoryService;
    private readonly IMemoryManagementService _memoryManager;
    private readonly IMemorySweepService _memorySweep;
    private readonly IGitService _git;
    private readonly IFileService _files;
    private readonly ISearchService _search;
    private readonly IInstructionRewriteService _instructionRewriter;
    private readonly INightscoutService _nightscout;
    private readonly IKaiTimerService _kaiTimer;
    private readonly KaiAutonomyService _autonomy;
    private readonly IActiveConversationService _activeConversation;
    private readonly ICgmBridgeService _cgmBridge;
    private readonly IToolCallLogService _toolLog;
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IHubContext<ChatHub> _hubContext;
    private readonly KaiChatConfig _config;
    private readonly ILogger<ChatHub> _logger;

    private static readonly ConcurrentDictionary<string, StreamSession> _streams = new();
    private static readonly ConcurrentDictionary<string, string> _connectionConversations = new();
    private const int MaxToolCallsPerRound = 3;
    private const int TranslationMaxAttempts = 5;
    private const int AssistantStreamMaxAttempts = 5;
    private static readonly System.Text.Json.JsonSerializerOptions ToolJsonOptions = new(System.Text.Json.JsonSerializerDefaults.Web)
    {
        PropertyNameCaseInsensitive = true
    };

    public ChatHub(IChatService chat, IConversationService conversation,
        ICompactionService compaction, IMemoryService memoryService,
        IMemoryManagementService memoryManager,
        IMemorySweepService memorySweep,
        IGitService git, IFileService files, ISearchService search,
        IInstructionRewriteService instructionRewriter,
        INightscoutService nightscout,
        IKaiTimerService kaiTimer,
        KaiAutonomyService autonomy,
        IActiveConversationService activeConversation,
        ICgmBridgeService cgmBridge,
        IToolCallLogService toolLog,
        IHttpContextAccessor httpContextAccessor,
        IHubContext<ChatHub> hubContext,
        KaiChatConfig config,
        ILogger<ChatHub> logger)
    {
        _chat = chat;
        _conversation = conversation;
        _compaction = compaction;
        _memoryService = memoryService;
        _memoryManager = memoryManager;
        _memorySweep = memorySweep;
        _git = git;
        _files = files;
        _search = search;
        _instructionRewriter = instructionRewriter;
        _nightscout = nightscout;
        _kaiTimer = kaiTimer;
        _autonomy = autonomy;
        _activeConversation = activeConversation;
        _cgmBridge = cgmBridge;
        _toolLog = toolLog;
        _httpContextAccessor = httpContextAccessor;
        _hubContext = hubContext;
        _config = config;
        _logger = logger;
    }

    public override Task OnDisconnectedAsync(Exception? ex)
    {
        _connectionConversations.TryRemove(Context.ConnectionId, out _);
        return base.OnDisconnectedAsync(ex);
    }

    public async Task JoinConversation(string conversationId, string? persona = null)
    {
        if (string.IsNullOrWhiteSpace(conversationId)) return;

        await SetCurrentConversationGroupAsync(conversationId);
        if (!string.IsNullOrWhiteSpace(persona))
            _connectionConversations[Context.ConnectionId] = conversationId;

        if (_streams.TryGetValue(conversationId, out var session))
            await ReplayStreamAsync(session);
    }

    private async Task SetCurrentConversationGroupAsync(string conversationId)
    {
        if (_connectionConversations.TryGetValue(Context.ConnectionId, out var previousId) &&
            previousId != conversationId)
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(previousId));
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(conversationId));
        _connectionConversations[Context.ConnectionId] = conversationId;
        _activeConversation.SetActive(conversationId);
    }

    private async Task ReplayStreamAsync(StreamSession session)
    {
        List<StreamReplayEvent> events;
        lock (session.Gate)
        {
            if (!session.Replayable) return;
            events = session.Events.ToList();
        }

        await Clients.Caller.SendAsync("StreamReplayStarted", session.ConversationId);
        foreach (var evt in events)
            await Clients.Caller.SendCoreAsync(evt.Name, evt.Args);
    }

    private async Task SendStreamEventAsync(StreamSession session, string name, params object?[] args)
    {
        lock (session.Gate)
        {
            session.UpdatedAt = DateTime.UtcNow;
            if (session.Replayable)
                session.Events.Add(new StreamReplayEvent(name, args));
        }

        await _hubContext.Clients.Group(GroupName(session.ConversationId)).SendCoreAsync(name, args);
    }

    private async Task SendToolCallStartedAsync(StreamSession? session, string id, string method, string path, string status, string body)
    {
        if (session != null)
            await SendStreamEventAsync(session, "ToolCallStarted", id, method, path, status, body);
        else
            await Clients.Caller.SendAsync("ToolCallStarted", id, method, path, status, body);
    }

    private async Task SendToolCallResultAsync(StreamSession? session, string id, string status, string result, bool isError)
    {
        if (session != null)
            await SendStreamEventAsync(session, "ToolCallResult", id, status, result, isError);
        else
            await Clients.Caller.SendAsync("ToolCallResult", id, status, result, isError);
    }

    private async Task SendMemorySweepEventAsync(StreamSession session, string eventName, object payload)
    {
        await SendStreamEventAsync(session, eventName, payload);
    }

    private async Task SendCompactionProgressAsync(
        StreamSession session,
        string compactionEventId,
        CompactionProgressEvent evt)
    {
        var payload = new
        {
            id = compactionEventId,
            type = evt.Type,
            message = evt.Message,
            text = evt.Text,
            chunkIndex = evt.ChunkIndex,
            chunkCount = evt.ChunkCount
        };

        var eventName = evt.Type switch
        {
            "thinking-delta" => "CompactionThinkingDelta",
            "summary-delta" or "summary-label" => "CompactionSummaryDelta",
            _ => "CompactionStatus"
        };

        await SendStreamEventAsync(session, eventName, payload);
    }

    public static string GroupName(string conversationId) => $"conversation:{conversationId}";

    private static object BuildMemorySweepPayload(
        string id,
        MemorySweepResult result,
        int compactionNumber,
        DateTime startedAt,
        DateTime completedAt)
    {
        return new
        {
            id,
            compactionNumber,
            message = result.Status,
            messagesSwept = result.MessagesSwept,
            changed = result.Changed,
            success = result.Success,
            operationCount = result.Operations.Count,
            writeCount = result.WriteCount,
            deleteCount = result.DeleteCount,
            failedCount = result.FailedCount,
            report = result.Report,
            rawResponsePreview = result.RawResponsePreview,
            operations = result.Operations.Select(o => new
            {
                action = o.Action,
                path = o.Path,
                success = o.Success,
                message = o.Message,
                reason = o.Reason
            }).ToList(),
            startedAt,
            completedAt
        };
    }

    private static ChatMessage CreateMemorySweepMessage(
        string id,
        MemorySweepResult result,
        int compactionNumber,
        DateTime startedAt,
        DateTime completedAt)
    {
        return new ChatMessage
        {
            Id = id,
            Role = "memory_sweep",
            Content = result.Report,
            Timestamp = completedAt,
            MemorySweep = new MemorySweepMessage
            {
                CompactionNumber = compactionNumber,
                MessagesSwept = result.MessagesSwept,
                Changed = result.Changed,
                Success = result.Success,
                OperationCount = result.Operations.Count,
                WriteCount = result.WriteCount,
                DeleteCount = result.DeleteCount,
                FailedCount = result.FailedCount,
                Status = result.Status,
                RawResponsePreview = result.RawResponsePreview,
                Operations = result.Operations.Select(o => new MemorySweepOperation
                {
                    Action = o.Action,
                    Path = o.Path,
                    Success = o.Success,
                    Message = o.Message,
                    Reason = o.Reason
                }).ToList(),
                StartedAt = startedAt,
                CompletedAt = completedAt
            }
        };
    }

    private static ChatMessage CreateCompactionMessage(
        string id,
        CompactionResult result,
        DateTime startedAt,
        DateTime completedAt)
    {
        return new ChatMessage
        {
            Id = id,
            Role = "compaction",
            Content = result.Summary ?? result.SummaryPreview ?? "",
            Thinking = result.Thinking,
            Timestamp = completedAt,
            Compaction = new CompactionMessage
            {
                CompactionNumber = result.CompactionNumber,
                ArchivePath = result.ArchivePath,
                PreviousArchivePath = result.PreviousArchivePath,
                MessagesToCompact = result.MessagesToCompact,
                MessagesToKeep = result.MessagesToKeep,
                ChunkCount = result.ChunkCount,
                EstimatedApiTokens = result.EstimatedApiTokens,
                EstimatedPostCompactionApiTokens = result.EstimatedPostCompactionApiTokens,
                StartedAt = startedAt,
                CompletedAt = completedAt
            }
        };
    }

    public async Task SendMessage(
        string conversationId,
        string message,
        string? title = null,
        string? messageId = null,
        string? truncateBeforeMessageId = null,
        string? persona = null)
    {
        StreamSession? session = null;
        var conversationsFolder = _conversation.ResolveConversationFolder(persona);
        using var memoryScope = _memoryService.EnterPersonaScope(persona);

        try
        {
            if (string.IsNullOrWhiteSpace(message))
            {
                await Clients.Caller.SendAsync("Error", "Message cannot be empty.");
                return;
            }

            var isNew = string.IsNullOrWhiteSpace(conversationId);
            var conv = isNew
                ? _conversation.CreateConversation()
                : _conversation.GetConversation(conversationId, conversationsFolder);

            if (conv == null)
            {
                await Clients.Caller.SendAsync("Error", "Conversation not found");
                return;
            }

            var newSession = new StreamSession(conv.Id);
            if (!_streams.TryAdd(conv.Id, newSession))
            {
                newSession.Cts.Dispose();
                await Clients.Caller.SendAsync("Error", "A response is already streaming for this conversation.");
                return;
            }
            session = newSession;

            await SetCurrentConversationGroupAsync(conv.Id);
            _activeConversation.SetActive(conv.Id);

            // Truncate to a specific message for retry/edit: find it by ID and remove everything from there.
            if (!string.IsNullOrEmpty(truncateBeforeMessageId))
            {
                var msgIdx = conv.Messages.FindIndex(m => m.Id == truncateBeforeMessageId);
                if (msgIdx >= 0)
                    conv.Messages = conv.Messages.Take(msgIdx).ToList();
            }

            var userMessage = new ChatMessage
            {
                Id = messageId ?? Guid.NewGuid().ToString("N")[..8],
                Role = "user",
                Content = message
            };
            conv.Messages.Add(userMessage);
            await _memoryService.EnsureUpdateMarkerAsync(userMessage.Timestamp.AddTicks(-1));

            if (isNew)
            {
                conv.Title = BuildInitialTitle(title, message);
                conv.TitleAutoGenerated = false;
            }

            await _conversation.SaveConversationAsync(conv, conversationsFolder);

            if (isNew)
                await Clients.Caller.SendAsync("ConversationCreated", conv);

            await SendStreamEventAsync(session, "UserMessageSaved", conv.Id, userMessage);

            var autonomyProductionMode = await _autonomy.IsProductionModeAsync(session.Cts.Token);
            var systemPrompt = await _conversation.BuildSystemPromptAsync(includeAutonomyTools: autonomyProductionMode, persona: persona);
            var compactionPreview = await _compaction.PreviewCompactionAsync(conv, systemPrompt);
            var compactionEventId = Guid.NewGuid().ToString("N")[..8];
            var memorySweepEventId = Guid.NewGuid().ToString("N")[..8];
            var memorySweepStartedAt = DateTime.UtcNow;
            MemorySweepResult? memorySweepResult = null;
            ChatMessage? memorySweepMessage = null;
            var canCompact = true;

            if (compactionPreview.ShouldCompact)
            {
                var messagesToSweep = conv.Messages
                    .Take(compactionPreview.MessagesToCompact)
                    .ToList();

                await SendMemorySweepEventAsync(
                    session,
                    "MemorySweepStarted",
                    new
                    {
                        id = memorySweepEventId,
                        compactionNumber = conv.CompactionCount + 1,
                        message = $"Running pre-compaction memory sweep ({messagesToSweep.Count} messages)...",
                        messagesSwept = messagesToSweep.Count,
                        changed = false,
                        success = true,
                        operationCount = 0,
                        writeCount = 0,
                        deleteCount = 0,
                        failedCount = 0,
                        startedAt = memorySweepStartedAt
                    });

                memorySweepResult = await _memorySweep.SweepBeforeCompactionAsync(
                    conv,
                    messagesToSweep,
                    compactionPreview,
                    conv.CompactionCount + 1,
                    session.Cts.Token);

                var memorySweepCompletedAt = DateTime.UtcNow;
                memorySweepMessage = CreateMemorySweepMessage(
                    memorySweepEventId,
                    memorySweepResult,
                    conv.CompactionCount + 1,
                    memorySweepStartedAt,
                    memorySweepCompletedAt);

                await SendMemorySweepEventAsync(
                    session,
                    "MemorySweepComplete",
                    BuildMemorySweepPayload(memorySweepEventId, memorySweepResult, conv.CompactionCount + 1, memorySweepStartedAt, memorySweepCompletedAt));

                canCompact = memorySweepResult.Success;
            }

            var compactionStartedAt = DateTime.UtcNow;
            CompactionResult? compactionResult = null;
            if (compactionPreview.ShouldCompact && canCompact)
            {
                await SendStreamEventAsync(
                    session,
                    "CompactionStarted",
                    new
                    {
                        id = compactionEventId,
                        compactionNumber = conv.CompactionCount + 1,
                        message = $"Compacting conversation ({compactionPreview.MessagesToCompact} messages, {compactionPreview.ChunkCount} chunks)...",
                        messagesToCompact = compactionPreview.MessagesToCompact,
                        messagesToKeep = compactionPreview.MessagesToKeep,
                        chunkCount = compactionPreview.ChunkCount,
                        estimatedApiTokens = compactionPreview.EstimatedApiTokens,
                        estimatedPostCompactionApiTokens = compactionPreview.EstimatedPostCompactionApiTokens,
                        startedAt = compactionStartedAt
                    });

                compactionResult = await _compaction.CompactIfNeededAsync(
                    conv,
                    systemPrompt,
                    progress: evt => SendCompactionProgressAsync(session, compactionEventId, evt),
                    ct: session.Cts.Token);
            }

            if (compactionPreview.ShouldCompact && canCompact && compactionResult != null)
            {
                var compactionMessage = compactionResult.Compacted
                    ? $"Compaction complete. Archived {compactionResult.MessagesToCompact} messages to {compactionResult.ArchivePath}."
                    : "Compaction skipped.";
                await SendStreamEventAsync(session, "CompactionComplete", new
                {
                    id = compactionEventId,
                    message = compactionMessage,
                    compactionNumber = compactionResult.CompactionNumber,
                    archivePath = compactionResult.ArchivePath,
                    previousArchivePath = compactionResult.PreviousArchivePath,
                    messagesToCompact = compactionResult.MessagesToCompact,
                    messagesToKeep = compactionResult.MessagesToKeep,
                    chunkCount = compactionResult.ChunkCount,
                    estimatedApiTokens = compactionResult.EstimatedApiTokens,
                    estimatedPostCompactionApiTokens = compactionResult.EstimatedPostCompactionApiTokens,
                    summary = compactionResult.Summary ?? compactionResult.SummaryPreview ?? "",
                    thinking = compactionResult.Thinking ?? "",
                    startedAt = compactionStartedAt,
                    completedAt = DateTime.UtcNow
                });

                if (memorySweepMessage != null)
                    conv.Messages.Add(memorySweepMessage);

                if (compactionResult.Compacted)
                {
                    conv.Messages.Add(CreateCompactionMessage(compactionEventId, compactionResult, compactionStartedAt, DateTime.UtcNow));
                }

                if (memorySweepMessage != null || compactionResult.Compacted)
                {
                    await _conversation.SaveConversationAsync(conv, conversationsFolder);
                    await _search.RebuildIndexAsync();
                }
            }
            else if (memorySweepMessage != null)
            {
                conv.Messages.Add(memorySweepMessage);
                await _conversation.SaveConversationAsync(conv, conversationsFolder);
                await _search.RebuildIndexAsync();
            }

            var streamSession = session ?? throw new InvalidOperationException("Stream session was not initialized.");
            var liveCgmContext = await BuildLiveCgmContextAsync(streamSession.Cts.Token);
            var allMessages = await BuildApiMessagesWithLiveContextAsync(conv, systemPrompt, liveCgmContext);
            _ = Task.Run(() => RunConversationStreamAsync(
                streamSession,
                conv,
                message,
                systemPrompt,
                liveCgmContext,
                allMessages,
                compactionResult,
                memorySweepResult,
                autonomyProductionMode,
                conversationsFolder));
            session = null;
        }
        catch (Exception ex)
        {
            if (session != null)
            {
                _streams.TryRemove(session.ConversationId, out _);
                session.Cts.Dispose();
            }

            _logger.LogError(ex, "SendMessage failed");
            await Clients.Caller.SendAsync("Error", "Server error: " + ex.Message);
        }
    }

    private async Task RunConversationStreamAsync(
        StreamSession session,
        Conversation conv,
        string userMessage,
        string systemPrompt,
        string liveCgmContext,
        List<ChatMessage> allMessages,
        CompactionResult? compactionResult,
        MemorySweepResult? memorySweepResult,
        bool enableAutonomyTools,
        string conversationsFolder)
    {
        var completedResponse = false;

        try
        {
            var apiMessages = allMessages;

            for (var round = 1; ; round++)
            {
                var plannedToolCalls = EnsureToolCallIds(PlanServerToolCallsForCurrentTurn(userMessage, conv));
                if (plannedToolCalls.Count > 0)
                {
                    await SaveVisibleToolPreludeAsync(
                        session,
                        conv,
                        "",
                        null,
                        plannedToolCalls,
                        conversationsFolder);
                    var plannedToolResults = await ProcessToolCalls(plannedToolCalls, session);
                    foreach (var toolResult in plannedToolResults)
                        conv.Messages.Add(CreateToolMessage(toolResult, round));

                    await _conversation.SaveConversationAsync(conv, conversationsFolder);
                    apiMessages = await BuildApiMessagesWithLiveContextAsync(conv, systemPrompt, liveCgmContext);
                    continue;
                }

                var response = await StreamAssistantResponseAsync(
                    session,
                    apiMessages,
                    enableTools: true,
                    enableThinking: true,
                    enableAutonomyTools: enableAutonomyTools);
                var toolExtraction = ExtractExecutableToolCalls(response.Content);
                var nativeToolCalls = ConvertNativeToolCalls(response.NativeToolCalls);
                var toolCalls = EnsureToolCallIds(MergeToolCalls(nativeToolCalls, toolExtraction.Calls));
                if (toolCalls.Count == 0)
                {
                    await SaveFinalAssistantResponseAsync(
                        session,
                        conv,
                        response,
                        userMessage,
                        session.Cts.Token,
                        conversationsFolder);
                    await CompleteStreamAsync(session, conv.Id);
                    completedResponse = true;
                    _ = AutoCommitAsync(conv, conversationsFolder, compactionResult, memorySweepResult);
                    return;
                }

                await SendStreamEventAsync(session, "DiscardAssistantDraft");
                await SaveVisibleToolPreludeAsync(
                    session,
                    conv,
                    toolExtraction.VisibleContent,
                    response.Thinking,
                    toolCalls,
                    conversationsFolder);

                var toolResults = await ProcessToolCalls(toolCalls, session);
                foreach (var toolResult in toolResults)
                    conv.Messages.Add(CreateToolMessage(toolResult, round));

                await _conversation.SaveConversationAsync(conv, conversationsFolder);

                apiMessages = await BuildApiMessagesWithLiveContextAsync(conv, systemPrompt, liveCgmContext);
            }
        }
        catch (OperationCanceledException)
        {
            lock (session.Gate)
            {
                session.Replayable = false;
                session.Events.Clear();
            }
            await SendStreamEventAsync(session, "StreamCancelled");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Conversation stream failed for {ConversationId}", session.ConversationId);
            await SendStreamEventAsync(session, "Error", "Server error: " + ex.Message);
        }
        finally
        {
            if (!completedResponse)
            {
                lock (session.Gate)
                {
                    session.Replayable = false;
                    session.Events.Clear();
                }
            }
            _streams.TryRemove(session.ConversationId, out _);
            session.Cts.Dispose();
        }
    }

    private async Task<List<ChatMessage>> BuildApiMessagesWithLiveContextAsync(
        Conversation conv,
        string systemPrompt,
        string liveCgmContext)
    {
        return await _compaction.BuildApiMessagesAsync(
            conv,
            AppendLiveContextToSystemPrompt(systemPrompt, liveCgmContext));
    }

    private async Task<string> BuildLiveCgmContextAsync(CancellationToken ct)
    {
        if (!_config.Nightscout.AttachLocalReadingsToMessages)
            return "";

        var count = _config.Nightscout.BridgeReadingCount <= 0
            ? 10
            : _config.Nightscout.BridgeReadingCount;
        var snapshot = await _cgmBridge.GetSnapshotAsync(count, ct);
        var cgmContext = _cgmBridge.FormatForPrompt(snapshot);
        if (string.IsNullOrWhiteSpace(cgmContext))
            return "";

        _logger.LogInformation(
            "Attached local CGM bridge context to message prompt: success={Success}, readings={ReadingCount}, reported={ReportedCount}, fetched={FetchedAt}",
            snapshot.Success,
            snapshot.Readings.Count,
            snapshot.ReportedCount,
            snapshot.FetchedAt);

        return cgmContext;
    }

    private static string AppendLiveContextToSystemPrompt(string systemPrompt, string liveCgmContext)
    {
        if (string.IsNullOrWhiteSpace(liveCgmContext))
            return systemPrompt;

        return $"{systemPrompt.TrimEnd()}\n\n{liveCgmContext.Trim()}\n";
    }

    private async Task SaveVisibleToolPreludeAsync(
        StreamSession session,
        Conversation conv,
        string visibleContent,
        string? thinking,
        IReadOnlyList<ToolCallRequest> toolCalls,
        string conversationsFolder)
    {
        visibleContent = ToolResultTextSanitizer.RemoveEchoedToolResultBlocks(visibleContent);

        if (string.IsNullOrWhiteSpace(visibleContent) && string.IsNullOrWhiteSpace(thinking) && toolCalls.Count == 0)
            return;

        conv.Messages.Add(new ChatMessage
        {
            Role = "assistant",
            Content = visibleContent,
            Thinking = string.IsNullOrWhiteSpace(thinking) ? null : thinking,
            ToolCalls = BuildStoredToolCalls(toolCalls),
            Timestamp = DateTime.UtcNow
        });
        await _conversation.SaveConversationAsync(conv, conversationsFolder);
        if (!string.IsNullOrWhiteSpace(visibleContent))
            await SendStreamEventAsync(session, "AssistantSegment", visibleContent);
    }

    private async Task<AssistantStreamResult> StreamAssistantResponseAsync(
        StreamSession session,
        List<ChatMessage> messages,
        bool enableTools,
        bool enableThinking,
        bool enableAutonomyTools)
    {
        await SendStreamEventAsync(session, "StreamStart");

        for (var attempt = 1; attempt <= AssistantStreamMaxAttempts; attempt++)
        {
            var fullResponse = new StringBuilder();
            var fullThinking = new StringBuilder();
            var nativeToolCalls = new List<NativeToolCall>();
            var totalPromptTokens = 0;
            var totalCompletionTokens = 0;
            var retryBecauseChinese = false;
            var retryReason = "";

            using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(session.Cts.Token);
            try
            {
                await foreach (var token in _chat.StreamChatAsync(
                    messages,
                    attemptCts.Token,
                    enableTools: enableTools,
                    enableThinking: enableThinking,
                    enableAutonomyTools: enableAutonomyTools))
                {
                    if (token.StartsWith("U|"))
                    {
                        var parts = token.Split('|');
                        if (parts.Length >= 3)
                        {
                            int.TryParse(parts[1], out totalPromptTokens);
                            int.TryParse(parts[2], out totalCompletionTokens);
                            var combined = _compaction.EstimateTokenCount(fullResponse.ToString()) + totalPromptTokens + totalCompletionTokens;
                            var triggerTokens = Math.Max(1, _compaction.GetTriggerTokenCount());
                            var compactionPct = Math.Min(100, (int)Math.Round(combined * 100.0 / triggerTokens));
                            await SendStreamEventAsync(session, "TokenUpdate", totalPromptTokens, totalCompletionTokens, combined, compactionPct);
                        }
                    }
                    else if (token.StartsWith("R|"))
                    {
                        var thought = token[2..];
                        if (string.IsNullOrEmpty(thought))
                            continue;

                        if (ContainsChinese(thought) && attempt < AssistantStreamMaxAttempts)
                        {
                            retryBecauseChinese = true;
                            retryReason = fullThinking.Length == 0 && fullResponse.Length == 0
                                ? "first thinking token contained Chinese"
                                : "thinking stream contained Chinese";
                        }

                        fullThinking.Append(thought);
                        await SendStreamEventAsync(session, "ReceiveThinking", thought);
                    }
                    else if (token.StartsWith("C|"))
                    {
                        var content = token[2..];
                        if (!string.IsNullOrEmpty(content))
                        {
                            if (ContainsChinese(content) && attempt < AssistantStreamMaxAttempts)
                            {
                                retryBecauseChinese = true;
                                retryReason = fullResponse.Length == 0
                                    ? "first response token contained Chinese"
                                    : "response stream contained Chinese";
                            }

                            fullResponse.Append(content);
                            await SendStreamEventAsync(session, "ReceiveToken", content);
                        }
                    }
                    else if (token.StartsWith("T|"))
                    {
                        try
                        {
                            var toolCall = System.Text.Json.JsonSerializer.Deserialize<NativeToolCall>(token[2..]);
                            if (toolCall != null)
                                nativeToolCalls.Add(toolCall);
                        }
                        catch (Exception ex)
                        {
                            _logger.LogWarning("Failed to parse native tool call delta: {Error}", ex.Message);
                        }
                    }
                }
            }
            catch (OperationCanceledException) when (retryBecauseChinese && !session.Cts.IsCancellationRequested)
            {
                // The attempt was deliberately interrupted because the model switched into Chinese.
            }

            session.Cts.Token.ThrowIfCancellationRequested();

            if (retryBecauseChinese && nativeToolCalls.Count == 0)
            {
                _logger.LogWarning(
                    "Assistant stream attempt {Attempt} produced Chinese ({Reason}); retrying.",
                    attempt,
                    retryReason);
                await SendStreamEventAsync(
                    session,
                    "AssistantRetry",
                    attempt + 1,
                    AssistantStreamMaxAttempts,
                    retryReason);
                await Task.Delay(150, session.Cts.Token);
                continue;
            }

            var thinking = fullThinking.ToString();
            return new AssistantStreamResult(
                fullResponse.ToString(),
                thinking.Length > 0 ? thinking : null,
                nativeToolCalls);
        }

        throw new InvalidOperationException("Assistant stream retry loop exited unexpectedly.");
    }

    private async Task SaveFinalAssistantResponseAsync(
        StreamSession session,
        Conversation conv,
        AssistantStreamResult response,
        string userMessage,
        CancellationToken ct,
        string conversationsFolder)
    {
        var content = ToolResultTextSanitizer.RemoveEchoedToolResultBlocks(response.Content);
        if (!string.Equals(content, response.Content, StringComparison.Ordinal))
            await SendStreamEventAsync(session, "ReplaceAssistantDraft", content);

        var assistantMsg = new ChatMessage
        {
            Role = "assistant",
            Content = content,
            Thinking = response.Thinking,
            Timestamp = DateTime.UtcNow
        };

        await TranslateMessagePartsAsync(
            session,
            assistantMsg,
            response.Thinking ?? "",
            content,
            userMessage,
            ct);

        conv.Messages.Add(assistantMsg);
        await _conversation.SaveConversationAsync(conv, conversationsFolder);
    }

    private async Task CompleteStreamAsync(StreamSession session, string conversationId)
    {
        await SendStreamEventAsync(session, "StreamComplete", conversationId);
        lock (session.Gate)
        {
            session.Replayable = false;
            session.Events.Clear();
        }
    }

    private static ChatMessage CreateToolMessage(ToolResult result, int round)
    {
        var status = GetToolStatus(result);
        var grounding = BuildToolGroundingInstruction(result);
        var content = $"""
        PRIVATE TOOL OUTPUT - DO NOT REPEAT IN CHAT (round {round})
        Instruction: Use this only as hidden context. Never copy this wrapper, the parameters, or the full output into a visible assistant reply.
        Reuse: If you need this same method/path/parameters again in this response chain, use this output instead of repeating the tool call unless Raymond asked for a refresh, a write changed the source, the previous result failed, or genuinely new data is needed.
        Grounding: {grounding}

        Tool: {result.Method} {result.Path}
        Status: {status}
        Parameters JSON:
        {FormatToolBodyForDisplay(result.Body)}
        Output:
        {result.Result}
        END PRIVATE TOOL OUTPUT
        """;

        return new ChatMessage
        {
            Role = "tool",
            Content = content,
            Timestamp = DateTime.UtcNow,
            Tool = new ToolMessage
            {
                Round = round,
                ToolCallId = result.ToolCallId,
                Method = result.Method,
                Path = result.Path,
                Body = result.Body,
                Status = status,
                IsError = status == "error",
                Result = result.Result
            }
        };
    }

    private static string BuildToolGroundingInstruction(ToolResult result)
    {
        if (result.Path.StartsWith("/api/files/list", StringComparison.OrdinalIgnoreCase))
            return "This is only a directory listing. It contains filenames and metadata, not file contents. If Raymond asked about the contents of a file or story, choose a path and call /api/files/read before answering.";

        if (result.Path.StartsWith("/api/files/read", StringComparison.OrdinalIgnoreCase))
            return "This is the exact file content returned by /api/files/read. Any quote, excerpt, favorite part, or summary must be grounded in this Output. Do not invent passages or claim text appears here unless it appears in the Output.";

        if (result.Path.StartsWith("/api/search", StringComparison.OrdinalIgnoreCase))
            return "Search results are snippets and references, not full source text. If Raymond asks for an exact quote or detailed content from a file, read the source file before answering.";

        if (result.Path.StartsWith("/api/nightscout", StringComparison.OrdinalIgnoreCase))
            return "This is read-only Nightscout CGM data. Always mention the reading timestamp/age when using current glucose context. CGM data can lag or be inaccurate; do not infer insulin dosing, treatment, or urgent medical decisions from this alone.";

        if (result.Path.StartsWith("/api/kaitimer", StringComparison.OrdinalIgnoreCase))
            return "This is local KaiTimer output. It is the source of truth for KaiChat's single count-up timer, ritual wheel, milestone prompts, counters, and dashboard.";

        if (result.Path.StartsWith("/api/autonomy", StringComparison.OrdinalIgnoreCase))
            return "This is Kai autonomy production guidance output. Treat proposed guidance as pending unless an approval tool call succeeds.";

        if (result.Path.StartsWith("/api/instructions/rewrite", StringComparison.OrdinalIgnoreCase))
            return "This instruction rewrite tool auto-commits successful rewrites. Do not call /api/git/commit after a successful rewrite unless this tool output says the commit failed or Raymond explicitly asks.";

        return "Use this tool output as evidence. Do not invent facts beyond what the tool returned.";
    }

    private static string FormatAutonomyStatusForTool(KaiAutonomyStatus status)
    {
        var builder = new StringBuilder();
        builder.AppendLine($"Production mode: {status.Settings.ProductionMode}");
        builder.AppendLine($"Scheduled steps enabled: {status.Settings.Enabled}");
        builder.AppendLine($"Interval minutes: {status.Settings.IntervalMinutes}");
        builder.AppendLine($"Target conversation: {status.TargetConversationId}");
        builder.AppendLine($"Running now: {status.IsRunning}");
        builder.AppendLine($"Last step UTC: {FormatUtc(status.LastStepUtc)}");
        builder.AppendLine($"Next due UTC: {FormatUtc(status.NextDueUtc)}");
        builder.AppendLine($"Pending guidance proposal: {(status.PendingGuidanceProposal is null ? "none" : status.PendingGuidanceProposal.Id)}");

        if (status.PendingGuidanceProposal is { } pending)
        {
            builder.AppendLine();
            builder.AppendLine("Pending proposal:");
            builder.AppendLine($"- Id: {pending.Id}");
            builder.AppendLine($"- Summary: {TrimToolText(pending.ChangeSummary, 500)}");
            builder.AppendLine($"- Reason: {TrimToolText(pending.Reason, 500)}");
            builder.AppendLine($"- Reflection: {TrimToolText(pending.ReflectionEntry, 700)}");
            builder.AppendLine($"- Proposed guidance preview: {TrimToolText(pending.ProposedGuidanceMarkdown, 1200)}");
        }

        var recentLogs = status.Logs.Take(5).ToList();
        if (recentLogs.Count > 0)
        {
            builder.AppendLine();
            builder.AppendLine("Recent logs:");
            foreach (var log in recentLogs)
            {
                builder.AppendLine($"- {log.StartedAtUtc:O} {log.Type} success={log.Success}: {TrimToolText(log.Message, 220)}");
                if (!string.IsNullOrWhiteSpace(log.VisibleMessage))
                    builder.AppendLine($"  Planned message: {TrimToolText(log.VisibleMessage, 260)}");
                if (!string.IsNullOrWhiteSpace(log.DeliveryStatus))
                    builder.AppendLine($"  Delivery: {TrimToolText(log.DeliveryStatus, 220)}");
                if (!string.IsNullOrWhiteSpace(log.GuidanceProposalId))
                    builder.AppendLine($"  Guidance proposal: {log.GuidanceProposalId}");
            }
        }

        return builder.ToString().Trim();
    }

    private static string FormatAutonomyGuidanceResultForTool(KaiAutonomyGuidanceResult result)
    {
        var builder = new StringBuilder();
        builder.AppendLine(result.Success ? "Success." : "Failed.");
        builder.AppendLine(TrimToolText(result.Message, 700));

        var proposal = result.Proposal ?? result.Status.PendingGuidanceProposal;
        if (proposal is not null)
        {
            builder.AppendLine();
            builder.AppendLine("Proposal:");
            builder.AppendLine($"- Id: {proposal.Id}");
            builder.AppendLine($"- Summary: {TrimToolText(proposal.ChangeSummary, 500)}");
            builder.AppendLine($"- Reason: {TrimToolText(proposal.Reason, 500)}");
        }

        builder.AppendLine();
        builder.AppendLine($"Pending guidance proposal: {(result.Status.PendingGuidanceProposal is null ? "none" : result.Status.PendingGuidanceProposal.Id)}");
        return builder.ToString().Trim();
    }

    private static string FormatUtc(DateTime? value)
    {
        return value.HasValue ? value.Value.ToString("O") : "-";
    }

    private static string TrimToolText(string? value, int maxChars)
    {
        if (string.IsNullOrWhiteSpace(value))
            return "";

        var text = value.Trim();
        return text.Length <= maxChars
            ? text
            : text[..maxChars].TrimEnd() + "...";
    }

    private static IReadOnlyList<ToolCallRequest> PlanServerToolCallsForCurrentTurn(string userMessage, Conversation conv)
    {
        var turnMessages = GetMessagesAfterLatestUser(conv);

        if (LooksLikeTimerStartRequest(userMessage))
        {
            if (!HasToolCall(turnMessages, "POST", "/api/kaitimer/start"))
                return [new ToolCallRequest("POST", "/api/kaitimer/start", "{}")];
        }

        if (LooksLikeTimerPauseRequest(userMessage))
        {
            if (!HasToolCall(turnMessages, "POST", "/api/kaitimer/pause"))
                return [new ToolCallRequest("POST", "/api/kaitimer/pause", "{}")];
        }

        if (LooksLikeTimerResetRequest(userMessage))
        {
            if (!HasToolCall(turnMessages, "POST", "/api/kaitimer/reset"))
                return [new ToolCallRequest("POST", "/api/kaitimer/reset", "{}")];
        }

        if (LooksLikeWheelSpinRequest(userMessage))
        {
            if (!HasToolCall(turnMessages, "POST", "/api/kaitimer/wheel/spin"))
                return [new ToolCallRequest("POST", "/api/kaitimer/wheel/spin", "{}")];
        }

        if (LooksLikeTimerDashboardRequest(userMessage))
        {
            if (!HasToolCall(turnMessages, "GET", "/api/kaitimer/dashboard"))
                return [new ToolCallRequest("GET", "/api/kaitimer/dashboard", "{}")];
        }

        if (LooksLikeTimerStatusRequest(userMessage))
        {
            if (!HasToolCall(turnMessages, "GET", "/api/kaitimer/status"))
                return [new ToolCallRequest("GET", "/api/kaitimer/status", "{}")];
        }

        return [];
    }

    private static bool LooksLikeTimerStartRequest(string userMessage)
    {
        var text = NormalizeUserToolText(userMessage);
        return MentionsTimer(text)
            && (text.Contains("start", StringComparison.Ordinal)
                || text.Contains("resume", StringComparison.Ordinal)
                || text.Contains("unpause", StringComparison.Ordinal));
    }

    private static bool LooksLikeTimerPauseRequest(string userMessage)
    {
        var text = NormalizeUserToolText(userMessage);
        return MentionsTimer(text)
            && (text.Contains("pause", StringComparison.Ordinal)
                || text.Contains("stop", StringComparison.Ordinal));
    }

    private static bool LooksLikeTimerResetRequest(string userMessage)
    {
        var text = NormalizeUserToolText(userMessage);
        return MentionsTimer(text)
            && (text.Contains("reset", StringComparison.Ordinal)
                || text.Contains("clear", StringComparison.Ordinal)
                || text.Contains("zero", StringComparison.Ordinal));
    }

    private static bool LooksLikeTimerStatusRequest(string userMessage)
    {
        var text = NormalizeUserToolText(userMessage);
        return MentionsTimer(text)
            && (text.Contains("status", StringComparison.Ordinal)
                || text.Contains("elapsed", StringComparison.Ordinal)
                || text.Contains("how long", StringComparison.Ordinal)
                || text.Contains("show", StringComparison.Ordinal)
                || text.Contains("check", StringComparison.Ordinal)
                || text.Contains("time", StringComparison.Ordinal));
    }

    private static bool LooksLikeWheelSpinRequest(string userMessage)
    {
        var text = NormalizeUserToolText(userMessage);
        return MentionsWheel(text)
            && (text.Contains("spin", StringComparison.Ordinal)
                || text.Contains("roll", StringComparison.Ordinal)
                || text.Contains("pick", StringComparison.Ordinal));
    }

    private static bool LooksLikeTimerDashboardRequest(string userMessage)
    {
        var text = NormalizeUserToolText(userMessage);
        return MentionsTimer(text)
            && (text.Contains("dashboard", StringComparison.Ordinal)
                || text.Contains("overview", StringComparison.Ordinal)
                || text.Contains("show everything", StringComparison.Ordinal));
    }

    private static string NormalizeUserToolText(string userMessage)
    {
        if (string.IsNullOrWhiteSpace(userMessage))
            return "";

        var text = Regex.Replace(userMessage, @"\s+", " ").Trim().ToLowerInvariant();
        return LooksLikeToolMetaRequest(text) ? "" : text;
    }

    private static bool MentionsTimer(string text)
    {
        return !string.IsNullOrWhiteSpace(text)
            && (text.Contains("timer", StringComparison.Ordinal)
                || text.Contains("kaitimer", StringComparison.Ordinal)
                || text.Contains("kai timer", StringComparison.Ordinal)
                || text.Contains("count up", StringComparison.Ordinal)
                || text.Contains("count-up", StringComparison.Ordinal)
                || text.Contains("counter", StringComparison.Ordinal));
    }

    private static bool MentionsWheel(string text)
    {
        return !string.IsNullOrWhiteSpace(text)
            && (text.Contains("wheel", StringComparison.Ordinal)
                || text.Contains("spinner", StringComparison.Ordinal)
                || text.Contains("spin", StringComparison.Ordinal));
    }

    private static IReadOnlyList<ChatMessage> GetMessagesAfterLatestUser(Conversation conv)
    {
        var index = conv.Messages.FindLastIndex(message =>
            string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase));

        return index >= 0
            ? conv.Messages.Skip(index + 1).ToList()
            : conv.Messages.ToList();
    }

    private static bool HasToolCall(IReadOnlyList<ChatMessage> messages, string method, string path)
    {
        return messages.Any(message =>
            message.Tool != null
            && string.Equals(message.Tool.Method, method, StringComparison.OrdinalIgnoreCase)
            && string.Equals(NormalizeToolCallPath(message.Tool.Path), path, StringComparison.OrdinalIgnoreCase));
    }

    private static bool LooksLikeToolMetaRequest(string text)
    {
        if (!MentionsToolSurface(text))
            return false;

        if (text.Contains("non-call guard", StringComparison.Ordinal)
            || text.Contains("tool non-call", StringComparison.Ordinal)
            || text.Contains("tool guard", StringComparison.Ordinal))
            return true;

        var metaPhrases = new[]
        {
            "why did you call",
            "why did you use",
            "why did you run",
            "why are you calling",
            "why are you using",
            "why are you running",
            "why did you repeat",
            "why did it call",
            "what happened with the tool",
            "what happened with tool",
            "what happened to the tool",
            "called the same tool",
            "called the same tools",
            "same tools multiple",
            "same tool multiple",
            "repeated tool",
            "duplicate tool",
            "tool calls you made",
            "tools you called",
            "tools you used",
            "tool feedback",
            "acceptable response"
        };

        if (metaPhrases.Any(phrase => text.Contains(phrase, StringComparison.Ordinal)))
            return true;

        var asksAboutSyntaxOrExamples =
            (text.Contains("tool call", StringComparison.Ordinal)
                || text.Contains("tool calls", StringComparison.Ordinal)
                || text.Contains("tools", StringComparison.Ordinal))
            && (text.Contains("syntax", StringComparison.Ordinal)
                || text.Contains("format", StringComparison.Ordinal)
                || text.Contains("how tools work", StringComparison.Ordinal)
                || text.Contains("how tool calls work", StringComparison.Ordinal)
                || text.Contains("example", StringComparison.Ordinal)
                || text.Contains("examples", StringComparison.Ordinal));

        return asksAboutSyntaxOrExamples
            && !text.Contains("test out", StringComparison.Ordinal)
            && !text.Contains("try out", StringComparison.Ordinal);
    }

    private static bool MentionsToolSurface(string text)
    {
        return text.Contains("tool", StringComparison.Ordinal)
            || text.Contains("/api/", StringComparison.Ordinal)
            || text.Contains("kaitimer", StringComparison.Ordinal)
            || text.Contains("kai timer", StringComparison.Ordinal)
            || text.Contains("timer", StringComparison.Ordinal)
            || text.Contains("memory", StringComparison.Ordinal)
            || text.Contains("file", StringComparison.Ordinal)
            || text.Contains("nightscout", StringComparison.Ordinal)
            || text.Contains("glucose", StringComparison.Ordinal)
            || text.Contains("git", StringComparison.Ordinal)
            || text.Contains("commit", StringComparison.Ordinal);
    }

    private static string GetToolStatus(ToolResult result)
    {
        if (result.IsError || result.Result.StartsWith("Error", StringComparison.OrdinalIgnoreCase))
            return "error";
        if (result.Result.StartsWith("Skipped repeated tool call", StringComparison.OrdinalIgnoreCase))
            return "skipped";
        return "success";
    }

    public async Task RenameConversation(string id, string newTitle, string? persona = null)
    {
        var conversationsFolder = _conversation.ResolveConversationFolder(persona);
        var conv = _conversation.GetConversation(id, conversationsFolder);
        if (conv == null) return;
        conv.Title = NormalizeTitle(newTitle, conv.Title);
        conv.TitleAutoGenerated = false;
        await _conversation.SaveConversationAsync(conv, conversationsFolder);
        await _hubContext.Clients.Group(GroupName(id)).SendAsync("ConversationRenamed", id, conv.Title);
        await Clients.All.SendAsync("ConversationsChanged");
    }

    private async Task<List<ToolResult>> ProcessToolCalls(
        IReadOnlyList<ToolCallRequest> calls,
        StreamSession? session = null)
    {
        var results = new List<ToolResult>();
        var callNumber = 0;
        foreach (var call in calls)
        {
            var method = call.Method;
            var path = NormalizeToolCallPath(call.Path) ?? call.Path;
            var body = call.Body;
            var callId = string.IsNullOrWhiteSpace(call.ToolCallId)
                ? $"{DateTime.UtcNow:HHmmssfff}-{++callNumber}"
                : call.ToolCallId;
            var resultStart = results.Count;

            await SendToolCallStartedAsync(session, callId, method, path, DescribeToolCall(method, path, body), body);

            var toolCt = session?.Cts.Token ?? Context.ConnectionAborted;
            var isAutonomyToolPath = IsAutonomyToolPath(path);
            if (isAutonomyToolPath && !await _autonomy.IsProductionModeAsync(toolCt))
            {
                results.Add(new ToolResult(
                    path,
                    $"Error: unsupported tool path '{path}'.",
                    method,
                    body,
                    true));
            }
            else
            {
                try
                {
                    switch (path)
                    {
                    case "/api/autonomy/status":
                        var autonomyStatus = await _autonomy.GetStatusAsync(toolCt);
                        results.Add(new ToolResult(path, FormatAutonomyStatusForTool(autonomyStatus), method, body));
                        break;
                    case "/api/autonomy/guidance/propose":
                        var proposeGuidanceReq = System.Text.Json.JsonSerializer.Deserialize<KaiAutonomyGuidanceProposalRequest>(body, ToolJsonOptions);
                        if (proposeGuidanceReq != null)
                        {
                            var proposeGuidanceResult = await _autonomy.ProposeGuidanceChangeAsync(proposeGuidanceReq, toolCt);
                            results.Add(new ToolResult(path, FormatAutonomyGuidanceResultForTool(proposeGuidanceResult), method, body, !proposeGuidanceResult.Success));
                        }
                        break;
                    case "/api/autonomy/guidance/approve":
                        var approveGuidanceReq = System.Text.Json.JsonSerializer.Deserialize<KaiAutonomyGuidanceDecisionRequest>(body, ToolJsonOptions);
                        var approveGuidanceResult = await _autonomy.ApproveGuidanceProposalAsync(approveGuidanceReq, toolCt);
                        results.Add(new ToolResult(path, FormatAutonomyGuidanceResultForTool(approveGuidanceResult), method, body, !approveGuidanceResult.Success));
                        break;
                    case "/api/autonomy/guidance/reject":
                        var rejectGuidanceReq = System.Text.Json.JsonSerializer.Deserialize<KaiAutonomyGuidanceDecisionRequest>(body, ToolJsonOptions);
                        var rejectGuidanceResult = await _autonomy.RejectGuidanceProposalAsync(rejectGuidanceReq, toolCt);
                        results.Add(new ToolResult(path, FormatAutonomyGuidanceResultForTool(rejectGuidanceResult), method, body, !rejectGuidanceResult.Success));
                        break;
                    case "/api/memory/files":
                        var listReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolList>(body);
                        var files = await _memoryService.ListMemoryFilesAsync(listReq?.IncludeTrash ?? false);
                        var fileSummary = string.Join("\n", files.Select(f =>
                            $"- {f.Path} ({f.Size:N0} bytes, loaded: {f.IsLoaded}, trash: {f.IsTrash})"));
                        results.Add(new ToolResult(path, string.IsNullOrWhiteSpace(fileSummary) ? "No memory files found." : fileSummary, method, body));
                        break;
                    case "/api/memory/file/read":
                        var readMemoryReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolFileRead>(body);
                        if (readMemoryReq != null)
                        {
                            var content = await _memoryService.ReadMemoryFileAsync(readMemoryReq.Path);
                            results.Add(new ToolResult(path, content, method, body));
                        }
                        break;
                    case "/api/memory/file/save":
                        var saveFileReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolFileSave>(body);
                        if (saveFileReq != null)
                        {
                            await _memoryService.SaveMemoryFileAsync(saveFileReq.Path, saveFileReq.Content);
                            await _git.AutoCommitAsync($"memory file save: {saveFileReq.Path}", _memoryService.MemoryDirectoryRelativePath);
                            results.Add(new ToolResult(path, $"Saved memory file '{saveFileReq.Path}'.", method, body));
                        }
                        break;
                    case "/api/memory/file/delete":
                        var deleteFileReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolFileDelete>(body);
                        if (deleteFileReq != null)
                        {
                            await _memoryService.SoftDeleteMemoryFileAsync(deleteFileReq.Path, deleteFileReq.Reason);
                            await _git.AutoCommitAsync($"memory file soft-delete: {deleteFileReq.Path}", _memoryService.MemoryDirectoryRelativePath);
                            results.Add(new ToolResult(path, $"Soft-deleted memory file '{deleteFileReq.Path}'.", method, body));
                        }
                        break;
                    case "/api/memory/save":
                        var saveReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolSave>(body);
                        if (saveReq != null)
                        {
                            await _memoryService.SaveMemoryAsync(saveReq.Section, saveReq.Content);
                            await _git.AutoCommitAsync($"memory update: {saveReq.Section}", _memoryService.MemoryDirectoryRelativePath);
                            results.Add(new ToolResult(path, $"Saved memory section '{saveReq.Section}'.", method, body));
                        }
                        break;
                    case "/api/memory/delete-section":
                        var delReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolDelete>(body);
                        if (delReq != null)
                        {
                            await _memoryService.DeleteSectionAsync(delReq.Section);
                            await _git.AutoCommitAsync($"memory delete-section: {delReq.Section}", _memoryService.MemoryDirectoryRelativePath);
                            results.Add(new ToolResult(path, $"Deleted memory section '{delReq.Section}'.", method, body));
                        }
                        break;
                    case "/api/memory/ai-manage":
                        var mgmtReq = System.Text.Json.JsonSerializer.Deserialize<MemoryToolManage>(body);
                        if (mgmtReq != null)
                        {
                            var memoryResult = await _memoryManager.ManageAsync(mgmtReq.Instruction);
                            if (memoryResult.Changed)
                                await _git.AutoCommitAsync("memory ai-manage", _memoryService.MemoryDirectoryRelativePath);

                            var summary = string.Join("\n", memoryResult.Operations.Select(o =>
                                $"- {o.Action} {o.Path}: {(o.Success ? "ok" : "failed")} — {o.Message}"));
                            results.Add(new ToolResult(path, string.IsNullOrWhiteSpace(summary) ? "No memory changes." : summary, method, body));
                        }
                        break;
                    case "/api/bible/update":
                        var bibleReq = System.Text.Json.JsonSerializer.Deserialize<BibleToolUpdate>(body);
                        if (bibleReq != null)
                        {
                            await UpdateBible(bibleReq.Note);
                            await _git.AutoCommitAsync($"bible: {bibleReq.Note}", "Story Bible.md");
                            results.Add(new ToolResult(path, $"Bible updated: {bibleReq.Note}", method, body));
                        }
                        break;
                    case "/api/instructions/rewrite":
                        var instrReq = System.Text.Json.JsonSerializer.Deserialize<InstructionRewriteRequest>(body);
                        if (instrReq != null)
                        {
                            var rewriteResult = await _instructionRewriter.RewriteAsync(instrReq.Path, instrReq.Instruction);
                            results.Add(new ToolResult(path, rewriteResult.Message, method, body, rewriteResult.IsError));
                        }
                        break;
                    case "/api/git/commit":
                        var stageResult = await _git.StageAllAsync();
                        if (!stageResult.Success)
                        {
                            results.Add(new ToolResult(path, $"Stage failed: {stageResult.Error}", method, body, true));
                            break;
                        }

                        var gitStatus = await _git.StatusAsync();
                        if (!gitStatus.Success)
                        {
                            results.Add(new ToolResult(path, $"Status failed: {gitStatus.Error}", method, body, true));
                            break;
                        }

                        if (string.IsNullOrWhiteSpace(gitStatus.Output))
                        {
                            results.Add(new ToolResult(path, "Nothing to commit. Recent write tools such as /api/instructions/rewrite, /api/memory/*, /api/bible/update, and KaiTimer controls auto-commit their own successful changes.", method, body));
                            break;
                        }

                        var commitResult = await _git.CommitAsync("KaiChat: auto-commit via tool call");
                        results.Add(new ToolResult(path, $"Stage: {stageResult.Success}, Commit: {commitResult.Success}. {GitOutputOrError(commitResult)}", method, body, !commitResult.Success));
                        break;
                    case "/api/files/list":
                    case var p when p.StartsWith("/api/files/list?"):
                        var listFileReq = System.Text.Json.JsonSerializer.Deserialize<FileToolList>(body) ?? new();
                        var listItems = _files.GetDirectoryContents(listFileReq.Path ?? "");
                        var patterns = GetFileListPatterns(listFileReq);
                        if (patterns.Count > 0)
                            listItems = listItems.Where(item => MatchesAnyFilePattern(item, patterns)).ToList();

                        results.Add(new ToolResult(path, BuildDirectoryListResult(listFileReq.Path, patterns, listItems), method, body));
                        break;
                    case "/api/files/read":
                    case var p when p.StartsWith("/api/files/read?"):
                        var fileReq = System.Text.Json.JsonSerializer.Deserialize<FileToolRead>(body);
                        if (fileReq != null)
                        {
                            try
                            {
                                var content = _files.ReadFile(fileReq.Path);
                                results.Add(new ToolResult(path, content, method, body));
                            }
                            catch (Exception ex)
                            {
                                results.Add(new ToolResult(path, $"Error reading file: {ex.Message}", method, body, true));
                            }
                        }
                        break;
                    case "/api/search":
                    case var p when p.StartsWith("/api/search?"):
                        var searchReq = System.Text.Json.JsonSerializer.Deserialize<SearchToolQuery>(body);
                        if (searchReq != null)
                        {
                            var searchResults = await _search.SearchAsync(searchReq.Q);
                            var summary = string.Join("\n\n", searchResults.Select(r =>
                            {
                                var location = string.IsNullOrWhiteSpace(r.SourcePath)
                                    ? r.Source
                                    : $"{r.Source}: {r.SourcePath}";
                                var compaction = r.CompactionNumber.HasValue
                                    ? $", compaction #{r.CompactionNumber}"
                                    : "";
                                var trace = string.IsNullOrWhiteSpace(r.Trace)
                                    ? ""
                                    : $"\n  Trace: {r.Trace}";
                                return $"- {r.ConversationTitle} [{location}{compaction}]\n  {r.Snippet}{trace}";
                            }));
                            results.Add(new ToolResult(path, summary, method, body));
                        }
                        break;
                    case "/api/nightscout/status":
                    case var p when p.StartsWith("/api/nightscout/status?"):
                        var statusResult = await _nightscout.GetStatusAsync();
                        results.Add(new ToolResult(path, _nightscout.FormatStatusForTool(statusResult), method, body, !statusResult.Success));
                        break;
                    case "/api/nightscout/latest":
                    case var p when p.StartsWith("/api/nightscout/latest?"):
                        var latestReq = System.Text.Json.JsonSerializer.Deserialize<NightscoutLatestRequest>(body) ?? new();
                        var latestResult = await _nightscout.GetLatestAsync(latestReq.Count);
                        results.Add(new ToolResult(path, _nightscout.FormatReadingsForTool(latestResult), method, body, !latestResult.Success));
                        break;
                    case "/api/nightscout/history":
                    case var p when p.StartsWith("/api/nightscout/history?"):
                        var historyReq = System.Text.Json.JsonSerializer.Deserialize<NightscoutHistoryRequest>(body) ?? new();
                        var historyResult = await _nightscout.GetHistoryAsync(historyReq.Start, historyReq.End, historyReq.Count);
                        results.Add(new ToolResult(path, _nightscout.FormatReadingsForTool(historyResult), method, body, !historyResult.Success));
                        break;
                    case "/api/kaitimer/status":
                    case "/api/kaitimer/dashboard":
                    case var p when p.StartsWith("/api/kaitimer/status?", StringComparison.OrdinalIgnoreCase)
                        || p.StartsWith("/api/kaitimer/dashboard?", StringComparison.OrdinalIgnoreCase):
                        var timerState = await _kaiTimer.GetStateAsync();
                        results.Add(new ToolResult(path, _kaiTimer.FormatStateForTool(timerState), method, body));
                        break;
                    case "/api/kaitimer/start":
                        var startTimerResult = await _kaiTimer.StartAsync();
                        if (startTimerResult.Success)
                            await _git.AutoCommitAsync("KaiTimer start", ".kaichat-timer.json");
                        results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(startTimerResult), method, body, !startTimerResult.Success));
                        break;
                    case "/api/kaitimer/pause":
                        var pauseTimerResult = await _kaiTimer.PauseAsync();
                        if (pauseTimerResult.Success)
                            await _git.AutoCommitAsync("KaiTimer pause", ".kaichat-timer.json");
                        results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(pauseTimerResult), method, body, !pauseTimerResult.Success));
                        break;
                    case "/api/kaitimer/reset":
                        var resetTimerResult = await _kaiTimer.ResetAsync();
                        if (resetTimerResult.Success)
                            await _git.AutoCommitAsync("KaiTimer reset", ".kaichat-timer.json");
                        results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(resetTimerResult), method, body, !resetTimerResult.Success));
                        break;
                    case "/api/kaitimer/wheel/segments":
                        var createSegmentReq = System.Text.Json.JsonSerializer.Deserialize<KaiWheelSegmentCreateRequest>(body);
                        if (createSegmentReq != null)
                        {
                            var createSegmentResult = await _kaiTimer.CreateWheelSegmentAsync(createSegmentReq);
                            if (createSegmentResult.Success)
                                await _git.AutoCommitAsync("KaiTimer wheel segment added", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(createSegmentResult), method, body, !createSegmentResult.Success));
                        }
                        break;
                    case "/api/kaitimer/wheel/segments/update":
                        var updateSegmentReq = System.Text.Json.JsonSerializer.Deserialize<KaiWheelSegmentUpdateRequest>(body);
                        if (updateSegmentReq != null)
                        {
                            var updateSegmentResult = await _kaiTimer.UpdateWheelSegmentAsync(updateSegmentReq);
                            if (updateSegmentResult.Success)
                                await _git.AutoCommitAsync("KaiTimer wheel segment updated", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(updateSegmentResult), method, body, !updateSegmentResult.Success));
                        }
                        break;
                    case "/api/kaitimer/wheel/segments/delete":
                        var deleteSegmentReq = System.Text.Json.JsonSerializer.Deserialize<KaiWheelSegmentDeleteRequest>(body);
                        if (deleteSegmentReq != null)
                        {
                            var deleteSegmentResult = await _kaiTimer.DeleteWheelSegmentAsync(deleteSegmentReq);
                            if (deleteSegmentResult.Success)
                                await _git.AutoCommitAsync("KaiTimer wheel segment removed", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(deleteSegmentResult), method, body, !deleteSegmentResult.Success));
                        }
                        break;
                    case "/api/kaitimer/wheel/spin":
                        var spinWheelResult = await _kaiTimer.SpinWheelAsync();
                        if (spinWheelResult.Success)
                            await _git.AutoCommitAsync("KaiTimer wheel spun", ".kaichat-timer.json");
                        results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(spinWheelResult), method, body, !spinWheelResult.Success));
                        break;
                    case "/api/kaitimer/counters":
                        var createCounterReq = System.Text.Json.JsonSerializer.Deserialize<KaiCounterCreateRequest>(body);
                        if (createCounterReq != null)
                        {
                            var createCounterResult = await _kaiTimer.CreateCounterAsync(createCounterReq);
                            if (createCounterResult.Success)
                                await _git.AutoCommitAsync("KaiTimer counter created", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(createCounterResult), method, body, !createCounterResult.Success));
                        }
                        break;
                    case "/api/kaitimer/counters/update":
                        var updateCounterReq = System.Text.Json.JsonSerializer.Deserialize<KaiCounterUpdateRequest>(body);
                        if (updateCounterReq != null)
                        {
                            var updateCounterResult = await _kaiTimer.UpdateCounterAsync(updateCounterReq);
                            if (updateCounterResult.Success)
                                await _git.AutoCommitAsync("KaiTimer counter updated", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(updateCounterResult), method, body, !updateCounterResult.Success));
                        }
                        break;
                    case "/api/kaitimer/counters/increment":
                        var incrementCounterReq = System.Text.Json.JsonSerializer.Deserialize<KaiCounterChangeRequest>(body);
                        if (incrementCounterReq != null)
                        {
                            var incrementCounterResult = await _kaiTimer.IncrementCounterAsync(incrementCounterReq);
                            if (incrementCounterResult.Success)
                                await _git.AutoCommitAsync("KaiTimer counter incremented", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(incrementCounterResult), method, body, !incrementCounterResult.Success));
                        }
                        break;
                    case "/api/kaitimer/counters/decrement":
                        var decrementCounterReq = System.Text.Json.JsonSerializer.Deserialize<KaiCounterChangeRequest>(body);
                        if (decrementCounterReq != null)
                        {
                            var decrementCounterResult = await _kaiTimer.DecrementCounterAsync(decrementCounterReq);
                            if (decrementCounterResult.Success)
                                await _git.AutoCommitAsync("KaiTimer counter decremented", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(decrementCounterResult), method, body, !decrementCounterResult.Success));
                        }
                        break;
                    case "/api/kaitimer/counters/delete":
                        var deleteCounterReq = System.Text.Json.JsonSerializer.Deserialize<KaiCounterDeleteRequest>(body);
                        if (deleteCounterReq != null)
                        {
                            var deleteCounterResult = await _kaiTimer.DeleteCounterAsync(deleteCounterReq);
                            if (deleteCounterResult.Success)
                                await _git.AutoCommitAsync("KaiTimer counter removed", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(deleteCounterResult), method, body, !deleteCounterResult.Success));
                        }
                        break;
                    case "/api/kaitimer/milestones":
                        var createMilestoneReq = System.Text.Json.JsonSerializer.Deserialize<KaiMilestoneCreateRequest>(body);
                        if (createMilestoneReq != null)
                        {
                            var createMilestoneResult = await _kaiTimer.CreateMilestoneAsync(createMilestoneReq);
                            if (createMilestoneResult.Success)
                                await _git.AutoCommitAsync("KaiTimer milestone created", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(createMilestoneResult), method, body, !createMilestoneResult.Success));
                        }
                        break;
                    case "/api/kaitimer/milestones/update":
                        var updateMilestoneReq = System.Text.Json.JsonSerializer.Deserialize<KaiMilestoneUpdateRequest>(body);
                        if (updateMilestoneReq != null)
                        {
                            var updateMilestoneResult = await _kaiTimer.UpdateMilestoneAsync(updateMilestoneReq);
                            if (updateMilestoneResult.Success)
                                await _git.AutoCommitAsync("KaiTimer milestone updated", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(updateMilestoneResult), method, body, !updateMilestoneResult.Success));
                        }
                        break;
                    case "/api/kaitimer/milestones/delete":
                        var deleteMilestoneReq = System.Text.Json.JsonSerializer.Deserialize<KaiMilestoneDeleteRequest>(body);
                        if (deleteMilestoneReq != null)
                        {
                            var deleteMilestoneResult = await _kaiTimer.DeleteMilestoneAsync(deleteMilestoneReq);
                            if (deleteMilestoneResult.Success)
                                await _git.AutoCommitAsync("KaiTimer milestone removed", ".kaichat-timer.json");
                            results.Add(new ToolResult(path, _kaiTimer.FormatResultForTool(deleteMilestoneResult), method, body, !deleteMilestoneResult.Success));
                        }
                        break;
                    default:
                        results.Add(new ToolResult(path, $"Error: unsupported tool path '{path}'.", method, body, true));
                        break;
                }
            }
            catch (Exception ex)
            {
                _logger.LogWarning("Tool call failed: {Method} {Path} - {Error}", method, path, ex.Message);
                results.Add(new ToolResult(path, $"Error: {ex.Message}", method, body, true));
            }
            }

            if (results.Count == resultStart)
                results.Add(new ToolResult(path, "Error: tool call did not produce a result.", method, body, true));

            for (var i = resultStart; i < results.Count; i++)
            {
                if (string.IsNullOrWhiteSpace(results[i].ToolCallId))
                    results[i] = results[i] with { ToolCallId = callId };
            }

            foreach (var result in results.Skip(resultStart))
            {
                var isError = result.IsError || result.Result.StartsWith("Error", StringComparison.OrdinalIgnoreCase);
                await SendToolCallResultAsync(
                    session,
                    callId,
                    isError ? "Tool error" : "Tool complete",
                    result.Result,
                    isError);
            }
        }

        // Build log entries centrally — one place, includes full result text for expandable display
        foreach (var r in results)
        {
            var status = GetToolStatus(r);
            _toolLog.Add(new()
            {
                Method = r.Method,
                Path = r.Path,
                Body = r.Body,
                Status = status,
                Summary = r.Result.Length > 120 ? r.Result[..120] + "..." : r.Result,
                Result = r.Result
            });
        }

        if (results.Count > 0)
            await _git.AutoCommitAsync("tool call log update", ".kaichat-tool-logs.json");

        return results;
    }

    private static ToolCallExtraction ExtractExecutableToolCalls(string response)
    {
        if (string.IsNullOrWhiteSpace(response))
            return new ToolCallExtraction([], "");

        var protectedRanges = FindMarkdownCodeRanges(response);
        var executableMatches = FindExecutableToolCallMatches(response, protectedRanges);

        if (executableMatches.Count == 0 || executableMatches.Count > MaxToolCallsPerRound)
            return new ToolCallExtraction([], response);

        var calls = new List<ToolCallRequest>(executableMatches.Count);
        var visible = new StringBuilder(response);
        for (var i = executableMatches.Count - 1; i >= 0; i--)
        {
            var match = executableMatches[i];
            visible.Remove(match.Start, match.End - match.Start);
        }

        foreach (var match in executableMatches)
            calls.Add(new ToolCallRequest(match.Method, match.Path, match.Body));

        return new ToolCallExtraction(
            calls,
            NormalizeVisibleToolContent(ToolResultTextSanitizer.RemoveEchoedToolResultBlocks(visible.ToString())));
    }

    private static IReadOnlyList<ToolCallRequest> MergeToolCalls(
        IReadOnlyList<ToolCallRequest> nativeToolCalls,
        IReadOnlyList<ToolCallRequest> textToolCalls)
    {
        if (nativeToolCalls.Count == 0)
            return textToolCalls;
        if (textToolCalls.Count == 0 && nativeToolCalls.Count <= MaxToolCallsPerRound)
            return nativeToolCalls;

        return nativeToolCalls
            .Concat(textToolCalls)
            .Take(MaxToolCallsPerRound)
            .ToList();
    }

    private static IReadOnlyList<ToolCallRequest> EnsureToolCallIds(IReadOnlyList<ToolCallRequest> calls)
    {
        if (calls.Count == 0)
            return calls;

        return calls
            .Select(call => string.IsNullOrWhiteSpace(call.ToolCallId)
                ? call with { ToolCallId = CreateToolCallId() }
                : call)
            .ToList();
    }

    private static string CreateToolCallId()
    {
        return "call_" + Guid.NewGuid().ToString("N")[..20];
    }

    private static List<StoredToolCall> BuildStoredToolCalls(IReadOnlyList<ToolCallRequest> calls)
    {
        return calls.Select(call => new StoredToolCall
        {
            Id = call.ToolCallId,
            Name = "kai_tool_call",
            Arguments = BuildStoredToolCallArguments(call)
        }).ToList();
    }

    private static string BuildStoredToolCallArguments(ToolCallRequest call)
    {
        using var emptyDoc = System.Text.Json.JsonDocument.Parse("{}");
        var body = emptyDoc.RootElement.Clone();
        try
        {
            using var doc = System.Text.Json.JsonDocument.Parse(call.Body);
            body = doc.RootElement.Clone();
        }
        catch
        {
            // Malformed tool bodies are still represented as an empty object in the API transcript.
        }

        return System.Text.Json.JsonSerializer.Serialize(new Dictionary<string, object?>
        {
            ["method"] = call.Method,
            ["path"] = call.Path,
            ["body"] = body
        });
    }

    private static IReadOnlyList<ToolCallRequest> ConvertNativeToolCalls(IReadOnlyList<NativeToolCall> nativeToolCalls)
    {
        if (nativeToolCalls.Count == 0)
            return [];

        var calls = new List<ToolCallRequest>();
        foreach (var nativeToolCall in nativeToolCalls)
        {
            if (!string.Equals(nativeToolCall.Name, "kai_tool_call", StringComparison.OrdinalIgnoreCase))
                continue;

            if (TryConvertNativeToolCall(nativeToolCall.Arguments, nativeToolCall.Id, out var call))
                calls.Add(call);
        }

        return calls;
    }

    private static bool TryConvertNativeToolCall(string arguments, string toolCallId, out ToolCallRequest call)
    {
        call = default!;

        if (string.IsNullOrWhiteSpace(arguments))
            return false;

        try
        {
            using var doc = System.Text.Json.JsonDocument.Parse(arguments);
            var root = doc.RootElement;
            if (root.ValueKind != System.Text.Json.JsonValueKind.Object)
                return false;

            var method = root.TryGetProperty("method", out var methodProp)
                ? methodProp.GetString()?.Trim().ToUpperInvariant()
                : null;
            if (!IsSupportedToolMethod(method))
                return false;

            var rawPath = root.TryGetProperty("path", out var pathProp)
                ? pathProp.GetString()?.Trim()
                : null;
            var path = NormalizeToolCallPath(rawPath);
            if (string.IsNullOrWhiteSpace(path))
                return false;

            var body = root.TryGetProperty("body", out var bodyProp)
                ? NormalizeNativeToolBody(bodyProp)
                : "{}";

            call = new ToolCallRequest(method!, path, body, toolCallId);
            return true;
        }
        catch
        {
            return false;
        }
    }

    private static string NormalizeNativeToolBody(System.Text.Json.JsonElement body)
    {
        if (body.ValueKind is System.Text.Json.JsonValueKind.Null or System.Text.Json.JsonValueKind.Undefined)
            return "{}";

        if (body.ValueKind == System.Text.Json.JsonValueKind.String)
        {
            var text = body.GetString();
            if (string.IsNullOrWhiteSpace(text))
                return "{}";

            try
            {
                using var doc = System.Text.Json.JsonDocument.Parse(text);
                return System.Text.Json.JsonSerializer.Serialize(doc.RootElement);
            }
            catch
            {
                return "{}";
            }
        }

        return System.Text.Json.JsonSerializer.Serialize(body);
    }

    private static List<ToolCallMatch> FindExecutableToolCallMatches(string text, IReadOnlyList<TextRange> protectedRanges)
    {
        var matches = new List<ToolCallMatch>();
        var searchIndex = 0;

        while (searchIndex < text.Length)
        {
            var start = text.IndexOf("[TOOL_CALL:", searchIndex, StringComparison.OrdinalIgnoreCase);
            if (start < 0)
                break;

            if (protectedRanges.Any(range => range.Overlaps(start, start + 1)))
            {
                searchIndex = start + 1;
                continue;
            }

            if (TryParseToolCall(text, start, out var match)
                && !protectedRanges.Any(range => range.Overlaps(match.Start, match.End)))
            {
                matches.Add(match);
                searchIndex = match.End;
                continue;
            }

            searchIndex = start + 1;
        }

        return matches;
    }

    private static bool TryParseToolCall(string text, int start, out ToolCallMatch match)
    {
        match = default;
        const string prefix = "[TOOL_CALL:";
        if (start < 0
            || start + prefix.Length >= text.Length
            || string.Compare(text, start, prefix, 0, prefix.Length, StringComparison.OrdinalIgnoreCase) != 0)
            return false;

        var index = start + prefix.Length;
        SkipWhitespace(text, ref index);

        var methodStart = index;
        while (index < text.Length && char.IsLetter(text[index]))
            index++;
        var method = text[methodStart..index].Trim().ToUpperInvariant();
        if (!IsSupportedToolMethod(method))
            return false;

        var pathStart = index;
        while (index < text.Length && text[index] != ',')
            index++;
        if (index >= text.Length)
            return false;
        var path = NormalizeToolCallPath(text[pathStart..index]);
        if (string.IsNullOrWhiteSpace(path))
            return false;

        index++;
        SkipWhitespace(text, ref index);
        if (index >= text.Length || text[index] != '{')
            return false;

        var bodyStart = index;
        if (!TryFindJsonObjectEnd(text, bodyStart, out var bodyEnd))
            return false;
        var body = text[bodyStart..bodyEnd];
        index = bodyEnd;

        SkipWhitespace(text, ref index);
        if (index >= text.Length || text[index] != ']')
            return false;

        match = new ToolCallMatch(start, index + 1, method, path, body);
        return true;
    }

    private static bool IsSupportedToolMethod(string? method)
    {
        return method is "GET" or "POST" or "PUT" or "PATCH" or "DELETE";
    }

    private static string? NormalizeToolCallPath(string? rawPath)
    {
        if (string.IsNullOrWhiteSpace(rawPath))
            return null;

        var value = rawPath.Trim().Replace('\\', '/');
        if (value.Length == 0) return null;

        string pathPart = value;
        string queryPart = "";
        var queryIndex = value.IndexOf('?');
        if (queryIndex >= 0)
        {
            pathPart = value[..queryIndex];
            queryPart = value[queryIndex..];
        }

        pathPart = pathPart.Trim();
        if (string.IsNullOrWhiteSpace(pathPart))
            return null;

        while (pathPart.Contains("//"))
            pathPart = pathPart.Replace("//", "/");

        if (!pathPart.StartsWith('/'))
            pathPart = "/" + pathPart;

        if (pathPart.StartsWith("/api//", StringComparison.OrdinalIgnoreCase))
            pathPart = "/api/" + pathPart[6..];

        return pathPart + queryPart;
    }

    private static bool IsAutonomyToolPath(string path)
    {
        return path.StartsWith("/api/autonomy", StringComparison.OrdinalIgnoreCase);
    }

    private static bool TryFindJsonObjectEnd(string text, int start, out int end)
    {
        end = -1;
        var depth = 0;
        var inString = false;
        var escaped = false;

        for (var index = start; index < text.Length; index++)
        {
            var ch = text[index];
            if (inString)
            {
                if (escaped)
                {
                    escaped = false;
                    continue;
                }

                if (ch == '\\')
                {
                    escaped = true;
                    continue;
                }

                if (ch == '"')
                    inString = false;
                continue;
            }

            if (ch == '"')
            {
                inString = true;
                continue;
            }

            if (ch == '{')
            {
                depth++;
                continue;
            }

            if (ch == '}')
            {
                depth--;
                if (depth == 0)
                {
                    end = index + 1;
                    return true;
                }
                if (depth < 0)
                    return false;
            }
        }

        return false;
    }

    private static void SkipWhitespace(string text, ref int index)
    {
        while (index < text.Length && char.IsWhiteSpace(text[index]))
            index++;
    }

    private static List<TextRange> FindMarkdownCodeRanges(string text)
    {
        var ranges = new List<TextRange>();
        var lineStart = 0;
        var inFence = false;
        var fenceStart = 0;
        var fenceMarker = '\0';
        var fenceLength = 0;

        while (lineStart < text.Length)
        {
            var nextLine = text.IndexOf('\n', lineStart);
            var lineEndWithNewline = nextLine >= 0 ? nextLine + 1 : text.Length;
            var lineEnd = nextLine >= 0 ? nextLine : text.Length;
            if (lineEnd > lineStart && text[lineEnd - 1] == '\r')
                lineEnd--;

            var marker = TryGetFenceMarker(text, lineStart, lineEnd);
            if (marker.HasValue)
            {
                var (character, length) = marker.Value;
                if (!inFence)
                {
                    inFence = true;
                    fenceStart = lineStart;
                    fenceMarker = character;
                    fenceLength = length;
                }
                else if (character == fenceMarker && length >= fenceLength)
                {
                    ranges.Add(new TextRange(fenceStart, lineEndWithNewline));
                    inFence = false;
                }
            }

            lineStart = lineEndWithNewline;
        }

        if (inFence)
            ranges.Add(new TextRange(fenceStart, text.Length));

        AddInlineCodeRanges(text, ranges);
        ranges.Sort((a, b) => a.Start.CompareTo(b.Start));
        return ranges;
    }

    private static (char Character, int Length)? TryGetFenceMarker(string text, int lineStart, int lineEnd)
    {
        var index = lineStart;
        var indent = 0;
        while (index < lineEnd && (text[index] == ' ' || text[index] == '\t'))
        {
            indent += text[index] == '\t' ? 4 : 1;
            if (indent > 3)
                return null;
            index++;
        }

        if (index >= lineEnd || (text[index] != '`' && text[index] != '~'))
            return null;

        var marker = text[index];
        var markerStart = index;
        while (index < lineEnd && text[index] == marker)
            index++;

        var markerLength = index - markerStart;
        return markerLength >= 3 ? (marker, markerLength) : null;
    }

    private static void AddInlineCodeRanges(string text, List<TextRange> ranges)
    {
        var orderedFenceRanges = ranges.OrderBy(range => range.Start).ToList();
        var segmentStart = 0;
        foreach (var fenceRange in orderedFenceRanges)
        {
            AddInlineCodeRanges(text, segmentStart, fenceRange.Start, ranges);
            segmentStart = fenceRange.End;
        }
        AddInlineCodeRanges(text, segmentStart, text.Length, ranges);
    }

    private static void AddInlineCodeRanges(string text, int start, int end, List<TextRange> ranges)
    {
        for (var index = start; index < end;)
        {
            if (text[index] != '`')
            {
                index++;
                continue;
            }

            var openStart = index;
            while (index < end && text[index] == '`')
                index++;

            var tickCount = index - openStart;
            if (tickCount >= 3)
                continue;

            var closeStart = FindClosingBacktickRun(text, index, end, tickCount);
            if (closeStart < 0)
                continue;

            var closeEnd = closeStart + tickCount;
            ranges.Add(new TextRange(openStart, closeEnd));
            index = closeEnd;
        }
    }

    private static int FindClosingBacktickRun(string text, int start, int end, int tickCount)
    {
        for (var index = start; index <= end - tickCount; index++)
        {
            if (text[index] != '`')
                continue;

            var runStart = index;
            while (index < end && text[index] == '`')
                index++;

            if (index - runStart == tickCount)
                return runStart;
        }

        return -1;
    }

    private static string NormalizeVisibleToolContent(string text)
    {
        var normalized = Regex.Replace(text, @"[ \t]+(\r?\n)", "$1");
        normalized = Regex.Replace(normalized, @"(\r?\n){3,}", Environment.NewLine + Environment.NewLine);
        return normalized.Trim();
    }

    private static string DescribeToolCall(string method, string path, string body)
    {
        try
        {
            using var doc = System.Text.Json.JsonDocument.Parse(body);
            var root = doc.RootElement;

            if ((path.StartsWith("/api/files/read", StringComparison.OrdinalIgnoreCase)
                    || path.StartsWith("/api/memory/file/read", StringComparison.OrdinalIgnoreCase))
                && root.TryGetProperty("path", out var pathProp))
                return $"{method} {path}: {pathProp.GetString()}";

            if (path.StartsWith("/api/files/list", StringComparison.OrdinalIgnoreCase))
            {
                var directory = root.TryGetProperty("path", out var listPathProp)
                    ? NormalizeToolPath(listPathProp.GetString() ?? "")
                    : ".";
                var pattern = root.TryGetProperty("pattern", out var patternProp)
                    ? patternProp.GetString()
                    : null;
                return string.IsNullOrWhiteSpace(pattern)
                    ? $"{method} {path}: {directory}"
                    : $"{method} {path}: {directory} ({pattern})";
            }

            if (path.StartsWith("/api/search", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("q", out var queryProp))
                return $"{method} {path}: {queryProp.GetString()}";

            if (path.StartsWith("/api/nightscout/latest", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("count", out var latestCountProp))
                return $"{method} {path}: latest {latestCountProp.GetInt32()} reading(s)";

            if (path.StartsWith("/api/nightscout/history", StringComparison.OrdinalIgnoreCase))
            {
                var start = root.TryGetProperty("start", out var startProp) ? startProp.GetString() : null;
                var end = root.TryGetProperty("end", out var endProp) ? endProp.GetString() : null;
                return $"{method} {path}: {start ?? "(default start)"} to {end ?? "(default end)"}";
            }

            if (path.StartsWith("/api/nightscout/status", StringComparison.OrdinalIgnoreCase))
                return $"{method} {path}: connection status";

            if (path.StartsWith("/api/kaitimer/wheel/segments", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("text", out var wheelTextProp))
                return $"{method} {path}: {TrimForStatus(wheelTextProp.GetString() ?? "")}";

            if (path.StartsWith("/api/kaitimer/counters", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("name", out var counterNameProp))
                return $"{method} {path}: {TrimForStatus(counterNameProp.GetString() ?? "")}";

            if (path.StartsWith("/api/kaitimer/milestones", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("name", out var milestoneNameProp))
                return $"{method} {path}: {TrimForStatus(milestoneNameProp.GetString() ?? "")}";

            if (path.StartsWith("/api/kaitimer/wheel/spin", StringComparison.OrdinalIgnoreCase))
                return $"{method} {path}: spin KaiTimer wheel";

            if (path.StartsWith("/api/kaitimer", StringComparison.OrdinalIgnoreCase))
                return $"{method} {path}: KaiTimer";

            if (path.StartsWith("/api/memory/ai-manage", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("instruction", out var instructionProp))
                return $"{method} {path}: {TrimForStatus(instructionProp.GetString() ?? "")}";

            if (path.StartsWith("/api/instructions/rewrite", StringComparison.OrdinalIgnoreCase)
                && root.TryGetProperty("path", out var rewritePathProp))
                return $"{method} {path}: {rewritePathProp.GetString()}";
        }
        catch
        {
            // Fall through to the generic label.
        }

        return $"{method} {path}";
    }

    private static IReadOnlyList<string> GetFileListPatterns(FileToolList request)
    {
        var patterns = new List<string>();
        if (!string.IsNullOrWhiteSpace(request.Pattern))
            patterns.Add(request.Pattern.Trim());

        if (request.Patterns != null)
        {
            patterns.AddRange(request.Patterns
                .Where(pattern => !string.IsNullOrWhiteSpace(pattern))
                .Select(pattern => pattern.Trim()));
        }

        return patterns
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();
    }

    private static bool MatchesAnyFilePattern(FileItem item, IReadOnlyList<string> patterns)
    {
        var name = item.Name.Replace('\\', '/');
        var relativePath = NormalizeToolPath(item.RelativePath);
        return patterns.Any(pattern =>
            System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(pattern, name, ignoreCase: true)
            || System.IO.Enumeration.FileSystemName.MatchesSimpleExpression(pattern, relativePath, ignoreCase: true));
    }

    private static string BuildDirectoryListResult(string? directory, IReadOnlyList<string> patterns, IReadOnlyList<FileItem> items)
    {
        var normalizedDirectory = NormalizeToolPath(directory ?? "");
        var builder = new StringBuilder();
        builder.AppendLine($"Directory: {normalizedDirectory}");
        builder.AppendLine(patterns.Count == 0
            ? "Patterns: (all)"
            : $"Patterns: {string.Join(", ", patterns)}");

        if (items.Count == 0)
        {
            builder.AppendLine("No matching files or folders found.");
            return builder.ToString().TrimEnd();
        }

        foreach (var item in items)
        {
            var itemPath = NormalizeToolPath(item.RelativePath);
            if (item.IsDirectory)
                builder.AppendLine($"- [dir] {itemPath}/ (modified {item.LastModified:g})");
            else
                builder.AppendLine($"- [file] {itemPath} ({item.SizeDisplay}, modified {item.LastModified:g})");
        }

        return builder.ToString().TrimEnd();
    }

    private static string NormalizeToolPath(string path)
    {
        var normalized = path.Replace('\\', '/').Trim('/');
        return string.IsNullOrWhiteSpace(normalized) ? "." : normalized;
    }

    private static string FormatToolBodyForDisplay(string body)
    {
        if (string.IsNullOrWhiteSpace(body))
            return "{}";

        try
        {
            using var doc = System.Text.Json.JsonDocument.Parse(body);
            return System.Text.Json.JsonSerializer.Serialize(doc.RootElement, new System.Text.Json.JsonSerializerOptions
            {
                WriteIndented = true
            });
        }
        catch
        {
            return body.Trim();
        }
    }

    private static string TrimForStatus(string value, int maxLength = 120)
    {
        value = Regex.Replace(value.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' '), @"\s+", " ").Trim();
        if (maxLength < 12)
            maxLength = 12;

        return value.Length <= maxLength ? value : value[..(maxLength - 3)] + "...";
    }

    private static string GitOutputOrError(GitResult result)
    {
        return string.IsNullOrWhiteSpace(result.Output)
            ? result.Error
            : result.Output;
    }

    private async Task UpdateBible(string note)
    {
        var biblePath = Path.Combine(
            _config.BaseFolder ?? ".",
            "Story Bible.md");
        if (File.Exists(biblePath))
        {
            var current = await File.ReadAllTextAsync(biblePath);
            var entry = $"\n\n---\n\n*Update {DateTime.UtcNow:g}* — {note}";
            await File.WriteAllTextAsync(biblePath, current + entry);
        }
    }

    // Tool call model types
    // JsonPropertyName uses lowercase to match what the AI is instructed to send
    private readonly record struct TextRange(int Start, int End)
    {
        public bool Overlaps(int start, int end) => start < End && end > Start;
    }

    private readonly record struct ToolCallMatch(int Start, int End, string Method, string Path, string Body);
    private record ToolCallExtraction(IReadOnlyList<ToolCallRequest> Calls, string VisibleContent);
    private record ToolCallRequest(string Method, string Path, string Body, string ToolCallId = "");
    private record NativeToolCall(string Id, string Name, string Arguments);

    private record MemoryToolList(
        [property: System.Text.Json.Serialization.JsonPropertyName("includeTrash")] bool IncludeTrash = false);
    private record MemoryToolFileRead(
        [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path);
    private record MemoryToolFileSave(
        [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path,
        [property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
    private record MemoryToolFileDelete(
        [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path,
        [property: System.Text.Json.Serialization.JsonPropertyName("reason")] string Reason);
    private record MemoryToolSave(
        [property: System.Text.Json.Serialization.JsonPropertyName("section")] string Section,
        [property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
    private record MemoryToolDelete(
        [property: System.Text.Json.Serialization.JsonPropertyName("section")] string Section);
    private record MemoryToolManage(
        [property: System.Text.Json.Serialization.JsonPropertyName("instruction")] string Instruction);
    private record BibleToolUpdate(
        [property: System.Text.Json.Serialization.JsonPropertyName("note")] string Note);
    private record InstructionRewriteRequest(
        [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path,
        [property: System.Text.Json.Serialization.JsonPropertyName("instruction")] string Instruction);
    private record FileToolList(
        [property: System.Text.Json.Serialization.JsonPropertyName("path")] string? Path = "",
        [property: System.Text.Json.Serialization.JsonPropertyName("pattern")] string? Pattern = null,
        [property: System.Text.Json.Serialization.JsonPropertyName("patterns")] string[]? Patterns = null);
    private record FileToolRead(
        [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path);
    private record SearchToolQuery(
        [property: System.Text.Json.Serialization.JsonPropertyName("q")] string Q);
    private record NightscoutLatestRequest(
        [property: System.Text.Json.Serialization.JsonPropertyName("count")] int Count = 6);
    private record NightscoutHistoryRequest(
        [property: System.Text.Json.Serialization.JsonPropertyName("start")] string? Start = null,
        [property: System.Text.Json.Serialization.JsonPropertyName("end")] string? End = null,
        [property: System.Text.Json.Serialization.JsonPropertyName("count")] int Count = 288);
    private record AssistantStreamResult(
        string Content,
        string? Thinking,
        IReadOnlyList<NativeToolCall> NativeToolCalls);
    private record ToolResult(string Path, string Result, string Method = "", string Body = "", bool IsError = false, string ToolCallId = "");
    private record StreamReplayEvent(string Name, object?[] Args);

    private sealed class StreamSession
    {
        public StreamSession(string conversationId)
        {
            ConversationId = conversationId;
        }

        public string ConversationId { get; }
        public CancellationTokenSource Cts { get; } = new();
        public object Gate { get; } = new();
        public List<StreamReplayEvent> Events { get; } = new();
        public bool Replayable { get; set; } = true;
        public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
    }

    public Task StopStreaming(string? conversationId = null)
    {
        if (string.IsNullOrWhiteSpace(conversationId) &&
            _connectionConversations.TryGetValue(Context.ConnectionId, out var joinedConversationId))
        {
            conversationId = joinedConversationId;
        }

        if (!string.IsNullOrWhiteSpace(conversationId))
            CancelStream(conversationId);

        return Task.CompletedTask;
    }

    public async Task CreateConversation(string title, string? persona = null)
    {
        var conv = _conversation.CreateConversation();
        conv.Title = NormalizeTitle(title, "New Chat");
        conv.TitleAutoGenerated = false;
        var conversationsFolder = _conversation.ResolveConversationFolder(persona);
        await _conversation.SaveConversationAsync(conv, conversationsFolder);
        await SetCurrentConversationGroupAsync(conv.Id);
        _activeConversation.SetActive(conv.Id);
        await Clients.Caller.SendAsync("ConversationCreated", conv);
    }

    public async Task DeleteConversation(string id, string? persona = null)
    {
        var conversationsFolder = _conversation.ResolveConversationFolder(persona);
        var conv = _conversation.GetConversation(id, conversationsFolder);
        if (conv == null)
        {
            await Clients.Caller.SendAsync("Error", "Conversation not found");
            return;
        }

        CancelStream(id);
        var relPath = _conversation.GetConversationRelativePath(id, conversationsFolder);
        await _conversation.DeleteConversationAsync(id, conversationsFolder);
        await Clients.Caller.SendAsync("ConversationDeleted", id);

        // Commit the deletion right away
        _ = CommitDeletionAsync(relPath, id);
    }

    private async Task CommitDeletionAsync(string relPath, string id)
    {
        try
        {
            var stageResult = await _git.StagePathAsync(relPath);
            if (!stageResult.Success)
                return;

            await _git.CommitAsync($"[{id}] delete conversation");
            _logger.LogInformation("Committed deletion: [{Id}]", id);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Commit deletion failed for {Id}", id);
        }
    }

    private static bool ContainsChinese(string text)
    {
        foreach (var rune in text.EnumerateRunes())
        {
            var value = rune.Value;
            if (value >= 0x4E00 && value <= 0x9FFF) return true;
            if (value >= 0x3400 && value <= 0x4DBF) return true;
            if (value >= 0xF900 && value <= 0xFAFF) return true;
            if (value >= 0x20000 && value <= 0x2A6DF) return true;
            if (value >= 0x2A700 && value <= 0x2EBEF) return true;
            if (value >= 0x30000 && value <= 0x3134F) return true;
        }
        return false;
    }

    private async Task<bool> TranslateMessagePartsAsync(
        StreamSession session,
        ChatMessage message,
        string thinkingText,
        string responseText,
        string userMessage,
        CancellationToken ct)
    {
        var needsSave = false;

        if (ContainsChinese(responseText))
        {
            var translated = await TranslateTextStreamingAsync(session, "Response", responseText, userMessage, responseText, ct);
            if (!string.IsNullOrWhiteSpace(translated))
            {
                message.Content = translated;
                needsSave = true;
            }
        }

        if (!string.IsNullOrWhiteSpace(thinkingText) && ContainsChinese(thinkingText))
        {
            var translated = await TranslateTextStreamingAsync(session, "Thinking", thinkingText, userMessage, responseText, ct);
            if (!string.IsNullOrWhiteSpace(translated))
            {
                message.Thinking = translated;
                needsSave = true;
            }
        }

        return needsSave;
    }

    private async Task<string?> TranslateTextStreamingAsync(
        StreamSession session,
        string eventPrefix,
        string text,
        string? userMessage,
        string? rawResponse,
        CancellationToken ct)
    {
        for (var attempt = 1; attempt <= TranslationMaxAttempts; attempt++)
        {
            var translated = new StringBuilder();
            var retryBecauseChinese = false;

            await SendStreamEventAsync(session, $"{eventPrefix}TranslationStarted", attempt, TranslationMaxAttempts);

            using var attemptCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            try
            {
                var msgs = BuildTranslationMessages(text, userMessage, rawResponse, attempt);
                await foreach (var token in _chat.StreamChatAsync(msgs, attemptCts.Token))
                {
                    if (!token.StartsWith("C|")) continue;

                    var content = token[2..];
                    if (string.IsNullOrEmpty(content)) continue;

                    if (ContainsChinese(content))
                    {
                        retryBecauseChinese = true;
                        attemptCts.Cancel();
                        break;
                    }

                    translated.Append(content);
                    await SendStreamEventAsync(session, $"{eventPrefix}TranslationDelta", content);
                }
            }
            catch (OperationCanceledException) when (retryBecauseChinese && !ct.IsCancellationRequested)
            {
                // The attempt was deliberately interrupted because the translator copied Chinese.
            }
            catch (Exception ex)
            {
                _logger.LogWarning("Translation attempt {Attempt} for {Target} failed: {Ex}", attempt, eventPrefix, ex.Message);
            }

            ct.ThrowIfCancellationRequested();

            var translatedText = translated.ToString().Trim();
            if (!string.IsNullOrWhiteSpace(translatedText) && !ContainsChinese(translatedText))
            {
                await SendStreamEventAsync(session, $"{eventPrefix}Translated", translatedText);
                return translatedText;
            }

            if (attempt < TranslationMaxAttempts)
            {
                _logger.LogWarning(
                    "Translation attempt {Attempt} for {Target} produced Chinese or no usable output; retrying.",
                    attempt,
                    eventPrefix);
                await SendStreamEventAsync(session, $"{eventPrefix}TranslationRetry", attempt + 1, TranslationMaxAttempts);
                await Task.Delay(150, ct);
            }
        }

        _logger.LogWarning("Translation for {Target} failed after {Attempts} attempts; keeping original text.", eventPrefix, TranslationMaxAttempts);
        await SendStreamEventAsync(session, $"{eventPrefix}Translated", text);
        return null;
    }

    private static List<ChatMessage> BuildTranslationMessages(string text, string? userMessage, string? rawResponse, int attempt)
    {
        var context = new StringBuilder();
        context.AppendLine("Translate the following text from Chinese to English.");
        context.AppendLine("Return ONLY the English translation, no explanations, no quotes, no formatting.");
        context.AppendLine("Do not reproduce the original Chinese text.");
        context.AppendLine("Do not output Chinese characters. Transliterate names or terms if needed.");
        context.AppendLine("If part of the text is already English, preserve that English and translate only the Chinese.");
        if (attempt > 1)
        {
            context.AppendLine();
            context.AppendLine("A previous attempt copied Chinese characters. This retry must be English-only.");
        }
        if (!string.IsNullOrEmpty(userMessage))
        {
            context.AppendLine();
            context.AppendLine("Context -- the user's original message that prompted this response:");
            context.AppendLine($"\"{userMessage}\"");
        }
        if (!string.IsNullOrEmpty(rawResponse))
        {
            context.AppendLine();
            context.AppendLine("Context -- the full untranslated response (may be Chinese or mixed):");
            context.AppendLine(rawResponse);
        }

        return new List<ChatMessage>
        {
            new() { Role = "system", Content = context.ToString() },
            new() { Role = "user", Content = text }
        };
    }

    private static void CancelStream(string conversationId)
    {
        if (_streams.TryGetValue(conversationId, out var session))
            session.Cts.Cancel();
    }

    private static string BuildInitialTitle(string? title, string message)
    {
        if (!string.IsNullOrWhiteSpace(title))
            return NormalizeTitle(title, "New Chat");

        var cleaned = NormalizeTitle(message, "New Chat");
        return cleaned.Length > 40 ? cleaned[..40].TrimEnd() + "..." : cleaned;
    }

    private static string NormalizeTitle(string? title, string fallback)
    {
        var normalized = string.IsNullOrWhiteSpace(title) ? fallback : title.Trim();
        normalized = Regex.Replace(normalized, @"\s+", " ");
        if (normalized.Length > 60)
            normalized = normalized[..60].TrimEnd();
        return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized;
    }

    private async Task AutoCommitAsync(
        Conversation conv,
        string conversationsFolder,
        CompactionResult? compactionResult = null,
        MemorySweepResult? memorySweepResult = null)
    {
        try
        {
            var relPath = _conversation.GetConversationRelativePath(conv.Id, conversationsFolder);
            var stageResult = await _git.StagePathAsync(relPath);
            if (!stageResult.Success)
                return;

            var markerStage = await _git.StagePathAsync(_memoryService.UpdateMarkerRelativePath);
            if (!markerStage.Success)
                _logger.LogWarning("Failed to stage memory update marker: {Error}", markerStage.Error);

            if (!string.IsNullOrWhiteSpace(compactionResult?.ArchivePath))
            {
                var archiveStage = await _git.StagePathAsync(compactionResult.ArchivePath);
                if (!archiveStage.Success)
                    _logger.LogWarning("Failed to stage compaction archive {Archive}: {Error}",
                        compactionResult.ArchivePath, archiveStage.Error);
            }

            if (memorySweepResult?.Changed == true)
            {
                var memoryStage = await _git.StagePathAsync(_memoryService.MemoryDirectoryRelativePath);
                if (!memoryStage.Success)
                    _logger.LogWarning("Failed to stage memory sweep changes: {Error}", memoryStage.Error);
            }

            // Check if the stage actually picked up changes
            var status = await _git.StatusAsync();
            if (string.IsNullOrWhiteSpace(status.Output))
                return; // nothing to commit

            var title = conv.Title?.Length > 50 ? conv.Title[..50] : (conv.Title ?? "untitled");
            var sanitized = title.Replace("\"", "'");
            await _git.CommitAsync($"[{conv.Id}] {sanitized}");
            _logger.LogInformation("Auto-committed: [{Id}] {Title}", conv.Id, title);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Auto-commit failed for {Id}", conv.Id);
        }
    }
}
Offline