pythinker-code 0.8.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 (790) hide show
  1. pythinker_code/CHANGELOG.md +60 -0
  2. pythinker_code/__init__.py +0 -0
  3. pythinker_code/__main__.py +97 -0
  4. pythinker_code/acp/AGENTS.md +93 -0
  5. pythinker_code/acp/__init__.py +13 -0
  6. pythinker_code/acp/convert.py +128 -0
  7. pythinker_code/acp/host.py +301 -0
  8. pythinker_code/acp/mcp.py +46 -0
  9. pythinker_code/acp/server.py +497 -0
  10. pythinker_code/acp/session.py +502 -0
  11. pythinker_code/acp/tools.py +174 -0
  12. pythinker_code/acp/types.py +13 -0
  13. pythinker_code/acp/version.py +45 -0
  14. pythinker_code/agents/default/agent.yaml +55 -0
  15. pythinker_code/agents/default/code_reviewer.yaml +47 -0
  16. pythinker_code/agents/default/coder.yaml +42 -0
  17. pythinker_code/agents/default/debugger.yaml +35 -0
  18. pythinker_code/agents/default/explore.yaml +59 -0
  19. pythinker_code/agents/default/implementer.yaml +46 -0
  20. pythinker_code/agents/default/plan.yaml +42 -0
  21. pythinker_code/agents/default/review.yaml +47 -0
  22. pythinker_code/agents/default/security_reviewer.yaml +37 -0
  23. pythinker_code/agents/default/system.md +192 -0
  24. pythinker_code/agents/default/verifier.yaml +46 -0
  25. pythinker_code/agents/okabe/agent.yaml +22 -0
  26. pythinker_code/agentspec.py +163 -0
  27. pythinker_code/app.py +847 -0
  28. pythinker_code/approval_runtime/__init__.py +29 -0
  29. pythinker_code/approval_runtime/models.py +42 -0
  30. pythinker_code/approval_runtime/runtime.py +235 -0
  31. pythinker_code/auth/__init__.py +25 -0
  32. pythinker_code/auth/anthropic_direct.py +207 -0
  33. pythinker_code/auth/deepseek.py +192 -0
  34. pythinker_code/auth/github_feedback.py +228 -0
  35. pythinker_code/auth/lm_studio.py +418 -0
  36. pythinker_code/auth/minimax.py +203 -0
  37. pythinker_code/auth/oauth.py +1145 -0
  38. pythinker_code/auth/ollama.py +293 -0
  39. pythinker_code/auth/openai.py +783 -0
  40. pythinker_code/auth/opencode_go.py +203 -0
  41. pythinker_code/auth/openrouter.py +225 -0
  42. pythinker_code/auth/platforms.py +475 -0
  43. pythinker_code/background/__init__.py +36 -0
  44. pythinker_code/background/agent_runner.py +231 -0
  45. pythinker_code/background/ids.py +19 -0
  46. pythinker_code/background/manager.py +668 -0
  47. pythinker_code/background/models.py +118 -0
  48. pythinker_code/background/store.py +243 -0
  49. pythinker_code/background/summary.py +66 -0
  50. pythinker_code/background/worker.py +209 -0
  51. pythinker_code/cli/__init__.py +1326 -0
  52. pythinker_code/cli/__main__.py +19 -0
  53. pythinker_code/cli/_lazy_group.py +268 -0
  54. pythinker_code/cli/debug.py +11 -0
  55. pythinker_code/cli/export.py +322 -0
  56. pythinker_code/cli/info.py +62 -0
  57. pythinker_code/cli/mcp.py +362 -0
  58. pythinker_code/cli/plugin.py +351 -0
  59. pythinker_code/cli/review.py +74 -0
  60. pythinker_code/cli/secscan.py +11 -0
  61. pythinker_code/cli/security_scan.py +35 -0
  62. pythinker_code/cli/toad.py +74 -0
  63. pythinker_code/cli/update.py +26 -0
  64. pythinker_code/cli/vis.py +38 -0
  65. pythinker_code/cli/web.py +80 -0
  66. pythinker_code/config.py +511 -0
  67. pythinker_code/constant.py +33 -0
  68. pythinker_code/events.py +106 -0
  69. pythinker_code/exception.py +43 -0
  70. pythinker_code/extensions.py +151 -0
  71. pythinker_code/hooks/__init__.py +4 -0
  72. pythinker_code/hooks/config.py +34 -0
  73. pythinker_code/hooks/engine.py +383 -0
  74. pythinker_code/hooks/events.py +190 -0
  75. pythinker_code/hooks/runner.py +92 -0
  76. pythinker_code/llm.py +441 -0
  77. pythinker_code/metadata.py +79 -0
  78. pythinker_code/notifications/__init__.py +33 -0
  79. pythinker_code/notifications/llm.py +77 -0
  80. pythinker_code/notifications/manager.py +145 -0
  81. pythinker_code/notifications/models.py +50 -0
  82. pythinker_code/notifications/notifier.py +41 -0
  83. pythinker_code/notifications/store.py +118 -0
  84. pythinker_code/notifications/wire.py +21 -0
  85. pythinker_code/plugin/__init__.py +124 -0
  86. pythinker_code/plugin/manager.py +166 -0
  87. pythinker_code/plugin/tool.py +173 -0
  88. pythinker_code/prompt_templates.py +181 -0
  89. pythinker_code/prompts/__init__.py +6 -0
  90. pythinker_code/prompts/compact.md +73 -0
  91. pythinker_code/prompts/init.md +21 -0
  92. pythinker_code/py.typed +0 -0
  93. pythinker_code/session.py +319 -0
  94. pythinker_code/session_fork.py +325 -0
  95. pythinker_code/session_state.py +132 -0
  96. pythinker_code/share.py +14 -0
  97. pythinker_code/skill/__init__.py +727 -0
  98. pythinker_code/skill/flow/__init__.py +99 -0
  99. pythinker_code/skill/flow/d2.py +482 -0
  100. pythinker_code/skill/flow/mermaid.py +266 -0
  101. pythinker_code/skills/pythinker-code-help/SKILL.md +54 -0
  102. pythinker_code/skills/skill-creator/SKILL.md +367 -0
  103. pythinker_code/soul/__init__.py +304 -0
  104. pythinker_code/soul/agent.py +552 -0
  105. pythinker_code/soul/approval.py +267 -0
  106. pythinker_code/soul/btw.py +220 -0
  107. pythinker_code/soul/compaction.py +189 -0
  108. pythinker_code/soul/context.py +339 -0
  109. pythinker_code/soul/denwarenji.py +39 -0
  110. pythinker_code/soul/dynamic_injection.py +84 -0
  111. pythinker_code/soul/dynamic_injections/__init__.py +0 -0
  112. pythinker_code/soul/dynamic_injections/auto_mode.py +72 -0
  113. pythinker_code/soul/dynamic_injections/plan_mode.py +239 -0
  114. pythinker_code/soul/message.py +92 -0
  115. pythinker_code/soul/permission.py +368 -0
  116. pythinker_code/soul/pythinkersoul.py +1763 -0
  117. pythinker_code/soul/slash.py +340 -0
  118. pythinker_code/soul/toolset.py +826 -0
  119. pythinker_code/subagents/__init__.py +21 -0
  120. pythinker_code/subagents/builder.py +43 -0
  121. pythinker_code/subagents/core.py +86 -0
  122. pythinker_code/subagents/discovery.py +234 -0
  123. pythinker_code/subagents/git_context.py +172 -0
  124. pythinker_code/subagents/models.py +56 -0
  125. pythinker_code/subagents/output.py +71 -0
  126. pythinker_code/subagents/registry.py +28 -0
  127. pythinker_code/subagents/runner.py +442 -0
  128. pythinker_code/subagents/store.py +200 -0
  129. pythinker_code/telemetry/__init__.py +217 -0
  130. pythinker_code/telemetry/config.py +113 -0
  131. pythinker_code/telemetry/crash.py +191 -0
  132. pythinker_code/telemetry/errors.py +113 -0
  133. pythinker_code/telemetry/metrics.py +208 -0
  134. pythinker_code/telemetry/otel.py +303 -0
  135. pythinker_code/telemetry/sentry.py +212 -0
  136. pythinker_code/telemetry/sink.py +189 -0
  137. pythinker_code/tools/AGENTS.md +6 -0
  138. pythinker_code/tools/__init__.py +105 -0
  139. pythinker_code/tools/agent/__init__.py +326 -0
  140. pythinker_code/tools/agent/description.md +65 -0
  141. pythinker_code/tools/ask_user/__init__.py +162 -0
  142. pythinker_code/tools/ask_user/description.md +19 -0
  143. pythinker_code/tools/background/__init__.py +318 -0
  144. pythinker_code/tools/background/list.md +10 -0
  145. pythinker_code/tools/background/output.md +11 -0
  146. pythinker_code/tools/background/stop.md +8 -0
  147. pythinker_code/tools/display.py +46 -0
  148. pythinker_code/tools/dmail/__init__.py +38 -0
  149. pythinker_code/tools/dmail/dmail.md +17 -0
  150. pythinker_code/tools/file/__init__.py +31 -0
  151. pythinker_code/tools/file/glob.md +17 -0
  152. pythinker_code/tools/file/glob.py +163 -0
  153. pythinker_code/tools/file/grep.md +6 -0
  154. pythinker_code/tools/file/grep_local.py +904 -0
  155. pythinker_code/tools/file/plan_mode.py +45 -0
  156. pythinker_code/tools/file/read.md +16 -0
  157. pythinker_code/tools/file/read.py +303 -0
  158. pythinker_code/tools/file/read_media.md +24 -0
  159. pythinker_code/tools/file/read_media.py +220 -0
  160. pythinker_code/tools/file/replace.md +7 -0
  161. pythinker_code/tools/file/replace.py +204 -0
  162. pythinker_code/tools/file/utils.py +257 -0
  163. pythinker_code/tools/file/write.md +5 -0
  164. pythinker_code/tools/file/write.py +186 -0
  165. pythinker_code/tools/plan/__init__.py +362 -0
  166. pythinker_code/tools/plan/description.md +29 -0
  167. pythinker_code/tools/plan/enter.py +193 -0
  168. pythinker_code/tools/plan/enter_description.md +35 -0
  169. pythinker_code/tools/plan/handoff.py +69 -0
  170. pythinker_code/tools/plan/heroes.py +277 -0
  171. pythinker_code/tools/shell/__init__.py +263 -0
  172. pythinker_code/tools/shell/bash.md +35 -0
  173. pythinker_code/tools/shell/powershell.md +30 -0
  174. pythinker_code/tools/test.py +55 -0
  175. pythinker_code/tools/think/__init__.py +21 -0
  176. pythinker_code/tools/think/think.md +1 -0
  177. pythinker_code/tools/todo/__init__.py +168 -0
  178. pythinker_code/tools/todo/set_todo_list.md +23 -0
  179. pythinker_code/tools/utils.py +200 -0
  180. pythinker_code/tools/web/__init__.py +4 -0
  181. pythinker_code/tools/web/fetch.md +1 -0
  182. pythinker_code/tools/web/fetch.py +261 -0
  183. pythinker_code/tools/web/search.md +1 -0
  184. pythinker_code/tools/web/search.py +163 -0
  185. pythinker_code/ui/__init__.py +0 -0
  186. pythinker_code/ui/acp/__init__.py +99 -0
  187. pythinker_code/ui/print/__init__.py +474 -0
  188. pythinker_code/ui/print/visualize.py +185 -0
  189. pythinker_code/ui/shell/__init__.py +1806 -0
  190. pythinker_code/ui/shell/components/__init__.py +110 -0
  191. pythinker_code/ui/shell/components/base.py +25 -0
  192. pythinker_code/ui/shell/components/bash_execution.py +249 -0
  193. pythinker_code/ui/shell/components/bordered_loader.py +62 -0
  194. pythinker_code/ui/shell/components/diff.py +308 -0
  195. pythinker_code/ui/shell/components/footer.py +231 -0
  196. pythinker_code/ui/shell/components/key_hints.py +27 -0
  197. pythinker_code/ui/shell/components/messages.py +152 -0
  198. pythinker_code/ui/shell/components/render_utils.py +198 -0
  199. pythinker_code/ui/shell/components/settings_list.py +369 -0
  200. pythinker_code/ui/shell/components/special_messages.py +125 -0
  201. pythinker_code/ui/shell/components/tool_execution.py +261 -0
  202. pythinker_code/ui/shell/console.py +109 -0
  203. pythinker_code/ui/shell/debug.py +190 -0
  204. pythinker_code/ui/shell/echo.py +30 -0
  205. pythinker_code/ui/shell/export_import.py +117 -0
  206. pythinker_code/ui/shell/keyboard.py +300 -0
  207. pythinker_code/ui/shell/keymap.py +84 -0
  208. pythinker_code/ui/shell/mcp_status.py +112 -0
  209. pythinker_code/ui/shell/model_picker.py +318 -0
  210. pythinker_code/ui/shell/oauth.py +273 -0
  211. pythinker_code/ui/shell/placeholders.py +578 -0
  212. pythinker_code/ui/shell/prompt.py +2888 -0
  213. pythinker_code/ui/shell/replay.py +215 -0
  214. pythinker_code/ui/shell/selector.py +364 -0
  215. pythinker_code/ui/shell/selectors/__init__.py +38 -0
  216. pythinker_code/ui/shell/selectors/extension.py +37 -0
  217. pythinker_code/ui/shell/selectors/oauth.py +63 -0
  218. pythinker_code/ui/shell/selectors/settings.py +349 -0
  219. pythinker_code/ui/shell/selectors/show_images.py +29 -0
  220. pythinker_code/ui/shell/selectors/theme.py +28 -0
  221. pythinker_code/ui/shell/selectors/thinking.py +42 -0
  222. pythinker_code/ui/shell/session_picker.py +227 -0
  223. pythinker_code/ui/shell/setup.py +212 -0
  224. pythinker_code/ui/shell/slash.py +1433 -0
  225. pythinker_code/ui/shell/spinner_words.py +222 -0
  226. pythinker_code/ui/shell/startup.py +32 -0
  227. pythinker_code/ui/shell/task_browser.py +486 -0
  228. pythinker_code/ui/shell/tool_renderers/__init__.py +197 -0
  229. pythinker_code/ui/shell/tool_renderers/_render_utils.py +168 -0
  230. pythinker_code/ui/shell/tool_renderers/agent.py +140 -0
  231. pythinker_code/ui/shell/tool_renderers/ask_user.py +93 -0
  232. pythinker_code/ui/shell/tool_renderers/background.py +144 -0
  233. pythinker_code/ui/shell/tool_renderers/bash.py +103 -0
  234. pythinker_code/ui/shell/tool_renderers/edit.py +163 -0
  235. pythinker_code/ui/shell/tool_renderers/find.py +81 -0
  236. pythinker_code/ui/shell/tool_renderers/generic.py +60 -0
  237. pythinker_code/ui/shell/tool_renderers/grep.py +98 -0
  238. pythinker_code/ui/shell/tool_renderers/plan.py +98 -0
  239. pythinker_code/ui/shell/tool_renderers/read.py +103 -0
  240. pythinker_code/ui/shell/tool_renderers/think.py +66 -0
  241. pythinker_code/ui/shell/tool_renderers/todo.py +164 -0
  242. pythinker_code/ui/shell/tool_renderers/web.py +128 -0
  243. pythinker_code/ui/shell/tool_renderers/write.py +102 -0
  244. pythinker_code/ui/shell/update.py +352 -0
  245. pythinker_code/ui/shell/usage.py +291 -0
  246. pythinker_code/ui/shell/usage_adapters/__init__.py +50 -0
  247. pythinker_code/ui/shell/usage_adapters/anthropic_admin.py +233 -0
  248. pythinker_code/ui/shell/usage_adapters/base.py +72 -0
  249. pythinker_code/ui/shell/usage_adapters/deepseek.py +137 -0
  250. pythinker_code/ui/shell/usage_adapters/minimax.py +236 -0
  251. pythinker_code/ui/shell/usage_adapters/openai_admin.py +225 -0
  252. pythinker_code/ui/shell/usage_adapters/openai_chatgpt.py +241 -0
  253. pythinker_code/ui/shell/usage_adapters/opencode_go.py +232 -0
  254. pythinker_code/ui/shell/usage_adapters/openrouter.py +105 -0
  255. pythinker_code/ui/shell/usage_adapters/pythinker.py +189 -0
  256. pythinker_code/ui/shell/usage_adapters/pythinker_ai.py +50 -0
  257. pythinker_code/ui/shell/usage_render.py +150 -0
  258. pythinker_code/ui/shell/visualize/__init__.py +165 -0
  259. pythinker_code/ui/shell/visualize/_approval_panel.py +539 -0
  260. pythinker_code/ui/shell/visualize/_blocks.py +802 -0
  261. pythinker_code/ui/shell/visualize/_btw_panel.py +227 -0
  262. pythinker_code/ui/shell/visualize/_input_router.py +48 -0
  263. pythinker_code/ui/shell/visualize/_interactive.py +531 -0
  264. pythinker_code/ui/shell/visualize/_live_view.py +891 -0
  265. pythinker_code/ui/shell/visualize/_question_panel.py +586 -0
  266. pythinker_code/ui/shell/visualize/_worklog.py +245 -0
  267. pythinker_code/ui/theme.py +395 -0
  268. pythinker_code/ui/tui_config.py +82 -0
  269. pythinker_code/usage_ratelimit_cache.py +175 -0
  270. pythinker_code/utils/__init__.py +0 -0
  271. pythinker_code/utils/aiohttp.py +24 -0
  272. pythinker_code/utils/aioqueue.py +72 -0
  273. pythinker_code/utils/broadcast.py +38 -0
  274. pythinker_code/utils/changelog.py +108 -0
  275. pythinker_code/utils/clipboard.py +246 -0
  276. pythinker_code/utils/datetime.py +64 -0
  277. pythinker_code/utils/diff.py +135 -0
  278. pythinker_code/utils/editor.py +91 -0
  279. pythinker_code/utils/environment.py +73 -0
  280. pythinker_code/utils/envvar.py +22 -0
  281. pythinker_code/utils/export.py +696 -0
  282. pythinker_code/utils/file_filter.py +375 -0
  283. pythinker_code/utils/frontmatter.py +70 -0
  284. pythinker_code/utils/io.py +27 -0
  285. pythinker_code/utils/logging.py +146 -0
  286. pythinker_code/utils/media_tags.py +29 -0
  287. pythinker_code/utils/message.py +24 -0
  288. pythinker_code/utils/path.py +199 -0
  289. pythinker_code/utils/proctitle.py +33 -0
  290. pythinker_code/utils/proxy.py +31 -0
  291. pythinker_code/utils/pyinstaller.py +45 -0
  292. pythinker_code/utils/rich/__init__.py +33 -0
  293. pythinker_code/utils/rich/columns.py +99 -0
  294. pythinker_code/utils/rich/diff_render.py +481 -0
  295. pythinker_code/utils/rich/markdown.py +935 -0
  296. pythinker_code/utils/rich/markdown_sample.md +108 -0
  297. pythinker_code/utils/rich/markdown_sample_short.md +2 -0
  298. pythinker_code/utils/rich/syntax.py +114 -0
  299. pythinker_code/utils/sensitive.py +54 -0
  300. pythinker_code/utils/server.py +121 -0
  301. pythinker_code/utils/signals.py +43 -0
  302. pythinker_code/utils/slashcmd.py +124 -0
  303. pythinker_code/utils/string.py +41 -0
  304. pythinker_code/utils/subprocess_env.py +83 -0
  305. pythinker_code/utils/term.py +168 -0
  306. pythinker_code/utils/typing.py +20 -0
  307. pythinker_code/vis/__init__.py +0 -0
  308. pythinker_code/vis/api/__init__.py +5 -0
  309. pythinker_code/vis/api/sessions.py +714 -0
  310. pythinker_code/vis/api/statistics.py +209 -0
  311. pythinker_code/vis/api/system.py +19 -0
  312. pythinker_code/vis/app.py +199 -0
  313. pythinker_code/vis/static/assets/highlighted-body-B3W2YXNL-CY1rtwrX.js +1 -0
  314. pythinker_code/vis/static/assets/index-DSRInNnm.css +1 -0
  315. pythinker_code/vis/static/assets/index-DgmTI2M_.js +185 -0
  316. pythinker_code/vis/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  317. pythinker_code/vis/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  318. pythinker_code/vis/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  319. pythinker_code/vis/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  320. pythinker_code/vis/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  321. pythinker_code/vis/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  322. pythinker_code/vis/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  323. pythinker_code/vis/static/index.html +17 -0
  324. pythinker_code/web/__init__.py +5 -0
  325. pythinker_code/web/api/__init__.py +15 -0
  326. pythinker_code/web/api/config.py +217 -0
  327. pythinker_code/web/api/open_in.py +233 -0
  328. pythinker_code/web/api/sessions.py +1256 -0
  329. pythinker_code/web/app.py +449 -0
  330. pythinker_code/web/auth.py +191 -0
  331. pythinker_code/web/models.py +98 -0
  332. pythinker_code/web/runner/__init__.py +5 -0
  333. pythinker_code/web/runner/messages.py +57 -0
  334. pythinker_code/web/runner/process.py +754 -0
  335. pythinker_code/web/runner/worker.py +97 -0
  336. pythinker_code/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  337. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  338. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  339. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  340. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  341. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  342. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  343. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  344. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  345. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  346. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  347. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  348. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  349. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  350. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  351. pythinker_code/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  352. pythinker_code/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  353. pythinker_code/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  354. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  355. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  356. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  357. pythinker_code/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  358. pythinker_code/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  359. pythinker_code/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  360. pythinker_code/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  361. pythinker_code/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  362. pythinker_code/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  363. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  364. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  365. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  366. pythinker_code/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  367. pythinker_code/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  368. pythinker_code/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  369. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  370. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  371. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  372. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  373. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  374. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  375. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  376. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  377. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  378. pythinker_code/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  379. pythinker_code/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  380. pythinker_code/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  381. pythinker_code/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  382. pythinker_code/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  383. pythinker_code/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  384. pythinker_code/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  385. pythinker_code/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  386. pythinker_code/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  387. pythinker_code/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  388. pythinker_code/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  389. pythinker_code/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  390. pythinker_code/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  391. pythinker_code/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  392. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  393. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  394. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  395. pythinker_code/web/static/assets/_baseUniq-DpSMr1jx.js +1 -0
  396. pythinker_code/web/static/assets/abap-BdImnpbu.js +1 -0
  397. pythinker_code/web/static/assets/actionscript-3-CfeIJUat.js +1 -0
  398. pythinker_code/web/static/assets/ada-bCR0ucgS.js +1 -0
  399. pythinker_code/web/static/assets/andromeeda-C-Jbm3Hp.js +1 -0
  400. pythinker_code/web/static/assets/angular-html-CU67Zn6k.js +1 -0
  401. pythinker_code/web/static/assets/angular-ts-BwZT4LLn.js +1 -0
  402. pythinker_code/web/static/assets/apache-Pmp26Uib.js +1 -0
  403. pythinker_code/web/static/assets/apex-D8_7TLub.js +1 -0
  404. pythinker_code/web/static/assets/apl-dKokRX4l.js +1 -0
  405. pythinker_code/web/static/assets/applescript-Co6uUVPk.js +1 -0
  406. pythinker_code/web/static/assets/ara-BRHolxvo.js +1 -0
  407. pythinker_code/web/static/assets/arc-DpsahJyV.js +1 -0
  408. pythinker_code/web/static/assets/architectureDiagram-VXUJARFQ-DqiRv9Eg.js +36 -0
  409. pythinker_code/web/static/assets/asciidoc-Dv7Oe6Be.js +1 -0
  410. pythinker_code/web/static/assets/asm-D_Q5rh1f.js +1 -0
  411. pythinker_code/web/static/assets/astro-CbQHKStN.js +1 -0
  412. pythinker_code/web/static/assets/aurora-x-D-2ljcwZ.js +1 -0
  413. pythinker_code/web/static/assets/awk-DMzUqQB5.js +1 -0
  414. pythinker_code/web/static/assets/ayu-dark-CmMr59Fi.js +1 -0
  415. pythinker_code/web/static/assets/ballerina-BFfxhgS-.js +1 -0
  416. pythinker_code/web/static/assets/bat-BkioyH1T.js +1 -0
  417. pythinker_code/web/static/assets/beancount-k_qm7-4y.js +1 -0
  418. pythinker_code/web/static/assets/berry-uYugtg8r.js +1 -0
  419. pythinker_code/web/static/assets/bibtex-CHM0blh-.js +1 -0
  420. pythinker_code/web/static/assets/bicep-Bmn6On1c.js +1 -0
  421. pythinker_code/web/static/assets/blade-D4QpJJKB.js +1 -0
  422. pythinker_code/web/static/assets/blockDiagram-VD42YOAC-WgtUvqbp.js +122 -0
  423. pythinker_code/web/static/assets/bsl-BO_Y6i37.js +1 -0
  424. pythinker_code/web/static/assets/c-BIGW1oBm.js +1 -0
  425. pythinker_code/web/static/assets/c3-VCDPK7BO.js +1 -0
  426. pythinker_code/web/static/assets/c4Diagram-YG6GDRKO-rK0RPuZd.js +10 -0
  427. pythinker_code/web/static/assets/cadence-Bv_4Rxtq.js +1 -0
  428. pythinker_code/web/static/assets/cairo-KRGpt6FW.js +1 -0
  429. pythinker_code/web/static/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  430. pythinker_code/web/static/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  431. pythinker_code/web/static/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  432. pythinker_code/web/static/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  433. pythinker_code/web/static/assets/channel-B0rlvkH-.js +1 -0
  434. pythinker_code/web/static/assets/chunk-4BX2VUAB-DIkMuLV-.js +1 -0
  435. pythinker_code/web/static/assets/chunk-55IACEB6-CORdm4k4.js +1 -0
  436. pythinker_code/web/static/assets/chunk-B4BG7PRW-D9xDhwHO.js +165 -0
  437. pythinker_code/web/static/assets/chunk-DI55MBZ5-BDmF9Bh-.js +220 -0
  438. pythinker_code/web/static/assets/chunk-FMBD7UC4-BCse_HmM.js +15 -0
  439. pythinker_code/web/static/assets/chunk-QN33PNHL-DCpBmTzA.js +1 -0
  440. pythinker_code/web/static/assets/chunk-QZHKN3VN-BqLuqobw.js +1 -0
  441. pythinker_code/web/static/assets/chunk-TZMSLE5B-8K2ogOKS.js +1 -0
  442. pythinker_code/web/static/assets/clarity-D53aC0YG.js +1 -0
  443. pythinker_code/web/static/assets/classDiagram-2ON5EDUG-D_ZHSii2.js +1 -0
  444. pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-D_ZHSii2.js +1 -0
  445. pythinker_code/web/static/assets/clojure-P80f7IUj.js +1 -0
  446. pythinker_code/web/static/assets/clone-GSXejyY1.js +1 -0
  447. pythinker_code/web/static/assets/cmake-D1j8_8rp.js +1 -0
  448. pythinker_code/web/static/assets/cobol-nwyudZeR.js +1 -0
  449. pythinker_code/web/static/assets/code-block-IT6T5CEO-DWTFYA28.js +2 -0
  450. pythinker_code/web/static/assets/codeowners-Bp6g37R7.js +1 -0
  451. pythinker_code/web/static/assets/codeql-DsOJ9woJ.js +1 -0
  452. pythinker_code/web/static/assets/coffee-Ch7k5sss.js +1 -0
  453. pythinker_code/web/static/assets/common-lisp-Cg-RD9OK.js +1 -0
  454. pythinker_code/web/static/assets/coq-DkFqJrB1.js +1 -0
  455. pythinker_code/web/static/assets/cose-bilkent-S5V4N54A-BRI7ES-N.js +1 -0
  456. pythinker_code/web/static/assets/cpp-CofmeUqb.js +1 -0
  457. pythinker_code/web/static/assets/crystal-tKQVLTB8.js +1 -0
  458. pythinker_code/web/static/assets/csharp-K5feNrxe.js +1 -0
  459. pythinker_code/web/static/assets/css-DPfMkruS.js +1 -0
  460. pythinker_code/web/static/assets/csv-fuZLfV_i.js +1 -0
  461. pythinker_code/web/static/assets/cue-D82EKSYY.js +1 -0
  462. pythinker_code/web/static/assets/cypher-COkxafJQ.js +1 -0
  463. pythinker_code/web/static/assets/cytoscape.esm-B6BxUuKW.js +321 -0
  464. pythinker_code/web/static/assets/d-85-TOEBH.js +1 -0
  465. pythinker_code/web/static/assets/dagre-6UL2VRFP-Ci5GdWfi.js +4 -0
  466. pythinker_code/web/static/assets/dark-plus-C3mMm8J8.js +1 -0
  467. pythinker_code/web/static/assets/dart-CF10PKvl.js +1 -0
  468. pythinker_code/web/static/assets/dax-CEL-wOlO.js +1 -0
  469. pythinker_code/web/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  470. pythinker_code/web/static/assets/desktop-BmXAJ9_W.js +1 -0
  471. pythinker_code/web/static/assets/diagram-PSM6KHXK-0hhAylV4.js +24 -0
  472. pythinker_code/web/static/assets/diagram-QEK2KX5R-8fxgaW6d.js +43 -0
  473. pythinker_code/web/static/assets/diagram-S2PKOQOG-FRr0_atE.js +24 -0
  474. pythinker_code/web/static/assets/diff-D97Zzqfu.js +1 -0
  475. pythinker_code/web/static/assets/docker-BcOcwvcX.js +1 -0
  476. pythinker_code/web/static/assets/dotenv-Da5cRb03.js +1 -0
  477. pythinker_code/web/static/assets/dracula-BzJJZx-M.js +1 -0
  478. pythinker_code/web/static/assets/dracula-soft-BXkSAIEj.js +1 -0
  479. pythinker_code/web/static/assets/dream-maker-BtqSS_iP.js +1 -0
  480. pythinker_code/web/static/assets/edge-BkV0erSs.js +1 -0
  481. pythinker_code/web/static/assets/elixir-CDX3lj18.js +1 -0
  482. pythinker_code/web/static/assets/elm-DbKCFpqz.js +1 -0
  483. pythinker_code/web/static/assets/emacs-lisp-C9XAeP06.js +1 -0
  484. pythinker_code/web/static/assets/erDiagram-Q2GNP2WA-B3T-hJUM.js +60 -0
  485. pythinker_code/web/static/assets/erb-BOJIQeun.js +1 -0
  486. pythinker_code/web/static/assets/erlang-DsQrWhSR.js +1 -0
  487. pythinker_code/web/static/assets/everforest-dark-BgDCqdQA.js +1 -0
  488. pythinker_code/web/static/assets/everforest-light-C8M2exoo.js +1 -0
  489. pythinker_code/web/static/assets/fennel-BYunw83y.js +1 -0
  490. pythinker_code/web/static/assets/fish-BvzEVeQv.js +1 -0
  491. pythinker_code/web/static/assets/flowDiagram-NV44I4VS-D0S3u7ot.js +162 -0
  492. pythinker_code/web/static/assets/fluent-C4IJs8-o.js +1 -0
  493. pythinker_code/web/static/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  494. pythinker_code/web/static/assets/fortran-free-form-BxgE0vQu.js +1 -0
  495. pythinker_code/web/static/assets/fsharp-CXgrBDvD.js +1 -0
  496. pythinker_code/web/static/assets/ganttDiagram-JELNMOA3-CHrN2a23.js +267 -0
  497. pythinker_code/web/static/assets/gdresource-B7Tvp0Sc.js +1 -0
  498. pythinker_code/web/static/assets/gdscript-DTMYz4Jt.js +1 -0
  499. pythinker_code/web/static/assets/gdshader-DkwncUOv.js +1 -0
  500. pythinker_code/web/static/assets/genie-D0YGMca9.js +1 -0
  501. pythinker_code/web/static/assets/gherkin-DyxjwDmM.js +1 -0
  502. pythinker_code/web/static/assets/git-commit-F4YmCXRG.js +1 -0
  503. pythinker_code/web/static/assets/git-rebase-r7XF79zn.js +1 -0
  504. pythinker_code/web/static/assets/gitGraphDiagram-NY62KEGX-CfcXZWg0.js +65 -0
  505. pythinker_code/web/static/assets/github-dark-DHJKELXO.js +1 -0
  506. pythinker_code/web/static/assets/github-dark-default-Cuk6v7N8.js +1 -0
  507. pythinker_code/web/static/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  508. pythinker_code/web/static/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  509. pythinker_code/web/static/assets/github-light-DAi9KRSo.js +1 -0
  510. pythinker_code/web/static/assets/github-light-default-D7oLnXFd.js +1 -0
  511. pythinker_code/web/static/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  512. pythinker_code/web/static/assets/gleam-BspZqrRM.js +1 -0
  513. pythinker_code/web/static/assets/glimmer-js-Rg0-pVw9.js +1 -0
  514. pythinker_code/web/static/assets/glimmer-ts-U6CK756n.js +1 -0
  515. pythinker_code/web/static/assets/glsl-DplSGwfg.js +1 -0
  516. pythinker_code/web/static/assets/gn-n2N0HUVH.js +1 -0
  517. pythinker_code/web/static/assets/gnuplot-DdkO51Og.js +1 -0
  518. pythinker_code/web/static/assets/go-Dn2_MT6a.js +1 -0
  519. pythinker_code/web/static/assets/graph-8jMJwCqE.js +1 -0
  520. pythinker_code/web/static/assets/graphql-ChdNCCLP.js +1 -0
  521. pythinker_code/web/static/assets/groovy-gcz8RCvz.js +1 -0
  522. pythinker_code/web/static/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  523. pythinker_code/web/static/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  524. pythinker_code/web/static/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  525. pythinker_code/web/static/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  526. pythinker_code/web/static/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  527. pythinker_code/web/static/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  528. pythinker_code/web/static/assets/hack-CaT9iCJl.js +1 -0
  529. pythinker_code/web/static/assets/haml-B8DHNrY2.js +1 -0
  530. pythinker_code/web/static/assets/handlebars-BL8al0AC.js +1 -0
  531. pythinker_code/web/static/assets/haskell-Df6bDoY_.js +1 -0
  532. pythinker_code/web/static/assets/haxe-CzTSHFRz.js +1 -0
  533. pythinker_code/web/static/assets/hcl-BWvSN4gD.js +1 -0
  534. pythinker_code/web/static/assets/hjson-D5-asLiD.js +1 -0
  535. pythinker_code/web/static/assets/hlsl-D3lLCCz7.js +1 -0
  536. pythinker_code/web/static/assets/houston-DnULxvSX.js +1 -0
  537. pythinker_code/web/static/assets/html-GMplVEZG.js +1 -0
  538. pythinker_code/web/static/assets/html-derivative-BFtXZ54Q.js +1 -0
  539. pythinker_code/web/static/assets/http-jrhK8wxY.js +1 -0
  540. pythinker_code/web/static/assets/hurl-irOxFIW8.js +1 -0
  541. pythinker_code/web/static/assets/hxml-Bvhsp5Yf.js +1 -0
  542. pythinker_code/web/static/assets/hy-DFXneXwc.js +1 -0
  543. pythinker_code/web/static/assets/imba-DGztddWO.js +1 -0
  544. pythinker_code/web/static/assets/index-BXrFnzMy.js +153 -0
  545. pythinker_code/web/static/assets/index-BpoLgcEt.js +1 -0
  546. pythinker_code/web/static/assets/index-BrfQJnRD.js +5 -0
  547. pythinker_code/web/static/assets/index-C4gFzubz.js +2 -0
  548. pythinker_code/web/static/assets/index-CzV_vCfu.css +1 -0
  549. pythinker_code/web/static/assets/index-DI2oedCt.js +19 -0
  550. pythinker_code/web/static/assets/infoDiagram-WHAUD3N6-DdxonBf3.js +2 -0
  551. pythinker_code/web/static/assets/ini-BEwlwnbL.js +1 -0
  552. pythinker_code/web/static/assets/init-Gi6I4Gst.js +1 -0
  553. pythinker_code/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  554. pythinker_code/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  555. pythinker_code/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  556. pythinker_code/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  557. pythinker_code/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  558. pythinker_code/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  559. pythinker_code/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  560. pythinker_code/web/static/assets/java-CylS5w8V.js +1 -0
  561. pythinker_code/web/static/assets/javascript-wDzz0qaB.js +1 -0
  562. pythinker_code/web/static/assets/jinja-4LBKfQ-Z.js +1 -0
  563. pythinker_code/web/static/assets/jison-wvAkD_A8.js +1 -0
  564. pythinker_code/web/static/assets/journeyDiagram-XKPGCS4Q-BXf4aQei.js +139 -0
  565. pythinker_code/web/static/assets/json-Cp-IABpG.js +1 -0
  566. pythinker_code/web/static/assets/json5-C9tS-k6U.js +1 -0
  567. pythinker_code/web/static/assets/jsonc-Des-eS-w.js +1 -0
  568. pythinker_code/web/static/assets/jsonl-DcaNXYhu.js +1 -0
  569. pythinker_code/web/static/assets/jsonnet-DFQXde-d.js +1 -0
  570. pythinker_code/web/static/assets/jssm-C2t-YnRu.js +1 -0
  571. pythinker_code/web/static/assets/jsx-g9-lgVsj.js +1 -0
  572. pythinker_code/web/static/assets/julia-CxzCAyBv.js +1 -0
  573. pythinker_code/web/static/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  574. pythinker_code/web/static/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  575. pythinker_code/web/static/assets/kanagawa-wave-DWedfzmr.js +1 -0
  576. pythinker_code/web/static/assets/kanban-definition-3W4ZIXB7-DLpPPOu8.js +89 -0
  577. pythinker_code/web/static/assets/katex-D2lIc1rk.css +1 -0
  578. pythinker_code/web/static/assets/kdl-DV7GczEv.js +1 -0
  579. pythinker_code/web/static/assets/kotlin-BdnUsdx6.js +1 -0
  580. pythinker_code/web/static/assets/kusto-DZf3V79B.js +1 -0
  581. pythinker_code/web/static/assets/laserwave-DUszq2jm.js +1 -0
  582. pythinker_code/web/static/assets/latex-B4uzh10-.js +1 -0
  583. pythinker_code/web/static/assets/layout-DH73UoAH.js +1 -0
  584. pythinker_code/web/static/assets/lean-BZvkOJ9d.js +1 -0
  585. pythinker_code/web/static/assets/less-B1dDrJ26.js +1 -0
  586. pythinker_code/web/static/assets/light-plus-B7mTdjB0.js +1 -0
  587. pythinker_code/web/static/assets/linear-bAer2-sK.js +1 -0
  588. pythinker_code/web/static/assets/liquid-DYVedYrR.js +1 -0
  589. pythinker_code/web/static/assets/llvm-BtvRca6l.js +1 -0
  590. pythinker_code/web/static/assets/log-2UxHyX5q.js +1 -0
  591. pythinker_code/web/static/assets/logo-BtOb2qkB.js +1 -0
  592. pythinker_code/web/static/assets/lua-BbnMAYS6.js +1 -0
  593. pythinker_code/web/static/assets/luau-C-HG3fhB.js +1 -0
  594. pythinker_code/web/static/assets/make-CHLpvVh8.js +1 -0
  595. pythinker_code/web/static/assets/markdown-Cvjx9yec.js +1 -0
  596. pythinker_code/web/static/assets/marko-DZsq8hO1.js +1 -0
  597. pythinker_code/web/static/assets/material-theme-D5KoaKCx.js +1 -0
  598. pythinker_code/web/static/assets/material-theme-darker-BfHTSMKl.js +1 -0
  599. pythinker_code/web/static/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  600. pythinker_code/web/static/assets/material-theme-ocean-CyktbL80.js +1 -0
  601. pythinker_code/web/static/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  602. pythinker_code/web/static/assets/matlab-D7o27uSR.js +1 -0
  603. pythinker_code/web/static/assets/mdc-DUICxH0z.js +1 -0
  604. pythinker_code/web/static/assets/mdx-Cmh6b_Ma.js +1 -0
  605. pythinker_code/web/static/assets/mermaid-VLURNSYL-B2P5VJ9v.css +1 -0
  606. pythinker_code/web/static/assets/mermaid-VLURNSYL-CuqbwKXv.js +465 -0
  607. pythinker_code/web/static/assets/mermaid-mWjccvbQ.js +1 -0
  608. pythinker_code/web/static/assets/mermaid.core-Nx-rTKiV.js +191 -0
  609. pythinker_code/web/static/assets/min-DbfD8Ywu.js +1 -0
  610. pythinker_code/web/static/assets/min-dark-CafNBF8u.js +1 -0
  611. pythinker_code/web/static/assets/min-light-CTRr51gU.js +1 -0
  612. pythinker_code/web/static/assets/mindmap-definition-VGOIOE7T-C6l761Ue.js +68 -0
  613. pythinker_code/web/static/assets/mipsasm-CKIfxQSi.js +1 -0
  614. pythinker_code/web/static/assets/mojo-B93PlW-d.js +1 -0
  615. pythinker_code/web/static/assets/monokai-D4h5O-jR.js +1 -0
  616. pythinker_code/web/static/assets/moonbit-Ba13S78F.js +1 -0
  617. pythinker_code/web/static/assets/move-Bu9oaDYs.js +1 -0
  618. pythinker_code/web/static/assets/narrat-DRg8JJMk.js +1 -0
  619. pythinker_code/web/static/assets/nextflow-BrzmwbiE.js +1 -0
  620. pythinker_code/web/static/assets/nginx-DknmC5AR.js +1 -0
  621. pythinker_code/web/static/assets/night-owl-C39BiMTA.js +1 -0
  622. pythinker_code/web/static/assets/nim-CVrawwO9.js +1 -0
  623. pythinker_code/web/static/assets/nix-CwoSXNpI.js +1 -0
  624. pythinker_code/web/static/assets/nord-Ddv68eIx.js +1 -0
  625. pythinker_code/web/static/assets/nushell-C-sUppwS.js +1 -0
  626. pythinker_code/web/static/assets/objective-c-DXmwc3jG.js +1 -0
  627. pythinker_code/web/static/assets/objective-cpp-CLxacb5B.js +1 -0
  628. pythinker_code/web/static/assets/ocaml-C0hk2d4L.js +1 -0
  629. pythinker_code/web/static/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  630. pythinker_code/web/static/assets/one-light-PoHY5YXO.js +1 -0
  631. pythinker_code/web/static/assets/openscad-C4EeE6gA.js +1 -0
  632. pythinker_code/web/static/assets/ordinal-Cboi1Yqb.js +1 -0
  633. pythinker_code/web/static/assets/pascal-D93ZcfNL.js +1 -0
  634. pythinker_code/web/static/assets/perl-C0TMdlhV.js +1 -0
  635. pythinker_code/web/static/assets/php-CDn_0X-4.js +1 -0
  636. pythinker_code/web/static/assets/pieDiagram-ADFJNKIX-fNg41mT9.js +30 -0
  637. pythinker_code/web/static/assets/pkl-u5AG7uiY.js +1 -0
  638. pythinker_code/web/static/assets/plastic-3e1v2bzS.js +1 -0
  639. pythinker_code/web/static/assets/plsql-ChMvpjG-.js +1 -0
  640. pythinker_code/web/static/assets/po-BTJTHyun.js +1 -0
  641. pythinker_code/web/static/assets/poimandres-CS3Unz2-.js +1 -0
  642. pythinker_code/web/static/assets/polar-C0HS_06l.js +1 -0
  643. pythinker_code/web/static/assets/postcss-CXtECtnM.js +1 -0
  644. pythinker_code/web/static/assets/powerquery-CEu0bR-o.js +1 -0
  645. pythinker_code/web/static/assets/powershell-Dpen1YoG.js +1 -0
  646. pythinker_code/web/static/assets/prisma-Dd19v3D-.js +1 -0
  647. pythinker_code/web/static/assets/prolog-CbFg5uaA.js +1 -0
  648. pythinker_code/web/static/assets/proto-C7zT0LnQ.js +1 -0
  649. pythinker_code/web/static/assets/pug-CGlum2m_.js +1 -0
  650. pythinker_code/web/static/assets/puppet-BMWR74SV.js +1 -0
  651. pythinker_code/web/static/assets/purescript-CklMAg4u.js +1 -0
  652. pythinker_code/web/static/assets/python-B6aJPvgy.js +1 -0
  653. pythinker_code/web/static/assets/qml-3beO22l8.js +1 -0
  654. pythinker_code/web/static/assets/qmldir-C8lEn-DE.js +1 -0
  655. pythinker_code/web/static/assets/qss-IeuSbFQv.js +1 -0
  656. pythinker_code/web/static/assets/quadrantDiagram-AYHSOK5B-DJz3Kx87.js +7 -0
  657. pythinker_code/web/static/assets/r-Dspwwk_N.js +1 -0
  658. pythinker_code/web/static/assets/racket-BqYA7rlc.js +1 -0
  659. pythinker_code/web/static/assets/raku-DXvB9xmW.js +1 -0
  660. pythinker_code/web/static/assets/razor-C1TweQQi.js +1 -0
  661. pythinker_code/web/static/assets/red-bN70gL4F.js +1 -0
  662. pythinker_code/web/static/assets/reg-C-SQnVFl.js +1 -0
  663. pythinker_code/web/static/assets/regexp-CDVJQ6XC.js +1 -0
  664. pythinker_code/web/static/assets/rel-C3B-1QV4.js +1 -0
  665. pythinker_code/web/static/assets/requirementDiagram-UZGBJVZJ-B4SbrfE9.js +64 -0
  666. pythinker_code/web/static/assets/riscv-BM1_JUlF.js +1 -0
  667. pythinker_code/web/static/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  668. pythinker_code/web/static/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  669. pythinker_code/web/static/assets/rose-pine-qdsjHGoJ.js +1 -0
  670. pythinker_code/web/static/assets/rosmsg-BJDFO7_C.js +1 -0
  671. pythinker_code/web/static/assets/rst-B0xPkSld.js +1 -0
  672. pythinker_code/web/static/assets/ruby-BvKwtOVI.js +1 -0
  673. pythinker_code/web/static/assets/rust-B1yitclQ.js +1 -0
  674. pythinker_code/web/static/assets/sankeyDiagram-TZEHDZUN-CoSUjLAG.js +10 -0
  675. pythinker_code/web/static/assets/sas-cz2c8ADy.js +1 -0
  676. pythinker_code/web/static/assets/sass-Cj5Yp3dK.js +1 -0
  677. pythinker_code/web/static/assets/scala-C151Ov-r.js +1 -0
  678. pythinker_code/web/static/assets/scheme-C98Dy4si.js +1 -0
  679. pythinker_code/web/static/assets/scss-OYdSNvt2.js +1 -0
  680. pythinker_code/web/static/assets/sdbl-DVxCFoDh.js +1 -0
  681. pythinker_code/web/static/assets/sequenceDiagram-WL72ISMW-PjhBNHi3.js +145 -0
  682. pythinker_code/web/static/assets/shaderlab-Dg9Lc6iA.js +1 -0
  683. pythinker_code/web/static/assets/shellscript-Yzrsuije.js +1 -0
  684. pythinker_code/web/static/assets/shellsession-BADoaaVG.js +1 -0
  685. pythinker_code/web/static/assets/slack-dark-BthQWCQV.js +1 -0
  686. pythinker_code/web/static/assets/slack-ochin-DqwNpetd.js +1 -0
  687. pythinker_code/web/static/assets/smalltalk-BERRCDM3.js +1 -0
  688. pythinker_code/web/static/assets/snazzy-light-Bw305WKR.js +1 -0
  689. pythinker_code/web/static/assets/solarized-dark-DXbdFlpD.js +1 -0
  690. pythinker_code/web/static/assets/solarized-light-L9t79GZl.js +1 -0
  691. pythinker_code/web/static/assets/solidity-rGO070M0.js +1 -0
  692. pythinker_code/web/static/assets/soy-Brmx7dQM.js +1 -0
  693. pythinker_code/web/static/assets/sparql-rVzFXLq3.js +1 -0
  694. pythinker_code/web/static/assets/splunk-BtCnVYZw.js +1 -0
  695. pythinker_code/web/static/assets/sql-BLtJtn59.js +1 -0
  696. pythinker_code/web/static/assets/ssh-config-_ykCGR6B.js +1 -0
  697. pythinker_code/web/static/assets/stata-BH5u7GGu.js +1 -0
  698. pythinker_code/web/static/assets/stateDiagram-FKZM4ZOC-DOwESt8-.js +1 -0
  699. pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-yl3OHWiP.js +1 -0
  700. pythinker_code/web/static/assets/stylus-BEDo0Tqx.js +1 -0
  701. pythinker_code/web/static/assets/svelte-zxCyuUbr.js +1 -0
  702. pythinker_code/web/static/assets/swift-Dg5xB15N.js +1 -0
  703. pythinker_code/web/static/assets/synthwave-84-CbfX1IO0.js +1 -0
  704. pythinker_code/web/static/assets/system-verilog-CnnmHF94.js +1 -0
  705. pythinker_code/web/static/assets/systemd-4A_iFExJ.js +1 -0
  706. pythinker_code/web/static/assets/talonscript-CkByrt1z.js +1 -0
  707. pythinker_code/web/static/assets/tasl-QIJgUcNo.js +1 -0
  708. pythinker_code/web/static/assets/tcl-dwOrl1Do.js +1 -0
  709. pythinker_code/web/static/assets/templ-W15q3VgB.js +1 -0
  710. pythinker_code/web/static/assets/terraform-BETggiCN.js +1 -0
  711. pythinker_code/web/static/assets/tex-CvyZ59Mk.js +1 -0
  712. pythinker_code/web/static/assets/timeline-definition-IT6M3QCI-CkCLnAgi.js +61 -0
  713. pythinker_code/web/static/assets/tokyo-night-hegEt444.js +1 -0
  714. pythinker_code/web/static/assets/toml-vGWfd6FD.js +1 -0
  715. pythinker_code/web/static/assets/treemap-KMMF4GRG-CZS5XwTf.js +128 -0
  716. pythinker_code/web/static/assets/ts-tags-zn1MmPIZ.js +1 -0
  717. pythinker_code/web/static/assets/tsv-B_m7g4N7.js +1 -0
  718. pythinker_code/web/static/assets/tsx-COt5Ahok.js +1 -0
  719. pythinker_code/web/static/assets/turtle-BsS91CYL.js +1 -0
  720. pythinker_code/web/static/assets/twig-CO9l9SDP.js +1 -0
  721. pythinker_code/web/static/assets/typescript-BPQ3VLAy.js +1 -0
  722. pythinker_code/web/static/assets/typespec-BGHnOYBU.js +1 -0
  723. pythinker_code/web/static/assets/typst-DHCkPAjA.js +1 -0
  724. pythinker_code/web/static/assets/v-BcVCzyr7.js +1 -0
  725. pythinker_code/web/static/assets/vala-CsfeWuGM.js +1 -0
  726. pythinker_code/web/static/assets/vb-D17OF-Vu.js +1 -0
  727. pythinker_code/web/static/assets/verilog-BQ8w6xss.js +1 -0
  728. pythinker_code/web/static/assets/vesper-DU1UobuO.js +1 -0
  729. pythinker_code/web/static/assets/vhdl-CeAyd5Ju.js +1 -0
  730. pythinker_code/web/static/assets/viml-CJc9bBzg.js +1 -0
  731. pythinker_code/web/static/assets/vitesse-black-Bkuqu6BP.js +1 -0
  732. pythinker_code/web/static/assets/vitesse-dark-D0r3Knsf.js +1 -0
  733. pythinker_code/web/static/assets/vitesse-light-CVO1_9PV.js +1 -0
  734. pythinker_code/web/static/assets/vue-DN_0RTcg.js +1 -0
  735. pythinker_code/web/static/assets/vue-html-AaS7Mt5G.js +1 -0
  736. pythinker_code/web/static/assets/vue-vine-CQOfvN7w.js +1 -0
  737. pythinker_code/web/static/assets/vyper-CDx5xZoG.js +1 -0
  738. pythinker_code/web/static/assets/wasm-CG6Dc4jp.js +1 -0
  739. pythinker_code/web/static/assets/wasm-MzD3tlZU.js +1 -0
  740. pythinker_code/web/static/assets/wenyan-BV7otONQ.js +1 -0
  741. pythinker_code/web/static/assets/wgsl-Dx-B1_4e.js +1 -0
  742. pythinker_code/web/static/assets/wikitext-BhOHFoWU.js +1 -0
  743. pythinker_code/web/static/assets/wit-5i3qLPDT.js +1 -0
  744. pythinker_code/web/static/assets/wolfram-lXgVvXCa.js +1 -0
  745. pythinker_code/web/static/assets/xml-sdJ4AIDG.js +1 -0
  746. pythinker_code/web/static/assets/xsl-CtQFsRM5.js +1 -0
  747. pythinker_code/web/static/assets/xychartDiagram-PRI3JC2R-DkqqHNLh.js +7 -0
  748. pythinker_code/web/static/assets/yaml-Buea-lGh.js +1 -0
  749. pythinker_code/web/static/assets/zenscript-DVFEvuxE.js +1 -0
  750. pythinker_code/web/static/assets/zig-VOosw3JB.js +1 -0
  751. pythinker_code/web/static/brand/apple-touch-icon.png +0 -0
  752. pythinker_code/web/static/brand/arctecture.webp +0 -0
  753. pythinker_code/web/static/brand/bimi-logo.svg +46 -0
  754. pythinker_code/web/static/brand/favicon.ico +0 -0
  755. pythinker_code/web/static/brand/fonts/dm-sans-latin-ext.woff2 +0 -0
  756. pythinker_code/web/static/brand/fonts/dm-sans-latin.woff2 +0 -0
  757. pythinker_code/web/static/brand/fonts/instrument-sans-latin-ext.woff2 +0 -0
  758. pythinker_code/web/static/brand/fonts/instrument-sans-latin.woff2 +0 -0
  759. pythinker_code/web/static/brand/fonts/instrument-serif-latin-ext.woff2 +0 -0
  760. pythinker_code/web/static/brand/fonts/instrument-serif-latin.woff2 +0 -0
  761. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin-ext.woff2 +0 -0
  762. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin.woff2 +0 -0
  763. pythinker_code/web/static/brand/fonts/libre-baskerville-latin-ext.woff2 +0 -0
  764. pythinker_code/web/static/brand/fonts/libre-baskerville-latin.woff2 +0 -0
  765. pythinker_code/web/static/brand/fonts/roboto-latin-ext.woff2 +0 -0
  766. pythinker_code/web/static/brand/fonts/roboto-latin.woff2 +0 -0
  767. pythinker_code/web/static/brand/icon-192.png +0 -0
  768. pythinker_code/web/static/brand/icon-512.png +0 -0
  769. pythinker_code/web/static/brand/icon.svg +1 -0
  770. pythinker_code/web/static/brand/logo.png +0 -0
  771. pythinker_code/web/static/brand/pythinker_animated.svg +79 -0
  772. pythinker_code/web/static/brand/robots.txt +4 -0
  773. pythinker_code/web/static/index.html +15 -0
  774. pythinker_code/web/static/logo.png +0 -0
  775. pythinker_code/web/store/__init__.py +1 -0
  776. pythinker_code/web/store/sessions.py +433 -0
  777. pythinker_code/wire/__init__.py +148 -0
  778. pythinker_code/wire/file.py +151 -0
  779. pythinker_code/wire/jsonrpc.py +263 -0
  780. pythinker_code/wire/protocol.py +2 -0
  781. pythinker_code/wire/root_hub.py +27 -0
  782. pythinker_code/wire/serde.py +26 -0
  783. pythinker_code/wire/server.py +1072 -0
  784. pythinker_code/wire/types.py +698 -0
  785. pythinker_code-0.8.0.dist-info/METADATA +706 -0
  786. pythinker_code-0.8.0.dist-info/RECORD +790 -0
  787. pythinker_code-0.8.0.dist-info/WHEEL +4 -0
  788. pythinker_code-0.8.0.dist-info/entry_points.txt +4 -0
  789. pythinker_code-0.8.0.dist-info/licenses/LICENSE +202 -0
  790. pythinker_code-0.8.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,1256 @@
1
+ """Sessions API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import mimetypes
8
+ import os
9
+ import shutil
10
+ import time
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+ from urllib.parse import quote
15
+ from uuid import UUID, uuid4
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
18
+ from fastapi.responses import FileResponse, Response
19
+ from pydantic import BaseModel, Field
20
+ from pythinker_host.path import HostPath
21
+ from starlette.websockets import WebSocket, WebSocketDisconnect
22
+
23
+ from pythinker_code.metadata import load_metadata, save_metadata
24
+ from pythinker_code.session import Session as PythinkerCLISession
25
+ from pythinker_code.utils.logging import logger
26
+ from pythinker_code.utils.subprocess_env import get_clean_env
27
+ from pythinker_code.web.auth import is_origin_allowed, is_private_ip, verify_token
28
+ from pythinker_code.web.models import (
29
+ GenerateTitleRequest,
30
+ GenerateTitleResponse,
31
+ GitDiffStats,
32
+ GitFileDiff,
33
+ Session,
34
+ SessionStatus,
35
+ UpdateSessionRequest,
36
+ )
37
+ from pythinker_code.web.runner.messages import new_session_status_message, send_history_complete
38
+ from pythinker_code.web.runner.process import PythinkerCLIRunner
39
+ from pythinker_code.web.store.sessions import (
40
+ JointSession,
41
+ invalidate_sessions_cache,
42
+ load_session_by_id,
43
+ load_sessions_page,
44
+ run_auto_archive,
45
+ )
46
+ from pythinker_code.wire.jsonrpc import (
47
+ ErrorCodes,
48
+ JSONRPCErrorObject,
49
+ JSONRPCErrorResponse,
50
+ JSONRPCInMessageAdapter,
51
+ JSONRPCPromptMessage,
52
+ )
53
+ from pythinker_code.wire.serde import deserialize_wire_message
54
+ from pythinker_code.wire.types import is_request
55
+
56
+ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
57
+ work_dirs_router = APIRouter(prefix="/api/work-dirs", tags=["work-dirs"])
58
+
59
+ # Constants
60
+ MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
61
+ DEFAULT_MAX_PUBLIC_PATH_DEPTH = 6
62
+ SENSITIVE_PATH_PARTS = {
63
+ "id_rsa",
64
+ "id_ed25519",
65
+ "known_hosts",
66
+ "credentials",
67
+ ".aws",
68
+ ".ssh",
69
+ ".gnupg",
70
+ ".kube",
71
+ ".npmrc",
72
+ ".pypirc",
73
+ ".netrc",
74
+ }
75
+ SENSITIVE_PATH_EXTENSIONS = {
76
+ ".pem",
77
+ ".key",
78
+ ".p12",
79
+ ".pfx",
80
+ ".kdbx",
81
+ ".der",
82
+ }
83
+ # Home directory patterns to detect if resolved path escapes to sensitive locations
84
+ SENSITIVE_HOME_PATHS = {
85
+ ".ssh",
86
+ ".gnupg",
87
+ ".aws",
88
+ ".kube",
89
+ }
90
+
91
+
92
+ def sanitize_filename(filename: str) -> str:
93
+ """Remove potentially dangerous characters from filename."""
94
+ # Keep only alphanumeric, dots, underscores, hyphens, and spaces
95
+ safe = "".join(c for c in filename if c.isalnum() or c in "._- ")
96
+ return safe.strip() or "unnamed"
97
+
98
+
99
+ def get_runner(req: Request) -> PythinkerCLIRunner:
100
+ """Get the PythinkerCLIRunner from the FastAPI app state."""
101
+ return req.app.state.runner
102
+
103
+
104
+ def get_runner_ws(ws: WebSocket) -> PythinkerCLIRunner:
105
+ """Get the PythinkerCLIRunner from the FastAPI app state (for WebSocket routes)."""
106
+ return ws.app.state.runner
107
+
108
+
109
+ def get_editable_session(
110
+ session_id: UUID,
111
+ runner: PythinkerCLIRunner,
112
+ ) -> JointSession:
113
+ """Get a session and verify it's not busy."""
114
+ session = load_session_by_id(session_id)
115
+ if session is None:
116
+ raise HTTPException(
117
+ status_code=status.HTTP_404_NOT_FOUND,
118
+ detail="Session not found",
119
+ )
120
+ # Check if session is busy
121
+ session_process = runner.get_session(session_id)
122
+ if session_process and session_process.is_busy:
123
+ raise HTTPException(
124
+ status_code=status.HTTP_400_BAD_REQUEST,
125
+ detail="Session is busy. Please wait for it to complete before modifying.",
126
+ )
127
+ return session
128
+
129
+
130
+ def _relative_parts(path: Path) -> list[str]:
131
+ return [part for part in path.parts if part not in {"", "."}]
132
+
133
+
134
+ def _is_sensitive_relative_path(rel_path: Path) -> bool:
135
+ parts = _relative_parts(rel_path)
136
+ for part in parts:
137
+ if part.startswith("."):
138
+ return True
139
+ if part.lower() in SENSITIVE_PATH_PARTS:
140
+ return True
141
+ return rel_path.suffix.lower() in SENSITIVE_PATH_EXTENSIONS
142
+
143
+
144
+ def _contains_symlink(path: Path, base: Path) -> bool:
145
+ """Check if any component of the path (relative to base) is a symlink."""
146
+ try:
147
+ current = base
148
+ rel_parts = path.relative_to(base).parts
149
+ for part in rel_parts:
150
+ current = current / part
151
+ if current.is_symlink():
152
+ return True
153
+ except (ValueError, OSError):
154
+ return True
155
+ return False
156
+
157
+
158
+ def _is_path_in_sensitive_location(path: Path) -> bool:
159
+ """Check if resolved path points to a sensitive location (e.g., ~/.ssh, ~/.aws)."""
160
+ try:
161
+ home = Path.home()
162
+ if path.is_relative_to(home):
163
+ rel_to_home = path.relative_to(home)
164
+ first_part = rel_to_home.parts[0] if rel_to_home.parts else ""
165
+ if first_part in SENSITIVE_HOME_PATHS:
166
+ return True
167
+ except (ValueError, RuntimeError):
168
+ pass
169
+ return False
170
+
171
+
172
+ def _ensure_public_file_access_allowed(
173
+ rel_path: Path,
174
+ restrict_sensitive_apis: bool,
175
+ max_path_depth: int = DEFAULT_MAX_PUBLIC_PATH_DEPTH,
176
+ ) -> None:
177
+ if not restrict_sensitive_apis:
178
+ return
179
+ rel_parts = _relative_parts(rel_path)
180
+ if len(rel_parts) > max_path_depth:
181
+ raise HTTPException(
182
+ status_code=status.HTTP_403_FORBIDDEN,
183
+ detail=f"Path too deep for public access "
184
+ f"(max depth: {max_path_depth}, current: {len(rel_parts)}).",
185
+ )
186
+ if _is_sensitive_relative_path(rel_path):
187
+ raise HTTPException(
188
+ status_code=status.HTTP_403_FORBIDDEN,
189
+ detail="Access to sensitive files is disabled.",
190
+ )
191
+
192
+
193
+ def _read_wire_lines(wire_file: Path) -> list[str]:
194
+ """Read and parse wire.jsonl into JSONRPC event strings (runs in thread)."""
195
+ result: list[str] = []
196
+ with open(wire_file, encoding="utf-8") as f:
197
+ for line in f:
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ try:
202
+ record = json.loads(line)
203
+ if not isinstance(record, dict):
204
+ continue
205
+ record = cast(dict[str, Any], record)
206
+ record_type = record.get("type")
207
+ if isinstance(record_type, str) and record_type == "metadata":
208
+ continue
209
+ message_raw = record.get("message")
210
+ if not isinstance(message_raw, dict):
211
+ continue
212
+ message_raw = cast(dict[str, Any], message_raw)
213
+ message = deserialize_wire_message(message_raw)
214
+ _is_req = is_request(message)
215
+ event_msg: dict[str, Any] = {
216
+ "jsonrpc": "2.0",
217
+ "method": "request" if _is_req else "event",
218
+ "params": message_raw,
219
+ }
220
+ if _is_req:
221
+ # JSON-RPC requests require a top-level ``id`` so the
222
+ # client can correlate its response. Use the request's
223
+ # own ``id`` field (e.g. ApprovalRequest.id,
224
+ # QuestionRequest.id). Note: ``message_raw`` wraps data
225
+ # as ``{"type": ..., "payload": {...}}`` so the id lives
226
+ # on the deserialized object, not at the raw dict top level.
227
+ event_msg["id"] = message.id
228
+ result.append(json.dumps(event_msg, ensure_ascii=False))
229
+ except (json.JSONDecodeError, KeyError, ValueError, TypeError):
230
+ continue
231
+ return result
232
+
233
+
234
+ async def replay_history(ws: WebSocket, session_dir: Path) -> None:
235
+ """Replay historical wire messages from wire.jsonl to a WebSocket."""
236
+ wire_file = session_dir / "wire.jsonl"
237
+ if not await asyncio.to_thread(wire_file.exists):
238
+ return
239
+
240
+ try:
241
+ lines = await asyncio.to_thread(_read_wire_lines, wire_file)
242
+ for event_text in lines:
243
+ await ws.send_text(event_text)
244
+ except Exception:
245
+ logger.debug("WebSocket wire replay failed", exc_info=True)
246
+
247
+
248
+ @router.get("/", summary="List all sessions")
249
+ async def list_sessions(
250
+ runner: PythinkerCLIRunner = Depends(get_runner),
251
+ limit: int = 100,
252
+ offset: int = 0,
253
+ q: str | None = None,
254
+ archived: bool | None = None,
255
+ ) -> list[Session]:
256
+ """List sessions with optional pagination and search.
257
+
258
+ Args:
259
+ limit: Maximum number of sessions to return (default 100, max 500).
260
+ offset: Number of sessions to skip (default 0).
261
+ q: Optional search query to filter by title or work_dir.
262
+ archived: Filter by archived status.
263
+ - None (default): Only return non-archived sessions.
264
+ - True: Only return archived sessions.
265
+ """
266
+ if limit <= 0:
267
+ limit = 100
268
+ if limit > 500:
269
+ limit = 500
270
+ if offset < 0:
271
+ offset = 0
272
+
273
+ # Run auto-archive in background (throttled internally, runs at most once per 5 minutes)
274
+ await asyncio.to_thread(run_auto_archive)
275
+
276
+ sessions = load_sessions_page(limit=limit, offset=offset, query=q, archived=archived)
277
+ for session in sessions:
278
+ session_process = runner.get_session(session.session_id)
279
+ session.is_running = session_process is not None and session_process.is_running
280
+ session.status = session_process.status if session_process else None
281
+ return cast(list[Session], sessions)
282
+
283
+
284
+ @router.get("/{session_id}", summary="Get session")
285
+ async def get_session(
286
+ session_id: UUID,
287
+ runner: PythinkerCLIRunner = Depends(get_runner),
288
+ ) -> Session | None:
289
+ """Get a session by ID."""
290
+ session = load_session_by_id(session_id)
291
+ if session is not None:
292
+ session_process = runner.get_session(session_id)
293
+ session.is_running = session_process is not None and session_process.is_running
294
+ session.status = session_process.status if session_process else None
295
+ return session
296
+
297
+
298
+ def _ensure_session_work_dir_allowed(work_dir_path: Path, request: Request) -> None:
299
+ if not getattr(request.app.state, "restrict_sensitive_apis", False):
300
+ return
301
+ startup_dir = getattr(request.app.state, "startup_dir", None)
302
+ if not startup_dir:
303
+ raise HTTPException(
304
+ status_code=status.HTTP_403_FORBIDDEN,
305
+ detail="Session work_dir is restricted in public mode.",
306
+ )
307
+ allowed_root = Path(str(startup_dir)).expanduser().resolve()
308
+ if not work_dir_path.is_relative_to(allowed_root):
309
+ raise HTTPException(
310
+ status_code=status.HTTP_403_FORBIDDEN,
311
+ detail="Session work_dir must stay within the public workspace.",
312
+ )
313
+
314
+
315
+ @router.post("/", summary="Create a new session")
316
+ async def create_session(
317
+ http_request: Request, request: CreateSessionRequest | None = None
318
+ ) -> Session:
319
+ """Create a new session."""
320
+ # Use provided work_dir or default to user's home directory
321
+ if request and request.work_dir:
322
+ work_dir_path = Path(request.work_dir).expanduser().resolve()
323
+ _ensure_session_work_dir_allowed(work_dir_path, http_request)
324
+ # Validate the directory exists
325
+ if not work_dir_path.exists():
326
+ if request.create_dir:
327
+ # Auto-create the directory
328
+ try:
329
+ work_dir_path.mkdir(parents=True, exist_ok=True)
330
+ except PermissionError as e:
331
+ raise HTTPException(
332
+ status_code=status.HTTP_403_FORBIDDEN,
333
+ detail=f"Permission denied: cannot create directory {request.work_dir}",
334
+ ) from e
335
+ except OSError as e:
336
+ raise HTTPException(
337
+ status_code=status.HTTP_400_BAD_REQUEST,
338
+ detail=f"Failed to create directory: {e}",
339
+ ) from e
340
+ else:
341
+ # Return 404 to indicate directory does not exist
342
+ raise HTTPException(
343
+ status_code=status.HTTP_404_NOT_FOUND,
344
+ detail=f"Directory does not exist: {request.work_dir}",
345
+ )
346
+ # Re-resolve after any creation/existence checks so the session uses the
347
+ # final real path, and re-apply public-mode containment after potential
348
+ # filesystem changes.
349
+ work_dir_path = work_dir_path.resolve()
350
+ _ensure_session_work_dir_allowed(work_dir_path, http_request)
351
+ if not work_dir_path.is_dir():
352
+ raise HTTPException(
353
+ status_code=status.HTTP_400_BAD_REQUEST,
354
+ detail=f"Path is not a directory: {request.work_dir}",
355
+ )
356
+ work_dir = HostPath.unsafe_from_local_path(work_dir_path)
357
+ else:
358
+ work_dir_path = Path.home().resolve()
359
+ _ensure_session_work_dir_allowed(work_dir_path, http_request)
360
+ work_dir = HostPath.unsafe_from_local_path(work_dir_path)
361
+ pythinker_code_session = await PythinkerCLISession.create(work_dir=work_dir)
362
+ context_file = pythinker_code_session.dir / "context.jsonl"
363
+ invalidate_sessions_cache()
364
+ invalidate_work_dirs_cache()
365
+ return Session(
366
+ session_id=UUID(pythinker_code_session.id),
367
+ title=pythinker_code_session.title,
368
+ last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),
369
+ is_running=False,
370
+ status=SessionStatus(
371
+ session_id=UUID(pythinker_code_session.id),
372
+ state="stopped",
373
+ seq=0,
374
+ worker_id=None,
375
+ reason=None,
376
+ detail=None,
377
+ updated_at=datetime.now(UTC),
378
+ ),
379
+ work_dir=str(work_dir),
380
+ session_dir=str(pythinker_code_session.dir),
381
+ )
382
+
383
+
384
+ class CreateSessionRequest(BaseModel):
385
+ """Create session request."""
386
+
387
+ work_dir: str | None = None
388
+ create_dir: bool = False # Whether to auto-create directory if it doesn't exist
389
+
390
+
391
+ class ForkSessionRequest(BaseModel):
392
+ """Fork session request."""
393
+
394
+ turn_index: int = Field(..., ge=0) # 0-based, fork includes this turn and all previous turns
395
+
396
+
397
+ class UploadSessionFileResponse(BaseModel):
398
+ """Upload file response."""
399
+
400
+ path: str
401
+ filename: str
402
+ size: int
403
+
404
+
405
+ @router.post("/{session_id}/files", summary="Upload file to session")
406
+ async def upload_session_file(
407
+ session_id: UUID,
408
+ file: UploadFile,
409
+ runner: PythinkerCLIRunner = Depends(get_runner),
410
+ ) -> UploadSessionFileResponse:
411
+ """Upload a file to a session."""
412
+ session = get_editable_session(session_id, runner)
413
+ session_dir = session.pythinker_code_session.dir
414
+ upload_dir = session_dir / "uploads"
415
+ upload_dir.mkdir(parents=True, exist_ok=True)
416
+
417
+ # Generate safe filename
418
+ file_name = str(uuid4())
419
+ if file.filename:
420
+ safe_name = sanitize_filename(file.filename)
421
+ name, ext = os.path.splitext(safe_name)
422
+ file_name = f"{name}_{file_name[:6]}{ext}"
423
+
424
+ upload_path = upload_dir / file_name
425
+ size = 0
426
+ try:
427
+ with upload_path.open("wb") as out:
428
+ while chunk := await file.read(64 * 1024):
429
+ size += len(chunk)
430
+ if size > MAX_UPLOAD_SIZE:
431
+ upload_path.unlink(missing_ok=True)
432
+ raise HTTPException(
433
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
434
+ detail=f"File too large (max {MAX_UPLOAD_SIZE // 1024 // 1024}MB)",
435
+ )
436
+ out.write(chunk)
437
+ finally:
438
+ await file.close()
439
+
440
+ return UploadSessionFileResponse(
441
+ path=str(upload_path),
442
+ filename=file_name,
443
+ size=size,
444
+ )
445
+
446
+
447
+ @router.get(
448
+ "/{session_id}/uploads/{path:path}",
449
+ summary="Get uploaded file from session uploads",
450
+ )
451
+ async def get_session_upload_file(
452
+ session_id: UUID,
453
+ path: str,
454
+ ) -> Response:
455
+ """Get a file from a session's uploads directory."""
456
+ session = load_session_by_id(session_id)
457
+ if session is None:
458
+ raise HTTPException(
459
+ status_code=status.HTTP_404_NOT_FOUND,
460
+ detail="Session not found",
461
+ )
462
+
463
+ uploads_dir = (session.pythinker_code_session.dir / "uploads").resolve()
464
+ if not uploads_dir.exists():
465
+ raise HTTPException(
466
+ status_code=status.HTTP_404_NOT_FOUND,
467
+ detail="Uploads directory not found",
468
+ )
469
+
470
+ file_path = (uploads_dir / path).resolve()
471
+ if not file_path.is_relative_to(uploads_dir):
472
+ raise HTTPException(
473
+ status_code=status.HTTP_400_BAD_REQUEST,
474
+ detail="Invalid path: path traversal not allowed",
475
+ )
476
+
477
+ if not file_path.exists() or not file_path.is_file():
478
+ raise HTTPException(
479
+ status_code=status.HTTP_404_NOT_FOUND,
480
+ detail="File not found",
481
+ )
482
+
483
+ media_type, _ = mimetypes.guess_type(file_path.name)
484
+ encoded_filename = quote(file_path.name, safe="")
485
+ return FileResponse(
486
+ file_path,
487
+ media_type=media_type or "application/octet-stream",
488
+ headers={
489
+ "Content-Disposition": f"inline; filename*=UTF-8''{encoded_filename}",
490
+ },
491
+ )
492
+
493
+
494
+ @router.get(
495
+ "/{session_id}/files/{path:path}",
496
+ summary="Get file or list directory from session work_dir",
497
+ )
498
+ async def get_session_file(
499
+ session_id: UUID,
500
+ path: str,
501
+ request: Request,
502
+ ) -> Response:
503
+ """Get a file or list directory from session work directory."""
504
+ session = load_session_by_id(session_id)
505
+ if session is None:
506
+ raise HTTPException(
507
+ status_code=status.HTTP_404_NOT_FOUND,
508
+ detail="Session not found",
509
+ )
510
+
511
+ # Security check: prevent path traversal attacks using resolve()
512
+ work_dir = Path(str(session.pythinker_code_session.work_dir)).resolve()
513
+ requested_path = work_dir / path
514
+ file_path = requested_path.resolve()
515
+
516
+ # Check path traversal
517
+ if not file_path.is_relative_to(work_dir):
518
+ raise HTTPException(
519
+ status_code=status.HTTP_400_BAD_REQUEST,
520
+ detail="Invalid path: path traversal not allowed",
521
+ )
522
+
523
+ rel_path = file_path.relative_to(work_dir)
524
+ restrict_sensitive_apis = getattr(request.app.state, "restrict_sensitive_apis", False)
525
+ max_path_depth = (
526
+ getattr(request.app.state, "max_public_path_depth", None) or DEFAULT_MAX_PUBLIC_PATH_DEPTH
527
+ )
528
+
529
+ # Additional security checks when restricting sensitive APIs
530
+ if restrict_sensitive_apis:
531
+ # Check for symlinks in the path
532
+ if _contains_symlink(requested_path, work_dir):
533
+ raise HTTPException(
534
+ status_code=status.HTTP_403_FORBIDDEN,
535
+ detail="Symbolic links are not allowed in public mode.",
536
+ )
537
+
538
+ # Check if resolved path points to sensitive location
539
+ if _is_path_in_sensitive_location(file_path):
540
+ raise HTTPException(
541
+ status_code=status.HTTP_403_FORBIDDEN,
542
+ detail="Access to sensitive system directories is not allowed.",
543
+ )
544
+
545
+ _ensure_public_file_access_allowed(rel_path, restrict_sensitive_apis, max_path_depth)
546
+
547
+ if not file_path.exists():
548
+ raise HTTPException(
549
+ status_code=status.HTTP_404_NOT_FOUND,
550
+ detail="File not found",
551
+ )
552
+
553
+ if file_path.is_dir():
554
+ result: list[dict[str, str | int]] = []
555
+ for subpath in file_path.iterdir():
556
+ if restrict_sensitive_apis:
557
+ rel_subpath = rel_path / subpath.name
558
+ if _is_sensitive_relative_path(rel_subpath):
559
+ continue
560
+ if subpath.is_dir():
561
+ result.append({"name": subpath.name, "type": "directory"})
562
+ else:
563
+ try:
564
+ size = subpath.stat().st_size
565
+ except OSError:
566
+ size = 0
567
+ result.append({"name": subpath.name, "type": "file", "size": size})
568
+ result.sort(key=lambda x: (cast(str, x["type"]), cast(str, x["name"])))
569
+ return Response(content=json.dumps(result), media_type="application/json")
570
+
571
+ media_type, _ = mimetypes.guess_type(file_path.name)
572
+ encoded_filename = quote(file_path.name, safe="")
573
+ return FileResponse(
574
+ file_path,
575
+ media_type=media_type or "application/octet-stream",
576
+ headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
577
+ )
578
+
579
+
580
+ def _update_last_session_id(session: JointSession) -> None:
581
+ """Update last_session_id for the session's work directory."""
582
+ pythinker_session = session.pythinker_code_session
583
+ work_dir = pythinker_session.work_dir
584
+
585
+ metadata = load_metadata()
586
+ work_dir_meta = metadata.get_work_dir_meta(work_dir)
587
+
588
+ if work_dir_meta is None:
589
+ work_dir_meta = metadata.new_work_dir_meta(work_dir)
590
+
591
+ work_dir_meta.last_session_id = pythinker_session.id
592
+ save_metadata(metadata)
593
+
594
+
595
+ @router.delete("/{session_id}", summary="Delete a session")
596
+ async def delete_session(
597
+ session_id: UUID, runner: PythinkerCLIRunner = Depends(get_runner)
598
+ ) -> None:
599
+ """Delete a session."""
600
+ session = get_editable_session(session_id, runner)
601
+ session_process = runner.get_session(session_id)
602
+ if session_process is not None:
603
+ await session_process.stop()
604
+ wd_meta = session.pythinker_code_session.work_dir_meta
605
+ if wd_meta.last_session_id == str(session_id):
606
+ metadata = load_metadata()
607
+ for wd in metadata.work_dirs:
608
+ if wd.path == wd_meta.path:
609
+ wd.last_session_id = None
610
+ break
611
+ save_metadata(metadata)
612
+ session_dir = session.pythinker_code_session.dir
613
+ if session_dir.exists():
614
+ shutil.rmtree(session_dir, ignore_errors=True)
615
+ invalidate_sessions_cache()
616
+
617
+
618
+ @router.patch("/{session_id}", summary="Update session")
619
+ async def update_session(
620
+ session_id: UUID,
621
+ request: UpdateSessionRequest,
622
+ runner: PythinkerCLIRunner = Depends(get_runner),
623
+ ) -> Session:
624
+ """Update a session (e.g., rename title or archive/unarchive)."""
625
+ from pythinker_code.session_state import load_session_state, save_session_state
626
+
627
+ session = get_editable_session(session_id, runner)
628
+ session_dir = session.pythinker_code_session.dir
629
+ state = load_session_state(session_dir)
630
+
631
+ # Update title if provided
632
+ if request.title is not None:
633
+ state.custom_title = request.title
634
+ state.title_generated = True
635
+
636
+ # Update archived status if provided
637
+ if request.archived is not None:
638
+ state.archived = request.archived
639
+ if request.archived:
640
+ state.archived_at = time.time()
641
+ state.auto_archive_exempt = False
642
+ else:
643
+ state.archived_at = None
644
+ state.auto_archive_exempt = True
645
+
646
+ save_session_state(state, session_dir)
647
+
648
+ # Invalidate cache to force reload
649
+ invalidate_sessions_cache()
650
+
651
+ # Return updated session
652
+ updated_session = load_session_by_id(session_id)
653
+ if updated_session is None:
654
+ raise HTTPException(
655
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
656
+ detail="Failed to reload session after update",
657
+ )
658
+ return updated_session
659
+
660
+
661
+ def extract_first_turn_from_wire(session_dir: Path) -> tuple[str, str] | None:
662
+ """Extract the first turn's user message and assistant response from wire.jsonl.
663
+
664
+ Returns:
665
+ tuple[str, str] | None: (user_message, assistant_response) or None if not found
666
+ """
667
+ wire_file = session_dir / "wire.jsonl"
668
+ if not wire_file.exists():
669
+ return None
670
+
671
+ user_message: str | None = None
672
+ assistant_response_parts: list[str] = []
673
+ in_first_turn = False
674
+
675
+ try:
676
+ with open(wire_file, encoding="utf-8") as f:
677
+ for line in f:
678
+ line = line.strip()
679
+ if not line:
680
+ continue
681
+ try:
682
+ record = json.loads(line)
683
+ message = record.get("message", {})
684
+ msg_type = message.get("type")
685
+
686
+ if msg_type == "TurnBegin":
687
+ if in_first_turn:
688
+ # Second turn started, stop
689
+ break
690
+ in_first_turn = True
691
+ user_input = message.get("payload", {}).get("user_input")
692
+ if user_input:
693
+ from pythinker_core.message import Message
694
+
695
+ msg = Message(role="user", content=user_input)
696
+ user_message = msg.extract_text(" ")
697
+
698
+ elif msg_type == "ContentPart" and in_first_turn:
699
+ payload = message.get("payload", {})
700
+ if payload.get("type") == "text" and payload.get("text"):
701
+ assistant_response_parts.append(payload["text"])
702
+
703
+ elif msg_type == "TurnEnd" and in_first_turn:
704
+ break
705
+
706
+ except json.JSONDecodeError:
707
+ continue
708
+ except OSError:
709
+ return None
710
+
711
+ if user_message and assistant_response_parts:
712
+ return (user_message, "".join(assistant_response_parts))
713
+ return None
714
+
715
+
716
+ @router.post("/{session_id}/fork", summary="Fork a session at a specific turn")
717
+ async def fork_session_endpoint(
718
+ session_id: UUID,
719
+ request: ForkSessionRequest,
720
+ runner: PythinkerCLIRunner = Depends(get_runner),
721
+ ) -> Session:
722
+ """Fork a session, creating a new session with history up to the specified turn.
723
+
724
+ The new session shares the same work_dir as the original session.
725
+ """
726
+ from pythinker_code.session_fork import fork_session as do_fork
727
+
728
+ source_session = get_editable_session(session_id, runner)
729
+ source_dir = source_session.pythinker_code_session.dir
730
+ work_dir = source_session.pythinker_code_session.work_dir
731
+
732
+ source_title = source_session.title
733
+
734
+ try:
735
+ new_session_id = await do_fork(
736
+ source_session_dir=source_dir,
737
+ work_dir=work_dir,
738
+ turn_index=request.turn_index,
739
+ title_prefix="Fork",
740
+ source_title=source_title,
741
+ )
742
+ except ValueError as e:
743
+ raise HTTPException(
744
+ status_code=status.HTTP_400_BAD_REQUEST,
745
+ detail=str(e),
746
+ ) from e
747
+
748
+ invalidate_sessions_cache()
749
+ invalidate_work_dirs_cache()
750
+
751
+ from pythinker_code.metadata import load_metadata
752
+ from pythinker_code.session_state import load_session_state
753
+
754
+ metadata = load_metadata()
755
+ work_dir_meta = metadata.get_work_dir_meta(work_dir)
756
+ assert work_dir_meta is not None
757
+ new_session_dir = work_dir_meta.sessions_dir / new_session_id
758
+ new_state = load_session_state(new_session_dir)
759
+ fork_title = new_state.custom_title or f"Fork: {source_title}"
760
+
761
+ context_file = new_session_dir / "context.jsonl"
762
+ return Session(
763
+ session_id=UUID(new_session_id),
764
+ title=fork_title,
765
+ last_updated=datetime.fromtimestamp(context_file.stat().st_mtime, tz=UTC),
766
+ is_running=False,
767
+ status=SessionStatus(
768
+ session_id=UUID(new_session_id),
769
+ state="stopped",
770
+ seq=0,
771
+ worker_id=None,
772
+ reason=None,
773
+ detail=None,
774
+ updated_at=datetime.now(UTC),
775
+ ),
776
+ work_dir=str(work_dir),
777
+ session_dir=str(new_session_dir),
778
+ )
779
+
780
+
781
+ @router.post("/{session_id}/generate-title", summary="Generate session title using AI")
782
+ async def generate_session_title(
783
+ session_id: UUID,
784
+ request: GenerateTitleRequest | None = None,
785
+ runner: PythinkerCLIRunner = Depends(get_runner),
786
+ ) -> GenerateTitleResponse:
787
+ """Generate a concise session title using AI based on the first conversation turn.
788
+
789
+ If request body is empty or parameters are missing, the backend will
790
+ automatically read the first turn from wire.jsonl.
791
+ """
792
+ session = get_editable_session(session_id, runner)
793
+ session_dir = session.pythinker_code_session.dir
794
+
795
+ from pythinker_code.session_state import load_session_state, save_session_state
796
+
797
+ state = load_session_state(session_dir)
798
+
799
+ # Check if title was already generated (avoid duplicate calls)
800
+ if state.title_generated:
801
+ return GenerateTitleResponse(title=state.custom_title or "Untitled")
802
+
803
+ # Get message content: prefer request parameters, otherwise read from wire.jsonl
804
+ user_message = request.user_message if request else None
805
+ assistant_response = request.assistant_response if request else None
806
+
807
+ if not user_message or not assistant_response:
808
+ first_turn = extract_first_turn_from_wire(session_dir)
809
+ if first_turn:
810
+ user_message, assistant_response = first_turn
811
+
812
+ # If still no user message, return default title
813
+ if not user_message:
814
+ return GenerateTitleResponse(title="Untitled")
815
+
816
+ from pythinker_code.utils.string import shorten
817
+
818
+ user_text = user_message.strip()
819
+ user_text = " ".join(user_text.split())
820
+ fallback_title = shorten(user_text, width=50) or "Untitled"
821
+
822
+ # If AI generation failed too many times, use fallback and mark as generated
823
+ if state.title_generate_attempts >= 3:
824
+ fresh = load_session_state(session_dir)
825
+ # Respect a title finalized by another request/user action while we
826
+ # were preparing a fallback.
827
+ if fresh.title_generated:
828
+ invalidate_sessions_cache()
829
+ return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
830
+ fresh.custom_title = fallback_title
831
+ fresh.title_generated = True
832
+ save_session_state(fresh, session_dir)
833
+ invalidate_sessions_cache()
834
+ return GenerateTitleResponse(title=fallback_title)
835
+
836
+ # Try to generate title using AI
837
+ title = fallback_title
838
+ ai_generated = False
839
+ try:
840
+ from pythinker_core import generate
841
+ from pythinker_core.message import Message
842
+
843
+ from pythinker_code.auth.oauth import OAuthManager
844
+ from pythinker_code.config import load_config
845
+ from pythinker_code.llm import create_llm
846
+
847
+ config = load_config()
848
+ model_name = config.default_model
849
+
850
+ if model_name and model_name in config.models:
851
+ model_config = config.models[model_name]
852
+ provider_config = config.providers.get(model_config.provider)
853
+
854
+ if provider_config:
855
+ oauth = OAuthManager(config)
856
+ await oauth.ensure_fresh()
857
+ llm = create_llm(provider_config, model_config, oauth=oauth)
858
+
859
+ if llm:
860
+ system_prompt = (
861
+ "Generate a concise session title (max 50 characters) "
862
+ "based on the conversation. "
863
+ "Only respond with the title text, nothing else. "
864
+ "No quotes, no explanation."
865
+ )
866
+
867
+ prompt = f"""User: {user_message[:300]}
868
+ Assistant: {(assistant_response or "")[:300]}
869
+
870
+ Title:"""
871
+
872
+ result = await generate(
873
+ chat_provider=llm.chat_provider,
874
+ system_prompt=system_prompt,
875
+ tools=[],
876
+ history=[Message(role="user", content=prompt)],
877
+ )
878
+
879
+ generated_title = result.message.extract_text().strip()
880
+ # Remove quotes if present
881
+ generated_title = generated_title.strip("\"'")
882
+
883
+ if generated_title and len(generated_title) <= 50:
884
+ title = generated_title
885
+ ai_generated = True
886
+ elif generated_title:
887
+ title = shorten(generated_title, width=50)
888
+ ai_generated = True
889
+
890
+ except Exception as e:
891
+ logger.warning(f"Failed to generate title using AI: {e}")
892
+ # Keep fallback_title, ai_generated stays False
893
+
894
+ # Read-modify-write: reload fresh state to avoid overwriting
895
+ # worker changes made during the LLM call
896
+ fresh = load_session_state(session_dir)
897
+ # Another request or manual rename may have finalized the title while the
898
+ # LLM call was in flight. Preserve that newer title instead of clobbering it.
899
+ if fresh.title_generated:
900
+ invalidate_sessions_cache()
901
+ return GenerateTitleResponse(title=fresh.custom_title or "Untitled")
902
+ fresh.custom_title = title
903
+ if ai_generated:
904
+ fresh.title_generated = True
905
+ else:
906
+ fresh.title_generate_attempts = fresh.title_generate_attempts + 1
907
+ save_session_state(fresh, session_dir)
908
+
909
+ # Invalidate cache
910
+ invalidate_sessions_cache()
911
+
912
+ return GenerateTitleResponse(title=title)
913
+
914
+
915
+ @router.websocket("/{session_id}/stream")
916
+ async def session_stream(
917
+ session_id: UUID,
918
+ websocket: WebSocket,
919
+ runner: PythinkerCLIRunner = Depends(get_runner_ws),
920
+ ) -> None:
921
+ """WebSocket stream for a session.
922
+
923
+ Flow:
924
+ 1. Accept the WebSocket connection
925
+ 2. If history exists, attach WebSocket in replay mode
926
+ 3. Replay history messages from wire.jsonl
927
+ 4. Start worker if needed
928
+ 5. Flush buffered live messages and send status snapshot
929
+ 6. Forward incoming messages to the subprocess
930
+ 7. Clean up on disconnect
931
+ """
932
+ expected_token = getattr(websocket.app.state, "session_token", None)
933
+ enforce_origin = getattr(websocket.app.state, "enforce_origin", False)
934
+ allowed_origins = getattr(websocket.app.state, "allowed_origins", [])
935
+ lan_only = getattr(websocket.app.state, "lan_only", False)
936
+
937
+ # LAN-only check
938
+ if lan_only:
939
+ client_ip = websocket.client.host if websocket.client else None
940
+ if client_ip and not is_private_ip(client_ip):
941
+ await websocket.close(code=4403, reason="Access denied: LAN only")
942
+ return
943
+
944
+ if enforce_origin:
945
+ origin = websocket.headers.get("origin")
946
+ if origin and not is_origin_allowed(origin, allowed_origins):
947
+ await websocket.close(code=4403, reason="Origin not allowed")
948
+ return
949
+
950
+ if expected_token:
951
+ token = websocket.query_params.get("token")
952
+ if not verify_token(token, expected_token):
953
+ await websocket.close(code=4401, reason="Auth required")
954
+ return
955
+
956
+ await websocket.accept()
957
+
958
+ # Check if session exists
959
+ session = await asyncio.to_thread(load_session_by_id, session_id)
960
+ if session is None:
961
+ await websocket.close(code=4004, reason="Session not found")
962
+ return
963
+
964
+ # Check if session has history
965
+ session_dir = session.pythinker_code_session.dir
966
+ wire_file = session_dir / "wire.jsonl"
967
+ has_history = await asyncio.to_thread(wire_file.exists)
968
+
969
+ session_process = await runner.get_or_create_session(session_id)
970
+ attached = False
971
+ try:
972
+ if has_history:
973
+ # Attach WebSocket in replay mode before history replay
974
+ await session_process.add_websocket_and_begin_replay(websocket)
975
+ attached = True
976
+
977
+ # Replay history
978
+ try:
979
+ await replay_history(websocket, session_dir)
980
+ except Exception as e:
981
+ logger.warning(f"Failed to replay history: {e}")
982
+
983
+ # Check if WebSocket is still connected before continuing
984
+ if not await send_history_complete(websocket):
985
+ logger.debug("WebSocket disconnected during history replay")
986
+ return
987
+
988
+ # Start session environment – if anything fails here, send an error
989
+ # status so the client doesn't hang on "Connecting to environment...".
990
+ try:
991
+ # Ensure work_dir exists
992
+ work_dir = Path(str(session.pythinker_code_session.work_dir))
993
+ await asyncio.to_thread(lambda: work_dir.mkdir(parents=True, exist_ok=True))
994
+
995
+ if not attached:
996
+ # No history: attach and start worker
997
+ session_process = await runner.get_or_create_session(session_id)
998
+ await session_process.add_websocket_and_begin_replay(websocket)
999
+ attached = True
1000
+
1001
+ assert session_process is not None
1002
+ # End replay and start worker
1003
+ await session_process.end_replay(websocket)
1004
+ await session_process.start()
1005
+ await session_process.send_status_snapshot(websocket)
1006
+ except Exception as e:
1007
+ logger.warning(f"Failed to start session environment: {e}")
1008
+ try:
1009
+ error_status = SessionStatus(
1010
+ session_id=session_id,
1011
+ state="error",
1012
+ seq=0,
1013
+ worker_id=None,
1014
+ reason="initialization_failed",
1015
+ detail=str(e),
1016
+ updated_at=datetime.now(UTC),
1017
+ )
1018
+ await websocket.send_text(
1019
+ new_session_status_message(error_status).model_dump_json()
1020
+ )
1021
+ except Exception:
1022
+ pass
1023
+ return
1024
+
1025
+ # Track whether we've updated last_session_id for this connection.
1026
+ # We defer the update until the first prompt message is actually forwarded,
1027
+ # so that merely opening/viewing a session does not change last_session_id.
1028
+ last_session_id_updated = False
1029
+
1030
+ # Forward incoming messages to the subprocess
1031
+ while True:
1032
+ try:
1033
+ message = await websocket.receive_text()
1034
+ # Reject new prompts when session is busy
1035
+ if session_process.is_busy:
1036
+ try:
1037
+ in_message = JSONRPCInMessageAdapter.validate_json(message)
1038
+ except ValueError:
1039
+ in_message = None
1040
+ if isinstance(in_message, JSONRPCPromptMessage):
1041
+ # If the session is in error state, the in-flight IDs
1042
+ # are stale from a failed prompt. Clear them so the
1043
+ # user can recover by sending a new message.
1044
+ if session_process.status.state == "error":
1045
+ logger.info(
1046
+ "Clearing stale in-flight prompts for "
1047
+ f"session {session_id} (was in error state)"
1048
+ )
1049
+ session_process.clear_in_flight()
1050
+ else:
1051
+ await websocket.send_text(
1052
+ JSONRPCErrorResponse(
1053
+ id=in_message.id,
1054
+ error=JSONRPCErrorObject(
1055
+ code=ErrorCodes.INVALID_STATE,
1056
+ message=(
1057
+ "Session is busy; wait for completion before sending "
1058
+ "a new prompt."
1059
+ ),
1060
+ ),
1061
+ ).model_dump_json()
1062
+ )
1063
+ continue
1064
+
1065
+ # Update last_session_id on first successful prompt
1066
+ if not last_session_id_updated:
1067
+ try:
1068
+ in_message = JSONRPCInMessageAdapter.validate_json(message)
1069
+ except ValueError:
1070
+ in_message = None
1071
+ if isinstance(in_message, JSONRPCPromptMessage):
1072
+ await asyncio.to_thread(_update_last_session_id, session)
1073
+ last_session_id_updated = True
1074
+
1075
+ logger.debug(f"sending message to session {session_id}")
1076
+ await session_process.send_message(message)
1077
+ except WebSocketDisconnect:
1078
+ logger.debug("WebSocket disconnected")
1079
+ break
1080
+ except Exception as e:
1081
+ logger.warning(f"WebSocket error: {e.__class__.__name__} {e}")
1082
+ break
1083
+ finally:
1084
+ if attached and session_process:
1085
+ await session_process.remove_websocket(websocket)
1086
+
1087
+
1088
+ # Work dirs cache
1089
+ _work_dirs_cache: list[str] | None = None
1090
+ _work_dirs_cache_time: float = 0.0
1091
+ _WORK_DIRS_CACHE_TTL = 30.0 # seconds
1092
+
1093
+
1094
+ def invalidate_work_dirs_cache() -> None:
1095
+ """Clear the work dirs cache."""
1096
+ global _work_dirs_cache, _work_dirs_cache_time
1097
+ _work_dirs_cache = None
1098
+ _work_dirs_cache_time = 0.0
1099
+
1100
+
1101
+ def _get_work_dirs_sync() -> list[str]:
1102
+ """Synchronous helper for get_work_dirs (runs in thread pool)."""
1103
+ import time
1104
+
1105
+ global _work_dirs_cache, _work_dirs_cache_time
1106
+
1107
+ # Check cache
1108
+ now = time.time()
1109
+ if _work_dirs_cache is not None and (now - _work_dirs_cache_time) < _WORK_DIRS_CACHE_TTL:
1110
+ return _work_dirs_cache
1111
+
1112
+ # Build fresh list
1113
+ metadata = load_metadata()
1114
+ work_dirs: list[str] = []
1115
+ for wd in metadata.work_dirs:
1116
+ # Filter out temporary directories
1117
+ if "/tmp" in wd.path or "/var/folders" in wd.path or "/.cache/" in wd.path:
1118
+ continue
1119
+ # Verify directory exists
1120
+ if Path(wd.path).exists():
1121
+ work_dirs.append(wd.path)
1122
+
1123
+ # Update cache
1124
+ result = work_dirs[:20]
1125
+ _work_dirs_cache = result
1126
+ _work_dirs_cache_time = now
1127
+ return result
1128
+
1129
+
1130
+ @work_dirs_router.get("/", summary="List available work directories")
1131
+ async def get_work_dirs() -> list[str]:
1132
+ """Get a list of available work directories from metadata."""
1133
+ return await asyncio.to_thread(_get_work_dirs_sync)
1134
+
1135
+
1136
+ @work_dirs_router.get("/startup", summary="Get the startup directory")
1137
+ async def get_startup_dir(request: Request) -> str:
1138
+ """Get the directory where pythinker web was started."""
1139
+ return request.app.state.startup_dir
1140
+
1141
+
1142
+ @router.get("/{session_id}/git-diff", summary="Get git diff stats")
1143
+ async def get_session_git_diff(session_id: UUID) -> GitDiffStats:
1144
+ """get git diff stats for the session's work directory"""
1145
+ session = load_session_by_id(session_id)
1146
+ if session is None:
1147
+ raise HTTPException(status_code=404, detail="Session not found")
1148
+
1149
+ work_dir = Path(str(session.pythinker_code_session.work_dir))
1150
+
1151
+ # Check if it is a git repository
1152
+ if not (work_dir / ".git").exists():
1153
+ return GitDiffStats(is_git_repo=False)
1154
+
1155
+ try:
1156
+ files: list[GitFileDiff] = []
1157
+ total_add, total_del = 0, 0
1158
+
1159
+ # Check if HEAD exists (repo has at least one commit)
1160
+ check_proc = await asyncio.create_subprocess_exec(
1161
+ "git",
1162
+ "rev-parse",
1163
+ "--verify",
1164
+ "HEAD",
1165
+ cwd=str(work_dir),
1166
+ stdout=asyncio.subprocess.DEVNULL,
1167
+ stderr=asyncio.subprocess.DEVNULL,
1168
+ env=get_clean_env(),
1169
+ )
1170
+ await check_proc.wait()
1171
+ has_head = check_proc.returncode == 0
1172
+
1173
+ if has_head:
1174
+ # Execute git diff --numstat HEAD (including staged and unstaged)
1175
+ proc = await asyncio.create_subprocess_exec(
1176
+ "git",
1177
+ "diff",
1178
+ "--numstat",
1179
+ "HEAD",
1180
+ cwd=str(work_dir),
1181
+ stdout=asyncio.subprocess.PIPE,
1182
+ stderr=asyncio.subprocess.PIPE,
1183
+ env=get_clean_env(),
1184
+ )
1185
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
1186
+
1187
+ # Parse output
1188
+ for line in stdout.decode("utf-8", errors="replace").strip().split("\n"):
1189
+ if not line:
1190
+ continue
1191
+ parts = line.split("\t")
1192
+ if len(parts) >= 3:
1193
+ add = int(parts[0]) if parts[0] != "-" else 0
1194
+ dele = int(parts[1]) if parts[1] != "-" else 0
1195
+ total_add += add
1196
+ total_del += dele
1197
+ # Determine file status
1198
+ file_status: str = "modified"
1199
+ if dele == 0 and add > 0:
1200
+ file_status = "added"
1201
+ elif add == 0 and dele > 0:
1202
+ file_status = "deleted"
1203
+ files.append(
1204
+ GitFileDiff(
1205
+ path=parts[2],
1206
+ additions=add,
1207
+ deletions=dele,
1208
+ status=file_status, # type: ignore[arg-type]
1209
+ )
1210
+ )
1211
+
1212
+ # Also get untracked files (new files not yet added to git)
1213
+ untracked_proc = await asyncio.create_subprocess_exec(
1214
+ "git",
1215
+ "ls-files",
1216
+ "--others",
1217
+ "--exclude-standard",
1218
+ cwd=str(work_dir),
1219
+ stdout=asyncio.subprocess.PIPE,
1220
+ stderr=asyncio.subprocess.DEVNULL,
1221
+ env=get_clean_env(),
1222
+ )
1223
+ untracked_stdout, _ = await asyncio.wait_for(untracked_proc.communicate(), timeout=5.0)
1224
+
1225
+ # Add untracked files to the result
1226
+ for line in untracked_stdout.decode("utf-8", errors="replace").strip().split("\n"):
1227
+ if line:
1228
+ files.append(
1229
+ GitFileDiff(
1230
+ path=line,
1231
+ additions=0, # Cannot count lines for untracked files
1232
+ deletions=0,
1233
+ status="added",
1234
+ )
1235
+ )
1236
+
1237
+ if not has_head:
1238
+ return GitDiffStats(
1239
+ is_git_repo=True,
1240
+ has_changes=len(files) > 0,
1241
+ total_additions=0,
1242
+ total_deletions=0,
1243
+ files=files,
1244
+ )
1245
+
1246
+ return GitDiffStats(
1247
+ is_git_repo=True,
1248
+ has_changes=len(files) > 0,
1249
+ total_additions=total_add,
1250
+ total_deletions=total_del,
1251
+ files=files,
1252
+ )
1253
+ except TimeoutError:
1254
+ return GitDiffStats(is_git_repo=True, error="Git command timed out")
1255
+ except Exception as e:
1256
+ return GitDiffStats(is_git_repo=True, error=str(e))