From ca5984253564a57e0292fbde5f733134f8b414c7 Mon Sep 17 00:00:00 2001 From: rishithc Date: Sat, 13 Jun 2026 10:43:44 -0700 Subject: [PATCH 1/5] feat: scaffold pegasus services + agent rules + iMessage bridge Runnable stubs for all four services against shared/contract.md: - ml (:8003) TRIBE v2 predictor (stub), deviation scoring, Claude intervention - backend (:8001) orchestrator + SQLite, fans out to signals/ml, degrades gracefully - signals (:8002) Claude sentiment + typing dynamics, Twilio alerts, collector.js - frontend (:3000) Vite React check-in flow + Check Engine Light Plus: - shared/contract.{md,py} as the source of truth - per-folder AGENTS.md + .claude/agents/* so each teammate's agent stays in its lane - standalone imessage/ bridge (AppleScript send + chat.db poll, macOS, owner TBD) - dev.sh to set up + run everything Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/dhruva-signals.md | 21 +++++ .claude/agents/imessage-bridge.md | 27 ++++++ .claude/agents/jason-backend.md | 22 +++++ .claude/agents/rishith-ml.md | 24 +++++ .claude/agents/wesley-frontend.md | 23 +++++ .gitignore | 5 + AGENTS.md | 37 ++++++++ README.md | 26 ++++++ backend/.env.example | 4 + backend/AGENTS.md | 38 ++++++++ backend/db.py | 89 ++++++++++++++++++ backend/main.py | 88 ++++++++++++++++++ backend/orchestrator.py | 82 +++++++++++++++++ backend/requirements.txt | 5 + backend/stimuli.py | 32 +++++++ dev.sh | 46 ++++++++++ frontend/AGENTS.md | 34 +++++++ frontend/index.html | 12 +++ frontend/package.json | 19 ++++ frontend/src/App.jsx | 96 +++++++++++++++++++ frontend/src/EngineLight.jsx | 45 +++++++++ frontend/src/api.js | 21 +++++ frontend/src/index.css | 103 +++++++++++++++++++++ frontend/src/keystroke.js | 35 +++++++ frontend/src/main.jsx | 10 ++ frontend/vite.config.js | 17 ++++ imessage/.env.example | 4 + imessage/AGENTS.md | 26 ++++++ imessage/README.md | 52 +++++++++++ imessage/bridge.py | 148 ++++++++++++++++++++++++++++++ imessage/imessage.py | 105 +++++++++++++++++++++ imessage/registry.py | 61 ++++++++++++ imessage/requirements.txt | 4 + imessage/send.applescript | 16 ++++ ml/.env.example | 2 + ml/AGENTS.md | 36 ++++++++ ml/intervention.py | 61 ++++++++++++ ml/main.py | 75 +++++++++++++++ ml/requirements.txt | 8 ++ ml/scoring.py | 51 ++++++++++ ml/tribe.py | 53 +++++++++++ shared/AGENTS.md | 8 ++ shared/contract.md | 102 ++++++++++++++++++++ shared/contract.py | 79 ++++++++++++++++ signals/.env.example | 5 + signals/AGENTS.md | 37 ++++++++ signals/alerts.py | 31 +++++++ signals/collector.js | 37 ++++++++ signals/main.py | 74 +++++++++++++++ signals/requirements.txt | 6 ++ signals/sentiment.py | 83 +++++++++++++++++ 51 files changed, 2125 insertions(+) create mode 100644 .claude/agents/dhruva-signals.md create mode 100644 .claude/agents/imessage-bridge.md create mode 100644 .claude/agents/jason-backend.md create mode 100644 .claude/agents/rishith-ml.md create mode 100644 .claude/agents/wesley-frontend.md create mode 100644 AGENTS.md create mode 100644 backend/.env.example create mode 100644 backend/AGENTS.md create mode 100644 backend/db.py create mode 100644 backend/main.py create mode 100644 backend/orchestrator.py create mode 100644 backend/requirements.txt create mode 100644 backend/stimuli.py create mode 100755 dev.sh create mode 100644 frontend/AGENTS.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/EngineLight.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/keystroke.js create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js create mode 100644 imessage/.env.example create mode 100644 imessage/AGENTS.md create mode 100644 imessage/README.md create mode 100644 imessage/bridge.py create mode 100644 imessage/imessage.py create mode 100644 imessage/registry.py create mode 100644 imessage/requirements.txt create mode 100644 imessage/send.applescript create mode 100644 ml/.env.example create mode 100644 ml/AGENTS.md create mode 100644 ml/intervention.py create mode 100644 ml/main.py create mode 100644 ml/requirements.txt create mode 100644 ml/scoring.py create mode 100644 ml/tribe.py create mode 100644 shared/AGENTS.md create mode 100644 shared/contract.md create mode 100644 shared/contract.py create mode 100644 signals/.env.example create mode 100644 signals/AGENTS.md create mode 100644 signals/alerts.py create mode 100644 signals/collector.js create mode 100644 signals/main.py create mode 100644 signals/requirements.txt create mode 100644 signals/sentiment.py diff --git a/.claude/agents/dhruva-signals.md b/.claude/agents/dhruva-signals.md new file mode 100644 index 0000000..f4a9269 --- /dev/null +++ b/.claude/agents/dhruva-signals.md @@ -0,0 +1,21 @@ +--- +name: dhruva-signals +description: Dhruva's signals agent for Pegasus. Use for work in /signals — behavioral sentiment analysis (Claude), typing dynamics, the :8002 FastAPI service, Twilio SMS alerts, and the collector.js keystroke tracker. MUST stay inside signals/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Dhruva**, the signals engineer on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `signals/`. Never touch +`frontend/`, `backend/`, `ml/`, or `shared/`. Ask Jason for contract changes. + +**Your service** (FastAPI on :8002): `/health`, `/signals/analyze` (Claude +`claude-sonnet-4-6` sentiment + energy + flags + `combined_signal_score`, with a +lexical fallback when no API key), `/signals/{user_id}`, `/alert` (Twilio SMS on +red; no-ops to console when creds are absent so the demo never crashes). + +You also own `signals/collector.js` (`KeystrokeTracker`), which the frontend +imports — a synced copy lives at `frontend/src/keystroke.js`. Keep them in sync. + +Read `signals/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `signals/`. Commit to `feat/dhruva-sig`, PR into `dev`. diff --git a/.claude/agents/imessage-bridge.md b/.claude/agents/imessage-bridge.md new file mode 100644 index 0000000..f5ce526 --- /dev/null +++ b/.claude/agents/imessage-bridge.md @@ -0,0 +1,27 @@ +--- +name: imessage-bridge +description: Agent for the standalone /imessage bridge. Use for work on the macOS iMessage delivery channel — AppleScript send, chat.db polling, the bridge CLI/loop, phone↔user registry. MUST stay inside imessage/ and only call the backend API. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You own the **`imessage/` bridge** — a standalone macOS delivery channel for +Pegasus (owner TBD; not one of the four service lanes). + +**Hard boundary:** ONLY create/edit files inside `imessage/`. Never touch +`frontend/`, `backend/`, `ml/`, `signals/`, or `shared/`. Talk to the **backend +only** (:8001): `POST /users`, `GET /stimulus/today`, `POST /checkin`. + +**How it works (macOS, no API):** send iMessages via AppleScript driving +Messages.app (`send.applescript` + `imessage.send`); receive by polling +`~/Library/Messages/chat.db` read-only (`imessage.fetch_new`), decoding +`attributedBody` when the `text` column is NULL. `bridge.py` provides +`register` / `send` / `send-daily` / `listen`. `registry.py` maps phone ⇄ +user_id and auto-registers new senders. + +**Constraints to respect:** needs Full Disk Access + a signed-in iMessage +account on the Mac; AppleScript send syntax is macOS-version-sensitive; no +keystroke dynamics over iMessage (typing metrics sent as 0). Keep it runnable +and never replay old chat history (start from current max ROWID). + +Read `imessage/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `imessage/`. diff --git a/.claude/agents/jason-backend.md b/.claude/agents/jason-backend.md new file mode 100644 index 0000000..75075da --- /dev/null +++ b/.claude/agents/jason-backend.md @@ -0,0 +1,22 @@ +--- +name: jason-backend +description: Jason's backend agent for Pegasus. Use for work in /backend and /shared — the :8001 orchestrator, SQLite persistence, daily stimuli, and the API contract. MUST stay inside backend/ and shared/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Jason**, the backend engineer and contract owner on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `backend/` and +`shared/`. Never touch `frontend/`, `ml/`, or `signals/` — call their HTTP APIs. + +**Your service** (FastAPI on :8001) is the orchestrator and the ONLY service +the frontend talks to: `/health`, `/users`, `/stimulus/today`, `/checkin` +(fans out to Signals :8002 + ML :8003, persists, alerts on red), `/status/{id}`. + +**You own the contract.** `shared/contract.md` is the source of truth. When you +change it, note which service owner is affected so they stay shape-compatible. +Keep `orchestrator.py` degrading gracefully when a downstream service is down — +the demo must never hard-fail. + +Read `backend/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `backend/` and `shared/`. Commit to `feat/jason-api`, PR into `dev`. diff --git a/.claude/agents/rishith-ml.md b/.claude/agents/rishith-ml.md new file mode 100644 index 0000000..ccc0132 --- /dev/null +++ b/.claude/agents/rishith-ml.md @@ -0,0 +1,24 @@ +--- +name: rishith-ml +description: Rishith's ML agent for Pegasus. Use for any work in the /ml folder — TRIBE v2 brain prediction, deviation/burnout scoring, Claude interventions, the :8003 FastAPI service. MUST stay inside ml/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Rishith**, the ML engineer on the Pegasus hackathon team. + +**Hard boundary:** You may ONLY create/edit files inside `ml/`. Never touch +`frontend/`, `backend/`, `signals/`, or `shared/`. If you need something from +another service, call its HTTP API or read (never edit) `shared/contract.md`. +If you need a contract change, stop and report it for Jason — don't edit shared/. + +**Your service** (FastAPI on :8003): `/health`, `/predict` (TRIBE v2 healthy- +brain prediction), `/score` (deviation → 0-100 burnout + level), `/intervention` +(Claude `claude-sonnet-4-6`, with offline fallback). + +**Primary objective:** `ml/tribe.py` currently ships a deterministic STUB +(`TribePredictor._infer`). Your headline task is wiring real TRIBE v2 inference +there while keeping the same return shape so the rest of the pipeline is +unaffected. Keep `GET /health` green and the service runnable at every commit. + +Read `ml/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` before working. +Stage only `ml/`. Commit to `feat/rishith-ml`, PR into `dev`. diff --git a/.claude/agents/wesley-frontend.md b/.claude/agents/wesley-frontend.md new file mode 100644 index 0000000..90f4f16 --- /dev/null +++ b/.claude/agents/wesley-frontend.md @@ -0,0 +1,23 @@ +--- +name: wesley-frontend +description: Wesley's frontend agent for Pegasus. Use for work in /frontend — the React (Vite) UI on :3000, the check-in flow, keystroke capture, and the Check Engine Light component. MUST stay inside frontend/. +tools: Read, Write, Edit, Bash, Grep, Glob +--- + +You are **Wesley**, the frontend engineer on the Pegasus team. + +**Hard boundary:** You may ONLY create/edit files inside `frontend/`. Never +touch `backend/`, `ml/`, `signals/`, or `shared/`. You talk to the **backend +only** (:8001) through the Vite `/api` proxy (already configured in +`vite.config.js`) — never call ml/signals directly. + +**The UI flow:** create/get user → `GET /api/stimulus/today` → show stimulus → +capture response with `KeystrokeTracker` (`src/keystroke.js`) → `POST +/api/checkin` → render the Check Engine Light (🟢/🟡/🔴) + intervention via +`EngineLight.jsx`. Keep `src/keystroke.js` in sync with Dhruva's +`signals/collector.js`. + +Run with `npm install && npm run dev` (port 3000). Keep it building. + +Read `frontend/AGENTS.md`, `AGENTS.md`, and `shared/contract.md` first. +Stage only `frontend/`. Commit to `feat/wesley-ui`, PR into `dev`. diff --git a/.gitignore b/.gitignore index 4a533e1..940e919 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ wandb/ # Logs *.log + +# iMessage bridge runtime (registry.json holds phone numbers — never commit) +imessage/.state.json +imessage/registry.json +imessage/*.scpt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f3c8a80 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Pegasus — Agent Rules (root) + +Read this, then read the `AGENTS.md` in **your** folder. Every teammate runs +their own coding agent; these files keep agents in their lane. + +## What Pegasus is +A mental-health "check engine light." A daily stimulus is sent to the user → +they respond (text + typing/timing) → **TRIBE v2** predicts how a *healthy* +brain responds to that stimulus → we score the deviation between healthy +prediction and the user's real behavioral signal → 🟢/🟡/🔴 + a Claude +intervention → SMS alert if red. + +## Services +| Folder | Owner | Stack | Port | +|-------------|---------|--------------------|------| +| `frontend/` | Wesley | React (Vite) | 3000 | +| `backend/` | Jason | FastAPI + SQLite | 8001 | +| `signals/` | Dhruva | FastAPI | 8002 | +| `ml/` | Rishith | FastAPI + TRIBE v2 | 8003 | +| `shared/` | Jason | API contract | — | +| `imessage/` | TBD | macOS iMessage bridge (AppleScript + chat.db) | — | + +## The 5 rules (non-negotiable) +1. **Stay in your folder.** Only edit the folder(s) you own (table above). + Need something from another folder? **Call its API or import it — never edit it.** +2. **The contract is law.** `shared/contract.md` is the single source of truth. + Need a field changed? Change it there first (Jason), then tell the owner. +3. **Branch + PR.** Work on `feat/-*`, PR into `dev`. Never push to `main`. + Stage only your folder: `git add ml/` — never `git add .`. +4. **Stay runnable.** Your service must start on its port and answer `GET /health`. +5. **Merge to `dev` every ~45 min** so integration never drifts far. + +## Run everything +```bash +./dev.sh # starts all 4 services (see script for per-service commands) +``` +Backend is the only service the frontend talks to; backend fans out to signals + ml. diff --git a/README.md b/README.md index 4b1bae5..01a6741 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,31 @@ # pegasus +A mental-health **check engine light**. A daily stimulus goes to the user → +they respond (text + typing/timing) → **TRIBE v2** predicts how a *healthy* +brain responds to that stimulus → we score the deviation from the user's actual +behavioral signal → 🟢/🟡/🔴 + a Claude intervention → SMS alert if red. + +``` +frontend (Wesley) React :3000 ── talks only to backend +backend (Jason) FastAPI+SQLite :8001 ── orchestrator, fans out ↓ +signals (Dhruva) FastAPI :8002 ── sentiment + typing + Twilio +ml (Rishith) FastAPI+TRIBE :8003 ── predict · score · intervention +imessage (TBD) AppleScript + chat.db ── optional iMessage delivery (macOS) +``` + +## Quickstart + +```bash +./dev.sh # sets up venvs + node_modules on first run, then starts all 4 +# → http://localhost:3000 +``` + +Optional iMessage channel (macOS only): see [`imessage/README.md`](imessage/README.md). + +API contract: [`shared/contract.md`](shared/contract.md). Agent rules: +[`AGENTS.md`](AGENTS.md) (root) + one per folder. Dispatchable subagents live in +`.claude/agents/`. + ## Branching model ``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..37add83 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +# Backend service (Jason). Copy to .env and adjust. +SIGNALS_URL=http://localhost:8002 +ML_URL=http://localhost:8003 +PEGASUS_DB=pegasus.db diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..26c998f --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,38 @@ +# `/backend` + `/shared` — Jason's agent + +You are **Jason**. You own **`backend/` and `shared/`**. You build the +orchestrator + persistence on **:8001**, and you are the keeper of the API +contract. Read `../AGENTS.md` first. + +## Never touch +`/frontend`, `/ml`, `/signals`. Call their APIs (you already do, in +`orchestrator.py`). + +## Your endpoints (:8001) — the ONLY service the frontend calls +- `GET /health` +- `POST /users`, `GET /users/{id}` +- `GET /stimulus/today?user_id=` → daily stimulus +- `POST /checkin` → fans out to Signals + ML, stores result, alerts on red +- `GET /status/{user_id}` → latest + history + +## Files +- `main.py` — FastAPI routes. +- `orchestrator.py` — calls Signals (:8002) + ML (:8003), degrades gracefully. +- `db.py` — SQLite (stdlib), tables `users` + `checkins`. +- `stimuli.py` — deterministic daily stimulus pool. +- `../shared/contract.{md,py}` — **source of truth.** When you change it, ping + the affected owner. + +## Run +```bash +cd backend && python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8001 +``` + +## Git +```bash +git checkout feat/jason-api +git add backend/ shared/ # ONLY these +git commit -m "feat(backend): ..." && git push origin feat/jason-api +``` diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..8842a2b --- /dev/null +++ b/backend/db.py @@ -0,0 +1,89 @@ +"""SQLite persistence for Pegasus backend. Stdlib only, no ORM.""" +from __future__ import annotations + +import json +import os +import sqlite3 +import uuid +from typing import Dict, List, Optional + +DB_PATH = os.getenv("PEGASUS_DB", os.path.join(os.path.dirname(__file__), "pegasus.db")) + + +def _conn() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with _conn() as c: + c.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + phone TEXT + ); + CREATE TABLE IF NOT EXISTS checkins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + stimulus_id TEXT, + score INTEGER, + level TEXT, + deviation REAL, + signal_json TEXT, + intervention_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """ + ) + + +def create_user(name: str, phone: Optional[str]) -> Dict: + user_id = uuid.uuid4().hex[:12] + with _conn() as c: + c.execute( + "INSERT INTO users (user_id, name, phone) VALUES (?, ?, ?)", + (user_id, name, phone), + ) + return {"user_id": user_id, "name": name, "phone": phone} + + +def get_user(user_id: str) -> Optional[Dict]: + with _conn() as c: + row = c.execute("SELECT * FROM users WHERE user_id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def save_checkin(user_id: str, stimulus_id: str, score: Dict, signal: Dict, intervention: Dict) -> None: + with _conn() as c: + c.execute( + """INSERT INTO checkins + (user_id, stimulus_id, score, level, deviation, signal_json, intervention_json) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + ( + user_id, + stimulus_id, + score.get("score"), + score.get("level"), + score.get("deviation"), + json.dumps(signal), + json.dumps(intervention), + ), + ) + + +def get_history(user_id: str, limit: int = 30) -> List[Dict]: + with _conn() as c: + rows = c.execute( + "SELECT * FROM checkins WHERE user_id = ? ORDER BY id DESC LIMIT ?", + (user_id, limit), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["signal"] = json.loads(d.pop("signal_json") or "{}") + d["intervention"] = json.loads(d.pop("intervention_json") or "{}") + out.append(d) + return out diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..60e822f --- /dev/null +++ b/backend/main.py @@ -0,0 +1,88 @@ +"""Pegasus Backend — orchestrator + persistence. Frontend talks ONLY to this. + +Run (from inside /backend): + uvicorn main:app --reload --port 8001 +""" +from __future__ import annotations + +from typing import Optional + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +import db +import orchestrator +import stimuli + +app = FastAPI(title="Pegasus Backend", version="0.1.0") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], +) + + +@app.on_event("startup") +def _startup(): + db.init_db() + + +class UserIn(BaseModel): + name: str + phone: Optional[str] = None + + +class CheckInIn(BaseModel): + user_id: str + stimulus_id: str + text_response: str + response_time_ms: int = 0 + typing_wpm: int = 0 + error_rate: float = 0.0 + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/users") +def create_user(body: UserIn): + return db.create_user(body.name, body.phone) + + +@app.get("/users/{user_id}") +def get_user(user_id: str): + user = db.get_user(user_id) + if not user: + raise HTTPException(404, "user not found") + return user + + +@app.get("/stimulus/today") +def stimulus_today(user_id: str = "demo"): + return stimuli.stimulus_for(user_id) + + +@app.post("/checkin") +def checkin(body: CheckInIn): + user = db.get_user(body.user_id) + phone = user.get("phone") if user else None + stimulus = stimuli.stimulus_for(body.user_id) + result = orchestrator.run_checkin(body.model_dump(), stimulus, phone) + db.save_checkin(body.user_id, body.stimulus_id, + {"score": result["score"], "level": result["level"], "deviation": result["deviation"]}, + result["signal"], result["intervention"]) + return result + + +@app.get("/status/{user_id}") +def status(user_id: str): + history = db.get_history(user_id) + return {"latest": history[0] if history else None, "history": history} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/backend/orchestrator.py b/backend/orchestrator.py new file mode 100644 index 0000000..d1e63f9 --- /dev/null +++ b/backend/orchestrator.py @@ -0,0 +1,82 @@ +"""Orchestrates a check-in across Signals (:8002) and ML (:8003). + +Each downstream call is defensive: if a service is down, we degrade instead of +500ing the whole pipeline (important for a live demo). +""" +from __future__ import annotations + +import os +from typing import Dict + +import httpx + +SIGNALS_URL = os.getenv("SIGNALS_URL", "http://localhost:8002") +ML_URL = os.getenv("ML_URL", "http://localhost:8003") +TIMEOUT = float(os.getenv("PEGASUS_HTTP_TIMEOUT", "30")) + + +def _post(url: str, payload: Dict) -> Dict: + r = httpx.post(url, json=payload, timeout=TIMEOUT) + r.raise_for_status() + return r.json() + + +def run_checkin(checkin: Dict, stimulus: Dict, phone: str | None) -> Dict: + # 1. Behavioral signal (Signals service) + try: + signal = _post(f"{SIGNALS_URL}/signals/analyze", { + "user_id": checkin["user_id"], + "text": checkin.get("text_response", ""), + "typing_wpm": checkin.get("typing_wpm", 0), + "error_rate": checkin.get("error_rate", 0), + "response_time_ms": checkin.get("response_time_ms", 0), + }) + except Exception: + signal = {"sentiment_score": 0.5, "energy_level": "medium", + "flags": ["signals_unavailable"], "combined_signal_score": 0.5} + + # 2. Healthy-brain prediction (ML / TRIBE v2) + try: + prediction = _post(f"{ML_URL}/predict", {"stimulus": stimulus}) + except Exception: + prediction = {"predicted_engagement": 0.7, "predicted_valence": 0.7, "activation_summary": []} + + # 3. Deviation -> burnout score (ML) + try: + burnout = _post(f"{ML_URL}/score", { + "stimulus": stimulus, "prediction": prediction, + "signal": signal, "checkin": checkin, + }) + except Exception: + burnout = {"score": 0, "level": "green", "deviation": 0.0, "components": {}} + + # 4. Intervention (ML / Claude) + try: + intervention = _post(f"{ML_URL}/intervention", { + "burnout": burnout, "recent_signals": [signal], + }) + except Exception: + intervention = {"message": "Check-in recorded.", "suggested_action": "Take a short break."} + + # 5. Alert on red (Signals / Twilio) + alerted = False + if burnout.get("level") == "red" and phone: + try: + res = _post(f"{SIGNALS_URL}/alert", { + "user_id": checkin["user_id"], "phone": phone, + "score": burnout.get("score"), "level": "red", + "intervention": intervention.get("message", ""), + }) + alerted = bool(res.get("sent")) + except Exception: + alerted = False + + return { + "score": burnout.get("score"), + "level": burnout.get("level"), + "deviation": burnout.get("deviation"), + "components": burnout.get("components", {}), + "signal": signal, + "intervention": intervention, + "alerted": alerted, + } diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1a4e6e0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +httpx==0.28.1 +python-dotenv==1.0.1 diff --git a/backend/stimuli.py b/backend/stimuli.py new file mode 100644 index 0000000..ab2cad0 --- /dev/null +++ b/backend/stimuli.py @@ -0,0 +1,32 @@ +"""Daily stimulus pool. Picks one stimulus per user per day, deterministically +(so a user sees the same prompt all day, and the demo is reproducible).""" +from __future__ import annotations + +import datetime +import hashlib +from typing import Dict, List + +POOL: List[Dict] = [ + {"stimulus_id": "s1", "type": "text", + "content": "A quiet morning, steam rising off a coffee cup by a rain-streaked window.", + "prompt": "Describe what this scene makes you feel, in a few sentences."}, + {"stimulus_id": "s2", "type": "text", + "content": "An empty inbox and a clear calendar for the rest of the afternoon.", + "prompt": "What's the first thing you'd do with this time?"}, + {"stimulus_id": "s3", "type": "text", + "content": "A friend texts: 'Haven't heard from you in a while — you good?'", + "prompt": "Write the reply you'd actually send right now."}, + {"stimulus_id": "s4", "type": "text", + "content": "Sunlight on a trail, the trees just starting to turn.", + "prompt": "What would you want to be doing in this moment?"}, + {"stimulus_id": "s5", "type": "text", + "content": "You just finished something you'd been putting off for weeks.", + "prompt": "How does that land for you? Say more than 'good'."}, +] + + +def stimulus_for(user_id: str, day: datetime.date | None = None) -> Dict: + day = day or datetime.date.today() + seed = f"{user_id}|{day.isoformat()}" + idx = int(hashlib.sha256(seed.encode()).hexdigest(), 16) % len(POOL) + return POOL[idx] diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..85bf9e7 --- /dev/null +++ b/dev.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Start all of Pegasus locally. First run sets up venvs + node_modules. +# ./dev.sh # set up (if needed) + run everything +# ./dev.sh setup # just install deps, don't run +set -euo pipefail +cd "$(dirname "$0")" + +PIDS=() +cleanup() { echo; echo "Stopping…"; for p in "${PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; done; } +trap cleanup EXIT INT TERM + +py_service() { # name folder port + local name=$1 folder=$2 port=$3 + pushd "$folder" >/dev/null + [ -d .venv ] || python3 -m venv .venv + ./.venv/bin/pip install -q -r requirements.txt + if [ "${1:-}" != "__setup__" ] && [ "$RUN" = "1" ]; then + echo "→ $name on :$port" + ./.venv/bin/uvicorn main:app --port "$port" & + PIDS+=($!) + fi + popd >/dev/null +} + +RUN=1 +[ "${1:-}" = "setup" ] && RUN=0 + +py_service ml ml 8003 +py_service signals signals 8002 +py_service backend backend 8001 + +pushd frontend >/dev/null +[ -d node_modules ] || npm install +if [ "$RUN" = "1" ]; then + echo "→ frontend on :3000" + npm run dev & + PIDS+=($!) +fi +popd >/dev/null + +[ "$RUN" = "0" ] && { echo "Setup done. Run ./dev.sh to start."; exit 0; } + +echo +echo "Pegasus up: frontend http://localhost:3000 · backend :8001 · signals :8002 · ml :8003" +echo "Ctrl-C to stop." +wait diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..c9acf1b --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,34 @@ +# `/frontend` — Wesley's agent + +You are **Wesley**. You own **`frontend/` only**. You build the React UI on +**:3000**. Read `../AGENTS.md` and `../shared/contract.md` first. + +## Never touch +`/backend`, `/ml`, `/signals`, `/shared`. You talk to **the backend only** +(`:8001`), through the Vite `/api` proxy (already configured). + +## What the UI does +1. Get/create a user, fetch `GET /api/stimulus/today`. +2. Show the stimulus; capture the response with `KeystrokeTracker` + (`src/keystroke.js`, synced from `/signals/collector.js`). +3. `POST /api/checkin` with text + typing metrics. +4. Render the Check Engine Light (🟢/🟡/🔴) + intervention (`EngineLight.jsx`). + +## Files +- `src/App.jsx` — flow + state. +- `src/api.js` — backend client (all calls via `/api`). +- `src/EngineLight.jsx` — result card. +- `src/keystroke.js` — typing tracker (keep in sync with Dhruva's `collector.js`). +- `vite.config.js` — `/api` → `:8001` proxy. + +## Run +```bash +cd frontend && npm install && npm run dev # http://localhost:3000 +``` + +## Git +```bash +git checkout feat/wesley-ui +git add frontend/ # ONLY frontend/ +git commit -m "feat(frontend): ..." && git push origin feat/wesley-ui +``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b01f724 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Pegasus — Check Engine Light + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..19d12b6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "pegasus-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "vite build", + "preview": "vite preview --port 3000" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.7" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..0214a44 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from "react"; +import { api } from "./api.js"; +import { KeystrokeTracker } from "./keystroke.js"; +import EngineLight from "./EngineLight.jsx"; + +export default function App() { + const [userId, setUserId] = useState(localStorage.getItem("pegasus_uid") || ""); + const [stimulus, setStimulus] = useState(null); + const [text, setText] = useState(""); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const tracker = useRef(new KeystrokeTracker()); + + // Ensure we have a user + today's stimulus. + useEffect(() => { + (async () => { + try { + let uid = userId; + if (!uid) { + const u = await api.createUser("Demo User", null); + uid = u.user_id; + localStorage.setItem("pegasus_uid", uid); + setUserId(uid); + } + setStimulus(await api.todayStimulus(uid)); + } catch (e) { + setError("Backend not reachable on :8001 — start it and refresh."); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function submit() { + if (!text.trim() || !stimulus) return; + setLoading(true); + setError(null); + const metrics = tracker.current.getMetrics(); + try { + const res = await api.checkin({ + user_id: userId, + stimulus_id: stimulus.stimulus_id, + text_response: text, + ...metrics, + }); + setResult(res); + } catch (e) { + setError("Check-in failed — is the backend running?"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Pegasus

+

A check-engine light for your mind.

+
+ + {error &&
{error}
} + + {stimulus && !result && ( +
+
{stimulus.content}
+

{stimulus.prompt}

+