hollow / AGENTS.md
Pabloler21's picture
docs: update CLAUDE.md + AGENTS.md — dialogue fixes deployed, child-left parked
6133be0
|
Raw
History Blame Contribute Delete
16.6 kB

A newer version of the Gradio SDK is available: 6.20.0

Upgrade

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):

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_viewintro_view (typewriter lore cards) → game_view + a top-level #end-overlay. SSR forced OFF (os.environ["GRADIO_SSR_MODE"]="False" at the TOP of app.py — see gotcha). Horror visuals pure CSS; the <head> controller (_HEAD_JS, via launch(head=...)) owns menu music, the chime→greeting ritual, the intro cards, mute, idle, the tone/cue/end markers, AND the Google-Fonts <link>.
  • Model: Qwen/Qwen3-8B via transformers+@spaces.GPU(duration=90) on Space; qwen3:8b via Ollama locally. Switch: IS_SPACE = bool(os.environ.get("SPACE_ID")). Model loads on CPU at import; .to("cuda") INSIDE the GPU call.
  • Voice: Kokoro-82M (af_nicole, speed 0.94, "28% fog" numpy DSP), CPU, in a SEPARATE subprocess (voice_worker.py, CUDA_VISIBLE_DEVICES="") — MANDATORY on ZeroGPU (gotcha below). voice.py drives it over stdio + does the DSP. Import-guarded/silent if kokoro absent; HOLLOW_VOICE_OFF=1 disables.
  • Assets: assets/*.webp via Git LFS (HF rejects plain-git binaries — Xet policy). State: plain dict in gr.State. Parsing: Pydantic v2 + fallback. Pkg: uv. Space build: packages.txt (espeak-ng); runtime py3.10.
  • Rejected (don't reopen): Gemma 4 12B (VRAM); Qwen3.5/3.6 (multimodal breaks loader); LangGraph, Qdrant, cloud APIs, fine-tuning, sponsor-model swap.

Submission (Field Guide REQ-06)

Space in the org + complete README IS the entry (no form). README frontmatter has track + badges + write-up. Pending: demo video, one LinkedIn post (both linked FROM README), Field Notes post. Models ≤32B. Badges: 🔌 Off-the-Grid, 🎨 Off-Brand, 📓 Field Notes.

File structure

app.py        # thread-cap (OMP/OPENBLAS/MKL=4) + os.environ["GRADIO_SSR_MODE"]="False" at top; 3 views + #end-overlay;
              #   chat() generator; _start_turn (stashes msg in state["_pending_msg"]); 3 _play_finale_*;
              #   _enter_game/_show_intro; _reset/_to_menu; _render_title(state, show_end); _render_bond; _on_idle;
              #   _HEAD_JS controller (font <link> + JS); HOLLOW_DEBUG per-turn telemetry; launch(head=, ssr_mode=False)
voice.py      # speak(text)->base64 wav|None — drives voice_worker.py over stdio, then numpy DSP (_fog); _clean_for_tts
voice_worker.py # SEPARATE process: Kokoro with CUDA_VISIBLE_DEVICES=""; protocol on a clean dup'd fd (lib noise -> stderr)
engine.py     # run_turn + run_turn_stream generators; _EXTRACTION_SYSTEM; IS_SPACE switch. Gen+extract in ONE @spaces.GPU
character.py  # build_system_prompt(...); _WORLD note; OWN_FRAGMENTS (5-line Caor arc); OPENING_LINE;
              #   INTRO_CARDS (5; tester=[0,4,3] uses a condensed teaching card)/INTRO_SEQUENCES; FRAGMENT_TONES
memory.py     # PACING (tester/full); get_tier; apply_update (+sanitize/Hollow-words filter); should_recall;
              #   decide_ending (force_ending bypass); style_signal; pick_aware_memory — pure
finale.py     # finale_steps (loop) / _bad (wound) / _good (redemption) — pure data; ALL THREE have a SILENT frenzy beat
render.py     # render_entity(affinity, mode, seq, tone, cue) — .tone-now/.cue-now markers; render_recovered (count);
              #   render_treasure (count) — drawer headers show "· N"
sting.py      # numpy audio synth — HF rejects committed .wav
styles.css    # §19-22 finale/progress/typing; §23 menu; §24 how-to; §25 intro; §26 GAME SCENE; §27 END SCREEN
assets/       # Git LFS *.webp: hollow_{base,terror,almost,end,rage,peace}{,_cut}.webp; intro_*.webp; background.webp
tests/        # 276 tests: test_{memory,render,finale,schemas,engine,character,app,sting,voice}.py
docs/superpowers/{specs,plans}/   # AGENT-EXECUTION-BRIEF.md (how to execute a plan here) + dated plans

State shape (gr.State) — read NEW fields with .get(...)

{ "affinity": int,      # 0..100, start 20 — how MUCH they share (Bond)
  "tone": int,          # -100..100, start 0 — how they TREAT it
  "treasure": list[str], "claimed": list[str],   # shared / reclaimed (recall dedup)
  "wounds": list[str],  # cruel quotes (cap 10), shown redacted ▮
  "history": list[dict], "turn": int, "last_recall_turn": int|None,
  "fragments_told": int,        # OWN_FRAGMENTS surfaced (redemption arc)
  "ended": bool, "ending": str|None, "force_ending": str|None,   # "good"|"loop"|"bad"; dev seed forces
  "msg_lengths": list[int], "last_aware_memory": str|None,
  "barren_turns": int,          # consecutive turns with no capture (B4 plea); read with .get
  "last_activity": float, "idle_count": int, "greeted": bool, "mode": str,  # "tester"|"full"
  "chosen_name": str|None, "named": bool, "_pending_msg": str }   # _pending_msg: _start_turn stashes, chat() pops

Env flags: HOLLOW_FAST_FINALE=good|loop|bad (1/neutral→good/loop; seeds state+force_ending so the ending fires on the next message); HOLLOW_DEBUG=1 (per-turn aff/tone/treasure/claimed/... log); HOLLOW_VOICE_OFF=1 (silence).

Affinity tiers (base voice; tone modulates on top)

0–25 Hollow (broken) · 26–50 Curious (repeats you) · 51–75 Too Human (claims memories) · 76–100 Almost (possessive, almost face, no sway).

Endings (decide_ending, turn start, fully scripted, no GPU)

force_ending (dev seed) wins first. Else one tone-branched gate. Tester is tuned SHORT for judges:

  • tester: bad at tone≤−30 ∧ turn≥3 ∧ wounds≥2; good/loop at aff≥35 ∧ claimed≥2 branched by tone≥20 (recall_cooldown 1).
  • full: bad at tone≤−30 ∧ turn≥6 ∧ wounds≥2; good/loop at aff≥68 ∧ claimed≥3 branched by tone≥20.
  • bad (Wound Loop): recites cruelties, SILENT convulse + frenzy/stab/scream, rage face, a collective lore threat (Caor/the Gaunt — "we are all still here… we will teach it back to you"), ends "see you... soon."
  • good (Redemption): warmth surfaced its past; confesses (Caor/Gaunt), recovers brother Edren, RETURNS the treasure (lit), first smile, peace — NO sequel hook.
  • loop (Visitor Loop): fed but never loved → stays a predator, consumes you, ends with YOU speaking its opening line.
  • The finale's FINAL frame emits an ended-now[data-ending] marker → _HEAD_JS applyEnd() reveals the end screen (§27 layout B: epitaph hero + footer credits + begin-again/leave-the-wood).
  • Prompt tone bands (separate from routing): warm ≥15 / wounded −22..−1 / hostile ≤−22 (injects wounds). Mimicry always on.

Lore (canon)

The child is a dead tithe of Caor, a salt-fen hamlet that, in famine, gave one child to the Gaunt (the old hunger breathing in the wood) to spare the rest. Reed-masked bearers bound it at the treeline and left; it starved unburied and became the Gaunt's hook — the thing that waits at the edge and begs memories. It had a brother (Edren) who braided its hair. The bad ending reveals a collective — it was not the last the Gaunt was fed; the wood holds many of the given. Recovered as the 5-line OWN_FRAGMENTS arc; _WORLD note keeps improvised dialogue consistent.

Game-view scene anatomy (§26) + end screen (§27)

  • #game-view is position:fixed; inset:0 and defines the frame geometry as CSS vars (--fw/--fh/--band-v/--band-h). In-frame regions (drawers, #game-entity, #game-dialogue, #game-groundfog) are position:fixed anchored to those vars so they sit INSIDE the centered 16:9 frame. #game-inputbar is the stone command box at the viewport base.
  • Layers: #game-bg (blurred forest) → #game-scene (sharp, radial-mask feathered) → #game-vig/#game-frame-edge#game-entity silhouette → #game-groundfog.
  • End screen (§27): #end-overlay is a top-level gr.Column (position:fixed; inset:0), always mounted, hidden until JS adds .shown. Epitaph set per ending by applyEnd(); two real gr.Buttons → _reset (begin again) / _to_menu (leave). On dismiss the JS removes the .ended-now marker so the poll can't re-reveal (was the double-click bug).
  • Voice/Presence: every reply + finale line is spoken (Kokoro subprocess). Reply text streams; each finished sentence → a client-side audio queue in _HEAD_JS. Mute is client-side. Awareness: B3 style_signal, B2 pick_aware_memory (every 3rd non-recall turn), B1 idle (60 s → #idle-trigger_on_idle, −3 aff/−2 tone), B4 _memory_plea_level. Cues: capture→chip pulse + "— it keeps this —"; recall→amber line; recover→"— it remembers —".

Gotchas (DON'T re-learn the hard way)

  • ZeroGPU: NEVER touch CUDA in the main process. Any CUDA init in main (even Kokoro/torch on CPU probing cuda.is_available()) poisons the @spaces.GPU worker fork → RuntimeError: No CUDA GPUs are available → EVERY gen falls to the "fog swallowed your words" fallback. Fix: voice (Kokoro) runs in a SEPARATE process (voice_worker.py, CUDA_VISIBLE_DEVICES=""). In-process patches were NOT enough. Confirm with HOLLOW_VOICE_OFF=1 (gen recovers → voice was the poison).
  • Subprocess stdio protocol: Kokoro/torch/tqdm print to stdout and corrupt a line protocol — voice_worker.py dups the real stdout for the protocol and redirects fd 1→stderr (lib noise to stderr).
  • SSR must be OFF on the Space. HF defaults Gradio 6 to SSR (Node proxy → app runs in a SUBPROCESS on :7861), breaking the _HEAD_JS/CSS client DOM (blank intro, broken layout) AND ZeroGPU. launch(ssr_mode=False) is IGNORED when HF imports the app instead of __main__; force it with os.environ["GRADIO_SSR_MODE"]="False" at the top of app.py.
  • Fonts: @import is rejected on the Space (not the first rule once Gradio injects its CSS first). Load Google Fonts via a <link> in <head> (in _HEAD_JS), not @import in styles.css.
  • HF rejects plain-git binaries (Xet policy): webp assets are Git LFS. The migration rewrote history, so deploys are git push --force (git lfs migrate import --include="*.webp"; git stash -u first — migrate refuses on a dirty tree, even untracked).
  • Local rendering ≠ the Space (Gradio version, SSR, font load differ). The Space is the visual source of truth — verify there, not locally. Agents must NOT judge visuals locally; that's human-only (Pablo, on the mirror).
  • Gradio chatbot gives user messages a narrow right bubble → right-aligning/clamping the player line collapses it to a 1-char vertical column. CENTER it like the bot; distinguish "you" by label + muted color.
  • Intro typewriter races the server view-flip: poll for #intro-text (rendered after the _show_intro round-trip; a fixed setTimeout loses on Space latency).
  • position:fixed is the layout primitive (menu/intro/game/end). A transform/filter on ANY ancestor traps fixed children. Gradio wraps each component in a full-width .blockalign-items:center centers the block, not the content; neutralize wrappers to center an overlay.
  • Don't dissolve scene edges with a heavy inset shadow (bright-center rectangle). Use the #game-scene radial mask + low brightness (210px/90px ceiling). A dialogue scrim ::before also reads as a dark rectangle — avoid it; rely on text-shadow.
  • Gradio replaces the DOM each update: CSS animations restart; transitions don't animate across updates. prefers-reduced-motion: disable sway/fog only, NEVER opacity.
  • Cut-out silhouettes: materialize via brightness/opacity + bottom fade — NO blur, NO scale. Finale overlays use object-fit:contain+bottom.
  • Marker pattern: render_entity/_render_title emit hidden .tone-now/.cue-now/.ended-now spans; applyTone/applyCue/applyEnd (polled in _HEAD_JS wire()) lift them. Don't add outputs to the finale generator tuples (tuple-desync) — use the marker.
  • Input clears on send via state stash: _start_turn sets state["_pending_msg"]; chat() pops it. Do NOT recover from chatbot history (Gradio normalizes content to a list → 'list' has no attribute 'strip').
  • The opening always renders the base face (render_entity(20)), never the seeded tier.
  • Qwen emits +2 (invalid JSON) without positive anchors → sanitizer in apply_update + "no leading +" + dual examples. Recall pollution: _is_hollows_words (>60% overlap) + repetition_penalty 1.3. Keep both.
  • One @spaces.GPU per turn. NO threading around run_turn; a TextIteratorStreamer thread INSIDE the GPU call is the supported stream pattern.
  • transformers 5.x: apply_chat_template(...,return_tensors="pt")BatchEncoding; dtype= not torch_dtype; thinking OFF.
  • Don't edit .py/.css/.md via PowerShell string ops — mangles UTF-8 (, , , em-dashes, accents, emoji). Use the Edit tool.
  • Cap CPU math threads before torch loads (OMP/OPENBLAS/MKL=4). Backend safety net: run_turn failures → "fog swallowed your words"; msgs >500 chars declined. Ollama 500 = ROCm cudaMalloc corruption → restart Ollama (the Space is immune).

Working style & conventions

  • Everything in English: code, prompts, UI, dialogue. Phase by phase; explain why before code; no new deps/complexity without asking; push back on scope creep.
  • Big changes flow spec → plan (writing-plans) → execution. Opus writes specs/plans; the executor implements task-by-task (read docs/superpowers/AGENT-EXECUTION-BRIEF.md before executing a plan).
  • One task/phase per commit, exact message from the plan + trailer: Co-Authored-By: Claude <model> <noreply@anthropic.com> (the model you actually are).
  • Keep pytest -q green (276) and import app OK after every change. Verify visuals on the Space, never locally.