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,595 @@
1
+ """Read-only query methods for ShadowDB.
2
+
3
+ Mixed into ShadowDB. Contains get_turn, list_turns, search_turns,
4
+ query_turns, and stats helpers. All methods return data dicts — no DB
5
+ mutations here.
6
+ """
7
+
8
+ import logging
9
+ from typing import Optional
10
+
11
+ logger = logging.getLogger("painapple-code.shadow-db")
12
+
13
+
14
+ class _QueriesMixin:
15
+ """Read-only query methods for ShadowDB."""
16
+
17
+ def get_turn(self, turn_id: str) -> Optional[dict]:
18
+ """Get a turn with all its fields, tags, files, and tools."""
19
+ columns, rows = self._fetch_columns(
20
+ "SELECT * FROM turns WHERE id = ?", [turn_id]
21
+ )
22
+ if not rows:
23
+ return None
24
+
25
+ turn = dict(zip(columns, rows[0]))
26
+
27
+ # Attach fields
28
+ turn["fields"] = {}
29
+ for r in self._fetch_all(
30
+ "SELECT field_key, value_text, value_list FROM turn_fields WHERE turn_id = ?",
31
+ [turn_id]
32
+ ):
33
+ turn["fields"][r[0]] = r[2] if r[2] is not None else r[1]
34
+
35
+ # Attach tags
36
+ turn["tags"] = [r[0] for r in self._fetch_all(
37
+ "SELECT tag FROM tags WHERE turn_id = ?", [turn_id]
38
+ )]
39
+
40
+ # Attach files
41
+ turn["files"] = [r[0] for r in self._fetch_all(
42
+ "SELECT file_path FROM turn_files WHERE turn_id = ?", [turn_id]
43
+ )]
44
+
45
+ # Attach tools
46
+ turn["tools"] = {r[0]: r[1] for r in self._fetch_all(
47
+ "SELECT tool_name, call_count FROM turn_tools WHERE turn_id = ?", [turn_id]
48
+ )}
49
+
50
+ return turn
51
+
52
+ def list_turns(
53
+ self,
54
+ project_hash: Optional[str] = None,
55
+ session_id: Optional[str] = None,
56
+ git_branch: Optional[str] = None,
57
+ status: Optional[str] = None,
58
+ tag: Optional[str] = None,
59
+ search: Optional[str] = None,
60
+ git_repo_hash: Optional[str] = None,
61
+ limit: int = 50,
62
+ offset: int = 0,
63
+ ) -> dict:
64
+ """List turns with filters. Returns {turns, total, has_more}."""
65
+ conditions = []
66
+ params = []
67
+
68
+ if git_repo_hash:
69
+ conditions.append("t.git_repo_hash = ?")
70
+ params.append(git_repo_hash)
71
+ elif project_hash:
72
+ conditions.append("t.project_hash = ?")
73
+ params.append(project_hash)
74
+ if session_id:
75
+ conditions.append("t.session_id = ?")
76
+ params.append(session_id)
77
+ if git_branch:
78
+ conditions.append("t.git_branch = ?")
79
+ params.append(git_branch)
80
+ if status:
81
+ conditions.append("t.status = ?")
82
+ params.append(status)
83
+ if tag:
84
+ conditions.append("EXISTS (SELECT 1 FROM tags tg WHERE tg.turn_id = t.id AND tg.tag = ?)")
85
+ params.append(tag)
86
+ if search:
87
+ if self._fts_ready:
88
+ self.rebuild_fts_if_needed()
89
+ conditions.append(
90
+ "(fts_main_turns.match_bm25(t.id, ?) IS NOT NULL OR EXISTS "
91
+ "(SELECT 1 FROM turn_fields f WHERE f.turn_id = t.id "
92
+ "AND fts_main_turn_fields.match_bm25(f.id, ?) IS NOT NULL))"
93
+ )
94
+ params.extend([search, search])
95
+ else:
96
+ conditions.append(
97
+ "(t.user_prompt ILIKE ? OR EXISTS "
98
+ "(SELECT 1 FROM turn_fields f WHERE f.turn_id = t.id AND f.search_text ILIKE ?))"
99
+ )
100
+ params.extend([f"%{search}%", f"%{search}%"])
101
+
102
+ where = " AND ".join(conditions) if conditions else "1=1"
103
+
104
+ # Count total
105
+ total = self._fetch_one(
106
+ f"SELECT COUNT(*) FROM turns t WHERE {where}", params
107
+ )[0]
108
+
109
+ # Fetch turns with summary
110
+ rows = self._fetch_all(
111
+ f"""SELECT t.id, t.session_id, t.project_hash, t.git_repo_hash, t.turn_number,
112
+ t.status, t.user_prompt, t.started_at, t.completed_at,
113
+ t.cost, t.git_branch, t.git_hash, t.model, t.is_error,
114
+ t.duration_ms, t.tokens_in, t.tokens_out,
115
+ f.value_text as summary
116
+ FROM turns t
117
+ LEFT JOIN turn_fields f ON t.id = f.turn_id AND f.field_key = 'summary'
118
+ WHERE {where}
119
+ ORDER BY t.started_at DESC
120
+ LIMIT ? OFFSET ?""",
121
+ params + [limit, offset]
122
+ )
123
+
124
+ columns = ["id", "session_id", "project_hash", "git_repo_hash", "turn_number",
125
+ "status", "user_prompt", "started_at", "completed_at",
126
+ "cost", "git_branch", "git_hash", "model", "is_error",
127
+ "duration_ms", "tokens_in", "tokens_out", "summary"]
128
+
129
+ turns = [dict(zip(columns, row)) for row in rows]
130
+
131
+ # Attach tags to each turn (batch)
132
+ if turns:
133
+ turn_ids = [t["id"] for t in turns]
134
+ placeholders = ", ".join(["?"] * len(turn_ids))
135
+ tag_rows = self._fetch_all(
136
+ f"SELECT turn_id, tag FROM tags WHERE turn_id IN ({placeholders})",
137
+ turn_ids
138
+ )
139
+ tags_by_turn = {}
140
+ for tid, tg in tag_rows:
141
+ tags_by_turn.setdefault(tid, []).append(tg)
142
+ for t in turns:
143
+ t["tags"] = tags_by_turn.get(t["id"], [])
144
+
145
+ return {"turns": turns, "total": total, "has_more": offset + limit < total}
146
+
147
+ def search_turns(
148
+ self,
149
+ *,
150
+ query: Optional[str] = None,
151
+ search_sections: Optional[list[str]] = None,
152
+ file_path: Optional[str] = None,
153
+ file_pattern: Optional[str] = None,
154
+ tag: Optional[str] = None,
155
+ git_branch: Optional[str] = None,
156
+ session_id: Optional[str] = None,
157
+ project_hash: Optional[str] = None,
158
+ git_repo_hash: Optional[str] = None,
159
+ status: Optional[str] = "completed",
160
+ since: Optional[str] = None,
161
+ until: Optional[str] = None,
162
+ fields: Optional[list[str]] = None,
163
+ include_tags: bool = True,
164
+ include_files: bool = False,
165
+ include_tools: bool = False,
166
+ limit: int = 20,
167
+ offset: int = 0,
168
+ ) -> dict:
169
+ """Rich search with section-scoped FTS and field selection.
170
+
171
+ Unlike list_turns(), this method:
172
+ - Supports section-scoped search (only match within specific field_keys)
173
+ - Returns selected turn_fields inline (not just summary)
174
+ - Supports file-based filtering via turn_files
175
+ - Supports date range filtering
176
+ - Defaults to status='completed' to skip noise
177
+
178
+ Returns {turns, total, has_more, query_info}.
179
+ """
180
+ conditions = []
181
+ params = []
182
+
183
+ if git_repo_hash:
184
+ conditions.append("t.git_repo_hash = ?")
185
+ params.append(git_repo_hash)
186
+ elif project_hash:
187
+ conditions.append("t.project_hash = ?")
188
+ params.append(project_hash)
189
+ if session_id:
190
+ conditions.append("t.session_id = ?")
191
+ params.append(session_id)
192
+ if git_branch:
193
+ conditions.append("t.git_branch = ?")
194
+ params.append(git_branch)
195
+ if status:
196
+ conditions.append("t.status = ?")
197
+ params.append(status)
198
+ if tag:
199
+ conditions.append("EXISTS (SELECT 1 FROM tags tg WHERE tg.turn_id = t.id AND tg.tag = ?)")
200
+ params.append(tag)
201
+ if file_path:
202
+ conditions.append(
203
+ "EXISTS (SELECT 1 FROM turn_files tf WHERE tf.turn_id = t.id AND tf.file_path = ?)"
204
+ )
205
+ params.append(file_path)
206
+ if file_pattern:
207
+ conditions.append(
208
+ "EXISTS (SELECT 1 FROM turn_files tf WHERE tf.turn_id = t.id AND tf.file_path ILIKE ?)"
209
+ )
210
+ params.append(file_pattern)
211
+ if since:
212
+ conditions.append("t.started_at >= ?")
213
+ params.append(since)
214
+ if until:
215
+ conditions.append("t.started_at <= ?")
216
+ params.append(until)
217
+
218
+ # Text search — section-scoped or broad
219
+ fts_used = False
220
+ if query:
221
+ if search_sections:
222
+ # Section-scoped: only match within specified field_keys
223
+ sec_placeholders = ", ".join(["?"] * len(search_sections))
224
+ if self._fts_ready:
225
+ self.rebuild_fts_if_needed()
226
+ fts_used = True
227
+ conditions.append(
228
+ f"EXISTS (SELECT 1 FROM turn_fields f WHERE f.turn_id = t.id "
229
+ f"AND f.field_key IN ({sec_placeholders}) "
230
+ f"AND fts_main_turn_fields.match_bm25(f.id, ?) IS NOT NULL)"
231
+ )
232
+ params.extend(search_sections + [query])
233
+ else:
234
+ conditions.append(
235
+ f"EXISTS (SELECT 1 FROM turn_fields f WHERE f.turn_id = t.id "
236
+ f"AND f.field_key IN ({sec_placeholders}) "
237
+ f"AND f.search_text ILIKE ?)"
238
+ )
239
+ params.extend(search_sections + [f"%{query}%"])
240
+ else:
241
+ # Broad: search user_prompt + all turn_fields
242
+ if self._fts_ready:
243
+ self.rebuild_fts_if_needed()
244
+ fts_used = True
245
+ conditions.append(
246
+ "(fts_main_turns.match_bm25(t.id, ?) IS NOT NULL OR EXISTS "
247
+ "(SELECT 1 FROM turn_fields f WHERE f.turn_id = t.id "
248
+ "AND fts_main_turn_fields.match_bm25(f.id, ?) IS NOT NULL))"
249
+ )
250
+ params.extend([query, query])
251
+ else:
252
+ conditions.append(
253
+ "(t.user_prompt ILIKE ? OR EXISTS "
254
+ "(SELECT 1 FROM turn_fields f WHERE f.turn_id = t.id "
255
+ "AND f.search_text ILIKE ?))"
256
+ )
257
+ params.extend([f"%{query}%", f"%{query}%"])
258
+
259
+ where = " AND ".join(conditions) if conditions else "1=1"
260
+
261
+ # Count
262
+ total = self._fetch_one(
263
+ f"SELECT COUNT(*) FROM turns t WHERE {where}", params
264
+ )[0]
265
+
266
+ # Fetch turns
267
+ rows = self._fetch_all(
268
+ f"""SELECT t.id, t.session_id, t.user_prompt, t.started_at, t.completed_at,
269
+ t.cost, t.model, t.git_branch, t.git_hash, t.duration_ms,
270
+ t.is_error, t.tokens_in, t.tokens_out, t.num_tool_loops
271
+ FROM turns t
272
+ WHERE {where}
273
+ ORDER BY t.started_at DESC
274
+ LIMIT ? OFFSET ?""",
275
+ params + [limit, offset]
276
+ )
277
+
278
+ columns = ["id", "session_id", "user_prompt", "started_at", "completed_at",
279
+ "cost", "model", "git_branch", "git_hash", "duration_ms",
280
+ "is_error", "tokens_in", "tokens_out", "num_tool_loops"]
281
+
282
+ turns = []
283
+ for row in rows:
284
+ t = dict(zip(columns, row))
285
+ # Truncate long prompts
286
+ if t["user_prompt"] and len(t["user_prompt"]) > 200:
287
+ t["user_prompt"] = t["user_prompt"][:200] + "..."
288
+ turns.append(t)
289
+
290
+ if not turns:
291
+ return {"turns": [], "total": total, "has_more": False,
292
+ "query_info": {"fts_used": fts_used}}
293
+
294
+ turn_ids = [t["id"] for t in turns]
295
+ id_placeholders = ", ".join(["?"] * len(turn_ids))
296
+
297
+ # Batch-fetch selected fields
298
+ if fields:
299
+ key_placeholders = ", ".join(["?"] * len(fields))
300
+ field_rows = self._fetch_all(
301
+ f"SELECT turn_id, field_key, value_text, value_list "
302
+ f"FROM turn_fields WHERE turn_id IN ({id_placeholders}) "
303
+ f"AND field_key IN ({key_placeholders})",
304
+ turn_ids + fields
305
+ )
306
+ fields_by_turn: dict[str, dict] = {}
307
+ for tid, fkey, vtext, vlist in field_rows:
308
+ fields_by_turn.setdefault(tid, {})[fkey] = vlist if vlist is not None else vtext
309
+ for t in turns:
310
+ t["fields"] = fields_by_turn.get(t["id"], {})
311
+
312
+ # Batch-fetch tags
313
+ if include_tags:
314
+ tag_rows = self._fetch_all(
315
+ f"SELECT turn_id, tag FROM tags WHERE turn_id IN ({id_placeholders})",
316
+ turn_ids
317
+ )
318
+ tags_by_turn: dict[str, list] = {}
319
+ for tid, tg in tag_rows:
320
+ tags_by_turn.setdefault(tid, []).append(tg)
321
+ for t in turns:
322
+ t["tags"] = tags_by_turn.get(t["id"], [])
323
+
324
+ # Batch-fetch files
325
+ if include_files:
326
+ file_rows = self._fetch_all(
327
+ f"SELECT turn_id, file_path FROM turn_files WHERE turn_id IN ({id_placeholders})",
328
+ turn_ids
329
+ )
330
+ files_by_turn: dict[str, list] = {}
331
+ for tid, fp in file_rows:
332
+ files_by_turn.setdefault(tid, []).append(fp)
333
+ for t in turns:
334
+ t["files"] = files_by_turn.get(t["id"], [])
335
+
336
+ # Batch-fetch tools
337
+ if include_tools:
338
+ tool_rows = self._fetch_all(
339
+ f"SELECT turn_id, tool_name, call_count FROM turn_tools "
340
+ f"WHERE turn_id IN ({id_placeholders})",
341
+ turn_ids
342
+ )
343
+ tools_by_turn: dict[str, dict] = {}
344
+ for tid, tn, cc in tool_rows:
345
+ tools_by_turn.setdefault(tid, {})[tn] = cc
346
+ for t in turns:
347
+ t["tools"] = tools_by_turn.get(t["id"], {})
348
+
349
+ return {
350
+ "turns": turns,
351
+ "total": total,
352
+ "has_more": offset + limit < total,
353
+ "query_info": {
354
+ "fts_used": fts_used,
355
+ "fields_requested": fields,
356
+ "search_sections": search_sections,
357
+ },
358
+ }
359
+
360
+ def query_turns(
361
+ self,
362
+ query: str,
363
+ *,
364
+ git_repo_hash: Optional[str] = None,
365
+ project_hash: Optional[str] = None,
366
+ fields: Optional[list[str]] = None,
367
+ include_tags: bool = True,
368
+ include_files: bool = False,
369
+ include_tools: bool = False,
370
+ limit: int = 20,
371
+ offset: int = 0,
372
+ ) -> dict:
373
+ """Execute a TurnQL query. Returns {turns, total, has_more, query_info}.
374
+
375
+ The query string supports Lucene-like syntax: section:text, AND/OR,
376
+ parenthesized grouping, negation, numeric ranges, date filters, etc.
377
+ See turn_query.py for full syntax docs.
378
+ """
379
+ from painapple_code.turn_query import parse_turn_query, compile_turn_query
380
+
381
+ # Accept comma-separated string for fields (common from API callers)
382
+ if isinstance(fields, str):
383
+ fields = [f.strip() for f in fields.split(",") if f.strip()]
384
+
385
+ # Ensure DB connection (and FTS indexes) are initialized before compiling
386
+ self._get_con()
387
+
388
+ ast, parse_warnings = parse_turn_query(query)
389
+ compiled = compile_turn_query(ast, fts_available=self._fts_ready)
390
+
391
+ if compiled.fts_used:
392
+ self.rebuild_fts_if_needed()
393
+
394
+ # Build full WHERE: TurnQL conditions + context filters
395
+ conditions = []
396
+ params = []
397
+
398
+ if compiled.where_clause and compiled.where_clause != "1=1":
399
+ conditions.append(compiled.where_clause)
400
+ params.extend(compiled.params)
401
+
402
+ if git_repo_hash:
403
+ conditions.append("t.git_repo_hash = ?")
404
+ params.append(git_repo_hash)
405
+ elif project_hash:
406
+ conditions.append("t.project_hash = ?")
407
+ params.append(project_hash)
408
+
409
+ where = " AND ".join(conditions) if conditions else "1=1"
410
+
411
+ # Count
412
+ total = self._fetch_one(
413
+ f"SELECT COUNT(*) FROM turns t WHERE {where}", params
414
+ )[0]
415
+
416
+ # Fetch turns
417
+ rows = self._fetch_all(
418
+ f"""SELECT t.id, t.session_id, t.user_prompt, t.started_at, t.completed_at,
419
+ t.cost, t.model, t.git_branch, t.git_hash, t.duration_ms,
420
+ t.is_error, t.tokens_in, t.tokens_out, t.num_tool_loops
421
+ FROM turns t
422
+ WHERE {where}
423
+ ORDER BY t.started_at DESC
424
+ LIMIT ? OFFSET ?""",
425
+ params + [limit, offset]
426
+ )
427
+
428
+ columns = ["id", "session_id", "user_prompt", "started_at", "completed_at",
429
+ "cost", "model", "git_branch", "git_hash", "duration_ms",
430
+ "is_error", "tokens_in", "tokens_out", "num_tool_loops"]
431
+
432
+ turns = []
433
+ for row in rows:
434
+ t = dict(zip(columns, row))
435
+ if t["user_prompt"] and len(t["user_prompt"]) > 200:
436
+ t["user_prompt"] = t["user_prompt"][:200] + "..."
437
+ turns.append(t)
438
+
439
+ if not turns:
440
+ return {"turns": [], "total": total, "has_more": False,
441
+ "query_info": {"fts_used": compiled.fts_used,
442
+ "description": compiled.description,
443
+ "warnings": parse_warnings + compiled.warnings}}
444
+
445
+ turn_ids = [t["id"] for t in turns]
446
+ id_placeholders = ", ".join(["?"] * len(turn_ids))
447
+
448
+ # Batch-fetch selected fields
449
+ if fields:
450
+ key_placeholders = ", ".join(["?"] * len(fields))
451
+ field_rows = self._fetch_all(
452
+ f"SELECT turn_id, field_key, value_text, value_list "
453
+ f"FROM turn_fields WHERE turn_id IN ({id_placeholders}) "
454
+ f"AND field_key IN ({key_placeholders})",
455
+ turn_ids + fields
456
+ )
457
+ fields_by_turn: dict[str, dict] = {}
458
+ for tid, fkey, vtext, vlist in field_rows:
459
+ fields_by_turn.setdefault(tid, {})[fkey] = vlist if vlist is not None else vtext
460
+ for t in turns:
461
+ t["fields"] = fields_by_turn.get(t["id"], {})
462
+
463
+ # Batch-fetch tags
464
+ if include_tags:
465
+ tag_rows = self._fetch_all(
466
+ f"SELECT turn_id, tag FROM tags WHERE turn_id IN ({id_placeholders})",
467
+ turn_ids
468
+ )
469
+ tags_by_turn: dict[str, list] = {}
470
+ for tid, tg in tag_rows:
471
+ tags_by_turn.setdefault(tid, []).append(tg)
472
+ for t in turns:
473
+ t["tags"] = tags_by_turn.get(t["id"], [])
474
+
475
+ # Batch-fetch files
476
+ if include_files:
477
+ file_rows = self._fetch_all(
478
+ f"SELECT turn_id, file_path FROM turn_files WHERE turn_id IN ({id_placeholders})",
479
+ turn_ids
480
+ )
481
+ files_by_turn: dict[str, list] = {}
482
+ for tid, fp in file_rows:
483
+ files_by_turn.setdefault(tid, []).append(fp)
484
+ for t in turns:
485
+ t["files"] = files_by_turn.get(t["id"], [])
486
+
487
+ # Batch-fetch tools
488
+ if include_tools:
489
+ tool_rows = self._fetch_all(
490
+ f"SELECT turn_id, tool_name, call_count FROM turn_tools "
491
+ f"WHERE turn_id IN ({id_placeholders})",
492
+ turn_ids
493
+ )
494
+ tools_by_turn: dict[str, dict] = {}
495
+ for tid, tn, cc in tool_rows:
496
+ tools_by_turn.setdefault(tid, {})[tn] = cc
497
+ for t in turns:
498
+ t["tools"] = tools_by_turn.get(t["id"], {})
499
+
500
+ all_warnings = parse_warnings + compiled.warnings
501
+ return {
502
+ "turns": turns,
503
+ "total": total,
504
+ "has_more": offset + limit < total,
505
+ "query_info": {
506
+ "fts_used": compiled.fts_used,
507
+ "description": compiled.description,
508
+ "warnings": all_warnings,
509
+ },
510
+ }
511
+
512
+ def get_tag_cloud(self, project_hash: Optional[str] = None) -> list[dict]:
513
+ """Get tag statistics."""
514
+ if project_hash:
515
+ rows = self._fetch_all(
516
+ """SELECT tg.tag, tg.source, COUNT(*) as count,
517
+ SUM(t.cost) as total_cost
518
+ FROM tags tg
519
+ JOIN turns t ON tg.turn_id = t.id
520
+ WHERE t.project_hash = ?
521
+ GROUP BY tg.tag, tg.source
522
+ ORDER BY count DESC""",
523
+ [project_hash]
524
+ )
525
+ else:
526
+ rows = self._fetch_all(
527
+ """SELECT tg.tag, tg.source, COUNT(*) as count,
528
+ SUM(t.cost) as total_cost
529
+ FROM tags tg
530
+ JOIN turns t ON tg.turn_id = t.id
531
+ GROUP BY tg.tag, tg.source
532
+ ORDER BY count DESC"""
533
+ )
534
+
535
+ return [{"tag": r[0], "source": r[1], "count": r[2], "total_cost": float(r[3] or 0)}
536
+ for r in rows]
537
+
538
+ def get_branch_stats(self, project_hash: Optional[str] = None) -> list[dict]:
539
+ """Get branch activity overview."""
540
+ conditions = ["git_branch IS NOT NULL"]
541
+ params = []
542
+ if project_hash:
543
+ conditions.append("project_hash = ?")
544
+ params.append(project_hash)
545
+
546
+ where = " AND ".join(conditions)
547
+ rows = self._fetch_all(
548
+ f"""SELECT git_branch, COUNT(*) as turns, SUM(cost) as total_cost,
549
+ MIN(started_at) as first_turn, MAX(started_at) as last_turn,
550
+ COUNT(DISTINCT session_id) as sessions
551
+ FROM turns
552
+ WHERE {where}
553
+ GROUP BY git_branch
554
+ ORDER BY last_turn DESC""",
555
+ params
556
+ )
557
+
558
+ return [{"branch": r[0], "turns": r[1], "cost": float(r[2] or 0),
559
+ "first_turn": r[3], "last_turn": r[4], "sessions": r[5]}
560
+ for r in rows]
561
+
562
+ def get_stats(self, project_hash: Optional[str] = None,
563
+ git_repo_hash: Optional[str] = None) -> dict:
564
+ """Get overall database statistics."""
565
+ conditions = []
566
+ params = []
567
+ if git_repo_hash:
568
+ conditions.append("git_repo_hash = ?")
569
+ params.append(git_repo_hash)
570
+ elif project_hash:
571
+ conditions.append("project_hash = ?")
572
+ params.append(project_hash)
573
+ where = " AND ".join(conditions) if conditions else "1=1"
574
+
575
+ row = self._fetch_one_dict(
576
+ f"""SELECT COUNT(*) as total_turns,
577
+ COUNT(CASE WHEN status = 'completed' THEN 1 END) as completed,
578
+ COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending,
579
+ COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
580
+ COUNT(CASE WHEN status = 'interrupted' THEN 1 END) as interrupted,
581
+ SUM(cost) as total_cost,
582
+ SUM(summary_cost) as total_summary_cost,
583
+ COUNT(DISTINCT session_id) as sessions,
584
+ COUNT(DISTINCT git_branch) as branches,
585
+ MIN(started_at) as first_turn,
586
+ MAX(started_at) as last_turn
587
+ FROM turns WHERE {where}""",
588
+ params
589
+ )
590
+
591
+ return {
592
+ **row,
593
+ "total_cost": float(row["total_cost"] or 0),
594
+ "total_summary_cost": float(row["total_summary_cost"] or 0),
595
+ }