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
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
|
| 13 |
|
| 14 |
## WHERE WE ARE (read first)
|
| 15 |
-
- **
|
| 16 |
-
- **
|
| 17 |
-
- **Next:**
|
| 18 |
|
| 19 |
## Commands
|
| 20 |
```bash
|
| 21 |
-
# Tests (no model/GPU needed
|
| 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
|
| 28 |
-
$env:HOLLOW_FAST_FINALE='good'; ./.venv-tts/Scripts/python app.py # good|loop|bad
|
| 29 |
|
| 30 |
-
# Deploy — mirror-first
|
| 31 |
-
git push space main
|
| 32 |
-
git push hackathon main
|
| 33 |
```
|
| 34 |
-
No linter/formatter/typechecker. `pytest` + `import app` are the only automated
|
| 35 |
|
| 36 |
## Stack (decided — do not reopen)
|
| 37 |
-
- **UI:** Gradio 6.
|
| 38 |
-
- **Model:** `Qwen/Qwen3-8B` via `transformers`+`@spaces.GPU(duration=90)` on Space; `qwen3:8b` via Ollama
|
| 39 |
-
- **Voice:** Kokoro-82M
|
| 40 |
-
- **State:** plain dict in `gr.State`. **Parsing:** Pydantic v2 + fallback. **Pkg:** `uv`. Space build
|
| 41 |
-
- **Rejected (don't reopen):** Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks
|
| 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
|
| 49 |
-
# _start_turn (stashes msg in state["_pending_msg"]
|
| 50 |
-
#
|
| 51 |
-
#
|
| 52 |
-
voice.py # speak(text)->base64 wav|None —
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
#
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 75 |
-
"force_ending": str|None, # dev seed forces this ending (deterministic)
|
| 76 |
"msg_lengths": list[int], "last_aware_memory": str|None,
|
| 77 |
-
"
|
| 78 |
-
"greeted": bool, "mode": str,
|
| 79 |
-
"chosen_name": str|None, "named": bool,
|
| 80 |
-
"_pending_msg": str } # transient — _start_turn stashes the sent msg, chat() pops it
|
| 81 |
```
|
| 82 |
-
|
| 83 |
|
| 84 |
## Affinity tiers (base voice; tone modulates on top)
|
| 85 |
-
0–25 Hollow (broken
|
| 86 |
|
| 87 |
## Endings (`decide_ending`, turn start, fully scripted, no GPU)
|
| 88 |
-
`force_ending` (dev seed) wins first. Else
|
| 89 |
-
- **tester:** `bad` at `tone≤−30 ∧ turn≥
|
| 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
|
| 92 |
-
- **good** (Redemption): warmth surfaced its
|
| 93 |
- **loop** (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line.
|
| 94 |
-
- The finale's
|
| 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.
|
| 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`).
|
| 102 |
-
- Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp
|
| 103 |
-
- **End screen (§27):** `#end-overlay` is a top-level `gr.Column` (`position:fixed; inset:0`), always mounted,
|
| 104 |
-
- **Voice/Presence:** every reply + finale line is spoken (Kokoro). Reply text streams; each finished sentence → a **client-side audio queue** in `_HEAD_JS`
|
| 105 |
|
| 106 |
## Gotchas (DON'T re-learn the hard way)
|
| 107 |
-
- **
|
| 108 |
-
- **
|
| 109 |
-
- **
|
| 110 |
-
- **
|
| 111 |
-
- **
|
| 112 |
-
- **
|
| 113 |
-
- **
|
| 114 |
-
- **
|
| 115 |
-
- **
|
| 116 |
-
- **
|
| 117 |
-
- **
|
| 118 |
-
- **
|
| 119 |
-
- **
|
| 120 |
-
- **
|
| 121 |
-
- **
|
| 122 |
-
- **
|
| 123 |
-
- **
|
| 124 |
-
- **
|
| 125 |
-
- **
|
|
|
|
| 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
|
|
|
|
| 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 |
-
- **
|
| 12 |
-
- **
|
| 13 |
-
- **Next:**
|
| 14 |
|
| 15 |
## Stack (decided — do not reopen)
|
| 16 |
-
- **UI:** Gradio 6.
|
| 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
|
| 19 |
-
- **State:** plain dict in `gr.State`. **Parsing:** Pydantic v2 + fallback. **Pkg:** `uv`. Space build
|
| 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) at top; 3 views
|
| 28 |
-
# _start_turn (stashes msg in state["_pending_msg"]
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 39 |
-
assets/ # hollow_{base,terror,almost,end,rage,peace}
|
| 40 |
-
tests/ #
|
| 41 |
-
docs/superpowers/{specs,plans}/ #
|
| 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,
|
| 53 |
-
"force_ending": str|None, # dev seed forces this ending (deterministic)
|
| 54 |
"msg_lengths": list[int], "last_aware_memory": str|None,
|
| 55 |
-
"
|
| 56 |
-
"greeted": bool, "mode": str,
|
| 57 |
-
"chosen_name": str|None, "named": bool,
|
| 58 |
-
"_pending_msg": str } # transient — _start_turn stashes the sent msg, chat() pops it
|
| 59 |
```
|
| 60 |
-
|
| 61 |
|
| 62 |
## Affinity tiers (base voice; tone modulates on top)
|
| 63 |
-
0–25 Hollow (broken
|
| 64 |
|
| 65 |
## Endings (`decide_ending`, turn start, fully scripted, no GPU)
|
| 66 |
-
`force_ending` (dev seed) wins first. Else
|
| 67 |
-
- **tester:** `bad` at `tone≤−30 ∧ turn≥
|
| 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),
|
| 70 |
-
- **good** (Redemption): warmth surfaced its
|
| 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.
|
| 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`).
|
| 79 |
-
- Layers: `#game-bg` (blurred forest) → `#game-scene` (sharp
|
| 80 |
-
- **
|
|
|
|
| 81 |
|
| 82 |
## Gotchas (DON'T re-learn the hard way)
|
| 83 |
-
- **
|
| 84 |
-
- **
|
| 85 |
-
- **
|
| 86 |
-
- **
|
| 87 |
-
- **
|
| 88 |
-
- **
|
| 89 |
-
- **
|
| 90 |
-
- **
|
| 91 |
-
- **
|
| 92 |
-
- **
|
| 93 |
-
- **
|
| 94 |
-
- **
|
| 95 |
-
- **
|
| 96 |
-
- **
|
| 97 |
-
- **
|
| 98 |
-
- **
|
| 99 |
-
- **
|
| 100 |
-
- **
|
|
|
|
|
|
|
| 101 |
|
| 102 |
## Workflows
|
| 103 |
-
**Deploy** (repo root, **mirror-first**
|
| 104 |
-
|
| 105 |
-
**
|
|
|
|
|
|
| 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`).
|