← Back to Files
MemoryUpdateService.cs
using System.Text;
using System.Text.RegularExpressions;
using KaiChat.Models;
namespace KaiChat.Services;
public class MemoryUpdateService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<MemoryUpdateService> _logger;
private readonly object _statusGate = new();
private readonly SemaphoreSlim _runGate = new(1, 1);
private static readonly TimeSpan UserQuietPeriod = TimeSpan.FromHours(1);
private static readonly TimeSpan SchedulePollInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UpdateRunTimeout = TimeSpan.FromMinutes(10);
private static readonly TimeSpan AutomaticRunCooldown = TimeSpan.FromHours(1);
private const int MaxMessagesPerRun = 80;
private const int MaxTranscriptCharsPerRun = 50_000;
private const int MaxContentCharsPerMessage = 12_000;
private const int MaxThinkingCharsPerMessage = 4_000;
private const int MaxToolBodyCharsPerMessage = 4_000;
private const int MaxToolResultCharsPerMessage = 8_000;
private const int MaxConsecutiveFailuresBeforeFuse = 2;
private DateTime? _lastRunStartedUtc;
private DateTime? _lastRunCompletedUtc;
private bool _isRunning;
private bool? _lastRunChanged;
private int? _lastRunOperationCount;
private int? _lastRunSourceConversationCount;
private string _lastRunStatus = "Not run yet.";
private string _lastError = "";
private int _consecutiveFailureCount;
private bool _memoryUpdateFuseTripped;
private DateTime? _memoryUpdateFuseTrippedUtc;
private string _memoryUpdateFuseLastFailureReason = "";
public MemoryUpdateService(IServiceProvider services, ILogger<MemoryUpdateService> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation(
"Memory update service started (quiet period after latest user message: {QuietPeriod})",
UserQuietPeriod);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var delay = await GetDelayUntilNextEligibleUpdateAsync(stoppingToken);
if (delay > TimeSpan.Zero)
{
await Task.Delay(Min(delay, SchedulePollInterval), stoppingToken);
continue;
}
if (!await _runGate.WaitAsync(0, stoppingToken))
{
await Task.Delay(SchedulePollInterval, stoppingToken);
continue;
}
try
{
await RunMemoryUpdateWithHeldGateAsync("Automatic", stoppingToken);
}
finally
{
_runGate.Release();
}
await Task.Delay(SchedulePollInterval, stoppingToken);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
MarkRunFailed(ex);
_logger.LogError(ex, "Memory update cycle failed");
if (!stoppingToken.IsCancellationRequested)
await Task.Delay(SchedulePollInterval, stoppingToken);
}
}
}
public async Task<MemoryUpdateForceResult> ForceUpdateAsync(CancellationToken ct = default)
{
if (IsFuseTripped())
{
return new MemoryUpdateForceResult(
Started: false,
Message: "Memory update is halted by failure fuse. Reset it before forcing a run.",
Status: await GetStatusAsync(ct));
}
if (!await _runGate.WaitAsync(0, ct))
{
return new MemoryUpdateForceResult(
Started: false,
Message: "Memory update is already running.",
Status: await GetStatusAsync(ct));
}
MarkRunStarted("Manual memory update running...");
_ = Task.Run(async () =>
{
try
{
await RunMemoryUpdateWithHeldGateAsync("Manual", CancellationToken.None, startedAlready: true);
}
finally
{
_runGate.Release();
}
});
return new MemoryUpdateForceResult(
Started: true,
Message: "Memory update started.",
Status: await GetStatusAsync(ct));
}
public async Task<MemoryUpdateForceResult> ResetFuseAndRunAsync(CancellationToken ct = default)
{
_logger.LogInformation("Resetting memory update fuse and starting immediate manual run.");
ResetFuseInternal();
return await ForceUpdateAsync(ct);
}
private void ResetFuseInternal()
{
lock (_statusGate)
{
_memoryUpdateFuseTripped = false;
_consecutiveFailureCount = 0;
_memoryUpdateFuseTrippedUtc = null;
_memoryUpdateFuseLastFailureReason = "";
_lastError = "";
}
}
private async Task RunMemoryUpdateWithHeldGateAsync(
string trigger,
CancellationToken stoppingToken,
bool startedAlready = false)
{
if (!startedAlready)
MarkRunStarted($"{trigger} memory update running...");
CancellationTokenSource? runCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
runCts.CancelAfter(UpdateRunTimeout);
var updateTask = CheckAndUpdateMemoryAsync(runCts.Token);
try
{
var result = await updateTask.WaitAsync(UpdateRunTimeout, stoppingToken);
MarkRunCompleted(result);
}
catch (TimeoutException ex)
{
runCts.Cancel();
ObserveAbandonedRun(updateTask, runCts);
runCts = null;
MarkRunFailed(ex);
_logger.LogError(ex, "{Trigger} memory update timed out", trigger);
}
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested && runCts.IsCancellationRequested)
{
var timeout = new TimeoutException($"Memory update exceeded the {UpdateRunTimeout} run timeout.");
ObserveAbandonedRun(updateTask, runCts);
runCts = null;
MarkRunFailed(timeout);
_logger.LogError(timeout, "{Trigger} memory update timed out", trigger);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
MarkRunFailed(ex);
_logger.LogError(ex, "{Trigger} memory update failed", trigger);
}
finally
{
runCts?.Dispose();
}
}
private void ObserveAbandonedRun(Task<MemoryUpdateRunResult> updateTask, CancellationTokenSource runCts)
{
_ = updateTask.ContinueWith(
task =>
{
try
{
if (task.IsFaulted)
_logger.LogDebug(task.Exception, "Abandoned memory update task faulted after timeout");
else if (task.IsCompletedSuccessfully)
_logger.LogWarning("Abandoned memory update task completed after timeout and was ignored");
}
finally
{
runCts.Dispose();
}
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
public async Task<MemoryUpdateStatus> GetStatusAsync(CancellationToken ct = default)
{
var schedule = await GetScheduleAsync(ct);
using var scope = _services.CreateScope();
var memory = scope.ServiceProvider.GetRequiredService<IMemoryService>();
var marker = await memory.GetUpdateMarkerInfoAsync();
lock (_statusGate)
{
return new MemoryUpdateStatus(
NowUtc: DateTime.UtcNow,
QuietPeriod: UserQuietPeriod,
SchedulePollInterval: SchedulePollInterval,
UpdateRunTimeout: UpdateRunTimeout,
AutomaticRunCooldown: AutomaticRunCooldown,
MaxMessagesPerRun: MaxMessagesPerRun,
MaxTranscriptCharsPerRun: MaxTranscriptCharsPerRun,
LastUpdateUtc: schedule.LastUpdateUtc,
Marker: marker,
LatestPendingUserMessageUtc: schedule.LatestUserMessageUtc,
NextEligibleUpdateUtc: schedule.NextEligibleUpdateUtc,
DelayUntilEligible: schedule.Delay,
IsDue: schedule.Delay == TimeSpan.Zero,
IsRunning: _isRunning,
LastRunStartedUtc: _lastRunStartedUtc,
LastRunCompletedUtc: _lastRunCompletedUtc,
LastRunChanged: _lastRunChanged,
LastRunOperationCount: _lastRunOperationCount,
LastRunSourceConversationCount: _lastRunSourceConversationCount,
LastRunStatus: _lastRunStatus,
FuseIsTripped: _memoryUpdateFuseTripped,
FuseConsecutiveFailureCount: _consecutiveFailureCount,
FuseFailureThreshold: MaxConsecutiveFailuresBeforeFuse,
FuseTrippedUtc: _memoryUpdateFuseTrippedUtc,
FuseLastFailureReason: _memoryUpdateFuseLastFailureReason,
LastError: _lastError);
}
}
private async Task<TimeSpan> GetDelayUntilNextEligibleUpdateAsync(CancellationToken ct)
{
return (await GetScheduleAsync(ct, logDue: true)).Delay;
}
private async Task<MemoryUpdateSchedule> GetScheduleAsync(CancellationToken ct, bool logDue = false)
{
using var scope = _services.CreateScope();
var convService = scope.ServiceProvider.GetRequiredService<IConversationService>();
var memory = scope.ServiceProvider.GetRequiredService<IMemoryService>();
ct.ThrowIfCancellationRequested();
var marker = await memory.GetUpdateMarkerInfoAsync();
if (!marker.ParsedUtc.HasValue)
{
_logger.LogDebug("No memory update marker is present; no pending memory update");
return new MemoryUpdateSchedule(
LastUpdateUtc: DateTime.MinValue,
LatestUserMessageUtc: null,
NextEligibleUpdateUtc: null,
Delay: SchedulePollInterval);
}
var lastUpdate = NormalizeUtc(marker.ParsedUtc.Value);
var latestUserMessage = FindLatestUserMessageAfter(convService.ListAllConversations(), lastUpdate);
if (!latestUserMessage.HasValue)
{
_logger.LogDebug("No user messages pending memory update since {LastUpdate}", lastUpdate);
if (logDue && marker.Exists)
{
var git = scope.ServiceProvider.GetRequiredService<IGitService>();
await ClearUnusedMarkerAsync(
memory,
git,
$"No user messages were newer than the marker timestamp {FormatCommitTime(lastUpdate)}.",
ct);
}
return new MemoryUpdateSchedule(
LastUpdateUtc: lastUpdate,
LatestUserMessageUtc: null,
NextEligibleUpdateUtc: null,
Delay: SchedulePollInterval);
}
if (IsFuseTripped())
{
return new MemoryUpdateSchedule(
LastUpdateUtc: lastUpdate,
LatestUserMessageUtc: latestUserMessage,
NextEligibleUpdateUtc: null,
Delay: SchedulePollInterval);
}
var dueAt = latestUserMessage.Value + UserQuietPeriod;
var now = DateTime.UtcNow;
if (dueAt <= now)
{
var cooldownDelay = GetAutomaticCooldownDelay(marker.LastWriteTimeUtc);
if (cooldownDelay > TimeSpan.Zero)
{
var nextRun = now + cooldownDelay;
_logger.LogDebug(
"Memory update is due but waiting for automatic cooldown until {NextRun}",
nextRun);
return new MemoryUpdateSchedule(lastUpdate, latestUserMessage, nextRun, cooldownDelay);
}
if (logDue)
{
_logger.LogInformation(
"Memory update due; latest user message was {LatestUserMessage}",
latestUserMessage.Value);
}
return new MemoryUpdateSchedule(lastUpdate, latestUserMessage, dueAt, TimeSpan.Zero);
}
var delay = dueAt - now;
_logger.LogDebug(
"Memory update waiting until {DueAt}; latest user message was {LatestUserMessage}",
dueAt,
latestUserMessage.Value);
return new MemoryUpdateSchedule(lastUpdate, latestUserMessage, dueAt, delay);
}
private TimeSpan GetAutomaticCooldownDelay(DateTime? markerLastWriteUtc)
{
DateTime? lastRunCompletedUtc;
lock (_statusGate)
{
lastRunCompletedUtc = _lastRunCompletedUtc;
}
return GetAutomaticCooldownDelay(markerLastWriteUtc, lastRunCompletedUtc, DateTime.UtcNow);
}
private static TimeSpan GetAutomaticCooldownDelay(
DateTime? markerLastWriteUtc,
DateTime? lastRunCompletedUtc,
DateTime nowUtc)
{
var latestCompletionUtc = LatestUtc(markerLastWriteUtc, lastRunCompletedUtc);
if (!latestCompletionUtc.HasValue)
return TimeSpan.Zero;
var nextRunUtc = latestCompletionUtc.Value + AutomaticRunCooldown;
var delay = nextRunUtc - NormalizeUtc(nowUtc);
return delay > TimeSpan.Zero ? delay : TimeSpan.Zero;
}
private async Task<MemoryUpdateRunResult> CheckAndUpdateMemoryAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var convService = scope.ServiceProvider.GetRequiredService<IConversationService>();
var memory = scope.ServiceProvider.GetRequiredService<IMemoryService>();
var memoryManager = scope.ServiceProvider.GetRequiredService<IMemoryManagementService>();
var git = scope.ServiceProvider.GetRequiredService<IGitService>();
var marker = await memory.GetUpdateMarkerInfoAsync();
if (!marker.ParsedUtc.HasValue)
{
_logger.LogDebug("No memory update marker is present; nothing to update");
return new MemoryUpdateRunResult(
Attempted: false,
Success: true,
Changed: false,
OperationCount: 0,
SourceConversationCount: 0,
Status: "No pending memory update marker is present.");
}
var lastUpdate = NormalizeUtc(marker.ParsedUtc.Value);
// NOTE: Use ListAllConversations to scan all persona directories.
// Memory is persona-scoped but the marker is global.
// We enter the "Kai" persona scope for memory ops; a future
// multi-persona enhancement would run memory update per-persona.
using var personaScope = memory.EnterPersonaScope("Kai");
var conversations = convService.ListAllConversations();
var existingMemoryPaths = (await memory.ListMemoryFilesAsync(includeTrash: false))
.Select(f => f.Path)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var pendingMessages = conversations
.SelectMany(c => c.Messages
.Where(HasMemoryUpdateContent)
.Select(m => new MemoryUpdateCandidate(c, m, NormalizeUtc(m.Timestamp))))
.Where(m => m.TimestampUtc > lastUpdate)
.OrderBy(m => m.TimestampUtc)
.ToList();
if (pendingMessages.Count == 0)
{
_logger.LogDebug("No new activity since {LastUpdate}", lastUpdate);
await ClearUnusedMarkerAsync(
memory,
git,
$"Manual memory update found no messages newer than the marker timestamp {FormatCommitTime(lastUpdate)}.",
ct);
return new MemoryUpdateRunResult(
Attempted: true,
Success: true,
Changed: false,
OperationCount: 0,
SourceConversationCount: 0,
Status: $"No new activity since {FormatCommitTime(lastUpdate)}; unused marker cleared if it was tracked.");
}
var totalPendingConversationCount = pendingMessages
.Select(m => m.Conversation.Id)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
var selectedMessages = SelectMemoryUpdateBatch(pendingMessages, out var truncated);
var processedThroughUtc = selectedMessages[^1].TimestampUtc;
var markerThroughUtc = truncated && processedThroughUtc > DateTime.MinValue
? processedThroughUtc.AddTicks(-1)
: processedThroughUtc;
var sourceConvs = selectedMessages
.GroupBy(m => m.Conversation.Id, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First().Conversation)
.ToList();
_logger.LogInformation(
"Found {MessageCount} pending memory message(s) across {ConversationCount} conversation(s) since {LastUpdate}; processing {SelectedCount}",
pendingMessages.Count,
totalPendingConversationCount,
lastUpdate,
selectedMessages.Count);
// Build a detailed recent-activity transcript for the memory librarian.
var summaryBuilder = new StringBuilder();
summaryBuilder.AppendLine("Recent KaiChat activity with Raymond:");
summaryBuilder.AppendLine($"Only messages newer than the last memory update ({FormatCommitTime(lastUpdate)}) are included when timestamps are available.");
summaryBuilder.AppendLine($"This run includes {selectedMessages.Count} of {pendingMessages.Count} pending message(s), through {FormatCommitTime(processedThroughUtc)}.");
if (truncated)
summaryBuilder.AppendLine("There is more pending activity after this batch. Do not treat the end of this transcript as the end of the current conversation history.");
summaryBuilder.AppendLine();
foreach (var group in selectedMessages.GroupBy(m => m.Conversation.Id, StringComparer.OrdinalIgnoreCase))
{
var conv = group.First().Conversation;
var block = new StringBuilder();
block.AppendLine($"--- {conv.Title} ({conv.Id}), updated {FormatCommitTime(conv.UpdatedAt)} ---");
foreach (var msg in group.OrderBy(m => m.TimestampUtc).Select(m => m.Message))
{
AppendMessageForMemoryUpdate(block, msg);
}
summaryBuilder.Append(block);
}
_logger.LogInformation("Requesting memory file updates from LLM...");
var result = await memoryManager.ManageAsync($"""
Integrate these recent KaiChat conversations into the memory directory.
Use the same broad-category memory rules as the historical rebuild: preserve durable specifics, avoid tiny files, avoid duplicate facts, and keep all still-valid details when saving a complete file.
The current memory files may already include AI-added memories from this same interval. Treat those as existing state; do not duplicate them. Only add missing durable details, materially changed context, or corrections.
Update `recent.md` for active state, update `history.md` for durable timeline milestones, and update topic files for durable facts.
If this batch refines or corrects something that an earlier batch already saved, update every stale version of that same point in the loaded memory files during this run. Do not assume a later memory update will reconcile old wording.
Prefer replacing an older instruction with the refined version over adding a second parallel bullet.
If nothing durable or currently useful happened, use noop.
{summaryBuilder}
""", ct);
ct.ThrowIfCancellationRequested();
if (!ShouldAdvanceMarker(result))
{
_logger.LogWarning("Memory manager returned no successful operation; marker was not advanced");
return new MemoryUpdateRunResult(
Attempted: true,
Success: false,
Changed: false,
OperationCount: result.Operations.Count,
SourceConversationCount: sourceConvs.Count,
Status: $"Memory update returned no successful operations after processing {selectedMessages.Count} message(s); marker was not advanced.");
}
var hasNewerPendingMessage = FindLatestUserMessageAfter(convService.ListAllConversations(), processedThroughUtc).HasValue;
var markerAction = "cleared";
if (truncated || hasNewerPendingMessage)
{
await memory.SetLastUpdateAsync(markerThroughUtc);
markerAction = "advanced";
}
else
{
await memory.DeleteUpdateMarkerAsync();
}
ct.ThrowIfCancellationRequested();
if (result.Changed)
{
_logger.LogInformation("Memory updated with {Count} operations", result.Operations.Count);
var commit = await CommitMemoryUpdateAsync(
git,
memory.MemoryDirectoryRelativePath,
memory.UpdateMarkerRelativePath,
result,
existingMemoryPaths,
sourceConvs,
totalPendingConversationCount,
lastUpdate);
if (commit.Success)
_logger.LogInformation("Committed idle memory update: {Output}", commit.Output);
else
_logger.LogWarning("Idle memory update commit failed: {Error}", commit.Error);
return new MemoryUpdateRunResult(
Attempted: true,
Success: commit.Success,
Changed: true,
OperationCount: result.Operations.Count,
SourceConversationCount: sourceConvs.Count,
Status: commit.Success
? $"Memory changed after processing {selectedMessages.Count} message(s) through {FormatCommitTime(processedThroughUtc)}{PendingSuffix(truncated)}; commit result: {TrimForCommit(commit.Output, 300)}"
: $"Memory changed after processing {selectedMessages.Count} message(s) through {FormatCommitTime(processedThroughUtc)}{PendingSuffix(truncated)} but commit failed: {TrimForCommit(commit.Error, 300)}");
}
else
{
_logger.LogInformation("No noteworthy content to save to memory");
var markerCommit = await CommitMarkerStateAsync(
git,
memory.UpdateMarkerRelativePath,
$"Memory update marker {markerAction}",
$"Memory updater processed {selectedMessages.Count} message(s) through {FormatCommitTime(processedThroughUtc)} with no markdown memory changes.");
var markerCommitSucceeded = markerCommit.Success;
return new MemoryUpdateRunResult(
Attempted: true,
Success: markerCommitSucceeded,
Changed: false,
OperationCount: result.Operations.Count,
SourceConversationCount: sourceConvs.Count,
Status: $"LLM returned no memory changes after processing {selectedMessages.Count} message(s) through {FormatCommitTime(processedThroughUtc)}{PendingSuffix(truncated)}; marker {markerAction}; commit result: {TrimForCommit(GitOutputOrError(markerCommit), 300)}");
}
}
private void MarkRunStarted(string status = "Running memory update...")
{
lock (_statusGate)
{
_isRunning = true;
_lastRunStartedUtc = DateTime.UtcNow;
_lastRunCompletedUtc = null;
_lastRunChanged = null;
_lastRunOperationCount = null;
_lastRunSourceConversationCount = null;
_lastRunStatus = status;
_lastError = "";
}
}
private async Task ClearUnusedMarkerAsync(
IMemoryService memory,
IGitService git,
string reason,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
await memory.DeleteUpdateMarkerAsync();
ct.ThrowIfCancellationRequested();
var commit = await CommitMarkerStateAsync(
git,
memory.UpdateMarkerRelativePath,
"Memory update marker cleared",
reason);
if (!commit.Success)
_logger.LogWarning("Failed to commit unused memory marker deletion: {Error}", GitOutputOrError(commit));
else if (!string.Equals(commit.Output, "Nothing to commit.", StringComparison.OrdinalIgnoreCase))
_logger.LogInformation("Committed unused memory marker deletion: {Output}", commit.Output);
}
private void MarkRunCompleted(MemoryUpdateRunResult result)
{
lock (_statusGate)
{
_isRunning = false;
_lastRunCompletedUtc = DateTime.UtcNow;
_lastRunChanged = result.Changed;
_lastRunOperationCount = result.OperationCount;
_lastRunSourceConversationCount = result.SourceConversationCount;
_lastRunStatus = result.Status;
_lastError = result.Success ? "" : result.Status;
}
HandleRunAttemptOutcome(result.Success, result.Status);
}
private void MarkRunFailed(Exception ex)
{
lock (_statusGate)
{
_isRunning = false;
_lastRunCompletedUtc = DateTime.UtcNow;
_lastRunStatus = "Memory update failed.";
_lastError = ex.Message;
}
HandleRunAttemptOutcome(false, ex.Message);
}
private void HandleRunAttemptOutcome(bool success, string reason)
{
lock (_statusGate)
{
if (success)
{
_consecutiveFailureCount = 0;
_memoryUpdateFuseTripped = false;
_memoryUpdateFuseTrippedUtc = null;
_memoryUpdateFuseLastFailureReason = "";
return;
}
_consecutiveFailureCount++;
_memoryUpdateFuseLastFailureReason = reason;
if (_consecutiveFailureCount >= MaxConsecutiveFailuresBeforeFuse)
{
_memoryUpdateFuseTripped = true;
_memoryUpdateFuseTrippedUtc ??= DateTime.UtcNow;
_logger.LogWarning(
"Memory update fuse tripped after {FailureCount} consecutive failures",
_consecutiveFailureCount);
}
}
}
private bool IsFuseTripped()
{
lock (_statusGate)
return _memoryUpdateFuseTripped;
}
private static async Task<GitResult> CommitMemoryUpdateAsync(
IGitService git,
string memoryDirectoryRelativePath,
string updateMarkerRelativePath,
MemoryManagementResult result,
HashSet<string> existingMemoryPaths,
IReadOnlyList<Conversation> sourceConvs,
int totalRecentConversationCount,
DateTime lastUpdate)
{
var stage = await git.StagePathAsync(memoryDirectoryRelativePath);
if (!stage.Success)
return stage;
var markerStage = await git.StagePathAsync(updateMarkerRelativePath);
if (!markerStage.Success)
return markerStage;
var status = await git.StatusAsync();
if (!status.Success)
return status;
if (!HasStagedPathChange(status.Output, memoryDirectoryRelativePath))
return new GitResult { Success = true, Output = "Nothing to commit." };
var body = BuildMemoryUpdateCommitBody(
result,
existingMemoryPaths,
sourceConvs,
totalRecentConversationCount,
lastUpdate);
return await git.CommitAsync("Memory update: one-hour idle KaiChat sync", body);
}
private static async Task<GitResult> CommitMarkerStateAsync(
IGitService git,
string updateMarkerRelativePath,
string message,
string body)
{
var stage = await git.StagePathAsync(updateMarkerRelativePath);
if (!stage.Success)
return stage;
var status = await git.StatusAsync();
if (!status.Success)
return status;
if (!HasStagedPathChange(status.Output, updateMarkerRelativePath))
return new GitResult { Success = true, Output = "Nothing to commit." };
return await git.CommitAsync(message, body);
}
private static string BuildMemoryUpdateCommitBody(
MemoryManagementResult result,
HashSet<string> existingMemoryPaths,
IReadOnlyList<Conversation> sourceConvs,
int totalRecentConversationCount,
DateTime lastUpdate)
{
var successfulWrites = result.Operations
.Where(o => o.Success && IsWriteAction(o.Action))
.ToList();
var added = successfulWrites
.Where(o => !existingMemoryPaths.Contains(o.Path))
.ToList();
var changed = successfulWrites
.Where(o => existingMemoryPaths.Contains(o.Path))
.ToList();
var removed = result.Operations
.Where(o => o.Success && IsDeleteAction(o.Action))
.ToList();
var failed = result.Operations
.Where(o => !o.Success)
.ToList();
var noops = result.Operations
.Where(o => o.Success && IsNoopAction(o.Action))
.ToList();
var builder = new StringBuilder();
builder.AppendLine("Memory change audit:");
builder.AppendLine($"- Added: {added.Count}; changed: {changed.Count}; moved: 0; removed: {removed.Count}; loss risks: {failed.Count}");
builder.AppendLine($"- Summary: {BuildCommitSummary(sourceConvs, totalRecentConversationCount, lastUpdate, successfulWrites.Count, removed.Count, failed.Count)}");
AppendCommitBullets(builder, "Added", added.Select(DescribeOperation));
AppendCommitBullets(builder, "Changed", changed.Select(DescribeOperation));
AppendCommitBullets(builder, "Removed", removed.Select(o => $"{o.Path} [{NormalizeAction(o.Action)}]: {OperationReason(o)}"));
AppendCommitBullets(builder, "Loss risks", failed.Select(o => $"{OperationPathOrAction(o)}: {OperationReason(o)}"));
var notes = new List<string>
{
$"Processed {sourceConvs.Count} of {totalRecentConversationCount} conversation(s) with activity since {FormatCommitTime(lastUpdate)}."
};
notes.AddRange(sourceConvs.Select(c => $"Source conversation: {c.Title} ({c.Id}), updated {FormatCommitTime(c.UpdatedAt)}."));
notes.AddRange(noops.Select(o => $"Noop: {OperationReason(o)}"));
AppendCommitBullets(builder, "Notes", notes);
return builder.ToString().Trim();
}
private static string BuildCommitSummary(
IReadOnlyList<Conversation> sourceConvs,
int totalRecentConversationCount,
DateTime lastUpdate,
int writeCount,
int removeCount,
int failedCount)
{
var sourceText = sourceConvs.Count == totalRecentConversationCount
? $"{sourceConvs.Count} conversation(s)"
: $"{sourceConvs.Count} of {totalRecentConversationCount} conversation(s)";
return TrimForCommit(
$"One-hour idle memory update integrated recent KaiChat activity from {sourceText} since {FormatCommitTime(lastUpdate)}. Applied {writeCount} write operation(s), {removeCount} removal(s), and recorded {failedCount} loss risk(s).",
300);
}
private static string DescribeOperation(MemoryOperationResult operation)
{
return $"{operation.Path}: {OperationReason(operation)}";
}
private static string OperationReason(MemoryOperationResult operation)
{
return TrimForCommit(
string.IsNullOrWhiteSpace(operation.Reason)
? operation.Message
: operation.Reason,
240);
}
private static string OperationPathOrAction(MemoryOperationResult operation)
{
return string.IsNullOrWhiteSpace(operation.Path)
? NormalizeAction(operation.Action)
: operation.Path;
}
private static string GitOutputOrError(GitResult result)
{
return string.IsNullOrWhiteSpace(result.Output)
? result.Error
: result.Output;
}
private static bool HasStagedPathChange(string statusOutput, string repoRelativePath)
{
var normalizedPath = repoRelativePath.Replace('\\', '/').TrimEnd('/');
return statusOutput
.Replace('\\', '/')
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Any(line => line.Length >= 4
&& line[0] != ' '
&& line[0] != '?'
&& IsStatusPathMatch(line[3..].Trim('"'), normalizedPath));
}
private static bool IsStatusPathMatch(string statusPath, string repoRelativePath)
{
return statusPath.Equals(repoRelativePath, StringComparison.OrdinalIgnoreCase)
|| statusPath.StartsWith(repoRelativePath.TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase);
}
private static bool IsWriteAction(string action)
{
return NormalizeAction(action) is "save" or "write" or "upsert";
}
private static bool IsDeleteAction(string action)
{
return NormalizeAction(action) is "soft_delete" or "delete";
}
private static bool IsNoopAction(string action)
{
return NormalizeAction(action) is "noop" or "none";
}
private static bool ShouldAdvanceMarker(MemoryManagementResult result)
{
return result.Operations.Any(o => o.Success);
}
private static DateTime? FindLatestUserMessageAfter(IEnumerable<Conversation> conversations, DateTime lastUpdate)
{
DateTime? latest = null;
foreach (var message in conversations.SelectMany(c => c.Messages))
{
if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(message.Content))
continue;
var timestamp = NormalizeUtc(message.Timestamp);
if (timestamp <= lastUpdate)
continue;
if (!latest.HasValue || timestamp > latest.Value)
latest = timestamp;
}
return latest;
}
private static bool HasMemoryUpdateContent(ChatMessage message)
{
return !string.IsNullOrWhiteSpace(message.Content)
|| !string.IsNullOrWhiteSpace(message.Thinking)
|| message.Tool is not null
|| message.Compaction is not null;
}
private static IReadOnlyList<MemoryUpdateCandidate> SelectMemoryUpdateBatch(
IReadOnlyList<MemoryUpdateCandidate> pendingMessages,
out bool truncated)
{
var selected = new List<MemoryUpdateCandidate>();
var estimatedChars = 0;
foreach (var pending in pendingMessages)
{
var nextEstimate = EstimateMessageTranscriptChars(pending.Message);
var wouldExceedMessageLimit = selected.Count >= MaxMessagesPerRun;
var wouldExceedCharLimit = selected.Count > 0
&& estimatedChars + nextEstimate > MaxTranscriptCharsPerRun;
if (wouldExceedMessageLimit || wouldExceedCharLimit)
break;
selected.Add(pending);
estimatedChars += nextEstimate;
}
if (selected.Count == 0 && pendingMessages.Count > 0)
selected.Add(pendingMessages[0]);
truncated = selected.Count < pendingMessages.Count;
return selected;
}
private static int EstimateMessageTranscriptChars(ChatMessage message)
{
var estimate = 128
+ (message.Role?.Length ?? 0)
+ EstimateTrimmedChars(message.Content, MaxContentCharsPerMessage)
+ EstimateTrimmedChars(message.Thinking, MaxThinkingCharsPerMessage);
if (message.Tool is not null)
{
estimate += 128
+ (message.Tool.Method?.Length ?? 0)
+ (message.Tool.Path?.Length ?? 0)
+ EstimateTrimmedChars(message.Tool.Body, MaxToolBodyCharsPerMessage)
+ (message.Tool.Status?.Length ?? 0)
+ EstimateTrimmedChars(message.Tool.Result, MaxToolResultCharsPerMessage);
}
if (message.Compaction is not null)
estimate += 256;
return estimate;
}
private static int EstimateTrimmedChars(string? value, int maxChars)
{
if (string.IsNullOrWhiteSpace(value))
return 0;
return Math.Min(value.Trim().Length, maxChars) + 64;
}
private static void AppendMessageForMemoryUpdate(StringBuilder builder, ChatMessage message)
{
builder.AppendLine($"[{FormatCommitTime(message.Timestamp)}] [{message.Role}]");
if (!string.IsNullOrWhiteSpace(message.Content))
{
AppendTrimmedText(builder, message.Content, MaxContentCharsPerMessage);
builder.AppendLine();
}
if (!string.IsNullOrWhiteSpace(message.Thinking))
{
builder.AppendLine("[thinking]");
AppendTrimmedText(builder, message.Thinking, MaxThinkingCharsPerMessage);
builder.AppendLine();
}
if (message.Tool is not null)
{
builder.AppendLine("[tool]");
builder.AppendLine($"round: {message.Tool.Round}");
builder.AppendLine($"request: {message.Tool.Method} {message.Tool.Path}");
if (!string.IsNullOrWhiteSpace(message.Tool.Body))
{
builder.AppendLine("parameters:");
AppendTrimmedText(builder, message.Tool.Body, MaxToolBodyCharsPerMessage);
}
builder.AppendLine($"status: {message.Tool.Status}");
builder.AppendLine($"is_error: {message.Tool.IsError}");
if (!string.IsNullOrWhiteSpace(message.Tool.Result))
{
builder.AppendLine("result:");
AppendTrimmedText(builder, message.Tool.Result, MaxToolResultCharsPerMessage);
}
builder.AppendLine();
}
if (message.Compaction is not null)
{
builder.AppendLine("[compaction]");
builder.AppendLine($"number: {message.Compaction.CompactionNumber}");
builder.AppendLine($"archive: {message.Compaction.ArchivePath}");
builder.AppendLine($"previous_archive: {message.Compaction.PreviousArchivePath}");
builder.AppendLine($"messages_to_compact: {message.Compaction.MessagesToCompact}");
builder.AppendLine($"messages_to_keep: {message.Compaction.MessagesToKeep}");
builder.AppendLine($"chunks: {message.Compaction.ChunkCount}");
builder.AppendLine($"estimated_api_tokens: {message.Compaction.EstimatedApiTokens}");
builder.AppendLine($"estimated_post_compaction_api_tokens: {message.Compaction.EstimatedPostCompactionApiTokens}");
builder.AppendLine($"started_at: {FormatCommitTime(message.Compaction.StartedAt)}");
builder.AppendLine($"completed_at: {FormatCommitTime(message.Compaction.CompletedAt)}");
builder.AppendLine();
}
}
private static void AppendTrimmedText(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 NormalizeAction(string action)
{
return action.Trim().ToLowerInvariant();
}
private static void AppendCommitBullets(StringBuilder builder, string title, IEnumerable<string?> lines)
{
var usable = lines
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => TrimForCommit(line!, 240))
.Take(5)
.ToList();
if (usable.Count == 0)
return;
builder.AppendLine();
builder.AppendLine($"{title}:");
foreach (var line in usable)
builder.AppendLine($"- {line}");
}
private static string TrimForCommit(string text, int maxLength)
{
var compact = Regex.Replace(text.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' '), @"\s+", " ").Trim();
return compact.Length <= maxLength ? compact : compact[..Math.Max(0, maxLength - 3)] + "...";
}
private static string FormatCommitTime(DateTime value)
{
return value == DateTime.MinValue
? "the beginning"
: value.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
private static string PendingSuffix(bool truncated)
{
return truncated ? " with more pending" : "";
}
private static DateTime NormalizeUtc(DateTime value)
{
return value == DateTime.MinValue
? DateTime.MinValue
: value.ToUniversalTime();
}
private static DateTime? LatestUtc(DateTime? left, DateTime? right)
{
if (!left.HasValue)
return right.HasValue ? NormalizeUtc(right.Value) : null;
if (!right.HasValue)
return NormalizeUtc(left.Value);
var normalizedLeft = NormalizeUtc(left.Value);
var normalizedRight = NormalizeUtc(right.Value);
return normalizedLeft >= normalizedRight ? normalizedLeft : normalizedRight;
}
private static TimeSpan Min(TimeSpan left, TimeSpan right)
{
return left <= right ? left : right;
}
private sealed record MemoryUpdateCandidate(
Conversation Conversation,
ChatMessage Message,
DateTime TimestampUtc);
}
public record MemoryUpdateStatus(
DateTime NowUtc,
TimeSpan QuietPeriod,
TimeSpan SchedulePollInterval,
TimeSpan UpdateRunTimeout,
TimeSpan AutomaticRunCooldown,
int MaxMessagesPerRun,
int MaxTranscriptCharsPerRun,
DateTime LastUpdateUtc,
MemoryUpdateMarkerInfo Marker,
DateTime? LatestPendingUserMessageUtc,
DateTime? NextEligibleUpdateUtc,
TimeSpan DelayUntilEligible,
bool IsDue,
bool IsRunning,
DateTime? LastRunStartedUtc,
DateTime? LastRunCompletedUtc,
bool? LastRunChanged,
int? LastRunOperationCount,
int? LastRunSourceConversationCount,
string LastRunStatus,
bool FuseIsTripped,
int FuseConsecutiveFailureCount,
int FuseFailureThreshold,
DateTime? FuseTrippedUtc,
string FuseLastFailureReason,
string LastError);
public record MemoryUpdateForceResult(
bool Started,
string Message,
MemoryUpdateStatus Status);
record MemoryUpdateSchedule(
DateTime LastUpdateUtc,
DateTime? LatestUserMessageUtc,
DateTime? NextEligibleUpdateUtc,
TimeSpan Delay);
record MemoryUpdateRunResult(
bool Attempted,
bool Success,
bool Changed,
int OperationCount,
int SourceConversationCount,
string Status);