Pabloler21 Claude Opus 4.8 commited on
Commit
58b47ef
·
1 Parent(s): 3f5210c

docs: rewrite AGENTS.md as a current mirror of CLAUDE.md

Browse files

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (1) hide show
  1. AGENTS.md +106 -134
AGENTS.md CHANGED
@@ -1,160 +1,132 @@
1
  # Hollow — Agent Instructions
2
 
3
- > This file mirrors CLAUDE.md so any agent has the same context, good practices, and hard-learned error memories.
4
 
5
  ## What this is
6
  A horror NPC chatbot for the **Build Small Hackathon** (HF / Gradio). Track: 🍄 An Adventure in Thousand Token Wood. **Deadline: Mon June 15, 2026.**
7
 
8
- **Pitch:** A lost child at the edge of the wood has no memories of its own. It asks for yours, stores them in a "treasure", later claims them as its own in first person (the "recall" — core wow). How you *treat* it branches three endings.
9
 
10
- **Spaces (deploy to both every time):**
11
  - Personal mirror: https://huggingface.co/spaces/Pabloler21/hollow (remote `space`)
12
- - **Submission** (must live in org): https://huggingface.co/spaces/build-small-hackathon/hollow (remote `hackathon`) — LIVE, ZeroGPU `zero-a10g`, public.
13
 
14
- ## Commands
 
 
 
15
 
 
16
  ```bash
17
- # Run all tests (no model/GPU needed — tests mock or avoid run_turn)
18
- .venv/Scripts/python -m pytest
19
-
20
- # Run a single test file
21
- .venv/Scripts/python -m pytest tests/test_memory.py
22
-
23
- # Run a single test
24
- .venv/Scripts/python -m pytest tests/test_memory.py::TestGetTier::test_hollow_at_zero
25
-
26
- # Launch app locally (Ollama backend on localhost:11434)
27
- .venv/Scripts/python app.py
28
-
29
- # Launch with fast finale for testing a specific ending
30
- HOLLOW_FAST_FINALE=good .venv/Scripts/python app.py
31
- # Values: good, loop, bad, 1/neutral (aliases for good/loop)
32
-
33
- # Deploy (push to both HF Spaces)
34
- git push space main && git push hackathon main
35
  ```
36
-
37
- No linter, formatter, or typechecker is configured. After changes, run `pytest` as the only verification step.
38
 
39
  ## Stack (decided — do not reopen)
40
- - **UI:** Gradio 6.x `gr.Blocks` + `gr.Chatbot`. A Fear & Hunger-inspired **front-door menu** (`menu_view`) with painted foggy-wood backdrop (`assets/background.webp`), two modes (`Tester Run` / `The Full Stay`), and a `How to Play` overlay switches to the hidden `game_view` on mode-select. Horror visuals are pure CSS; the `<head>` JS controller handles menu music, the chime→greeting ritual on mode-select/restart, mute, and idle timing.
41
  - **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"))`.
42
- - **Voice:** Kokoro-82M TTS (`voice.py`), **CPU** — the child whispers (`af_nicole`, speed 0.88, "28% fog" numpy DSP). Import-guarded → **silent if unavailable** (local 3.13 `.venv` has no kokoro; works in 3.12 `.venv-tts` and on the Space at py3.11). Per-reply/finale audio inlined as autoplay `<audio>` in a mounted-but-CSS-collapsed `voice_panel`. The opening ritual (detuned music-box chime → spoken greeting) is queued from `data:` URI constants in `_HEAD_JS`. Plays on every Hollow reply + all finale lines.
43
- - **State:** plain dict in `gr.State` per session. **Parsing:** Pydantic v2 + fallback.
44
- - **Art:** grayscale FLUX portraits (Fear & Hunger style) in `assets/`, base64 WebP. Audio synthesized in numpy at import (`sting.py`).
45
- - **Pkg manager:** `uv` (`uv pip install`). Space build also needs `packages.txt` (espeak-ng) + `python_version: "3.11"` (Kokoro needs <3.13).
46
- - **Rejected (don't reopen):** Gemma 4 12B (no 8 GB VRAM fit); **Qwen3.5/3.6 (all multimodal → break the transformers loader; the field uses 1-8B, so 8B stays on-brand)**; LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap (re-tuning risk for speculative prize).
47
 
48
- ## Submission requirements (verified, Field Guide REQ-06)
49
- - Space in the org ✅. README frontmatter: track + badge tags + write-up ✅ (`track:wood`, `achievement:offgrid/offbrand/fieldnotes`). **No submission form — the Space in the org with a complete README IS the entry.**
50
- - **Pending:** demo video (public, YouTube or uploaded), one social post (LinkedIn) — both linked FROM the README; Field Notes blog post.
51
- - Models ≤32B. Badges pursued: 🔌 Off-the-Grid, 🎨 Off-Brand, 📓 Field Notes.
52
 
53
  ## File structure
54
  ```
55
- app.py # thread-cap (OMP/OPENBLAS=4) at top; Gradio layout (3 cols: entity|chat|treasure + hidden voice_panel); chat() generator; 3 _play_finale_*; _PENDING typing bubble; _on_idle (hidden #idle-trigger); _memory_plea_level; _voice_html/_voice_and_pause; _HEAD_JS controller (chime-ritual/mute/idle — mute only, no volume slider); show_progress="hidden"
56
- voice.py # speak(text)->base64 wav|None — Kokoro-82M whisper + locked DSP, CPU, import-guarded; thread-cap; _clean_for_tts strips dashes (Kokoro verbalizes them)
57
- engine.py # run_turn + run_turn_stream(chat_messages, user_msg) generators; _EXTRACTION_SYSTEM; IS_SPACE switch; repetition_penalty on reply gen. Extraction runs once after the stream inside the same GPU acquisition. (Active plan: split into a streaming generator `run_turn_stream`.)
58
- character.py # build_system_prompt(... tone, wounds, memory_plea, own_fragment, style, aware_memory); tone bands; OWN_FRAGMENTS; OPENING_LINE
59
- memory.py # get_tier, apply_update (+ sanitize/Hollow-words filter), should_recall, decide_ending, style_signal (B3), pick_aware_memory (B2) — pure
60
- finale.py # finale_steps (loop), finale_steps_bad (wound), finale_steps_good (redemption) — pure data; each has a "frenzy" convulse stage
61
- render.py # render_entity(affinity, mode, seq) — modes: idle/flash/flash_strong/build/frenzy/convulse_good/convulse_loop/end/end_settle/rage/peace/peace_settle/peace_dissolve; materialization = fog+brightness (no blur); render_treasure(...)
62
- sting.py # _wav + sting/chime/heartbeat/sigh/flatline/menu_loop _wav_bytes — numpy audio synth (HF rejects committed .wav)
63
- styles.css # horror CSS §15-24 (23 = opening menu, 24 = How to Play overlay; 22 = finale/restart/voice-channel)
64
- assets/ # hollow_{base,terror,almost,end,rage,peace}.webp (rage = almost copy placeholder; peace = real art)
65
- requirements.txt / packages.txt # +kokoro/misaki[en]/soundfile/spacy/en_core_web_sm; espeak-ng (apt)
66
- tests/ # 231 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
67
- docs/superpowers/{specs,plans}/ # approved specs + plans (active: 2026-06-14-pacing-streaming-overhaul)
68
- tools/pacing_sim.py # scripted visitors through real Ollama — pacing tuning
 
 
69
  ```
70
 
71
- ## State shape (gr.State) — all NEW-field reads use `.get(...)` for pre-deploy sessions
72
  ```python
73
- {
74
- "affinity": int, # 0..100, starts 20 — how MUCH they share (Bond)
75
- "tone": int, # -100..100, starts 0 — how they TREAT it
76
- "treasure": list[str], # memories shared
77
- "claimed": list[str], # memories Hollow reclaimed (recall dedup)
78
- "wounds": list[str], # cruel quotes captured (cap 10), shown redacted ▮
79
- "history": list[dict], # {"role","content"}
80
- "turn": int,
81
- "last_recall_turn": int | None,
82
- "fragments_told": int, # how many OWN_FRAGMENTS surfaced (redemption path)
83
- "ended": bool, # a finale fired — input dead
84
- "ending": str | None, # "good"|"loop"|"bad" once fired
85
- "msg_lengths": list[int], # B3 — rolling word counts (cap 5) for style_signal
86
- "last_aware_memory": str|None, # B2 — avoid re-surfacing the same memory
87
- "last_activity": float, # vestigial (client owns idle timing now)
88
- "idle_count": int, # how many idle nudges fired (tier + bond decay)
89
- "barren_turns": int, # consecutive turns with no captured memory
90
- "mode": str, # "tester" | "full" — pacing profile
91
- }
92
  ```
93
- Dev seed: `HOLLOW_FAST_FINALE=good|loop|bad` (`1`/`neutral` alias→ good/loop) — each ending fires on the 2nd message.
94
-
95
- ## Affinity tiers (the base voice; tone modulates on top)
96
- | Range | Tier | Voice | Portrait |
97
- |-------|------|-------|----------|
98
- | 0–25 | Hollow | broken, barely speaks | smudge in fog |
99
- | 26–50 | Curious | repeats your words | child-shaped, no face |
100
- | 51–75 | Too Human | fluent, claims memories | sad eyes |
101
- | 76–100| Almost | possessive | `hollow_almost`, no sway |
102
-
103
- ## Ending matrix (`decide_ending`, checked at turn start, fully scripted, no GPU)
104
- Mode-driven `PACING` config in `memory.py` (tester = fast/judge-friendly, full = longer experience). Single gate so no lower threshold can race a warm run:
105
  - **tester:** `bad` at `tone≤−30 ∧ turn≥4 ∧ wounds≥2`; `good/loop` at `aff≥50 ∧ claimed≥2` branched by `tone≥20`.
106
  - **full:** `bad` at `tone≤−30 ∧ turn≥6 ∧ wounds≥2`; `good/loop` at `aff≥68 ∧ claimed≥3` branched by `tone≥20`.
107
- - **bad** (Wound Loop): recites your cruelties (redacted ▮ → un-redacted), header→"Your Words", frenzy convulsion + triple full-screen stab + scream, reveals `hollow_almost`, threatens, ends "see you... soon."
108
- - **good** (Redemption): genuine sustained warmth surfaced `OWN_FRAGMENTS` of its real past; finale confesses it's the thing that waits, RETURNS the treasure (un-claimed, lit), first smile (`hollow_peace` → `peace_dissolve`), leaves in peace.
109
- - **loop** (Visitor Loop): fed plenty but treated transactionally, never truly loved → stays a predator, consumes you, ends with YOU speaking its opening line.
110
- - Tone bands in prompt (separate from ending routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.
111
-
112
- ## Voice + Presence (the child speaks & watches)
113
- - **Voice:** every Hollow reply + all finale lines are spoken (Kokoro, CPU). The front-door menu plays a synthesized eerie **menu loop** on the first gesture; choosing a mode stops it and plays the **chime → greeting** ritual. "Begin again" re-triggers the ritual. The reply text streams token-by-token, and each completed sentence is synthesized and handed to a **client-side audio queue** in the `<head>` controller so sentences play in order without cutting each other. Finale `_voice_and_pause` stretches each step's pause to the audio length (+0.15 s). **Mute is client-side**: the controller owns global `{muted}` (persisted to `localStorage`), applies it to queued audio, menu music, and future audio, driving the 🔊/🔇 button. The server always synthesizes; the browser decides audibility.
114
- - **Awareness (B1/B2/B3):** B3 `style_signal` (Hollow notices short/long messages); B2 `pick_aware_memory` (resurfaces an earlier memory unprompted, every 3rd non-recall turn); B1 idle is timed client-side (60 s, reset by any pointer/keyboard/input/scroll) and clicks a hidden `#idle-trigger` to run `_on_idle` — escalating plead→hurt→hostile (`_IDLE_TIERS`) with affinity −3 / tone −2 per nudge. B4 `memory_plea`: consecutive barren turns make Hollow ask more plainly for a real memory.
115
- - **Materialization:** the child is **sharp, veiled in fog + shadow** (brightness 0.4→1, fog/vig denser at low bond) — NOT blurred (blur read as a broken image).
116
-
117
- ## Critical Gotchas (DON'T re-learn these the hard way)
118
-
119
- - **Verify CSS against the RUNNING app**, never static HTML — it lacks Gradio's cascade and has lied. Workflow: launch `HOLLOW_FAST_FINALE=good .venv/Scripts/python app.py` background → headless Chrome screenshot (`chrome.exe --headless --disable-gpu --screenshot=... --window-size=1900,950 --virtual-time-budget=12000 http://localhost:7860`) → Read the PNG → kill the port owner.
120
- - **Headless verification is fragile in this shell env.** Background children spawned via `Start-Job`/`Start-Process` are killed when the shell session ends, so the app cannot be launched in one command and screenshot/audio-checked in another. Chrome headless also refused `localhost:7860`/`127.0.0.1:7860` even when `Test-NetConnection` succeeded. Use `PYTHONUNBUFFERED=1` to see startup logs; use `.venv` for fast layout probes; reserve `.venv-tts` + the user's ear for the real playthrough.
121
- - **Gradio replaces the HTML DOM each update:** CSS *animations* restart (this IS the flash/screamer mechanism); CSS *transitions* DON'T animate across updates.
122
- - **`prefers-reduced-motion`:** only disable sway/fog drift. NEVER opacity fades — that silently killed the terror flash once.
123
- - **Qwen emits `+2` (invalid JSON)** if examples lack positive anchors → every positive turn fell back to delta 0 (bond frozen). Fixed by sanitizer in `apply_update` + explicit "no leading +" instruction + dual examples. Keep both.
124
- - **transformers 5.x:** `apply_chat_template(...,return_tensors="pt")` returns `BatchEncoding` (use `hasattr(t,"input_ids")`); use `dtype=` not deprecated `torch_dtype`. Qwen3 thinking OFF: `enable_thinking=False` (Space) / `"think": False` (Ollama).
125
- - **One GPU acquisition per turn:** gen + extract inside a single `@spaces.GPU` call. ZeroGPU ≈8 s/turn, PRO ≈1500 s/day.
126
- - **Portrait fills the arch:** art is square (1:1), arch is 5:6 → materialization is brightness/opacity/fog ONLY, never `scale` (left an ugly gap). **No CSS `blur`** — it read as a broken/loading image; the child is sharp, veiled in shadow+fog. `!important` img sizing (Gradio base reset wins otherwise).
127
- - **HF rejects committed binaries (.wav):** synthesize audio at runtime (`sting.py`, `voice.py`). Same would apply to any new binary asset that isn't an image.
128
- - **Don't edit `.py` via PowerShell string ops** — it mangled UTF-8 (em-dash → mojibake) once. Use the Edit tool.
129
- - **Ollama ROCm can corrupt** (`cudaMalloc failed` with free VRAM) → symptom is red Error on ALL components. Fix: restart Ollama (tray + server). The Space (transformers) is immune.
130
- - **Backend safety net:** `run_turn` failures degrade to an in-character "fog swallowed your words" reply (no Error chip, recall not burned). Messages >500 chars declined in character, no GPU spent.
131
- - **Recall pollution:** on recall turns Hollow retells a memory in 1st person; extraction can grab THAT line as a "new memory", then keep recalling its own words verbatim. Filtered in `apply_update` (`_is_hollows_words`, >60% token overlap with the reply dropped) + `repetition_penalty 1.3` on reply gen. Keep both.
132
- - **NO threading around `run_turn`:** `@spaces.GPU` needs the main request context — a thread pool breaks ZeroGPU. (A `TextIteratorStreamer` thread *inside* the `@spaces.GPU` call is different — that's the supported streaming pattern; verify on the Space before relying on it. A generator that yields partials is NOT a worker thread either.)
133
- - **Space streaming caveat:** `TextIteratorStreamer` inside `@spaces.GPU` was the documented way to stream on ZeroGPU, but it must be verified on the mirror Space before trusting it. If it breaks, gate the Space branch to fall back to the non-streaming `run_turn` (yield the whole reply once), leaving the Ollama path streaming.
134
- - **Hide Gradio progress, animate our own:** `show_progress="hidden"` on the events kills the per-component "processing | Xs" overlay (CSS class-hiding was unreliable); we render our own `_PENDING` typing bubble. The HTML span + class survives Gradio's markdown sanitizer (verified) so §20 CSS animates it.
135
- - **Idle is client-timed via the `<head>` controller:** a hidden `#idle-trigger` button runs `_on_idle`; the server only guards against pending/ended states. Any interaction resets the 60 s clock.
136
- - **Browser autoplay blocks audio before the visitor's first gesture** — the head controller starts the menu loop on the first menu gesture, then plays the chime → greeting ritual when a mode is selected or "begin again" is clicked. The controller applies volume/mute to queued voice audio and the menu bed via MutationObserver + direct reference.
137
- - **Mute is client-side, not server-side:** never gate `speak()` on a `voice_on` flag. The server always synthesizes audio; the browser mutes the playing element.
138
- - **Media mute = control the element, never replay.** Mute must toggle the *playing* audio's `.muted` (client-side); do NOT clear-then-replay-the-last-line on un-mute — that produced an infinite re-play loop (bug we hit). The browser owns playback state, not the server.
139
- - **Never default a time/clock field to `0.0`.** `time.time() - 0` ≈ 1.7e9 s, so any "elapsed since" gate fires instantly (bug: the idle nudge fired every 4 s, not after 35 s). Stamp at session start, or better, time it client-side.
140
- - **Gradio 6.0 moved `js=` and `head=` from the `Blocks` constructor to `launch()`** — pass them to `launch(...)` (Blocks emits a deprecation warning). `launch(js=)` did NOT reliably attach a `window` listener; injecting a `<head>` `<script>` via `launch(head=...)` is the robust way to add JS.
141
- - **Verify shipped JS/markup server-side, audio by ear.** `curl` the page HTML for `<head>` injection; `curl /config` for `gr.HTML` initial values (they're client-rendered, NOT in the page HTML). Headless `--dump-dom` returns empty for the Gradio SPA here — don't trust it. Audio can only be confirmed by the user listening.
142
- - **Kokoro deps:** import-guarded (silent fallback, never crashes). Needs python <3.13, `misaki[en]`→`spacy`+`en_core_web_sm`, `espeak-ng` (apt). Local: 3.12 `.venv-tts` for voice; the 3.13 `.venv` runs silent (fine for tests, which mock `speak`). TTS verbalizes dashes → strip them (`_clean_for_tts`, planned).
143
- - **Ollama 500 = the ROCm `cudaMalloc` corruption** (see above), NOT our code — a short prompt may pass while a real 300-token turn 500s. Symptom in-app: every reply is the "fog swallowed your words" fallback. Fix: restart Ollama (kill `ollama.exe` + `ollama app.exe`, reopen). The Space (transformers) is immune.
144
-
145
- ## Conventions
146
-
147
  - **Everything in English:** code, prompts, UI, dialogue.
148
- - **Working style:** phase by phase; explain *why* before code; no new deps/complexity without asking; push back on scope creep.
149
- - **Deploy targets:** push to both `space` (personal mirror) and `hackathon` (submission org) remotes.
150
- - **Commit trailer:** `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
151
- - **After deploy:** wait ~2 min for rebuild, then Ctrl+F5 (cache serves old build otherwise).
152
- - **Art:** grayscale FLUX portraits in `assets/` as base64 WebP. `hollow_rage.webp` is a placeholder copy of `hollow_almost`. To swap a real PNG: `Image.open(png).convert("RGB").resize((512,512),LANCZOS).save(webp,"WEBP",quality=80,method=6)`. Pure asset swap.
153
- - **Voice:** Kokoro TTS (`af_nicole` voice, 28% fog filter, speed 0.80). CPU only. Import-guarded — app runs silent if kokoro unavailable.
154
-
155
- ## What's next / pending
156
- - **Current work is on branch `feat/front-door-menu`, branched off `feat/voice-awareness`, NOT deployed.** Both Spaces are frozen at the pre-voice commit (`4deeed5`). Done on the branch (231 tests green): front-door menu (Direction C), Tester/Full pacing modes, How to Play overlay, menu music + mode-select chime→greeting ritual.
157
- - **Before merge/deploy:** live playthrough in `.venv-tts` + Ollama with the user to verify menu visuals, music, mode-select ritual, mute, and both pacing arcs; then merge `feat/front-door-menu` → `feat/voice-awareness`. Deploy remains **mirror-first**.
158
- - **README/FIELD_NOTES:** update the stale "no JS" boast; the only JS satisfies browser autoplay-gesture + audio/idle + menu music.
159
- - **`FIELD_NOTES.md`** and deliverables (demo video, LinkedIn post) are Pablo's.
160
- - **Mon June 15:** submit early + community vote.
 
1
  # Hollow — Agent Instructions
2
 
3
+ > Cross-tool mirror of `CLAUDE.md` (same context, practices, hard-won error memories). Keep the two in sync. Record CURRENT STATE + reusable lessons, NOT a day-by-day changelog (git has that). When something changes, edit the relevant line; don't append.
4
 
5
  ## What this is
6
  A horror NPC chatbot for the **Build Small Hackathon** (HF / Gradio). Track: 🍄 An Adventure in Thousand Token Wood. **Deadline: Mon June 15, 2026.**
7
 
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
+ **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(...)`
67
  ```python
68
+ { "affinity": int, # 0..100, start 20 — how MUCH they share (Bond)
69
+ "tone": int, # -100..100, start 0 — how they TREAT it
70
+ "treasure": list[str], "claimed": list[str], # shared / reclaimed (recall dedup)
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.