import os import secrets import time import uuid from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from starlette.middleware.sessions import SessionMiddleware from app.scorer import score_with_trace from app.soul_bridge import SoulBridge from app.appearance import mood_to_appearance from app.trace_view import shape_trace from app.crisis_view import crisis_block from app.vocab import mood_word from app.voice import emoticon, simlish, voice_params from app.audit import AuditLog, build_row from app.persistence import restore_soul, Snapshotter from app.auth import make_auth_router from app.identity import RecognitionStore, hash_uid from app.care import CareState, CARE_ACTIONS from app.toys import toy_score, TOY_NAMES from app.relation import RelationStore from app.moments import MomentsStore, notable from app.autonomy import maybe_self_soothe from clanker_soul import Score app = FastAPI(title="Clanker Pet") app.add_middleware( SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET") or secrets.token_hex(16), max_age=60 * 60 * 24 * 365, # 1 year: the anonymous-recognition cookie persists same_site="lax", https_only=True, # HF Spaces are HTTPS-only; mark the cookie Secure ) _DB = os.environ.get("SOUL_DB", "./data/soul.db") _STATE_REPO = os.environ.get("SOUL_STATE_REPO", "deucebucket/clanker-soul-state") restore_soul(_DB, _STATE_REPO) # boot: pull latest soul before opening it _bridge = SoulBridge(db_path=_DB, agent_id="clanker-pet-baby-v1") _snap = Snapshotter(db_path=_DB, repo_id=_STATE_REPO) _audit = AuditLog(repo_id="deucebucket/clanker-audit") _recog = RecognitionStore(_DB) _care = CareState(_DB) _relation = RelationStore(_DB) _moments = MomentsStore(_DB) _SESSION = uuid.uuid4().hex[:12] def _traffic() -> int: """Cumulative interaction count (soul events) — drives traffic-aware decay.""" try: return int(_bridge.maturity().get("events", 0)) except Exception: return 0 _care.rebase_legacy_traffic(_traffic()) # one-time: fix pre-traffic-column rows def _apply_neglect_once(): s = _care.neglect_score(_traffic()) if s: _bridge.ingest(s) _apply_neglect_once() app.include_router(make_auth_router()) class SayIn(BaseModel): text: str = Field(max_length=2000) class CareIn(BaseModel): action: str class ToyIn(BaseModel): toy: str def _state() -> dict: mood = _bridge.mood() soul = _bridge.soul_baseline() return { "appearance": mood_to_appearance(mood), "mood_word": mood_word(mood), "emoticon": emoticon(mood), "needs": _care.needs(_traffic()), "soul": soul, "distance": _bridge.soul_distance(), # default: no text-derived crisis (physical/idle states are calm). The # /say handler overrides this with the real read+structures concern. "crisis": {"value": 0.0, "label": "calm"}, } @app.get("/health") def health(): return {"status": "ok"} @app.get("/snapshot") def snapshot(): state = _state() soothe = maybe_self_soothe(_bridge, state.get("needs"), time.time()) if soothe: _snap.maybe_snapshot() state = _state() # re-read mood after the self-soothe lift state["self_soothe"] = soothe # tells the client which item to go carry return state def _enrich_physical(state: dict, label: str, read: list[int], deltas: list[int]) -> None: """Toy/care interactions have no language to parse, but they DO push a Score through the soul. Surface the real read, deltas, armor and dominant move so the panels track the action instead of showing the previous chat message, and mark the trace as a physical (non-linguistic) interaction.""" state["read"] = read state["deltas"] = deltas state["acceptance"] = _bridge.last_acceptance() state["trace"] = {"words": [], "structures": [], "contributors": [], "kind": "physical", "label": label} state["why"] = f"{label}: " + _why(read, deltas) @app.post("/care") def care(body: CareIn): if body.action not in CARE_ACTIONS: raise HTTPException(status_code=422, detail=f"unknown action: {body.action!r}") s = _care.score_for(body.action) before = _bridge.mood() _bridge.ingest(s) _snap.maybe_snapshot() after = _bridge.mood() _care.do(body.action, _traffic()) # stamp fill AFTER this interaction counts read = [s.v, s.a, s.d, s.u, s.g, s.w, s.i] deltas = [after[i] - before[i] for i in range(7)] state = _state() _enrich_physical(state, f"you {body.action} it", read, deltas) state["line"] = f"you {body.action} it" return state @app.post("/toy") def toy(body: ToyIn): if body.toy not in TOY_NAMES: raise HTTPException(status_code=422, detail=f"unknown toy: {body.toy!r}") s = toy_score(body.toy) before = _bridge.mood() _bridge.ingest(s) _snap.maybe_snapshot() after = _bridge.mood() read = [s.v, s.a, s.d, s.u, s.g, s.w, s.i] deltas = [after[i] - before[i] for i in range(7)] state = _state() _enrich_physical(state, body.toy, read, deltas) state["line"] = body.toy _audit.append(build_row( session=_SESSION, text=f"[toy:{body.toy}]", read=read, mood_before=before, mood_after=after, deltas=deltas, trace={"words": [], "structures": [], "contributors": []}, mood_word=state["mood_word"], ts=time.time(), )) return state def _why(read: list[int], deltas: list[int]) -> str: names = ["V", "A", "D", "U", "G", "W", "I"] big = max(range(7), key=lambda i: abs(deltas[i])) sign = "+" if deltas[big] >= 0 else "" return f"strongest move: {names[big]} {sign}{deltas[big]} (read {names[big]}={read[big]})" @app.post("/say") def say(body: SayIn, request: Request): text = body.text.strip() if not text: raise HTTPException(status_code=422, detail="empty text") score, raw, trace = score_with_trace(text, source="space:chat") _bridge.ingest(score) _snap.maybe_snapshot() mood = _bridge.mood() state = _state() state["read"] = raw state["deltas"] = _bridge.deltas() state["trace"] = shape_trace(trace) state["crisis"] = crisis_block(raw, state["trace"].get("structures")) state["why"] = _why(raw, state["deltas"]) state["simlish"] = simlish(mood) state["voice"] = voice_params(mood) state["acceptance"] = _bridge.last_acceptance() breached = bool(state["acceptance"].get("breached")) res = notable(raw, state["deltas"], breached=breached, crisis_emergency=False) if res: _moments.capture(res[0], raw, res[1]) # anonymous, sign-in-free recognition: every browser gets a hashed device id in # its (persistent) session cookie. No HF login, no username — just "this device". uid = request.session.get("uid") if not uid: uid = hash_uid(secrets.token_hex(16)) request.session["uid"] = uid state["recognition"] = _recog.see(uid) if not request.session.get("greeted") and _relation.known(uid): aff = _relation.affinity(uid) _bridge.ingest(Score(v=aff[0], a=aff[1], d=aff[2], u=aff[3], g=aff[4], w=aff[5], i=aff[6], source="relation:recall")) request.session["greeted"] = True _relation.observe(uid, raw, state["mood_word"]) aff = _relation.affinity(uid) state["relation"] = {"known": _relation.known(uid), "affinity_valence": (aff or [128])[0]} _audit.append(build_row( session=_SESSION, text=text, read=raw, mood_before=_bridge.previous_mood(), mood_after=_bridge.mood(), deltas=state["deltas"], trace=state["trace"], mood_word=state["mood_word"], ts=time.time(), )) return state @app.get("/history") def history(): return {"events": _bridge.history(limit=50)} @app.get("/moments") def moments(): return {"events": _moments.recent(limit=20)} @app.get("/llms.txt") def llms_txt(): """Plain-text project explainer for LLMs/agents (llmstxt.org convention).""" return FileResponse("static/llms.txt", media_type="text/plain") @app.get("/robots.txt") def robots_txt(): return PlainTextResponse( "User-agent: *\nAllow: /\n\n# About this site: /llms.txt\n" ) # static frontend (Task 7). Mounted last so /health etc. win. if os.path.isdir("static"): @app.get("/") def index(): return FileResponse("static/index.html") app.mount("/static", StaticFiles(directory="static"), name="static")