Single-file HTML, Canvas 2D, no libraries, no images, no fonts to load.
The one idea worth explaining: the accent colour IS the valence.
moodColor(v) maps v in [-1,1] across cold blue -> neutral grey -> warm amber,
and every mark in the frame reads from it. So when she drops, the whole
picture goes cold -- no caption has to tell you she is sad. Same data,
three places: the trace colour, the memory hue, the retrieval highlight.
Timeline is a pure function render(t), no RAF accumulation. Every frame
renders in isolation, which means frames are reproducible, any resolution
can be re-exported, and the preview can be captured frame-by-frame instead
of screen-recorded.
Four acts:
0-6s membrane matter bounces off it, information passes through
6-12s heartbeat beats with no input; positive recovers fast, negative slow
12-17s memory points sink by salience; one gets pulled back up
by a mood-matching cue; floor at 0.03 so nothing vanishes
17-22s title
Also: ?t=12.5 freezes on that frame (for cover shots), and a REC button
uses canvas.captureStream + MediaRecorder to export webm directly.
Preview: 200 ANMF frames, 22000ms total, verified by parsing the RIFF/ANMF
chunks (Pillow does not expose per-frame WebP duration).
tools/render_preview.py renders frames via headless Chrome and assembles.
On this Windows box Python cannot spawn Chrome (0x80000003, same family as
the 0xC0000142 shell failure), so the render loop was driven from PowerShell
and the script only assembled. Both paths are documented in the script.
Corrects a common misconception: the 60s heartbeat must not call any LLM.
It runs the mood decay equation -- microseconds of float math. Giving it a
model is an architecture error, not a resource problem.
Real token spend lands in two places:
- per-turn input context (ontology + state + dyad + memory)
- background task frequency
So route by scene, not by time:
none rule/math tick, reach-out decision, fillers, avatar drive
low local small life events, memory gist, fact extract
standard mid + short speculative-response second segment
high flagship main dialogue, proactive message, crisis
Hard constraints:
- dialogue floor is LOW, never NONE (can get dumber, cannot lose lines)
- crisis never degrades (product constraint, not perf tuning)
- degradation is predictable: one rung per request, floor only at
the hard budget threshold
Cost levers, by actual payoff:
1. prompt caching (fixed prefix) -- by far the largest
2. upstream compression (small model condenses 20 memories to 3)
3. batching (one batch per 30min, not 20 single calls)
4. swapping to a smaller model -- smallest payoff, hurts persona
Also: cost degradation is narratable. 'Net error -> I zoned out' already
exists in the projection table, so being throttled is in-character and true.
- config.py: tiers / routing / budget, replacing llm+llm_fast+llm_background
- think/: scenes, budget, router, wiring
- models.yaml restructured around tiers
- SPEC gains 9.5
- 20 new tests; 78 total pass; ruff clean
Contracts (the shared language):
- percept / state / context / utterance / render / audio / capabilities
- Capability model instead of vendor schemas (see below)
Providers (capability-based abstraction):
- LLM + Embedding: OpenAI-compatible shape IS the standard, adopted directly
- ASR: internal contract is an event stream (partial -> final), because
/v1/audio/transcriptions has no partial and dialogue needs it for
endpointing. OpenAI adapter degrades to supports_partial=False.
- TTS: internal contract carries tags/emotion/style, because
/v1/audio/speech has no paralinguistics and [laughter]/[breath] are
the core of feeling alive. Unsupported providers are stripped AND
reported via Degradation -- silent degradation is forbidden.
- Avatar: no de-facto standard, custom RenderCmd protocol driven by State.
Core:
- mood: dynamical system, slow variables, negativity bias (grudge),
numbing after prolonged negativity
- bazi: four pillars -> numeric vector; 'ming' is data, not prose
- projection: phi, system event -> in-world statement; uncovered events
are counted as projection gaps
Storage:
- SQLite schema, three memory layers, dyad ledger
- accessibility floor 0.03 (not 0): forgettable but still there
Config split:
- models.yaml is the ONLY place model versions live
- character.yaml holds world rules / emotion params, no version numbers
58 tests pass; ruff clean.