using System.Text.Json; using ClaudeDo.Worker.Report.Interfaces; namespace ClaudeDo.Worker.Report; public sealed class ClaudeHistoryReader : IClaudeHistoryReader { private readonly string _projectsRoot; public ClaudeHistoryReader(string projectsRoot) => _projectsRoot = projectsRoot; public Task> ReadAsync( DateOnly start, DateOnly end, IReadOnlyList excludedPrefixes, CancellationToken ct = default) { var buckets = new Dictionary<(string Repo, DateOnly Date), DayActivity>(); var normalizedExcludes = excludedPrefixes .Select(NormalizePath).Where(p => p.Length > 0).ToArray(); if (Directory.Exists(_projectsRoot)) { foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories)) { ct.ThrowIfCancellationRequested(); ReadFile(file, start, end, normalizedExcludes, buckets); } } var repos = buckets .GroupBy(kv => kv.Key.Repo) .Select(g => { var ra = new RepoActivity { RepoPath = g.Key }; foreach (var day in g.OrderBy(kv => kv.Key.Date).Select(kv => kv.Value)) ra.Days.Add(day); return ra; }) .OrderBy(r => r.RepoPath) .ToList(); return Task.FromResult>(repos); } private static void ReadFile( string file, DateOnly start, DateOnly end, string[] excludes, Dictionary<(string, DateOnly), DayActivity> buckets) { string? lastAssistantText = null; string? lastAssistantRepo = null; DateOnly lastAssistantDate = default; foreach (var line in File.ReadLines(file)) { if (string.IsNullOrWhiteSpace(line)) continue; JsonDocument doc; try { doc = JsonDocument.Parse(line); } catch (JsonException) { continue; } using (doc) { var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) continue; if (!root.TryGetProperty("type", out var typeEl)) continue; var type = typeEl.GetString(); if (type is not ("user" or "assistant")) continue; if (!root.TryGetProperty("cwd", out var cwdEl) || cwdEl.ValueKind != JsonValueKind.String) continue; var cwd = cwdEl.GetString()!; if (IsExcluded(cwd, excludes)) continue; if (!root.TryGetProperty("timestamp", out var tsEl) || !DateTimeOffset.TryParse(tsEl.GetString(), out var ts)) continue; var date = DateOnly.FromDateTime(ts.LocalDateTime); if (date < start || date > end) continue; var text = ExtractText(root); if (string.IsNullOrWhiteSpace(text)) continue; if (type == "user") { if (text.Contains("", StringComparison.OrdinalIgnoreCase)) continue; Bucket(buckets, cwd, date).Prompts.Add(text.Trim()); } else { // Keep only the closing summary per (repo, day). If this turn moved to a // different repo/day (e.g. the session cd'd), flush the previous one first. if (lastAssistantText is not null && (cwd != lastAssistantRepo || date != lastAssistantDate)) { Bucket(buckets, lastAssistantRepo!, lastAssistantDate).Summaries.Add(lastAssistantText); lastAssistantText = null; } lastAssistantText = text.Trim(); lastAssistantRepo = cwd; lastAssistantDate = date; } } } if (lastAssistantText is not null && lastAssistantRepo is not null) Bucket(buckets, lastAssistantRepo, lastAssistantDate).Summaries.Add(lastAssistantText); } private static DayActivity Bucket( Dictionary<(string, DateOnly), DayActivity> buckets, string repo, DateOnly date) { var key = (repo, date); if (!buckets.TryGetValue(key, out var day)) { day = new DayActivity { Date = date }; buckets[key] = day; } return day; } private static string ExtractText(JsonElement root) { if (!root.TryGetProperty("message", out var msg) || !msg.TryGetProperty("content", out var content)) return ""; if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? ""; if (content.ValueKind != JsonValueKind.Array) return ""; var parts = new List(); foreach (var item in content.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; if (item.TryGetProperty("type", out var t) && t.GetString() == "text" && item.TryGetProperty("text", out var txt) && txt.ValueKind == JsonValueKind.String) parts.Add(txt.GetString() ?? ""); } return string.Join("\n", parts); } private static bool IsExcluded(string cwd, string[] excludes) { var norm = NormalizePath(cwd); return excludes.Any(p => norm.StartsWith(p, StringComparison.Ordinal)); } private static string NormalizePath(string p) => (p ?? "").Replace('/', '\\').TrimEnd('\\').ToLowerInvariant(); }