studyctl 2.4.0__tar.gz → 2.5.0__tar.gz

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 (196) hide show
  1. {studyctl-2.4.0 → studyctl-2.5.0}/PKG-INFO +1 -1
  2. {studyctl-2.4.0 → studyctl-2.5.0}/pyproject.toml +1 -1
  3. studyctl-2.5.0/src/studyctl/adapters/codex.py +39 -0
  4. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/agent_launcher.py +3 -0
  5. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/__init__.py +2 -0
  6. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_doctor.py +88 -2
  7. studyctl-2.5.0/src/studyctl/cli/_install.py +80 -0
  8. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_session.py +9 -0
  9. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_setup.py +2 -2
  10. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_shared.py +17 -20
  11. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_study.py +1 -1
  12. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_upgrade.py +31 -0
  13. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/agents.py +6 -1
  14. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/core.py +2 -2
  15. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/__init__.py +2 -0
  16. studyctl-2.5.0/src/studyctl/history/_connection.py +69 -0
  17. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/sessions.py +35 -0
  18. studyctl-2.5.0/src/studyctl/installers.py +301 -0
  19. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/review_db.py +4 -4
  20. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/session/orchestrator.py +5 -1
  21. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/session/start.py +186 -111
  22. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/session_state.py +13 -4
  23. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/settings.py +11 -3
  24. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_adapter_builtins.py +26 -3
  25. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_agent_launcher.py +25 -2
  26. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_cli_doctor.py +14 -0
  27. studyctl-2.5.0/tests/test_cli_install.py +53 -0
  28. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_cli_session.py +18 -0
  29. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_cli_upgrade.py +24 -0
  30. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_agents.py +18 -0
  31. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_core.py +2 -2
  32. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_harness_matrix.py +2 -2
  33. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_review_db.py +36 -0
  34. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_session_start.py +91 -1
  35. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_settings_custom.py +15 -0
  36. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_setup_wizard.py +14 -0
  37. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_study_integration.py +14 -1
  38. studyctl-2.4.0/src/studyctl/history/_connection.py +0 -55
  39. {studyctl-2.4.0 → studyctl-2.5.0}/.gitignore +0 -0
  40. {studyctl-2.4.0 → studyctl-2.5.0}/README.md +0 -0
  41. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/__init__.py +0 -0
  42. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/__init__.py +0 -0
  43. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/_custom.py +0 -0
  44. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/_local_llm.py +0 -0
  45. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/_protocol.py +0 -0
  46. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/_strategies.py +0 -0
  47. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/claude.py +0 -0
  48. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/gemini.py +0 -0
  49. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/kiro.py +0 -0
  50. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/lmstudio.py +0 -0
  51. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/ollama.py +0 -0
  52. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/opencode.py +0 -0
  53. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/adapters/registry.py +0 -0
  54. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/__main__.py +0 -0
  55. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_backup.py +0 -0
  56. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_clean.py +0 -0
  57. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_config.py +0 -0
  58. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_content.py +0 -0
  59. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_lazy.py +0 -0
  60. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_review.py +0 -0
  61. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_sync.py +0 -0
  62. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_topics.py +0 -0
  63. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/cli/_web.py +0 -0
  64. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/__init__.py +0 -0
  65. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/markdown_converter.py +0 -0
  66. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/models.py +0 -0
  67. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/notebooklm_client.py +0 -0
  68. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/splitter.py +0 -0
  69. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/storage.py +0 -0
  70. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/content/syllabus.py +0 -0
  71. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/data/tmux-studyctl.conf +0 -0
  72. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/db.py +0 -0
  73. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/__init__.py +0 -0
  74. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/config.py +0 -0
  75. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/database.py +0 -0
  76. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/deps.py +0 -0
  77. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/models.py +0 -0
  78. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/doctor/updates.py +0 -0
  79. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/bridges.py +0 -0
  80. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/concepts.py +0 -0
  81. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/medication.py +0 -0
  82. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/progress.py +0 -0
  83. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/search.py +0 -0
  84. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/streaks.py +0 -0
  85. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/history/teachback.py +0 -0
  86. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/__init__.py +0 -0
  87. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/backlog_logic.py +0 -0
  88. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/break_logic.py +0 -0
  89. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/briefing_logic.py +0 -0
  90. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/clean_logic.py +0 -0
  91. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/streaks_logic.py +0 -0
  92. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/logic/topic_resolver.py +0 -0
  93. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/maintenance.py +0 -0
  94. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/mcp/__init__.py +0 -0
  95. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/mcp/server.py +0 -0
  96. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/mcp/tools.py +0 -0
  97. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/output.py +0 -0
  98. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/parking.py +0 -0
  99. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/pdf.py +0 -0
  100. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/review_loader.py +0 -0
  101. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/services/__init__.py +0 -0
  102. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/services/backlog.py +0 -0
  103. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/services/content.py +0 -0
  104. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/services/flashcard_writer.py +0 -0
  105. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/services/review.py +0 -0
  106. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/session/__init__.py +0 -0
  107. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/session/cleanup.py +0 -0
  108. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/session/resume.py +0 -0
  109. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/shared.py +0 -0
  110. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/state.py +0 -0
  111. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/sync.py +0 -0
  112. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/tmux.py +0 -0
  113. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/topics.py +0 -0
  114. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/tui/__init__.py +0 -0
  115. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/tui/__main__.py +0 -0
  116. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/tui/sidebar.py +0 -0
  117. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/__init__.py +0 -0
  118. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/app.py +0 -0
  119. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/auth.py +0 -0
  120. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/__init__.py +0 -0
  121. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/artefacts.py +0 -0
  122. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/cards.py +0 -0
  123. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/courses.py +0 -0
  124. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/history.py +0 -0
  125. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/session.py +0 -0
  126. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/routes/terminal_proxy.py +0 -0
  127. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/components.js +0 -0
  128. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/icon-192.svg +0 -0
  129. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/icon-512.svg +0 -0
  130. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/index.html +0 -0
  131. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/manifest.json +0 -0
  132. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/style.css +0 -0
  133. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/sw.js +0 -0
  134. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
  135. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
  136. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
  137. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
  138. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/css/inter.css +0 -0
  139. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/css/opendyslexic-400.css +0 -0
  140. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +0 -0
  141. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +0 -0
  142. {studyctl-2.4.0 → studyctl-2.5.0}/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +0 -0
  143. {studyctl-2.4.0 → studyctl-2.5.0}/tests/__init__.py +0 -0
  144. {studyctl-2.4.0 → studyctl-2.5.0}/tests/_helpers.py +0 -0
  145. {studyctl-2.4.0 → studyctl-2.5.0}/tests/harness/__init__.py +0 -0
  146. {studyctl-2.4.0 → studyctl-2.5.0}/tests/harness/agents.py +0 -0
  147. {studyctl-2.4.0 → studyctl-2.5.0}/tests/harness/study.py +0 -0
  148. {studyctl-2.4.0 → studyctl-2.5.0}/tests/harness/terminal.py +0 -0
  149. {studyctl-2.4.0 → studyctl-2.5.0}/tests/harness/tmux.py +0 -0
  150. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_adapter_custom.py +0 -0
  151. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_adapter_protocol.py +0 -0
  152. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_adapter_registry.py +0 -0
  153. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_adapter_strategies.py +0 -0
  154. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_backlog_logic.py +0 -0
  155. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_backup.py +0 -0
  156. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_break_logic.py +0 -0
  157. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_briefing_logic.py +0 -0
  158. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_clean.py +0 -0
  159. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_cli.py +0 -0
  160. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_content_notebooklm.py +0 -0
  161. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_content_splitter.py +0 -0
  162. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_content_storage.py +0 -0
  163. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_content_syllabus.py +0 -0
  164. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_config.py +0 -0
  165. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_database.py +0 -0
  166. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_deps.py +0 -0
  167. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_integration.py +0 -0
  168. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_models.py +0 -0
  169. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_doctor_updates.py +0 -0
  170. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_e2e_session_demo.py +0 -0
  171. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_flashcard_writer.py +0 -0
  172. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_history.py +0 -0
  173. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_install_mentor_prompt.py +0 -0
  174. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_lan_auth.py +0 -0
  175. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_mcp_tools.py +0 -0
  176. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_orchestrator.py +0 -0
  177. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_parking.py +0 -0
  178. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_review_loader.py +0 -0
  179. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_session_cleanup.py +0 -0
  180. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_session_db_integration.py +0 -0
  181. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_session_state.py +0 -0
  182. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_shared.py +0 -0
  183. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_sidebar_pilot.py +0 -0
  184. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_streaks_logic.py +0 -0
  185. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_study.py +0 -0
  186. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_study_lifecycle.py +0 -0
  187. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_terminal_proxy.py +0 -0
  188. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_tmux.py +0 -0
  189. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_topic_resolver.py +0 -0
  190. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_topics_cli.py +0 -0
  191. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_uat_terminal.py +0 -0
  192. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_web_app.py +0 -0
  193. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_web_artefacts.py +0 -0
  194. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_web_session.py +0 -0
  195. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_web_terminal.py +0 -0
  196. {studyctl-2.4.0 → studyctl-2.5.0}/tests/test_web_vendor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: studyctl
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline
5
5
  Project-URL: Homepage, https://github.com/NetDevAutomate/socratic-study-mentor
6
6
  Project-URL: Repository, https://github.com/NetDevAutomate/socratic-study-mentor
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "studyctl"
3
- version = "2.4.0"
3
+ version = "2.5.0"
4
4
  description = "AuDHD-aware study tool with AI Socratic mentoring, spaced repetition, and content pipeline"
5
5
  requires-python = ">=3.12"
6
6
  readme = "README.md"
@@ -0,0 +1,39 @@
1
+ """Codex adapter — persona via AGENTS.md in session CWD.
2
+
3
+ Codex CLI auto-loads AGENTS.md from the current working directory.
4
+ The setup function writes the canonical persona as plain markdown in
5
+ the session directory; launch just invokes the codex binary there.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from typing import TYPE_CHECKING
12
+
13
+ from studyctl.adapters._protocol import AgentAdapter
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+
19
+ def _codex_setup(canonical_content: str, session_dir: Path) -> Path:
20
+ """Write AGENTS.md to the session dir for Codex auto-discovery."""
21
+ persona_path = session_dir / "AGENTS.md"
22
+ persona_path.write_text(canonical_content)
23
+ return persona_path
24
+
25
+
26
+ def _codex_launch(_persona_path: Path, resume: bool) -> str:
27
+ """Build Codex launch command. Codex reads AGENTS.md from cwd."""
28
+ binary = shutil.which("codex") or "codex"
29
+ if resume:
30
+ return f"{binary} --resume"
31
+ return binary
32
+
33
+
34
+ ADAPTER = AgentAdapter(
35
+ name="codex",
36
+ binary="codex",
37
+ setup=_codex_setup,
38
+ launch_cmd=_codex_launch,
39
+ )
@@ -24,6 +24,7 @@ from studyctl.adapters._local_llm import _get_local_llm_config, _local_llm_env_p
24
24
  from studyctl.adapters._protocol import AgentAdapter
25
25
  from studyctl.adapters._strategies import cli_flag_setup as _claude_setup
26
26
  from studyctl.adapters.claude import _claude_launch
27
+ from studyctl.adapters.codex import _codex_launch, _codex_setup
27
28
  from studyctl.adapters.gemini import _gemini_launch, _gemini_setup
28
29
  from studyctl.adapters.kiro import _KIRO_BACKUP_SUFFIX, KIRO_AGENT_NAME
29
30
  from studyctl.adapters.lmstudio import _lmstudio_launch
@@ -42,6 +43,8 @@ __all__ = [
42
43
  "AgentAdapter",
43
44
  "_claude_launch",
44
45
  "_claude_setup",
46
+ "_codex_launch",
47
+ "_codex_setup",
45
48
  "_gemini_launch",
46
49
  "_gemini_mcp",
47
50
  "_gemini_setup",
@@ -22,6 +22,8 @@ from studyctl.cli._lazy import LazyGroup
22
22
  "dedup": "studyctl.cli._sync:dedup",
23
23
  # _setup.py — first-run setup wizard
24
24
  "setup": "studyctl.cli._setup:setup",
25
+ # _install.py — typed installation helpers
26
+ "install": "studyctl.cli._install:install_group",
25
27
  # _config.py — configuration
26
28
  "config": "studyctl.cli._config:config_group",
27
29
  # _review.py — spaced repetition, progress, wins, streaks, bridges
@@ -9,6 +9,15 @@ from rich.table import Table
9
9
 
10
10
  from studyctl.cli._shared import console
11
11
  from studyctl.doctor.models import VALID_CATEGORIES, CheckResult
12
+ from studyctl.installers import (
13
+ InstallError,
14
+ ensure_default_config,
15
+ ensure_review_database,
16
+ ensure_review_directories,
17
+ install_agent_definitions,
18
+ install_workspace_tools,
19
+ require_repo_root,
20
+ )
12
21
 
13
22
 
14
23
  def _get_registry():
@@ -94,13 +103,70 @@ def _summary_line(results: list[CheckResult]) -> str:
94
103
  parts.append(f"{counts['info']} info")
95
104
  summary = ", ".join(parts) + "."
96
105
  if auto_fixable:
97
- summary += f" Run 'studyctl upgrade' to fix {auto_fixable} issues."
106
+ summary += f" Run 'studyctl doctor --fix' to fix {auto_fixable} issues."
98
107
  return summary
99
108
 
100
109
 
110
+ def _apply_fixes(results: list[CheckResult]) -> list[str]:
111
+ """Apply safe automatic fixes for the provided results."""
112
+ actions: list[str] = []
113
+
114
+ def needs(category: str, name: str | None = None) -> bool:
115
+ return any(
116
+ r.category == category
117
+ and (name is None or r.name == name)
118
+ and r.status in ("warn", "fail")
119
+ and r.fix_auto
120
+ for r in results
121
+ )
122
+
123
+ if needs("core", "config_file"):
124
+ path = ensure_default_config()
125
+ actions.append(f"created config: {path}")
126
+
127
+ if needs("core", "agent_session_tools"):
128
+ repo_root = require_repo_root()
129
+ install_workspace_tools(repo_root, sync_workspace=True, force=True)
130
+ actions.append("reinstalled workspace tools")
131
+
132
+ if any(
133
+ r.category == "config"
134
+ and r.name.startswith("review_dir_")
135
+ and r.status in ("warn", "fail")
136
+ and r.fix_auto
137
+ for r in results
138
+ ):
139
+ created = ensure_review_directories()
140
+ actions.append(f"ensured review directories ({len(created)} created)")
141
+
142
+ if needs("database", "review_db"):
143
+ db_path = ensure_review_database()
144
+ actions.append(f"migrated review DB: {db_path}")
145
+
146
+ if any(r.category == "agents" and r.status in ("warn", "fail") and r.fix_auto for r in results):
147
+ repo_root = require_repo_root()
148
+ summary = install_agent_definitions(repo_root)
149
+ changed = sum(summary.values())
150
+ actions.append(f"refreshed agent definitions ({changed} changes)")
151
+
152
+ if any(
153
+ r.category == "updates" and r.status in ("warn", "fail") and r.fix_auto for r in results
154
+ ):
155
+ from studyctl.cli._upgrade import _detect_package_manager, _upgrade_packages
156
+
157
+ manager = _detect_package_manager()
158
+ if not _upgrade_packages(manager, dry_run=False):
159
+ msg = "package upgrade failed"
160
+ raise InstallError(msg)
161
+ actions.append(f"upgraded packages via {manager}")
162
+
163
+ return actions
164
+
165
+
101
166
  @click.command("doctor")
102
167
  @click.option("--json", "as_json", is_flag=True, help="Output as JSON array")
103
168
  @click.option("--quiet", is_flag=True, help="Summary line only")
169
+ @click.option("--fix", is_flag=True, help="Apply safe automatic fixes before reporting.")
104
170
  @click.option(
105
171
  "--category",
106
172
  type=click.Choice(sorted(VALID_CATEGORIES)),
@@ -108,11 +174,25 @@ def _summary_line(results: list[CheckResult]) -> str:
108
174
  help="Check specific category",
109
175
  )
110
176
  @click.pass_context
111
- def doctor(ctx: click.Context, as_json: bool, quiet: bool, category: str | None) -> None:
177
+ def doctor(
178
+ ctx: click.Context,
179
+ as_json: bool,
180
+ quiet: bool,
181
+ fix: bool,
182
+ category: str | None,
183
+ ) -> None:
112
184
  """Check installation health and report issues."""
113
185
  registry = _get_registry()
114
186
 
115
187
  results = registry.run_category(category) if category else registry.run_all()
188
+ applied: list[str] = []
189
+ if fix:
190
+ try:
191
+ applied = _apply_fixes(results)
192
+ except (InstallError, click.ClickException) as exc:
193
+ raise click.ClickException(str(exc)) from exc
194
+ if applied:
195
+ results = registry.run_category(category) if category else registry.run_all()
116
196
 
117
197
  exit_code = _compute_exit_code(results)
118
198
 
@@ -127,6 +207,12 @@ def doctor(ctx: click.Context, as_json: bool, quiet: bool, category: str | None)
127
207
  return
128
208
 
129
209
  # Rich table output grouped by category
210
+ if applied:
211
+ console.print("[bold green]Applied fixes:[/bold green]")
212
+ for action in applied:
213
+ console.print(f" {action}")
214
+ console.print()
215
+
130
216
  table = Table(title="studyctl doctor", show_lines=False)
131
217
  table.add_column("Status", justify="center", width=3)
132
218
  table.add_column("Check", style="cyan")
@@ -0,0 +1,80 @@
1
+ """Typed install commands for tools and agent definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from studyctl.cli._shared import console
10
+ from studyctl.installers import (
11
+ InstallError,
12
+ install_agent_definitions,
13
+ install_workspace_tools,
14
+ require_repo_root,
15
+ )
16
+
17
+
18
+ @click.group(name="install")
19
+ def install_group() -> None:
20
+ """Install studyctl tools and agent definitions."""
21
+
22
+
23
+ @install_group.command(name="tools")
24
+ @click.option(
25
+ "--repo-root",
26
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
27
+ default=None,
28
+ help="Repository root to install from (defaults to auto-detect).",
29
+ )
30
+ @click.option("--sync/--skip-sync", default=True, show_default=True, help="Run `uv sync` first.")
31
+ @click.option(
32
+ "--force/--no-force",
33
+ default=True,
34
+ show_default=True,
35
+ help="Force reinstall uv tools.",
36
+ )
37
+ def install_tools(repo_root: Path | None, sync: bool, force: bool) -> None:
38
+ """Install editable workspace packages as uv tools."""
39
+ try:
40
+ root = require_repo_root(repo_root)
41
+ installed = install_workspace_tools(root, sync_workspace=sync, force=force)
42
+ except InstallError as exc:
43
+ raise click.ClickException(str(exc)) from exc
44
+ except Exception as exc:
45
+ raise click.ClickException(f"Tool installation failed: {exc}") from exc
46
+
47
+ console.print("[green]Installed tools:[/green] " + ", ".join(installed))
48
+
49
+
50
+ @install_group.command(name="agents")
51
+ @click.option(
52
+ "--repo-root",
53
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
54
+ default=None,
55
+ help="Repository root to install from (defaults to auto-detect).",
56
+ )
57
+ @click.option(
58
+ "--tool",
59
+ "tools",
60
+ multiple=True,
61
+ type=click.Choice(["kiro", "claude", "gemini", "opencode", "codex", "amp"]),
62
+ help="Install for a specific AI tool. Repeat to install multiple.",
63
+ )
64
+ @click.option("--uninstall", is_flag=True, help="Remove installed agent definitions instead.")
65
+ def install_agents(repo_root: Path | None, tools: tuple[str, ...], uninstall: bool) -> None:
66
+ """Install or remove agent definitions for supported AI tools."""
67
+ try:
68
+ root = require_repo_root(repo_root)
69
+ summary = install_agent_definitions(root, tools=list(tools) or None, uninstall=uninstall)
70
+ except InstallError as exc:
71
+ raise click.ClickException(str(exc)) from exc
72
+ except Exception as exc:
73
+ action = "Agent uninstall" if uninstall else "Agent install"
74
+ raise click.ClickException(f"{action} failed: {exc}") from exc
75
+
76
+ action_word = "Removed" if uninstall else "Updated"
77
+ lines = [f"{name}: {count}" for name, count in summary.items()]
78
+ console.print(f"[green]{action_word} agent definitions.[/green]")
79
+ for line in lines:
80
+ console.print(f" {line}")
@@ -29,9 +29,18 @@ def session_start(topic: str, energy: int) -> None:
29
29
  PARKING_FILE,
30
30
  TOPICS_FILE,
31
31
  _ensure_session_dir,
32
+ is_session_active,
32
33
  write_session_state,
33
34
  )
34
35
 
36
+ if is_session_active():
37
+ console.print(
38
+ "[yellow]A session is already active.[/yellow]\n"
39
+ " Check: [bold]studyctl session status[/bold]\n"
40
+ " End: [bold]studyctl session end[/bold]"
41
+ )
42
+ raise SystemExit(1)
43
+
35
44
  _ensure_session_dir()
36
45
 
37
46
  from studyctl.output import energy_to_label
@@ -58,14 +58,14 @@ def setup() -> None:
58
58
  # Step 2 — AI coding assistant / MCP registration
59
59
  # ------------------------------------------------------------------
60
60
  console.print("[bold]Step 2 of 5[/bold] Do you use an AI coding assistant?")
61
- console.print(" [dim](Claude Code, Kiro, Gemini CLI, etc.)[/dim]")
61
+ console.print(" [dim](Claude Code, Codex CLI, Kiro, Gemini CLI, etc.)[/dim]")
62
62
  has_ai = click.confirm(" Use an AI assistant?", default=True)
63
63
  if has_ai:
64
64
  assistant = click.prompt(
65
65
  " Which one",
66
66
  default="claude-code",
67
67
  type=click.Choice(
68
- ["claude-code", "kiro", "gemini-cli", "other"],
68
+ ["claude-code", "codex", "kiro", "gemini-cli", "other"],
69
69
  case_sensitive=False,
70
70
  ),
71
71
  show_choices=True,
@@ -2,9 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import subprocess
6
5
  from pathlib import Path
7
6
 
7
+ from click import ClickException
8
+
9
+ from studyctl.installers import InstallError, install_agent_definitions, require_repo_root
8
10
  from studyctl.output import console
9
11
  from studyctl.topics import Topic, get_topics
10
12
 
@@ -50,31 +52,26 @@ def offer_agent_install(flag: bool | None) -> None:
50
52
  Args:
51
53
  flag: True = install, False = skip, None = ask interactively.
52
54
  """
53
- # Find install-agents.sh relative to the package
54
- candidate = Path(__file__).resolve().parent.parent
55
- for _ in range(6):
56
- script = candidate / "scripts" / "install-agents.sh"
57
- if script.exists():
58
- break
59
- candidate = candidate.parent
60
- else:
61
- return # Script not found — skip silently (pip install, not git clone)
62
-
63
55
  if flag is None:
64
56
  console.print("\n[bold cyan]Agent Installation[/bold cyan]")
65
57
  console.print(
66
58
  "The study mentor agents can be installed for detected AI tools\n"
67
- "(Claude Code, Kiro CLI, Gemini, OpenCode, Amp).\n"
59
+ "(Claude Code, Codex CLI, Kiro CLI, Gemini, OpenCode, Amp).\n"
68
60
  )
69
61
  reply = input("Install agent definitions now? [Y/n] ").strip().lower()
70
62
  flag = reply in ("", "y", "yes")
71
63
 
72
64
  if flag:
73
- console.print("[dim]Running install-agents.sh...[/dim]")
74
- result = subprocess.run(["bash", str(script)], capture_output=True, text=True)
75
- if result.returncode == 0:
76
- for line in result.stdout.strip().splitlines():
77
- console.print(f" {line}")
78
- else:
79
- console.print("[yellow]Agent install had issues — run manually:[/yellow]")
80
- console.print(f" bash {script}")
65
+ try:
66
+ repo_root = require_repo_root(Path.cwd())
67
+ summary = install_agent_definitions(repo_root)
68
+ except (InstallError, OSError) as exc:
69
+ console.print(f"[yellow]Agent install skipped:[/yellow] {exc}")
70
+ return
71
+ except ClickException as exc:
72
+ console.print(f"[yellow]Agent install skipped:[/yellow] {exc}")
73
+ return
74
+
75
+ console.print("[green]Agent definitions installed.[/green]")
76
+ for name, count in summary.items():
77
+ console.print(f" {name}: {count}")
@@ -242,7 +242,7 @@ def sidebar_cmd() -> None:
242
242
  except ImportError:
243
243
  console.print(
244
244
  "[red]Textual is required for the sidebar.[/red]\n"
245
- " Install: [bold]pip install 'studyctl[tui]'[/bold]"
245
+ " Install: pip install 'studyctl[tui]'"
246
246
  )
247
247
  raise SystemExit(1) from None
248
248
 
@@ -15,6 +15,7 @@ from rich.table import Table
15
15
 
16
16
  from studyctl.cli._doctor import _get_registry
17
17
  from studyctl.cli._shared import console
18
+ from studyctl.installers import InstallError, install_agent_definitions, require_repo_root
18
19
 
19
20
  if TYPE_CHECKING:
20
21
  from studyctl.doctor.models import CheckResult
@@ -171,6 +172,28 @@ def _upgrade_database(dry_run: bool) -> bool:
171
172
  return True
172
173
 
173
174
 
175
+ def _upgrade_agents(dry_run: bool) -> bool:
176
+ """Install or refresh agent definitions from the current source checkout."""
177
+ try:
178
+ repo_root = require_repo_root()
179
+ except InstallError as exc:
180
+ console.print(f"[red] Agent upgrade failed:[/red] {exc}")
181
+ return False
182
+
183
+ if dry_run:
184
+ console.print(f" [dim]Would refresh agent definitions from {repo_root}[/dim]")
185
+ return True
186
+
187
+ try:
188
+ install_agent_definitions(repo_root)
189
+ except Exception as exc:
190
+ console.print(f"[red] Agent upgrade failed:[/red] {exc}")
191
+ return False
192
+
193
+ console.print("[green] Agent definitions refreshed.[/green]")
194
+ return True
195
+
196
+
174
197
  def _result_matches_component(result: CheckResult, component: str) -> bool:
175
198
  """Return True if *result* is relevant to the upgrade *component*."""
176
199
  if component == "all":
@@ -263,12 +286,15 @@ def upgrade(ctx: click.Context, dry_run: bool, component: str, force: bool) -> N
263
286
  _upgrade_packages(manager, dry_run=True)
264
287
  elif mapped == "database":
265
288
  _upgrade_database(dry_run=True)
289
+ elif mapped == "agents":
290
+ _upgrade_agents(dry_run=True)
266
291
  ctx.exit(0)
267
292
  return
268
293
 
269
294
  # Non-dry-run: apply upgrades grouped by component
270
295
  needs_packages = any(_CATEGORY_TO_COMPONENT.get(r.category) == "packages" for r in actionable)
271
296
  needs_database = any(_CATEGORY_TO_COMPONENT.get(r.category) == "database" for r in actionable)
297
+ needs_agents = any(_CATEGORY_TO_COMPONENT.get(r.category) == "agents" for r in actionable)
272
298
 
273
299
  success = True
274
300
 
@@ -283,6 +309,11 @@ def upgrade(ctx: click.Context, dry_run: bool, component: str, force: bool) -> N
283
309
  if not _upgrade_database(dry_run=False):
284
310
  success = False
285
311
 
312
+ if needs_agents:
313
+ console.print("[bold]Upgrading agent definitions...[/bold]")
314
+ if not _upgrade_agents(dry_run=False):
315
+ success = False
316
+
286
317
  if success:
287
318
  console.print("\n[green]Upgrade complete.[/green]")
288
319
  else:
@@ -16,11 +16,13 @@ import urllib.request
16
16
  from pathlib import Path
17
17
 
18
18
  from studyctl.doctor.models import CheckResult
19
+ from studyctl.installers import find_repo_root
19
20
 
20
21
  MANIFEST_URL = "https://raw.githubusercontent.com/NetDevAutomate/socratic-study-mentor/main/agents/manifest.json"
21
22
 
22
23
  TOOL_AGENTS: dict[str, tuple[str, str]] = {
23
24
  "claude": ("claude", "~/.claude/commands/socratic-mentor.md"),
25
+ "codex": ("codex", "{repo_root}/AGENTS.md"),
24
26
  "kiro": ("kiro-cli", "~/.kiro/agents/study-mentor.json"),
25
27
  "gemini": ("gemini", "~/.gemini/agents/study-mentor.md"),
26
28
  "opencode": ("opencode", "~/.config/opencode/agents/study-mentor.md"),
@@ -35,6 +37,9 @@ def _detect_ai_tools() -> list[str]:
35
37
 
36
38
  def _get_agent_install_path(tool: str) -> Path:
37
39
  _, path_template = TOOL_AGENTS[tool]
40
+ if "{repo_root}" in path_template:
41
+ repo_root = find_repo_root(Path.cwd()) or Path.cwd()
42
+ return Path(path_template.format(repo_root=repo_root)).expanduser()
38
43
  return Path(path_template).expanduser()
39
44
 
40
45
 
@@ -230,7 +235,7 @@ def check_agent_definitions() -> list[CheckResult]:
230
235
  "no_ai_tools",
231
236
  "info",
232
237
  "No AI coding tools detected",
233
- "Install Claude Code, Kiro CLI, Gemini CLI, or OpenCode",
238
+ "Install Claude Code, Codex CLI, Kiro CLI, Gemini CLI, OpenCode, or Amp",
234
239
  False,
235
240
  )
236
241
  ]
@@ -68,7 +68,7 @@ def check_agent_session_tools() -> list[CheckResult]:
68
68
  "agent_session_tools",
69
69
  "warn",
70
70
  "agent-session-tools not installed (sessions DB unavailable)",
71
- "uv pip install agent-session-tools",
71
+ "studyctl install tools",
72
72
  fix_auto=True,
73
73
  )
74
74
  ]
@@ -101,7 +101,7 @@ def check_config_file() -> list[CheckResult]:
101
101
  "config_file",
102
102
  "warn",
103
103
  f"Config not found: {config_path}",
104
- "studyctl config init",
104
+ "studyctl doctor --fix",
105
105
  fix_auto=True,
106
106
  )
107
107
  ]
@@ -18,6 +18,7 @@ from .progress import (
18
18
  )
19
19
  from .search import struggle_topics, topic_frequency
20
20
  from .sessions import (
21
+ abort_study_session,
21
22
  end_study_session,
22
23
  get_energy_session_data,
23
24
  get_last_session_summary,
@@ -30,6 +31,7 @@ from .teachback import get_teachback_history, record_teachback
30
31
 
31
32
  __all__ = [
32
33
  "ConceptSummary",
34
+ "abort_study_session",
33
35
  "check_medication_window",
34
36
  "end_study_session",
35
37
  "get_bridges",
@@ -0,0 +1,69 @@
1
+ """Shared database connection helpers for the history package.
2
+
3
+ Auto-creates the sessions DB and applies migrations on first use,
4
+ so ``studyctl study`` works on a fresh machine without ``studyctl doctor``
5
+ or any other bootstrap step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from ..db import connect_db
13
+ from ..settings import load_settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _get_db_path():
19
+ """Return the configured sessions DB path (always a Path, never None)."""
20
+ return load_settings().session_db
21
+
22
+
23
+ def _has_schema(conn) -> bool:
24
+ """Check whether the study_sessions table exists."""
25
+ row = conn.execute(
26
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='study_sessions'"
27
+ ).fetchone()
28
+ return row is not None
29
+
30
+
31
+ def _connect():
32
+ """Open a connection to sessions.db, applying schema and migrations.
33
+
34
+ On every connection: applies base schema if tables are missing, then
35
+ runs any pending migrations. Both operations are idempotent.
36
+ Returns ``None`` only if agent-session-tools is not installed or
37
+ schema setup fails.
38
+ """
39
+ db = _get_db_path()
40
+ db.parent.mkdir(parents=True, exist_ok=True)
41
+
42
+ conn = connect_db(db, row_factory=True)
43
+
44
+ try:
45
+ from agent_session_tools.export_sessions import SCHEMA_FILE
46
+ from agent_session_tools.migrations import migrate
47
+ except ImportError:
48
+ # Without agent-session-tools we can still read an existing DB
49
+ # but cannot create or upgrade one.
50
+ if _has_schema(conn):
51
+ return conn
52
+ logger.warning("agent-session-tools not installed — cannot initialise sessions DB")
53
+ conn.close()
54
+ return None
55
+
56
+ try:
57
+ if not _has_schema(conn):
58
+ with open(SCHEMA_FILE) as f:
59
+ conn.executescript(f.read())
60
+ logger.info("Created sessions DB at %s", db)
61
+
62
+ # Always run pending migrations — safe on an up-to-date DB
63
+ migrate(conn)
64
+ except Exception:
65
+ logger.exception("Failed to initialise/migrate sessions DB")
66
+ conn.close()
67
+ return None
68
+
69
+ return conn
@@ -2,12 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  import sqlite3
6
7
  import uuid
7
8
  from datetime import UTC, datetime
8
9
 
9
10
  from . import _connection, search
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
11
14
 
12
15
  def start_study_session(
13
16
  topic: str,
@@ -34,6 +37,10 @@ def start_study_session(
34
37
  conn.commit()
35
38
  return study_id
36
39
  except sqlite3.OperationalError:
40
+ logger.warning(
41
+ "Failed to insert study session — sessions DB may lack schema",
42
+ exc_info=True,
43
+ )
37
44
  return None
38
45
  finally:
39
46
  conn.close()
@@ -108,6 +115,34 @@ def end_study_session(
108
115
  conn.close()
109
116
 
110
117
 
118
+ def abort_study_session(study_id: str, reason: str) -> bool:
119
+ """Mark a study session as ended when startup fails before steady state."""
120
+ conn = _connection._connect()
121
+ if not conn:
122
+ return False
123
+ try:
124
+ now = datetime.now(UTC).isoformat()
125
+ conn.execute(
126
+ """
127
+ UPDATE study_sessions
128
+ SET ended_at = COALESCE(ended_at, ?),
129
+ duration_minutes = COALESCE(duration_minutes, 0),
130
+ notes = CASE
131
+ WHEN notes IS NULL OR notes = '' THEN ?
132
+ ELSE notes || char(10) || ?
133
+ END
134
+ WHERE id = ?
135
+ """,
136
+ (now, reason, reason, study_id),
137
+ )
138
+ conn.commit()
139
+ return True
140
+ except sqlite3.OperationalError:
141
+ return False
142
+ finally:
143
+ conn.close()
144
+
145
+
111
146
  def get_study_session_stats(days: int = 30) -> list[dict]:
112
147
  """Get study session stats grouped by course slug (or raw topic) for the given period."""
113
148
  conn = _connection._connect()