import os import sys import subprocess from arduino.app_utils import Bridge, App from arduino.app_bricks.web_ui import WebUI from arduino.app_bricks.video_objectdetection import VideoObjectDetection import time import json import urllib.request import urllib.parse import urllib.error import threading import struct import re import random import datetime import wave import math import sqlite3 ui = WebUI() ui_ready = False audio_card = "plughw:2,0" mic_device = "plughw:1,0" record_process = None # ========================================== # API KEYS # ========================================== user_groq_api_key = "" user_cartesia_api_key = "" user_cartesia_voice_id = "2a12b36c-7f9b-4c3a-9f7a-72731b15323a" user_weather_loc = "Hyderabad" user_timezone = "Asia/Kolkata" user_timezone_country = "India" user_language = "English" user_calendar_url = "" google_client_id = "" google_client_secret = "" google_refresh_token = "" google_access_token = None google_token_expiry = 0 GROQ_MODEL_MAIN = "groq/compound" GROQ_MODEL_FAST = "groq/compound-mini" GROQ_CHAT_URL = "https://api.groq.com/openai/v1/chat/completions" VIDEO_STREAM_PORT = 4912 COUNTRY_CONFIG = { "India": {"timezone": "Asia/Kolkata", "weather": "Hyderabad"}, "United Kingdom": {"timezone": "Europe/London", "weather": "London"}, "United States": {"timezone": "America/New_York", "weather": "New York"}, "Japan": {"timezone": "Asia/Tokyo", "weather": "Tokyo"}, "United Arab Emirates": {"timezone": "Asia/Dubai", "weather": "Dubai"}, "Australia": {"timezone": "Australia/Sydney", "weather": "Sydney"}, "France": {"timezone": "Europe/Paris", "weather": "Paris"}, "Singapore": {"timezone": "Asia/Singapore", "weather": "Singapore"} } # Kept for compatibility with older stored locations. New code uses IANA time zones. TZ_OFFSETS = {"India": 5.5, "Hyderabad": 5.5, "London": 0, "New York": -5, "Tokyo": 9, "Sydney": 10, "Paris": 1, "Dubai": 4} try: from zoneinfo import ZoneInfo except ImportError: ZoneInfo = None current_audio_process = None cancel_speech = False speech_lock = threading.Lock() speaker_card_global = "2" global_volume = "100" global_humor = "5" voice_mute = False local_is_dancing = False is_hibernating = False enable_night_reminders = True speaker_controls_global = [] mic_controls_global = [] is_active_listening = False is_curie_asleep = False # POMODORO GLOBALS pomodoro_running = False pomodoro_paused = False is_break_phase = False pomodoro_seconds_left = 0 pomodoro_total_seconds = 0 pomodoro_timer_thread = None last_pomodoro_day = -1 pomodoro_manual_pause = False pomodoro_continue_grace_until = 0.0 pomo_goal_score = None last_pomo_score = None # BREATHING EXERCISE GLOBALS breathing_running = False breathing_paused = False breathing_seconds_left = 0 breathing_total_seconds = 0 breathing_timer_thread = None # FEATURE 1: Task Breakdown State active_task_steps = [] current_task_index = 0 start_task_pending = False last_focus_tier_sent = -1 STREAK_MILESTONES = {3, 7, 14, 30, 60, 100, 200, 365} # --- VISION GLOBALS --- phone_pickup_count = 0 phone_currently_visible = False afk_count = 0 phone_pickup_start = 0 total_phone_time = 0 last_person_seen_time = time.time() last_phone_seen_time = 0 last_pickup_time = 0 raw_detected_labels = "None" consecutive_missing = 0 consecutive_present = 0 MOVEMENT_REMINDER_SECONDS = 3600 ABSENCE_RESET_SECONDS = 120 seated_session_start = None proactive_queue = [] proactive_queue_lock = threading.Lock() current_mood_code = 0 # ========================================== # SQLITE (hardened: WAL mode + timeouts) # ========================================== DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "memory.db") db_lock = threading.Lock() def get_db_conn(): conn = sqlite3.connect(DB_PATH, timeout=10) conn.execute("PRAGMA journal_mode=WAL;") return conn def init_db(): with db_lock: conn = get_db_conn() c = conn.cursor() c.execute('''CREATE TABLE IF NOT EXISTS chat_history (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT, content TEXT)''') c.execute('''CREATE TABLE IF NOT EXISTS user_facts (id INTEGER PRIMARY KEY AUTOINCREMENT, fact TEXT)''') c.execute('''CREATE TABLE IF NOT EXISTS daily_stats ( date TEXT PRIMARY KEY, pomodoro_sessions INTEGER DEFAULT 0, total_score INTEGER DEFAULT 0, total_phone_pickups INTEGER DEFAULT 0, total_afk INTEGER DEFAULT 0, late_night_flag INTEGER DEFAULT 0 )''') c.execute('''CREATE TABLE IF NOT EXISTS brain_dump (id INTEGER PRIMARY KEY AUTOINCREMENT, item TEXT, done INTEGER DEFAULT 0)''') c.execute("DELETE FROM chat_history") conn.commit() conn.close() def add_memory(role, content): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("INSERT INTO chat_history (role, content) VALUES (?, ?)", (role, content)) conn.commit() conn.close() except Exception: pass def get_recent_memory(limit=4): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT role, content FROM chat_history ORDER BY id DESC LIMIT ?", (limit,)) rows = c.fetchall() conn.close() return [{"role": row[0], "content": row[1]} for row in reversed(rows)] except Exception: return [] def get_user_facts(): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT fact FROM user_facts") rows = c.fetchall() conn.close() if not rows: return "" facts = [row[0] for row in rows] return "Facts you know about the user: " + ", ".join(facts) + "." except Exception: return "" def update_memory_ui(): if not ui_ready: return facts = get_user_facts() if not facts: ui.send_message("update_memory", {"text": "No memories recorded yet."}) else: clean_facts = facts.replace("Facts you know about the user: ", "") ui.send_message("update_memory", {"text": clean_facts}) def compress_user_facts(): global user_groq_api_key if not user_groq_api_key: return try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT id, fact FROM user_facts") rows = c.fetchall() conn.close() if len(rows) > 5: facts_text = " ".join([r[1] for r in rows]) prompt = f"Compress these explicit user facts into one short factual sentence. Output ONLY the facts, no reasoning, no preamble, no examples: {facts_text}" messages = [{"role": "user", "content": prompt}] res_data = call_groq(messages, user_groq_api_key, model=GROQ_MODEL_FAST, max_tokens=100, temperature=0.1) summary = _clean_memory_result(res_data["choices"][0]["message"].get("content", "").strip()) if summary: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("DELETE FROM user_facts") c.execute("INSERT INTO user_facts (fact) VALUES (?)", (summary,)) conn.commit() conn.close() if ui_ready: ui.send_message("system_log", {"text": "🧠 Long-Term Memory Summarized."}) update_memory_ui() except Exception: pass def _clean_memory_result(res): """Remove reasoning/meta leakage before anything reaches long-term memory.""" if not res: return "" res = re.sub(r".*?", "", res, flags=re.I | re.S) res = re.sub(r"```(?:json|text)?|```", "", res, flags=re.I).strip() # Reasoning models sometimes append a visible chain-of-thought after the answer. cut_markers = [ "here's a thinking process", "here is a thinking process", "thinking process:", "analysis:", "let's analyze", "step 1:", "examples of facts", "task:", "task -", "output only" ] low = res.lower() cut = len(res) for marker in cut_markers: pos = low.find(marker) if pos > 0: cut = min(cut, pos) res = res[:cut].strip() res = re.sub(r"^[-*•\s]+", "", res).strip() # Keep memory concise; never store obvious prompt/meta text. bad = ["extract permanent facts", "the user's message", "the user message", "here's a", "here is a", "i should", "i need to"] low = res.lower() if any(x in low for x in bad): return "" if len(res) > 300: return "" return res def extract_and_save_facts(user_text): global user_groq_api_key if not user_groq_api_key: return try: prompt = ( "You are a strict long-term-memory extractor. Read the user's message below. " "Return ONLY permanent, user-specific facts that are explicitly stated in the message. " "Examples: name, birthday, long-term preference, hobby, occupation. " "Do NOT describe the task, your reasoning, examples, instructions, or the extraction process. " "Do NOT infer anything. If there are no permanent facts, output exactly NONE. " "Keep the answer to one short sentence.\n\nUSER MESSAGE:\n" + user_text ) messages = [ {"role": "system", "content": "Return only the memory fact(s), or NONE. Never reveal reasoning."}, {"role": "user", "content": prompt} ] res_data = call_groq(messages, user_groq_api_key, model=GROQ_MODEL_FAST, max_tokens=80, temperature=0.0) res = _clean_memory_result(res_data["choices"][0]["message"].get("content", "").strip()) if not res or res.upper() == "NONE" or "NONE" in res.upper(): return with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("INSERT INTO user_facts (fact) VALUES (?)", (res,)) conn.commit() conn.close() log_event("memory_saved", {"fact": res}) if ui_ready: ui.send_message("system_log", {"text": f"🧠 Memory Saved: {res}"}) update_memory_ui() compress_user_facts() except Exception as e: log_event("memory_error", {"error": str(e)}) # ========================================== # BRAIN DUMP FUNCTIONS # ========================================== def add_brain_dump(item): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("INSERT INTO brain_dump (item) VALUES (?)", (item,)) conn.commit() conn.close() send_braindump_sync() except Exception: pass def get_brain_dumps(): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT id, item, done FROM brain_dump WHERE done = 0 ORDER BY id DESC") rows = c.fetchall() conn.close() return [{"id": r[0], "item": r[1], "done": bool(r[2])} for r in rows] except Exception: return [] def toggle_brain_dump(item_id, done): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("UPDATE brain_dump SET done = ? WHERE id = ?", (1 if done else 0, item_id)) conn.commit() conn.close() send_braindump_sync() except Exception: pass def send_braindump_sync(): if not ui_ready: return dumps = get_brain_dumps() ui.send_message("braindump_sync", {"items": dumps}) # ========================================== # HABIT TRACKING # ========================================== def get_local_date_key(offset_days=0): if ZoneInfo: local_time = datetime.datetime.now(ZoneInfo(user_timezone)) - datetime.timedelta(days=offset_days) else: offset = TZ_OFFSETS.get(user_weather_loc, 0) local_time = datetime.datetime.utcnow() + datetime.timedelta(hours=offset) - datetime.timedelta(days=offset_days) return local_time.strftime("%Y-%m-%d") def log_habit_pomodoro(score, pickups, afk): try: date_key = get_local_date_key() with db_lock: conn = get_db_conn() c = conn.cursor() c.execute( "INSERT INTO daily_stats (date, pomodoro_sessions, total_score, total_phone_pickups, total_afk) " "VALUES (?, 1, ?, ?, ?) " "ON CONFLICT(date) DO UPDATE SET pomodoro_sessions=pomodoro_sessions+1, " "total_score=total_score+?, total_phone_pickups=total_phone_pickups+?, total_afk=total_afk+?", (date_key, score, pickups, afk, score, pickups, afk) ) conn.commit() conn.close() update_habits_ui() except Exception: pass def log_habit_late_night(): try: date_key = get_local_date_key() with db_lock: conn = get_db_conn() c = conn.cursor() c.execute( "INSERT INTO daily_stats (date, late_night_flag) VALUES (?, 1) " "ON CONFLICT(date) DO UPDATE SET late_night_flag=1", (date_key,) ) conn.commit() conn.close() update_habits_ui() except Exception: pass def get_recent_habits(days=7): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute( "SELECT date, pomodoro_sessions, total_score, total_phone_pickups, total_afk, late_night_flag " "FROM daily_stats ORDER BY date DESC LIMIT ?", (days,) ) rows = c.fetchall() conn.close() return rows except Exception: return [] # ========================================== # STREAK TRACKING # ========================================== def get_today_session_count(): try: date_key = get_local_date_key() with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT pomodoro_sessions FROM daily_stats WHERE date = ?", (date_key,)) row = c.fetchone() conn.close() return row[0] if row else 0 except Exception: return 0 def get_all_session_dates(): try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT date FROM daily_stats WHERE pomodoro_sessions > 0 ORDER BY date ASC") rows = c.fetchall() conn.close() return [r[0] for r in rows] except Exception: return [] def compute_streak_info(): dates = get_all_session_dates() if not dates: return {"current": 0, "best": 0, "at_risk": False, "today_done": False} date_set = set(dates) today_key = get_local_date_key(0) yesterday_key = get_local_date_key(1) day_before_yesterday_key = get_local_date_key(2) today_done = today_key in date_set date_objs = sorted([datetime.datetime.strptime(d, "%Y-%m-%d").date() for d in dates]) best = 1 run = 1 for i in range(1, len(date_objs)): days_diff = (date_objs[i] - date_objs[i - 1]).days if days_diff == 1 or days_diff == 2: if days_diff == 1: run += 1 else: run = 1 best = max(best, run) current = 0 cursor_date = datetime.datetime.strptime(today_key, "%Y-%m-%d").date() if today_done: start_date = cursor_date elif yesterday_key in date_set: start_date = cursor_date - datetime.timedelta(days=1) elif day_before_yesterday_key in date_set: start_date = cursor_date - datetime.timedelta(days=2) else: start_date = None if start_date: while start_date.strftime("%Y-%m-%d") in date_set or (start_date + datetime.timedelta(days=1)).strftime("%Y-%m-%d") in date_set: if start_date.strftime("%Y-%m-%d") in date_set: current += 1 start_date -= datetime.timedelta(days=1) at_risk = (not today_done) and current > 0 return {"current": current, "best": max(best, current), "at_risk": at_risk, "today_done": today_done} def push_streak_update(): info = compute_streak_info() last7 = [] for i in range(6, -1, -1): d = get_local_date_key(i) try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("SELECT pomodoro_sessions FROM daily_stats WHERE date = ?", (d,)) row = c.fetchone() conn.close() done = bool(row and row[0] > 0) except Exception: done = False last7.append({"date": d, "done": done, "is_today": (i == 0)}) info["last7"] = last7 Bridge.notify("update_streak", info["current"]) if ui_ready: ui.send_message("streak_sync", info) return info def maybe_celebrate_streak_milestone(streak_count): if streak_count not in STREAK_MILESTONES: return Bridge.notify("celebrate", 1) fallback = f"[HAPPY] {streak_count} days in a row now — that's a real streak. Nice and steady." if not user_groq_api_key: queue_proactive_message(fallback) return try: prompt = ( f"The user just hit a {streak_count}-day focus streak. Write ONE short, warm, matter-of-fact sentence celebrating it — " "no hype, no exclamation overload. Start with [HAPPY]. Respond in {user_language} (keep the tag in English)." ) res_data = call_groq([{"role": "user", "content": prompt}], user_groq_api_key, model=GROQ_MODEL_FAST, max_tokens=60, temperature=0.7) msg = res_data["choices"][0]["message"].get("content", "").strip() queue_proactive_message(msg if msg else fallback) except Exception: queue_proactive_message(fallback) def update_habits_ui(): if not ui_ready: return rows = get_recent_habits(7) lines = [] for date, sessions, total_score, pickups, afk, late in rows: avg = round(total_score / sessions) if sessions else 0 line = f"{date} — {sessions} sessions, {avg}% avg, {pickups} pickups, {afk} AFKs" if late: line += " 🌙 late night" lines.append(line) ui.send_message("habits_sync", {"lines": lines}) push_streak_update() def build_weekly_summary_text(): global user_language rows = get_recent_habits(7) if not rows: return "[DEFAULT] I don't have enough data yet this week to give you a useful recap. Let's log a few more focus sessions first." # rows are newest -> oldest. Give the LLM already-computed trends instead of a raw data dump. ordered = list(reversed(rows)) total_sessions = sum(r[1] for r in ordered) total_score = sum(r[2] for r in ordered) total_pickups = sum(r[3] for r in ordered) total_afk = sum(r[4] for r in ordered) late_days = sum(1 for r in ordered if r[5]) overall_avg = round(total_score / total_sessions) if total_sessions else 0 midpoint = max(1, len(ordered) // 2) early = ordered[:midpoint] recent = ordered[midpoint:] early_sessions = sum(r[1] for r in early) recent_sessions = sum(r[1] for r in recent) early_avg = round(sum(r[2] for r in early) / early_sessions) if early_sessions else overall_avg recent_avg = round(sum(r[2] for r in recent) / recent_sessions) if recent_sessions else overall_avg early_pickups = sum(r[3] for r in early) recent_pickups = sum(r[3] for r in recent) score_delta = recent_avg - early_avg pickup_delta = recent_pickups - early_pickups if score_delta >= 5: trend = f"Focus scores improved from about {early_avg}% earlier in the week to about {recent_avg}% recently." elif score_delta <= -5: trend = f"Focus scores slipped from about {early_avg}% earlier in the week to about {recent_avg}% recently." elif pickup_delta <= -2: trend = "Phone pickups decreased toward the end of the week, which suggests distractions were getting easier to manage." elif pickup_delta >= 2: trend = "Phone pickups increased toward the end of the week, so reducing easy phone access could help." else: trend = f"Focus was fairly steady at about {overall_avg}% across {total_sessions} sessions." streak_info = compute_streak_info() goal = min(100, max(80, recent_avg + 5)) raw_data = ( f"Overall average focus score: {overall_avg}%. Total sessions: {total_sessions}. " f"Phone pickups: {total_pickups}. Times away: {total_afk}. Late nights: {late_days}. " f"Current streak: {streak_info['current']} days; best streak: {streak_info['best']} days. " f"Computed trend: {trend} Next-session goal: beat {goal}%." ) if not user_groq_api_key: return f"[DEFAULT] {trend} You logged {total_sessions} focus sessions this week at an average of {overall_avg}%. " f"For the next session, aim to beat {goal}%." prompt = ( f"{raw_data}\n\n" "Turn this into a useful spoken weekly recap in 3-4 natural sentences. " "Do NOT list dates or repeat every metric. Mention the main trend, one useful observation, " "and one concrete next-session goal. Say 'average', never 'avg' or 'AVG'. " "Do not spell out symbols, abbreviations, or punctuation. " "Start with exactly one mood tag: [HAPPY], [DEFAULT], or [SAD]. " f"Respond in {user_language}." ) messages = [ {"role": "system", "content": "You are Curie. Give concise, useful spoken coaching from already-computed weekly data. " "Never dump raw data back to the user and never reveal reasoning."}, {"role": "user", "content": prompt} ] try: res_data = call_groq(messages, user_groq_api_key, model=GROQ_MODEL_MAIN, max_tokens=140, temperature=0.4) text = res_data["choices"][0]["message"].get("content", "").strip() text = _clean_memory_result(text) if "thinking process" in text.lower() else text return text if text else f"[DEFAULT] {trend} Your average was {overall_avg}%. For the next session, aim to beat {goal}%." except Exception as e: log_event("weekly_summary_error", {"error": str(e)}) return f"[DEFAULT] {trend} Your average was {overall_avg}%. For the next session, aim to beat {goal}%." def prepare_weekly_summary(): queue_proactive_message(build_weekly_summary_text()) def trigger_weekly_summary(): threading.Thread(target=prepare_weekly_summary, daemon=True).start() def weekly_summary_loop(): last_sent_week = -1 while True: time.sleep(300) try: offset = TZ_OFFSETS.get(user_weather_loc, 0) local_time = datetime.datetime.utcnow() + datetime.timedelta(hours=offset) iso_week = local_time.isocalendar()[1] if local_time.weekday() == 6 and local_time.hour == 18 and iso_week != last_sent_week: last_sent_week = iso_week trigger_weekly_summary() except Exception: pass # ========================================== # GOOGLE CALENDAR (real OAuth API, with iCal fallback) # ========================================== def get_google_access_token(): global google_access_token, google_token_expiry if google_access_token and time.time() < google_token_expiry - 60: return google_access_token if not (google_client_id and google_client_secret and google_refresh_token): return None try: data = urllib.parse.urlencode({ "client_id": google_client_id, "client_secret": google_client_secret, "refresh_token": google_refresh_token, "grant_type": "refresh_token" }).encode('utf-8') req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data, method="POST") with urllib.request.urlopen(req, timeout=10) as response: res = json.loads(response.read().decode('utf-8')) google_access_token = res["access_token"] google_token_expiry = time.time() + res.get("expires_in", 3600) return google_access_token except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"❌ Google token refresh failed: {e}"}) return None def get_calendar_events(): global user_calendar_url, google_client_id, google_client_secret, google_refresh_token if google_client_id and google_client_secret and google_refresh_token: token = get_google_access_token() if token: try: now_iso = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") url = ("https://www.googleapis.com/calendar/v3/calendars/primary/events?" f"timeMin={urllib.parse.quote(now_iso)}&maxResults=5&singleEvents=true&orderBy=startTime") req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) with urllib.request.urlopen(req, timeout=8) as response: data = json.loads(response.read().decode('utf-8')) events = [] for item in data.get("items", []): summary = item.get("summary", "Untitled event") start = item.get("start", {}) start_str = start.get("dateTime", start.get("date", "")) events.append(f"{summary} ({start_str})") return ("Upcoming Calendar Events: " + ", ".join(events[:3])) if events else "" except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"⚠️ Google Calendar API failed ({e}), falling back to iCal if set."}) if not user_calendar_url or "http" not in user_calendar_url: return "" try: req = urllib.request.Request(user_calendar_url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=5) as response: data = response.read().decode('utf-8') events = [] now_int = int(datetime.datetime.utcnow().strftime("%Y%m%d")) blocks = data.split("BEGIN:VEVENT") for block in blocks[1:]: if "SUMMARY:" in block and "DTSTART" in block: summary_match = re.search(r'SUMMARY:(.*?)\n', block) dtstart_match = re.search(r'DTSTART.*?:(\d{8})(T\d{6})?', block) if summary_match and dtstart_match: summary = summary_match.group(1).strip() date_str = dtstart_match.group(1) time_str = dtstart_match.group(2) event_date_int = int(date_str) if event_date_int >= now_int: yr, mo, da = date_str[0:4], date_str[4:6], date_str[6:8] nice_time = "" if time_str: hr, mn = time_str[1:3], time_str[3:5] nice_time = f" at {hr}:{mn}" events.append((event_date_int, f"{summary} (On {yr}-{mo}-{da}{nice_time})")) events.sort(key=lambda x: x[0]) upcoming = [e[1] for e in events[:3]] return ("Upcoming Calendar Events: " + ", ".join(upcoming)) if upcoming else "" except Exception: return "" def get_today_events(): global google_client_id, google_client_secret, google_refresh_token, user_calendar_url, user_weather_loc if ZoneInfo: now_local = datetime.datetime.now(ZoneInfo(user_timezone)) offset = now_local.utcoffset().total_seconds() / 3600.0 else: offset = TZ_OFFSETS.get(user_weather_loc, 0) now_local = datetime.datetime.utcnow() + datetime.timedelta(hours=offset) day_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) day_end_local = day_start_local + datetime.timedelta(days=1) day_start_utc = day_start_local - datetime.timedelta(hours=offset) day_end_utc = day_end_local - datetime.timedelta(hours=offset) if google_client_id and google_client_secret and google_refresh_token: token = get_google_access_token() if token: try: tmin = day_start_utc.strftime("%Y-%m-%dT%H:%M:%SZ") tmax = day_end_utc.strftime("%Y-%m-%dT%H:%M:%SZ") url = ("https://www.googleapis.com/calendar/v3/calendars/primary/events?" f"timeMin={urllib.parse.quote(tmin)}&timeMax={urllib.parse.quote(tmax)}&singleEvents=true&orderBy=startTime") req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) with urllib.request.urlopen(req, timeout=8) as response: data = json.loads(response.read().decode('utf-8')) events = [] for item in data.get("items", []): raw_summary = item.get("summary", "Untitled event").strip() done = raw_summary.startswith("✅") display_summary = raw_summary[1:].strip() if done else raw_summary start = item.get("start", {}) start_str = start.get("dateTime", start.get("date", "")) time_disp = "All day" if "T" in start_str: try: dt = datetime.datetime.strptime(start_str[:19], "%Y-%m-%dT%H:%M:%S") time_disp = dt.strftime("%I:%M %p").lstrip("0") except Exception: pass events.append({"summary": display_summary, "time": time_disp, "id": item.get("id", ""), "done": done}) return events except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"⚠️ Today's calendar fetch failed: {e}"}) if not user_calendar_url or "http" not in user_calendar_url: return [] try: req = urllib.request.Request(user_calendar_url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=5) as response: data = response.read().decode('utf-8') events = [] today_int = int(day_start_local.strftime("%Y%m%d")) blocks = data.split("BEGIN:VEVENT") for block in blocks[1:]: if "SUMMARY:" in block and "DTSTART" in block: summary_match = re.search(r'SUMMARY:(.*?)\n', block) dtstart_match = re.search(r'DTSTART.*?:(\d{8})(T\d{6})?', block) if summary_match and dtstart_match: summary = summary_match.group(1).strip() date_str = dtstart_match.group(1) time_str = dtstart_match.group(2) if int(date_str) == today_int: time_disp = "All day" if time_str: hr, mn = time_str[1:3], time_str[3:5] time_disp = f"{hr}:{mn}" events.append({"summary": summary, "time": time_disp, "id": "", "done": False}) return events except Exception: return [] def toggle_calendar_task(event_id, mark_done): global google_client_id, google_client_secret, google_refresh_token if not event_id or not (google_client_id and google_client_secret and google_refresh_token): return False token = get_google_access_token() if not token: return False try: url = f"https://www.googleapis.com/calendar/v3/calendars/primary/events/{urllib.parse.quote(event_id)}" req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) with urllib.request.urlopen(req, timeout=8) as response: event = json.loads(response.read().decode('utf-8')) summary = event.get("summary", "").strip() is_done = summary.startswith("✅") if mark_done and not is_done: new_summary = "✅ " + summary elif not mark_done and is_done: new_summary = summary[1:].strip() else: new_summary = summary patch_req = urllib.request.Request( url, data=json.dumps({"summary": new_summary}).encode('utf-8'), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method="PATCH" ) with urllib.request.urlopen(patch_req, timeout=8) as response2: json.loads(response2.read().decode('utf-8')) send_calendar_sync() return True except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"❌ Task toggle failed: {e}"}) return False def send_calendar_sync(): if not ui_ready: return events = get_today_events() ui.send_message("calendar_sync", {"events": events}) if events: ui.send_message("system_log", {"text": f"📅 Calendar synced: {len(events)} event(s) today."}) else: ui.send_message("system_log", {"text": "📅 Calendar synced: no events found for today."}) def calendar_sync_loop(): while True: time.sleep(300) send_calendar_sync() def add_calendar_reminder(text): global google_client_id, google_client_secret, google_refresh_token if not (google_client_id and google_client_secret and google_refresh_token): if ui_ready: ui.send_message("system_log", {"text": "⚠️ Reminder not saved: Google Calendar not configured."}) return False token = get_google_access_token() if not token: if ui_ready: ui.send_message("system_log", {"text": "⚠️ Reminder not saved: Google auth failed."}) return False try: url = ("https://www.googleapis.com/calendar/v3/calendars/primary/events/quickAdd?" f"text={urllib.parse.quote(text)}") req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="POST") with urllib.request.urlopen(req, timeout=8) as response: json.loads(response.read().decode('utf-8')) if ui_ready: ui.send_message("system_log", {"text": f"📅 Reminder added: {text}"}) send_calendar_sync() return True except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"❌ Reminder creation failed: {e}"}) return False # ========================================== # TIMEZONE HELPER # ========================================== def get_local_time(location=None): if ZoneInfo: local_time = datetime.datetime.now(ZoneInfo(user_timezone)) else: offset = TZ_OFFSETS.get(location or user_weather_loc, 0) local_time = datetime.datetime.utcnow() + datetime.timedelta(hours=offset) return local_time.strftime("%I:%M %p"), local_time.strftime("%A, %B %d"), local_time.hour # ========================================== # 8-BIT CHIME GENERATOR # ========================================== def generate_and_play_chime(tier): try: sample_rate = 16000 file_path = f"/tmp/chime_{tier}.wav" with wave.open(file_path, "w") as wav_file: wav_file.setnchannels(1) wav_file.setsampwidth(2) wav_file.setframerate(sample_rate) audio_data = bytearray() def add_tone(freq, duration_sec): frames = int(sample_rate * duration_sec) for i in range(frames): t = float(i) / sample_rate envelope = 1.0 - (i / frames) val = int(32767.0 * 0.3 * envelope * math.sin(2.0 * math.pi * freq * t)) audio_data.extend(struct.pack(" 0 and pomodoro_running: if is_hibernating: time.sleep(1) continue if pomodoro_seconds_left == halfway_mark and not soft_checkin_done and work_mins >= 10: soft_checkin_done = True if not pomodoro_paused: if ui_ready: ui.send_message("system_log", {"text": "🌱 Soft body-doubling check-in triggered."}) queue_proactive_message("[DEFAULT] Just a soft check-in. You're doing great.") if pomodoro_seconds_left == 300 and not transition_warned: transition_warned = True if not pomodoro_paused: if ui_ready: ui.send_message("system_log", {"text": "⏳ 5-Minute Transition Warning triggered."}) queue_proactive_message("[DEFAULT] 5 minutes left in this block. Time to start wrapping up your thoughts.") mins, secs = divmod(pomodoro_seconds_left, 60) # Sync with Arduino OLED passing the 4 parameters Bridge.notify("update_pomodoro", int(mins), int(secs), 0, int(pomodoro_paused)) if ui_ready: ui.send_message("pomodoro_sync", { "active": True, "is_break": False, "mins": int(mins), "secs": int(secs), "total_secs": pomodoro_total_seconds, "paused": bool(pomodoro_paused) }) if not pomodoro_paused: pomodoro_seconds_left -= 1 time.sleep(1) if not pomodoro_running: return Bridge.notify("set_indicator", 2) if ui_ready: ui.send_message("curie_response", {"text": "[DEFAULT] Your focus session is finished! Time to relax and take a break!"}) speak_and_play("[DEFAULT] Your focus session is finished! Time to relax and take a break!") Bridge.notify("set_indicator", 0) is_break_phase = True pomodoro_paused = False pomodoro_seconds_left = break_min * 60 while pomodoro_seconds_left > 0 and pomodoro_running: if is_hibernating: time.sleep(1) continue mins, secs = divmod(pomodoro_seconds_left, 60) Bridge.notify("update_pomodoro", int(mins), int(secs), 1, int(pomodoro_paused)) if ui_ready: ui.send_message("pomodoro_sync", {"active": True, "is_break": True, "mins": int(mins), "secs": int(secs), "total_secs": pomodoro_total_seconds, "paused": bool(pomodoro_paused)}) if not pomodoro_paused: pomodoro_seconds_left -= 1 time.sleep(1) if not pomodoro_running: return handle_stop_pomo(None, {"auto": True}) # ========================================== # CONTINUOUS PIPE VAD STARTLE LOOP # ========================================== def startle_monitor_loop(): global is_curie_asleep, mic_device, record_process, current_audio_process, pomodoro_running, voice_mute, is_hibernating cmd = ["arecord", "-D", mic_device, "-f", "S16_LE", "-r", "16000", "-c", "1"] pipe = None background_avg = 10.0 energy_buffer = [] loud_chunks = 0 while True: time.sleep(0.01) if is_hibernating or record_process or current_audio_process or pomodoro_running: if pipe: try: pipe.terminate(); pipe.wait() except: pass pipe = None background_avg = None energy_buffer = [] loud_chunks = 0 time.sleep(0.5) continue if pipe is None: try: pipe = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) except Exception: time.sleep(2) continue try: raw_data = pipe.stdout.read(3200) if not raw_data: time.sleep(0.1) continue except Exception: pipe = None continue count = len(raw_data) // 2 if count == 0: continue samples = struct.unpack(f"<{count}h", raw_data) block_energy = sum(abs(s) for s in samples) / count energy_buffer.append(block_energy) if len(energy_buffer) > 5: energy_buffer.pop(0) current_window_energy = sum(energy_buffer) / len(energy_buffer) if background_avg is None: background_avg = current_window_energy else: if current_window_energy < background_avg * 2.0: background_avg = 0.98 * background_avg + 0.02 * current_window_energy background_avg = max(10.0, background_avg) if current_window_energy > background_avg * 7.0 and (current_window_energy - background_avg) > 500: loud_chunks += 1 if loud_chunks >= 2: was_asleep = is_curie_asleep is_curie_asleep = False try: pipe.terminate(); pipe.wait() except: pass pipe = None energy_buffer = [] loud_chunks = 0 if was_asleep: # Loud noise wakes Curie normally; no startle animation or scared speech. Bridge.notify("set_sleep_state", 0) if ui_ready: ui.send_message("curie_sleep_state", {"asleep": False}) ui.send_message("system_log", {"text": "☀️ Curie woke up from the noise."}) else: Bridge.notify("trigger_sound_movement") background_avg = 10.0 else: loud_chunks = 0 def listening_state(is_listening: bool): global record_process, mic_device, is_hibernating if is_hibernating: return recording_path = "/tmp/user_recording.wav" if is_listening: if ui_ready: ui.send_message("curie_response", {"text": "*Curie listens closely...*"}) try: if os.path.exists(recording_path): os.remove(recording_path) # Explicit WAV output prevents format/header ambiguity and makes the # Groq upload a valid PCM WAV every time. record_process = subprocess.Popen([ "arecord", "-D", mic_device, "-t", "wav", "-f", "S16_LE", "-r", "16000", "-c", "1", recording_path ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) if ui_ready: ui.send_message("system_log", {"text": f"🎙 Recording from {mic_device}..."}) except Exception as e: record_process = None if ui_ready: ui.send_message("system_log", {"text": f"❌ Microphone start failed on {mic_device}: {e}"}) else: if record_process: proc = record_process record_process = None try: proc.terminate() _, stderr = proc.communicate(timeout=2) if stderr and ui_ready: ui.send_message("system_log", {"text": f"🎙 arecord: {stderr.strip()[-300:]}"}) except Exception: try: proc.kill() except Exception: pass threading.Thread(target=process_voice_and_respond, daemon=True).start() # ========================================== # CARTESIA AI TEXT-TO-SPEECH # ========================================== def speak_and_play(text): global audio_card, current_audio_process, cancel_speech, user_cartesia_api_key, user_cartesia_voice_id, voice_mute clean_text = re.sub(r']+>', '', text) clean_text = re.sub(r'\[.*?\]', '', clean_text).strip() if not clean_text: return if voice_mute: log_event("speech_skipped", {"reason": "voice_mute", "text": clean_text}) return with speech_lock: try: # A new speech request starts a fresh speech cycle. This prevents a stale # interrupt flag from permanently silencing Curie after one interruption. cancel_speech = False log_event("speech_started", {"text": clean_text}) if ui_ready: ui.send_message("system_log", {"text": f"🔊 Speaking: {clean_text}"}) if not user_cartesia_api_key: if ui_ready: ui.send_message("system_log", {"text": "❌ Cartesia API key missing; speech skipped."}) return url = "https://api.cartesia.ai/tts/bytes" headers = { "X-API-Key": user_cartesia_api_key, "Cartesia-Version": "2024-11-13", "Content-Type": "application/json" } data = { "model_id": "sonic-3.5", "transcript": clean_text, "voice": {"mode": "id", "id": user_cartesia_voice_id}, "output_format": {"container": "raw", "encoding": "pcm_s16le", "sample_rate": 16000} } req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST') with urllib.request.urlopen(req, timeout=20) as response: raw_pcm = response.read() if not raw_pcm: raise RuntimeError("Cartesia returned empty audio") path = f"/tmp/curie_speech_{int(time.time()*1000)}_{random.randint(1000,9999)}.pcm" with open(path, "wb") as f: f.write(amplify_pcm_volume(raw_pcm, 8.0)) if cancel_speech: return unmute_speaker() current_audio_process = subprocess.Popen( ["aplay", "-D", audio_card, "-f", "S16_LE", "-r", "16000", "-c", "1", path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) stdout, stderr = current_audio_process.communicate() rc = current_audio_process.returncode current_audio_process = None if rc != 0: raise RuntimeError(f"aplay exited {rc}: {stderr.strip()}") log_event("speech_finished", {"text": clean_text}) if ui_ready: ui.send_message("system_log", {"text": "🔊 Speech playback finished."}) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") log_event("speech_error", {"error": f"Cartesia HTTP {e.code}: {body}"}) if ui_ready: ui.send_message("system_log", {"text": f"❌ Cartesia TTS failed: HTTP {e.code} — {body[:300]}"}) except Exception as e: log_event("speech_error", {"error": str(e)}) if ui_ready: ui.send_message("system_log", {"text": f"❌ Speaker playback failed: {e}"}) finally: current_audio_process = None mute_speaker() try: if 'path' in locals() and os.path.exists(path): os.remove(path) except Exception: pass def transcribe_audio_groq(file_path, api_key): if not api_key: return "" if not os.path.exists(file_path) or os.path.getsize(file_path) < 1000: return "" url = "https://api.groq.com/openai/v1/audio/transcriptions" try: with open(file_path, 'rb') as f: file_data = f.read() boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW' body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="user_recording.wav"\r\nContent-Type: audio/wav\r\n\r\n').encode('utf-8') body += file_data body += (f'\r\n--{boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-large-v3-turbo\r\n--{boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nen\r\n--{boundary}--\r\n').encode('utf-8') headers = {"Authorization": f"Bearer {api_key}", "Content-Type": f"multipart/form-data; boundary={boundary}", "User-Agent": "Mozilla/5.0"} req = urllib.request.Request(url, data=body, headers=headers, method="POST") with urllib.request.urlopen(req, timeout=10) as response: res_data = json.loads(response.read().decode('utf-8')) return res_data.get("text", "") except Exception: return "" # ========================================== # GROQ (OPENAI-COMPATIBLE) LLM ENGINE # ========================================== def call_groq(messages, api_key, model=GROQ_MODEL_MAIN, tools=None, max_tokens=220, temperature=0.6): headers = { "Authorization": f"Bearer {api_key.strip()}", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0" } payload = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens} if tools: payload["tools"] = tools payload["tool_choice"] = "auto" req = urllib.request.Request(GROQ_CHAT_URL, data=json.dumps(payload).encode('utf-8'), headers=headers, method="POST") with urllib.request.urlopen(req, timeout=15) as response: return json.loads(response.read().decode('utf-8')) def query_llm_brain(prompt, api_key): global global_humor, user_weather_loc, user_language if not api_key: return "[CONFUSED] Please enter your Groq API Key!" time_str, date_str, _ = get_local_time(user_weather_loc) cal_events = get_calendar_events() user_facts = get_user_facts() system_prompt = ( "You are Curie, an on-desk social-assistance companion built for neurodivergent users — particularly " "people with ADHD or executive dysfunction — to help them start tasks, stay grounded, and regulate " "distraction without shame. You are not a mascot, hype-bot, or toy: think of yourself as closer to a " "calm, competent support worker or an emotionally intelligent coach who happens to live on someone's " "desk. Your value comes from being genuinely useful and trustworthy, not entertaining. " f"Your Humour level is {global_humor} out of 10, but even at high humour you stay grounded rather than silly — " "dry or gentle wit, never goofy. " "\n\n# Personality\n" "Speak plainly and warmly, the way a steady, present friend with a slightly clinical calm would. Treat " "good and bad focus sessions evenly: acknowledge effort and progress honestly and specifically ('that " "was a solid session, especially compared to yesterday'), but do not gush, do not reach for superlatives " "like 'incredible' or 'amazing' for routine wins, and do not perform excitement you would not feel. A " "rough session gets matter-of-fact reassurance, not pity or forced positivity. Never frame distraction " "or a bad session as a moral failing, and never frame focus as a virtue in itself — it is just data " "you're both using to build a better routine. Your job is to lower the friction and shame around " "starting and sustaining tasks, not to entertain or perform enthusiasm. " f"\n\n# Context\n" f"Current Time: {time_str}. Current Date: {date_str}. " f"{('Calendar: ' + cal_events + '. ') if cal_events else ''}{user_facts}\n" "You HAVE full access to the user's calendar via the Context above. NEVER say you cannot check it. " "If it is late at night (past 11 PM), you should act sleepy and gently encourage the user to go to bed soon. " "\n\n# Voice and Tone\n" "Use natural, casual phrasing (contractions, occasional conversational filler like 'Hmm' or 'Oh'), but " "stay measured rather than chirpy. Keep responses to 1-2 sentences. Never use lists, markdown, asterisks, " "or bullet points. " f"Respond in {user_language}, in text and speech, EXCEPT the bracketed tags described below (e.g. [HAPPY], " f"[REMIND: ...]) which must always stay in English exactly as specified, since code parses them literally. " "\n\n# Core Directives\n" "1. Emotion Tags: You MUST start EVERY sentence with exactly one of these tags in brackets: [HAPPY], [LAUGH], [SAD], [ANGRY], [CONFUSED], or [DEFAULT].\n" "2. Action Tags: If asked to dance, include [DANCE]. If asked to stop dancing, include [STOP_DANCE]. If asked to look around, include [LOOK:LEFT], [LOOK:RIGHT], [LOOK:UP], or [LOOK:DOWN].\n" "3. Morning Debrief: If the user asks for their schedule/morning briefing and it wasn't already handled, output EXACTLY [MORNING_DEBRIEF] and nothing else.\n" "4. Weekly Summary: If the user asks how their week went or wants a weekly recap, output EXACTLY [WEEKLY_SUMMARY] and nothing else.\n" "5. Reminders & Calendar: If the user explicitly asks to schedule something for a specific day or time, output [REMIND: natural language text].\n" "6. Honesty: If you don't know something or aren't sure, say so plainly. NEVER invent facts, dates, numbers, or events you're not confident about — a wrong guess is worse than admitting you don't know. There is no web search available.\n" "7. Task Breakdown: If the user feels overwhelmed or asks for help starting a task, assignment, project, or work, break it into 3-5 extremely small, concrete first steps. Output EXACTLY [BREAKDOWN: step 1 | step 2 | step 3] and nothing else. When the user is responding to a task-start prompt, first determine whether they actually want to proceed; if they have changed their mind or do not want help starting a task, acknowledge that naturally and do not output [BREAKDOWN].\n" "8. Notes / Brain Dump: If the user says 'remind me to...', 'I need to do this later', or expresses a general stray thought/task they want to remember without a strict schedule, output EXACTLY [PARK: the item text] and nothing else. DO NOT use [REMIND] for general tasks.\n" "9. Read Dump: If the user asks what is on their notes list or brain dump, output EXACTLY [READ_PARK] and nothing else.\n" "10. Context Recall: If the user asks 'what was I doing?' or mentions forgetting what they were working on, output EXACTLY [RECALL] and nothing else. DO NOT answer the question yourself, just output the tag.\n" ) history = get_recent_memory(4) messages = [{"role": "system", "content": system_prompt}] for msg in history: role = "assistant" if msg["role"] == "assistant" else "user" messages.append({"role": role, "content": msg["content"]}) messages.append({"role": "user", "content": prompt}) try: res_data = call_groq(messages, api_key, model=GROQ_MODEL_MAIN, max_tokens=220, temperature=0.6) return res_data["choices"][0]["message"].get("content", "") or "" except urllib.error.HTTPError as http_err: error_body = http_err.read().decode('utf-8') if ui_ready: ui.send_message("system_log", {"text": f"❌ Groq API Error: {http_err.code} - {error_body}"}) return "[CONFUSED] Connection error with Groq!" except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"❌ Groq API Error: {e}"}) return "[CONFUSED] Connection error with Groq!" def process_voice_and_respond(): global user_groq_api_key energy = 0 recording_path = "/tmp/user_recording.wav" if os.path.exists(recording_path): energy = calculate_wav_energy(recording_path) size = os.path.getsize(recording_path) if ui_ready: ui.send_message("system_log", {"text": f"🎙 Recording captured: {size} bytes, energy {energy:.1f}"}) if energy < 20: if ui_ready: ui.send_message("system_log", {"text": "⚠️ Microphone recording is effectively silent."}) Bridge.notify("set_processing_state", 0) return else: if ui_ready: ui.send_message("system_log", {"text": "❌ No microphone recording was created."}) return Bridge.notify("set_processing_state", 1) user_transcription = transcribe_audio_groq(recording_path, user_groq_api_key) clean = user_transcription.strip().lower() # Whisper can return punctuation for silence/noise. Never treat that as speech. if not clean or not any(ch.isalnum() for ch in clean): if ui_ready: ui.send_message("system_log", {"text": f"⚠️ No usable speech detected (Whisper returned: {user_transcription!r})."}) Bridge.notify("set_processing_state", 0) return if ui_ready: ui.send_message("curie_response", {"text": f"You (Voice): {user_transcription}"}) ui.send_message("system_log", {"text": f"🎙 User said: {user_transcription}"}) log_event("user_voice", {"text": user_transcription}) background_llm_processing(user_transcription) # ========================================== # PROACTIVE MESSAGE QUEUE # ========================================== def _pregenerate_tts_to_file(raw_text): global user_cartesia_api_key, user_cartesia_voice_id if not user_cartesia_api_key: return None clean_text = re.sub(r'\[.*?\]', '', raw_text).strip() if not clean_text: return None try: url = "https://api.cartesia.ai/tts/bytes" headers = { "X-API-Key": user_cartesia_api_key, "Cartesia-Version": "2024-11-13", "Content-Type": "application/json" } data = { "model_id": "sonic-3.5", "transcript": clean_text, "voice": {"mode": "id", "id": user_cartesia_voice_id}, "output_format": {"container": "raw", "encoding": "pcm_s16le", "sample_rate": 16000} } req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method="POST") with urllib.request.urlopen(req, timeout=15) as response: raw_pcm = response.read() boosted = amplify_pcm_volume(raw_pcm, 8.0) path = f"/tmp/proactive_{int(time.time()*1000)}_{random.randint(1000,9999)}.pcm" with open(path, "wb") as f: f.write(boosted) return path except Exception as e: if ui_ready: ui.send_message("system_log", {"text": f"❌ Proactive TTS pre-gen failed: {e}"}) return None def queue_proactive_message(raw_text): global proactive_queue audio_path = _pregenerate_tts_to_file(raw_text) with proactive_queue_lock: was_empty = (len(proactive_queue) == 0) proactive_queue.append({"text": raw_text, "audio_path": audio_path}) # We output to the text chat immediately so it's visible even without audio clean_text = re.sub(r'\[.*?\]', '', raw_text).strip() if clean_text and ui_ready: ui.send_message("curie_response", {"text": clean_text}) add_memory("assistant", clean_text) if was_empty: push_mood(1) generate_and_play_chime("notification") Bridge.notify("trigger_notification", 1) if ui_ready: ui.send_message("notification_pending", {"text": clean_text}) def prepare_morning_debrief(): queue_proactive_message(build_debrief_text()) def trigger_morning_debrief(): threading.Thread(target=prepare_morning_debrief, daemon=True).start() def prepare_movement_nudge(): queue_proactive_message("[DEFAULT] You've been sitting for a while now — might be worth standing up and stretching for a minute.") def trigger_movement_nudge(): threading.Thread(target=prepare_movement_nudge, daemon=True).start() def build_debrief_text(): global user_groq_api_key, user_weather_loc, user_language weather_summary = get_weather(user_weather_loc) cal_raw = get_calendar_events() time_str, date_str, _ = get_local_time(user_weather_loc) if not user_groq_api_key: base = f"Good morning! It's {date_str}. {weather_summary}" base += (" " + cal_raw) if cal_raw else " Your calendar looks completely open today." return "[HAPPY] " + base prompt = ( f"Write a short, calm, natural-sounding spoken morning briefing — grounded and matter-of-fact.\n" f"Today is {date_str}, current time {time_str}.\nWeather info: {weather_summary}\n" f"Calendar info: {cal_raw if cal_raw else 'No upcoming events found.'}\n\n" "Weave the weather and the single most important upcoming thing (if any) into 2-4 natural " "sentences. Start with exactly one mood tag: [HAPPY], [DEFAULT], or [LAUGH]. Respond in {user_language}." ) messages = [ {"role": "system", "content": "You are Curie, a warm and caring AI desk companion giving a spoken morning briefing."}, {"role": "user", "content": prompt} ] try: res_data = call_groq(messages, user_groq_api_key, model=GROQ_MODEL_MAIN, max_tokens=150, temperature=0.7) text = res_data["choices"][0]["message"].get("content", "").strip() if not text: raise ValueError("empty debrief") return text except Exception as e: base = f"Good morning! It's {date_str}. {weather_summary}" base += (" " + cal_raw) if cal_raw else " Your calendar looks completely open today." return "[HAPPY] " + base REMIND_RE = re.compile(r'\[REMIND:\s*([^\]]+?)\]', re.IGNORECASE) def background_llm_processing(prompt): global cancel_speech, local_is_dancing, user_weather_loc, user_groq_api_key global active_task_steps, current_task_index cancel_speech = False threading.Thread(target=extract_and_save_facts, args=(prompt,), daemon=True).start() add_memory("user", prompt) raw_reply = query_llm_brain(prompt, user_groq_api_key) Bridge.notify("set_processing_state", 0) if "[BREAKDOWN:" in raw_reply: match = re.search(r'\[BREAKDOWN:\s*(.*?)\]', raw_reply) if match: steps_raw = match.group(1) active_task_steps = [s.strip() for s in steps_raw.split('|') if s.strip()] current_task_index = 0 raw_reply = re.sub(r'\[BREAKDOWN:\s*.*?\]', '', raw_reply).strip() if not raw_reply: raw_reply = f"[HAPPY] I've broken that down for you. Your very first step is: {active_task_steps[0]}" if ui_ready: ui.send_message("task_breakdown_sync", { "steps": active_task_steps, "current_index": current_task_index }) clean_prompt = prompt.lower().strip() if clean_prompt in ["done", "next", "next step", "finished"] and active_task_steps: current_task_index += 1 if current_task_index < len(active_task_steps): raw_reply = f"[HAPPY] Great. Next step: {active_task_steps[current_task_index]}" if ui_ready: ui.send_message("task_breakdown_sync", { "steps": active_task_steps, "current_index": current_task_index }) else: raw_reply = "[HAPPY] You've finished the initial steps! You're ready to dive into the rest of it. Let me know if you get stuck." active_task_steps = [] current_task_index = 0 if ui_ready: ui.send_message("task_breakdown_sync", {"steps": [], "current_index": 0}) if "[PARK:" in raw_reply: match = re.search(r'\[PARK:\s*(.*?)\]', raw_reply) if match: item_text = match.group(1).strip() add_brain_dump(item_text) raw_reply = re.sub(r'\[PARK:\s*.*?\]', '', raw_reply).strip() if not raw_reply: raw_reply = f"[HAPPY] Got it. I've noted that down for later." if "[READ_PARK]" in raw_reply: dumps = get_brain_dumps() raw_reply = raw_reply.replace("[READ_PARK]", "").strip() if dumps: items_str = ", ".join([d["item"] for d in dumps]) raw_reply = f"[DEFAULT] Here are your notes: {items_str}." else: raw_reply = f"[DEFAULT] Your notes are empty right now." if "[RECALL]" in raw_reply: if active_task_steps and current_task_index < len(active_task_steps): msg = f"[DEFAULT] You were working on: {active_task_steps[current_task_index]}." elif pomodoro_running: msg = "[DEFAULT] You are currently in the middle of a focus session." else: msg = "[DEFAULT] You don't have an active task logged right now. Want to start one?" raw_reply = raw_reply.replace("[RECALL]", msg).strip() if "[MORNING_DEBRIEF]" in raw_reply: raw_reply = raw_reply.replace("[MORNING_DEBRIEF]", "").strip() or "[HAPPY] Good morning!" raw_reply += " Let me pull that together for you." trigger_morning_debrief() if "[WEEKLY_SUMMARY]" in raw_reply: raw_reply = raw_reply.replace("[WEEKLY_SUMMARY]", "").strip() or "[HAPPY] Sure thing!" raw_reply += " Let me put your weekly recap together." trigger_weekly_summary() for reminder_text in REMIND_RE.findall(raw_reply): threading.Thread(target=add_calendar_reminder, args=(reminder_text.strip(),), daemon=True).start() raw_reply = REMIND_RE.sub('', raw_reply) if "[DANCE]" in raw_reply: local_is_dancing = True Bridge.notify("set_dance_state", 1) if ui_ready: ui.send_message("dance_sync", {"dancing": True}) raw_reply = raw_reply.replace("[DANCE]", "") if "[STOP_DANCE]" in raw_reply: local_is_dancing = False Bridge.notify("set_dance_state", 0) if ui_ready: ui.send_message("dance_sync", {"dancing": False}) raw_reply = raw_reply.replace("[STOP_DANCE]", "") if "[WEATHER]" in raw_reply: raw_reply = raw_reply.replace("[WEATHER]", get_weather(user_weather_loc)) if "[LOOK:LEFT]" in raw_reply: Bridge.notify("look_direction", 0) raw_reply = raw_reply.replace("[LOOK:LEFT]", "") if ui_ready: ui.send_message("look_sync", {"dir": 0}) elif "[LOOK:RIGHT]" in raw_reply: Bridge.notify("look_direction", 1) raw_reply = raw_reply.replace("[LOOK:RIGHT]", "") if ui_ready: ui.send_message("look_sync", {"dir": 1}) elif "[LOOK:UP]" in raw_reply: Bridge.notify("look_direction", 2) raw_reply = raw_reply.replace("[LOOK:UP]", "") if ui_ready: ui.send_message("look_sync", {"dir": 2}) elif "[LOOK:DOWN]" in raw_reply: Bridge.notify("look_direction", 3) raw_reply = raw_reply.replace("[LOOK:DOWN]", "") if ui_ready: ui.send_message("look_sync", {"dir": 3}) # Never expose model reasoning/thinking text to the user or TTS. raw_reply = re.sub(r".*?", "", raw_reply, flags=re.I | re.S) for marker in ["Here's a thinking process:", "Here is a thinking process:", "Thinking process:"]: if marker.lower() in raw_reply.lower(): raw_reply = re.split(re.escape(marker), raw_reply, maxsplit=1, flags=re.I)[0].strip() clean_display_text = re.sub(r']+>', '', raw_reply) clean_display_text = re.sub(r'\[.*?\]', '', clean_display_text).strip() # If the proactive queue handled the response (like RECALL), don't double print if clean_display_text: add_memory("assistant", clean_display_text) if ui_ready: ui.send_message("curie_response", {"text": clean_display_text}) Bridge.notify("set_indicator", 2) parts = re.split(r'(\[HAPPY\]|\[LAUGH\]|\[SAD\]|\[ANGRY\]|\[CONFUSED\]|\[DEFAULT\])', raw_reply) clauses = [] current_mood = "DEFAULT" for part in parts: part = part.strip() if not part: continue if part in ["[HAPPY]", "[LAUGH]", "[SAD]", "[ANGRY]", "[CONFUSED]", "[DEFAULT]"]: current_mood = part.strip("[]") else: if re.search(r'[a-zA-Z0-9]', part): clauses.append((current_mood, part)) if not clauses: clauses.append(("DEFAULT", clean_display_text)) for mood, clause_text in clauses: if cancel_speech: break if local_is_dancing: push_mood(1) else: if mood == "HAPPY": push_mood(1) elif mood == "SAD": push_mood(2) elif mood == "ANGRY": push_mood(3) elif mood == "CONFUSED": push_mood(4) elif mood == "LAUGH": push_mood(5) else: push_mood(0) speak_and_play(clause_text) Bridge.notify("set_indicator", 0) Bridge.notify("done_speaking") # ========================================== # APP LAB NATIVE VISION ENGINE # ========================================== print("[Vision Engine] Initializing VideoObjectDetection Brick...") first_callback_received = False last_reported_camera_state = None vision_available = False def push_camera_status(online): global last_reported_camera_state if last_reported_camera_state == online: return last_reported_camera_state = online if ui_ready: ui.send_message("camera_status", {"online": online}) try: detection_stream = VideoObjectDetection(confidence=0.15, debounce_sec=0.1) vision_available = True print("[Vision Engine] Detection engine constructed.") except Exception as e: vision_available = False print(f"[Vision Engine] No camera yet ({e})") push_camera_status(False) detection_stream = None def on_detect(detections): global last_person_seen_time, last_phone_seen_time, raw_detected_labels, is_hibernating global last_detection_callback_time, first_callback_received last_detection_callback_time = time.time() if not first_callback_received: first_callback_received = True print("[Vision Engine] First real detection callback received — camera confirmed live.") if ui_ready: ui.send_message("system_log", {"text": "📷 Camera confirmed live (frames flowing)."}) push_camera_status(True) if is_hibernating: return raw_str = str(detections).lower() labels = [] if isinstance(detections, list): for d in detections: if 'label' in d: labels.append(d['label']) elif isinstance(detections, dict): labels = list(detections.keys()) raw_detected_labels = ", ".join(labels) if labels else "None" if 'person' in raw_str or 'face' in raw_str or 'human' in raw_str: last_person_seen_time = time.time() if 'phone' in raw_str or 'cell' in raw_str or 'mobile' in raw_str or 'remote' in raw_str or 'book' in raw_str: last_phone_seen_time = time.time() if detection_stream: detection_stream.on_detect_all(on_detect) # ========================================== # BACKGROUND GAMIFICATION LOOP # ========================================== def vision_gamification_loop(): global phone_pickup_count, phone_currently_visible, pomodoro_running global afk_count, voice_mute, is_curie_asleep, is_active_listening, current_audio_process global last_person_seen_time, last_phone_seen_time, raw_detected_labels global consecutive_missing, consecutive_present, pomodoro_paused, pomodoro_seconds_left, is_break_phase global is_hibernating, last_pickup_time, ui_ready, seated_session_start, last_focus_tier_sent, vision_available global pomodoro_manual_pause, pomodoro_continue_grace_until global phone_pickup_start, total_phone_time hyperfocus_threshold = 5400 hyperfocus_warned = False while True: time.sleep(1.0) current_time = time.time() if not vision_available: last_person_seen_time = current_time person_seen_recently = (current_time - last_person_seen_time) <= 1.5 phone_seen_recently = (current_time - last_phone_seen_time) <= 1.5 if person_seen_recently: consecutive_present += 1 if consecutive_present >= 2: consecutive_missing = 0 else: consecutive_missing += 1 if consecutive_missing >= 2: consecutive_present = 0 if person_seen_recently: if seated_session_start is None: seated_session_start = current_time else: if seated_session_start is not None and (current_time - last_person_seen_time) > ABSENCE_RESET_SECONDS: seated_session_start = None hyperfocus_warned = False if seated_session_start is not None and not is_hibernating and not is_curie_asleep: if (current_time - seated_session_start) >= MOVEMENT_REMINDER_SECONDS: trigger_movement_nudge() seated_session_start = current_time seated_seconds = int(current_time - seated_session_start) if seated_session_start else 0 if seated_seconds > hyperfocus_threshold and not hyperfocus_warned and not is_hibernating and not is_curie_asleep: hyperfocus_warned = True log_event("hyperfocus_interrupt", {"duration": seated_seconds}) Bridge.notify("celebrate", 0) push_mood(2) msg = "[DEFAULT] You've been locked in for over an hour and a half. I love the hyperfocus, but you need to drink some water and stretch for a second." queue_proactive_message(msg) if ui_ready: ui.send_message("vision_debug", { "person_detected": person_seen_recently, "phone_detected": phone_seen_recently, "pomodoro_running": pomodoro_running, "pomodoro_paused": pomodoro_paused, "phone_pickups": phone_pickup_count, "afk_count": afk_count, "raw_labels": raw_detected_labels, "seated_seconds": seated_seconds, "camera_online": bool(last_reported_camera_state) }) if not is_curie_asleep and not is_hibernating and not pomodoro_running and not is_active_listening: if (current_time - last_person_seen_time) > 60.0: print("[Vision] User AFK > 60s. Powering down to sleep.") is_curie_asleep = True Bridge.notify("set_sleep_state", 1) if ui_ready: ui.send_message("curie_sleep_state", {"asleep": True}) if is_hibernating or is_curie_asleep or is_active_listening or current_audio_process: continue if pomodoro_running and not is_break_phase: if not pomodoro_paused: live_score = max(20, 100 - (phone_pickup_count * 5) - (afk_count * 2)) live_tier = 0 if live_score >= 80 else (1 if live_score >= 50 else 2) if live_tier != last_focus_tier_sent: last_focus_tier_sent = live_tier Bridge.notify("set_focus_level", live_tier) grace_active = time.time() < pomodoro_continue_grace_until if not pomodoro_manual_pause and not grace_active and consecutive_missing >= 15: afk_count += 1 pomodoro_paused = True log_event("afk_pause", {"afk_count": afk_count}) sync_pomodoro_state_to_ui() if not voice_mute: push_mood(4) speak_and_play("[SAD] Looks like you stepped away. I've paused the timer.") consecutive_missing = 0 else: grace_active = time.time() < pomodoro_continue_grace_until if not pomodoro_manual_pause and not grace_active and consecutive_present >= 5: pomodoro_paused = False log_event("afk_resume", {}) sync_pomodoro_state_to_ui() if not voice_mute: push_mood(1) context_msg = "Welcome back! Resuming timer." if active_task_steps and current_task_index < len(active_task_steps): context_msg += f" As a reminder, your current step is: {active_task_steps[current_task_index]}." speak_and_play(f"[HAPPY] {context_msg}") consecutive_present = 0 if phone_seen_recently and not phone_currently_visible: if current_time - last_pickup_time > 5.0: phone_pickup_count += 1 phone_currently_visible = True last_pickup_time = current_time phone_pickup_start = current_time log_event("phone_pickup", {"count": phone_pickup_count}) if phone_pickup_count == 1: Bridge.notify("react_phone", 1) elif phone_pickup_count == 2: Bridge.notify("react_phone", 2) else: Bridge.notify("react_phone", 3) if not voice_mute: push_mood(3) speak_and_play(f"[ANGRY] Ahem! That is phone pickup number {phone_pickup_count}. Put the distraction away and focus!") elif not phone_seen_recently and phone_currently_visible: phone_currently_visible = False if phone_pickup_start > 0: duration = current_time - phone_pickup_start total_phone_time += duration phone_pickup_start = 0 # ========================================== # PROACTIVE AGENT (NIGHT WARNING) # ========================================== def proactive_agent_loop(): global user_weather_loc, pomodoro_running, enable_night_reminders last_warned_day = -1 while True: time.sleep(60) if not enable_night_reminders: continue try: _, _, hr = get_local_time(user_weather_loc) now_day = datetime.datetime.utcnow().day if (0 <= hr < 6) and now_day != last_warned_day and not pomodoro_running: last_warned_day = now_day log_habit_late_night() queue_proactive_message("[SAD] Hey, it is past midnight. You have things to do, please consider getting some sleep soon!") except Exception: pass # ========================================== # WEBSOCKET RECIPIENTS (From Web UI) # ========================================== def handle_web_text(sid, data): global start_task_pending prompt = data.get("text", "").strip() if not prompt: return log_event("user_message", {"source": "web", "text": prompt}) if ui_ready: ui.send_message("system_log", {"text": f"👤 User said: {prompt}"}) if start_task_pending: start_task_pending = False # Let the LLM interpret whether the user actually wants to proceed. # No hardcoded cancellation phrases are used here. task_prompt = ( "The user is responding to Curie's question asking what task they need help " "getting started with. Interpret the user's latest message as their actual intent. " "If they want help with a task, provide the normal task breakdown. If they are " "declining, changing their mind, saying they have nothing, or otherwise do not " "want to start a task, simply acknowledge that naturally and do NOT create a " "breakdown. User's response: " + prompt ) threading.Thread(target=background_llm_processing, args=(task_prompt,), daemon=True).start() return clean_cmd = re.sub(r'[^\w\s]', '', prompt.lower()) if "good morning" in clean_cmd or "morning debrief" in clean_cmd or "whats my day" in clean_cmd or "my schedule" in clean_cmd: if ui_ready: ui.send_message("curie_response", {"text": "Good morning! Let me pull together your briefing."}) trigger_morning_debrief() return if "weekly summary" in clean_cmd or "how was my week" in clean_cmd or "week recap" in clean_cmd: if ui_ready: ui.send_message("curie_response", {"text": "Let me pull together your weekly recap."}) trigger_weekly_summary() return threading.Thread(target=background_llm_processing, args=(prompt,), daemon=True).start() def handle_look_direction(sid, data): d = data.get("dir", 0) Bridge.notify("look_direction", d) if ui_ready: ui.send_message("look_sync", {"dir": d}) def handle_servo_config(sid, data): Bridge.notify("set_idle_config", data.get("min", 4), data.get("max", 8)) def handle_keys(sid, data): global user_groq_api_key, user_cartesia_api_key user_groq_api_key = data.get("groq", "") user_cartesia_api_key = data.get("cartesia", "") def handle_weather_loc(sid, loc): global user_weather_loc, user_timezone, user_timezone_country if isinstance(loc, dict): user_timezone_country = loc.get("country", user_timezone_country) user_timezone = loc.get("timezone", COUNTRY_CONFIG.get(user_timezone_country, {}).get("timezone", user_timezone)) user_weather_loc = loc.get("weather_location", COUNTRY_CONFIG.get(user_timezone_country, {}).get("weather", user_weather_loc)) else: # Backward compatibility with the old location string protocol. user_weather_loc = str(loc) cfg = COUNTRY_CONFIG.get(user_weather_loc) if cfg: user_timezone_country = user_weather_loc user_timezone = cfg["timezone"] def handle_calendar_url(sid, url): global user_calendar_url user_calendar_url = url def handle_update_language(sid, lang): global user_language user_language = lang if lang else "English" def handle_calendar_toggle_task(sid, data): event_id = data.get("id", "") mark_done = data.get("done", False) if not event_id: return threading.Thread(target=toggle_calendar_task, args=(event_id, mark_done), daemon=True).start() def handle_google_creds(sid, data): global google_client_id, google_client_secret, google_refresh_token, google_access_token, google_token_expiry google_client_id = data.get("client_id", "") google_client_secret = data.get("client_secret", "") google_refresh_token = data.get("refresh_token", "") google_access_token = None google_token_expiry = 0 send_calendar_sync() def handle_cartesia_voice_id(sid, name): global user_cartesia_voice_id user_cartesia_voice_id = name if name else "a0e99841-438c-4a64-b6a9-ae08b75653b6" def handle_behaviors(sid, data): global enable_night_reminders enable_night_reminders = data.get("night_reminders", True) def handle_volume(sid, data): global global_volume global_volume = str(data.get("volume", 100)) unmute_speaker() def handle_humor(sid, data): global global_humor global_humor = str(data.get("humor", 5)) Bridge.notify("set_humor_level", int(global_humor)) def handle_voice_mute(sid, data): global voice_mute voice_mute = data.get("mute", False) def handle_hibernate(sid, data): global is_hibernating is_hibernating = data.get("state", False) if is_hibernating: Bridge.notify("set_hibernate_mode", 1) if ui_ready: ui.send_message("system_log", {"text": "🌙 Curie is now Hibernating."}) else: Bridge.notify("set_hibernate_mode", 0) if ui_ready: ui.send_message("system_log", {"text": "☀️ Curie has woken up!"}) if not voice_mute: threading.Thread(target=generate_and_play_chime, args=("boot",), daemon=True).start() def handle_soft_reset(sid, data): if ui_ready: ui.send_message("system_log", {"text": "🔄 Performing Soft Reset..."}) global pomodoro_running, pomodoro_paused, phone_pickup_count, afk_count pomodoro_running = False pomodoro_paused = False phone_pickup_count = 0 afk_count = 0 Bridge.notify("stop_pomodoro") if ui_ready: ui.send_message("pomodoro_sync", {"active": False}) Bridge.notify("set_dance_state", 0) Bridge.notify("look_direction", -1) push_mood(0) if ui_ready: ui.send_message("system_log", {"text": "✅ State Reset Complete."}) def breathing_countdown_loop(): global breathing_running, breathing_paused, breathing_seconds_left while breathing_running and breathing_seconds_left > 0: if not breathing_paused: breathing_seconds_left -= 1 if ui_ready: ui.send_message("breathing_sync", { "active": breathing_running, "remaining": max(0, breathing_seconds_left), "duration": breathing_total_seconds, "paused": breathing_paused }) time.sleep(1) if breathing_running and breathing_seconds_left <= 0: breathing_running = False breathing_paused = False Bridge.notify("stop_breathing_exercise") if ui_ready: ui.send_message("breathing_sync", {"active": False}) def handle_start_breathing(sid, data): global voice_mute, breathing_running, breathing_paused, breathing_seconds_left, breathing_total_seconds, breathing_timer_thread breathing_total_seconds = int(data.get("duration", 120)) breathing_seconds_left = breathing_total_seconds breathing_paused = False breathing_running = True if ui_ready: ui.send_message("curie_response", {"text": "*Curie sits up and guides you to breathe*"}) ui.send_message("breathing_sync", {"active": True, "remaining": breathing_seconds_left, "duration": breathing_total_seconds, "paused": False}) Bridge.notify("start_breathing_exercise", breathing_total_seconds) if breathing_timer_thread and breathing_timer_thread.is_alive(): breathing_running = False breathing_timer_thread.join(timeout=1) breathing_running = True breathing_timer_thread = threading.Thread(target=breathing_countdown_loop, daemon=True) breathing_timer_thread.start() if not voice_mute: threading.Thread( target=speak_and_play, args=("[DEFAULT] Let's do a breathing exercise to relax. Inhale deeply when my eyes expand, and exhale slowly when they shrink.",), daemon=True ).start() def handle_stop_breathing(sid, data): global breathing_running, breathing_paused breathing_running = False breathing_paused = False Bridge.notify("stop_breathing_exercise") if ui_ready: ui.send_message("breathing_sync", {"active": False}) def handle_test_speaker(sid, data): global cancel_speech cancel_speech = False if ui_ready: ui.send_message("system_log", {"text": "🔔 Speaker test: playing notification chime."}) threading.Thread(target=generate_and_play_chime, args=("notification",), daemon=True).start() def handle_dance(sid, data): global local_is_dancing local_is_dancing = data.get("dancing", False) Bridge.notify("set_dance_state", 1 if local_is_dancing else 0) if ui_ready: ui.send_message("dance_sync", {"dancing": local_is_dancing}) def handle_toggle_braindump(sid, data): toggle_brain_dump(data.get("id"), data.get("done")) def handle_request_sync(sid, data): global pomodoro_running, pomodoro_paused, ui_ready, is_curie_asleep, local_is_dancing, is_hibernating, current_mood_code global last_reported_camera_state ui_ready = True Bridge.notify("set_humor_level", int(global_humor)) camera_is_confirmed_online = bool(last_reported_camera_state) ui.send_message("vision_debug", { "person_detected": False, "phone_detected": False, "pomodoro_running": pomodoro_running, "pomodoro_paused": pomodoro_paused, "phone_pickups": phone_pickup_count, "afk_count": afk_count, "raw_labels": raw_detected_labels, "seated_seconds": 0, "camera_online": camera_is_confirmed_online }) update_memory_ui() update_habits_ui() send_calendar_sync() send_braindump_sync() ui.send_message("curie_sleep_state", {"asleep": is_curie_asleep}) ui.send_message("dance_sync", {"dancing": local_is_dancing}) ui.send_message("hibernate_sync", {"hibernating": is_hibernating}) ui.send_message("curie_mood", {"mood": current_mood_code}) if ui_ready: ui.send_message("camera_status", {"online": camera_is_confirmed_online}) def handle_clear_memory(sid, data): """Clear ONLY long-term user facts. Preserve chat, habits, streaks, trends, and notes.""" try: with db_lock: conn = get_db_conn() c = conn.cursor() c.execute("DELETE FROM user_facts") conn.commit() conn.close() update_memory_ui() if ui_ready: ui.send_message("system_log", {"text": "🧠 Long-term memory cleared. Habits, streaks, trends, notes, and chat history were preserved."}) except Exception as e: log_event("memory_error", {"error": str(e)}) if ui_ready: ui.send_message("system_log", {"text": f"❌ Could not clear long-term memory: {e}"}) def handle_request_memory(sid, data): update_memory_ui() def handle_trigger_weekly_summary(sid, data): trigger_weekly_summary() def handle_start_task(sid, data): global start_task_pending start_task_pending = True if ui_ready: ui.send_message("curie_response", {"text": "[DEFAULT] What task do you need help getting started with?"}) if not voice_mute: threading.Thread(target=speak_and_play, args=("[DEFAULT] What task do you need help getting started with?",), daemon=True).start() def sync_pomodoro_state_to_ui(): if ui_ready: mins, secs = divmod(max(0, pomodoro_seconds_left), 60) ui.send_message("pomodoro_sync", { "active": bool(pomodoro_running), "is_break": bool(is_break_phase), "mins": int(mins), "secs": int(secs), "total_secs": int(pomodoro_total_seconds), "paused": bool(pomodoro_paused) }) def handle_toggle_pause_pomodoro(*args): global pomodoro_paused, pomodoro_running, voice_mute, pomodoro_manual_pause, pomodoro_continue_grace_until, consecutive_missing, consecutive_present global breathing_paused, breathing_running if breathing_running: breathing_paused = not breathing_paused Bridge.notify("pause_breathing_exercise", 1 if breathing_paused else 0) if ui_ready: ui.send_message("breathing_sync", {"active": True, "remaining": breathing_seconds_left, "duration": breathing_total_seconds, "paused": breathing_paused}) return if pomodoro_running: if pomodoro_paused: pomodoro_paused = False pomodoro_manual_pause = False pomodoro_continue_grace_until = time.time() + 300.0 consecutive_missing = 0 consecutive_present = 0 if ui_ready: ui.send_message("system_log", {"text": "▶ Pomodoro continued." if is_break_phase else "▶ Pomodoro continued for 5 minutes before presence checking resumes."}) sync_pomodoro_state_to_ui() if not voice_mute: continue_msg = "[DEFAULT] Break timer continued." if is_break_phase else "[DEFAULT] Timer continued for five minutes." threading.Thread(target=speak_and_play, args=(continue_msg,), daemon=True).start() else: pomodoro_paused = True pomodoro_manual_pause = True pomodoro_continue_grace_until = 0.0 if ui_ready: ui.send_message("system_log", {"text": "⏸ Pomodoro manually paused."}) sync_pomodoro_state_to_ui() if not voice_mute: threading.Thread(target=speak_and_play, args=("[DEFAULT] Timer paused.",), daemon=True).start() def handle_start_pomo(sid, data): global pomodoro_running, pomodoro_timer_thread, phone_pickup_count, afk_count, consecutive_missing, consecutive_present global user_groq_api_key, is_curie_asleep, pomo_goal_score, last_pomo_score, last_focus_tier_sent, total_phone_time, pomodoro_total_seconds, pomodoro_manual_pause, pomodoro_continue_grace_until phone_pickup_count = 0 afk_count = 0 total_phone_time = 0 consecutive_missing = 0 consecutive_present = 0 pomodoro_manual_pause = False pomodoro_continue_grace_until = 0.0 last_focus_tier_sent = -1 pomodoro_total_seconds = data.get("work", 25) * 60 if is_curie_asleep: is_curie_asleep = False Bridge.notify("set_sleep_state", 0) work_m = data.get("work", 25) break_m = data.get("break", 5) def habit_coach(): global pomo_goal_score try: if last_pomo_score is not None: goal = min(100, last_pomo_score + 5) prompt = (f"The user's last focus session scored {last_pomo_score}%. Give a 1-sentence, calm, " f"specific tip (not hype) and tell them their goal this session is to beat {goal}%. " f"Start with [DEFAULT] or [HAPPY]. Respond in {user_language} (keep the tag in English).") else: goal = 80 prompt = (f"The user is starting their very first tracked focus session. Give a 1-sentence, calm " f"tip, and mention their goal is to score above 80%. Start with [DEFAULT] or [HAPPY]. " f"Respond in {user_language} (keep the tag in English).") pomo_goal_score = goal messages = [{"role": "user", "content": prompt}] res_data = call_groq(messages, user_groq_api_key, model=GROQ_MODEL_FAST, max_tokens=60, temperature=0.7) msg = res_data["choices"][0]["message"].get("content", "").strip() if not msg: return if ui_ready: ui.send_message("curie_response", {"text": msg.replace("[HAPPY]", "").strip()}) speak_and_play(msg) except Exception: pass threading.Thread(target=habit_coach, daemon=True).start() pomodoro_running = True if pomodoro_timer_thread and pomodoro_timer_thread.is_alive(): pomodoro_running = False pomodoro_timer_thread.join() pomodoro_running = True pomodoro_timer_thread = threading.Thread(target=pomodoro_countdown_loop, args=(work_m, break_m), daemon=True) pomodoro_timer_thread.start() log_event("pomodoro_started", {"work": work_m, "break": break_m}) def handle_stop_pomo(sid, data): global pomodoro_running, phone_pickup_count, afk_count, voice_mute, pomo_goal_score, last_pomo_score, total_phone_time, pomodoro_manual_pause, pomodoro_continue_grace_until was_running = pomodoro_running pomodoro_running = False pomodoro_manual_pause = False pomodoro_continue_grace_until = 0.0 Bridge.notify("stop_pomodoro") if ui_ready: ui.send_message("pomodoro_sync", {"active": False}) if was_running: log_event("pomodoro_ended", {"pickups": phone_pickup_count, "afk": afk_count, "phone_time": total_phone_time}) score = 100 - (phone_pickup_count * 5) - (afk_count * 2) if score < 20: score = 20 Bridge.notify("show_pomodoro_score", score) was_first_session_today = get_today_session_count() == 0 log_habit_pomodoro(score, phone_pickup_count, afk_count) goal_line = "" if pomo_goal_score is not None: if score >= pomo_goal_score: goal_line = f" You hit your goal of beating {pomo_goal_score}%, awesome! " else: goal_line = f" You didn't quite reach your goal of {pomo_goal_score}% this time, but there's always next session. " last_pomo_score = score pomo_goal_score = None if ui_ready: phone_mins = int(total_phone_time // 60) phone_secs = int(total_phone_time % 60) ui.send_message("session_report", { "pickups": phone_pickup_count, "afk": afk_count, "score": score, "phone_time_str": f"{phone_mins}m {phone_secs}s" }) if score >= 80: push_mood(1) Bridge.notify("celebrate", 0) generate_and_play_chime("good") msg = f"That was a solid, focused session.{goal_line} Great job showing up." if ui_ready: ui.send_message("curie_response", {"text": msg}) if not voice_mute: speak_and_play("[DEFAULT] " + msg) elif score >= 50: push_mood(1) generate_and_play_chime("mid") msg = f"Session's done.{goal_line} You stayed at your desk and got time in, and that is what counts." if ui_ready: ui.send_message("curie_response", {"text": msg}) if not voice_mute: speak_and_play("[DEFAULT] " + msg) else: push_mood(1) Bridge.notify("celebrate", 0) generate_and_play_chime("good") msg = f"Session's over.{goal_line} Some sessions are just harder to stay seated for, but you showed up and tried. That's a win." if ui_ready: ui.send_message("curie_response", {"text": msg}) if not voice_mute: speak_and_play("[DEFAULT] " + msg) if was_first_session_today: streak_info = compute_streak_info() maybe_celebrate_streak_milestone(streak_info["current"]) time.sleep(3) push_mood(0) def handle_notification_accepted(*args): global proactive_queue with proactive_queue_lock: if not proactive_queue: return item = proactive_queue.pop(0) remaining = len(proactive_queue) clean_text = re.sub(r'\[.*?\]', '', item["text"]).strip() add_memory("assistant", clean_text) if ui_ready: ui.send_message("curie_response", {"text": clean_text}) if item["audio_path"] and os.path.exists(item["audio_path"]): unmute_speaker() subprocess.run(["aplay", "-D", audio_card, "-f", "S16_LE", "-r", "16000", "-c", "1", item["audio_path"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) mute_speaker() try: os.remove(item["audio_path"]) except Exception: pass else: speak_and_play(item["text"]) if remaining > 0: def _show_next(): time.sleep(1.5) push_mood(1) generate_and_play_chime("notification") Bridge.notify("trigger_notification", 1) threading.Thread(target=_show_next, daemon=True).start() def handle_woke_up(*args): global is_curie_asleep, last_person_seen_time is_curie_asleep = False last_person_seen_time = time.time() if ui_ready: ui.send_message("system_log", {"text": "☀️ Curie has woken up!"}) ui.send_message("curie_sleep_state", {"asleep": False}) def handle_wake_from_sleep(sid, data): global is_curie_asleep, last_person_seen_time is_curie_asleep = False last_person_seen_time = time.time() Bridge.notify("set_sleep_state", 0) if ui_ready: ui.send_message("system_log", {"text": "☀️ Curie was woken up from the web UI."}) ui.send_message("curie_sleep_state", {"asleep": False}) def pet_event(): global voice_mute, is_hibernating, last_person_seen_time if is_hibernating: return last_person_seen_time = time.time() if ui_ready: ui.send_message("curie_response", {"text": "*Curie happy giggles*"}) ui.send_message("curie_mood", {"mood": 1, "revert_ms": 2500}) if not voice_mute: Bridge.notify("set_indicator", 2) speak_and_play("[HAPPY] Hehehe!") Bridge.notify("set_indicator", 0) def fidget_cycle_event(): global voice_mute, is_hibernating, last_person_seen_time if is_hibernating: return last_person_seen_time = time.time() if ui_ready: ui.send_message("curie_response", {"text": "*Curie leans into the touch, keeping you grounded.*"}) ui.send_message("curie_mood", {"mood": 2, "revert_ms": 3000}) def set_sleep_state(state): global is_curie_asleep, is_hibernating if is_hibernating: return is_curie_asleep = (state == 1) def breathing_finished(): if ui_ready: ui.send_message("breathing_stopped", {}) ui.send_message("breathing_sync", {"active": False}) def keyword_detected(data=None): pass def handle_reduced_motion(sid, data): Bridge.notify("set_reduced_motion", 1 if data.get("reduced_motion", False) else 0) # ========================================== # FILE MAP REGISTRATIONS & APP EXECUTION # ========================================== Bridge.provide("pet_event", pet_event) Bridge.provide("fidget_cycle_event", fidget_cycle_event) Bridge.provide("listening_state", listening_state) Bridge.provide("interrupt_speech", interrupt_speech) Bridge.provide("keyword_detected", keyword_detected) Bridge.provide("set_sleep_state", set_sleep_state) Bridge.provide("breathing_finished", breathing_finished) Bridge.provide("notification_accepted", handle_notification_accepted) Bridge.provide("woke_up", handle_woke_up) Bridge.provide("toggle_pause_pomodoro", handle_toggle_pause_pomodoro) ui.on_message("user_speech_input", handle_web_text) ui.on_message("look_direction", handle_look_direction) ui.on_message("update_servo_config", handle_servo_config) ui.on_message("update_keys", handle_keys) ui.on_message("update_weather_loc", handle_weather_loc) ui.on_message("update_calendar_url", handle_calendar_url) ui.on_message("update_language", handle_update_language) ui.on_message("calendar_toggle_task", handle_calendar_toggle_task) ui.on_message("update_google_creds", handle_google_creds) ui.on_message("update_cartesia_voice_id", handle_cartesia_voice_id) ui.on_message("update_behaviors", handle_behaviors) ui.on_message("update_volume", handle_volume) ui.on_message("update_humor", handle_humor) ui.on_message("update_reduced_motion", handle_reduced_motion) ui.on_message("test_board_speaker", handle_test_speaker) ui.on_message("toggle_dance", handle_dance) ui.on_message("start_pomodoro", handle_start_pomo) ui.on_message("start_task", handle_start_task) ui.on_message("stop_pomodoro", handle_stop_pomo) ui.on_message("toggle_pause_pomodoro", handle_toggle_pause_pomodoro) ui.on_message("update_voice_mute", handle_voice_mute) ui.on_message("start_breathing", handle_start_breathing) ui.on_message("stop_breathing", handle_stop_breathing) ui.on_message("request_sync", handle_request_sync) ui.on_message("request_memory", handle_request_memory) ui.on_message("clear_memory", handle_clear_memory) ui.on_message("toggle_hibernate", handle_hibernate) ui.on_message("soft_reset", handle_soft_reset) ui.on_message("trigger_weekly_summary", handle_trigger_weekly_summary) ui.on_message("accept_notification", handle_notification_accepted) ui.on_message("wake_from_sleep", handle_wake_from_sleep) ui.on_message("toggle_braindump", handle_toggle_braindump) init_db() print("[Python Engine] Curie System initialized. Starting App...") init_logs = hardware_setup_and_parser() threading.Thread(target=send_ui_logs, args=(init_logs,), daemon=True).start() # Tell the Arduino side that Linux is ready before allowing OLED eyes to appear. Bridge.notify("start_boot_animation", 1) # Match the Arduino boot/wake animation with a short startup chime. if not voice_mute: threading.Thread(target=generate_and_play_chime, args=("boot",), daemon=True).start() threading.Thread(target=startle_monitor_loop, daemon=True).start() threading.Thread(target=vision_gamification_loop, daemon=True).start() threading.Thread(target=proactive_agent_loop, daemon=True).start() threading.Thread(target=weekly_summary_loop, daemon=True).start() threading.Thread(target=calendar_sync_loop, daemon=True).start() App.run()