120 lines
5.0 KiB
C#
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);
|
|
}
|