painapple-code 1.0.0__py3-none-any.whl
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.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost Analytics Module for painapple-code.
|
|
3
|
+
|
|
4
|
+
Aggregates cost data across projects, sessions, models, and tools.
|
|
5
|
+
Provides multi-dimensional breakdowns for cost analysis.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
|
|
15
|
+
from painapple_code.paths import (
|
|
16
|
+
ensure_data_home,
|
|
17
|
+
get_project_path_from_hash,
|
|
18
|
+
get_summary_model,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ModelCost:
|
|
24
|
+
"""Cost breakdown for a specific model."""
|
|
25
|
+
model: str
|
|
26
|
+
cost: float = 0.0
|
|
27
|
+
input_tokens: int = 0
|
|
28
|
+
output_tokens: int = 0
|
|
29
|
+
cache_read_tokens: int = 0
|
|
30
|
+
cache_write_tokens: int = 0
|
|
31
|
+
calls: int = 0
|
|
32
|
+
|
|
33
|
+
def add(self, cost: float, input_t: int = 0, output_t: int = 0,
|
|
34
|
+
cache_read: int = 0, cache_write: int = 0):
|
|
35
|
+
self.cost += cost
|
|
36
|
+
self.input_tokens += input_t
|
|
37
|
+
self.output_tokens += output_t
|
|
38
|
+
self.cache_read_tokens += cache_read
|
|
39
|
+
self.cache_write_tokens += cache_write
|
|
40
|
+
self.calls += 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ToolCost:
|
|
45
|
+
"""Cost attribution for a tool."""
|
|
46
|
+
tool: str
|
|
47
|
+
invocations: int = 0
|
|
48
|
+
attributed_cost: float = 0.0
|
|
49
|
+
turns_with_tool: int = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class SessionCost:
|
|
54
|
+
"""Cost data for a single session."""
|
|
55
|
+
session_id: str
|
|
56
|
+
project_hash: str
|
|
57
|
+
project_path: str
|
|
58
|
+
name: str
|
|
59
|
+
total_cost: float = 0.0
|
|
60
|
+
turn_count: int = 0
|
|
61
|
+
message_count: int = 0
|
|
62
|
+
model: Optional[str] = None
|
|
63
|
+
created_at: Optional[str] = None
|
|
64
|
+
last_activity: Optional[str] = None
|
|
65
|
+
# Shadow git
|
|
66
|
+
shadow_cost: float = 0.0 # Main model cost tracked by shadow
|
|
67
|
+
summary_cost: float = 0.0 # Summary-fork cost for rich commits
|
|
68
|
+
summary_calls: int = 0
|
|
69
|
+
commit_count: int = 0
|
|
70
|
+
# Model breakdown (when available)
|
|
71
|
+
model_costs: dict = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ProjectCost:
|
|
76
|
+
"""Cost data for a project."""
|
|
77
|
+
project_hash: str
|
|
78
|
+
project_path: str
|
|
79
|
+
total_cost: float = 0.0
|
|
80
|
+
session_count: int = 0
|
|
81
|
+
turn_count: int = 0
|
|
82
|
+
summary_overhead: float = 0.0
|
|
83
|
+
sessions: list = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class CostSummary:
|
|
88
|
+
"""Overall cost summary."""
|
|
89
|
+
total_cost: float = 0.0
|
|
90
|
+
total_sessions: int = 0
|
|
91
|
+
total_turns: int = 0
|
|
92
|
+
total_messages: int = 0
|
|
93
|
+
# By model
|
|
94
|
+
by_model: dict = field(default_factory=dict) # model -> ModelCost
|
|
95
|
+
# By project
|
|
96
|
+
by_project: dict = field(default_factory=dict) # hash -> ProjectCost
|
|
97
|
+
# By tool
|
|
98
|
+
by_tool: dict = field(default_factory=dict) # tool -> ToolCost
|
|
99
|
+
# By thread (main session vs Task subagents). Heuristic: any
|
|
100
|
+
# model_usage entry whose model_id != the session's primary model
|
|
101
|
+
# is attributed to subagents (Task tool fans out under different models).
|
|
102
|
+
main_thread_cost: float = 0.0
|
|
103
|
+
subagent_cost: float = 0.0
|
|
104
|
+
subagent_calls: int = 0
|
|
105
|
+
# Overhead
|
|
106
|
+
shadow_summary_cost: float = 0.0
|
|
107
|
+
shadow_summary_calls: int = 0
|
|
108
|
+
# Cache stats
|
|
109
|
+
total_cache_read: int = 0
|
|
110
|
+
total_cache_write: int = 0
|
|
111
|
+
# Time range
|
|
112
|
+
earliest_session: Optional[str] = None
|
|
113
|
+
latest_activity: Optional[str] = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CostAnalyzer:
|
|
117
|
+
"""
|
|
118
|
+
Analyzes cost data across all projects and sessions.
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
analyzer = CostAnalyzer()
|
|
122
|
+
summary = analyzer.get_summary()
|
|
123
|
+
by_model = analyzer.get_by_model()
|
|
124
|
+
by_project = analyzer.get_by_project()
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self):
|
|
128
|
+
self.data_home = ensure_data_home()
|
|
129
|
+
self.projects_dir = self.data_home / "projects"
|
|
130
|
+
self._cache = {}
|
|
131
|
+
self._cache_time = None
|
|
132
|
+
|
|
133
|
+
def _get_project_path(self, project_hash: str) -> Optional[str]:
|
|
134
|
+
"""Read project path from the 'path' file."""
|
|
135
|
+
return get_project_path_from_hash(project_hash)
|
|
136
|
+
|
|
137
|
+
def _load_session_meta(self, meta_path: Path) -> Optional[dict]:
|
|
138
|
+
"""Load and parse session metadata."""
|
|
139
|
+
try:
|
|
140
|
+
return json.loads(meta_path.read_text(encoding="utf-8"))
|
|
141
|
+
except (json.JSONDecodeError, IOError):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def _parse_raw_result(self, raw_path: Path) -> list[dict]:
|
|
145
|
+
"""Parse raw.jsonl for result messages with modelUsage."""
|
|
146
|
+
results = []
|
|
147
|
+
if not raw_path.exists():
|
|
148
|
+
return results
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with open(raw_path, encoding="utf-8") as f:
|
|
152
|
+
for line in f:
|
|
153
|
+
try:
|
|
154
|
+
entry = json.loads(line)
|
|
155
|
+
if entry.get("type") == "result" and entry.get("dir") == "out":
|
|
156
|
+
data = entry.get("data", "{}")
|
|
157
|
+
if isinstance(data, str):
|
|
158
|
+
data = json.loads(data)
|
|
159
|
+
if "modelUsage" in data or "total_cost_usd" in data:
|
|
160
|
+
results.append({
|
|
161
|
+
"ts": entry.get("ts"),
|
|
162
|
+
"cost": data.get("total_cost_usd", 0),
|
|
163
|
+
"model_usage": data.get("modelUsage", {}),
|
|
164
|
+
"usage": data.get("usage", {}),
|
|
165
|
+
"duration_ms": data.get("duration_ms", 0),
|
|
166
|
+
"num_turns": data.get("num_turns", 1),
|
|
167
|
+
})
|
|
168
|
+
except (json.JSONDecodeError, TypeError):
|
|
169
|
+
continue
|
|
170
|
+
except IOError:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
return results
|
|
174
|
+
|
|
175
|
+
def _parse_messages_for_tools(self, messages_path: Path) -> dict:
|
|
176
|
+
"""Parse messages.jsonl to count tool invocations per turn."""
|
|
177
|
+
tool_counts = defaultdict(int)
|
|
178
|
+
if not messages_path.exists():
|
|
179
|
+
return dict(tool_counts)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with open(messages_path, encoding="utf-8") as f:
|
|
183
|
+
for line in f:
|
|
184
|
+
try:
|
|
185
|
+
msg = json.loads(line)
|
|
186
|
+
if msg.get("role") == "tool":
|
|
187
|
+
tool_name = msg.get("tool_name", "unknown")
|
|
188
|
+
tool_counts[tool_name] += 1
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
continue
|
|
191
|
+
except IOError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
return dict(tool_counts)
|
|
195
|
+
|
|
196
|
+
def _parse_messages_results(self, messages_path: Path) -> list[dict]:
|
|
197
|
+
"""Parse messages.jsonl for result messages with enriched model_usage.
|
|
198
|
+
|
|
199
|
+
This is faster than parsing raw.jsonl since messages.jsonl is smaller
|
|
200
|
+
and contains pre-parsed data.
|
|
201
|
+
"""
|
|
202
|
+
results = []
|
|
203
|
+
if not messages_path.exists():
|
|
204
|
+
return results
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
with open(messages_path, encoding="utf-8") as f:
|
|
208
|
+
for line in f:
|
|
209
|
+
try:
|
|
210
|
+
msg = json.loads(line)
|
|
211
|
+
if msg.get("role") == "result":
|
|
212
|
+
results.append({
|
|
213
|
+
"ts": msg.get("timestamp"),
|
|
214
|
+
"cost": msg.get("cost_usd", 0),
|
|
215
|
+
"model_usage": msg.get("model_usage", {}),
|
|
216
|
+
"tokens": msg.get("tokens", {}),
|
|
217
|
+
"tools": msg.get("tools", []),
|
|
218
|
+
"duration_ms": msg.get("duration_ms", 0),
|
|
219
|
+
"num_turns": msg.get("num_turns", 1),
|
|
220
|
+
})
|
|
221
|
+
except json.JSONDecodeError:
|
|
222
|
+
continue
|
|
223
|
+
except IOError:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
return results
|
|
227
|
+
|
|
228
|
+
def _scan_sessions(self, project_hash: str) -> list[SessionCost]:
|
|
229
|
+
"""Scan all sessions in a project."""
|
|
230
|
+
sessions = []
|
|
231
|
+
project_path = self._get_project_path(project_hash)
|
|
232
|
+
sessions_dir = self.projects_dir / project_hash / "sessions"
|
|
233
|
+
|
|
234
|
+
if not sessions_dir.exists():
|
|
235
|
+
return sessions
|
|
236
|
+
|
|
237
|
+
for session_dir in sessions_dir.iterdir():
|
|
238
|
+
if not session_dir.is_dir():
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
meta_path = session_dir / "meta.json"
|
|
242
|
+
meta = self._load_session_meta(meta_path)
|
|
243
|
+
if not meta:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
session = SessionCost(
|
|
247
|
+
session_id=meta.get("id", session_dir.name),
|
|
248
|
+
project_hash=project_hash,
|
|
249
|
+
project_path=project_path or "",
|
|
250
|
+
name=meta.get("name", ""),
|
|
251
|
+
total_cost=meta.get("total_cost", 0),
|
|
252
|
+
message_count=meta.get("message_count", 0),
|
|
253
|
+
model=meta.get("model"),
|
|
254
|
+
created_at=meta.get("created_at"),
|
|
255
|
+
last_activity=meta.get("last_activity"),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Shadow git data
|
|
259
|
+
shadow = meta.get("shadow", {})
|
|
260
|
+
if shadow:
|
|
261
|
+
session.shadow_cost = shadow.get("total_cost", 0)
|
|
262
|
+
# Back-compat: the shadow.json keys were haiku_* before the rename.
|
|
263
|
+
session.summary_cost = shadow.get("summary_cost", shadow.get("haiku_cost", 0))
|
|
264
|
+
session.summary_calls = shadow.get("summary_calls", shadow.get("haiku_calls", 0))
|
|
265
|
+
session.commit_count = shadow.get("turn_count", 0)
|
|
266
|
+
|
|
267
|
+
# Try messages.jsonl first (faster, enriched format)
|
|
268
|
+
# Fall back to raw.jsonl for older sessions without enriched data
|
|
269
|
+
messages_path = session_dir / "messages.jsonl"
|
|
270
|
+
results = self._parse_messages_results(messages_path)
|
|
271
|
+
|
|
272
|
+
# Check if we got enriched model_usage data
|
|
273
|
+
has_enriched = any(r.get("model_usage") for r in results)
|
|
274
|
+
|
|
275
|
+
if not has_enriched:
|
|
276
|
+
# Fall back to raw.jsonl for detailed model breakdown
|
|
277
|
+
raw_path = session_dir / "raw.jsonl"
|
|
278
|
+
results = self._parse_raw_result(raw_path)
|
|
279
|
+
|
|
280
|
+
for result in results:
|
|
281
|
+
session.turn_count += 1
|
|
282
|
+
model_usage = result.get("model_usage") or {}
|
|
283
|
+
|
|
284
|
+
for model_name, usage in model_usage.items():
|
|
285
|
+
if model_name not in session.model_costs:
|
|
286
|
+
session.model_costs[model_name] = {
|
|
287
|
+
"cost": 0, "input": 0, "output": 0,
|
|
288
|
+
"cache_read": 0, "cache_write": 0, "calls": 0
|
|
289
|
+
}
|
|
290
|
+
mc = session.model_costs[model_name]
|
|
291
|
+
|
|
292
|
+
# Handle both enriched format (compact keys) and raw format (full keys)
|
|
293
|
+
if "cost" in usage:
|
|
294
|
+
# Enriched format from messages.jsonl
|
|
295
|
+
mc["cost"] += usage.get("cost", 0)
|
|
296
|
+
mc["input"] += usage.get("in", 0)
|
|
297
|
+
mc["output"] += usage.get("out", 0)
|
|
298
|
+
mc["cache_read"] += usage.get("cache_read", 0)
|
|
299
|
+
mc["cache_write"] += usage.get("cache_write", 0)
|
|
300
|
+
else:
|
|
301
|
+
# Raw format from raw.jsonl
|
|
302
|
+
mc["cost"] += usage.get("costUSD", 0)
|
|
303
|
+
mc["input"] += usage.get("inputTokens", 0)
|
|
304
|
+
mc["output"] += usage.get("outputTokens", 0)
|
|
305
|
+
mc["cache_read"] += usage.get("cacheReadInputTokens", 0)
|
|
306
|
+
mc["cache_write"] += usage.get("cacheCreationInputTokens", 0)
|
|
307
|
+
mc["calls"] += 1
|
|
308
|
+
|
|
309
|
+
sessions.append(session)
|
|
310
|
+
|
|
311
|
+
return sessions
|
|
312
|
+
|
|
313
|
+
def get_summary(
|
|
314
|
+
self,
|
|
315
|
+
project_hash: Optional[str] = None,
|
|
316
|
+
session_id: Optional[str] = None,
|
|
317
|
+
since: Optional[str] = None,
|
|
318
|
+
until: Optional[str] = None,
|
|
319
|
+
) -> dict:
|
|
320
|
+
"""
|
|
321
|
+
Get comprehensive cost summary.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
project_hash: Filter by project
|
|
325
|
+
session_id: Filter by session
|
|
326
|
+
since: Start date (ISO format)
|
|
327
|
+
until: End date (ISO format)
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Dictionary with cost breakdown
|
|
331
|
+
"""
|
|
332
|
+
summary = CostSummary()
|
|
333
|
+
|
|
334
|
+
# Determine which projects to scan
|
|
335
|
+
if project_hash:
|
|
336
|
+
project_hashes = [project_hash]
|
|
337
|
+
else:
|
|
338
|
+
project_hashes = [
|
|
339
|
+
d.name for d in self.projects_dir.iterdir()
|
|
340
|
+
if d.is_dir() and (d / "sessions").exists()
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
# Parse date filters (ensure timezone-aware)
|
|
344
|
+
since_dt = None
|
|
345
|
+
until_dt = None
|
|
346
|
+
if since:
|
|
347
|
+
# Handle both full ISO timestamps and date-only strings
|
|
348
|
+
if "T" in since:
|
|
349
|
+
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
350
|
+
else:
|
|
351
|
+
# Just a date like "2025-12-30" - add time and UTC timezone
|
|
352
|
+
since_dt = datetime.fromisoformat(f"{since}T00:00:00+00:00")
|
|
353
|
+
if until:
|
|
354
|
+
if "T" in until:
|
|
355
|
+
until_dt = datetime.fromisoformat(until.replace("Z", "+00:00"))
|
|
356
|
+
else:
|
|
357
|
+
# Just a date - set to end of day
|
|
358
|
+
until_dt = datetime.fromisoformat(f"{until}T23:59:59+00:00")
|
|
359
|
+
|
|
360
|
+
for ph in project_hashes:
|
|
361
|
+
project_path = self._get_project_path(ph)
|
|
362
|
+
sessions = self._scan_sessions(ph)
|
|
363
|
+
|
|
364
|
+
project = ProjectCost(
|
|
365
|
+
project_hash=ph,
|
|
366
|
+
project_path=project_path or "",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
for session in sessions:
|
|
370
|
+
# Filter by session_id
|
|
371
|
+
if session_id and session.session_id != session_id:
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
# Filter by date
|
|
375
|
+
if since_dt and session.created_at:
|
|
376
|
+
try:
|
|
377
|
+
created = datetime.fromisoformat(session.created_at.replace("Z", "+00:00"))
|
|
378
|
+
if created < since_dt:
|
|
379
|
+
continue
|
|
380
|
+
except ValueError:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
if until_dt and session.last_activity:
|
|
384
|
+
try:
|
|
385
|
+
activity = datetime.fromisoformat(session.last_activity.replace("Z", "+00:00"))
|
|
386
|
+
if activity > until_dt:
|
|
387
|
+
continue
|
|
388
|
+
except ValueError:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
# Aggregate
|
|
392
|
+
summary.total_cost += session.total_cost
|
|
393
|
+
summary.total_sessions += 1
|
|
394
|
+
summary.total_turns += session.turn_count or session.message_count // 2
|
|
395
|
+
summary.total_messages += session.message_count
|
|
396
|
+
|
|
397
|
+
# Shadow/summary overhead
|
|
398
|
+
summary.shadow_summary_cost += session.summary_cost
|
|
399
|
+
summary.shadow_summary_calls += session.summary_calls
|
|
400
|
+
|
|
401
|
+
# Project aggregation
|
|
402
|
+
project.total_cost += session.total_cost
|
|
403
|
+
project.session_count += 1
|
|
404
|
+
project.turn_count += session.turn_count
|
|
405
|
+
project.summary_overhead += session.summary_cost
|
|
406
|
+
project.sessions.append({
|
|
407
|
+
"id": session.session_id,
|
|
408
|
+
"name": session.name,
|
|
409
|
+
"cost": session.total_cost,
|
|
410
|
+
"messages": session.message_count,
|
|
411
|
+
"created": session.created_at,
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
# Model aggregation + thread split (main vs Task subagent)
|
|
415
|
+
primary = session.model
|
|
416
|
+
for model_name, mc in session.model_costs.items():
|
|
417
|
+
if model_name not in summary.by_model:
|
|
418
|
+
summary.by_model[model_name] = ModelCost(model=model_name)
|
|
419
|
+
m = summary.by_model[model_name]
|
|
420
|
+
m.cost += mc["cost"]
|
|
421
|
+
m.input_tokens += mc["input"]
|
|
422
|
+
m.output_tokens += mc["output"]
|
|
423
|
+
m.cache_read_tokens += mc["cache_read"]
|
|
424
|
+
m.cache_write_tokens += mc["cache_write"]
|
|
425
|
+
m.calls += mc["calls"]
|
|
426
|
+
|
|
427
|
+
# If session has no recorded primary model, attribute to
|
|
428
|
+
# main thread rather than guessing.
|
|
429
|
+
if primary and model_name != primary:
|
|
430
|
+
summary.subagent_cost += mc["cost"]
|
|
431
|
+
summary.subagent_calls += mc["calls"]
|
|
432
|
+
else:
|
|
433
|
+
summary.main_thread_cost += mc["cost"]
|
|
434
|
+
|
|
435
|
+
# Track time range
|
|
436
|
+
if session.created_at:
|
|
437
|
+
if not summary.earliest_session or session.created_at < summary.earliest_session:
|
|
438
|
+
summary.earliest_session = session.created_at
|
|
439
|
+
if session.last_activity:
|
|
440
|
+
if not summary.latest_activity or session.last_activity > summary.latest_activity:
|
|
441
|
+
summary.latest_activity = session.last_activity
|
|
442
|
+
|
|
443
|
+
if project.session_count > 0:
|
|
444
|
+
summary.by_project[ph] = project
|
|
445
|
+
|
|
446
|
+
# Calculate cache totals
|
|
447
|
+
for m in summary.by_model.values():
|
|
448
|
+
summary.total_cache_read += m.cache_read_tokens
|
|
449
|
+
summary.total_cache_write += m.cache_write_tokens
|
|
450
|
+
|
|
451
|
+
return self._summary_to_dict(summary)
|
|
452
|
+
|
|
453
|
+
def _summary_to_dict(self, summary: CostSummary) -> dict:
|
|
454
|
+
"""Convert summary to JSON-serializable dict."""
|
|
455
|
+
# Calculate cache efficiency
|
|
456
|
+
total_input = sum(m.input_tokens + m.cache_read_tokens for m in summary.by_model.values())
|
|
457
|
+
cache_hit_rate = (summary.total_cache_read / total_input * 100) if total_input > 0 else 0
|
|
458
|
+
|
|
459
|
+
# Estimate cache savings (cache reads are much cheaper than fresh input)
|
|
460
|
+
# Rough estimate: cache read is ~10% cost of fresh input
|
|
461
|
+
cache_savings_tokens = summary.total_cache_read * 0.9
|
|
462
|
+
|
|
463
|
+
# TRUE total includes shadow git overhead (it's a real cost!)
|
|
464
|
+
work_cost = summary.total_cost # Cost from main Claude conversations
|
|
465
|
+
overhead_cost = summary.shadow_summary_cost # Shadow git summary-fork cost
|
|
466
|
+
true_total = work_cost + overhead_cost
|
|
467
|
+
|
|
468
|
+
# Build model breakdown, merging shadow summary cost into Haiku model
|
|
469
|
+
by_model_merged = {}
|
|
470
|
+
for name, m in summary.by_model.items():
|
|
471
|
+
by_model_merged[name] = {
|
|
472
|
+
"cost": round(m.cost, 6),
|
|
473
|
+
"input_tokens": m.input_tokens,
|
|
474
|
+
"output_tokens": m.output_tokens,
|
|
475
|
+
"cache_read_tokens": m.cache_read_tokens,
|
|
476
|
+
"cache_write_tokens": m.cache_write_tokens,
|
|
477
|
+
"calls": m.calls,
|
|
478
|
+
"pct": round(m.cost / true_total * 100, 1) if true_total > 0 else 0,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
# Merge shadow summary cost into Haiku model entry
|
|
482
|
+
haiku_key = None
|
|
483
|
+
for name in by_model_merged:
|
|
484
|
+
if "haiku" in name.lower():
|
|
485
|
+
haiku_key = name
|
|
486
|
+
break
|
|
487
|
+
|
|
488
|
+
if overhead_cost > 0:
|
|
489
|
+
if haiku_key:
|
|
490
|
+
# Add shadow git cost to existing Haiku entry
|
|
491
|
+
by_model_merged[haiku_key]["cost"] = round(
|
|
492
|
+
by_model_merged[haiku_key]["cost"] + overhead_cost, 6
|
|
493
|
+
)
|
|
494
|
+
by_model_merged[haiku_key]["calls"] += summary.shadow_summary_calls
|
|
495
|
+
by_model_merged[haiku_key]["pct"] = round(
|
|
496
|
+
by_model_merged[haiku_key]["cost"] / true_total * 100, 1
|
|
497
|
+
) if true_total > 0 else 0
|
|
498
|
+
# Add breakdown detail
|
|
499
|
+
by_model_merged[haiku_key]["breakdown"] = {
|
|
500
|
+
"conversation": round(by_model_merged[haiku_key]["cost"] - overhead_cost, 6),
|
|
501
|
+
"shadow_git": round(overhead_cost, 6),
|
|
502
|
+
}
|
|
503
|
+
else:
|
|
504
|
+
# Create Haiku entry for shadow git only
|
|
505
|
+
by_model_merged[get_summary_model()] = {
|
|
506
|
+
"cost": round(overhead_cost, 6),
|
|
507
|
+
"input_tokens": 0,
|
|
508
|
+
"output_tokens": 0,
|
|
509
|
+
"cache_read_tokens": 0,
|
|
510
|
+
"cache_write_tokens": 0,
|
|
511
|
+
"calls": summary.shadow_summary_calls,
|
|
512
|
+
"pct": round(overhead_cost / true_total * 100, 1) if true_total > 0 else 0,
|
|
513
|
+
"breakdown": {
|
|
514
|
+
"conversation": 0,
|
|
515
|
+
"shadow_git": round(overhead_cost, 6),
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
# Sort by cost descending
|
|
520
|
+
by_model_sorted = dict(sorted(by_model_merged.items(), key=lambda x: -x[1]["cost"]))
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
"total_cost": round(true_total, 6), # TRUE total including overhead
|
|
524
|
+
"work_cost": round(work_cost, 6), # Main conversation cost
|
|
525
|
+
"total_sessions": summary.total_sessions,
|
|
526
|
+
"total_turns": summary.total_turns,
|
|
527
|
+
"total_messages": summary.total_messages,
|
|
528
|
+
|
|
529
|
+
"by_model": by_model_sorted,
|
|
530
|
+
|
|
531
|
+
"by_project": {
|
|
532
|
+
ph: {
|
|
533
|
+
"path": p.project_path,
|
|
534
|
+
"name": Path(p.project_path).name if p.project_path else ph,
|
|
535
|
+
"cost": round(p.total_cost + p.summary_overhead, 6), # Include overhead
|
|
536
|
+
"work_cost": round(p.total_cost, 6),
|
|
537
|
+
"sessions": p.session_count,
|
|
538
|
+
"turns": p.turn_count,
|
|
539
|
+
"summary_overhead": round(p.summary_overhead, 6),
|
|
540
|
+
"top_sessions": sorted(p.sessions, key=lambda x: -x["cost"])[:5],
|
|
541
|
+
}
|
|
542
|
+
for ph, p in sorted(summary.by_project.items(), key=lambda x: -(x[1].total_cost + x[1].summary_overhead))
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
"overhead": {
|
|
546
|
+
"summary_cost": round(overhead_cost, 6),
|
|
547
|
+
"summary_calls": summary.shadow_summary_calls,
|
|
548
|
+
"summary_pct": round(overhead_cost / true_total * 100, 2) if true_total > 0 else 0,
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
"by_thread": {
|
|
552
|
+
"main_thread": {
|
|
553
|
+
"cost": round(summary.main_thread_cost, 6),
|
|
554
|
+
"pct": round(summary.main_thread_cost / true_total * 100, 1) if true_total > 0 else 0,
|
|
555
|
+
},
|
|
556
|
+
"subagents": {
|
|
557
|
+
"cost": round(summary.subagent_cost, 6),
|
|
558
|
+
"calls": summary.subagent_calls,
|
|
559
|
+
"pct": round(summary.subagent_cost / true_total * 100, 1) if true_total > 0 else 0,
|
|
560
|
+
},
|
|
561
|
+
"shadow_git": {
|
|
562
|
+
"cost": round(overhead_cost, 6),
|
|
563
|
+
"calls": summary.shadow_summary_calls,
|
|
564
|
+
"pct": round(overhead_cost / true_total * 100, 1) if true_total > 0 else 0,
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
"cache": {
|
|
569
|
+
"read_tokens": summary.total_cache_read,
|
|
570
|
+
"write_tokens": summary.total_cache_write,
|
|
571
|
+
"hit_rate_pct": round(cache_hit_rate, 1),
|
|
572
|
+
"estimated_savings_tokens": int(cache_savings_tokens),
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
"time_range": {
|
|
576
|
+
"earliest": summary.earliest_session,
|
|
577
|
+
"latest": summary.latest_activity,
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
def get_sessions_ranked(
|
|
582
|
+
self,
|
|
583
|
+
limit: int = 20,
|
|
584
|
+
project_hash: Optional[str] = None,
|
|
585
|
+
) -> list[dict]:
|
|
586
|
+
"""Get sessions ranked by cost."""
|
|
587
|
+
all_sessions = []
|
|
588
|
+
|
|
589
|
+
if project_hash:
|
|
590
|
+
project_hashes = [project_hash]
|
|
591
|
+
else:
|
|
592
|
+
project_hashes = [
|
|
593
|
+
d.name for d in self.projects_dir.iterdir()
|
|
594
|
+
if d.is_dir() and (d / "sessions").exists()
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
for ph in project_hashes:
|
|
598
|
+
sessions = self._scan_sessions(ph)
|
|
599
|
+
all_sessions.extend(sessions)
|
|
600
|
+
|
|
601
|
+
# Sort by TRUE cost (including shadow git overhead) descending
|
|
602
|
+
all_sessions.sort(key=lambda s: -(s.total_cost + s.summary_cost))
|
|
603
|
+
|
|
604
|
+
result = []
|
|
605
|
+
for s in all_sessions[:limit]:
|
|
606
|
+
# TRUE cost includes shadow git summary overhead
|
|
607
|
+
true_cost = s.total_cost + s.summary_cost
|
|
608
|
+
|
|
609
|
+
# Build model breakdown, merging shadow summary cost if present
|
|
610
|
+
model_breakdown = None
|
|
611
|
+
if s.model_costs:
|
|
612
|
+
model_breakdown = {}
|
|
613
|
+
for name, mc in s.model_costs.items():
|
|
614
|
+
model_breakdown[name] = {"cost": round(mc["cost"], 6), "calls": mc["calls"]}
|
|
615
|
+
|
|
616
|
+
# Merge shadow summary cost into Haiku entry
|
|
617
|
+
if s.summary_cost > 0:
|
|
618
|
+
haiku_key = None
|
|
619
|
+
for name in model_breakdown:
|
|
620
|
+
if "haiku" in name.lower():
|
|
621
|
+
haiku_key = name
|
|
622
|
+
break
|
|
623
|
+
|
|
624
|
+
if haiku_key:
|
|
625
|
+
conv_cost = model_breakdown[haiku_key]["cost"]
|
|
626
|
+
model_breakdown[haiku_key]["cost"] = round(conv_cost + s.summary_cost, 6)
|
|
627
|
+
model_breakdown[haiku_key]["calls"] += s.summary_calls
|
|
628
|
+
model_breakdown[haiku_key]["breakdown"] = {
|
|
629
|
+
"conversation": conv_cost,
|
|
630
|
+
"shadow_git": round(s.summary_cost, 6),
|
|
631
|
+
}
|
|
632
|
+
else:
|
|
633
|
+
# Create Haiku entry for shadow git only
|
|
634
|
+
model_breakdown[get_summary_model()] = {
|
|
635
|
+
"cost": round(s.summary_cost, 6),
|
|
636
|
+
"calls": s.summary_calls,
|
|
637
|
+
"breakdown": {
|
|
638
|
+
"conversation": 0,
|
|
639
|
+
"shadow_git": round(s.summary_cost, 6),
|
|
640
|
+
},
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
result.append({
|
|
644
|
+
"id": s.session_id,
|
|
645
|
+
"project": Path(s.project_path).name if s.project_path else s.project_hash,
|
|
646
|
+
"project_hash": s.project_hash,
|
|
647
|
+
"name": s.name,
|
|
648
|
+
"cost": round(true_cost, 6), # TRUE cost including overhead
|
|
649
|
+
"work_cost": round(s.total_cost, 6), # Main conversation cost
|
|
650
|
+
"messages": s.message_count,
|
|
651
|
+
"turns": s.turn_count,
|
|
652
|
+
"model": s.model,
|
|
653
|
+
"summary_overhead": round(s.summary_cost, 6),
|
|
654
|
+
"created": s.created_at,
|
|
655
|
+
"last_activity": s.last_activity,
|
|
656
|
+
"model_breakdown": model_breakdown,
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
return result
|
|
660
|
+
|
|
661
|
+
def get_tool_attribution(
|
|
662
|
+
self,
|
|
663
|
+
project_hash: Optional[str] = None,
|
|
664
|
+
session_id: Optional[str] = None,
|
|
665
|
+
) -> dict:
|
|
666
|
+
"""
|
|
667
|
+
Get tool cost attribution.
|
|
668
|
+
|
|
669
|
+
This is an estimate - we attribute turn cost proportionally
|
|
670
|
+
to tools used in that turn.
|
|
671
|
+
"""
|
|
672
|
+
tool_stats = defaultdict(lambda: {"invocations": 0, "turns": 0, "attributed_cost": 0.0})
|
|
673
|
+
|
|
674
|
+
if project_hash:
|
|
675
|
+
project_hashes = [project_hash]
|
|
676
|
+
else:
|
|
677
|
+
project_hashes = [
|
|
678
|
+
d.name for d in self.projects_dir.iterdir()
|
|
679
|
+
if d.is_dir() and (d / "sessions").exists()
|
|
680
|
+
]
|
|
681
|
+
|
|
682
|
+
for ph in project_hashes:
|
|
683
|
+
sessions_dir = self.projects_dir / ph / "sessions"
|
|
684
|
+
if not sessions_dir.exists():
|
|
685
|
+
continue
|
|
686
|
+
|
|
687
|
+
for session_dir in sessions_dir.iterdir():
|
|
688
|
+
if not session_dir.is_dir():
|
|
689
|
+
continue
|
|
690
|
+
|
|
691
|
+
if session_id and session_dir.name != session_id:
|
|
692
|
+
continue
|
|
693
|
+
|
|
694
|
+
messages_path = session_dir / "messages.jsonl"
|
|
695
|
+
raw_path = session_dir / "raw.jsonl"
|
|
696
|
+
|
|
697
|
+
# Get per-turn costs
|
|
698
|
+
turn_costs = {}
|
|
699
|
+
for result in self._parse_raw_result(raw_path):
|
|
700
|
+
ts = result.get("ts", "")
|
|
701
|
+
turn_costs[ts] = result.get("cost", 0)
|
|
702
|
+
|
|
703
|
+
# Get tool invocations grouped by approximate turn
|
|
704
|
+
if not messages_path.exists():
|
|
705
|
+
continue
|
|
706
|
+
|
|
707
|
+
current_turn_tools = []
|
|
708
|
+
current_turn_ts = None
|
|
709
|
+
|
|
710
|
+
try:
|
|
711
|
+
with open(messages_path, encoding="utf-8") as f:
|
|
712
|
+
for line in f:
|
|
713
|
+
msg = json.loads(line)
|
|
714
|
+
ts = msg.get("timestamp", "")
|
|
715
|
+
|
|
716
|
+
if msg.get("role") == "tool":
|
|
717
|
+
tool_name = msg.get("tool_name", "unknown")
|
|
718
|
+
tool_stats[tool_name]["invocations"] += 1
|
|
719
|
+
if tool_name not in current_turn_tools:
|
|
720
|
+
current_turn_tools.append(tool_name)
|
|
721
|
+
current_turn_ts = ts
|
|
722
|
+
|
|
723
|
+
elif msg.get("role") == "result":
|
|
724
|
+
# End of turn - attribute cost
|
|
725
|
+
cost = msg.get("cost_usd", 0)
|
|
726
|
+
if current_turn_tools and cost > 0:
|
|
727
|
+
per_tool = cost / len(current_turn_tools)
|
|
728
|
+
for tool in current_turn_tools:
|
|
729
|
+
tool_stats[tool]["attributed_cost"] += per_tool
|
|
730
|
+
tool_stats[tool]["turns"] += 1
|
|
731
|
+
current_turn_tools = []
|
|
732
|
+
|
|
733
|
+
except (json.JSONDecodeError, IOError):
|
|
734
|
+
continue
|
|
735
|
+
|
|
736
|
+
# Sort by attributed cost
|
|
737
|
+
sorted_tools = sorted(
|
|
738
|
+
tool_stats.items(),
|
|
739
|
+
key=lambda x: -x[1]["attributed_cost"]
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
total_cost = sum(t[1]["attributed_cost"] for t in sorted_tools)
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
"tools": {
|
|
746
|
+
name: {
|
|
747
|
+
"invocations": stats["invocations"],
|
|
748
|
+
"turns_with_tool": stats["turns"],
|
|
749
|
+
"attributed_cost": round(stats["attributed_cost"], 6),
|
|
750
|
+
"avg_per_invocation": round(stats["attributed_cost"] / stats["invocations"], 6) if stats["invocations"] > 0 else 0,
|
|
751
|
+
"pct": round(stats["attributed_cost"] / total_cost * 100, 1) if total_cost > 0 else 0,
|
|
752
|
+
}
|
|
753
|
+
for name, stats in sorted_tools
|
|
754
|
+
},
|
|
755
|
+
"total_attributed": round(total_cost, 6),
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
def get_trends(
|
|
759
|
+
self,
|
|
760
|
+
period: str = "daily", # daily, weekly, monthly
|
|
761
|
+
project_hash: Optional[str] = None,
|
|
762
|
+
days: int = 30,
|
|
763
|
+
) -> dict:
|
|
764
|
+
"""Get cost trends over time."""
|
|
765
|
+
from datetime import timedelta
|
|
766
|
+
|
|
767
|
+
# Aggregate by date
|
|
768
|
+
by_date = defaultdict(lambda: {"cost": 0, "sessions": 0, "turns": 0})
|
|
769
|
+
|
|
770
|
+
if project_hash:
|
|
771
|
+
project_hashes = [project_hash]
|
|
772
|
+
else:
|
|
773
|
+
project_hashes = [
|
|
774
|
+
d.name for d in self.projects_dir.iterdir()
|
|
775
|
+
if d.is_dir() and (d / "sessions").exists()
|
|
776
|
+
]
|
|
777
|
+
|
|
778
|
+
for ph in project_hashes:
|
|
779
|
+
sessions = self._scan_sessions(ph)
|
|
780
|
+
|
|
781
|
+
for session in sessions:
|
|
782
|
+
if not session.created_at:
|
|
783
|
+
continue
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
dt = datetime.fromisoformat(session.created_at.replace("Z", "+00:00"))
|
|
787
|
+
|
|
788
|
+
if period == "daily":
|
|
789
|
+
key = dt.strftime("%Y-%m-%d")
|
|
790
|
+
elif period == "weekly":
|
|
791
|
+
# Week start (Monday)
|
|
792
|
+
week_start = dt - timedelta(days=dt.weekday())
|
|
793
|
+
key = week_start.strftime("%Y-%m-%d")
|
|
794
|
+
else: # monthly
|
|
795
|
+
key = dt.strftime("%Y-%m")
|
|
796
|
+
|
|
797
|
+
by_date[key]["cost"] += session.total_cost
|
|
798
|
+
by_date[key]["sessions"] += 1
|
|
799
|
+
by_date[key]["turns"] += session.turn_count
|
|
800
|
+
|
|
801
|
+
except ValueError:
|
|
802
|
+
continue
|
|
803
|
+
|
|
804
|
+
# Sort by date
|
|
805
|
+
sorted_dates = sorted(by_date.items())
|
|
806
|
+
|
|
807
|
+
return {
|
|
808
|
+
"period": period,
|
|
809
|
+
"data": [
|
|
810
|
+
{
|
|
811
|
+
"date": date,
|
|
812
|
+
"cost": round(stats["cost"], 6),
|
|
813
|
+
"sessions": stats["sessions"],
|
|
814
|
+
"turns": stats["turns"],
|
|
815
|
+
}
|
|
816
|
+
for date, stats in sorted_dates[-days:]
|
|
817
|
+
],
|
|
818
|
+
"totals": {
|
|
819
|
+
"cost": round(sum(s["cost"] for _, s in sorted_dates), 6),
|
|
820
|
+
"sessions": sum(s["sessions"] for _, s in sorted_dates),
|
|
821
|
+
"turns": sum(s["turns"] for _, s in sorted_dates),
|
|
822
|
+
},
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
def get_efficiency_metrics(
|
|
826
|
+
self,
|
|
827
|
+
project_hash: Optional[str] = None,
|
|
828
|
+
) -> dict:
|
|
829
|
+
"""Calculate efficiency metrics."""
|
|
830
|
+
summary = self.get_summary(project_hash=project_hash)
|
|
831
|
+
|
|
832
|
+
total_cost = summary["total_cost"]
|
|
833
|
+
total_turns = summary["total_turns"] or 1
|
|
834
|
+
total_messages = summary["total_messages"] or 1
|
|
835
|
+
|
|
836
|
+
# Calculate totals from model breakdown
|
|
837
|
+
total_input = sum(m["input_tokens"] for m in summary["by_model"].values())
|
|
838
|
+
total_output = sum(m["output_tokens"] for m in summary["by_model"].values())
|
|
839
|
+
cache_read = summary["cache"]["read_tokens"]
|
|
840
|
+
cache_write = summary["cache"]["write_tokens"]
|
|
841
|
+
|
|
842
|
+
# Estimate what cost would be without cache
|
|
843
|
+
# Cache reads cost ~10% of fresh input (rough estimate for Claude)
|
|
844
|
+
# So savings = cache_read * 0.9 * input_price_per_token
|
|
845
|
+
# For Opus: ~$15/M input, so ~$0.000015 per token
|
|
846
|
+
# Savings per cached token: ~$0.0000135
|
|
847
|
+
estimated_no_cache_cost = total_cost + (cache_read * 0.0000135)
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
"cost_per_turn": round(total_cost / total_turns, 4) if total_turns else 0,
|
|
851
|
+
"cost_per_message": round(total_cost / total_messages, 4) if total_messages else 0,
|
|
852
|
+
"tokens_per_dollar": {
|
|
853
|
+
"input": int(total_input / total_cost) if total_cost > 0 else 0,
|
|
854
|
+
"output": int(total_output / total_cost) if total_cost > 0 else 0,
|
|
855
|
+
},
|
|
856
|
+
"cache_efficiency": {
|
|
857
|
+
"hit_rate_pct": summary["cache"]["hit_rate_pct"],
|
|
858
|
+
"estimated_savings": round(estimated_no_cache_cost - total_cost, 4),
|
|
859
|
+
"savings_pct": round((1 - total_cost / estimated_no_cache_cost) * 100, 1) if estimated_no_cache_cost > 0 else 0,
|
|
860
|
+
},
|
|
861
|
+
"overhead": {
|
|
862
|
+
"summary_pct": summary["overhead"]["summary_pct"],
|
|
863
|
+
"summary_cost": summary["overhead"]["summary_cost"],
|
|
864
|
+
"work_efficiency_pct": round(100 - summary["overhead"]["summary_pct"], 2),
|
|
865
|
+
},
|
|
866
|
+
"model_efficiency": {
|
|
867
|
+
name: {
|
|
868
|
+
"cost_per_1k_input": round(m["cost"] / (m["input_tokens"] / 1000), 4) if m["input_tokens"] > 0 else 0,
|
|
869
|
+
"cost_per_1k_output": round(m["cost"] / (m["output_tokens"] / 1000), 4) if m["output_tokens"] > 0 else 0,
|
|
870
|
+
}
|
|
871
|
+
for name, m in summary["by_model"].items()
|
|
872
|
+
},
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
# Convenience function for quick summary
|
|
877
|
+
def get_cost_summary(**kwargs) -> dict:
|
|
878
|
+
"""Quick access to cost summary."""
|
|
879
|
+
return CostAnalyzer().get_summary(**kwargs)
|