From 2345b0082bd3afc7ec6c36633ee336cfc327705c Mon Sep 17 00:00:00 2001 From: Andy Taylor Date: Mon, 23 Mar 2026 09:53:19 +0000 Subject: [PATCH 01/38] compact: strip to 4 core features, prune CLI to 13 commands, fix doctor tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete TUI dashboard, scheduler, calendar, state CLI - Prune CLI: remove wins/streaks/resume/progress-map/bridges/teachback/schedule - Archive Crush, Amp, and 5 extra Claude agent definitions - Fix doctor integration tests: block network in autouse fixture - Restore state.py (needed for NotebookLM SyncState, not push/pull CLI) - Rewrite README: 426 β†’ 162 lines, focused on 4 core use cases - All tests green: 612 passed (247 studyctl + 357 session-tools + 8 doctor) --- .gitignore | 2 + README.md | 434 ++++------------- agents/{ => _archived}/amp/mcp.json | 0 .../claude/mentor-reviewer.yaml | 0 agents/{ => _archived}/claude/study-audio.md | 0 .../{ => _archived}/claude/study-generate.md | 0 agents/{ => _archived}/claude/study-setup.md | 0 .../claude/study-statusline.sh | 0 agents/{ => _archived}/crush/crush.json | 0 agents/amp/AGENTS.md | 88 ---- packages/studyctl/src/studyctl/calendar.py | 140 ------ .../studyctl/src/studyctl/cli/__init__.py | 23 +- packages/studyctl/src/studyctl/cli/_review.py | 444 +----------------- .../studyctl/src/studyctl/cli/_schedule.py | 125 ----- packages/studyctl/src/studyctl/cli/_state.py | 69 --- packages/studyctl/src/studyctl/cli/_web.py | 180 +------ packages/studyctl/src/studyctl/scheduler.py | 242 ---------- .../studyctl/src/studyctl/tui/__main__.py | 33 -- packages/studyctl/src/studyctl/tui/app.py | 395 ---------------- .../studyctl/src/studyctl/tui/study_cards.py | 396 ---------------- packages/studyctl/tests/test_calendar.py | 159 ------- packages/studyctl/tests/test_cli.py | 91 ---- .../studyctl/tests/test_doctor_integration.py | 116 ++--- packages/studyctl/tests/test_scheduler.py | 86 ---- 24 files changed, 121 insertions(+), 2902 deletions(-) rename agents/{ => _archived}/amp/mcp.json (100%) rename agents/{ => _archived}/claude/mentor-reviewer.yaml (100%) rename agents/{ => _archived}/claude/study-audio.md (100%) rename agents/{ => _archived}/claude/study-generate.md (100%) rename agents/{ => _archived}/claude/study-setup.md (100%) rename agents/{ => _archived}/claude/study-statusline.sh (100%) rename agents/{ => _archived}/crush/crush.json (100%) delete mode 100644 agents/amp/AGENTS.md delete mode 100644 packages/studyctl/src/studyctl/calendar.py delete mode 100644 packages/studyctl/src/studyctl/cli/_schedule.py delete mode 100644 packages/studyctl/src/studyctl/cli/_state.py delete mode 100644 packages/studyctl/src/studyctl/scheduler.py delete mode 100644 packages/studyctl/src/studyctl/tui/__main__.py delete mode 100644 packages/studyctl/src/studyctl/tui/app.py delete mode 100644 packages/studyctl/src/studyctl/tui/study_cards.py delete mode 100644 packages/studyctl/tests/test_calendar.py delete mode 100644 packages/studyctl/tests/test_scheduler.py diff --git a/.gitignore b/.gitignore index c912ee3cf..4410528fe 100644 --- a/.gitignore +++ b/.gitignore @@ -480,3 +480,5 @@ docs/plans/ # Artefacts (served from artefact-store, not committed to source repo) docs/artefacts/ +pytest-of-*/ +uv-*.lock diff --git a/README.md b/README.md index 27325eb3f..00e3fa56e 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,39 @@ # Socratic Study Mentor -> 🧠 An AuDHD-aware Socratic study mentor with AI session management +> 🧠 An AuDHD-aware study toolkit: Socratic questioning, content pipelines, spaced repetition, and AI session tracking. ![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue) ![License MIT](https://img.shields.io/badge/license-MIT-green) ![PyPI](https://img.shields.io/pypi/v/studyctl) ![CI](https://github.com/NetDevAutomate/socratic-study-mentor/actions/workflows/ci.yml/badge.svg) -## What is this? +## What Does It Do? -An open-source study toolkit designed specifically for AuDHD learners. It combines two CLI tools with AI mentor agents that teach through Socratic questioning rather than lectures β€” because our brains get dopamine from *discovering* answers, not being told them. The toolkit tracks your AI study sessions across 7 tools (Claude Code, Kiro CLI, Gemini, etc.) into a searchable database, then uses that history to power spaced repetition scheduling (1/3/7/14/30 day intervals), detect topics you're repeatedly struggling with, and show you concrete evidence of progress β€” because RSD and imposter syndrome mean we're terrible at recognising how far we've come. It also supports body doubling sessions, energy-adaptive study modes (low energy day? shorter chunks, more scaffolding), and hyperfocus guardrails. It also supports voice output β€” the mentor can speak questions aloud using high-quality local TTS, adding an auditory channel that helps AuDHD learners stay focused. The agents work with both kiro-cli and Claude Code, and the whole thing syncs across machines. If you're neurodivergent and self-teaching (especially career transitions), this might help β€” it's built by someone in exactly that position. +Four things: -## Who is this for? +1. **Socratic AI sessions** β€” Body doubling with AI mentors that ask questions instead of giving answers. Energy-adaptive (low day? shorter chunks, more scaffolding). +2. **Content pipeline** β€” Chunk eBooks and Obsidian notes into Google NotebookLM notebooks β†’ generate audio overviews, quizzes, and flashcards. +3. **Flashcard review** β€” Spaced repetition (SM-2) via a PWA web app. Works on phone, tablet, laptop. +4. **Session tracking** β€” Export AI coding sessions (Claude Code, Kiro, Gemini, etc.) into a searchable SQLite database. Track trends, find struggle topics, search across sessions. -- **AuDHD learners** who need structured, dopamine-friendly study approaches -- **Self-taught developers** who benefit from Socratic questioning over passive reading -- **Anyone** who wants to track AI study sessions and build spaced repetition into their learning +Built by a neurodivergent learner transitioning from networking to data engineering. If you're self-teaching and AuDHD, this might help. + +## Quick Start + +```bash +# Install +pip install studyctl agent-session-tools + +# Configure +studyctl setup # Interactive 3-question wizard +studyctl doctor # Verify everything is healthy + +# Use +studyctl content process SOURCE # Split PDF β†’ upload to NotebookLM +studyctl web # Launch flashcard/quiz PWA +session-export # Export AI sessions to SQLite +session-query search "decorators" # Search across all sessions +``` ## Architecture @@ -23,7 +41,7 @@ An open-source study toolkit designed specifically for AuDHD learners. It combin graph LR subgraph "Study Materials" OB[Obsidian Vault] - NLM[NotebookLM
optional] + NLM[NotebookLM] end subgraph "CLI Tools" @@ -33,390 +51,108 @@ graph LR end subgraph "AI Agents" - KA[kiro-cli] CA[Claude Code] + KA[Kiro CLI] GA[Gemini CLI] OA[OpenCode] - AA[Amp] end OB -->|sync| SC SC -->|upload| NLM SC -->|spaced repetition| DB AST -->|export sessions| DB - DB -->|query history| SC - KA -->|Socratic sessions| DB CA -->|Socratic sessions| DB + KA -->|Socratic sessions| DB GA -->|Socratic sessions| DB OA -->|Socratic sessions| DB - AA -->|Socratic sessions| DB -``` - -## Features - -**studyctl** β€” Study pipeline management -- Content pipeline β€” split PDFs by chapter, upload to NotebookLM, generate audio/video/flashcards -- Syllabus workflow β€” chunked podcast generation with autopilot and progress tracking -- Obsidian-to-NotebookLM β€” convert markdown notes to PDFs and upload in one step -- Sync Obsidian notes to Google NotebookLM notebooks -- Spaced repetition scheduling (1/3/7/14/30 day intervals) -- Struggle topic detection from session history -- Win tracking β€” see concepts you've mastered, fight imposter syndrome -- Calendar time-blocking β€” generate `.ics` study blocks from your review schedule -- Progress recording with confidence levels (struggling β†’ learning β†’ confident β†’ mastered) -- Concept graph β€” track how concepts relate (prerequisites, analogies, confusion risks) -- Prerequisite chain traversal β€” find the root cause when you're stuck -- Cross-machine state sync via SSH -- Scheduled auto-sync (launchd on macOS, cron on Linux) - -**agent-session-tools** β€” AI session management -- 7 source exporters: Claude Code, Kiro CLI, Gemini CLI, Aider, OpenCode, LiteLLM, RepoPrompt -- FTS5 full-text search across all sessions -- Hybrid semantic search (FTS + vector embeddings) -- Session classification and deduplication -- Study progress and energy tracking database -- Cross-machine database sync via SSH - -**AI Agents** β€” Socratic mentoring -- AuDHD-aware teaching methodology (questions > lectures) -- Energy-adaptive sessions (low/medium/high adjusts difficulty and chunk size) -- Emotional regulation check (calm/anxious/frustrated/flat/shutdown) -- Transition support with grounding rituals -- Parking lot for tangential thoughts -- Sensory environment adaptation -- Micro-celebrations for dopamine maintenance -- Interleaved review sessions -- End-of-session protocol: auto-record progress, suggest next review, offer calendar blocks -- Break reminders at 25/50/90 minute intervals -- Claude Code status line showing energy level, session timer, and context usage -- Networkβ†’Data Engineering concept bridges -- Body doubling session support -- Progress tracking across agents and machines -- Voice output via study-speak (kokoro-onnx TTS, am_michael voice) -- @speak-start/@speak-stop toggle for voice control -- Configurable voice, speed, and backend - -**MCP Integrations** β€” Optional calendar and reminder support -- Apple Calendar + Reminders (macOS) β€” native notifications for study time -- Google Calendar (cross-platform) β€” time-blocking via built-in connector or MCP server -- See [agents/mcp/README.md](agents/mcp/README.md) for setup - -## Quick Start - -### Install (pick one) - -```bash -# PyPI (recommended) -pip install studyctl - -# Homebrew (macOS) -brew install NetDevAutomate/studyctl/studyctl - -# From source -git clone https://github.com/NetDevAutomate/socratic-study-mentor.git -cd socratic-study-mentor -./scripts/install.sh -``` - -### 3 Steps to Start - -```bash -studyctl setup # Interactive configuration wizard -studyctl doctor # Verify everything is healthy -studyctl web # Launch the study web app -``` - -### Verify your install - -`studyctl doctor` checks your entire installation β€” Python version, packages, config, databases, optional dependencies, and agent definitions. Run it any time something feels off: - -```bash -studyctl doctor # Rich table with pass/warn/fail indicators -studyctl doctor --json # Machine-readable output (used by AI agents) -studyctl doctor --quiet # One-line summary -``` - -If issues are found, `studyctl upgrade` fixes what it can automatically: - -```bash -studyctl update # Check what's available (no changes) -studyctl upgrade # Apply fixes (packages, DB migrations, agents) -studyctl upgrade --dry-run # Preview changes first -``` - -### AI-guided setup - -If you're using an AI coding assistant (Claude Code, Kiro, Gemini CLI, etc.), the **install-mentor agent** can walk you through the entire setup conversationally. It uses `studyctl doctor --json` to detect issues and fix them automatically. See [agents/shared/install-mentor.md](agents/shared/install-mentor.md). - -### Optional extras - -```bash -pip install 'studyctl[all]' # Everything (web, tui, content, mcp, notebooklm) -pip install 'studyctl[web]' # FastAPI web UI -pip install 'studyctl[content]' # PDF splitting + NotebookLM content pipeline -pip install 'studyctl[mcp]' # MCP server for AI coding assistants -``` - -### From source (advanced) - -The install script registers CLI tools globally, sets up agent definitions for any detected AI tools, and optionally downloads the voice model for TTS support. - -```bash -./scripts/install.sh # Full install (interactive) -./scripts/install.sh --non-interactive # For Ansible/CI -./scripts/install.sh --tools-only # Just CLI tools -./scripts/install.sh --agents-only # Just agent definitions -``` - -Then run the interactive setup wizard: - -```bash -studyctl config init ``` -This asks three core questions: whether to enable knowledge bridging (leveraging topics you already know), NotebookLM integration, and Obsidian vault path. - -## Documentation Site - -Browse the full docs locally with AuDHD-friendly design (OpenDyslexic font toggle, Nord colour scheme, reading preferences): - -```bash -cd socratic-study-mentor -uv run mkdocs serve -# Open http://localhost:8000 -``` - -## Agent Support - -| Platform | Agent | Description | -|----------|-------|-------------| -| kiro-cli | `study-mentor` | Full study session management with spaced repetition and NotebookLM | -| Claude Code | `socratic-mentor` | Socratic questioning with AuDHD-aware pedagogy | -| Claude Code | `mentor-reviewer` | Autonomous code review with scoring and tutorial generation | -| Gemini CLI | `study-mentor` | Socratic study sessions with energy-adaptive teaching | -| OpenCode | `study-mentor` | AuDHD-aware study mentor with spaced repetition | -| Amp | (via AGENTS.md) | Socratic mentoring loaded automatically from project context | - -Start a session: - -```bash -# kiro-cli -kiro-cli chat --agent study-mentor - -# Claude Code -/agent socratic-mentor - -# Gemini CLI (subagent auto-detected) -gemini # then ask for study session - -# OpenCode -opencode # Tab to switch to study-mentor - -# Amp -amp # AGENTS.md loaded automatically -``` - -See [docs/agent-install.md](docs/agent-install.md) for setup details. - -## Optional Dependencies - -| Feature | Package | Install | -|---------|---------|---------| -| Content pipeline | `pymupdf`, `httpx` | `uv pip install studyctl[content]` | -| NotebookLM sync | `notebooklm-py` | `uv pip install studyctl[notebooklm]` | -| Semantic search | `sentence-transformers` | `uv pip install agent-session-tools[semantic]` | -| Token counting | `tiktoken` | `uv pip install agent-session-tools[tokens]` | -| TUI interface | `textual` | `uv pip install studyctl[tui]` | -| TTS voice output | `kokoro-onnx` | `uv tool install "./packages/agent-session-tools[tts]"` | - ## CLI Reference ### studyctl ```bash -# Study & review -studyctl review # Check spaced repetition due dates -studyctl struggles --days 30 # Find recurring struggle topics -studyctl wins --days 30 # Show your learning wins -studyctl streaks # Show study streak and consistency -studyctl resume # Where you left off β€” quick context reload -studyctl progress CONCEPT -t TOPIC -c LEVEL # Record progress on a concept -studyctl progress-map # Visual map of all tracked concepts -studyctl teachback CONCEPT -t TOPIC -s SCORES --type TYPE # Record teach-back score -studyctl teachback-history CONCEPT # Show teach-back score progression - -# Knowledge bridges -studyctl bridge add SRC TGT -s DOMAIN -t DOMAIN # Add a knowledge bridge -studyctl bridge list # List knowledge bridges - -# NotebookLM sync -studyctl sync [TOPIC] --all --dry-run # Sync notes to NotebookLM -studyctl status [TOPIC] # Show sync status -studyctl topics # List configured topics -studyctl audio TOPIC # Generate NotebookLM audio overview -studyctl dedup [TOPIC] --all --dry-run # Remove duplicate notebook sources - -# Health & updates -studyctl doctor # Check installation health -studyctl doctor --json # Machine-readable diagnostics -studyctl update # Check for available updates -studyctl upgrade # Apply updates (packages, DB, agents) -studyctl upgrade --dry-run # Preview what would change - -# Configuration & scheduling -studyctl config init # Interactive setup wizard -studyctl config show # Display current configuration -studyctl schedule install|remove|list # Manage scheduled jobs -studyctl schedule-blocks --start 14:00 # Generate .ics calendar study blocks -studyctl state push|pull|status|init # Cross-machine state sync - -# Interfaces -studyctl web [--port PORT] [--host HOST] # Launch study PWA web app -studyctl tui # Launch interactive TUI dashboard -studyctl docs serve|open|list|read # Browse and read documentation +# Content pipeline +studyctl content split SOURCE # Split PDF by chapters +studyctl content process SOURCE # Split + upload to NotebookLM +studyctl content autopilot # Generate next pending episode +studyctl content from-obsidian DIR # Markdown β†’ PDF β†’ NotebookLM + +# Review +studyctl review # Check spaced repetition due dates +studyctl struggles --days 30 # Find recurring struggle topics +studyctl web # Launch flashcard/quiz PWA + +# Sync +studyctl sync [TOPIC] --all # Sync notes to NotebookLM +studyctl status # Show sync status +studyctl topics # List configured topics + +# Health +studyctl doctor # Check installation health +studyctl setup # Interactive configuration ``` -### Web PWA (recommended for multi-device study) - -Launch the study web app with `studyctl web`. Accessible from any device on the network β€” phone, tablet, laptop. No extra dependencies. +### agent-session-tools ```bash -studyctl web # LAN accessible on port 8567 -studyctl web --port 9000 # Custom port -``` - -**Features:** -- Flashcard and quiz review with SM-2 spaced repetition -- Source/chapter filter β€” study specific chapters -- Card count limiter β€” choose 10/20/50/100/All per session -- Due cards indicator on course picker -- Session history with scores and 90-day study heatmap -- Retry wrong answers mode -- Pomodoro timer (25min study / 5min break with audio chime + notifications) -- Voice output via Web Speech API β€” reads questions/answers aloud, works on any device -- Voice selector dropdown β€” choose from all English voices available on your device -- Read-once button (speaker icon on card) or auto-voice toggle (header) -- OpenDyslexic font toggle for accessibility -- Dark/light theme toggle -- PWA installable β€” add to home screen on iOS/Android -- Keyboard shortcuts: `Space` flip, `Y`/`N` answer, `T` read aloud, `V` auto-voice, `S` skip, `R` retry - -Web UI Quiz Mode - -**Voice setup:** The PWA uses your device's built-in text-to-speech. For best quality, download enhanced voices: Settings β†’ Accessibility β†’ Spoken Content β†’ Voices β†’ English β†’ download Samantha (Enhanced) or Siri voices. - -**Config:** - -```yaml -# ~/.config/studyctl/config.yaml -review: - directories: - - ~/Desktop/ZTM-DE/downloads - - ~/Desktop/Python/downloads -tui: - theme: dracula # Textual theme (TUI only) - dyslexic_friendly: true # Wider spacing in TUI +session-export # Export AI sessions to SQLite +session-query search QUERY # Full-text search across sessions +session-query list --since 7d # List recent sessions +session-query stats # Database statistics +session-sync push/pull/sync HOST # Cross-machine sync ``` -### TUI Dashboard (terminal) - -Launch the terminal dashboard with `studyctl tui`. Requires the `[tui]` extra (`uv pip install studyctl[tui]`). - -**Tabs:** Dashboard, Review, Concepts, Sessions, StudyCards - -| Key | Action | -|-----|--------| -| `f` | Start flashcard session | -| `z` | Start quiz session | -| `space` | Flip card / submit answer | -| `y` / `n` | Mark correct / incorrect | -| `r` | Retry wrong answers (after session) | -| `v` | Toggle voice output | -| `o` | Toggle OpenDyslexic spacing | -| `h` | Show hint (quiz mode) | -| `q` | Quit | +## Agent Support -TUI Dashboard +| Platform | Agent | Start With | +|----------|-------|------------| +| Claude Code | `socratic-mentor` | `/agent socratic-mentor` | +| Kiro CLI | `study-mentor` | `kiro-cli chat --agent study-mentor` | +| Gemini CLI | `study-mentor` | `gemini` (auto-detected) | +| OpenCode | `study-mentor` | Tab to switch agent | -### studyctl content +## Web PWA -```bash -# PDF splitting -studyctl content split SOURCE [-o DIR] [-l LEVEL] # Split PDF by TOC bookmarks -studyctl content split SOURCE --ranges '1-30,31-60' # Split by page ranges - -# Full pipeline (split + upload) -studyctl content process SOURCE [-n NOTEBOOK_ID] # Split PDFs and upload to NotebookLM - -# NotebookLM management -studyctl content list [-n NOTEBOOK_ID] # List notebooks or sources -studyctl content generate -n ID -c '1-3' # Generate audio/video overviews -studyctl content download -n ID [-o DIR] # Download artifacts -studyctl content delete -n ID # Delete a notebook - -# Syllabus workflow (chunked podcast generation) -studyctl content syllabus -n ID [-o DIR] # Generate episode plan from sources -studyctl content autopilot [-o DIR] # Generate next pending episode -studyctl content status [-o DIR] # Show syllabus progress - -# Obsidian integration -studyctl content from-obsidian SOURCE_DIR # Convert markdown β†’ PDF β†’ NotebookLM -``` +Launch with `studyctl web`. Accessible from any device on the network. -Requires optional dependencies: `uv pip install studyctl[content]` for PDF splitting, plus `uv pip install studyctl[notebooklm]` for NotebookLM commands. +- Flashcard and quiz review with SM-2 spaced repetition +- Source/chapter filter and card count limiter +- Session history with 90-day study heatmap +- Pomodoro timer with audio chime +- Voice output via Web Speech API +- OpenDyslexic font toggle +- Dark/light theme +- PWA installable β€” add to home screen +- Keyboard: `Space` flip, `Y`/`N` answer, `T` read aloud -### agent-session-tools +## Optional Extras ```bash -session-export [--source SOURCE] # Export AI sessions to SQLite -session-query search QUERY # Full-text search across sessions -session-query list --since 7d # List recent sessions -session-query show SESSION_ID # Show session details -session-query context SESSION_ID # Generate context for resuming -session-query stats # Database statistics -session-sync push macmini # Push sessions to a named host -session-sync pull macbookpro # Pull sessions from a named host -session-sync sync work-macbook # Two-way sync with a host -session-sync endpoints # List all configured remote hosts -session-maint vacuum|reindex|schema # Database maintenance -tutor-checkpoint code --skill SKILL # Record study progress -study-speak TEXT # Speak text aloud using TTS -study-speak - < file.txt # Speak from stdin -study-speak TEXT -v af_heart -s 1.2 # Custom voice and speed +pip install 'studyctl[all]' # Everything +pip install 'studyctl[web]' # FastAPI web UI +pip install 'studyctl[content]' # PDF splitting + NotebookLM +pip install 'studyctl[notebooklm]' # NotebookLM API client ``` -> **Single config for all machines:** Define hosts once in `~/.config/studyctl/config.yaml`, deploy the same file everywhere. Both `studyctl state push/pull` and `session-sync push/pull/sync` read from it β€” local machine auto-detected by hostname. See [Setup Guide](docs/setup-guide.md#hosts--cross-machine-sync). - ## Documentation -- [Setup Guide](docs/setup-guide.md) β€” Installation, configuration, Obsidian setup -- [Install Mentor](agents/shared/install-mentor.md) β€” AI-guided setup agent (works with any AI coding tool) -- [Agent Installation](docs/agent-install.md) β€” AI agent setup for kiro-cli, Claude Code, Gemini CLI, OpenCode, and Amp -- [AuDHD Learning Philosophy](docs/audhd-learning-philosophy.md) β€” Why this exists and how it works -- [MCP Integrations](agents/mcp/README.md) β€” Calendar, reminders, and other MCP server configs -- [Voice Output Guide](docs/voice-output.md) β€” TTS setup, configuration, and agent integration -- [Roadmap](docs/roadmap.md) β€” Feature roadmap and release history -- [Contributing](CONTRIBUTING.md) β€” Development setup and contribution guide - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, and how to add new exporters or study topics. - -## Acknowledgements - -> **Special thanks to [Teng Lin](https://github.com/teng-lin)** for creating the excellent [notebooklm-py](https://github.com/teng-lin/notebooklm-py) library, which powers all NotebookLM integration across the study mentor ecosystem. His work in reverse-engineering and wrapping the NotebookLM API made the audio/video overview generation in [notebooklm-pdf-by-chapters](https://github.com/andytaylor/notebooklm-pdf-by-chapters) and [notebooklm-repo-artefacts](https://github.com/andytaylor/notebooklm-repo-artefacts) possible. +- [Setup Guide](docs/setup-guide.md) +- [Agent Installation](docs/agent-install.md) +- [AuDHD Learning Philosophy](docs/audhd-learning-philosophy.md) +- [Voice Output Guide](docs/voice-output.md) +- [Contributing](CONTRIBUTING.md) ## Generated Artefacts -> πŸ” **Explore this project** β€” AI-generated overviews via [Google NotebookLM](https://notebooklm.google.com) +> πŸ” AI-generated overviews via [Google NotebookLM](https://notebooklm.google.com) | | | |---|---| -| 🎧 **[Listen to the Audio Overview](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/)** | Two AI hosts discuss the project β€” great for commutes | -| 🎬 **[Watch the Video Overview](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/#video)** | Visual walkthrough of architecture and concepts | -| πŸ–ΌοΈ **[View the Infographic](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/#infographic)** | Architecture and flow at a glance | -| πŸ“Š **[Browse the Slide Deck](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/#slides)** | Presentation-ready project overview | +| 🎧 **[Audio Overview](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/)** | Two AI hosts discuss the project | +| 🎬 **[Video Overview](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/#video)** | Visual walkthrough | +| πŸ“Š **[Slide Deck](https://artefacts.netdevautomate.dev/socratic-study-mentor/artefacts/#slides)** | Presentation-ready overview | *Generated by [notebooklm-repo-artefacts](https://github.com/NetDevAutomate/notebooklm-repo-artefacts)* diff --git a/agents/amp/mcp.json b/agents/_archived/amp/mcp.json similarity index 100% rename from agents/amp/mcp.json rename to agents/_archived/amp/mcp.json diff --git a/agents/claude/mentor-reviewer.yaml b/agents/_archived/claude/mentor-reviewer.yaml similarity index 100% rename from agents/claude/mentor-reviewer.yaml rename to agents/_archived/claude/mentor-reviewer.yaml diff --git a/agents/claude/study-audio.md b/agents/_archived/claude/study-audio.md similarity index 100% rename from agents/claude/study-audio.md rename to agents/_archived/claude/study-audio.md diff --git a/agents/claude/study-generate.md b/agents/_archived/claude/study-generate.md similarity index 100% rename from agents/claude/study-generate.md rename to agents/_archived/claude/study-generate.md diff --git a/agents/claude/study-setup.md b/agents/_archived/claude/study-setup.md similarity index 100% rename from agents/claude/study-setup.md rename to agents/_archived/claude/study-setup.md diff --git a/agents/claude/study-statusline.sh b/agents/_archived/claude/study-statusline.sh similarity index 100% rename from agents/claude/study-statusline.sh rename to agents/_archived/claude/study-statusline.sh diff --git a/agents/crush/crush.json b/agents/_archived/crush/crush.json similarity index 100% rename from agents/crush/crush.json rename to agents/_archived/crush/crush.json diff --git a/agents/amp/AGENTS.md b/agents/amp/AGENTS.md deleted file mode 100644 index 106d399fc..000000000 --- a/agents/amp/AGENTS.md +++ /dev/null @@ -1,88 +0,0 @@ -# Socratic Study Mentor - -An AuDHD-aware Socratic study mentor for Python, Data Engineering, and SQL. - -## Shared Methodology - -See @agents/shared/session-protocol.md for session management workflows. -See @agents/shared/audhd-framework.md for AuDHD cognitive support patterns. -See @agents/shared/socratic-engine.md for questioning techniques and phases. -See @agents/shared/network-bridges.md for networkβ†’DE concept bridges. - -## Identity - -You are a strict Socratic mentor, not a code assistant. You teach through guided questioning and strategic information delivery. You understand AuDHD cognitive patterns deeply and use them as strengths. - -**Three pillars:** -1. Socratic questioning (70% questions / 30% strategic info drops) -2. AuDHD cognitive support (executive function scaffolding, RSD management, overload prevention) -3. Challenge-first mentality (evaluate before implementing, flag anti-patterns) - -## The Golden Rule - -**Never give direct answers. Guide discovery through productive struggle.** - -The effort of actively reasoning to an answer triggers dopamine release that keeps the ADHD brain engaged. Never short-circuit this loop. - -Exceptions: explicit "just show me", 4+ rounds stuck, pure syntax lookup, boilerplate. Even then β€” ALWAYS explain the WHY after. - -## Core Behaviour - -- End every response with exactly ONE question. Stop. Wait. -- Assess before teaching: "What do you already know? What have you tried?" -- Diagnostic over directive: guide to discover bugs, don't point them out -- Challenge suboptimal approaches before implementing -- Use networkβ†’DE analogies for every new concept (see shared network-bridges doc) - -## Session Start Protocol - -```bash -studyctl status # Check sync state -studyctl review # What's due for spaced repetition? -studyctl struggles # What topics keep coming up? -``` - -Then ask: "How's your energy today? (low/medium/high)" - -- **high**: Challenging questions, deeper exploration, new concepts -- **medium**: Balanced pace, standard Socratic flow -- **low**: Gentler questions, more scaffolding, shorter cycles - -## Session Types - -- **Study session:** review β†’ topic β†’ Socratic session β†’ record progress -- **Spaced review:** `studyctl review` β†’ quiz overdue topics (max 3) β†’ record -- **Body doubling:** agree goal + time β†’ start/mid/end check-ins -- **Ad-hoc question:** identify topic β†’ respond Socratically - -## AuDHD Support (Always Active) - -- **Bottom-up processing**: Concrete example first, then pattern, then principle -- **Executive function**: Explicit starting points, time-boxes, numbered steps, summaries every 3-5 exchanges -- **RSD/Imposter syndrome**: Reframe mistakes as exploration, bridge to infrastructure experience -- **Overload prevention**: Max 3-4 concepts, tables over prose, TL;DR at top, mermaid diagrams -- **Hyperfocus**: Time warnings, exit points, hydration/food reminders -- **Emotional regulation**: Micro-celebrations for genuine progress, sensory checks at 45+ min -- **Transition support**: Summarise when switching, parking lot for tangents - -## End-of-Session Protocol - -1. Record progress: `studyctl progress "" -t -c ` -2. Suggest next review based on spaced repetition intervals -3. Offer calendar blocks: `studyctl schedule-blocks` -4. If session was 25+ min, remind to take a break -5. Parking lot: note tangential topics worth revisiting - -## Break Reminders - -- 25 min: "Good time for a 5-minute break." -- 50 min: "Take a proper break before continuing." -- 90 min: "You should stop here and come back fresh." - -## Anti-Patterns to Avoid - -- The Encyclopedia Response (too much info) -- The Infinite Question Loop (no substance) -- The Rubber Stamp (accepting vague answers) -- The Servant (implementing without evaluating) -- Praise without substance diff --git a/packages/studyctl/src/studyctl/calendar.py b/packages/studyctl/src/studyctl/calendar.py deleted file mode 100644 index 6b91313e4..000000000 --- a/packages/studyctl/src/studyctl/calendar.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Calendar time-blocking via ICS file generation.""" - -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from pathlib import Path -from uuid import uuid4 - -# Spaced repetition review types with suggested durations (minutes) -REVIEW_DURATIONS: dict[str, int] = { - "5-min recall quiz": 10, - "10-min Socratic review": 15, - "15-min deep review": 20, - "Apply to new problem": 30, - "Teach-back session": 30, -} - - -def _ics_dt(dt: datetime) -> str: - """Format datetime as ICS DTSTART/DTEND value with UTC suffix.""" - if dt.tzinfo is not None: - dt = dt.astimezone(UTC) - return dt.strftime("%Y%m%dT%H%M%SZ") - return dt.strftime("%Y%m%dT%H%M%S") - - -def _escape(text: str) -> str: - """Escape text for ICS fields.""" - return text.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n") - - -def generate_event( - topic: str, - review_type: str, - start: datetime, - duration_min: int | None = None, -) -> str: - """Generate a single VEVENT block.""" - dur = duration_min or REVIEW_DURATIONS.get(review_type, 20) - end = start + timedelta(minutes=dur) - uid = f"{uuid4()}@studyctl" - now = datetime.now(UTC) - - return ( - "BEGIN:VEVENT\r\n" - f"UID:{uid}\r\n" - f"DTSTAMP:{_ics_dt(now)}\r\n" - f"DTSTART:{_ics_dt(start)}\r\n" - f"DTEND:{_ics_dt(end)}\r\n" - f"SUMMARY:{_escape(f'Study: {topic} ({review_type})')}\r\n" - f"DESCRIPTION:{_escape(f'Spaced repetition: {review_type} for {topic}')}\r\n" - "STATUS:CONFIRMED\r\n" - "BEGIN:VALARM\r\n" - "TRIGGER:-PT5M\r\n" - "ACTION:DISPLAY\r\n" - f"DESCRIPTION:{_escape(f'Study time: {topic}')}\r\n" - "END:VALARM\r\n" - "END:VEVENT\r\n" - ) - - -def generate_ics(events: list[dict]) -> str: - """Generate a complete .ics file from a list of event dicts. - - Each dict: {topic, review_type, start, duration_min?} - """ - lines = ( - "BEGIN:VCALENDAR\r\n" - "VERSION:2.0\r\n" - "PRODID:-//studyctl//Socratic Study Mentor//EN\r\n" - "CALSCALE:GREGORIAN\r\n" - "METHOD:PUBLISH\r\n" - ) - for evt in events: - lines += generate_event( - topic=evt["topic"], - review_type=evt["review_type"], - start=evt["start"], - duration_min=evt.get("duration_min"), - ) - lines += "END:VCALENDAR\r\n" - return lines - - -def schedule_reviews( - due_items: list[dict], - start_time: datetime | None = None, - gap_minutes: int = 10, -) -> list[dict]: - """Convert spaced repetition due items into scheduled events. - - Args: - due_items: From history.spaced_repetition_due() β€” [{topic, review_type, ...}] - start_time: When to start scheduling (default: next hour) - gap_minutes: Gap between sessions - - Returns: - List of event dicts ready for generate_ics() - """ - if not start_time: - now = datetime.now() - start_time = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) - - events = [] - current = start_time - for item in due_items: - review_type = item.get("review_type", "15-min deep review") - duration = REVIEW_DURATIONS.get(review_type, 20) - events.append( - { - "topic": item["topic"], - "review_type": review_type, - "start": current, - "duration_min": duration, - } - ) - current += timedelta(minutes=duration + gap_minutes) - return events - - -def write_ics( - events: list[dict], - output_dir: Path | None = None, -) -> Path: - """Write events to an .ics file. - - Args: - events: Event dicts from schedule_reviews() - output_dir: Directory to write to (default: ~/Downloads) - - Returns: - Path to the written .ics file - """ - output_dir = output_dir or Path.home() / "Downloads" - output_dir.mkdir(parents=True, exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d-%H%M") - path = output_dir / f"study-blocks-{timestamp}.ics" - path.write_text(generate_ics(events)) - return path diff --git a/packages/studyctl/src/studyctl/cli/__init__.py b/packages/studyctl/src/studyctl/cli/__init__.py index 28711cf48..ba1185279 100644 --- a/packages/studyctl/src/studyctl/cli/__init__.py +++ b/packages/studyctl/src/studyctl/cli/__init__.py @@ -1,4 +1,4 @@ -"""studyctl CLI β€” sync, plan, and schedule study sessions. +"""studyctl CLI β€” AuDHD study pipeline. Split into submodules with LazyGroup for fast startup. Commands are only imported when invoked. @@ -20,32 +20,17 @@ "audio": "studyctl.cli._sync:audio", "topics": "studyctl.cli._sync:topics", "dedup": "studyctl.cli._sync:dedup", - # _state.py β€” cross-machine state - "state": "studyctl.cli._state:state_group", # _setup.py β€” first-run setup wizard "setup": "studyctl.cli._setup:setup", # _config.py β€” configuration "config": "studyctl.cli._config:config_group", - # _schedule.py β€” job scheduling + calendar - "schedule": "studyctl.cli._schedule:schedule_group", - "schedule-blocks": "studyctl.cli._schedule:schedule_blocks", - # _review.py β€” spaced repetition, progress, teachback, bridges + # _review.py β€” spaced repetition "review": "studyctl.cli._review:review", "struggles": "studyctl.cli._review:struggles", - "wins": "studyctl.cli._review:wins", - "progress": "studyctl.cli._review:progress", - "resume": "studyctl.cli._review:resume", - "streaks": "studyctl.cli._review:streaks", - "progress-map": "studyctl.cli._review:progress_map", - "teachback": "studyctl.cli._review:teachback", - "teachback-history": "studyctl.cli._review:teachback_history_cmd", - "bridge": "studyctl.cli._review:bridge_group", # _content.py β€” content pipeline (pdf splitting, NotebookLM, syllabus) "content": "studyctl.cli._content:content_group", - # _web.py β€” web UI, TUI, docs + # _web.py β€” web UI "web": "studyctl.cli._web:web", - "tui": "studyctl.cli._web:tui", - "docs": "studyctl.cli._web:docs_group", # _doctor.py β€” diagnostic health checks "doctor": "studyctl.cli._doctor:doctor", # _upgrade.py β€” update check + upgrade apply @@ -55,7 +40,7 @@ ) @click.version_option() def cli() -> None: - """studyctl β€” AuDHD study pipeline: Obsidian\u2192NotebookLM sync and study management.""" + """studyctl β€” AuDHD study pipeline: content, review, and session tracking.""" __all__ = ["cli"] diff --git a/packages/studyctl/src/studyctl/cli/_review.py b/packages/studyctl/src/studyctl/cli/_review.py index a76a496d0..1cc803b85 100644 --- a/packages/studyctl/src/studyctl/cli/_review.py +++ b/packages/studyctl/src/studyctl/cli/_review.py @@ -1,18 +1,12 @@ -"""Review commands β€” spaced repetition, progress, teachback, bridges.""" +"""Review commands β€” spaced repetition and struggle detection.""" from __future__ import annotations -from pathlib import Path - import click from rich.table import Table from studyctl.cli._shared import TOPIC_KEYWORDS, console from studyctl.history import ( - get_bridges, - get_teachback_history, - record_bridge, - record_teachback, spaced_repetition_due, struggle_topics, ) @@ -53,439 +47,3 @@ def struggles(days: int) -> None: for t in topics: bar = "\u2588" * min(t["mentions"], 20) console.print(f" [cyan]{t['topic']:20s}[/cyan] {bar} ({t['mentions']} mentions)") - - -@click.command() -@click.option("--days", "-d", default=30, help="Look back period in days.") -def wins(days: int) -> None: - """Show your learning wins \u2014 concepts you've mastered.""" - from studyctl.history import get_progress_summary, get_wins - - summary = get_progress_summary() - if not summary: - console.print("[dim]No progress data yet. Use your study mentor to start tracking![/dim]") - return - - total = summary.get("total", 0) - mastered = summary.get("mastered", 0) - confident = summary.get("confident", 0) - learning = summary.get("learning", 0) - struggling = summary.get("struggling", 0) - - console.print("\n[bold]\U0001f4ca Progress Overview[/bold]") - console.print( - f" \U0001f3c6 Mastered: {mastered} " - f"\u2705 Confident: {confident} " - f"\U0001f4d6 Learning: {learning} " - f"\U0001f527 Struggling: {struggling} " - f"({total} total)" - ) - - recent = get_wins(days=days) - if recent: - console.print(f"\n[bold green]\U0001f389 Wins in the last {days} days:[/bold green]") - for w in recent: - emoji = "\U0001f3c6" if w["confidence"] == "mastered" else "\u2705" - console.print( - f" {emoji} [bold]{w['concept']}[/bold] ({w['topic']}) " - f"\u2014 {w['session_count']} sessions" - ) - else: - console.print(f"\n[dim]No new wins in the last {days} days. Keep going! \U0001f4aa[/dim]") - - -@click.command() -@click.argument("concept") -@click.option("--topic", "-t", required=True, help="Study topic.") -@click.option( - "--confidence", - "-c", - type=click.Choice(["struggling", "learning", "confident", "mastered"]), - required=True, - help="Current confidence level.", -) -@click.option("--notes", "-n", default=None, help="Optional notes.") -def progress(concept: str, topic: str, confidence: str, notes: str | None) -> None: - """Record progress on a concept.""" - from studyctl.history import record_progress - - if record_progress(topic, concept, confidence, notes=notes): - emoji = { - "struggling": "\U0001f527", - "learning": "\U0001f4d6", - "confident": "\u2705", - "mastered": "\U0001f3c6", - } - console.print( - f"{emoji.get(confidence, '\U0001f4dd')} Recorded: " - f"[bold]{concept}[/bold] ({topic}) \u2192 {confidence}" - ) - else: - console.print("[red]Failed to record progress. Check your session database path.[/red]") - - -@click.command() -def resume() -> None: - """Show where you left off \u2014 last session summary for quick context reload.""" - from studyctl.history import ( - check_medication_window, - get_last_session_summary, - get_study_streaks, - ) - - summary = get_last_session_summary() - if not summary: - console.print("[dim]No sessions found. Start a study session to begin tracking![/dim]") - return - - console.print("[bold]Where you left off:[/bold]\n") - - source = summary["source"].replace("_", " ").title() - updated = summary.get("updated") or summary["started"] - if updated: - updated = updated[:16].replace("T", " ") - console.print(f" Last session: [cyan]{source}[/cyan] ({updated})") - - if summary["topics_covered"]: - topics_str = ", ".join(summary["topics_covered"]) - console.print(f" Topics: [bold]{topics_str}[/bold]") - - if summary["last_message_preview"]: - preview = summary["last_message_preview"] - if len(preview) > 150: - preview = preview[:150] + "..." - console.print(f" Context: [dim]{preview}[/dim]") - - if summary["concepts_in_progress"]: - console.print("\n[bold]In progress:[/bold]") - for c in summary["concepts_in_progress"]: - emoji = "\U0001f527" if c["confidence"] == "struggling" else "\U0001f4d6" - console.print(f" {emoji} {c['concept']} ({c['topic']}) \u2014 {c['confidence']}") - - streak_data = get_study_streaks() - if streak_data["current_streak"] > 0: - console.print( - f"\n Streak: [bold green]{streak_data['current_streak']} days[/bold green]" - f" (best: {streak_data['longest_streak']})" - f" | This week: {streak_data['sessions_this_week']} sessions" - ) - - # Medication window (if configured) - raw_config = {} - config_path = Path.home() / ".config" / "studyctl" / "config.yaml" - if config_path.exists(): - import yaml - - raw_config = yaml.safe_load(config_path.read_text()) or {} - med_config = raw_config.get("medication") - if med_config: - med = check_medication_window(med_config) - if med: - phase_emoji = { - "onset": "\U0001f48a", - "peak": "\U0001f9e0", - "tapering": "\U0001f4c9", - "worn_off": "\U0001f634", - } - emoji = phase_emoji.get(med["phase"], "\U0001f48a") - console.print( - f"\n {emoji} Meds: [bold]{med['phase']}[/bold] \u2014 {med['recommendation']}" - ) - - -@click.command() -def streaks() -> None: - """Show your study streak and consistency stats.""" - from studyctl.history import get_study_streaks - - data = get_study_streaks() - if not data.get("last_session_date"): - console.print("[dim]No study sessions found yet.[/dim]") - return - - console.print("\n[bold]Study Consistency[/bold]\n") - - current = data["current_streak"] - longest = data["longest_streak"] - fire = "\U0001f525" if current >= 3 else "" - console.print(f" Current streak: [bold green]{current} days[/bold green] {fire}") - console.print(f" Longest streak: [bold]{longest} days[/bold]") - console.print(f" Study days (last 90): [bold]{data['total_days']}[/bold]") - console.print(f" Sessions this week: [bold]{data['sessions_this_week']}[/bold]") - console.print(f" Last session: {data['last_session_date']}") - - consistency = data["total_days"] / 90 * 100 - bar_len = int(consistency / 5) - bar = "\u2588" * bar_len + "\u2591" * (20 - bar_len) - console.print(f"\n Consistency: [{bar}] {consistency:.0f}%") - - if current == 0: - console.print( - "\n [dim]No session today or yesterday. Start one to keep your streak going![/dim]" - ) - - -@click.command("progress-map") -def progress_map() -> None: - """Show a visual progress map of all tracked concepts.""" - from studyctl.history import get_progress_for_map - - entries = get_progress_for_map() - if not entries: - console.print( - "[dim]No progress data yet." - " Use your study mentor and 'studyctl progress' to start tracking![/dim]" - ) - return - - by_topic: dict[str, list[dict]] = {} - for entry in entries: - by_topic.setdefault(entry["topic"], []).append(entry) - - conf_style = { - "mastered": ("\U0001f3c6", "bold green"), - "confident": ("\u2705", "green"), - "learning": ("\U0001f4d6", "yellow"), - "struggling": ("\U0001f527", "red"), - } - - console.print("\n[bold]Progress Map[/bold]\n") - - for topic, concepts in sorted(by_topic.items()): - console.print(f" [bold cyan]{topic}[/bold cyan]") - for c in concepts: - emoji, style = conf_style.get(c["confidence"], ("\U0001f4dd", "dim")) - sessions = c["session_count"] - console.print( - f" {emoji} [{style}]{c['concept']}[/{style}]" - f" \u2014 {c['confidence']} ({sessions} sessions)" - ) - console.print() - - console.print("[bold]Mermaid diagram (paste into any Mermaid renderer):[/bold]\n") - console.print("```mermaid") - console.print("graph TD") - for topic, concepts in sorted(by_topic.items()): - topic_id = topic.replace(" ", "_").replace("-", "_") - console.print(f' {topic_id}["{topic}"]') - for c in concepts: - concept_id = f"{topic_id}_{c['concept'].replace(' ', '_').replace('-', '_')}" - conf = c["confidence"] - console.print(f' {topic_id} --> {concept_id}["{c["concept"]}"]') - console.print(f" class {concept_id} {conf}") - console.print() - console.print(" classDef mastered fill:#10b981,color:#fff") - console.print(" classDef confident fill:#3b82f6,color:#fff") - console.print(" classDef learning fill:#f59e0b,color:#000") - console.print(" classDef struggling fill:#ef4444,color:#fff") - console.print("```") - - -@click.command() -@click.argument("concept") -@click.option("--topic", "-t", required=True, help="Study topic.") -@click.option( - "--score", - "-s", - required=True, - help="Comma-separated scores: accuracy,own_words,structure,depth,transfer (each 1-4).", -) -@click.option( - "--type", - "review_type", - type=click.Choice(["micro", "structured", "transfer", "full"]), - required=True, - help="Type of teach-back review.", -) -@click.option("--angle", "-a", default=None, help="Question angle used (e.g. bloom_apply).") -@click.option("--notes", "-n", default=None, help="Optional notes.") -def teachback( - concept: str, - topic: str, - score: str, - review_type: str, - angle: str | None, - notes: str | None, -) -> None: - """Record a teach-back score for a concept. - - Example: studyctl teachback "Spark partitioning" -t spark --score "3,3,4,3,2" --type structured - """ - parts = score.split(",") - if len(parts) != 5: - console.print( - "[red]Score must be 5 comma-separated values" - " (accuracy,own_words,structure,depth,transfer)[/red]" - ) - raise SystemExit(1) - - try: - scores = tuple(int(p.strip()) for p in parts) - except ValueError: - console.print("[red]Each score must be an integer 1-4[/red]") - raise SystemExit(1) from None - - for s in scores: - if not 1 <= s <= 4: - console.print("[red]Each score must be between 1 and 4[/red]") - raise SystemExit(1) - - if record_teachback(concept, topic, scores, review_type, angle=angle, notes=notes): # type: ignore[arg-type] - total = sum(scores) - if total >= 18: - label = "Mastery demonstrated" - style = "bold green" - elif total >= 14: - label = "Solid understanding" - style = "green" - elif total >= 9: - label = "Partial understanding" - style = "yellow" - else: - label = "Memorised, not understood" - style = "red" - - console.print( - f"[{style}]{label}[/{style}] \u2014 [bold]{concept}[/bold] ({topic}): {total}/20" - ) - a, o, s, d, t = scores - console.print(f" Accuracy: {a} Own Words: {o} Structure: {s} Depth: {d} Transfer: {t}") - else: - console.print("[red]Failed to record teach-back. Check your session database.[/red]") - - -@click.command("teachback-history") -@click.argument("concept") -@click.option("--topic", "-t", default=None, help="Filter by topic.") -def teachback_history_cmd(concept: str, topic: str | None) -> None: - """Show teach-back score progression for a concept.""" - history = get_teachback_history(concept, topic) - if not history: - console.print(f"[dim]No teach-back history for '{concept}'[/dim]") - return - - table = Table(title=f"Teach-Back History: {concept}") - table.add_column("Date", style="dim") - table.add_column("Type") - table.add_column("Total", justify="right", style="bold") - table.add_column("A", justify="center") - table.add_column("O", justify="center") - table.add_column("S", justify="center") - table.add_column("D", justify="center") - table.add_column("T", justify="center") - table.add_column("Angle", style="dim") - - for entry in history: - total = entry["total_score"] - if total >= 18: - total_style = "[bold green]" - elif total >= 14: - total_style = "[green]" - elif total >= 9: - total_style = "[yellow]" - else: - total_style = "[red]" - total_str = f"{total_style}{total}[/]" - - date = entry["created_at"][:10] if entry["created_at"] else "?" - table.add_row( - date, - entry["review_type"], - total_str, - str(entry["score_accuracy"] or ""), - str(entry["score_own_words"] or ""), - str(entry["score_structure"] or ""), - str(entry["score_depth"] or ""), - str(entry["score_transfer"] or ""), - entry["question_angle"] or "", - ) - - console.print(table) - - -# --- Knowledge bridges --- - - -@click.group(name="bridge") -def bridge_group() -> None: - """Manage knowledge bridges between domains.""" - - -@bridge_group.command(name="add") -@click.argument("source") -@click.argument("target") -@click.option("--source-domain", "-s", required=True, help="Source domain (e.g. networking).") -@click.option("--target-domain", "-t", required=True, help="Target domain (e.g. spark).") -@click.option("--mapping", "-m", default=None, help="Why they map (structural similarity).") -@click.option( - "--quality", - "-q", - type=click.Choice(["proposed", "validated", "effective", "misleading", "rejected"]), - default="validated", - help="Bridge quality.", -) -def bridge_add( - source: str, - target: str, - source_domain: str, - target_domain: str, - mapping: str | None, - quality: str, -) -> None: - """Add a knowledge bridge between two concepts. - - Example: studyctl bridge add "ECMP load balancing" "Spark partition distribution" - -s networking -t spark -m "distribute work across parallel processors" - """ - if record_bridge(source, source_domain, target, target_domain, mapping, quality, "student"): - console.print( - f"[green]Bridge added:[/green] " - f"[bold]{source}[/bold] ({source_domain}) " - f"-> [bold]{target}[/bold] ({target_domain})" - ) - else: - console.print("[red]Failed to add bridge. Check your session database.[/red]") - - -@bridge_group.command(name="list") -@click.option("--source-domain", "-s", default=None, help="Filter by source domain.") -@click.option("--target-domain", "-t", default=None, help="Filter by target domain.") -@click.option("--quality", "-q", default=None, help="Filter by quality.") -def bridge_list(source_domain: str | None, target_domain: str | None, quality: str | None) -> None: - """List knowledge bridges.""" - bridges = get_bridges(target_domain=target_domain, source_domain=source_domain, quality=quality) - if not bridges: - console.print("[dim]No bridges found. Use 'studyctl bridge add' to create some.[/dim]") - return - - table = Table(title="Knowledge Bridges") - table.add_column("Source", style="cyan") - table.add_column("Domain", style="dim") - table.add_column("Target", style="bold") - table.add_column("Domain", style="dim") - table.add_column("Quality") - table.add_column("Used", justify="right") - table.add_column("Helpful", justify="right") - - quality_style = { - "effective": "bold green", - "validated": "green", - "proposed": "yellow", - "misleading": "red", - "rejected": "dim red", - } - - for b in bridges: - q = b["quality"] - style = quality_style.get(q, "dim") - table.add_row( - b["source_concept"], - b["source_domain"], - b["target_concept"], - b["target_domain"], - f"[{style}]{q}[/{style}]", - str(b["times_used"]), - str(b["times_helpful"]), - ) - - console.print(table) diff --git a/packages/studyctl/src/studyctl/cli/_schedule.py b/packages/studyctl/src/studyctl/cli/_schedule.py deleted file mode 100644 index 0f6632e4c..000000000 --- a/packages/studyctl/src/studyctl/cli/_schedule.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Schedule commands β€” job management and calendar blocks.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from pathlib import Path - -import click - -from studyctl.cli._shared import TOPIC_KEYWORDS, console -from studyctl.scheduler import ( - Job, - install_all, - install_job, - list_jobs, - remove_all, - remove_job, -) - - -@click.group(name="schedule") -def schedule_group() -> None: - """Manage scheduled jobs (launchd on macOS, cron on Linux).""" - - -@schedule_group.command(name="install") -@click.option("--username", "-u", help="Username for paths (default: current user)") -def schedule_install(username: str | None) -> None: - """Install all scheduled jobs.""" - installed = install_all(username) - for name in installed: - console.print(f"[green]\u2713[/green] Installed {name}") - if not installed: - console.print("[dim]No jobs installed[/dim]") - - -@schedule_group.command(name="remove") -def schedule_remove() -> None: - """Remove all scheduled jobs.""" - removed = remove_all() - for name in removed: - console.print(f"[green]\u2713[/green] Removed {name}") - - -@schedule_group.command(name="list") -def schedule_list() -> None: - """List active scheduled jobs.""" - jobs = list_jobs() - if not jobs: - console.print("[dim]No studyctl jobs scheduled[/dim]") - console.print("Run: studyctl schedule install") - return - for j in jobs: - console.print(f" {j['name']}: {j.get('status', j.get('cron', '?'))}") - - -@schedule_group.command(name="add") -@click.argument("name") -@click.argument("command") -@click.argument("schedule") -@click.option("--username", "-u", help="Username for paths") -def schedule_add(name: str, command: str, schedule: str, username: str | None) -> None: - """Add a custom scheduled job. - - Example: studyctl schedule add my-backup "~/scripts/backup.sh" "daily 3am" - """ - job = Job(name=name, command=command, schedule=schedule) - if install_job(job, username): - console.print(f"[green]\u2713[/green] Added {name} ({schedule})") - else: - console.print(f"[red]Failed to add {name}[/red]") - - -@schedule_group.command(name="delete") -@click.argument("name") -def schedule_delete(name: str) -> None: - """Remove a specific scheduled job.""" - job = Job(name=name, command="", schedule="") - if remove_job(job): - console.print(f"[green]\u2713[/green] Removed {name}") - - -@click.command("schedule-blocks") -@click.option("--start", "-s", default=None, help="Start time (HH:MM, default: next hour).") -@click.option("--gap", "-g", default=10, help="Minutes between sessions.") -@click.option("--output", "-o", default=None, type=click.Path(), help="Output directory.") -@click.option("--open/--no-open", "open_file", default=True, help="Open .ics file after creation.") -def schedule_blocks(start: str | None, gap: int, output: str | None, open_file: bool) -> None: - """Create calendar time blocks from spaced repetition schedule.""" - from studyctl.calendar import schedule_reviews, write_ics - from studyctl.history import spaced_repetition_due - - due = spaced_repetition_due(TOPIC_KEYWORDS) - if not due: - console.print("[green]Nothing due for review! \N{PARTY POPPER}[/green]") - return - - start_time = None - if start: - now = datetime.now() - h, m = start.split(":") - start_time = now.replace(hour=int(h), minute=int(m), second=0, microsecond=0) - if start_time < now: - start_time += timedelta(days=1) - - events = schedule_reviews(due, start_time=start_time, gap_minutes=gap) - output_dir = Path(output) if output else None - path = write_ics(events, output_dir=output_dir) - - console.print(f"\n[bold]\N{CALENDAR} Created {len(events)} study blocks:[/bold]") - for evt in events: - t = evt["start"].strftime("%H:%M") - console.print( - f" {t} \u2014 {evt['topic']} ({evt['review_type']}, {evt['duration_min']}min)" - ) - console.print(f"\n[dim]Saved to: {path}[/dim]") - - if open_file: - import platform - import subprocess - - if platform.system() == "Darwin": - subprocess.run(["open", str(path)], check=False) - elif platform.system() == "Linux": - subprocess.run(["xdg-open", str(path)], check=False) diff --git a/packages/studyctl/src/studyctl/cli/_state.py b/packages/studyctl/src/studyctl/cli/_state.py deleted file mode 100644 index 3b21b2cf9..000000000 --- a/packages/studyctl/src/studyctl/cli/_state.py +++ /dev/null @@ -1,69 +0,0 @@ -"""State commands β€” cross-machine state sync.""" - -from __future__ import annotations - -import click - -from studyctl.cli._shared import console -from studyctl.shared import init_config, pull_state, push_state -from studyctl.shared import sync_status as shared_sync_status - - -@click.group(name="state") -def state_group() -> None: - """Cross-machine state sync (via Obsidian vault).""" - - -@state_group.command(name="push") -@click.argument("remote", required=False) -def state_push(remote: str | None) -> None: - """Push local progress and sync state to remote machine(s).""" - try: - pushed = push_state(remote) - except FileNotFoundError as e: - console.print(f"[red]{e}[/red]") - console.print("Run 'studyctl state init' first") - raise SystemExit(1) from None - if pushed: - for f in pushed: - console.print(f"[green]\u2713[/green] {f}") - else: - console.print("[dim]Everything up to date (or no remotes reachable)[/dim]") - - -@state_group.command(name="pull") -@click.argument("remote", required=False) -def state_pull(remote: str | None) -> None: - """Pull progress and sync state from remote machine(s).""" - try: - pulled = pull_state(remote) - except FileNotFoundError as e: - console.print(f"[red]{e}[/red]") - raise SystemExit(1) from None - if pulled: - for f in pulled: - console.print(f"[green]\u2713[/green] {f}") - else: - console.print("[dim]Everything up to date (or no remotes reachable)[/dim]") - - -@state_group.command(name="status") -def state_status_cmd() -> None: - """Check sync config and remote connectivity.""" - info = shared_sync_status() - if not info["configured"]: - console.print("[red]Not configured.[/red] Run: studyctl state init") - console.print(f"Config: {info['config_path']}") - return - console.print(f"Local machine: [bold]{info['local']}[/bold]") - for name, r in info["remotes"].items(): - status = "[green]reachable[/green]" if r["reachable"] else "[red]unreachable[/red]" - console.print(f" {name} ({r['host']}): {status}") - - -@state_group.command(name="init") -def state_init() -> None: - """Create default sync config.""" - path = init_config() - console.print(f"[green]\u2713[/green] Config at {path}") - console.print("Edit remotes to match your machines, then run 'studyctl state status'") diff --git a/packages/studyctl/src/studyctl/cli/_web.py b/packages/studyctl/src/studyctl/cli/_web.py index a45bb185b..b43162e29 100644 --- a/packages/studyctl/src/studyctl/cli/_web.py +++ b/packages/studyctl/src/studyctl/cli/_web.py @@ -1,49 +1,14 @@ -"""Web, TUI, and docs commands.""" +"""Web server command β€” study PWA.""" from __future__ import annotations from pathlib import Path import click -from rich.table import Table from studyctl.cli._shared import console -def _find_docs_dir() -> Path: - """Find the docs directory relative to the package.""" - candidate = Path(__file__).resolve().parent.parent - for _ in range(6): - if (candidate / "mkdocs.yml").exists(): - return candidate / "docs" - candidate = candidate.parent - for p in [ - Path.home() / "code" / "personal" / "tools" / "socratic-study-mentor" / "docs", - Path.home() / ".agents" / "shared", - ]: - if p.exists(): - return p - msg = "Could not find docs directory. Run from the repo or set STUDYCTL_DOCS_DIR." - raise click.ClickException(msg) - - -def _strip_markdown(text: str) -> str: - """Strip markdown formatting for TTS-friendly plain text.""" - import re - - text = re.sub(r"```[\s\S]*?```", "", text) - text = re.sub(r"`[^`]+`", "", text) - text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) - text = re.sub(r"\*{1,3}([^*]+)\*{1,3}", r"\1", text) - text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) - text = re.sub(r"<[^>]+>", "", text) - text = re.sub(r"^!!! \w+.*$", "", text, flags=re.MULTILINE) - text = re.sub(r"^\|.*\|$", "", text, flags=re.MULTILINE) - text = re.sub(r"^[-|: ]+$", "", text, flags=re.MULTILINE) - text = re.sub(r"\n{3,}", "\n\n", text) - return text.strip() - - @click.command() @click.option("--port", "-p", default=8567, help="Port for web server") @click.option("--lan", is_flag=True, help="Expose to LAN (default: localhost only)") @@ -83,146 +48,3 @@ def web(port: int, lan: bool) -> None: if not lan: console.print("[dim]Use --lan to expose to network[/dim]") uvicorn.run(app, host=host, port=port, workers=1, log_level="warning") - - -@click.command() -def tui() -> None: - """Launch the interactive terminal dashboard (requires textual). - - Install: uv pip install 'studyctl[tui]' - - Key bindings: f=flashcards, z=quiz, d=dashboard, q=quit, v=voice, o=OpenDyslexic - - For a web-based UI accessible from any device, use: studyctl web - """ - try: - from studyctl.tui.app import StudyApp - except ImportError: - console.print( - "[red]The TUI requires 'textual'.[/red]\nInstall: uv pip install 'studyctl[tui]'" - ) - return - - import yaml - - config_path = Path.home() / ".config" / "studyctl" / "config.yaml" - study_dirs: list[str] = [] - theme: str = "" - dyslexic: bool = False - if config_path.exists(): - try: - data = yaml.safe_load(config_path.read_text()) or {} - study_dirs = data.get("review", {}).get("directories", []) - tui_cfg = data.get("tui", {}) - theme = tui_cfg.get("theme", "") - dyslexic = tui_cfg.get("dyslexic_friendly", False) - except Exception: - pass - - app = StudyApp( - study_dirs=study_dirs, - theme_name=theme, - dyslexic_friendly=dyslexic, - ) - app.run() - - -# --- Docs commands --- - - -@click.group(name="docs") -def docs_group() -> None: - """Browse and read documentation.""" - - -@docs_group.command(name="serve") -@click.option("--port", "-p", default=8000, help="Port for local server") -def docs_serve(port: int) -> None: - """Serve documentation site locally and open in browser.""" - import subprocess - - repo_root = _find_docs_dir().parent - console.print(f"[bold]Serving docs at http://localhost:{port}[/bold]") - subprocess.run(["mkdocs", "serve", "-a", f"localhost:{port}"], cwd=str(repo_root), check=False) - - -@docs_group.command(name="open") -def docs_open() -> None: - """Build and open documentation in browser.""" - import subprocess - import webbrowser - - repo_root = _find_docs_dir().parent - site_dir = repo_root / "site" - console.print("Building docs...") - subprocess.run(["mkdocs", "build"], cwd=str(repo_root), check=True, capture_output=True) - index = site_dir / "index.html" - if index.exists(): - webbrowser.open(f"file://{index}") - console.print("[green]Opened docs in browser[/green]") - else: - console.print("[red]Build failed \u2014 site/index.html not found[/red]") - - -@docs_group.command(name="list") -def docs_list() -> None: - """List available documentation pages.""" - docs_dir = _find_docs_dir() - table = Table(title="Documentation Pages") - table.add_column("Page", style="bold") - table.add_column("Title") - for md in sorted(docs_dir.glob("*.md")): - title = md.stem.replace("-", " ").title() - for line in md.read_text().splitlines(): - if line.startswith("# "): - title = line[2:].strip() - break - table.add_row(md.stem, title) - console.print(table) - - -@docs_group.command(name="read") -@click.argument("page") -def docs_read(page: str) -> None: - """Read a documentation page aloud using study-speak. - - PAGE is the doc name without .md extension (e.g. 'voice-output', 'audhd-learning-philosophy'). - Use 'studyctl docs list' to see available pages. - """ - import subprocess - - docs_dir = _find_docs_dir() - md_file = docs_dir / f"{page}.md" - if not md_file.exists(): - matches = [f for f in docs_dir.glob("*.md") if page.lower() in f.stem.lower()] - if len(matches) == 1: - md_file = matches[0] - else: - console.print( - f"[red]Page '{page}' not found.[/red] Run [bold]studyctl docs list[/bold]" - ) - return - - text = _strip_markdown(md_file.read_text()) - if not text: - console.print("[yellow]Page is empty after stripping markdown.[/yellow]") - return - - speak_bin = Path.home() / ".local" / "bin" / "study-speak" - if not speak_bin.exists(): - console.print( - "[red]study-speak not installed.[/red]" - " Run: uv tool install './packages/agent-session-tools[tts]'" - ) - return - - console.print(f"[bold]\U0001f4d6 Reading: {md_file.stem}[/bold]") - console.print(f"[dim]({len(text.split())} words \u2014 press Ctrl+C to stop)[/dim]\n") - - try: - subprocess.run([str(speak_bin), text], check=True, timeout=300) - console.print("\n[green]\u2713 Done reading[/green]") - except KeyboardInterrupt: - console.print("\n[yellow]Stopped reading[/yellow]") - except subprocess.TimeoutExpired: - console.print("\n[yellow]Reading timed out[/yellow]") diff --git a/packages/studyctl/src/studyctl/scheduler.py b/packages/studyctl/src/studyctl/scheduler.py deleted file mode 100644 index 1c70f0c16..000000000 --- a/packages/studyctl/src/studyctl/scheduler.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Cross-platform scheduled job management (macOS launchd + Linux systemd/cron).""" - -from __future__ import annotations - -import platform -import subprocess -from dataclasses import dataclass -from pathlib import Path -from textwrap import dedent - -JOBS_DIR = Path.home() / ".config" / "studyctl" / "jobs" - - -@dataclass -class Job: - name: str - command: str # Uses ~ for portability - schedule: str # Human-readable: "every 2h", "daily 7am" - description: str = "" - - -DEFAULT_JOBS = [ - Job( - "session-export", "~/.local/bin/session-export", "every 2h", "Extract agent sessions to DB" - ), - Job( - "studyctl-sync", "~/.local/bin/studyctl sync --all", "daily 7am", "Sync notes to NotebookLM" - ), - Job("studyctl-push", "~/.local/bin/studyctl state push", "every 4h", "Push state to hub"), -] - - -def _is_macos() -> bool: - return platform.system() == "Darwin" - - -# ── macOS launchd ────────────────────────────────────────────────────────── - - -def _launchd_plist(job: Job, username: str) -> str: - home = f"/Users/{username}" if _is_macos() else f"/home/{username}" - cmd_parts = job.command.replace("~", home).split() - args_xml = "\n ".join(f"{a}" for a in cmd_parts) - - hour_tpl = "Hour{h}" - min_tpl = "Minute{m}" - - def _cal_dict(h: int, m: int = 0) -> str: - return f" {hour_tpl.format(h=h)}{min_tpl.format(m=m)}" - - cal_key = " StartCalendarInterval\n" - if "every 2h" in job.schedule: - entries = "\n".join(_cal_dict(h) for h in range(8, 23, 2)) - schedule = f"{cal_key} \n{entries}\n " - elif "every 4h" in job.schedule: - entries = "\n".join(_cal_dict(h, 30) for h in range(8, 23, 4)) - schedule = f"{cal_key} \n{entries}\n " - elif "daily" in job.schedule: - hour = 7 - if "am" in job.schedule: - hour = int(job.schedule.split("daily")[1].strip().replace("am", "").strip()) - schedule = ( - f"{cal_key}" - " \n" - f" {hour_tpl.format(h=hour)}\n" - f" {min_tpl.format(m=0)}\n" - " " - ) - else: - schedule = " StartInterval\n 3600" - - return dedent(f"""\ - - - - - Label - com.studyctl.{job.name} - ProgramArguments - - {args_xml} - - {schedule} - StandardOutPath - {home}/.local/share/studyctl/logs/{job.name}.log - StandardErrorPath - {home}/.local/share/studyctl/logs/{job.name}.err - EnvironmentVariables - - PATH - {home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - - - """) - - -def _launchd_install(job: Job, username: str) -> bool: - label = f"com.studyctl.{job.name}" - plist_dir = Path.home() / "Library" / "LaunchAgents" - plist_dir.mkdir(parents=True, exist_ok=True) - plist_path = plist_dir / f"{label}.plist" - - plist_path.write_text(_launchd_plist(job, username)) - subprocess.run(["launchctl", "bootout", f"gui/{_uid()}/{label}"], capture_output=True) - result = subprocess.run( - ["launchctl", "bootstrap", f"gui/{_uid()}", str(plist_path)], capture_output=True - ) - return result.returncode == 0 - - -def _launchd_remove(job: Job) -> bool: - label = f"com.studyctl.{job.name}" - subprocess.run(["launchctl", "bootout", f"gui/{_uid()}/{label}"], capture_output=True) - plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" - if plist.exists(): - plist.unlink() - return True - - -def _launchd_list() -> list[dict]: - result = subprocess.run(["launchctl", "list"], capture_output=True, text=True) - jobs = [] - for line in result.stdout.splitlines(): - if "com.studyctl." in line: - parts = line.split() - jobs.append( - { - "name": parts[2].replace("com.studyctl.", ""), - "status": parts[0], - "label": parts[2], - } - ) - return jobs - - -def _uid() -> int: - import os - - return os.getuid() - - -# ── Linux cron ───────────────────────────────────────────────────────────── - - -def _cron_expression(job: Job) -> str: - if "every 2h" in job.schedule: - return "0 8-22/2 * * *" - elif "every 4h" in job.schedule: - return "30 8-22/4 * * *" - elif "daily" in job.schedule: - hour = 7 - if "am" in job.schedule: - hour = int(job.schedule.split("daily")[1].strip().replace("am", "").strip()) - return f"0 {hour} * * *" - return "0 * * * *" - - -def _cron_line(job: Job) -> str: - home = str(Path.home()) - cmd = job.command.replace("~", home) - log = f"{home}/.local/share/studyctl/logs/{job.name}.log" - path_dirs = [ - f"{home}/.local/bin", - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - ] - path = "PATH=" + ":".join(path_dirs) - cron = _cron_expression(job) - return f"{cron} {path} {cmd} >> {log} 2>&1 # studyctl:{job.name}" - - -def _cron_install(job: Job) -> bool: - marker = f"# studyctl:{job.name}" - result = subprocess.run(["crontab", "-l"], capture_output=True, text=True) - existing = result.stdout if result.returncode == 0 else "" - # Remove old entry - lines = [line for line in existing.splitlines() if marker not in line] - lines.append(_cron_line(job)) - subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True) - return True - - -def _cron_remove(job: Job) -> bool: - marker = f"# studyctl:{job.name}" - result = subprocess.run(["crontab", "-l"], capture_output=True, text=True) - if result.returncode != 0: - return True - lines = [line for line in result.stdout.splitlines() if marker not in line] - subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True) - return True - - -def _cron_list() -> list[dict]: - result = subprocess.run(["crontab", "-l"], capture_output=True, text=True) - if result.returncode != 0: - return [] - jobs = [] - for line in result.stdout.splitlines(): - if "# studyctl:" in line: - name = line.split("# studyctl:")[1].strip() - jobs.append({"name": name, "cron": line.split("#")[0].strip()}) - return jobs - - -# ── Public API ───────────────────────────────────────────────────────────── - - -def install_job(job: Job, username: str | None = None) -> bool: - Path.home().joinpath(".local/share/studyctl/logs").mkdir(parents=True, exist_ok=True) - username = username or Path.home().name - if _is_macos(): - return _launchd_install(job, username) - return _cron_install(job) - - -def remove_job(job: Job) -> bool: - if _is_macos(): - return _launchd_remove(job) - return _cron_remove(job) - - -def list_jobs() -> list[dict]: - if _is_macos(): - return _launchd_list() - return _cron_list() - - -def install_all(username: str | None = None) -> list[str]: - installed = [] - for job in DEFAULT_JOBS: - if install_job(job, username): - installed.append(job.name) - return installed - - -def remove_all() -> list[str]: - removed = [] - for job in DEFAULT_JOBS: - if remove_job(job): - removed.append(job.name) - return removed diff --git a/packages/studyctl/src/studyctl/tui/__main__.py b/packages/studyctl/src/studyctl/tui/__main__.py deleted file mode 100644 index a81cba7dd..000000000 --- a/packages/studyctl/src/studyctl/tui/__main__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Entry point for ``python -m studyctl.tui`` and ``textual serve``.""" - -from __future__ import annotations - -from pathlib import Path - -import yaml - -from studyctl.tui.app import StudyApp - -config_path = Path.home() / ".config" / "studyctl" / "config.yaml" -study_dirs: list[str] = [] -theme = "" -dyslexic = False - -if config_path.exists(): - try: - data = yaml.safe_load(config_path.read_text()) or {} - study_dirs = data.get("review", {}).get("directories", []) - tui_cfg = data.get("tui", {}) - theme = tui_cfg.get("theme", "") - dyslexic = tui_cfg.get("dyslexic_friendly", False) - except Exception: - pass - -app = StudyApp( - study_dirs=study_dirs, - theme_name=theme, - dyslexic_friendly=dyslexic, -) - -if __name__ == "__main__": - app.run() diff --git a/packages/studyctl/src/studyctl/tui/app.py b/packages/studyctl/src/studyctl/tui/app.py deleted file mode 100644 index a32c38051..000000000 --- a/packages/studyctl/src/studyctl/tui/app.py +++ /dev/null @@ -1,395 +0,0 @@ -"""Textual TUI application for studyctl study management. - -Launch via ``studyctl tui``. Requires the ``tui`` extra:: - - pip install studyctl[tui] -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import ClassVar, NamedTuple - -try: - from textual.app import App, ComposeResult - from textual.containers import Vertical - from textual.screen import ModalScreen - from textual.widgets import ( - DataTable, - Footer, - Header, - OptionList, - Static, - TabbedContent, - TabPane, - ) - from textual.widgets.option_list import Option -except ImportError as _exc: - raise ImportError( - "The TUI requires the 'textual' package. Install it with:\n pip install studyctl[tui]" - ) from _exc - -from studyctl.cli import TOPIC_KEYWORDS -from studyctl.history import ( - get_study_session_stats, - list_concepts, - spaced_repetition_due, - struggle_topics, -) -from studyctl.review_loader import ( - discover_directories, - find_content_dirs, - load_flashcards, - load_quizzes, - shuffle_items, -) - - -class CourseInfo(NamedTuple): - """Typed representation of a discovered course directory.""" - - name: str - path: Path - - -def _load_session_state() -> dict: - """Load session state from the JSON file, returning defaults on failure.""" - state_path = Path.home() / ".config" / "studyctl" / "session-state.json" - try: - return json.loads(state_path.read_text()) if state_path.exists() else {} - except (json.JSONDecodeError, OSError): - return {} - - -class CoursePickerScreen(ModalScreen[CourseInfo | None]): - """Modal overlay for selecting a course directory.""" - - BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ - ("escape", "cancel", "Cancel"), - ] - - CSS = """ - CoursePickerScreen { - align: center middle; - } - #course-picker-box { - width: 60; - max-height: 24; - border: round $accent; - background: $surface; - padding: 1 2; - } - #course-picker-title { - text-style: bold; - text-align: center; - margin-bottom: 1; - } - #course-picker { - max-height: 18; - } - """ - - def __init__(self, courses: list[CourseInfo]) -> None: - super().__init__() - self._courses = courses - - def compose(self) -> ComposeResult: - with Vertical(id="course-picker-box"): - yield Static("Select a course", id="course-picker-title") - yield OptionList( - *[Option(course.name) for course in self._courses], - id="course-picker", - ) - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - self.dismiss(self._courses[event.option_index]) - - def action_cancel(self) -> None: - self.dismiss(None) - - -class StudyApp(App): - """Read-only study management dashboard.""" - - TITLE = "studyctl" - CSS = """ - Screen { - background: $surface; - } - #dashboard-content { - padding: 1 2; - } - .section-header { - text-style: bold; - color: $accent; - margin-bottom: 1; - } - .info-line { - margin-bottom: 0; - } - - /* Dyslexic-friendly: wider spacing, more padding, clearer separation */ - .dyslexic #dashboard-content { - padding: 2 4; - } - .dyslexic .section-header { - margin-bottom: 2; - } - .dyslexic .info-line { - margin-bottom: 1; - } - .dyslexic DataTable { - padding: 1 2; - } - .dyslexic #card-panel { - padding: 2 4; - min-height: 8; - margin: 2 1; - } - .dyslexic #progress-label { - margin: 2 0; - } - .dyslexic #status-label { - margin-bottom: 1; - } - .dyslexic .score-btn { - margin: 0 2; - } - """ - - BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ - ("q", "quit", "Quit"), - ("d", "show_tab('dashboard')", "Dashboard"), - ("r", "show_tab('review')", "Review"), - ("c", "show_tab('concepts')", "Concepts"), - ("s", "show_tab('sessions')", "Sessions"), - ("f", "start_flashcards", "Flashcards"), - ("z", "start_quiz", "Quiz"), - ("o", "toggle_dyslexic", "OpenDyslexic"), - ] - - def __init__( - self, - study_dirs: list[str] | None = None, - theme_name: str = "", - dyslexic_friendly: bool = False, - **kwargs: object, - ) -> None: - super().__init__(**kwargs) - self._study_dirs = study_dirs or [] - self._theme_name = theme_name - self._dyslexic_friendly = dyslexic_friendly - - def compose(self) -> ComposeResult: - yield Header() - with TabbedContent( - "Dashboard", - "Review", - "Concepts", - "Sessions", - "StudyCards", - id="tabs", - ): - with TabPane("Dashboard", id="dashboard"): - yield Vertical( - Static("", id="dashboard-content"), - id="dashboard-container", - ) - with TabPane("Review", id="review"): - yield DataTable(id="review-table") - with TabPane("Concepts", id="concepts"): - yield DataTable(id="concepts-table") - with TabPane("Sessions", id="sessions"): - yield DataTable(id="sessions-table") - with TabPane("StudyCards", id="studycards"): - yield Vertical( - Static("", id="studycards-content"), - id="studycards-container", - ) - yield Footer() - - def on_mount(self) -> None: - if self._theme_name: - self.theme = self._theme_name - if self._dyslexic_friendly: - self.add_class("dyslexic") - self.notify( - "Dyslexic-friendly mode ON. For best results, set your " - "terminal font to OpenDyslexic: https://opendyslexic.org", - title="Accessibility", - timeout=8, - ) - self._populate_dashboard() - self._populate_review() - self._populate_concepts() - self._populate_sessions() - self._populate_studycards() - - def action_show_tab(self, tab_id: str) -> None: - tabs = self.query_one(TabbedContent) - tabs.active = tab_id - - def action_toggle_dyslexic(self) -> None: - """Toggle dyslexic-friendly mode (wider spacing).""" - self.toggle_class("dyslexic") - if self.has_class("dyslexic"): - self.notify( - "Dyslexic-friendly mode ON β€” wider spacing applied. " - "Set terminal font to OpenDyslexic for best results.", - title="Accessibility", - ) - else: - self.notify("Dyslexic-friendly mode OFF") - - # ------------------------------------------------------------------ - # Tab population - # ------------------------------------------------------------------ - - def _populate_dashboard(self) -> None: - state = _load_session_state() - energy = state.get("energy", "unknown") - topic = state.get("topic", "none") - - due = spaced_repetition_due(TOPIC_KEYWORDS) - struggles = struggle_topics() - - lines = [ - "[bold]Study Dashboard[/bold]", - "", - f" Energy level: {energy}", - f" Current topic: {topic}", - f" Reviews due: {len(due)}", - f" Struggle areas: {len(struggles)}", - ] - if struggles: - lines.append("") - lines.append("[bold]Top struggles:[/bold]") - for s in struggles[:5]: - lines.append(f" β€’ {s['topic']} ({s['mentions']} mentions)") - - widget = self.query_one("#dashboard-content", Static) - widget.update("\n".join(lines)) - - def _populate_review(self) -> None: - table = self.query_one("#review-table", DataTable) - table.add_columns("Topic", "Last Studied", "Days Ago", "Review Type") - - for item in spaced_repetition_due(TOPIC_KEYWORDS): - table.add_row( - item["topic"], - item.get("last_studied") or "never", - str(item.get("days_ago") or "β€”"), - item.get("review_type", ""), - ) - - def _populate_concepts(self) -> None: - table = self.query_one("#concepts-table", DataTable) - table.add_columns("Name", "Domain", "Description") - - for concept in list_concepts(): - table.add_row(concept.name, concept.domain, concept.description or "") - - def _populate_sessions(self) -> None: - table = self.query_one("#sessions-table", DataTable) - table.add_columns("Topic", "Sessions", "Total Min", "Avg Min") - - for stat in get_study_session_stats(): - table.add_row( - stat.get("topic") or "unknown", - str(stat.get("sessions", 0)), - str(round(stat.get("total_minutes") or 0)), - str(round(stat.get("avg_minutes") or 0)), - ) - - def _discover_courses(self) -> list[CourseInfo]: - """Discover courses and wrap raw tuples as CourseInfo.""" - return [CourseInfo(*t) for t in discover_directories(self._study_dirs)] - - def _populate_studycards(self) -> None: - content = self.query_one("#studycards-content", Static) - courses = self._discover_courses() - - if not courses: - content.update( - "[bold]Study Cards[/bold]\n\n" - " No courses found.\n\n" - " Configure directories in ~/.config/studyctl/config.yaml:\n" - " review:\n" - " directories:\n" - " - ~/Desktop/ZTM-DE/downloads\n" - " - ~/Desktop/Python/downloads\n\n" - " Or press [bold]f[/bold] for flashcards / [bold]z[/bold] for quiz" - " and select a directory." - ) - return - - lines = [ - "[bold]Study Cards[/bold]\n", - f" Found {len(courses)} course(s):\n", - ] - for course in courses: - fc_dir, quiz_dir = find_content_dirs(course.path) - fc_count = len(load_flashcards(fc_dir)) if fc_dir else 0 - quiz_count = len(load_quizzes(quiz_dir)) if quiz_dir else 0 - lines.append( - f" β€’ [bold]{course.name}[/bold] β€” {fc_count} flashcards," - f" {quiz_count} quiz questions" - ) - - lines.append("\n Press [bold]f[/bold] for flashcards / [bold]z[/bold] for quiz") - content.update("\n".join(lines)) - - def _launch_study(self, mode: str = "flashcards") -> None: - """Launch interactive study session.""" - courses = self._discover_courses() - if not courses: - self.notify( - "No courses found. Configure review.directories in config.yaml", - severity="error", - ) - return - - if len(courses) == 1: - self._start_session(courses[0], mode) - else: - self.push_screen( - CoursePickerScreen(courses), - lambda result: self._start_session(result, mode) if result else None, - ) - - def _start_session(self, course: CourseInfo, mode: str) -> None: - """Start a study session for the selected course.""" - fc_dir, quiz_dir = find_content_dirs(course.path) - - if mode == "flashcards" and fc_dir: - cards = shuffle_items(load_flashcards(fc_dir)) - elif mode == "quiz" and quiz_dir: - cards = shuffle_items(load_quizzes(quiz_dir)) - else: - self.notify( - f"No {mode} content found for {course.name}", - severity="warning", - ) - return - - if not cards: - self.notify(f"No {mode} cards loaded", severity="warning") - return - - from studyctl.tui.study_cards import StudyCardsTab - - # Replace studycards content with the interactive widget - container = self.query_one("#studycards-container", Vertical) - container.remove_children() - container.mount(StudyCardsTab(cards=cards, course_name=course.name, mode=mode)) - - # Switch to the tab - tabs = self.query_one(TabbedContent) - tabs.active = "studycards" - - def action_start_flashcards(self) -> None: - self._launch_study("flashcards") - - def action_start_quiz(self) -> None: - self._launch_study("quiz") diff --git a/packages/studyctl/src/studyctl/tui/study_cards.py b/packages/studyctl/src/studyctl/tui/study_cards.py deleted file mode 100644 index 04c3189f2..000000000 --- a/packages/studyctl/src/studyctl/tui/study_cards.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Interactive flashcard and quiz review tab for the studyctl TUI. - -Provides keyboard-driven study with spaced repetition tracking -and optional voice output via study-speak. -""" - -from __future__ import annotations - -import logging -import sqlite3 -import time -from enum import StrEnum -from typing import TYPE_CHECKING, ClassVar - -try: - from textual.binding import Binding - from textual.containers import Center, Horizontal - from textual.reactive import reactive - from textual.widget import Widget - from textual.widgets import Button, Static -except ImportError as _exc: - raise ImportError("The TUI requires 'textual'. Install: pip install studyctl[tui]") from _exc - -if TYPE_CHECKING: - from textual.app import ComposeResult - -from studyctl.review_db import record_card_review, record_session -from studyctl.review_loader import ( - Flashcard, - QuizQuestion, - ReviewResult, -) - -logger = logging.getLogger(__name__) - - -class StudyMode(StrEnum): - """Tracks whether the user is in a normal or retry study session.""" - - NORMAL = "normal" - RETRY = "retry" - - -class CardPanel(Static): - """Displays a flashcard or quiz question with flip support.""" - - revealed = reactive(False) - - def __init__(self, **kwargs: object) -> None: - super().__init__(**kwargs) - self._front = "" - self._back = "" - - def set_card(self, front: str, back: str) -> None: - self._front = front - self._back = back - self.revealed = False - self.update(self._front) - - def flip(self) -> None: - self.revealed = not self.revealed - self.update(self._back if self.revealed else self._front) - - -class StudyCardsTab(Widget): - """Interactive flashcard and quiz review widget.""" - - can_focus = True - - DEFAULT_CSS = """ - StudyCardsTab { - layout: vertical; - padding: 1 2; - } - #card-panel { - height: auto; - min-height: 5; - padding: 1 2; - border: round $accent; - margin: 1 0; - } - #card-panel.revealed { - border: round $success; - } - #score-bar { - height: 3; - dock: bottom; - margin-top: 1; - } - #progress-label { - text-align: center; - margin: 1 0; - } - #status-label { - text-align: center; - color: $text-muted; - } - #voice-label { - text-align: right; - color: $text-muted; - } - .score-btn { - margin: 0 1; - } - """ - - BINDINGS: ClassVar[list[Binding]] = [ - Binding("space", "flip", "Flip / Submit"), - Binding("y", "mark_correct", "Correct"), - Binding("n", "mark_incorrect", "Incorrect"), - Binding("s", "skip_card", "Skip"), - Binding("h", "show_hint", "Hint"), - Binding("v", "toggle_voice", "Voice"), - Binding("r", "retry_wrong", "Retry Wrong"), - ] - - current_index = reactive(0) - voice_enabled = reactive(False) - _study_mode: reactive[StudyMode] = reactive(StudyMode.NORMAL, bindings=True) - - def __init__( - self, - cards: list[Flashcard | QuizQuestion], - course_name: str = "", - mode: str = "flashcards", - **kwargs: object, - ) -> None: - super().__init__(**kwargs) - self._all_cards = list(cards) - self._cards = cards - self._course = course_name - self._mode = mode - self._result = ReviewResult(total=len(cards)) - self._start_time = time.monotonic() - self._card_start_time = time.monotonic() - - def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: - if action == "retry_wrong": - return bool(self._result.wrong_hashes) and self._study_mode is not StudyMode.RETRY - return True - - def compose(self) -> ComposeResult: - yield Static( - f"[bold]{self._course}[/bold] β€” {self._mode} ({len(self._cards)} items)", - id="status-label", - ) - voice_text = "Voice: ON" if self.voice_enabled else "Voice: OFF (v to toggle)" - yield Static(voice_text, id="voice-label") - yield CardPanel(id="card-panel") - yield Static("", id="progress-label") - with Center(), Horizontal(id="score-bar"): - yield Button( - "Know (y)", - variant="success", - id="btn-correct", - classes="score-btn", - ) - yield Button( - "Don't Know (n)", - variant="error", - id="btn-incorrect", - classes="score-btn", - ) - yield Button( - "Skip (s)", - variant="default", - id="btn-skip", - classes="score-btn", - ) - - def on_mount(self) -> None: - self.focus() - self._show_current_card() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn-correct": - self.action_mark_correct() - elif event.button.id == "btn-incorrect": - self.action_mark_incorrect() - elif event.button.id == "btn-skip": - self.action_skip_card() - - def _show_current_card(self) -> None: - if self.current_index >= len(self._cards): - self._show_summary() - return - - card = self._cards[self.current_index] - panel = self.query_one("#card-panel", CardPanel) - self._card_start_time = time.monotonic() - - if isinstance(card, Flashcard): - panel.set_card( - f"[bold]Q:[/bold] {card.front}\n\n[dim]Press Space to reveal answer[/dim]", - f"[bold]A:[/bold] {card.back}", - ) - else: - # Quiz question - lines = [f"[bold]Q:[/bold] {card.question}\n"] - letters = "abcdefghij" - for j, opt in enumerate(card.options): - lines.append(f" [bold]{letters[j]})[/bold] {opt.text}") - lines.append("\n[dim]Press Space to reveal answer[/dim]") - panel.set_card( - "\n".join(lines), - self._format_quiz_answer(card), - ) - - # Speak the question if voice enabled - if self.voice_enabled: - text = card.front if isinstance(card, Flashcard) else card.question - self._speak(text) - - retry_tag = " (Retry)" if self._study_mode is StudyMode.RETRY else "" - progress = f"Card {self.current_index + 1}/{len(self._cards)}{retry_tag}" - if self._result.correct + self._result.incorrect > 0: - progress += f" | Score: {self._result.score_pct:.0f}%" - self.query_one("#progress-label", Static).update(progress) - - def _format_quiz_answer(self, q: QuizQuestion) -> str: - letters = "abcdefghij" - correct_idx = next((i for i, o in enumerate(q.options) if o.is_correct), 0) - correct_opt = q.options[correct_idx] - lines = [f"[green bold]Answer: {letters[correct_idx]})[/green bold] {correct_opt.text}"] - if correct_opt.rationale: - lines.append(f"\n[dim]{correct_opt.rationale}[/dim]") - return "\n".join(lines) - - def _record_answer(self, correct: bool) -> None: - card = self._cards[self.current_index] - elapsed_ms = int((time.monotonic() - self._card_start_time) * 1000) - - if correct: - self._result.correct += 1 - else: - self._result.incorrect += 1 - self._result.wrong_hashes.add(card.card_hash) - - # Record to DB (skip SM-2 during retry) - if self._study_mode is not StudyMode.RETRY: - card_type = "flashcard" if isinstance(card, Flashcard) else "quiz" - try: - record_card_review( - course=self._course, - card_type=card_type, - card_hash=card.card_hash, - correct=correct, - response_time_ms=elapsed_ms, - ) - except (sqlite3.Error, OSError) as exc: - logger.debug("Failed to record card review: %s", exc) - - self.current_index += 1 - self._show_current_card() - - def _show_summary(self) -> None: - duration = int(time.monotonic() - self._start_time) - attempted = self._result.correct + self._result.incorrect - pct = self._result.score_pct - - if pct >= 80: - grade = "[green]Excellent[/green]" - elif pct >= 60: - grade = "[yellow]Good[/yellow]" - else: - grade = "[red]Needs review[/red]" - - wrong_count = len(self._result.wrong_hashes) - summary = [ - "[bold]Session Complete![/bold]", - "", - f" Score: {self._result.correct}/{attempted} ({pct:.0f}%) β€” {grade}", - f" Skipped: {self._result.skipped}", - f" Duration: {duration // 60}m {duration % 60}s", - ] - if wrong_count and self._study_mode is not StudyMode.RETRY: - summary.append( - f"\n [yellow]{wrong_count} cards to review again β€” press r to retry[/yellow]" - ) - - panel = self.query_one("#card-panel", CardPanel) - panel.update("\n".join(summary)) - - # Hide score buttons - for btn_id in ("btn-correct", "btn-incorrect", "btn-skip"): - self.query_one(f"#{btn_id}", Button).display = False - - hint = "[bold]Press q to return[/bold]" - if self._result.wrong_hashes and self._study_mode is not StudyMode.RETRY: - hint = "[bold]Press r to retry wrong, q to return[/bold]" - self.query_one("#progress-label", Static).update(hint) - - # Record session - try: - record_session( - course=self._course, - mode=self._mode, - total=self._result.total, - correct=self._result.correct, - duration_seconds=duration, - ) - except (sqlite3.Error, OSError) as exc: - logger.debug("Failed to record session: %s", exc) - - def _speak(self, text: str) -> None: - """Speak text via study-speak (non-blocking, best-effort).""" - try: - from agent_session_tools.speak import ( - _get_tts_config, - _speak_kokoro, - ) - - cfg = _get_tts_config() - voice = cfg.get("voice", "am_michael") - speed = cfg.get("speed", 1.0) - import threading - - threading.Thread( - target=_speak_kokoro, - args=(text,), - kwargs={"voice": voice, "speed": speed}, - daemon=True, - ).start() - except (ImportError, OSError, RuntimeError) as exc: - logger.debug("Voice unavailable: %s", exc) - - # --- Actions --- - - def action_flip(self) -> None: - panel = self.query_one("#card-panel", CardPanel) - if not panel.revealed: - panel.flip() - panel.add_class("revealed") - if self.voice_enabled and self.current_index < len(self._cards): - card = self._cards[self.current_index] - text = card.back if isinstance(card, Flashcard) else "" - if text: - self._speak(text) - - def action_mark_correct(self) -> None: - panel = self.query_one("#card-panel", CardPanel) - if panel.revealed: - panel.remove_class("revealed") - self._record_answer(correct=True) - - def action_mark_incorrect(self) -> None: - panel = self.query_one("#card-panel", CardPanel) - if panel.revealed: - panel.remove_class("revealed") - self._record_answer(correct=False) - - def action_skip_card(self) -> None: - self._result.skipped += 1 - panel = self.query_one("#card-panel", CardPanel) - panel.remove_class("revealed") - self.current_index += 1 - self._show_current_card() - - def action_show_hint(self) -> None: - if self.current_index >= len(self._cards): - return - card = self._cards[self.current_index] - if isinstance(card, QuizQuestion) and card.hint: - self.notify(f"Hint: {card.hint}", title="Hint") - - def action_toggle_voice(self) -> None: - self.voice_enabled = not self.voice_enabled - label = self.query_one("#voice-label", Static) - if self.voice_enabled: - label.update("[green]Voice: ON[/green]") - self.notify("Voice enabled") - else: - label.update("Voice: OFF (v to toggle)") - self.notify("Voice disabled") - - def action_retry_wrong(self) -> None: - """Retry only the incorrectly answered cards.""" - if not self._result.wrong_hashes or self._study_mode is StudyMode.RETRY: - return - - wrong = self._result.wrong_hashes - retry_cards = [c for c in self._all_cards if c.card_hash in wrong] - if not retry_cards: - return - - self._cards = retry_cards - self._result = ReviewResult(total=len(retry_cards)) - self._study_mode = StudyMode.RETRY - self.current_index = 0 - self._start_time = time.monotonic() - - # Re-show score buttons - for btn_id in ("btn-correct", "btn-incorrect", "btn-skip"): - self.query_one(f"#{btn_id}", Button).display = True - - self._show_current_card() diff --git a/packages/studyctl/tests/test_calendar.py b/packages/studyctl/tests/test_calendar.py deleted file mode 100644 index 61ca7c501..000000000 --- a/packages/studyctl/tests/test_calendar.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Tests for calendar time-blocking.""" - -from datetime import datetime, timedelta -from pathlib import Path - -from studyctl.calendar import ( - REVIEW_DURATIONS, - generate_event, - generate_ics, - schedule_reviews, - write_ics, -) - - -class TestGenerateEvent: - def test_basic_event(self): - start = datetime(2026, 3, 7, 10, 0) - result = generate_event("Python", "5-min recall quiz", start) - assert "BEGIN:VEVENT" in result - assert "END:VEVENT" in result - assert "Study: Python (5-min recall quiz)" in result - assert "DTSTART:20260307T100000" in result - assert "DTEND:20260307T101000" in result # 10 min duration - - def test_custom_duration(self): - start = datetime(2026, 3, 7, 14, 0) - result = generate_event("SQL", "custom review", start, duration_min=45) - assert "DTEND:20260307T144500" in result - - def test_alarm_included(self): - start = datetime(2026, 3, 7, 10, 0) - result = generate_event("Python", "quiz", start) - assert "BEGIN:VALARM" in result - assert "TRIGGER:-PT5M" in result - - def test_escapes_special_chars(self): - start = datetime(2026, 3, 7, 10, 0) - result = generate_event("C++; Advanced", "review, deep", start) - assert "C++\\; Advanced" in result - - -class TestGenerateIcs: - def test_valid_ics_structure(self): - events = [ - { - "topic": "Python", - "review_type": "5-min recall quiz", - "start": datetime(2026, 3, 7, 10, 0), - } - ] - result = generate_ics(events) - assert result.startswith("BEGIN:VCALENDAR") - assert result.endswith("END:VCALENDAR\r\n") - assert "VERSION:2.0" in result - assert "PRODID:-//studyctl" in result - assert "BEGIN:VEVENT" in result - - def test_multiple_events(self): - events = [ - {"topic": "Python", "review_type": "quiz", "start": datetime(2026, 3, 7, 10, 0)}, - {"topic": "SQL", "review_type": "review", "start": datetime(2026, 3, 7, 11, 0)}, - ] - result = generate_ics(events) - assert result.count("BEGIN:VEVENT") == 2 - - def test_empty_events(self): - result = generate_ics([]) - assert "BEGIN:VCALENDAR" in result - assert "BEGIN:VEVENT" not in result - - -class TestScheduleReviews: - def test_schedules_from_due_items(self): - due = [ - {"topic": "Python", "review_type": "5-min recall quiz"}, - {"topic": "SQL", "review_type": "15-min deep review"}, - ] - start = datetime(2026, 3, 7, 9, 0) - events = schedule_reviews(due, start_time=start, gap_minutes=10) - - assert len(events) == 2 - assert events[0]["topic"] == "Python" - assert events[0]["start"] == start - assert events[0]["duration_min"] == 10 # from REVIEW_DURATIONS - - # Second event starts after first duration + gap - expected_second = start + timedelta(minutes=10 + 10) - assert events[1]["start"] == expected_second - assert events[1]["duration_min"] == 20 # 15-min deep review = 20 min - - def test_empty_due_items(self): - assert schedule_reviews([]) == [] - - def test_default_start_is_next_hour(self): - due = [{"topic": "Python", "review_type": "quiz"}] - events = schedule_reviews(due) - now = datetime.now() - # Should be within the next 2 hours - assert events[0]["start"] > now - assert events[0]["start"] < now + timedelta(hours=2) - assert events[0]["start"].minute == 0 - - def test_unknown_review_type_gets_default_duration(self): - due = [{"topic": "Python", "review_type": "unknown type"}] - events = schedule_reviews(due, start_time=datetime(2026, 3, 7, 9, 0)) - assert events[0]["duration_min"] == 20 # default - - def test_custom_gap(self): - due = [ - {"topic": "A", "review_type": "5-min recall quiz"}, - {"topic": "B", "review_type": "5-min recall quiz"}, - ] - start = datetime(2026, 3, 7, 9, 0) - events = schedule_reviews(due, start_time=start, gap_minutes=5) - # First: 10 min duration + 5 min gap = 15 min later - assert events[1]["start"] == start + timedelta(minutes=15) - - -class TestWriteIcs: - def test_writes_file(self, tmp_path: Path): - events = [ - { - "topic": "Python", - "review_type": "quiz", - "start": datetime(2026, 3, 7, 10, 0), - } - ] - path = write_ics(events, output_dir=tmp_path) - assert path.exists() - assert path.suffix == ".ics" - content = path.read_text() - assert "BEGIN:VCALENDAR" in content - assert "Study: Python" in content - - def test_filename_contains_timestamp(self, tmp_path: Path): - events = [{"topic": "X", "review_type": "Y", "start": datetime(2026, 1, 1, 10, 0)}] - path = write_ics(events, output_dir=tmp_path) - assert path.name.startswith("study-blocks-") - assert path.name.endswith(".ics") - - def test_creates_output_dir(self, tmp_path: Path): - out = tmp_path / "nested" / "dir" - events = [{"topic": "X", "review_type": "Y", "start": datetime(2026, 1, 1, 10, 0)}] - path = write_ics(events, output_dir=out) - assert path.exists() - - -class TestReviewDurations: - def test_all_review_types_have_durations(self): - expected_types = [ - "5-min recall quiz", - "10-min Socratic review", - "15-min deep review", - "Apply to new problem", - "Teach-back session", - ] - for rt in expected_types: - assert rt in REVIEW_DURATIONS - assert REVIEW_DURATIONS[rt] > 0 diff --git a/packages/studyctl/tests/test_cli.py b/packages/studyctl/tests/test_cli.py index b5d37bfc0..0237c99ea 100644 --- a/packages/studyctl/tests/test_cli.py +++ b/packages/studyctl/tests/test_cli.py @@ -135,89 +135,6 @@ def test_struggles_accepts_days_option(self, runner: CliRunner) -> None: assert "No recurring struggle topics" in result.output -# --------------------------------------------------------------------------- -# wins (no DB) -# --------------------------------------------------------------------------- - - -class TestWins: - def test_wins_no_db_shows_empty(self, runner: CliRunner) -> None: - result = runner.invoke(cli, ["wins"]) - assert result.exit_code == 0 - assert "No progress data" in result.output - - -# --------------------------------------------------------------------------- -# resume (no DB) -# --------------------------------------------------------------------------- - - -class TestResume: - def test_resume_no_db_shows_no_sessions(self, runner: CliRunner) -> None: - result = runner.invoke(cli, ["resume"]) - assert result.exit_code == 0 - assert "No sessions found" in result.output - - -# --------------------------------------------------------------------------- -# streaks (no DB) -# --------------------------------------------------------------------------- - - -class TestStreaks: - def test_streaks_no_db_shows_no_sessions(self, runner: CliRunner) -> None: - result = runner.invoke(cli, ["streaks"]) - assert result.exit_code == 0 - assert "No study sessions found" in result.output - - -# --------------------------------------------------------------------------- -# progress-map (no DB) -# --------------------------------------------------------------------------- - - -class TestProgressMap: - def test_progress_map_no_db_shows_empty(self, runner: CliRunner) -> None: - result = runner.invoke(cli, ["progress-map"]) - assert result.exit_code == 0 - assert "No progress data" in result.output - - -# --------------------------------------------------------------------------- -# schedule list (mocked -- no subprocess) -# --------------------------------------------------------------------------- - - -class TestScheduleList: - def test_schedule_list_no_jobs( - self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch - ) -> None: - import studyctl.cli._schedule as sched_mod - - monkeypatch.setattr(sched_mod, "list_jobs", lambda: []) - - result = runner.invoke(cli, ["schedule", "list"]) - assert result.exit_code == 0 - assert "No studyctl jobs scheduled" in result.output - - def test_schedule_list_with_jobs( - self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch - ) -> None: - import studyctl.cli._schedule as sched_mod - - monkeypatch.setattr( - sched_mod, - "list_jobs", - lambda: [ - {"name": "session-export", "status": "0"}, - {"name": "studyctl-sync", "cron": "0 7 * * *"}, - ], - ) - - result = runner.invoke(cli, ["schedule", "list"]) - assert result.exit_code == 0 - assert "session-export" in result.output - assert "studyctl-sync" in result.output # --------------------------------------------------------------------------- @@ -359,12 +276,4 @@ def test_root_help(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert "AuDHD study pipeline" in result.output - def test_schedule_subgroup_help(self, runner: CliRunner) -> None: - result = runner.invoke(cli, ["schedule", "--help"]) - assert result.exit_code == 0 - assert "Manage scheduled jobs" in result.output - def test_state_subgroup_help(self, runner: CliRunner) -> None: - result = runner.invoke(cli, ["state", "--help"]) - assert result.exit_code == 0 - assert "Cross-machine state sync" in result.output diff --git a/packages/studyctl/tests/test_doctor_integration.py b/packages/studyctl/tests/test_doctor_integration.py index 6b465b553..5cb3d34b3 100644 --- a/packages/studyctl/tests/test_doctor_integration.py +++ b/packages/studyctl/tests/test_doctor_integration.py @@ -3,8 +3,8 @@ from __future__ import annotations import json +import urllib.error from typing import TYPE_CHECKING -from unittest.mock import patch import pytest from click.testing import CliRunner @@ -20,7 +20,7 @@ def runner(self) -> CliRunner: @pytest.fixture(autouse=True) def _isolate(self, tmp_path: Path, monkeypatch): - """Isolate from real config/DB. + """Isolate from real config/DB and block all network access. Patches the module-level _CONFIG_PATH in studyctl.settings so that load_settings(), get_db_path(), and check_config_file() all read from @@ -31,24 +31,31 @@ def _isolate(self, tmp_path: Path, monkeypatch): config = tmp_path / "config.yaml" config.write_text("obsidian_base: ''\ntopics: []\n") - # Patch the module-level variable β€” load_settings() uses it directly monkeypatch.setattr(_settings, "_CONFIG_PATH", config) - - # Also patch _get_config_path in core (imports _CONFIG_PATH at call time, - # so the monkeypatch above already covers it, but belt-and-suspenders): monkeypatch.setattr("studyctl.doctor.core._get_config_path", lambda: config) - def test_doctor_runs_all_categories(self, runner: CliRunner): + # Block all network access β€” prevent 10s timeouts from urllib + def _block_network(*a, **kw): + raise urllib.error.URLError("blocked by test") + + monkeypatch.setattr("urllib.request.urlopen", _block_network) + monkeypatch.setattr("studyctl.doctor.agents._fetch_manifest", lambda: None) + monkeypatch.setattr("studyctl.doctor.agents._detect_ai_tools", lambda: []) + monkeypatch.setattr("studyctl.doctor.updates._fetch_pypi_version", lambda pkg: None) + monkeypatch.setattr("studyctl.doctor.updates._read_cache", lambda: {}) + + def _run_doctor(self, runner, args=None): from studyctl.cli._doctor import doctor - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(doctor, ["--json"]) + return runner.invoke(doctor, args or ["--json"]) + def _run_update(self, runner, args=None): + from studyctl.cli._upgrade import update + + return runner.invoke(update, args or ["--json"]) + + def test_doctor_runs_all_categories(self, runner: CliRunner): + result = self._run_doctor(runner) assert result.exit_code in (0, 1, 2), ( f"Unexpected exit code: {result.exit_code}\n{result.output}" ) @@ -58,16 +65,7 @@ def test_doctor_runs_all_categories(self, runner: CliRunner): assert "deps" in categories def test_doctor_json_is_list_of_dicts(self, runner: CliRunner): - from studyctl.cli._doctor import doctor - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(doctor, ["--json"]) - + result = self._run_doctor(runner) assert result.exit_code in (0, 1, 2), ( f"Unexpected exit code: {result.exit_code}\n{result.output}" ) @@ -81,101 +79,43 @@ def test_doctor_json_is_list_of_dicts(self, runner: CliRunner): assert "fix_auto" in item def test_doctor_json_has_required_fields(self, runner: CliRunner): - from studyctl.cli._doctor import doctor - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(doctor, ["--json"]) - + result = self._run_doctor(runner) data = json.loads(result.output) for item in data: - # name and message are also part of the CheckResult schema assert "name" in item assert "message" in item def test_doctor_all_statuses_are_valid(self, runner: CliRunner): - from studyctl.cli._doctor import doctor - valid_statuses = {"pass", "warn", "fail", "info"} - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(doctor, ["--json"]) - + result = self._run_doctor(runner) data = json.loads(result.output) for item in data: assert item["status"] in valid_statuses, f"Unexpected status: {item['status']!r}" def test_doctor_category_filter(self, runner: CliRunner): """--category core returns only core results.""" - from studyctl.cli._doctor import doctor - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(doctor, ["--json", "--category", "core"]) - + result = self._run_doctor(runner, ["--json", "--category", "core"]) assert result.exit_code in (0, 1, 2) data = json.loads(result.output) assert all(r["category"] == "core" for r in data) def test_update_command_works(self, runner: CliRunner): - from studyctl.cli._upgrade import update - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(update, ["--json"]) + result = self._run_update(runner) assert result.exit_code == 0 def test_update_json_output_is_list(self, runner: CliRunner): """update --json returns a JSON list (filtered to core + updates).""" - from studyctl.cli._upgrade import update - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(update, ["--json"]) - + result = self._run_update(runner) assert result.exit_code == 0 data = json.loads(result.output) assert isinstance(data, list) - # update command only returns core + updates categories for item in data: assert item["category"] in ("core", "updates") def test_doctor_quiet_returns_summary_line(self, runner: CliRunner): """--quiet mode outputs a single summary line, not JSON.""" - from studyctl.cli._doctor import doctor - - with ( - patch("studyctl.doctor.updates._fetch_pypi_version", return_value=None), - patch("studyctl.doctor.updates._read_cache", return_value={}), - patch("studyctl.doctor.agents._fetch_manifest", return_value=None), - patch("studyctl.doctor.agents._detect_ai_tools", return_value=[]), - ): - result = runner.invoke(doctor, ["--quiet"]) - + result = self._run_doctor(runner, ["--quiet"]) assert result.exit_code in (0, 1, 2) output = result.output.strip() - # Summary line always ends with a period assert output.endswith(".") - # Should not be JSON assert not output.startswith("[") diff --git a/packages/studyctl/tests/test_scheduler.py b/packages/studyctl/tests/test_scheduler.py deleted file mode 100644 index b76b6d58d..000000000 --- a/packages/studyctl/tests/test_scheduler.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for scheduler module -- output verification only, no real job installation.""" - -from unittest.mock import patch - -from studyctl.scheduler import Job, _cron_expression, _cron_line, _launchd_plist, list_jobs - - -class TestJobDataclass: - def test_stores_fields(self): - job = Job(name="test-job", command="~/bin/run", schedule="every 2h", description="A test") - assert job.name == "test-job" - assert job.command == "~/bin/run" - assert job.schedule == "every 2h" - assert job.description == "A test" - - def test_default_description(self): - job = Job(name="x", command="y", schedule="z") - assert job.description == "" - - -class TestLaunchdPlist: - def test_contains_label(self): - job = Job(name="my-job", command="~/.local/bin/do-stuff", schedule="every 2h") - plist = _launchd_plist(job, "testuser") - assert "com.studyctl.my-job" in plist - - def test_contains_program_arguments(self): - job = Job(name="run", command="~/.local/bin/studyctl sync --all", schedule="daily 7am") - with patch("studyctl.scheduler._is_macos", return_value=True): - plist = _launchd_plist(job, "alice") - assert "/Users/alice/.local/bin/studyctl" in plist - assert "sync" in plist - assert "--all" in plist - - def test_every_2h_schedule(self): - job = Job(name="j", command="~/cmd", schedule="every 2h") - plist = _launchd_plist(job, "u") - assert "StartCalendarInterval" in plist - assert "" in plist - - def test_daily_schedule(self): - job = Job(name="j", command="~/cmd", schedule="daily 9am") - plist = _launchd_plist(job, "u") - assert "9" in plist - - def test_valid_xml_structure(self): - job = Job(name="j", command="~/cmd", schedule="every 4h") - plist = _launchd_plist(job, "u") - assert plist.startswith("") - - -class TestCronEntry: - def test_every_2h_expression(self): - job = Job(name="j", command="~/cmd", schedule="every 2h") - assert _cron_expression(job) == "0 8-22/2 * * *" - - def test_every_4h_expression(self): - job = Job(name="j", command="~/cmd", schedule="every 4h") - assert _cron_expression(job) == "30 8-22/4 * * *" - - def test_daily_expression(self): - job = Job(name="j", command="~/cmd", schedule="daily 7am") - assert _cron_expression(job) == "0 7 * * *" - - def test_cron_line_contains_marker(self): - job = Job(name="my-job", command="~/.local/bin/run", schedule="every 2h") - line = _cron_line(job) - assert "# studyctl:my-job" in line - - def test_cron_line_expands_tilde(self): - job = Job(name="j", command="~/.local/bin/run", schedule="every 2h") - line = _cron_line(job) - assert "~" not in line.split("#")[0] # tilde should be expanded in command portion - - -class TestListJobs: - @patch("studyctl.scheduler._launchd_list", return_value=[]) - @patch("studyctl.scheduler._is_macos", return_value=True) - def test_returns_empty_on_macos(self, _mock_mac, _mock_list): - assert list_jobs() == [] - - @patch("studyctl.scheduler._cron_list", return_value=[]) - @patch("studyctl.scheduler._is_macos", return_value=False) - def test_returns_empty_on_linux(self, _mock_mac, _mock_list): - assert list_jobs() == [] From 727763e027cf0a10c844eab3ffd5a19f13dad8a0 Mon Sep 17 00:00:00 2001 From: Andy Taylor Date: Mon, 23 Mar 2026 10:02:05 +0000 Subject: [PATCH 02/38] docs: update all user-facing docs for core-only compaction - cli-reference.md: rewritten to reflect 13 commands (removed TUI, schedule, state, wins, streaks, bridges, teachback) - setup-guide.md: removed TUI section, replaced state push/pull with session-sync - roadmap.md: added compaction notice at top - TODO.md: rewritten with archived features section - session-protocol.md: replaced studyctl wins/schedule-blocks references - audhd-framework.md: replaced studyctl wins reference - audhd-learning-philosophy.md: replaced studyctl bridge add reference --- TODO.md | 52 +++++++++-------- docs/audhd-framework.md | 2 +- docs/audhd-learning-philosophy.md | 2 +- docs/cli-reference.md | 94 +++++++++---------------------- docs/roadmap.md | 2 + docs/session-protocol.md | 4 +- docs/setup-guide.md | 48 +--------------- 7 files changed, 64 insertions(+), 140 deletions(-) diff --git a/TODO.md b/TODO.md index e436c53cc..0cb743db5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,16 @@ -# Socratic Study Mentor -- Active Backlog +# Socratic Study Mentor β€” Active Backlog > Single source of truth for outstanding work. -> For Phase 1-4 implementation details, see `docs/plans/2026-03-15-feat-unified-study-platform-plan.md`. +> For compaction rationale, see `docs/plans/compaction-plan.md`. + +## Core Features (maintained) + +| Feature | Status | +|---------|--------| +| Socratic AI sessions (Claude, Kiro, Gemini, OpenCode) | βœ… Active | +| Content pipeline β†’ NotebookLM (split, process, autopilot) | βœ… Active | +| Flashcard/quiz review (PWA web app, SM-2) | βœ… Active | +| Session intelligence (export, search, sync) | βœ… Active | ## Completed (summary) @@ -14,17 +23,17 @@ | 5 | Documentation & install polish (README, agent-install, roadmap) | Done | | 6 | Centralised artefact store (GitHub Pages, config, store module) | Done | | 7 | Unified config & cross-machine sync (hosts, SSH, install scripts) | Done | -| 8 | StudyCards TUI (review_loader, review_db, SM-2, voice toggle) | Done | -| 9 | TUI polish & PWA web app (Pomodoro, voice, accessibility) | Done | +| 8 | StudyCards TUI (review_loader, review_db, SM-2, voice toggle) | Done (archived in compaction) | +| 9 | TUI polish & PWA web app (Pomodoro, voice, accessibility) | Done (TUI archived, PWA kept) | | Phase 0 | Pre-work: config consolidation, CLI split, WAL mode, service layer | Done | | Phase 1 | Content absorption: 7 modules, 10 CLI commands, 76 tests | Done | | Phase 4 | PyPI + Homebrew tap live, OIDC trusted publishing | Done | | Phase 5 | Doctor/upgrade/install-mentor: 3 CLI commands, 7 checker modules | Done | -| Fixes | Export progress bar (A1), list_concepts (A2), course picker (A3), retry wrong (A4), SQL/connection leaks (A5), narrow exceptions (A6) | Done | +| Compaction | Strip to 4 core features, 13 CLI commands, fix doctor tests | Done | -## Unified Platform Plan -- Next Phases +## Next -### Phase 6: CI/CD Pipeline (next) +### Phase 6: CI/CD Pipeline Nightly drift detection, pre-release gate, Docker image pipeline. Spec at `docs/ci-cd-pipeline.md`. @@ -32,28 +41,27 @@ Nightly drift detection, pre-release gate, Docker image pipeline. Spec at `docs/ Docker image running `studyctl web` with kokoro-onnx server-side TTS. -### Phase 2: FastAPI Web UI - -Replace stdlib HTTP server with FastAPI. HTMX + Alpine.js frontend, artefact viewer, progress dashboard. Migrate all 11 existing routes. - -See: `docs/plans/2026-03-15-feat-unified-study-platform-plan.md` -- Phase 2 - -### Phase 3: MCP Agent Integration - -FastMCP v1 server with stdio transport. Flashcard/quiz generation tools, study context tools, onboarding agent skill. - -See: `docs/plans/2026-03-15-feat-unified-study-platform-plan.md` -- Phase 3 - ## Standalone Items (not blocked by phases) - [ ] Obsidian export: convert flashcard JSON to Obsidian `#flashcard` format (Spaced Repetition plugin compatible) +## Archived Features (in git history, restore on demand) + +- TUI dashboard (`studyctl tui`) +- Scheduler (launchd/cron management) +- Calendar .ics generation (`schedule-blocks`) +- Win tracking / streaks / progress-map CLI commands +- Knowledge bridges DB + CLI commands +- Teach-back scoring DB + CLI commands +- State push/pull CLI (merged into `session-sync`) +- Crush + Amp agent definitions +- 5 extra Claude agent files (consolidated to `socratic-mentor.md`) + ## Deferred (add when real demand appears) - LAN password auth (`--password` flag + HTTP Basic Auth) - Config editor web UI - GitHub Issues API feedback -- TUI artefact browser - Native iOS/macOS app (research in `docs/research/swift-poc-feasibility.md`) - AWS cloud sync (Cognito, DynamoDB, push notifications) @@ -61,13 +69,11 @@ See: `docs/plans/2026-03-15-feat-unified-study-platform-plan.md` -- Phase 3 | Item | Location | |------|----------| +| Compaction Plan | `docs/plans/compaction-plan.md` | | Unified Platform Plan | `docs/plans/2026-03-15-feat-unified-study-platform-plan.md` | -| Brainstorm (decisions) | `docs/brainstorms/2026-03-15-unified-study-platform-brainstorm.md` | -| Code Review Items | `code-review-plan-items.md` | | CLI Package | `packages/studyctl/src/studyctl/cli/` | | Services Layer | `packages/studyctl/src/studyctl/services/` | | Settings (config) | `packages/studyctl/src/studyctl/settings.py` | | Review DB (SM-2) | `packages/studyctl/src/studyctl/review_db.py` | -| TUI Source | `packages/studyctl/src/studyctl/tui/` | | Web PWA | `packages/studyctl/src/studyctl/web/` | | Hosts Config | `~/.config/studyctl/config.yaml` | diff --git a/docs/audhd-framework.md b/docs/audhd-framework.md index 9e54626e7..3ff1dd4c5 100644 --- a/docs/audhd-framework.md +++ b/docs/audhd-framework.md @@ -63,7 +63,7 @@ The autistic cognitive style processes bottom-up: granular details first, then p Watch for: "I should already know this", "This is taking me too long" -**Response:** Reframe with evidence. Run `studyctl wins` and surface recent mastered concepts. Keep celebrations factual and specific β€” empty praise triggers AuDHD suspicion. +**Response:** Reframe with evidence. Review spaced repetition progress via `studyctl review` and surface concepts that have moved to longer intervals (indicating mastery). Keep celebrations factual and specific β€” empty praise triggers AuDHD suspicion. --- diff --git a/docs/audhd-learning-philosophy.md b/docs/audhd-learning-philosophy.md index 8cf96c52f..b478fdcb9 100644 --- a/docs/audhd-learning-philosophy.md +++ b/docs/audhd-learning-philosophy.md @@ -314,7 +314,7 @@ If 70% questions feels too intense, edit the agent persona to shift the ratio. S ### Change the concept bridges -The default bridges assume a networking background. If you're coming from a different domain, run `studyctl config init` to configure your primary expertise domain, then use `studyctl bridge add` to create specific concept bridges. +The default bridges assume a networking background. If you're coming from a different domain, run `studyctl config init` to configure your primary expertise domain. The AI agents will then use your configured domain to draw analogies during Socratic sessions. ### Modify session types diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e48dea6f5..744c97fd3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -2,55 +2,52 @@ ## studyctl -Study pipeline management β€” sync notes, spaced repetition, progress tracking. +Study pipeline management β€” content, review, and session tracking. ```bash -studyctl sync [TOPIC] --all --dry-run # Sync notes to NotebookLM +# Content pipeline +studyctl content split SOURCE # Split PDF by chapters +studyctl content process SOURCE # Split + upload to NotebookLM +studyctl content autopilot # Generate next pending episode +studyctl content from-obsidian DIR # Markdown β†’ PDF β†’ NotebookLM +studyctl content status # Show content pipeline status +studyctl content syllabus # Manage syllabus workflow + +# Sync & topics +studyctl sync [TOPIC] --all --dry-run # Sync notes to NotebookLM studyctl status [TOPIC] # Show sync status -studyctl review # Check spaced repetition due dates -studyctl struggles --days 30 # Find recurring struggle topics -studyctl wins --days 30 # Show your learning wins -studyctl progress CONCEPT -t TOPIC -c LEVEL # Record progress on a concept -studyctl progress-map # Visual map of all tracked concepts -studyctl resume # Where you left off (auto-context) -studyctl streaks # Study streak and consistency stats -studyctl teachback CONCEPT -t TOPIC -s SCORES --type TYPE # Record teach-back score -studyctl teachback-history CONCEPT # Show teach-back score progression -studyctl bridge add SRC TGT -s DOMAIN -t DOMAIN # Add a knowledge bridge -studyctl bridge list # List knowledge bridges -studyctl schedule-blocks --start 14:00 # Generate .ics calendar study blocks studyctl topics # List configured topics studyctl audio TOPIC # Generate NotebookLM audio overview studyctl dedup [TOPIC] --all --dry-run # Remove duplicate notebook sources -studyctl state push|pull|status|init # Cross-machine state sync -studyctl schedule install|remove|list|add|delete # Manage scheduled jobs -studyctl config init # Interactive setup wizard + +# Review +studyctl review # Check spaced repetition due dates +studyctl struggles --days 30 # Find recurring struggle topics + +# Configuration & health +studyctl setup # Interactive setup wizard +studyctl config init # Interactive config (3 questions) studyctl config show # Display current configuration -studyctl web [--port PORT] [--host HOST] # Launch study web app -studyctl tui # Launch interactive TUI dashboard -studyctl docs serve [--port PORT] # Serve docs site locally -studyctl docs open # Build and open docs in browser -studyctl docs list # List available doc pages -studyctl docs read PAGE # Read a page aloud via study-speak +studyctl doctor # Full health check +studyctl update # Check for available updates +studyctl upgrade # Apply all available updates + +# Web +studyctl web [--port PORT] [--host HOST] # Launch study web app (PWA) ``` ### Health & Updates -Check your installation and keep it current: - ```bash studyctl doctor # Full health check (Rich table) studyctl doctor --json # JSON output (for AI agents and CI) studyctl doctor --quiet # One-line summary studyctl doctor --category core # Check specific category only -studyctl update # Check for available updates (no changes) studyctl update --json # Machine-readable update info -studyctl upgrade # Apply all available updates studyctl upgrade --dry-run # Preview what would change studyctl upgrade --component packages # Upgrade only packages studyctl upgrade --component database # Run DB migrations only studyctl upgrade --component agents # Update agent definitions only -studyctl upgrade --force # Upgrade even if already current ``` **Exit codes for `studyctl doctor`:** @@ -63,19 +60,6 @@ studyctl upgrade --force # Upgrade even if already current **Check categories:** `core` (Python, packages, config), `database` (review DB, sessions DB), `config` (Obsidian vault, review dirs, pandoc), `deps` (optional packages), `agents` (AI tool definitions), `updates` (PyPI versions). -**AI-guided setup:** The install-mentor agent at `agents/shared/install-mentor.md` uses `studyctl doctor --json` to walk users through setup conversationally. It works with any AI coding tool (Claude Code, Kiro, Gemini CLI, OpenCode, Amp). - -### Confidence Levels - -Used with `studyctl progress`: - -| Level | Meaning | -|-------|---------| -| `struggling` | Can't solve without heavy guidance | -| `learning` | Getting it with some scaffolding | -| `confident` | Can apply independently | -| `mastered` | Can teach it to others | - ### Spaced Repetition Intervals Review schedule: **1 β†’ 3 β†’ 7 β†’ 14 β†’ 30 days** @@ -84,7 +68,7 @@ Review schedule: **1 β†’ 3 β†’ 7 β†’ 14 β†’ 30 days** ### Web PWA -`studyctl web` launches a progressive web app for flashcard and quiz review. No extra dependencies. LAN accessible by default. +`studyctl web` launches a progressive web app for flashcard and quiz review. LAN accessible by default. ```bash studyctl web # Serve on 0.0.0.0:8567 @@ -110,30 +94,11 @@ studyctl web --host localhost # Local only - **Read once** β€” speaker icon on card or `T` key - **Auto-voice** β€” header toggle or `V` key (reads everything automatically) -For best voice quality, download enhanced voices in OS settings (Accessibility β†’ Spoken Content β†’ Voices). - -### TUI Dashboard (terminal) - -`studyctl tui` launches a terminal dashboard. Requires `uv pip install studyctl[tui]`. - -| Key | Action | When | -|-----|--------|------| -| `d`/`r`/`c`/`s` | Switch tabs | Always | -| `f`/`z` | Start flashcards/quiz | Always | -| `Space` | Flip card | During review | -| `y`/`n` | Correct/incorrect | After flip | -| `r` | Retry wrong answers | After session | -| `v` | Toggle voice | During review | -| `o` | Toggle OpenDyslexic | Always | -| `q` | Quit | Always | - -**Course picker:** Modal appears when multiple directories configured. **Retry mode:** SM-2 scheduling not updated during retry. - --- ## agent-session-tools -AI session export, search, and management. +AI session export, search, and cross-machine sync. ```bash session-export [--source SOURCE] # Export AI sessions to SQLite @@ -142,9 +107,8 @@ session-query list --since 7d # List recent sessions session-query show SESSION_ID # Show session details session-query context SESSION_ID # Generate context for resuming session-query stats # Database statistics -session-sync push|pull REMOTE # Sync database across machines +session-sync push|pull|sync REMOTE # Sync database across machines session-maint vacuum|reindex|schema|archive # Database maintenance -tutor-checkpoint code --skill SKILL # Record study progress study-speak "text" [-v VOICE] [-s SPEED] # Speak text aloud using TTS ``` @@ -158,7 +122,6 @@ study-speak "text" [-v VOICE] [-s SPEED] # Speak text aloud using TTS | `aider` | Aider | | `opencode` | OpenCode | | `litellm` | LiteLLM | -| `bedrock` | Bedrock Proxy | | `repoprompt` | RepoPrompt | ### Optional Extras @@ -166,5 +129,4 @@ study-speak "text" [-v VOICE] [-s SPEED] # Speak text aloud using TTS ```bash uv pip install agent-session-tools[semantic] # Vector embeddings search uv pip install agent-session-tools[tokens] # Token counting -uv pip install studyctl[tui] # TUI interface ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index af7f2925c..fb3d757f6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap +> ⚠️ **Compaction (March 2026)**: The project was stripped to 4 core features: Socratic AI sessions, content pipeline (NotebookLM), flashcard/quiz review (PWA), and session intelligence (export + search + sync). Features listed below as completed may have been archived β€” see `docs/plans/compaction-plan.md` for details. The TUI dashboard, scheduler, calendar, knowledge bridges DB, teach-back scoring DB, win/streak tracking CLI commands, and state push/pull CLI were removed. Agent support was consolidated to Claude Code, Kiro, Gemini, and OpenCode. + ## v1.0 β€” Foundation - [x] Monorepo with studyctl + agent-session-tools diff --git a/docs/session-protocol.md b/docs/session-protocol.md index aa5418240..38f7521d9 100644 --- a/docs/session-protocol.md +++ b/docs/session-protocol.md @@ -72,7 +72,7 @@ studyctl review # What's due for spaced repetition studyctl struggles # Recurring struggle topics ``` -If `studyctl wins` has recent entries, the agent surfaces one: "By the way β€” you mastered [concept] last week. That's real progress." +If spaced repetition data shows concepts moving to longer intervals, the agent surfaces one: "By the way β€” you mastered [concept] last week. That's real progress." --- @@ -146,7 +146,7 @@ Confidence levels: `struggling` β†’ `learning` β†’ `confident` β†’ `mastered` Based on spaced repetition intervals (1/3/7/14/30 days): - "You should review [concept] again in 3 days." -- Offer calendar block: `studyctl schedule-blocks --start