Merge branch 'claudedo/840fdb981c0e42198062c8769233fc14'
This commit is contained in:
@@ -21,7 +21,7 @@ Worker/
|
||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
||||
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
||||
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message); interface in Usage/Interfaces/ (IUsageClient)
|
||||
Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message), TranscriptUsageReader (aggregates Claude Code transcript token usage from `~/.claude/projects/**/*.jsonl` by date/model/scope (ClaudeDo vs Other), deduped by requestId, with a per-file length+mtime cache); interfaces in Usage/Interfaces/ (IUsageClient, ITranscriptUsageReader)
|
||||
```
|
||||
|
||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||
|
||||
@@ -124,6 +124,9 @@ builder.Services.AddSingleton<IClaudeHistoryReader>(_ =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects")));
|
||||
builder.Services.AddSingleton<IWeekReportService, WeekReportService>();
|
||||
|
||||
// Usage
|
||||
builder.Services.AddSingleton<ITranscriptUsageReader, TranscriptUsageReader>();
|
||||
|
||||
// Prime Claude
|
||||
builder.Services.AddSingleton<IPrimeClock, PrimeClock>();
|
||||
builder.Services.AddSingleton<PrimeScheduleSignal>();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClaudeDo.Worker.Usage.Interfaces;
|
||||
|
||||
public interface ITranscriptUsageReader
|
||||
{
|
||||
Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
|
||||
DateOnly start, DateOnly end, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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 string _centralRoot;
|
||||
private readonly string _sandboxRoot;
|
||||
private readonly ConcurrentDictionary<string, FileCacheEntry> _cache = new();
|
||||
|
||||
public TranscriptUsageReader(WorkerConfig cfg, string? projectsRoot = null)
|
||||
{
|
||||
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
|
||||
_centralRoot = NormalizePath(cfg.CentralWorktreeRoot);
|
||||
_sandboxRoot = NormalizePath(cfg.SandboxRoot);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<UsageAggregateRow>> ReadAsync(
|
||||
DateOnly start, DateOnly end, CancellationToken ct = default)
|
||||
{
|
||||
var seenKeys = new HashSet<string>();
|
||||
var buckets = new Dictionary<(DateOnly Date, string Model, UsageScope Scope), Accumulator>();
|
||||
|
||||
if (Directory.Exists(_projectsRoot))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var record in GetOrReadFile(file))
|
||||
{
|
||||
if (record.Date < start || record.Date > end) continue;
|
||||
if (!seenKeys.Add(record.DedupeKey)) continue;
|
||||
|
||||
var key = (record.Date, record.Model, record.Scope);
|
||||
if (!buckets.TryGetValue(key, out var acc))
|
||||
{
|
||||
acc = new Accumulator();
|
||||
buckets[key] = acc;
|
||||
}
|
||||
acc.Input += record.InputTokens;
|
||||
acc.Output += record.OutputTokens;
|
||||
acc.CacheRead += record.CacheReadTokens;
|
||||
acc.CacheCreation += record.CacheCreationTokens;
|
||||
acc.Messages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var rows = buckets
|
||||
.Select(kv => new UsageAggregateRow(
|
||||
kv.Key.Date, kv.Key.Model, kv.Key.Scope,
|
||||
kv.Value.Input, kv.Value.Output, kv.Value.CacheRead, kv.Value.CacheCreation, kv.Value.Messages))
|
||||
.OrderBy(r => r.Date).ThenBy(r => r.Model).ThenBy(r => r.Scope)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<UsageAggregateRow>>(rows);
|
||||
}
|
||||
|
||||
private List<UsageMessageRecord> GetOrReadFile(string file)
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
if (_cache.TryGetValue(file, out var cached) &&
|
||||
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
|
||||
{
|
||||
return cached.Records;
|
||||
}
|
||||
|
||||
var records = ReadFile(file);
|
||||
_cache[file] = 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;
|
||||
|
||||
var date = DateOnly.FromDateTime(ts.LocalDateTime);
|
||||
var model = modelEl.GetString()!;
|
||||
|
||||
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 cwd = TryGetString(root, "cwd") ?? "";
|
||||
var dedupeKey = TryGetString(root, "requestId")
|
||||
?? TryGetString(msg, "id")
|
||||
?? Guid.NewGuid().ToString();
|
||||
|
||||
records.Add(new UsageMessageRecord(
|
||||
date, model, ResolveScope(cwd), input, output, cacheRead, cacheCreation, dedupeKey));
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private UsageScope ResolveScope(string cwd)
|
||||
{
|
||||
var norm = NormalizePath(cwd);
|
||||
if (norm.Length == 0) return UsageScope.Other;
|
||||
|
||||
// Sibling-strategy worktrees are placed next to whatever repo they belong to
|
||||
// (no single root path), but always under a literal ".claudedo-worktrees" segment.
|
||||
if (norm.Split('\\').Any(seg => seg == ".claudedo-worktrees")) return UsageScope.ClaudeDo;
|
||||
if (IsUnderRoot(norm, _centralRoot)) return UsageScope.ClaudeDo;
|
||||
if (IsUnderRoot(norm, _sandboxRoot)) return UsageScope.ClaudeDo;
|
||||
return UsageScope.Other;
|
||||
}
|
||||
|
||||
private static bool IsUnderRoot(string normPath, string normRoot) =>
|
||||
normRoot.Length > 0 && (normPath == normRoot || normPath.StartsWith(normRoot + "\\", StringComparison.Ordinal));
|
||||
|
||||
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 static string NormalizePath(string p) =>
|
||||
(p ?? "").Replace('/', '\\').TrimEnd('\\').ToLowerInvariant();
|
||||
|
||||
private sealed class Accumulator
|
||||
{
|
||||
public long Input, Output, CacheRead, CacheCreation;
|
||||
public int Messages;
|
||||
}
|
||||
|
||||
private sealed record FileCacheEntry(long Length, DateTime LastWriteUtc, List<UsageMessageRecord> Records);
|
||||
|
||||
private sealed record UsageMessageRecord(
|
||||
DateOnly Date, string Model, UsageScope Scope,
|
||||
long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens,
|
||||
string DedupeKey);
|
||||
}
|
||||
@@ -16,3 +16,19 @@ public sealed record UsageSnapshot(
|
||||
UsageBucket? SevenDay,
|
||||
IReadOnlyList<UsageLimitRow> Limits,
|
||||
DateTime FetchedAtUtc);
|
||||
|
||||
public enum UsageScope
|
||||
{
|
||||
ClaudeDo,
|
||||
Other,
|
||||
}
|
||||
|
||||
public sealed record UsageAggregateRow(
|
||||
DateOnly Date,
|
||||
string Model,
|
||||
UsageScope Scope,
|
||||
long InputTokens,
|
||||
long OutputTokens,
|
||||
long CacheReadTokens,
|
||||
long CacheCreationTokens,
|
||||
int Messages);
|
||||
|
||||
Reference in New Issue
Block a user