acorn / app.py
tatermohit's picture
Upload app.py
c4249ed verified
Raw
History Blame Contribute Delete
83.2 kB
from __future__ import annotations
# Acorn single-file build (auto-generated)
# ===== llm =====
"""Tiny client for a LOCAL, OpenAI-compatible model endpoint.
Works unchanged with:
* Ollama -> http://localhost:11434/v1 (default)
* llama.cpp server-> http://localhost:8080/v1 (set OPENAI_BASE_URL)
If no local model is reachable, every call returns ``None`` and the caller
falls back to deterministic templates. That is what keeps Acorn "always runs"
even on a fresh Hugging Face Space with no model installed.
Nothing here ever talks to a cloud provider — the whole point of the project.
"""
import os
try:
import requests
except Exception: # pragma: no cover - requests is in requirements
requests = None
# Default to Ollama's OpenAI-compatible endpoint. Override for llama.cpp.
BASE_URL = os.environ.get("OPENAI_BASE_URL", "http://localhost:11434/v1").rstrip("/")
MODEL = os.environ.get("ACORN_MODEL", "qwen2.5:7b-instruct")
API_KEY = os.environ.get("OPENAI_API_KEY", "local") # local servers ignore this
def available(timeout: float = 0.6) -> bool:
"""Best-effort check that a local model server is up. Never raises."""
if requests is None:
return False
try:
r = requests.get(
BASE_URL + "/models",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=timeout,
)
return r.ok
except Exception:
return False
def chat(messages, temperature: float = 0.4, max_tokens: int = 400,
timeout: float = 30.0):
"""Return assistant text, or ``None`` on any failure (caller falls back)."""
if requests is None:
return None
try:
r = requests.post(
BASE_URL + "/chat/completions",
headers={"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"},
json={
"model": MODEL,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
},
timeout=timeout,
)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"].strip()
except Exception:
return None
def info() -> dict:
return {"base_url": BASE_URL, "model": MODEL, "available": available()}
# ===== profile =====
"""User profile + onboarding state, persisted as JSON on-device.
This is half of Acorn's memory: the *declared* preferences from onboarding.
The other half (learned baselines) lives in py.
"""
import json
import os
ACORN_HOME = os.environ.get("ACORN_HOME", os.path.expanduser("~/.acorn"))
os.makedirs(ACORN_HOME, exist_ok=True)
PROFILE_PATH = os.path.join(ACORN_HOME, "profile.json")
# Onboarding maps directly to the user's original wishlist.
GOAL_CHOICES = [
"Track where my time goes",
"Block distracting apps on demand",
"Remind me of things",
"Keep my screen easy on the eyes",
"Warn me about bloat / power hogs",
"Manage my clipboard",
"Organize my screenshots",
"Suggest setup tweaks for my role",
]
CONSENT_LEVELS = ["observe", "suggest", "act"] # silent -> nudge -> one-tap
DEFAULT = {
"name": "",
"role": "Software / ML engineer",
"goals": list(GOAL_CHOICES),
"consent": "suggest",
"screenshot_dir": os.path.expanduser("~/Desktop"),
"quiet_hours_start": 21, # warm the screen after this hour
"break_interval_min": 25, # force a breathing break after this much continuous use
"break_duration_sec": 90, # how long the break overlay stays
"break_strict": False, # strict = no skip until the break finishes
"onboarded": False,
}
def profile_load() -> dict:
try:
with open(PROFILE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
merged = dict(DEFAULT)
merged.update(data or {})
return merged
except Exception:
return dict(DEFAULT)
def profile_save(profile: dict) -> dict:
merged = dict(DEFAULT)
merged.update(profile or {})
try:
with open(PROFILE_PATH, "w", encoding="utf-8") as f:
json.dump(merged, f, indent=2)
except Exception:
pass
return merged
# ===== store =====
"""Local SQLite memory — the substrate that lets Acorn 'get smarter over time'.
Stores telemetry samples, clipboard history, screenshot index, app-usage
rollups, and emitted Pure stdlib (sqlite3). Lives under ~/.acorn.
"""
import hashlib
import os
import sqlite3
import threading
import time
ACORN_HOME = os.environ.get("ACORN_HOME", os.path.expanduser("~/.acorn"))
os.makedirs(ACORN_HOME, exist_ok=True)
DB_PATH = os.path.join(ACORN_HOME, "acorn.db")
_lock = threading.Lock()
def _conn():
c = sqlite3.connect(DB_PATH)
c.row_factory = sqlite3.Row
return c
SCHEMA = """
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL,
cpu REAL, mem_pct REAL, mem_used_gb REAL, swap_gb REAL,
battery REAL, power_plugged INTEGER, top_proc TEXT, top_proc_gb REAL
);
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, kind TEXT,
preview TEXT, hash TEXT UNIQUE, count INTEGER DEFAULT 1, pinned INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS shots (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, path TEXT UNIQUE,
title TEXT, ocr TEXT, filed INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS appusage (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, app TEXT, seconds REAL, category TEXT
);
CREATE TABLE IF NOT EXISTS nudges (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, module TEXT, severity TEXT,
title TEXT, body TEXT, action_id TEXT, status TEXT DEFAULT 'new'
);
"""
def init_db():
with _lock, _conn() as c:
c.executescript(SCHEMA)
# ---- samples -------------------------------------------------------------
def record_sample(v: dict):
with _lock, _conn() as c:
c.execute(
"INSERT INTO samples(ts,cpu,mem_pct,mem_used_gb,swap_gb,battery,"
"power_plugged,top_proc,top_proc_gb) VALUES(?,?,?,?,?,?,?,?,?)",
(time.time(), v.get("cpu"), v.get("mem_pct"), v.get("mem_used_gb"),
v.get("swap_gb"), v.get("battery"), int(bool(v.get("power_plugged"))),
v.get("top_proc"), v.get("top_proc_gb")),
)
def recent_samples(n: int = 60):
with _lock, _conn() as c:
rows = c.execute("SELECT * FROM samples ORDER BY id DESC LIMIT ?", (n,)).fetchall()
return [dict(r) for r in rows]
# ---- clipboard -----------------------------------------------------------
def upsert_clip(preview: str, kind: str = "text") -> int:
h = hashlib.sha1(preview.encode("utf-8", "ignore")).hexdigest()
with _lock, _conn() as c:
row = c.execute("SELECT id,count FROM clips WHERE hash=?", (h,)).fetchone()
if row:
c.execute("UPDATE clips SET count=count+1, ts=? WHERE id=?",
(time.time(), row["id"]))
return row["count"] + 1
c.execute("INSERT INTO clips(ts,kind,preview,hash) VALUES(?,?,?,?)",
(time.time(), kind, preview[:500], h))
return 1
def top_clips(n: int = 20):
with _lock, _conn() as c:
rows = c.execute(
"SELECT * FROM clips ORDER BY pinned DESC, count DESC, ts DESC LIMIT ?",
(n,)).fetchall()
return [dict(r) for r in rows]
def pin_clip(clip_id: int):
with _lock, _conn() as c:
c.execute("UPDATE clips SET pinned=1 WHERE id=?", (clip_id,))
# ---- screenshots ---------------------------------------------------------
def add_shot(path: str, title: str = "", ocr: str = "") -> bool:
with _lock, _conn() as c:
try:
c.execute("INSERT INTO shots(ts,path,title,ocr) VALUES(?,?,?,?)",
(time.time(), path, title, ocr))
return True
except sqlite3.IntegrityError:
return False # already indexed
def recent_shots(n: int = 20):
with _lock, _conn() as c:
rows = c.execute("SELECT * FROM shots ORDER BY ts DESC LIMIT ?", (n,)).fetchall()
return [dict(r) for r in rows]
def search_shots(q: str, n: int = 20):
like = f"%{q}%"
with _lock, _conn() as c:
rows = c.execute(
"SELECT * FROM shots WHERE title LIKE ? OR ocr LIKE ? "
"ORDER BY ts DESC LIMIT ?", (like, like, n)).fetchall()
return [dict(r) for r in rows]
# ---- app usage -----------------------------------------------------------
def add_appusage(app: str, seconds: float, category: str = "other"):
with _lock, _conn() as c:
c.execute("INSERT INTO appusage(ts,app,seconds,category) VALUES(?,?,?,?)",
(time.time(), app, seconds, category))
def usage_rollup(since_hours: float = 24):
cutoff = time.time() - since_hours * 3600
with _lock, _conn() as c:
rows = c.execute(
"SELECT app, category, SUM(seconds) AS secs FROM appusage "
"WHERE ts>=? GROUP BY app ORDER BY secs DESC", (cutoff,)).fetchall()
return [dict(r) for r in rows]
# ---- nudges --------------------------------------------------------------
def save_nudge(n: dict):
with _lock, _conn() as c:
cur = c.execute(
"INSERT INTO nudges(ts,module,severity,title,body,action_id,status) "
"VALUES(?,?,?,?,?,?, 'new')",
(time.time(), n.get("module"), n.get("severity"), n.get("title"),
n.get("body"), n.get("action_id")))
return cur.lastrowid
def feed_recent(n: int = 15):
"""Recent insights/nudges — the async engine's delivery feed."""
with _lock, _conn() as c:
rows = c.execute("SELECT * FROM nudges ORDER BY id DESC LIMIT ?", (n,)).fetchall()
return [dict(r) for r in rows]
def set_feed_status(nudge_id: int, status: str):
with _lock, _conn() as c:
c.execute("UPDATE nudges SET status=? WHERE id=?", (status, nudge_id))
def _today_midnight() -> float:
n = time.localtime()
return time.mktime((n.tm_year, n.tm_mon, n.tm_mday, 0, 0, 0, 0, 0, -1))
def day_metrics() -> dict:
"""Aggregate everything Acorn remembers about *today* — feeds the """
midnight = _today_midnight()
with _lock, _conn() as c:
usage = [dict(r) for r in c.execute(
"SELECT app, category, SUM(seconds) AS secs FROM appusage "
"WHERE ts>=? GROUP BY app ORDER BY secs DESC", (midnight,)).fetchall()]
switches = c.execute(
"SELECT COUNT(*) FROM appusage WHERE ts>=?", (midnight,)).fetchone()[0]
shots = c.execute(
"SELECT COUNT(*) FROM shots WHERE ts>=?", (midnight,)).fetchone()[0]
clips = c.execute(
"SELECT COUNT(*) FROM clips WHERE ts>=?", (midnight,)).fetchone()[0]
top_clip = c.execute(
"SELECT preview, count FROM clips ORDER BY count DESC LIMIT 1").fetchone()
nudge_n = c.execute(
"SELECT COUNT(*) FROM nudges WHERE ts>=?", (midnight,)).fetchone()[0]
peak = c.execute(
"SELECT MAX(cpu), MAX(mem_pct) FROM samples WHERE ts>=?", (midnight,)).fetchone()
cats = {}
for u in usage:
cats[u.get("category", "other")] = cats.get(u.get("category", "other"), 0) + (u["secs"] or 0)
return {
"usage": usage,
"switches": switches,
"shots": shots,
"clips": clips,
"top_clip": dict(top_clip) if top_clip else None,
"nudges": nudge_n,
"peak_cpu": peak[0] if peak else None,
"peak_mem": peak[1] if peak else None,
"active_seconds": sum((u["secs"] or 0) for u in usage),
"categories": cats,
}
def stats() -> dict:
with _lock, _conn() as c:
def one(q):
return c.execute(q).fetchone()[0]
return {
"samples": one("SELECT COUNT(*) FROM samples"),
"clips": one("SELECT COUNT(*) FROM clips"),
"shots": one("SELECT COUNT(*) FROM shots"),
"nudges": one("SELECT COUNT(*) FROM nudges"),
}
# ===== sensors =====
"""Sensors — cheap, poll-based observation of the machine.
Two runtimes, chosen by platform:
* MacSensors -> real macOS signals (psutil + pbpaste + folder scan + osascript)
* DemoSensors -> for the Hugging Face Space / Linux: real psutil where it works
(container CPU/RAM are genuinely live!) + clearly-labelled sample
data for the mac-only signals.
This dual-runtime design is how one codebase satisfies "must be a Space" while
still being a real local Mac app (see build plan §4.3).
"""
import os
import platform
import shutil
import subprocess
import time
try:
import psutil
except Exception: # pragma: no cover
psutil = None
IS_MAC = platform.system() == "Darwin"
# --------------------------------------------------------------------------
class BaseSensors:
runtime = "base"
demo = True
# ---- system vitals (psutil works on Mac AND in a Linux Space) ----
def vitals(self) -> dict:
if psutil is None:
return {"cpu": 0, "mem_pct": 0, "mem_used_gb": 0, "swap_gb": 0,
"battery": None, "power_plugged": True,
"top_proc": "n/a", "top_proc_gb": 0}
vm = psutil.virtual_memory()
sw = psutil.swap_memory()
cpu = psutil.cpu_percent(interval=0.2)
bat, plugged = None, True
try:
b = psutil.sensors_battery()
if b is not None:
bat, plugged = round(b.percent, 0), b.power_plugged
except Exception:
pass
top = self.top_processes(1)
tp = top[0] if top else {"name": "n/a", "mem_gb": 0}
return {
"cpu": round(cpu, 1),
"mem_pct": round(vm.percent, 1),
"mem_used_gb": round(vm.used / 1e9, 1),
"mem_total_gb": round(vm.total / 1e9, 1),
"swap_gb": round(sw.used / 1e9, 2),
"battery": bat,
"power_plugged": plugged,
"top_proc": tp["name"],
"top_proc_gb": tp["mem_gb"],
}
def top_processes(self, n: int = 5):
if psutil is None:
return []
procs = []
for p in psutil.process_iter(["name", "memory_info", "cpu_percent"]):
try:
mi = p.info["memory_info"]
procs.append({
"name": p.info["name"] or "?",
"mem_gb": round((mi.rss if mi else 0) / 1e9, 2),
"cpu": p.info.get("cpu_percent") or 0.0,
})
except Exception:
continue
procs.sort(key=lambda x: x["mem_gb"], reverse=True)
return procs[:n]
# ---- the mac-only signals: overridden by each runtime ----
def disk_reclaimable(self):
raise NotImplementedError
def clipboard_peek(self):
raise NotImplementedError
def recent_screenshots(self, limit=10):
raise NotImplementedError
def display_state(self):
raise NotImplementedError
def frontmost_app(self):
raise NotImplementedError
# --------------------------------------------------------------------------
class MacSensors(BaseSensors):
runtime = "mac"
demo = False
DEV_PATHS = [
("Xcode DerivedData", "~/Library/Developer/Xcode/DerivedData"),
("User caches", "~/Library/Caches"),
("iOS DeviceSupport", "~/Library/Developer/Xcode/iOS DeviceSupport"),
("npm cache", "~/.npm"),
("Old Downloads", "~/Downloads"),
]
def _du(self, path: str) -> float:
p = os.path.expanduser(path)
if not os.path.exists(p):
return 0.0
try:
out = subprocess.run(["du", "-sk", p], capture_output=True,
text=True, timeout=8).stdout
kb = float(out.split("\t")[0])
return round(kb / 1e6, 2) # GB
except Exception:
return 0.0
def disk_reclaimable(self):
items = [{"label": lbl, "path": pth, "gb": self._du(pth)}
for lbl, pth in self.DEV_PATHS]
items = [i for i in items if i["gb"] > 0.05]
items.sort(key=lambda x: x["gb"], reverse=True)
return items
def clipboard_peek(self):
try:
out = subprocess.run(["pbpaste"], capture_output=True, text=True,
timeout=3).stdout
return out.strip()[:500]
except Exception:
return ""
def recent_screenshots(self, limit=10):
d = os.path.expanduser(profile_load().get("screenshot_dir", "~/Desktop"))
shots = []
try:
for fn in os.listdir(d):
low = fn.lower()
if (low.endswith((".png", ".jpg", ".jpeg")) and
("screenshot" in low or "screen shot" in low)):
fp = os.path.join(d, fn)
shots.append({"path": fp, "name": fn, "mtime": os.path.getmtime(fp)})
except Exception:
pass
shots.sort(key=lambda x: x["mtime"], reverse=True)
return shots[:limit]
def display_state(self):
# Best-effort; real Night Shift state needs CoreBrightness. Report appearance.
appearance = "unknown"
try:
out = subprocess.run(
["osascript", "-e",
'tell application "System Events" to tell appearance preferences '
'to return dark mode'], capture_output=True, text=True, timeout=3).stdout
appearance = "dark" if "true" in out.lower() else "light"
except Exception:
pass
return {"appearance": appearance, "hour": time.localtime().tm_hour}
def frontmost_app(self):
try:
out = subprocess.run(
["osascript", "-e",
'tell application "System Events" to get name of first application '
'process whose frontmost is true'],
capture_output=True, text=True, timeout=3).stdout.strip()
return out or "Unknown"
except Exception:
return "Unknown"
# --------------------------------------------------------------------------
class DemoSensors(BaseSensors):
"""For the Space / non-mac. Real psutil vitals + labelled sample data."""
runtime = "demo"
demo = True
def disk_reclaimable(self):
return [
{"label": "Xcode DerivedData", "path": "~/Library/Developer/Xcode/DerivedData", "gb": 8.7},
{"label": "User caches", "path": "~/Library/Caches", "gb": 3.9},
{"label": "Old Downloads", "path": "~/Downloads", "gb": 1.6},
]
def clipboard_peek(self):
return "sk_live_demo_1234 (sample clipboard content)"
def recent_screenshots(self, limit=10):
now = time.time()
sample = [
{"path": "~/Desktop/Screenshot 2026-06-08 at 10.14.png",
"name": "Screenshot — Stripe webhook docs", "mtime": now - 600},
{"path": "~/Desktop/Screenshot 2026-06-08 at 09.02.png",
"name": "Screenshot — stack trace KeyError", "mtime": now - 4200},
{"path": "~/Desktop/Screenshot 2026-06-07 at 18.20.png",
"name": "Screenshot — Figma button states", "mtime": now - 60000},
]
return sample[:limit]
def display_state(self):
return {"appearance": "light", "hour": time.localtime().tm_hour}
def frontmost_app(self):
return "Visual Studio Code"
def get_sensors() -> BaseSensors:
return MacSensors() if IS_MAC else DemoSensors()
# ===== nudges =====
"""The Brain — turns signals + profile into nudges, a brief, and answers.
The small local model is load-bearing but used for what small models are good
at: ranking, classification, and warm phrasing. If no model is reachable we
fall back to deterministic templates so Acorn ALWAYS runs.
"""
import time
VOICE = (
"You are Acorn, a calm, brief macOS companion (a friendly squirrel). "
"You run fully on-device and never send data to the cloud. "
"Speak warmly, in 1-2 short sentences, never naggy. Be specific and kind."
)
# ---- gather a snapshot of everything we know right now -------------------
def gather_signals(sensors, profile) -> dict:
v = sensors.vitals()
return {
"vitals": v,
"top_processes": sensors.top_processes(5),
"reclaimable": sensors.disk_reclaimable(),
"screenshots": sensors.recent_screenshots(8),
"clips": top_clips(8),
"usage": usage_rollup(24),
"display": sensors.display_state(),
"hour": time.localtime().tm_hour,
"profile": profile,
"runtime": sensors.runtime,
}
# ---- rule-based candidate nudges (deterministic, always available) -------
def rule_nudges(sig: dict) -> list:
out = []
v = sig["vitals"]
prof = sig["profile"]
goals = set(prof.get("goals", []))
# Pulse: a memory-hungry top process
tp = (sig["top_processes"] or [{}])[0]
if tp and tp.get("mem_gb", 0) >= 2.0 and "Warn me about bloat / power hogs" in goals:
out.append({
"module": "Pulse", "severity": "warn",
"title": f"{tp['name']} is holding {tp['mem_gb']} GB",
"body": f"{tp['name']} is your biggest memory user right now.",
"action_id": "quit_app", "action_label": f"Quit {tp['name']}",
"action_args": {"name": tp["name"]},
})
# Pulse: reclaimable disk
reclaim = sig.get("reclaimable", [])
total = round(sum(i.get("gb", 0) for i in reclaim), 1)
if total >= 2 and "Warn me about bloat / power hogs" in goals:
out.append({
"module": "Pulse", "severity": "info",
"title": f"Reclaim {total} GB of developer junk",
"body": "Caches, DerivedData and old downloads are piling up.",
"action_id": "preview_cleanup", "action_label": "Preview cleanup",
"action_args": {"items": reclaim},
})
# Pulse: on battery and draining hard
if (v.get("power_plugged") is False and (v.get("cpu", 0) > 60)
and "Warn me about bloat / power hogs" in goals):
out.append({
"module": "Pulse", "severity": "warn",
"title": "High CPU on battery",
"body": f"CPU at {v['cpu']}% while unplugged — something is working hard.",
"action_id": "deep_work_on", "action_label": "Calm things down",
"action_args": {},
})
# Stash: a recurring paste worth pinning
for clip in sig.get("clips", []):
if clip.get("count", 0) >= 3 and not clip.get("pinned") \
and "Manage my clipboard" in goals:
out.append({
"module": "Stash", "severity": "info",
"title": "You keep pasting this",
"body": f"Copied {clip['count']}×: “{(clip.get('preview') or '')[:40]}…” — pin it?",
"action_id": "pin_clip", "action_label": "Pin it",
"action_args": {"clip_id": clip["id"]},
})
break
# Stash: screenshots piling up
shots = sig.get("screenshots", [])
if len(shots) >= 3 and "Organize my screenshots" in goals:
out.append({
"module": "Stash", "severity": "info",
"title": f"{len(shots)} screenshots on your Desktop",
"body": "Want me to title and file them so they're searchable?",
"action_id": "reveal_path", "action_label": "Show newest",
"action_args": {"path": shots[0]["path"]},
})
# Comfort: evening eye care
if sig["hour"] >= prof.get("quiet_hours_start", 21) \
and "Keep my screen easy on the eyes" in goals:
out.append({
"module": "Comfort", "severity": "info",
"title": "It's getting late",
"body": "Warm and dim the screen so your eyes can wind down?",
"action_id": "warm_screen", "action_label": "Warm my screen",
"action_args": {},
})
# Trails: distraction check (from learned usage)
usage = sig.get("usage", [])
distract = sum(u["secs"] for u in usage if u.get("category") == "distraction")
if distract > 1800 and "Block distracting apps on demand" in goals:
out.append({
"module": "Trails", "severity": "info",
"title": f"{int(distract//60)} min in distractions today",
"body": "Want a Deep Work block for the next stretch?",
"action_id": "deep_work_on", "action_label": "Start Deep Work",
"action_args": {},
})
return out
# ---- optional LLM re-phrasing of a single nudge --------------------------
def phrase_nudge(nudge: dict) -> dict:
text = chat([
{"role": "system", "content": VOICE},
{"role": "user", "content":
f"Rewrite this nudge as one warm sentence. Title: {nudge['title']}. "
f"Detail: {nudge['body']}"},
], max_tokens=60)
if text:
nudge = dict(nudge)
nudge["body"] = text
return nudge
def nudges(sensors, profile, use_llm: bool = True) -> list:
sig = gather_signals(sensors, profile)
cands = rule_nudges(sig)
for n in cands:
save_nudge(n)
if use_llm and available():
cands = [phrase_nudge(n) for n in cands[:5]]
return cands
# ---- the daily brief -----------------------------------------------------
def daily_brief(sensors, profile) -> str:
sig = gather_signals(sensors, profile)
v = sig["vitals"]
total = round(sum(i.get("gb", 0) for i in sig.get("reclaimable", [])), 1)
facts = (
f"CPU {v['cpu']}%, RAM {v['mem_pct']}% "
f"({v['mem_used_gb']}/{v.get('mem_total_gb','?')} GB), "
f"top process {v['top_proc']} ({v['top_proc_gb']} GB). "
f"~{total} GB reclaimable. "
f"{len(sig.get('screenshots', []))} recent screenshots, "
f"{len(sig.get('clips', []))} clipboard items remembered. "
f"Local hour {sig['hour']}."
)
text = chat([
{"role": "system", "content": VOICE},
{"role": "user", "content":
f"Write a 2-3 sentence morning-style brief for a {profile.get('role')}. "
f"Be specific, warm, end with one gentle suggestion. Facts: {facts}"},
], max_tokens=160)
if text:
return text
# template fallback
return (
f"🌰 Here's your Mac right now: CPU {v['cpu']}%, RAM {v['mem_pct']}%, "
f"with {v['top_proc']} using the most memory ({v['top_proc_gb']} GB). "
f"I can reclaim about {total} GB whenever you like. "
+ ("Late evening — say the word and I'll warm your screen."
if sig['hour'] >= profile.get('quiet_hours_start', 21)
else "Looking tidy. I'll keep watch.")
)
# ---- ask-me-anything -----------------------------------------------------
def answer(question: str, sensors, profile) -> str:
sig = gather_signals(sensors, profile)
v = sig["vitals"]
context = (
f"vitals={v}; top={sig['top_processes'][:3]}; "
f"reclaimable={sig['reclaimable']}; clips={[c.get('preview') for c in sig['clips'][:5]]}; "
f"screenshots={[s.get('name') for s in sig['screenshots'][:5]]}; hour={sig['hour']}"
)
text = chat([
{"role": "system", "content": VOICE +
" Answer ONLY from the provided local data. If unknown, say so plainly."},
{"role": "user", "content": f"Data: {context}\n\nQuestion: {question}"},
], max_tokens=220)
if text:
return text
return (f"Right now: CPU {v['cpu']}%, RAM {v['mem_pct']}%, top memory user "
f"{v['top_proc']} ({v['top_proc_gb']} GB). "
"(Install a local model via Ollama for fuller answers.)")
# ---- 'people in your role configure X' -----------------------------------
DEV_CONFIG_TIPS = [
"Show all filename extensions in Finder",
"Set a fast key-repeat rate (great for vim/editors)",
"Save screenshots to a dedicated ~/Screenshots folder",
"Auto-hide the Dock to reclaim screen space",
"Install a window manager (Rectangle) and a launcher (Raycast)",
"Enable tap-to-click and three-finger drag",
"Turn on FileVault disk encryption",
]
def config_suggestions(profile) -> str:
tips = "; ".join(DEV_CONFIG_TIPS)
text = chat([
{"role": "system", "content": VOICE},
{"role": "user", "content":
f"For a {profile.get('role')}, pick the 5 most useful Mac setup tweaks "
f"from this list and present them as a short friendly checklist: {tips}"},
], max_tokens=220)
if text:
return text
return "Recommended for your role:\n" + "\n".join(f"- [ ] {t}" for t in DEV_CONFIG_TIPS)
# ===== journal =====
"""Acorn's Almanac — the End-of-Day Journal.
At day's end, the local model reads everything Acorn remembered today (app time,
context switches, screenshots, clipboard, nudges, peak load, reclaimable space)
and writes a warm narrative + takeaways. Saved as dated markdown under
~/.acorn/journal/. Only possible *because* the memory is local — that's the point.
"""
import os
import time
JOURNAL_DIR = os.path.join(ACORN_HOME, "journal")
os.makedirs(JOURNAL_DIR, exist_ok=True)
def _facts(sensors, profile):
m = day_metrics()
v = sensors.vitals()
reclaim = round(sum(i.get("gb", 0) for i in sensors.disk_reclaimable()), 1)
top = m["usage"][:4]
active_min = int(m["active_seconds"] // 60)
top_str = ", ".join(f"{u['app']} {int((u['secs'] or 0)//60)}m" for u in top) \
or "no apps tracked yet today"
facts = (
f"active ~{active_min} min; app switches {m['switches']}; top apps {top_str}; "
f"screenshots today {m['shots']}; clipboard items {m['clips']}; "
f"nudges surfaced {m['nudges']}; peak CPU {m['peak_cpu']}; peak RAM {m['peak_mem']}; "
f"~{reclaim} GB reclaimable now."
)
return m, active_min, top_str, reclaim, facts
def _template(date, active_min, top_str, m, reclaim) -> str:
return (
f"Today your Mac was busy for about **{active_min} minutes** of tracked activity, "
f"mostly across {top_str}. You switched apps {m['switches']} times, took {m['shots']} "
f"screenshots, and Acorn kept {m['clips']} clipboard items for you. "
f"Peak load touched CPU {m['peak_cpu']}% / RAM {m['peak_mem']}%. "
f"There's about {reclaim} GB you could reclaim whenever you like.\n\n"
f"**Takeaways**\n\n"
f"- Most of your time went to {top_str.split(',')[0] if top_str else 'focused work'}.\n"
f"- {m['switches']} context switches — batching similar work could cut that down.\n"
f"- {reclaim} GB of reclaimable space is quietly piling up.\n\n"
f"**For tomorrow:** start with your heaviest task while the machine is fresh — "
f"and let me reclaim that {reclaim} GB so you begin with a tidy burrow. 🌰"
)
def write_journal(sensors, profile) -> tuple[str, str]:
"""Generate today's journal, save it, return (markdown, path)."""
m, active_min, top_str, reclaim, facts = _facts(sensors, profile)
date = time.strftime("%Y-%m-%d")
body = chat(
[
{"role": "system", "content": VOICE},
{"role": "user", "content":
f"Write an end-of-day journal entry for a {profile.get('role')} about how they used "
f"their Mac today. 4-6 warm sentences, then a '**Takeaways**' list of 3 short bullets, "
f"then one '**For tomorrow:**' suggestion. Be specific to the facts; do not invent "
f"numbers. Facts: {facts}"},
],
max_tokens=420,
)
if not body:
body = _template(date, active_min, top_str, m, reclaim)
md = f"# 🌰 Acorn Almanac — {date}\n\n_A diary of your Mac. Written on-device; never leaves it._\n\n{body}\n"
path = os.path.join(JOURNAL_DIR, f"{date}.md")
try:
with open(path, "w", encoding="utf-8") as f:
f.write(md)
except Exception:
pass
return md, path
def list_journals():
try:
files = sorted((f for f in os.listdir(JOURNAL_DIR) if f.endswith(".md")), reverse=True)
return [os.path.join(JOURNAL_DIR, f) for f in files]
except Exception:
return []
# ===== async_engine =====
"""Async Insight Engine — the layer that makes Acorn a *companion*, not a dashboard.
A background loop evaluates triggers (interval health sweep, heads-down/break rhythm,
rule nudges), ranks candidates, and delivers the best one to:
* an in-app feed (persisted via store), and
* macOS Notification Center (osascript) — so it reaches you without opening the app.
Guardrails keep it from nagging: consent level, quiet hours, an hourly nudge budget,
per-title de-dupe, and a dismissed-set. All on-device; notifications make no network calls.
"""
import collections
import platform
import subprocess
import threading
import time
IS_MAC = platform.system() == "Darwin"
def notify(title: str, message: str) -> bool:
"""Post a macOS notification. No-op (returns False) off-mac."""
if not IS_MAC:
return False
safe = message.replace('"', "'")
try:
subprocess.run(
["osascript", "-e", f'display notification "{safe}" with title "{title}"'],
timeout=5, capture_output=True)
return True
except Exception:
return False
class AsyncEngine:
def __init__(self, sensors, get_profile, interval=60, break_minutes=50, max_per_hour=3):
self.sensors = sensors
self.get_profile = get_profile # callable -> current profile dict
self.interval = interval # seconds between sweeps
self.break_minutes = break_minutes
self.max_per_hour = max_per_hour
self._thread = None
self._stop = threading.Event()
self._emitted = collections.deque() # timestamps, for the hourly budget
self._last_title = {} # title -> ts, for de-dupe
self.dismissed = set() # titles the user dismissed -> back off
self.active_since = time.time() # for heads-down detection
self.log = [] # (time, title, delivery) for the UI
# ---- lifecycle ----
def running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def start(self):
if self.running():
return
self._stop.clear()
self.active_since = time.time()
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._stop.set()
self._thread = None
# ---- guardrails ----
def _quiet(self, prof) -> bool:
h = time.localtime().tm_hour
return h >= int(prof.get("quiet_hours_start", 21)) or h < 7
def _budget_ok(self) -> bool:
now = time.time()
while self._emitted and now - self._emitted[0] > 3600:
self._emitted.popleft()
return len(self._emitted) < self.max_per_hour
# ---- candidate insights ----
def _candidates(self, prof):
sig = gather_signals(self.sensors, prof)
cands = rule_nudges(sig)
# Heads-down / break rhythm (async-only signal).
active_min = (time.time() - self.active_since) / 60
if active_min >= self.break_minutes and "Remind me of things" in prof.get("goals", []):
cands.insert(0, {
"module": "Trails", "severity": "info",
"title": f"Heads-down {int(active_min)} min — take a breather?",
"body": "Open the Breathe tab for a slow 4·7·8 — two minutes resets your focus.",
"action_id": "warm_screen", "action_label": "Breathe",
"action_args": {},
})
self.active_since = time.time()
return cands
# ---- one evaluation pass ----
def tick(self, force=False):
prof = self.get_profile()
consent = prof.get("consent", "suggest")
delivered = []
for n in self._candidates(prof):
title = n["title"]
if title in self.dismissed:
continue
if not force:
if time.time() - self._last_title.get(title, 0) < 1800: # 30-min de-dupe
continue
if not self._budget_ok():
break
save_nudge(n)
self._last_title[title] = time.time()
self._emitted.append(time.time())
sent = False
if consent in ("suggest", "act") and (force or not self._quiet(prof)):
sent = notify("Acorn 🌰", title)
self.log.append((time.strftime("%H:%M:%S"), title, "🔔 notified" if sent else "📋 feed"))
delivered.append(n)
if not force: # one good nudge per sweep is plenty
break
return delivered
def dismiss(self, title: str):
if title:
self.dismissed.add(title)
def _loop(self):
while not self._stop.wait(self.interval):
try:
self.tick()
except Exception:
pass
def status(self) -> str:
if not self.running():
return "💤 Companion is asleep."
return (f"🟢 Companion is watching · sweep every {self.interval}s · "
f"break after {self.break_minutes}m · ≤{self.max_per_hour} nudges/hr"
+ ("" if IS_MAC else " · (notifications are mac-only; showing in feed)"))
# ===== actions =====
"""Actions — the one-tap macOS effects behind each nudge.
Design rules (match the user's safety preferences):
* Consent-gated: destructive actions require an explicit confirm.
* Dry-run first: cleanup PREVIEWS sizes; only deletes when confirmed AND
ACORN_ALLOW_DELETE=1 is set (belt-and-suspenders against accidents).
* Safe on the Space: on non-mac, actions become labelled no-ops.
Uses native macOS power moves: Shortcuts, Focus, osascript, defaults, pmset.
"""
import os
import platform
import shutil
import subprocess
IS_MAC = platform.system() == "Darwin"
ALLOW_DELETE = os.environ.get("ACORN_ALLOW_DELETE") == "1"
def _osa(script: str):
if not IS_MAC:
return True, "(demo) applied — this runs for real on your Mac."
try:
subprocess.run(["osascript", "-e", script], check=True,
capture_output=True, text=True, timeout=6)
return True, "Done."
except Exception as e:
return False, f"AppleScript failed: {e}"
# ---- comfort -------------------------------------------------------------
def warm_screen(args=None, confirm=False):
"""Enable a warm, dimmed screen (Night Shift-style) for the evening."""
if not IS_MAC:
return True, "🌙 (demo) On your Mac this warms + dims the screen for evening."
# Toggling Night Shift programmatically needs CoreBrightness; we approximate
# by enabling Dark Mode + lowering brightness via a user Shortcut hook.
ok, _ = _osa(
'tell application "System Events" to tell appearance preferences '
'to set dark mode to true')
return ok, ("🌙 Warmed the screen (Dark Mode on; tip: bind brightness to a Shortcut)."
if ok else "Couldn't reach System Events.")
def dark_mode_toggle(args=None, confirm=False):
if not IS_MAC:
return True, "🌗 (demo) On your Mac this toggles Dark Mode."
return _osa(
'tell application "System Events" to tell appearance preferences '
'to set dark mode to not dark mode')
# ---- focus ---------------------------------------------------------------
def deep_work_on(args=None, confirm=False):
"""Turn on a Focus mode for deep work (best-effort via Shortcuts)."""
if not IS_MAC:
return True, "(demo) would enable a 'Deep Work' Focus and block distractors."
# Most robust path: a user-created Shortcut named 'Deep Work'.
return run_shortcut({"name": "Deep Work"})
def run_shortcut(args, confirm=False):
name = (args or {}).get("name", "")
if not name:
return False, "No Shortcut name given."
if not IS_MAC:
return True, f"(demo) would run Shortcut: {name!r}"
try:
subprocess.run(["shortcuts", "run", name], check=True,
capture_output=True, text=True, timeout=10)
return True, f"▶️ Ran Shortcut: {name}"
except Exception as e:
return False, f"Couldn't run Shortcut {name!r} (create it in the Shortcuts app). {e}"
# ---- pulse / cleanup -----------------------------------------------------
def preview_cleanup(args, confirm=False):
"""Dry-run: report how much could be reclaimed. Never deletes."""
items = (args or {}).get("items", [])
total = sum(i.get("gb", 0) for i in items)
lines = "\n".join(f" • {i['label']}: {i['gb']} GB" for i in items)
return True, f"Dry-run — {round(total,1)} GB reclaimable:\n{lines}\n(Confirm to clean.)"
def reclaim_space(args, confirm=False):
"""Delete cache contents — ONLY with confirm AND ACORN_ALLOW_DELETE=1."""
items = (args or {}).get("items", [])
total = round(sum(i.get("gb", 0) for i in items), 1)
if not confirm:
return False, f"Needs confirmation to reclaim {total} GB."
if not IS_MAC:
return True, f"(demo) would reclaim ~{total} GB from caches/DerivedData."
if not ALLOW_DELETE:
return True, (f"Safe mode: would reclaim ~{total} GB. "
"Set ACORN_ALLOW_DELETE=1 to enable real deletion.")
freed = 0.0
for i in items:
p = os.path.expanduser(i.get("path", ""))
# Only ever clear *contents* of known cache dirs, never the dir itself.
if "Caches" in p or "DerivedData" in p:
try:
for entry in os.listdir(p):
fp = os.path.join(p, entry)
shutil.rmtree(fp, ignore_errors=True) if os.path.isdir(fp) \
else os.remove(fp)
freed += i.get("gb", 0)
except Exception:
continue
return True, f"🧹 Reclaimed ~{round(freed,1)} GB."
def quit_app(args, confirm=False):
name = (args or {}).get("name", "")
if not confirm:
return False, f"Confirm to quit {name}."
if not IS_MAC:
return True, f"(demo) would quit {name}."
return _osa(f'tell application "{name}" to quit')
# ---- stash ---------------------------------------------------------------
def reveal_path(args, confirm=False):
path = os.path.expanduser((args or {}).get("path", ""))
if not IS_MAC:
return True, f"(demo) would reveal in Finder: {path}"
try:
subprocess.run(["open", "-R", path], check=True, timeout=5)
return True, f"📁 Revealed {os.path.basename(path)} in Finder."
except Exception as e:
return False, str(e)
# Registry: action_id -> callable(args, confirm)
REGISTRY = {
"warm_screen": warm_screen,
"dark_mode_toggle": dark_mode_toggle,
"deep_work_on": deep_work_on,
"run_shortcut": run_shortcut,
"preview_cleanup": preview_cleanup,
"reclaim_space": reclaim_space,
"quit_app": quit_app,
"reveal_path": reveal_path,
}
def run(action_id: str, args=None, confirm=False):
fn = REGISTRY.get(action_id)
if not fn:
return False, f"Unknown action: {action_id}"
try:
return fn(args or {}, confirm=confirm)
except Exception as e:
return False, f"Action error: {e}"
# ===== spellbook =====
"""Spellbook — the local model as a macOS automation author.
You describe what you want ("tile my two coding windows", "hide everything but
the front app") and the on-device model writes the AppleScript. Acorn never runs
generated code blind:
generate -> SHOW the script + plain-English explanation -> validate -> you click Run
Safety model:
* A denylist validator blocks dangerous patterns (shell-out, deletion, admin
rights, keystroke injection, network) before anything can run.
* A curated, pre-vetted LIBRARY guarantees the demo works even if the model is
slow or wrong — the model handles novel requests.
* Recipes prefer REVERSIBLE actions (hide / minimize / move) over close / quit,
so you never lose unsaved work.
All on-device. Generation uses the local model; nothing is sent to the cloud.
Note: controlling other apps' windows uses System Events, which needs macOS
Accessibility permission (granted once in System Settings → Privacy & Security).
"""
import os
import platform
import re
import subprocess
import time
IS_MAC = platform.system() == "Darwin"
SPELL_DIR = os.path.join(ACORN_HOME, "spells")
os.makedirs(SPELL_DIR, exist_ok=True)
# Reusable AppleScript prelude: screen size + frontmost process.
_PRELUDE = '''tell application "Finder" to set _d to bounds of window of desktop
set _sw to item 3 of _d
set _sh to item 4 of _d
tell application "System Events" to set _p to first application process whose frontmost is true'''
# ---- curated, pre-vetted library ----------------------------------------
LIBRARY = [
{
"name": "Tile front window — left half",
"desc": "Snap the active window to the left half of the screen.",
"script": _PRELUDE + '''
tell application "System Events"
set position of window 1 of _p to {0, 25}
set size of window 1 of _p to {_sw / 2, _sh - 25}
end tell''',
},
{
"name": "Tile front window — right half",
"desc": "Snap the active window to the right half of the screen.",
"script": _PRELUDE + '''
tell application "System Events"
set position of window 1 of _p to {_sw / 2, 25}
set size of window 1 of _p to {_sw / 2, _sh - 25}
end tell''',
},
{
"name": "Maximize front window",
"desc": "Make the active window fill the screen (below the menu bar).",
"script": _PRELUDE + '''
tell application "System Events"
set position of window 1 of _p to {0, 25}
set size of window 1 of _p to {_sw, _sh - 25}
end tell''',
},
{
"name": "Center front window",
"desc": "Center the active window at a comfortable reading size.",
"script": _PRELUDE + '''
set _w to _sw * 0.66
set _h to (_sh - 25) * 0.85
tell application "System Events"
set size of window 1 of _p to {_w, _h}
set position of window 1 of _p to {(_sw - _w) / 2, 25 + ((_sh - 25) - _h) / 2}
end tell''',
},
{
"name": "Full height (keep width)",
"desc": "Stretch the active window to full height without changing its width.",
"script": _PRELUDE + '''
tell application "System Events"
set _pos to position of window 1 of _p
set _sz to size of window 1 of _p
set position of window 1 of _p to {item 1 of _pos, 25}
set size of window 1 of _p to {item 1 of _sz, _sh - 25}
end tell''',
},
{
"name": "Hide background apps",
"desc": "Hide every app except the one in front (reversible — nothing is closed).",
"script": '''tell application "System Events"
set visible of (every application process whose frontmost is false and background only is false) to false
end tell''',
},
{
"name": "Cascade all windows",
"desc": "Fan the front window of each visible app into a tidy cascade.",
"script": '''tell application "System Events"
set _i to 0
repeat with _proc in (every application process whose visible is true and background only is false)
try
set position of window 1 of _proc to {40 + _i * 40, 50 + _i * 36}
set _i to _i + 1
end try
end repeat
end tell''',
},
{
"name": "Move front window to 2nd display",
"desc": "Nudge the active window onto a second display to the right.",
"script": _PRELUDE + '''
tell application "System Events"
set position of window 1 of _p to {_sw + 60, 60}
end tell''',
},
]
def library_names():
return [s["name"] for s in LIBRARY]
def get_library(name):
return next((s for s in LIBRARY if s["name"] == name), None)
# ---- safety validator ----------------------------------------------------
# Patterns that must never appear in a script we run.
_DENY = [
r"do\s+shell\s+script", r"\bsudo\b", r"administrator\s+privileges",
r"\brm\s+-", r"\bdelete\b", r"\bmove\b.+\bto\s+trash", r"empty\s+trash",
r"keystroke", r"key\s+code", r"\bcurl\b", r"\bwget\b", r"/bin/", r"/usr/bin",
r'tell\s+application\s+"Terminal"', r"set\s+volume", r"shut\s*down",
r"restart\b", r"log\s+out", r"\bpassword\b", r"system\s+attribute",
]
def is_safe(script: str):
low = (script or "").lower()
for pat in _DENY:
if re.search(pat, low):
return False, f"blocked: matched unsafe pattern /{pat}/"
if not low.strip():
return False, "empty script"
return True, "ok"
# ---- model generation ----------------------------------------------------
_SYS = (
"You are Acorn's automation author. Convert the user's request into a SINGLE "
"macOS AppleScript suitable for `osascript`. Rules: "
"(1) Output ONLY AppleScript — no markdown fences, no prose. "
"(2) The FIRST line must be a comment '-- <one short sentence describing it>'. "
"(3) Control windows with System Events; get screen size from "
"`tell application \"Finder\" to get bounds of window of desktop`. "
"(4) Prefer REVERSIBLE actions (hide, minimize, move, resize) over closing or "
"quitting apps. "
"(5) NEVER use: do shell script, administrator privileges, keystroke, delete, "
"empty trash, set volume, shutdown/restart/logout, or anything touching files, "
"the network, or passwords. Window and layout automation only."
)
def generate(request: str, profile=None):
"""NL -> {script, explanation, source}. Falls back to the closest library spell."""
text = chat(
[{"role": "system", "content": _SYS},
{"role": "user", "content": request}],
temperature=0.2, max_tokens=320,
)
if text:
script = text.strip().strip("`")
# pull the leading comment as the explanation
expl = ""
m = re.match(r"\s*--\s*(.+)", script)
if m:
expl = m.group(1).strip()
ok, reason = is_safe(script)
if ok:
return {"script": script, "explanation": expl or "Generated automation.",
"source": "model", "safe": True}
return {"script": script, "explanation": f"⚠️ Generated, but {reason}. Not runnable.",
"source": "model", "safe": False}
# fallback: keyword-match a library spell so the feature still works offline
spell = _closest_library(request)
if spell:
return {"script": spell["script"], "explanation":
f"(no local model — used a library spell: {spell['name']})",
"source": "library", "safe": True}
return {"script": "", "explanation":
"No local model available and no close library match. Try Ollama, or pick a library spell.",
"source": "none", "safe": False}
def _closest_library(request: str):
r = (request or "").lower()
keys = {
"left": 0, "right": 1, "max": 2, "full screen": 2, "center": 3,
"centre": 3, "height": 4, "hide": 5, "minimi": 5, "cascade": 6,
"stack": 6, "display": 7, "monitor": 7, "second screen": 7,
}
for k, idx in keys.items():
if k in r:
return LIBRARY[idx]
return None
# ---- run -----------------------------------------------------------------
def spell_run(script: str, confirm: bool = False):
ok, reason = is_safe(script)
if not ok:
return False, f"🛑 Refused — {reason}"
if not confirm:
return True, "Preview only — click Run to execute."
if not IS_MAC:
return True, "(demo) On your Mac this AppleScript would run via osascript."
try:
path = os.path.join(SPELL_DIR, "_run.applescript")
with open(path, "w", encoding="utf-8") as f:
f.write(script)
out = subprocess.run(["osascript", path], capture_output=True, text=True, timeout=12)
if out.returncode == 0:
return True, "✨ Done." + (f" {out.stdout.strip()}" if out.stdout.strip() else "")
return False, f"AppleScript error: {out.stderr.strip()[:200]} "\
"(System Events automations need Accessibility permission)."
except Exception as e:
return False, f"Run error: {e}"
def save_spell(name: str, script: str):
ok, reason = is_safe(script)
if not ok:
return False, f"Won't save — {reason}"
safe_name = re.sub(r"[^A-Za-z0-9_-]+", "_", (name or "spell").strip()) or "spell"
path = os.path.join(SPELL_DIR, f"{safe_name}.applescript")
try:
with open(path, "w", encoding="utf-8") as f:
f.write(script)
return True, f"🪄 Saved spell: `{path}`"
except Exception as e:
return False, f"Save error: {e}"
def list_spells():
try:
return [os.path.join(SPELL_DIR, f) for f in sorted(os.listdir(SPELL_DIR))
if f.endswith(".applescript") and not f.startswith("_")]
except Exception:
return []
# ===== app =====
"""Acorn — your Mac's quiet woodland companion.
Build Small Hackathon (Gradio × Hugging Face) · Backyard AI track.
Local-first: runs entirely on-device, no cloud APIs.
Run locally on your Mac for the full thing:
pip install -r requirements.txt
python app.py
(Optional, for the AI brief/nudges) install Ollama and `ollama pull qwen2.5:7b-instruct`.
On a Hugging Face Space (Linux) it runs in demo mode automatically.
"""
import os
import threading
import time
import gradio as gr
# ---- singletons ----------------------------------------------------------
init_db()
SENSORS = get_sensors()
PROFILE = profile_load()
ENGINE = AsyncEngine(SENSORS, lambda: PROFILE,
interval=60, break_minutes=50, max_per_hour=3)
ACCENT = "#7a9e6e"
# ---- small render helpers ------------------------------------------------
def meter(label: str, pct, detail: str = "") -> str:
pct = max(0.0, min(100.0, float(pct or 0)))
color = ACCENT if pct < 70 else ("#d9a441" if pct < 88 else "#c4694e")
return (
f'<div class="acorn-meter"><div class="acorn-meter-label">'
f'<b>{label}</b><span>{detail}</span></div>'
f'<div class="acorn-bar"><div class="acorn-fill" '
f'style="width:{pct:.0f}%;background:{color}"></div></div></div>'
)
def render_vitals_html() -> str:
v = SENSORS.vitals()
record_sample(v)
bat = "—" if v.get("battery") is None else f"{int(v['battery'])}%"
plug = "plugged in" if v.get("power_plugged") else "on battery"
html = [f'<div class="acorn-card">']
html.append(meter("CPU", v["cpu"], f"{v['cpu']}%"))
html.append(meter("Memory", v["mem_pct"],
f"{v['mem_used_gb']} / {v.get('mem_total_gb','?')} GB"))
html.append(
f'<div class="acorn-row"><span>🔋 Battery</span><span>{bat} · {plug}</span></div>')
html.append(
f'<div class="acorn-row"><span>🧠 Top memory</span>'
f'<span>{v["top_proc"]} · {v["top_proc_gb"]} GB</span></div>')
if SENSORS.demo:
html.append('<div class="acorn-note">Live CPU/RAM are real (this container). '
'Mac-only signals show sample data — full power runs on your Mac.</div>')
html.append("</div>")
return "\n".join(html)
def render_top_rows():
return [[p["name"], p["mem_gb"], p["cpu"]] for p in SENSORS.top_processes(8)]
_RECLAIM_CACHE = {"items": []}
def render_reclaim():
items = SENSORS.disk_reclaimable()
_RECLAIM_CACHE["items"] = items
total = round(sum(i["gb"] for i in items), 1)
lines = "\n".join(f"- **{i['label']}** — {i['gb']} GB \n `{i['path']}`" for i in items)
return f"### ♻️ ~{total} GB reclaimable\n{lines or '_Nothing notable right now._'}"
def render_clip_rows():
return [["📌" if c.get("pinned") else "", (c.get("preview") or "")[:60],
c.get("count", 1)] for c in top_clips(15)]
def render_shot_rows():
shots = SENSORS.recent_screenshots(12)
for s in shots:
add_shot(s["path"], title=s.get("name", ""), ocr=s.get("name", ""))
return [[s.get("name", os.path.basename(s["path"])), s["path"]] for s in shots]
CATEGORY = {
"Code": "coding", "Visual Studio Code": "coding", "Xcode": "coding",
"iTerm": "coding", "Terminal": "coding", "PyCharm": "coding",
"Slack": "comms", "Mail": "comms", "Messages": "comms",
"Safari": "browsing", "Google Chrome": "browsing", "Arc": "browsing",
"Twitter": "distraction", "X": "distraction", "YouTube": "distraction",
}
def categorize(app: str) -> str:
return CATEGORY.get(app, "other")
def render_usage_rows():
rows = usage_rollup(24)
if not rows: # seed a tiny demo picture so the tab isn't empty on first run
demo = [("Visual Studio Code", "coding", 14400), ("Google Chrome", "browsing", 5400),
("Slack", "comms", 3600), ("Twitter", "distraction", 2100)]
return [[a, c, f"{s//60} min"] for a, c, s in demo]
return [[r["app"], r.get("category", "other"), f"{int(r['secs']//60)} min"] for r in rows]
# ---- callbacks -----------------------------------------------------------
def save_onboarding(name, role, goals, consent, quiet_hour, break_interval=25, strict=False):
global PROFILE
PROFILE = profile_save({
"name": name, "role": role, "goals": goals or [],
"consent": consent, "quiet_hours_start": int(quiet_hour or 21),
"break_interval_min": int(break_interval or 25), "break_strict": bool(strict),
"onboarded": True,
})
mode = "strict" if strict else "skippable"
return (f"🌰 Saved. Acorn will {consent} for {len(goals or [])} habits · "
f"{mode} breathing break every {int(break_interval or 25)} min.")
def refresh_brief():
brief = daily_brief(SENSORS, PROFILE)
cand = nudges(SENSORS, PROFILE)
if not cand:
cards = "_All calm — nothing needs you right now._"
else:
cards = "\n\n".join(
f'<div class="acorn-nudge acorn-{n["severity"]}">'
f'<b>{n["title"]}</b><br><span>{n["body"]}</span><br>'
f'<i>→ {n["action_label"]}</i></div>' for n in cand)
titles = [n["title"] for n in cand]
return brief, cards, gr.update(choices=titles, value=(titles[0] if titles else None)), cand
def apply_nudge(title, cand):
if not title or not cand:
return "Pick a suggestion first."
n = next((x for x in cand if x["title"] == title), None)
if not n:
return "That suggestion is gone — refresh the brief."
aid, args = n["action_id"], n.get("action_args", {})
if aid == "pin_clip":
pin_clip(args.get("clip_id"))
return "📌 Pinned that clip."
ok, msg = run(aid, args, confirm=True)
return ("✅ " if ok else "⚠️ ") + msg
def pulse_refresh():
return render_vitals_html(), render_top_rows(), render_reclaim()
def do_preview_cleanup():
ok, msg = run("preview_cleanup", {"items": _RECLAIM_CACHE["items"]})
return msg
def do_reclaim():
ok, msg = run("reclaim_space", {"items": _RECLAIM_CACHE["items"]}, confirm=True)
return ("✅ " if ok else "⚠️ ") + msg
def capture_clipboard():
text = SENSORS.clipboard_peek()
if text:
count = upsert_clip(text)
return render_clip_rows(), f"Stashed (seen {count}×)."
return render_clip_rows(), "Clipboard was empty."
def search_screenshots(q):
if not q:
return render_shot_rows()
render_shot_rows() # ensure indexed
hits = search_shots(q)
return [[h.get("title") or os.path.basename(h["path"]), h["path"]] for h in hits]
def deep_work(on: bool):
ok, msg = run("deep_work_on" if on else "run_shortcut",
{"name": "Deep Work Off"}, confirm=True)
return ("✅ " if ok else "⚠️ ") + msg
def comfort_action(which):
ok, msg = run(which, {}, confirm=True)
return ("✅ " if ok else "⚠️ ") + msg
def get_config_tips():
return config_suggestions(PROFILE)
def chat_respond(message, history):
history = history or []
reply = answer(message, SENSORS, PROFILE)
history = history + [{"role": "user", "content": message},
{"role": "assistant", "content": reply}]
return history, ""
# ---- Almanac (end-of-day journal) ----
def gen_journal():
md, path = write_journal(SENSORS, PROFILE)
return md, f"_Saved on-device: `{path}`_"
# ---- Companion (async engine) ----
def render_engine_log():
if not ENGINE.log:
return "_No insights delivered yet. Start the companion or run a check._"
rows = list(reversed(ENGINE.log[-12:]))
return "\n".join(f"- `{t}` · {how} · {title}" for (t, title, how) in rows)
def engine_start():
ENGINE.start()
return ENGINE.status(), render_engine_log()
def engine_stop():
ENGINE.stop()
return ENGINE.status(), render_engine_log()
def engine_check():
ENGINE.tick(force=True)
return ENGINE.status(), render_engine_log()
# ---- Spellbook (model -> AppleScript automations) ----
def show_library_spell(name):
sp = get_library(name)
return sp["script"] if sp else ""
def run_library_spell(name):
sp = get_library(name)
if not sp:
return "Pick a spell first."
ok, msg = spell_run(sp["script"], confirm=True)
return msg
def conjure(request):
res = generate(request, PROFILE)
note = ("✅ looks safe" if res.get("safe") else "⚠️ flagged by the validator")
return res["script"], f"**{res['explanation']}** · _{res['source']}_ · {note}", res["script"]
def run_generated(script):
ok, msg = spell_run(script or "", confirm=True)
return msg
def save_generated(name, script):
ok, msg = save_spell(name, script or "")
return msg
# ---- Forced breathing break (scheduled full-window overlay) ----
WORK_STATE = {"last_break": time.time(), "active": False, "started": 0.0}
def break_tick():
"""Runs on a Timer. Decides whether the break overlay should show, and updates it."""
now = time.time()
interval = PROFILE.get("break_interval_min", 25) * 60
dur = PROFILE.get("break_duration_sec", 90)
strict = PROFILE.get("break_strict", False)
if not WORK_STATE["active"]:
if now - WORK_STATE["last_break"] >= interval:
WORK_STATE["active"] = True
WORK_STATE["started"] = now
else:
return (gr.update(visible=False), "", gr.update(visible=True), gr.update(visible=True))
remaining = int(dur - (time.time() - WORK_STATE["started"]))
if remaining <= 0:
WORK_STATE["active"] = False
WORK_STATE["last_break"] = time.time()
return (gr.update(visible=False), "", gr.update(visible=True), gr.update(visible=True))
show = not strict
msg = (f"Resume in {remaining}s — settle in." if strict
else f"{remaining}s of calm · or skip whenever you're ready")
return (gr.update(visible=True), msg, gr.update(visible=show), gr.update(visible=show))
def break_skip():
WORK_STATE["active"] = False
WORK_STATE["last_break"] = time.time()
return gr.update(visible=False)
def break_postpone():
WORK_STATE["active"] = False
WORK_STATE["last_break"] = time.time() - max(0, PROFILE.get("break_interval_min", 25) - 5) * 60
return gr.update(visible=False)
def break_now():
WORK_STATE["active"] = True
WORK_STATE["started"] = time.time()
return gr.update(visible=True)
# ---- UI ------------------------------------------------------------------
CUSTOM_CSS = r"""
/* Acorn — woodland theme (Off-Brand / Custom UI badge) */
.gradio-container { max-width: 920px !important; margin: 0 auto !important; }
.acorn-head { display:flex; align-items:center; gap:14px; padding:8px 4px 2px; }
.acorn-head h1 { margin:0; font-size:1.7rem; color:#3f4a36; letter-spacing:.5px; }
.acorn-head p { margin:0; color:#7a8470; font-size:.9rem; }
.acorn-logo { font-size:2.4rem; filter:drop-shadow(0 2px 3px rgba(90,110,70,.3)); }
.acorn-card {
background:linear-gradient(180deg,#fbf9f3,#f3efe4);
border:1px solid #e3ddcc; border-radius:16px; padding:16px 18px;
}
.acorn-meter { margin:10px 0; }
.acorn-meter-label { display:flex; justify-content:space-between; font-size:.85rem; color:#5b6450; }
.acorn-meter-label span { color:#9aa28c; }
.acorn-bar { height:9px; background:#e6e0d0; border-radius:6px; overflow:hidden; margin-top:5px; }
.acorn-fill { height:100%; border-radius:6px; transition:width .6s cubic-bezier(.2,.7,.3,1); }
.acorn-row { display:flex; justify-content:space-between; padding:6px 2px; border-top:1px dashed #e3ddcc;
font-size:.9rem; color:#5b6450; margin-top:6px; }
.acorn-note { margin-top:10px; font-size:.78rem; color:#a08a5c; background:#faf4e3;
border-radius:10px; padding:8px 10px; }
.acorn-nudge { background:#fbf9f3; border:1px solid #e3ddcc; border-left:4px solid #7a9e6e;
border-radius:12px; padding:12px 14px; margin:8px 0; }
.acorn-nudge span { color:#6b7360; font-size:.9rem; }
.acorn-nudge i { color:#7a9e6e; font-size:.82rem; }
.acorn-warn { border-left-color:#d9a441; }
.acorn-info { border-left-color:#7a9e6e; }
button.primary, .primary button { border-radius:12px !important; }
/* Breathing guide */
.abr-wrap{display:flex;flex-direction:column;align-items:center;gap:22px;padding:26px 0 10px}
.abr-stage{position:relative;display:grid;place-items:center;width:340px;height:340px}
.abr-stage>*{grid-area:1/1}
.abr-ring{width:272px;height:272px;border-radius:50%;border:1px solid rgba(196,105,78,.18);animation:abr-ring 19s cubic-bezier(.37,0,.63,1) infinite}
.abr-orb{width:220px;height:220px;border-radius:50%;background:radial-gradient(circle at 50% 45%, rgba(196,105,78,.30), rgba(217,164,65,.10) 42%, rgba(243,239,228,0) 72%);border:1px solid rgba(196,105,78,.28);box-shadow:0 0 80px rgba(217,164,65,.13);animation:abr-breathe 19s cubic-bezier(.37,0,.63,1) infinite}
.abr-num{height:52px;overflow:hidden;line-height:52px}
.abr-strip{animation:abr-count 19s steps(19,end) infinite}
.abr-strip div{height:52px;font-size:32px;font-weight:300;color:#b07a52;text-align:center}
@keyframes abr-count{to{transform:translateY(-988px)}}
@keyframes abr-breathe{0%{transform:scale(.56)}21%{transform:scale(1)}58%{transform:scale(1)}100%{transform:scale(.56)}}
@keyframes abr-ring{0%{transform:scale(.6);opacity:.22}21%{transform:scale(1.05);opacity:.5}58%{transform:scale(1.05);opacity:.5}100%{transform:scale(.6);opacity:.22}}
.abr-phase{position:relative;height:1.5em;width:300px;text-align:center}
.abr-phase span{position:absolute;inset:0;letter-spacing:.42em;text-transform:uppercase;font-size:1.02rem;color:#6f7a5e;opacity:0;animation:19s linear infinite}
.abr-in{animation-name:abr-lin}.abr-hold{animation-name:abr-lhold}.abr-out{animation-name:abr-lout}
@keyframes abr-lin{0%{opacity:1}19%{opacity:1}23%{opacity:0}100%{opacity:0}}
@keyframes abr-lhold{0%{opacity:0}21%{opacity:0}25%{opacity:1}56%{opacity:1}60%{opacity:0}100%{opacity:0}}
@keyframes abr-lout{0%{opacity:0}58%{opacity:0}62%{opacity:1}98%{opacity:1}100%{opacity:0}}
.abr-cap{color:#a9b09c;font-size:.78rem;letter-spacing:.04em}
@media (prefers-reduced-motion:reduce){.abr-orb,.abr-ring,.abr-strip,.abr-phase span{animation:none}.abr-orb{transform:scale(.85)}.abr-hold{opacity:1}.abr-strip{transform:none}}
/* Full-window forced-break overlay */
#acorn-break{position:fixed;inset:0;z-index:9999;background:rgba(244,240,230,.97);
align-items:center;justify-content:center;gap:6px;padding:40px}
#acorn-break .acorn-break-title h3{color:#5b6450;font-weight:500;text-align:center}
"""
THEME = gr.themes.Soft(primary_hue="green")
# A calm, CSS-only 4-7-8 breathing guide with a phase label + countdown
# (no JS, so it renders inside gr.HTML). The number strip steps once per second.
BREATHE_HTML = """
<div class="abr-wrap">
<div class="abr-stage">
<div class="abr-ring"></div>
<div class="abr-orb"></div>
<div class="abr-num"><div class="abr-strip">
<div>4</div><div>3</div><div>2</div><div>1</div>
<div>7</div><div>6</div><div>5</div><div>4</div><div>3</div><div>2</div><div>1</div>
<div>8</div><div>7</div><div>6</div><div>5</div><div>4</div><div>3</div><div>2</div><div>1</div>
</div></div>
</div>
<div class="abr-phase">
<span class="abr-in">Breathe in</span><span class="abr-hold">Hold</span><span class="abr-out">Breathe out</span>
</div>
<div class="abr-cap">4 · 7 · 8 — follow the circle and the count</div>
</div>
"""
def build_app() -> gr.Blocks:
# Inject CSS as a <style> block so the woodland theme applies regardless of
# how the app is launched (Gradio 6 moved css= to launch()).
with gr.Blocks(title="Acorn") as demo:
gr.HTML(f"<style>{CUSTOM_CSS}</style>")
gr.HTML(
'<div class="acorn-head"><span class="acorn-logo">🌰</span>'
'<div><h1>Acorn</h1><p>your Mac\'s quiet woodland companion · '
'<b>100% on-device</b></p></div></div>')
model = info()
runtime_label = ("🖥️ Mac (full power)" if not SENSORS.demo
else "🧪 Demo mode (deploy/Linux)")
model_label = (f"🦙 model: {model['model']} (live)" if model["available"]
else "💤 no local model — using built-in rules (works fine)")
gr.Markdown(f"**Runtime:** {runtime_label} · **{model_label}**")
with gr.Tabs():
# ---- Brief -------------------------------------------------
with gr.Tab("🌰 Brief"):
brief_md = gr.Markdown("_Press refresh for your brief._")
brief_btn = gr.Button("Refresh brief & nudges", variant="primary")
nudge_html = gr.HTML()
with gr.Row():
nudge_pick = gr.Dropdown(label="Apply a suggestion", choices=[])
nudge_apply = gr.Button("Do it")
nudge_result = gr.Markdown()
nudge_state = gr.State([])
brief_btn.click(refresh_brief, None,
[brief_md, nudge_html, nudge_pick, nudge_state],
api_name="refresh_brief")
nudge_apply.click(apply_nudge, [nudge_pick, nudge_state], nudge_result)
# ---- Pulse -------------------------------------------------
with gr.Tab("🫀 Pulse"):
gr.Markdown("System health & power.")
pulse_btn = gr.Button("Refresh vitals", variant="primary")
vitals_html = gr.HTML()
gr.Markdown("**Top processes**")
top_df = gr.Dataframe(headers=["Process", "Mem (GB)", "CPU %"],
interactive=False, wrap=True)
reclaim_md = gr.Markdown()
with gr.Row():
prev_btn = gr.Button("Preview cleanup")
reclaim_btn = gr.Button("Reclaim space (confirm)", variant="stop")
reclaim_out = gr.Markdown()
pulse_btn.click(pulse_refresh, None, [vitals_html, top_df, reclaim_md],
api_name="pulse")
prev_btn.click(do_preview_cleanup, None, reclaim_out)
reclaim_btn.click(do_reclaim, None, reclaim_out)
# ---- Stash -------------------------------------------------
with gr.Tab("🐿️ Stash"):
gr.Markdown("Clipboard memory + screenshot inbox.")
with gr.Row():
cap_btn = gr.Button("Capture current clipboard", variant="primary")
cap_msg = gr.Markdown()
clip_df = gr.Dataframe(headers=["", "Clip", "×"], interactive=False, wrap=True)
gr.Markdown("**Screenshots** — searchable by their text/title")
with gr.Row():
shot_q = gr.Textbox(label="Find a screenshot", placeholder="e.g. Stripe")
shot_btn = gr.Button("Search")
shot_df = gr.Dataframe(headers=["Screenshot", "Path"], interactive=False, wrap=True)
cap_btn.click(capture_clipboard, None, [clip_df, cap_msg],
api_name="capture_clipboard")
shot_btn.click(search_screenshots, shot_q, shot_df)
# ---- Trails ------------------------------------------------
with gr.Tab("🧭 Trails"):
gr.Markdown("Where your time goes · focus on demand.")
trails_btn = gr.Button("Refresh time map", variant="primary")
usage_df = gr.Dataframe(headers=["App", "Category", "Time"],
interactive=False, wrap=True)
with gr.Row():
dw_on = gr.Button("Start Deep Work")
dw_off = gr.Button("End Deep Work")
dw_msg = gr.Markdown()
trails_btn.click(lambda: render_usage_rows(), None, usage_df,
api_name="trails")
dw_on.click(lambda: deep_work(True), None, dw_msg, api_name="deep_work_on")
dw_off.click(lambda: deep_work(False), None, dw_msg, api_name="deep_work_off")
# ---- Comfort -----------------------------------------------
with gr.Tab("☀️ Comfort"):
gr.Markdown("Eye comfort + setup tips for your role.")
with gr.Row():
warm_btn = gr.Button("Warm my screen", variant="primary")
dark_btn = gr.Button("Toggle Dark Mode")
comfort_msg = gr.Markdown()
gr.Markdown("**People in your role configure…**")
tips_btn = gr.Button("Suggest setup tweaks")
tips_md = gr.Markdown()
warm_btn.click(lambda: comfort_action("warm_screen"), None, comfort_msg,
api_name="warm_screen")
dark_btn.click(lambda: comfort_action("dark_mode_toggle"), None, comfort_msg,
api_name="dark_mode")
tips_btn.click(get_config_tips, None, tips_md)
# ---- Breathe ----------------------------------------------
with gr.Tab("🌬️ Breathe"):
gr.Markdown("A slow **4·7·8** breath — in for 4, hold for 7, out for 8. Acorn also pops "
"this up **full-screen on a schedule** (interval set in Setup) so you "
"actually pause — skippable, or strict if you want no escape.")
gr.HTML(BREATHE_HTML)
try_break = gr.Button("Try a forced break now (preview)")
# ---- Ask ---------------------------------------------------
with gr.Tab("💬 Ask Acorn"):
gr.Markdown("Ask anything about your Mac — answered from local data only.")
chatbot = gr.Chatbot(height=320)
msg = gr.Textbox(placeholder="what's eating my memory?", show_label=False)
msg.submit(chat_respond, [msg, chatbot], [chatbot, msg], api_name="ask")
# ---- Almanac (end-of-day journal) --------------------------
with gr.Tab("📔 Almanac"):
gr.Markdown("Your **end-of-day journal** — the model writes a diary of your Mac "
"from today's local memory. Written on-device; never leaves it.")
alm_btn = gr.Button("Write today's journal", variant="primary")
alm_md = gr.Markdown()
alm_path = gr.Markdown()
alm_btn.click(gen_journal, None, [alm_md, alm_path], api_name="journal")
# ---- Companion (async insight engine) ----------------------
with gr.Tab("🔔 Companion"):
gr.Markdown("Acorn watching in the **background**, surfacing the right nudge at the "
"right time — delivered as macOS notifications + this feed.")
comp_status = gr.Markdown(ENGINE.status())
with gr.Row():
comp_start = gr.Button("Start companion", variant="primary")
comp_stop = gr.Button("Stop")
comp_check = gr.Button("Run a check now")
gr.Markdown("**Delivered insights**")
comp_feed = gr.Markdown(render_engine_log())
comp_start.click(engine_start, None, [comp_status, comp_feed],
api_name="companion_start")
comp_stop.click(engine_stop, None, [comp_status, comp_feed],
api_name="companion_stop")
comp_check.click(engine_check, None, [comp_status, comp_feed],
api_name="companion_check")
# ---- Spellbook (model -> AppleScript automations) ----------
with gr.Tab("🪄 Spellbook"):
gr.Markdown("The local model writes **macOS automations** for you. You always see "
"the script and approve it before anything runs.")
gr.Markdown("**Library — pre-vetted spells** (safe, reversible)")
with gr.Row():
spell_pick = gr.Dropdown(library_names(), label="Pick a spell")
spell_show = gr.Button("Show script")
spell_run = gr.Button("Run spell", variant="primary")
lib_code = gr.Code(label="AppleScript")
lib_msg = gr.Markdown()
spell_show.click(show_library_spell, spell_pick, lib_code)
spell_run.click(run_library_spell, spell_pick, lib_msg, api_name="run_spell")
gr.Markdown("**Conjure — describe an automation in plain English**")
conj_in = gr.Textbox(label="e.g. tile my two front windows side by side")
conj_btn = gr.Button("Conjure automation", variant="primary")
gen_code = gr.Code(label="Generated AppleScript (review before running)")
gen_expl = gr.Markdown()
gen_state = gr.State("")
with gr.Row():
gen_run = gr.Button("Run it", variant="stop")
gen_name = gr.Textbox(label="Save as", scale=2)
gen_save = gr.Button("Save spell")
gen_msg = gr.Markdown()
conj_btn.click(conjure, conj_in, [gen_code, gen_expl, gen_state], api_name="conjure")
gen_run.click(run_generated, gen_state, gen_msg, api_name="run_generated")
gen_save.click(save_generated, [gen_name, gen_state], gen_msg)
gr.Markdown("_Safety: a validator blocks shell-out, deletion, keystrokes, volume/power, "
"and anything destructive before it can run. Window automations need macOS "
"Accessibility permission (granted once in System Settings)._")
# ---- Settings ----------------------------------------------
with gr.Tab("⚙️ Setup"):
gr.Markdown("### Onboarding — tell Acorn what to learn")
name_in = gr.Textbox(label="Your name (optional)", value=PROFILE.get("name", ""))
role_in = gr.Textbox(label="Your role", value=PROFILE.get("role", ""))
goals_in = gr.CheckboxGroup(GOAL_CHOICES, label="What should I learn?",
value=PROFILE.get("goals", []))
consent_in = gr.Radio(CONSENT_LEVELS, label="How forward should I be?",
value=PROFILE.get("consent", "suggest"),
info="observe = silent · suggest = nudge · act = one-tap")
quiet_in = gr.Slider(18, 23, step=1, label="Warm my screen after (hour)",
value=PROFILE.get("quiet_hours_start", 21))
gr.Markdown("**Breathing breaks**")
interval_in = gr.Slider(1, 90, step=1, label="Force a breathing break every (minutes)",
value=PROFILE.get("break_interval_min", 25),
info="A full-screen 4·7·8 break appears after this much continuous use.")
strict_in = gr.Checkbox(label="Strict mode — no skip until the break finishes",
value=PROFILE.get("break_strict", False))
save_btn = gr.Button("Save", variant="primary")
save_msg = gr.Markdown()
save_btn.click(save_onboarding,
[name_in, role_in, goals_in, consent_in, quiet_in, interval_in, strict_in],
save_msg, api_name="save_setup")
gr.Markdown("_Privacy: everything is stored in `~/.acorn` on this machine. "
"Acorn makes no network calls._")
# ---- Forced break overlay — covers the window, driven by a Timer ----
with gr.Column(visible=False, elem_id="acorn-break") as break_overlay:
gr.Markdown("### 🌬️ Time for a breath", elem_classes="acorn-break-title")
gr.HTML(BREATHE_HTML)
break_remaining = gr.Markdown("")
with gr.Row():
break_postpone_btn = gr.Button("Postpone 5 min")
break_skip_btn = gr.Button("Skip", variant="primary")
break_ticker = gr.Timer(2)
break_ticker.tick(break_tick, None,
[break_overlay, break_remaining, break_skip_btn, break_postpone_btn])
break_skip_btn.click(break_skip, None, break_overlay)
break_postpone_btn.click(break_postpone, None, break_overlay)
try_break.click(break_now, None, break_overlay)
return demo
# ---- optional background sampler (real usage learning) -------------------
def start_sampler(interval=15):
def loop():
last_app, last_t = None, time.time()
while True:
try:
v = SENSORS.vitals()
record_sample(v)
app = SENSORS.frontmost_app()
now = time.time()
if last_app:
add_appusage(last_app, now - last_t, categorize(last_app))
last_app, last_t = app, now
except Exception:
pass
time.sleep(interval)
t = threading.Thread(target=loop, daemon=True)
t.start()
return t
# Module-level app for Hugging Face Spaces (Spaces looks for `demo`).
demo = build_app()
if __name__ == "__main__":
if os.environ.get("ACORN_NO_SAMPLER") != "1":
start_sampler()
demo.queue().launch(css=CUSTOM_CSS, theme=THEME,
inbrowser=os.environ.get("SPACE_ID") is None)