← Back to Files
KaiTimerService.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace KaiChat.Services;
public interface IKaiTimerService
{
Task<KaiTimerState> GetStateAsync(CancellationToken ct = default);
Task<KaiTimerMilestonePromptContext> GetMilestonePromptContextAsync(CancellationToken ct = default);
Task<KaiTimerOperationResult> StartAsync(CancellationToken ct = default);
Task<KaiTimerOperationResult> PauseAsync(CancellationToken ct = default);
Task<KaiTimerOperationResult> ResetAsync(CancellationToken ct = default);
Task<KaiTimerOperationResult> CreateWheelSegmentAsync(KaiWheelSegmentCreateRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> UpdateWheelSegmentAsync(KaiWheelSegmentUpdateRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> DeleteWheelSegmentAsync(KaiWheelSegmentDeleteRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> SpinWheelAsync(CancellationToken ct = default);
Task<KaiTimerOperationResult> CreateCounterAsync(KaiCounterCreateRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> UpdateCounterAsync(KaiCounterUpdateRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> IncrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> DecrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> DeleteCounterAsync(KaiCounterDeleteRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> CreateMilestoneAsync(KaiMilestoneCreateRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> UpdateMilestoneAsync(KaiMilestoneUpdateRequest request, CancellationToken ct = default);
Task<KaiTimerOperationResult> DeleteMilestoneAsync(KaiMilestoneDeleteRequest request, CancellationToken ct = default);
string FormatStateForTool(KaiTimerState state);
string FormatResultForTool(KaiTimerOperationResult result);
string FormatMilestonePromptForSystem(KaiTimerMilestonePromptContext context);
}
public sealed class KaiTimerService : IKaiTimerService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _path;
private const int MaxRetries = 3;
private static readonly TimeSpan[] RetryDelays = [
TimeSpan.FromMilliseconds(50),
TimeSpan.FromMilliseconds(150),
TimeSpan.FromMilliseconds(300)
];
public KaiTimerService(IConfiguration configuration)
{
var baseFolder = configuration.GetValue<string>("KaiChat:BaseFolder") ?? ".";
_path = Path.Combine(baseFolder, ".kaichat-timer.json");
}
public async Task<KaiTimerState> GetStateAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
PopulateComputedState(state, DateTime.UtcNow);
return state;
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerMilestonePromptContext> GetMilestonePromptContextAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var due = state.Milestones
.Where(m => m.Enabled && m.ThresholdSeconds <= state.ElapsedSeconds && !m.LastPromptedAt.HasValue)
.OrderBy(m => m.ThresholdSeconds)
.ToList();
if (due.Count == 0)
return new KaiTimerMilestonePromptContext(false, state, []);
foreach (var milestone in due)
{
milestone.TriggeredAt ??= now;
milestone.LastPromptedAt = now;
milestone.UpdatedAt = now;
milestone.IsDue = false;
milestone.WasPrompted = true;
AddEvent(state, "milestone_output_due", $"Milestone output queued: {milestone.Name}", now);
}
state.UpdatedAt = now;
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return new KaiTimerMilestonePromptContext(true, state, due);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> StartAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
if (state.IsRunning)
return KaiTimerOperationResult.Ok("Timer is already running.", state);
state.IsRunning = true;
state.StartedAt = now;
state.LastStartedAt = now;
state.UpdatedAt = now;
AddEvent(state, "started", "Timer started.", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok("Started timer.", state);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> PauseAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
if (!state.IsRunning)
return KaiTimerOperationResult.Ok("Timer is already paused.", state);
state.AccumulatedSeconds = state.ElapsedSeconds;
state.IsRunning = false;
state.StartedAt = null;
state.LastPausedAt = now;
state.UpdatedAt = now;
AddEvent(state, "paused", "Timer paused.", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok("Paused timer.", state);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> ResetAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
state.AccumulatedSeconds = 0;
state.ElapsedSeconds = 0;
state.DisplayElapsed = FormatDuration(0);
state.IsRunning = false;
state.StartedAt = null;
state.LastResetAt = now;
state.UpdatedAt = now;
foreach (var milestone in state.Milestones)
{
milestone.TriggeredAt = null;
milestone.LastPromptedAt = null;
milestone.UpdatedAt = now;
}
AddEvent(state, "reset", "Timer reset.", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok("Reset timer.", state);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> CreateWheelSegmentAsync(KaiWheelSegmentCreateRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var text = (request.Text ?? "").Trim();
if (string.IsNullOrWhiteSpace(text))
return KaiTimerOperationResult.Fail("Wheel segment text is required.", state);
var segment = new KaiWheelSegment
{
Id = CreateId(),
Text = text,
Weight = ClampWeight(request.Weight),
Enabled = request.Enabled,
CreatedAt = now,
UpdatedAt = now
};
state.WheelSegments.Add(segment);
state.UpdatedAt = now;
AddEvent(state, "wheel_segment_created", $"Wheel segment added: {segment.Text}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Added wheel segment '{segment.Text}'.", state, segment);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> UpdateWheelSegmentAsync(KaiWheelSegmentUpdateRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var segment = state.WheelSegments.FirstOrDefault(s => SameId(s.Id, request.Id));
if (segment == null)
return KaiTimerOperationResult.Fail($"Wheel segment '{request.Id}' was not found.", state);
if (request.Text != null)
{
var text = request.Text.Trim();
if (string.IsNullOrWhiteSpace(text))
return KaiTimerOperationResult.Fail("Wheel segment text cannot be empty.", state);
segment.Text = text;
}
if (request.Weight.HasValue)
segment.Weight = ClampWeight(request.Weight.Value);
if (request.Enabled.HasValue)
segment.Enabled = request.Enabled.Value;
segment.UpdatedAt = now;
state.UpdatedAt = now;
AddEvent(state, "wheel_segment_updated", $"Wheel segment updated: {segment.Text}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Updated wheel segment '{segment.Text}'.", state, segment);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> DeleteWheelSegmentAsync(KaiWheelSegmentDeleteRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var segment = state.WheelSegments.FirstOrDefault(s => SameId(s.Id, request.Id));
if (segment == null)
return KaiTimerOperationResult.Fail($"Wheel segment '{request.Id}' was not found.", state);
state.WheelSegments.Remove(segment);
state.UpdatedAt = now;
AddEvent(state, "wheel_segment_deleted", $"Wheel segment removed: {segment.Text}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Removed wheel segment '{segment.Text}'.", state, segment);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> SpinWheelAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var segments = state.WheelSegments
.Where(s => s.Enabled)
.ToList();
if (segments.Count == 0)
return KaiTimerOperationResult.Fail("The wheel has no enabled segments to spin.", state);
var totalWeight = segments.Sum(s => Math.Max(1, s.Weight));
var draw = RandomNumberGenerator.GetInt32(totalWeight);
var cursor = 0;
var selected = segments[^1];
foreach (var segment in segments)
{
cursor += Math.Max(1, segment.Weight);
if (draw < cursor)
{
selected = segment;
break;
}
}
selected.LastSelectedAt = now;
selected.SelectionCount++;
selected.UpdatedAt = now;
state.LastWheelSpinAt = now;
state.UpdatedAt = now;
AddEvent(state, "wheel_spun", $"Wheel spun: {selected.Text}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Wheel result for Raymond: {selected.Text}", state, selected);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> CreateCounterAsync(KaiCounterCreateRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var name = (request.Name ?? "").Trim();
if (string.IsNullOrWhiteSpace(name))
return KaiTimerOperationResult.Fail("Counter name is required.", state);
var mode = NormalizeCounterMode(request.Mode);
var automaticKind = NormalizeAutomaticKind(request.AutomaticKind);
if (mode == KaiCounterModes.Automatic && string.IsNullOrWhiteSpace(automaticKind))
return KaiTimerOperationResult.Fail("Automatic counters require an automaticKind.", state);
if (mode == KaiCounterModes.Automatic && !IsSupportedAutomaticCounterKind(automaticKind))
return KaiTimerOperationResult.Fail($"Unsupported automatic counter kind '{automaticKind}'.", state);
var counter = new KaiCounter
{
Id = CreateId(),
Name = name,
Mode = mode,
AutomaticKind = mode == KaiCounterModes.Automatic ? automaticKind : null,
Value = mode == KaiCounterModes.Manual ? request.Value : 0,
Active = request.Active,
CreatedAt = now,
UpdatedAt = now,
LastChangedAt = now
};
state.Counters.Add(counter);
state.UpdatedAt = now;
AddEvent(state, "counter_created", $"Counter created: {counter.Name}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Created counter '{counter.Name}'.", state, counter);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> UpdateCounterAsync(KaiCounterUpdateRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var counter = state.Counters.FirstOrDefault(c => SameId(c.Id, request.Id));
if (counter == null)
return KaiTimerOperationResult.Fail($"Counter '{request.Id}' was not found.", state);
if (request.Name != null)
{
var name = request.Name.Trim();
if (string.IsNullOrWhiteSpace(name))
return KaiTimerOperationResult.Fail("Counter name cannot be empty.", state);
counter.Name = name;
}
var nextMode = request.Mode == null ? counter.Mode : NormalizeCounterMode(request.Mode);
var nextAutomaticKind = request.AutomaticKind == null
? NormalizeAutomaticKind(counter.AutomaticKind)
: NormalizeAutomaticKind(request.AutomaticKind);
if (nextMode == KaiCounterModes.Automatic)
{
if (string.IsNullOrWhiteSpace(nextAutomaticKind))
return KaiTimerOperationResult.Fail("Automatic counters require an automaticKind.", state);
if (!IsSupportedAutomaticCounterKind(nextAutomaticKind))
return KaiTimerOperationResult.Fail($"Unsupported automatic counter kind '{nextAutomaticKind}'.", state);
}
counter.Mode = nextMode;
counter.AutomaticKind = nextMode == KaiCounterModes.Automatic ? nextAutomaticKind : null;
if (request.Value.HasValue && counter.Mode == KaiCounterModes.Manual)
counter.Value = request.Value.Value;
if (request.Active.HasValue)
counter.Active = request.Active.Value;
counter.UpdatedAt = now;
counter.LastChangedAt = now;
state.UpdatedAt = now;
AddEvent(state, "counter_updated", $"Counter updated: {counter.Name}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Updated counter '{counter.Name}'.", state, counter);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> IncrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default)
{
return await ChangeCounterAsync(request, Math.Abs(request.Amount == 0 ? 1 : request.Amount), "incremented", ct);
}
public async Task<KaiTimerOperationResult> DecrementCounterAsync(KaiCounterChangeRequest request, CancellationToken ct = default)
{
return await ChangeCounterAsync(request, -Math.Abs(request.Amount == 0 ? 1 : request.Amount), "decremented", ct);
}
public async Task<KaiTimerOperationResult> DeleteCounterAsync(KaiCounterDeleteRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var counter = state.Counters.FirstOrDefault(c => SameId(c.Id, request.Id));
if (counter == null)
return KaiTimerOperationResult.Fail($"Counter '{request.Id}' was not found.", state);
state.Counters.Remove(counter);
state.UpdatedAt = now;
AddEvent(state, "counter_deleted", $"Counter removed: {counter.Name}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Removed counter '{counter.Name}'.", state, counter);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> CreateMilestoneAsync(KaiMilestoneCreateRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var validation = ValidateMilestone(request.Name, request.ThresholdSeconds, request.Prompt, state);
if (validation != null)
return validation;
var milestone = new KaiMilestonePrompt
{
Id = CreateId(),
Name = request.Name.Trim(),
ThresholdSeconds = request.ThresholdSeconds,
Prompt = request.Prompt.Trim(),
Enabled = request.Enabled,
CreatedAt = now,
UpdatedAt = now
};
state.Milestones.Add(milestone);
state.UpdatedAt = now;
AddEvent(state, "milestone_created", $"Milestone created: {milestone.Name} at {FormatDuration(milestone.ThresholdSeconds)}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Created milestone '{milestone.Name}'.", state, milestone);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> UpdateMilestoneAsync(KaiMilestoneUpdateRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var milestone = state.Milestones.FirstOrDefault(m => SameId(m.Id, request.Id));
if (milestone == null)
return KaiTimerOperationResult.Fail($"Milestone '{request.Id}' was not found.", state);
var nextName = request.Name == null ? milestone.Name : request.Name.Trim();
var nextThreshold = request.ThresholdSeconds ?? milestone.ThresholdSeconds;
var nextPrompt = request.Prompt == null ? milestone.Prompt : request.Prompt.Trim();
var validation = ValidateMilestone(nextName, nextThreshold, nextPrompt, state);
if (validation != null)
return validation;
milestone.Name = nextName;
milestone.ThresholdSeconds = nextThreshold;
milestone.Prompt = nextPrompt;
if (request.Enabled.HasValue)
milestone.Enabled = request.Enabled.Value;
if (request.ResetPromptState)
{
milestone.TriggeredAt = null;
milestone.LastPromptedAt = null;
}
milestone.UpdatedAt = now;
state.UpdatedAt = now;
AddEvent(state, "milestone_updated", $"Milestone updated: {milestone.Name}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Updated milestone '{milestone.Name}'.", state, milestone);
}
finally
{
_gate.Release();
}
}
public async Task<KaiTimerOperationResult> DeleteMilestoneAsync(KaiMilestoneDeleteRequest request, CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var milestone = state.Milestones.FirstOrDefault(m => SameId(m.Id, request.Id));
if (milestone == null)
return KaiTimerOperationResult.Fail($"Milestone '{request.Id}' was not found.", state);
state.Milestones.Remove(milestone);
state.UpdatedAt = now;
AddEvent(state, "milestone_deleted", $"Milestone removed: {milestone.Name}", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Removed milestone '{milestone.Name}'.", state, milestone);
}
finally
{
_gate.Release();
}
}
public string FormatStateForTool(KaiTimerState state)
{
PopulateComputedState(state, DateTime.UtcNow);
return FormatState("KaiTimer:", state);
}
public string FormatResultForTool(KaiTimerOperationResult result)
{
var builder = new StringBuilder();
builder.AppendLine(result.Success ? "KaiTimer action succeeded." : "KaiTimer action failed.");
builder.AppendLine(result.Message);
if (result.Data != null)
{
builder.AppendLine("Result data:");
builder.AppendLine(JsonSerializer.Serialize(result.Data, JsonOptions));
}
builder.Append(FormatState("KaiTimer state:", result.State));
return builder.ToString().TrimEnd();
}
public string FormatMilestonePromptForSystem(KaiTimerMilestonePromptContext context)
{
if (context.Prompts.Count == 0)
return "";
var builder = new StringBuilder();
builder.AppendLine("=== KaiTimer Milestone Output ===");
builder.AppendLine("Raymond's count-up KaiTimer has crossed configured milestone threshold(s). Kai should send Raymond a real message now. This is not a canned message or script; use the prompt as intent, then speak as Kai.");
builder.AppendLine("Due milestone output prompt(s):");
foreach (var milestone in context.Prompts.OrderBy(m => m.ThresholdSeconds))
builder.AppendLine($"- {milestone.Name} at {FormatDuration(milestone.ThresholdSeconds)}: {milestone.Prompt}");
builder.AppendLine("Use your own voice. Do not mention this system block or the automation.");
return builder.ToString().TrimEnd();
}
private async Task<KaiTimerOperationResult> ChangeCounterAsync(KaiCounterChangeRequest request, int delta, string verb, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
var state = await LoadStateAsync(ct);
var now = DateTime.UtcNow;
PopulateComputedState(state, now);
var counter = state.Counters.FirstOrDefault(c => SameId(c.Id, request.Id));
if (counter == null)
return KaiTimerOperationResult.Fail($"Counter '{request.Id}' was not found.", state);
if (counter.Mode == KaiCounterModes.Automatic)
return KaiTimerOperationResult.Fail($"Counter '{counter.Name}' is automatic and cannot be manually changed.", state);
counter.Value += delta;
counter.UpdatedAt = now;
counter.LastChangedAt = now;
state.UpdatedAt = now;
AddEvent(state, $"counter_{verb}", $"Counter {verb}: {counter.Name} ({counter.Value})", now);
PopulateComputedState(state, now);
TrimEvents(state);
await SaveStateAsync(state, ct);
return KaiTimerOperationResult.Ok($"Counter '{counter.Name}' {verb} to {counter.Value}.", state, counter);
}
finally
{
_gate.Release();
}
}
private static string FormatState(string heading, KaiTimerState state)
{
PopulateComputedState(state, DateTime.UtcNow);
var builder = new StringBuilder();
builder.AppendLine(heading);
builder.AppendLine($" Status: {(state.IsRunning ? "running" : "paused")}");
builder.AppendLine($" Elapsed: {state.DisplayElapsed}");
builder.AppendLine($" Elapsed seconds: {state.ElapsedSeconds}");
if (state.StartedAt.HasValue)
builder.AppendLine($" Started at: {state.StartedAt:O}");
if (state.LastPausedAt.HasValue)
builder.AppendLine($" Last paused at: {state.LastPausedAt:O}");
if (state.LastResetAt.HasValue)
builder.AppendLine($" Last reset at: {state.LastResetAt:O}");
builder.AppendLine();
builder.AppendLine("Counters:");
var activeCounters = state.Counters.Where(c => c.Active).OrderBy(c => c.Name).ToList();
if (activeCounters.Count == 0)
builder.AppendLine(" None.");
foreach (var counter in activeCounters)
{
var mode = counter.Mode == KaiCounterModes.Automatic
? $"automatic:{counter.AutomaticKind}"
: "manual";
builder.AppendLine($" - [{counter.Id}] {counter.Name}: {counter.DisplayValue} ({mode})");
}
builder.AppendLine();
builder.AppendLine("Wheel segments:");
if (state.WheelSegments.Count == 0)
builder.AppendLine(" None.");
foreach (var segment in state.WheelSegments.OrderBy(s => s.CreatedAt))
builder.AppendLine($" - [{segment.Id}] {(segment.Enabled ? "enabled" : "disabled")} weight={segment.Weight}: {segment.Text}");
builder.AppendLine();
builder.AppendLine("Milestones:");
if (state.Milestones.Count == 0)
builder.AppendLine(" None.");
foreach (var milestone in state.Milestones.OrderBy(m => m.ThresholdSeconds))
{
var stateText = !milestone.Enabled
? "disabled"
: milestone.IsDue ? "due"
: milestone.LastPromptedAt.HasValue ? "prompted"
: "upcoming";
builder.AppendLine($" - [{milestone.Id}] {stateText} at {milestone.DisplayThreshold}: {milestone.Name} - {milestone.Prompt}");
}
builder.AppendLine();
builder.AppendLine("Recent actions:");
var events = state.Events.OrderByDescending(e => e.Timestamp).Take(10).ToList();
if (events.Count == 0)
builder.AppendLine(" None.");
foreach (var evt in events)
builder.AppendLine($" - {evt.Timestamp:O} [{evt.Type}] {evt.Message}");
return builder.ToString().TrimEnd();
}
private async Task<KaiTimerState> LoadStateAsync(CancellationToken ct)
{
for (var attempt = 0; attempt < MaxRetries; attempt++)
{
try
{
if (!File.Exists(_path))
{
var fresh = new KaiTimerState();
PopulateComputedState(fresh, DateTime.UtcNow);
return fresh;
}
await using var stream = File.OpenRead(_path);
var loaded = await JsonSerializer.DeserializeAsync<KaiTimerState>(stream, JsonOptions, ct)
?? new KaiTimerState();
PopulateComputedState(loaded, DateTime.UtcNow);
return loaded;
}
catch (IOException) when (attempt < MaxRetries - 1)
{
await Task.Delay(RetryDelays[attempt], ct);
}
}
// Last attempt — if this throws, let the caller see it
await using var finalStream = File.OpenRead(_path);
var finalLoaded = await JsonSerializer.DeserializeAsync<KaiTimerState>(finalStream, JsonOptions, ct)
?? new KaiTimerState();
PopulateComputedState(finalLoaded, DateTime.UtcNow);
return finalLoaded;
}
private async Task SaveStateAsync(KaiTimerState state, CancellationToken ct)
{
Directory.CreateDirectory(Path.GetDirectoryName(_path) ?? ".");
PopulateComputedState(state, DateTime.UtcNow);
// Write to a temp file then atomically move into place.
// This avoids the File.Create truncation window that collides with git or external readers.
var tempPath = _path + "." + Guid.NewGuid().ToString("N")[..8] + ".tmp";
for (var attempt = 0; attempt < MaxRetries; attempt++)
{
try
{
await using (var stream = File.Create(tempPath))
await JsonSerializer.SerializeAsync(stream, state, JsonOptions, ct);
File.Move(tempPath, _path, overwrite: true);
return;
}
catch (IOException) when (attempt < MaxRetries - 1)
{
await Task.Delay(RetryDelays[attempt], ct);
}
}
// Last attempt
try
{
await using (var stream = File.Create(tempPath))
await JsonSerializer.SerializeAsync(stream, state, JsonOptions, ct);
File.Move(tempPath, _path, overwrite: true);
}
finally
{
try { File.Delete(tempPath); } catch { /* best effort */ }
}
}
private static void PopulateComputedState(KaiTimerState state, DateTime now)
{
EnsureCollections(state);
var elapsed = Math.Max(0, state.AccumulatedSeconds);
if (state.IsRunning && state.StartedAt.HasValue)
elapsed += Math.Max(0, (long)Math.Floor((now - state.StartedAt.Value).TotalSeconds));
state.ElapsedSeconds = elapsed;
state.DisplayElapsed = FormatDuration(elapsed);
foreach (var counter in state.Counters)
{
counter.Mode = NormalizeCounterMode(counter.Mode);
counter.AutomaticKind = NormalizeAutomaticKind(counter.AutomaticKind);
if (counter.Mode == KaiCounterModes.Automatic)
counter.Value = ComputeAutomaticCounter(counter, state, now);
counter.DisplayValue = counter.Value.ToString("N0");
}
foreach (var segment in state.WheelSegments)
{
if (string.IsNullOrWhiteSpace(segment.Id))
segment.Id = CreateId();
segment.Weight = ClampWeight(segment.Weight);
}
foreach (var milestone in state.Milestones)
{
if (string.IsNullOrWhiteSpace(milestone.Id))
milestone.Id = CreateId();
milestone.DisplayThreshold = FormatDuration(milestone.ThresholdSeconds);
milestone.IsDue = milestone.Enabled && milestone.ThresholdSeconds <= elapsed && !milestone.LastPromptedAt.HasValue;
milestone.WasPrompted = milestone.LastPromptedAt.HasValue;
milestone.IsUpcoming = milestone.Enabled && milestone.ThresholdSeconds > elapsed;
}
}
private static int ComputeAutomaticCounter(KaiCounter counter, KaiTimerState state, DateTime now)
{
return counter.AutomaticKind switch
{
KaiAutomaticCounterKinds.DaysSinceLastReset => state.LastResetAt.HasValue
? Math.Max(0, (int)Math.Floor((now - state.LastResetAt.Value).TotalDays))
: 0,
KaiAutomaticCounterKinds.ElapsedDays => Math.Max(0, (int)Math.Floor(state.ElapsedSeconds / 86400.0)),
_ => counter.Value
};
}
private static void EnsureCollections(KaiTimerState state)
{
state.Events ??= new();
state.WheelSegments ??= new();
state.Milestones ??= new();
state.Counters ??= new();
}
private static string FormatDuration(long totalSeconds)
{
var value = TimeSpan.FromSeconds(Math.Max(0, totalSeconds));
if (value.TotalDays >= 1)
return $"{(int)value.TotalDays}d {value.Hours}h {value.Minutes}m {value.Seconds}s";
if (value.TotalHours >= 1)
return $"{(int)value.TotalHours}h {value.Minutes}m {value.Seconds}s";
return $"{value.Minutes}m {value.Seconds}s";
}
private static void AddEvent(KaiTimerState state, string type, string message, DateTime timestamp)
{
state.Events.Add(KaiTimerEvent.Create(type, message, timestamp));
}
private static void TrimEvents(KaiTimerState state)
{
if (state.Events.Count <= 100)
return;
state.Events = state.Events
.OrderByDescending(e => e.Timestamp)
.Take(100)
.OrderBy(e => e.Timestamp)
.ToList();
}
private static KaiTimerOperationResult? ValidateMilestone(string? name, long thresholdSeconds, string? prompt, KaiTimerState state)
{
if (string.IsNullOrWhiteSpace(name))
return KaiTimerOperationResult.Fail("Milestone name is required.", state);
if (thresholdSeconds <= 0)
return KaiTimerOperationResult.Fail("Milestone thresholdSeconds must be greater than zero.", state);
if (string.IsNullOrWhiteSpace(prompt))
return KaiTimerOperationResult.Fail("Milestone prompt is required.", state);
return null;
}
private static int ClampWeight(int weight)
{
return Math.Clamp(weight <= 0 ? 1 : weight, 1, 100);
}
private static string NormalizeCounterMode(string? mode)
{
return string.Equals(mode, KaiCounterModes.Automatic, StringComparison.OrdinalIgnoreCase)
? KaiCounterModes.Automatic
: KaiCounterModes.Manual;
}
private static string? NormalizeAutomaticKind(string? kind)
{
return string.IsNullOrWhiteSpace(kind)
? null
: kind.Trim().Replace('-', '_').ToLowerInvariant();
}
private static bool IsSupportedAutomaticCounterKind(string? kind)
{
return kind is KaiAutomaticCounterKinds.DaysSinceLastReset or KaiAutomaticCounterKinds.ElapsedDays;
}
private static bool SameId(string? left, string? right)
{
return string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase);
}
private static string CreateId()
{
return Guid.NewGuid().ToString("N")[..10];
}
}
public static class KaiCounterModes
{
public const string Manual = "manual";
public const string Automatic = "automatic";
}
public static class KaiAutomaticCounterKinds
{
public const string DaysSinceLastReset = "days_since_last_reset";
public const string ElapsedDays = "elapsed_days";
}
public sealed class KaiTimerState
{
public bool IsRunning { get; set; }
public long AccumulatedSeconds { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? LastStartedAt { get; set; }
public DateTime? LastPausedAt { get; set; }
public DateTime? LastResetAt { get; set; }
public DateTime? LastWheelSpinAt { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public long ElapsedSeconds { get; set; }
public string DisplayElapsed { get; set; } = "0m 0s";
public List<KaiTimerEvent> Events { get; set; } = new();
public List<KaiWheelSegment> WheelSegments { get; set; } = new();
public List<KaiMilestonePrompt> Milestones { get; set; } = new();
public List<KaiCounter> Counters { get; set; } = new();
}
public sealed class KaiWheelSegment
{
public string Id { get; set; } = "";
public string Text { get; set; } = "";
public int Weight { get; set; } = 1;
public bool Enabled { get; set; } = true;
public int SelectionCount { get; set; }
public DateTime? LastSelectedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class KaiMilestonePrompt
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public long ThresholdSeconds { get; set; }
public string DisplayThreshold { get; set; } = "0m 0s";
public string Prompt { get; set; } = "";
public bool Enabled { get; set; } = true;
public bool IsDue { get; set; }
public bool IsUpcoming { get; set; }
public bool WasPrompted { get; set; }
public DateTime? TriggeredAt { get; set; }
public DateTime? LastPromptedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class KaiCounter
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public int Value { get; set; }
public string DisplayValue { get; set; } = "0";
public string Mode { get; set; } = KaiCounterModes.Manual;
public string? AutomaticKind { get; set; }
public bool Active { get; set; } = true;
public DateTime? LastChangedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class KaiTimerEvent
{
public string Type { get; set; } = "";
public string Message { get; set; } = "";
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public static KaiTimerEvent Create(string type, string message, DateTime timestamp)
{
return new KaiTimerEvent
{
Type = type,
Message = message,
Timestamp = timestamp
};
}
}
public sealed record KaiTimerOperationResult(
bool Success,
string Message,
KaiTimerState State,
object? Data = null)
{
public static KaiTimerOperationResult Ok(string message, KaiTimerState state, object? data = null)
{
return new KaiTimerOperationResult(true, message, state, data);
}
public static KaiTimerOperationResult Fail(string message, KaiTimerState state, object? data = null)
{
return new KaiTimerOperationResult(false, message, state, data);
}
}
public sealed record KaiTimerMilestonePromptContext(
bool Changed,
KaiTimerState State,
IReadOnlyList<KaiMilestonePrompt> Prompts);
public sealed record KaiWheelSegmentCreateRequest(
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("weight")] int Weight = 1,
[property: JsonPropertyName("enabled")] bool Enabled = true);
public sealed record KaiWheelSegmentUpdateRequest(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("text")] string? Text = null,
[property: JsonPropertyName("weight")] int? Weight = null,
[property: JsonPropertyName("enabled")] bool? Enabled = null);
public sealed record KaiWheelSegmentDeleteRequest(
[property: JsonPropertyName("id")] string Id);
public sealed record KaiCounterCreateRequest(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("value")] int Value = 0,
[property: JsonPropertyName("mode")] string Mode = KaiCounterModes.Manual,
[property: JsonPropertyName("automaticKind")] string? AutomaticKind = null,
[property: JsonPropertyName("active")] bool Active = true);
public sealed record KaiCounterUpdateRequest(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string? Name = null,
[property: JsonPropertyName("value")] int? Value = null,
[property: JsonPropertyName("mode")] string? Mode = null,
[property: JsonPropertyName("automaticKind")] string? AutomaticKind = null,
[property: JsonPropertyName("active")] bool? Active = null);
public sealed record KaiCounterChangeRequest(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("amount")] int Amount = 1);
public sealed record KaiCounterDeleteRequest(
[property: JsonPropertyName("id")] string Id);
public sealed record KaiMilestoneCreateRequest(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("thresholdSeconds")] long ThresholdSeconds,
[property: JsonPropertyName("prompt")] string Prompt,
[property: JsonPropertyName("enabled")] bool Enabled = true);
public sealed record KaiMilestoneUpdateRequest(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("name")] string? Name = null,
[property: JsonPropertyName("thresholdSeconds")] long? ThresholdSeconds = null,
[property: JsonPropertyName("prompt")] string? Prompt = null,
[property: JsonPropertyName("enabled")] bool? Enabled = null,
[property: JsonPropertyName("resetPromptState")] bool ResetPromptState = false);
public sealed record KaiMilestoneDeleteRequest(
[property: JsonPropertyName("id")] string Id);