Pabloler21 Claude Opus 4.8 commited on
Commit
a3e5c2d
·
1 Parent(s): 527c283

docs: update CLAUDE.md + AGENTS.md to the deployed state (voice subprocess, SSR off, LFS, 276 tests)

Browse files
Files changed (2) hide show
  1. AGENTS.md +73 -73
  2. CLAUDE.md +67 -60
AGENTS.md CHANGED
@@ -9,58 +9,60 @@ A horror NPC chatbot for the **Build Small Hackathon** (HF / Gradio). Track:
9
 
10
  **Spaces (deploy mirror-first, both every time):**
11
  - Personal mirror: https://huggingface.co/spaces/Pabloler21/hollow (remote `space`)
12
- - **Submission** (must live in the org): https://huggingface.co/spaces/build-small-hackathon/hollow (remote `hackathon`) — ZeroGPU `zero-a10g`, public.
13
 
14
  ## WHERE WE ARE (read first)
15
- - **Branch `feat/child-redesign`** (off `feat/voice-awareness`), **NOT deployed**. Both Spaces frozen at the pre-voice commit `4deeed5`. **269 tests green.**
16
- - **Done this redesign:** menu → cinematic **intro** (lore cards) → **game-view rebuilt as a letterboxed foggy scene** (Direction 3b). The child is a **cut-out silhouette** standing in fog (not an arch); corner-drawer collections; conversation as a **fading subtitle transcript** with a labeled two-voice split; **amber = memory / red = cruelty** color language; **whispered in-voice feedback cues**; full **lore** (Caor / the Gaunt / brother Edren); "What it remembers" weaves the memories it stole from you; **O2** scene (edges dissolve to shadow/fog); **F1** stone command-box input; deterministic dev seed; finale pacing/convulse/sigh fixes; an **end screen** (§27 — epitaph + credits over the fog, revealed by an `ended-now` marker).
17
- - **Next:** (1) live-verify the redesign in `.venv-tts` + Ollama with Pablo; (2) **rework the end screen into layout B** (epitaph as hero, credits demoted to a quiet bottom footer — current §27 layout is unbalanced); (3) replicate the GOOD finale's snappy pacing + **silent convulse beat** to the **loop & bad** finales (only good has it); (4) optional: blur the central frame edges MORE via the scene **mask + lower contrast** (the heavy frame-edge shadow approach FAILED — see gotchas); (5) merge `feat/child-redesign` → `feat/voice-awareness`, then deploy **mirror-first**; (6) deliverables (demo video, LinkedIn, Field Notes) are Pablo's.
18
 
19
  ## Commands
20
  ```bash
21
- # Tests (no model/GPU needed — tests mock or avoid run_turn). 3.13 .venv is silent (fine).
22
  ./.venv/Scripts/python -m pytest -q
23
- ./.venv/Scripts/python -m pytest tests/test_memory.py # one file
24
  ./.venv/Scripts/python -m pytest tests/test_app.py::TestRenderTitle # one class/test
25
  ./.venv/Scripts/python -c "import app; print('ok')" # import smoke check
26
 
27
- # Local voice playthrough — PowerShell (&& and inline env-prefix don't work); needs Ollama running.
28
- $env:HOLLOW_FAST_FINALE='good'; ./.venv-tts/Scripts/python app.py # good|loop|bad (1/neutral→good/loop)
29
 
30
- # Deploy — mirror-first: push space → wait ~3-4 min (Kokoro/spacy build) → Ctrl+F5, verify → then hackathon.
31
- git push space main # then, only after verifying the mirror:
32
- git push hackathon main
33
  ```
34
- No linter/formatter/typechecker. `pytest` + `import app` are the only automated verification. Visual/audio is **human-only** (Pablo, live).
35
 
36
  ## Stack (decided — do not reopen)
37
- - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot`. Three full-viewport views toggled by visibility: `menu_view` (Fear & Hunger front door, modes Tester/Full + How-to-Play) → `intro_view` (client-driven typewriter lore cards) → `game_view` (the scene). Horror visuals are pure CSS; the `<head>` JS controller (`_HEAD_JS`) owns menu music, the chime→greeting ritual, the intro cards, mute, idle timing, lifting the tone/cue classes onto `#game-view`, and revealing the end screen.
38
- - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`.
39
- - **Voice:** Kokoro-82M TTS (`voice.py`, `af_nicole`, speed 0.94, "28% fog" numpy DSP), **CPU**, import-guarded → **silent if unavailable** (3.13 `.venv` has no kokoro; voice lives in 3.12 `.venv-tts`, and on the Space at py3.11). `_clean_for_tts` strips dashes (Kokoro verbalizes them).
40
- - **State:** plain dict in `gr.State`. **Parsing:** Pydantic v2 + fallback. **Pkg:** `uv`. Space build needs `packages.txt` (espeak-ng) + `python_version: "3.11"` (Kokoro needs <3.13).
41
- - **Rejected (don't reopen):** Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks the loader); LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap.
42
 
43
  ## Submission (Field Guide REQ-06)
44
  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.
45
 
46
  ## File structure
47
  ```
48
- app.py # thread-cap (OMP/OPENBLAS/MKL=4) at top; 3 views (menu/intro/game) + end-overlay; chat() generator;
49
- # _start_turn (stashes msg in state["_pending_msg"], clears box); 3 _play_finale_*; _enter_game/_show_intro;
50
- # _reset / _to_menu; _render_title(state, show_end) (emits ended-now marker on final finale frame);
51
- # _voice_html / _voice_and_pause; _recovered(state); _render_bond; _on_idle; _HEAD_JS controller
52
- voice.py # speak(text)->base64 wav|None — Kokoro whisper + locked DSP, CPU, import-guarded; _clean_for_tts; thread-cap
53
- engine.py # run_turn + run_turn_stream generators; _EXTRACTION_SYSTEM; IS_SPACE switch; repetition_penalty. Gen+extract in ONE @spaces.GPU
54
- character.py # build_system_prompt(...); _WORLD note; OWN_FRAGMENTS (5-line Caor arc); OPENING_LINE; INTRO_CARDS/INTRO_SEQUENCES; FRAGMENT_TONES
55
- 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
56
- finale.py # finale_steps (loop) / _bad (wound) / _good (redemption) — pure data; good has a SILENT frenzy beat + Edren recovery
57
- render.py # render_entity(affinity, mode, seq, tone, cue) — emits .tone-now + .cue-now markers; cut-out silhouette + finale modes;
58
- # render_recovered(fragments_told, claimed) — its arc + the stolen memories; render_treasure(...)
59
- sting.py # numpy audio synth (sting/chime/heartbeat/sigh/flatline/menu_loop/type_tick) — HF rejects committed .wav
60
- styles.css # §19-22 finale/progress/typing; §23 menu; §24 how-to; §25 intro; §26 GAME SCENE (3b); §27 END SCREEN
61
- assets/ # hollow_{base,terror,almost,end,rage,peace}.webp + *_cut.webp (rembg alpha silhouettes); intro_*.webp; background.webp
62
- tests/ # 269 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
63
- docs/superpowers/{specs,plans}/ # approved specs + plans; AGENT-EXECUTION-BRIEF.md (how to execute a plan here)
 
 
 
64
  ```
65
 
66
  ## State shape (gr.State) — read NEW fields with `.get(...)`
@@ -71,62 +73,60 @@ docs/superpowers/{specs,plans}/ # approved specs + plans; AGENT-EXECUTION-BRIE
71
  "wounds": list[str], # cruel quotes (cap 10), shown redacted ▮
72
  "history": list[dict], "turn": int, "last_recall_turn": int|None,
73
  "fragments_told": int, # OWN_FRAGMENTS surfaced (redemption arc)
74
- "ended": bool, "ending": str|None, # "good"|"loop"|"bad"
75
- "force_ending": str|None, # dev seed forces this ending (deterministic)
76
  "msg_lengths": list[int], "last_aware_memory": str|None,
77
- "last_activity": float, "idle_count": int,
78
- "greeted": bool, "mode": str, # "tester"|"full" pacing
79
- "chosen_name": str|None, "named": bool, # the child gropes toward a name
80
- "_pending_msg": str } # transient — _start_turn stashes the sent msg, chat() pops it
81
  ```
82
- Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral`→good/loop) — seeds state AND `force_ending` so the named ending fires deterministically on the next message.
83
 
84
  ## Affinity tiers (base voice; tone modulates on top)
85
- 0–25 Hollow (broken, barely speaks) · 26–50 Curious (repeats you) · 51–75 Too Human (claims memories) · 76–100 Almost (possessive, `almost` face, no sway).
86
 
87
  ## Endings (`decide_ending`, turn start, fully scripted, no GPU)
88
- `force_ending` (dev seed) wins first. Else, one tone-branched gate (no lower threshold races a warm run):
89
- - **tester:** `bad` at `tone≤−30 ∧ turn≥4 ∧ wounds≥2`; `good/loop` at `aff≥50 ∧ claimed≥2` branched by `tone≥20`.
90
  - **full:** `bad` at `tone≤−30 ∧ turn≥6 ∧ wounds≥2`; `good/loop` at `aff≥68 ∧ claimed≥3` branched by `tone≥20`.
91
- - **bad** (Wound Loop): recites cruelties (redacted→un-redacted), frenzy + triple full-screen stab + scream, reveals rage face, "see you... soon."
92
- - **good** (Redemption): warmth surfaced its real past; confesses (Caor/the Gaunt), recovers brother **Edren**, RETURNS the treasure (un-claimed, lit), first smile (`peace`→`peace_dissolve`), resolves in peace — NO sequel/quest hook.
93
  - **loop** (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line.
94
- - The finale's final frame emits an `ended-now[data-ending]` marker → `_HEAD_JS applyEnd()` reveals the **end screen** (§27) with the per-ending epitaph.
95
  - Prompt tone bands (separate from routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
96
 
97
  ## Lore (canon)
98
- 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. Its family paid the debt; 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 to fill the hollow where its own used to be. It had a **brother (Edren)** who braided its hair. Recovered as the 5-line `OWN_FRAGMENTS` arc; `_WORLD` note in the system prompt keeps improvised dialogue consistent.
99
 
100
  ## Game-view scene anatomy (§26) + end screen (§27)
101
- - `#game-view` is `position:fixed; inset:0` and defines the **frame geometry as CSS vars** (`--fw/--fh/--band-v/--band-h`). Every in-frame region (drawers, `#game-entity`, `#game-dialogue` subtitle, `#game-groundfog`) is `position:fixed` and anchored to those vars so it sits INSIDE the centered 16:9 frame. `#game-inputbar` is the stone command box at the viewport base, below the frame.
102
- - Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp forest, radial-mask feathered = O2) → `#game-vig`/`#game-frame-edge` → `#game-entity` silhouette → `#game-groundfog`.
103
- - **End screen (§27):** `#end-overlay` is a top-level `gr.Column` (`position:fixed; inset:0`), always mounted, `opacity:0/pointer-events:none` until JS adds `.shown`. Epitaph text set per ending by `applyEnd()`; two real `gr.Button`s wired to `_reset` (begin again) and `_to_menu` (leave the wood).
104
- - **Voice/Presence:** every reply + finale line is spoken (Kokoro). Reply text streams; each finished sentence → a **client-side audio queue** in `_HEAD_JS` (sentences never cut). **Mute is client-side** (the controller owns `{muted}` in localStorage; the server always synthesizes). Awareness: B3 `style_signal`, B2 `pick_aware_memory` (every 3rd non-recall turn), B1 idle (client-timed 60 s → hidden `#idle-trigger` → `_on_idle`, escalating, −3 aff/−2 tone), B4 `_memory_plea_level` (barren turns plead harder). Feedback cues: capture→Treasure chip pulse + "— it keeps this —"; recall→amber line + claimed mark; recover→"— it remembers —".
105
 
106
  ## Gotchas (DON'T re-learn the hard way)
107
- - **`position:fixed` is the layout primitive here** (menu/intro/game/end). A `transform`/`filter` on ANY ancestor traps fixed children → they fall into Gradio flow. `#game-stage` has `transform:none`; `#game-entity` centers via `left:calc(50% - var(--fw)*0.15)` (NO transform) so the full-screen `.entity-ghost` finale stab isn't trapped in the narrow column.
108
- - **Gradio wraps each component (`gr.HTML`, `gr.Row`, `gr.Button`) in a full-width `.block`.** So `align-items:center` on a parent flex centers the *block*, not your inner content, and a `gr.Row` of buttons defaults to spreading them. To truly center an overlay's content, neutralize the wrappers (`width`/`max-width`/`background`/`border`) and override the row/buttons explicitly.
109
- - **Don't dissolve scene edges with a heavy full-viewport inset shadow** — it darkens the viewport border and leaves a bright-center RECTANGLE (the "cut"). Dissolve via the `#game-scene` radial **mask + low brightness contrast** vs the blur. (210px/90px frame-edge is the accepted ceiling; 250/120 broke it.)
110
- - **Verify CSS against the RUNNING app, never the static harness.** Headless screenshots are fragile in this shell; `.venv` for fast layout probes, `.venv-tts` + Pablo's eyes/ear for the real playthrough. Audio can only be confirmed by ear. **Do NOT launch/headless-verify as an agent — that step is human-only.**
111
- - **Gradio replaces the DOM each update:** CSS *animations* restart (the flash/screamer mechanism); *transitions* don't animate across updates. `prefers-reduced-motion`: disable sway/fog only, NEVER opacity (killed the terror flash once).
112
- - **Cut-out silhouettes:** `hollow_*_cut.webp` (rembg alpha). Child materializes via brightness/opacity + a bottom fade mask into the fog — NO `blur` (reads as broken image), NO `scale`. Finale/flash overlays (`.entity-flick` etc.) MUST use `object-fit:contain`+bottom like the base, or they balloon past the frame.
113
- - **Finale audio:** one-shot cues (sigh/flatline/sting) carry `class="cue-audio"` (NOT autoplay) → routed through the client voice queue so they don't talk over the playing line; the looping heartbeat stays autoplay. `_voice_and_pause` paces spoken steps to `audio_len + 0.1` (silent beats keep their scripted pause). Good finale splits the convulsion into a spoken line + a **silent `frenzy` beat** so the queue drains and the relief sigh lands exactly on the smile.
114
- - **Input clears on send via state stash:** `_start_turn` sets `state["_pending_msg"]` + clears the box; `chat()` pops it. Do NOT recover from chatbot history — Gradio normalizes message `content` to a list (`'list' has no attribute 'strip'`).
115
- - **The opening always renders the base face** (`_enter_game` → `render_entity(20)`), never the seeded Almost tier — it's the vulnerable first contact.
116
- - **Marker pattern for client state:** `render_entity`/`_render_title` emit hidden `.tone-now[data-tone]` / `.cue-now[data-cue][data-seq]` / `.ended-now[data-ending]` spans; `applyTone()`/`applyCue()`/`applyEnd()` (polled in `_HEAD_JS wire()`) lift them onto `#game-view` or reveal the overlay. Markers are siblings of `#game-bg`, not ancestors — that's why the JS lifts them. Reuse `openingRitual()` etc.; don't redefine existing JS functions. Don't add outputs to the finale generator tuples (tuple-desync hazard) — use the marker.
117
- - **Qwen emits `+2` (invalid JSON)** without positive anchors → bond froze. Sanitizer in `apply_update` + "no leading +" + dual examples. Keep both.
118
- - **Recall pollution:** extraction grabbed Hollow's own recalled line as a "new memory". `_is_hollows_words` (>60% overlap dropped) + `repetition_penalty 1.3`. Keep both.
119
- - **One `@spaces.GPU` per turn** (gen+extract). NO threading around `run_turn` (breaks ZeroGPU); a `TextIteratorStreamer` thread INSIDE the GPU call is the supported stream pattern (verify on the Space; fall back to non-stream if it breaks).
120
- - **transformers 5.x:** `apply_chat_template(...,return_tensors="pt")`→`BatchEncoding` (`hasattr(t,"input_ids")`); `dtype=` not `torch_dtype`; thinking OFF (`enable_thinking=False` / `"think":False`).
121
- - **HF rejects committed binaries (.wav):** synth at runtime (`sting.py`). New non-image binaries: same.
122
- - **Don't edit `.py`/`.css`/`.md` via PowerShell string ops** — mangles UTF-8 (em-dashes, `❯`, accents, emoji). Use the Edit tool.
123
- - **Cap CPU math threads before torch loads** (`OMP/OPENBLAS/MKL=4`) or OpenBLAS exhausts the CPU allocator → Kokoro silently disabled (NOT real RAM exhaustion).
124
- - **Backend safety net:** `run_turn` failures → in-character "fog swallowed your words" reply. Msgs >500 chars declined in character. **Ollama 500 = ROCm `cudaMalloc` corruption** (every reply becomes the fallback) → restart Ollama; the Space is immune.
125
- - **Gradio 6 moved `js=`/`head=` to `launch()`.** Inject JS via `launch(head=...)`. `gr.HTML` initial values are client-rendered (in `/config`, not page HTML).
 
126
 
127
  ## Working style & conventions
128
- - **Everything in English:** code, prompts, UI, dialogue.
129
- - Phase by phase; explain *why* before code; no new deps/complexity without asking; push back on scope creep.
130
  - 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).
131
  - **One task/phase per commit**, exact message from the plan + trailer: `Co-Authored-By: Claude <model> <noreply@anthropic.com>` (the model you actually are).
132
- - Keep the test suite green (`pytest -q`); `import app` must stay OK after every change.
 
9
 
10
  **Spaces (deploy mirror-first, both every time):**
11
  - Personal mirror: https://huggingface.co/spaces/Pabloler21/hollow (remote `space`)
12
+ - **Submission** (must live in the org): https://huggingface.co/spaces/build-small-hackathon/hollow (remote `hackathon`) — ZeroGPU, public.
13
 
14
  ## WHERE WE ARE (read first)
15
+ - **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.**
16
+ - **Current work branch `feat/game-chrome-polish`** — game-view chrome polish: controls icon-cluster ✅, drawer counts ✅, dialogue band IN PROGRESS. Pending (plan `docs/superpowers/plans/2026-06-15-dialogue-fixes.md`): **center the player line** (right-align collapses in Gradio's user bubble), **remove the dialogue scrim rectangle**, **shrink the input box**.
17
+ - **Next:** finish those dialogue fixes (verify on the Space); Pablo's deliverables (demo video, LinkedIn, Field Notes) link from the README.
18
 
19
  ## Commands
20
  ```bash
21
+ # Tests (no model/GPU needed). 3.13 .venv runs silent (no kokoro) — fine.
22
  ./.venv/Scripts/python -m pytest -q
 
23
  ./.venv/Scripts/python -m pytest tests/test_app.py::TestRenderTitle # one class/test
24
  ./.venv/Scripts/python -c "import app; print('ok')" # import smoke check
25
 
26
+ # Local voice run — PowerShell (&& and inline env-prefix don't work); needs Ollama. VISUALS are validated on the Space, not here.
27
+ $env:HOLLOW_FAST_FINALE='good'; ./.venv-tts/Scripts/python app.py # good|loop|bad ; HOLLOW_DEBUG=1 logs per-turn state
28
 
29
+ # Deploy — mirror-first; history is LFS-rewritten so FORCE-push the branch HEAD to main.
30
+ git push --force space HEAD:main # wait ~3-4 min, Ctrl+F5, verify on the Space, THEN:
31
+ git push --force hackathon HEAD:main
32
  ```
33
+ 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.**
34
 
35
  ## Stack (decided — do not reopen)
36
+ - **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>`.
37
+ - **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.
38
+ - **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.
39
+ - **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.
40
+ - **Rejected (don't reopen):** Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks loader); LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap.
41
 
42
  ## Submission (Field Guide REQ-06)
43
  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.
44
 
45
  ## File structure
46
  ```
47
+ app.py # thread-cap (OMP/OPENBLAS/MKL=4) + os.environ["GRADIO_SSR_MODE"]="False" at top; 3 views + #end-overlay;
48
+ # chat() generator; _start_turn (stashes msg in state["_pending_msg"]); 3 _play_finale_*;
49
+ # _enter_game/_show_intro; _reset/_to_menu; _render_title(state, show_end); _render_bond; _on_idle;
50
+ # _HEAD_JS controller (font <link> + JS); HOLLOW_DEBUG per-turn telemetry; launch(head=, ssr_mode=False)
51
+ voice.py # speak(text)->base64 wav|None — drives voice_worker.py over stdio, then numpy DSP (_fog); _clean_for_tts
52
+ voice_worker.py # SEPARATE process: Kokoro with CUDA_VISIBLE_DEVICES=""; protocol on a clean dup'd fd (lib noise -> stderr)
53
+ engine.py # run_turn + run_turn_stream generators; _EXTRACTION_SYSTEM; IS_SPACE switch. Gen+extract in ONE @spaces.GPU
54
+ character.py # build_system_prompt(...); _WORLD note; OWN_FRAGMENTS (5-line Caor arc); OPENING_LINE;
55
+ # INTRO_CARDS (5; tester=[0,4,3] uses a condensed teaching card)/INTRO_SEQUENCES; FRAGMENT_TONES
56
+ memory.py # PACING (tester/full); get_tier; apply_update (+sanitize/Hollow-words filter); should_recall;
57
+ # decide_ending (force_ending bypass); style_signal; pick_aware_memory — pure
58
+ finale.py # finale_steps (loop) / _bad (wound) / _good (redemption) — pure data; ALL THREE have a SILENT frenzy beat
59
+ render.py # render_entity(affinity, mode, seq, tone, cue) — .tone-now/.cue-now markers; render_recovered (count);
60
+ # render_treasure (count) — drawer headers show "· N"
61
+ sting.py # numpy audio synth — HF rejects committed .wav
62
+ styles.css # §19-22 finale/progress/typing; §23 menu; §24 how-to; §25 intro; §26 GAME SCENE; §27 END SCREEN
63
+ assets/ # Git LFS *.webp: hollow_{base,terror,almost,end,rage,peace}{,_cut}.webp; intro_*.webp; background.webp
64
+ tests/ # 276 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
65
+ docs/superpowers/{specs,plans}/ # AGENT-EXECUTION-BRIEF.md (how to execute a plan here) + dated plans
66
  ```
67
 
68
  ## State shape (gr.State) — read NEW fields with `.get(...)`
 
73
  "wounds": list[str], # cruel quotes (cap 10), shown redacted ▮
74
  "history": list[dict], "turn": int, "last_recall_turn": int|None,
75
  "fragments_told": int, # OWN_FRAGMENTS surfaced (redemption arc)
76
+ "ended": bool, "ending": str|None, "force_ending": str|None, # "good"|"loop"|"bad"; dev seed forces
 
77
  "msg_lengths": list[int], "last_aware_memory": str|None,
78
+ "barren_turns": int, # consecutive turns with no capture (B4 plea); read with .get
79
+ "last_activity": float, "idle_count": int, "greeted": bool, "mode": str, # "tester"|"full"
80
+ "chosen_name": str|None, "named": bool, "_pending_msg": str } # _pending_msg: _start_turn stashes, chat() pops
 
81
  ```
82
+ 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).
83
 
84
  ## Affinity tiers (base voice; tone modulates on top)
85
+ 0–25 Hollow (broken) · 26–50 Curious (repeats you) · 51–75 Too Human (claims memories) · 76–100 Almost (possessive, `almost` face, no sway).
86
 
87
  ## Endings (`decide_ending`, turn start, fully scripted, no GPU)
88
+ `force_ending` (dev seed) wins first. Else one tone-branched gate. Tester is tuned SHORT for judges:
89
+ - **tester:** `bad` at `tone≤−30 ∧ turn≥3 ∧ wounds≥2`; `good/loop` at `aff≥35 ∧ claimed≥2` branched by `tone≥20` (recall_cooldown 1).
90
  - **full:** `bad` at `tone≤−30 ∧ turn≥6 ∧ wounds≥2`; `good/loop` at `aff≥68 ∧ claimed≥3` branched by `tone≥20`.
91
+ - **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."
92
+ - **good** (Redemption): warmth surfaced its past; confesses (Caor/Gaunt), recovers brother **Edren**, RETURNS the treasure (lit), first smile, peace — NO sequel hook.
93
  - **loop** (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line.
94
+ - 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).
95
  - Prompt tone bands (separate from routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
96
 
97
  ## Lore (canon)
98
+ 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.
99
 
100
  ## Game-view scene anatomy (§26) + end screen (§27)
101
+ - `#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.
102
+ - Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp, radial-mask feathered) → `#game-vig`/`#game-frame-edge` → `#game-entity` silhouette → `#game-groundfog`.
103
+ - **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).
104
+ - **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 —".
105
 
106
  ## Gotchas (DON'T re-learn the hard way)
107
+ - **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).
108
+ - **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).
109
+ - **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.
110
+ - **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.
111
+ - **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).
112
+ - **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).
113
+ - **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.
114
+ - **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).
115
+ - **`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.
116
+ - **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.
117
+ - **Gradio replaces the DOM each update:** CSS *animations* restart; *transitions* don't animate across updates. `prefers-reduced-motion`: disable sway/fog only, NEVER opacity.
118
+ - **Cut-out silhouettes:** materialize via brightness/opacity + bottom fade — NO `blur`, NO `scale`. Finale overlays use `object-fit:contain`+bottom.
119
+ - **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.
120
+ - **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'`).
121
+ - **The opening always renders the base face** (`render_entity(20)`), never the seeded tier.
122
+ - **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.
123
+ - **One `@spaces.GPU` per turn.** NO threading around `run_turn`; a `TextIteratorStreamer` thread INSIDE the GPU call is the supported stream pattern.
124
+ - **transformers 5.x:** `apply_chat_template(...,return_tensors="pt")`→`BatchEncoding`; `dtype=` not `torch_dtype`; thinking OFF.
125
+ - **Don't edit `.py`/`.css`/`.md` via PowerShell string ops** — mangles UTF-8 (`❯`, `↺`, `✦`, em-dashes, accents, emoji). Use the Edit tool.
126
+ - **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).
127
 
128
  ## Working style & conventions
129
+ - **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.
 
130
  - 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).
131
  - **One task/phase per commit**, exact message from the plan + trailer: `Co-Authored-By: Claude <model> <noreply@anthropic.com>` (the model you actually are).
132
+ - Keep `pytest -q` green (276) and `import app` OK after every change. **Verify visuals on the Space, never locally.**
CLAUDE.md CHANGED
@@ -8,37 +8,41 @@ A horror NPC chatbot for the **Build Small Hackathon** (HF / Gradio). Track:
8
  **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.
9
 
10
  ## WHERE WE ARE (read first)
11
- - **Branch `feat/child-redesign`** (off `feat/voice-awareness`), **NOT deployed**. Both Spaces frozen at the pre-voice commit `4deeed5`. **268 tests green.**
12
- - **Done this redesign:** menu → cinematic **intro** (lore cards) → **game-view rebuilt as a letterboxed foggy scene** (Direction 3b). The child is a **cut-out silhouette** standing in fog (not an arch); corner-drawer collections; conversation as a **fading subtitle transcript** with a labeled two-voice split; **amber = memory / red = cruelty** color language; **whispered in-voice feedback cues**; full **lore** (Caor / the Gaunt / brother Edren); "What it remembers" weaves the memories it stole from you; **O2** scene (edges dissolve to shadow/fog); **F1** stone command-box input; deterministic dev seed; finale pacing/convulse/sigh fixes.
13
- - **Next:** (1) live-verify the redesign in `.venv-tts` + Ollama with Pablo; (2) replicate the GOOD finale's snappy pacing + **silent convulse beat** to the **loop & bad** finales (only good has it); (3) optional: blur the central frame edges MORE via the scene **mask + lower contrast** (the heavy frame-edge shadow approach FAILED — see gotchas); (4) merge `feat/child-redesign` → `feat/voice-awareness`, then deploy **mirror-first**; (5) deliverables (demo video, LinkedIn, Field Notes) are Pablo's.
14
 
15
  ## Stack (decided — do not reopen)
16
- - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot`. Three full-viewport views toggled by visibility: `menu_view` (Fear & Hunger front door, modes Tester/Full + How-to-Play) → `intro_view` (client-driven typewriter lore cards) → `game_view` (the scene). Horror visuals are pure CSS; the `<head>` JS controller (`_HEAD_JS`) owns menu music, the chime→greeting ritual, the intro cards, mute, idle timing, and lifting the tone/cue classes onto `#game-view`.
17
- - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`.
18
- - **Voice:** Kokoro-82M TTS (`voice.py`, `af_nicole`, speed 0.88, "28% fog" DSP), **CPU**, import-guarded → **silent if unavailable** (3.13 `.venv` has no kokoro; voice lives in 3.12 `.venv-tts`, and on the Space at py3.11). `_clean_for_tts` strips dashes (Kokoro verbalizes them).
19
- - **State:** plain dict in `gr.State`. **Parsing:** Pydantic v2 + fallback. **Pkg:** `uv`. Space build needs `packages.txt` (espeak-ng) + `python_version: "3.11"`.
20
  - **Rejected (don't reopen):** Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks the loader); LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap.
21
 
22
  ## Submission (Field Guide REQ-06)
23
- 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. Deploy remotes: `space` (mirror), `hackathon` (org submission).
24
 
25
  ## File structure
26
  ```
27
- app.py # thread-cap (OMP/OPENBLAS/MKL=4) at top; 3 views (menu/intro/game); chat() generator;
28
- # _start_turn (stashes msg in state["_pending_msg"], clears box); 3 _play_finale_*; _enter_game/_show_intro;
29
- # _voice_html / _voice_and_pause; _recovered(state); _render_title/_render_bond; _on_idle; _HEAD_JS controller
30
- voice.py # speak(text)->base64 wav|None — Kokoro whisper + locked DSP, CPU, import-guarded; _clean_for_tts; thread-cap
31
- engine.py # run_turn + run_turn_stream generators; _EXTRACTION_SYSTEM; IS_SPACE switch; repetition_penalty. Gen+extract in ONE @spaces.GPU
32
- character.py # build_system_prompt(...); _WORLD note; OWN_FRAGMENTS (5-line Caor arc); OPENING_LINE; INTRO_CARDS/INTRO_SEQUENCES; FRAGMENT_TONES
33
- 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
34
- finale.py # finale_steps (loop) / _bad (wound) / _good (redemption) — pure data; good has a SILENT frenzy beat + Edren recovery
35
- render.py # render_entity(affinity, mode, seq, tone, cue) — emits .tone-now + .cue-now markers; cut-out silhouette + finale modes;
36
- # render_recovered(fragments_told, claimed) — its arc + the stolen memories; render_treasure(...)
 
 
 
 
37
  sting.py # numpy audio synth (sting/chime/heartbeat/sigh/flatline/menu_loop/type_tick) — HF rejects committed .wav
38
- styles.css # §19-22 finale/progress/typing; §23 menu; §24 how-to; §25 intro; §26 GAME SCENE (3b)
39
- assets/ # hollow_{base,terror,almost,end,rage,peace}.webp + *_cut.webp (rembg alpha silhouettes); intro_*.webp; background.webp
40
- tests/ # 268 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
41
- docs/superpowers/{specs,plans}/ # latest: 2026-06-15-lore-and-scene-polish, -game-ui-polish, -game-match-mockup
42
  ```
43
 
44
  ## State shape (gr.State) — read NEW fields with `.get(...)`
@@ -49,57 +53,60 @@ docs/superpowers/{specs,plans}/ # latest: 2026-06-15-lore-and-scene-polish, -g
49
  "wounds": list[str], # cruel quotes (cap 10), shown redacted ▮
50
  "history": list[dict], "turn": int, "last_recall_turn": int|None,
51
  "fragments_told": int, # OWN_FRAGMENTS surfaced (redemption arc)
52
- "ended": bool, "ending": str|None, # "good"|"loop"|"bad"
53
- "force_ending": str|None, # dev seed forces this ending (deterministic)
54
  "msg_lengths": list[int], "last_aware_memory": str|None,
55
- "last_activity": float, "idle_count": int,
56
- "greeted": bool, "mode": str, # "tester"|"full" pacing
57
- "chosen_name": str|None, "named": bool, # the child gropes toward a name
58
- "_pending_msg": str } # transient — _start_turn stashes the sent msg, chat() pops it
59
  ```
60
- Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral`→good/loop) — seeds state AND `force_ending` so the named ending fires deterministically on the next message.
61
 
62
  ## Affinity tiers (base voice; tone modulates on top)
63
- 0–25 Hollow (broken, barely speaks) · 26–50 Curious (repeats you) · 51–75 Too Human (claims memories) · 76–100 Almost (possessive, `almost` face, no sway).
64
 
65
  ## Endings (`decide_ending`, turn start, fully scripted, no GPU)
66
- `force_ending` (dev seed) wins first. Else, one tone-branched gate (no lower threshold races a warm run):
67
- - **tester:** `bad` at `tone≤−30 ∧ turn≥4 ∧ wounds≥2`; `good/loop` at `aff≥50 ∧ claimed≥2` branched by `tone≥20`.
68
  - **full:** `bad` at `tone≤−30 ∧ turn≥6 ∧ wounds≥2`; `good/loop` at `aff≥68 ∧ claimed≥3` branched by `tone≥20`.
69
- - **bad** (Wound Loop): recites cruelties (redacted→un-redacted), frenzy + triple full-screen stab + scream, reveals rage face, "see you... soon."
70
- - **good** (Redemption): warmth surfaced its real past; confesses (Caor/the Gaunt), recovers brother **Edren**, RETURNS the treasure (un-claimed, lit), first smile (`peace`→`peace_dissolve`), resolves in peace — NO sequel/quest hook.
71
  - **loop** (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line.
 
72
  - Prompt tone bands (separate from routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
73
 
74
  ## Lore (canon)
75
- 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. Its family paid the debt; 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 to fill the hollow where its own used to be. It had a **brother (Edren)** who braided its hair. Recovered as the 5-line `OWN_FRAGMENTS` arc; `_WORLD` note in the system prompt keeps improvised dialogue consistent.
76
 
77
- ## Game-view scene anatomy (§26)
78
- - `#game-view` is `position:fixed; inset:0` and defines the **frame geometry as CSS vars** (`--fw/--fh/--band-v/--band-h`). Every in-frame region (drawers, `#game-entity`, `#game-dialogue` subtitle, `#game-groundfog`) is `position:fixed` and anchored to those vars so it sits INSIDE the centered 16:9 frame. `#game-inputbar` is the stone command box at the viewport base, below the frame.
79
- - Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp forest, radial-mask feathered = O2) → `#game-vig`/`#game-frame-edge` → `#game-entity` silhouette → `#game-groundfog`.
80
- - **Voice/Presence:** every reply + finale line is spoken (Kokoro). Reply text streams; each finished sentence → a **client-side audio queue** in `_HEAD_JS` (sentences never cut). **Mute is client-side** (the controller owns `{muted}` in localStorage; the server always synthesizes). Awareness: B3 `style_signal`, B2 `pick_aware_memory` (every 3rd non-recall turn), B1 idle (client-timed 60 s → hidden `#idle-trigger` → `_on_idle`, escalating, −3 aff/−2 tone), B4 `_memory_plea_level` (barren turns plead harder). Feedback cues: capture→Treasure chip pulse + "— it keeps this —"; recall→amber line + claimed mark; recover→"— it remembers —".
 
81
 
82
  ## Gotchas (DON'T re-learn the hard way)
83
- - **`position:fixed` is the layout primitive here** (menu/intro/game). A `transform`/`filter` on ANY ancestor traps fixed children → they fall into Gradio flow. `#game-stage` has `transform:none`; `#game-entity` centers via `left:calc(50% - var(--fw)*0.15)` (NO transform) so the full-screen `.entity-ghost` finale stab isn't trapped in the narrow column.
84
- - **Don't dissolve scene edges with a heavy full-viewport inset shadow** — it darkens the viewport border and leaves a bright-center RECTANGLE (the "cut"). Dissolve via the `#game-scene` radial **mask + low brightness contrast** vs the blur. (210px/90px frame-edge is the accepted ceiling; 250/120 broke it.)
85
- - **Verify CSS against the RUNNING app, never the static harness.** Headless screenshots are fragile in this shell; `.venv` for fast layout probes, `.venv-tts` + Pablo's eyes/ear for the real playthrough. Audio can only be confirmed by ear.
86
- - **Gradio replaces the DOM each update:** CSS *animations* restart (the flash/screamer mechanism); *transitions* don't animate across updates. `prefers-reduced-motion`: disable sway/fog only, NEVER opacity (killed the terror flash once).
87
- - **Cut-out silhouettes:** `hollow_*_cut.webp` (rembg alpha). Child stands free, materializes via brightness/opacity + a bottom fade mask into the fog — NO `blur` (reads as broken image), NO `scale`. Finale/flash overlays (`.entity-flick` etc.) MUST use `object-fit:contain`+bottom like the base, or they balloon past the frame.
88
- - **Finale audio:** one-shot cues (sigh/flatline/sting) carry `class="cue-audio"` (NOT autoplay) → routed through the client voice queue so they don't talk over the playing line; the looping heartbeat stays autoplay. `_voice_and_pause` paces spoken steps to `audio_len + 0.25` (silent beats keep their scripted pause). Good finale splits the convulsion into a spoken line + a **silent `frenzy` beat** so the queue drains and the relief sigh lands exactly on the smile.
89
- - **Input clears on send via state stash:** `_start_turn` sets `state["_pending_msg"]` + clears the box; `chat()` pops it. Do NOT recover from chatbot history — Gradio normalizes message `content` to a list (`'list' has no attribute 'strip'`).
90
- - **The opening always renders the base face** (`_enter_game` → `render_entity(20)`), never the seeded Almost tier — it's the vulnerable first contact.
91
- - **Tone & cue classes ride a marker pattern:** `render_entity` emits hidden `.tone-now[data-tone]` and `.cue-now[data-cue][data-seq]`; `applyTone()`/`applyCue()` (polled in `_HEAD_JS` `wire()`) lift them onto `#game-view` so the WHOLE scene tints (the markers are siblings of `#game-bg`, not ancestors).
92
- - **Qwen emits `+2` (invalid JSON)** without positive anchors → bond froze. Sanitizer in `apply_update` + "no leading +" + dual examples. Keep both.
93
- - **Recall pollution:** extraction grabbed Hollow's own recalled line as a "new memory". `_is_hollows_words` (>60% overlap dropped) + `repetition_penalty 1.3`. Keep both.
94
- - **One `@spaces.GPU` per turn** (gen+extract). NO threading around `run_turn` (breaks ZeroGPU); a `TextIteratorStreamer` thread INSIDE the GPU call is the supported stream pattern (verify on the Space; fall back to non-stream if it breaks).
95
- - **transformers 5.x:** `apply_chat_template(...,return_tensors="pt")`→`BatchEncoding` (`hasattr(t,"input_ids")`); `dtype=` not `torch_dtype`; thinking OFF (`enable_thinking=False` / `"think":False`).
96
- - **HF rejects committed binaries (.wav):** synth at runtime (`sting.py`). New non-image binaries: same.
97
- - **Don't edit `.py`/`.css` via PowerShell string ops** — mangles UTF-8 (em-dashes, `❯`, accents). Use the Edit tool.
98
- - **Cap CPU math threads before torch loads** (`OMP/OPENBLAS/MKL=4`) or OpenBLAS exhausts the CPU allocator → Kokoro silently disabled (NOT real RAM exhaustion).
99
- - **Backend safety net:** `run_turn` failures → in-character "fog swallowed your words" reply. Msgs >500 chars declined in character. **Ollama 500 = ROCm `cudaMalloc` corruption** (every reply becomes the fallback) → restart Ollama; the Space is immune.
100
- - **Gradio 6 moved `js=`/`head=` to `launch()`.** Inject JS via `launch(head=...)`. `gr.HTML` initial values are client-rendered (in `/config`, not page HTML).
 
 
101
 
102
  ## Workflows
103
- **Deploy** (repo root, **mirror-first**): `git push space main` → wait ~3-4 min (Kokoro/spacy build) → Ctrl+F5, verify build+voice → only then `git push hackathon main`. Commit trailer: `Co-Authored-By: Claude <model> <noreply@anthropic.com>`.
104
- **Local voice test:** `$env:HOLLOW_FAST_FINALE='good'; ./.venv-tts/Scripts/python app.py` (PowerShell; `&&` and inline env-prefix don't work) with Ollama running. The 3.13 `.venv` is silent (fine for tests).
105
- **Working style:** phase by phase; explain *why* before code; everything **English** (code/UI/dialogue); no new deps/complexity without asking; push back on scope creep. Big changes via spec → plan (writing-plans) → subagent execution (Sonnet 4.6 has executed the last plans; Opus writes the plans).
 
 
8
  **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.
9
 
10
  ## WHERE WE ARE (read first)
11
+ - **DEPLOYED + LIVE on BOTH Spaces** (no longer frozen at `4deeed5`): mirror `Pabloler21/hollow` (remote `space`) + submission `build-small-hackathon/hollow` (remote `hackathon`, ZeroGPU). The full redesign works on the Space — menu → intro → game (**chat + voice + recall + 3 finales + end screen**). **276 tests green.**
12
+ - **Current work branch `feat/game-chrome-polish`** — game-view chrome polish: controls icon-cluster ✅, drawer counts ✅, dialogue band IN PROGRESS. Pending (plan `docs/superpowers/plans/2026-06-15-dialogue-fixes.md`): **center the player line** (right-align collapses in Gradio's user bubble), **remove the dialogue scrim rectangle**, **shrink the input box**.
13
+ - **Next:** finish those dialogue fixes (verify on the Space); Pablo's deliverables (demo video, LinkedIn, Field Notes) link from the README.
14
 
15
  ## Stack (decided — do not reopen)
16
+ - **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 are 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>`.
17
+ - **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama (`localhost:11434`) locally. Switch: `IS_SPACE = bool(os.environ.get("SPACE_ID"))`. Model loads on CPU at import; `.to("cuda")` happens INSIDE the GPU call.
18
+ - **Voice:** Kokoro-82M (`af_nicole`, speed 0.94, "28% fog" numpy DSP), **CPU**. Runs in a SEPARATE subprocess (`voice_worker.py`, `CUDA_VISIBLE_DEVICES=""`) — MANDATORY on ZeroGPU (gotcha below). `voice.py` drives it over stdio and does the DSP in-process. Import-guarded/silent if kokoro absent (3.13 `.venv`); `HOLLOW_VOICE_OFF=1` disables.
19
+ - **Assets:** `assets/*.webp` tracked via **Git LFS** (HF rejects plain-git binaries — Xet policy; see gotcha). **State:** plain dict in `gr.State`. **Parsing:** Pydantic v2 + fallback. **Pkg:** `uv`. Space build: `packages.txt` (espeak-ng); runtime py3.10.
20
  - **Rejected (don't reopen):** Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks the loader); LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap.
21
 
22
  ## Submission (Field Guide REQ-06)
23
+ 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.
24
 
25
  ## File structure
26
  ```
27
+ app.py # thread-cap (OMP/OPENBLAS/MKL=4) + os.environ["GRADIO_SSR_MODE"]="False" at top; 3 views + #end-overlay;
28
+ # chat() generator; _start_turn (stashes msg in state["_pending_msg"]); 3 _play_finale_*;
29
+ # _enter_game/_show_intro; _reset/_to_menu; _render_title(state, show_end); _render_bond; _on_idle;
30
+ # _HEAD_JS controller (font <link> + JS); HOLLOW_DEBUG per-turn telemetry; launch(head=, ssr_mode=False)
31
+ voice.py # speak(text)->base64 wav|None — drives voice_worker.py over stdio, then numpy DSP (_fog); _clean_for_tts
32
+ voice_worker.py # SEPARATE process: Kokoro with CUDA_VISIBLE_DEVICES=""; protocol on a clean dup'd fd (lib noise -> stderr)
33
+ engine.py # run_turn + run_turn_stream generators; _EXTRACTION_SYSTEM; IS_SPACE switch. Gen+extract in ONE @spaces.GPU
34
+ character.py # build_system_prompt(...); _WORLD note; OWN_FRAGMENTS (5-line Caor arc); OPENING_LINE;
35
+ # INTRO_CARDS (5; tester=[0,4,3] uses a condensed teaching card)/INTRO_SEQUENCES; FRAGMENT_TONES
36
+ memory.py # PACING (tester/full); get_tier; apply_update (+sanitize/Hollow-words filter); should_recall;
37
+ # decide_ending (force_ending bypass); style_signal; pick_aware_memory — pure
38
+ finale.py # finale_steps (loop) / _bad (wound) / _good (redemption) — pure data; ALL THREE have a SILENT frenzy beat
39
+ render.py # render_entity(affinity, mode, seq, tone, cue) — .tone-now/.cue-now markers; render_recovered (count);
40
+ # render_treasure (count) — drawer headers show "· N"
41
  sting.py # numpy audio synth (sting/chime/heartbeat/sigh/flatline/menu_loop/type_tick) — HF rejects committed .wav
42
+ styles.css # §19-22 finale/progress/typing; §23 menu; §24 how-to; §25 intro; §26 GAME SCENE; §27 END SCREEN
43
+ assets/ # Git LFS *.webp: hollow_{base,terror,almost,end,rage,peace}{,_cut}.webp; intro_*.webp; background.webp
44
+ tests/ # 276 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
45
+ docs/superpowers/{specs,plans}/ # AGENT-EXECUTION-BRIEF.md (how to execute a plan here) + dated plans
46
  ```
47
 
48
  ## State shape (gr.State) — read NEW fields with `.get(...)`
 
53
  "wounds": list[str], # cruel quotes (cap 10), shown redacted ▮
54
  "history": list[dict], "turn": int, "last_recall_turn": int|None,
55
  "fragments_told": int, # OWN_FRAGMENTS surfaced (redemption arc)
56
+ "ended": bool, "ending": str|None, "force_ending": str|None, # "good"|"loop"|"bad"; dev seed forces
 
57
  "msg_lengths": list[int], "last_aware_memory": str|None,
58
+ "barren_turns": int, # consecutive turns with no capture (B4 plea); read with .get
59
+ "last_activity": float, "idle_count": int, "greeted": bool, "mode": str, # "tester"|"full"
60
+ "chosen_name": str|None, "named": bool, "_pending_msg": str } # _pending_msg: _start_turn stashes, chat() pops
 
61
  ```
62
+ 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).
63
 
64
  ## Affinity tiers (base voice; tone modulates on top)
65
+ 0–25 Hollow (broken) · 26–50 Curious (repeats you) · 51–75 Too Human (claims memories) · 76–100 Almost (possessive, `almost` face, no sway).
66
 
67
  ## Endings (`decide_ending`, turn start, fully scripted, no GPU)
68
+ `force_ending` (dev seed) wins first. Else one tone-branched gate. Tester is tuned SHORT for judges:
69
+ - **tester:** `bad` at `tone≤−30 ∧ turn≥3 ∧ wounds≥2`; `good/loop` at `aff≥35 ∧ claimed≥2` branched by `tone≥20` (recall_cooldown 1).
70
  - **full:** `bad` at `tone≤−30 ∧ turn≥6 ∧ wounds≥2`; `good/loop` at `aff≥68 ∧ claimed≥3` branched by `tone≥20`.
71
+ - **bad** (Wound Loop): recites cruelties (redacted→un-redacted), SILENT convulse + frenzy/stab/scream, rage face, and a **collective lore threat** (Caor/the Gaunt — "we are all still here… we will teach it back to you"), ends "see you... soon."
72
+ - **good** (Redemption): warmth surfaced its past; confesses (Caor/Gaunt), recovers brother **Edren**, RETURNS the treasure (lit), first smile (`peace`→`peace_dissolve`), peace — NO sequel hook.
73
  - **loop** (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line.
74
+ - 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).
75
  - Prompt tone bands (separate from routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
76
 
77
  ## Lore (canon)
78
+ 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.
79
 
80
+ ## Game-view scene anatomy (§26) + end screen (§27)
81
+ - `#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.
82
+ - Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp, radial-mask feathered) → `#game-vig`/`#game-frame-edge` → `#game-entity` silhouette → `#game-groundfog`.
83
+ - **End screen (§27):** `#end-overlay` is a top-level `gr.Column` (`position:fixed; inset:0`), always mounted, `opacity:0/pointer-events:none` 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).
84
+ - **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 (client-timed 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 —".
85
 
86
  ## Gotchas (DON'T re-learn the hard way)
87
+ - **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 (`is_available=False`) were NOT enough. Confirm with `HOLLOW_VOICE_OFF=1` (gen recovers → voice was the poison).
88
+ - **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).
89
+ - **SSR must be OFF on the Space.** HF defaults Gradio 6 to SSR (Node proxy → the app runs in a SUBPROCESS on :7861), which breaks 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 running `__main__`; force it with `os.environ["GRADIO_SSR_MODE"]="False"` at the top of app.py (gradio reads it in `_resolve_ssr_mode`).
90
+ - **Fonts: `@import` is rejected on the Space** (Gradio injects styles.css after its own, so the `@import` isn't the first rule → "not allowed here"). Load Google Fonts via a `<link>` in `<head>` (in `_HEAD_JS`), not `@import` in styles.css.
91
+ - **HF rejects plain-git binaries (Xet policy):** webp assets are **Git LFS**. The LFS 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).
92
+ - **Local rendering ≠ the Space** (Gradio version, SSR, font load all 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).
93
+ - **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 the label + muted color.
94
+ - **Intro typewriter races the server view-flip:** poll for `#intro-text` (it's rendered after the `_show_intro` round-trip; on the Space the latency loses a fixed `setTimeout`).
95
+ - **`position:fixed` is the layout primitive** (menu/intro/game/end). A `transform`/`filter` on ANY ancestor traps fixed children. `#game-entity` centers via `left:calc(...)` (NO transform) so the full-screen finale stab isn't trapped. **Gradio wraps each component in a full-width `.block`** — `align-items:center` centers the block, not the content; neutralize wrappers to truly center an overlay.
96
+ - **Don't dissolve scene edges with a heavy full-viewport inset shadow** (bright-center rectangle). Use the `#game-scene` radial **mask + low brightness** (210px/90px frame-edge is the ceiling). A dialogue **scrim `::before` also reads as a dark rectangle** — avoid it; rely on text-shadow.
97
+ - **Gradio replaces the DOM each update:** CSS *animations* restart (the flash mechanism); *transitions* don't animate across updates. `prefers-reduced-motion`: disable sway/fog only, NEVER opacity.
98
+ - **Cut-out silhouettes** (`hollow_*_cut.webp`, rembg alpha): materialize via brightness/opacity + bottom fade — NO `blur`, NO `scale`. Finale overlays use `object-fit:contain`+bottom or they balloon.
99
+ - **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. Markers are siblings of `#game-bg`. Don't add outputs to the finale generator tuples (tuple-desync) — use the marker.
100
+ - **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'`).
101
+ - **The opening always renders the base face** (`render_entity(20)`), never the seeded tier.
102
+ - **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.
103
+ - **One `@spaces.GPU` per turn.** NO threading around `run_turn`; a `TextIteratorStreamer` thread INSIDE the GPU call is the supported stream pattern.
104
+ - **transformers 5.x:** `apply_chat_template(...,return_tensors="pt")`→`BatchEncoding`; `dtype=` not `torch_dtype`; thinking OFF.
105
+ - **Don't edit `.py`/`.css`/`.md` via PowerShell string ops** — mangles UTF-8 (em-dashes, `❯`, `↺`, `✦`, accents, emoji). Use the Edit tool.
106
+ - **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).
107
 
108
  ## Workflows
109
+ **Deploy** (repo root, **mirror-first**, history is LFS-rewritten so **force-push** the branch HEAD to `main`):
110
+ `git push --force space HEAD:main` → wait ~3-4 min (model+Kokoro build) → Ctrl+F5, verify on the Space (build + chat + voice + layout) → only then `git push --force hackathon HEAD:main`. Commit trailer: `Co-Authored-By: Claude <model> <noreply@anthropic.com>`.
111
+ **Local:** `pytest -q` (276) + `import app` are the only automated checks; the 3.13 `.venv` runs silent (no kokoro). `$env:HOLLOW_FAST_FINALE='good'; ./.venv-tts/Scripts/python app.py` for a local voice run (PowerShell; `&&`/inline env-prefix don't work) — but **visuals are validated on the Space, not local**.
112
+ **Working style:** phase by phase; explain *why* before code; everything **English**; no new deps/complexity without asking; push back on scope creep. Big changes via spec → plan (writing-plans) → executor (Opus plans; an executing agent implements task-by-task — read `docs/superpowers/AGENT-EXECUTION-BRIEF.md`).