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