Repository navigation
Personas: what is left after the character sheet #25
Closed
robert-dean
started this conversation in
Ideas
Replies: 2 comments
|
Status check, 2026-09-22 (main at 23b983e) Done since the last update: the ratings clause §2 was waiting on landed: Still open:
|
0 replies
|
Closing: everything here is built except §6, note decay, which moved to #448. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Scoped 2026-08-15, against the tree as it stood with the news bulletin in flight. Who the station
IS is built and is described in
docs/internals/personas.md:deadair.personas(migration 0012), the sheet and itsdiction split (
persona.sheet.ts), the templates chain, the voice on the row, theone-active partial unique index,
PersonaRepository.presenting, the console page and editor, and thefour seeds in
persona.defaults.ts. None of that is re-opened here.What follows is the five things a persona cannot do yet. Two of them (§1, §2) are the raw list's
shows should use personasandpersona memoryscoped against real code; §3 is what those two arefor; §4 is the smallest and has no design question in it. §5 was added a day later and is what §3
leaves unsaid. §6 was added 2026-08-28, after §2 was built, and is what §2 leaves unsaid: the
notebook it created has no way to lose a line except an operator's hand.
They are ordered by what blocks what. §1 is a schema change §2 wants to be made before it writes its
own; §3 needs both; §4 needs neither and can be taken on any afternoon. §5 needs §3 to exist and
nothing else.
That ordering was wrong about §3, which landed first (2026-08-19) and needed neither §1 nor §2.
The blocker it was waiting on was the daypart schedule rather than anything about personas: a slot
carries a
persona_idand the changeover copies it onto the running order, which is all this everwas. §5 is therefore unblocked and is now the one to take, and its rule is already written down in
docs/internals/director.md§ "Who owns the running order" — a clock-fired changeover defers itsspoken half to the next track boundary, where an operator's airs at once.
1. One station, one persona, and the newsreader is the case that broke it
PersonaRepository.presenting(lineupPersonaId)answers ONE row: this broadcast's host, then thestation's active one, then nothing. Every writer reads that same row, which was exactly right while
the only thing the station said was a talk break, and stopped being right the day it read the news.
Today, with a persona on air, a bulletin is written from that persona's sheet
(model.news.break.writer.ts:104)
and spoken in that persona's voice
(write.break.job.ts:204,
segment.voice ?? persona?.voice). So a pirate captain reads the headlines in the pirate's voice, and the stationhas no way to say whether that is the joke or the bug. Both answers are legitimate — a character
station wants its host reading everything, a straight station wants a newsreader — and the shape that
cannot express either is the current one.
One thing this entry assumed is no longer true, and it makes the work smaller. It was written
when a station had one usable voice, so "the pirate reads the headlines in the pirate's voice" was
half a hypothetical:
segments.voicecould name a newsreader and nothing would sound different.Since 2026-08-21 both bundled speech plugins ship a map covering every seeded persona plus a
newsreaderslot, put there for exactly this entry, and each persona names its own voice. So thequestion this section asks is now a real one with an audible answer, the row to point at already
exists on both engines, and none of the work below is blocked on a voice to reach for — what is
missing is only the
kindcolumn and the precedence, which is what this section was always about.See
docs/internals/render.mdanddocs/internals/personas.md.The seam is a
kindondeadair.personas, and the precedence stays in the one place it alreadylives. The column landed on 2026-08-25 with the caller work, and it is NOT NULL with a default of
host— which is a correction to what follows rather than a detail. This entry sketches it nullablewith the active index becoming
(station_key, kind)"with nulls distinct", and nulls distinct isPostgres's DEFAULT for a unique index: two rows with a null kind would not conflict, so the station
could have two active hosts. With a positive value that hole does not exist and the index below is
safe to write when the newsreader arrives. Adding
newsreaderis one entry in the check constraint:Four things are load-bearing, and each one is a way this goes wrong if it is built casually.
The index has to move with the column.
personas_one_active_idxisunique (station_key) where active, so a second active persona of any kind is rejected by the database. A kind-scoped persona isactive at the same time as the host by definition, so the index becomes
(station_key, kind)withnulls distinct — and
setActiveclears within a kind rather than across the station. Getting thiswrong does not produce a bad break, it produces a persona an operator cannot switch on.
The floor has to resolve the same persona as the model, or a declined bulletin comes out in the
host's phrasings and the newsreader's voice, which is worse than either alone. That means
resolveTemplatesand the voice stamp both read the kind-scoped answer, not justbreakPrompt. Note thatNewsBreakWriterdeliberately does NOT chain into a persona's templatestoday, for
WelcomeWriter's reason(news.break.writer.ts:39); that
decision is worth re-reading once there is a newsreader to chain into, and may well survive.
A persona per kind is not a persona per SEGMENT.
segments.persona_idalready records whichcharacter a break was written as, and it stays the record rather than becoming the control: the
station decides who speaks from the kind, and the row remembers what it decided.
station_lineup.persona_idis unaffected. A show names its host, and a host is not a newsreader;a briefed broadcast that names a persona overrides the host arm of the precedence and nothing else.
2. Persona memory, which the short window is not — BUILT
Built 2026-08-22, and it cost what this section predicted plus one thing it did not. The
prerequisite landed first as its own commit (
script_history.persona_key, stamped on every writeATTEMPT rather than only the winner, so a character whose model breaks are all being refused is
visible rather than hidden behind the floor).
deadair.persona_notesis the store, shaped afterdeadair.pronunciations;deadair.persona_note_passesis the watermark, which is where this partscompany with
fact_extractions— documents arrive in no order and each needs its own mark, scriptsare a time-ordered stream and one timestamp covers them.
The thing it did not predict is a precision bug the smoke script caught: Luxon is
millisecond-resolution and Postgres is microsecond, so a watermark taken from a row's own
created_atcompares as EARLIER than that row and re-reads it on every pass, forever. Both endscarry the column's own text now. Anything else that stores "how far did I get" against a
timestamptzhas the same trap waiting.What is left is the OPINION. The pass reads every written break by a character, and nothing in the
station says whether any of them were any good — so a note distilled from a break the operator
disliked is the character being taught to repeat what did not land. The one clause is named in a
comment on
ScriptHistoryRepository.writtenBy; break-ratings is the other end.The rest of this section is kept as the record of what was decided and why.
There is already an avoid-list:
WriteBreakJobhands the writer the last six scripts of the samekind (write.break.job.ts:172,
RECENT_WINDOW = 6), and the prompt tells the model not to reuse their opening or their shape(break.prompt.ts:228). That is the cheap
seed dj-voice correction 3 asked for, and
segment.repository.ts:337already says where itstops being enough.
It stops in three specific places, all of which are the same missing fact:
the late-night one hands the new character the old one's last six lines and tells it not to sound
like them, which is an instruction about the wrong person.
instruction about the last hour; "you mentioned this record last week" is an instruction about the
last week, and no window of raw scripts is the right shape for it because the useful form is a
SUMMARY rather than transcripts.
script_historyis the append-only rowper write attempt and it is denormalised precisely so it outlives its segment — and it has no
persona column.
segments.persona_idcannot cover for that, because the whole point of thedenormalisation is that the segment may be gone. So a
persona_keyonscript_historyis theprerequisite, and it is a prerequisite whether or not the rest of this section is built: without
it, "what has this character said" is not a question the station can ask of its own history at all.
Beyond that column the shape is a
persona_notestable (or anotesjsonb on the persona row, whichis worse for the reason a mood was worse: it is a list an operator and the station both append to)
holding short summarized lines per persona, written periodically from
script_historyrather than onevery break, read into the prompt beside the sheet and capped exactly like
PERSONA_SHEET_LIMITScaps everything else. Two questions are genuinely open and should be decided before it is built:
whether a note is a FACT the character stated ("said the drummer plays left-handed") or a growth in
the character itself, and whether the operator can see and edit them — the first is a station being
consistent, the second is a station writing its own sheet, and the second is a much bigger idea.
Both were decided on 2026-08-22, and the answer to the first is BOTH, split by kind. A
saidnoterecords something the character actually put on air and carries the script as its evidence, so it goes
active unattended; a
traitnote is an inference about who the character is becoming and arrives assuggested, which answers the second question in the only way that keeps the sheet the operator's own.That is
deadair.pronunciations' shape exactly — a confident derivation goes live, an uncertain oneproposes, and
rejectedis a state rather than a deletion so the next pass cannot re-propose itforever. Notes come from the station's own history and nothing else; a searched source is a different
threat posture and is not this.
The one thing it needs that does not exist is an opinion. A note distilled from a break the
operator disliked is the character reinforcing what did not land, and nothing anywhere records that a
break was bad — the automatic signal is
characterFault, which is the station's own rubric. So thedistil selection is written with the predicate that skips a disliked break named in a comment, and
break-ratings is the table that makes it a clause.
This is also the whole of the raw list's
have the talk shows keep a history so it can grow organically: a show that remembers is a persona that remembers, and building it twice would give thestation two characters with the same name.
3. A schedule chooses the persona — BUILT
Built 2026-08-19, with the daypart schedule, and it cost exactly what this entry predicted:
deadair.schedule_slots.persona_idrides the slot, the changeover copies it onto the binding, andpresentingreads it as it already did. Nothing in the director, the writers or the console changed."9-12 is the warm host, midnight is the late-night one" is now a row an operator writes at
/schedule.The one thing the entry did not anticipate is which way the fallback points at the SLOT level.
schedule_slots.persona_idison delete set nulllike the lineup's, so deleting a persona leavesthe slot standing and drops it back to the station's own host — and a slot naming a persona that has
since gone is voided when the schedule is RESOLVED rather than refused when it is saved, on the same
argument
presentingalready makes. Refusing to broadcast over a question about the DJ is worse thanfalling back.
What follows is the entry as it stood.
Nothing here is new work on personas — it is the daypart schedule in
director-and-lineups reaching a column that already exists.
station_lineup.persona_idis on the row,presentingalready prefers it, and migration 0013 alreadyargues the
on delete set nullfallback. So "9-12 is the warm host, midnight is the late-night one"is a scheduler writing one uuid when it builds a running order, and nothing in the director,
the writers or the console changes.
Worth recording so it is not designed twice: the persona is chosen when the order is BUILT, not read
per break, for the same reason the brief rides the row — a character held in a refill's payload would
last one batch. A daypart boundary that should change the host mid-broadcast is therefore a new
running order, which is what a schedule produces anyway.
4. A rehearsal: hear a persona before putting it on air — BUILT
Built 2026-08-16 (
ad36004), asPOST /personas/{id}/rehearseandPersonaRehearsalService, against fabricated neighbours and withrecent: []so a reading isrepeatable. What follows is the design as it stood, and it is kept for the two constraints below,
both of which are now honoured — and for the note at the end, which is what the same question turned
into once it was asked over a playlist rather than over one pair of records.
The priority half was not done when this was written, and now is. The tier it asks for exists —
gate.priority.ts, added the same day, wherepreviewqueues behind everything the station does foritself and is preempted out of the model when the station wants it back — and the fix it named is
built:
BreakWriteRequest.priorityis threaded toconverseby the model bindings, andPersonaRehearsalServicepassespreview. See the note at the end of this section.An operator can already audition a VOICE —
GET /voices/{voiceId}/samplerenders a fixed line andthe console plays the blob — but not a persona. What a sheet actually produces is unknowable until it
airs, so writing one is a matter of switching characters and waiting for a break.
The seam is a
POST /personas/{id}/rehearsethat builds aBreakRequestagainst fabricatedneighbours (or the current ones, read-only) and runs the writer registry, answering with the
ATTEMPTS the registry already reports — so an operator sees the model's line, the decline, and the
floor's line underneath it, which is the same triple the activity feed reports after the fact. That
makes
dictionMarkerstunable in seconds instead of over an evening.Two constraints, both of which are why this is a considered piece of work rather than a button:
LlmGateserializes, and a rehearsal must not preempt a refill ora real break. It gets the background job's treatment — a bounded
maxWaitMs, and giving up is alegitimate answer that the console reports as "the station is busy". (Now expressible as
priority: 'preview', and see the note at the top: it is not yet passed.)deadair.segments, exactly as thevoice sample writes no row. Its own store or no store at all; a script that can be planted is a
break, and a break is not a preview.
Whether a rehearsal is also SPOKEN is a separate call. The words are the expensive half to get right
and the cheap half to produce; speaking them costs a synthesis per click and can be added later
behind the same route. That call is cheaper than it was:
SpeechGatenow serializes the engine andtakes the same
previewtier, so a spoken rehearsal is amaxWaitMsand a priority rather than anew question about what an operator clicking twice does to a render in flight.
Both of those are answered, and the note at the top of this section is now stale in the other
direction, 2026-09-09.
PersonaRehearsalServicepassespriority: 'preview', so the constraintthis section was written to protect is honoured rather than half honoured; and a rehearsal IS spoken,
through the sample store, from a button on the panel. What that section did not anticipate is the
shape the same question takes over a PLAYLIST, which is now built (migration 0024,
PersonaAuditionJob, the Auditions tab on Voice, anddocs/internals/personas.md§ "Hearing a character before it goes on air"): one host, one providerplaylist, one break per transition, a job per transition so a run survives a redeploy, and nothing
aired. Two things it forced that a single rehearsal never had to answer — a run must carry its OWN
recentor it measures a host repeating a signature phrase no broadcast would let it repeat, and amodel lost to the station must NOT be recorded, because a run that quietly stores the floor's line
reports a busy Tuesday as a fact about the sheet. The second cost
WriteAttempt.code, which is theonly change the registry needed.
The rehearsal itself stays exactly as it was, and should: a fixed pair is the right substrate for
judging one edit, and an audition is the wrong tool for that question — it moves too much at once.
5. The changeover: a schedule swaps the host and the station says nothing
Scoped 2026-08-16, from reading §3 back. §3 answers who is presenting after a daypart boundary
and stops there, which leaves the boundary itself as a hard cut: the warm host is talking over one
record and the late-night one is talking over the next, with no line between them saying so. Every
other thing the station does about a change in its own state has a sentence attached — a listener
arriving gets
WELCOME_KIND, a silence cause changing gets astation_eventsrow — and the onemoment a LISTENER can actually hear has none.
The shape is one break, written and spoken by the INCOMING persona, naming the outgoing one. Not two,
and that is a constraint rather than a preference:
segments.voiceis one id and a segment is onerender, so a sign-off answered by a greeting is the two-voice exchange this file already excludes,
and it would want a
kindwith turns rather than a persona feature. One break in the new host'svoice is the whole of what a schedule can say without that.
Three things make it real work rather than a fifth writer.
The old order is retracted before the new one exists. A changeover is a new running order (§3),
which is
putOnAir, andputOnAiropens by callingrundown.retract()— so anything the OUTGOINGpersona was to say has to have already aired, not merely be committed, or it goes out with the
programme it belonged to. That is why the incoming host carries the line: a break rendered under the
new order is on the near side of the retraction and needs no ordering argument at all. A genuine
sign-off would need the schedule to write, render and WAIT for a segment on an order it is about to
throw away, which is a lot of machinery for the less interesting half of the moment.
It is
next, neverinterrupt. A daypart boundary is not urgent and the changeover policy indirector-and-lineups already names the moment: finish the track, then
swap. So this is
prepareRequested's rendered-before-injected path with the urgency that lands on arecord boundary, and the audio exists before the slot does — which is the same guarantee that keeps
a welcome from arriving as silence.
The floor has no phrasing for it.
rotation.breakTemplatessays nothing about a host changing,and a persona's own
templatesare that character's ordinary breaks. A changeover template needs toname the outgoing host, so
break.templates.tsgains a value the way{{next.album}}will —{{previous.host}}, resolving to the retiring persona'sdjName— and the seeds gain one phrasingeach. Without that a model-less station gets a silent changeover, which is the same station it has
today and is an acceptable first cut, but it is the half that decides whether this is a character
feature or a model feature.
Two decisions, neither settled:
only the source is a change in the music and the host has no news to report. So the request is
gated on
presentingactually differing, which means the scheduler compares two persona ids italready holds and usually posts nothing.
station_eventsrow. Leaning yes and independent of thespoken half — "why did the station change character at 9" is exactly the question the activity
feed exists to answer, and it is one
ActivityRecordercall whether or not anybody is listening.6. The notebook only grows, and nothing decides what a character stops being
Written 2026-08-28, and it is a question rather than a fault.
deadair.persona_noteshas 4 rowstoday, so nothing here has been measured and nothing can be. The right time to answer it is now,
because every answer is cheap while the table is small and one of them is a migration.
§2 built the notebook and gave it two ways to lose a line, both of them an operator's: reject what
the station proposed, delete what you wrote. Neither is a rule, and the table has no other exit.
The distil pass appends,
last_used_atdecides which notes are SHOWN on a given break, and thatrotation is real mitigation that answers a different question. Rotating a growing pool spreads the
same pool thinner. It does not stop the pool being the wrong pool.
What that risks is specific and it compounds in the direction that is hardest to notice: the pass
reads what a character SAID in order to decide what it has settled into, and what it said was written
under the notes it already had. A trait that lands early is over-represented in the scripts the next
pass reads, which is evidence for keeping it, which puts it in front of more scripts. Nothing in that
loop is wrong at any step and its fixed point is a character that has one idea. The
proposedversusin usesplit guards the CLAIM (nothing can verify an inference, so an inference is only everoffered) and says nothing about the claim's shelf life.
Which TIER this is, decided 2026-08-30, because the three candidates below were all one tier and
that was not obvious until a second one was named. A continuity store can be bounded in three ways:
soft decay on age or use, a boundary reset at an edge the product actually has, and an operator's hard
purge. They are not alternatives, they are different questions, and this tree already has one of them
built.
The boundary tier exists and is correct, one layer down.
BreakWriteRequest.recentis theBROADCAST's memory rather than all-time history, and is kind-agnostic, on the argument written out at
break.writer.ts:306: a listener who tuned
in twenty minutes ago has heard this show and none of the one before it, so a phrase is spent only if
it was spent tonight.
The notebook must not get that tier, and that is the whole reason it needs its own.
persona_notesexists precisely to outlive a broadcast. Resetting it at a boundary would delete the feature. So the
notebook wants soft decay specifically, the hard purge is already built as the operator's reject
and delete, and the boundary tier is correctly somebody else's. Anything proposed below is a decay
rule and should be read as one.
One thing that falls out of naming the tiers, and it is not about decay at all: an "already said, do
not repeat" block is not a forget mechanism. Telling a model what it may not say makes the thing more
available to it, not less. This tree does not have that bug, and it is worth knowing why rather than
being glad:
break.prompt.tsbans a spent signature and in the same sentence invites a replacement,and
characterFaultrefuses a script that ignores the ban. A decay rule that quietly turns into alonger avoid-list would be walking back into it.
Three candidate answers, cheapest first, and they are not exclusive:
last_used_at, no schema change. A note not reached for in N passes stops beingeligible and is not deleted. Cheapest, reversible, and it is a change to the reader rather than to
the store. Its weakness is that it decays what the rotation happened not to pick, which is close to
random on a small pool.
over a window, so a trait survives by being re-evidenced rather than by having once been true. This
is the one that actually breaks the loop above, and it is the most expensive: it turns an append into
a diff, and the operator's
in usemarks have to survive it or the pass overwrites their judgementevery night.
PERSONA_SHEET_LIMITSalready does for the sheet.Bounds the size and decides nothing about staleness, so it is a floor under either of the above
rather than an alternative to them.
Do not build any of it before break-ratings. §2 already names the missing
clause: a note distilled from a break the operator disliked is the character being taught to repeat
what did not land. A decay rule with no opinion attached decays on age and use, which are both proxies
for "was this any good", and the real signal is one table away. The order is ratings, then this,
because a rule written first will be written against the proxy and will then be the thing that has to
be unwritten.
The one thing that is safe to decide now, before any of it: a station that has never rated anything
must not have its characters quietly forgetting. Whatever lands defaults to off or to a horizon long
enough to be inert, for
rotation.breaks' reason. A character changing on its own is a feature anoperator opts into, not the behaviour of a fresh install.
What this file does not cover
Multiple personas talking to each other — ANSWERED (2026-08-25), and the answer was not a
persona feature. It is a PRODUCTION: one turn per segment, one voice each, and the block enters
the running order whole. That is what this entry meant by "a segment kind with two voices and a
script with turns", and the reason it could not be one segment is unchanged —
segments.voiceisone id and a segment is one render.
What personas had to lend it is one column.
personas.kindishostorcaller, a caller cannever be active (the database refuses it), and everything else a caller needs — the sheet, the
diction check, the voice slot,
persona_notes,persona_stories— it already had. Seeproduced-episodes for the six things that were decided in the building.
Mood, which is station-moment and is deliberately a different axis: a
persona is who the station is and a mood is what the hour is like. A persona that changed with the
clock would be a schedule, which is §3.
A persona choosing its own music at all — RESOLVED, in the other direction (2026-08-21). This
entry read "a persona choosing its own music beyond the
musicline", and the line itself is nowgone. It was a FOURTH way to steer the programming beside the three keyed to the clock
(
station_lineup.brief,schedule_slots.brief,schedule.sustainingBrief), and two prosedescriptions reaching one local model made it split the difference — so it had to be withheld from
any briefed refill, which was a structural rule costing a page of explanation in three files.
Deleting the field deleted the rule, and
SetInputs.personawent with it: the record chooser nolonger learns who is presenting. A persona is a voice. What an hour plays is the brief, plus
the one structured half a brief can have —
era_from/era_to, which is what lets a decade reachthe deterministic draw as well as the model. See
docs/internals/personas.mdand the period rulein
docs/internals/director.md.Moved from
docs/todo/personas.md. The file as it last stood: https://github.com/robert-dean/deadair/blob/71e431d45ce7b35a189c7d226832ec4a6247e9dc/docs/todo/personas.mdAll reactions