-
v4.3.0
StableAll checks were successfulCI / Bot/backend version sync (push) Successful in 42sRelease / Create Forgejo release (push) Successful in 12sCI / Backend lint (ruff) (push) Successful in 55sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m29sCI / Frontend tests, audit, and build (push) Successful in 2m16sCI / Bot tests and audit (push) Successful in 2m36sCI / Docker image build (push) Successful in 4m13sRelease / Build and push versioned images (push) Successful in 3m53sCI / Backend migration, tests, and audit (push) Successful in 10m13sCI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m47sreleased this
2026-09-06 09:51:10 +00:00 | 113 commits to main since this releaseProduct Surface: Navigation, Attention & Feedback. The features Quest Board already had are now findable, acknowledged and self-diagnosing: a navigation bar on every page, one name for each thing, a "Needs you" list on the dashboard, RSVP on the web, a session page that shows every post-session task, a Prep workspace with a front door and a sheet that looks forward, a wiki you can search and review from one place, a Discord bot that answers back and explains itself, and an invite link you can paste. Structural fixes, not a redesign; that is v4.4.0.
Three migrations, applied in this order by the migrate container:
f0ab1c2d3e4f(a delivery-status column on campaigns, #369),fa0b1c2d3e4f(an RSVP status on session attendance, #370),0b1c2d3e4f5a(post-session task status on sessions, #380). All are additive; nothing is backfilled. Head after upgrade:0b1c2d3e4f5a.Bot API contract v1, unchanged. Everything new on
/api/bot/*is additive:POST /bot/campaigns/{id}/delivery-status,GET /bot/guilds/{id}/campaigns,GET /bot/guilds/{id}/sessions/recordable, astatefield onlink-status, areasonfield on/ask, and a link field in every notification payload. An older bot works against this backend; an older backend leaves a newer bot's new features quietly off. Deploy both images together as usual.BOT_EXPECTED_APP_VERSIONis bumped so the bot's startup log matches.One new public route on the backend:
GET /api/invites/{code}resolves an invite code to a campaign preview so/join/<code>links work before sign-in. The code is the secret; the route is rate-limited and a wrong code is an ordinary 404. Nothing else on/apiis unauthenticated.Two HTML pages served by the backend (the account-linking outcomes) carry a slightly relaxed Content-Security-Policy that allows inline styles for
text/htmlresponses only; JSON responses keep the strict policy.No new settings. Nothing to add to
.env. Operator notes carried from the patch releases: the Message Content toggle in the Discord Developer Portal can be turned off; if a campaign's notification channel is misconfigured the campaign page now says so, and the per-field "Test" buttons on the Edit form prove a fix. The old/sessions/<id>/recordingaddress redirects to the session page, which is now the one live-session screen.Added
-
[webapp] You can now RSVP to a session in Quest Board (#370). Saying whether
you were coming used to mean reacting ✅ or ❌ to a message in Discord — so if
your group does not live in Discord, or you never linked your account, you
could not answer at all, and nobody could see who had. A confirmed or
tentative session now has an "Are you in?" control at the top of its page with
Yes, Maybe and No, and the whole party can see the roster of who has answered
and who is still to. It is the same answer either way: a reaction in Discord
shows up in the roster, and an answer given here shows up for everyone. Your
GM's after-the-fact attendance list is untouched — saying you are coming is not
the same as having been there. -
[webapp] A "Needs you" list on the dashboard (#368). The dashboard showed
your campaigns and nothing else, so the only way to find out that a vote was
waiting on you, or that a session next week still needed your RSVP, or that a
transcript had been sitting ready for you to approve, was to open each
campaign and look. Anything actually waiting on you now appears at the top of
the dashboard — open votes, missing RSVPs, sessions in progress, and for GMs
transcripts to review and suggested wiki updates — and every line takes you
straight there. When nothing is pending the section is simply not there. The
"Next session in 3d 4h" line on each campaign is now a link to that session. -
[webapp] A "Post-session processing" checklist on every session (#380).
Finishing a recording sets six things going: the transcript and summary, the
suggested wiki updates, the campaign storyline, the session's journal entry,
the next session's title ideas, and the recap email to the party. Only the
first two ever said anything, so if the storyline never rebuilt or the recap
email was refused by the mail server, nothing anywhere told you — it looked
exactly like a job that had not got round to it yet. All six now appear as a
checklist on the session page, and "failed" reads differently from "hasn't
run" and from "deliberately skipped" (journal entries turned off, campaign
archived, storyline written by hand). The recap email says whether it is
queued, sent, or failed. Includes a migration (0b1c2d3e4f5a), which
adds one column and backfills nothing — sessions processed before this
release read as "no record", because that is the truth about them. -
[webapp] A navigation bar that is the same on every page (#378). Until now
the only thing every screen shared was a corner strip with your name, Profile
and Sign out; getting from a session to the wiki meant walking back up the
breadcrumbs one page at a time. There is now a single bar across the top of
the whole app: the Quest Board wordmark always goes home, a switcher jumps
straight to any of your other campaigns, and while you are inside a campaign —
including on a session, its recording page or its shelf — the bar carries that
campaign's Sessions, Prep, Wiki and Highlights. The two wiki links on the
campaign page also stop opening a second browser tab, so the back button
works through the wiki again. -
[bot]
/help, a welcome when the bot joins a server, and a presence that
points at both (#373). The bot arrived in a server and said nothing, ever:
there was no command list, no message on join, and nothing under its name in
the member list, so the only way to find out that/nextexisted was to be
told by somebody who already knew./helpnow lists every command with one
line each; joining a server posts the three setup steps — link a campaign to
this server (quoting the server's own ID, ready to paste), everyone runs
/link, then try/help— and the member list reads "Watching Quest Board ·
/help". The welcome also warns that Discord takes up to an hour to roll
out a new bot's slash commands, because the first impression of a working
install was otherwise a server where/helpdid not exist. -
[webapp] The wiki has a search box (#396). Until now the only way to
reach an article was to know which of thirteen categories it had been filed
under and scroll the list — fine for a campaign with twenty articles, useless
for one that has been recorded for a year. There is now a search box on the
wiki's front page and in the article page's category rail. It searches every
article in the campaign rather than just the ones already on screen, and it
looks at each article's text and its alternative names as well as its title,
so "the innkeeper with the limp" finds the innkeeper even when nobody at the
table remembers his name. -
[webapp] An invite link you can actually send someone (#387). Inviting a
player used to mean finding a 12-pixel "▼ Show invite code" disclosure under
the Archive and Delete buttons, copying eleven characters out of it, and
hoping the recipient worked out that they had to create an account, find the
Join button on their own dashboard, and paste it in. There is now an Invite
players control in the member list, which gives you a link — paste it into
your group chat and whoever opens it sees which campaign they have been
invited to and who invited them, signs in if they need to, comes straight back
to the invite, and joins. It works for somebody who has never heard of Quest
Board. The code is still there underneath for when a link will not survive the
trip, and the "+ Add" member search now says so too, instead of ending at "No
users found." — which it always did for the one person you were most likely to
be looking for. -
[webapp] A first-run checklist on a new campaign (#387). A campaign you
had just created looked exactly like one you had finished setting up: an Edit
form, a member list of one, and an empty session list. Four things actually
need doing — set the timezone, invite the players, connect Discord, schedule
the first session — and each was discoverable on its own but not as a set.
They now appear as a short card at the top of the campaign page, each with a
button that takes you to the control that does it rather than near it. Items
tick themselves off from the campaign's own state, so a GM who did all four
last year never sees it, and Dismiss makes it go away for good on that
campaign. -
[webapp] Guides for GMs and players, one click from where you are (#390).
Everything written down about Quest Board was for whoever installs it. A GM
running their first campaign, or a player handed an invite link, had the
interface and nothing else. There are now two guides — "Running your first
campaign" and "Joining a campaign" — that live in the app, linked from the
bottom of your dashboard, from the invite page and from the sign-in page. They
are readable without an account, on purpose: the reader who needs "Joining a
campaign" most has not signed up yet. The sign-in page also stops describing
the product as scheduling, which it has not only been for a long time, and its
button says "Sign in" rather than "Sign in with SSO". -
[webapp] Players get the "Previously on" recap too (#388). A short recap of
last session, and the quests still open, has been waiting at the top of the
GM's shelf since v4.1 — where nobody but the GM could ever see it. The same
strip now sits at the top of a session's page and of "At the table", for
everyone in the campaign, while the session is upcoming or in progress. It is
the same recap from the same place, narrowed rather than rewritten: players
see the open quests your GM has marked visible to the party, and not the ones
they haven't. You can fold it away, and it stays folded for that session on
that device, but it comes back with one click — it is not a dismissal you can
make by accident and never undo. -
[webapp] Three things you could do, but had no way to undo or reach
(#389). Showing a card to the table was one-way: once revealed it stayed on
the players' table view for the rest of the session, however plainly you had
meant to reveal a different card. The shelf now lists what is currently up,
with an "Un-reveal" on each — and the players' view drops it on its next
refresh, exactly as it picks up a reveal. XP was the same shape of problem
from the other side: the ledger showed totals only, so a mistyped award could
be made and never unmade. Every award is now listed, party and per-member,
each with a delete that names the award it is about to remove. And "Add to
Calendar" offered Google and nothing else, which is a dead end if you keep
your calendar in Apple Calendar, Outlook or Thunderbird; there is now a
"Download .ics" beside it, offered wherever the Google link is.
Changed
-
[webapp] One screen for a live session (#386). Running a session meant
keeping two pages open that described the same recording and disagreed about
it. The session page had a "Recording & Transcript" section; a separate
Recording page had the live picture — the clock, who the bot could hear, who
was in the voice channel and silent, and Start and Stop — plus a second copy
of the summary you could not edit and a second attendance list whose toggle
said "✓ Here" where the session page said "Present". Worse, the link to that
page only appeared once a session had a confirmed time, so the one moment you
most want to check the bot can hear the table — before committing to a date —
was the one moment the controls were out of reach. All of it is now on the
session page: a Recording panel with the live clock, per-speaker capture,
who is present but not captured yet, Start and Stop, and the "connection
lost, showing the last reading from … ago" honesty added in #377. It is there
from the moment a session is proposed, and the shelf — the screen a GM
actually has open during play — now has a Recording link straight to it. The
summary is editable in one place and read-only in none, attendance says
"Present" or "Absent" wherever you meet it, and the roster is available from
the moment a session is under way rather than only after it is over. The old
/sessions/…/recordingaddress still works: it takes you to the panel, so
bookmarks and older Discord links are unaffected. -
[webapp] One name per thing, across the whole app (#379). The same concept
answered to a different name on almost every screen, which made two screens
about the same thing look like two different features. The GM prep workspace
was "GM Workbench" in its own heading, "Session Prep" in the palette and
"Session Prep Sheet" in the panel; it is Prep everywhere now, and its
output is "prep notes". The wiki landing page said "Wiki" in the breadcrumb
and "Campaign Story" in its heading; it says Wiki. Plot threads were
"Thread Tracker", "loose ends" and "open loops"; they are Quests, and the
shelf's cold open lists "Open quests". The Quote Board is Highlights. A
suggested wiki entry was "AI Proposals", "Entry Proposals", "Bot Proposals" or
"lore proposals" depending where you stood — and "Bot" was wrong besides,
since the backend worker writes them, not the Discord bot; they are
suggested wiki updates. The generation history rail and its filter now
name each tool ("Session Prep", "NPC Voice Cues") instead of printing the
backend'ssession_prepandnpc_voiceat you. The link from a session to
its recording page says "Recording", not "Dashboard". Nothing moved: every URL
is unchanged, so bookmarks and Discord links still work. A character's
timeline also stops rendering twice on a wiki article — the full section
stays, the cramped duplicate in the infobox is gone. -
[webapp] "Approve" on a suggested wiki update stops meaning two different
things (#385). One button either wrote the article straight into the wiki
or carried you off to a merge-review page you had not asked for, depending on
whether the suggestion changed an article you already had — a difference
signalled only by a small "Update" chip. A suggestion that creates a new
article now says Approve; one that changes an existing article says
Review merge…, so the destination is on the button. The words either side
of that click have been made true as well: the queue promised the suggestion
would be "appended" and the review page then offered to "replace the
article"; every draft that was not a merge or a stat conversion called itself
an "LLM rephrase", generated backstories included; and the approve button
read "Approve and replace article" even for the two kinds of draft that
replace nothing at all. Each kind is now named for what it is and its button
for what it does — "Generated backstory for…" approves as Add to article,
a stat block approves as Approve stat block and says the article's text is
left alone. Alongside that: all four actions in the queue now show they are
working, so a second click cannot approve or discard the same thing twice;
when one fails it says so on the suggestion that failed, instead of in a
browser alert box — the last one in the product; and approving an article
confirms it and offers a link to the article you just made, rather than
making the row vanish and leaving you to guess.
Fixed
-
[webapp] Pages that are not for you now say so, instead of vanishing or
bouncing you (#382). A player who followed a Prep link a GM had shared was
redirected to the campaign page with no message, which reads as a broken link
rather than as "Prep is the GM's workspace" — it now says which it is, and
offers the way back. Four wiki pages ended a player's navigation with the
words "GM access only." on an otherwise empty screen, with nowhere to go but
the back button; each now explains the restriction and links back to the
article, the wiki, the campaign and the dashboard. Opening an article's
/editURL without permission silently showed the ordinary read view, as
though the link were broken; it says you can read but not edit this one. The
"Add to shelf" control disappeared entirely when a campaign had no upcoming
session, so the answer to "why can't I put this on a shelf?" was a blank
space; it now says there is no upcoming session and points at where to
schedule one. A highlight's "Draft" badge said only that it was not visible
to players, never what would make it visible; it now names the thing that
publishes it — approving the session summary, which is on the same page. And
the archived-campaign banner named no way back: archiving is something a GM
can do, un-archiving is not, so the banner now says an instance administrator
restores it, and that scheduled sessions stay on the calendar but stop
sending reminders. -
[webapp] Prep's session pickers open on your next session, not the one
furthest away (#302). Every session dropdown in Prep — Session Prep, Beat
Planner, the Name Generator's session context, Series Titles, "Add to shelf"
— was seeded from whichever session the API happened to list first, which is
the most recently created one. For a weekly game that is the last occurrence
on the calendar, so a GM who opened Prep to plan Tuesday was silently pointed
at a session three months out, and notes saved there landed on the wrong
night. Those lists are now ordered by when the session actually is, soonest
first (undated ones last), and default to the session in progress if there is
one, otherwise the next one up. -
[webapp] Prep notes can no longer be wiped by a button you did not mean to
press (#303). "Save Notes" in the Beat Planner is now inert until you have
actually changed something, so an editor you opened and did not type in
cannot write an empty note over what was stored — and if the notes fail to
load at all, the box and the Save button are both disabled and say why,
rather than presenting a blank page you could save. "Append to prep notes" on
a generated prep sheet re-reads the stored notes at the moment you click it,
so notes you edited in another tab since the sheet appeared are kept, and the
button is not offered at all when those notes cannot be read. Notes written
by the prep sheet also show up in the Beat Planner without a reload, and if
you had unsaved typing when that happened, your typing wins and you are told. -
[webapp] Prep no longer throws away what it just generated (#304). Switching
tools in Prep wiped the sheet you were reading, and "Re-open" in Generation
History only moved you to the right tool and then showed you an empty panel —
even though every run had been saved on the server the whole time. Re-open now
brings the run back: its output, and the inputs it was made with. Opening a
tool also brings back its last run when that run is still about what you are
looking at — the same session, say — and deliberately shows nothing when it is
not, rather than handing you a prep sheet for a session that already happened. -
[webapp] The generated prep sheet stops handing you back last session
(#305). The only forward-looking thing the prep prompt had to work with was
your notes for the upcoming session — which were almost always empty, because
nothing in Prep ever loaded them (#302, #303). Left with a pile of recent
summaries and asked for a prep sheet, the model wrote a recap. Three changes:
the notes box for the selected session is now in the Session Prep panel
itself, so writing down where you want the evening to go is part of asking
for a sheet; a "What I want from this session" box steers one sheet without
being saved anywhere ("they finally meet the Duchess", "keep it in the
sewers", "no combat"); and the prompt now says what the recent summaries are
for — build on them, do not repeat them — with every scene and secret
required to move past where they left off, and only "loose ends" allowed to
look backwards. -
[webapp] Prep has a front door, opens on the right tool, and its output
goes somewhere you can find (#374). You could only reach Prep from the
campaign page or the nav bar, and it opened on the Beat Planner with a
campaign-wide picker that had already chosen a session — usually not yours.
Every upcoming session row on the campaign page, and every session page you
run, now has a Prep this session button that takes you straight to the
prep sheet for that session. Prep opens on Session Prep. Untitled sessions
read as their date in every picker instead of four identical rows saying
"Untitled session". The three unlabelled buttons under a generated sheet
("Append to prep notes", "Replace prep notes", and a second shelf picker) are
now one Save to this session and one Add to shelf, each of which names
the session it wrote to and links there. Generation History refreshes when a
run finishes, instead of insisting there were no generations while one sat on
the screen beside it. And the tool palette is six entries — Session Prep,
Story Arcs, Quests, NPCs, Names, At the Table — instead of thirteen siblings:
nothing was removed, the smaller tools now sit inside the job they serve. -
[webapp] "Processing…" now tells you how long it has been, and how long it
usually takes (#381). A spinner with no clock is indistinguishable from a
hang after about ninety seconds — which is roughly when a GM decides the thing
is broken and hits Retry, adding a second job to the queue that was the delay.
The session page, the recording page and every Prep tool now count the time
elapsed and say what normal looks like beside it ("usually 2–5 minutes for a
3-hour session, longer when other recordings are queued"). A Prep run that
overruns also points at "Generation History" by its actual name, instead of
telling you to check "the history rail", which is not a thing on the page. -
[bot/webapp] Discord account linking no longer dead-ends (#392). Two of
the ways linking can fail answered the browser with raw JSON — literally
{"detail":"Link token is invalid or expired"}on a blank page — and one of
them was a trap. If the Discord account was already connected to a different
Quest Board account, the bot's poller could not tell that from "hasn't
clicked the link yet", so ten minutes later it DMed the player that their
link had expired; they ran/linkagain, got the same silence, and went
round a loop that/linkcould never resolve, because the fix was
/unlinkon the other account and nothing had ever said so. Both failures
now render a real page that names the next step, the bot reports the actual
outcome (and reports it as soon as it happens, rather than at the end of the
window), and the conflict message names/unlink. Two smaller silences went
with it: a "you need to link" DM to somebody with DMs closed used to vanish
entirely — it now falls back to a short self-deleting note in the channel —
and the vote reminder that lists players who are not linked now tells them
to run/link. The profile page names the command too, instead of alluding
to "the bot's account-linking flow". -
[bot/webapp]
/record startknows which session you mean, and a restart no
longer swallows a recording in silence (#393). The command asked for a
session ID that nothing in Discord could give you —/nextshows one session
and omits its id,/historylists only finished ones — so the answer was to
go and copy a UUID out of a browser. It now offers this server's upcoming and
in-progress sessions as you type, and a session that does not exist is
refused when you start rather than discovered at the end of the evening, which
is when the mistake used to surface. Every recording started from Discord also
announced itself as "Recording started — Unknown Session", and said it again
at upload, because the slash command fetched the session's details and then
dropped the title; it now uses the real one./record stopno longer leaves
"Quest Board is thinking…" on screen for ever — it answers the person who ran
it, as well as posting to the channel. And a bot restart mid-session used to
leave the channel showing "🔴 Recording started" with no ending,/record stopinsisting no recording was active, and a GM with no way to know the
audio had in fact been kept: the active recording is now remembered outside
the process, so when the bot comes back it says what happened to the audio —
recovered and being transcribed, kept and awaiting a retry, or never captured
— and/record stopanswers honestly instead of denying the session
happened. Failure messages have been rewritten for the people reading them:
no raw exception text in a public channel, and "a GM can retry from the
session page" with a link, rather than advice to use a console that a hosted
customer does not have. -
[bot/webapp] Discord now tells you what actually went wrong (#373). Every
Quest Board API call the bot made ended the same way when it failed — "Could
not reach Quest Board right now. Please try again shortly." — including when
the backend had answered with something you could act on. "LLM endpoint not
configured. Set it in Admin → Bot Settings." was being thrown away and
replaced with a message that said the service was down, which it was not.
The bot now shows the backend's own sentence when the backend is answering
rather than failing (a 4xx, or a 503 naming a dependency that is not set up),
and keeps the generic line for a genuine outage or an unhandled error, where
it is the only true thing to say. Related:/next,/questsand/history
used to answer "nothing scheduled" for a server nobody had finished setting
up — they now say when no campaign is linked to this Discord server, name
the field to fill in, and quote the server's ID;/record start's "you are
not the GM" refusal now mentions that Quest Board only recognises a GM whose
account is linked, and points at/link; and/askon a campaign with
nothing recorded finally gives the "record a session first" advice it always
had written down, which a placeholder answer from the backend had made
unreachable. -
[bot/webapp] A wrong Discord channel ID no longer swallows every
notification in silence (#369). The bot notification channel is a snowflake
a GM types into a plain text box. Get it wrong — a typo, a channel that was
later deleted, one the bot cannot see or cannot post in — and every
notification that campaign would ever send vanished, permanently, with no
error anywhere: the bot logged a warning nobody reads, and the durable event
queue acked the event exactly as if it had been delivered. The campaign simply
went quiet, and the only visible clue was the absence of messages nobody was
looking for. The bot now records why it could not deliver, against the
campaign, and the campaign page carries a warning at the top of the page —
outside the collapsed Edit form, where the broken field lives — naming the
actual problem ("Discord has no channel with that ID", "the bot isn't allowed
to see that channel", "give it Send Messages permission") and offering a
button that opens the field. The warning clears itself the moment a
notification lands. The event is still acked, because a message the bot cannot
post must not wedge the queue for every other campaign — but if the failure
cannot even be recorded, that now goes to the log as an error rather than
disappearing too. The "Send test message" button is fixed in the same breath:
it was admin-only on a page any GM can open (so most GMs got a permission
error), it reported success on having queued the attempt rather than on
anything arriving, and it tested the bot channel and the webhook as one thing.
There are now two buttons, one per field, each waiting for the real answer,
and the channel ID field itself rejects anything that is not 17–20 digits
before you can save it. Adds a migration (f0ab1c2d3e4f). -
[bot/webapp] Voting in Discord now answers back, and a GM can change one
time without cancelling the vote (#371). Reacting to a poll or an RSVP
message did nothing visible: no confirmation, no error, and a vote that failed
to reach Quest Board looked exactly like one that landed. Every reaction now
gets a short reply in the channel that mentions you and deletes itself — "your
yes for Option A is recorded", or the reason it was not — including,
at last, the rule nobody could have guessed: removing a reaction records a
no for that time, not a blank. The poll message says so itself now, and
says to use Quest Board for "maybe", which reactions cannot express. Four
further asymmetries go with it. A vote cast in Discord now fires the same
"🗳️ Vote Update" post that a vote cast in the web app always did, so the
channel's own tally stops going stale. A reaction on a poll for an
already-confirmed session is refused instead of quietly recorded, the reaction
is taken back off, and the player is told when the session was confirmed for —
and the poll message is relabelled "Voting closed" the moment a time is
confirmed, so it stops inviting reactions in the first place. A GM can add or
remove a single time option on an open vote from the session page, keeping
every vote already cast; the only previous route was "Reopen for voting",
which replaced the slate and discarded them. And when the bot has to fall back
to keeping its poll bookkeeping in memory — no Redis, or Redis unreachable —
it now says so loudly at the moment it starts, and logs every reaction it
drops, because the symptom otherwise is a channel full of polls that look
perfectly alive and record nothing. -
[webapp] The wiki updates the app suggests are findable again (#375). On
the wiki's own front page the only way into the suggestions queue was a link
in the left-hand rail, and that rail is hidden on anything narrower than a
tablet — so from a phone there was no route to it at all. It now sits beside
New article in the main column, with the number of pending suggestions on
it, at every screen size. The campaign page carried a second, cut-down copy
of the queue: it left out suggested relationships entirely, truncated every
suggested article to three lines, and disappeared when the queue was empty —
which is exactly the moment a GM hunting for it needed to be told there was
nothing waiting. That copy is gone, replaced by one row that is always there
and always says which of "3 suggested wiki updates", "no pending suggestions"
or "couldn't load them" is true, with Review going to the one page that
shows the whole queue. Both pages now tell a failed read apart from an empty
one, instead of showing you an empty queue when the request simply failed. -
[webapp] A wiki draft can no longer claim to be generating for ever, and a
failure in one panel stops taking the whole page with it (#395). While the
LLM writes a draft, the review page asks the server for it every couple of
seconds — and that check only understood one kind of failure. A server error,
a dropped connection or a worker that died mid-run left "Generating draft…"
on screen indefinitely, with nothing to suggest anything had gone wrong. The
page now says when a check fails, stops pretending it is still watching after
three minutes, and offers Check again beside the existing reset. Three
other failures used to replace an entire page with a single red line: a wiki
article's edit history failing to load, a Restore of an old version
failing, and the wiki's Load more failing each wiped out the article — or
the whole campaign story — you were reading. Each now reports itself beside
the control that failed and leaves the page alone. And Full regenerate,
which throws the campaign story away and rewrites it from every session, now
asks first, like every other destructive action in the wiki. -
[webapp] The wiki's category list stops running out after 200 articles
(#396). The article page's category rail asked for the entire wiki in one
request, and the server sends at most 200 entries — so on a campaign with
more than that the rail simply ended, with nothing to say anything was
missing, and every article past the cut-off was unreachable by browsing. It
now pages the way the wiki's front page already did, with the same Load
more (200 of 250) button, so both lists reach the end of the wiki. -
[webapp] Things that failed now say they failed (#377). A dozen places in
the web app treated a failed request as if nothing had happened, so there was
no way to tell a working screen from a broken one. The worst of them was the
recording page: if the connection dropped mid-session, the page redrew itself
as "No active recording", idle, with a Start Recording button — while the
bot was still recording in Discord — because "the server says nothing is
recording" and "we could not reach the server" were the same value in the
code. It now keeps the last thing the server actually said, says "Connection
lost, retrying…" over it with how long ago that was true, and clears the
warning the moment it hears back; where it has never heard anything, it says
Unknown rather than guessing at the answer that invites a second
recording of the same evening. The players' table view says the same thing
when its own polling stops working, instead of quietly showing a frozen list.
Elsewhere: a vote the server refused, and a session-shelf card whose new
position never saved, both now say so and offer to try again — the shelf was
the sneakiest, because the board looked right until the next reload put every
card back. A session's attendance and its loot & XP stop reporting a failed
read as an evening where nobody came and nothing was won. The last session's
recap, the title suggestions on a new session, and Prep's name pre-generation
each say when they could not load rather than simply not appearing. And in the
wiki, "Check for link suggestions" answers "No link suggestions found"
instead of doing nothing visible, and a stat block whose game-system schema
could not be read says why its labels have turned into raw field names.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v4.2.3
StableAll checks were successfulCI / Docker image build (push) Successful in 24sCI / Backend lint (ruff) (push) Successful in 36sCI / Bot/backend version sync (push) Successful in 38sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 50sRelease / Create Forgejo release (push) Successful in 14sCI / Bot tests and audit (push) Successful in 1m36sCI / Frontend tests, audit, and build (push) Successful in 1m44sRelease / Build and push versioned images (push) Successful in 4m52sCI / Backend migration, tests, and audit (push) Successful in 6m46sCI / Synthetic session harness (no GPU, no LLM) (push) Successful in 17m32sreleased this
2026-09-06 02:20:47 +00:00 | 171 commits to main since this releaseThe first slice of v4.3.0, shipped early because three of these are things the Discord bot was telling your players that were not true. A failed summary is no longer announced as ready, every notification now links back to the session it is about, the bot no longer asks Discord for a permission it never used, and a campaign you archive stops scheduling sessions and pinging people about them. Plus three small web fixes: players can name their own character, wiki articles are readable on a phone, and an unreachable duplicate page is gone.
No migration. The database schema is unchanged since v4.2.0 (
e9fa0b1c2d3eremains the head).Bot API contract v1, unchanged. The notification payload gained an optional link field; an older bot ignores it and a newer bot renders without it.
BOT_EXPECTED_APP_VERSIONis bumped so the bot's startup log matches.One Discord setting you can now turn off. The bot no longer needs the "Message Content Intent" toggle in the Discord Developer Portal. Leaving it on is harmless; turning it off is one fewer privileged permission.
No new settings. Nothing to add to
.env.Fixed
-
[webapp] The wiki article page no longer squeezes the article text into a
sliver on a phone (#376). The infobox now stacks below the article instead
of sitting beside it below thelgbreakpoint, so the article body gets the
full screen width on narrow screens. -
[webapp] Archiving a campaign now makes it go quiet (#479). Archiving was
a display state and nothing more: it changed which list a campaign appeared
in, and every scheduled task went on treating it as fully live. The root was
not the reminders — it was session creation. The hourly materialiser selected
active recurring series with no campaign check at all, so an archived campaign
with a weekly game kept minting confirmed sessions 28 days ahead, every hour,
and the reminder poller then correctly reminded a group that had stopped
playing about every one of them. A campaign a GM had explicitly retired could
therefore be the noisiest thing on a Discord server. Materialisation now skips
archived campaigns (and trashed ones, which had the same hole), and the five
notifications that fire with nobody in the loop — the session reminder, the
at-risk warning, the three-day vote nudge, the recap email and the
end-of-session attendance post — are suppressed for a campaign that is
archived, each logging the campaign id once so an operator can see why nothing
was sent. Nothing else changes: an archived campaign still transcribes and
summarises its recordings, still writes journal entries, still warns before
retention deletes its audio, and still answers a vote or a "reveal to Discord"
that somebody actually asked for — the line is unprompted, not
Discord-facing, because swallowing a button press would be a worse bug than
the one being fixed. Future sessions that a series already materialised are
kept, and archiving is never refused because of them: deleting them would
destroy rows a group may have notes and attendance on, and refusing would make
archiving fail on exactly the campaigns it exists for. They simply stop
speaking, and the archive response now returns how many just went quiet.
Un-archiving resumes everything, materialisation included. -
[bot] A failed transcription no longer announces itself as "Session Summary
Ready" (#372). When summarisation failed, the backend sent the same
session_summarisedevent with an error instead of a summary, and the bot
posted the success embed anyway: a green "Session Summary Ready" headline over
the body "No summary was generated.", followed by 👍/⚠️/❌ reactions inviting
the table to rate a summary that did not exist. The failure now gets its own
red notice that names the session, says the recording was kept so it can be
run again, and points a GM at the retry on the session page — with no feedback
reactions, because there is nothing to rate. The underlying error goes to the
bot's log rather than the channel. -
[bot/webapp] Every Discord notification now links back to Quest Board
(#391). Twelve of the thirteen notification embeds named a session and then
left you to go and find it; only the vote reminder carried a link, and the
summary embed's footer said "Full transcript available to GMs on Quest Board"
without being clickable, because Discord embed footers cannot hold links. The
backend now attaches the session's URL to every notification it sends, and
each embed renders it as an "Open in Quest Board" field — the summary's
signpost among them, moved out of the footer so it can actually be followed.
Nothing changes for an instance with noAPP_URLset: the field is omitted
rather than filled with a link that goes nowhere. -
[bot] The bot no longer asks Discord for the privileged Message Content
intent (#394). It never read message text — every command is a slash
command — but requesting the intent meant a self-hoster who did not tick
"Message Content Intent" in the Discord Developer Portal got a bot that
refused to connect and, under Compose's restart policy, restarted for ever.
The intent is gone, so that toggle is no longer needed; Server Members
Intent is still required and is what per-speaker recording attribution
depends on. If a privileged intent ever is missing, the bot now says which
one and where to enable it, once, and keeps serving/healthinstead of
crash-looping..env.exampleand the Discord setup docs have been corrected —
they told you to enable Message Content and called Server Members optional,
which was backwards.
Internal (no operator-visible change)
- Deleted the orphaned
CampaignNotespage and its unused/campaigns/:id/notes
route (#384). It was linked from nowhere and duplicated the campaign journal
already shown on the campaign page itself; the "back to dashboard" pattern it
was cited as an example of still exists inCampaignHighlights.jsx. - Added regression tests locking in that a player already sees an actionable
"+ Add character" button on their own row when they have no character yet
(#383). Investigation found the underlying behaviour was already fixed as a
side effect of the multi-character work in #330; no production code changed.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v4.2.2
StableAll checks were successfulRelease / Create Forgejo release (push) Successful in 12sCI / Bot/backend version sync (push) Successful in 45sCI / Backend lint (ruff) (push) Successful in 54sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m28sCI / Frontend tests, audit, and build (push) Successful in 1m55sCI / Bot tests and audit (push) Successful in 2m9sCI / Docker image build (push) Successful in 4m11sRelease / Build and push versioned images (push) Successful in 4m25sCI / Backend migration, tests, and audit (push) Successful in 9m59sCI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m56sreleased this
2026-09-05 17:26:13 +00:00 | 186 commits to main since this releasev4.2.1's headline fix did not hold on the bundled stack, so this patch follows within the hour. The hardware preflight now measures a reasoning model's throughput from the tokens it actually produced, whether or not it finished its answer, and reports a profile band on the default self-hosted configuration.
No migration. The database schema is unchanged since v4.2.0 (
e9fa0b1c2d3eremains the head).Bot API contract v1, unchanged.
BOT_EXPECTED_APP_VERSIONis bumped so the bot's startup log matches.No new settings. If you deployed v4.2.1 and the preflight panel still said "Not measured" for the LLM, upgrade and re-run the preflight; that is the whole of the change. If you skipped v4.2.1, its notes still apply.
Fixed
- [webapp] The hardware preflight now measures a reasoning model whether or
not it finishes its answer (#507). v4.2.1 gave a thinking model a bigger
output budget on the theory that it would finish inside one. On the bundled
llama.cpp stack serving qwen3.5 — the default self-hosted configuration — it
did not: the model thought for the whole budget, 2048 tokens in 61 seconds,
and the panel reported "Not measured" again. Raising the cap further is the
wrong lever, because a budget large enough for a workstation GPU to finish
inside is a timeout on a CPU box. So the probe stopped asking the model to
finish. 2048 tokens in 61 seconds is 33.6 tokens per second, which is the
only number the preflight exists to obtain, and it is now read off the
generation that ran out of room instead of being thrown away with it. A
deployment running a local reasoning model gets a generation rate, a profile
band and an expected session time, with the panel saying that the rate came
from a generation that never reached a visible answer. An endpoint that
reports no tokens at all is still reported as broken.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- [webapp] The hardware preflight now measures a reasoning model whether or
-
v4.2.1
StableAll checks were successfulCI / Bot/backend version sync (push) Successful in 43sCI / Backend lint (ruff) (push) Successful in 54sRelease / Create Forgejo release (push) Successful in 12sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m25sCI / Frontend tests, audit, and build (push) Successful in 1m51sCI / Bot tests and audit (push) Successful in 2m9sCI / Docker image build (push) Successful in 4m30sRelease / Build and push versioned images (push) Successful in 4m41sCI / Backend migration, tests, and audit (push) Successful in 9m24sCI / Synthetic session harness (no GPU, no LLM) (push) Successful in 15m14sreleased this
2026-09-05 16:26:30 +00:00 | 193 commits to main since this releaseSuperseded by v4.2.2. The preflight fix below did not hold on the bundled llama.cpp stack: a reasoning model thinks for as many tokens as it is given, so the larger budget still produced no visible answer and the panel still read "Not measured" (#507). v4.2.2 measures throughput from the tokens the model did produce instead. The other two fixes in this release are good; upgrade straight to v4.2.2.
A patch on v4.2.0 from the first live validation of the release: the hardware preflight now measures reasoning models instead of reporting "Not measured", usage rows name the ASR model that actually served the run, and the synthetic-session harness scores its in-character assembly against the right denominator.
No migration. The database schema is unchanged from v4.2.0 (
e9fa0b1c2d3eremains the head).Bot API contract v1, unchanged. A v4.2.0 bot works with a v4.2.1 backend and vice versa;
BOT_EXPECTED_APP_VERSIONis bumped so the bot's startup log matches.No new settings. Nothing to add to
.env. If your preflight panel showed "Not measured" for the LLM on a self-hosted reasoning model, re-run the preflight after upgrading; nothing else is required.Fixed
-
[webapp] The hardware preflight now measures deployments running a
reasoning model, instead of reporting "Not measured" (#507). Admin → AI →
"Run preflight" asked for at most 128 tokens of prose, which is not a short
answer for a thinking model — it is no answer at all, because the hidden
reasoning spends the whole budget before the first visible word. On the
bundled llama.cpp stack serving qwen3.5, which is the default self-hosted
configuration, the probe failed every time: no generation rate, no expected
session time, and a "Not measured" label beside an endpoint that summarises
whole sessions perfectly well. A provider that declares reasoning is now
probed with a budget it can answer in, and one that turns out to need it
anyway — an OpenAI o-series model, or Ollama serving qwen3 — is retried once
with the larger budget, with the panel saying why that measurement took
longer. The reported rate still counts the reasoning tokens, because a real
summarisation run pays for them too. -
[webapp] Admin → AI Usage now says which model transcribed a session
(#508). If you run the bundled transcription server, the model column was
empty on every run: the server picks its model at startup and takes no model
parameter, so there was nothing configured to record — while the
summarisation half happily named its model. That left the one question worth
asking of a self-hosted box unanswerable, because the model does change
under you when somebody redeploys it with a smaller one, and nothing in the
history said when. Quest Board already asks the server what it is running
(that is how it knows whether your server can do word timings); it now
records the answer alongside the run and shows it beside the transcription
time. A model you have set by hand still wins, an endpoint that never
answered is recorded as unknown rather than blank, and a run on your own
hardware is still costed in compute seconds rather than dollars. Older rows
keep their empty column — the model a session used last month is not
recoverable now. -
[webapp] "No vocabulary hints" is now reported as a medium-impact gap, not
a low one (#506). The capability panel ranked it below "the provider was
guessed from the URL", which told operators the wrong thing to fix first: the
measurement in v4.2.0 put the campaign vocabulary at roughly ten to
twenty-five points of proper-noun recall on identical audio, at no cost to
the rest of the words — the largest single effect anything in that release
measured. The wording now says what you actually lose, which is your own
characters' and places' names coming back mis-heard and spelled differently
every time. Nothing about the pipeline changed; if you run the bundled
transcription server, the fix is still to update it.
Internal (no operator-visible change)
- The synthetic-session harness scored marker detection for its
ic_only
assembly over the whole script's 54 marker occurrences, four of which are
only ever spoken in out-of-character lines that assembly does not render —
so that leg understated itself by counting markers no transcriber could have
returned (#506). Each assembly is now scored against its own script, and the
excluded occurrences are reported next to the count.fullis unchanged.
Committed calibration figures are annotated rather than restated: re-deriving
them needs a rerun on the GPU box. - The same harness seeded its Discord links unverified, and attendance
resolution deliberately ignores unclaimed links, so a live run reported all
five known players as unmatched strangers and never reached the
uncaptured-member check the fixture exists to exercise (#506). The links are
seeded verified now,seed_db --verifychecks that they are, and the
expected attendance outcome of a live run is written down.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v4.2.0
StableAll checks were successfulCI / Backend lint (ruff) (push) Successful in 27sCI / Bot/backend version sync (push) Successful in 22sRelease / Create Forgejo release (push) Successful in 12sCI / Bot tests and audit (push) Successful in 1m26sRelease / Build and push versioned images (push) Successful in 46sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 2m1sCI / Frontend tests, audit, and build (push) Successful in 2m42sCI / Docker image build (push) Successful in 4m24sCI / Backend migration, tests, and audit (push) Successful in 9m55sCI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m20sreleased this
2026-09-05 06:22:55 +00:00 | 210 commits to main since this releaseAI Provider Abstraction & BYO-AI. No commitment to any single transcription or
language-model provider: a normalised contract with swappable adapters, a
campaign can bring its own providers, self-hosting is a first-class, measured
target on both GPU and CPU, and the pipeline tells you what your configuration
cannot do instead of quietly working around it.⚠️ Five migrations —
a5b6c7d8e9fa(provenance on summary runs),
b6c7d8e9fa0b(per-session usage and cost),c7d8e9fa0b1c(per-word timings
on transcript segments),d8e9fa0b1c2d(per-campaign AI settings) and
e9fa0b1c2d3e(resolved provider identity on usage rows). All are additive —
new tables or nullable columns, nothing rewritten, nothing backfilled — and
each was verified to apply, roll back and re-apply cleanly. For a minor
upgrade this size take a snapshot first:make migrate-safedoes the
preflight, backup and migrate in one step.The bot contract is unchanged (still v1) and nothing under
/api/bot/*
changed shape, so the backend and bot images can be upgraded independently in
either order.⚠️ If you run the bundled transcription server, update it. The word
timings and the campaign-vocabulary hint in this release need its API v2;
against an older server Quest Board keeps working exactly as before and the
new "What your AI configuration can and cannot do" panel tells you to update.
The server lives outside this repository; see OPERATIONS → "Upgrading the
bundled transcription server".Six new optional
.envsettings, all with safe defaults:
QB_LLM_CONCURRENCY_LIMIT,QB_ASR_CONCURRENCY_LIMIT,QB_AI_PROFILE,
QB_LLM_TIMEOUT_SECONDS,QB_LLM_PROSE_TIMEOUT_SECONDS,
QB_ASR_TIMEOUT_SECONDS. Leave them unset unless OPERATIONS tells you
otherwise; a CPU-only deployment should pinQB_AI_PROFILE=cpu.The repository now carries ~9 MB of invented audio — a committed
synthetic 30-minute session that exercises transcription end to end in CI.
No real voices, no real content; seeCLAUDE.mdfor the carve-out.Added
-
[webapp] Each group can now bring its own AI provider, and pay its own
bill (#128). One additive migration (d8e9fa0b1c2d) adds a new
campaign_ai_settingstable; nothing existing is touched, nothing is
backfilled, and every campaign keeps using the instance's providers until
someone changes that. Transcription and summarisation have always been one
endpoint each for the whole instance, which is right when you host one group
and awkward the moment you host several — "whose OpenAI bill is this" is a
question those groups need to be able to answer separately. Admins get one new
switch in Admin → Bot Settings, off by default: "Let campaigns bring their
own AI providers". With it on, each campaign's settings page grows a panel
where its GM can point transcription and summarisation at their own endpoints
and keys. The two sides are independent, so "our own key for summaries, your
Whisper box for transcription" works; leaving either blank keeps using yours.
Keys are encrypted at rest with the same key as your own instance secrets and
are never shown again, to anyone. Every URL goes through the same checks an
admin's does, so a GM cannot aim the instance at an address on your private
network. Turning the switch back off makes every override on the instance
inert immediately without deleting any of them — switch it on again and
each group has exactly what it had. Two things worth knowing before you enable
it: a group pointing at a hosted service is uploading their session audio to
that service under their contract rather than yours, and a campaign whose own
endpoint is misconfigured fails visibly rather than quietly borrowing yours.
See "Per-campaign AI providers (bring your own)" indocs/OPERATIONS.md. -
[webapp] Quote timestamps now point at the word, not at the sentence — if
your transcription server is current (#352). One additive migration
(c7d8e9fa0b1c) adds a nullablewordscolumn totranscript_segments;
nothing existing is touched and nothing is backfilled. The bundled
transcription server used to return one time per sentence, so a quote
captioned "[01:12:40]" was really "somewhere in the twenty seconds that
started at 01:12:40" — which is fine for a one-line reply and useless for a
line thirty seconds into somebody's monologue. The current server aligns
every word, and Quest Board now reads those timings and anchors each quote to
the word it actually begins on. Alignment is not perfect and is not pretended
to be: a word the server could not place is positioned between its neighbours
and marked as an estimate, and a quote anchored to an estimated word keeps
the old sentence-level timestamp rather than a prettier invented one.
Because two versions of that server exist and you update it on your own
schedule, Quest Board now asks it what it can do — one cachedGET /health
— instead of assuming. On an older server everything behaves exactly as it
did before, with one deliberate change: it is now reported as taking no
vocabulary hints, because it accepts the campaign-names field and silently
ignores it, and Admin → Bot Settings tells you that updating the server is
the fix. Word timings and vocabulary bias apply to new sessions only —
nothing is reprocessed, and older transcripts keep their sentence-level
times. See OPERATIONS.md → "Upgrading the bundled transcription server". -
[webapp] A busy evening now makes you wait instead of making you retry
(#356). Groups play on the same two evenings, so a week of recordings lands
within a few hours of each other — against one transcriber that does one
session at a time and one AI endpoint that does two requests at a time.
Quest Board now knows those limits and queues behind them instead of pushing
past them, so a Saturday backlog costs you some patience rather than a
failed session with a rate-limit error in it. If a service turns a request
away anyway — a shared GPU box, or a hosted provider's own limit — that
session goes back in the queue and waits as long as the service asked,
instead of failing with something you could not have fixed. While a session is waiting its
page says so — "Queued behind 3 other sessions — about 20 minutes" — rather
than showing the same silent "Processing…" a genuinely stuck session shows,
which is what has been sending GMs to the Retry button and putting a second
copy of the same recording into the same queue./askand the Test LLM
button never wait: if the queue is full they say so immediately, and say
that your endpoint is not the problem. Admins get the whole picture at
GET /api/admin/ai/queue. The defaults suit the deployments this ships
with; if you run llama.cpp with--parallel 4, or several transcription
replicas behind one URL, two new optional settings say so —
QB_LLM_CONCURRENCY_LIMITandQB_ASR_CONCURRENCY_LIMITin.env. No
migration, and nothing to change if you leave them alone. -
[webapp] Transcription is now tested end to end against a session that
never happened (#433). Recording, transcribing and attributing a session is
the part of Quest Board with the most ways to go quietly wrong, and until now
it was only ever tested against mocks — nothing checked that a real speech
server, given six speakers talking over each other for half an hour, gave
every line back to the right person. There is now a complete invented session
in the repository: six speaker tracks of computer-generated speech, thirty
minutes, every word written for the test, and every check that guards your
recordings watched actually firing against it. No real voices and no real
session content are involved, and none ever will be. This costs the
repository about 9 MB of audio; the reasoning for allowing that at all, and
the rules it lives under, are inCLAUDE.md. -
[webapp] Transcription is now told your campaign's names before it
listens (#355). Your party's character names, your wiki's people, places,
factions and artifacts, your NPC cards and any alternate names you have
recorded are handed to the speech model as a hint at the start of every
recording it transcribes. Invented names are the one thing a general-purpose
speech model has never heard, and they are also the words that matter most in
a session — and once a name is wrong in the transcript, nothing further down
can fix it, because the summary, the highlights and the wiki suggestions all
read the transcript rather than the audio. You should see fewer sessions
where a character's name is spelled three different ways, or split into two
ordinary words. Nothing to configure, and nothing changes for a campaign with
an empty wiki. The list is rebuilt from your wiki each time a recording is
processed, so a name you add tonight is in effect for tonight's session, and
a name you retire stops being suggested. Entries in the wiki trash and
suggestions the bot has made but you have not accepted are deliberately left
out. -
[webapp] Admin → Bot Settings now tells you what your AI providers cannot
do, instead of quietly working around it (#358). One additive migration
(a5b6c7d8e9fa) adds a nullable column tosummarisation_runs; nothing is
backfilled and nothing existing changes. A new panel, "What your AI
configuration can and cannot do", names the resolved provider on each side and
lists every gap in plain language with the consequence you would otherwise
just experience: an endpoint that cannot enforce a JSON schema (the big one —
events can be dropped or mis-attributed from a summary with nothing failing),
a context window too small to hold a session in one prompt and how many passes
it will take instead, a provider that does not report prompt tokens so an
over-long transcript cannot be detected, an endpoint that silently discards
the start of an over-long prompt, a provider guessed from its URL rather
than configured, and transcription without word-level timestamps so quote
timings are estimated. The "Test connection" buttons report the same thing
alongside reachability, because an endpoint can answer in 300 ms and still be
unable to do any of this. Quest Board has known all of it since v4.1.1 and
told nobody, which meant a limitation of the endpoint you picked read as a
limitation of Quest Board. Summaries also now record the gaps that applied
when they were written — shown on the session as "Summarised under a limited
configuration" — and a run on a provider that cannot enforce a schema is
marked degraded up front rather than only when its output came back visibly
broken. -
[webapp] You can now have OpenAI or Groq transcribe your sessions instead
of the Whisper server you run yourself (#128, #350). No migration, and
nothing changes unless you choose it — the bundled server stays the default
and stays a fully tested path.Admin → Bot Settingsgains a transcription
provider select and a model field alongside the endpoint URL; point it at
https://api.groq.com/openai/v1orhttps://api.openai.com/v1and leave the
model blank for that host's default, or name any other server that speaks
the same API. Read this part before you switch: with a managed provider,
every per-speaker track of every recording is uploaded to a third party. If
you self-host Quest Board precisely so your table's recordings stay on your
own machine, this setting undoes that and no amount of accuracy is worth it.
If that is not why you self-host, what you get is worth having: word-level
timings that are measured rather than estimated across each sentence, so
jumping to a quote lands on the word rather than near it; no GPU to own or
keep running; and a three-hour session transcribed in minutes on Groq's turbo
model. The "What your AI configuration can and cannot do" panel makes the
difference concrete — the bundled server lists two gaps there and this lists
none. Both services cap one request at 25 MB, which is under fourteen minutes
of audio, so tracks are cut into speech spans and stitched back onto the
session timeline automatically — including when you have silence trimming
switched off, because otherwise every track would be refused unsent. A chunk
that is refused anyway is counted, and a track that loses more than a quarter
of itself fails the run rather than handing you a transcript quietly missing
a third of the evening. See OPERATIONS.md → "Choosing a transcription
provider". -
[webapp] Quest Board now checks its own AI providers once a month, so a
dead endpoint is found before a session needs it (#359). Every AI call in
the product happens because a GM asked for something, which means a Whisper
server that stopped answering in April is discovered by a failed
transcription in July — with the recording already made. A new monthly
background task transcribes one second of silence through whichever ASR you
have configured and asks your LLM to reply with "OK", then records
{ran_at, asr_ok, llm_ok, asr_provider, llm_provider, error}under the
provider_canarykey inapp_settings, logging a warning when either side
fails. It costs a second of audio and a handful of tokens a month; it grades
nothing, retries nothing, and treats "not configured" as a blank rather than
a fault. There is no admin panel for it yet — read it with thepsql
one-liner in OPERATIONS.md → Alerting. Alongside it, every AI provider
adapter now has to pass a shared conformance suite in CI, which is what keeps
"swap your AI provider without a rewrite" true as the code moves, and keeps
the self-hosted fallback a tested path rather than a hopeful one. -
[webapp] Quest Board now records what each session costs to process, in
dollars or in GPU minutes depending on what you run (#357). One additive
migration (b6c7d8e9fa0b) adds a newsession_usagetable; nothing
existing is touched and nothing is backfilled, because there is nothing to
backfill from — none of this was ever recorded. A new panel in Admin → Bot
Settings shows the last 30 days by campaign, by month, and run by run: how
many hours of per-speaker audio went in, how much of it the silence pre-pass
removed before transcription, how long each phase took, how many tokens the
model read and wrote, and what it is estimated to have cost. Every processing
run gets a row, including the ones that failed — transcription is the
expensive half and it finishes long before most failures do, so a run that
transcribed three hours and then fell over has already been paid for, and a
retry is a second row rather than a replacement for the first. Dollar
figures are estimates from published list rates on a stated date, marked as
such on every number: no provider tells us what it actually charged, and a
model our rate table has never heard of gets no figure rather than a
plausible-looking guess. If you self-host your transcription or your LLM there
is no bill to estimate, so those runs show the compute time they occupied
instead — which is the number to size a GPU against, and the honest answer
where "$0.00" would imply the run was free. Nothing to configure; it starts
recording on the next session you process. -
[webapp] Admin → Bot Settings can now measure your hardware, and the docs
say what to expect from three named profiles (#353, #354). No migration.
A new panel, "Hardware profile", has a Run preflight button: it makes one
health round trip to your transcription endpoint and one short generation
against your LLM, times them, and reports what it found — a detected profile,
tokens per second, transcription speed relative to real time, the gaps in your
configuration, and roughly how long a 3.5-hour, five-player session will
take on your hardware. Until now the only way to learn that was to record
one and wait. The result is stored with the date it was taken, so the panel
says "as measured on …" rather than pretending to be live, and the
transcription speed is read from sessions you have actually processed rather
than from a synthetic clip — a deployment that has processed nothing sees
"not yet measured" instead of an invented number.POSTandGET /api/admin/ai/preflightare the same thing as JSON. docs/OPERATIONS.md →
"Hardware profiles" now carries a table of three profiles — CPU-only, modest
GPU, workstation-class GPU — with the model, the hardware, the expected
processing time and the measured accuracy scores for each, including the
command that produced every number, plus
docs/examples/docker-compose.cpu-llm.yml: a complete, runnable CPU-only LLM
service to drop into the stack if you have no GPU.
Changed
- [webapp] Running without a GPU is now a supported configuration rather than
a slower version of one (#353). No migration. Every timeout in the AI
pipeline used to be a fixed number chosen against a GPU — five minutes for a
structured call, ten for a transcription track, thirty seconds for the "Test
connection" button. On CPU-only hardware those are not slow budgets, they are
impossible ones, so a self-hoster without a GPU saw failures rather than
patience, with a recording already made and nothing naming the cause. A new
QB_AI_PROFILEsetting (auto,gpuorcpu) scales all of them together,
andQB_LLM_TIMEOUT_SECONDS,QB_LLM_PROSE_TIMEOUT_SECONDSand
QB_ASR_TIMEOUT_SECONDSset any of the three directly if you have measured
your own endpoint — a value you write down is used exactly as written. The
Celery time limits are derived from those timeouts rather than configured
beside them, so the limit can never be shorter than the provider calls it is
supposed to contain. Nothing changes for an existing deployment: the
default isauto, which is the GPU timings unless the new preflight has
measured your box as CPU-class, and on the GPU profile the task limits are the
same 6 hours they have always been. Two smaller consequences worth knowing:
the monthly provider health check and the admin "Test connection" button now
scale too, because a thirty-second cap on a CPU endpoint could only ever
report a working provider as broken.
Fixed
- [webapp] A paid transcription service was being reported as free compute
(#502). One additive migration (e9fa0b1c2d3e) adds three nullable
columns tosession_usage; nothing existing is touched and nothing is
backfilled. Admin → AI Usage recorded the provider you configured rather
than the one that ran — and "Detect from the URL" is the default, which
stores nothing at all. So if you had pasted an OpenAI or Groq endpoint and
left that select alone, every one of those runs was filed as self-hosted:
the page showed GPU seconds and no dollars for sessions you were being
invoiced for, which is the exact opposite of what it is for. Runs now record
the transcription service, model and endpoint they actually used, so a
managed one is counted as managed from here on. Older rows are left as they
are — the endpoint a session used six weeks ago is not recoverable now, and
guessing it would be inventing history rather than repairing it. Alongside
that: managed transcription can be priced at all for the first time (OpenAI
and Groq list rates, per model — the Groq figure is a floor, because their
ten-second minimum per request is not visible from what is recorded), the
rate table has learned the Claude 5 models the documentation recommends
instead of showing no cost for them, andmake conformance-livecan now be
pointed at a managed transcription service rather than only at the bundled
one.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v4.1.2
StableAll checks were successfulRelease / Create Forgejo release (push) Successful in 13sCI / Bot/backend version sync (push) Successful in 43sCI / Backend lint (ruff) (push) Successful in 51sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m17sCI / Frontend tests, audit, and build (push) Successful in 1m54sCI / Bot tests and audit (push) Successful in 2m12sCI / Docker image build (push) Successful in 4m51sRelease / Build and push versioned images (push) Successful in 4m40sCI / Backend migration, tests, and audit (push) Successful in 7m13sreleased this
2026-09-05 01:25:00 +00:00 | 311 commits to main since this releaseThe AI provider you chose in v4.1.1 is now honoured everywhere, including the
session summary itself. Upgrade straight past v4.1.1 if you have not already.No migrations. Nothing to rehearse, nothing to back out.
The bot contract is unchanged (still v1), and nothing under
/api/bot/*
changed at all, so backend and bot images can be upgraded independently in
either order.Under the hood every generation path now carries the configured LLM as one
object instead of five loose parameters (#484). That is invisible to operators
and changes no behaviour; it is what made the fix below a one-place change.Fixed
- [webapp] The AI provider you chose is now used for session summaries and
for the Test button, not just for everything else (#488). The provider
select added in v4.1.1 was honoured when generating structured content and
answering/ask, but two paths went on detecting the provider from the
endpoint URL and ignoring your choice: writing the session summary itself,
and Admin → Bot Settings' "Test LLM" button. This only affects you if you set
the provider to something the URL does not already give away — typically a
gateway or reverse proxy on your own domain — in which case summaries were
being sent to the wrong service with credentials in the wrong header, usually
failing outright, and the Test button reported on a connection nothing else
in the product would have made. Nothing changes if you left the provider on
"Detect from the URL".
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- [webapp] The AI provider you chose is now used for session summaries and
-
v4.1.1
StableAll checks were successfulCI / Backend lint (ruff) (push) Successful in 27sCI / Bot/backend version sync (push) Successful in 32sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m13sRelease / Create Forgejo release (push) Successful in 13sCI / Frontend tests, audit, and build (push) Successful in 1m42sCI / Bot tests and audit (push) Successful in 1m50sCI / Docker image build (push) Successful in 4m41sCI / Backend migration, tests, and audit (push) Successful in 5m56sRelease / Build and push versioned images (push) Successful in 7m12sreleased this
2026-09-05 00:19:52 +00:00 | 324 commits to main since this releaseA patch on the way to v4.2.0: the AI provider is named rather than guessed
from its URL, and a/askanswer that ran out of room now says so.No migrations. Nothing to rehearse, nothing to back out.
The bot contract is unchanged (still v1). The one
/api/bot/*change is
an optional field added to the/askresponse, which the bot tolerates when
absent, so backend and bot images can be upgraded independently in either
order.One new optional setting — the AI provider select in Admin → Bot Settings.
Nothing changes on upgrade unless you set it; see the entry below for who
should.Added
- [webapp] Admin → Bot Settings can now name the AI provider explicitly
instead of letting it be detected from the endpoint URL (#351). Detection
matches on the URL —anthropic.com,openai.com,:11434— and falls back
to llama.cpp for everything else, so if you run a gateway or reverse proxy
on your own domain, your endpoint has been treated as llama.cpp regardless of
what is behind it. The provider decides the JSON-schema format, how
truncation is detected, and the default context window, so a wrong guess
degrades summaries quietly rather than failing. Leave it on "Detect from the
URL" if that guess is already right for you — nothing changes on upgrade.
Fixed
/askno longer presents a cut-off answer as a complete one (#426). The
question-answering path never checked whether the model ran out of room, and
a truncated answer is prose that simply stops — there was nothing to notice.
It is now marked in the Discord embed. Two related fixes on the same path:
the output limit was 1024 tokens on Anthropic and OpenAI (small for an answer
drawn from ten sessions plus the wiki) and unset on llama.cpp, and is now
4096 everywhere; and on Ollama the question was being silently cut to
that provider's 2–4k default, so the model answered from a fraction of the
context it was supposed to have. If your/askanswers have felt oddly
narrow on a self-hosted Ollama, that is why.- [webapp] A silently truncated prompt is now reported on every LLM provider,
not just Ollama (#351). The check that warns when a provider consumed far
fewer prompt tokens than were sent existed, but was wired to a single code
path. llama.cpp, Anthropic and OpenAI deployments got no warning at all, so a
transcript that overflowed the context window could be summarised from its
tail with nothing in the logs to say so. If you self-host llama.cpp and see
this warning appear after upgrading, your configured context window is too
large — see the note under "Context window (tokens)" in Bot Settings.
The check stays quiet on Anthropic and OpenAI, where prefix caching makes a
low count ambiguous rather than alarming.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- [webapp] Admin → Bot Settings can now name the AI provider explicitly
-
v4.1.0
StableAll checks were successfulCI / Backend lint (ruff) (push) Successful in 46sCI / Docker image build (push) Successful in 9sRelease / Create Forgejo release (push) Successful in 12sCI / Bot/backend version sync (push) Successful in 41sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m42sRelease / Build and push versioned images (push) Successful in 54sCI / Frontend tests, audit, and build (push) Successful in 2m15sCI / Bot tests and audit (push) Successful in 2m32sCI / Backend migration, tests, and audit (push) Successful in 7m37sreleased this
2026-09-01 18:11:30 +00:00 | 336 commits to main since this releaseData Durability & Recovery. Nothing you have made can be destroyed by a single
click, a crashed worker, or a background job you never saw run.⚠️ Two migrations —
e3f4a5b6c7d9(wiki entry soft delete) and
f4a5b6c7d8ea(campaign soft delete). Both add nullable columns and partial
indexes only; no data is rewritten, and both were verified to apply, roll back
and re-apply cleanly. For a major upgrade take a snapshot first —make migrate-safedoes the preflight, backup and migrate in one step.The bot contract is unchanged (still v1), so you can upgrade the backend and
bot images independently if you need to. Everything added to/api/bot/*this
release is optional on both sides.⚠️ Deleted things now occupy disk for 30 days. Campaigns and wiki entries go
to a trash rather than being removed immediately, and are purged by a daily job
once the grace period elapses. The window is enforced with a 7-day floor in
code — configuring a shorter one is clamped up, deliberately, because these are
artefacts nobody can reconstruct.New optional setting:
RESTART_POLICYin.env. Leave it unset unless
something else on your host owns boot ordering; see.env.example.Added
-
[webapp] The container restart policy can now be set per host. A new
optionalRESTART_POLICYin.env. Left unset it behaves exactly as before
— the stack comes back on its own after a reboot — so no existing install
needs to change anything.Set it to
on-failureonly if something else on your host is responsible for
starting stacks in order, such as a boot sequencer pacing many Compose stacks
across shared disks. It still restarts containers that crash; it only stops
Docker bringing the stack up by itself when the daemon starts. -
[webapp] Deleting a campaign can now be undone, and is much harder to do by
accident. Deleting a campaign removed everything in it — every session,
transcript, summary, wiki entry, note and ledger entry — immediately and
permanently, and the only thing standing in the way was a browser dialog
that any GM on the campaign could dismiss on reflex. There was no undo, no
pause, and nothing suggesting you take a copy first.Deleting now asks you to type the campaign's name, offers to export the
campaign before you go ahead, and puts it in a trash for 30 days rather than
destroying it. Deleted campaigns appear at the bottom of your dashboard
with a countdown, and restoring one brings back everything exactly as it was.
Nothing is deleted for real until the 30 days are up.Archiving is unchanged and still means what it always did — a campaign moved
out of the active list that you can still read, browse and export. -
[webapp] Deleting a wiki entry can now be undone. Deleting an entry used
to remove it permanently and immediately — along with its entire edit
history, which went in the same instant. A GM who deleted what looked like a
duplicate, and realised afterwards it was the real entry with months of lore
in it, had nothing at all to go back to.Deleted entries now sit in a trash for 30 days, where a GM can put them back
with everything intact. Admin → your campaign's wiki trash lists what is
in there and when each one stops being recoverable. -
[webapp] A wiki entry can be rolled back to an earlier version. The edit
history has always been visible and never usable: recovering an older draft
meant copying it out of the history view and pasting it back in by hand, and
the sidebar and timeline fields could not realistically be restored at all.
There is now a restore button on each version.Restoring saves the current state first, so if you restore the wrong version
you can simply restore your way back.
Changed
-
[bot] Recordings now take a sixth of the disk space they used to. The bot
was storing audio in Discord's format and converting it after the session
ended. It now converts as it records, so a six-hour session occupies about
0.7 GB per speaker instead of 4.2 GB, and there is no conversion step to wait
through when you stop recording.Audio quality is unchanged — the conversion filters the audio properly on the
way down, the same as the old post-processing step did. Recordings made by an
older version are still readable, and are converted the way they always were.
Added
-
[webapp] Backups can now leave the machine they are protecting. Until now
the scheduled backup wrote a database dump to a folder on the same host and
disk as the database itself. Losing that host — a failed disk, a
deprovisioned server, ransomware — took the database, the recordings, and
every backup of the database at the same moment.Set an off-host destination in Admin → Backups and each backup is copied
there as well. It uses rclone, so the same setting works for S3, Backblaze,
a NAS over SFTP, and most other storage; your storage credentials live in
rclone's own configuration and are never stored by Quest Board.A backup that cannot be copied off-host is now reported as failed, even
though the dump itself worked. A backup sitting on the disk it was supposed
to protect is not a backup, and calling it a success is how an instance finds
out it has none at the worst possible moment. The local copy is still kept. -
[webapp] Recordings can be included in backups. A database dump cannot
contain audio files. Switch on Include audio recordings and each backup
gains a compressed archive of them.It is off by default because recordings are far larger than a dump — check
your disk space before enabling it. But if you keep recordings at all, you
probably want it: a session whose transcription failed has no transcript, so
its audio is the only record that the session happened.
Fixed
-
[webapp] Granting admin by email no longer crashes when you have two
accounts. Accounts are identified by which login provider you came from,
not by your email address, so the same person can hold more than one — most
commonly after copying a production database onto a test machine, where your
test login is a separate account that was never made an admin.make set-admin EMAIL=…used to fail with a raw database error in exactly
that situation. It now lists the matching accounts, shows which one already
has admin, and tells you how to pick the right one. Nothing is changed until
you do. The operations guide has a section on this, since it looks like lost
access when it is not. -
[docs] The operations guide told you to leave recordings out of your
backups. It described the audio volume as scratch space that "does not need
to survive a restore". That stopped being true some time ago — recordings
stay until a GM approves them, a campaign can be set to keep them forever,
and a failed transcription leaves the audio as the only copy of anything.
Anyone who followed that advice would have lost every recording on the
instance during a restore. The guide now describes what the volume actually
holds, and how to back it up and restore it.
Changed
-
[webapp] Build tooling:
@vitejs/plugin-react6.0.5 → 6.1.1. Purely a
build-time dependency; nothing about the app itself changes.The update had been stuck since it was first offered, because 6.1.1 declares
an optional peer whose own optional peer wants Babel 8 while the project is
on Babel 7 — enough for npm to refuse the update even though none of those
packages are actually installed. A singleoverridesentry stops npm chasing
that chain, which unblocks it without dragging in 61 unrelated dependency
bumps as the alternative would have.
Fixed
-
[bot] A recording interrupted by a crash now finishes itself on the next
start. If the bot went down mid-session, the audio it had already captured
was left as raw working files, and there was no way to get it to Quest Board
short of someone converting it by hand — the earlier fix stopped that audio
being deleted, but stopped there. A recording that was interrupted an hour
into a four-hour session sat on disk indefinitely.On its next start the bot now converts what it captured and hands it over for
transcription, and the session appears as normal. Nothing is reconstructed:
the tracks were already recorded against a single session clock, so the
timings and who-said-what are the real ones. Speakers are named the way they
are on any other recording — server nickname first, so a player whose
nickname is their character's name still shows up as that character.It errs towards leaving the audio alone. If it cannot reach Quest Board, or
the session's group has no Discord server attached, it changes nothing and
tries again next start. If the session already has a transcript it keeps the
take but will not overwrite what is there.
Added
-
Recordings left behind by a deleted campaign are now reclaimed. Deleting
a campaign removed its sessions but left every recording on disk, and nothing
ever cleaned them up — one sweep only looks at sessions that still exist, and
the other treats a handed-off recording as permanently off-limits. On a busy
instance that is storage that only ever grows.A daily job now removes recording folders that no session refers to, after
giving each a week's grace, and records what it removed in the audit log.It is deliberately cautious about it: it only ever touches folders it
recognises as its own, it leaves anything recent alone, and if the session
list comes back empty it refuses to do anything at all — an instance with
recordings on disk and no sessions has a database problem, and treating that
as "everything is an orphan" would delete every recording on the volume.
Added
-
The bot event queue's size is configurable, and reports whether it is big
enough. Notifications for the bot wait in a queue capped at 10,000 — enough
for one group, but every group on an instance shares it. A bot down long
enough for its unread notifications to age out loses them; the queue is what
makes delivery reliable, so there is no catching up afterwards.BOT_EVENTS_MAXLENnow sets that cap, and a new
bot_events_trimmed_unread_totalmetric reports whether notifications are
actually being lost — so the number can be sized from evidence instead of
guessed at. It reads nothing rather than zero when there is not yet enough
data to tell, since "nothing to measure" should not look like "nothing
wrong".
Fixed
-
Unsaved writing is much harder to lose. Three surfaces could discard
work with no warning:- The Session Shelf's tonight-notes — the field you type into during
play — only saved when it lost focus. Closing the tab with the cursor
still in it lost everything since the last time you clicked away. It now
saves as you type, a moment after you stop. - The Beat Planner discarded your draft when you switched sessions in
its own dropdown. It now asks first. - Session summary and transcript editors, the Beat Planner and the Shelf
all now warn before a tab close or reload with unsaved text.
Note that the browser shows its own generic "Leave site?" wording for that
warning — it does not let an app supply the message. Warning on in-app
navigation needs a change to how the app routes pages and is tracked
separately. - The Session Shelf's tonight-notes — the field you type into during
Fixed
-
[bot] A vote that doesn't reach Quest Board now tells you. Reacting to a
poll in Discord leaves your emoji on the message whether or not the vote was
actually recorded — so a brief backend outage during a voting window meant
several people believed they had voted when they hadn't. Voting would then
close on the tally that happened to arrive, confirm a time, and nobody would
ever learn a vote had gone missing.A reaction that fails to register now gets you a DM saying so and what to do,
while there is still time to fix it. If your DMs are closed, the bot marks the
message with ❗ instead. Successful reactions stay silent.The same applies to RSVPs and summary feedback, and to reactions on a poll
Quest Board has stopped tracking (an old poll, or a bot restart) — previously
indistinguishable from reacting to any other message, and equally silent.
Fixed
-
A departing member's shared notes are kept when someone else has already
left. Deleting an account anonymises what you wrote onto a shared "Deleted
user" record. If a second person later deleted their account and had written
on the same session, their note collided with the first one and was thrown
away instead — so the campaign quietly lost one person's writing, in a way
indistinguishable from it never having been written.Both accounts' notes are now kept together on that session.
Votes and attendance still cannot be merged — one vote per person is the
point of the rule, and there is only ever one "Deleted user" — so a second
departure genuinely does reduce those tallies by one. That was already true;
what is new is that it is now counted and recorded in the audit log rather
than happening silently.
Fixed
-
The campaign journal works for campaigns with more than one GM. It
errored outright for every member of a co-GM campaign — the page looked as
though everyone's notes had vanished, when in fact it was crashing while
loading them. Co-GMs are supported everywhere else in the app; this one query
assumed there was exactly one.Both GMs' public notes now appear, each labelled with who wrote it, so two
people's notes under one "GM Notes" heading no longer read as one person
contradicting themselves.
Fixed
- A queue hiccup no longer leaves lore generation stuck at "queued…". The
session was marked queued before the job was actually handed to the queue, so
a momentary blip left it claiming work that nobody would ever pick up — and
because it looked busy, re-running it was refused. The failure is now
reported straight away, as a failure, with the re-run available immediately
rather than after a wait.
Added
-
You are told before a recording is deleted for good. Audio reaches
deletion through the trash, and until now nothing announced that the clock
was running — the countdown existed only in the admin console, so noticing
required knowing to go and look there.Quest Board now posts to your campaign's Discord channel a few days out,
saying which recording, exactly when, and how to keep it. Once per trip
through the trash, not daily: a message that arrives every morning is one you
stop reading. The same countdown appears on the session page itself.It says explicitly when a session's transcription failed, because that is
the case where the recording is the only copy of anything — no transcript was
ever produced — and losing it loses the session rather than just its media.
Fixed
-
"Append to beat notes" no longer wipes them. The Workbench's session-prep
tool composed its append against a field the campaign session list has never
returned, so the existing notes always read as empty — the generated sheet
replaced whatever the GM had written, and the tool reported "Appended to
beat notes." A GM with an evening of hand-written prep lost it, and was told
the opposite. There is no undo for beat notes.The same root cause left the beat-notes editor opening blank no matter
what was stored, so typing there and saving replaced the stored notes too.Beat notes now have a read path and both places use it. If the current notes
cannot be loaded, appending fails and says so rather than composing against
nothing — a button labelled Append must never be able to replace. The notes
stay GM-only: the read is gated the same way the transcript is, so a player
cannot see the GM's plan for the evening. -
Nothing the app generates will overwrite what you wrote. Two paths did,
both silently, and neither keeps history — so the work was not merely
replaced, it was gone.The campaign storyline was rebuilt from raw session summaries every time
any session in the campaign was transcribed, re-transcribed, or had its
summary touched. An hour spent turning AI summaries into a readable chronicle
survived until the next recording. A hand-edited storyline is now left alone;
the "Regenerate" button still rebuilds it, because that is you asking.Picking a canonical name — settling what to call a tavern — re-summarised
the whole session and replaced the summary with it. A GM who had corrected
the AI's mistakes by hand got those mistakes back, from an action that only
claimed to name something. Naming no longer touches the summary at all; the
name still applies to everything generated afterwards, which is what it was
for.The two compounded: one canonical-name pick could destroy a corrected summary
and the campaign chronicle, because the first rewrite triggered the second.
Fixed
-
Two people editing the same thing no longer silently erase each other.
Notes, summaries and planning notes were saved last-write-wins with no check
of any kind, so whoever clicked Save second won and the other edit was gone —
no error, no warning, nothing in a log.The case that actually bit: you open a session on a laptop, type notes from
your phone over Discord all evening (which correctly appends them), then click
Save on the laptop out of habit at the end of the night. The laptop still held
the note as it looked before the session, and saving replaced the whole
evening with it.Saves now carry the version they were based on. If the thing changed
underneath you, the save is refused and you are told to reload rather than
finding out later that something is missing. Nothing is auto-merged and
nothing you typed is thrown away — you choose what to keep.Appending instead of replacing was the other option and is worse for a text
editor: fixing a typo would duplicate the whole note rather than correct it.
Refusing the write loses nothing either way.
Added
-
Four actions that could only be taken once can now be taken back.
Marking a session complete, cancelling one, approving a summary and having a
wiki draft stall were all one-way doors: no endpoint could reverse them, so a
mis-click or a lost worker left editing the database by hand as the only
repair.- Undo complete and Reinstate return a session to where it was. A
cancelled session goes back to confirmed if it still holds a time and to
proposed if it does not. Both are recorded in the audit log, because they
rewrite something players and Discord have already seen. - Withdraw approval lets a GM correct a summary and publish the
correction. Approving is what posts the summary to Discord, and it could
only happen once — so an error spotted after approval was permanent in the
channel no matter how many times the text was fixed in the app. Quotes are
unpublished from players while the approval is withdrawn, mirroring what
approving did. - A wiki draft stuck generating can be released without discarding it.
Discarding was the only exit and it deleted the draft outright, taking
every inline edit and every round of iteration with it. Releasing marks the
draft failed and changes nothing else, which is enough — iterating,
approving and discarding are all reachable from there. Guarded so a draft
that may still be running is left alone.
What none of these do is pretend the side effects never happened. A Discord
message already delivered is not recalled, shelf notes already merged into a
wiki entry's GM notes stay merged, and withdrawing an approval does not pull
the audio back out of the trash — that grace window is deliberately bounded
(see the retention change in v4.0.1) and an approve/withdraw cycle must not
be able to extend it. - Undo complete and Reinstate return a session to where it was. A
Fixed
-
[webapp] A stalled lore run no longer hides its own retry button. The
re-run action was hidden for every in-progress state, so a run whose worker
died left the session showing "Extracting lore candidates…" with nothing to
click. Once a run has gone half an hour without any sign of progress, the
action comes back, labelled so it is clear the run is being replaced rather
than queued alongside. A run that is merely slow keeps reporting progress at
each stage and is left alone.Closing the same hole from the other side: the endpoint behind that button
refused a second run only while the first was queued, not while it was
extracting or matching. Triggering it during either of those stages started a
second extraction over the same transcript — duplicate proposals, and
double the tokens. Nothing hit it in practice only because the button was
hidden, which is exactly the rule being relaxed here, so both halves ship
together. -
[webapp] A restart during processing no longer strands a session forever.
Celery acknowledged a job the moment it was received, so a worker that died
mid-run took the job with it — nothing was redelivered and nothing was logged.
The session had already been flipped to "Processing…" on intake, and both
retry buttons refuse to act on a session in that state, so the only way out
was an UPDATE against the database by hand. An ordinary
docker compose up -d --buildduring a three-hour transcription was enough to
cause it.Work is now acknowledged on completion, so the queue redelivers whatever a
dying worker was holding. Two safeguards ship with it, because redelivery
alone would turn a job that kills its worker into a job that kills every
worker in turn: a six-hour limit (above the six-hour recording maximum, so a
legitimate full-length transcription is never cut off) converts that into an
ordinary failure, and each worker now reserves one long job at a time rather
than a batch.An hourly watchdog is the backstop for anything redelivery cannot reach.
A job with no progress for eight hours is marked failed with a plain
explanation, which puts the retry button back within reach; audio, lore
extraction, entry drafts and Workbench generations are all covered. Nothing is
deleted, the audio path is left intact, and a job still legitimately running is
left alone. Transient network failures during transcription now retry twice on
their own before any of this applies. -
[bot] The bot no longer deletes recordings it told you were saved. Its
startup cleanup removed any session directory that was unmarked and more than
an hour old, and the "this is safe" marker was only written after a
successful upload. So a failed upload, a crash around the moment of upload, a
crash mid-recording, or the backend declining to overwrite an existing
transcript all left a real recording looking like abandoned scratch — and the
next restart destroyed it. In two of those cases the bot had just told the GM
in the channel that the recording was safe.Deletion now requires proof there is nothing to lose: anything holding a
non-empty track is kept, marked or not, and reported in the log instead. A
recording whose upload failed is also retried automatically on the next
start, replaying the original request rather than a reconstruction, so it
usually repairs itself without anyone noticing.Raw capture files from a crash mid-session are likewise kept rather than
deleted. They still need finalising by hand — the log names the path — but
they are no longer thrown away.
Changed
-
Scheduled database backups are now on by default, and land somewhere you
can see. They shipped disabled, which meant an instance only had backups if
its operator went looking for the setting — production ran for months with no
scheduled dump ever taken. They now run nightly out of the box, writing to
./backupsbeside yourdocker-compose.ymlinstead of a Docker named
volume buried under/var/lib/docker. That is the difference between a file
your own backup tooling already sweeps up and one it never sees. An install
that has deliberately turned backups off keeps that choice.Upgrading: your existing dumps are still in the
quest-board_backups
volume. Nothing moves or deletes them —docs/OPERATIONS.mdhas a one-liner
to copy them across when you are ready.Still not covered: the media and audio volumes, and any off-host copy. A
backup on the same disk survives a bad migration, not a dead machine. Point
your own tooling at./backupsin the meantime.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v4.0.1
StableAll checks were successfulCI / Frontend tests, audit, and build (push) Successful in 1m18sCI / Backend lint (ruff) (push) Successful in 33sCI / Docker image build (push) Successful in 13sRelease / Create Forgejo release (push) Successful in 19sCI / Bot/backend version sync (push) Successful in 59sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 2m41sRelease / Build and push versioned images (push) Successful in 1m26sCI / Backend migration, tests, and audit (push) Successful in 4m54sCI / Bot tests and audit (push) Successful in 4m38sreleased this
2026-08-29 07:01:42 +00:00 | 407 commits to main since this releaseRaw audio is never destroyed without a recovery window, and the secrets at
rest are both readable and actually encrypted.No migrations. Nothing to rehearse, nothing to back out.
⚠️ Rebuild, do not pull. The backend image changes (it now pins its
PostgreSQL client to the database's major version), so deploy with
docker compose up -d --build. A pull-and-restart will not apply it.⚠️ Audio now occupies disk for longer. Every retention mode reaches
deletion through the trash, so audio survives its retention window plus the
trash grace (minimum 7 days) rather than being removed the moment the window
expires. Under the previous default it was deleted during processing and
occupied nothing at all.If the startup log names a setting as having been stored in plaintext,
rotate that credential. Re-encrypting protects it going forward; it does not
un-expose a value that was already readable in every backup taken until then.Security
- Sensitive settings stored in plaintext are now encrypted on startup.
Encryption only ever happened on write, and rows written before it was
introduced were passed through on read forever — so a credential that had
never been rotated since could still be sitting in clear text in the database
and in every backup, with nothing reporting it. Any such row is re-encrypted
at startup and logged by name. If a row is reported, rotate that credential
— it was readable in every backup taken until now.
Fixed
-
Database backups are restorable again. The backup ran
pg_dumpfrom the
backend image while a restore runspg_restoreagainst the database image,
and the two were on different major versions — so every dump was rejected by
the server's own tooling withunsupported version (1.16) in file header,
while the job reported success and recorded it as one. The backend image now
pins its client to the database's major version, a test asserts the two stay
in step, and the backup refuses to run rather than writing a dump it knows
cannot be restored. Existing dumps written before this are not restorable
without a matching newer client — replace them by taking a fresh backup
after upgrading.
crashes on first use.** Sensitive settings are encrypted with a key derived
fromSECRET_KEY, which lives in.envand is not part of a database dump —
so restoring elsewhere left them unreadable, and reading one raised a raw
cryptography error out of the settings layer instead of taking the
"not configured" path every caller already handles. Unreadable settings now
degrade to unconfigured, are reported once and clearly, and are listed at
startup so the problem is found during the restore rather than days later
when a session fails to summarise.docs/OPERATIONS.mdnow states that
SECRET_KEYmust be backed up alongside the database. -
Admin can distinguish "never configured" from "configured but
unreadable". They render identically in every settings form, which is how a
restore produced a system that looked complete and was not. -
Raw audio is no longer deleted the moment a transcript exists. The
recommended retention mode destroyed the per-speaker WAVs inline during
processing, on the reasoning that there was nothing left to reprocess. That is
wrong in the case that matters, because the pipeline itself changes: v4.0.0
rewrote transcription, extraction, validation and composition, and no real
recording survived to validate any of it against. The mode is now
trash_after_processing— the audio moves to the trash, stays recoverable
there, and is deleted only once the grace has elapsed. The old
delete_after_processingname is still accepted from existing configuration
and normalised on read, so no action is needed on upgrade. -
The trash grace is a floor, not a default. A configured value below seven
days is clamped up, so a misconfiguration cannot reintroduce same-day loss. -
An expiring retention window no longer deletes directly. Under
retain_daysthe sweeper now moves expired audio to the trash rather than
removing it, so every mode reaches deletion through the same recoverable
window. Audio therefore lives for the configured window plus the trash
grace — budget disk accordingly. -
A trashed recording is no longer deleted early by the retention window.
The grace-period sweep and the policy sweep raced, and the shorter one won: a
recording trashed today, whose window had already passed, was removed on the
next scheduled run without its grace ever being observed. -
A silent speaker is no longer reported as having spoken. The fix that was
supposed to report a backend-dropped silent track as present-but-uncaptured
was overridden, because the same person was still counted among the captured
speakers and that assignment won. It only affected recordings the current bot
did not produce — which is precisely the case the guard existed for.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Sensitive settings stored in plaintext are now encrypted on startup.
-
v4.0.0
StableAll checks were successfulCI / Frontend tests, audit, and build (push) Successful in 1m17sCI / Backend lint (ruff) (push) Successful in 37sCI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m16sCI / Docker image build (push) Successful in 17sRelease / Create Forgejo release (push) Successful in 18sCI / Bot/backend version sync (push) Successful in 40sCI / Bot tests and audit (push) Successful in 2m2sRelease / Build and push versioned images (push) Successful in 2m44sCI / Backend migration, tests, and audit (push) Successful in 9m18sreleased this
2026-08-28 23:56:11 +00:00 | 412 commits to main since this releaseThe summary is no longer something the model asserts. It is composed from events
that have each been checked against the transcript in code — so chronology is a
sorted()and attribution is a lookup, not a model behaviour.Six migrations (
a2b3c4d5e6f8,b3c4d5e6f7a9,c4d5e6f7a8b0,
d5e6f7a8b0c1,e6f7a8b0c1d2,f7a8b0c1d2e3), applied in order by the
migrateservice. All additive. Only one backfills:c4d5e6f7a8b0copies each
member's existing character into the newcampaign_characterstable and leaves
the old columns in place, so a downgrade is twoDROP TABLEs and no data is at
risk. Take a snapshot before upgrading anyway — six at once is more than
usual, and the automatic pre-migration backup only helps if you know it is there.Rehearsed against a copy of production data before release: all six apply
cleanly, row counts unchanged, backfill exact.BOT_CONTRACT_VERSIONstays 1, so you can upgrade one image at a time.
BOT_EXPECTED_APP_VERSIONmoves to 4.0.0, which only logs a warning on
mismatch. Upgrading both together is still recommended.If you run with VAD disabled, transcription request volume changes. That
path used to send every speaker's track in one/transcribe/sessionrequest;
it now sends one request per speaker to/transcribe, the same endpoint the
VAD path already uses. Attribution can no longer depend on a label the server
echoes back — see below. VAD is on by default, so most installs are unaffected.Nothing is backfilled into the new structure. Transcript segment rows,
beats and summarisation runs exist only for sessions recorded after upgrading.
Old sessions keep working; they simply have no provenance to show, and a
plausible reconstruction would look authoritative without being so.Added
- Structured, verified session summaries. Extraction emits timestamped
events with cited transcript lines, constrained by a decoding grammar where
the provider supports one. A pure-code validator then checks every citation
resolves, every claimed actor actually spoke a cited line (or was named by the
GM in one), and every event's time range agrees with its own evidence. Only
events that pass reach the prose. The check costs nothing and behaves
identically on a 9B local model and a frontier hosted one, which is what keeps
the accuracy floor independent of model size. - The events behind a summary are kept and readable.
GET /api/sessions/{id}/summarisation-runs(GM only) returns each run with its
beats, their cited lines resolved to text, which failed validation and why,
what the validator repaired, and timestamp anchors back into the transcript. A
reprocess appends a run rather than replacing one, so two summaries of the same
audio can be compared. - Transcript segment rows. One row per utterance, with the track owner on it.
Member erasure, lore chunking and per-speaker QA read these instead of
re-parsing the rendered text, so the display format stops being a data
contract.GET /api/sessions/{id}/transcript-qa/speakersreports per-speaker
segment counts and spoken duration. - A player can run several characters (
campaign_characters), with a
per-session override so correcting a mid-campaign swap does not relabel old
recordings when they are reprocessed. - A per-campaign spoken language. Unset means detect, which stays the
default — a default of English would silently mistranscribe every non-English
table until someone found the setting. - A declared context window per provider, set in Admin → Bot Settings, used
to size every prompt in the pipeline. - A golden-transcript eval harness (
python -m evals) scoring coverage,
attribution, chronology and validation rate, with--repeatbecause one run
of a sampled pipeline is not a measurement. - Admin → summarisation mode, a one-release escape hatch back to the
pre-4.0.0 prose path if the verified one misbehaves on your data. It defaults
to the verified path and logs a warning on every session summarised without
checks.
Fixed
- A session that ran long on the clock but short on talking was thrown away.
Every track is padded with silence to the full length of the recording, and
nothing stops a recording when the voice channel empties — so a table that
played for two hours and forgot to stop for another fifty produced a complete,
correctly attributed transcript that the coverage check then rejected for
covering "only" 58% of the recording. It was measuring speech against wall
clock, which is not a coverage measure of anything. Worse, it ran after
transcription, so the work was already done, and it failed identically on
every retry while blaming the capture clock. Coverage is now measured against
how far into the session the tracks actually carry audible speech. - A second recording destroyed the first one's work. A GM who stopped at the
break and started again overwrote the transcript, any edits made to it, every
curated highlight, and the source audio — so no reprocess could recover it.
Intake now refuses with a clear message, curated highlights survive a
reprocess, and a second take is stored beside the first instead of over it. - A quiet player could be deleted from a full-length session. The silence
guard sampled 32 seconds of a 77-minute track, so it only reliably detected a
speaker whose audio contained one unbroken stretch longer than ~145 seconds. A
player who says a few words every several minutes fell between the probes and
their entire track was dropped before transcription, with the pipeline
reporting success. Detection is now an exhaustive scan, and a speaker dropped
by the backend is reported to the GM as present-but-uncaptured rather than
vanishing into a log. - The transcription server's echoed speaker label could silently relabel a
whole track. Attribution is now taken from the file that was sent and never
from the response, on every path. - Ollama installs summarised a fraction of each session.
num_ctxwas only
set when a caller happened to pass a window, and almost none did, so requests
fell back to Ollama's 2-4k default and were silently truncated. Every request
now declares one. - A summary cut short by the model's output limit looked complete. It is
still returned — half a summary a GM can read and correct beats none — but the
truncation is now recorded and surfaced. - Constrained JSON output was doing nothing on llama.cpp. The documented
request shape is silently ignored by real servers; the working form is now sent
alongside it, and a response that needed repair is recorded as degraded. - Structured extraction sampled at random, so summary coverage varied up to
eightfold run to run on the same recording. It is greedy-decoded now. - Compose silently discarded about a quarter of the verified events it was
given. Missing events are detected and re-requested by name. - Quotes on the quote board carried a speaker and timestamp the model
asserted; both now come from the utterance the words were found in. - Four prompts grew without limit —
/askin particular assembled every
approved lore entry at full length, forever. - Erasing a member matched their name in the transcript text. It now works
from the track owner recorded per utterance. - A bare Discord ID could appear as a speaker name and be read as a person.
Changed
- Speaker resolution happens entirely in code before the model sees anything.
The prompt no longer asks it to rename players to characters or to guess which
GM lines were an NPC. - Long transcripts are summarised in ordered windows sized from the declared
context window, rather than sent whole and hoped for. - Same-speaker runs are merged before prompting, cutting prompt size
substantially without losing attribution. - Overlapping speech renders as a time range, but only on the lines that
actually overlap. - "No LLM configured" now behaves the same way everywhere instead of eight
different ways.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Structured, verified session summaries. Extraction emits timestamped