"""Agent (Cursor) schedule endpoints using Bearer AGENT_TOKEN or session. GET context packs priors, risks, and constraints; PUT replaces the day plan. """ from __future__ import annotations import hmac from datetime import date, datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, Request, status from app.deps import require_login from app.models import ApiEnvelope, err, ok from app.routers.loop import loop_summary_for from app.schedule_math import capacity_hint, trigger_operators from app.schedule_risk import risk_from_stores from app.schedule_store import DayPlanPut router = APIRouter(prefix="/api/agent", tags=["agent"]) def require_agent_or_login(request: Request) -> str: """Allow AGENT_TOKEN bearer or a valid human session.""" settings = request.app.state.settings auth = request.headers.get("authorization") or "" if auth.lower().startswith("bearer ") and settings.agent_token: token = auth[7:].strip() if hmac.compare_digest(token, settings.agent_token): return "agent" # Fall back to session login require_login(request) return "session" @router.get("/context", response_model=ApiEnvelope) def agent_context( request: Request, day: date | None = None, _auth: str = Depends(require_agent_or_login), ) -> dict[str, Any]: settings = request.app.state.settings target = day or datetime.now(timezone.utc).date() day_s = target.isoformat() store = request.app.state.schedule_store entry_store = request.app.state.entry_store daily_store = request.app.state.daily_store now = datetime.now(timezone.utc) since = now - timedelta(hours=48) risk_tags_extra = {"home", "spain", "build"} entries_48h = [] for entry in entry_store._load(): ts = entry.ts if entry.ts.tzinfo else entry.ts.replace(tzinfo=timezone.utc) if ts < since: continue entries_48h.append( { "id": entry.id, "ts": entry.ts.isoformat(), "tags": entry.tags, "emotions": entry.emotions, "intensity": entry.intensity, "remedy": entry.remedy, "result": entry.result.value, "happened": entry.happened[:280], } ) risk = risk_from_stores(entry_store, daily_store, now=now, target_day=target) # Keep agent context tags slightly broader for Spain/home signals. tags_1h = sorted(set(risk["risk_1h_tags"])) for entry in entry_store._load(): ts = entry.ts if entry.ts.tzinfo else entry.ts.replace(tzinfo=timezone.utc) if ts < now - timedelta(hours=1): continue hit = sorted((set(entry.tags) | set(entry.emotions)) & risk_tags_extra) tags_1h = sorted(set(tags_1h) | set(hit)) risk_score = float(len(tags_1h)) last_hour_high = risk["last_hour_high"] or bool( {"urge", "court", "corn", "rerun"} & set(tags_1h) ) triggers_y = risk["triggers_yesterday"] cap = capacity_hint( risk_1h_score=risk_score, triggers_yesterday=triggers_y, yesterday_trigger=len(triggers_y) > 0, last_hour_high=last_hour_high, ) ops = trigger_operators(risk_1h_tags=tags_1h, triggers_yesterday=triggers_y) pending = sum(1 for e in entry_store._load() if e.result.value == "pending") priors = store.load_priors() y_daily = daily_store.get(target - timedelta(days=1)) t_daily = daily_store.get(target) movement = daily_store.movement_for(target) loop = loop_summary_for( entry_store=entry_store, daily_store=daily_store, as_of=target, days=7, ) return ok( { "date": day_s, "timezone": settings.schedule_timezone, "now": now.isoformat(), "daily_yesterday": y_daily.model_dump(mode="json") if y_daily else None, "daily_today": t_daily.model_dump(mode="json") if t_daily else None, "movement": movement, "indoors_streak": movement["indoors_streak"], "label_only_fail_rate": loop["label_only_fail_rate"], "loop_summary": loop, "entries_last_48h": entries_48h, "risk_1h": {"score": risk_score, "tags": tags_1h, "high": last_hour_high}, "triggers_yesterday": triggers_y, "capacity_hint": cap, "operators": ops, "priors": list(priors.values()), "server_priors_markdown": store.priors_table(), "current_plan": store.plan_with_feedback(day_s), "pending_entries_count": pending, "templates": store.templates(), "constraints": { "max_blocks": settings.schedule_max_blocks, "must_include_explore_or_restore": True, "p0_locked": True, "pad_with_d_hat": True, "day_start": settings.schedule_day_start, "day_end": settings.schedule_day_end, "scope": "P1_thin_loop", "no_perma_fields": True, "ev_formula_locked": True, "schedule_does_not_replace_daily_or_log": True, }, "formulas": ( "EV=P(done)*U+γ*I+η*Fun-λt*d_hat-λm*cost-λf*FSE (locked; no δ/H/PERMA). " "d_hat=(n/(n+m))*mean_actual+(m/(n+m))*prior. " "On trigger_day: raise λ_f and η; P0 only overrides. " "Escape≠explore. Server owns p_helped/rank/picks from logs." ), } ) @router.put("/plan/{day}", response_model=ApiEnvelope) def agent_put_plan( request: Request, day: date, body: DayPlanPut, _auth: str = Depends(require_agent_or_login), ) -> object: put = body.model_copy(update={"source": body.source or "cursor"}) try: request.app.state.schedule_store.save_plan(day.isoformat(), put) return ok(request.app.state.schedule_store.plan_with_feedback(day.isoformat())) except ValueError as exc: return err("validation_error", str(exc), status.HTTP_422_UNPROCESSABLE_ENTITY)