usage-cli 0.30.23__tar.gz → 0.31.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {usage_cli-0.30.23/usage_cli.egg-info → usage_cli-0.31.0}/PKG-INFO +4 -4
- {usage_cli-0.30.23 → usage_cli-0.31.0}/README.md +3 -3
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/aggregator.py +1 -0
- usage_cli-0.31.0/analyzer/dispatch_ledger.py +451 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/reporter.py +24 -12
- {usage_cli-0.30.23 → usage_cli-0.31.0}/i18n.json +65 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/agy_loader.py +39 -5
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/__init__.py +14 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/dynamic_height.py +34 -11
- {usage_cli-0.30.23 → usage_cli-0.31.0}/pyproject.toml +1 -1
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_aggregator.py +20 -1
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_loader.py +98 -4
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_analyzer_pipeline.py +1 -0
- usage_cli-0.31.0/tests/test_dispatch_ledger.py +312 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_dynamic_height.py +6 -3
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_html_report_snapshot.py +2 -0
- usage_cli-0.31.0/tests/test_panel_footer_layout.py +44 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panels.py +3 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_reporter_build_data.py +4 -1
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_wintray.py +217 -2
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/html_report.py +5 -2
- usage_cli-0.31.0/ui/report_ledger.py +74 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0/usage_cli.egg-info}/PKG-INFO +4 -4
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/SOURCES.txt +4 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/app.py +129 -31
- {usage_cli-0.30.23 → usage_cli-0.31.0}/LICENSE +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/claude.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/codex.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/muse.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/rate_limits.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/registry.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/adapters/types.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/blocks.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/diagnoser.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/insights.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/persona_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/subscription.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/analyzer/usage_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/doctor.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/fsevents_watch.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/i18n.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/session_hooks.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/setup_hook.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/installer/statusline_settings.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/agy_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/agy_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/cache_quarantine.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/claude_paths.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_events.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_fork_replay.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/codex_paths.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/disk_cache_common.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/disk_cache_lifecycle.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/grok_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/grok_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/history_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/history_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/jsonl_limits.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/jsonl_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/loaders/muse_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/main.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/actions.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/chrome.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/menu.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/notify.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/popover.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/prefs.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/refresh.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/title.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/menubar/update.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/base.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/panel_scale.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/panel_window.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/panel_window_state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/payload.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/web_panel.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/panels/window_drag.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/prefs.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/pricing.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/project_resolver.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/agy_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/burn_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/codex_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/usage_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/quota/window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/service_status.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/setup.cfg +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/setup_app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_adapters_claude.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_adapters_codex.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_adapters_rate_limits.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_adapter.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_agy_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_analyzer_blocks.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_burn_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_cache_quarantine.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_claude_paths.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_codex_consistency.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_codex_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_codex_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_diagnoser.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_disk_cache_shards.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_doctor.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_fsevents_watch.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_grok_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_grok_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_history_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_hook_scripts_are_standalone.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_i18n_format_fallback.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_i18n_key_parity.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_i18n_packaged_path.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_insights.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_jsonl_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_main.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_prefs.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_menubar_state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_muse_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_packaged_resources.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_packaging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panel_scale.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panel_window_state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_panels_lazy_import.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_persona_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_pricing.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_project_resolver.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_report_daily_chart.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_report_filter_js.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_service_status.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_session_resume.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook_coexistence.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook_resume.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_setup_hook_terse.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_subprocess_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_subscription.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_time_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_tui.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_tui_sprite.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_ui_tables.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_update_checker.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_update_gate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_update_release_notes.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_cli.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_client.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_dir_sweeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_lang.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_logging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_notifications.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_snapshot_replay.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline_agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline_forwarder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_statusline_grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_terse_mode.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_usage_terse_reminder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_web_panel_payload.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_wheel_packaging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_win_login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tests/test_windows_manifest.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tui/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tui/app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/tui/sprite.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_charts.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_daily_chart.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_filter.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_scripts.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/report_styles.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/ui/tables.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/checker.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/gate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/updates/release_notes.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/dependency_links.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/entry_points.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/requires.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.egg-info/top_level.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_cli.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_client.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/subprocess_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/time_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/usage_dir_sweeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/usage_lang.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_common/usage_logging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_notifications.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_session_resume.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline_agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline_forwarder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_statusline_grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_terse_mode.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/usage_terse_reminder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/menu.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.31.0}/wintray/watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usage-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.31.0
|
|
4
4
|
Summary: usage — 在 macOS menu bar 與 Windows 系統匣顯示 Claude Code、Codex、Antigravity 用量的小工具(也提供終端機 TUI)
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -87,7 +87,7 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
|
|
|
87
87
|
|
|
88
88
|
### Experience & Customization
|
|
89
89
|
|
|
90
|
-
- **
|
|
90
|
+
- **15 Visual Themes:** Switch between panel styles including Default, Matrix, Windows 95, Vintage Newspaper, Cloud Observation, Midnight Aquarium, Prism Arcade, Black Hole, World Cup 2026, Lepidoptera, Migration, Stained Glass, Origami, Sketchbook, and Catppuccin (official palette, all four flavors).
|
|
91
91
|
- **Place the Panel Anywhere:** The panel is no longer pinned under the menu bar icon. Drag it from any empty spot to wherever you want it, and it reopens there next time. It stays put when another app takes focus — a second click on the menu bar icon, or Escape, closes it.
|
|
92
92
|
- **Drag to Reorder:** Grab any quota card and drag it up or down to swap the order — the arrangement is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
|
|
93
93
|
- **Automatic Localization:** UI text is available in Traditional Chinese, Simplified Chinese, English, Japanese, and Korean, automatically matching your system settings.
|
|
@@ -147,7 +147,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
|
|
|
147
147
|
|
|
148
148
|
Windows has the full core experience: the system-tray UI, Claude Code status-line hook, and Codex history parsing all work natively. Download `usage-windows.zip` from the [latest GitHub Release](https://github.com/aqua5230/usage/releases/latest), unzip it, then run `usage.exe`—no installer is needed. If SmartScreen shows **Windows protected your PC** on first launch, click **More info** → **Run anyway**. The tray UI requires Microsoft Edge WebView2 Runtime, which is normally included with Windows 10 and 11.
|
|
149
149
|
|
|
150
|
-
The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same
|
|
150
|
+
The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same 15 quota themes available on macOS (Default plus the other fourteen) in WebView2. Right-click provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
|
|
151
151
|
|
|
152
152
|
Windows differences: the panel opens at the bottom-right of the working area rather than next to the tray icon; update prompts use a system Yes/No dialog.
|
|
153
153
|
|
|
@@ -164,7 +164,7 @@ Privacy policy: this program will not transfer any information to other networke
|
|
|
164
164
|
|
|
165
165
|
## Theme Gallery
|
|
166
166
|
|
|
167
|
-
Switch between **
|
|
167
|
+
Switch between **15 visual themes** directly from the UI:
|
|
168
168
|
|
|
169
169
|
<p align="center">
|
|
170
170
|
<img src="docs/classic.en.png" width="32%" alt="Classic theme" />
|
|
@@ -67,7 +67,7 @@ It lands in your Applications folder automatically. Open it once; if macOS 15 or
|
|
|
67
67
|
|
|
68
68
|
### Experience & Customization
|
|
69
69
|
|
|
70
|
-
- **
|
|
70
|
+
- **15 Visual Themes:** Switch between panel styles including Default, Matrix, Windows 95, Vintage Newspaper, Cloud Observation, Midnight Aquarium, Prism Arcade, Black Hole, World Cup 2026, Lepidoptera, Migration, Stained Glass, Origami, Sketchbook, and Catppuccin (official palette, all four flavors).
|
|
71
71
|
- **Place the Panel Anywhere:** The panel is no longer pinned under the menu bar icon. Drag it from any empty spot to wherever you want it, and it reopens there next time. It stays put when another app takes focus — a second click on the menu bar icon, or Escape, closes it.
|
|
72
72
|
- **Drag to Reorder:** Grab any quota card and drag it up or down to swap the order — the arrangement is shared across every theme with quota cards (all except World Cup 2026) and survives restarts.
|
|
73
73
|
- **Automatic Localization:** UI text is available in Traditional Chinese, Simplified Chinese, English, Japanese, and Korean, automatically matching your system settings.
|
|
@@ -127,7 +127,7 @@ Once set up, the bottom of the Claude Code window will show a status line like t
|
|
|
127
127
|
|
|
128
128
|
Windows has the full core experience: the system-tray UI, Claude Code status-line hook, and Codex history parsing all work natively. Download `usage-windows.zip` from the [latest GitHub Release](https://github.com/aqua5230/usage/releases/latest), unzip it, then run `usage.exe`—no installer is needed. If SmartScreen shows **Windows protected your PC** on first launch, click **More info** → **Run anyway**. The tray UI requires Microsoft Edge WebView2 Runtime, which is normally included with Windows 10 and 11.
|
|
129
129
|
|
|
130
|
-
The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same
|
|
130
|
+
The system-tray icon updates with your Claude quota percentage; its tooltip summarizes the Claude and Codex windows. Left-click opens the same 15 quota themes available on macOS (Default plus the other fourteen) in WebView2. Right-click provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
|
|
131
131
|
|
|
132
132
|
Windows differences: the panel opens at the bottom-right of the working area rather than next to the tray icon; update prompts use a system Yes/No dialog.
|
|
133
133
|
|
|
@@ -144,7 +144,7 @@ Privacy policy: this program will not transfer any information to other networke
|
|
|
144
144
|
|
|
145
145
|
## Theme Gallery
|
|
146
146
|
|
|
147
|
-
Switch between **
|
|
147
|
+
Switch between **15 visual themes** directly from the UI:
|
|
148
148
|
|
|
149
149
|
<p align="center">
|
|
150
150
|
<img src="docs/classic.en.png" width="32%" alt="Classic theme" />
|
|
@@ -122,6 +122,7 @@ def aggregate_sessions(entries: list[UsageEntry]) -> list[SessionStats]:
|
|
|
122
122
|
start_time=first.timestamp,
|
|
123
123
|
end_time=last.timestamp,
|
|
124
124
|
duration_minutes=round(duration, 1),
|
|
125
|
+
agent_id=first.agent_id,
|
|
125
126
|
)
|
|
126
127
|
for e in session_entries:
|
|
127
128
|
cost = calculate_cost(e)
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
|
|
4
|
+
"""Local dispatch matching and subscription quota estimates for HTML reports."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
from typing import Any, TypedDict
|
|
16
|
+
|
|
17
|
+
from adapters import rate_limits
|
|
18
|
+
from adapters.types import UsageEntry
|
|
19
|
+
from loaders import agy_loader, agy_quota_probe, codex_loader
|
|
20
|
+
from loaders.claude_paths import claude_config_dirs
|
|
21
|
+
|
|
22
|
+
_DISPATCH = {
|
|
23
|
+
"codex": re.compile(r"(^|[;&|(\s])codex exec"),
|
|
24
|
+
"antigravity": re.compile(r"(^|[;&|(\s])agy -p"),
|
|
25
|
+
}
|
|
26
|
+
_UUID = re.compile(r"\b[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\b")
|
|
27
|
+
_CWD = re.compile(rb'"Cwd":"((?:[^"\\]|\\.)*)"')
|
|
28
|
+
_file_cache: dict[tuple[Path, int, float], list[Dispatch]] = {}
|
|
29
|
+
_codex_cache: dict[Path, tuple[tuple[int, float], Candidate | None]] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Dispatch:
|
|
34
|
+
timestamp: datetime
|
|
35
|
+
session_id: str
|
|
36
|
+
cwd: str
|
|
37
|
+
command: str
|
|
38
|
+
agent_id: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Candidate:
|
|
43
|
+
agent_id: str
|
|
44
|
+
session_id: str
|
|
45
|
+
timestamp: datetime
|
|
46
|
+
cwd: str
|
|
47
|
+
first_message: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class QuotaWindow:
|
|
52
|
+
used_pct: float
|
|
53
|
+
resets_at: datetime
|
|
54
|
+
length: timedelta
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LedgerRow(TypedDict):
|
|
58
|
+
date: str
|
|
59
|
+
project: str
|
|
60
|
+
title: str
|
|
61
|
+
dispatch_count: int
|
|
62
|
+
claude_pct: float | None
|
|
63
|
+
codex_pct: float | None
|
|
64
|
+
agy_pct: float | None
|
|
65
|
+
total_pct: float
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class LedgerData(TypedDict):
|
|
69
|
+
rows: list[LedgerRow]
|
|
70
|
+
unowned_codex: int
|
|
71
|
+
unowned_agy: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _timestamp(value: object) -> datetime | None:
|
|
75
|
+
try:
|
|
76
|
+
if isinstance(value, (float, int)) and not isinstance(value, bool):
|
|
77
|
+
return datetime.fromtimestamp(value, UTC)
|
|
78
|
+
if isinstance(value, str):
|
|
79
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
80
|
+
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
|
|
81
|
+
except (ValueError, OverflowError):
|
|
82
|
+
pass
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dict(value: object) -> dict[str, Any]:
|
|
87
|
+
return value if isinstance(value, dict) else {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _claude_paths(cutoff: datetime) -> list[Path]:
|
|
91
|
+
paths: list[Path] = []
|
|
92
|
+
for root in claude_config_dirs():
|
|
93
|
+
for path in (root / "projects").glob("*/*.jsonl"):
|
|
94
|
+
try:
|
|
95
|
+
if path.stat().st_mtime >= cutoff.timestamp():
|
|
96
|
+
paths.append(path)
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
return paths
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_dispatches(path: Path) -> list[Dispatch]:
|
|
103
|
+
try:
|
|
104
|
+
stat = path.stat()
|
|
105
|
+
key = (path, stat.st_size, stat.st_mtime)
|
|
106
|
+
cached = _file_cache.get(key)
|
|
107
|
+
if cached is not None:
|
|
108
|
+
return cached
|
|
109
|
+
found: list[Dispatch] = []
|
|
110
|
+
with path.open(encoding="utf-8", errors="replace") as stream:
|
|
111
|
+
for line in stream:
|
|
112
|
+
if "codex exec" not in line and "agy -p" not in line:
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
record = _dict(json.loads(line))
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
continue
|
|
118
|
+
timestamp = _timestamp(record.get("timestamp"))
|
|
119
|
+
if timestamp is None:
|
|
120
|
+
continue
|
|
121
|
+
message = _dict(record.get("message"))
|
|
122
|
+
blocks = message.get("content")
|
|
123
|
+
if not isinstance(blocks, list):
|
|
124
|
+
blocks = [record]
|
|
125
|
+
for block in blocks:
|
|
126
|
+
item = _dict(block)
|
|
127
|
+
if item.get("type") != "tool_use" or item.get("name") != "Bash":
|
|
128
|
+
continue
|
|
129
|
+
command = _dict(item.get("input")).get("command")
|
|
130
|
+
if not isinstance(command, str):
|
|
131
|
+
continue
|
|
132
|
+
for agent_id, pattern in _DISPATCH.items():
|
|
133
|
+
for _ in pattern.finditer(command):
|
|
134
|
+
found.append(
|
|
135
|
+
Dispatch(
|
|
136
|
+
timestamp,
|
|
137
|
+
path.stem,
|
|
138
|
+
str(record.get("cwd") or ""),
|
|
139
|
+
command,
|
|
140
|
+
agent_id,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
for old_key in tuple(_file_cache):
|
|
144
|
+
if old_key[0] == path and old_key != key:
|
|
145
|
+
del _file_cache[old_key]
|
|
146
|
+
_file_cache[key] = found
|
|
147
|
+
return found
|
|
148
|
+
except OSError:
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_codex_candidate(path: Path) -> Candidate | None:
|
|
153
|
+
try:
|
|
154
|
+
stat = path.stat()
|
|
155
|
+
signature = (stat.st_size, stat.st_mtime)
|
|
156
|
+
cached = _codex_cache.get(path)
|
|
157
|
+
if cached is not None and cached[0] == signature:
|
|
158
|
+
return cached[1]
|
|
159
|
+
candidate = None
|
|
160
|
+
with path.open(encoding="utf-8", errors="replace") as stream:
|
|
161
|
+
first = _dict(json.loads(next(stream)))
|
|
162
|
+
meta = _dict(first.get("payload"))
|
|
163
|
+
started = _timestamp(first.get("timestamp"))
|
|
164
|
+
if (
|
|
165
|
+
first.get("type") == "session_meta"
|
|
166
|
+
and meta.get("originator") == "codex_exec"
|
|
167
|
+
and started is not None
|
|
168
|
+
):
|
|
169
|
+
message = ""
|
|
170
|
+
for line in stream:
|
|
171
|
+
if '"user_message"' not in line:
|
|
172
|
+
continue
|
|
173
|
+
try:
|
|
174
|
+
payload = _dict(_dict(json.loads(line)).get("payload"))
|
|
175
|
+
except json.JSONDecodeError:
|
|
176
|
+
continue
|
|
177
|
+
if payload.get("type") == "user_message":
|
|
178
|
+
message = str(payload.get("message") or "")
|
|
179
|
+
break
|
|
180
|
+
candidate = Candidate(
|
|
181
|
+
"codex",
|
|
182
|
+
str(meta.get("id") or path.stem),
|
|
183
|
+
started,
|
|
184
|
+
str(meta.get("cwd") or ""),
|
|
185
|
+
message,
|
|
186
|
+
)
|
|
187
|
+
_codex_cache[path] = (signature, candidate)
|
|
188
|
+
return candidate
|
|
189
|
+
except (OSError, StopIteration, json.JSONDecodeError):
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _codex_candidates(cutoff: datetime, now: datetime) -> list[Candidate]:
|
|
194
|
+
day = (cutoff - timedelta(days=1)).date()
|
|
195
|
+
day_dirs = []
|
|
196
|
+
while day <= (now + timedelta(days=1)).date():
|
|
197
|
+
day_dirs.append(codex_loader.SESSIONS_DIR / f"{day:%Y}" / f"{day:%m}" / f"{day:%d}")
|
|
198
|
+
day += timedelta(days=1)
|
|
199
|
+
result: list[Candidate] = []
|
|
200
|
+
for path in (path for day_dir in day_dirs for path in day_dir.glob("*.jsonl")):
|
|
201
|
+
candidate = _read_codex_candidate(path)
|
|
202
|
+
if candidate is not None and cutoff <= candidate.timestamp <= now:
|
|
203
|
+
result.append(candidate)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _agy_candidates(cutoff: datetime, now: datetime) -> list[Candidate]:
|
|
208
|
+
result: list[Candidate] = []
|
|
209
|
+
for path in agy_loader.AGY_SESSIONS_DIR.glob("*.db"):
|
|
210
|
+
try:
|
|
211
|
+
if path.stat().st_mtime < cutoff.timestamp():
|
|
212
|
+
continue
|
|
213
|
+
with sqlite3.connect(agy_loader._readonly_sqlite_uri(path), uri=True) as connection:
|
|
214
|
+
started = agy_loader._session_timestamp(connection, path)
|
|
215
|
+
if not cutoff <= started <= now:
|
|
216
|
+
continue
|
|
217
|
+
row = connection.execute(
|
|
218
|
+
'SELECT step_payload FROM steps WHERE step_payload LIKE \'%"Cwd":"%\' ORDER BY idx LIMIT 1'
|
|
219
|
+
).fetchone()
|
|
220
|
+
match = _CWD.search(row[0]) if row and isinstance(row[0], bytes) else None
|
|
221
|
+
cwd = (
|
|
222
|
+
json.loads('"' + match.group(1).decode("utf-8", "replace") + '"')
|
|
223
|
+
if match
|
|
224
|
+
else ""
|
|
225
|
+
)
|
|
226
|
+
result.append(
|
|
227
|
+
Candidate(
|
|
228
|
+
"antigravity", path.stem, started, cwd if isinstance(cwd, str) else ""
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
except (OSError, sqlite3.Error, json.JSONDecodeError):
|
|
232
|
+
continue
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def match_candidate(
|
|
237
|
+
candidate: Candidate, dispatches: list[Dispatch], known_sessions: set[str]
|
|
238
|
+
) -> str | None:
|
|
239
|
+
if candidate.agent_id == "codex":
|
|
240
|
+
direct: set[str] = {
|
|
241
|
+
value.lower() for value in _UUID.findall(candidate.cwd + " " + candidate.first_message)
|
|
242
|
+
} & known_sessions
|
|
243
|
+
if len(direct) == 1:
|
|
244
|
+
return next(iter(direct))
|
|
245
|
+
if len(direct) > 1:
|
|
246
|
+
return None
|
|
247
|
+
eligible = [
|
|
248
|
+
dispatch
|
|
249
|
+
for dispatch in dispatches
|
|
250
|
+
if dispatch.agent_id == candidate.agent_id
|
|
251
|
+
and -5 <= (candidate.timestamp - dispatch.timestamp).total_seconds() <= 120
|
|
252
|
+
]
|
|
253
|
+
owners = {dispatch.session_id for dispatch in eligible}
|
|
254
|
+
if len(owners) > 1:
|
|
255
|
+
eligible = [
|
|
256
|
+
dispatch
|
|
257
|
+
for dispatch in eligible
|
|
258
|
+
if candidate.cwd
|
|
259
|
+
and (candidate.cwd == dispatch.cwd or candidate.cwd in dispatch.command)
|
|
260
|
+
]
|
|
261
|
+
owners = {dispatch.session_id for dispatch in eligible}
|
|
262
|
+
return next(iter(owners)) if len(owners) == 1 else None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _window(pct: float | None, reset: object, minutes: float) -> QuotaWindow | None:
|
|
266
|
+
resets_at = _timestamp(reset)
|
|
267
|
+
if pct is None or resets_at is None or minutes <= 0:
|
|
268
|
+
return None
|
|
269
|
+
return QuotaWindow(pct, resets_at, timedelta(minutes=minutes))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def load_windows() -> dict[str, QuotaWindow]:
|
|
273
|
+
windows: dict[str, QuotaWindow] = {}
|
|
274
|
+
claude = rate_limits.load_rate_limits()
|
|
275
|
+
if claude:
|
|
276
|
+
window = _window(claude.seven_day_pct, claude.seven_day_resets_at, 7 * 24 * 60)
|
|
277
|
+
if window:
|
|
278
|
+
windows["claude-code"] = window
|
|
279
|
+
codex = codex_loader.load_rate_limits()
|
|
280
|
+
if codex:
|
|
281
|
+
window = _window(
|
|
282
|
+
codex.seven_day_pct,
|
|
283
|
+
codex.seven_day_resets_at,
|
|
284
|
+
codex.seven_day_window_minutes or 7 * 24 * 60,
|
|
285
|
+
)
|
|
286
|
+
if window:
|
|
287
|
+
windows["codex"] = window
|
|
288
|
+
agy = agy_quota_probe._read_cache()
|
|
289
|
+
if agy:
|
|
290
|
+
fetched = _timestamp(agy.fetched_at)
|
|
291
|
+
if fetched:
|
|
292
|
+
for group in agy.groups:
|
|
293
|
+
minutes = group.weekly.resets_in_minutes
|
|
294
|
+
if minutes is None:
|
|
295
|
+
continue
|
|
296
|
+
window = QuotaWindow(
|
|
297
|
+
100 - group.weekly.remaining_percent,
|
|
298
|
+
fetched + timedelta(minutes=minutes),
|
|
299
|
+
timedelta(days=7),
|
|
300
|
+
)
|
|
301
|
+
group_key = f"antigravity:{group.name}"
|
|
302
|
+
windows[group_key] = window
|
|
303
|
+
for model in group.models:
|
|
304
|
+
windows[f"antigravity-model:{model.lower()}"] = window
|
|
305
|
+
return windows
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def estimate_pct(
|
|
309
|
+
session_tokens: int, total_tokens: int, window: QuotaWindow | None, now: datetime
|
|
310
|
+
) -> float | None:
|
|
311
|
+
if window is None or window.resets_at <= now or total_tokens <= 0:
|
|
312
|
+
return None
|
|
313
|
+
return session_tokens / total_tokens * window.used_pct
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def estimate_sessions(
|
|
317
|
+
entries: list[UsageEntry], windows: dict[str, QuotaWindow], now: datetime
|
|
318
|
+
) -> dict[tuple[str, str], float | None]:
|
|
319
|
+
totals: dict[str, int] = defaultdict(int)
|
|
320
|
+
sessions: dict[tuple[str, str, str], int] = defaultdict(int)
|
|
321
|
+
for entry in entries:
|
|
322
|
+
group = entry.agent_id
|
|
323
|
+
if entry.agent_id == "antigravity":
|
|
324
|
+
model = entry.model.lower().replace("-", " ")
|
|
325
|
+
matched = next(
|
|
326
|
+
(
|
|
327
|
+
label.removeprefix("antigravity-model:")
|
|
328
|
+
for label in windows
|
|
329
|
+
if label.startswith("antigravity-model:")
|
|
330
|
+
and all(
|
|
331
|
+
word in model for word in label.removeprefix("antigravity-model:").split()
|
|
332
|
+
)
|
|
333
|
+
),
|
|
334
|
+
None,
|
|
335
|
+
)
|
|
336
|
+
if matched is None:
|
|
337
|
+
continue
|
|
338
|
+
group = next(
|
|
339
|
+
(
|
|
340
|
+
key
|
|
341
|
+
for key, window in windows.items()
|
|
342
|
+
if key.startswith("antigravity:")
|
|
343
|
+
and window is windows[f"antigravity-model:{matched}"]
|
|
344
|
+
),
|
|
345
|
+
"",
|
|
346
|
+
)
|
|
347
|
+
window = windows.get(group)
|
|
348
|
+
if window is None or not window.resets_at - window.length <= entry.timestamp <= now:
|
|
349
|
+
continue
|
|
350
|
+
tokens = entry.input_tokens + entry.output_tokens + entry.cache_creation_tokens
|
|
351
|
+
totals[group] += tokens
|
|
352
|
+
sessions[(entry.agent_id, entry.session_id, group)] += tokens
|
|
353
|
+
estimates: dict[tuple[str, str], float | None] = {}
|
|
354
|
+
for (agent_id, session_id, group), tokens in sessions.items():
|
|
355
|
+
value = estimate_pct(tokens, totals[group], windows.get(group), now)
|
|
356
|
+
if value is not None:
|
|
357
|
+
key = (agent_id, session_id)
|
|
358
|
+
estimates[key] = (estimates.get(key) or 0.0) + value
|
|
359
|
+
return estimates
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _session_title(path: Path) -> str:
|
|
363
|
+
"""Return Claude Code's own ai-title, falling back to the first user text."""
|
|
364
|
+
title = first_text = ""
|
|
365
|
+
try:
|
|
366
|
+
with path.open(encoding="utf-8", errors="replace") as stream:
|
|
367
|
+
for line in stream:
|
|
368
|
+
if '"ai-title"' not in line and (first_text or '"user"' not in line):
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
item = _dict(json.loads(line))
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
continue
|
|
374
|
+
if item.get("type") == "ai-title" and isinstance(item.get("aiTitle"), str):
|
|
375
|
+
title = item["aiTitle"].strip()
|
|
376
|
+
elif item.get("type") == "user" and not first_text:
|
|
377
|
+
content = _dict(item.get("message")).get("content")
|
|
378
|
+
blocks = [{"text": content}] if isinstance(content, str) else content
|
|
379
|
+
for block in blocks if isinstance(blocks, list) else []:
|
|
380
|
+
text = _dict(block).get("text")
|
|
381
|
+
if isinstance(text, str) and text.strip():
|
|
382
|
+
first_text = text.strip()[:40]
|
|
383
|
+
break
|
|
384
|
+
except OSError:
|
|
385
|
+
return ""
|
|
386
|
+
return title or first_text
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def build_ledger(estimates: dict[tuple[str, str], float | None], now: datetime) -> LedgerData:
|
|
390
|
+
cutoff = now - timedelta(days=7)
|
|
391
|
+
paths = _claude_paths(cutoff)
|
|
392
|
+
dispatches = [
|
|
393
|
+
item for path in paths for item in _read_dispatches(path) if cutoff <= item.timestamp <= now
|
|
394
|
+
]
|
|
395
|
+
empty: LedgerData = {"rows": [], "unowned_codex": 0, "unowned_agy": 0}
|
|
396
|
+
if not dispatches:
|
|
397
|
+
return empty
|
|
398
|
+
known_sessions = {path.stem for path in paths}
|
|
399
|
+
candidates = _codex_candidates(cutoff - timedelta(seconds=5), now) + _agy_candidates(
|
|
400
|
+
cutoff - timedelta(seconds=5), now
|
|
401
|
+
)
|
|
402
|
+
owned: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
|
|
403
|
+
for candidate in candidates:
|
|
404
|
+
owner = match_candidate(candidate, dispatches, known_sessions)
|
|
405
|
+
if owner is None:
|
|
406
|
+
if candidate.agent_id == "codex":
|
|
407
|
+
empty["unowned_codex"] += 1
|
|
408
|
+
else:
|
|
409
|
+
empty["unowned_agy"] += 1
|
|
410
|
+
else:
|
|
411
|
+
owned[owner][candidate.agent_id].add(candidate.session_id)
|
|
412
|
+
if not owned:
|
|
413
|
+
return empty
|
|
414
|
+
path_by_session = {path.stem: path for path in paths}
|
|
415
|
+
by_owner: dict[str, list[Dispatch]] = defaultdict(list)
|
|
416
|
+
for dispatch in dispatches:
|
|
417
|
+
by_owner[dispatch.session_id].append(dispatch)
|
|
418
|
+
rows: list[tuple[str, LedgerRow]] = []
|
|
419
|
+
for owner, children in owned.items():
|
|
420
|
+
origin = by_owner.get(owner)
|
|
421
|
+
if not origin:
|
|
422
|
+
continue
|
|
423
|
+
shares = {"claude-code": estimates.get(("claude-code", owner))}
|
|
424
|
+
for agent_id in ("codex", "antigravity"):
|
|
425
|
+
values = [estimates.get((agent_id, session_id)) for session_id in children[agent_id]]
|
|
426
|
+
shares[agent_id] = (
|
|
427
|
+
sum(value for value in values if value is not None)
|
|
428
|
+
if any(value is not None for value in values)
|
|
429
|
+
else None
|
|
430
|
+
)
|
|
431
|
+
first = min(origin, key=lambda item: item.timestamp)
|
|
432
|
+
rows.append(
|
|
433
|
+
(
|
|
434
|
+
owner,
|
|
435
|
+
{
|
|
436
|
+
"date": first.timestamp.astimezone().strftime("%Y-%m-%d"),
|
|
437
|
+
"project": Path(first.cwd).name or "unknown",
|
|
438
|
+
"title": "",
|
|
439
|
+
"dispatch_count": len(origin),
|
|
440
|
+
"claude_pct": shares["claude-code"],
|
|
441
|
+
"codex_pct": shares["codex"],
|
|
442
|
+
"agy_pct": shares["antigravity"],
|
|
443
|
+
"total_pct": sum(value for value in shares.values() if value is not None),
|
|
444
|
+
},
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
rows.sort(key=lambda item: item[1]["total_pct"], reverse=True)
|
|
448
|
+
for owner, row in rows[:15]:
|
|
449
|
+
row["title"] = _session_title(path_by_session[owner])
|
|
450
|
+
empty["rows"].append(row)
|
|
451
|
+
return empty
|
|
@@ -27,7 +27,7 @@ from adapters.types import AgentInfo, UsageEntry
|
|
|
27
27
|
from pricing import calculate_cost, is_model_priced
|
|
28
28
|
|
|
29
29
|
from .aggregator import aggregate_sessions
|
|
30
|
-
from . import diagnoser, usage_snapshot
|
|
30
|
+
from . import diagnoser, dispatch_ledger, usage_snapshot
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
|
|
@@ -43,7 +43,7 @@ _YEAR_WEEKS = 53
|
|
|
43
43
|
YEAR_CACHE_PATH = Path(os.path.expanduser("~/.usage/year_cache.json"))
|
|
44
44
|
YEAR_LEDGER_PATH = YEAR_CACHE_PATH.with_name("year_ledger.json")
|
|
45
45
|
YEAR_CACHE_TTL_SECONDS = 6 * 3600
|
|
46
|
-
_YEAR_CACHE_SCHEMA =
|
|
46
|
+
_YEAR_CACHE_SCHEMA = 4
|
|
47
47
|
_YEAR_LEDGER_SCHEMA = 1
|
|
48
48
|
_YEAR_LEDGER_TRIM_BUFFER_DAYS = 60
|
|
49
49
|
|
|
@@ -130,6 +130,7 @@ class TopSessionReportRow(TypedDict):
|
|
|
130
130
|
duration_min: float
|
|
131
131
|
tokens: int
|
|
132
132
|
cost: float
|
|
133
|
+
weekly_quota_pct: float | None
|
|
133
134
|
|
|
134
135
|
|
|
135
136
|
class CubeAgentData(TypedDict):
|
|
@@ -241,6 +242,7 @@ class ReportData(_ReportDataRequired, total=False):
|
|
|
241
242
|
by_agent_model: list[AgentModelReportRow]
|
|
242
243
|
cube: CubeReportData
|
|
243
244
|
sessions: list[SessionRowData]
|
|
245
|
+
dispatch_ledger: dispatch_ledger.LedgerData
|
|
244
246
|
|
|
245
247
|
|
|
246
248
|
@dataclass(frozen=True)
|
|
@@ -911,6 +913,11 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
911
913
|
prev_date_from = date_from - timedelta(days=total_days_for_comparison)
|
|
912
914
|
hours_back = ((date_to - prev_date_from).days + 2) * 24
|
|
913
915
|
|
|
916
|
+
weekly_preloaded = (
|
|
917
|
+
[entry for agent in agents for entry in _load_agent_entries(agent, 8 * 24)]
|
|
918
|
+
if hours_back < 8 * 24
|
|
919
|
+
else None
|
|
920
|
+
)
|
|
914
921
|
raw_entries: list[UsageEntry] = []
|
|
915
922
|
for agent in agents:
|
|
916
923
|
raw_entries.extend(_load_agent_entries(agent, hours_back))
|
|
@@ -1193,6 +1200,10 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
1193
1200
|
cursor += timedelta(days=1)
|
|
1194
1201
|
|
|
1195
1202
|
top_sessions: list[TopSessionReportRow] = []
|
|
1203
|
+
now = datetime.now().astimezone(timezone.utc)
|
|
1204
|
+
weekly_entries = raw_entries if weekly_preloaded is None else weekly_preloaded
|
|
1205
|
+
windows = dispatch_ledger.load_windows() if weekly_entries else {}
|
|
1206
|
+
estimates = dispatch_ledger.estimate_sessions(weekly_entries, windows, now)
|
|
1196
1207
|
sessions_by_cost = sorted(
|
|
1197
1208
|
aggregate_sessions(entries), key=lambda session: session.cost_usd, reverse=True
|
|
1198
1209
|
)
|
|
@@ -1200,16 +1211,16 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
1200
1211
|
sessions_by_cost, key=lambda session: session.total_tokens, reverse=True
|
|
1201
1212
|
)
|
|
1202
1213
|
for session in sessions_by_tokens[:5]:
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
)
|
|
1214
|
+
session_row: TopSessionReportRow = {
|
|
1215
|
+
"start_time": _session_start_text(session.start_time),
|
|
1216
|
+
"project": session.project or "unknown",
|
|
1217
|
+
"model": session.model or "unknown",
|
|
1218
|
+
"duration_min": session.duration_minutes,
|
|
1219
|
+
"tokens": session.total_tokens,
|
|
1220
|
+
"cost": _round_cost(session.cost_usd),
|
|
1221
|
+
"weekly_quota_pct": estimates.get((session.agent_id, session.session_id)),
|
|
1222
|
+
}
|
|
1223
|
+
top_sessions.append(session_row)
|
|
1213
1224
|
|
|
1214
1225
|
session_rows: list[SessionRowData] = []
|
|
1215
1226
|
for session in sessions_by_cost:
|
|
@@ -1256,6 +1267,7 @@ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportD
|
|
|
1256
1267
|
"sessions": session_rows,
|
|
1257
1268
|
"daily_trend": daily_trend,
|
|
1258
1269
|
"top_sessions": top_sessions,
|
|
1270
|
+
"dispatch_ledger": dispatch_ledger.build_ledger(estimates, now),
|
|
1259
1271
|
"comparison": comparison,
|
|
1260
1272
|
"subscriptions": subscription.load_subscriptions(),
|
|
1261
1273
|
"persona": _load_persona_for_period(period),
|