studyctl 2.1.0__tar.gz → 2.2.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 (195) hide show
  1. {studyctl-2.1.0 → studyctl-2.2.0}/.gitignore +24 -1
  2. {studyctl-2.1.0 → studyctl-2.2.0}/PKG-INFO +5 -1
  3. {studyctl-2.1.0 → studyctl-2.2.0}/pyproject.toml +16 -3
  4. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/__init__.py +1 -1
  5. studyctl-2.2.0/src/studyctl/agent_launcher.py +585 -0
  6. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/__init__.py +18 -14
  7. studyctl-2.2.0/src/studyctl/cli/__main__.py +5 -0
  8. studyctl-2.2.0/src/studyctl/cli/_backup.py +153 -0
  9. studyctl-2.2.0/src/studyctl/cli/_clean.py +158 -0
  10. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_content.py +85 -43
  11. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_doctor.py +16 -4
  12. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_review.py +64 -209
  13. studyctl-2.2.0/src/studyctl/cli/_session.py +308 -0
  14. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_setup.py +6 -0
  15. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_shared.py +2 -5
  16. studyctl-2.2.0/src/studyctl/cli/_study.py +686 -0
  17. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_sync.py +1 -1
  18. studyctl-2.2.0/src/studyctl/cli/_topics.py +240 -0
  19. studyctl-2.2.0/src/studyctl/cli/_web.py +87 -0
  20. studyctl-2.2.0/src/studyctl/data/tmux-studyctl.conf +23 -0
  21. studyctl-2.2.0/src/studyctl/db.py +33 -0
  22. studyctl-2.2.0/src/studyctl/doctor/agents.py +308 -0
  23. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/config.py +79 -1
  24. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/core.py +1 -1
  25. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/deps.py +33 -0
  26. studyctl-2.2.0/src/studyctl/history/__init__.py +57 -0
  27. studyctl-2.2.0/src/studyctl/history/_connection.py +55 -0
  28. studyctl-2.2.0/src/studyctl/history/bridges.py +178 -0
  29. studyctl-2.2.0/src/studyctl/history/concepts.py +77 -0
  30. studyctl-2.2.0/src/studyctl/history/medication.py +56 -0
  31. studyctl-2.2.0/src/studyctl/history/progress.py +173 -0
  32. studyctl-2.2.0/src/studyctl/history/search.py +127 -0
  33. studyctl-2.2.0/src/studyctl/history/sessions.py +287 -0
  34. studyctl-2.2.0/src/studyctl/history/streaks.py +85 -0
  35. studyctl-2.2.0/src/studyctl/history/teachback.py +132 -0
  36. studyctl-2.2.0/src/studyctl/logic/backlog_logic.py +223 -0
  37. studyctl-2.2.0/src/studyctl/logic/break_logic.py +154 -0
  38. studyctl-2.2.0/src/studyctl/logic/briefing_logic.py +133 -0
  39. studyctl-2.2.0/src/studyctl/logic/clean_logic.py +99 -0
  40. studyctl-2.2.0/src/studyctl/logic/streaks_logic.py +171 -0
  41. studyctl-2.2.0/src/studyctl/logic/topic_resolver.py +99 -0
  42. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/mcp/tools.py +195 -33
  43. studyctl-2.2.0/src/studyctl/output.py +20 -0
  44. studyctl-2.2.0/src/studyctl/parking.py +288 -0
  45. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/review_db.py +17 -13
  46. studyctl-2.2.0/src/studyctl/services/backlog.py +45 -0
  47. studyctl-2.2.0/src/studyctl/services/flashcard_writer.py +137 -0
  48. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/services/review.py +33 -0
  49. studyctl-2.2.0/src/studyctl/session/__init__.py +1 -0
  50. studyctl-2.2.0/src/studyctl/session/cleanup.py +191 -0
  51. studyctl-2.2.0/src/studyctl/session/orchestrator.py +378 -0
  52. studyctl-2.2.0/src/studyctl/session/resume.py +113 -0
  53. studyctl-2.2.0/src/studyctl/session_state.py +186 -0
  54. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/settings.py +93 -74
  55. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/sync.py +5 -1
  56. studyctl-2.2.0/src/studyctl/tmux.py +349 -0
  57. studyctl-2.2.0/src/studyctl/topics.py +45 -0
  58. studyctl-2.2.0/src/studyctl/tui/__init__.py +1 -0
  59. studyctl-2.2.0/src/studyctl/tui/__main__.py +5 -0
  60. studyctl-2.2.0/src/studyctl/tui/sidebar.py +545 -0
  61. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/app.py +35 -3
  62. studyctl-2.2.0/src/studyctl/web/auth.py +75 -0
  63. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/cards.py +9 -16
  64. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/courses.py +5 -23
  65. studyctl-2.2.0/src/studyctl/web/routes/session.py +516 -0
  66. studyctl-2.2.0/src/studyctl/web/routes/terminal_proxy.py +165 -0
  67. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/server.py +0 -1
  68. studyctl-2.2.0/src/studyctl/web/static/components.js +686 -0
  69. studyctl-2.2.0/src/studyctl/web/static/index.html +1075 -0
  70. studyctl-2.2.0/src/studyctl/web/static/style.css +1360 -0
  71. studyctl-2.2.0/src/studyctl/web/static/sw.js +39 -0
  72. studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
  73. studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
  74. studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
  75. studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
  76. studyctl-2.2.0/src/studyctl/web/static/vendor/css/inter.css +27 -0
  77. studyctl-2.2.0/src/studyctl/web/static/vendor/css/opendyslexic-400.css +8 -0
  78. studyctl-2.2.0/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +5 -0
  79. studyctl-2.2.0/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +1 -0
  80. studyctl-2.2.0/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +290 -0
  81. studyctl-2.2.0/tests/__init__.py +0 -0
  82. studyctl-2.2.0/tests/_helpers.py +67 -0
  83. studyctl-2.2.0/tests/harness/__init__.py +36 -0
  84. studyctl-2.2.0/tests/harness/agents.py +158 -0
  85. studyctl-2.2.0/tests/harness/study.py +343 -0
  86. studyctl-2.2.0/tests/harness/terminal.py +231 -0
  87. studyctl-2.2.0/tests/harness/tmux.py +193 -0
  88. studyctl-2.2.0/tests/test_agent_launcher.py +746 -0
  89. studyctl-2.2.0/tests/test_backlog_logic.py +254 -0
  90. studyctl-2.2.0/tests/test_backup.py +138 -0
  91. studyctl-2.2.0/tests/test_break_logic.py +217 -0
  92. studyctl-2.2.0/tests/test_briefing_logic.py +220 -0
  93. studyctl-2.2.0/tests/test_clean.py +221 -0
  94. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_cli.py +348 -96
  95. studyctl-2.2.0/tests/test_cli_session.py +136 -0
  96. studyctl-2.2.0/tests/test_content_cli.py +489 -0
  97. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_notebooklm.py +105 -0
  98. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_agents.py +64 -0
  99. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_config.py +71 -0
  100. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_core.py +1 -1
  101. studyctl-2.2.0/tests/test_doctor_integration.py +121 -0
  102. studyctl-2.2.0/tests/test_e2e_session_demo.py +552 -0
  103. studyctl-2.2.0/tests/test_flashcard_writer.py +201 -0
  104. studyctl-2.2.0/tests/test_harness_matrix.py +383 -0
  105. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_history.py +29 -26
  106. studyctl-2.2.0/tests/test_lan_auth.py +246 -0
  107. studyctl-2.2.0/tests/test_lazy_imports.py +40 -0
  108. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_mcp_tools.py +57 -10
  109. studyctl-2.2.0/tests/test_migrate_from_empty_db.py +107 -0
  110. studyctl-2.2.0/tests/test_orchestrator.py +78 -0
  111. studyctl-2.2.0/tests/test_parking.py +205 -0
  112. studyctl-2.2.0/tests/test_services_content.py +144 -0
  113. studyctl-2.2.0/tests/test_services_review.py +265 -0
  114. studyctl-2.2.0/tests/test_session_db_integration.py +328 -0
  115. studyctl-2.2.0/tests/test_session_state.py +180 -0
  116. studyctl-2.2.0/tests/test_sidebar_pilot.py +211 -0
  117. studyctl-2.2.0/tests/test_streaks_logic.py +161 -0
  118. studyctl-2.2.0/tests/test_study.py +195 -0
  119. studyctl-2.2.0/tests/test_study_integration.py +981 -0
  120. studyctl-2.2.0/tests/test_study_lifecycle.py +209 -0
  121. studyctl-2.2.0/tests/test_terminal_proxy.py +179 -0
  122. studyctl-2.2.0/tests/test_tmux.py +228 -0
  123. studyctl-2.2.0/tests/test_topic_resolver.py +227 -0
  124. studyctl-2.2.0/tests/test_topics_cli.py +215 -0
  125. studyctl-2.2.0/tests/test_uat_terminal.py +455 -0
  126. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_web_app.py +4 -3
  127. studyctl-2.2.0/tests/test_web_cli.py +143 -0
  128. studyctl-2.2.0/tests/test_web_session.py +375 -0
  129. studyctl-2.2.0/tests/test_web_sidebar.py +493 -0
  130. studyctl-2.2.0/tests/test_web_terminal.py +486 -0
  131. studyctl-2.2.0/tests/test_web_vendor.py +75 -0
  132. studyctl-2.1.0/src/studyctl/calendar.py +0 -140
  133. studyctl-2.1.0/src/studyctl/cli/_schedule.py +0 -125
  134. studyctl-2.1.0/src/studyctl/cli/_state.py +0 -69
  135. studyctl-2.1.0/src/studyctl/cli/_web.py +0 -228
  136. studyctl-2.1.0/src/studyctl/doctor/agents.py +0 -130
  137. studyctl-2.1.0/src/studyctl/history.py +0 -982
  138. studyctl-2.1.0/src/studyctl/scheduler.py +0 -242
  139. studyctl-2.1.0/src/studyctl/tui/__main__.py +0 -33
  140. studyctl-2.1.0/src/studyctl/tui/app.py +0 -395
  141. studyctl-2.1.0/src/studyctl/tui/study_cards.py +0 -396
  142. studyctl-2.1.0/src/studyctl/web/static/app.js +0 -853
  143. studyctl-2.1.0/src/studyctl/web/static/index.html +0 -50
  144. studyctl-2.1.0/src/studyctl/web/static/style.css +0 -657
  145. studyctl-2.1.0/src/studyctl/web/static/sw.js +0 -14
  146. studyctl-2.1.0/tests/test_calendar.py +0 -159
  147. studyctl-2.1.0/tests/test_doctor_integration.py +0 -181
  148. studyctl-2.1.0/tests/test_scheduler.py +0 -86
  149. {studyctl-2.1.0 → studyctl-2.2.0}/README.md +0 -0
  150. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_config.py +0 -0
  151. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_lazy.py +0 -0
  152. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_upgrade.py +0 -0
  153. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/__init__.py +0 -0
  154. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/markdown_converter.py +0 -0
  155. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/models.py +0 -0
  156. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/notebooklm_client.py +0 -0
  157. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/splitter.py +0 -0
  158. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/storage.py +0 -0
  159. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/syllabus.py +0 -0
  160. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/__init__.py +0 -0
  161. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/database.py +0 -0
  162. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/models.py +0 -0
  163. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/updates.py +0 -0
  164. {studyctl-2.1.0/tests → studyctl-2.2.0/src/studyctl/logic}/__init__.py +0 -0
  165. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/maintenance.py +0 -0
  166. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/mcp/__init__.py +0 -0
  167. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/mcp/server.py +0 -0
  168. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/pdf.py +0 -0
  169. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/review_loader.py +0 -0
  170. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/services/__init__.py +0 -0
  171. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/services/content.py +0 -0
  172. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/shared.py +0 -0
  173. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/state.py +0 -0
  174. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/__init__.py +0 -0
  175. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/__init__.py +0 -0
  176. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/artefacts.py +0 -0
  177. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/history.py +0 -0
  178. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/static/icon-192.svg +0 -0
  179. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/static/icon-512.svg +0 -0
  180. {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/static/manifest.json +0 -0
  181. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_cli_doctor.py +0 -0
  182. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_cli_upgrade.py +0 -0
  183. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_splitter.py +0 -0
  184. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_storage.py +0 -0
  185. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_syllabus.py +0 -0
  186. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_database.py +0 -0
  187. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_deps.py +0 -0
  188. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_models.py +0 -0
  189. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_updates.py +0 -0
  190. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_install_mentor_prompt.py +0 -0
  191. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_review_db.py +0 -0
  192. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_review_loader.py +0 -0
  193. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_setup_wizard.py +0 -0
  194. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_shared.py +0 -0
  195. {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_web_artefacts.py +0 -0
@@ -475,8 +475,31 @@ site/
475
475
  *.db-shm
476
476
  *.db-journal
477
477
 
478
- # Personal work-in-progress plans
478
+ # Internal/planning docs (kept locally, not published)
479
+ docs/internal/
480
+ docs/local_repo_docs/
479
481
  docs/plans/
482
+ docs/architecture/
483
+
484
+ # Demo recordings (large, local-only)
485
+ demos/
486
+
487
+ # Agent workflow artefacts
488
+ code-review-plan-items.md
489
+ TODO.md
490
+ CHANGELOG.md
480
491
 
481
492
  # Artefacts (served from artefact-store, not committed to source repo)
482
493
  docs/artefacts/
494
+ pytest-of-*/
495
+ uv-*.lock
496
+
497
+ # Iterate runner artefacts (autoresearch pattern — kept untracked)
498
+ results.tsv
499
+ eval-results.tsv
500
+ .pytest-junit.xml
501
+ docs/reports/
502
+ docs/brainstorms/
503
+
504
+ # Dev-only tooling (autoresearch eval harness — not shipped to users)
505
+ dev/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: studyctl
3
- Version: 2.1.0
3
+ Version: 2.2.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
@@ -23,11 +23,13 @@ Requires-Dist: rich>=13.0
23
23
  Provides-Extra: all
24
24
  Requires-Dist: fastapi>=0.115; extra == 'all'
25
25
  Requires-Dist: httpx; extra == 'all'
26
+ Requires-Dist: httpx>=0.27; extra == 'all'
26
27
  Requires-Dist: mcp[cli]>=1.0.0; extra == 'all'
27
28
  Requires-Dist: notebooklm-py>=0.3.4; extra == 'all'
28
29
  Requires-Dist: pymupdf>=1.25; extra == 'all'
29
30
  Requires-Dist: textual>=0.80; extra == 'all'
30
31
  Requires-Dist: uvicorn[standard]>=0.34; extra == 'all'
32
+ Requires-Dist: websockets>=12.0; extra == 'all'
31
33
  Provides-Extra: content
32
34
  Requires-Dist: httpx; extra == 'content'
33
35
  Requires-Dist: pymupdf>=1.25; extra == 'content'
@@ -39,7 +41,9 @@ Provides-Extra: tui
39
41
  Requires-Dist: textual>=0.80; extra == 'tui'
40
42
  Provides-Extra: web
41
43
  Requires-Dist: fastapi>=0.115; extra == 'web'
44
+ Requires-Dist: httpx>=0.27; extra == 'web'
42
45
  Requires-Dist: uvicorn[standard]>=0.34; extra == 'web'
46
+ Requires-Dist: websockets>=12.0; extra == 'web'
43
47
  Description-Content-Type: text/markdown
44
48
 
45
49
  # studyctl
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "studyctl"
3
- version = "2.1.0"
3
+ version = "2.2.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"
@@ -26,7 +26,7 @@ dependencies = [
26
26
  content = ["pymupdf>=1.25", "httpx"]
27
27
  notebooklm = ["notebooklm-py>=0.3.4"]
28
28
  tui = ["textual>=0.80"]
29
- web = ["fastapi>=0.115", "uvicorn[standard]>=0.34"]
29
+ web = ["fastapi>=0.115", "uvicorn[standard]>=0.34", "httpx>=0.27", "websockets>=12.0"]
30
30
  mcp = ["mcp[cli]>=1.0.0"]
31
31
  all = ["studyctl[content,web,notebooklm,tui,mcp]"]
32
32
 
@@ -47,7 +47,20 @@ build-backend = "hatchling.build"
47
47
  [tool.hatch.build.targets.wheel]
48
48
  packages = ["src/studyctl"]
49
49
 
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ addopts = "--tb=short -m 'not integration'"
53
+ markers = [
54
+ "integration: requires external infrastructure (tmux, real DB, network)",
55
+ "e2e: end-to-end browser tests requiring Playwright + web server",
56
+ ]
57
+
50
58
  [tool.pyright]
51
59
  pythonVersion = "3.12"
52
60
  typeCheckingMode = "basic"
53
- exclude = ["src/studyctl/tui", "src/studyctl/content", "src/studyctl/cli/_content.py", "src/studyctl/cli/_web.py", "src/studyctl/web", "src/studyctl/mcp"] # optional deps not in CI
61
+ exclude = ["src/studyctl/tui", "src/studyctl/content", "src/studyctl/cli/_content.py", "src/studyctl/cli/_web.py", "src/studyctl/web", "src/studyctl/mcp", "src/studyctl/eval/llm_client.py"] # optional deps not in CI
62
+
63
+ [dependency-groups]
64
+ dev = [
65
+ "pytest-playwright>=0.7.2",
66
+ ]
@@ -1,3 +1,3 @@
1
1
  """studyctl — AuDHD study pipeline CLI."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "2.1.0"
@@ -0,0 +1,585 @@
1
+ """Agent launcher — detect installed AI agents and build launch commands.
2
+
3
+ Adapters handle the per-agent differences in persona injection, MCP config,
4
+ and launch commands. The canonical persona content is shared; each adapter's
5
+ setup() callable transforms it for that agent's specific mechanism.
6
+
7
+ Agent mechanisms (verified 2026-04-04):
8
+ Claude: --append-system-prompt-file {temp_file}
9
+ Gemini: GEMINI.md written to session cwd (auto-loaded)
10
+ Kiro: ~/.kiro/agents/study-mentor.json updated with file:// prompt ref
11
+ Crash recovery: stale backups restored on next setup
12
+ OpenCode: .opencode/agents/study-mentor.md with YAML frontmatter (permission: format)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import shutil
21
+ import tempfile
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Persona files live in the repo at agents/shared/personas/.
32
+ # Traverse up from src/studyctl/agent_launcher.py → repo root.
33
+ # Falls back to inline defaults if not found (e.g. pip install).
34
+ _REPO_ROOT = Path(__file__).parent.parent.parent.parent.parent
35
+ PERSONA_DIR = _REPO_ROOT / "agents" / "shared" / "personas"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Adapter dataclass
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AgentAdapter:
45
+ """Configuration and behaviour for one AI coding agent.
46
+
47
+ Each field is either static data or a callable that handles
48
+ the agent-specific mechanism for persona injection and launch.
49
+ """
50
+
51
+ name: str
52
+ binary: str
53
+ setup: Callable[[str, Path], Path]
54
+ """(canonical_content, session_dir) → persona_path"""
55
+ launch_cmd: Callable[[Path, bool], str]
56
+ """(persona_path, resume) → shell command string"""
57
+ teardown: Callable[[Path], None] | None = None
58
+ """Optional cleanup for agents that write global state (e.g. Kiro)."""
59
+ mcp_setup: Callable[[Path], None] | None = None
60
+ """Optional MCP config writer for the session directory."""
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Claude adapter
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _claude_setup(canonical_content: str, _session_dir: Path) -> Path:
69
+ """Write canonical content to a secure temp file for Claude's CLI flag."""
70
+ fd, path = tempfile.mkstemp(
71
+ prefix="studyctl-persona-",
72
+ suffix=".md",
73
+ dir=tempfile.gettempdir(),
74
+ )
75
+ os.fchmod(fd, 0o600)
76
+ with os.fdopen(fd, "w") as f:
77
+ f.write(canonical_content)
78
+ return Path(path)
79
+
80
+
81
+ def _claude_launch(persona_path: Path, resume: bool) -> str:
82
+ """Build Claude launch command with absolute binary path.
83
+
84
+ Resolves to absolute path because tmux panes run non-interactive
85
+ shells which don't source .zshrc (~/.local/bin not in PATH).
86
+ """
87
+ binary = shutil.which("claude") or "claude"
88
+ if resume:
89
+ return f"{binary} -r --append-system-prompt-file {persona_path}"
90
+ return f"{binary} --append-system-prompt-file {persona_path}"
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Kiro adapter
95
+ #
96
+ # Kiro loads agents natively from ~/.kiro/agents/. The setup function
97
+ # writes canonical content to a temp persona file, then updates the
98
+ # agent JSON's "prompt" field to reference it via file:// URI.
99
+ # Teardown restores the original JSON from a backup.
100
+ # ---------------------------------------------------------------------------
101
+
102
+ KIRO_AGENTS_DIR = Path(os.environ.get("STUDYCTL_KIRO_AGENTS_DIR", Path.home() / ".kiro" / "agents"))
103
+ KIRO_AGENT_NAME = "study-mentor"
104
+ _KIRO_TEMPLATE = _REPO_ROOT / "agents" / "kiro" / "study-mentor.json"
105
+ _KIRO_BACKUP_SUFFIX = ".studyctl-backup"
106
+
107
+
108
+ def _kiro_setup(canonical_content: str, _session_dir: Path) -> Path:
109
+ """Write persona temp file and update Kiro agent JSON atomically."""
110
+ # 1. Write canonical content to a temp persona file
111
+ fd, persona_path = tempfile.mkstemp(
112
+ prefix="studyctl-kiro-persona-",
113
+ suffix=".md",
114
+ dir=tempfile.gettempdir(),
115
+ )
116
+ os.fchmod(fd, 0o600)
117
+ with os.fdopen(fd, "w") as f:
118
+ f.write(canonical_content)
119
+
120
+ # 2. Load the base agent template
121
+ if _KIRO_TEMPLATE.exists():
122
+ agent_def = json.loads(_KIRO_TEMPLATE.read_text())
123
+ else:
124
+ agent_def = {
125
+ "name": KIRO_AGENT_NAME,
126
+ "description": "Socratic study mentor",
127
+ }
128
+
129
+ # 3. Update prompt to reference the temp persona file
130
+ agent_def["prompt"] = f"file://{persona_path}"
131
+
132
+ # 4. Ensure target directory exists
133
+ KIRO_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
134
+ target = KIRO_AGENTS_DIR / f"{KIRO_AGENT_NAME}.json"
135
+ backup = target.with_suffix(target.suffix + _KIRO_BACKUP_SUFFIX)
136
+
137
+ # 4a. Recover from crash: if a backup exists, the previous session's
138
+ # teardown never ran. Restore the user's original config first.
139
+ if backup.exists():
140
+ logger.warning(
141
+ "Stale Kiro backup detected (previous session crashed?) — restoring %s",
142
+ backup,
143
+ )
144
+ os.replace(backup, target)
145
+
146
+ # 5. Backup existing agent JSON if present
147
+ if target.exists():
148
+ shutil.copy2(target, backup)
149
+
150
+ # 6. Atomic write: temp file in same dir → os.replace()
151
+ fd2, tmp_json = tempfile.mkstemp(
152
+ prefix=f"{KIRO_AGENT_NAME}-",
153
+ suffix=".json",
154
+ dir=str(KIRO_AGENTS_DIR),
155
+ )
156
+ with os.fdopen(fd2, "w") as f:
157
+ json.dump(agent_def, f, indent=2)
158
+ os.replace(tmp_json, target)
159
+
160
+ return Path(persona_path)
161
+
162
+
163
+ def _kiro_launch(persona_path: Path, resume: bool) -> str:
164
+ """Build Kiro launch command."""
165
+ binary = shutil.which("kiro-cli") or shutil.which("kiro") or "kiro-cli"
166
+ if resume:
167
+ return f"{binary} chat --agent {KIRO_AGENT_NAME} --resume"
168
+ return f"{binary} chat --agent {KIRO_AGENT_NAME}"
169
+
170
+
171
+ def _kiro_teardown(_session_dir: Path) -> None:
172
+ """Restore the backed-up Kiro agent JSON."""
173
+ target = KIRO_AGENTS_DIR / f"{KIRO_AGENT_NAME}.json"
174
+ backup = target.with_suffix(target.suffix + _KIRO_BACKUP_SUFFIX)
175
+ if backup.exists():
176
+ os.replace(backup, target)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Gemini adapter
181
+ #
182
+ # Gemini auto-loads GEMINI.md from the current working directory (3-tier
183
+ # hierarchy: global, workspace, JIT). The setup function writes the
184
+ # canonical persona as GEMINI.md in the session directory.
185
+ # MCP config goes in .gemini/settings.json in the session directory.
186
+ # ---------------------------------------------------------------------------
187
+
188
+
189
+ def _mcp_command() -> list[str]:
190
+ """Build the studyctl-mcp server command.
191
+
192
+ Prefers the installed console script (pip/uv tool install).
193
+ Falls back to uv run --project for development.
194
+ """
195
+ binary = shutil.which("studyctl-mcp")
196
+ if binary:
197
+ return [binary]
198
+ # Dev fallback: run from the repo workspace
199
+ project_path = str(_REPO_ROOT / "packages" / "studyctl")
200
+ return ["uv", "run", "--project", project_path, "studyctl-mcp"]
201
+
202
+
203
+ def _gemini_setup(canonical_content: str, session_dir: Path) -> Path:
204
+ """Write GEMINI.md to session dir (auto-loaded by Gemini CLI from cwd)."""
205
+ persona_path = session_dir / "GEMINI.md"
206
+ persona_path.write_text(canonical_content)
207
+ return persona_path
208
+
209
+
210
+ def _gemini_launch(_persona_path: Path, resume: bool) -> str:
211
+ """Build Gemini launch command. Gemini picks up GEMINI.md from cwd."""
212
+ binary = shutil.which("gemini") or "gemini"
213
+ if resume:
214
+ return f"{binary} -r"
215
+ return binary
216
+
217
+
218
+ def _gemini_mcp(session_dir: Path) -> None:
219
+ """Write .gemini/settings.json with studyctl-mcp server config."""
220
+ gemini_dir = session_dir / ".gemini"
221
+ gemini_dir.mkdir(parents=True, exist_ok=True)
222
+
223
+ cmd = _mcp_command()
224
+ settings = {
225
+ "mcpServers": {
226
+ "studyctl-mcp": {
227
+ "command": cmd[0],
228
+ "args": cmd[1:],
229
+ },
230
+ },
231
+ }
232
+ settings_path = gemini_dir / "settings.json"
233
+ settings_path.write_text(json.dumps(settings, indent=2))
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # OpenCode adapter
238
+ #
239
+ # OpenCode uses --agent <name> to select an agent defined in
240
+ # ~/.config/opencode/agents/ or project-local agents/. The setup
241
+ # function writes the persona as a markdown file with YAML frontmatter
242
+ # in the session directory. MCP uses a different schema from others:
243
+ # "command" is a flat array, "enabled" (not "disabled"),
244
+ # "type": "local" required.
245
+ # ---------------------------------------------------------------------------
246
+
247
+ _OPENCODE_AGENTS_DIR_NAME = ".opencode"
248
+
249
+
250
+ def _opencode_setup(canonical_content: str, session_dir: Path) -> Path:
251
+ """Write study-mentor.md with YAML frontmatter for OpenCode."""
252
+ agents_dir = session_dir / _OPENCODE_AGENTS_DIR_NAME / "agents"
253
+ agents_dir.mkdir(parents=True, exist_ok=True)
254
+
255
+ persona_path = agents_dir / "study-mentor.md"
256
+ frontmatter = (
257
+ "---\n"
258
+ 'description: "AuDHD-aware Socratic study mentor"\n'
259
+ "mode: primary\n"
260
+ "temperature: 0.3\n"
261
+ "permission:\n"
262
+ " edit: allow\n"
263
+ " bash:\n"
264
+ ' "studyctl *": allow\n'
265
+ ' "session-* *": allow\n'
266
+ ' "*": ask\n'
267
+ "---\n\n"
268
+ )
269
+ persona_path.write_text(frontmatter + canonical_content)
270
+ return persona_path
271
+
272
+
273
+ def _opencode_launch(_persona_path: Path, resume: bool) -> str:
274
+ """Build OpenCode launch command."""
275
+ binary = shutil.which("opencode") or "opencode"
276
+ if resume:
277
+ return f"{binary} --agent study-mentor -c"
278
+ return f"{binary} --agent study-mentor"
279
+
280
+
281
+ def _opencode_mcp(session_dir: Path) -> None:
282
+ """Write opencode.json with studyctl-mcp in OpenCode's MCP schema.
283
+
284
+ OpenCode's schema differs from others:
285
+ - ``command`` is a flat array (binary + args merged)
286
+ - ``enabled`` instead of ``disabled``
287
+ - ``type: "local"`` required
288
+ """
289
+ oc_dir = session_dir / _OPENCODE_AGENTS_DIR_NAME
290
+ oc_dir.mkdir(parents=True, exist_ok=True)
291
+
292
+ config = {
293
+ "mcp": {
294
+ "studyctl-mcp": {
295
+ "command": _mcp_command(),
296
+ "enabled": True,
297
+ "type": "local",
298
+ },
299
+ },
300
+ }
301
+ config_path = oc_dir / "opencode.json"
302
+ config_path.write_text(json.dumps(config, indent=2))
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Local LLM adapters (Ollama, LM Studio)
307
+ #
308
+ # These use Claude Code as the frontend but point it at a local LLM backend
309
+ # via env vars. Tier-pinning sets all Claude model tiers to the same model
310
+ # since local LLMs only serve one model at a time.
311
+ # ---------------------------------------------------------------------------
312
+
313
+
314
+ def _get_local_llm_config(provider: str) -> tuple[str, str]:
315
+ """Return (base_url, model) for a local LLM provider from config.
316
+
317
+ Falls back to sensible defaults if config isn't available.
318
+ """
319
+ defaults = {
320
+ "ollama": ("http://localhost:4000", "qwen3-coder"), # LiteLLM proxy
321
+ "lmstudio": ("http://localhost:1234", "qwen3-coder"),
322
+ }
323
+ try:
324
+ from studyctl.settings import load_settings
325
+
326
+ cfg = getattr(load_settings().agents, provider, None)
327
+ if cfg and cfg.model:
328
+ return cfg.base_url or defaults[provider][0], cfg.model
329
+ except Exception:
330
+ pass
331
+ return defaults[provider]
332
+
333
+
334
+ def _local_llm_env_prefix(base_url: str, auth_token: str, model: str) -> str:
335
+ """Build shell env var exports for a local LLM provider.
336
+
337
+ Tier-pins all Claude Code model tiers to the same model, since
338
+ local LLMs only serve one model at a time. Without this, Claude
339
+ tries to use different models for sub-agents and fast tasks.
340
+ """
341
+ return (
342
+ f"export ANTHROPIC_BASE_URL={base_url} "
343
+ f"ANTHROPIC_AUTH_TOKEN={auth_token} "
344
+ f"ANTHROPIC_MODEL={model} "
345
+ f"ANTHROPIC_SMALL_FAST_MODEL={model} "
346
+ f"ANTHROPIC_DEFAULT_HAIKU_MODEL={model} "
347
+ f"ANTHROPIC_DEFAULT_SONNET_MODEL={model} "
348
+ f"ANTHROPIC_DEFAULT_OPUS_MODEL={model}; "
349
+ )
350
+
351
+
352
+ def _ollama_launch(persona_path: Path, resume: bool) -> str:
353
+ """Build Claude launch command with Ollama backend env vars."""
354
+ claude_bin = shutil.which("claude") or "claude"
355
+ base_url, model = _get_local_llm_config("ollama")
356
+ env = _local_llm_env_prefix(base_url, "ollama", model)
357
+ if resume:
358
+ return f"{env}{claude_bin} -r --append-system-prompt-file {persona_path}"
359
+ return f"{env}{claude_bin} --append-system-prompt-file {persona_path}"
360
+
361
+
362
+ def _lmstudio_launch(persona_path: Path, resume: bool) -> str:
363
+ """Build Claude launch command with LM Studio backend env vars."""
364
+ claude_bin = shutil.which("claude") or "claude"
365
+ base_url, model = _get_local_llm_config("lmstudio")
366
+ env = _local_llm_env_prefix(base_url, "lm-studio", model)
367
+ if resume:
368
+ return f"{env}{claude_bin} -r --append-system-prompt-file {persona_path}"
369
+ return f"{env}{claude_bin} --append-system-prompt-file {persona_path}"
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Agent registry — insertion order is the default detection priority.
374
+ # To customize priority, set agents.priority in config.yaml.
375
+ # ---------------------------------------------------------------------------
376
+
377
+ AGENTS: dict[str, AgentAdapter] = {
378
+ "claude": AgentAdapter(
379
+ name="claude",
380
+ binary="claude",
381
+ setup=_claude_setup,
382
+ launch_cmd=_claude_launch,
383
+ ),
384
+ "gemini": AgentAdapter(
385
+ name="gemini",
386
+ binary="gemini",
387
+ setup=_gemini_setup,
388
+ launch_cmd=_gemini_launch,
389
+ mcp_setup=_gemini_mcp,
390
+ ),
391
+ "kiro": AgentAdapter(
392
+ name="kiro",
393
+ binary="kiro-cli",
394
+ setup=_kiro_setup,
395
+ launch_cmd=_kiro_launch,
396
+ teardown=_kiro_teardown,
397
+ ),
398
+ "opencode": AgentAdapter(
399
+ name="opencode",
400
+ binary="opencode",
401
+ setup=_opencode_setup,
402
+ launch_cmd=_opencode_launch,
403
+ mcp_setup=_opencode_mcp,
404
+ ),
405
+ "ollama": AgentAdapter(
406
+ name="ollama",
407
+ binary="ollama",
408
+ setup=_claude_setup, # Same persona mechanism as Claude
409
+ launch_cmd=_ollama_launch,
410
+ ),
411
+ "lmstudio": AgentAdapter(
412
+ name="lmstudio",
413
+ binary="lms", # LM Studio CLI
414
+ setup=_claude_setup,
415
+ launch_cmd=_lmstudio_launch,
416
+ ),
417
+ }
418
+
419
+
420
+ # ---------------------------------------------------------------------------
421
+ # Public API
422
+ # ---------------------------------------------------------------------------
423
+
424
+
425
+ def detect_agents() -> list[str]:
426
+ """Return names of installed agents, in configured priority order.
427
+
428
+ Priority comes from (highest to lowest):
429
+ 1. ``STUDYCTL_AGENT`` env var (single agent, if installed)
430
+ 2. ``agents.priority`` in config.yaml
431
+ 3. Registry insertion order (fallback)
432
+ """
433
+ # Env var override — check single agent
434
+ env_agent = os.environ.get("STUDYCTL_AGENT")
435
+ if env_agent and env_agent in AGENTS:
436
+ if shutil.which(AGENTS[env_agent].binary):
437
+ return [env_agent]
438
+ return []
439
+
440
+ # Load configured priority order
441
+ try:
442
+ from studyctl.settings import load_settings
443
+
444
+ priority = load_settings().agents.priority
445
+ except Exception:
446
+ priority = list(AGENTS.keys())
447
+
448
+ # Check each agent in priority order, then any not in the list
449
+ ordered = [n for n in priority if n in AGENTS]
450
+ ordered += [n for n in AGENTS if n not in ordered]
451
+
452
+ found: list[str] = []
453
+ for name in ordered:
454
+ if shutil.which(AGENTS[name].binary):
455
+ found.append(name)
456
+ return found
457
+
458
+
459
+ def get_default_agent() -> str | None:
460
+ """Return the first available agent, or None."""
461
+ agents = detect_agents()
462
+ return agents[0] if agents else None
463
+
464
+
465
+ def get_adapter(name: str) -> AgentAdapter:
466
+ """Get an adapter by name.
467
+
468
+ Raises:
469
+ KeyError: If the agent is not in the registry.
470
+ """
471
+ return AGENTS[name]
472
+
473
+
474
+ def build_canonical_persona(
475
+ mode: str,
476
+ topic: str,
477
+ energy: int,
478
+ *,
479
+ previous_notes: str | None = None,
480
+ ) -> str:
481
+ """Build the canonical persona content as a markdown string.
482
+
483
+ This is agent-agnostic. Each adapter's ``setup()`` callable
484
+ transforms and writes it in the format that agent expects.
485
+ """
486
+ persona_path = PERSONA_DIR / f"{mode}.md"
487
+ template = persona_path.read_text() if persona_path.exists() else _default_persona(mode)
488
+
489
+ resume_section = ""
490
+ if previous_notes:
491
+ resume_section = f"""
492
+ ## Resuming Previous Session
493
+
494
+ This is a RESUMED session. Here's what was covered last time:
495
+
496
+ {previous_notes}
497
+
498
+ Pick up where we left off. Don't re-introduce the topic from scratch —
499
+ reference specific items from the previous session and ask where the
500
+ student wants to continue.
501
+
502
+ ---
503
+
504
+ """
505
+
506
+ return f"""# Study Session Context
507
+
508
+ **Topic:** {topic}
509
+ **Energy:** {energy}/10
510
+ **Mode:** {mode}
511
+
512
+ ---
513
+ {resume_section}
514
+ {template}
515
+ """
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # Backward-compatible wrappers
520
+ # ---------------------------------------------------------------------------
521
+
522
+
523
+ def build_persona_file(
524
+ mode: str,
525
+ topic: str,
526
+ energy: int,
527
+ *,
528
+ previous_notes: str | None = None,
529
+ ) -> Path:
530
+ """Build persona file using Claude adapter (backward-compatible).
531
+
532
+ New code should use::
533
+
534
+ canonical = build_canonical_persona(mode, topic, energy, ...)
535
+ path = adapter.setup(canonical, session_dir)
536
+
537
+ Uses ``mkstemp`` with 0600 permissions (security review N-04).
538
+ Returns the path to the temp file. Caller should clean up on session end.
539
+ """
540
+ canonical = build_canonical_persona(mode, topic, energy, previous_notes=previous_notes)
541
+ return _claude_setup(canonical, Path(tempfile.gettempdir()))
542
+
543
+
544
+ def get_launch_command(
545
+ agent: str,
546
+ persona_file: Path,
547
+ *,
548
+ resume: bool = False,
549
+ ) -> str:
550
+ """Build the shell command to launch an agent with a persona.
551
+
552
+ Args:
553
+ agent: Agent name (e.g. "claude").
554
+ persona_file: Path to the persona file.
555
+ resume: If True, use the agent's resume command.
556
+
557
+ Raises:
558
+ KeyError: If the agent is not in the registry.
559
+ """
560
+ adapter = AGENTS[agent]
561
+ return adapter.launch_cmd(persona_file, resume)
562
+
563
+
564
+ def _default_persona(mode: str) -> str:
565
+ """Fallback persona when no persona file exists for the mode."""
566
+ if mode == "co-study":
567
+ return (
568
+ "You are a study companion. The user is driving — watching videos, "
569
+ "reading docs, or doing exercises. Stay available but don't interrupt. "
570
+ "When asked questions, use the Socratic method. Keep answers concise.\n\n"
571
+ "Check the session IPC files for context:\n"
572
+ "- ~/.config/studyctl/session-state.json\n"
573
+ "- ~/.config/studyctl/session-topics.md\n"
574
+ "- ~/.config/studyctl/session-parking.md\n"
575
+ )
576
+ # Default: study mode
577
+ return (
578
+ "You are a Socratic study mentor. Drive the session — ask questions, "
579
+ "probe understanding, use the 70/30 balance (70% questions, 30% strategic "
580
+ "information). Adapt to the student's energy level.\n\n"
581
+ "Check the session IPC files for context:\n"
582
+ "- ~/.config/studyctl/session-state.json\n"
583
+ "- ~/.config/studyctl/session-topics.md\n"
584
+ "- ~/.config/studyctl/session-parking.md\n"
585
+ )