using System.Collections.Concurrent; using System.Text.Json; using ClaudeDo.Data; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Usage.Interfaces; namespace ClaudeDo.Worker.Usage; public sealed class TranscriptUsageReader : ITranscriptUsageReader { private readonly string _projectsRoot; private readonly ConcurrentDictionary _cache = new(); // cfg is unused since the model/scope aggregation moved to TokenTracker, but the parameter // stays so the DI registration and every existing call site keep working. public TranscriptUsageReader(WorkerConfig cfg, string? projectsRoot = null) { _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects"); } public Task ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot)) return Task.FromResult(null); var file = new DirectoryInfo(_projectsRoot) .EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories) .FirstOrDefault(); if (file is null) return Task.FromResult(null); var seenKeys = new HashSet(); long input = 0, output = 0, cacheRead = 0, cacheCreation = 0; foreach (var record in GetOrReadFile(file)) { ct.ThrowIfCancellationRequested(); if (!seenKeys.Add(record.DedupeKey)) continue; input += record.InputTokens; output += record.OutputTokens; cacheRead += record.CacheReadTokens; cacheCreation += record.CacheCreationTokens; } return Task.FromResult( new SessionUsageTotals(input, output, cacheRead, cacheCreation)); } private List GetOrReadFile(FileInfo info) { if (_cache.TryGetValue(info.FullName, out var cached) && cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc) { return cached.Records; } var records = ReadFile(info.FullName); _cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records); return records; } private List ReadFile(string file) { var records = new List(); 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) || typeEl.GetString() != "assistant") continue; if (!root.TryGetProperty("timestamp", out var tsEl) || !DateTimeOffset.TryParse(tsEl.GetString(), out var ts)) continue; if (!root.TryGetProperty("message", out var msg) || msg.ValueKind != JsonValueKind.Object) continue; if (!msg.TryGetProperty("model", out var modelEl) || modelEl.ValueKind != JsonValueKind.String) continue; // The timestamp and model are still parsed above: they gate out non-assistant // lines and models, which is right for the session totals too. var model = modelEl.GetString()!; if (model == "") continue; long input = 0, output = 0, cacheRead = 0, cacheCreation = 0; if (msg.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) { input = GetLong(usage, "input_tokens"); output = GetLong(usage, "output_tokens"); cacheRead = GetLong(usage, "cache_read_input_tokens"); cacheCreation = GetLong(usage, "cache_creation_input_tokens"); } var dedupeKey = TryGetString(root, "requestId") ?? TryGetString(msg, "id") ?? Guid.NewGuid().ToString(); records.Add(new UsageMessageRecord(input, output, cacheRead, cacheCreation, dedupeKey)); } } return records; } private static string? TryGetString(JsonElement obj, string prop) => obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; private static long GetLong(JsonElement obj, string prop) => obj.TryGetProperty(prop, out var el) && el.TryGetInt64(out var v) ? v : 0; private sealed record FileCacheEntry(long Length, DateTime LastWriteUtc, List Records); private sealed record UsageMessageRecord( long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens, string DedupeKey); }