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