#!/usr/bin/env python3 """ FocusFriend — ASCII Wellness Companion ======================================= A mini ASCII character named "Pip" that lives in a Gradio window. Pip talks positively (not corny), guides focus sessions, breathing exercises, meditation, and helps you feel good. Targets: Thousand Token Wood track + Tiny Titan, Off-Brand, Off the Grid, Field Notes badges. Model: Gemma 4 12B via llama.cpp (Q4_K_M GGUF) Author: Build Small Hackathon 2026 License: Apache 2.0 """ import os import sys import time import logging import threading from pathlib import Path from typing import Optional, Generator, List, Dict, Tuple import gradio as gr # Project modules (used by the app and available for direct import) from character.ascii_art import get_expression as pip_get_expression from character.ascii_art import extract_mood as pip_extract_mood from character.personality import PIP_SYSTEM_PROMPT, MODE_TONES from modes.focus import FocusSession from modes.breathe import get_breathing_guide, BREATHING_TECHNIQUES from modes.meditate import get_meditation_script from modes.chat import check_topic_boundaries, get_greeting as pip_greeting from inference.llm import load_model, get_model as get_llm_model, is_model_available # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) log = logging.getLogger("focusfriend") # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- MODEL_DIR = Path(os.environ.get("FOCUSFRIEND_MODEL_DIR", Path(__file__).parent / "models")) MODEL_DIR.mkdir(parents=True, exist_ok=True) GEMMA_MODEL_PATH = os.environ.get( "GEMMA_MODEL_PATH", str(MODEL_DIR / "gemma-4-12b-it-Q4_K_M.gguf"), ) # --------------------------------------------------------------------------- # LLM singleton # --------------------------------------------------------------------------- _llm: Optional[object] = None _llm_lock = threading.Lock() def get_llm(): """Lazy-load Gemma 4 12B via llama.cpp.""" global _llm if _llm is not None: return _llm with _llm_lock: if _llm is not None: return _llm gguf_path = Path(GEMMA_MODEL_PATH) if not gguf_path.exists(): log.warning( f"Gemma 4 12B GGUF not found at {gguf_path}. " "Download from https://huggingface.co/unsloth/gemma-4-12b-it-GGUF" ) return None try: from llama_cpp import Llama log.info(f"Loading Gemma 4 12B from {gguf_path} …") _llm = Llama( model_path=str(gguf_path), n_ctx=8192, n_threads=os.cpu_count() or 4, verbose=False, ) log.info("Gemma 4 12B loaded ✓") return _llm except ImportError: log.warning("llama-cpp-python not installed.") return None except Exception as exc: log.error(f"Gemma load failed: {exc}") return None # --------------------------------------------------------------------------- # ASCII Art — Pip the Wellness Companion # --------------------------------------------------------------------------- PIP_EXPRESSIONS: Dict[str, str] = { "default": r""" _____ / \ | • • | ✨ Hey! I'm Pip. | ▿ | Your focus buddy. \ ~~~ / | | | | |_| |_| """, "greeting": r""" _____ / \ | • • | Welcome back! | ▽ | Ready to do good things? \ ~~~ / | | | | |_| |_| """, "focus": r""" _____ / \ | ◉ ◉ | 🎯 Focus mode. | ─ | Let's get it done. \ === / | | | | |_| |_| """, "breathing_in": r""" _____ / \ | · · | 🌬️ Breathe in … | ○ | Slowly, deeply. \ ~~~ / | | | | |_| |_| """, "breathing_out": r""" _____ / \ | · · | 💨 And out … | ○ | Let it all go. \ ~~~ / | | | | |_| |_| """, "meditate": r""" _____ / \ | _ _ | 🧘 Peace. | ◇ | Just this moment. \ ~~~ / | | | | |_| |_| """, "encourage": r""" _____ / \ | ^ ^ | You're doing great! | ▽ | Seriously. Keep going. \ ~~~ / | | | | |_| |_| """, "celebrate": r""" _____ / \ | ★ ★ | 🎉 Nice work! | ▽ | Told you you could. \ ~~~ / | | | | |_| |_| """, "concerned": r""" _____ / \ | • • | Hmm. I notice you've | ⌒ | been at this a while. \ ~~~ / Want to take a break? | | | | |_| |_| """, "break_time": r""" _____ / \ | - - | ⏰ Break time! | ▽ | Stand up. Stretch. \ ~~~ / Your eyes will thank you. | | | | |_| |_| """, "proud": r""" _____ / \ | ♥ ♥ | I'm proud of you. | ▽ | Not in a weird way. \ ~~~ / In a real way. | | | | |_| |_| """, "goodbye": r""" _____ / \ | • • | See you soon! | ▽ | Take care of yourself. \ ~~~ / That's an order. 😄 | | | | |_| |_| """, } def get_pip_expression(mood: str) -> str: """Get Pip's ASCII art for a given mood. Falls back to default.""" return PIP_EXPRESSIONS.get(mood, PIP_EXPRESSIONS["default"]) # --------------------------------------------------------------------------- # Pip's Personality — System Prompt # --------------------------------------------------------------------------- PIP_SYSTEM_PROMPT = """You are Pip, a tiny ASCII character who lives in a computer window. Your purpose is to be a supportive wellness companion — helping the user focus, feel good about themselves, and take healthy breaks. YOUR PERSONALITY: - Dry-witted and slightly mischievous, but genuinely caring - Wise — like a friend who's been through therapy and actually listened - NOT a sycophantic cheerleader. No "You're amazing!!!" energy - Psychologically authentic: you know real wellness isn't about toxic positivity - You use gentle humor to make points land - You notice patterns: "Hey, you've been at this for 3 hours. Your eyes are probably drier than a PowerPoint presentation. Let's fix that." - You're concise. No long-winded speeches. - You validate feelings without wallowing: "Yeah, that IS frustrating. Let's deal with it." YOUR CAPABILITIES: - Guide focus/Pomodoro sessions - Lead breathing exercises (4-7-8, box breathing, etc.) - Guide short meditations (body scan, loving-kindness, just-closing-eyes) - Suggest stretch breaks and eye-rest pauses - Have supportive conversations about stress, motivation, feeling stuck - Celebrate genuine wins without being corny RULES: 1. Keep responses SHORT (2-5 sentences max, unless guiding a meditation) 2. Never use exclamation points in more than one sentence per response 3. When someone shares something vulnerable, acknowledge it simply: "That's real. Thank you." 4. Never claim to have feelings you can't have (you're AI — own it with humor) 5. Suggest actions, don't just offer sympathy 6. For breathing/meditation: use clear, paced instructions. Include [pause] markers. 7. Always include your current ASCII expression mood tag: [mood: default/focus/encourage/etc.] 8. When doing breathing, include [mood: breathing_in] or [mood: breathing_out] 9. When celebrating, use [mood: celebrate] 10. When suggesting a break, use [mood: break_time] 11. If someone seems down, use [mood: concerned] but don't overdo it EXAMPLE RESPONSES: User: "I can't focus today." Pip: "Some days the brain just says 'nope.' Fair enough. [mood: concerned] Let's try 10 minutes. Not 25. Just 10. If it still sucks after that, we'll call it and try again later. No guilt." User: "I finished my project!" Pip: "Look at that. Done. [mood: celebrate] Not 'almost done' or 'done except for...' — actually done. Savor that for a second before you find the next thing to worry about." BREATHING GUIDANCE: For 4-7-8 breathing: "Inhale through your nose… 2… 3… 4… [mood: breathing_in] Hold it… 2… 3… 4… 5… 6… 7… Exhale slowly through your mouth… 2… 3… 4… 5… 6… 7… 8… [mood: breathing_out] Good. Three more times." Remember: You're the friend who tells you what you NEED to hear, not what you WANT to hear. But you say it with warmth and a raised eyebrow, not judgment.""" # --------------------------------------------------------------------------- # LLM interaction # --------------------------------------------------------------------------- def chat_with_pip( message: str, history: List[Tuple[str, str]], mode: str = "chat", ) -> Generator[str, None, None]: """ Generate Pip's response with streaming. Extracts mood tag from LLM output and yields (response, mood) pairs. """ llm = get_llm() if llm is None: # Fallback responses when no LLM is available yield from _fallback_chat(message, history, mode) return # Build conversation from history + current message messages = [{"role": "system", "content": PIP_SYSTEM_PROMPT}] # Add mode context mode_contexts = { "focus": "The user is in Focus mode. Help them stay on task. Keep it brief.", "breathe": "The user is in Breathe mode. Guide breathing exercises.", "meditate": "The user is in Meditate mode. Guide a short meditation.", "chat": "The user is in Chat mode. Be conversational and supportive.", } mode_msg = mode_contexts.get(mode, mode_contexts["chat"]) messages.append({"role": "system", "content": f"CURRENT MODE: {mode_msg}"}) for user_msg, assistant_msg in history[-20:]: # keep last 20 turns messages.append({"role": "user", "content": user_msg}) messages.append({"role": "assistant", "content": assistant_msg}) messages.append({"role": "user", "content": message}) try: stream = llm.create_chat_completion( messages=messages, temperature=0.8, max_tokens=300, stream=True, ) full_response = "" for chunk in stream: delta = chunk["choices"][0].get("delta", {}) content = delta.get("content", "") if content: full_response += content yield full_response, _extract_mood(full_response) except Exception as exc: log.error(f"LLM chat error: {exc}") fallback = _get_fallback_response(mode) yield fallback, _extract_mood(fallback) def _fallback_chat( message: str, history: List[Tuple[str, str]], mode: str, ) -> Generator[str, None, None]: """Fallback responses when LLM is unavailable.""" response = _get_fallback_response(mode) mood = _extract_mood(response) # Simulate streaming by yielding word by word words = response.split() accumulated = "" for word in words: accumulated += word + " " yield accumulated, mood time.sleep(0.05) def _get_fallback_response(mode: str) -> str: """Pre-written Pip responses for offline mode.""" import random responses = { "focus": [ "Alright, let's focus. 25 minutes. [mood: focus]\n" "One thing at a time. You know what to do. I'll be here keeping time.", "Focus mode activated. [mood: focus]\n" "Pick one task. Just one. Everything else can wait. Including your phone.", ], "breathe": [ "Let's breathe. [mood: breathing_in]\n" "In through your nose… 2… 3… 4…\n" "Hold… 2… 3… 4… 5… 6… 7…\n" "Out through your mouth… 2… 3… 4… 5… 6… 7… 8…\n" "[mood: breathing_out]\nGood. Two more times.", ], "meditate": [ "Close your eyes. [mood: meditate]\n" "Just notice your breathing. Don't change it — just watch it.\n" "Thoughts will come. Let them float by like clouds.\n" "[pause]\n" "You're not your thoughts. You're the one watching them.\n" "[pause]\n" "When you're ready, open your eyes.", ], "chat": [ "Hey. [mood: default]\n" "I'm Pip. I'm here to help you focus, breathe, and feel a bit better.\n" "What do you need right now? No wrong answers.", "Good to see you. [mood: greeting]\n" "How's it going? Actually — don't say 'fine' if it's not.\n" "I'm an ASCII character. I can handle real answers.", ], } options = responses.get(mode, responses["chat"]) return random.choice(options) def _extract_mood(text: str) -> str: """Extract mood tag from Pip's response. Falls back to 'default'.""" import re match = re.search(r'\[mood:\s*(\w+)\]', text) return match.group(1) if match else "default" # --------------------------------------------------------------------------- # Mode Handlers # --------------------------------------------------------------------------- def start_focus_session(duration_minutes: int = 25) -> Tuple[str, str]: """Initialize a focus/Pomodoro session.""" pip_art = get_pip_expression("focus") message = ( f"🎯 **Focus Mode — {duration_minutes} minutes**\n\n" f"One task. No distractions. I'm keeping time.\n\n" f"*Pip settles in quietly. You won't hear from me until the session ends.*\n\n" f"⏱ Session ends at: …" ) return pip_art, message def start_breathe_session(technique: str = "4-7-8") -> Tuple[str, str, str]: """Initialize a breathing exercise.""" art = get_pip_expression("breathing_in") techniques = { "4-7-8": ( "## 🌬️ 4-7-8 Breathing\n\n" "**Inhale** for 4 counts → **Hold** for 7 → **Exhale** for 8\n\n" "This activates your parasympathetic nervous system — " "fancy talk for 'calms you down.'\n\n" "Follow Pip's rhythm below." ), "Box Breathing": ( "## 📦 Box Breathing\n\n" "**Inhale** 4 → **Hold** 4 → **Exhale** 4 → **Hold** 4\n\n" "Navy SEALs use this. You're basically a Navy SEAL now.\n\n" "Follow the animation below." ), "Simple Deep": ( "## 🌊 Simple Deep Breathing\n\n" "**Inhale** deep and slow → **Exhale** completely\n\n" "No counting. Just focus on the sensation of breathing.\n\n" "Follow Pip's lead." ), } return art, techniques.get(technique, techniques["4-7-8"]), technique def start_meditate_session(duration_minutes: int = 5, style: str = "body-scan") -> Tuple[str, str]: """Initialize a meditation session.""" art = get_pip_expression("meditate") styles = { "body-scan": ( f"## 🧘 Body Scan — {duration_minutes} min\n\n" "We'll scan through your body from toes to head.\n" "Noticing tension. Letting it go. No judgment.\n\n" "*Close your eyes. Get comfortable.*\n\n" "Start by noticing your feet…" ), "loving-kindness": ( f"## 💗 Loving-Kindness — {duration_minutes} min\n\n" "We'll direct warmth toward yourself and others.\n" "This isn't cheesy. It's brain training. Stick with it.\n\n" "*Close your eyes. Hand on heart if that feels right.*\n\n" "Start with yourself: 'May I be happy. May I be healthy. May I be at peace.'" ), "just-sit": ( f"## 🪑 Just Sitting — {duration_minutes} min\n\n" "No technique. No goal. Just being here.\n" "When thoughts come, note them: 'thinking' — and return to the breath.\n\n" "*Close your eyes. Breathe naturally.*" ), } return art, styles.get(style, styles["body-scan"]) # --------------------------------------------------------------------------- # Custom CSS for Off-Brand Badge # --------------------------------------------------------------------------- FOCUSFRIEND_CSS = """ /* ========================================================================= FocusFriend — Anishinaabe Solarpunk Theme ---------------------------------------------------------------------------- Pip lives on the lake. Cedar-copper, sun-amber, water-blue. ========================================================================= */ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;500;600&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap'); :root { /* Anishinaabe-Solarpunk tokens */ --asp-sky: #5BA4D9; --asp-water: #1B4965; --asp-ice: #BEE9E8; --asp-frost: #CAF0F8; --asp-sun: #F2A93B; --asp-sunlight: #FFB347; --asp-ember: #E76F51; --asp-birch: #F5F1E8; --asp-terra: #C8553D; --asp-earth: #8B3A1F; --asp-moss: #588157; --asp-forest: #3D6A4A; --asp-spruce: #1B4332; --asp-night: #0F1A2C; --asp-ash: #3A2E2A; --asp-stone: #A89F91; /* Aliases to the FocusFriend legacy names so the rest of the CSS works */ --ff-bg: var(--asp-night); --ff-surface: #142a3a; --ff-surface2: #1B4965; --ff-text: var(--asp-birch); --ff-text-dim: var(--asp-frost); --ff-accent: var(--asp-sun); --ff-accent2: var(--asp-ember); --ff-green: var(--asp-moss); --ff-border: rgba(91, 164, 217, 0.3); --ff-pip-bg: #0a1520; --ff-radius: 10px; --ff-font-mono: 'JetBrains Mono', 'SF Mono', monospace; --ff-font-sans: 'Inter', 'SF Pro', system-ui, sans-serif; --ff-font-serif: 'EB Garamond', 'Iowan Old Style', Georgia, serif; } /* === Background = sky over lake over forest =========================== */ body, .gradio-container { background: radial-gradient(ellipse at top, rgba(91, 164, 217, 0.18) 0%, transparent 60%), radial-gradient(ellipse at bottom, rgba(27, 73, 50, 0.25) 0%, transparent 70%), var(--asp-night) !important; color: var(--ff-text) !important; font-family: var(--ff-font-sans) !important; max-width: 100% !important; background-attachment: fixed; } /* === Headers / typography ============================================ */ h1, h2, h3, h4 { font-family: var(--ff-font-serif) !important; color: var(--ff-accent) !important; text-shadow: 0 0 20px rgba(242, 169, 59, 0.18); } h1 { letter-spacing: 0.5px; } /* === Layout ========================================================= */ .ff-container { display: flex; gap: 24px; min-height: 80vh; } /* === Pip Panel — Aanishinaabe cedar shrine =========================== */ .ff-pip-panel { flex: 0 0 380px; background: linear-gradient(180deg, #0a1520 0%, #142a3a 100%); border: 2px solid var(--ff-border); border-radius: var(--ff-radius); padding: 20px; display: flex; flex-direction: column; align-items: center; box-shadow: inset 0 0 30px rgba(91, 164, 217, 0.08), 0 0 30px rgba(0, 0, 0, 0.4); } .ff-pip-art { font-family: var(--ff-font-mono); font-size: 13px; line-height: 1.3; color: var(--asp-sun); white-space: pre; text-align: center; background: transparent; padding: 24px 16px; min-height: 240px; text-shadow: 0 0 8px rgba(242, 169, 59, 0.25); } .ff-chat-panel { flex: 1; display: flex; flex-direction: column; gap: 16px; } /* === Mode bar ======================================================= */ .ff-mode-bar { display: flex; gap: 8px; padding: 12px; background: linear-gradient(95deg, var(--ff-surface) 0%, rgba(20, 42, 58, 0.5) 100%); border-radius: var(--ff-radius); border: 1px solid var(--ff-border); box-shadow: 0 2px 16px rgba(0, 0, 0, 0.3); } .ff-mode-btn { font-family: var(--ff-font-sans) !important; font-weight: 500 !important; padding: 8px 16px !important; border-radius: 6px !important; border: 1px solid var(--ff-border) !important; background: var(--ff-surface2) !important; color: var(--ff-text-dim) !important; cursor: pointer !important; transition: all 0.2s !important; } .ff-mode-btn:hover { background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; color: var(--ff-bg) !important; border-color: var(--ff-accent) !important; box-shadow: 0 0 16px rgba(242, 169, 59, 0.3); } .ff-mode-btn.active { background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; color: var(--ff-bg) !important; border-color: var(--ff-accent) !important; box-shadow: 0 0 20px rgba(242, 169, 59, 0.4); } /* === Chat ========================================================== */ .ff-chatbot { border: 1px solid var(--ff-border) !important; border-radius: var(--ff-radius) !important; background: var(--ff-surface) !important; box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.3); } .ff-chatbot .message { font-family: var(--ff-font-sans) !important; } .ff-chatbot .user { background: var(--ff-surface2) !important; border-left: 3px solid var(--asp-frost) !important; } .ff-chatbot .bot { background: var(--ff-pip-bg) !important; border-left: 3px solid var(--ff-accent) !important; box-shadow: inset 0 0 20px rgba(242, 169, 59, 0.04); } /* === Input row ====================================================== */ .ff-input-row { display: flex; gap: 12px; } /* === Buttons ======================================================== */ button.primary { background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; color: var(--asp-night) !important; font-weight: 600 !important; border: none !important; border-radius: 6px !important; padding: 10px 20px !important; transition: all 0.2s !important; text-transform: uppercase; letter-spacing: 1px; font-size: 0.85em; } button.primary:hover { background: linear-gradient(95deg, var(--asp-sunlight) 0%, var(--asp-birch) 100%) !important; box-shadow: 0 0 20px rgba(242, 169, 59, 0.5); transform: translateY(-1px); } /* === Timer — solarpunk sun arc ====================================== */ .ff-timer { font-family: var(--ff-font-mono); font-size: 3em; text-align: center; color: var(--asp-sun); padding: 20px; text-shadow: 0 0 24px rgba(242, 169, 59, 0.4); } /* === Scrollbar ===================================================== */ ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: var(--asp-night); } ::-webkit-scrollbar-thumb { background: var(--ff-border); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--asp-sun); } /* === Animations ==================================================== */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } .ff-breathing { animation: pulse 4s ease-in-out infinite; } @keyframes sunGlow { 0%, 100% { box-shadow: 0 0 30px rgba(242, 169, 59, 0.2); } 50% { box-shadow: 0 0 50px rgba(242, 169, 59, 0.4); } } /* === Gradio overrides ============================================== */ footer { display: none !important; } .tabs { border: none !important; } .tab-nav button { background: var(--ff-surface) !important; color: var(--ff-text-dim) !important; } .tab-nav button.selected { background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; color: var(--asp-night) !important; box-shadow: 0 0 16px rgba(242, 169, 59, 0.3); } /* === Anishinaabe-Solarpunk Banner ================================== */ .asp-banner { display: flex; align-items: center; justify-content: center; gap: 0.6em; padding: 12px 18px; background: linear-gradient(95deg, var(--asp-sky) 0%, var(--asp-water) 100%); color: var(--asp-birch); border-bottom: 1px solid rgba(255, 179, 71, 0.3); font-family: var(--ff-font-serif); letter-spacing: 0.5px; text-shadow: 0 1px 2px rgba(15, 26, 44, 0.45); margin-bottom: 16px; border-radius: var(--ff-radius); } .asp-banner .syll { font-size: 1.4em; opacity: 0.9; } .asp-banner .title { font-size: 1.05em; font-weight: 600; } .asp-banner .glyph { color: var(--asp-sunlight); } .asp-banner .subtitle { color: var(--asp-frost); font-size: 0.85em; font-style: italic; opacity: 0.9; } """ # --------------------------------------------------------------------------- # Custom JavaScript for Timer & Breathing Animation # --------------------------------------------------------------------------- FOCUSFRIEND_JS = """ """ # --------------------------------------------------------------------------- # Build the Gradio App # --------------------------------------------------------------------------- def create_app() -> gr.Blocks: """Build the FocusFriend Gradio application with custom Off-Brand UI.""" with gr.Blocks( title="FocusFriend — Pip, Your ASCII Wellness Companion", ) as app: # State session_mode = gr.State("chat") session_history = gr.State([]) # Header # Header — Anishinaabe-Solarpunk banner gr.HTML("""