Architecture
How ProjectBEA is actually put together. Every claim here is anchored to a file so it can be checked against the code rather than trusted.
The shape of it#
main.py → src/cli.py:main()
├─ BrainConfig() # config.json + env + CLI, in that order
├─ STT / LLM / TTS / OBS # interchangeable modules
├─ ModelRegistry(config, stt) # one model pool per role (mind / background)
└─ AIVtuberBrain(config, registry, tts, stt, obs)
├─ initialize() # avatars, soul, OBS, session, _build_consciousness()
├─ start_skills() # start the Consciousness (if enabled) + warmup
└─ run_loop() or run_server(brain, host, port)
AIVtuberBrain (src/core/brain.py) is not an orchestrator: it is a
composition root plus a handful of HTTP/CLI entrypoints. There is no separate
reactive chat path — the consciousness is the only mind.
| Component | File | Role |
|---|---|---|
PerceptionBus | src/core/perception/bus.py | the one sensory channel: an asyncio.Queue that closes a batch on a quiet gap |
SkillRegistry | src/core/skills/base.py | the catalog of capabilities |
SingleContext | src/core/mind/single_context.py | the one sliding window: token-budgeted log that breathes 0 → trigger → ~target |
TokenBudget | src/core/mind/token_budget.py | the counter: ceiling (default 150k, up to 500k), derived trigger/hot, hot/cold split (pure) |
HandoffWorker | src/core/mind/handoff.py | background handoff: cold past to prose, hot ongoing verbatim |
SpontaneousPresence | src/core/mind/spontaneous.py | occasionally opens a conversation herself |
Expression | src/core/expression/voice.py | the only voice/visual output sink |
TextHumanizer | src/core/expression/humanizer.py | written output: one line = one message, with typing |
Attention | src/core/attention/ | the gate: what wakes the mind vs what she merely notices |
AffectState | src/core/affect/ | how she feels, and who put her there |
MemoryStore | src/core/memory/ | everything she remembers, in one SQLite file |
Consciousness | src/core/consciousness.py | the mind: one context, one loop |
EventManager | src/core/events.py | 200-event ring buffer for the dashboard |
HistoryManager | src/utils/history_manager.py | one session = one JSON in data/conversations/ |
language | src/core/language.py | what a language code means, for the prompt, the transcriber and the voices |
Skills are registered in this order (brain.py, _build_consciousness):
ChatSurface, VoiceSurface, TelegramSkill, TwitchSkill, DonationSkill,
IdleSurface, MinecraftSurface, MemorySkill, SocialMemory, DreamSkill.
Repository layout#
ProjectBEA/
├── main.py # thin wrapper; the entrypoint is src/cli.py
├── config.example.json # copy to config.json and edit
├── install.sh / .ps1 # one-command bootstrap for a fresh machine
├── Dockerfile # engine + dashboard + discord bot in one image
├── docker-compose.yml # `setup` runs the wizard, `bea` runs the engine
├── Makefile # setup · docker · run · web · test · lint · migrate
├── assets/ # the images the README renders
├── data/
│ ├── bea.db # everything she remembers (gitignored)
│ ├── conversations/ # session transcripts, one JSON per session
│ ├── embeddings_cache/ # the local embedding model's cache
│ ├── models/ # .vrm models: the library, `make model`, yours (gitignored)
│ ├── clips/ # .vrma and Mixamo .fbx clips (gitignored, `make model`)
│ ├── pngs/ # avatars per mood (idle/talking)
│ └── prompts/ # soul · operating · monologue · minecraft · chat
├── docs/ # this documentation, rendered by the docs site
├── tests/ # 112 files, no network
└── src/
├── cli.py # argument parsing and composition
├── core/
│ ├── brain.py # composition root
│ ├── consciousness.py # the one always-on loop
│ ├── config.py
│ ├── events.py # pub/sub + SSE fan-out
│ ├── perception/ # bus, Perception, Author
│ ├── attention/ # the gate: rules (pure) + state
│ ├── affect/ # how she feels, and who put her there
│ ├── floor/ # who holds the floor, and when she yields it
│ ├── mind/ # routing, handoff, single_context, token_budget, tools
│ ├── memory/ # sqlite, rag, embedder, profiler, plan
│ ├── social/ # the roster, reach and agenda
│ ├── expression/ # the single output sink, the local player, humanizer, the face
│ ├── stage.py # fan-out to the OBS browser source
│ ├── agent/ # LLMClient, role pools, tools, runner
│ ├── update/ # the in-place updater: git, three-way merge, backups
│ └── skills/ # one package per capability
├── interfaces/ # TTS · STT · OBS · Avatar · Caption contracts
├── modules/ # llm · tts · stt · obs · avatar · caption implementations
├── setup/ # the wizard behind `bea --setup`
├── utils/
└── web/
├── app.py # the FastAPI app: cors, static files, the SPA catch-all
├── deps.py # the brain every router depends on
├── routers/ # one module per thing the api is about
│ ├── settings.py # /config and /settings: the one validated write path
│ ├── persona.py # who she is, and the six questions that draft her
│ ├── chat.py # talking to her from the dashboard, and sessions
│ ├── voice.py # uploaded audio, the call, the bot's audio link
│ ├── status.py # state, skills, the event stream, the home screen
│ ├── plan.py # the stream plan
│ ├── memory.py # what she remembers, and recall
│ ├── stage.py # what the OBS browser source draws
│ ├── probes.py # does this actually work?
│ ├── donations.py# the donation webhook
│ ├── updates.py # the updater over HTTP
│ └── health.py # `--doctor` over HTTP
└── frontend/ # React + Vite + Tailwind
├── index.html # the dashboard
├── stage.html # the OBS browser source (separate bundle)
└── src/stage/ # caption + the VRM renderer
One mind, one loop#
"One mind" is a constraint on identity — one soul, one self-lore, one set of people, one memory — and on concurrency: one loop, one frame, one window. A telegram DM and a voice call land in the same batch, are read together, and answered from the same context.
┌────────────────────────────────────────┐
senses ──────────────▶│ PerceptionBus │
└──────────────────┬─────────────────────┘
▼
┌────────────────────────────────────────┐
│ Attention │
│ annotate: every perception gets a │
│ priority (addressed/follow-up = 1.0, │
│ else a clamped score). Nothing is │
│ ever dropped here. │
└──────────────────┬─────────────────────┘
▼
┌────────────────────────────────────────┐
│ Mind — the one loop │
│ one frame per batch, ordered by │
│ priority; one sliding window for │
│ context; speak or send_message │
└───────┬────────────────────┬───────────┘
▼ ▼
┌──────────────────┐ ┌────────────────────────┐
│ Expression │ │ platform skills │
│ voice + OBS │ │ send_message / react │
└──────────────────┘ └────────────────────────┘
The rule that must never break: one batch, one frame, one turn. The
conversation key (src/core/mind/routing.py) only tags where a perception
came from — answering the same message twice, from two contexts that know
nothing about each other, is the worst failure mode here.
Two things hold that rule up, and both live outside the model. The batch
boundary is a quiet gap rather than a stopwatch: the bus keeps taking until
nothing new has arrived for text_window (1.2s for typed text) or window
(0.3s for a live sense), so three lines typed in a row are one batch. The gap
is counted from the last arrival, not from when the loop gets to it: what
queued up while she was answering has already been quiet all along. And a
perception that lands during a turn, in a conversation she has already
answered in that turn, goes back on the bus instead of being read to her as
fresh input — one answer per conversation per turn, always.
What is the stage: her voice, a Discord call, the game, the console, Twitch
chat. She is present, live, and answers out loud. What is a written
conversation: asynchronous text — a Discord channel, a Telegram group. It is
read in the same frame and answered with send_message(platform, channel, text) — never out loud — because the destination rides in the tool arguments,
not in a rule a model can ignore.
Cross-awareness is deterministic grounding: a [WHERE YOU ARE] block
(platform, conversation, who — injected from code) plus a per-line [via …]
provenance tag. That is how she never concludes she has no telegram while
answering on it.
The consciousness loop#
Consciousness.run() is the heart. Per iteration:
- Drain the bus. With the
idleskill active,bus.wait_or_idle(idle_after)synthesises anIDLEperception after the timeout; otherwisebus.drain()blocks until something real happens. Both then settle the batch the same way: every arrival pushes the deadline out, and the batch closes when the senses have been quiet for the gap that the most impatient thing in it wants —text_windowfor a typed message,windowfor a voice line or a game event — or atmax_windowhowever busy it stays. A batch of pure texture — everything in it markednoise, like the game body's heartbeat — does not count as something happening: it is swallowed and the timer keeps running, or a surface that ticks faster thanidle_afterwould hold the silence off forever and she would never notice it. - Barge-in. If Bea is speaking and the batch contains anything that is not
IDLE, her voice is interrupted. - Correlations. Collect the
correlation_ids in the batch — HTTP callers waiting on a synchronous reply. 3b. Attention.Attention.annotate(batch)assigns every perception a priority, highest first: addressed and follow-up always 1.0, everything else a clamped score. Nothing is dropped, nothing costs a model call — the model itself decides what deserves an answer. - Rebuild the briefing (
_briefing): the static part (soul + operating manual + the active skills' sections) is deliberately split from the dynamic part (RAG, person cards, live state) and travels as its own system message below the sliding window, so a provider caches the static prompt and the window behind it. Every transport preserves that order:_to_itemsand_to_messageshoist only the leading system messages intoinstructions/system. The dynamic part runs inasyncio.to_threadso a slow retrieval never stalls the loop. - Append the perception frame as a
usermessage. - Reasoning burst, up to
burst_steps(6) steps:_steering()folds anything that arrived during reasoning in as a steering frame with an explicit header. A written line from a conversation she has already answered this turn gets its own header (the next thing they said, to answer once) and goes in too, after the same quiet gap that closes a batch (text_window, capped bymax_window): somebody still typing at a step boundary is waited for, so two lines get one reply. Anything live ends that wait at once. If she is shown such a line and ends the turn without answering it or choosing silence, the one rescue step is hers. A turn carrying voice keeps the older rule: nothing waits, and what belongs to an answered conversation goes back on the bus and becomes the next batch;llm.complete(context, tools=…); the LLM streams its response back;- free assistant text is inner monologue (published as
EventCategory.THOUGHT, kept in the Turn Log) and is never spoken; - tools run, and each one publishes its own outcome —
EventCategory.TOOLwhen it worked,EventCategory.ERRORwhen it did not; - if she spoke (
speak) or chose silence (stay_silent,say_nothing) the turn ends without burning another model call. - if nothing she did reached anybody — a final answer written as plain
text, or every audience-reaching tool failed — she is told so once and
given one more step (
_NO_TOOL_NUDGE). Only tools that declarereaches=True(audience:speak, chat sends, reacts, DMs, explicit silence) count as reaching someone; side effects (objective_*, game actions, recall) never stand in for the answer. An audience success suppresses the rescue even if plain text follows it — no double answer, the extra words stay inthought.
- Resolve any dangling correlations, write the full context and decision to the Turn Log, and mirror the turn into the sliding
window (
_record_window) — retention is token-budgeted (ceiling 150k by default, up to 500k), never message-count-trimmed. The turn only marks the window dirty; a flush behind it writes it in one transaction, and stopping the mind flushes it synchronously. A handoff is scheduled when the budget hits the trigger (_schedule_handoff) — see below.
Details that matter:
speakis streamed: the model streams themessageargument out of a JSON string that has not closed yet, so voice synthesis and chunking start while she is still writing the line.send_messageis handed over, not waited on: every written line goes out with a typing pause in front of it, and the mind does not hold still for it. A message that never lands is published as an error rather than lost, and stopping the mind waits (up to ten seconds) for the lines still going out.- Body actions (
long_running=True) run in a single-slot task that preempts the previous one; the result comes back as a perception. - The Turn Log: every turn is recorded — the prompt in force, the briefing, the perceptions, what she thought in plain text, the tool calls with their outcomes, and what it cost, reasoning tokens included.
The sliding window#
One mind, one log. Every live turn lands in SingleContext
(src/core/mind/single_context.py), a token-budgeted append-only log that
replaces message-count trimming with a real ceiling. The ceiling is the one
number the owner sets — 150k by default, adjustable from 150k to 500k —
and the rest of the shape follows it: handoff trigger at four fifths, hot
present at a fifth (the default is 150k max, trigger at 120k, 30k hot).
The fifth between trigger and ceiling is the room she keeps talking in while
a handoff is being written. Trigger and hot can still be pinned individually
(0 means "follow the ceiling"); the trigger is clamped so it never passes
the ceiling, and a save that would put it at or below the hot present is
refused, since every new window would then open already due for a recap. The window lives in RAM
while she talks —
appends never touch the disk — and a flush after each turn, plus a synchronous
one at shutdown, carries it over in a single transaction.
The window is written down as plain entries (who, where, what) and read back
by the model through replay(), which hands her own lines back as the calls
they were: a spoken line becomes a speak call with the mood it was said
with, a written one a send_message to the conversation it went to, each
followed by the tool's answer. That is the one protocol the manual gives her —
plain text is thinking nobody hears — and a history of bare assistant text
would teach her the opposite with every line. Call ids come from the entry's
seq, so the same window always replays identically and the provider's
prefix cache holds. The budget counts what a replayed call costs on the wire,
not just its words. messages() stays the log as written, for everything that
reads the window rather than answers from it.
A perception is written into the window, and into the stream, as it arrived —
unless it says otherwise. A sense that delivers something bulky, like a web
page that finished loading after its turn, sets meta["keep_as"]: she reads
the whole page in the turn it lands, and what stays behind is that one line
(Perception.kept). Without it every later turn would pay for a page she has
already answered from.
Every write to the store carries a write sequence, taken when the snapshot
is built rather than when it lands, and the store keeps only what is strictly
newer than what it holds. That is what orders two writes: version moves only
on a swap or a clear, so the flushes of one window all share it, and a
background flush, a swap and the synchronous flush at shutdown can land in any
order. On restore the sequence is read back, so it keeps rising across
restarts.
When the trigger hits, HandoffWorker (src/core/mind/handoff.py) runs on
the background pool, in parallel with the loop. Turns keep going the whole
time. Nothing in the handoff blocks the loop, and the only step that touches
the window, the slide at the end, is synchronous:
- the cut is taken when the handoff starts. Hot is the newest
hot_tokens(30k by default), counted in tokens only: a conversation that paused for an hour is still the conversation. Up to 5k tokens of the older past right before it stay verbatim as well, so a sentence is never cut in half at the boundary. Everything older is cold; - the cold part goes to the worker, which writes a short prose recap — "you talked about food for two hours": facts and open threads, no identity (the soul is already in context);
- when the recap is ready, the window slides. The recap opens the next
window as an
[EARLIER]system block, followed by every line after the cut, read off the window as it is at that moment: the hot present plus everything that arrived while the recap was being written. None of these was summarized, so none of them is dropped to reach a resting size. They only give way to the ceiling, oldest first, and that loss is counted invalve_evicted_tokens; - the window breathes: it fills up to the trigger and comes back to about the hot present, never pinned at the ceiling. A failed handoff keeps the old window intact and tries again five minutes later. A handoff that finishes after the consolidation has emptied the window drops its recap instead of putting the evening back.
Written channels get two related guarantees: a deterministic [WHERE YOU ARE]
header (platform, conversation, who — injected from code, so she never
concludes she has no telegram while answering on it), and one rescue retry
when a text-only answer would otherwise leave a mute turn (NO_TOOL_CALL).
GET /context exposes the budget live; the dashboard overview shows it as
the Context tile, and a ceiling changed in Settings → Mind reaches the live
window on the next turn — no restart. New knobs live under consciousness
(context_max_tokens, plus the advanced pins handoff_trigger_tokens and
hot_tokens, and context_handoff,
window_persist_after_turn, dynamic_context_timeout) — see Configuration.
Skills#
A Skill (src/core/skills/base.py) may do any subset of: perceive, expose
tools, contribute static prompt rules (context_section), contribute per-batch
prompt content (context_for), expose volatile state (live_state), own
infrastructure (start/stop).
enabled reads config.skills[key].enabled: the UI is the single source of
truth. Bea can never arm a capability by herself.
The toolbox is assembled on every read (src/core/mind/tools.py). A skill's
tools move with the skill's own state and not with whether it is switched on:
discord_leave_voice exists only while she is in a call, objective_done only
while there is a plan, the game tools only while the mod is connected. The
whole set costs microseconds to build, and unarmed
(src/core/mind/operating.py) logs an ERROR when a skill's prompt section
names a tool the schema does not carry — a model told to call something it has
not been given invents the call.
| Skill | name | toggle | What it does |
|---|---|---|---|
ChatSurface | chat:ui | — (core) | text input from the dashboard; Author(platform="ui", is_owner=True) |
VoiceSurface | voice:discord | discord | owns the node subprocess (needed for voice); input arrives via the HTTP endpoints the bot calls |
TelegramSkill | chat:telegram | telegram | in-process polling, no subprocess; written conversations answered via send_message |
TwitchSkill | chat:twitch | twitch | anonymous IRC read; every message is tallied, all of them reach the one frame with a priority |
DonationSkill | donation | donations | POST /webhook/donation; always reacts, promotes the donor immediately |
IdleSurface | idle | monologue | produces no input: supplies the monologue rules on a pure-idle frame |
MinecraftSurface | game:mc | minecraft | WebSocket client to the mod; every game tool is hers, actions that take time run in the background and answer as ACTION perceptions |
MemorySkill | memory | memory | RAG over bea.db, injected via context_for in two labelled blocks; no tools |
SocialMemory | social | social_memory | roster tally + person cards; injects [WHO YOU'RE TALKING TO] |
DreamSkill | dream | dream | self-lore + hot facts always in context; morning pass; go_to_sleep; offline dreamer |
StreamPlanSkill | plan | — (core) | the owner's plan for the stream in live_state; objective_started/done/dropped. Contributes nothing while the plan is empty |
WebSkill | web | web | web_search down a provider chain with the keyless search last, web_fetch as markdown; off by default, every URL and DNS answer SSRF-checked |
Discord#
Two processes, HTTP in both directions plus one socket that stays open:
Python (brain) Node (src/core/skills/voice/bot/)
────────────── ─────────────────────────────────
DiscordTransport ──POST /send,/reply,─▶ api/server.js (express, port 3030)
(transport.py) /react,/dm,/summon,
/voice/join,/leave
routers/ ◀──POST /discord/chat─────── handlers/messages.js
voice.py◀──POST /discord/audio────── classes/VoiceManager.js
◀──POST /voice/transcript──── classes/VoiceManager.js
chat.py◀───POST /interrupt────────── classes/VoiceManager.js
Expression ═══WS /voice/ws══════════▶ classes/BrainLink.js
(her voice out) ◀══ played_ms ════ (what the room really heard)
Everything the bot sends is a sense: it deposits a perception and the request ends. Her voice travels the other way, on the socket, whenever she decides to speak — which is what separates a bot that answers from someone who is in the call.
- Text answers only when whitelisted and (mention | reply to Bea | DM). It deposits a perception and returns immediately: Bea decides on her own whether and how to answer, using the discord tools.
- Voice in decodes Opus → PCM 48k stereo → mono 16k WAV, with a robust Voice Activity Detection (VAD) layer that accurately hears a sentence out to its end. It uses a two-stage barge-in: duck first, stop only if they keep going.
- Voice out is pushed over the socket as 48 kHz stereo PCM as it is synthesised — part by part within a sentence, when the engine streams — so the room hears the start of a line while the rest is still being made. The socket is uncompressed: raw PCM barely shrinks, and deflating it would cost the brain's event loop in front of every sentence.
- The bot dies silently when the token is missing or
node_modulesis absent;VoiceSurface._watch_transportthen marks the skill inactive.
The bot token is read from DISCORD_TOKEN. It is never written to
config.json and is masked in GET /config.
Minecraft#
The mod is a separate project (BeaCraft, Fabric 1.21.1, Java 21). It is client-side: it drives the local player by simulating input and sends normal packets, so to a server it looks like an ordinary client.
Python side (src/core/skills/minecraft/):
client.py— WebSocket thread bridged to the event loop withcall_soon_threadsafe. Dispatches on packettypebeforestatus, so chat, joins, combat and the full death event reach the surface instead of falling on the floor.surface.py— turns those packets into perceptions with a realAuthorbuilt on the player's UUID. That one detail is what switches the entire social stack on inside the game: the roster, person cards, promotion and the attention gate are all keyed onAuthor, so none of them needed Minecraft-specific code.tools.py— the game tools, handed to her like any other skill's. One that takes time (walking, mining, crafting, building, fighting) is long-running: the consciousness starts it in the background and its answer comes back as anACTIONperception. Several in one step run in order, the first failure stops the rest; a later step replaces what is still going. The fast loop of playing is the mod's: reflexes and whole-job actions.state.py— renders the state packet as a few readable lines instead of a wall of JSON, and it lives inlive_state()rather than in a perception: it is where she is, always true, not an event that should make her think.
Two audiences, on purpose. speak is her voice — her stream hears it.
mc_chat is what she types in game — the players read it. Using both in the same
turn is usually right, and is much of what makes a persona playing multiplayer
worth watching.
The two nudges. The heartbeat is marked noise so a quiet server costs
nothing. Two perceptions ask something of her, and surface.py emits at most
one per tick:
- she is in the middle of something long — what, for how long, how far it
has got and what she carries, at most every
commentary_seconds(20 by default, 0 to disable) and only when something in it changed since she last heard. It asks for words, not a decision. - she is standing around — no action running for
idle_nudge_seconds(30 by default, 0 to disable), with the open objectives of the stream plan when there are any.
Both carry meta["addressed"], which is what makes them survive the gate: a
nudge filed under "noticed" is a nudge that never happened.
The stream plan#
What the owner tells her to get done, edited from the dashboard's Stream
Plan page and stored in bea.db (objectives + settings).
src/core/memory/plan.py— the store. A headline directive plus an ordered list of objectives, eachtodo/doing/done/droppedwith an outcome in her own words.src/core/skills/plan/surface.py— a core skill:live_state()puts[TODAY'S PLAN]in every prompt andtools()arms the three tools that close an item. Both are empty while there is no plan, so an unused feature costs no prompt space and no tokens.
The number shown next to an objective is its database id, and it is the same
number she passes to objective_done — one identifier, no mapping to get wrong.
The plan reaches the live loop: it describes what she is doing on stage.
Memory#
Everything lives in one transactional SQLite file, data/bea.db
(src/core/memory/). One store means promoting someone touches two tables in a
single transaction, and "who have I seen most" is a query rather than a scan.
| Register | Table | Always in context | Written by |
|---|---|---|---|
| Episodic diary | memories (scope diary) | no, top-3 per batch | DiaryGenerator at session end |
| Roster (tally) | roster + identities | never | SocialMemory.context_for, per perception |
| Person cards | people + facts | only those present, max 5 | auto-promotion + remember_person + dreamer + profiler |
| The stream | messages | append-only log for dream/recall/dashboard, never built into live context | the consciousness |
| Self-lore | self_facts + self_profile | yes (last 15 facts) | the dreamer only |
| Hot facts | hot_facts (TTL) | yes (max 6) | dreamer + morning pass + a strong reaction |
| Standing mood | settings (affect.state) | only past a threshold | every line she speaks |
| Standing with a person | people.warmth | with their card | a strong reaction |
| Sessions | sessions | never | the brain + the dreamer |
Re-ranking is similarity*0.7 + recency*0.3 with 1/(1+days*0.1) decay. Every
injection is explicitly capped so the prompt cannot bloat.
Three things worth knowing:
-
Three logs, one live. The sliding window is the only context the loop reads. SQLite
messagesand the JSON session files (HistoryManager) are append-only: dream, recall and the dashboard read them, the loop never does. -
The stream is append-only; the cap is on the read. Nothing deletes from
messages: a year of daily four-hour sessions is ~74 MB and every read of it is indexed. One consolidation reads at mostSTREAM_LIMIT(2000) rows of a sitting, the most recent ones, and logs a warning for a session longer than that — the start of it is outside the pass, andstream_overflowsays by how many rows. -
memories.sourceseparates what people said (person) from what Bea said (bea).recall_splitreturns them as two labelled blocks. Bea invents on purpose; without the split her own inventions would re-enter the prompt as facts and the fiction would compound into incoherence. -
The embedding model is multilingual (
paraphrase-multilingual-MiniLM-L12-v2), because her people write in Italian and an English-only model collapses Italian into one region of the space, making retrieval close to random.Rag.ensure_modelre-embeds automatically if the model ever changes — vectors from two models are not comparable.
Vector search uses sqlite-vec when available, purely as a coarse pre-filter;
the ranking decision is always the same Python cosine, so both paths agree.
Importing a Chroma-era store: uv run python tools/migrate_to_sqlite.py
(idempotent, --dry-run supported).
Attention#
src/core/attention/. Before it existed, every perception cost a full reasoning
cycle: with the game connected, one model call every ten seconds forever.
rules.pyis pure — no IO, no asyncio, noSkill. It takes primitives and returns a number or a reason, which is what makes the scoring testable against a table of cases instead of tuned by watching a live stream.gate.pyholds the state (per-conversation activity, when she last spoke) withclockinjected, andannotate()assigns every perception a priority without ever dropping one. The follow-up question ("are they answering me") reads the tagged entries of the one sliding window, never SQLite.
Two questions, deliberately kept apart. "Is this for me?" (is_addressed) is
deterministic and bypasses cooldowns and quiet hours. "Does this concern
me?" (score) is a deterministic heuristic over salience, cooldowns, quiet
hours and recent activity.
Every perception is ordered by its priority in the one frame the model reads, and the model itself decides what deserves an answer. There is no threshold, no dice, no digest, no second regime.
Mood#
src/core/affect/. Everything that happened to her either lasted under two
minutes (a cooldown, an activity window) or became a fact in a prompt. Nothing
lived on the scale of the last half hour, which is the scale on which people
read someone's mood.
There is no new sense and no new tool. She already picks a mood for every line she speaks. A semantic picker maps whatever expression she names to the nearest one the avatar actually has. It is the signal, so this costs no extra model call and no extra prompt.
rules.pyis pure —Affectis(valence, arousal, updated_at), and the two axes are two rather than one because angry and sad are both negative and sound nothing alike.state.pyholds it, persists it insettingsand decides who caused it.
It decays on read, not on a tick. The value carries the moment it was
written and is aged when someone asks for it — the same reason hot_facts has
no sweeper. No background task, and a restart comes back correctly aged for
free: crash while furious and she reboots furious, sleep on it and she does not.
One strong line lands on three clocks:
| Where | Half-life | |
|---|---|---|
| the mood itself | settings | ~25 min |
| how she stands with whoever caused it | people.warmth | ~2.5 days |
| what it was about | hot_facts (source='live') | 6h TTL |
Attribution is one clear author or nothing. In a busy room the mood is the
room's, and picking a face out of it would be inventing a grudge. Someone who
did get a real reaction out of her earns a card, through the promotion rule that
already exists — marked_by_bea — rather than a new one.
Two rules keep it honest:
- It never touches
is_addressed. That gate is deterministic so that answering someone who just spoke to you is not a dice roll. A mood may colour the probabilistic side; it may not make her ignore her own name. - It describes, it never prescribes.
[HOW YOU FEEL]says how she feels and stops. How a person like her acts on that is the soul's business, and the soul is a file the owner writes — an instruction here would be the engine overwriting somebody's character.
Where it is audible: expression/prosody.py turns a mood into a deviation from
the configured voice (rate, pitch, volume), which each TTS wrapper translates as
far as it can — Edge does all three, Kokoro only rate, Orpheus none. Prosody()
is neutral and byte-identical to no prosody at all, so switching the feature off
really does leave her voice alone.
Scoped conversation turns read the mood and never write it: a written reply has no mood to pick, so the mood forms on stage and merely colours what she types.
Models#
src/core/agent/registry.py. One pool per role, as provider:model specs.
Round-robin spreads rate limits; the rest of the pool is the fallback, so a
single 429 does not make her mute.
mind— the consciousness. Every model in it must support tool calling: she speaks only through tools, so one that cannot would never say anything. A model that rejects tools is skipped and logged atERROR— that is configuration, not a transient failure.background— diary, dreamer, person profiles, and the game body. Batch work that must never compete with the part of her that talks.
Web and UI#
FastAPI (src/web/app.py, one router per subject under src/web/routers/) +
React/Vite/Tailwind (src/web/frontend). Every endpoint takes the brain as a
dependency, so what it needs is declared rather than reached for. Events
arrive over SSE (GET /events/stream) rather than a two-second poll, so the
UI is current and the brain is not answering requests for nothing. Each turn
publishes what it cost (calls, tokens, ms) — the point of the attention gate is
spending fewer of them, and that cannot be tuned unseen.
POST/PATCH/DELETE /plan/... edit the stream plan and return the whole plan
back, so the dashboard never has to guess what the server holds.
Security posture: the API has no authentication. The server therefore binds
to 127.0.0.1 by default (--host is an explicit opt-in), CORS carries an
allowlist rather than a wildcard, and GET /config returns
BrainConfig.public_dict() — every secret dropped or masked.
Contracts#
A perception#
Perception(
kind=PerceptionKind.CHAT, # CHAT|VOICE|GAME|ACTION|IDLE|SYSTEM
surface="discord:text", # who produced it
content="[marco] ciao bea", # already-rendered text
salience=0.8, # INFORMATIVE, not imperative
meta={"channel_id": "...", "message_id": "...", "conversation_key": "discord:123"},
author=Author(platform="discord", native_id="4711", display_name="marco"),
)
author.identity (platform:native_id) is the truth; display_name is
cosmetic. Every input skill is responsible for building a correct, stable
Author — the whole social stack is keyed on it.
A tool#
Tool(
name="discord_reply",
description="...", # this IS prompt: write it like one
parameters={...}, # JSON Schema
handler=async_or_sync_callable, # returns a string = the observation
long_running=False, # True → runs async, preempts, returns as a perception
)
Errors are not raised: they come back as observations, so the model can
react to them instead of dropping the loop. surface is what a long-running
action's result gets attributed to when it comes back as a perception.
An observation starting ERROR or FAILED is published as
EventCategory.ERROR rather than EventCategory.TOOL, and it does not count
as having answered anybody: a turn whose only tool call failed gets the same
rescue as a turn that produced nothing but plain text. Whether a tool can
stand in for an answer is declared on the tool itself (Tool.reaches, default
False); the turn record carries a rescued flag so "she chose silence" and
"nobody heard her" stay distinguishable after the fact.
Testing#
Decisions are pure functions. Effects are injected.
That is the whole rule, and it is why the pure layers here (attention/rules.py,
humanizer.split, sanitize, routing, the scheduler, is_bot_called, the
Twitch IRC parser) are tested against tables of cases rather than against a live
stream.
tests/fakes.py carries the piece neither this project nor its reference had: a
FakeLLMClient that replays scripted AssistantMessages. With it, the whole
consciousness loop runs end-to-end without a network, and questions like "how
many model calls did those thirty chat messages cost?" or "how many replies did
those three typed lines get?" become assertions rather than something you find
out on stream (tests/test_one_batch_one_turn.py).
Three checks exist because the two halves of one fact are written in two
places and used to drift: tests/test_prompt_promises.py (every tool a skill's
prompt section names is in the schema), tests/test_the_manual.py (the shipped
operating manual and the built-in copy of it say the same thing) and
src/web/frontend/test/flux.test.js (every event category the engine publishes
has a badge and a filter in the dashboard).
Coverage is not a percentage target. The rule is: every new pure function arrives with its tests in the same commit.
Commands#
make install # uv sync
make node # uv run bea --install-node (dashboard + discord bot)
make run # CLI
make web # build the javascript + dashboard on :8000
make update # fast-forward, preserving edited prompts (see updating.md)
uv run bea --doctor # or: make doctor (diagnose and fix issues)
make test # pytest
make lint # ruff
make migrate # one-shot: import a chroma/json store into data/bea.db
Discord bot: cd src/core/skills/voice/bot && npm install (once).
Minecraft mod: make build && make run from the BeaCraft repo.