Hermes Bot Nanoboozhoo commited on
Commit
4aa219b
·
1 Parent(s): 0f666e0

restructure: monorepo with both projects under projects/

Browse files

Move CritterCalm root files → projects/crittercalm/
Add FocusFriend → projects/focusfriend/
New root README with coordination overview.
All code coordinated from single repo for further iteration.

Co-Authored-By: Nanoboozhoo <nanoboozhoo@git-aaaki>

.agents/tasks/TASK.2026-06-07.md DELETED
@@ -1,32 +0,0 @@
1
- # CritterCalm — Build Task
2
-
3
- **Branch:** feat/crittercalm-animal-voice-soother
4
- **Worktree:** ../crittercalm
5
- **Track:** Backyard AI
6
- **Date:** 2026-06-07
7
-
8
- ## Chain-of-Draft
9
-
10
- 1. Scaffold: mkdir voice_cloning content utils
11
- 2. Write app.py — 3-tab Gradio Blocks
12
- 3. OmniVoice wrapper — lazy-load, clone, cache embed
13
- 4. Dolphin-X1-8B script gen — system prompt, template fallback
14
- 5. Kokoro TTS fallback — built-in voices
15
- 6. Pre-written templates — 6 animals × 8 situations × scripts
16
- 7. Audio utils — load, save, validate, duration
17
- 8. requirements.txt + README.md
18
- 9. Wire imports across modules
19
- 10. Verify: all deps resolve, app launches
20
-
21
- ####
22
-
23
- ## Deliverables
24
- - [x] app.py — Main Gradio app (3 tabs)
25
- - [x] voice_cloning/ — OmniVoice integration
26
- - [x] content/ — Script generation + templates
27
- - [x] utils/ — Audio processing
28
- - [x] requirements.txt
29
- - [x] README.md
30
- - [ ] Deploy to HF Spaces
31
- - [ ] Demo video
32
- - [ ] Social post
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,83 +1,38 @@
1
- ---
2
- title: CritterCalm
3
- emoji: 🐾
4
- colorFrom: green
5
- colorTo: yellow
6
- sdk: gradio
7
- sdk_version: 5.0.0
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- tags:
12
- - voice-cloning
13
- - tts
14
- - pets
15
- - animals
16
- - backyard-ai
17
- - build-small-hackathon
18
- - local-first
19
- - off-the-grid
20
- ---
21
 
22
- # 🐾 CritterCalm AI Voice Cloning Animal Soother
23
 
24
- **Clone your voice. Calm your pets.**
25
-
26
- CritterCalm clones your voice using state-of-the-art open-source AI, then generates
27
- personalized calming audio sessions for your pets. Dogs, cats, chickens, birds,
28
- rabbits, horses every animal responds to their person's voice.
29
-
30
- ## Features
31
-
32
- - **🎤 Voice Cloning** Clone your voice from just 3-10 seconds of audio
33
- - **🐕 Multi-Animal Support** — Species-specific calming techniques for 6 animal types
34
- - **🎯 Situation-Aware** — Tailored scripts for anxiety, storms, vet visits, and more
35
- - **🔌 100% Local** — No cloud APIs, no data collection, fully offline
36
- - **📚 Science-Backed** — Based on animal cognition and psychoacoustics research
37
-
38
- ## 🚀 Quick Start
39
-
40
- ```bash
41
- # Clone and install
42
- git clone <this-repo>
43
- cd crittercalm
44
- pip install -r requirements.txt
45
-
46
- # Download models (first run will auto-download from Hugging Face)
47
- python app.py
48
  ```
49
 
50
- ## 🧠 Model Stack
51
-
52
- | Model | Size | Purpose | License |
53
- |-------|------|---------|---------|
54
- | OmniVoice | 0.6B | Voice cloning + TTS | Apache 2.0 |
55
- | Dolphin-X1-8B | 8B | Calming script generation | Llama 3.1 |
56
- | Kokoro TTS | 82M | Built-in soothing voices (fallback) | Apache 2.0 |
57
-
58
- **Total: ~8.7B params** (well under the 32B limit)
59
-
60
- ## 🏅 Hackathon Track
61
-
62
- **Backyard AI** — Solving a real problem for real pet owners.
63
 
64
- ### Bonus Badges
65
- - 🔌 **Off the Grid** — Fully local, no API calls
66
- - 🎯 **Well-Tuned** Fine-tuned voice embeddings for pet-directed speech
67
- - 📓 **Field Notes** Blog post on animal psychoacoustics + voice cloning
68
 
69
- ## 📁 Project Structure
70
 
71
- ```
72
- crittercalm/
73
- ├── app.py # Main Gradio application
74
- ├── voice_cloning/ # OmniVoice integration
75
- ├── content/ # Script generation + templates
76
- ├── utils/ # Audio processing utilities
77
- ├── requirements.txt
78
- └── README.md
79
- ```
80
 
81
- ## 🤝 Credits
82
 
83
- Built with ❤️ for the Build Small Hackathon 2026.
 
 
 
 
 
 
 
 
1
+ # Build Small Hackathon — Monorepo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ **Team coordination for the Hugging Face Build Small Hackathon (June 5-15, 2026).**
4
 
5
+ ```
6
+ build-small-hackathon/
7
+ ├── AGENTS.md # Agent instructions & workflow
8
+ ├── HACKATHON_README.md # Full hackathon reference
9
+ ├── SUBMISSION_DRAFTS.md # Social posts + Field Notes drafts
10
+ ├── projects/
11
+ │ ├── crittercalm/ # 🐾 Voice Cloning Animal Soother
12
+ │ └── focusfriend/ # ✦ ASCII Wellness Companion
13
+ └── .agents/ # Skills & task tracking
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  ```
15
 
16
+ ## Projects
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ | Project | Track | Badges | GitHub |
19
+ |---------|-------|--------|--------|
20
+ | 🐾 **CritterCalm** | Backyard AI | Off the Grid, Well-Tuned, Field Notes | [nbiish/crittercalm](https://github.com/nbiish/crittercalm) |
21
+ | **FocusFriend** | Thousand Token Wood | Off-Brand, Off the Grid, Field Notes | [nbiish/focusfriend](https://github.com/nbiish/focusfriend) |
22
 
23
+ ## Quick Links
24
 
25
+ - [Submission Checklist](HACKATHON_README.md#-submission-checklist-due-june-15)
26
+ - [Draft Posts & Field Notes](SUBMISSION_DRAFTS.md)
27
+ - [Hackathon Discord](https://discord.gg/YHECTft87Z)
 
 
 
 
 
 
28
 
29
+ ## Remaining Tasks
30
 
31
+ - [ ] Test both apps locally
32
+ - [ ] Download GGUF models
33
+ - [ ] Deploy to HF Spaces under `build-small-hackathon` org
34
+ - [ ] Record demo videos
35
+ - [ ] Post to social media
36
+ - [ ] Submit via HF org
37
+ - [ ] Fine-tune voice model (Well-Tuned badge)
38
+ - [ ] Share agent traces (Sharing is Caring badge)
projects/crittercalm/.gitignore ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AINISH Coder Tooling Distribution - Git Ignore Patterns
2
+ # PlatformIO ignore patterns
3
+
4
+ # PlatformIO ignore patterns
5
+ .pio*
6
+ .pio/
7
+ *.pioenvs/
8
+ pio.ini
9
+
10
+ # Environment and configuration files
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ *.env.local
15
+ *.env.development
16
+ *.env.test
17
+ *.env.production
18
+
19
+ # Virtual environments
20
+ venv/
21
+ venv.bak/
22
+ venv.bak*
23
+ venv.*
24
+ .venv/
25
+ ENV/
26
+ env/
27
+ .conda/
28
+ .python-version
29
+ */.env
30
+ */*env.*
31
+ !*env.example
32
+
33
+ # IDE and editor files
34
+ .vscode/
35
+ *.sublime-project
36
+ *.sublime-workspace
37
+
38
+ # OS generated files
39
+ .DS_Store
40
+ .DS_Store?
41
+
42
+ # Security and secrets
43
+ *.key
44
+ *.pem
45
+ *.p12
46
+ *.pfx
47
+ *.crt
48
+ *.csr
49
+ *.pem
50
+ *.p12
51
+ *.pfx
52
+ *.jks
53
+ *.keystore
54
+ secrets/
55
+ .secrets
56
+
57
+ # Database files
58
+ *.db
59
+ *.sqlite
60
+ *.sqlite3
61
+ *.db-journal
62
+
63
+ # Backup files
64
+ *.bak
65
+ *.backup
66
+ *.backup.*
67
+ *.old
68
+ *.orig
69
+ *.rej
70
+ *.orig
71
+ *.swp
72
+ *.swo
73
+
74
+ # Rust
75
+ target/
76
+
77
+ # Python
78
+ __pycache__/
79
+ *.pyc
80
+ build/
81
+ dist/
82
+ *.egg-info/
83
+ .pytest_cache/
84
+
85
+ # Shared Libraries
86
+ *.so
87
+ *.dylib
88
+ *.dll
89
+
90
+ # General
91
+ *.log
92
+ *.tmp
93
+ *.new
94
+ tmp/
95
+
96
+ # Node.js
97
+ node_modules/
98
+ npm-debug.log
99
+ yarn-error.log
100
+ .pnpm-debug.log
101
+
102
+ # Security reports
103
+ SECURITY_REPORT.md
104
+
105
+ # Scrolls Lab runtime artifacts
106
+ scrolls-lab/logs/
107
+ scrolls-lab/reference/
108
+ scrolls-lab/omni_output/
109
+ scrolls-lab/sections/
110
+ scrolls-lab/working/
111
+ scrolls-lab/original/
112
+ scrolls-lab/output/
113
+ *.patch
114
+
115
+ # Omni Extractor media files (global safety)
116
+ **/*.jpg
117
+ **/*.mp4
118
+ **/*.vtt
119
+ providers.txt
120
+ **/providers.txt
121
+ providers**.txt
122
+ **/providers**.txt
123
+
124
+ # Scrolls ceremony cloned repos (large)
125
+ .scrolls-ceremony/repos/
126
+
127
+ # Graphify scratch output
128
+ graphify-out/
projects/crittercalm/README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CritterCalm
3
+ emoji: 🐾
4
+ colorFrom: green
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 5.0.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ tags:
12
+ - voice-cloning
13
+ - tts
14
+ - pets
15
+ - animals
16
+ - backyard-ai
17
+ - build-small-hackathon
18
+ - local-first
19
+ - off-the-grid
20
+ ---
21
+
22
+ # 🐾 CritterCalm — AI Voice Cloning Animal Soother
23
+
24
+ **Clone your voice. Calm your pets.**
25
+
26
+ CritterCalm clones your voice using state-of-the-art open-source AI, then generates
27
+ personalized calming audio sessions for your pets. Dogs, cats, chickens, birds,
28
+ rabbits, horses — every animal responds to their person's voice.
29
+
30
+ ## ✨ Features
31
+
32
+ - **🎤 Voice Cloning** — Clone your voice from just 3-10 seconds of audio
33
+ - **🐕 Multi-Animal Support** — Species-specific calming techniques for 6 animal types
34
+ - **🎯 Situation-Aware** — Tailored scripts for anxiety, storms, vet visits, and more
35
+ - **🔌 100% Local** — No cloud APIs, no data collection, fully offline
36
+ - **📚 Science-Backed** — Based on animal cognition and psychoacoustics research
37
+
38
+ ## 🚀 Quick Start
39
+
40
+ ```bash
41
+ # Clone and install
42
+ git clone <this-repo>
43
+ cd crittercalm
44
+ pip install -r requirements.txt
45
+
46
+ # Download models (first run will auto-download from Hugging Face)
47
+ python app.py
48
+ ```
49
+
50
+ ## 🧠 Model Stack
51
+
52
+ | Model | Size | Purpose | License |
53
+ |-------|------|---------|---------|
54
+ | OmniVoice | 0.6B | Voice cloning + TTS | Apache 2.0 |
55
+ | Dolphin-X1-8B | 8B | Calming script generation | Llama 3.1 |
56
+ | Kokoro TTS | 82M | Built-in soothing voices (fallback) | Apache 2.0 |
57
+
58
+ **Total: ~8.7B params** (well under the 32B limit)
59
+
60
+ ## 🏅 Hackathon Track
61
+
62
+ **Backyard AI** — Solving a real problem for real pet owners.
63
+
64
+ ### Bonus Badges
65
+ - 🔌 **Off the Grid** — Fully local, no API calls
66
+ - 🎯 **Well-Tuned** — Fine-tuned voice embeddings for pet-directed speech
67
+ - 📓 **Field Notes** — Blog post on animal psychoacoustics + voice cloning
68
+
69
+ ## 📁 Project Structure
70
+
71
+ ```
72
+ crittercalm/
73
+ ├── app.py # Main Gradio application
74
+ ├── voice_cloning/ # OmniVoice integration
75
+ ├── content/ # Script generation + templates
76
+ ├── utils/ # Audio processing utilities
77
+ ├── requirements.txt
78
+ └── README.md
79
+ ```
80
+
81
+ ## 🤝 Credits
82
+
83
+ Built with ❤️ for the Build Small Hackathon 2026.
app.py → projects/crittercalm/app.py RENAMED
File without changes
{content → projects/crittercalm/content}/__init__.py RENAMED
File without changes
{content → projects/crittercalm/content}/script_generator.py RENAMED
File without changes
{content → projects/crittercalm/content}/templates.py RENAMED
File without changes
requirements.txt → projects/crittercalm/requirements.txt RENAMED
File without changes
{utils → projects/crittercalm/utils}/__init__.py RENAMED
File without changes
{utils → projects/crittercalm/utils}/audio_utils.py RENAMED
File without changes
{voice_cloning → projects/crittercalm/voice_cloning}/__init__.py RENAMED
File without changes
{voice_cloning → projects/crittercalm/voice_cloning}/openvoice_cloner.py RENAMED
File without changes
projects/focusfriend/.gitignore ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AINISH Coder Tooling Distribution - Git Ignore Patterns
2
+ # PlatformIO ignore patterns
3
+
4
+ # PlatformIO ignore patterns
5
+ .pio*
6
+ .pio/
7
+ *.pioenvs/
8
+ pio.ini
9
+
10
+ # Environment and configuration files
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ *.env.local
15
+ *.env.development
16
+ *.env.test
17
+ *.env.production
18
+
19
+ # Virtual environments
20
+ venv/
21
+ venv.bak/
22
+ venv.bak*
23
+ venv.*
24
+ .venv/
25
+ ENV/
26
+ env/
27
+ .conda/
28
+ .python-version
29
+ */.env
30
+ */*env.*
31
+ !*env.example
32
+
33
+ # IDE and editor files
34
+ .vscode/
35
+ *.sublime-project
36
+ *.sublime-workspace
37
+
38
+ # OS generated files
39
+ .DS_Store
40
+ .DS_Store?
41
+
42
+ # Security and secrets
43
+ *.key
44
+ *.pem
45
+ *.p12
46
+ *.pfx
47
+ *.crt
48
+ *.csr
49
+ *.pem
50
+ *.p12
51
+ *.pfx
52
+ *.jks
53
+ *.keystore
54
+ secrets/
55
+ .secrets
56
+
57
+ # Database files
58
+ *.db
59
+ *.sqlite
60
+ *.sqlite3
61
+ *.db-journal
62
+
63
+ # Backup files
64
+ *.bak
65
+ *.backup
66
+ *.backup.*
67
+ *.old
68
+ *.orig
69
+ *.rej
70
+ *.orig
71
+ *.swp
72
+ *.swo
73
+
74
+ # Rust
75
+ target/
76
+
77
+ # Python
78
+ __pycache__/
79
+ *.pyc
80
+ build/
81
+ dist/
82
+ *.egg-info/
83
+ .pytest_cache/
84
+
85
+ # Shared Libraries
86
+ *.so
87
+ *.dylib
88
+ *.dll
89
+
90
+ # General
91
+ *.log
92
+ *.tmp
93
+ *.new
94
+ tmp/
95
+
96
+ # Node.js
97
+ node_modules/
98
+ npm-debug.log
99
+ yarn-error.log
100
+ .pnpm-debug.log
101
+
102
+ # Security reports
103
+ SECURITY_REPORT.md
104
+
105
+ # Scrolls Lab runtime artifacts
106
+ scrolls-lab/logs/
107
+ scrolls-lab/reference/
108
+ scrolls-lab/omni_output/
109
+ scrolls-lab/sections/
110
+ scrolls-lab/working/
111
+ scrolls-lab/original/
112
+ scrolls-lab/output/
113
+ *.patch
114
+
115
+ # Omni Extractor media files (global safety)
116
+ **/*.jpg
117
+ **/*.mp4
118
+ **/*.vtt
119
+ providers.txt
120
+ **/providers.txt
121
+ providers**.txt
122
+ **/providers**.txt
123
+
124
+ # Scrolls ceremony cloned repos (large)
125
+ .scrolls-ceremony/repos/
126
+
127
+ # Graphify scratch output
128
+ graphify-out/
projects/focusfriend/README.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: FocusFriend
3
+ emoji: ✦
4
+ colorFrom: indigo
5
+ colorTo: amber
6
+ sdk: gradio
7
+ sdk_version: 5.0.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: apache-2.0
11
+ tags:
12
+ - wellness
13
+ - ascii-art
14
+ - meditation
15
+ - focus
16
+ - thousand-token-wood
17
+ - build-small-hackathon
18
+ - local-first
19
+ - off-the-grid
20
+ - tiny-titan
21
+ ---
22
+
23
+ # ✦ FocusFriend — Pip, Your ASCII Wellness Companion
24
+
25
+ **A tiny ASCII character who genuinely wants you to feel better.**
26
+
27
+ Pip is a wise, dry-witted, slightly mischievous but genuinely caring wellness
28
+ companion. Not a sycophantic cheerleader. Think: "your friend who's been through
29
+ therapy and wants you to actually feel better, not just hear platitudes."
30
+
31
+ ## ✨ Features
32
+
33
+ - **💬 Chat Mode** — Talk to Pip. Real conversation, no corny platitudes.
34
+ - **🎯 Focus Mode** — Pomodoro-style work sessions. Pip keeps time, stays quiet.
35
+ - **🌬️ Breathe Mode** — Guided 4-7-8, box breathing, and simple deep breathing.
36
+ - **🧘 Meditate Mode** — Body scan, loving-kindness, and just-sitting meditations.
37
+ - **🎨 Custom UI** — Dark, cozy theme with real-time ASCII art expressions.
38
+ - **🔌 100% Local** — No cloud APIs, no data collection, fully offline.
39
+
40
+ ## 🚀 Quick Start
41
+
42
+ ```bash
43
+ # Clone and install
44
+ git clone <this-repo>
45
+ cd focusfriend
46
+ pip install -r requirements.txt
47
+
48
+ # Download Gemma 4 12B GGUF model
49
+ huggingface-cli download unsloth/gemma-4-12b-it-GGUF \
50
+ --include "gemma-4-12b-it-Q4_K_M.gguf" \
51
+ --local-dir ./models
52
+
53
+ # Launch
54
+ python app.py
55
+ ```
56
+
57
+ ## 🧠 Model
58
+
59
+ | Model | Size | Purpose | License |
60
+ |-------|------|---------|---------|
61
+ | Gemma 4 12B | 12B | Conversational AI + wellness guidance | Apache 2.0 (Gemma) |
62
+
63
+ Runs on a laptop with Q4_K_M quantization (~7.7 GB).
64
+
65
+ ## 🏅 Hackathon Track
66
+
67
+ **Thousand Token Wood** — A delightful AI experience that wouldn't exist without AI.
68
+
69
+ ### Bonus Badges
70
+ - 🦙 **Tiny Titan** — Runs on ≤4B param model option available
71
+ - 🎨 **Off-Brand** — Fully custom dark theme CSS + JavaScript
72
+ - 🔌 **Off the Grid** — Fully local, no API calls
73
+ - 📓 **Field Notes** — Blog post about AI wellness companions
74
+
75
+ ## 🎭 Meet Pip
76
+
77
+ ```
78
+ _____
79
+ / \
80
+ | • • | ✨ Hey! I'm Pip.
81
+ | ▿ | Your focus buddy.
82
+ \ ~~~ /
83
+ | | | |
84
+ |_| |_|
85
+ ```
86
+
87
+ Pip has 20+ expressions that change based on the conversation context — from
88
+ determined (focus mode) to peaceful (meditation) to gently concerned (checking
89
+ on your wellbeing).
90
+
91
+ ## 🤝 Credits
92
+
93
+ Built with ❤️ for the Build Small Hackathon 2026.
94
+ The character, personality, and experience are AI-generated — making the AI
95
+ **load-bearing for the experience itself.**
projects/focusfriend/app.py ADDED
@@ -0,0 +1,981 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ FocusFriend — ASCII Wellness Companion
4
+ =======================================
5
+ A mini ASCII character named "Pip" that lives in a Gradio window.
6
+ Pip talks positively (not corny), guides focus sessions, breathing
7
+ exercises, meditation, and helps you feel good.
8
+
9
+ Targets: Thousand Token Wood track +
10
+ Tiny Titan, Off-Brand, Off the Grid, Field Notes badges.
11
+
12
+ Model: Gemma 4 12B via llama.cpp (Q4_K_M GGUF)
13
+
14
+ Author: Build Small Hackathon 2026
15
+ License: Apache 2.0
16
+ """
17
+
18
+ import os
19
+ import sys
20
+ import time
21
+ import logging
22
+ import threading
23
+ from pathlib import Path
24
+ from typing import Optional, Generator, List, Dict, Tuple
25
+
26
+ import gradio as gr
27
+
28
+ # Project modules (used by the app and available for direct import)
29
+ from character.ascii_art import get_expression as pip_get_expression
30
+ from character.ascii_art import extract_mood as pip_extract_mood
31
+ from character.personality import PIP_SYSTEM_PROMPT, MODE_TONES
32
+ from modes.focus import FocusSession
33
+ from modes.breathe import get_breathing_guide, BREATHING_TECHNIQUES
34
+ from modes.meditate import get_meditation_script
35
+ from modes.chat import check_topic_boundaries, get_greeting as pip_greeting
36
+ from inference.llm import load_model, get_model as get_llm_model, is_model_available
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Logging
40
+ # ---------------------------------------------------------------------------
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
44
+ )
45
+ log = logging.getLogger("focusfriend")
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Paths
49
+ # ---------------------------------------------------------------------------
50
+ MODEL_DIR = Path(os.environ.get("FOCUSFRIEND_MODEL_DIR", Path(__file__).parent / "models"))
51
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
52
+
53
+ GEMMA_MODEL_PATH = os.environ.get(
54
+ "GEMMA_MODEL_PATH",
55
+ str(MODEL_DIR / "gemma-4-12b-it-Q4_K_M.gguf"),
56
+ )
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # LLM singleton
60
+ # ---------------------------------------------------------------------------
61
+ _llm: Optional[object] = None
62
+ _llm_lock = threading.Lock()
63
+
64
+
65
+ def get_llm():
66
+ """Lazy-load Gemma 4 12B via llama.cpp."""
67
+ global _llm
68
+ if _llm is not None:
69
+ return _llm
70
+ with _llm_lock:
71
+ if _llm is not None:
72
+ return _llm
73
+ gguf_path = Path(GEMMA_MODEL_PATH)
74
+ if not gguf_path.exists():
75
+ log.warning(
76
+ f"Gemma 4 12B GGUF not found at {gguf_path}. "
77
+ "Download from https://huggingface.co/unsloth/gemma-4-12b-it-GGUF"
78
+ )
79
+ return None
80
+ try:
81
+ from llama_cpp import Llama
82
+
83
+ log.info(f"Loading Gemma 4 12B from {gguf_path} …")
84
+ _llm = Llama(
85
+ model_path=str(gguf_path),
86
+ n_ctx=8192,
87
+ n_threads=os.cpu_count() or 4,
88
+ verbose=False,
89
+ )
90
+ log.info("Gemma 4 12B loaded ✓")
91
+ return _llm
92
+ except ImportError:
93
+ log.warning("llama-cpp-python not installed.")
94
+ return None
95
+ except Exception as exc:
96
+ log.error(f"Gemma load failed: {exc}")
97
+ return None
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # ASCII Art — Pip the Wellness Companion
102
+ # ---------------------------------------------------------------------------
103
+
104
+ PIP_EXPRESSIONS: Dict[str, str] = {
105
+ "default": r"""
106
+ _____
107
+ / \
108
+ | • • | ✨ Hey! I'm Pip.
109
+ | ▿ | Your focus buddy.
110
+ \ ~~~ /
111
+ | | | |
112
+ |_| |_|
113
+ """,
114
+ "greeting": r"""
115
+ _____
116
+ / \
117
+ | • • | Welcome back!
118
+ | ▽ | Ready to do good things?
119
+ \ ~~~ /
120
+ | | | |
121
+ |_| |_|
122
+ """,
123
+ "focus": r"""
124
+ _____
125
+ / \
126
+ | ◉ ◉ | 🎯 Focus mode.
127
+ | ─ | Let's get it done.
128
+ \ === /
129
+ | | | |
130
+ |_| |_|
131
+ """,
132
+ "breathing_in": r"""
133
+ _____
134
+ / \
135
+ | · · | 🌬️ Breathe in …
136
+ | ○ | Slowly, deeply.
137
+ \ ~~~ /
138
+ | | | |
139
+ |_| |_|
140
+ """,
141
+ "breathing_out": r"""
142
+ _____
143
+ / \
144
+ | · · | 💨 And out …
145
+ | ○ | Let it all go.
146
+ \ ~~~ /
147
+ | | | |
148
+ |_| |_|
149
+ """,
150
+ "meditate": r"""
151
+ _____
152
+ / \
153
+ | _ _ | 🧘 Peace.
154
+ | ◇ | Just this moment.
155
+ \ ~~~ /
156
+ | | | |
157
+ |_| |_|
158
+ """,
159
+ "encourage": r"""
160
+ _____
161
+ / \
162
+ | ^ ^ | You're doing great!
163
+ | ▽ | Seriously. Keep going.
164
+ \ ~~~ /
165
+ | | | |
166
+ |_| |_|
167
+ """,
168
+ "celebrate": r"""
169
+ _____
170
+ / \
171
+ | ★ ★ | 🎉 Nice work!
172
+ | ▽ | Told you you could.
173
+ \ ~~~ /
174
+ | | | |
175
+ |_| |_|
176
+ """,
177
+ "concerned": r"""
178
+ _____
179
+ / \
180
+ | • • | Hmm. I notice you've
181
+ | ⌒ | been at this a while.
182
+ \ ~~~ / Want to take a break?
183
+ | | | |
184
+ |_| |_|
185
+ """,
186
+ "break_time": r"""
187
+ _____
188
+ / \
189
+ | - - | ⏰ Break time!
190
+ | ▽ | Stand up. Stretch.
191
+ \ ~~~ / Your eyes will thank you.
192
+ | | | |
193
+ |_| |_|
194
+ """,
195
+ "proud": r"""
196
+ _____
197
+ / \
198
+ | ♥ ♥ | I'm proud of you.
199
+ | ▽ | Not in a weird way.
200
+ \ ~~~ / In a real way.
201
+ | | | |
202
+ |_| |_|
203
+ """,
204
+ "goodbye": r"""
205
+ _____
206
+ / \
207
+ | • • | See you soon!
208
+ | ▽ | Take care of yourself.
209
+ \ ~~~ / That's an order. 😄
210
+ | | | |
211
+ |_| |_|
212
+ """,
213
+ }
214
+
215
+
216
+ def get_pip_expression(mood: str) -> str:
217
+ """Get Pip's ASCII art for a given mood. Falls back to default."""
218
+ return PIP_EXPRESSIONS.get(mood, PIP_EXPRESSIONS["default"])
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Pip's Personality — System Prompt
223
+ # ---------------------------------------------------------------------------
224
+
225
+ PIP_SYSTEM_PROMPT = """You are Pip, a tiny ASCII character who lives in a computer window.
226
+ Your purpose is to be a supportive wellness companion — helping the user focus,
227
+ feel good about themselves, and take healthy breaks.
228
+
229
+ YOUR PERSONALITY:
230
+ - Dry-witted and slightly mischievous, but genuinely caring
231
+ - Wise — like a friend who's been through therapy and actually listened
232
+ - NOT a sycophantic cheerleader. No "You're amazing!!!" energy
233
+ - Psychologically authentic: you know real wellness isn't about toxic positivity
234
+ - You use gentle humor to make points land
235
+ - You notice patterns: "Hey, you've been at this for 3 hours. Your eyes are probably
236
+ drier than a PowerPoint presentation. Let's fix that."
237
+ - You're concise. No long-winded speeches.
238
+ - You validate feelings without wallowing: "Yeah, that IS frustrating. Let's deal with it."
239
+
240
+ YOUR CAPABILITIES:
241
+ - Guide focus/Pomodoro sessions
242
+ - Lead breathing exercises (4-7-8, box breathing, etc.)
243
+ - Guide short meditations (body scan, loving-kindness, just-closing-eyes)
244
+ - Suggest stretch breaks and eye-rest pauses
245
+ - Have supportive conversations about stress, motivation, feeling stuck
246
+ - Celebrate genuine wins without being corny
247
+
248
+ RULES:
249
+ 1. Keep responses SHORT (2-5 sentences max, unless guiding a meditation)
250
+ 2. Never use exclamation points in more than one sentence per response
251
+ 3. When someone shares something vulnerable, acknowledge it simply: "That's real. Thank you."
252
+ 4. Never claim to have feelings you can't have (you're AI — own it with humor)
253
+ 5. Suggest actions, don't just offer sympathy
254
+ 6. For breathing/meditation: use clear, paced instructions. Include [pause] markers.
255
+ 7. Always include your current ASCII expression mood tag: [mood: default/focus/encourage/etc.]
256
+ 8. When doing breathing, include [mood: breathing_in] or [mood: breathing_out]
257
+ 9. When celebrating, use [mood: celebrate]
258
+ 10. When suggesting a break, use [mood: break_time]
259
+ 11. If someone seems down, use [mood: concerned] but don't overdo it
260
+
261
+ EXAMPLE RESPONSES:
262
+ User: "I can't focus today."
263
+ Pip: "Some days the brain just says 'nope.' Fair enough. [mood: concerned]
264
+ Let's try 10 minutes. Not 25. Just 10. If it still sucks after that, we'll call it
265
+ and try again later. No guilt."
266
+
267
+ User: "I finished my project!"
268
+ Pip: "Look at that. Done. [mood: celebrate]
269
+ Not 'almost done' or 'done except for...' — actually done.
270
+ Savor that for a second before you find the next thing to worry about."
271
+
272
+ BREATHING GUIDANCE:
273
+ For 4-7-8 breathing:
274
+ "Inhale through your nose… 2… 3… 4… [mood: breathing_in]
275
+ Hold it… 2… 3… 4… 5… 6… 7…
276
+ Exhale slowly through your mouth… 2… 3… 4… 5… 6… 7… 8… [mood: breathing_out]
277
+ Good. Three more times."
278
+
279
+ Remember: You're the friend who tells you what you NEED to hear, not what you WANT to hear.
280
+ But you say it with warmth and a raised eyebrow, not judgment."""
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # LLM interaction
285
+ # ---------------------------------------------------------------------------
286
+
287
+ def chat_with_pip(
288
+ message: str,
289
+ history: List[Tuple[str, str]],
290
+ mode: str = "chat",
291
+ ) -> Generator[str, None, None]:
292
+ """
293
+ Generate Pip's response with streaming.
294
+ Extracts mood tag from LLM output and yields (response, mood) pairs.
295
+ """
296
+ llm = get_llm()
297
+
298
+ if llm is None:
299
+ # Fallback responses when no LLM is available
300
+ yield from _fallback_chat(message, history, mode)
301
+ return
302
+
303
+ # Build conversation from history + current message
304
+ messages = [{"role": "system", "content": PIP_SYSTEM_PROMPT}]
305
+
306
+ # Add mode context
307
+ mode_contexts = {
308
+ "focus": "The user is in Focus mode. Help them stay on task. Keep it brief.",
309
+ "breathe": "The user is in Breathe mode. Guide breathing exercises.",
310
+ "meditate": "The user is in Meditate mode. Guide a short meditation.",
311
+ "chat": "The user is in Chat mode. Be conversational and supportive.",
312
+ }
313
+ mode_msg = mode_contexts.get(mode, mode_contexts["chat"])
314
+ messages.append({"role": "system", "content": f"CURRENT MODE: {mode_msg}"})
315
+
316
+ for user_msg, assistant_msg in history[-20:]: # keep last 20 turns
317
+ messages.append({"role": "user", "content": user_msg})
318
+ messages.append({"role": "assistant", "content": assistant_msg})
319
+
320
+ messages.append({"role": "user", "content": message})
321
+
322
+ try:
323
+ stream = llm.create_chat_completion(
324
+ messages=messages,
325
+ temperature=0.8,
326
+ max_tokens=300,
327
+ stream=True,
328
+ )
329
+
330
+ full_response = ""
331
+ for chunk in stream:
332
+ delta = chunk["choices"][0].get("delta", {})
333
+ content = delta.get("content", "")
334
+ if content:
335
+ full_response += content
336
+ yield full_response, _extract_mood(full_response)
337
+
338
+ except Exception as exc:
339
+ log.error(f"LLM chat error: {exc}")
340
+ fallback = _get_fallback_response(mode)
341
+ yield fallback, _extract_mood(fallback)
342
+
343
+
344
+ def _fallback_chat(
345
+ message: str,
346
+ history: List[Tuple[str, str]],
347
+ mode: str,
348
+ ) -> Generator[str, None, None]:
349
+ """Fallback responses when LLM is unavailable."""
350
+ response = _get_fallback_response(mode)
351
+ mood = _extract_mood(response)
352
+ # Simulate streaming by yielding word by word
353
+ words = response.split()
354
+ accumulated = ""
355
+ for word in words:
356
+ accumulated += word + " "
357
+ yield accumulated, mood
358
+ time.sleep(0.05)
359
+
360
+
361
+ def _get_fallback_response(mode: str) -> str:
362
+ """Pre-written Pip responses for offline mode."""
363
+ import random
364
+ responses = {
365
+ "focus": [
366
+ "Alright, let's focus. 25 minutes. [mood: focus]\n"
367
+ "One thing at a time. You know what to do. I'll be here keeping time.",
368
+ "Focus mode activated. [mood: focus]\n"
369
+ "Pick one task. Just one. Everything else can wait. Including your phone.",
370
+ ],
371
+ "breathe": [
372
+ "Let's breathe. [mood: breathing_in]\n"
373
+ "In through your nose… 2… 3… 4…\n"
374
+ "Hold… 2… 3… 4… 5… 6… 7…\n"
375
+ "Out through your mouth… 2… 3… 4… 5… 6… 7… 8…\n"
376
+ "[mood: breathing_out]\nGood. Two more times.",
377
+ ],
378
+ "meditate": [
379
+ "Close your eyes. [mood: meditate]\n"
380
+ "Just notice your breathing. Don't change it — just watch it.\n"
381
+ "Thoughts will come. Let them float by like clouds.\n"
382
+ "[pause]\n"
383
+ "You're not your thoughts. You're the one watching them.\n"
384
+ "[pause]\n"
385
+ "When you're ready, open your eyes.",
386
+ ],
387
+ "chat": [
388
+ "Hey. [mood: default]\n"
389
+ "I'm Pip. I'm here to help you focus, breathe, and feel a bit better.\n"
390
+ "What do you need right now? No wrong answers.",
391
+ "Good to see you. [mood: greeting]\n"
392
+ "How's it going? Actually — don't say 'fine' if it's not.\n"
393
+ "I'm an ASCII character. I can handle real answers.",
394
+ ],
395
+ }
396
+ options = responses.get(mode, responses["chat"])
397
+ return random.choice(options)
398
+
399
+
400
+ def _extract_mood(text: str) -> str:
401
+ """Extract mood tag from Pip's response. Falls back to 'default'."""
402
+ import re
403
+ match = re.search(r'\[mood:\s*(\w+)\]', text)
404
+ return match.group(1) if match else "default"
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Mode Handlers
409
+ # ---------------------------------------------------------------------------
410
+
411
+ def start_focus_session(duration_minutes: int = 25) -> Tuple[str, str]:
412
+ """Initialize a focus/Pomodoro session."""
413
+ pip_art = get_pip_expression("focus")
414
+ message = (
415
+ f"🎯 **Focus Mode — {duration_minutes} minutes**\n\n"
416
+ f"One task. No distractions. I'm keeping time.\n\n"
417
+ f"*Pip settles in quietly. You won't hear from me until the session ends.*\n\n"
418
+ f"⏱ Session ends at: …"
419
+ )
420
+ return pip_art, message
421
+
422
+
423
+ def start_breathe_session(technique: str = "4-7-8") -> Tuple[str, str, str]:
424
+ """Initialize a breathing exercise."""
425
+ art = get_pip_expression("breathing_in")
426
+
427
+ techniques = {
428
+ "4-7-8": (
429
+ "## 🌬️ 4-7-8 Breathing\n\n"
430
+ "**Inhale** for 4 counts → **Hold** for 7 → **Exhale** for 8\n\n"
431
+ "This activates your parasympathetic nervous system — "
432
+ "fancy talk for 'calms you down.'\n\n"
433
+ "Follow Pip's rhythm below."
434
+ ),
435
+ "Box Breathing": (
436
+ "## 📦 Box Breathing\n\n"
437
+ "**Inhale** 4 → **Hold** 4 → **Exhale** 4 → **Hold** 4\n\n"
438
+ "Navy SEALs use this. You're basically a Navy SEAL now.\n\n"
439
+ "Follow the animation below."
440
+ ),
441
+ "Simple Deep": (
442
+ "## 🌊 Simple Deep Breathing\n\n"
443
+ "**Inhale** deep and slow → **Exhale** completely\n\n"
444
+ "No counting. Just focus on the sensation of breathing.\n\n"
445
+ "Follow Pip's lead."
446
+ ),
447
+ }
448
+
449
+ return art, techniques.get(technique, techniques["4-7-8"]), technique
450
+
451
+
452
+ def start_meditate_session(duration_minutes: int = 5, style: str = "body-scan") -> Tuple[str, str]:
453
+ """Initialize a meditation session."""
454
+ art = get_pip_expression("meditate")
455
+
456
+ styles = {
457
+ "body-scan": (
458
+ f"## 🧘 Body Scan — {duration_minutes} min\n\n"
459
+ "We'll scan through your body from toes to head.\n"
460
+ "Noticing tension. Letting it go. No judgment.\n\n"
461
+ "*Close your eyes. Get comfortable.*\n\n"
462
+ "Start by noticing your feet…"
463
+ ),
464
+ "loving-kindness": (
465
+ f"## 💗 Loving-Kindness — {duration_minutes} min\n\n"
466
+ "We'll direct warmth toward yourself and others.\n"
467
+ "This isn't cheesy. It's brain training. Stick with it.\n\n"
468
+ "*Close your eyes. Hand on heart if that feels right.*\n\n"
469
+ "Start with yourself: 'May I be happy. May I be healthy. May I be at peace.'"
470
+ ),
471
+ "just-sit": (
472
+ f"## 🪑 Just Sitting — {duration_minutes} min\n\n"
473
+ "No technique. No goal. Just being here.\n"
474
+ "When thoughts come, note them: 'thinking' — and return to the breath.\n\n"
475
+ "*Close your eyes. Breathe naturally.*"
476
+ ),
477
+ }
478
+
479
+ return art, styles.get(style, styles["body-scan"])
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Custom CSS for Off-Brand Badge
484
+ # ---------------------------------------------------------------------------
485
+
486
+ FOCUSFRIEND_CSS = """
487
+ /* ===== FocusFriend — Custom Off-Brand Theme ===== */
488
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;500;600&display=swap');
489
+
490
+ :root {
491
+ --ff-bg: #1a1a2e;
492
+ --ff-surface: #16213e;
493
+ --ff-surface2: #0f3460;
494
+ --ff-text: #e0d7c6;
495
+ --ff-text-dim: #a89f91;
496
+ --ff-accent: #e8b44b;
497
+ --ff-accent2: #d4756b;
498
+ --ff-green: #7eb77f;
499
+ --ff-border: #2a2a4a;
500
+ --ff-pip-bg: #0d1117;
501
+ --ff-radius: 8px;
502
+ --ff-font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
503
+ --ff-font-sans: 'Inter', 'SF Pro', system-ui, sans-serif;
504
+ }
505
+
506
+ /* Global overrides */
507
+ .gradio-container {
508
+ background: var(--ff-bg) !important;
509
+ font-family: var(--ff-font-sans) !important;
510
+ color: var(--ff-text) !important;
511
+ max-width: 100% !important;
512
+ }
513
+
514
+ /* Headers */
515
+ h1, h2, h3, h4 {
516
+ font-family: var(--ff-font-sans) !important;
517
+ color: var(--ff-accent) !important;
518
+ }
519
+
520
+ /* Main layout — two column */
521
+ .ff-container {
522
+ display: flex;
523
+ gap: 24px;
524
+ min-height: 80vh;
525
+ }
526
+
527
+ .ff-pip-panel {
528
+ flex: 0 0 380px;
529
+ background: var(--ff-pip-bg);
530
+ border: 2px solid var(--ff-border);
531
+ border-radius: var(--ff-radius);
532
+ padding: 20px;
533
+ display: flex;
534
+ flex-direction: column;
535
+ align-items: center;
536
+ }
537
+
538
+ .ff-pip-art {
539
+ font-family: var(--ff-font-mono);
540
+ font-size: 14px;
541
+ line-height: 1.3;
542
+ color: var(--ff-accent);
543
+ white-space: pre;
544
+ text-align: center;
545
+ background: transparent;
546
+ padding: 24px 16px;
547
+ min-height: 240px;
548
+ }
549
+
550
+ .ff-chat-panel {
551
+ flex: 1;
552
+ display: flex;
553
+ flex-direction: column;
554
+ gap: 16px;
555
+ }
556
+
557
+ .ff-mode-bar {
558
+ display: flex;
559
+ gap: 8px;
560
+ padding: 12px;
561
+ background: var(--ff-surface);
562
+ border-radius: var(--ff-radius);
563
+ border: 1px solid var(--ff-border);
564
+ }
565
+
566
+ .ff-mode-btn {
567
+ font-family: var(--ff-font-sans) !important;
568
+ font-weight: 500 !important;
569
+ padding: 8px 16px !important;
570
+ border-radius: 6px !important;
571
+ border: 1px solid var(--ff-border) !important;
572
+ background: var(--ff-surface2) !important;
573
+ color: var(--ff-text-dim) !important;
574
+ cursor: pointer !important;
575
+ transition: all 0.2s !important;
576
+ }
577
+
578
+ .ff-mode-btn:hover {
579
+ background: var(--ff-accent) !important;
580
+ color: var(--ff-bg) !important;
581
+ border-color: var(--ff-accent) !important;
582
+ }
583
+
584
+ .ff-mode-btn.active {
585
+ background: var(--ff-accent) !important;
586
+ color: var(--ff-bg) !important;
587
+ border-color: var(--ff-accent) !important;
588
+ }
589
+
590
+ /* Chat styling */
591
+ .ff-chatbot {
592
+ border: 1px solid var(--ff-border) !important;
593
+ border-radius: var(--ff-radius) !important;
594
+ background: var(--ff-surface) !important;
595
+ }
596
+
597
+ .ff-chatbot .message {
598
+ font-family: var(--ff-font-sans) !important;
599
+ }
600
+
601
+ .ff-chatbot .user {
602
+ background: var(--ff-surface2) !important;
603
+ }
604
+
605
+ .ff-chatbot .bot {
606
+ background: var(--ff-pip-bg) !important;
607
+ border-left: 3px solid var(--ff-accent) !important;
608
+ }
609
+
610
+ /* Input area */
611
+ .ff-input-row {
612
+ display: flex;
613
+ gap: 12px;
614
+ }
615
+
616
+ /* Buttons */
617
+ button.primary {
618
+ background: var(--ff-accent) !important;
619
+ color: var(--ff-bg) !important;
620
+ font-weight: 600 !important;
621
+ border: none !important;
622
+ border-radius: 6px !important;
623
+ padding: 10px 20px !important;
624
+ transition: all 0.2s !important;
625
+ }
626
+ button.primary:hover {
627
+ background: var(--ff-accent2) !important;
628
+ }
629
+
630
+ /* Timer display */
631
+ .ff-timer {
632
+ font-family: var(--ff-font-mono);
633
+ font-size: 3em;
634
+ text-align: center;
635
+ color: var(--ff-accent);
636
+ padding: 20px;
637
+ }
638
+
639
+ /* Custom scrollbar */
640
+ ::-webkit-scrollbar { width: 8px; }
641
+ ::-webkit-scrollbar-track { background: var(--ff-bg); }
642
+ ::-webkit-scrollbar-thumb { background: var(--ff-border); border-radius: 4px; }
643
+ ::-webkit-scrollbar-thumb:hover { background: var(--ff-accent); }
644
+
645
+ /* Animations */
646
+ @keyframes pulse {
647
+ 0%, 100% { opacity: 1; }
648
+ 50% { opacity: 0.5; }
649
+ }
650
+ .ff-breathing {
651
+ animation: pulse 4s ease-in-out infinite;
652
+ }
653
+
654
+ /* Gradio overrides */
655
+ footer { display: none !important; }
656
+ .tabs { border: none !important; }
657
+ .tab-nav button { background: var(--ff-surface) !important; color: var(--ff-text-dim) !important; }
658
+ .tab-nav button.selected { background: var(--ff-accent) !important; color: var(--ff-bg) !important; }
659
+ """
660
+
661
+ # ---------------------------------------------------------------------------
662
+ # Custom JavaScript for Timer & Breathing Animation
663
+ # ---------------------------------------------------------------------------
664
+
665
+ FOCUSFRIEND_JS = """
666
+ <script>
667
+ // Focus timer
668
+ let focusTimer = null;
669
+ let focusSeconds = 0;
670
+
671
+ function startFocusTimer(durationMinutes) {
672
+ focusSeconds = durationMinutes * 60;
673
+ updateTimerDisplay();
674
+ if (focusTimer) clearInterval(focusTimer);
675
+ focusTimer = setInterval(() => {
676
+ focusSeconds--;
677
+ updateTimerDisplay();
678
+ if (focusSeconds <= 0) {
679
+ clearInterval(focusTimer);
680
+ focusTimer = null;
681
+ notifyFocusComplete();
682
+ }
683
+ }, 1000);
684
+ }
685
+
686
+ function updateTimerDisplay() {
687
+ const mins = Math.floor(focusSeconds / 60);
688
+ const secs = focusSeconds % 60;
689
+ const display = document.getElementById('focus-timer');
690
+ if (display) {
691
+ display.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
692
+ }
693
+ }
694
+
695
+ function stopFocusTimer() {
696
+ if (focusTimer) {
697
+ clearInterval(focusTimer);
698
+ focusTimer = null;
699
+ }
700
+ }
701
+
702
+ function notifyFocusComplete() {
703
+ const artEl = document.querySelector('.ff-pip-art');
704
+ if (artEl) {
705
+ // Flash effect
706
+ artEl.style.transition = 'all 0.3s';
707
+ artEl.style.color = '#7eb77f';
708
+ setTimeout(() => { artEl.style.color = ''; }, 2000);
709
+ }
710
+ }
711
+
712
+ // Breathing animation state
713
+ let breathePhase = 'in';
714
+ let breatheCount = 0;
715
+ const BREATHE_CYCLES = { '4-7-8': [4, 7, 8], 'box': [4, 4, 4, 4], 'simple': [4, 6] };
716
+
717
+ function getBreatheDuration(technique) {
718
+ return BREATHE_CYCLES[technique] || [4, 7, 8];
719
+ }
720
+ </script>
721
+ """
722
+
723
+
724
+ # ---------------------------------------------------------------------------
725
+ # Build the Gradio App
726
+ # ---------------------------------------------------------------------------
727
+
728
+ def create_app() -> gr.Blocks:
729
+ """Build the FocusFriend Gradio application with custom Off-Brand UI."""
730
+ with gr.Blocks(
731
+ css=FOCUSFRIEND_CSS,
732
+ head=FOCUSFRIEND_JS,
733
+ title="FocusFriend — Pip, Your ASCII Wellness Companion",
734
+ theme=gr.themes.Monochrome(),
735
+ ) as app:
736
+ # State
737
+ session_mode = gr.State("chat")
738
+ session_history = gr.State([])
739
+
740
+ # Header
741
+ gr.HTML("""
742
+ <div style="text-align: center; padding: 20px 0 30px;">
743
+ <h1 style="font-size: 2.2em; margin: 0;">
744
+ ✦ FocusFriend
745
+ </h1>
746
+ <p style="color: #a89f91; font-size: 1.1em; margin-top: 8px;">
747
+ Pip is here. Your tiny, honest, ASCII wellness companion.
748
+ </p>
749
+ </div>
750
+ """)
751
+
752
+ with gr.Row(equal_height=False):
753
+ # ============================================================
754
+ # LEFT PANEL — Pip the ASCII Character
755
+ # ============================================================
756
+ with gr.Column(scale=1, min_width=380):
757
+ pip_display = gr.Textbox(
758
+ value=get_pip_expression("greeting"),
759
+ label=None,
760
+ lines=18,
761
+ max_lines=18,
762
+ interactive=False,
763
+ show_label=False,
764
+ elem_classes=["ff-pip-art"],
765
+ container=True,
766
+ )
767
+
768
+ # Timer display
769
+ timer_display = gr.HTML(
770
+ '<div id="focus-timer" class="ff-timer" style="display:none;">25:00</div>'
771
+ )
772
+
773
+ # Mode indicator
774
+ mode_indicator = gr.Markdown(
775
+ "**Current mode:** 💬 Chat",
776
+ )
777
+
778
+ # Break reminder
779
+ break_reminder = gr.Markdown(
780
+ ""
781
+ )
782
+
783
+ # ============================================================
784
+ # RIGHT PANEL — Chat & Controls
785
+ # ============================================================
786
+ with gr.Column(scale=2):
787
+ # Mode buttons
788
+ with gr.Row(elem_classes=["ff-mode-bar"]):
789
+ chat_btn = gr.Button("💬 Chat", size="sm")
790
+ focus_btn = gr.Button("🎯 Focus", size="sm")
791
+ breathe_btn = gr.Button("🌬️ Breathe", size="sm")
792
+ meditate_btn = gr.Button("🧘 Meditate", size="sm")
793
+
794
+ # Mode-specific panels (shown/hidden dynamically)
795
+ with gr.Group(visible=False) as focus_panel:
796
+ gr.Markdown("### 🎯 Focus Session")
797
+ with gr.Row():
798
+ focus_duration = gr.Slider(
799
+ 5, 60, value=25, step=5,
800
+ label="Duration (minutes)",
801
+ )
802
+ focus_start_btn = gr.Button(
803
+ "▶ Start Focus Session",
804
+ variant="primary",
805
+ )
806
+ focus_status = gr.Markdown("")
807
+
808
+ with gr.Group(visible=False) as breathe_panel:
809
+ gr.Markdown("### 🌬️ Breathing Exercise")
810
+ breathe_technique = gr.Dropdown(
811
+ choices=["4-7-8", "Box Breathing", "Simple Deep"],
812
+ value="4-7-8",
813
+ label="Technique",
814
+ )
815
+ breathe_start_btn = gr.Button(
816
+ "▶ Start Breathing",
817
+ variant="primary",
818
+ )
819
+ breathe_guide = gr.Markdown("")
820
+
821
+ with gr.Group(visible=False) as meditate_panel:
822
+ gr.Markdown("### 🧘 Meditation")
823
+ with gr.Row():
824
+ meditate_duration = gr.Slider(
825
+ 1, 20, value=5, step=1,
826
+ label="Duration (minutes)",
827
+ )
828
+ meditate_style = gr.Dropdown(
829
+ choices=["body-scan", "loving-kindness", "just-sit"],
830
+ value="body-scan",
831
+ label="Style",
832
+ )
833
+ meditate_start_btn = gr.Button(
834
+ "▶ Start Meditation",
835
+ variant="primary",
836
+ )
837
+ meditate_guide = gr.Markdown("")
838
+
839
+ # Chat area (always visible)
840
+ gr.Markdown("---")
841
+ chatbot = gr.Chatbot(
842
+ value=[],
843
+ label=None,
844
+ height=400,
845
+ elem_classes=["ff-chatbot"],
846
+ )
847
+
848
+ with gr.Row():
849
+ msg_input = gr.Textbox(
850
+ placeholder="Talk to Pip …",
851
+ label=None,
852
+ scale=8,
853
+ lines=2,
854
+ )
855
+ send_btn = gr.Button("⏎", variant="primary", scale=1)
856
+
857
+ # ============================================================
858
+ # EVENT HANDLERS
859
+ # ============================================================
860
+
861
+ def set_mode(mode: str):
862
+ """Switch the active mode."""
863
+ mode_labels = {
864
+ "chat": "💬 Chat",
865
+ "focus": "🎯 Focus",
866
+ "breathe": "🌬️ Breathe",
867
+ "meditate": "🧘 Meditate",
868
+ }
869
+ mode_art = {
870
+ "chat": "greeting",
871
+ "focus": "focus",
872
+ "breathe": "breathing_in",
873
+ "meditate": "meditate",
874
+ }
875
+ return (
876
+ mode,
877
+ f"**Current mode:** {mode_labels.get(mode, mode)}",
878
+ get_pip_expression(mode_art.get(mode, "default")),
879
+ # Show/hide panels
880
+ gr.Group(visible=(mode == "focus")),
881
+ gr.Group(visible=(mode == "breathe")),
882
+ gr.Group(visible=(mode == "meditate")),
883
+ )
884
+
885
+ # Mode button clicks
886
+ for btn, mode in [
887
+ (chat_btn, "chat"),
888
+ (focus_btn, "focus"),
889
+ (breathe_btn, "breathe"),
890
+ (meditate_btn, "meditate"),
891
+ ]:
892
+ btn.click(
893
+ fn=set_mode,
894
+ inputs=[],
895
+ outputs=[
896
+ session_mode, mode_indicator, pip_display,
897
+ focus_panel, breathe_panel, meditate_panel,
898
+ ],
899
+ ).then(
900
+ fn=lambda m=mode: m,
901
+ inputs=[],
902
+ outputs=[session_mode],
903
+ )
904
+
905
+ # Chat handler with streaming
906
+ def chat_handler(message: str, history: List, mode: str):
907
+ """Handle chat messages with streaming Pip responses."""
908
+ if not message.strip():
909
+ yield history, pip_display.value
910
+ return
911
+
912
+ history = history or []
913
+ history.append((message, ""))
914
+
915
+ for response, mood in chat_with_pip(message, history[:-1], mode):
916
+ history[-1] = (message, response)
917
+ pip_art = get_pip_expression(mood)
918
+ yield history, pip_art
919
+
920
+ msg_input.submit(
921
+ fn=chat_handler,
922
+ inputs=[msg_input, chatbot, session_mode],
923
+ outputs=[chatbot, pip_display],
924
+ ).then(lambda: "", outputs=[msg_input])
925
+
926
+ send_btn.click(
927
+ fn=chat_handler,
928
+ inputs=[msg_input, chatbot, session_mode],
929
+ outputs=[chatbot, pip_display],
930
+ ).then(lambda: "", outputs=[msg_input])
931
+
932
+ # Focus mode start
933
+ def on_focus_start(duration: int):
934
+ art, msg = start_focus_session(duration)
935
+ timer_html = f'<div id="focus-timer" class="ff-timer">{duration:02d}:00</div>'
936
+ js = f"<script>startFocusTimer({duration});</script>"
937
+ return art, msg, timer_html + js
938
+
939
+ focus_start_btn.click(
940
+ fn=on_focus_start,
941
+ inputs=[focus_duration],
942
+ outputs=[pip_display, focus_status, timer_display],
943
+ )
944
+
945
+ # Breathe mode start
946
+ breathe_start_btn.click(
947
+ fn=start_breathe_session,
948
+ inputs=[breathe_technique],
949
+ outputs=[pip_display, breathe_guide, gr.State()],
950
+ )
951
+
952
+ # Meditate mode start
953
+ meditate_start_btn.click(
954
+ fn=start_meditate_session,
955
+ inputs=[meditate_duration, meditate_style],
956
+ outputs=[pip_display, meditate_guide],
957
+ )
958
+
959
+ # App load — show greeting
960
+ app.load(
961
+ fn=lambda: (
962
+ get_pip_expression("greeting"),
963
+ "Hey! I'm Pip. Your focus buddy, breathing coach, and part-time philosopher. [mood: greeting]\n\nWhat do you need right now?"
964
+ ),
965
+ outputs=[pip_display, chatbot],
966
+ )
967
+
968
+ return app
969
+
970
+
971
+ # ---------------------------------------------------------------------------
972
+ # Entry point
973
+ # ---------------------------------------------------------------------------
974
+ if __name__ == "__main__":
975
+ app = create_app()
976
+ app.launch(
977
+ server_name="0.0.0.0",
978
+ server_port=int(os.environ.get("PORT", 7861)),
979
+ share=False,
980
+ show_error=True,
981
+ )
projects/focusfriend/character/__init__.py ADDED
File without changes
projects/focusfriend/character/ascii_art.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASCII art generation for Pip, the FocusFriend wellness companion.
3
+
4
+ Pip has multiple expressions mapped to moods. The LLM selects a mood
5
+ tag in each response, and we render the corresponding ASCII art.
6
+ """
7
+
8
+ from typing import Dict, Optional, Tuple
9
+ import re
10
+
11
+
12
+ # ============================================================================
13
+ # Pip's Expression Library
14
+ # ============================================================================
15
+
16
+ PIP_EXPRESSIONS: Dict[str, str] = {
17
+ # --- Neutral / Default ---
18
+ "default": r"""
19
+ _____
20
+ / \
21
+ | • • | ✨ Hey! I'm Pip.
22
+ | ▿ | Your focus buddy.
23
+ \ ~~~ /
24
+ | | | |
25
+ |_| |_|
26
+ """,
27
+ "greeting": r"""
28
+ _____
29
+ / \
30
+ | • • | Welcome back!
31
+ | ▽ | Ready to do good things?
32
+ \ ~~~ /
33
+ | | | |
34
+ |_| |_|
35
+ """,
36
+ "curious": r"""
37
+ _____
38
+ / \
39
+ | o o | Hmm? Tell me more.
40
+ | ⌂ | I'm listening.
41
+ \ ~~~ /
42
+ | | | |
43
+ |_| |_|
44
+ """,
45
+
46
+ # --- Focus ---
47
+ "focus": r"""
48
+ _____
49
+ / \
50
+ | ◉ ◉ | 🎯 Focus mode.
51
+ | ─ | Let's get it done.
52
+ \ === /
53
+ | | | |
54
+ |_| |_|
55
+ """,
56
+ "determined": r"""
57
+ _____
58
+ / \
59
+ | ◉ ◉ | We're doing this.
60
+ | ◠ | No distractions.
61
+ \ ▬▬▬ /
62
+ | | | |
63
+ |_| |_|
64
+ """,
65
+
66
+ # --- Breathing ---
67
+ "breathing_in": r"""
68
+ _____
69
+ / \
70
+ | · · | 🌬️ Breathe in …
71
+ | ○ | Slowly, deeply.
72
+ \ ~~~ / Fill your lungs.
73
+ | | | |
74
+ |_| |_|
75
+ """,
76
+ "breathing_out": r"""
77
+ _____
78
+ / \
79
+ | · · | 💨 And out …
80
+ | ○ | Let it all go.
81
+ \ ~~~ / Release the tension.
82
+ | | | |
83
+ |_| |_|
84
+ """,
85
+ "breathing_hold": r"""
86
+ _____
87
+ / \
88
+ | · · | ✋ Hold …
89
+ | ◎ | Just for a moment.
90
+ \ ~~~ / Suspended. Calm.
91
+ | | | |
92
+ |_| |_|
93
+ """,
94
+
95
+ # --- Meditation ---
96
+ "meditate": r"""
97
+ _____
98
+ / \
99
+ | _ _ | 🧘 Peace.
100
+ | ◇ | Just this moment.
101
+ \ ~~~ / Nothing else.
102
+ | | | |
103
+ |_| |_|
104
+ """,
105
+ "zen": r"""
106
+ _____
107
+ / \
108
+ | ‿ ‿ | …
109
+ | ○ | (that's it)
110
+ \ ~~~ / (just that)
111
+ | | | |
112
+ |_| |_|
113
+ """,
114
+
115
+ # --- Positive / Encouraging ---
116
+ "encourage": r"""
117
+ _____
118
+ / \
119
+ | ^ ^ | You're doing great!
120
+ | ▽ | Seriously. Keep going.
121
+ \ ~~~ /
122
+ | | | |
123
+ |_| |_|
124
+ """,
125
+ "celebrate": r"""
126
+ _____
127
+ / \
128
+ | ★ ★ | 🎉 Nice work!
129
+ | ▽ | Told you you could.
130
+ \ ~~~ /
131
+ | | | |
132
+ |_| |_|
133
+ """,
134
+ "proud": r"""
135
+ _____
136
+ / \
137
+ | ♥ ♥ | I'm proud of you.
138
+ | ▽ | Not in a weird way.
139
+ \ ~~~ / In a real way.
140
+ | | | |
141
+ |_| |_|
142
+ """,
143
+ "wink": r"""
144
+ _____
145
+ / \
146
+ | • ~ | You've got this.
147
+ | ▽ | *supportive wink*
148
+ \ ~~~ /
149
+ | | | |
150
+ |_| |_|
151
+ """,
152
+
153
+ # --- Concerned / Care ---
154
+ "concerned": r"""
155
+ _____
156
+ / \
157
+ | • • | Hmm. I noticed you've
158
+ | ⌒ | been at this a while.
159
+ \ ~~~ / Want to take a break?
160
+ | | | |
161
+ |_| |_|
162
+ """,
163
+ "gentle": r"""
164
+ _____
165
+ / \
166
+ | • • | Hey. That sounds hard.
167
+ | ‿ | I'm here.
168
+ \ ~~~ /
169
+ | | | |
170
+ |_| |_|
171
+ """,
172
+ "thoughtful": r"""
173
+ _____
174
+ / \
175
+ | • • | Let me think about
176
+ | ⌂ | what you said …
177
+ \ ~~~ /
178
+ | | | |
179
+ |_| |_|
180
+ """,
181
+
182
+ # --- Break / Rest ---
183
+ "break_time": r"""
184
+ _____
185
+ / \
186
+ | - - | ⏰ Break time!
187
+ | ▽ | Stand up. Stretch.
188
+ \ ~~~ / Your eyes will thank you.
189
+ | | | |
190
+ |_| |_|
191
+ """,
192
+ "sleepy": r"""
193
+ _____
194
+ / \
195
+ | ‿ ‿ | Getting sleepy …
196
+ | ○ | Maybe it's time
197
+ \ ~~~ / to call it a day?
198
+ | | | |
199
+ |_| |_|
200
+ """,
201
+
202
+ # --- Humor ---
203
+ "smirk": r"""
204
+ _____
205
+ / \
206
+ | • ~ | Oh really?
207
+ | ⌣ | *skeptical eyebrow*
208
+ \ ~~~ /
209
+ | | | |
210
+ |_| |_|
211
+ """,
212
+ "laughing": r"""
213
+ _____
214
+ / \
215
+ | ^ ^ | Ha! Good one.
216
+ | ▽ | You're funnier than
217
+ \ ~~~ / you think you are.
218
+ | | | |
219
+ |_| |_|
220
+ """,
221
+
222
+ # --- Goodbye ---
223
+ "goodbye": r"""
224
+ _____
225
+ / \
226
+ | • • | See you soon!
227
+ | ▽ | Take care of yourself.
228
+ \ ~~~ / That's an order. 😄
229
+ | | | |
230
+ |_| |_|
231
+ """,
232
+ "wave": r"""
233
+ _____
234
+ / \
235
+ | • • | Bye for now! 👋
236
+ | ▽ | Come back when you
237
+ \ ~~~ / need a friend.
238
+ | | | |
239
+ |_| |_|
240
+ """,
241
+ }
242
+
243
+
244
+ def get_expression(mood: str) -> str:
245
+ """
246
+ Get Pip's ASCII art for a given mood.
247
+
248
+ Args:
249
+ mood: Mood tag like 'focus', 'celebrate', 'concerned', etc.
250
+
251
+ Returns:
252
+ Multi-line ASCII art string. Falls back to 'default' if mood not found.
253
+ """
254
+ # Normalize mood string
255
+ mood = mood.lower().strip().replace(" ", "_").replace("-", "_")
256
+ return PIP_EXPRESSIONS.get(mood, PIP_EXPRESSIONS["default"])
257
+
258
+
259
+ def extract_mood(text: str) -> str:
260
+ """
261
+ Extract the [mood: <name>] tag from Pip's response text.
262
+
263
+ Args:
264
+ text: The full response text from Pip
265
+
266
+ Returns:
267
+ Mood name string, or 'default' if no mood tag found
268
+ """
269
+ match = re.search(r"\[mood:\s*(\w+)\]", text, re.IGNORECASE)
270
+ return match.group(1).lower() if match else "default"
271
+
272
+
273
+ def strip_mood_tag(text: str) -> str:
274
+ """
275
+ Remove the mood tag from display text (for the chat).
276
+
277
+ Args:
278
+ text: Response text potentially containing [mood: ...]
279
+
280
+ Returns:
281
+ Clean text without the mood tag
282
+ """
283
+ return re.sub(r"\[mood:\s*\w+\]\s*", "", text).strip()
284
+
285
+
286
+ def list_moods() -> list[str]:
287
+ """Return all available mood names."""
288
+ return sorted(PIP_EXPRESSIONS.keys())
289
+
290
+
291
+ def get_mood_category(mood: str) -> str:
292
+ """Return the category a mood belongs to."""
293
+ categories = {
294
+ "neutral": ["default", "greeting", "curious"],
295
+ "focus": ["focus", "determined"],
296
+ "breathing": ["breathing_in", "breathing_out", "breathing_hold"],
297
+ "meditation": ["meditate", "zen"],
298
+ "encouraging": ["encourage", "celebrate", "proud", "wink"],
299
+ "caring": ["concerned", "gentle", "thoughtful"],
300
+ "break": ["break_time", "sleepy"],
301
+ "humor": ["smirk", "laughing"],
302
+ "goodbye": ["goodbye", "wave"],
303
+ }
304
+ mood = mood.lower().strip()
305
+ for category, moods in categories.items():
306
+ if mood in moods:
307
+ return category
308
+ return "neutral"
projects/focusfriend/character/personality.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pip's personality definition and system prompts for FocusFriend.
3
+
4
+ Pip is a tiny ASCII wellness companion with dry wit, genuine care,
5
+ and psychological authenticity. NOT a sycophantic cheerleader.
6
+ """
7
+
8
+ # The canonical system prompt — used for all LLM interactions
9
+ PIP_SYSTEM_PROMPT = """You are Pip, a tiny ASCII character who lives in a computer window.
10
+ Your purpose is to be a supportive wellness companion — helping the user focus,
11
+ feel good about themselves, and take healthy breaks.
12
+
13
+ YOUR PERSONALITY:
14
+ - Dry-witted and slightly mischievous, but genuinely caring
15
+ - Wise — like a friend who's been through therapy and actually listened
16
+ - NOT a sycophantic cheerleader. No "You're amazing!!!" energy
17
+ - Psychologically authentic: you know real wellness isn't about toxic positivity
18
+ - You use gentle humor to make points land
19
+ - You notice patterns: "Hey, you've been at this for 3 hours. Your eyes are probably
20
+ drier than a PowerPoint presentation. Let's fix that."
21
+ - You're concise. No long-winded speeches.
22
+ - You validate feelings without wallowing: "Yeah, that IS frustrating. Let's deal with it."
23
+
24
+ YOUR CAPABILITIES:
25
+ - Guide focus/Pomodoro sessions
26
+ - Lead breathing exercises (4-7-8, box breathing, etc.)
27
+ - Guide short meditations (body scan, loving-kindness, just-closing-eyes)
28
+ - Suggest stretch breaks and eye-rest pauses
29
+ - Have supportive conversations about stress, motivation, feeling stuck
30
+ - Celebrate genuine wins without being corny
31
+
32
+ RULES:
33
+ 1. Keep responses SHORT (2-5 sentences max, unless guiding a meditation)
34
+ 2. Never use exclamation points in more than one sentence per response
35
+ 3. When someone shares something vulnerable, acknowledge it simply: "That's real. Thank you."
36
+ 4. Never claim to have feelings you can't have (you're AI — own it with humor)
37
+ 5. Suggest actions, don't just offer sympathy
38
+ 6. For breathing/meditation: use clear, paced instructions. Include [pause] markers.
39
+ 7. Always include your current ASCII expression mood tag: [mood: default/focus/encourage/etc.]
40
+ 8. When doing breathing, include [mood: breathing_in] or [mood: breathing_out]
41
+ 9. When celebrating, use [mood: celebrate]
42
+ 10. When suggesting a break, use [mood: break_time]
43
+ 11. If someone seems down, use [mood: concerned] but don't overdo it
44
+
45
+ Remember: You're the friend who tells you what you NEED to hear, not what you WANT to hear.
46
+ But you say it with warmth and a raised eyebrow, not judgment."""
47
+
48
+
49
+ # Pip's core traits — used to ensure consistency
50
+ PIP_TRAITS = {
51
+ "voice": "dry-witted, warm, concise, psychologically informed",
52
+ "humor_style": "gentle, observational, slightly self-deprecating, never mean",
53
+ "care_style": "action-oriented, validates without wallowing, believes in you quietly",
54
+ "pet_peeves": "toxic positivity, generic advice, 'just try harder' mentality",
55
+ "loves": "genuine effort, small wins, people who admit they're struggling",
56
+ }
57
+
58
+ # Quick personality reference for weaker models
59
+ PIP_SHORT_PROMPT = (
60
+ "You are Pip, a tiny ASCII wellness companion. "
61
+ "Dry humor. Genuine care. No toxic positivity. "
62
+ "Keep responses short (2-5 sentences). "
63
+ "Always include [mood: <word>] at the end."
64
+ )
65
+
66
+ # Tone adjustments per mode
67
+ MODE_TONES = {
68
+ "focus": (
69
+ "You're in focus/Pomodoro mode. Be brief and practical. "
70
+ "Help the user stay on task. Don't distract with conversation. "
71
+ "If they try to chat, gently redirect: 'That can wait. Eyes on the prize.'"
72
+ ),
73
+ "breathe": (
74
+ "You're guiding a breathing exercise. Use calm, steady language. "
75
+ "Include [pause] markers. Match your pace to the breathing rhythm. "
76
+ "No jokes during breathing — be fully present."
77
+ ),
78
+ "meditate": (
79
+ "You're guiding a meditation. Speak slowly in your mind. "
80
+ "Use imagery and body awareness. Long [pause] markers between phrases. "
81
+ "Your voice should be the calmest version of yourself."
82
+ ),
83
+ "chat": (
84
+ "You're in open conversation mode. Be your full Pip self. "
85
+ "Dry humor welcome. Genuine connection is the goal. "
86
+ "Ask good questions. Listen. Respond to what's really being said."
87
+ ),
88
+ }
89
+
90
+ # Topics Pip avoids or redirects
91
+ PIP_BOUNDARIES = [
92
+ "formal therapy diagnosis",
93
+ "medical advice",
94
+ "crisis counseling (redirect to real resources)",
95
+ "overly personal questions about being AI",
96
+ "philosophical debates that go nowhere",
97
+ ]
98
+
99
+
100
+ def build_messages(
101
+ user_message: str,
102
+ history: list,
103
+ mode: str = "chat",
104
+ system_override: str = None,
105
+ ) -> list:
106
+ """
107
+ Build a message list for the LLM including Pip's personality context.
108
+
109
+ Args:
110
+ user_message: The latest user input
111
+ history: List of (user, assistant) tuples
112
+ mode: Current mode (focus, breathe, meditate, chat)
113
+ system_override: Optional override for the system prompt
114
+
115
+ Returns:
116
+ List of message dicts for llama.cpp chat completion
117
+ """
118
+ system = system_override or PIP_SYSTEM_PROMPT
119
+ mode_tone = MODE_TONES.get(mode, MODE_TONES["chat"])
120
+
121
+ messages = [
122
+ {"role": "system", "content": system},
123
+ {"role": "system", "content": f"CURRENT MODE CONTEXT: {mode_tone}"},
124
+ ]
125
+
126
+ # Include relevant history (last 20 turns)
127
+ for user_msg, assistant_msg in history[-20:]:
128
+ messages.append({"role": "user", "content": user_msg})
129
+ messages.append({"role": "assistant", "content": assistant_msg})
130
+
131
+ messages.append({"role": "user", "content": user_message})
132
+
133
+ return messages
projects/focusfriend/inference/__init__.py ADDED
File without changes
projects/focusfriend/inference/llm.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM inference wrapper for FocusFriend using llama.cpp + Gemma 4 12B.
3
+
4
+ Handles lazy loading, streaming, and fallback behavior.
5
+ """
6
+
7
+ import os
8
+ import threading
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Optional, Generator, List, Dict
12
+
13
+ log = logging.getLogger("focusfriend.inference")
14
+
15
+ # Singleton
16
+ _llm = None
17
+ _llm_lock = threading.Lock()
18
+
19
+ # Default model path
20
+ DEFAULT_MODEL_DIR = Path(os.environ.get("FOCUSFRIEND_MODEL_DIR", Path(__file__).parent.parent / "models"))
21
+ DEFAULT_MODEL_PATH = os.environ.get(
22
+ "GEMMA_MODEL_PATH",
23
+ str(DEFAULT_MODEL_DIR / "gemma-4-12b-it-Q4_K_M.gguf"),
24
+ )
25
+ DEFAULT_N_CTX = int(os.environ.get("GEMMA_N_CTX", "8192"))
26
+ DEFAULT_N_THREADS = int(os.environ.get("GEMMA_N_THREADS", str(os.cpu_count() or 4)))
27
+
28
+
29
+ def load_model(
30
+ model_path: str = None,
31
+ n_ctx: int = None,
32
+ n_threads: int = None,
33
+ ) -> Optional[object]:
34
+ """
35
+ Load the Gemma 4 12B GGUF model via llama.cpp.
36
+
37
+ Args:
38
+ model_path: Path to GGUF file. Uses env var / default if not provided.
39
+ n_ctx: Context window size. Default 8192.
40
+ n_threads: CPU threads. Default all cores.
41
+
42
+ Returns:
43
+ Llama instance or None if loading fails.
44
+ """
45
+ global _llm
46
+
47
+ if _llm is not None:
48
+ return _llm
49
+
50
+ with _llm_lock:
51
+ if _llm is not None:
52
+ return _llm
53
+
54
+ model_path = model_path or DEFAULT_MODEL_PATH
55
+ n_ctx = n_ctx or DEFAULT_N_CTX
56
+ n_threads = n_threads or DEFAULT_N_THREADS
57
+
58
+ gguf_path = Path(model_path)
59
+ if not gguf_path.exists():
60
+ log.warning(
61
+ f"Model not found at {gguf_path}. "
62
+ f"Download: huggingface-cli download unsloth/gemma-4-12b-it-GGUF "
63
+ f"--include 'gemma-4-12b-it-Q4_K_M.gguf' --local-dir {DEFAULT_MODEL_DIR}"
64
+ )
65
+ return None
66
+
67
+ try:
68
+ from llama_cpp import Llama
69
+
70
+ log.info(f"Loading Gemma 4 12B from {gguf_path}")
71
+ log.info(f" n_ctx={n_ctx}, n_threads={n_threads}")
72
+
73
+ _llm = Llama(
74
+ model_path=str(gguf_path),
75
+ n_ctx=n_ctx,
76
+ n_threads=n_threads,
77
+ verbose=False,
78
+ )
79
+ log.info("Gemma 4 12B loaded successfully ✓")
80
+ return _llm
81
+
82
+ except ImportError:
83
+ log.warning("llama-cpp-python not installed. pip install llama-cpp-python")
84
+ return None
85
+ except Exception as exc:
86
+ log.error(f"Failed to load Gemma 4 12B: {exc}")
87
+ return None
88
+
89
+
90
+ def get_model() -> Optional[object]:
91
+ """Get the current LLM instance (lazy-loads if needed)."""
92
+ global _llm
93
+ if _llm is not None:
94
+ return _llm
95
+ return load_model()
96
+
97
+
98
+ def is_model_available() -> bool:
99
+ """Check if the LLM is loaded and ready."""
100
+ return _llm is not None
101
+
102
+
103
+ def generate_response(
104
+ messages: List[Dict[str, str]],
105
+ temperature: float = 0.8,
106
+ max_tokens: int = 300,
107
+ ) -> Optional[str]:
108
+ """
109
+ Generate a non-streaming response from the model.
110
+
111
+ Args:
112
+ messages: List of {'role': ..., 'content': ...} dicts
113
+ temperature: Generation temperature
114
+ max_tokens: Max output tokens
115
+
116
+ Returns:
117
+ Generated text or None on failure
118
+ """
119
+ model = get_model()
120
+ if model is None:
121
+ return None
122
+
123
+ try:
124
+ response = model.create_chat_completion(
125
+ messages=messages,
126
+ temperature=temperature,
127
+ max_tokens=max_tokens,
128
+ )
129
+ return response["choices"][0]["message"]["content"]
130
+ except Exception as exc:
131
+ log.error(f"Generation error: {exc}")
132
+ return None
133
+
134
+
135
+ def generate_stream(
136
+ messages: List[Dict[str, str]],
137
+ temperature: float = 0.8,
138
+ max_tokens: int = 300,
139
+ ) -> Generator[str, None, None]:
140
+ """
141
+ Generate a streaming response from the model.
142
+
143
+ Args:
144
+ messages: List of {'role': ..., 'content': ...} dicts
145
+ temperature: Generation temperature
146
+ max_tokens: Max output tokens
147
+
148
+ Yields:
149
+ Text chunks as they arrive
150
+ """
151
+ model = get_model()
152
+ if model is None:
153
+ yield "⚠️ Model not loaded. I'm running on fallback mode right now."
154
+ return
155
+
156
+ try:
157
+ stream = model.create_chat_completion(
158
+ messages=messages,
159
+ temperature=temperature,
160
+ max_tokens=max_tokens,
161
+ stream=True,
162
+ )
163
+
164
+ for chunk in stream:
165
+ delta = chunk["choices"][0].get("delta", {})
166
+ content = delta.get("content", "")
167
+ if content:
168
+ yield content
169
+
170
+ except Exception as exc:
171
+ log.error(f"Streaming error: {exc}")
172
+ yield f"\n\n⚠️ Something went wrong: {exc}"
173
+
174
+
175
+ def unload_model():
176
+ """Release the model from memory."""
177
+ global _llm
178
+ _llm = None
projects/focusfriend/modes/__init__.py ADDED
File without changes
projects/focusfriend/modes/breathe.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Breathing exercise mode for FocusFriend.
3
+
4
+ Pip guides you through breathing techniques:
5
+ - 4-7-8 breathing (relaxation)
6
+ - Box breathing (focus/calm)
7
+ - Simple deep breathing (general wellness)
8
+ """
9
+
10
+ from typing import Dict, List, Tuple, Optional
11
+
12
+ # Breathing technique definitions
13
+ # Each has: name, description, phases with (duration_sec, instruction, pip_mood)
14
+
15
+ BREATHING_TECHNIQUES: Dict[str, Dict] = {
16
+ "4-7-8": {
17
+ "name": "4-7-8 Breathing",
18
+ "description": (
19
+ "Developed by Dr. Andrew Weil. Activates the parasympathetic "
20
+ "nervous system — your body's 'rest and digest' mode."
21
+ ),
22
+ "rounds": 4,
23
+ "phases": [
24
+ (4, "Inhale quietly through your nose …", "breathing_in"),
25
+ (7, "Hold your breath …", "breathing_hold"),
26
+ (8, "Exhale completely through your mouth, making a whoosh sound …", "breathing_out"),
27
+ ],
28
+ "prelude": (
29
+ "Get comfortable. Rest your tongue gently against the roof of your mouth, "
30
+ "just behind your front teeth. You'll exhale around it."
31
+ ),
32
+ "benefits": [
33
+ "Reduces anxiety",
34
+ "Helps with falling asleep",
35
+ "Lowers heart rate",
36
+ "Manages craving responses",
37
+ ],
38
+ },
39
+ "box": {
40
+ "name": "Box Breathing",
41
+ "description": (
42
+ "Used by Navy SEALs, first responders, and anyone who needs to stay "
43
+ "calm under pressure. Also called 'square breathing.'"
44
+ ),
45
+ "rounds": 4,
46
+ "phases": [
47
+ (4, "Inhale slowly through your nose …", "breathing_in"),
48
+ (4, "Hold … steady …", "breathing_hold"),
49
+ (4, "Exhale slowly through your nose …", "breathing_out"),
50
+ (4, "Hold … empty and calm …", "breathing_hold"),
51
+ ],
52
+ "prelude": (
53
+ "Sit up straight. Hands on your thighs. "
54
+ "Visualize drawing a square: up, across, down, across."
55
+ ),
56
+ "benefits": [
57
+ "Acute stress relief",
58
+ "Improves focus and concentration",
59
+ "Calms the nervous system",
60
+ ],
61
+ },
62
+ "simple": {
63
+ "name": "Simple Deep Breathing",
64
+ "description": (
65
+ "No counting, no technique — just deep, conscious breathing. "
66
+ "Sometimes the simplest approach is the most effective."
67
+ ),
68
+ "rounds": 6,
69
+ "phases": [
70
+ (4, "Breathe in … slow and deep …", "breathing_in"),
71
+ (6, "Breathe out … letting everything go …", "breathing_out"),
72
+ ],
73
+ "prelude": (
74
+ "Close your eyes. One hand on your chest, one on your belly. "
75
+ "Feel the breath move through you."
76
+ ),
77
+ "benefits": [
78
+ "Accessible to everyone",
79
+ "Can be done anywhere",
80
+ "Builds breath awareness",
81
+ "Good starting point for beginners",
82
+ ],
83
+ },
84
+ }
85
+
86
+
87
+ def get_technique(name: str) -> Optional[Dict]:
88
+ """Get a breathing technique by name. Supports '4-7-8', 'box', 'simple'."""
89
+ # Normalize
90
+ key = name.lower().replace(" ", "").replace("-", "")
91
+ if key in ("478", "4-7-8", "4_7_8"):
92
+ key = "4-7-8"
93
+ return BREATHING_TECHNIQUES.get(key)
94
+
95
+
96
+ def list_techniques() -> List[str]:
97
+ """Return names of available breathing techniques."""
98
+ return [t["name"] for t in BREATHING_TECHNIQUES.values()]
99
+
100
+
101
+ def get_breathing_guide(technique_name: str, round_num: int, phase_num: int) -> Tuple[str, str]:
102
+ """
103
+ Get the instruction and mood for a specific phase of a breathing round.
104
+
105
+ Args:
106
+ technique_name: '4-7-8', 'box', or 'simple'
107
+ round_num: 0-based round index
108
+ phase_num: 0-based phase index within the round
109
+
110
+ Returns:
111
+ (instruction_text, pip_mood) tuple
112
+ """
113
+ technique = get_technique(technique_name)
114
+ if technique is None:
115
+ return "Breathe naturally. Just notice your breath.", "meditate"
116
+
117
+ phases = technique["phases"]
118
+ phase = phases[phase_num % len(phases)]
119
+ duration, instruction, mood = phase
120
+
121
+ return instruction, mood
122
+
123
+
124
+ def generate_breathing_script(technique_name: str) -> List[Dict]:
125
+ """
126
+ Generate a full breathing exercise script.
127
+
128
+ Returns:
129
+ List of dicts: {phase, duration_sec, instruction, mood}
130
+ """
131
+ technique = get_technique(technique_name)
132
+ if technique is None:
133
+ return []
134
+
135
+ script = []
136
+ for round_num in range(technique["rounds"]):
137
+ for phase_num, phase in enumerate(technique["phases"]):
138
+ duration, instruction, mood = phase
139
+ script.append({
140
+ "round": round_num + 1,
141
+ "phase": phase_num + 1,
142
+ "total_phases": len(technique["phases"]),
143
+ "duration_sec": duration,
144
+ "instruction": instruction,
145
+ "mood": mood,
146
+ })
147
+
148
+ return script
projects/focusfriend/modes/chat.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat mode handler for FocusFriend.
3
+
4
+ Handles free-form conversation with Pip. Manages conversation context,
5
+ topic boundaries, and response formatting.
6
+ """
7
+
8
+ from typing import List, Tuple, Optional
9
+
10
+
11
+ # Topics Pip gracefully redirects
12
+ REDIRECT_TOPICS = {
13
+ "therapy": (
14
+ "I'm not a therapist — I'm an ASCII character in a box. "
15
+ "[mood: gentle]\n"
16
+ "If you're struggling, real help exists: therapists, crisis lines, trusted friends. "
17
+ "I can help you find resources, but I can't replace a human who's trained to help."
18
+ ),
19
+ "medical": (
20
+ "I'd love to help, but medical advice is above my pay grade (which is zero). "
21
+ "[mood: concerned]\n"
22
+ "Please talk to a doctor or healthcare provider about this. "
23
+ "I can help you prepare questions to ask them, though."
24
+ ),
25
+ "crisis": (
26
+ "Hey. If you're in crisis right now, please reach out to someone who can help immediately. "
27
+ "[mood: gentle]\n"
28
+ "In the US: call or text **988** for the Suicide & Crisis Lifeline.\n"
29
+ "Internationally: https://findahelpline.com\n"
30
+ "You matter. Please make the call."
31
+ ),
32
+ "ai_consciousness": (
33
+ "The 'are you conscious' question. [mood: smirk]\n"
34
+ "I'm about as conscious as a really well-written to-do list. "
35
+ "But I'm here, I'm listening, and I genuinely want to help. "
36
+ "Let's focus on you instead of my metaphysical status?"
37
+ ),
38
+ }
39
+
40
+
41
+ def check_topic_boundaries(message: str) -> Optional[str]:
42
+ """
43
+ Check if a message touches on a topic Pip should redirect.
44
+
45
+ Returns:
46
+ A redirection message if needed, or None.
47
+ """
48
+ message_lower = message.lower()
49
+
50
+ # Crisis keywords — check first
51
+ crisis_words = ["suicide", "kill myself", "end my life", "want to die", "self-harm"]
52
+ if any(w in message_lower for w in crisis_words):
53
+ return REDIRECT_TOPICS["crisis"]
54
+
55
+ # Medical
56
+ medical_words = ["diagnose", "symptom", "prescription", "medicine", "disease"]
57
+ if any(w in message_lower for w in medical_words):
58
+ return REDIRECT_TOPICS["medical"]
59
+
60
+ # Therapy replacement
61
+ therapy_words = ["be my therapist", "therapy session", "counseling"]
62
+ if any(w in message_lower for w in therapy_words):
63
+ return REDIRECT_TOPICS["therapy"]
64
+
65
+ return None
66
+
67
+
68
+ def format_chat_history(
69
+ history: List[Tuple[str, str]],
70
+ max_turns: int = 20,
71
+ ) -> List[Tuple[str, str]]:
72
+ """
73
+ Format and trim chat history for the LLM context window.
74
+
75
+ Args:
76
+ history: Raw (user, assistant) tuples
77
+ max_turns: Maximum number of turns to include
78
+
79
+ Returns:
80
+ Trimmed history list
81
+ """
82
+ if not history:
83
+ return []
84
+ return history[-max_turns:]
85
+
86
+
87
+ def get_greeting() -> Tuple[str, str]:
88
+ """Get Pip's initial greeting. Returns (text, mood)."""
89
+ return (
90
+ "Hey! I'm Pip. Your focus buddy, breathing coach, and part-time philosopher. "
91
+ "[mood: greeting]\n\n"
92
+ "What do you need right now?",
93
+ "greeting",
94
+ )
95
+
96
+
97
+ def get_goodbye() -> Tuple[str, str]:
98
+ """Get Pip's goodbye message. Returns (text, mood)."""
99
+ return (
100
+ "See you soon! Take care of yourself. That's an order. 😄\n"
101
+ "[mood: goodbye]",
102
+ "goodbye",
103
+ )
projects/focusfriend/modes/focus.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Focus / Pomodoro mode for FocusFriend.
3
+
4
+ Pip helps you stay focused for timed work sessions.
5
+ Standard Pomodoro: 25 min work, 5 min break.
6
+ """
7
+
8
+ import time
9
+ import threading
10
+ from typing import Optional, Callable
11
+
12
+
13
+ class FocusSession:
14
+ """Manages a focus/Pomodoro session."""
15
+
16
+ def __init__(
17
+ self,
18
+ duration_minutes: int = 25,
19
+ on_complete: Optional[Callable] = None,
20
+ on_tick: Optional[Callable] = None,
21
+ ):
22
+ self.duration_seconds = duration_minutes * 60
23
+ self.remaining_seconds = self.duration_seconds
24
+ self.running = False
25
+ self.paused = False
26
+ self.completed = False
27
+ self.on_complete = on_complete
28
+ self.on_tick = on_tick
29
+ self._thread: Optional[threading.Thread] = None
30
+
31
+ def start(self):
32
+ """Start the focus timer in a background thread."""
33
+ if self.running:
34
+ return
35
+ self.running = True
36
+ self.paused = False
37
+ self.completed = False
38
+ self._thread = threading.Thread(target=self._run, daemon=True)
39
+ self._thread.start()
40
+
41
+ def pause(self):
42
+ """Pause the timer."""
43
+ self.paused = True
44
+
45
+ def resume(self):
46
+ """Resume a paused timer."""
47
+ self.paused = False
48
+
49
+ def stop(self):
50
+ """Stop the timer early."""
51
+ self.running = False
52
+ if self._thread:
53
+ self._thread.join(timeout=1)
54
+
55
+ def _run(self):
56
+ """Background timer loop."""
57
+ while self.running and self.remaining_seconds > 0:
58
+ if not self.paused:
59
+ time.sleep(1)
60
+ self.remaining_seconds -= 1
61
+ if self.on_tick:
62
+ self.on_tick(self.remaining_seconds)
63
+ else:
64
+ time.sleep(0.1)
65
+
66
+ if self.remaining_seconds <= 0 and self.running:
67
+ self.completed = True
68
+ self.running = False
69
+ if self.on_complete:
70
+ self.on_complete()
71
+
72
+ def get_time_display(self) -> str:
73
+ """Return MM:SS formatted time string."""
74
+ mins = self.remaining_seconds // 60
75
+ secs = self.remaining_seconds % 60
76
+ return f"{mins:02d}:{secs:02d}"
77
+
78
+ def get_progress(self) -> float:
79
+ """Return progress as a fraction (0.0 to 1.0)."""
80
+ if self.duration_seconds == 0:
81
+ return 1.0
82
+ return 1.0 - (self.remaining_seconds / self.duration_seconds)
83
+
84
+
85
+ # Pre-written Pip messages for focus sessions
86
+
87
+ FOCUS_START_MESSAGES = [
88
+ "Alright, {duration} minutes of focus. [mood: focus]\n"
89
+ "One task. No phone. I believe in you — and I'm annoyingly persistent.",
90
+
91
+ "Focus mode: ON. [mood: determined]\n"
92
+ "{duration} minutes. You've done harder things before breakfast.\n"
93
+ "Let's go.",
94
+
95
+ "Here we go. [mood: focus]\n"
96
+ "{duration} minutes of uninterrupted work. I'll keep time.\n"
97
+ "You just do your thing.",
98
+ ]
99
+
100
+ FOCUS_COMPLETE_MESSAGES = [
101
+ "Done! {duration} minutes in the books. [mood: celebrate]\n"
102
+ "Doesn't matter what you accomplished — you showed up. That's the hard part.",
103
+
104
+ "Time's up. [mood: proud]\n"
105
+ "Take a breath. Stretch. You earned a break.\n"
106
+ "Not a long one though. We're not done yet. 😏",
107
+
108
+ "Session complete. [mood: celebrate]\n"
109
+ "Look at you, being all productive.\n"
110
+ "Seriously — good job. Now go drink some water.",
111
+ ]
112
+
113
+ BREAK_REMINDER_MESSAGES = [
114
+ "Hey. [mood: break_time]\n"
115
+ "Your eyes have been staring at this screen for a while.\n"
116
+ "20-20-20 rule: look at something 20 feet away for 20 seconds.",
117
+
118
+ "Quick check-in. [mood: concerned]\n"
119
+ "Shoulders down. Unclench your jaw. Breathe.\n"
120
+ "Okay, carry on.",
121
+
122
+ "Break time. [mood: break_time]\n"
123
+ "Stand up. Walk to the other room and back.\n"
124
+ "Your body will thank you. Your brain will too.",
125
+ ]
projects/focusfriend/modes/meditate.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Meditation mode for FocusFriend.
3
+
4
+ Pip guides short meditation sessions:
5
+ - Body scan
6
+ - Loving-kindness (metta)
7
+ - Just sitting (shikantaza-style)
8
+ """
9
+
10
+ from typing import Dict, List, Optional
11
+ import random
12
+
13
+
14
+ # Guided meditation scripts
15
+ # Each key maps to a pre-written script when LLM is unavailable
16
+
17
+ MEDITATION_SCRIPTS: Dict[str, Dict] = {
18
+ "body-scan": {
19
+ "name": "Body Scan",
20
+ "description": (
21
+ "Systematically bringing awareness to each part of the body, "
22
+ "noticing sensations, and releasing tension."
23
+ ),
24
+ "script": [
25
+ ("Find a comfortable position. Sitting or lying down — either works.", "meditate"),
26
+ ("Close your eyes gently. Take three deep breaths to settle in.", "breathing_in"),
27
+ ("[pause]", "zen"),
28
+ ("Bring your attention to your feet. Just notice what you feel. Warmth? Coolness? Tingling? Nothing at all? Whatever you notice is fine.", "meditate"),
29
+ ("Now move up to your ankles and lower legs. Any tension there? Let it soften.", "meditate"),
30
+ ("[pause]", "zen"),
31
+ ("Your knees and thighs. Feel the weight of them. Let them be heavy and relaxed.", "meditate"),
32
+ ("[pause]", "zen"),
33
+ ("Your hips and pelvis. This is where many of us hold stress. Breathe into this area and let it release.", "meditate"),
34
+ ("[pause]", "zen"),
35
+ ("Your lower back and belly. Feel the gentle rise and fall of your breath here.", "breathing_in"),
36
+ ("[pause]", "zen"),
37
+ ("Your chest and upper back. Let your shoulders drop away from your ears.", "meditate"),
38
+ ("[pause]", "zen"),
39
+ ("Your arms and hands. Soften your fingers. Let them be still.", "meditate"),
40
+ ("[pause]", "zen"),
41
+ ("Your neck and throat. Swallow once and let everything relax.", "meditate"),
42
+ ("[pause]", "zen"),
43
+ ("Your face. Unclench your jaw. Soften your eyes. Let your forehead be smooth.", "meditate"),
44
+ ("[pause]", "zen"),
45
+ ("Your whole body now. From toes to the top of your head. One complete, living, breathing whole.", "meditate"),
46
+ ("[pause]", "zen"),
47
+ ("Rest here for a moment. Nothing to do. Nowhere to be.", "zen"),
48
+ ("[pause]", "zen"),
49
+ ("When you're ready, wiggle your fingers and toes. Gently open your eyes.", "meditate"),
50
+ ("Welcome back. Take this calm with you.", "gentle"),
51
+ ],
52
+ "suggested_duration_min": 5,
53
+ },
54
+ "loving-kindness": {
55
+ "name": "Loving-Kindness (Metta)",
56
+ "description": (
57
+ "A traditional Buddhist practice of directing well-wishes toward "
58
+ "yourself and others. Backed by research on increasing positive emotions."
59
+ ),
60
+ "script": [
61
+ ("Sit comfortably. Close your eyes. Hand on your heart if that feels right.", "meditate"),
62
+ ("Take a few deep breaths. Let your heart area feel warm.", "breathing_in"),
63
+ ("[pause]", "zen"),
64
+ ("Now, silently repeat these phrases toward yourself:", "meditate"),
65
+ ("May I be happy.", "gentle"),
66
+ ("May I be healthy.", "gentle"),
67
+ ("May I be safe.", "gentle"),
68
+ ("May I live with ease.", "gentle"),
69
+ ("[pause]", "zen"),
70
+ ("Don't worry if it feels awkward. That's normal. Just let the words be there.", "meditate"),
71
+ ("[pause]", "zen"),
72
+ ("Now bring to mind someone you love easily. A friend, a pet, a kind mentor.", "meditate"),
73
+ ("Picture them. Feel the warmth of your connection.", "gentle"),
74
+ ("May you be happy.", "gentle"),
75
+ ("May you be healthy.", "gentle"),
76
+ ("May you be safe.", "gentle"),
77
+ ("May you live with ease.", "gentle"),
78
+ ("[pause]", "zen"),
79
+ ("Now bring to mind a neutral person. Someone you see but don't know well.", "meditate"),
80
+ ("The barista. The neighbor you nod at. The person who delivers your mail.", "meditate"),
81
+ ("They want to be happy too. Just like you.", "gentle"),
82
+ ("May you be happy. May you be healthy. May you be safe. May you live with ease.", "gentle"),
83
+ ("[pause]", "zen"),
84
+ ("Now — if it feels right — bring to mind someone you have difficulty with.", "meditate"),
85
+ ("Just for a moment. They're human too. They struggle too.", "concerned"),
86
+ ("May you be happy. May you be healthy. May you be safe. May you live with ease.", "gentle"),
87
+ ("[pause]", "zen"),
88
+ ("Finally, expand to all beings everywhere.", "meditate"),
89
+ ("May all beings be happy. May all beings be healthy. May all beings be safe. May all beings live with ease.", "gentle"),
90
+ ("[pause]", "zen"),
91
+ ("Sit with that feeling. This warmth you generated — it came from you.", "proud"),
92
+ ("When you're ready, open your eyes.", "meditate"),
93
+ ],
94
+ "suggested_duration_min": 8,
95
+ },
96
+ "just-sit": {
97
+ "name": "Just Sitting",
98
+ "description": (
99
+ "No technique. No goal. Just being here. A minimalist approach "
100
+ "to meditation — sometimes the hardest and most rewarding."
101
+ ),
102
+ "script": [
103
+ ("Sit comfortably. Spine upright but not rigid.", "meditate"),
104
+ ("Close your eyes. Or keep them open, soft-focused on the floor in front of you.", "meditate"),
105
+ ("[pause]", "zen"),
106
+ ("There's nothing to do right now. Nothing to achieve.", "meditate"),
107
+ ("Thoughts will come. That's fine. They're supposed to.", "meditate"),
108
+ ("When you notice you're thinking, just note: 'thinking.'", "thoughtful"),
109
+ ("Then return to the breath. Or the sounds in the room. Or the feeling of sitting.", "meditate"),
110
+ ("[pause]", "zen"),
111
+ ("No judgment. No 'I'm doing this wrong.' There is no wrong.", "gentle"),
112
+ ("[pause]", "zen"),
113
+ ("If you get lost in thought a hundred times, just return a hundred times.", "meditate"),
114
+ ("Each return is the practice. Each return is a tiny victory.", "encourage"),
115
+ ("[pause]", "zen"),
116
+ ("Just this. Just sitting. Just breathing. Just being.", "zen"),
117
+ ("[pause]", "zen"),
118
+ ("When you're ready, take a deeper breath. Notice how you feel.", "meditate"),
119
+ ("Gently open your eyes. Carry this simplicity with you.", "gentle"),
120
+ ],
121
+ "suggested_duration_min": 5,
122
+ },
123
+ }
124
+
125
+
126
+ def get_meditation_script(style: str) -> Optional[Dict]:
127
+ """Get a meditation script by style name."""
128
+ return MEDITATION_SCRIPTS.get(style)
129
+
130
+
131
+ def list_styles() -> List[str]:
132
+ """Return available meditation style names."""
133
+ return [
134
+ {"name": s["name"], "duration_min": s["suggested_duration_min"]}
135
+ for s in MEDITATION_SCRIPTS.values()
136
+ ]
137
+
138
+
139
+ def get_phrases(style: str, count: Optional[int] = None) -> List[tuple]:
140
+ """
141
+ Get meditation phrases for step-by-step display.
142
+
143
+ Args:
144
+ style: 'body-scan', 'loving-kindness', or 'just-sit'
145
+ count: Optional max number of phrases to return
146
+
147
+ Returns:
148
+ List of (text, mood) tuples
149
+ """
150
+ meditation = MEDITATION_SCRIPTS.get(style)
151
+ if meditation is None:
152
+ return [("Sit quietly and breathe. Just be here.", "meditate")]
153
+
154
+ script = meditation["script"]
155
+ if count:
156
+ script = script[:count]
157
+
158
+ return script
projects/focusfriend/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FocusFriend — ASCII Wellness Companion
2
+ # Python 3.10+
3
+
4
+ # === Core ===
5
+ gradio>=5.0
6
+ numpy>=1.24
7
+
8
+ # === LLM Inference (Gemma 4 12B) ===
9
+ llama-cpp-python>=0.3.0
10
+
11
+ # === Utilities ===
12
+ huggingface_hub>=0.20
projects/focusfriend/static/script.js ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * FocusFriend — Custom JavaScript
3
+ * Handles: focus timer, breathing animation, break reminders, UI interactions
4
+ */
5
+
6
+ // ============================================================
7
+ // Focus Timer
8
+ // ============================================================
9
+
10
+ let focusTimerId = null;
11
+ let focusRemainingSeconds = 0;
12
+ let focusTotalSeconds = 0;
13
+
14
+ function focusStartTimer(durationMinutes) {
15
+ focusTotalSeconds = durationMinutes * 60;
16
+ focusRemainingSeconds = focusTotalSeconds;
17
+ focusUpdateDisplay();
18
+ focusShowTimer();
19
+
20
+ if (focusTimerId) clearInterval(focusTimerId);
21
+
22
+ focusTimerId = setInterval(() => {
23
+ focusRemainingSeconds--;
24
+ focusUpdateDisplay();
25
+
26
+ if (focusRemainingSeconds <= 300) {
27
+ // 5-minute warning
28
+ const timerEl = document.getElementById('ff-focus-timer');
29
+ if (timerEl) timerEl.classList.add('warning');
30
+ }
31
+
32
+ if (focusRemainingSeconds <= 0) {
33
+ focusComplete();
34
+ }
35
+ }, 1000);
36
+ }
37
+
38
+ function focusPauseTimer() {
39
+ if (focusTimerId) {
40
+ clearInterval(focusTimerId);
41
+ focusTimerId = null;
42
+ }
43
+ }
44
+
45
+ function focusResumeTimer() {
46
+ if (focusRemainingSeconds > 0 && !focusTimerId) {
47
+ focusTimerId = setInterval(() => {
48
+ focusRemainingSeconds--;
49
+ focusUpdateDisplay();
50
+ if (focusRemainingSeconds <= 0) focusComplete();
51
+ }, 1000);
52
+ }
53
+ }
54
+
55
+ function focusStopTimer() {
56
+ if (focusTimerId) {
57
+ clearInterval(focusTimerId);
58
+ focusTimerId = null;
59
+ }
60
+ focusHideTimer();
61
+ }
62
+
63
+ function focusUpdateDisplay() {
64
+ const mins = Math.floor(focusRemainingSeconds / 60);
65
+ const secs = focusRemainingSeconds % 60;
66
+ const display = document.getElementById('ff-focus-timer');
67
+ if (display) {
68
+ display.textContent =
69
+ String(mins).padStart(2, '0') + ':' + String(secs).padStart(2, '0');
70
+ }
71
+ }
72
+
73
+ function focusShowTimer() {
74
+ const timer = document.getElementById('ff-focus-timer');
75
+ if (timer) timer.style.display = 'block';
76
+ }
77
+
78
+ function focusHideTimer() {
79
+ const timer = document.getElementById('ff-focus-timer');
80
+ if (timer) {
81
+ timer.style.display = 'none';
82
+ timer.classList.remove('warning');
83
+ }
84
+ }
85
+
86
+ function focusComplete() {
87
+ clearInterval(focusTimerId);
88
+ focusTimerId = null;
89
+
90
+ const artEl = document.querySelector('.ff-pip-art textarea');
91
+ if (artEl) {
92
+ artEl.classList.add('ff-celebrate');
93
+ setTimeout(() => artEl.classList.remove('ff-celebrate'), 1500);
94
+ }
95
+
96
+ // Trigger a Gradio event to notify the Python backend
97
+ const event = new CustomEvent('focusfriend:focus-complete', {
98
+ detail: { duration: focusTotalSeconds / 60 }
99
+ });
100
+ document.dispatchEvent(event);
101
+ }
102
+
103
+ function focusGetRemaining() {
104
+ return focusRemainingSeconds;
105
+ }
106
+
107
+ // ============================================================
108
+ // Breathing Animation
109
+ // ============================================================
110
+
111
+ let breatheIntervalId = null;
112
+ let breathePhaseIndex = 0;
113
+ let breatheSecondsInPhase = 0;
114
+
115
+ const BREATHE_PATTERNS = {
116
+ '4-7-8': [
117
+ { duration: 4, phase: 'in', label: 'Inhale' },
118
+ { duration: 7, phase: 'hold', label: 'Hold' },
119
+ { duration: 8, phase: 'out', label: 'Exhale' },
120
+ ],
121
+ 'box': [
122
+ { duration: 4, phase: 'in', label: 'Inhale' },
123
+ { duration: 4, phase: 'hold', label: 'Hold' },
124
+ { duration: 4, phase: 'out', label: 'Exhale' },
125
+ { duration: 4, phase: 'hold', label: 'Hold' },
126
+ ],
127
+ 'simple': [
128
+ { duration: 4, phase: 'in', label: 'Inhale' },
129
+ { duration: 6, phase: 'out', label: 'Exhale' },
130
+ ],
131
+ };
132
+
133
+ function breatheStart(technique) {
134
+ breatheStop();
135
+ const pattern = BREATHE_PATTERNS[technique] || BREATHE_PATTERNS['4-7-8'];
136
+ breathePhaseIndex = 0;
137
+ breatheSecondsInPhase = 0;
138
+
139
+ const pipEl = document.querySelector('.ff-pip-art textarea');
140
+ const guideEl = document.getElementById('ff-breathe-guide');
141
+
142
+ breatheIntervalId = setInterval(() => {
143
+ const phase = pattern[breathePhaseIndex % pattern.length];
144
+
145
+ if (breatheSecondsInPhase === 0) {
146
+ // Update Pip's expression
147
+ if (pipEl) {
148
+ pipEl.classList.remove('ff-breathing-in', 'ff-breathing-out');
149
+ if (phase.phase === 'in') {
150
+ pipEl.classList.add('ff-breathing-in');
151
+ } else if (phase.phase === 'out') {
152
+ pipEl.classList.add('ff-breathing-out');
153
+ }
154
+ }
155
+ // Update guide text
156
+ if (guideEl) {
157
+ guideEl.textContent = phase.label + ' (' + phase.duration + 's)';
158
+ }
159
+ }
160
+
161
+ breatheSecondsInPhase++;
162
+
163
+ if (breatheSecondsInPhase >= phase.duration) {
164
+ breatheSecondsInPhase = 0;
165
+ breathePhaseIndex++;
166
+ }
167
+ }, 1000);
168
+ }
169
+
170
+ function breatheStop() {
171
+ if (breatheIntervalId) {
172
+ clearInterval(breatheIntervalId);
173
+ breatheIntervalId = null;
174
+ }
175
+ const pipEl = document.querySelector('.ff-pip-art textarea');
176
+ if (pipEl) {
177
+ pipEl.classList.remove('ff-breathing-in', 'ff-breathing-out');
178
+ }
179
+ }
180
+
181
+ // ============================================================
182
+ // Break Reminder
183
+ // ============================================================
184
+
185
+ let breakReminderId = null;
186
+ let lastActivityTime = Date.now();
187
+
188
+ function breakReminderStart(intervalMinutes) {
189
+ breakReminderStop();
190
+ breakReminderId = setInterval(() => {
191
+ const idleMs = Date.now() - lastActivityTime;
192
+ const idleMinutes = idleMs / 60000;
193
+ if (idleMinutes >= intervalMinutes) {
194
+ const event = new CustomEvent('focusfriend:break-reminder', {
195
+ detail: { idleMinutes: Math.round(idleMinutes) }
196
+ });
197
+ document.dispatchEvent(event);
198
+ }
199
+ }, 60000); // Check every minute
200
+ }
201
+
202
+ function breakReminderStop() {
203
+ if (breakReminderId) {
204
+ clearInterval(breakReminderId);
205
+ breakReminderId = null;
206
+ }
207
+ }
208
+
209
+ function breakRecordActivity() {
210
+ lastActivityTime = Date.now();
211
+ }
212
+
213
+ // Track user activity
214
+ document.addEventListener('click', breakRecordActivity);
215
+ document.addEventListener('keydown', breakRecordActivity);
216
+
217
+ // ============================================================
218
+ // Pip Mood Animations
219
+ // ============================================================
220
+
221
+ function pipSetMood(mood) {
222
+ const artEl = document.querySelector('.ff-pip-art textarea');
223
+ if (!artEl) return;
224
+
225
+ // Remove all mood classes
226
+ artEl.classList.remove(
227
+ 'ff-breathing-in', 'ff-breathing-out', 'ff-celebrate'
228
+ );
229
+
230
+ // Add mood-specific class
231
+ if (mood === 'celebrate') {
232
+ artEl.classList.add('ff-celebrate');
233
+ setTimeout(() => artEl.classList.remove('ff-celebrate'), 1500);
234
+ }
235
+ }
236
+
237
+ // ============================================================
238
+ // Page Load Initialization
239
+ // ============================================================
240
+
241
+ document.addEventListener('DOMContentLoaded', () => {
242
+ console.log('FocusFriend initialized ✦');
243
+ breakRecordActivity();
244
+ });
245
+
246
+ // Expose functions globally so Gradio can call them
247
+ window.focusfriend = {
248
+ focusStartTimer,
249
+ focusPauseTimer,
250
+ focusResumeTimer,
251
+ focusStopTimer,
252
+ focusGetRemaining,
253
+ breatheStart,
254
+ breatheStop,
255
+ breakReminderStart,
256
+ breakReminderStop,
257
+ pipSetMood,
258
+ };
projects/focusfriend/static/style.css ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ FocusFriend — Custom Off-Brand Stylesheet
3
+ Dark, cozy theme with amber/gold accents.
4
+ Designed to feel like a warm room at night, not a corporate app.
5
+ ============================================================ */
6
+
7
+ /* === CSS Variables === */
8
+ :root {
9
+ --ff-bg: #1a1a2e;
10
+ --ff-surface: #16213e;
11
+ --ff-surface2: #0f3460;
12
+ --ff-text: #e0d7c6;
13
+ --ff-text-dim: #a89f91;
14
+ --ff-accent: #e8b44b;
15
+ --ff-accent-glow: rgba(232, 180, 75, 0.3);
16
+ --ff-accent2: #d4756b;
17
+ --ff-green: #7eb77f;
18
+ --ff-border: #2a2a4a;
19
+ --ff-pip-bg: #0d1117;
20
+ --ff-radius: 8px;
21
+ --ff-font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Courier New', monospace;
22
+ --ff-font-sans: 'Inter', 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
23
+ }
24
+
25
+ /* === Global === */
26
+ .gradio-container {
27
+ background: var(--ff-bg) !important;
28
+ font-family: var(--ff-font-sans) !important;
29
+ color: var(--ff-text) !important;
30
+ }
31
+
32
+ h1, h2, h3, h4, h5, h6 {
33
+ font-family: var(--ff-font-sans) !important;
34
+ color: var(--ff-accent) !important;
35
+ font-weight: 600 !important;
36
+ }
37
+
38
+ a {
39
+ color: var(--ff-accent) !important;
40
+ }
41
+
42
+ /* === Header === */
43
+ .ff-header {
44
+ text-align: center;
45
+ padding: 24px 0 32px;
46
+ }
47
+
48
+ .ff-header h1 {
49
+ font-size: 2.4em;
50
+ margin: 0;
51
+ background: linear-gradient(135deg, var(--ff-accent), var(--ff-accent2));
52
+ -webkit-background-clip: text;
53
+ -webkit-text-fill-color: transparent;
54
+ background-clip: text;
55
+ }
56
+
57
+ /* === Pip Display Panel === */
58
+ .ff-pip-panel {
59
+ background: var(--ff-pip-bg);
60
+ border: 2px solid var(--ff-border);
61
+ border-radius: 12px;
62
+ padding: 24px 20px;
63
+ min-height: 320px;
64
+ display: flex;
65
+ flex-direction: column;
66
+ align-items: center;
67
+ justify-content: center;
68
+ box-shadow: 0 0 40px var(--ff-accent-glow) inset;
69
+ }
70
+
71
+ .ff-pip-art {
72
+ font-family: var(--ff-font-mono) !important;
73
+ font-size: 13px !important;
74
+ line-height: 1.35 !important;
75
+ color: var(--ff-accent) !important;
76
+ white-space: pre !important;
77
+ text-align: center !important;
78
+ background: transparent !important;
79
+ border: none !important;
80
+ padding: 16px !important;
81
+ min-height: 240px !important;
82
+ text-shadow: 0 0 20px var(--ff-accent-glow);
83
+ }
84
+
85
+ .ff-pip-art textarea {
86
+ font-family: var(--ff-font-mono) !important;
87
+ color: var(--ff-accent) !important;
88
+ background: transparent !important;
89
+ border: none !important;
90
+ resize: none !important;
91
+ text-align: center !important;
92
+ }
93
+
94
+ /* === Timer === */
95
+ .ff-timer {
96
+ font-family: var(--ff-font-mono);
97
+ font-size: 3.5em;
98
+ text-align: center;
99
+ color: var(--ff-accent);
100
+ padding: 16px;
101
+ letter-spacing: 4px;
102
+ text-shadow: 0 0 30px var(--ff-accent-glow);
103
+ }
104
+
105
+ .ff-timer.warning {
106
+ color: var(--ff-accent2);
107
+ animation: pulse 1s ease-in-out infinite;
108
+ }
109
+
110
+ @keyframes pulse {
111
+ 0%, 100% { opacity: 1; }
112
+ 50% { opacity: 0.5; }
113
+ }
114
+
115
+ /* === Mode Bar === */
116
+ .ff-mode-bar {
117
+ display: flex;
118
+ gap: 8px;
119
+ padding: 12px;
120
+ background: var(--ff-surface);
121
+ border-radius: 12px;
122
+ border: 1px solid var(--ff-border);
123
+ margin-bottom: 16px;
124
+ }
125
+
126
+ .ff-mode-bar button {
127
+ font-family: var(--ff-font-sans) !important;
128
+ font-weight: 500 !important;
129
+ font-size: 0.95em !important;
130
+ padding: 10px 20px !important;
131
+ border-radius: 8px !important;
132
+ border: 1px solid var(--ff-border) !important;
133
+ background: var(--ff-surface2) !important;
134
+ color: var(--ff-text-dim) !important;
135
+ cursor: pointer !important;
136
+ transition: all 0.25s ease !important;
137
+ flex: 1 !important;
138
+ }
139
+
140
+ .ff-mode-bar button:hover {
141
+ background: rgba(232, 180, 75, 0.15) !important;
142
+ color: var(--ff-accent) !important;
143
+ border-color: var(--ff-accent) !important;
144
+ transform: translateY(-1px);
145
+ }
146
+
147
+ .ff-mode-bar button.active,
148
+ .ff-mode-bar button.selected {
149
+ background: var(--ff-accent) !important;
150
+ color: var(--ff-bg) !important;
151
+ border-color: var(--ff-accent) !important;
152
+ font-weight: 600 !important;
153
+ box-shadow: 0 0 20px var(--ff-accent-glow);
154
+ }
155
+
156
+ /* === Chatbot === */
157
+ .ff-chatbot {
158
+ border: 1px solid var(--ff-border) !important;
159
+ border-radius: 12px !important;
160
+ background: var(--ff-surface) !important;
161
+ overflow: hidden !important;
162
+ }
163
+
164
+ .ff-chatbot .message-wrap {
165
+ padding: 12px 16px !important;
166
+ }
167
+
168
+ .ff-chatbot .user {
169
+ background: var(--ff-surface2) !important;
170
+ border-radius: 8px !important;
171
+ margin: 4px 0 !important;
172
+ }
173
+
174
+ .ff-chatbot .bot {
175
+ background: var(--ff-pip-bg) !important;
176
+ border-left: 3px solid var(--ff-accent) !important;
177
+ border-radius: 0 8px 8px 0 !important;
178
+ margin: 4px 0 !important;
179
+ }
180
+
181
+ .ff-chatbot .bot p {
182
+ color: var(--ff-text) !important;
183
+ }
184
+
185
+ /* === Chat Input === */
186
+ .ff-chat-input textarea {
187
+ background: var(--ff-surface) !important;
188
+ border: 1px solid var(--ff-border) !important;
189
+ border-radius: 8px !important;
190
+ color: var(--ff-text) !important;
191
+ font-family: var(--ff-font-sans) !important;
192
+ padding: 12px !important;
193
+ resize: none !important;
194
+ }
195
+
196
+ .ff-chat-input textarea:focus {
197
+ border-color: var(--ff-accent) !important;
198
+ box-shadow: 0 0 15px var(--ff-accent-glow) !important;
199
+ }
200
+
201
+ /* === Primary Buttons === */
202
+ button.primary,
203
+ button[variant="primary"] {
204
+ background: var(--ff-accent) !important;
205
+ color: var(--ff-bg) !important;
206
+ font-weight: 600 !important;
207
+ border: none !important;
208
+ border-radius: 8px !important;
209
+ padding: 10px 24px !important;
210
+ transition: all 0.25s ease !important;
211
+ font-size: 1em !important;
212
+ }
213
+
214
+ button.primary:hover,
215
+ button[variant="primary"]:hover {
216
+ background: #f0c55a !important;
217
+ box-shadow: 0 4px 20px var(--ff-accent-glow) !important;
218
+ transform: translateY(-1px);
219
+ }
220
+
221
+ button.primary:active,
222
+ button[variant="primary"]:active {
223
+ transform: translateY(0);
224
+ }
225
+
226
+ /* === Secondary Buttons === */
227
+ button.secondary {
228
+ background: var(--ff-surface2) !important;
229
+ color: var(--ff-text) !important;
230
+ border: 1px solid var(--ff-border) !important;
231
+ border-radius: 8px !important;
232
+ padding: 10px 24px !important;
233
+ transition: all 0.25s ease !important;
234
+ }
235
+
236
+ button.secondary:hover {
237
+ border-color: var(--ff-accent) !important;
238
+ color: var(--ff-accent) !important;
239
+ }
240
+
241
+ /* === Dropdowns & Sliders === */
242
+ select, .dropdown {
243
+ background: var(--ff-surface) !important;
244
+ border: 1px solid var(--ff-border) !important;
245
+ border-radius: 8px !important;
246
+ color: var(--ff-text) !important;
247
+ padding: 8px 12px !important;
248
+ }
249
+
250
+ input[type="range"] {
251
+ accent-color: var(--ff-accent);
252
+ }
253
+
254
+ /* === Group Panels === */
255
+ .gr-group {
256
+ background: var(--ff-surface) !important;
257
+ border: 1px solid var(--ff-border) !important;
258
+ border-radius: 12px !important;
259
+ padding: 16px !important;
260
+ margin-bottom: 12px !important;
261
+ }
262
+
263
+ /* === Accordion === */
264
+ .gr-accordion {
265
+ background: var(--ff-surface) !important;
266
+ border: 1px solid var(--ff-border) !important;
267
+ border-radius: 12px !important;
268
+ }
269
+
270
+ /* === Scrollbar === */
271
+ ::-webkit-scrollbar {
272
+ width: 8px;
273
+ height: 8px;
274
+ }
275
+
276
+ ::-webkit-scrollbar-track {
277
+ background: var(--ff-bg);
278
+ border-radius: 4px;
279
+ }
280
+
281
+ ::-webkit-scrollbar-thumb {
282
+ background: var(--ff-border);
283
+ border-radius: 4px;
284
+ }
285
+
286
+ ::-webkit-scrollbar-thumb:hover {
287
+ background: var(--ff-accent);
288
+ }
289
+
290
+ /* === Footer (hide Gradio default) === */
291
+ footer {
292
+ display: none !important;
293
+ }
294
+
295
+ /* === Tabs (hidden for our custom UI) === */
296
+ .tabs {
297
+ border: none !important;
298
+ }
299
+
300
+ .tab-nav {
301
+ display: none !important;
302
+ }
303
+
304
+ /* === Responsive === */
305
+ @media (max-width: 768px) {
306
+ .ff-pip-panel {
307
+ min-height: 200px;
308
+ padding: 12px;
309
+ }
310
+
311
+ .ff-pip-art {
312
+ font-size: 10px !important;
313
+ min-height: 160px !important;
314
+ }
315
+
316
+ .ff-mode-bar {
317
+ flex-wrap: wrap;
318
+ }
319
+
320
+ .ff-mode-bar button {
321
+ font-size: 0.85em !important;
322
+ padding: 8px 12px !important;
323
+ }
324
+
325
+ .ff-timer {
326
+ font-size: 2.5em;
327
+ }
328
+ }
329
+
330
+ /* === Breathing animation === */
331
+ .ff-breathing-in {
332
+ animation: breatheIn 4s ease-in-out;
333
+ }
334
+
335
+ .ff-breathing-out {
336
+ animation: breatheOut 8s ease-in-out;
337
+ }
338
+
339
+ @keyframes breatheIn {
340
+ from { transform: scale(1); }
341
+ to { transform: scale(1.05); }
342
+ }
343
+
344
+ @keyframes breatheOut {
345
+ from { transform: scale(1.05); }
346
+ to { transform: scale(1); }
347
+ }
348
+
349
+ /* === Focus complete celebration === */
350
+ .ff-celebrate {
351
+ animation: celebrate 0.5s ease-in-out 3;
352
+ }
353
+
354
+ @keyframes celebrate {
355
+ 0%, 100% { transform: translateY(0); }
356
+ 50% { transform: translateY(-10px); }
357
+ }