From 371e90a0589be163c7f5ad93f3b7c5d1341c28d6 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 22:49:18 +0800 Subject: [PATCH 1/3] refactor(core): count and price tokens in one place RecentActivityBuilder and SessionBlockBuilder each summed a response's tokens, split cache writes by lifetime and priced them with their own copy of the same code, and a response total was spelled out again in StatsCacheBuilder and HourlyActivityBuilder. Add ModelTokenTotals for the per-model sums and cost, and TokenUsage.TotalTokens for a response's total, and use them everywhere. --- .../Providers/Claude/HourlyActivityBuilder.cs | 8 +- .../Providers/Claude/ModelTokenTotals.cs | 106 ++++++++++++++++++ .../Providers/Claude/Models/SessionMessage.cs | 6 + .../Providers/Claude/RecentActivityBuilder.cs | 21 +--- .../Providers/Claude/SessionBlockBuilder.cs | 21 +--- .../Providers/Claude/StatsCacheBuilder.cs | 3 +- .../Providers/Claude/ModelTokenTotalsTests.cs | 105 +++++++++++++++++ 7 files changed, 225 insertions(+), 45 deletions(-) create mode 100644 src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs create mode 100644 tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs diff --git a/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs index 297d577..3f44692 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/HourlyActivityBuilder.cs @@ -74,13 +74,7 @@ private void ProcessFile(string file, long[] tokensByHour, TranscriptDeduplicato continue; } - var tokens = - usage.InputTokens + - usage.OutputTokens + - usage.CacheReadInputTokens + - usage.CacheCreationInputTokens; - - tokensByHour[ts.LocalDateTime.Hour] += tokens; + tokensByHour[ts.LocalDateTime.Hour] += usage.TotalTokens; } } } \ No newline at end of file diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs b/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs new file mode 100644 index 0000000..db37350 --- /dev/null +++ b/src/AIUsageMonitor.Core/Providers/Claude/ModelTokenTotals.cs @@ -0,0 +1,106 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Providers.Claude.Models; + +namespace AIUsageMonitor.Core.Providers.Claude; + +/// +/// Adds up the token usage of one model, keeping the cache writes split by lifetime because the two are priced differently. +/// Every transcript aggregation counts and prices tokens through this type, so they cannot disagree. +/// +internal sealed class ModelTokenTotals +{ + /// + /// Gets the input tokens. + /// + public long Input { get; private set; } + + /// + /// Gets the output tokens. + /// + public long Output { get; private set; } + + /// + /// Gets the tokens read from the prompt cache. + /// + public long CacheRead { get; private set; } + + /// + /// Gets all tokens written to the prompt cache. + /// + public long CacheWrite { get; private set; } + + /// + /// Gets the part of written with the 5-minute lifetime. + /// + public long CacheWrite5m { get; private set; } + + /// + /// Gets the part of written with the 1-hour lifetime. + /// + public long CacheWrite1h { get; private set; } + + /// + /// Gets the total tokens: input, output, cache reads and cache writes. + /// + public long Total => Input + Output + CacheRead + CacheWrite; + + /// + /// Adds the usage reported for one response. + /// + /// The usage to add. + public void Add(TokenUsage usage) + { + // A response without the per-lifetime split wrote all of its cache with the default 5-minute lifetime. + var (cacheWrite5m, cacheWrite1h) = usage.CacheCreation is { } detail + ? (detail.Ephemeral5mInputTokens, detail.Ephemeral1hInputTokens) + : (usage.CacheCreationInputTokens, 0L); + + Input += usage.InputTokens; + Output += usage.OutputTokens; + CacheRead += usage.CacheReadInputTokens; + CacheWrite += usage.CacheCreationInputTokens; + CacheWrite5m += cacheWrite5m; + CacheWrite1h += cacheWrite1h; + } + + /// + /// Estimates the cost of the accumulated usage. + /// + /// The model the usage belongs to. + /// The calculator that prices the tokens. + /// The estimated cost, in US dollars. + public decimal EstimateCost(string model, CostCalculator costCalculator) + { + return costCalculator.EstimateCost(model, Input, Output, CacheRead, CacheWrite5m, CacheWrite1h); + } + + /// + /// Adds the usage of one response to the running totals of its model. + /// + /// The running totals per model. + /// The model that produced the response. + /// The usage reported for the response. + /// The total tokens of the response, for callers that also keep their own sums. + public static long Record(Dictionary byModel, string model, TokenUsage usage) + { + if (!byModel.TryGetValue(model, out var totals)) + { + totals = new ModelTokenTotals(); + byModel[model] = totals; + } + + totals.Add(usage); + return usage.TotalTokens; + } + + /// + /// Estimates the cost of the usage of several models. + /// + /// The running totals per model. + /// The calculator that prices the tokens. + /// The summed estimated cost, in US dollars. + public static decimal EstimateCost(Dictionary byModel, CostCalculator costCalculator) + { + return byModel.Sum(pair => pair.Value.EstimateCost(pair.Key, costCalculator)); + } +} diff --git a/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs b/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs index df9fa8c..ca9e8e5 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/Models/SessionMessage.cs @@ -128,6 +128,12 @@ public sealed class TokenUsage /// [JsonPropertyName("output_tokens")] public long OutputTokens { get; init; } + + /// + /// Gets the tokens of the response in all four kinds: input, output, cache reads and cache writes. + /// + [JsonIgnore] + public long TotalTokens => InputTokens + OutputTokens + CacheReadInputTokens + CacheCreationInputTokens; } /// diff --git a/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs index ee152b5..254f52f 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/RecentActivityBuilder.cs @@ -37,7 +37,7 @@ public RecentActivitySummary Build(IReadOnlyList sessionFiles, TimeSpan long totalTokens = 0; var sessionIds = new HashSet(); var tokensByModel = new Dictionary(); - var modelUsage = new Dictionary(); + var modelUsage = new Dictionary(); var hourBuckets = new Dictionary(); var deduplicator = new TranscriptDeduplicator(); @@ -49,9 +49,7 @@ public RecentActivitySummary Build(IReadOnlyList sessionFiles, TimeSpan ProcessFile(file); } - var estimatedCost = modelUsage.Sum(kvp => - costCalculator.EstimateCost(kvp.Key, kvp.Value.Input, kvp.Value.Output, kvp.Value.CacheRead, - kvp.Value.CacheCreation5m, kvp.Value.CacheCreation1h)); + var estimatedCost = ModelTokenTotals.EstimateCost(modelUsage, costCalculator); var firstHour = StartOfLocalHour(since, zone); var lastHour = StartOfLocalHour(now, zone); @@ -122,23 +120,10 @@ void ProcessFile(string file) var model = msg.Message?.Model ?? "unknown"; if (model != "" && deduplicator.TryAddUsage(msg)) { - var tokens = usage.InputTokens + usage.OutputTokens - + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; + var tokens = ModelTokenTotals.Record(modelUsage, model, usage); totalTokens += tokens; tokensByModel[model] = tokensByModel.GetValueOrDefault(model) + tokens; bucket.Tokens += tokens; - - var (cacheCreation5m, cacheCreation1h) = usage.CacheCreation is { } detail - ? (detail.Ephemeral5mInputTokens, detail.Ephemeral1hInputTokens) - : (usage.CacheCreationInputTokens, 0L); - - var entry = modelUsage.GetValueOrDefault(model); - modelUsage[model] = ( - entry.Input + usage.InputTokens, - entry.Output + usage.OutputTokens, - entry.CacheRead + usage.CacheReadInputTokens, - entry.CacheCreation5m + cacheCreation5m, - entry.CacheCreation1h + cacheCreation1h); } } diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs index d5cfab2..30c8baa 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionBlockBuilder.cs @@ -330,7 +330,7 @@ private UsageWindowSummary Summarize( var messageCount = 0; long totalTokens = 0; var tokensByModel = new Dictionary(); - var modelUsage = new Dictionary(); + var modelUsage = new Dictionary(); var deduplicator = new TranscriptDeduplicator(); foreach (var (_, msg) in messageList) @@ -355,27 +355,12 @@ private UsageWindowSummary Summarize( continue; } - var tokens = usage.InputTokens + usage.OutputTokens - + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; + var tokens = ModelTokenTotals.Record(modelUsage, model, usage); totalTokens += tokens; tokensByModel[model] = tokensByModel.GetValueOrDefault(model) + tokens; - - var (cacheCreation5m, cacheCreation1h) = usage.CacheCreation is { } detail - ? (detail.Ephemeral5mInputTokens, detail.Ephemeral1hInputTokens) - : (usage.CacheCreationInputTokens, 0L); - - var entry = modelUsage.GetValueOrDefault(model); - modelUsage[model] = ( - entry.Input + usage.InputTokens, - entry.Output + usage.OutputTokens, - entry.CacheRead + usage.CacheReadInputTokens, - entry.CacheCreation5m + cacheCreation5m, - entry.CacheCreation1h + cacheCreation1h); } - var estimatedCost = modelUsage.Sum(kvp => - costCalculator.EstimateCost(kvp.Key, kvp.Value.Input, kvp.Value.Output, kvp.Value.CacheRead, - kvp.Value.CacheCreation5m, kvp.Value.CacheCreation1h)); + var estimatedCost = ModelTokenTotals.EstimateCost(modelUsage, costCalculator); return new(windowStart, resetsAt, messageCount, totalTokens, tokensByModel, estimatedCost, confidence); } diff --git a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs index 516e50d..c1c1c6e 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/StatsCacheBuilder.cs @@ -119,8 +119,7 @@ void ProcessFile(string file) continue; } - var tokens = usage.InputTokens + usage.OutputTokens - + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; + var tokens = usage.TotalTokens; var modelTokensForDate = dailyModelTokens.TryGetValue(dateOnly, out var mt) ? mt diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs new file mode 100644 index 0000000..1c73ba5 --- /dev/null +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ModelTokenTotalsTests.cs @@ -0,0 +1,105 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Providers.Claude; +using AIUsageMonitor.Core.Providers.Claude.Models; +using Xunit; + +namespace AIUsageMonitor.Core.Tests.Providers.Claude; + +/// +/// Tests for , which every transcript aggregation uses to count and price tokens. +/// +public class ModelTokenTotalsTests +{ + /// + /// Creates a usage with a cache-write split. + /// + /// The usage. + private static TokenUsage SplitUsage() + { + return new TokenUsage + { + InputTokens = 100, + OutputTokens = 50, + CacheReadInputTokens = 1000, + CacheCreationInputTokens = 500, + CacheCreation = new CacheCreationDetail { Ephemeral5mInputTokens = 200, Ephemeral1hInputTokens = 300 }, + }; + } + + /// + /// Verifies that a usage with a split adds each lifetime to its own counter. + /// + [Fact] + public void Add_UsageWithCacheSplit_KeepsTheLifetimesApart() + { + var totals = new ModelTokenTotals(); + + totals.Add(SplitUsage()); + + Assert.Equal(100, totals.Input); + Assert.Equal(50, totals.Output); + Assert.Equal(1000, totals.CacheRead); + Assert.Equal(500, totals.CacheWrite); + Assert.Equal(200, totals.CacheWrite5m); + Assert.Equal(300, totals.CacheWrite1h); + Assert.Equal(1650, totals.Total); + } + + /// + /// Verifies that a response's total counts all four kinds of token once. + /// + [Fact] + public void TotalTokens_UsageWithAllKinds_SumsInputOutputCacheReadAndCacheWrite() + { + Assert.Equal(1650, SplitUsage().TotalTokens); + Assert.Equal(0, new TokenUsage().TotalTokens); + } + + /// + /// Verifies that a usage without a split is priced as 5-minute cache writes. + /// + [Fact] + public void Add_UsageWithoutCacheSplit_CountsAllCacheWritesAsFiveMinute() + { + var totals = new ModelTokenTotals(); + + totals.Add(new TokenUsage { CacheCreationInputTokens = 400 }); + + Assert.Equal(400, totals.CacheWrite); + Assert.Equal(400, totals.CacheWrite5m); + Assert.Equal(0, totals.CacheWrite1h); + } + + /// + /// Verifies that recording returns the tokens of that response and accumulates per model. + /// + [Fact] + public void Record_TwoResponses_ReturnsEachResponsesTokensAndAccumulates() + { + var byModel = new Dictionary(); + + var first = ModelTokenTotals.Record(byModel, "sonnet-5", SplitUsage()); + var second = ModelTokenTotals.Record(byModel, "sonnet-5", new TokenUsage { InputTokens = 7 }); + + Assert.Equal(1650, first); + Assert.Equal(7, second); + Assert.Equal(1657, byModel["sonnet-5"].Total); + } + + /// + /// Verifies that the cost is the calculator price of the split counters, summed over models. + /// + [Fact] + public void EstimateCost_TwoModels_SumsTheCalculatorPrices() + { + var costs = new CostCalculator(); + var byModel = new Dictionary(); + ModelTokenTotals.Record(byModel, "sonnet-5", SplitUsage()); + ModelTokenTotals.Record(byModel, "claude-opus-5-5", new TokenUsage { InputTokens = 10, OutputTokens = 20 }); + + var cost = ModelTokenTotals.EstimateCost(byModel, costs); + + Assert.Equal(costs.EstimateCost("sonnet-5", 100, 50, 1000, 200, 300) + costs.EstimateCost("claude-opus-5-5", 10, 20, 0, 0, 0), cost); + Assert.Equal(costs.EstimateCost("sonnet-5", 100, 50, 1000, 200, 300), byModel["sonnet-5"].EstimateCost("sonnet-5", costs)); + } +} From 6777d0a94309f7e9ea8220dbb9a0e934221079e3 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 22:50:35 +0800 Subject: [PATCH 2/3] feat(cli): add projects command and sessions --list Add per-session and per-project usage. SessionUsageBuilder aggregates the transcripts by the session id on each line, across files, for a range of local days: resumed copies are counted once and stay with the original session and project folder, a streamed response is counted once, and a model filter counts only matching assistant messages. DataService exposes GetSessionUsage and GetProjectUsage, with a project text filter. aimon projects and aimon sessions --list take the date range options, --top, --sort, --project, --model and --json, default to the last 30 days, and share their flow in UsageList. sessions without --list is unchanged and rejects the list options. Remove the unused SessionParser.ParseSessionSummary and SessionSummary. --- .../Commands/DateRangeOptions.cs | 8 +- .../Commands/ListOptions.cs | 132 ++++ .../Commands/ProjectsCommand.cs | 45 ++ .../Commands/SessionsCommand.cs | 47 +- src/AIUsageMonitor.Cli/Commands/UsageList.cs | 69 +++ .../Commands/UsageSorting.cs | 88 +++ src/AIUsageMonitor.Cli/Program.cs | 1 + .../Rendering/CliJsonContext.cs | 2 + .../Rendering/SessionStatsReport.cs | 2 +- .../Rendering/SpectreRenderer.cs | 196 ++++++ .../Rendering/TableLayout.cs | 8 + .../Rendering/UsageReports.cs | 145 +++++ .../Models/AnalyticsDtos.cs | 106 +++- .../Providers/Claude/ClaudeUsageProvider.cs | 17 +- .../Providers/Claude/ProjectFilter.cs | 39 ++ .../Providers/Claude/SessionParser.cs | 63 -- .../Providers/Claude/SessionUsageBuilder.cs | 373 +++++++++++ .../Providers/IUsageProvider.cs | 12 +- .../Services/DataService.cs | 32 + .../Services/ServiceCollectionExtensions.cs | 1 + .../CommandActionTestBase.cs | 210 +++++++ .../CommandActionTests.cs | 235 +------ .../ConsoleMarkupTests.cs | 5 +- .../ListCommandActionTests.cs | 190 ++++++ .../ReportOptionsTests.cs | 2 +- .../UsageListTests.cs | 288 +++++++++ .../Models/ProjectUsageTests.cs | 125 ++++ .../Claude/ClaudeUsageProviderTests.cs | 1 + .../Providers/Claude/SessionParserTests.cs | 74 --- .../Claude/SessionUsageBuilderTests.cs | 577 ++++++++++++++++++ .../Services/DataServiceTests.cs | 61 ++ .../Services/DataServiceWatcherTests.cs | 1 + .../DashboardViewModelTests.cs | 3 + 33 files changed, 2786 insertions(+), 372 deletions(-) create mode 100644 src/AIUsageMonitor.Cli/Commands/ListOptions.cs create mode 100644 src/AIUsageMonitor.Cli/Commands/ProjectsCommand.cs create mode 100644 src/AIUsageMonitor.Cli/Commands/UsageList.cs create mode 100644 src/AIUsageMonitor.Cli/Commands/UsageSorting.cs create mode 100644 src/AIUsageMonitor.Cli/Rendering/TableLayout.cs create mode 100644 src/AIUsageMonitor.Cli/Rendering/UsageReports.cs create mode 100644 src/AIUsageMonitor.Core/Providers/Claude/ProjectFilter.cs create mode 100644 src/AIUsageMonitor.Core/Providers/Claude/SessionUsageBuilder.cs create mode 100644 tests/AIUsageMonitor.Cli.Tests/CommandActionTestBase.cs create mode 100644 tests/AIUsageMonitor.Cli.Tests/ListCommandActionTests.cs create mode 100644 tests/AIUsageMonitor.Cli.Tests/UsageListTests.cs create mode 100644 tests/AIUsageMonitor.Core.Tests/Models/ProjectUsageTests.cs create mode 100644 tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionUsageBuilderTests.cs diff --git a/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs b/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs index ae50a94..dadc0fc 100644 --- a/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs +++ b/src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs @@ -10,7 +10,13 @@ namespace AIUsageMonitor.Cli.Commands; /// The last day of a range (yyyy-MM-dd), or when not given. /// The number of days ending today, as typed, or when not given. /// A calendar month (yyyy-MM), or when not given. -internal sealed record DateRangeRequest(string? Date = null, string? From = null, string? To = null, int? Last = null, string? Month = null); +internal sealed record DateRangeRequest(string? Date = null, string? From = null, string? To = null, int? Last = null, string? Month = null) +{ + /// + /// Gets a value indicating whether any date argument was given. + /// + public bool HasAny => Date is not null || From is not null || To is not null || Last is not null || Month is not null; +} /// /// Defines the date-range options shared by the report commands and turns them into a concrete date range. diff --git a/src/AIUsageMonitor.Cli/Commands/ListOptions.cs b/src/AIUsageMonitor.Cli/Commands/ListOptions.cs new file mode 100644 index 0000000..4289407 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/ListOptions.cs @@ -0,0 +1,132 @@ +using System.CommandLine; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// The values of the list options a user supplied. +/// +/// The most rows to show, or for all. +/// The sort key, in lower case. +/// The project filter, trimmed, or when not given. +/// The model filter, trimmed, or when not given. +internal sealed record ListRequest(int? Top, string Sort, string? Project, string? Model); + +/// +/// Defines the options shared by the project and session lists: --top, --sort, --project and --model. +/// +internal sealed class ListOptions +{ + /// + /// The --top option. + /// + private readonly Option _top; + + /// + /// The --sort option. + /// + private readonly Option _sort; + + /// + /// The --project option. + /// + private readonly Option _project; + + /// + /// The --model option. + /// + private readonly Option _model; + + /// + /// Creates the options and adds them to . + /// + /// The command that receives the options. + /// The sort keys the command accepts. + /// The sort key used when --sort is not given. + public ListOptions(Command command, string[] sortKeys, string defaultSort) + { + _top = new Option("--top") { Description = "Only show the first N rows" }; + _top.Validators.Add(result => + { + if (result.GetValueOrDefault() < 1) + { + result.AddError("--top must be at least 1."); + } + }); + + _sort = new Option("--sort") + { + Description = $"Order, largest first: {string.Join(", ", sortKeys)}", + DefaultValueFactory = _ => defaultSort, + }; + _sort.Validators.Add(result => + { + var value = result.GetValueOrDefault(); + if (!sortKeys.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + result.AddError($"Unsupported sort '{value}'; expected one of: {string.Join(", ", sortKeys)}."); + } + }); + + _project = new Option("--project") + { + Description = "Only projects whose path or folder name contains this text (case-insensitive)" + }; + _model = new Option("--model") + { + Description = "Only count usage of models whose name contains this text (case-insensitive)" + }; + + command.Options.Add(_top); + command.Options.Add(_sort); + command.Options.Add(_project); + command.Options.Add(_model); + } + + /// + /// Reads the option values the user supplied. + /// + /// The parse result of the invoked command. + /// The list request. + public ListRequest Read(ParseResult parseResult) + { + return new ListRequest( + parseResult.GetValue(_top), + parseResult.GetValue(_sort)!.ToLowerInvariant(), + Trimmed(parseResult.GetValue(_project)), + Trimmed(parseResult.GetValue(_model))); + } + + /// + /// Determines whether the user typed any of these options, as opposed to leaving them at their defaults. + /// + /// The parse result of the invoked command. + /// when at least one option was given on the command line. + public bool AnySupplied(ParseResult parseResult) + { + return Supplied(parseResult, _top) + || Supplied(parseResult, _sort) + || Supplied(parseResult, _project) + || Supplied(parseResult, _model); + } + + /// + /// Determines whether an option appeared on the command line. + /// + /// The parse result of the invoked command. + /// The option to look for. + /// when the user typed the option. + private static bool Supplied(ParseResult parseResult, Option option) + { + return parseResult.GetResult(option) is { Implicit: false }; + } + + /// + /// Trims a filter text and turns blank text into . + /// + /// The text given by the user. + /// The trimmed text, or when it was missing or blank. + private static string? Trimmed(string? text) + { + return string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/ProjectsCommand.cs b/src/AIUsageMonitor.Cli/Commands/ProjectsCommand.cs new file mode 100644 index 0000000..1d9c1a3 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/ProjectsCommand.cs @@ -0,0 +1,45 @@ +using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Core.Services; +using System.CommandLine; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// Represents the "projects" command, which shows token usage and estimated cost per project. +/// +public static class ProjectsCommand +{ + /// + /// The length of the range used when no range option is given, ending today. + /// + private static readonly TimeSpan DefaultRange = TimeSpan.FromDays(30); + + /// + /// Creates a new instance of the "projects" command with the specified data service. + /// + /// The data service used to retrieve the project usage. + /// The clock that decides what "today" is; the system clock when . + /// A configured instance for showing usage per project. + public static Command Create(DataService dataService, TimeProvider? timeProvider = null) + { + var clock = timeProvider ?? TimeProvider.System; + var command = new Command("projects", "Show usage per project (default: last 30 days)"); + var rangeOptions = new DateRangeOptions(command, singleDay: false, month: true); + var listOptions = new ListOptions(command, UsageSorting.ProjectKeys, "tokens"); + var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(jsonOption); + var setup = new UsageListSetup(clock, DefaultRange, rangeOptions, listOptions, "usage"); + + command.SetAction(parseResult => UsageList.Run( + parseResult, + setup, + parseResult.GetValue(jsonOption), + load: (range, request, progress) => dataService.GetProjectUsage(range.From, range.To, request.Model, request.Project, progress), + sort: (projects, request) => UsageSorting.SortProjects(projects, request.Sort, request.Top), + writeJson: (range, total, shown) => + JsonOutput.Write(ProjectListReport.Create(range.From, range.To, total, shown), CliJsonContext.Default.ProjectListReport), + writeTable: (range, total, shown, request) => + SpectreRenderer.RenderProjectUsage(shown, total, range.From, range.To, UsageSorting.ShowsMessages(request.Sort)))); + return command; + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs b/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs index df70481..3be29c5 100644 --- a/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs +++ b/src/AIUsageMonitor.Cli/Commands/SessionsCommand.cs @@ -5,28 +5,63 @@ namespace AIUsageMonitor.Cli.Commands; /// -/// Represents the command for displaying session statistics in the AI Usage Monitor CLI application. +/// Represents the command for displaying session statistics, or a list of individual sessions, in the AI Usage Monitor CLI application. /// public static class SessionsCommand { /// - /// Creates a new instance of the "sessions" command, which displays session statistics. + /// The length of the range used by --list when no range option is given, ending today. /// - /// The data service used to retrieve session statistics. + private static readonly TimeSpan DefaultRange = TimeSpan.FromDays(30); + + /// + /// Creates a new instance of the "sessions" command, which displays session statistics or, with --list, a list of sessions. + /// + /// The data service used to retrieve session statistics and usage. + /// The clock that decides what "today" is; the system clock when . /// A configured instance for showing session statistics. - public static Command Create(DataService dataService) + public static Command Create(DataService dataService, TimeProvider? timeProvider = null) { - var command = new Command("sessions", "Show session statistics"); + var clock = timeProvider ?? TimeProvider.System; + var command = new Command("sessions", "Show session statistics, or list individual sessions with --list"); + var listOption = new Option("--list") + { + Description = "List individual sessions (default: last 30 days) instead of showing overall statistics" + }; + var rangeOptions = new DateRangeOptions(command, singleDay: false, month: true); + var listOptions = new ListOptions(command, UsageSorting.SessionKeys, "tokens"); var jsonOption = JsonOutput.CreateOption(); + command.Options.Add(listOption); command.Options.Add(jsonOption); + var setup = new UsageListSetup(clock, DefaultRange, rangeOptions, listOptions, "sessions"); command.SetAction(parseResult => { var json = parseResult.GetValue(jsonOption); + if (parseResult.GetValue(listOption)) + { + return UsageList.Run( + parseResult, + setup, + json, + load: (range, request, progress) => dataService.GetSessionUsage(range.From, range.To, request.Model, request.Project, progress), + sort: (sessions, request) => UsageSorting.SortSessions(sessions, request.Sort, request.Top), + writeJson: (range, total, shown) => + JsonOutput.Write(SessionListReport.Create(range.From, range.To, total, shown), CliJsonContext.Default.SessionListReport), + writeTable: (range, total, shown, request) => + SpectreRenderer.RenderSessionList(shown, total, range.From, range.To, UsageSorting.ShowsMessages(request.Sort))); + } + + if (rangeOptions.Read(parseResult).HasAny || listOptions.AnySupplied(parseResult)) + { + Console.Error.WriteLine("The date range options, --top, --sort, --project and --model require --list."); + return 1; + } + var stats = JsonOutput.Load(json, "Loading usage data...", dataService.GetSessionStats); if (json) { - JsonOutput.Write(SessionStatsReport.From(stats), CliJsonContext.Default.SessionStatsReport); + JsonOutput.Write(SessionStatsReport.Create(stats), CliJsonContext.Default.SessionStatsReport); } else { diff --git a/src/AIUsageMonitor.Cli/Commands/UsageList.cs b/src/AIUsageMonitor.Cli/Commands/UsageList.cs new file mode 100644 index 0000000..1684570 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/UsageList.cs @@ -0,0 +1,69 @@ +using System.CommandLine; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// What a list command fixes once, when it is created, and passes to every run of . +/// +/// The clock that decides what "today" is. +/// The length of the range used when no range option is given, ending today. +/// The date-range options of the command. +/// The list options of the command. +/// What the empty-result hint says was not found, e.g. sessions. +internal sealed record UsageListSetup( + TimeProvider Clock, TimeSpan DefaultRange, DateRangeOptions RangeOptions, ListOptions ListOptions, string Noun); + +/// +/// The flow shared by the project and session lists: resolve the date range, read the list options, load, order, and print +/// JSON, a hint when nothing matched, or the table. +/// +internal static class UsageList +{ + /// + /// Runs a list command. + /// + /// The type of the rows being listed. + /// The parse result of the invoked command. + /// What the command fixed when it was created. + /// Whether to print JSON. + /// Loads every matching row for a date range and the list options, reporting progress. + /// Orders the rows and keeps the first few, as the list options ask. + /// Prints the JSON report for the range, the number of matching rows and the rows shown. + /// Prints the table for the range, the number of matching rows, the rows shown and the list options. + /// The exit code: 0 on success, 1 when the date range is invalid. + public static int Run( + ParseResult parseResult, + UsageListSetup setup, + bool json, + Func?, List> load, + Func, ListRequest, List> sort, + Action> writeJson, + Action, ListRequest> writeTable) + { + var resolution = DateRangeResolver.Resolve(setup.RangeOptions.Read(parseResult), DateRangeResolver.Today(setup.Clock), setup.DefaultRange); + if (resolution.Range is not { } range) + { + Console.Error.WriteLine(resolution.Error); + return 1; + } + + var request = setup.ListOptions.Read(parseResult); + var items = JsonOutput.Load(json, "Loading usage data...", progress => load(range, request, progress)); + var shown = sort(items, request); + + if (json) + { + writeJson(range, items.Count, shown); + } + else if (items.Count == 0) + { + Console.Error.WriteLine($"No {setup.Noun} found between {range.From:yyyy-MM-dd} and {range.To:yyyy-MM-dd}."); + } + else + { + writeTable(range, items.Count, shown, request); + } + + return 0; + } +} diff --git a/src/AIUsageMonitor.Cli/Commands/UsageSorting.cs b/src/AIUsageMonitor.Cli/Commands/UsageSorting.cs new file mode 100644 index 0000000..969000f --- /dev/null +++ b/src/AIUsageMonitor.Cli/Commands/UsageSorting.cs @@ -0,0 +1,88 @@ +using AIUsageMonitor.Core.Models; + +namespace AIUsageMonitor.Cli.Commands; + +/// +/// Orders and truncates the project and session lists. Every order is descending by its key (newest first for +/// start), with the identifier as a tiebreak so equal rows keep a stable position. +/// +internal static class UsageSorting +{ + /// + /// The sort key that orders by the number of messages, the one key whose value the tables only show when asked. + /// + public const string MessagesKey = "messages"; + + /// + /// The sort keys aimon projects accepts. + /// + public static readonly string[] ProjectKeys = ["tokens", "cost", "sessions", MessagesKey]; + + /// + /// The sort keys aimon sessions --list accepts. + /// + public static readonly string[] SessionKeys = ["tokens", "cost", MessagesKey, "duration", "start"]; + + /// + /// Determines whether the tables need a Messages column to show the value the rows are ordered by. + /// + /// The sort key. + /// when the rows are ordered by the number of messages. + public static bool ShowsMessages(string sort) + { + return sort == MessagesKey; + } + + /// + /// Orders projects and keeps the first few. + /// + /// The projects to order. + /// One of . + /// The most projects to keep, or to keep all. + /// The ordered, truncated projects. + public static List SortProjects(IEnumerable projects, string sort, int? top) + { + var ordered = sort switch + { + "cost" => projects.OrderByDescending(p => p.EstimatedCost), + "sessions" => projects.OrderByDescending(p => p.Sessions), + MessagesKey => projects.OrderByDescending(p => p.Messages), + _ => projects.OrderByDescending(p => p.TotalTokens), + }; + + return Take(ordered.ThenBy(p => p.ProjectKey, StringComparer.OrdinalIgnoreCase), top); + } + + /// + /// Orders sessions and keeps the first few. + /// + /// The sessions to order. + /// One of . + /// The most sessions to keep, or to keep all. + /// The ordered, truncated sessions. + public static List SortSessions(IEnumerable sessions, string sort, int? top) + { + var ordered = sort switch + { + "cost" => sessions.OrderByDescending(s => s.EstimatedCost), + MessagesKey => sessions.OrderByDescending(s => s.Messages), + "duration" => sessions.OrderByDescending(s => s.Duration), + "start" => sessions.OrderByDescending(s => s.Start), + _ => sessions.OrderByDescending(s => s.TotalTokens), + }; + + return Take(ordered.ThenBy(s => s.SessionId, StringComparer.Ordinal), top); + } + + /// + /// Materializes an ordered sequence, keeping only the first few items. + /// + /// The type of the items. + /// The ordered items. + /// The most items to keep, or to keep all. + /// The kept items. + private static List Take(IEnumerable ordered, int? top) + { + return (top is { } count ? ordered.Take(count) : ordered).ToList(); + } +} diff --git a/src/AIUsageMonitor.Cli/Program.cs b/src/AIUsageMonitor.Cli/Program.cs index 75e433a..52c53c7 100644 --- a/src/AIUsageMonitor.Cli/Program.cs +++ b/src/AIUsageMonitor.Cli/Program.cs @@ -39,6 +39,7 @@ rootCommand.Subcommands.Add(MonthCommand.Create(dataService)); rootCommand.Subcommands.Add(ModelsCommand.Create(dataService)); rootCommand.Subcommands.Add(SessionsCommand.Create(dataService)); + rootCommand.Subcommands.Add(ProjectsCommand.Create(dataService)); rootCommand.Subcommands.Add(HoursCommand.Create(dataService)); rootCommand.Subcommands.Add(ExportCommand.Create(dataService)); var watchCommand = WatchCommand.Create(dataService, updateCheckLogger); diff --git a/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs b/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs index 8117832..1113648 100644 --- a/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs +++ b/src/AIUsageMonitor.Cli/Rendering/CliJsonContext.cs @@ -11,5 +11,7 @@ namespace AIUsageMonitor.Cli.Rendering; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SessionStatsReport))] +[JsonSerializable(typeof(ProjectListReport))] +[JsonSerializable(typeof(SessionListReport))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] internal sealed partial class CliJsonContext : JsonSerializerContext; diff --git a/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs b/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs index 721893e..ce5de6f 100644 --- a/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs +++ b/src/AIUsageMonitor.Cli/Rendering/SessionStatsReport.cs @@ -22,7 +22,7 @@ internal sealed record SessionStatsReport( /// /// The statistics to convert. /// The JSON-shaped report. - public static SessionStatsReport From(SessionStats stats) + public static SessionStatsReport Create(SessionStats stats) { return new SessionStatsReport( stats.Total, diff --git a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs index 45f1e2f..2e1d335 100644 --- a/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs +++ b/src/AIUsageMonitor.Cli/Rendering/SpectreRenderer.cs @@ -128,6 +128,178 @@ public static IRenderable BuildModelDistribution(List models) return table; } + /// + /// The narrowest a project name is shortened to, however little room the other columns leave. + /// + private const int MinProjectNameWidth = 12; + + /// + /// The widest a project name is shown, however much room there is. + /// + private const int MaxProjectNameWidth = 80; + + /// + /// Builds a table of usage per project. + /// + /// The projects to show, in order. + /// The number of projects that matched before the list was truncated. + /// The first day of the range. + /// The last day of the range. + /// The console width and which optional columns to show. + /// An table of projects. + public static IRenderable BuildProjectUsage( + IReadOnlyList shown, int totalCount, DateOnly from, DateOnly to, TableLayout layout) + { + var columns = new List<(string Header, bool RightAligned)> { ("Sessions", true) }; + if (layout.ShowMessages) + { + columns.Add(("Messages", true)); + } + + columns.Add(("Tokens", true)); + columns.Add(("Est. Cost", true)); + columns.Add(("Last Active", false)); + + var rows = shown.Select(project => + { + var cells = new List { FormatCount(project.Sessions) }; + if (layout.ShowMessages) + { + cells.Add(FormatCount(project.Messages)); + } + + cells.Add(FormatTokens(project.TotalTokens)); + cells.Add(FormatCost(project.EstimatedCost)); + cells.Add(FormatLocalTime(project.LastActivity, "yyyy-MM-dd")); + return (Name: project.ProjectPath ?? project.ProjectKey, Cells: cells); + }).ToList(); + + var caption = shown.Count < totalCount ? $"Showing {shown.Count} of {totalCount} projects" : null; + return BuildNamedTable($"{from:yyyy-MM-dd} ~ {to:yyyy-MM-dd}", caption, columns, nameIndex: 0, rows, layout.ConsoleWidth); + } + + /// + /// Builds a table of sessions. + /// + /// The sessions to show, in order. + /// The number of sessions that matched before the list was truncated. + /// The first day of the range. + /// The last day of the range. + /// The console width and which optional columns to show. + /// An table of sessions. + public static IRenderable BuildSessionList( + IReadOnlyList shown, int totalCount, DateOnly from, DateOnly to, TableLayout layout) + { + // Without a year two sessions of different years can look identical, so a range across years shows it. + var startFormat = from.Year == to.Year ? "MM-dd HH:mm" : "yyyy-MM-dd HH:mm"; + var columns = new List<(string Header, bool RightAligned)> { ("Session", false), ("Started", false), ("Duration", true) }; + if (layout.ShowMessages) + { + columns.Add(("Messages", true)); + } + + columns.Add(("Tokens", true)); + columns.Add(("Est. Cost", true)); + + var rows = shown.Select(session => + { + var cells = new List + { + session.SessionId.Length > 8 ? session.SessionId[..8] : session.SessionId, + FormatLocalTime(session.Start, startFormat), + FormatDuration(session.Duration), + }; + if (layout.ShowMessages) + { + cells.Add(FormatCount(session.Messages)); + } + + cells.Add(FormatTokens(session.TotalTokens)); + cells.Add(FormatCost(session.EstimatedCost)); + return (Name: session.ProjectPath ?? session.ProjectKey, Cells: cells); + }).ToList(); + + var caption = shown.Count < totalCount ? $"Showing {shown.Count} of {totalCount} sessions" : null; + return BuildNamedTable($"{from:yyyy-MM-dd} ~ {to:yyyy-MM-dd}", caption, columns, nameIndex: 1, rows, layout.ConsoleWidth); + } + + /// + /// Builds a table whose "Project" column takes the width the other columns leave free, so the name is shortened from the + /// left to fit instead of being wrapped in the middle of a word. + /// + /// The title above the table. + /// The caption below the table, or for none. + /// The columns other than the project name. + /// The position of the project name among all columns. + /// The rows: a project name and the cells of the other columns. + /// The width of the console, in characters. + /// The table. + private static Table BuildNamedTable( + string title, + string? caption, + IReadOnlyList<(string Header, bool RightAligned)> columns, + int nameIndex, + IReadOnlyList<(string Name, List Cells)> rows, + int consoleWidth) + { + var table = new Table().Title(title); + if (caption is not null) + { + table.Caption(caption); + } + + // Each column costs its widest cell, two spaces of padding and a border; the table has one more border. + var othersWidth = columns.Select((column, index) => + Math.Max(column.Header.Length, rows.Select(row => row.Cells[index].Length).DefaultIfEmpty(0).Max())).Sum(); + var overhead = 3 * (columns.Count + 1) + 1; + var nameWidth = Math.Clamp(consoleWidth - overhead - othersWidth, MinProjectNameWidth, MaxProjectNameWidth); + + var allColumns = columns.ToList(); + allColumns.Insert(nameIndex, ("Project", false)); + for (var index = 0; index < allColumns.Count; index++) + { + var (header, rightAligned) = allColumns[index]; + var column = new TableColumn(header); + if (index != nameIndex) + { + column = column.NoWrap(); + } + + table.AddColumn(rightAligned ? column.RightAligned() : column); + } + + foreach (var (name, cells) in rows) + { + var row = cells.Select(Markup.Escape).ToList(); + row.Insert(nameIndex, Markup.Escape(ShortenPath(name, nameWidth))); + table.AddRow(row.ToArray()); + } + + return table; + } + + /// + /// Shortens a path from the left, keeping its tail, which is the part that tells projects apart. + /// + /// The path to shorten. + /// The most characters to return, the ellipsis included. + /// The path, or an ellipsis followed by its last characters when it is longer than . + internal static string ShortenPath(string path, int maxLength) + { + return path.Length <= maxLength ? path : "…" + path[^(maxLength - 1)..]; + } + + /// + /// Formats a moment in the machine's local time zone. + /// + /// The moment to format. + /// The custom date and time format. + /// The local date and time, formatted with the invariant culture. + private static string FormatLocalTime(DateTimeOffset moment, string format) + { + return moment.ToLocalTime().ToString(format, CultureInfo.InvariantCulture); + } + /// /// Builds a renderable summary for a date range, including key stats and a daily token chart. /// @@ -359,6 +531,30 @@ public static IRenderable BuildUsageLimits( /// The period summary data to render. public static void RenderPeriodSummary(PeriodSummary summary) => AnsiConsole.Write(BuildPeriodSummary(summary)); + /// + /// Writes a table of usage per project to the console, sized to the console's width. + /// + /// The projects to show, in order. + /// The number of projects that matched before the list was truncated. + /// The first day of the range. + /// The last day of the range. + /// Whether to add a Messages column. + public static void RenderProjectUsage( + IReadOnlyList shown, int totalCount, DateOnly from, DateOnly to, bool showMessages) => + AnsiConsole.Write(BuildProjectUsage(shown, totalCount, from, to, new TableLayout(AnsiConsole.Profile.Width, showMessages))); + + /// + /// Writes a table of sessions to the console, sized to the console's width. + /// + /// The sessions to show, in order. + /// The number of sessions that matched before the list was truncated. + /// The first day of the range. + /// The last day of the range. + /// Whether to add a Messages column. + public static void RenderSessionList( + IReadOnlyList shown, int totalCount, DateOnly from, DateOnly to, bool showMessages) => + AnsiConsole.Write(BuildSessionList(shown, totalCount, from, to, new TableLayout(AnsiConsole.Profile.Width, showMessages))); + /// /// Renders the recent activity summary directly to the console. /// diff --git a/src/AIUsageMonitor.Cli/Rendering/TableLayout.cs b/src/AIUsageMonitor.Cli/Rendering/TableLayout.cs new file mode 100644 index 0000000..1a55c22 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Rendering/TableLayout.cs @@ -0,0 +1,8 @@ +namespace AIUsageMonitor.Cli.Rendering; + +/// +/// The facts that decide how the project and session tables are laid out. +/// +/// The width of the console, in characters; the project name takes whatever the other columns leave. +/// Whether to add a Messages column; it is only shown when the rows are ordered by it, to keep the table narrow. +public readonly record struct TableLayout(int ConsoleWidth, bool ShowMessages); diff --git a/src/AIUsageMonitor.Cli/Rendering/UsageReports.cs b/src/AIUsageMonitor.Cli/Rendering/UsageReports.cs new file mode 100644 index 0000000..3505dc0 --- /dev/null +++ b/src/AIUsageMonitor.Cli/Rendering/UsageReports.cs @@ -0,0 +1,145 @@ +using AIUsageMonitor.Core.Models; +using System.Globalization; + +namespace AIUsageMonitor.Cli.Rendering; + +/// +/// The JSON shape of one project in . +/// +/// The name of the folder under projects/. +/// The working directory of the project, if one was recorded. +/// The number of sessions. +/// The number of counted transcript lines. +/// The number of tool calls. +/// The total tokens used. +/// The tokens used per model. +/// The estimated cost, in US dollars. +/// The time of the first counted message. +/// The time of the last counted message. +internal sealed record ProjectUsageItem( + string ProjectKey, + string? ProjectPath, + int Sessions, + int Messages, + int ToolCalls, + long TotalTokens, + Dictionary TokensByModel, + decimal EstimatedCost, + DateTimeOffset FirstActivity, + DateTimeOffset LastActivity) +{ + /// + /// Converts project usage to its JSON shape. + /// + /// The project usage. + /// The JSON-shaped item. + public static ProjectUsageItem Create(ProjectUsage project) + { + return new ProjectUsageItem( + project.ProjectKey, project.ProjectPath, project.Sessions, project.Messages, project.ToolCalls, + project.TotalTokens, project.TokensByModel, project.EstimatedCost, project.FirstActivity, project.LastActivity); + } +} + +/// +/// The JSON shape printed by aimon projects --json. +/// +/// The first day of the range, as yyyy-MM-dd. +/// The last day of the range, as yyyy-MM-dd. +/// The number of projects that matched, before --top was applied. +/// The projects shown, in the requested order. +internal sealed record ProjectListReport(string From, string To, int TotalProjects, List Projects) +{ + /// + /// Builds the report. + /// + /// The first day of the range. + /// The last day of the range. + /// The number of projects that matched. + /// The projects shown, in order. + /// The report. + public static ProjectListReport Create(DateOnly from, DateOnly to, int totalProjects, IEnumerable shown) + { + return new ProjectListReport( + from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + totalProjects, + shown.Select(ProjectUsageItem.Create).ToList()); + } +} + +/// +/// The JSON shape of one session in , with the duration as plain seconds. +/// +/// The session id. +/// The name of the folder under projects/. +/// The working directory, if one was recorded. +/// The time of the first counted message. +/// The time of the last counted message. +/// The wall-clock time between the first and last message, idle gaps included, in seconds. +/// The number of counted transcript lines. +/// The number of tool calls. +/// The total tokens used. +/// The input tokens used. +/// The output tokens used. +/// The tokens read from the prompt cache. +/// The tokens written to the prompt cache. +/// The tokens used per model. +/// The estimated cost, in US dollars. +internal sealed record SessionUsageItem( + string SessionId, + string ProjectKey, + string? ProjectPath, + DateTimeOffset Start, + DateTimeOffset End, + double DurationSeconds, + int Messages, + int ToolCalls, + long TotalTokens, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheWriteTokens, + Dictionary TokensByModel, + decimal EstimatedCost) +{ + /// + /// Converts session usage to its JSON shape. + /// + /// The session usage. + /// The JSON-shaped item. + public static SessionUsageItem Create(SessionUsage session) + { + return new SessionUsageItem( + session.SessionId, session.ProjectKey, session.ProjectPath, session.Start, session.End, + session.Duration.TotalSeconds, session.Messages, session.ToolCalls, session.TotalTokens, session.InputTokens, + session.OutputTokens, session.CacheReadTokens, session.CacheWriteTokens, session.TokensByModel, session.EstimatedCost); + } +} + +/// +/// The JSON shape printed by aimon sessions --list --json. +/// +/// The first day of the range, as yyyy-MM-dd. +/// The last day of the range, as yyyy-MM-dd. +/// The number of sessions that matched, before --top was applied. +/// The sessions shown, in the requested order. +internal sealed record SessionListReport(string From, string To, int TotalSessions, List Sessions) +{ + /// + /// Builds the report. + /// + /// The first day of the range. + /// The last day of the range. + /// The number of sessions that matched. + /// The sessions shown, in order. + /// The report. + public static SessionListReport Create(DateOnly from, DateOnly to, int totalSessions, IEnumerable shown) + { + return new SessionListReport( + from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + totalSessions, + shown.Select(SessionUsageItem.Create).ToList()); + } +} diff --git a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs index a3b5672..6cffd53 100644 --- a/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs +++ b/src/AIUsageMonitor.Core/Models/AnalyticsDtos.cs @@ -184,25 +184,103 @@ public sealed record SessionStats( string? LongestSessionId); /// -/// Represents a summary of usage activity for a single session. +/// Represents the usage of one session within a date range. A session that started before the range shows only +/// what happened inside it, and a session resumed into a new transcript is one session however many files it spans. /// /// The unique identifier of the session. -/// The name of the project associated with the session, or if unavailable. -/// The timestamp when the session started. -/// The timestamp when the session ended. -/// The total duration of the session. -/// The total number of messages sent during the session. -/// The total number of tokens used during the session. +/// The name of the folder under projects/ that holds the session's earliest counted line. +/// The working directory of the session's earliest counted line that has one, or if unavailable. +/// The time of the first counted message. +/// The time of the last counted message. +/// The number of counted transcript lines; a streamed response is written as several lines, so this is not the number of turns. +/// The number of tool calls. +/// The total tokens used: input, output, cache reads and cache writes. +/// The input tokens used. +/// The output tokens used. +/// The tokens read from the prompt cache. +/// The tokens written to the prompt cache. /// A mapping of model name to the number of tokens consumed by that model. -public sealed record SessionSummary( +/// The estimated cost of the session, in US dollars. +public sealed record SessionUsage( string SessionId, - string? Project, - DateTimeOffset StartTime, - DateTimeOffset EndTime, - TimeSpan Duration, - int MessageCount, + string ProjectKey, + string? ProjectPath, + DateTimeOffset Start, + DateTimeOffset End, + int Messages, + int ToolCalls, + long TotalTokens, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheWriteTokens, + Dictionary TokensByModel, + decimal EstimatedCost) +{ + /// + /// Gets the wall-clock time between the first and last counted message, idle gaps included. + /// + public TimeSpan Duration => End - Start; +} + +/// +/// Represents the usage of one project within a date range. +/// +/// The name of the folder under projects/. +/// The working directory of the project's earliest session that has one, or if unavailable. +/// The number of sessions. +/// The number of counted transcript lines. +/// The number of tool calls. +/// The total tokens used. +/// A mapping of model name to the number of tokens consumed by that model. +/// The estimated cost, in US dollars. +/// The time of the project's first counted message. +/// The time of the project's last counted message. +public sealed record ProjectUsage( + string ProjectKey, + string? ProjectPath, + int Sessions, + int Messages, + int ToolCalls, long TotalTokens, - Dictionary TokensByModel); + Dictionary TokensByModel, + decimal EstimatedCost, + DateTimeOffset FirstActivity, + DateTimeOffset LastActivity) +{ + /// + /// Groups sessions by project folder (case-insensitively) and sums their usage. + /// + /// The sessions to group. + /// One entry per project folder, in no particular order. + public static List FromSessions(IEnumerable sessions) + { + return sessions + .GroupBy(s => s.ProjectKey, StringComparer.OrdinalIgnoreCase) + .Select(group => + { + var byStart = group.OrderBy(s => s.Start).ThenBy(s => s.SessionId, StringComparer.Ordinal).ToList(); + var tokensByModel = new Dictionary(); + foreach (var (model, tokens) in byStart.SelectMany(s => s.TokensByModel)) + { + tokensByModel[model] = tokensByModel.GetValueOrDefault(model) + tokens; + } + + return new ProjectUsage( + byStart[0].ProjectKey, + byStart.Select(s => s.ProjectPath).FirstOrDefault(p => p is not null), + byStart.Count, + byStart.Sum(s => s.Messages), + byStart.Sum(s => s.ToolCalls), + byStart.Sum(s => s.TotalTokens), + tokensByModel, + byStart.Sum(s => s.EstimatedCost), + byStart[0].Start, + byStart.Max(s => s.End)); + }) + .ToList(); + } +} /// /// Represents the full data payload produced when exporting usage data. diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs index df6ebc4..6d92578 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs @@ -13,6 +13,7 @@ namespace AIUsageMonitor.Core.Providers.Claude; /// Builds a summary of recent activity from session transcripts. /// Builds hourly activity data from session transcripts. /// Builds current-session and weekly usage window summaries from session transcripts. +/// Builds per-session usage for a range of days from session transcripts. /// Tracks the latest session file write time without re-scanning the projects directory on every call. /// Logger used to report cache fallback diagnostics. public sealed class ClaudeUsageProvider( @@ -22,6 +23,7 @@ public sealed class ClaudeUsageProvider( RecentActivityBuilder recentActivityBuilder, HourlyActivityBuilder hourlyActivityBuilder, SessionBlockBuilder sessionBlockBuilder, + SessionUsageBuilder sessionUsageBuilder, SessionActivityTracker sessionActivityTracker, ILogger logger) : IUsageProvider { @@ -121,4 +123,17 @@ public UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anc { return sessionBlockBuilder.BuildWeekWindow(locator.GetSessionFiles(), anchor, progress); } -} \ No newline at end of file + + /// + /// Builds per-session usage for a range of local days from Claude session transcripts. + /// + /// The first local day to include. + /// The last local day to include. + /// When set, only usage of models whose name contains this text is counted. + /// Optional progress reporter for tracking build progress (0-100). + /// One per session with activity in the range, ordered by start time. + public List GetSessionUsage(DateOnly from, DateOnly to, string? model, IProgress? progress = null) + { + return sessionUsageBuilder.Build(locator.GetSessionFiles(), locator.ProjectsDir, from, to, model, progress); + } +} diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ProjectFilter.cs b/src/AIUsageMonitor.Core/Providers/Claude/ProjectFilter.cs new file mode 100644 index 0000000..ec8b6bf --- /dev/null +++ b/src/AIUsageMonitor.Core/Providers/Claude/ProjectFilter.cs @@ -0,0 +1,39 @@ +using AIUsageMonitor.Core.Models; + +namespace AIUsageMonitor.Core.Providers.Claude; + +/// +/// Decides whether a session belongs to a project the user asked for by name. +/// +internal static class ProjectFilter +{ + /// + /// Determines whether a session matches a project filter. The text is matched case-insensitively as a substring of the + /// project's working directory or of its folder name under projects/ (an encoded path, so it also matches + /// when no working directory was recorded), with \ and / treated alike. + /// + /// The session to test. + /// The text to look for; every session matches when it is or blank. + /// when the session matches. + public static bool Matches(SessionUsage session, string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + var needle = Normalize(text.Trim()); + return (session.ProjectPath is not null && Normalize(session.ProjectPath).Contains(needle, StringComparison.OrdinalIgnoreCase)) + || session.ProjectKey.Contains(text.Trim(), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Makes path separators uniform. + /// + /// The path or text to normalize. + /// The text with every backslash replaced by a forward slash. + private static string Normalize(string path) + { + return path.Replace('\\', '/'); + } +} diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs index 1c42d0d..a821a03 100644 --- a/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionParser.cs @@ -160,69 +160,6 @@ private static bool IsAllWhiteSpace(ReadOnlySpan line) return true; } - /// - /// Parses a file containing session messages and returns a summary of the session, including the session ID, project, start and end times, duration, message count, total tokens used, and tokens used by model. - /// - /// The path to the file containing the session messages. - /// A object containing the summary of the session, or null if no messages were found. - public SessionSummary? ParseSessionSummary(string filePath) - { - var messages = ParseFile(filePath).ToList(); - if (messages.Count == 0) - { - return null; - } - - var sessionId = messages.FirstOrDefault(m => m.SessionId is not null)?.SessionId - ?? Path.GetFileNameWithoutExtension(filePath); - var project = messages.FirstOrDefault(m => m.Cwd is not null)?.Cwd; - - var timestamps = messages - .Where(m => m.Timestamp is not null) - .Select(m => m.Timestamp!.Value) - .OrderBy(t => t) - .ToList(); - - if (timestamps.Count == 0) - { - return null; - } - - var startTime = timestamps[0]; - var endTime = timestamps[^1]; - - var assistantMessages = messages - .Where(m => m is { Type: "assistant", Message.Usage: not null }) - .DistinctBy(m => (m.Message!.Id, m.RequestId) is (null, null) - ? (object)m.Uuid! - : (m.Message!.Id, m.RequestId)) - .ToList(); - - long totalTokens = 0; - var tokensByModel = new Dictionary(); - - foreach (var msg in assistantMessages) - { - var usage = msg.Message!.Usage!; - var msgTokens = usage.InputTokens + usage.OutputTokens - + usage.CacheReadInputTokens + usage.CacheCreationInputTokens; - totalTokens += msgTokens; - - var model = msg.Message.Model ?? "unknown"; - tokensByModel[model] = tokensByModel.GetValueOrDefault(model) + msgTokens; - } - - return new( - sessionId, - project, - startTime, - endTime, - endTime - startTime, - messages.Count(m => m.Type is "user" or "assistant"), - totalTokens, - tokensByModel); - } - /// /// Opens the given file for shared reading, retrying briefly if another process (e.g. the Claude CLI) /// currently has an exclusive lock on it. diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionUsageBuilder.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionUsageBuilder.cs new file mode 100644 index 0000000..ee5b7e4 --- /dev/null +++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionUsageBuilder.cs @@ -0,0 +1,373 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Models; +using AIUsageMonitor.Core.Providers.Claude.Models; + +namespace AIUsageMonitor.Core.Providers.Claude; + +/// +/// Aggregates raw session transcripts into per-session usage for a range of local days. A session is identified by the +/// session id on each line, so one resumed across several transcript files is still one session, and lines copied into +/// a resumed transcript are counted once, as in the other transcript aggregations. +/// +/// The session file cache used to retrieve session messages from session files. +/// The cost calculator used to estimate costs based on token usage. +/// The clock and local time zone used to place lines on local days; defaults to the system clock. +public sealed class SessionUsageBuilder( + SessionFileCache sessionFileCache, CostCalculator costCalculator, TimeProvider? timeProvider = null) +{ + /// + /// The earliest moment a range is widened to cover; no transcript is older. + /// + private static readonly DateTimeOffset EarliestBound = new(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + /// + /// The latest moment a range is widened to cover; no transcript is newer. + /// + private static readonly DateTimeOffset LatestBound = new(9999, 12, 1, 0, 0, 0, TimeSpan.Zero); + + /// + /// The clock whose local time zone decides which calendar day a line belongs to, read on every build. + /// + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + /// + /// Builds per-session usage for the specified session files and range of local days. + /// + /// The session file paths to process. + /// The directory the session files live under; the first folder below it names the project. + /// The first local day to include. + /// The last local day to include. + /// + /// When set, only assistant lines of models whose name contains this text (case-insensitive) are counted, for + /// messages, tool calls, tokens and cost alike. + /// + /// An optional progress reporter for tracking build progress (0-100). + /// + /// One entry per session with at least one counted line, ordered by start time. A session that began before + /// shows only what happened inside the range. + /// + public List Build( + IReadOnlyList sessionFiles, + string projectsDir, + DateOnly from, + DateOnly to, + string? modelFilter = null, + IProgress? progress = null) + { + var zone = _clock.LocalTimeZone; + + // Local days map to UTC instants within +-14 hours of midnight UTC, so a day margin on each side never drops a line + // that belongs to the range; the exact local-day check below decides. + var coarseFrom = CoarseBound(from, -1); + var coarseTo = CoarseBound(to, 2); + var model = string.IsNullOrWhiteSpace(modelFilter) ? null : modelFilter.Trim(); + + var candidateFiles = sessionFileCache.GetFilesModifiedSince(sessionFiles, coarseFrom) + .Order(StringComparer.Ordinal) + .ToList(); + sessionFileCache.WarmUp(candidateFiles, progress); + if (candidateFiles.Count == 0) + { + progress?.Report(100); + } + + var transcripts = candidateFiles + .Select(file => Partition(file, projectsDir, coarseFrom, coarseTo)) + .OfType() + .ToList(); + + var deduplicator = new TranscriptDeduplicator(); + var sessions = new Dictionary(); + + // Claude Code names a transcript after its session, so a line sits in its home transcript when its session id matches + // the file name. Home lines are counted first: when a resumed transcript in another folder copies them, the copy is + // the duplicate, and the original's folder is the one the session is credited to, whatever the files' times or names. + foreach (var transcript in transcripts) + { + Count(transcript, transcript.HomeLines); + } + + foreach (var transcript in transcripts) + { + Count(transcript, transcript.OtherLines); + } + + return sessions + .Select(pair => pair.Value.ToUsage(pair.Key, costCalculator)) + .OrderBy(s => s.Start) + .ThenBy(s => s.SessionId, StringComparer.Ordinal) + .ToList(); + + void Count(TranscriptLines transcript, List lines) + { + foreach (var (ts, msg) in lines) + { + // A line copied in from the transcript this session was resumed from was already counted there. + if (!deduplicator.TryAddLine(msg)) + { + continue; + } + + // A streamed response is several lines sharing one usage; the first one claims it, wherever it falls, so a + // response straddling the edge of the range is attributed to the same day as in the daily statistics. + var usage = msg.Message?.Usage; + var lineModel = msg.Message?.Model ?? "unknown"; + var claimsUsage = msg.Type == "assistant" + && usage is not null + && lineModel != "" + && deduplicator.TryAddUsage(msg); + + var day = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(ts, zone).DateTime); + if (day < from || day > to) + { + continue; + } + + if (model is not null + && (msg.Type != "assistant" || !lineModel.Contains(model, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var sessionId = msg.SessionId ?? transcript.FallbackSessionId; + if (!sessions.TryGetValue(sessionId, out var session)) + { + session = new SessionAccumulator(); + sessions[sessionId] = session; + } + + session.AddLine(ts, transcript.Folder, msg.Cwd, msg.Message?.ToolUseCount ?? 0); + if (claimsUsage) + { + session.AddUsage(lineModel, usage!); + } + } + } + } + + /// + /// Reads a transcript and sorts its message lines that fall inside the widened range into home lines and other lines. + /// + /// The path of the transcript. + /// The directory the session files live under. + /// The earliest moment a line may have. + /// The moment from which lines are too late. + /// The sorted lines, or when the transcript cannot be read. + private TranscriptLines? Partition(string file, string projectsDir, DateTimeOffset coarseFrom, DateTimeOffset coarseTo) + { + IReadOnlyList parsed; + try + { + parsed = sessionFileCache.GetRows(file); + } + catch + { + return null; + } + + var fileName = Path.GetFileNameWithoutExtension(file); + var home = new List(); + var other = new List(); + + foreach (var msg in parsed) + { + if (msg.Timestamp is not { } ts || ts < coarseFrom || ts >= coarseTo) + { + continue; + } + + if (msg.Type is not "user" and not "assistant") + { + continue; + } + + // Lines without a session id take the identity of their file, so they are home lines too. + (msg.SessionId is null || msg.SessionId == fileName ? home : other).Add(new TimedLine(ts, msg)); + } + + // Only a fallback for lines without their own session id; see StatsCacheBuilder for why the first line's id cannot + // stand for the whole file. + var fallbackSessionId = parsed.FirstOrDefault(m => m.SessionId is not null)?.SessionId ?? fileName; + return new TranscriptLines(ProjectFolder(projectsDir, file), fallbackSessionId, home, other); + } + + /// + /// Gets the start of the UTC day a number of days away from a local day, kept within a range where date arithmetic + /// cannot overflow, so a range such as to means everything. + /// + /// The local day. + /// The days to move, negative for earlier. + /// The moment, clamped to January 1970 at the earliest and December 9999 at the latest. + private static DateTimeOffset CoarseBound(DateOnly day, int marginDays) + { + var number = Math.Clamp((long)day.DayNumber + marginDays, DateOnly.MinValue.DayNumber, DateOnly.MaxValue.DayNumber); + var start = new DateTimeOffset(DateOnly.FromDayNumber((int)number).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + return start < EarliestBound ? EarliestBound : start > LatestBound ? LatestBound : start; + } + + /// + /// Finds the project folder a transcript belongs to: the first folder below the projects directory, which also covers + /// transcripts nested deeper, such as those of sub-agents. + /// + /// The directory the session files live under. + /// The path of the transcript. + /// The name of the project folder. + internal static string ProjectFolder(string projectsDir, string file) + { + var relative = Path.GetRelativePath(projectsDir, file); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (segments.Length > 1 && segments[0] != "..") + { + return segments[0]; + } + + return Path.GetFileName(Path.GetDirectoryName(file)) ?? ""; + } + + /// + /// Compares two (time, folder) positions, earliest first, with the folder name as a tiebreak so the result does not + /// depend on the order files are visited in. + /// + /// The time of the first position. + /// The folder of the first position. + /// The time of the second position. + /// The folder of the second position. + /// A negative number when the first position comes first. + private static int ComparePosition(DateTimeOffset time, string folder, DateTimeOffset otherTime, string otherFolder) + { + var byTime = time.CompareTo(otherTime); + return byTime != 0 ? byTime : string.CompareOrdinal(folder, otherFolder); + } + + /// + /// A transcript line with its time, already known to be a user or assistant message inside the widened range. + /// + /// The time of the line. + /// The line. + private readonly record struct TimedLine(DateTimeOffset Time, SessionMessage Message); + + /// + /// The usable lines of one transcript, sorted once so each counting pass walks only the lines it needs. + /// + /// The project folder of the transcript. + /// The session id of lines that have none. + /// The lines whose session id is the transcript's own, or that have none. + /// The lines copied in from, or belonging to, another session. + private sealed record TranscriptLines(string Folder, string FallbackSessionId, List HomeLines, List OtherLines); + + /// + /// Collects the counted lines of one session while the transcripts are walked. + /// + private sealed class SessionAccumulator + { + /// + /// The usage of each model seen in the session. + /// + private readonly Dictionary _models = []; + + /// + /// The time and folder of the earliest counted line. + /// + private (DateTimeOffset Time, string Folder)? _first; + + /// + /// The time and folder of the latest counted line. + /// + private DateTimeOffset _last; + + /// + /// The time, folder and working directory of the earliest counted line that has a working directory. + /// + private (DateTimeOffset Time, string Folder, string Cwd)? _firstCwd; + + /// + /// The number of counted lines. + /// + private int _messages; + + /// + /// The number of tool calls. + /// + private int _toolCalls; + + /// + /// Counts a transcript line. + /// + /// The time of the line. + /// The project folder of the transcript holding the line. + /// The working directory recorded on the line, if any. + /// The number of tool calls on the line. + public void AddLine(DateTimeOffset time, string folder, string? cwd, int toolCalls) + { + _messages++; + _toolCalls += toolCalls; + + if (_first is not { } first || ComparePosition(time, folder, first.Time, first.Folder) < 0) + { + _first = (time, folder); + } + + if (_messages == 1 || time > _last) + { + _last = time; + } + + if (cwd is not null + && (_firstCwd is not { } firstCwd || ComparePosition(time, folder, firstCwd.Time, firstCwd.Folder) < 0)) + { + _firstCwd = (time, folder, cwd); + } + } + + /// + /// Adds the token usage of a response. + /// + /// The model that produced the response. + /// The usage reported for the response. + public void AddUsage(string model, TokenUsage usage) + { + ModelTokenTotals.Record(_models, model, usage); + } + + /// + /// Turns the collected lines into a session record, pricing each model's usage. + /// + /// The identifier of the session. + /// The calculator that prices the token usage. + /// The usage of the session. + public SessionUsage ToUsage(string sessionId, CostCalculator costCalculator) + { + var tokensByModel = new Dictionary(); + var input = 0L; + var output = 0L; + var cacheRead = 0L; + var cacheWrite = 0L; + + foreach (var (model, totals) in _models) + { + tokensByModel[model] = totals.Total; + input += totals.Input; + output += totals.Output; + cacheRead += totals.CacheRead; + cacheWrite += totals.CacheWrite; + } + + return new SessionUsage( + sessionId, + _first!.Value.Folder, + _firstCwd?.Cwd, + _first.Value.Time, + _last, + _messages, + _toolCalls, + input + output + cacheRead + cacheWrite, + input, + output, + cacheRead, + cacheWrite, + tokensByModel, + ModelTokenTotals.EstimateCost(_models, costCalculator)); + } + } +} diff --git a/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs b/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs index c17f63a..1d2b03c 100644 --- a/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs +++ b/src/AIUsageMonitor.Core/Providers/IUsageProvider.cs @@ -53,4 +53,14 @@ public interface IUsageProvider /// An optional progress reporter. /// A object. UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anchor, IProgress? progress = null); -} \ No newline at end of file + + /// + /// Retrieves per-session usage for a range of local days. + /// + /// The first local day to include. + /// The last local day to include. + /// When set, only usage of models whose name contains this text is counted. + /// An optional progress reporter. + /// One per session with activity in the range, ordered by start time. + List GetSessionUsage(DateOnly from, DateOnly to, string? model, IProgress? progress = null); +} diff --git a/src/AIUsageMonitor.Core/Services/DataService.cs b/src/AIUsageMonitor.Core/Services/DataService.cs index 4258da7..775afbd 100644 --- a/src/AIUsageMonitor.Core/Services/DataService.cs +++ b/src/AIUsageMonitor.Core/Services/DataService.cs @@ -241,6 +241,38 @@ public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? return _provider.GetRecentActivity(window, progress); } + /// + /// Gets per-session usage for a range of local days, read fresh from the session transcripts. + /// + /// The first local day to include. + /// The last local day to include. + /// When set, only usage of models whose name contains this text is counted. + /// When set, only sessions of projects whose path or folder name contains this text are returned. + /// An optional progress reporter to report the progress of the operation. + /// One per matching session, ordered by start time. + public List GetSessionUsage( + DateOnly from, DateOnly to, string? model = null, string? project = null, IProgress? progress = null) + { + return _provider.GetSessionUsage(from, to, model, progress) + .Where(s => ProjectFilter.Matches(s, project)) + .ToList(); + } + + /// + /// Gets per-project usage for a range of local days, read fresh from the session transcripts. + /// + /// The first local day to include. + /// The last local day to include. + /// When set, only usage of models whose name contains this text is counted. + /// When set, only projects whose path or folder name contains this text are returned. + /// An optional progress reporter to report the progress of the operation. + /// One per matching project, in no particular order. + public List GetProjectUsage( + DateOnly from, DateOnly to, string? model = null, string? project = null, IProgress? progress = null) + { + return ProjectUsage.FromSessions(GetSessionUsage(from, to, model, project, progress)); + } + /// /// Gets a summary of the current 5-hour session window. /// diff --git a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs index 28d925b..3b1b893 100644 --- a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs +++ b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs @@ -30,6 +30,7 @@ public static IServiceCollection AddClaudeUsageCore(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/tests/AIUsageMonitor.Cli.Tests/CommandActionTestBase.cs b/tests/AIUsageMonitor.Cli.Tests/CommandActionTestBase.cs new file mode 100644 index 0000000..cbd90ac --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/CommandActionTestBase.cs @@ -0,0 +1,210 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Models; +using AIUsageMonitor.Core.Providers; +using AIUsageMonitor.Core.Providers.Claude; +using AIUsageMonitor.Core.Providers.Claude.Models; +using AIUsageMonitor.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Spectre.Console; +using System.CommandLine; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Defines the collection that keeps console-capturing tests from running in parallel with each other. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class ConsoleCollection +{ + /// + /// The name of the collection. + /// + public const string Name = "Console"; +} + +/// +/// The fixture shared by the tests that run the report commands end to end: a data service over a fake provider holding a few +/// days of activity, a fixed clock, and a helper that runs a command while capturing what it writes. +/// +public abstract class CommandActionTestBase : IDisposable +{ + /// + /// The date the fake clock reports as today. + /// + private static readonly DateOnly Today = new(2026, 10, 9); + + /// + /// The directory that stands in for the Claude data directory. + /// + private readonly string _dataDir = Path.Combine(Path.GetTempPath(), $"aimon-tests-{Guid.NewGuid():N}"); + + /// + /// The data service under test. + /// + protected DataService DataService { get; } + + /// + /// The provider behind , kept to inspect how it was used. + /// + protected FakeProvider Provider { get; } + + /// + /// The clock reporting at noon, UTC. + /// + protected TimeProvider Clock { get; } = new FixedTimeProvider(new DateTimeOffset(2026, 10, 9, 12, 0, 0, TimeSpan.Zero)); + + /// + /// Creates the data service over a fake provider holding a few days of activity. + /// + protected CommandActionTestBase() + { + Directory.CreateDirectory(_dataDir); + var cache = new StatsCache + { + TotalSessions = 4, + TotalMessages = 30, + DailyActivity = + [ + new() { Date = Today, MessageCount = 10, SessionCount = 2, ToolCallCount = 5 }, + new() { Date = Today.AddDays(-1), MessageCount = 20, SessionCount = 2, ToolCallCount = 7 }, + ], + DailyModelTokens = + [ + new() { Date = Today, TokensByModel = new() { ["claude-opus-5-5"] = 1000 } }, + new() { Date = Today.AddDays(-1), TokensByModel = new() { ["claude-sonnet-5-5"] = 3000 } }, + ], + ModelUsage = new() + { + ["claude-opus-5-5"] = new() { InputTokens = 1000 }, + ["claude-sonnet-5-5"] = new() { InputTokens = 3000 }, + }, + }; + + var locator = new ClaudeDataLocator(_dataDir); + Provider = new FakeProvider(cache); + DataService = new DataService( + Provider, + new UsageAnalyzer(new CostCalculator()), + new SessionFileCache(new SessionParser(NullLogger.Instance), NullLogger.Instance), + new SessionActivityTracker(locator), + NullLogger.Instance); + } + + /// + /// Releases the data service and removes the temporary directory. + /// + public void Dispose() + { + DataService.Dispose(); + Directory.Delete(_dataDir, recursive: true); + } + + /// + /// Invokes a command while capturing what it writes to standard output and standard error. + /// + /// The command to invoke. + /// The command-line arguments. + /// The exit code and the captured output. + protected static (int ExitCode, string Out, string Error) Run(Command command, params string[] args) + { + var originalOut = Console.Out; + var originalError = Console.Error; + var originalConsole = AnsiConsole.Console; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(stdout), + }); + var exitCode = command.Parse(args).Invoke(); + return (exitCode, stdout.ToString(), stderr.ToString()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + AnsiConsole.Console = originalConsole; + } + } + + /// + /// A clock that always reports the same instant, in UTC. + /// + /// The instant to report. + private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider + { + /// + public override DateTimeOffset GetUtcNow() => now; + + /// + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } + + /// + /// A provider that serves a fixed stats cache, one hour of hourly activity and a single recent-activity bucket. + /// + /// The stats cache to serve. + protected sealed class FakeProvider(StatsCache cache) : IUsageProvider + { + /// + public string Name => "fake"; + + /// + /// Gets or sets the sessions returned to callers. + /// + public List Sessions { get; set; } = []; + + /// + /// Gets the days and model filter of the most recent session-usage request. + /// + public (DateOnly From, DateOnly To, string? Model)? LastSessionUsageRequest { get; private set; } + + /// + public List GetSessionUsage(DateOnly from, DateOnly to, string? model, IProgress? progress = null) + { + LastSessionUsageRequest = (from, to, model); + return Sessions; + } + + /// + public List GetHourlyActivity(IProgress? progress = null) => [new(14, 500)]; + + /// + /// Gets how many times recent activity was requested. + /// + public int RecentActivityCalls { get; private set; } + + /// + /// Gets the window of the most recent recent-activity request. + /// + public TimeSpan? LastRecentActivityWindow { get; private set; } + + /// + public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null) + { + RecentActivityCalls++; + LastRecentActivityWindow = window; + return new RecentActivitySummary( + window, 3, 1, 0, 1500, [], 0m, [new HourBucket(new DateTimeOffset(2026, 10, 9, 9, 0, 0, TimeSpan.Zero), 3, 1500)]); + } + + /// + public StatsCache GetStatsCache(IProgress? progress = null) => cache; + + /// + public UsageWindowSummary GetCurrentSessionWindow(DateTimeOffset? sessionResetAt, IProgress? progress = null) => + throw new NotSupportedException(); + + /// + public UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anchor, IProgress? progress = null) => + throw new NotSupportedException(); + } +} diff --git a/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs b/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs index e7ff126..160c6bd 100644 --- a/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/CommandActionTests.cs @@ -1,114 +1,24 @@ using AIUsageMonitor.Cli.Commands; -using AIUsageMonitor.Core.Analytics; -using AIUsageMonitor.Core.Models; -using AIUsageMonitor.Core.Providers; -using AIUsageMonitor.Core.Providers.Claude; -using AIUsageMonitor.Core.Providers.Claude.Models; using AIUsageMonitor.Core.Services; -using Microsoft.Extensions.Logging.Abstractions; -using Spectre.Console; -using System.CommandLine; using System.Text.Json; using Xunit; namespace AIUsageMonitor.Cli.Tests; /// -/// Defines the collection that keeps console-capturing tests from running in parallel with each other. -/// -[CollectionDefinition(Name, DisableParallelization = true)] -public sealed class ConsoleCollection -{ - /// - /// The name of the collection. - /// - public const string Name = "Console"; -} - -/// -/// Runs the report commands end to end against a fake provider and checks what they print and return. +/// Runs the date-range, model, hour and session-statistics report commands end to end against a fake provider and checks what +/// they print and return. /// [Collection(ConsoleCollection.Name)] -public sealed class CommandActionTests : IDisposable +public sealed class CommandActionTests : CommandActionTestBase { - /// - /// The date the fake clock reports as today. - /// - private static readonly DateOnly Today = new(2026, 10, 9); - - /// - /// The directory that stands in for the Claude data directory. - /// - private readonly string _dataDir = Path.Combine(Path.GetTempPath(), $"aimon-tests-{Guid.NewGuid():N}"); - - /// - /// The data service under test. - /// - private readonly DataService _dataService; - - /// - /// The provider behind , kept to inspect how it was used. - /// - private readonly FakeProvider _provider; - - /// - /// The clock reporting at noon, UTC. - /// - private readonly FixedTimeProvider _clock = new(new DateTimeOffset(2026, 10, 9, 12, 0, 0, TimeSpan.Zero)); - - /// - /// Creates the data service over a fake provider holding a few days of activity. - /// - public CommandActionTests() - { - Directory.CreateDirectory(_dataDir); - var cache = new StatsCache - { - TotalSessions = 4, - TotalMessages = 30, - DailyActivity = - [ - new() { Date = Today, MessageCount = 10, SessionCount = 2, ToolCallCount = 5 }, - new() { Date = Today.AddDays(-1), MessageCount = 20, SessionCount = 2, ToolCallCount = 7 }, - ], - DailyModelTokens = - [ - new() { Date = Today, TokensByModel = new() { ["claude-opus-5-5"] = 1000 } }, - new() { Date = Today.AddDays(-1), TokensByModel = new() { ["claude-sonnet-5-5"] = 3000 } }, - ], - ModelUsage = new() - { - ["claude-opus-5-5"] = new() { InputTokens = 1000 }, - ["claude-sonnet-5-5"] = new() { InputTokens = 3000 }, - }, - }; - - var locator = new ClaudeDataLocator(_dataDir); - _provider = new FakeProvider(cache); - _dataService = new DataService( - _provider, - new UsageAnalyzer(new CostCalculator()), - new SessionFileCache(new SessionParser(NullLogger.Instance), NullLogger.Instance), - new SessionActivityTracker(locator), - NullLogger.Instance); - } - - /// - /// Releases the data service and removes the temporary directory. - /// - public void Dispose() - { - _dataService.Dispose(); - Directory.Delete(_dataDir, recursive: true); - } - /// /// Verifies that today --json prints the summary for the clock's date and nothing else. /// [Fact] public void Create_TodayJson_PrintsSummaryForClockDate() { - var result = Run(TodayCommand.Create(_dataService, _clock), "--json"); + var result = Run(TodayCommand.Create(DataService, Clock), "--json"); Assert.True(result.ExitCode == 0, result.Error); using var document = JsonDocument.Parse(result.Out); @@ -123,8 +33,8 @@ public void Create_TodayJson_PrintsSummaryForClockDate() [Fact] public void Create_TodayJsonWithDate_PrintsThatDayOrAnEmptySummary() { - var yesterday = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-10-08", "--json"); - var quiet = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-01-01", "--json"); + var yesterday = Run(TodayCommand.Create(DataService, Clock), "--date", "2026-10-08", "--json"); + var quiet = Run(TodayCommand.Create(DataService, Clock), "--date", "2026-01-01", "--json"); using var yesterdayDocument = JsonDocument.Parse(yesterday.Out); using var quietDocument = JsonDocument.Parse(quiet.Out); @@ -139,7 +49,7 @@ public void Create_TodayJsonWithDate_PrintsThatDayOrAnEmptySummary() [Fact] public void Create_TodayInvalidDate_WritesErrorToStderrAndReturnsOne() { - var result = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-13-01", "--json"); + var result = Run(TodayCommand.Create(DataService, Clock), "--date", "2026-13-01", "--json"); Assert.Equal(1, result.ExitCode); Assert.Contains("--date", result.Error); @@ -152,7 +62,7 @@ public void Create_TodayInvalidDate_WritesErrorToStderrAndReturnsOne() [Fact] public void Create_WeekJsonWithLast_CoversTheRequestedDays() { - var result = Run(WeekCommand.Create(_dataService, _clock), "--last", "2", "--json"); + var result = Run(WeekCommand.Create(DataService, Clock), "--last", "2", "--json"); Assert.True(result.ExitCode == 0, result.Error); using var document = JsonDocument.Parse(result.Out); @@ -167,8 +77,8 @@ public void Create_WeekJsonWithLast_CoversTheRequestedDays() [Fact] public void Create_WeekAndMonthJsonWithoutRange_UseTheirDefaultLength() { - var week = Run(WeekCommand.Create(_dataService, _clock), "--json"); - var month = Run(MonthCommand.Create(_dataService, _clock), "--json"); + var week = Run(WeekCommand.Create(DataService, Clock), "--json"); + var month = Run(MonthCommand.Create(DataService, Clock), "--json"); using var weekDocument = JsonDocument.Parse(week.Out); using var monthDocument = JsonDocument.Parse(month.Out); @@ -182,7 +92,7 @@ public void Create_WeekAndMonthJsonWithoutRange_UseTheirDefaultLength() [Fact] public void Create_MonthJsonWithMonth_CoversTheCalendarMonth() { - var result = Run(MonthCommand.Create(_dataService, _clock), "--month", "2026-09", "--json"); + var result = Run(MonthCommand.Create(DataService, Clock), "--month", "2026-09", "--json"); using var document = JsonDocument.Parse(result.Out); Assert.Equal("2026-09-01", document.RootElement.GetProperty("from").GetString()); @@ -199,7 +109,7 @@ public void Create_MonthJsonWithMonth_CoversTheCalendarMonth() [InlineData("week", "--from 2026-10-05 --to 2026-10-01", "must not be after")] public void Create_PeriodCommandsInvalidRange_ReturnOneWithMessage(string commandName, string options, string expectedError) { - var command = commandName == "week" ? WeekCommand.Create(_dataService, _clock) : MonthCommand.Create(_dataService, _clock); + var command = commandName == "week" ? WeekCommand.Create(DataService, Clock) : MonthCommand.Create(DataService, Clock); var result = Run(command, [.. options.Split(' '), "--json"]); @@ -214,9 +124,9 @@ public void Create_PeriodCommandsInvalidRange_ReturnOneWithMessage(string comman [Fact] public void Create_ModelsJson_FiltersByModelName() { - var all = Run(ModelsCommand.Create(_dataService), "--json"); - var sonnet = Run(ModelsCommand.Create(_dataService), "--model", "SONNET", "--json"); - var none = Run(ModelsCommand.Create(_dataService), "--model", "gpt", "--json"); + var all = Run(ModelsCommand.Create(DataService), "--json"); + var sonnet = Run(ModelsCommand.Create(DataService), "--model", "SONNET", "--json"); + var none = Run(ModelsCommand.Create(DataService), "--model", "gpt", "--json"); using var allDocument = JsonDocument.Parse(all.Out); using var sonnetDocument = JsonDocument.Parse(sonnet.Out); @@ -232,7 +142,7 @@ public void Create_ModelsJson_FiltersByModelName() [Fact] public void Create_ModelsFilterMatchesNothing_WritesHintToStderr() { - var result = Run(ModelsCommand.Create(_dataService), "--model", "gpt"); + var result = Run(ModelsCommand.Create(DataService), "--model", "gpt"); Assert.True(result.ExitCode == 0, result.Error); Assert.Contains("No model matches 'gpt'", result.Error); @@ -245,7 +155,7 @@ public void Create_ModelsFilterMatchesNothing_WritesHintToStderr() [Fact] public void Create_SessionsJson_PrintsSessionStatsInSeconds() { - var result = Run(SessionsCommand.Create(_dataService), "--json"); + var result = Run(SessionsCommand.Create(DataService), "--json"); Assert.True(result.ExitCode == 0, result.Error); using var document = JsonDocument.Parse(result.Out); @@ -259,7 +169,7 @@ public void Create_SessionsJson_PrintsSessionStatsInSeconds() [Fact] public void Create_HoursJson_PrintsHourlyActivity() { - var result = Run(HoursCommand.Create(_dataService), "--json"); + var result = Run(HoursCommand.Create(DataService), "--json"); Assert.True(result.ExitCode == 0, result.Error); using var document = JsonDocument.Parse(result.Out); @@ -272,14 +182,14 @@ public void Create_HoursJson_PrintsHourlyActivity() [Fact] public void Create_TodayTable_RendersSummaryAndLoadsRecentActivity() { - var result = Run(TodayCommand.Create(_dataService, _clock)); + var result = Run(TodayCommand.Create(DataService, Clock)); Assert.True(result.ExitCode == 0, result.Error); Assert.Contains("2026-10-09 Summary", result.Out); Assert.Contains("Tokens by Hour", result.Out); Assert.Contains("09:00", result.Out); - Assert.Equal(1, _provider.RecentActivityCalls); - Assert.Equal(TimeSpan.FromHours(12), _provider.LastRecentActivityWindow); + Assert.Equal(1, Provider.RecentActivityCalls); + Assert.Equal(TimeSpan.FromHours(12), Provider.LastRecentActivityWindow); } /// @@ -288,11 +198,11 @@ public void Create_TodayTable_RendersSummaryAndLoadsRecentActivity() [Fact] public void Create_TodayTableWithOtherDate_RendersSummaryWithoutRecentActivity() { - var result = Run(TodayCommand.Create(_dataService, _clock), "--date", "2026-10-08"); + var result = Run(TodayCommand.Create(DataService, Clock), "--date", "2026-10-08"); Assert.True(result.ExitCode == 0, result.Error); Assert.Contains("2026-10-08 Summary", result.Out); - Assert.Equal(0, _provider.RecentActivityCalls); + Assert.Equal(0, Provider.RecentActivityCalls); } /// @@ -301,105 +211,14 @@ public void Create_TodayTableWithOtherDate_RendersSummaryWithoutRecentActivity() [Fact] public void Create_ReportCommandsTable_RenderFormattedOutput() { - var week = Run(WeekCommand.Create(_dataService, _clock), "--last", "2"); - var models = Run(ModelsCommand.Create(_dataService)); - var hours = Run(HoursCommand.Create(_dataService)); - var sessions = Run(SessionsCommand.Create(_dataService)); + var week = Run(WeekCommand.Create(DataService, Clock), "--last", "2"); + var models = Run(ModelsCommand.Create(DataService)); + var hours = Run(HoursCommand.Create(DataService)); + var sessions = Run(SessionsCommand.Create(DataService)); Assert.Contains("2026-10-08 ~ 2026-10-09", week.Out); Assert.Contains("Input", models.Out); Assert.Contains("14", hours.Out); Assert.Contains("Total Sessions", sessions.Out); } - - /// - /// Invokes a command while capturing what it writes to standard output and standard error. - /// - /// The command to invoke. - /// The command-line arguments. - /// The exit code and the captured output. - private static (int ExitCode, string Out, string Error) Run(Command command, params string[] args) - { - var originalOut = Console.Out; - var originalError = Console.Error; - var originalConsole = AnsiConsole.Console; - using var stdout = new StringWriter(); - using var stderr = new StringWriter(); - try - { - Console.SetOut(stdout); - Console.SetError(stderr); - AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings - { - Ansi = AnsiSupport.No, - ColorSystem = ColorSystemSupport.NoColors, - Interactive = InteractionSupport.No, - Out = new AnsiConsoleOutput(stdout), - }); - var exitCode = command.Parse(args).Invoke(); - return (exitCode, stdout.ToString(), stderr.ToString()); - } - finally - { - Console.SetOut(originalOut); - Console.SetError(originalError); - AnsiConsole.Console = originalConsole; - } - } - - /// - /// A clock that always reports the same instant, in UTC. - /// - /// The instant to report. - private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider - { - /// - public override DateTimeOffset GetUtcNow() => now; - - /// - public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; - } - - /// - /// A provider that serves a fixed stats cache, one hour of hourly activity and a single recent-activity bucket. - /// - /// The stats cache to serve. - private sealed class FakeProvider(StatsCache cache) : IUsageProvider - { - /// - public string Name => "fake"; - - /// - public List GetHourlyActivity(IProgress? progress = null) => [new(14, 500)]; - - /// - /// Gets how many times recent activity was requested. - /// - public int RecentActivityCalls { get; private set; } - - /// - /// Gets the window of the most recent recent-activity request. - /// - public TimeSpan? LastRecentActivityWindow { get; private set; } - - /// - public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null) - { - RecentActivityCalls++; - LastRecentActivityWindow = window; - return new RecentActivitySummary( - window, 3, 1, 0, 1500, [], 0m, [new HourBucket(new DateTimeOffset(2026, 10, 9, 9, 0, 0, TimeSpan.Zero), 3, 1500)]); - } - - /// - public StatsCache GetStatsCache(IProgress? progress = null) => cache; - - /// - public UsageWindowSummary GetCurrentSessionWindow(DateTimeOffset? sessionResetAt, IProgress? progress = null) => - throw new NotSupportedException(); - - /// - public UsageWindowSummary GetWeekWindow((DayOfWeek Day, TimeSpan TimeOfDay)? anchor, IProgress? progress = null) => - throw new NotSupportedException(); - } } diff --git a/tests/AIUsageMonitor.Cli.Tests/ConsoleMarkupTests.cs b/tests/AIUsageMonitor.Cli.Tests/ConsoleMarkupTests.cs index a39688f..39aad12 100644 --- a/tests/AIUsageMonitor.Cli.Tests/ConsoleMarkupTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/ConsoleMarkupTests.cs @@ -14,8 +14,9 @@ public class ConsoleMarkupTests /// Renders a renderable to plain text, without colors or ANSI sequences. /// /// The renderable to render. + /// The width of the console, in characters. /// The rendered text. - internal static string RenderPlain(IRenderable renderable) + internal static string RenderPlain(IRenderable renderable, int width = 200) { var writer = new StringWriter(); var console = AnsiConsole.Create(new AnsiConsoleSettings @@ -25,7 +26,7 @@ internal static string RenderPlain(IRenderable renderable) Out = new AnsiConsoleOutput(writer), Interactive = InteractionSupport.No, }); - console.Profile.Width = 200; + console.Profile.Width = width; console.Write(renderable); return writer.ToString(); diff --git a/tests/AIUsageMonitor.Cli.Tests/ListCommandActionTests.cs b/tests/AIUsageMonitor.Cli.Tests/ListCommandActionTests.cs new file mode 100644 index 0000000..67ccb32 --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/ListCommandActionTests.cs @@ -0,0 +1,190 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Core.Models; +using AIUsageMonitor.Core.Services; +using System.Text.Json; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Runs aimon projects and aimon sessions --list end to end against a fake provider and checks what they print +/// and return. +/// +[Collection(ConsoleCollection.Name)] +public sealed class ListCommandActionTests : CommandActionTestBase +{ + /// + /// Creates a session for the fake provider to serve. + /// + /// The session id. + /// The project path. + /// The total tokens. + /// The estimated cost. + /// The session. + private static SessionUsage SessionOf(string id, string? project, long tokens, decimal cost) + { + var start = new DateTimeOffset(2026, 10, 8, 9, 0, 0, TimeSpan.Zero); + return new SessionUsage( + id, "C--repos-app", project, start, start.AddMinutes(30), 4, 1, tokens, tokens, 0, 0, 0, + new Dictionary { ["sonnet-5"] = tokens }, cost); + } + + /// + /// Verifies that projects covers the last 30 days by default and passes the model filter to the provider. + /// + [Fact] + public void Create_ProjectsJson_UsesTheDefaultRangeAndModelFilter() + { + Provider.Sessions = [SessionOf("s1", "C:/repos/app", 100, 1m)]; + + var result = Run(ProjectsCommand.Create(DataService, Clock), "--model", "opus", "--json"); + + Assert.True(result.ExitCode == 0, result.Error); + Assert.Equal((new DateOnly(2026, 9, 10), new DateOnly(2026, 10, 9), "opus"), Provider.LastSessionUsageRequest); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal("2026-09-10", document.RootElement.GetProperty("from").GetString()); + Assert.Equal("2026-10-09", document.RootElement.GetProperty("to").GetString()); + Assert.Equal("C:/repos/app", document.RootElement.GetProperty("projects")[0].GetProperty("projectPath").GetString()); + } + + /// + /// Verifies that projects honors --last, --sort, --top and --project, and reports the matches before truncation. + /// + [Fact] + public void Create_ProjectsJsonWithListOptions_FiltersSortsAndTruncates() + { + Provider.Sessions = + [ + SessionOf("s1", "C:/repos/app", 100, 1m) with { ProjectKey = "k-app" }, + SessionOf("s2", "C:/repos/lib", 900, 2m) with { ProjectKey = "k-lib" }, + SessionOf("s3", "C:/repos/tool", 500, 9m) with { ProjectKey = "k-tool" }, + ]; + + var byCost = Run(ProjectsCommand.Create(DataService, Clock), "--last", "7", "--sort", "cost", "--top", "2", "--json"); + var requestedFrom = Provider.LastSessionUsageRequest!.Value.From; + var filtered = Run(ProjectsCommand.Create(DataService, Clock), "--project", "repos/LIB", "--json"); + + using var costDocument = JsonDocument.Parse(byCost.Out); + Assert.Equal(new DateOnly(2026, 10, 3), requestedFrom); + Assert.Equal(3, costDocument.RootElement.GetProperty("totalProjects").GetInt32()); + Assert.Equal(["k-tool", "k-lib"], costDocument.RootElement.GetProperty("projects").EnumerateArray().Select(p => p.GetProperty("projectKey").GetString())); + using var filteredDocument = JsonDocument.Parse(filtered.Out); + Assert.Equal("k-lib", Assert.Single(filteredDocument.RootElement.GetProperty("projects").EnumerateArray()).GetProperty("projectKey").GetString()); + } + + /// + /// Verifies that projects prints a table, and a hint on stderr when nothing matches. + /// + [Fact] + public void Create_ProjectsTable_RendersRowsOrHintsWhenEmpty() + { + Provider.Sessions = [SessionOf("s1", "C:/repos/[app]", 1500, 1.5m)]; + var table = Run(ProjectsCommand.Create(DataService, Clock)); + Provider.Sessions = []; + var empty = Run(ProjectsCommand.Create(DataService, Clock)); + + Assert.True(table.ExitCode == 0, table.Error); + Assert.Contains("[app]", table.Out); + Assert.Contains("2026-09-10 ~ 2026-10-09", table.Out); + Assert.Equal(0, empty.ExitCode); + Assert.Equal("", empty.Out); + Assert.Contains("No usage found between 2026-09-10 and 2026-10-09", empty.Error); + } + + /// + /// Verifies that ordering by messages shows the Messages column, which is otherwise left out. + /// + [Fact] + public void Create_ProjectsTable_ShowsMessagesColumnOnlyWhenSortedByMessages() + { + Provider.Sessions = [SessionOf("s1", "C:/repos/app", 1500, 1.5m)]; + + var byTokens = Run(ProjectsCommand.Create(DataService, Clock)); + var byMessages = Run(ProjectsCommand.Create(DataService, Clock), "--sort", "messages"); + + Assert.DoesNotContain("Messages", byTokens.Out); + Assert.Contains("Messages", byMessages.Out); + } + + /// + /// Verifies that an invalid date range makes projects fail with a message and exit code 1. + /// + [Fact] + public void Create_ProjectsInvalidRange_ReturnsOneWithMessage() + { + var result = Run(ProjectsCommand.Create(DataService, Clock), "--from", "2026-10-05", "--to", "2026-10-01"); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("must not be after", result.Error); + Assert.Equal("", result.Out); + } + + /// + /// Verifies that sessions --list prints the sessions in the requested order with durations in seconds. + /// + [Fact] + public void Create_SessionsListJson_OrdersAndReportsSeconds() + { + Provider.Sessions = [SessionOf("small", null, 10, 1m), SessionOf("big", null, 900, 5m), SessionOf("mid", null, 500, 3m)]; + + var result = Run(SessionsCommand.Create(DataService, Clock), "--list", "--top", "2", "--json"); + + Assert.True(result.ExitCode == 0, result.Error); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal(3, document.RootElement.GetProperty("totalSessions").GetInt32()); + var sessions = document.RootElement.GetProperty("sessions").EnumerateArray().ToList(); + Assert.Equal(["big", "mid"], sessions.Select(s => s.GetProperty("sessionId").GetString())); + Assert.Equal(1800, sessions[0].GetProperty("durationSeconds").GetDouble()); + Assert.Equal((new DateOnly(2026, 9, 10), new DateOnly(2026, 10, 9), null), Provider.LastSessionUsageRequest); + } + + /// + /// Verifies that sessions --list prints a table, and a hint on stderr when nothing matches. + /// + [Fact] + public void Create_SessionsListTable_RendersRowsOrHintsWhenEmpty() + { + Provider.Sessions = [SessionOf("abcdef0123456789", "C:/repos/app", 1500, 1.5m)]; + var table = Run(SessionsCommand.Create(DataService, Clock), "--list"); + Provider.Sessions = []; + var empty = Run(SessionsCommand.Create(DataService, Clock), "--list"); + + Assert.Contains("abcdef01", table.Out); + Assert.DoesNotContain("0123456789", table.Out); + Assert.Equal(0, empty.ExitCode); + Assert.Contains("No sessions found between 2026-09-10 and 2026-10-09", empty.Error); + } + + /// + /// Verifies that the list options are rejected without --list instead of being ignored. + /// + [Theory] + [InlineData("--top 3")] + [InlineData("--sort cost")] + [InlineData("--project app")] + [InlineData("--model opus")] + [InlineData("--last 7")] + [InlineData("--month 2026-09")] + public void Create_SessionsWithoutListFlag_RejectsListOptions(string options) + { + var result = Run(SessionsCommand.Create(DataService, Clock), options.Split(' ')); + + Assert.Equal(1, result.ExitCode); + Assert.Contains("require --list", result.Error); + Assert.Equal("", result.Out); + } + + /// + /// Verifies that sessions without --list still prints the overall statistics, not a list. + /// + [Fact] + public void Create_SessionsWithoutListFlag_StillPrintsOverallStatistics() + { + var result = Run(SessionsCommand.Create(DataService, Clock), "--json"); + + Assert.Equal(0, result.ExitCode); + using var document = JsonDocument.Parse(result.Out); + Assert.Equal(4, document.RootElement.GetProperty("total").GetInt32()); + Assert.False(document.RootElement.TryGetProperty("sessions", out _)); + } +} diff --git a/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs b/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs index 80351c1..3f5ca38 100644 --- a/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs +++ b/tests/AIUsageMonitor.Cli.Tests/ReportOptionsTests.cs @@ -263,7 +263,7 @@ public void Serialize_SessionStats_EmitsDurationsAsSeconds() { var stats = new SessionStats(3, TimeSpan.FromMinutes(5), 2.5, TimeSpan.FromHours(30), "abc"); - var json = JsonOutput.Serialize(SessionStatsReport.From(stats), CliJsonContext.Default.SessionStatsReport); + var json = JsonOutput.Serialize(SessionStatsReport.Create(stats), CliJsonContext.Default.SessionStatsReport); using var document = JsonDocument.Parse(json); var root = document.RootElement; diff --git a/tests/AIUsageMonitor.Cli.Tests/UsageListTests.cs b/tests/AIUsageMonitor.Cli.Tests/UsageListTests.cs new file mode 100644 index 0000000..f46c153 --- /dev/null +++ b/tests/AIUsageMonitor.Cli.Tests/UsageListTests.cs @@ -0,0 +1,288 @@ +using AIUsageMonitor.Cli.Commands; +using AIUsageMonitor.Cli.Rendering; +using AIUsageMonitor.Core.Models; +using System.Text.Json; +using Xunit; + +namespace AIUsageMonitor.Cli.Tests; + +/// +/// Tests for the sorting, option parsing, rendering and JSON shapes of the project and session lists. +/// +public class UsageListTests +{ + /// + /// Creates a session with only the fields these tests care about. + /// + /// The session id. + /// The start time, as an hour of 2026-08-02 (UTC). + /// The total tokens. + /// The estimated cost. + /// The message count. + /// The duration in minutes. + /// The project path. + /// The session. + private static SessionUsage Session( + string id, int startHour, long tokens, decimal cost, int messages = 1, int minutes = 10, string? path = "C:/repos/app") + { + var start = new DateTimeOffset(2026, 8, 2, startHour, 0, 0, TimeSpan.Zero); + return new SessionUsage( + id, "C--repos-app", path, start, start.AddMinutes(minutes), messages, 2, tokens, tokens, 0, 0, 0, + new Dictionary { ["sonnet-5"] = tokens }, cost); + } + + /// + /// Creates a project with only the fields these tests care about. + /// + /// The project folder. + /// The session count. + /// The message count. + /// The total tokens. + /// The estimated cost. + /// The project. + private static ProjectUsage Project(string key, int sessions, int messages, long tokens, decimal cost) + { + var at = new DateTimeOffset(2026, 8, 2, 10, 0, 0, TimeSpan.Zero); + return new ProjectUsage(key, null, sessions, messages, 0, tokens, [], cost, at, at); + } + + /// + /// Verifies every project sort key, largest first. + /// + [Theory] + [InlineData("tokens", "b,c,a")] + [InlineData("cost", "c,a,b")] + [InlineData("sessions", "a,c,b")] + [InlineData("messages", "c,b,a")] + public void SortProjects_EachKey_OrdersLargestFirst(string sort, string expected) + { + var projects = new[] { Project("a", 9, 1, 10, 3m), Project("b", 1, 5, 30, 1m), Project("c", 5, 9, 20, 7m) }; + + var sorted = UsageSorting.SortProjects(projects, sort, null); + + Assert.Equal(expected, string.Join(",", sorted.Select(p => p.ProjectKey))); + } + + /// + /// Verifies every session sort key, largest (or newest) first. + /// + [Theory] + [InlineData("tokens", "b,c,a")] + [InlineData("cost", "c,a,b")] + [InlineData("messages", "a,c,b")] + [InlineData("duration", "c,a,b")] + [InlineData("start", "c,b,a")] + public void SortSessions_EachKey_OrdersLargestFirst(string sort, string expected) + { + var sessions = new[] + { + Session("a", 8, 10, 3m, messages: 9, minutes: 20), + Session("b", 9, 30, 1m, messages: 1, minutes: 5), + Session("c", 10, 20, 7m, messages: 5, minutes: 60), + }; + + var sorted = UsageSorting.SortSessions(sessions, sort, null); + + Assert.Equal(expected, string.Join(",", sorted.Select(s => s.SessionId))); + } + + /// + /// Verifies that equal keys keep a stable order, by identifier. + /// + [Fact] + public void SortSessions_EqualKeys_BreaksTiesBySessionId() + { + var sessions = new[] { Session("z", 8, 10, 1m), Session("a", 9, 10, 1m), Session("m", 10, 10, 1m) }; + + var sorted = UsageSorting.SortSessions(sessions, "tokens", null); + + Assert.Equal("a,m,z", string.Join(",", sorted.Select(s => s.SessionId))); + } + + /// + /// Verifies that --top keeps the first rows only after ordering, and that a larger value keeps everything. + /// + [Fact] + public void SortProjects_Top_KeepsTheLargestRows() + { + var projects = new[] { Project("a", 1, 1, 10, 1m), Project("b", 1, 1, 30, 1m), Project("c", 1, 1, 20, 1m) }; + + Assert.Equal("b,c", string.Join(",", UsageSorting.SortProjects(projects, "tokens", 2).Select(p => p.ProjectKey))); + Assert.Equal(3, UsageSorting.SortProjects(projects, "tokens", 10).Count); + } + + /// + /// Verifies that the start of a session shows its year only when the range spans more than one year. + /// + [Fact] + public void BuildSessionList_RangeAcrossYears_ShowsTheYearOfTheStart() + { + var session = Session("s1", 9, 1500, 1.5m); + var layout = new TableLayout(200, false); + + var sameYear = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildSessionList([session], 1, new(2026, 1, 1), new(2026, 12, 31), layout)); + var acrossYears = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildSessionList([session], 1, new(2025, 6, 1), new(2026, 12, 31), layout)); + + Assert.DoesNotContain("2026-08-02 ", sameYear.Split('\n').Last(line => line.Contains("s1"))); + Assert.Contains("2026-08-02 ", acrossYears.Split('\n').Last(line => line.Contains("s1"))); + } + + /// + /// Verifies that only ordering by messages asks the tables for a Messages column. + /// + [Theory] + [InlineData("messages", true)] + [InlineData("tokens", false)] + [InlineData("cost", false)] + [InlineData("sessions", false)] + [InlineData("duration", false)] + [InlineData("start", false)] + public void ShowsMessages_SortKey_IsTrueOnlyForMessages(string sort, bool expected) + { + Assert.Equal(expected, UsageSorting.ShowsMessages(sort)); + Assert.Contains(UsageSorting.MessagesKey, UsageSorting.ProjectKeys); + Assert.Contains(UsageSorting.MessagesKey, UsageSorting.SessionKeys); + } + + /// + /// Verifies that the list commands accept their options and reject bad values. + /// + [Fact] + public void Create_ListCommands_ValidateTopAndSort() + { + var projects = ProjectsCommand.Create(null!); + var sessions = SessionsCommand.Create(null!); + + Assert.Empty(projects.Parse("--top 5 --sort cost --project app --model opus --last 7 --json").Errors); + Assert.Empty(projects.Parse("--sort COST").Errors); + Assert.NotEmpty(projects.Parse("--top 0").Errors); + Assert.NotEmpty(projects.Parse("--sort bogus").Errors); + Assert.NotEmpty(projects.Parse("--sort start").Errors); + Assert.Empty(sessions.Parse("--list --sort start --top 3 --month 2026-08").Errors); + Assert.NotEmpty(sessions.Parse("--list --sort sessions").Errors); + } + + /// + /// Verifies that only options typed on the command line count as supplied, not defaults. + /// + [Fact] + public void AnySupplied_DefaultsOnly_IsFalse() + { + var command = new System.CommandLine.Command("x"); + var options = new ListOptions(command, UsageSorting.SessionKeys, "tokens"); + + Assert.False(options.AnySupplied(command.Parse([]))); + Assert.True(options.AnySupplied(command.Parse("--sort cost"))); + Assert.True(options.AnySupplied(command.Parse("--project app"))); + Assert.Equal(new ListRequest(null, "tokens", null, null), options.Read(command.Parse([]))); + Assert.Equal(new ListRequest(3, "cost", "app", "opus"), options.Read(command.Parse("--top 3 --sort COST --project app --model opus"))); + } + + /// + /// Verifies that long paths lose their beginning, keeping the part that tells projects apart. + /// + [Theory] + [InlineData("C:/short", 20, "C:/short")] + [InlineData("C:/repos/a-very-long-project-name", 12, "\u2026roject-name")] + public void ShortenPath_Path_KeepsTheTail(string path, int max, string expected) + { + var shortened = SpectreRenderer.ShortenPath(path, max); + + Assert.Equal(expected, shortened); + Assert.True(shortened.Length <= max); + } + + /// + /// Verifies that markup characters in paths are shown literally and do not break rendering. + /// + [Fact] + public void BuildProjectUsage_PathWithMarkupCharacters_RendersThemLiterally() + { + var project = Project("key", 2, 3, 1500, 1.5m) with { ProjectPath = "C:/repos/[wip]/app" }; + + var rendered = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildProjectUsage([project], 1, new(2026, 8, 1), new(2026, 8, 31), new TableLayout(200, false))); + + Assert.Contains("[wip]", rendered); + Assert.Contains("2026-08-01 ~ 2026-08-31", rendered); + Assert.DoesNotContain("Showing", rendered); + } + + /// + /// Verifies that a truncated list says how many rows exist. + /// + [Fact] + public void BuildSessionList_Truncated_ShowsHowManyExist() + { + var session = Session("[abc]-0123456789", 9, 1500, 1.5m, path: null); + + var rendered = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildSessionList([session], 12, new(2026, 8, 1), new(2026, 8, 31), new TableLayout(200, false))); + + Assert.Contains("Showing 1 of 12 sessions", rendered); + Assert.Contains("[abc]-01", rendered); + Assert.DoesNotContain("0123456789", rendered); + } + + /// + /// Verifies that a narrow console shortens the project name from the left to fit, while a wide one shows it whole. + /// + [Fact] + public void BuildProjectUsage_NarrowConsole_ShortensTheNameFromTheLeftToFit() + { + var project = Project("key", 2, 3, 1500, 1.5m) with { ProjectPath = "C:/repos/a-fairly-long-folder/the-actual-project-name" }; + var from = new DateOnly(2026, 8, 1); + var to = new DateOnly(2026, 8, 31); + + var narrow = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildProjectUsage([project], 1, from, to, new TableLayout(70, false)), 70); + var wide = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildProjectUsage([project], 1, from, to, new TableLayout(200, false))); + + Assert.Contains("\u2026", narrow); + Assert.Contains("project-name", narrow); + Assert.DoesNotContain("C:/repos", narrow); + Assert.All(narrow.Split('\n'), line => Assert.True(line.TrimEnd('\r').Length <= 70, line)); + Assert.Contains("C:/repos/a-fairly-long-folder/the-actual-project-name", wide); + } + + /// + /// Verifies that the Messages column appears only when asked for, in both tables. + /// + [Fact] + public void BuildTables_ShowMessages_AddsTheMessagesColumnOnlyWhenAsked() + { + var from = new DateOnly(2026, 8, 1); + var to = new DateOnly(2026, 8, 31); + var project = Project("key", 2, 3, 1500, 1.5m); + var session = Session("s1", 9, 1500, 1.5m, messages: 4321); + + var projectWithout = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildProjectUsage([project], 1, from, to, new TableLayout(200, false))); + var projectWith = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildProjectUsage([project], 1, from, to, new TableLayout(200, true))); + var sessionWithout = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildSessionList([session], 1, from, to, new TableLayout(200, false))); + var sessionWith = ConsoleMarkupTests.RenderPlain(SpectreRenderer.BuildSessionList([session], 1, from, to, new TableLayout(200, true))); + + Assert.DoesNotContain("Messages", projectWithout); + Assert.Contains("Messages", projectWith); + Assert.DoesNotContain("Messages", sessionWithout); + Assert.Contains("Messages", sessionWith); + Assert.Contains("4,321", sessionWith); + } + + /// + /// Verifies the JSON shape of the project and session reports, including the duration in seconds. + /// + [Fact] + public void Create_Reports_SerializeWithCamelCaseAndSeconds() + { + var projects = ProjectListReport.Create(new(2026, 8, 1), new(2026, 8, 31), 4, [Project("key", 2, 3, 1500, 1.5m)]); + var sessions = SessionListReport.Create(new(2026, 8, 1), new(2026, 8, 31), 7, [Session("s1", 9, 1500, 1.5m, minutes: 90)]); + + using var projectDocument = JsonDocument.Parse(JsonOutput.Serialize(projects, CliJsonContext.Default.ProjectListReport)); + using var sessionDocument = JsonDocument.Parse(JsonOutput.Serialize(sessions, CliJsonContext.Default.SessionListReport)); + + Assert.Equal("2026-08-01", projectDocument.RootElement.GetProperty("from").GetString()); + Assert.Equal(4, projectDocument.RootElement.GetProperty("totalProjects").GetInt32()); + Assert.Equal("key", projectDocument.RootElement.GetProperty("projects")[0].GetProperty("projectKey").GetString()); + Assert.Equal(7, sessionDocument.RootElement.GetProperty("totalSessions").GetInt32()); + var item = sessionDocument.RootElement.GetProperty("sessions")[0]; + Assert.Equal(5400, item.GetProperty("durationSeconds").GetDouble()); + Assert.Equal(1500, item.GetProperty("tokensByModel").GetProperty("sonnet-5").GetInt64()); + } +} diff --git a/tests/AIUsageMonitor.Core.Tests/Models/ProjectUsageTests.cs b/tests/AIUsageMonitor.Core.Tests/Models/ProjectUsageTests.cs new file mode 100644 index 0000000..2ec983a --- /dev/null +++ b/tests/AIUsageMonitor.Core.Tests/Models/ProjectUsageTests.cs @@ -0,0 +1,125 @@ +using AIUsageMonitor.Core.Models; +using AIUsageMonitor.Core.Providers.Claude; +using Xunit; + +namespace AIUsageMonitor.Core.Tests.Models; + +/// +/// Tests for grouping sessions into projects and for the project filter. +/// +public class ProjectUsageTests +{ + /// + /// Creates a session with only the fields these tests care about. + /// + /// The session id. + /// The project folder. + /// The working directory, if any. + /// The start time, as a day of August 2026. + /// The tokens, all attributed to sonnet-5. + /// The estimated cost. + /// The session. + private static SessionUsage Session(string id, string projectKey, string? projectPath, int start, long tokens, decimal cost) + { + var begin = new DateTimeOffset(2026, 8, start, 10, 0, 0, TimeSpan.Zero); + return new SessionUsage( + id, projectKey, projectPath, begin, begin.AddMinutes(30), 4, 2, tokens, tokens, 0, 0, 0, + new Dictionary { ["sonnet-5"] = tokens }, cost); + } + + /// + /// Verifies that sessions of one project folder are summed, ignoring the case of the folder name. + /// + [Fact] + public void FromSessions_SessionsOfOneFolder_AreSummed() + { + var sessions = new[] + { + Session("s1", "E--repos-App", "E:/repos/App", 3, 100, 1m), + Session("s2", "e--repos-app", null, 1, 50, 0.5m), + Session("s3", "E--repos-Other", "E:/repos/Other", 2, 7, 0.1m), + }; + + var projects = ProjectUsage.FromSessions(sessions); + + Assert.Equal(2, projects.Count); + var app = projects.Single(p => p.ProjectKey.Equals("E--repos-App", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(2, app.Sessions); + Assert.Equal(8, app.Messages); + Assert.Equal(4, app.ToolCalls); + Assert.Equal(150, app.TotalTokens); + Assert.Equal(150, app.TokensByModel["sonnet-5"]); + Assert.Equal(1.5m, app.EstimatedCost); + Assert.Equal(new DateTimeOffset(2026, 8, 1, 10, 0, 0, TimeSpan.Zero), app.FirstActivity); + Assert.Equal(new DateTimeOffset(2026, 8, 3, 10, 30, 0, TimeSpan.Zero), app.LastActivity); + } + + /// + /// Verifies that the project path comes from the earliest session that has one, and may be missing. + /// + [Fact] + public void FromSessions_ProjectPath_ComesFromTheEarliestSessionThatHasOne() + { + var sessions = new[] + { + Session("late", "p", "C:/late", 5, 1, 0m), + Session("early", "p", null, 1, 1, 0m), + Session("middle", "p", "C:/middle", 3, 1, 0m), + Session("alone", "q", null, 1, 1, 0m), + }; + + var projects = ProjectUsage.FromSessions(sessions); + + Assert.Equal("C:/middle", projects.Single(p => p.ProjectKey == "p").ProjectPath); + Assert.Null(projects.Single(p => p.ProjectKey == "q").ProjectPath); + } + + /// + /// Verifies that no sessions give no projects. + /// + [Fact] + public void FromSessions_NoSessions_ReturnsNoProjects() + { + Assert.Empty(ProjectUsage.FromSessions([])); + } + + /// + /// Verifies that the duration is the wall-clock time between the first and last message. + /// + [Fact] + public void Duration_Session_IsTheSpanBetweenStartAndEnd() + { + Assert.Equal(TimeSpan.FromMinutes(30), Session("s", "p", null, 1, 1, 0m).Duration); + } + + /// + /// Verifies the project filter against paths, encoded folder names and separators. + /// + [Theory] + [InlineData("repos/app", true)] + [InlineData("REPOS\\APP", true)] + [InlineData("c:/repos/app", true)] + [InlineData("--repos-app", true)] + [InlineData("nothing", false)] + [InlineData("", true)] + [InlineData(" ", true)] + [InlineData(null, true)] + public void Matches_PathOrFolderName_IsCaseAndSeparatorInsensitive(string? text, bool expected) + { + var session = Session("s", "C--repos-app", "C:/repos/app", 1, 1, 0m); + + Assert.Equal(expected, ProjectFilter.Matches(session, text)); + } + + /// + /// Verifies that the filter still matches the folder name when no working directory was recorded. + /// + [Fact] + public void Matches_NoProjectPath_MatchesTheFolderName() + { + var session = Session("s", "C--repos-app", null, 1, 1, 0m); + + Assert.True(ProjectFilter.Matches(session, "repos-app")); + Assert.False(ProjectFilter.Matches(session, "repos/app")); + } +} diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs index 8c67330..5b88924 100644 --- a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs @@ -26,6 +26,7 @@ public ClaudeUsageProviderTests() new RecentActivityBuilder(sessionFileCache, costCalculator), new HourlyActivityBuilder(sessionFileCache), new SessionBlockBuilder(sessionFileCache, costCalculator), + new SessionUsageBuilder(sessionFileCache, costCalculator), new SessionActivityTracker(locator), NullLogger.Instance); } diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs index 7a79d3b..0f8748e 100644 --- a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionParserTests.cs @@ -33,80 +33,6 @@ public void ParseFile_SkipsBlankAndMalformedLines() Assert.Equal(2, messages.Count); } - [Fact] - public void ParseSessionSummary_ComputesDurationAndTokensByModel() - { - File.WriteAllLines(_tempFile, - [ - """{"type":"user","timestamp":"2026-08-01T00:00:00Z","sessionId":"s1","cwd":"/proj"}""", - """{"type":"assistant","timestamp":"2026-08-01T00:05:00Z","sessionId":"s1","message":{"role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", - ]); - - var summary = _sut.ParseSessionSummary(_tempFile); - - Assert.NotNull(summary); - Assert.Equal("s1", summary.SessionId); - Assert.Equal("/proj", summary.Project); - Assert.Equal(TimeSpan.FromMinutes(5), summary.Duration); - Assert.Equal(160, summary.TotalTokens); - Assert.Equal(160, summary.TokensByModel["sonnet-5"]); - Assert.Equal(2, summary.MessageCount); - } - - [Fact] - public void ParseSessionSummary_DuplicateMessageId_CountsTokensOnce() - { - File.WriteAllLines(_tempFile, - [ - """{"type":"user","timestamp":"2026-08-01T00:00:00Z","sessionId":"s1"}""", - """{"type":"assistant","timestamp":"2026-08-01T00:01:00Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", - """{"type":"assistant","timestamp":"2026-08-01T00:01:05Z","sessionId":"s1","requestId":"req1","message":{"id":"msg1","role":"assistant","model":"sonnet-5","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10,"cache_creation_input_tokens":0}}}""", - ]); - - var summary = _sut.ParseSessionSummary(_tempFile); - - Assert.NotNull(summary); - Assert.Equal(160, summary.TotalTokens); - Assert.Equal(160, summary.TokensByModel["sonnet-5"]); - } - - [Fact] - public void ParseSessionSummary_EmptyFile_ReturnsNull() - { - File.WriteAllText(_tempFile, ""); - - var summary = _sut.ParseSessionSummary(_tempFile); - - Assert.Null(summary); - } - - [Fact] - public void ParseSessionSummary_FallsBackToFileNameWhenNoSessionId() - { - File.WriteAllLines(_tempFile, - [ - """{"type":"user","timestamp":"2026-08-01T00:00:00Z"}""", - ]); - - var summary = _sut.ParseSessionSummary(_tempFile); - - Assert.NotNull(summary); - Assert.Equal(Path.GetFileNameWithoutExtension(_tempFile), summary.SessionId); - } - - [Fact] - public void ParseSessionSummary_NoTimestamps_ReturnsNull() - { - File.WriteAllLines(_tempFile, - [ - """{"type":"user","sessionId":"s1"}""", - ]); - - var summary = _sut.ParseSessionSummary(_tempFile); - - Assert.Null(summary); - } - [Fact] public void ParseFile_ContentWithToolUseBlocks_CountsOnlyTopLevelToolUseBlocks() { diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionUsageBuilderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionUsageBuilderTests.cs new file mode 100644 index 0000000..96a857a --- /dev/null +++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/SessionUsageBuilderTests.cs @@ -0,0 +1,577 @@ +using AIUsageMonitor.Core.Analytics; +using AIUsageMonitor.Core.Models; +using AIUsageMonitor.Core.Providers.Claude; +using Microsoft.Extensions.Logging.Abstractions; +using System.Text; +using Xunit; + +namespace AIUsageMonitor.Core.Tests.Providers.Claude; + +/// +/// Tests for : session identity across transcript files, de-duplication, day placement, +/// model filtering, project attribution and pricing. +/// +public class SessionUsageBuilderTests : IDisposable +{ + /// + /// The directory standing in for ~/.claude. + /// + private readonly string _claudeDir = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid()}"); + + /// + /// The cache the builder reads the transcripts through. + /// + private readonly SessionFileCache _cache = new( + new SessionParser(NullLogger.Instance), NullLogger.Instance); + + /// + /// The prices used to expect costs. + /// + private readonly CostCalculator _costs = new(); + + /// + /// Initializes a new instance of the class. + /// + public SessionUsageBuilderTests() + { + Directory.CreateDirectory(ProjectsDir); + } + + /// + /// Gets the directory holding the project folders. + /// + private string ProjectsDir => Path.Combine(_claudeDir, "projects"); + + /// + /// Deletes the transcript files. + /// + public void Dispose() + { + if (Directory.Exists(_claudeDir)) + { + Directory.Delete(_claudeDir, recursive: true); + } + } + + /// + /// Builds a transcript line. + /// + /// The line type: user, assistant or something else. + /// The line's uuid. + /// The line's ISO timestamp. + /// The session id, or to omit it. + /// The API message id of an assistant line; defaults to the uuid. + /// The model of an assistant line, or for a line without usage. + /// The input tokens. + /// The output tokens. + /// The cache-read tokens. + /// The cache-creation tokens. + /// The working directory, or to omit it. + /// An optional cache_creation object with the 5-minute and 1-hour split. + /// The JSON line. + private static string Line( + string type, + string uuid, + string timestamp, + string? sessionId = "s1", + string? messageId = null, + string? model = null, + int input = 0, + int output = 0, + int cacheRead = 0, + int cacheCreation = 0, + string? cwd = null, + string? cacheDetail = null) + { + var builder = new StringBuilder(); + builder.Append($"{{\"type\":\"{type}\",\"uuid\":\"{uuid}\",\"timestamp\":\"{timestamp}\""); + if (sessionId is not null) + { + builder.Append($",\"sessionId\":\"{sessionId}\""); + } + + if (cwd is not null) + { + builder.Append($",\"cwd\":\"{cwd}\""); + } + + if (model is not null) + { + var id = messageId ?? uuid; + builder.Append($",\"requestId\":\"r-{id}\",\"message\":{{\"id\":\"{id}\",\"role\":\"assistant\",\"model\":\"{model}\",\"usage\":{{"); + builder.Append($"\"input_tokens\":{input},\"output_tokens\":{output},\"cache_read_input_tokens\":{cacheRead},\"cache_creation_input_tokens\":{cacheCreation}"); + if (cacheDetail is not null) + { + builder.Append($",\"cache_creation\":{cacheDetail}"); + } + + builder.Append("}}"); + } + + builder.Append('}'); + return builder.ToString(); + } + + /// + /// Writes a transcript under a project folder. + /// + /// The path below the projects directory, e.g. projA/s1.jsonl. + /// The transcript lines. + /// The full path of the written file. + private string Write(string relativePath, params string[] lines) + { + var path = Path.Combine(ProjectsDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllLines(path, lines); + return path; + } + + /// + /// Builds sessions for a range of days from every transcript written so far. + /// + /// The first day. + /// The last day. + /// The model filter, if any. + /// The clock deciding the local day; the system clock when . + /// The built sessions. + private List Build(DateOnly from, DateOnly to, string? model = null, TimeProvider? clock = null) + { + var files = Directory.GetFiles(ProjectsDir, "*.jsonl", SearchOption.AllDirectories); + return new SessionUsageBuilder(_cache, _costs, clock).Build(files, ProjectsDir, from, to, model); + } + + /// + /// Verifies that one session id found in two transcript files is a single session. + /// + [Fact] + public void Build_SessionSpanningTwoFiles_MergesIntoOneSession() + { + Write("projA/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z"), Line("assistant", "a1", "2026-08-02T10:01:00Z", model: "sonnet-5", input: 10)); + Write("projA/b.jsonl", Line("user", "u2", "2026-08-02T11:00:00Z"), Line("assistant", "a2", "2026-08-02T11:01:00Z", model: "sonnet-5", input: 20)); + + var sessions = Build(new(2026, 8, 1), new(2026, 8, 3)); + + var session = Assert.Single(sessions); + Assert.Equal("s1", session.SessionId); + Assert.Equal(4, session.Messages); + Assert.Equal(30, session.TotalTokens); + Assert.Equal(TimeSpan.FromMinutes(61), session.Duration); + } + + /// + /// Verifies that lines copied into a resumed transcript are credited to the session they came from, once. + /// + [Fact] + public void Build_ResumedTranscriptWithCopiedLines_CountsCopiesOnce() + { + Write("projA/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z", "s1"), Line("assistant", "a1", "2026-08-02T10:01:00Z", "s1", model: "sonnet-5", input: 10)); + Write("projA/b.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z", "s1"), + Line("assistant", "a1", "2026-08-02T10:01:00Z", "s1", model: "sonnet-5", input: 10), + Line("user", "u3", "2026-08-02T12:00:00Z", "s2")); + + var sessions = Build(new(2026, 8, 2), new(2026, 8, 2)); + + Assert.Equal(2, sessions.Count); + Assert.Equal(2, sessions.Single(s => s.SessionId == "s1").Messages); + Assert.Equal(10, sessions.Single(s => s.SessionId == "s1").TotalTokens); + Assert.Equal(1, sessions.Single(s => s.SessionId == "s2").Messages); + } + + /// + /// Verifies that the several lines of one streamed response share a single usage. + /// + [Fact] + public void Build_StreamedResponseWithSameMessageId_CountsUsageOnce() + { + Write("projA/a.jsonl", + Line("assistant", "a1", "2026-08-02T10:00:00Z", messageId: "m1", model: "sonnet-5", input: 100, output: 50), + Line("assistant", "a2", "2026-08-02T10:00:05Z", messageId: "m1", model: "sonnet-5", input: 100, output: 50)); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))); + + Assert.Equal(2, session.Messages); + Assert.Equal(150, session.TotalTokens); + Assert.Equal(150, session.TokensByModel["sonnet-5"]); + } + + /// + /// Verifies that a response whose first line is before the range keeps its usage out of the range, as in the daily statistics. + /// + [Fact] + public void Build_ResponseStraddlingTheStartOfTheRange_AttributesUsageToItsFirstLine() + { + var utc = new FixedClock(TimeZoneInfo.Utc); + Write("projA/a.jsonl", + Line("assistant", "a1", "2026-08-01T23:59:59Z", messageId: "m1", model: "sonnet-5", input: 100), + Line("assistant", "a2", "2026-08-02T00:00:01Z", messageId: "m1", model: "sonnet-5", input: 100)); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2), clock: utc)); + + Assert.Equal(1, session.Messages); + Assert.Equal(0, session.TotalTokens); + } + + /// + /// Verifies that both end days of the range are included and the days around them are not. + /// + [Fact] + public void Build_RangeEdges_AreInclusive() + { + var utc = new FixedClock(TimeZoneInfo.Utc); + Write("projA/a.jsonl", + Line("user", "u0", "2026-07-31T12:00:00Z"), + Line("user", "u1", "2026-08-01T00:00:00Z"), + Line("user", "u2", "2026-08-02T23:59:59Z"), + Line("user", "u3", "2026-08-03T00:00:00Z")); + + var session = Assert.Single(Build(new(2026, 8, 1), new(2026, 8, 2), clock: utc)); + + Assert.Equal(2, session.Messages); + } + + /// + /// Verifies that a line is placed on the local day of the injected time zone. + /// + [Fact] + public void Build_LineNearMidnightUtc_UsesTheInjectedTimeZoneForTheDay() + { + var plusEight = new FixedClock(TimeZoneInfo.CreateCustomTimeZone("UTC+8", TimeSpan.FromHours(8), "UTC+8", "UTC+8")); + Write("projA/a.jsonl", Line("user", "u1", "2026-08-01T20:00:00Z")); + + var onAugustSecond = Build(new(2026, 8, 2), new(2026, 8, 2), clock: plusEight); + var onAugustFirst = Build(new(2026, 8, 1), new(2026, 8, 1), clock: plusEight); + + Assert.Single(onAugustSecond); + Assert.Empty(onAugustFirst); + } + + /// + /// Verifies that a model filter counts, and prices, only the assistant lines of matching models. + /// + [Fact] + public void Build_ModelFilter_CountsOnlyMatchingAssistantLines() + { + Write("projA/a.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z"), + Line("assistant", "a1", "2026-08-02T10:01:00Z", model: "claude-opus-5-5", input: 1000, output: 100), + Line("assistant", "a2", "2026-08-02T10:02:00Z", model: "claude-sonnet-5-5", input: 5000)); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2), model: "OPUS")); + + Assert.Equal(1, session.Messages); + Assert.Equal(1100, session.TotalTokens); + Assert.Equal(["claude-opus-5-5"], session.TokensByModel.Keys); + Assert.Equal(_costs.EstimateCost("claude-opus-5-5", 1000, 100, 0, 0, 0), session.EstimatedCost); + } + + /// + /// Verifies that a model filter matching nothing leaves no sessions. + /// + [Fact] + public void Build_ModelFilterMatchingNothing_ReturnsNoSessions() + { + Write("projA/a.jsonl", Line("assistant", "a1", "2026-08-02T10:01:00Z", model: "sonnet-5", input: 10)); + + Assert.Empty(Build(new(2026, 8, 2), new(2026, 8, 2), model: "gpt")); + } + + /// + /// Verifies that synthetic lines count as messages but carry no tokens. + /// + [Fact] + public void Build_SyntheticModel_CountsTheLineButNoTokens() + { + Write("projA/a.jsonl", Line("assistant", "a1", "2026-08-02T10:01:00Z", model: "", input: 500)); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))); + + Assert.Equal(1, session.Messages); + Assert.Equal(0, session.TotalTokens); + Assert.Empty(session.TokensByModel); + } + + /// + /// Verifies that a sub-agent transcript nested below a project folder rolls into its parent session and project. + /// + [Fact] + public void Build_NestedSubagentTranscript_BelongsToTheParentSessionAndProject() + { + Write("projA/s1.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z")); + Write("projA/s1/subagents/agent.jsonl", Line("assistant", "a1", "2026-08-02T10:05:00Z", model: "sonnet-5", input: 40)); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))); + + Assert.Equal("projA", session.ProjectKey); + Assert.Equal(2, session.Messages); + Assert.Equal(40, session.TotalTokens); + } + + /// + /// Verifies that a session spanning two project folders takes the folder of its earliest line, whatever the folder names sort like. + /// + [Theory] + [InlineData("projA", "projB")] + [InlineData("projB", "projA")] + public void Build_SessionAcrossProjectFolders_PicksTheFolderOfTheEarliestLine(string earlierFolder, string laterFolder) + { + Write($"{earlierFolder}/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z")); + Write($"{laterFolder}/b.jsonl", Line("user", "u2", "2026-08-02T11:00:00Z")); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))); + + Assert.Equal(earlierFolder, session.ProjectKey); + } + + /// + /// Verifies that the working directory comes from the earliest line that has one. + /// + [Fact] + public void Build_WorkingDirectoryChangesDuringTheSession_UsesTheEarliestOne() + { + Write("projA/a.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z"), + Line("user", "u2", "2026-08-02T10:01:00Z", cwd: "C:/repos/app"), + Line("user", "u3", "2026-08-02T10:02:00Z", cwd: "C:/repos/app/src")); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))); + + Assert.Equal("C:/repos/app", session.ProjectPath); + } + + /// + /// Verifies that a session without any recorded working directory has no project path. + /// + [Fact] + public void Build_NoWorkingDirectoryRecorded_LeavesTheProjectPathNull() + { + Write("projA/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z")); + + Assert.Null(Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))).ProjectPath); + } + + /// + /// Verifies that lines without a session id use the first id in their file, or the file name when there is none. + /// + [Fact] + public void Build_LinesWithoutSessionId_FallBackToTheFileIdentity() + { + Write("projA/withid.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z", "known"), Line("user", "u2", "2026-08-02T10:01:00Z", sessionId: null)); + Write("projA/noid.jsonl", Line("user", "u3", "2026-08-02T10:02:00Z", sessionId: null)); + + var sessions = Build(new(2026, 8, 2), new(2026, 8, 2)); + + Assert.Equal(["known", "noid"], sessions.Select(s => s.SessionId).Order(StringComparer.Ordinal)); + Assert.Equal(2, sessions.Single(s => s.SessionId == "known").Messages); + } + + /// + /// Verifies that lines that are neither user nor assistant messages do not make a session. + /// + [Fact] + public void Build_OnlyNonMessageLines_ReturnsNoSessions() + { + Write("projA/a.jsonl", Line("summary", "x1", "2026-08-02T10:00:00Z"), Line("system", "x2", "2026-08-02T10:01:00Z")); + + Assert.Empty(Build(new(2026, 8, 2), new(2026, 8, 2))); + } + + /// + /// Verifies that the cost is the sum of each model's cost, including the 5-minute and 1-hour cache-write split. + /// + [Fact] + public void Build_TwoModelsWithCacheWrites_PricesEachModel() + { + Write("projA/a.jsonl", + Line("assistant", "a1", "2026-08-02T10:00:00Z", model: "sonnet-5", input: 1000, output: 200, cacheRead: 3000, cacheCreation: 500, + cacheDetail: "{\"ephemeral_5m_input_tokens\":200,\"ephemeral_1h_input_tokens\":300}"), + Line("assistant", "a2", "2026-08-02T10:01:00Z", model: "claude-opus-5-5", input: 10, output: 20)); + + var session = Assert.Single(Build(new(2026, 8, 2), new(2026, 8, 2))); + + var expected = _costs.EstimateCost("sonnet-5", 1000, 200, 3000, 200, 300) + _costs.EstimateCost("claude-opus-5-5", 10, 20, 0, 0, 0); + Assert.Equal(expected, session.EstimatedCost); + Assert.Equal(4700, session.TokensByModel["sonnet-5"]); + Assert.Equal(1010, session.InputTokens); + Assert.Equal(220, session.OutputTokens); + Assert.Equal(3000, session.CacheReadTokens); + Assert.Equal(500, session.CacheWriteTokens); + Assert.Equal(4730, session.TotalTokens); + } + + /// + /// Verifies that a transcript last written long before the range is not even read. + /// + [Fact] + public void Build_TranscriptNotModifiedSinceTheRange_IsSkipped() + { + var path = Write("projA/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z")); + File.SetLastWriteTimeUtc(path, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.Empty(Build(new(2026, 8, 2), new(2026, 8, 2))); + } + + /// + /// Verifies that a transcript that cannot be read is ignored rather than failing the whole build. + /// + [Fact] + public void Build_MissingTranscript_IsIgnored() + { + var missing = Path.Combine(ProjectsDir, "projA", "gone.jsonl"); + + var sessions = new SessionUsageBuilder(_cache, _costs).Build([missing], ProjectsDir, new(2026, 8, 2), new(2026, 8, 2)); + + Assert.Empty(sessions); + } + + /// + /// Verifies that lines copied into a resumed transcript in another project folder stay credited to the original's folder, + /// whichever folder name sorts first and whichever transcript was written last. + /// + [Theory] + [InlineData("projC", "projB", false)] + [InlineData("projB", "projC", false)] + [InlineData("projC", "projB", true)] + [InlineData("projB", "projC", true)] + public void Build_ResumedInAnotherProjectFolder_KeepsTheOriginalProject(string originalFolder, string resumedFolder, bool originalWrittenLast) + { + var original = Write($"{originalFolder}/s1.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z", "s1", cwd: "C:/original"), + Line("user", "u2", "2026-08-02T10:05:00Z", "s1", cwd: "C:/original")); + var resumed = Write($"{resumedFolder}/s2.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z", "s1", cwd: "C:/original"), + Line("user", "u2", "2026-08-02T10:05:00Z", "s1", cwd: "C:/original"), + Line("user", "u3", "2026-08-03T09:00:00Z", "s2", cwd: "C:/resumed")); + File.SetLastWriteTimeUtc(original, new DateTime(2026, 8, originalWrittenLast ? 5 : 4, 0, 0, 0, DateTimeKind.Utc)); + File.SetLastWriteTimeUtc(resumed, new DateTime(2026, 8, originalWrittenLast ? 4 : 5, 0, 0, 0, DateTimeKind.Utc)); + + var sessions = Build(new(2026, 8, 1), new(2026, 8, 5)); + + var first = sessions.Single(s => s.SessionId == "s1"); + Assert.Equal(originalFolder, first.ProjectKey); + Assert.Equal("C:/original", first.ProjectPath); + Assert.Equal(2, first.Messages); + Assert.Equal(resumedFolder, sessions.Single(s => s.SessionId == "s2").ProjectKey); + } + + /// + /// Verifies that copied lines are still counted once, in the copy's folder, when the original transcript is not there. + /// + [Fact] + public void Build_CopiedLinesWithoutTheOriginalTranscript_AreCountedFromTheCopy() + { + Write("projB/s2.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z", "s1"), + Line("user", "u2", "2026-08-03T09:00:00Z", "s2")); + + var sessions = Build(new(2026, 8, 1), new(2026, 8, 5)); + + Assert.Equal(["s1", "s2"], sessions.Select(s => s.SessionId)); + Assert.All(sessions, s => Assert.Equal("projB", s.ProjectKey)); + } + + /// + /// Verifies that the time zone is read on every build, not once when the builder is created. + /// + [Fact] + public void Build_TimeZoneChangesBetweenCalls_UsesTheCurrentZone() + { + var clock = new ChangingClock { Zone = TimeZoneInfo.Utc }; + Write("projA/a.jsonl", Line("user", "u1", "2026-08-01T20:00:00Z")); + var builder = new SessionUsageBuilder(_cache, _costs, clock); + var files = Directory.GetFiles(ProjectsDir, "*.jsonl", SearchOption.AllDirectories); + + var inUtc = builder.Build(files, ProjectsDir, new(2026, 8, 2), new(2026, 8, 2)); + clock.Zone = TimeZoneInfo.CreateCustomTimeZone("UTC+8", TimeSpan.FromHours(8), "UTC+8", "UTC+8"); + var inPlusEight = builder.Build(files, ProjectsDir, new(2026, 8, 2), new(2026, 8, 2)); + + Assert.Empty(inUtc); + Assert.Single(inPlusEight); + } + + /// + /// Verifies that a range from the first to the last representable day means everything instead of overflowing. + /// + [Fact] + public void Build_UnboundedRange_ReturnsEverythingWithoutOverflowing() + { + Write("projA/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z")); + + var session = Assert.Single(Build(DateOnly.MinValue, DateOnly.MaxValue)); + + Assert.Equal(1, session.Messages); + } + + /// + /// Verifies that the result does not depend on the order the transcripts are listed in. + /// + [Fact] + public void Build_FilesInDifferentOrder_GivesTheSameSessions() + { + var first = Write("projA/a.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z", "s1"), Line("user", "u2", "2026-08-02T10:01:00Z", "s2")); + var second = Write("projB/b.jsonl", Line("user", "u1", "2026-08-02T10:00:00Z", "s1"), Line("user", "u3", "2026-08-02T10:02:00Z", "s3")); + var builder = new SessionUsageBuilder(_cache, _costs); + + var forward = builder.Build([first, second], ProjectsDir, new(2026, 8, 2), new(2026, 8, 2)); + var backward = builder.Build([second, first], ProjectsDir, new(2026, 8, 2), new(2026, 8, 2)); + + Assert.Equal(forward.Select(s => (s.SessionId, s.ProjectKey, s.Messages)), backward.Select(s => (s.SessionId, s.ProjectKey, s.Messages))); + } + + /// + /// Verifies that the tokens over a whole range add up to what the daily statistics report for the same transcripts. + /// + [Fact] + public void Build_TotalsOverTheWholeRange_MatchTheDailyStatistics() + { + Write("projA/a.jsonl", + Line("user", "u1", "2026-08-02T10:00:00Z", "s1"), + Line("assistant", "a1", "2026-08-02T10:01:00Z", "s1", messageId: "m1", model: "sonnet-5", input: 100, output: 10, cacheRead: 5, cacheCreation: 7), + Line("assistant", "a2", "2026-08-02T10:01:02Z", "s1", messageId: "m1", model: "sonnet-5", input: 100, output: 10, cacheRead: 5, cacheCreation: 7), + Line("assistant", "a3", "2026-08-05T10:00:00Z", "s2", model: "claude-opus-5-5", input: 30)); + Write("projB/b.jsonl", + Line("assistant", "a1", "2026-08-02T10:01:00Z", "s1", messageId: "m1", model: "sonnet-5", input: 100, output: 10, cacheRead: 5, cacheCreation: 7), + Line("assistant", "a4", "2026-08-06T09:00:00Z", "s3", model: "sonnet-5", input: 11)); + var files = Directory.GetFiles(ProjectsDir, "*.jsonl", SearchOption.AllDirectories); + var stats = new StatsCacheBuilder(_cache).Build(files); + + var sessions = Build(new(2026, 8, 1), new(2026, 8, 31)); + + Assert.Equal(stats.DailyModelTokens.Sum(d => d.TokensByModel.Values.Sum()), sessions.Sum(s => s.TotalTokens)); + } + + /// + /// Verifies the project folder of transcripts directly below, deeper below and outside the projects directory. + /// + [Fact] + public void ProjectFolder_VariousLocations_ReturnsTheFirstFolderBelowTheProjectsDirectory() + { + Assert.Equal("projA", SessionUsageBuilder.ProjectFolder(ProjectsDir, Path.Combine(ProjectsDir, "projA", "s.jsonl"))); + Assert.Equal("projA", SessionUsageBuilder.ProjectFolder(ProjectsDir, Path.Combine(ProjectsDir, "projA", "s", "subagents", "x.jsonl"))); + Assert.Equal("elsewhere", SessionUsageBuilder.ProjectFolder(ProjectsDir, Path.Combine(_claudeDir, "elsewhere", "s.jsonl"))); + } + + /// + /// A clock whose local time zone can be changed between calls. + /// + private sealed class ChangingClock : TimeProvider + { + /// + /// Gets or sets the local time zone to report. + /// + public TimeZoneInfo Zone { get; set; } = TimeZoneInfo.Utc; + + /// + public override TimeZoneInfo LocalTimeZone => Zone; + } + + /// + /// A clock that decides the local time zone and otherwise follows the system clock. + /// + /// The local time zone to report. + private sealed class FixedClock(TimeZoneInfo zone) : TimeProvider + { + /// + public override TimeZoneInfo LocalTimeZone => zone; + } +} diff --git a/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs b/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs index fcede74..080a319 100644 --- a/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Services/DataServiceTests.cs @@ -200,6 +200,49 @@ public void Dispose_DoesNotThrow_WhenNoFileWatchersWereCreated() Assert.Null(exception); } + /// + /// Verifies that session usage is requested from the provider with the days and model, and filtered by project. + /// + [Fact] + public void GetSessionUsage_ProjectFilter_KeepsMatchingSessionsOnly() + { + _provider.SessionUsage = [SessionOf("s1", "C--repos-app", "C:/repos/app"), SessionOf("s2", "C--repos-other", "C:/repos/other")]; + using var sut = CreateSut(); + + var sessions = sut.GetSessionUsage(new(2026, 8, 1), new(2026, 8, 31), "opus", "repos/app"); + + Assert.Equal("s1", Assert.Single(sessions).SessionId); + Assert.Equal((new DateOnly(2026, 8, 1), new DateOnly(2026, 8, 31), "opus"), _provider.LastSessionUsageRequest); + } + + /// + /// Verifies that project usage groups the sessions that pass the filter. + /// + [Fact] + public void GetProjectUsage_SessionsOfTwoProjects_GroupsThem() + { + _provider.SessionUsage = [SessionOf("s1", "p1", null), SessionOf("s2", "p1", null), SessionOf("s3", "p2", null)]; + using var sut = CreateSut(); + + var projects = sut.GetProjectUsage(new(2026, 8, 1), new(2026, 8, 31)); + + Assert.Equal(2, projects.Count); + Assert.Equal(2, projects.Single(p => p.ProjectKey == "p1").Sessions); + } + + /// + /// Creates a session with only the project fields set. + /// + /// The session id. + /// The project folder. + /// The working directory, if any. + /// The session. + private static SessionUsage SessionOf(string id, string key, string? path) + { + var start = new DateTimeOffset(2026, 8, 2, 10, 0, 0, TimeSpan.Zero); + return new SessionUsage(id, key, path, start, start.AddMinutes(1), 1, 0, 10, 10, 0, 0, 0, [], 0m); + } + private sealed class FakeUsageProvider : IUsageProvider { public int StatsCacheCallCount { get; private set; } @@ -215,6 +258,24 @@ private sealed class FakeUsageProvider : IUsageProvider public string Name => "Fake"; + /// + /// Gets or sets the sessions returned to callers. + /// + public List SessionUsage { get; set; } = []; + + /// + /// Gets the days and model filter of the most recent session-usage request. + /// + public (DateOnly From, DateOnly To, string? Model)? LastSessionUsageRequest { get; private set; } + + + /// + public List GetSessionUsage(DateOnly from, DateOnly to, string? model, IProgress? progress = null) + { + LastSessionUsageRequest = (from, to, model); + return SessionUsage; + } + public List GetHourlyActivity(IProgress? progress = null) => HourlyActivity; public RecentActivitySummary GetRecentActivity(TimeSpan window, IProgress? progress = null) diff --git a/tests/AIUsageMonitor.Core.Tests/Services/DataServiceWatcherTests.cs b/tests/AIUsageMonitor.Core.Tests/Services/DataServiceWatcherTests.cs index 1677f7d..d9eb72d 100644 --- a/tests/AIUsageMonitor.Core.Tests/Services/DataServiceWatcherTests.cs +++ b/tests/AIUsageMonitor.Core.Tests/Services/DataServiceWatcherTests.cs @@ -62,6 +62,7 @@ public DataServiceWatcherTests() new RecentActivityBuilder(_sessionFileCache, costCalculator), new HourlyActivityBuilder(_sessionFileCache), new SessionBlockBuilder(_sessionFileCache, costCalculator), + new SessionUsageBuilder(_sessionFileCache, costCalculator), tracker, NullLogger.Instance); diff --git a/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs b/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs index 946bfab..a4d2442 100644 --- a/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs +++ b/tests/AIUsageMonitor.WPF.Tests/DashboardViewModelTests.cs @@ -237,6 +237,9 @@ private sealed class FakeUsageProvider : IUsageProvider /// public string Name => "Fake"; + /// + public List GetSessionUsage(DateOnly from, DateOnly to, string? model, IProgress? progress = null) => []; + /// public List GetHourlyActivity(IProgress? progress = null) { From fb64dfa45d87117e1a103afe509b3a38c25601aa Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Fri, 9 Oct 2026 22:50:40 +0800 Subject: [PATCH 3/3] docs: describe projects, sessions --list and the shared token accounting --- CLAUDE.md | 10 +++++----- README.md | 24 ++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1117fc3..8136bbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,11 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Build: `dotnet build AIUsageMonitor.slnx` - Run CLI (binary name is `aimon`): `dotnet run --project src/AIUsageMonitor.Cli -- ` - - Commands: `today`, `week`, `month`, `models`, `sessions`, `hours`, `watch`, `export` + - Commands: `today`, `week`, `month`, `models`, `sessions`, `projects`, `hours`, `watch`, `export` - `watch` takes a sub-view (`limits|today|week|models|sessions|hours`, default `limits`) and refreshes it on an interval - `watch` (default `--view limits`) continuously refreshes "current session" (5h) / "this week" usage with reset times (`--interval`, `--session-reset `, `--week-reset `; both persist to `%LOCALAPPDATA%\aimon\limits-settings.json`, and `r` re-prompts for both reset times while watching; the session reset is auto-computed at startup and never prompted for there, only the week reset is) - `export` supports `--format json|csv` and `--output ` (defaults to stdout, JSON) - - Report commands (`today|week|month|models|hours|sessions`) take `--json` (machine-readable stdout, no progress bar, update check skipped). `today` takes `--date yyyy-MM-dd`; `week`/`month` take `--from [--to]` or `--last ` (`month` also `--month yyyy-MM`); `models` takes `--model `. Date arguments are validated by `Commands/DateRangeResolver` (max 3650 days, years 2000-2999) and a bad one prints to stderr and exits 1; `Commands/DateRangeOptions` only defines/reads the options, and `Commands/JsonOutput` holds the shared `--json` plumbing (JSON shapes are in `Rendering/CliJsonContext`, durations as seconds). + - Report commands (`today|week|month|models|hours|sessions`) take `--json` (machine-readable stdout, no progress bar, update check skipped). `today` takes `--date yyyy-MM-dd`; `week`/`month` take `--from [--to]` or `--last ` (`month` also `--month yyyy-MM`); `models` takes `--model `. `projects` and `sessions --list` (default last 30 days, same range options) take `--top`, `--sort`, `--project`, `--model` (`Commands/ListOptions`, ordering in `Commands/UsageSorting`, JSON in `Rendering/UsageReports`; the range / load / sort / JSON-or-hint-or-table flow both commands share is `Commands/UsageList.Run`, so a new list command should reuse it); `sessions` without `--list` is the unchanged aggregate view and rejects those options. Date arguments are validated by `Commands/DateRangeResolver` (max 3650 days, years 2000-2999) and a bad one prints to stderr and exits 1; `Commands/DateRangeOptions` only defines/reads the options, and `Commands/JsonOutput` holds the shared `--json` plumbing (JSON shapes are in `Rendering/CliJsonContext`, durations as seconds). - Run WPF app (Windows-only): `dotnet run --project src/AIUsageMonitor.WPF` - Run tests: `dotnet test AIUsageMonitor.slnx` - Single test: `dotnet test tests/AIUsageMonitor.Core.Tests --filter "FullyQualifiedName~MethodName"` @@ -29,17 +29,17 @@ The tool is a **read-only analytics layer over Claude Code's own local usage dat ### Data flow (Core) -Provider-specific code lives under `Providers//` and implements `Providers.IUsageProvider` (`GetStatsCache()`, `GetSessionSummaries()`). Today there is one provider, `Providers/Claude/`: `ClaudeDataLocator` resolves the Claude Code data directory and enumerates its files → `StatsCacheParser` / `SessionParser` / `HistoryParser` parse the JSON/JSONL line-by-line (tolerant of malformed lines) using a source-generated `System.Text.Json` context (`Models/JsonContext.cs`, AOT/trim-friendly, no reflection) → `ClaudeUsageProvider` wraps the locator/parsers behind `IUsageProvider`. +Provider-specific code lives under `Providers//` and implements `Providers.IUsageProvider` (`GetStatsCache()`, `GetSessionUsage()` and the hourly/recent/window queries). Today there is one provider, `Providers/Claude/`: `ClaudeDataLocator` resolves the Claude Code data directory and enumerates its files → `StatsCacheParser` / `SessionParser` / `HistoryParser` parse the JSON/JSONL line-by-line (tolerant of malformed lines) using a source-generated `System.Text.Json` context (`Models/JsonContext.cs`, AOT/trim-friendly, no reflection) → `ClaudeUsageProvider` wraps the locator/parsers behind `IUsageProvider`. `Analytics/UsageAnalyzer` computes daily/period/model-distribution/hourly/session summaries from an `IUsageProvider`'s `StatsCache`, using `Analytics/CostCalculator` for token cost estimation → `Services/DataService` is the single facade over all of this, consumed by both Cli and WPF. `StatsCache` is currently Claude's own cache-file shape (`Providers/Claude/Models`); adding a second provider will require either normalizing its output to that shape or generalizing `UsageAnalyzer`'s input type. -`DataService` caches the parsed `StatsCache` for 10 minutes and invalidates early via `FileSystemWatcher`s on `stats-cache.json` and the session transcripts (Claude-provider-specific, via a type check in `DataService`'s constructor); an invalidation that arrives while the stats are being computed limits that result to a 2-second lifetime (`InvalidateStatsCache` bumps a version that `GetStatsCache` checks before storing), so overlapping reads still share it without it being served for long. Session-level summaries (`GetSessionSummaries`) are read fresh from the raw session files rather than the cache. Long-running `DataService` reads accept an optional `IProgress`, which the Cli surfaces as a Spectre.Console progress bar. +`DataService` caches the parsed `StatsCache` for 10 minutes and invalidates early via `FileSystemWatcher`s on `stats-cache.json` and the session transcripts (Claude-provider-specific, via a type check in `DataService`'s constructor); an invalidation that arrives while the stats are being computed limits that result to a 2-second lifetime (`InvalidateStatsCache` bumps a version that `GetStatsCache` checks before storing), so overlapping reads still share it without it being served for long. Tokens are summed and priced (5-minute and 1-hour cache writes apart) through `Providers/Claude/ModelTokenTotals` by the builders that price usage (`RecentActivityBuilder`, `SessionBlockBuilder`, `SessionUsageBuilder`), and a response's total is `TokenUsage.TotalTokens`. Per-session and per-project usage (`GetSessionUsage` / `GetProjectUsage`, built by `Providers/Claude/SessionUsageBuilder`) is read fresh from the transcripts rather than the cache: it takes inclusive local days, aggregates by the session id on each line across files (a session is clipped to the range), counts a transcript's home lines (session id equal to the file name, which Claude Code uses to name transcripts, or no session id) before the rest so a copy in a resumed transcript is the duplicate and the session stays credited to the original's project folder, applies the model filter inside the builder, and takes the project folder and working directory from the session's earliest counted line; the project text filter is `ProjectFilter` in `DataService`. Long-running `DataService` reads accept an optional `IProgress`, which the Cli surfaces as a Spectre.Console progress bar. Transcript parsing is cached by `Providers/Claude/SessionFileCache` in three layers: an in-memory entry per file, a persistent per-file binary entry under `%LOCALAPPDATA%\aimon\cache\session-rows` (`SessionRowDiskCache`, so every new process starts warm; bump its `FormatVersion` whenever the stored row layout or the meaning of a parsed field changes), and incremental parsing - transcripts are append-only, so a changed file is extended by parsing only the bytes after `CachedSessionFile.ParsedBytes`, after a hash of the preceding bytes confirms it was not rewritten. Windowed queries (`SessionBlockBuilder`, `RecentActivityBuilder`) additionally skip files whose last-write time is older than the window via `GetFilesModifiedSince`. A disk-cache problem is always just a cache miss; the transcripts stay the source of truth. `ClaudeDataLocator.GetSessionFiles()` caches its recursive directory scan once `DataService` calls `EnableChangeTracking()` (after its `FileSystemWatcher` is running): created/deleted/renamed events update the listing, a watcher error invalidates it, and it is rescanned every 5 minutes as a safety net; without tracking every call scans the disk. The watcher invalidates the stats cache immediately but refreshes changed transcripts through `PathChangeDebouncer` (250 ms quiet period, 1 s maximum hold-back) on a background thread, and `SessionFileCache` serializes refreshes per file so a caller and the background refresh never parse the same file twice. `SessionFileCache.GetFilesModifiedSince` remembers each transcript's last-write time once `DataService` calls `EnableWriteTimeTracking()`: the watcher keeps it current through `NoteWritten` (a remembered time only ever moves forward), a watcher error calls `ForgetWriteTimes()`, and it is re-read from disk every minute as a safety net. Anything that makes a file newer without raising a watcher event is therefore invisible to windowed queries for up to a minute. -Resuming a Claude Code session copies the earlier transcript lines (same `uuid`) into the new file, and a streamed response is written as several lines that repeat the same usage. Every aggregation over several files (`StatsCacheBuilder`, `HourlyActivityBuilder`, `RecentActivityBuilder`, `SessionBlockBuilder`) therefore shares one `TranscriptDeduplicator` per pass: `TryAddLine` counts a line (by `uuid`) once across files, `TryAddUsage` counts a response's tokens (by message id + request id) once. Sessions are identified per line by its own `sessionId`, and `StatsCacheBuilder` measures the longest session over each session's own lines, so copied history is credited only to the session it came from. `SessionBlockBuilder` and `RecentActivityBuilder` take an optional `TimeProvider` (clock and local time zone) so window placement, DST handling and hourly buckets are testable; production uses the system clock. +Resuming a Claude Code session copies the earlier transcript lines (same `uuid`) into the new file, and a streamed response is written as several lines that repeat the same usage. Every aggregation over several files (`StatsCacheBuilder`, `HourlyActivityBuilder`, `RecentActivityBuilder`, `SessionBlockBuilder`, `SessionUsageBuilder`) therefore shares one `TranscriptDeduplicator` per pass: `TryAddLine` counts a line (by `uuid`) once across files, `TryAddUsage` counts a response's tokens (by message id + request id) once. Sessions are identified per line by its own `sessionId`, and `StatsCacheBuilder` measures the longest session over each session's own lines, so copied history is credited only to the session it came from. `SessionBlockBuilder` and `RecentActivityBuilder` take an optional `TimeProvider` (clock and local time zone) so window placement, DST handling and hourly buckets are testable; production uses the system clock. DI is wired through `ServiceCollectionExtensions.AddClaudeUsageCore()`, which registers the Claude provider's locator/parsers, binds it as the singleton `IUsageProvider`, and registers `CostCalculator`, `UsageAnalyzer`, and `DataService`. diff --git a/README.md b/README.md index 3e1efb1..439a9ed 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ Commands: - `week` — current week's usage - `month` — current month's usage - `models` — usage broken down by model -- `sessions` — per-session summaries +- `sessions` — overall session statistics; with `--list`, the individual sessions +- `projects` — usage per project - `hours` — usage broken down by hour of day - `export` — export raw analytics; supports `--format json|csv` and `--output ` (defaults to stdout, JSON) @@ -75,6 +76,15 @@ Report options (`today`, `week`, `month`, `models`, `hours`, `sessions`): - `week` / `month`: `--from yyyy-MM-dd [--to yyyy-MM-dd]` or `--last `; `month` also takes `--month yyyy-MM`. Ranges are limited to 3650 days and years 2000-2999; invalid values print an error to stderr and exit with code 1 - `models --model ` — only models whose name contains the text (percentages stay relative to all models). If nothing matches, the table view prints `No model matches ''.` to stderr and exits 0, and `--json` prints `[]` +Per-project and per-session usage (`projects`, `sessions --list`): + +- Range: `--from`/`--to`, `--last`, `--month` as above; the default is the **last 30 days** +- `--top ` — only the first N rows; `--sort` — `projects`: `tokens|cost|sessions|messages`, `sessions --list`: `tokens|cost|messages|duration|start` (largest or newest first, default `tokens`); a Messages column is added to the table when the rows are ordered by `messages` +- `--project ` — only projects whose working directory or `projects/` folder name contains the text; `--model ` — only count usage of matching models (for sessions, only those assistant messages are counted) +- `--json` prints `totalProjects` / `totalSessions` (matches before `--top`) and the rows, with durations as `durationSeconds` +- `sessions` without `--list` is unchanged; giving it a range, `--top`, `--sort`, `--project` or `--model` without `--list` is an error +- A session is clipped to the range (it shows only what happened inside it) and may span several transcript files; `messages` counts transcript lines and `duration` is the wall-clock time between the first and last message, idle gaps included. Tokens match `month` for the same days; the cost here prices every message exactly (including 5-minute and 1-hour cache writes), so it can differ a little from `month`, which scales an all-time model mix, and `month` counts a session once per day it was active + `watch`'s default view, `limits`, approximates the "Current Session" (rolling 5-hour window) and "This Week" panels from Claude's own account UI, each with a **Time Progress** bar (how far the window has elapsed, colored green/orange when token usage is pacing notably behind/ahead of it) and a **Token Progress** bar (how close it is to its budget). For the session window, a pace hint also appears below the table when token usage is running noticeably ahead of or behind elapsed time, suggesting you slow down or use more freely. The real reset times and token limits live on your Anthropic account, not locally, so pin them once: @@ -169,7 +179,8 @@ aimon <命令> - `week` — 本周用量 - `month` — 本月用量 - `models` — 按模型统计用量 -- `sessions` — 每个会话的用量汇总 +- `sessions` — 会话整体统计;加 `--list` 则列出每个会话 +- `projects` — 按项目统计用量 - `hours` — 按小时统计用量 - `export` — 导出原始分析数据;支持 `--format json|csv` 与 `--output `(默认输出到标准输出,格式为 JSON) @@ -180,6 +191,15 @@ aimon <命令> - `week` / `month`:`--from yyyy-MM-dd [--to yyyy-MM-dd]` 或 `--last <天数>`;`month` 另支持 `--month yyyy-MM`。范围最长 3650 天,年份限定在 2000–2999;值无效时向 stderr 输出错误并以退出码 1 结束 - `models --model <文本>` — 只显示名称包含该文本的模型(占比仍相对于全部模型)。若没有匹配项,表格模式会向 stderr 输出 `No model matches '<文本>'.` 并以退出码 0 结束,`--json` 则输出 `[]` +按项目、按会话统计(`projects`、`sessions --list`): + +- 范围:`--from`/`--to`、`--last`、`--month` 同上,默认**最近 30 天** +- `--top ` — 只显示前 N 行;`--sort` — `projects`:`tokens|cost|sessions|messages`,`sessions --list`:`tokens|cost|messages|duration|start`(从大到小,`start` 为最新在前,默认 `tokens`);按 `messages` 排序时表格会增加 Messages 列 +- `--project <文本>` — 只看工作目录或 `projects/` 文件夹名包含该文本的项目;`--model <文本>` — 只统计名称匹配的模型(会话中只计入这些助手消息) +- `--json` 会输出 `totalProjects` / `totalSessions`(应用 `--top` 之前的匹配数)和各行数据,时长字段为 `durationSeconds` +- 不带 `--list` 的 `sessions` 行为不变;不带 `--list` 却给了范围、`--top`、`--sort`、`--project` 或 `--model` 会报错 +- 会话会被范围裁剪(只显示范围内发生的部分),可能跨多个转录文件;`messages` 统计转录行数,`duration` 是首尾消息之间的墙钟时间(含空闲)。同一天范围内 token 与 `month` 一致;这里的费用按每条消息精确计价(含 5 分钟/1 小时缓存写入),所以和 `month`(按全期模型比例缩放估算)略有出入,且 `month` 会把跨天会话在每个活跃日各算一次 + `watch` 的默认视图 `limits` 近似展示 Claude 官方账户界面中的 "Current Session"(滚动 5 小时窗口)和 "This Week" 面板,各自附带 **Time Progress**(窗口已过去的时间比例,当 token 消耗明显落后/领先于时间进度时会分别显示绿色/橙色)与 **Token Progress**(用量占预算的比例)两条进度条。针对当前会话窗口,当 token 消耗进度明显快于或慢于时间进度时,表格下方还会出现一条节奏提示,建议你放慢或可以放心多用。 真实的重置时间和 token 上限存储在 Anthropic 账号侧,本地无法读取,建议各录入一次: