Spaces:
Sleeping
Sleeping
| # Hollow — Agent Instructions | |
| > Cross-tool mirror of `CLAUDE.md` (same context, practices, hard-won error memories). Keep the two in sync. Record CURRENT STATE + reusable lessons, NOT a day-by-day changelog (git has that). When something changes, edit the relevant line; don't append. | |
| ## What this is | |
| A horror NPC chatbot for the **Build Small Hackathon** (HF / Gradio). Track: 🍄 An Adventure in Thousand Token Wood. **Deadline: Mon June 15, 2026.** | |
| **Pitch:** A lost child at the edge of a dead wood has no memories of its own. It asks for yours, stores them in a "treasure", later claims them as its own in the first person (the **recall** — core wow). How you *treat* it branches three endings. | |
| **Spaces (deploy mirror-first, both every time):** | |
| - Personal mirror: https://huggingface.co/spaces/Pabloler21/hollow (remote `space`) | |
| - **Submission** (must live in the org): https://huggingface.co/spaces/build-small-hackathon/hollow (remote `hackathon`) — ZeroGPU, public. | |
| ## WHERE WE ARE (read first) | |
| - **DEPLOYED + LIVE on BOTH Spaces** (no longer frozen at `4deeed5`). The full redesign works on the Space — menu → intro → game (**chat + voice + recall + 3 finales + end screen**). **276 tests green.** | |
| - **Current work branch `feat/game-chrome-polish`** — game-view chrome polish done: controls icon-cluster ✅, drawer counts ✅, dialogue band ✅. Dialogue fixes deployed to **both Spaces**: player line centered, scrim removed, input box slimmer. Attempt to move the child's dialogue to the left caused a UI freeze; reverted and parked. **276 tests green.** | |
| - **Next:** Pablo's deliverables (demo video, LinkedIn, Field Notes) linked from the README. | |
| ## Commands | |
| ```bash | |
| # Tests (no model/GPU needed). 3.13 .venv runs silent (no kokoro) — fine. | |
| ./.venv/Scripts/python -m pytest -q | |
| ./.venv/Scripts/python -m pytest tests/test_app.py::TestRenderTitle # one class/test | |
| ./.venv/Scripts/python -c "import app; print('ok')" # import smoke check | |
| # Local voice run — PowerShell (&& and inline env-prefix don't work); needs Ollama. VISUALS are validated on the Space, not here. | |
| $env:HOLLOW_FAST_FINALE='good'; ./.venv-tts/Scripts/python app.py # good|loop|bad ; HOLLOW_DEBUG=1 logs per-turn state | |
| # Deploy — mirror-first; history is LFS-rewritten so FORCE-push the branch HEAD to main. | |
| git push --force space HEAD:main # wait ~3-4 min, Ctrl+F5, verify on the Space, THEN: | |
| git push --force hackathon HEAD:main | |
| ``` | |
| No linter/formatter/typechecker. `pytest` + `import app` are the only automated checks. **Visual/audio is human-only (Pablo), and verified on the live Space — local rendering differs.** | |
| ## Stack (decided — do not reopen) | |
| - **UI:** Gradio **6.18.0** (PINNED: README `sdk_version: 6.18.0` + `requirements gradio==6.18.0`). `gr.Blocks` + `gr.Chatbot`. Three full-viewport views by visibility: `menu_view` → `intro_view` (typewriter lore cards) → `game_view` + a top-level `#end-overlay`. **SSR forced OFF** (`os.environ["GRADIO_SSR_MODE"]="False"` at the TOP of app.py — see gotcha). Horror visuals pure CSS; the `<head>` controller (`_HEAD_JS`, via `launch(head=...)`) owns menu music, the chime→greeting ritual, the intro cards, mute, idle, the tone/cue/end markers, AND the Google-Fonts `<link>`. | |
| - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`. Model loads on CPU at import; `.to("cuda")` INSIDE the GPU call. | |
| - **Voice:** Kokoro-82M (`af_nicole`, speed 0.94, "28% fog" numpy DSP), **CPU**, in a SEPARATE subprocess (`voice_worker.py`, `CUDA_VISIBLE_DEVICES=""`) — MANDATORY on ZeroGPU (gotcha below). `voice.py` drives it over stdio + does the DSP. Import-guarded/silent if kokoro absent; `HOLLOW_VOICE_OFF=1` disables. | |
| - **Assets:** `assets/*.webp` via **Git LFS** (HF rejects plain-git binaries — Xet policy). **State:** plain dict in `gr.State`. **Parsing:** Pydantic v2 + fallback. **Pkg:** `uv`. Space build: `packages.txt` (espeak-ng); runtime py3.10. | |
| - **Rejected (don't reopen):** Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks loader); LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap. | |
| ## Submission (Field Guide REQ-06) | |
| Space in the org + complete README IS the entry (no form). README frontmatter has track + badges + write-up. **Pending:** demo video, one LinkedIn post (both linked FROM README), Field Notes post. Models ≤32B. Badges: 🔌 Off-the-Grid, 🎨 Off-Brand, 📓 Field Notes. | |
| ## File structure | |
| ``` | |
| app.py # thread-cap (OMP/OPENBLAS/MKL=4) + os.environ["GRADIO_SSR_MODE"]="False" at top; 3 views + #end-overlay; | |
| # chat() generator; _start_turn (stashes msg in state["_pending_msg"]); 3 _play_finale_*; | |
| # _enter_game/_show_intro; _reset/_to_menu; _render_title(state, show_end); _render_bond; _on_idle; | |
| # _HEAD_JS controller (font <link> + JS); HOLLOW_DEBUG per-turn telemetry; launch(head=, ssr_mode=False) | |
| voice.py # speak(text)->base64 wav|None — drives voice_worker.py over stdio, then numpy DSP (_fog); _clean_for_tts | |
| voice_worker.py # SEPARATE process: Kokoro with CUDA_VISIBLE_DEVICES=""; protocol on a clean dup'd fd (lib noise -> stderr) | |
| engine.py # run_turn + run_turn_stream generators; _EXTRACTION_SYSTEM; IS_SPACE switch. Gen+extract in ONE @spaces.GPU | |
| character.py # build_system_prompt(...); _WORLD note; OWN_FRAGMENTS (5-line Caor arc); OPENING_LINE; | |
| # INTRO_CARDS (5; tester=[0,4,3] uses a condensed teaching card)/INTRO_SEQUENCES; FRAGMENT_TONES | |
| memory.py # PACING (tester/full); get_tier; apply_update (+sanitize/Hollow-words filter); should_recall; | |
| # decide_ending (force_ending bypass); style_signal; pick_aware_memory — pure | |
| finale.py # finale_steps (loop) / _bad (wound) / _good (redemption) — pure data; ALL THREE have a SILENT frenzy beat | |
| render.py # render_entity(affinity, mode, seq, tone, cue) — .tone-now/.cue-now markers; render_recovered (count); | |
| # render_treasure (count) — drawer headers show "· N" | |
| sting.py # numpy audio synth — HF rejects committed .wav | |
| styles.css # §19-22 finale/progress/typing; §23 menu; §24 how-to; §25 intro; §26 GAME SCENE; §27 END SCREEN | |
| assets/ # Git LFS *.webp: hollow_{base,terror,almost,end,rage,peace}{,_cut}.webp; intro_*.webp; background.webp | |
| tests/ # 276 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py | |
| docs/superpowers/{specs,plans}/ # AGENT-EXECUTION-BRIEF.md (how to execute a plan here) + dated plans | |
| ``` | |
| ## State shape (gr.State) — read NEW fields with `.get(...)` | |
| ```python | |
| { "affinity": int, # 0..100, start 20 — how MUCH they share (Bond) | |
| "tone": int, # -100..100, start 0 — how they TREAT it | |
| "treasure": list[str], "claimed": list[str], # shared / reclaimed (recall dedup) | |
| "wounds": list[str], # cruel quotes (cap 10), shown redacted ▮ | |
| "history": list[dict], "turn": int, "last_recall_turn": int|None, | |
| "fragments_told": int, # OWN_FRAGMENTS surfaced (redemption arc) | |
| "ended": bool, "ending": str|None, "force_ending": str|None, # "good"|"loop"|"bad"; dev seed forces | |
| "msg_lengths": list[int], "last_aware_memory": str|None, | |
| "barren_turns": int, # consecutive turns with no capture (B4 plea); read with .get | |
| "last_activity": float, "idle_count": int, "greeted": bool, "mode": str, # "tester"|"full" | |
| "chosen_name": str|None, "named": bool, "_pending_msg": str } # _pending_msg: _start_turn stashes, chat() pops | |
| ``` | |
| Env flags: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral`→good/loop; seeds state+`force_ending` so the ending fires on the next message); `HOLLOW_DEBUG=1` (per-turn `aff/tone/treasure/claimed/...` log); `HOLLOW_VOICE_OFF=1` (silence). | |
| ## Affinity tiers (base voice; tone modulates on top) | |
| 0–25 Hollow (broken) · 26–50 Curious (repeats you) · 51–75 Too Human (claims memories) · 76–100 Almost (possessive, `almost` face, no sway). | |
| ## Endings (`decide_ending`, turn start, fully scripted, no GPU) | |
| `force_ending` (dev seed) wins first. Else one tone-branched gate. Tester is tuned SHORT for judges: | |
| - **tester:** `bad` at `tone≤−30 ∧ turn≥3 ∧ wounds≥2`; `good/loop` at `aff≥35 ∧ claimed≥2` branched by `tone≥20` (recall_cooldown 1). | |
| - **full:** `bad` at `tone≤−30 ∧ turn≥6 ∧ wounds≥2`; `good/loop` at `aff≥68 ∧ claimed≥3` branched by `tone≥20`. | |
| - **bad** (Wound Loop): recites cruelties, SILENT convulse + frenzy/stab/scream, rage face, a **collective lore threat** (Caor/the Gaunt — "we are all still here… we will teach it back to you"), ends "see you... soon." | |
| - **good** (Redemption): warmth surfaced its past; confesses (Caor/Gaunt), recovers brother **Edren**, RETURNS the treasure (lit), first smile, peace — NO sequel hook. | |
| - **loop** (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line. | |
| - The finale's FINAL frame emits an `ended-now[data-ending]` marker → `_HEAD_JS applyEnd()` reveals the **end screen** (§27 layout B: epitaph hero + footer credits + begin-again/leave-the-wood). | |
| - Prompt tone bands (separate from routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on. | |
| ## Lore (canon) | |
| The child is a dead tithe of **Caor**, a salt-fen hamlet that, in famine, gave **one** child to **the Gaunt** (the old hunger breathing in the wood) to spare the rest. Reed-masked bearers bound it at the treeline and left; it starved unburied and became the Gaunt's hook — the thing that waits at the edge and begs memories. It had a **brother (Edren)** who braided its hair. The **bad ending reveals a collective** — it was not the last the Gaunt was fed; the wood holds many of the given. Recovered as the 5-line `OWN_FRAGMENTS` arc; `_WORLD` note keeps improvised dialogue consistent. | |
| ## Game-view scene anatomy (§26) + end screen (§27) | |
| - `#game-view` is `position:fixed; inset:0` and defines the **frame geometry as CSS vars** (`--fw/--fh/--band-v/--band-h`). In-frame regions (drawers, `#game-entity`, `#game-dialogue`, `#game-groundfog`) are `position:fixed` anchored to those vars so they sit INSIDE the centered 16:9 frame. `#game-inputbar` is the stone command box at the viewport base. | |
| - Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp, radial-mask feathered) → `#game-vig`/`#game-frame-edge` → `#game-entity` silhouette → `#game-groundfog`. | |
| - **End screen (§27):** `#end-overlay` is a top-level `gr.Column` (`position:fixed; inset:0`), always mounted, hidden until JS adds `.shown`. Epitaph set per ending by `applyEnd()`; two real `gr.Button`s → `_reset` (begin again) / `_to_menu` (leave). On dismiss the JS **removes the `.ended-now` marker** so the poll can't re-reveal (was the double-click bug). | |
| - **Voice/Presence:** every reply + finale line is spoken (Kokoro subprocess). Reply text streams; each finished sentence → a **client-side audio queue** in `_HEAD_JS`. **Mute is client-side**. Awareness: B3 `style_signal`, B2 `pick_aware_memory` (every 3rd non-recall turn), B1 idle (60 s → `#idle-trigger` → `_on_idle`, −3 aff/−2 tone), B4 `_memory_plea_level`. Cues: capture→chip pulse + "— it keeps this —"; recall→amber line; recover→"— it remembers —". | |
| ## Gotchas (DON'T re-learn the hard way) | |
| - **ZeroGPU: NEVER touch CUDA in the main process.** Any CUDA init in main (even Kokoro/torch on CPU probing `cuda.is_available()`) poisons the `@spaces.GPU` worker fork → `RuntimeError: No CUDA GPUs are available` → EVERY gen falls to the "fog swallowed your words" fallback. Fix: voice (Kokoro) runs in a SEPARATE process (`voice_worker.py`, `CUDA_VISIBLE_DEVICES=""`). In-process patches were NOT enough. Confirm with `HOLLOW_VOICE_OFF=1` (gen recovers → voice was the poison). | |
| - **Subprocess stdio protocol:** Kokoro/torch/tqdm print to stdout and corrupt a line protocol — `voice_worker.py` dups the real stdout for the protocol and redirects fd 1→stderr (lib noise to stderr). | |
| - **SSR must be OFF on the Space.** HF defaults Gradio 6 to SSR (Node proxy → app runs in a SUBPROCESS on :7861), breaking the `_HEAD_JS`/CSS client DOM (blank intro, broken layout) AND ZeroGPU. `launch(ssr_mode=False)` is IGNORED when HF imports the app instead of `__main__`; force it with `os.environ["GRADIO_SSR_MODE"]="False"` at the top of app.py. | |
| - **Fonts: `@import` is rejected on the Space** (not the first rule once Gradio injects its CSS first). Load Google Fonts via a `<link>` in `<head>` (in `_HEAD_JS`), not `@import` in styles.css. | |
| - **HF rejects plain-git binaries (Xet policy):** webp assets are **Git LFS**. The migration rewrote history, so deploys are **`git push --force`** (`git lfs migrate import --include="*.webp"`; `git stash -u` first — migrate refuses on a dirty tree, even untracked). | |
| - **Local rendering ≠ the Space** (Gradio version, SSR, font load differ). The Space is the visual source of truth — **verify there, not locally**. Agents must NOT judge visuals locally; that's human-only (Pablo, on the mirror). | |
| - **Gradio chatbot gives user messages a narrow right bubble** → right-aligning/clamping the player line collapses it to a 1-char vertical column. CENTER it like the bot; distinguish "you" by label + muted color. | |
| - **Intro typewriter races the server view-flip:** poll for `#intro-text` (rendered after the `_show_intro` round-trip; a fixed `setTimeout` loses on Space latency). | |
| - **`position:fixed` is the layout primitive** (menu/intro/game/end). A `transform`/`filter` on ANY ancestor traps fixed children. **Gradio wraps each component in a full-width `.block`** — `align-items:center` centers the block, not the content; neutralize wrappers to center an overlay. | |
| - **Don't dissolve scene edges with a heavy inset shadow** (bright-center rectangle). Use the `#game-scene` radial mask + low brightness (210px/90px ceiling). A dialogue **scrim `::before` also reads as a dark rectangle** — avoid it; rely on text-shadow. | |
| - **Gradio replaces the DOM each update:** CSS *animations* restart; *transitions* don't animate across updates. `prefers-reduced-motion`: disable sway/fog only, NEVER opacity. | |
| - **Cut-out silhouettes:** materialize via brightness/opacity + bottom fade — NO `blur`, NO `scale`. Finale overlays use `object-fit:contain`+bottom. | |
| - **Marker pattern:** `render_entity`/`_render_title` emit hidden `.tone-now`/`.cue-now`/`.ended-now` spans; `applyTone/applyCue/applyEnd` (polled in `_HEAD_JS wire()`) lift them. Don't add outputs to the finale generator tuples (tuple-desync) — use the marker. | |
| - **Input clears on send via state stash:** `_start_turn` sets `state["_pending_msg"]`; `chat()` pops it. Do NOT recover from chatbot history (Gradio normalizes content to a list → `'list' has no attribute 'strip'`). | |
| - **The opening always renders the base face** (`render_entity(20)`), never the seeded tier. | |
| - **Qwen emits `+2` (invalid JSON)** without positive anchors → sanitizer in `apply_update` + "no leading +" + dual examples. **Recall pollution:** `_is_hollows_words` (>60% overlap) + `repetition_penalty 1.3`. Keep both. | |
| - **One `@spaces.GPU` per turn.** NO threading around `run_turn`; a `TextIteratorStreamer` thread INSIDE the GPU call is the supported stream pattern. | |
| - **transformers 5.x:** `apply_chat_template(...,return_tensors="pt")`→`BatchEncoding`; `dtype=` not `torch_dtype`; thinking OFF. | |
| - **Don't edit `.py`/`.css`/`.md` via PowerShell string ops** — mangles UTF-8 (`❯`, `↺`, `✦`, em-dashes, accents, emoji). Use the Edit tool. | |
| - **Cap CPU math threads before torch loads** (`OMP/OPENBLAS/MKL=4`). **Backend safety net:** `run_turn` failures → "fog swallowed your words"; msgs >500 chars declined. **Ollama 500 = ROCm `cudaMalloc` corruption** → restart Ollama (the Space is immune). | |
| ## Working style & conventions | |
| - **Everything in English:** code, prompts, UI, dialogue. Phase by phase; explain *why* before code; no new deps/complexity without asking; push back on scope creep. | |
| - Big changes flow **spec → plan (writing-plans) → execution**. Opus writes specs/plans; the executor implements task-by-task (read `docs/superpowers/AGENT-EXECUTION-BRIEF.md` before executing a plan). | |
| - **One task/phase per commit**, exact message from the plan + trailer: `Co-Authored-By: Claude <model> <noreply@anthropic.com>` (the model you actually are). | |
| - Keep `pytest -q` green (276) and `import app` OK after every change. **Verify visuals on the Space, never locally.** | |