usage-cli 0.30.23__tar.gz → 0.30.24__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.30.24}/PKG-INFO +1 -1
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/aggregator.py +1 -0
- usage_cli-0.30.24/analyzer/dispatch_ledger.py +451 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/reporter.py +23 -11
- {usage_cli-0.30.23 → usage_cli-0.30.24}/i18n.json +60 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/dynamic_height.py +8 -7
- {usage_cli-0.30.23 → usage_cli-0.30.24}/pyproject.toml +1 -1
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_aggregator.py +20 -1
- usage_cli-0.30.24/tests/test_dispatch_ledger.py +312 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_dynamic_height.py +4 -1
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_html_report_snapshot.py +2 -0
- usage_cli-0.30.24/tests/test_panel_footer_layout.py +43 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_reporter_build_data.py +4 -1
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/html_report.py +5 -2
- usage_cli-0.30.24/ui/report_ledger.py +74 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24/usage_cli.egg-info}/PKG-INFO +1 -1
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_cli.egg-info/SOURCES.txt +4 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/LICENSE +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/README.md +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/claude.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/codex.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/muse.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/rate_limits.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/registry.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/adapters/types.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/blocks.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/diagnoser.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/insights.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/persona_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/subscription.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/analyzer/usage_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/doctor.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/fsevents_watch.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/i18n.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/installer/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/installer/login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/installer/session_hooks.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/installer/setup_hook.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/installer/statusline_settings.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/agy_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/agy_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/agy_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/cache_quarantine.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/claude_paths.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/codex_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/codex_events.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/codex_fork_replay.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/codex_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/codex_paths.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/disk_cache_common.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/disk_cache_lifecycle.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/grok_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/grok_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/history_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/history_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/jsonl_limits.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/jsonl_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/loaders/muse_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/main.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/actions.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/chrome.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/menu.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/notify.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/popover.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/prefs.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/refresh.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/title.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/menubar/update.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/base.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/panel_scale.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/panel_window.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/panel_window_state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/payload.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/web_panel.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/panels/window_drag.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/prefs.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/pricing.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/project_resolver.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/quota/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/quota/agy_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/quota/burn_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/quota/codex_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/quota/usage_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/quota/window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/service_status.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/setup.cfg +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/setup_app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_adapters_claude.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_adapters_codex.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_adapters_rate_limits.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_agy_adapter.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_agy_disk_cache.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_agy_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_agy_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_agy_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_analyzer_blocks.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_analyzer_pipeline.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_burn_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_cache_quarantine.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_claude_paths.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_codex_consistency.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_codex_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_codex_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_diagnoser.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_disk_cache_shards.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_doctor.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_fsevents_watch.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_grok_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_grok_quota_probe.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_history_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_hook_scripts_are_standalone.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_i18n_format_fallback.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_i18n_key_parity.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_i18n_packaged_path.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_insights.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_jsonl_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_main.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_menubar.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_menubar_agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_menubar_grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_menubar_prefs.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_menubar_state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_muse_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_packaged_resources.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_packaging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_panel_scale.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_panel_window_state.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_panels.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_panels_lazy_import.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_persona_loader.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_pricing.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_project_resolver.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_report_daily_chart.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_report_filter_js.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_service_status.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_session_resume.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_setup_app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_setup_hook.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_setup_hook_coexistence.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_setup_hook_resume.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_setup_hook_terse.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_subprocess_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_subscription.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_time_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_tui.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_tui_sprite.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_ui_tables.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_update_checker.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_update_gate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_update_release_notes.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_cli.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_client.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_dir_sweeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_lang.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_logging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_notifications.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_rate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_snapshot_replay.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_statusline.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_statusline_agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_statusline_forwarder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_statusline_grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_terse_mode.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_usage_terse_reminder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_web_panel_payload.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_wheel_packaging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_win_login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_window_keeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_windows_manifest.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tests/test_wintray.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tui/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tui/app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/tui/sprite.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/report_charts.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/report_daily_chart.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/report_filter.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/report_scripts.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/report_styles.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/ui/tables.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/updates/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/updates/checker.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/updates/gate.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/updates/release_notes.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_cli.egg-info/dependency_links.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_cli.egg-info/entry_points.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_cli.egg-info/requires.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_cli.egg-info/top_level.txt +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_cli.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_client.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_common/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_common/subprocess_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_common/time_utils.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_common/usage_dir_sweeper.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_common/usage_lang.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_common/usage_logging.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_diagnosis_snapshot.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_notifications.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_session_resume.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_statusline.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_statusline_agy.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_statusline_forwarder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_statusline_grok.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_terse_mode.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/usage_terse_reminder.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/wintray/__init__.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/wintray/app.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/wintray/login_item.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/wintray/menu.py +0 -0
- {usage_cli-0.30.23 → usage_cli-0.30.24}/wintray/watch.py +0 -0
|
@@ -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
|
|
|
@@ -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),
|
|
@@ -205,6 +205,18 @@
|
|
|
205
205
|
"report_agent": "工具",
|
|
206
206
|
"report_collapse": "收合",
|
|
207
207
|
"report_cost": "花費",
|
|
208
|
+
"report_quota_share": "約佔週額度",
|
|
209
|
+
"report_quota_approx": "約 {value}%",
|
|
210
|
+
"report_ledger_section": "派工帳本(最近 7 天)",
|
|
211
|
+
"report_ledger_date": "日期",
|
|
212
|
+
"report_ledger_title": "對話標題",
|
|
213
|
+
"report_ledger_count": "派工次數",
|
|
214
|
+
"report_ledger_claude": "Claude",
|
|
215
|
+
"report_ledger_codex": "Codex",
|
|
216
|
+
"report_ledger_agy": "Antigravity",
|
|
217
|
+
"report_ledger_unowned_part": "{count} 個 {agent}",
|
|
218
|
+
"report_ledger_unowned_sep": "、",
|
|
219
|
+
"report_ledger_unowned": "另有 {items} 對話不是 Claude 派的(多半是排程或你自己開的)",
|
|
208
220
|
"report_daily_chart_title": "每日用量",
|
|
209
221
|
"report_daily_chart_mode_tokens": "Tokens",
|
|
210
222
|
"report_daily_chart_mode_cost": "花費",
|
|
@@ -618,6 +630,18 @@
|
|
|
618
630
|
"report_agent": "Tool",
|
|
619
631
|
"report_collapse": "Collapse",
|
|
620
632
|
"report_cost": "Cost",
|
|
633
|
+
"report_quota_share": "Approx. weekly quota",
|
|
634
|
+
"report_quota_approx": "about {value}%",
|
|
635
|
+
"report_ledger_section": "Dispatch ledger (last 7 days)",
|
|
636
|
+
"report_ledger_date": "Date",
|
|
637
|
+
"report_ledger_title": "Conversation title",
|
|
638
|
+
"report_ledger_count": "Dispatches",
|
|
639
|
+
"report_ledger_claude": "Claude",
|
|
640
|
+
"report_ledger_codex": "Codex",
|
|
641
|
+
"report_ledger_agy": "Antigravity",
|
|
642
|
+
"report_ledger_unowned_part": "{count} {agent}",
|
|
643
|
+
"report_ledger_unowned_sep": ", ",
|
|
644
|
+
"report_ledger_unowned": "Also, {items} conversations were not dispatched by Claude (likely scheduled or started by you).",
|
|
621
645
|
"report_daily_chart_title": "Daily usage",
|
|
622
646
|
"report_daily_chart_mode_tokens": "Tokens",
|
|
623
647
|
"report_daily_chart_mode_cost": "Cost",
|
|
@@ -1031,6 +1055,18 @@
|
|
|
1031
1055
|
"report_agent": "工具",
|
|
1032
1056
|
"report_collapse": "收起",
|
|
1033
1057
|
"report_cost": "花费",
|
|
1058
|
+
"report_quota_share": "约占周额度",
|
|
1059
|
+
"report_quota_approx": "约 {value}%",
|
|
1060
|
+
"report_ledger_section": "派工账本(最近 7 天)",
|
|
1061
|
+
"report_ledger_date": "日期",
|
|
1062
|
+
"report_ledger_title": "对话标题",
|
|
1063
|
+
"report_ledger_count": "派工次数",
|
|
1064
|
+
"report_ledger_claude": "Claude",
|
|
1065
|
+
"report_ledger_codex": "Codex",
|
|
1066
|
+
"report_ledger_agy": "Antigravity",
|
|
1067
|
+
"report_ledger_unowned_part": "{count} 个 {agent}",
|
|
1068
|
+
"report_ledger_unowned_sep": "、",
|
|
1069
|
+
"report_ledger_unowned": "另有 {items} 对话不是 Claude 派的(多半是定时任务或你自己开的)",
|
|
1034
1070
|
"report_daily_chart_title": "每日用量",
|
|
1035
1071
|
"report_daily_chart_mode_tokens": "Tokens",
|
|
1036
1072
|
"report_daily_chart_mode_cost": "花费",
|
|
@@ -1444,6 +1480,18 @@
|
|
|
1444
1480
|
"report_agent": "ツール",
|
|
1445
1481
|
"report_collapse": "折りたたむ",
|
|
1446
1482
|
"report_cost": "費用",
|
|
1483
|
+
"report_quota_share": "週間枠の推定割合",
|
|
1484
|
+
"report_quota_approx": "約 {value}%",
|
|
1485
|
+
"report_ledger_section": "委任台帳(直近 7 日間)",
|
|
1486
|
+
"report_ledger_date": "日付",
|
|
1487
|
+
"report_ledger_title": "会話タイトル",
|
|
1488
|
+
"report_ledger_count": "委任回数",
|
|
1489
|
+
"report_ledger_claude": "Claude",
|
|
1490
|
+
"report_ledger_codex": "Codex",
|
|
1491
|
+
"report_ledger_agy": "Antigravity",
|
|
1492
|
+
"report_ledger_unowned_part": "{agent} {count} 件",
|
|
1493
|
+
"report_ledger_unowned_sep": "、",
|
|
1494
|
+
"report_ledger_unowned": "ほかに {items} の会話は Claude からの委任ではありません(スケジュール実行か手動開始の可能性があります)。",
|
|
1447
1495
|
"report_daily_chart_title": "日別の使用量",
|
|
1448
1496
|
"report_daily_chart_mode_tokens": "Tokens",
|
|
1449
1497
|
"report_daily_chart_mode_cost": "コスト",
|
|
@@ -1857,6 +1905,18 @@
|
|
|
1857
1905
|
"report_agent": "도구",
|
|
1858
1906
|
"report_collapse": "접기",
|
|
1859
1907
|
"report_cost": "비용",
|
|
1908
|
+
"report_quota_share": "주간 한도 예상 비율",
|
|
1909
|
+
"report_quota_approx": "약 {value}%",
|
|
1910
|
+
"report_ledger_section": "작업 위임 내역 (최근 7일)",
|
|
1911
|
+
"report_ledger_date": "날짜",
|
|
1912
|
+
"report_ledger_title": "대화 제목",
|
|
1913
|
+
"report_ledger_count": "위임 횟수",
|
|
1914
|
+
"report_ledger_claude": "Claude",
|
|
1915
|
+
"report_ledger_codex": "Codex",
|
|
1916
|
+
"report_ledger_agy": "Antigravity",
|
|
1917
|
+
"report_ledger_unowned_part": "{agent} {count}개",
|
|
1918
|
+
"report_ledger_unowned_sep": ", ",
|
|
1919
|
+
"report_ledger_unowned": "그 외 {items} 대화는 Claude가 위임하지 않았습니다(예약 실행 또는 직접 시작했을 수 있습니다).",
|
|
1860
1920
|
"report_daily_chart_title": "일별 사용량",
|
|
1861
1921
|
"report_daily_chart_mode_tokens": "Tokens",
|
|
1862
1922
|
"report_daily_chart_mode_cost": "비용",
|