Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <command>`
- 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 <HH:mm>`, `--week-reset <Ddd HH:mm>`; 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 <path>` (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 <days>` (`month` also `--month yyyy-MM`); `models` takes `--model <text>`. 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 <days>` (`month` also `--month yyyy-MM`); `models` takes `--model <text>`. `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"`
Expand All @@ -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/<Name>/` 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/<Name>/` 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<int>`, 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<int>`, 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`.

Expand Down
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` (defaults to stdout, JSON)

Expand All @@ -75,6 +76,15 @@ Report options (`today`, `week`, `month`, `models`, `hours`, `sessions`):
- `week` / `month`: `--from yyyy-MM-dd [--to yyyy-MM-dd]` or `--last <days>`; `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 <text>` — only models whose name contains the text (percentages stay relative to all models). If nothing matches, the table view prints `No model matches '<text>'.` 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 <n>` — 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 <text>` — only projects whose working directory or `projects/` folder name contains the text; `--model <text>` — 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:
Expand Down Expand Up @@ -169,7 +179,8 @@ aimon <命令>
- `week` — 本周用量
- `month` — 本月用量
- `models` — 按模型统计用量
- `sessions` — 每个会话的用量汇总
- `sessions` — 会话整体统计;加 `--list` 则列出每个会话
- `projects` — 按项目统计用量
- `hours` — 按小时统计用量
- `export` — 导出原始分析数据;支持 `--format json|csv` 与 `--output <path>`(默认输出到标准输出,格式为 JSON)

Expand All @@ -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>` — 只显示前 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 账号侧,本地无法读取,建议各录入一次:
Expand Down
8 changes: 7 additions & 1 deletion src/AIUsageMonitor.Cli/Commands/DateRangeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ namespace AIUsageMonitor.Cli.Commands;
/// <param name="To">The last day of a range (<c>yyyy-MM-dd</c>), or <see langword="null"/> when not given.</param>
/// <param name="Last">The number of days ending today, as typed, or <see langword="null"/> when not given.</param>
/// <param name="Month">A calendar month (<c>yyyy-MM</c>), or <see langword="null"/> when not given.</param>
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)
{
/// <summary>
/// Gets a value indicating whether any date argument was given.
/// </summary>
public bool HasAny => Date is not null || From is not null || To is not null || Last is not null || Month is not null;
}

/// <summary>
/// Defines the date-range options shared by the report commands and turns them into a concrete date range.
Expand Down
Loading
Loading