Files
ClaudeDo/src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs
T

120 lines
5.0 KiB
C#

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<string, FileCacheEntry> _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<SessionUsageTotals?> ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
return Task.FromResult<SessionUsageTotals?>(null);
var file = new DirectoryInfo(_projectsRoot)
.EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
.FirstOrDefault();
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
var seenKeys = new HashSet<string>();
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<SessionUsageTotals?>(
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
}
private List<UsageMessageRecord> 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<UsageMessageRecord> ReadFile(string file)
{
var records = new List<UsageMessageRecord>();
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 <synthetic> models, which is right for the session totals too.
var model = modelEl.GetString()!;
if (model == "<synthetic>") 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<UsageMessageRecord> Records);
private sealed record UsageMessageRecord(
long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens,
string DedupeKey);
}