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