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.
- {studyctl-2.1.0 → studyctl-2.2.0}/.gitignore +24 -1
- {studyctl-2.1.0 → studyctl-2.2.0}/PKG-INFO +5 -1
- {studyctl-2.1.0 → studyctl-2.2.0}/pyproject.toml +16 -3
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/__init__.py +1 -1
- studyctl-2.2.0/src/studyctl/agent_launcher.py +585 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/__init__.py +18 -14
- studyctl-2.2.0/src/studyctl/cli/__main__.py +5 -0
- studyctl-2.2.0/src/studyctl/cli/_backup.py +153 -0
- studyctl-2.2.0/src/studyctl/cli/_clean.py +158 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_content.py +85 -43
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_doctor.py +16 -4
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_review.py +64 -209
- studyctl-2.2.0/src/studyctl/cli/_session.py +308 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_setup.py +6 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_shared.py +2 -5
- studyctl-2.2.0/src/studyctl/cli/_study.py +686 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_sync.py +1 -1
- studyctl-2.2.0/src/studyctl/cli/_topics.py +240 -0
- studyctl-2.2.0/src/studyctl/cli/_web.py +87 -0
- studyctl-2.2.0/src/studyctl/data/tmux-studyctl.conf +23 -0
- studyctl-2.2.0/src/studyctl/db.py +33 -0
- studyctl-2.2.0/src/studyctl/doctor/agents.py +308 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/config.py +79 -1
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/core.py +1 -1
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/deps.py +33 -0
- studyctl-2.2.0/src/studyctl/history/__init__.py +57 -0
- studyctl-2.2.0/src/studyctl/history/_connection.py +55 -0
- studyctl-2.2.0/src/studyctl/history/bridges.py +178 -0
- studyctl-2.2.0/src/studyctl/history/concepts.py +77 -0
- studyctl-2.2.0/src/studyctl/history/medication.py +56 -0
- studyctl-2.2.0/src/studyctl/history/progress.py +173 -0
- studyctl-2.2.0/src/studyctl/history/search.py +127 -0
- studyctl-2.2.0/src/studyctl/history/sessions.py +287 -0
- studyctl-2.2.0/src/studyctl/history/streaks.py +85 -0
- studyctl-2.2.0/src/studyctl/history/teachback.py +132 -0
- studyctl-2.2.0/src/studyctl/logic/backlog_logic.py +223 -0
- studyctl-2.2.0/src/studyctl/logic/break_logic.py +154 -0
- studyctl-2.2.0/src/studyctl/logic/briefing_logic.py +133 -0
- studyctl-2.2.0/src/studyctl/logic/clean_logic.py +99 -0
- studyctl-2.2.0/src/studyctl/logic/streaks_logic.py +171 -0
- studyctl-2.2.0/src/studyctl/logic/topic_resolver.py +99 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/mcp/tools.py +195 -33
- studyctl-2.2.0/src/studyctl/output.py +20 -0
- studyctl-2.2.0/src/studyctl/parking.py +288 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/review_db.py +17 -13
- studyctl-2.2.0/src/studyctl/services/backlog.py +45 -0
- studyctl-2.2.0/src/studyctl/services/flashcard_writer.py +137 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/services/review.py +33 -0
- studyctl-2.2.0/src/studyctl/session/__init__.py +1 -0
- studyctl-2.2.0/src/studyctl/session/cleanup.py +191 -0
- studyctl-2.2.0/src/studyctl/session/orchestrator.py +378 -0
- studyctl-2.2.0/src/studyctl/session/resume.py +113 -0
- studyctl-2.2.0/src/studyctl/session_state.py +186 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/settings.py +93 -74
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/sync.py +5 -1
- studyctl-2.2.0/src/studyctl/tmux.py +349 -0
- studyctl-2.2.0/src/studyctl/topics.py +45 -0
- studyctl-2.2.0/src/studyctl/tui/__init__.py +1 -0
- studyctl-2.2.0/src/studyctl/tui/__main__.py +5 -0
- studyctl-2.2.0/src/studyctl/tui/sidebar.py +545 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/app.py +35 -3
- studyctl-2.2.0/src/studyctl/web/auth.py +75 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/cards.py +9 -16
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/courses.py +5 -23
- studyctl-2.2.0/src/studyctl/web/routes/session.py +516 -0
- studyctl-2.2.0/src/studyctl/web/routes/terminal_proxy.py +165 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/server.py +0 -1
- studyctl-2.2.0/src/studyctl/web/static/components.js +686 -0
- studyctl-2.2.0/src/studyctl/web/static/index.html +1075 -0
- studyctl-2.2.0/src/studyctl/web/static/style.css +1360 -0
- studyctl-2.2.0/src/studyctl/web/static/sw.js +39 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/inter-latin-ext.woff2 +0 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/inter-latin.woff2 +0 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff +0 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/css/files/opendyslexic-latin-400-normal.woff2 +0 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/css/inter.css +27 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/css/opendyslexic-400.css +8 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/js/alpine-3.14.8.min.js +5 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/js/htmx-2.0.4.min.js +1 -0
- studyctl-2.2.0/src/studyctl/web/static/vendor/js/htmx-ext-sse-2.2.2.js +290 -0
- studyctl-2.2.0/tests/__init__.py +0 -0
- studyctl-2.2.0/tests/_helpers.py +67 -0
- studyctl-2.2.0/tests/harness/__init__.py +36 -0
- studyctl-2.2.0/tests/harness/agents.py +158 -0
- studyctl-2.2.0/tests/harness/study.py +343 -0
- studyctl-2.2.0/tests/harness/terminal.py +231 -0
- studyctl-2.2.0/tests/harness/tmux.py +193 -0
- studyctl-2.2.0/tests/test_agent_launcher.py +746 -0
- studyctl-2.2.0/tests/test_backlog_logic.py +254 -0
- studyctl-2.2.0/tests/test_backup.py +138 -0
- studyctl-2.2.0/tests/test_break_logic.py +217 -0
- studyctl-2.2.0/tests/test_briefing_logic.py +220 -0
- studyctl-2.2.0/tests/test_clean.py +221 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_cli.py +348 -96
- studyctl-2.2.0/tests/test_cli_session.py +136 -0
- studyctl-2.2.0/tests/test_content_cli.py +489 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_notebooklm.py +105 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_agents.py +64 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_config.py +71 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_core.py +1 -1
- studyctl-2.2.0/tests/test_doctor_integration.py +121 -0
- studyctl-2.2.0/tests/test_e2e_session_demo.py +552 -0
- studyctl-2.2.0/tests/test_flashcard_writer.py +201 -0
- studyctl-2.2.0/tests/test_harness_matrix.py +383 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_history.py +29 -26
- studyctl-2.2.0/tests/test_lan_auth.py +246 -0
- studyctl-2.2.0/tests/test_lazy_imports.py +40 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_mcp_tools.py +57 -10
- studyctl-2.2.0/tests/test_migrate_from_empty_db.py +107 -0
- studyctl-2.2.0/tests/test_orchestrator.py +78 -0
- studyctl-2.2.0/tests/test_parking.py +205 -0
- studyctl-2.2.0/tests/test_services_content.py +144 -0
- studyctl-2.2.0/tests/test_services_review.py +265 -0
- studyctl-2.2.0/tests/test_session_db_integration.py +328 -0
- studyctl-2.2.0/tests/test_session_state.py +180 -0
- studyctl-2.2.0/tests/test_sidebar_pilot.py +211 -0
- studyctl-2.2.0/tests/test_streaks_logic.py +161 -0
- studyctl-2.2.0/tests/test_study.py +195 -0
- studyctl-2.2.0/tests/test_study_integration.py +981 -0
- studyctl-2.2.0/tests/test_study_lifecycle.py +209 -0
- studyctl-2.2.0/tests/test_terminal_proxy.py +179 -0
- studyctl-2.2.0/tests/test_tmux.py +228 -0
- studyctl-2.2.0/tests/test_topic_resolver.py +227 -0
- studyctl-2.2.0/tests/test_topics_cli.py +215 -0
- studyctl-2.2.0/tests/test_uat_terminal.py +455 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_web_app.py +4 -3
- studyctl-2.2.0/tests/test_web_cli.py +143 -0
- studyctl-2.2.0/tests/test_web_session.py +375 -0
- studyctl-2.2.0/tests/test_web_sidebar.py +493 -0
- studyctl-2.2.0/tests/test_web_terminal.py +486 -0
- studyctl-2.2.0/tests/test_web_vendor.py +75 -0
- studyctl-2.1.0/src/studyctl/calendar.py +0 -140
- studyctl-2.1.0/src/studyctl/cli/_schedule.py +0 -125
- studyctl-2.1.0/src/studyctl/cli/_state.py +0 -69
- studyctl-2.1.0/src/studyctl/cli/_web.py +0 -228
- studyctl-2.1.0/src/studyctl/doctor/agents.py +0 -130
- studyctl-2.1.0/src/studyctl/history.py +0 -982
- studyctl-2.1.0/src/studyctl/scheduler.py +0 -242
- studyctl-2.1.0/src/studyctl/tui/__main__.py +0 -33
- studyctl-2.1.0/src/studyctl/tui/app.py +0 -395
- studyctl-2.1.0/src/studyctl/tui/study_cards.py +0 -396
- studyctl-2.1.0/src/studyctl/web/static/app.js +0 -853
- studyctl-2.1.0/src/studyctl/web/static/index.html +0 -50
- studyctl-2.1.0/src/studyctl/web/static/style.css +0 -657
- studyctl-2.1.0/src/studyctl/web/static/sw.js +0 -14
- studyctl-2.1.0/tests/test_calendar.py +0 -159
- studyctl-2.1.0/tests/test_doctor_integration.py +0 -181
- studyctl-2.1.0/tests/test_scheduler.py +0 -86
- {studyctl-2.1.0 → studyctl-2.2.0}/README.md +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_config.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_lazy.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/cli/_upgrade.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/markdown_converter.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/models.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/notebooklm_client.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/splitter.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/storage.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/content/syllabus.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/database.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/models.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/doctor/updates.py +0 -0
- {studyctl-2.1.0/tests → studyctl-2.2.0/src/studyctl/logic}/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/maintenance.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/mcp/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/mcp/server.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/pdf.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/review_loader.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/services/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/services/content.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/shared.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/state.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/__init__.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/artefacts.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/routes/history.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/static/icon-192.svg +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/static/icon-512.svg +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/src/studyctl/web/static/manifest.json +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_cli_doctor.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_cli_upgrade.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_splitter.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_storage.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_content_syllabus.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_database.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_deps.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_models.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_doctor_updates.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_install_mentor_prompt.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_review_db.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_review_loader.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_setup_wizard.py +0 -0
- {studyctl-2.1.0 → studyctl-2.2.0}/tests/test_shared.py +0 -0
- {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
|
-
#
|
|
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.
|
|
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.
|
|
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
|
+
]
|
|
@@ -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
|
+
)
|