• v4.3.0 9dd77986b5

    v4.3.0
    All checks were successful
    CI / Bot/backend version sync (push) Successful in 42s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Backend lint (ruff) (push) Successful in 55s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m29s
    CI / Frontend tests, audit, and build (push) Successful in 2m16s
    CI / Bot tests and audit (push) Successful in 2m36s
    CI / Docker image build (push) Successful in 4m13s
    Release / Build and push versioned images (push) Successful in 3m53s
    CI / Backend migration, tests, and audit (push) Successful in 10m13s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m47s
    Stable

    claude-bot released this 2026-09-06 09:51:10 +00:00 | 113 commits to main since this release

    Product 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, a state field on link-status, a reason field 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_VERSION is 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 /api is 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/html responses 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>/recording address 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 /next existed was to be
      told by somebody who already knew. /help now 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 /help did 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/…/recording address 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's session_prep and npc_voice at 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
      /edit URL 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 /link again, got the same silence, and went
      round a loop that /link could never resolve, because the fix was
      /unlink on 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 start knows 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 — /next shows one session
      and omits its id, /history lists 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 stop no 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 stop insisting 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 stop answers 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, /quests and /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 /ask on 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
  • v4.2.3 64b5e15920

    v4.2.3
    All checks were successful
    CI / Docker image build (push) Successful in 24s
    CI / Backend lint (ruff) (push) Successful in 36s
    CI / Bot/backend version sync (push) Successful in 38s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 50s
    Release / Create Forgejo release (push) Successful in 14s
    CI / Bot tests and audit (push) Successful in 1m36s
    CI / Frontend tests, audit, and build (push) Successful in 1m44s
    Release / Build and push versioned images (push) Successful in 4m52s
    CI / Backend migration, tests, and audit (push) Successful in 6m46s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 17m32s
    Stable

    claude-bot released this 2026-09-06 02:20:47 +00:00 | 171 commits to main since this release

    The 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 (e9fa0b1c2d3e remains 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_VERSION is 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 the lg breakpoint, 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_summarised event 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 no APP_URL set: 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 /health instead of
      crash-looping. .env.example and 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 CampaignNotes page 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 in CampaignHighlights.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
  • v4.2.2 5d3fa863e6

    v4.2.2
    All checks were successful
    Release / Create Forgejo release (push) Successful in 12s
    CI / Bot/backend version sync (push) Successful in 45s
    CI / Backend lint (ruff) (push) Successful in 54s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m28s
    CI / Frontend tests, audit, and build (push) Successful in 1m55s
    CI / Bot tests and audit (push) Successful in 2m9s
    CI / Docker image build (push) Successful in 4m11s
    Release / Build and push versioned images (push) Successful in 4m25s
    CI / Backend migration, tests, and audit (push) Successful in 9m59s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m56s
    Stable

    claude-bot released this 2026-09-05 17:26:13 +00:00 | 186 commits to main since this release

    v4.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 (e9fa0b1c2d3e remains the head).

    Bot API contract v1, unchanged. BOT_EXPECTED_APP_VERSION is 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
  • v4.2.1 504b1d943e

    v4.2.1
    All checks were successful
    CI / Bot/backend version sync (push) Successful in 43s
    CI / Backend lint (ruff) (push) Successful in 54s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m25s
    CI / Frontend tests, audit, and build (push) Successful in 1m51s
    CI / Bot tests and audit (push) Successful in 2m9s
    CI / Docker image build (push) Successful in 4m30s
    Release / Build and push versioned images (push) Successful in 4m41s
    CI / Backend migration, tests, and audit (push) Successful in 9m24s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 15m14s
    Stable

    claude-bot released this 2026-09-05 16:26:30 +00:00 | 193 commits to main since this release

    Superseded 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 (e9fa0b1c2d3e remains 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_VERSION is 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. full is 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 --verify checks that they are, and the
      expected attendance outcome of a live run is written down.
    Downloads
  • v4.2.0 f7aa15e457

    v4.2.0
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 27s
    CI / Bot/backend version sync (push) Successful in 22s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Bot tests and audit (push) Successful in 1m26s
    Release / Build and push versioned images (push) Successful in 46s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 2m1s
    CI / Frontend tests, audit, and build (push) Successful in 2m42s
    CI / Docker image build (push) Successful in 4m24s
    CI / Backend migration, tests, and audit (push) Successful in 9m55s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m20s
    Stable

    claude-bot released this 2026-09-05 06:22:55 +00:00 | 210 commits to main since this release

    AI 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 migrationsa5b6c7d8e9fa (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-safe does 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 .env settings, 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 pin QB_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; see CLAUDE.md for 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_settings table; 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)" in docs/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 nullable words column to transcript_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 cached GET /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. /ask and 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_LIMIT and QB_ASR_CONCURRENCY_LIMIT in .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 in CLAUDE.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 to summarisation_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 Settings gains a transcription
      provider select and a model field alongside the endpoint URL; point it at
      https://api.groq.com/openai/v1 or https://api.openai.com/v1 and 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_canary key in app_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 the psql
      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 new session_usage table; 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. POST and GET /api/admin/ai/preflight are 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_PROFILE setting (auto, gpu or cpu) scales all of them together,
      and QB_LLM_TIMEOUT_SECONDS, QB_LLM_PROSE_TIMEOUT_SECONDS and
      QB_ASR_TIMEOUT_SECONDS set 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 is auto, 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 to session_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, and make conformance-live can now be
      pointed at a managed transcription service rather than only at the bundled
      one.
    Downloads
  • v4.1.2 bbf25d9c67

    v4.1.2
    All checks were successful
    Release / Create Forgejo release (push) Successful in 13s
    CI / Bot/backend version sync (push) Successful in 43s
    CI / Backend lint (ruff) (push) Successful in 51s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m17s
    CI / Frontend tests, audit, and build (push) Successful in 1m54s
    CI / Bot tests and audit (push) Successful in 2m12s
    CI / Docker image build (push) Successful in 4m51s
    Release / Build and push versioned images (push) Successful in 4m40s
    CI / Backend migration, tests, and audit (push) Successful in 7m13s
    Stable

    claude-bot released this 2026-09-05 01:25:00 +00:00 | 311 commits to main since this release

    The 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
  • v4.1.1 5b1bd2a427

    v4.1.1
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 27s
    CI / Bot/backend version sync (push) Successful in 32s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m13s
    Release / Create Forgejo release (push) Successful in 13s
    CI / Frontend tests, audit, and build (push) Successful in 1m42s
    CI / Bot tests and audit (push) Successful in 1m50s
    CI / Docker image build (push) Successful in 4m41s
    CI / Backend migration, tests, and audit (push) Successful in 5m56s
    Release / Build and push versioned images (push) Successful in 7m12s
    Stable

    claude-bot released this 2026-09-05 00:19:52 +00:00 | 324 commits to main since this release

    A patch on the way to v4.2.0: the AI provider is named rather than guessed
    from its URL, and a /ask answer 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 /ask response, 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

    • /ask no 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 /ask answers 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
  • v4.1.0 68ec94ffef

    v4.1.0
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 46s
    CI / Docker image build (push) Successful in 9s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Bot/backend version sync (push) Successful in 41s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m42s
    Release / Build and push versioned images (push) Successful in 54s
    CI / Frontend tests, audit, and build (push) Successful in 2m15s
    CI / Bot tests and audit (push) Successful in 2m32s
    CI / Backend migration, tests, and audit (push) Successful in 7m37s
    Stable

    claude-bot released this 2026-09-01 18:11:30 +00:00 | 336 commits to main since this release

    Data 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 migrationse3f4a5b6c7d9 (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-safe does 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_POLICY in .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
      optional RESTART_POLICY in .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-failure only 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-react 6.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 single overrides entry 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_MAXLEN now sets that cap, and a new
      bot_events_trimmed_unread_total metric 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.

    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.

    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 --build during 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
      ./backups beside your docker-compose.yml instead 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.md has 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 ./backups in the meantime.

    Downloads
  • v4.0.1 db77a4e657

    v4.0.1
    All checks were successful
    CI / Frontend tests, audit, and build (push) Successful in 1m18s
    CI / Backend lint (ruff) (push) Successful in 33s
    CI / Docker image build (push) Successful in 13s
    Release / Create Forgejo release (push) Successful in 19s
    CI / Bot/backend version sync (push) Successful in 59s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 2m41s
    Release / Build and push versioned images (push) Successful in 1m26s
    CI / Backend migration, tests, and audit (push) Successful in 4m54s
    CI / Bot tests and audit (push) Successful in 4m38s
    Stable

    claude-bot released this 2026-08-29 07:01:42 +00:00 | 407 commits to main since this release

    Raw 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_dump from the
      backend image while a restore runs pg_restore against the database image,
      and the two were on different major versions — so every dump was rejected by
      the server's own tooling with unsupported 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
      from SECRET_KEY, which lives in .env and 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.md now states that
      SECRET_KEY must 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_processing name 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_days the 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
  • v4.0.0 23f5a1b671

    v4.0.0
    All checks were successful
    CI / Frontend tests, audit, and build (push) Successful in 1m17s
    CI / Backend lint (ruff) (push) Successful in 37s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m16s
    CI / Docker image build (push) Successful in 17s
    Release / Create Forgejo release (push) Successful in 18s
    CI / Bot/backend version sync (push) Successful in 40s
    CI / Bot tests and audit (push) Successful in 2m2s
    Release / Build and push versioned images (push) Successful in 2m44s
    CI / Backend migration, tests, and audit (push) Successful in 9m18s
    Stable

    claude-bot released this 2026-08-28 23:56:11 +00:00 | 412 commits to main since this release

    The 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
    migrate service. All additive. Only one backfills: c4d5e6f7a8b0 copies each
    member's existing character into the new campaign_characters table and leaves
    the old columns in place, so a downgrade is two DROP 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_VERSION stays 1, so you can upgrade one image at a time.
    BOT_EXPECTED_APP_VERSION moves 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/session request;
    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/speakers reports 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 --repeat because 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_ctx was 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/ask in 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