Size: 16.4 KB Modified: 6/07/2026 1:48 PM
using KaiChat.Hubs;
using KaiChat.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.SignalR;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace KaiChat.Services;

public interface IKaiAutonomousConversationService
{
    string TargetConversationId { get; }
    string TargetConversationFolder { get; }
    Task<Conversation> GetTargetConversationAsync(CancellationToken ct = default);
    Task<string> BuildRecentContextAsync(int maxMessages = 24, int maxChars = 12000, CancellationToken ct = default);
    Task<KaiAutonomousAppendResult> AppendAssistantMessageAsync(
        string content,
        string source,
        IEnumerable<string>? extraStagePaths = null,
        CancellationToken ct = default);
}

public sealed class KaiAutonomousConversationService : IKaiAutonomousConversationService
{
    private readonly IConversationService _conversations;
    private readonly ICompactionService _compaction;
    private readonly IGitService _git;
    private readonly ISearchService _search;
    private readonly IHubContext<ChatHub> _hub;
    private readonly ILogger<KaiAutonomousConversationService> _logger;
    private readonly string _personalityName;
    private readonly string _personalityFilePath;
    private readonly string _conversationFolder;
    private readonly string _conversationFolderRelative;
    private readonly string _targetConversationId;
    private readonly SemaphoreSlim _gate = new(1, 1);

    public KaiAutonomousConversationService(
        IConversationService conversations,
        ICompactionService compaction,
        IGitService git,
        ISearchService search,
        IHubContext<ChatHub> hub,
        IConfiguration configuration,
        ILogger<KaiAutonomousConversationService> logger)
    {
        _conversations = conversations;
        _compaction = compaction;
        _git = git;
        _search = search;
        _hub = hub;
        _logger = logger;

        var configuredBase = configuration.GetValue<string>("KaiChat:BaseFolder") ?? ".";
        var baseFolder = Path.GetFullPath(configuredBase);
        var statePath = ResolvePrimaryStatePath(baseFolder);
        var stateDirectory = Path.GetDirectoryName(statePath) ?? Path.Combine(baseFolder, "KaiChat", "Personas", "Kai");

        _personalityFilePath = ResolvePersonalityFilePath(stateDirectory);
        _personalityName = Path.GetFileNameWithoutExtension(_personalityFilePath);
        _conversationFolder = Path.Combine(stateDirectory, "Conversations");
        _targetConversationId = ResolveTargetConversationId(_conversationFolder, _personalityName);
        _conversationFolderRelative = Path.GetRelativePath(baseFolder, _conversationFolder).Replace('\\', '/');
    }

    public string TargetConversationId => _targetConversationId;
    public string TargetConversationFolder => _conversationFolderRelative;

    public async Task<Conversation> GetTargetConversationAsync(CancellationToken ct = default)
    {
        var existing = _conversations.GetConversation(_targetConversationId, _conversationFolder);
        if (existing != null)
            return existing;

        var created = _conversations.CreateConversation();
        created.Id = _targetConversationId;
        created.Title = _personalityName;
        created.TitleAutoGenerated = false;
        await _conversations.SaveConversationAsync(created, _conversationFolder);
        return created;
    }

    public async Task<string> BuildRecentContextAsync(int maxMessages = 24, int maxChars = 12000, CancellationToken ct = default)
    {
        var conv = await GetTargetConversationAsync(ct);
        var messages = conv.Messages
            .Where(m => m.Role is "user" or "assistant" or "tool" or "compaction" or "memory_sweep")
            .TakeLast(Math.Max(1, maxMessages))
            .ToList();

        var builder = new StringBuilder();
        builder.AppendLine($"Conversation file: {_conversations.GetConversationRelativePath(conv.Id, _conversationFolder)}");
        builder.AppendLine($"Conversation title: {conv.Title}");
        builder.AppendLine($"Total live messages: {conv.Messages.Count}");
        builder.AppendLine($"Compactions: {conv.CompactionCount}");
        if (!string.IsNullOrWhiteSpace(conv.CompactedSummary))
        {
            builder.AppendLine();
            builder.AppendLine("Existing compacted summary from this same conversation:");
            AppendTrimmed(builder, conv.CompactedSummary, 2500);
        }

        builder.AppendLine();
        builder.AppendLine("Recent messages from this conversation only:");
        foreach (var message in messages)
        {
            ct.ThrowIfCancellationRequested();
            builder.AppendLine();
            builder.AppendLine($"[{message.Timestamp:O}] [{message.Role}] id={message.Id}");
            var content = FormatMessageContent(message);
            AppendTrimmed(builder, content, 1800);
            if (builder.Length >= maxChars)
                break;
        }

        return builder.Length > maxChars
            ? builder.ToString()[..maxChars] + "\n...[recent context truncated]..."
            : builder.ToString();
    }

    public async Task<KaiAutonomousAppendResult> AppendAssistantMessageAsync(
        string content,
        string source,
        IEnumerable<string>? extraStagePaths = null,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(content))
            return KaiAutonomousAppendResult.Skipped("Visible message was empty.");

        if (!await _gate.WaitAsync(0, ct))
            return KaiAutonomousAppendResult.Skipped("Another autonomous conversation write is already running.");

        try
        {
            var conv = await GetTargetConversationAsync(ct);
            var message = new ChatMessage
            {
                Role = "assistant",
                Content = content.Trim(),
                Timestamp = DateTime.UtcNow
            };

            conv.Messages.Add(message);
            await _conversations.SaveConversationAsync(conv, _conversationFolder);

            CompactionResult? compactionResult = null;
            ChatMessage? compactionMessage = null;
            string? compactionError = null;
            try
            {
                var systemPrompt = await _conversations.BuildSystemPromptAsync();
                var preview = await _compaction.PreviewCompactionAsync(conv, systemPrompt);
                if (preview.ShouldCompact)
                {
                    var startedAt = DateTime.UtcNow;
                    compactionResult = await _compaction.CompactIfNeededAsync(conv, systemPrompt, ct: ct, conversationsFolder: _conversationFolder);
                    if (compactionResult.Compacted)
                    {
                        compactionMessage = CreateCompactionMessage(Guid.NewGuid().ToString("N")[..8], compactionResult, startedAt, DateTime.UtcNow);
                        conv.Messages.Add(compactionMessage);
                        await _conversations.SaveConversationAsync(conv, _conversationFolder);
                        await _search.RebuildIndexAsync();
                    }
                }
            }
            catch (Exception ex)
            {
                compactionError = ex.Message;
                _logger.LogWarning(ex, "Autonomous append compaction check failed for {ConversationId}", _targetConversationId);
            }

            var staged = new List<string> { _conversations.GetConversationRelativePath(conv.Id, _conversationFolder) };
            if (!string.IsNullOrWhiteSpace(compactionResult?.ArchivePath))
                staged.Add(compactionResult.ArchivePath);
            if (extraStagePaths != null)
                staged.AddRange(extraStagePaths.Where(p => !string.IsNullOrWhiteSpace(p)));

            var stageResults = new List<GitResult>();
            foreach (var path in staged.Distinct(StringComparer.OrdinalIgnoreCase))
                stageResults.Add(await _git.StagePathAsync(path));

            GitResult? commit = null;
            if (stageResults.All(r => r.Success))
                commit = await _git.CommitAsync($"[{conv.Id}] Autonomous {source} message");

            await _hub.Clients.Group(ChatHub.GroupName(conv.Id)).SendAsync("KaiMessageAppended", conv.Id, message, source, ct);
            if (compactionMessage != null)
                await _hub.Clients.Group(ChatHub.GroupName(conv.Id)).SendAsync("KaiMessageAppended", conv.Id, compactionMessage, "autonomous_compaction", ct);
            await _hub.Clients.All.SendAsync("ConversationsChanged", ct);

            var resultMessage = compactionError == null
                ? "Autonomous message appended."
                : $"Autonomous message appended; compaction check failed: {compactionError}";

            return new KaiAutonomousAppendResult(
                true,
                resultMessage,
                conv.Id,
                message.Id,
                compactionResult?.Compacted == true,
                compactionResult?.ArchivePath,
                commit);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Autonomous append to conversation {ConversationId} failed", _targetConversationId);
            return KaiAutonomousAppendResult.Skipped(ex.Message);
        }
        finally
        {
            _gate.Release();
        }
    }

    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
            }
        };
    }

    private static string FormatMessageContent(ChatMessage message)
    {
        if (message.Tool != null)
            return $"{message.Tool.Method} {message.Tool.Path} [{message.Tool.Status}]\n{message.Tool.Result}";

        if (message.Compaction != null)
            return $"Compaction #{message.Compaction.CompactionNumber}: {message.Content}";

        if (message.MemorySweep != null)
            return $"Memory sweep: {message.MemorySweep.Status}\n{message.Content}";

        return message.Content ?? "";
    }

    private static void AppendTrimmed(StringBuilder builder, string? value, int maxChars)
    {
        var text = (value ?? "").Trim();
        if (text.Length <= maxChars)
        {
            builder.AppendLine(text);
            return;
        }

        builder.AppendLine(text[..maxChars].TrimEnd());
        builder.AppendLine($"[truncated {text.Length - maxChars} character(s)]");
    }

    private static string ResolvePrimaryStatePath(string baseFolder)
    {
        var preferredStatePath = Path.Combine(baseFolder, "KaiChat", "Personas", "Kai", ".kaichat-autonomy.json");
        if (File.Exists(preferredStatePath))
            return preferredStatePath;

        var personasPath = Path.Combine(baseFolder, "KaiChat", "Personas");
        if (Directory.Exists(personasPath))
        {
            var options = new EnumerationOptions
            {
                IgnoreInaccessible = true,
                RecurseSubdirectories = false
            };

            var stateFiles = Directory.EnumerateFiles(personasPath, ".kaichat-autonomy.json", options)
                .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
                .ToList();
            if (stateFiles.Count > 0)
                return stateFiles[0];
        }

        var legacyPath = Path.Combine(baseFolder, ".kaichat-autonomy.json");
        if (File.Exists(legacyPath))
            return legacyPath;

        return preferredStatePath;
    }

    private static string ResolvePersonalityFilePath(string personaDirectory)
    {
        var normalizedDirectory = Path.GetFullPath(personaDirectory);
        var directoryName = Path.GetFileName(normalizedDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
        var preferred = Path.Combine(normalizedDirectory, $"{directoryName}.md");
        if (File.Exists(preferred))
            return preferred;

        if (!Directory.Exists(normalizedDirectory))
            return Path.Combine(normalizedDirectory, $"{directoryName}.md");

        var rootFiles = Directory.GetFiles(normalizedDirectory, "*.md", SearchOption.TopDirectoryOnly);
        var candidate = rootFiles
            .Where(file => !string.IsNullOrWhiteSpace(Path.GetFileName(file)))
            .FirstOrDefault(file =>
                !string.Equals(Path.GetFileName(file), ".kaichat-autonomy.json", StringComparison.OrdinalIgnoreCase) &&
                !IsDirectoryMarker(file));
        if (candidate != null)
            return candidate;

        return preferred;
    }

    private static bool IsDirectoryMarker(string filePath)
    {
        return string.Equals(
            Path.GetFileNameWithoutExtension(filePath),
            ".kaichat-autonomy",
            StringComparison.OrdinalIgnoreCase);
    }

    private static string ResolveTargetConversationId(string conversationFolder, string fallbackPersonaName)
    {
        if (!Directory.Exists(conversationFolder))
        {
            Directory.CreateDirectory(conversationFolder);
            return EnsureConversationIdFallback(fallbackPersonaName);
        }

        var candidates = Directory
            .GetFiles(conversationFolder, "*.json")
            .Select(LoadConversationForSelection)
            .Where(conversation => conversation is not null)
            .Where(conversation => !string.IsNullOrWhiteSpace(conversation!.Id))
            .ToList();

        if (candidates.Count == 0)
            return EnsureConversationIdFallback(fallbackPersonaName);

        var selected = candidates
            .OrderByDescending(c => c!.Messages.Count)
            .ThenByDescending(c => c!.UpdatedAt)
            .ThenByDescending(c => c!.Id, StringComparer.Ordinal)
            .FirstOrDefault();

        return selected?.Id
            ?? EnsureConversationIdFallback(fallbackPersonaName);
    }

    private static Conversation? LoadConversationForSelection(string conversationPath)
    {
        try
        {
            var content = File.ReadAllText(conversationPath);
            return JsonSerializer.Deserialize<Conversation>(content);
        }
        catch
        {
            return null;
        }
    }

    private static string EnsureConversationIdFallback(string fallbackPersonaName)
    {
        var fallback = NormalizePersonaNameForConversationId(fallbackPersonaName);
        if (!string.IsNullOrWhiteSpace(fallback) && fallback is not "kai")
            return fallback;

        if (!string.IsNullOrWhiteSpace(fallback))
            return fallback;

        return StableHashBasedConversationId(fallbackPersonaName, string.Empty);
    }

    private static string NormalizePersonaNameForConversationId(string? value)
    {
        var cleaned = new string((value ?? "kai")
            .Where(ch => ch is >= '0' and <= '9' || ch is >= 'A' and <= 'Z' || ch is >= 'a' and <= 'z' || ch == '_' || ch == '-')
            .ToArray());

        if (cleaned.Length > 1)
            return cleaned.Length > 64 ? cleaned[..64] : cleaned;

        return "kai";
    }

    private static string StableHashBasedConversationId(string fallback, string pathSeed)
    {
        var input = $"{fallback}:{pathSeed}:{DateTime.UtcNow:O}";
        var hash = SHA1.HashData(Encoding.UTF8.GetBytes(input));
        var id = BitConverter.ToString(hash).Replace("-", "", StringComparison.Ordinal).ToLowerInvariant();
        return id.Substring(0, 8);
    }
}

public sealed record KaiAutonomousAppendResult(
    bool Success,
    string Message,
    string? ConversationId = null,
    string? MessageId = null,
    bool Compacted = false,
    string? ArchivePath = null,
    GitResult? Commit = null)
{
    public static KaiAutonomousAppendResult Skipped(string message) => new(false, message);
}
Offline