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.
Files changed (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. 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)