• v4.4.0 aa2c965c0c

    v4.4.0
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 35s
    CI / Docker image build (push) Successful in 34s
    Release / Create Forgejo release (push) Successful in 32s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m0s
    CI / Bot/backend version sync (push) Successful in 1m10s
    Release / Build and push versioned images (push) Successful in 1m6s
    CI / Bot tests and audit (push) Successful in 1m56s
    CI / Frontend tests, audit, and build (push) Successful in 3m16s
    CI / Backend migration, tests, and audit (push) Successful in 7m54s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 17m35s
    Stable

    claude-bot released this 2026-09-12 00:01:27 +00:00 | 2308 commits to main since this release

    Added

    • [webapp] Wiki suggestions wait until you approve the summary, and your
      corrections clear them
      (#610). Suggested wiki updates used to be made once,
      automatically, the moment a session finished transcribing — before anyone had
      read a word of it. They are built from the summary and the transcript and
      never from the events, so nothing you struck, corrected or relabelled could
      reach them: a GM who misspoke "You're in Ironroot Hold" could correct the
      event and watch the wiki go on proposing Ironroot Hold as a place the party
      had arrived at. Now nothing is suggested until you press Approve summary,
      which is the moment you have decided the session's record is right; and any
      regenerate, restore or hand-edit throws away the suggestions you have not
      acted on yet, putting the button back to Suggest wiki updates. Wiki
      entries you already approved are never touched by that. If you want
      suggestions before approving, the button works exactly as it always did.

    • [webapp] Tell the summary what is story, and what it must not leave out
      (#607). Two more corrections in "Everything that happened", beside Strike and
      Correct. Mark as table note moves an event the model mistook for story —
      a rules explanation filed as a reveal — into the folded table notes, where it
      is never narrated; a table note that was really part of the story gets Mark
      as story
      , with the kind you pick (other, unless you choose). Emphasise
      marks an event the summary must include: it is written in on top of the
      events the summary is sampled from on a long session, never in place of one,
      and the writing step is told it may not be left out. As before, everything is
      held on the page — several corrections on one event if they go together, each
      undoable — until Regenerate summary. Afterwards the new summary's events
      show a star on what you emphasised and "was: reveal" on what you relabelled,
      and Un-emphasise takes a star off. Players see the result — a relabelled
      table note folded like any other, an ordinary row for one brought into the
      story — and never the markers.

    • [webapp] When the summary is wrong because the table said something wrong,
      you can fix the event and get a new summary
      (#424). Some mistakes are true
      to the transcript, so nothing can catch them: the GM says "You're in Ironroot
      Hold", meaning "you were", the event checks out because it was said, and every
      summary since puts the party in Ironroot Hold. Under the summary, in
      "Everything that happened", a GM can now Strike an event or Correct its
      wording, see the pending changes marked, undo any of them, and then
      Regenerate summary. Only the writing step runs again — no transcription,
      no re-reading of the session — so it takes about as long as the summary's
      last step did. The events the model found are kept exactly as it found them;
      the corrected set is recorded alongside them, with the original wording, so a
      corrected event shows "Corrected by GM" and what it used to say, and a struck
      one stays visible to you, struck through. Players see the corrected text and
      never see a struck event. Regenerating replaces the current summary, hand edits
      included, so the old one is kept and Restore the summary from before puts
      it back. An approved summary can be regenerated just as it can be edited, but
      the copy already posted to Discord is not changed — withdraw approval and
      approve again to repost. If the regenerate fails, the summary is left as it
      was, the reason is shown, and your corrections stay on screen to try again.

    • [webapp] A short summary you will actually read, and underneath it,
      everything that happened
      (#568). Long sessions were being summarised badly in
      two different directions, and both were the same argument: the summariser is
      asked for three to six paragraphs and told to represent every verified event,
      and on a real 59-event session the local model resolved that by quietly leaving
      twelve of them out, while the hosted models resolved it by writing ten thousand
      characters nobody skims. So the summary is now written from up to thirty-six
      events (three to seven paragraphs' worth; see #600), sampled across the whole
      evening rather than off the top of it, in the order they happened (see #589) —
      and every verified event, however many
      there are, is listed in full under it: "Everything that happened (59 events)",
      collapsed until you want it, then one timestamped line each saying when, who,
      and what. That list is built from the checked events themselves rather than
      written by a model, so nothing in it is a guess and nothing can be dropped from
      it, and it is there for your players too, exactly like the summary it sits
      under. Events the validator could not confirm are still yours alone, with the
      reason, where they always were. A session under thirty-six events reads exactly
      as it did before this release.

    • [webapp] You can tell Quest Board what the transcriber calls your
      characters, and it will stop calling them that
      (#579). Every character now
      has an "Also heard as" list — the names the transcript gets wrong for them —
      and after transcription each whole-word match is snapped back to the real
      name, possessives included ("Vic's" becomes "Viq's") and without touching a
      word that merely starts the same way ("Victor" is left alone). This is not
      another attempt to persuade the decoder: the campaign's names already go to
      Whisper as an initial prompt and, since #577, optionally as hotwords, and on
      the session that prompted this "Viq" still came back as Vic, Vick or Nick in
      all 22 mentions, because a speech model will not overturn a common English
      word the audio genuinely matches. So the correction is stated by you and
      applied deterministically, to every speaker's track — players say each other's
      characters' names constantly, and a line that says "Vic" cannot support a
      summary beat about Viq. The listed misspellings also go to the transcriber
      alongside the real names, which sounds backwards and is the point: a
      consistently wrong spelling is one that can be corrected, and three different
      wrong ones are not. Quest Board refuses an entry that is another character's
      name, and each run records what it corrected as name_snaps on the
      summarisation run, so a name that never needed fixing is an entry you can
      delete (migration 6b7c8d9e0f1a).

    • [webapp] The campaign's names can be sent to the transcriber as hotwords
      instead of an initial prompt
      (#577). The bundled WhisperX server takes the
      same list of proper nouns two ways — Whisper's initial_prompt, a sentence
      the decoder is told it has just transcribed, and faster-whisper's hotwords,
      the dedicated bias for exactly this job — and they share one slot in the
      prompt, so exactly one is ever sent. Which works better is a question about
      your audio, your model and your campaign's names, so it is now a switch:
      QB_ASR_VOCABULARY_STYLE=hotwords (or none, the free control arm, which
      does not even query the wiki). Unset is initial_prompt and sends
      byte-identical requests to every release before this one
      , so nothing moves
      until you move it. Asking for hotwords from a server that does not declare
      them falls back to the prompt and warns on every capability probe rather than
      running your comparison on the arm you were trying to leave. Each run records
      the style it used in the new nullable summarisation_runs.vocabulary_style
      (migration 4f5a6b7c8d9e), exposed as vocabulary_style on the
      summarisation-run responses, so the comparison can be read back from the
      database instead of from a memory of when the setting changed.

    • [webapp] When a rightsholder writes in, there is now somewhere to put it
      and something to do about it
      (#555). An administrator can record a notice —
      who wrote in, how to reach them, what they claim, what they say it is about,
      when it arrived, and your own notes — and then act on it across everything on
      the instance in one place: disable or remove a pack loaded for the whole
      instance, a pack in any campaign including one you did not import, or
      individual records inside either. Disabling is immediate and reversible and
      takes the content out of every search on the next query; removal is final.
      Whatever you do, the person who imported the content is told on the pack
      itself, in a message naming the notice and quoting the affirmation they
      agreed to at import — the version on their own pack row, not today's wording
      — and every action is written to the audit log with the notice id, so the log
      can still answer "why did this go?" after the pack itself no longer exists. A
      batch of actions is all-or-nothing. There is also one operator-set contact
      address for notices, readable by any signed-in user so a licences page can
      print it. Quest Board decides none of this. Nothing is taken down
      automatically, no notice moves its own status, no counter-notice reinstates
      anything, and there is no repeat-infringer logic — whether a claim is valid
      and what you are obliged to do about it are yours, and the software only
      records and applies what you chose. New migration 2d3e4f5a6b7c adds
      content_notices; no existing table changes. What an operator actually does
      with all this is written up in docs/OPERATIONS.md → "Handling a
      rightsholder notice", and the endpoints are in docs/API.md.

    • [webapp] Import a Foundry VTT compendium export (#553). Upload the folder,
      a zip of it, or the single JSON array people share — Quest Board reads the
      documents either way. Pathfinder 2e and D&D 5e are mapped in full, so their
      creatures arrive as stat blocks rather than as prose, and a system Quest Board
      has no map for still imports as searchable text with its system block kept
      intact for export. HTML descriptions become Markdown, and an actor's own items
      become the strikes and abilities in its entry. Foundry keeps a document's
      publication and licence in four different places depending on whether it is an
      item or an actor and whether the export predates the Pathfinder remaster; all
      four are read, because a creature that imports with no licence is the one
      failure a content pack cannot afford.

    • [webapp] Import the D&D 5e homebrew you already have, from 5etools or
      Dungeon Master's Vault
      (#553). Two more readers for the content-pack
      importer, so people with homebrew in the two places most of it lives do not
      have to convert it by hand. A 5etools homebrew .json brings in its
      monsters as full stat blocks — armour class, hit points, speed, challenge
      rating, ability scores, size and type — along with its spells, items,
      conditions, feats, traps, variant rules, classes and backgrounds; 5etools'
      inline markup is rendered to readable text rather than left as {@hit 7}, so
      what you search is what you would read. A .orcbrew export from Dungeon
      Master's Vault or OrcPub2 brings in its monsters, spells, magic items, feats
      and character options, computes a monster's hit points from its hit dice when
      the file does not state them, and takes the pack's name from the homebrew pack
      the entries belong to, since the format has no header to read it from.
      Neither format records a licence unless the file itself declares one —
      Quest Board does not guess on your behalf. Content in either file that
      Quest Board does not recognise is imported as searchable text with the
      original kept for export, and it tells you what it did rather than dropping
      it quietly; anything it genuinely could not read is reported as a warning on
      the import, alongside the ones the importer itself raises. Adds one backend
      dependency, edn-format, for reading .orcbrew.

    • [webapp] Import Open5e data, and build the two starter packs we are allowed
      to publish
      (#553). The last of the five readers takes an Open5e v2 export in
      whichever shape you saved it — a single API page, a plain array of records, or
      a folder or zip with one file per resource — and brings in its creatures as
      full stat blocks alongside its spells, conditions, rules, items, feats and
      character options. An Open5e record carries no licence of its own: the licence
      belongs to the document it came from, so the reader looks each one up. The
      two SRDs and the Black Flag reference document are CC-BY-4.0, the Kobold Press
      books and the other known third-party documents are OGL 1.0a, and a document
      Quest Board does not recognise gets no licence at all rather than a guess — it
      still imports, and the exporter withholds it. Alongside the reader, two build
      scripts under scripts/content/ produce the starter packs an administrator
      can fetch on request: build_5e_starter.py takes an Open5e export and keeps
      only the srd-2014 and srd-2024 documents, and build_pf2e_starter.py
      takes a Foundry Pathfinder 2e checkout and keeps only the records the ORC
      License covers, dropping creature flavour text because that is where the
      Reserved Material the licence excludes lives. Each writes a .qbpack carrying
      the notice its licence requires — the SRD's Creative Commons attribution to
      Wizards of the Coast, or the ORC Notice with Paizo Inc. named as the licensor
      of the products the pack drew on — and a SOURCES.md beside it listing the
      documents, the counts, and everything withheld with the reason.
      Operators: these packs are published as release assets and fetched on
      request, never bundled in the image, so an operator who wants neither ships
      neither. Each build is pinned to an upstream commit and is byte-identical when
      re-run from the same source, so a content update arrives as a diff someone can
      read rather than as an opaque new blob.

    • [webapp] Load the rules and bestiary content you already have, and look it
      up without leaving Quest Board
      (#553). Quest Board still ships no game
      content of its own — bundling a monster manual would make every operator a
      distributor of it. Instead there is now an importer: an administrator loads a
      pack for the whole instance, a GM loads one for a single campaign, and either
      affirms at the point of import that they are entitled to. Quest Board records
      who affirmed what and when, shows the licence notice the pack carries,
      searches it locally, and exports back only the records whose licence actually
      permits it — the rest are listed in the export with the reason they were
      withheld. This first slice reads Quest Board's own format in three shapes: a
      .qbpack archive, a plain JSON array of records, and a CSV you can write in a
      spreadsheet (kind,name,level,traits,summary,body). A campaign's own version
      of a creature ranks above the instance-wide one of the same name and both are
      shown, each saying which pack it came from; a GM's records stay GM-only until
      they say otherwise. Operators: an administrator can disable any pack on
      the instance — a campaign's included — which removes it from every search
      immediately while keeping the rows, and can remove it outright; both actions,
      and every import and export, are written to the audit log. Descriptions
      imported as HTML are sanitised before they are stored, so nothing executable
      survives an import. New migration 1c2d3e4f5a6b adds content_packs and
      reference_entries; the backend gains two dependencies, nh3 and
      markdownify. Full format reference in docs/CONTENT-PACKS.md. The screen
      that drives it is the next entry; the player-facing search lands with the
      v4.4.0 interface work, and the API is documented in docs/API.md.

    • [webapp] A screen for loading content packs, and for the promise you make
      when you do
      (#553). Administrators get a Content packs tab in Admin for
      the packs the whole instance can search; a GM gets a Content packs panel
      on their campaign page for that campaign's own. Both take a file or a URL,
      let you name the game system and the format (or leave it to detect), and say
      who imported each pack, when, what is in it, and under what licence — with
      the licence's own notice one click away, because that text is attribution the
      licence requires be carried. The affirmation you agree to is fetched from the
      server and shown word for word rather than copied into the app, the box is
      unticked again after every import because affirming is per pack and not per
      visit, and if the wording changes while the page is open the server sends the
      new text back and the box asks again. A pack too large to import in one
      request reports its progress with a clock on it and updates itself when it
      finishes; warnings from an import are shown rather than counted. Export is a
      download link, and removing a pack asks first and names how many records go
      with it. Operators: disabling and re-enabling a pack stay administrator
      actions on both scopes — a GM can see that one of their packs was disabled
      and why, and can remove it, but putting it back is the host's call, since
      disabling is how a rightsholder's notice gets answered.

    Fixed

    • [webapp] Suggested wiki updates no longer arrive in duplicate (#609).
      Every suggestion for a session was written twice — twelve suggestions meaning
      twenty-four rows to read and dismiss — whenever the matching step took longer
      than a minute, which with a self-hosted model is every time. The step that
      writes the suggestions waits for the earlier phases by asking to be retried,
      and that request was being caught and turned into a second retry with the
      same delay, so two identical copies of the job woke together and each wrote a
      full set. The retry is now passed through untouched, and the write takes a
      lock and recognises when its twin has already finished, so a repeated
      delivery does nothing at all. Existing duplicates are not cleaned up
      automatically: press Look for wiki updates again on an affected session
      and its suggestions are replaced with one clean set.

    • [webapp] A long session's summary no longer spends its spare room on the
      start of the evening, and talk that is not about the game stays out of it

      (#603). Since #589 the summary takes one event from every stretch of the
      session that has any, and because some stretches are quiet there are usually
      a few places left over. Those went to whatever was left in the order it
      happened, so always to the start of the evening and never to a busy ending.
      They are now shared out in proportion to how much each stretch still has to
      offer: a stretch where a lot happened gets more of them, wherever it falls,
      and a quiet one keeps its one. A busy stretch's events are chosen from across
      it rather than from its first few seconds, and the summary is now always
      written from the session's last event as well as its first, so its closing
      paragraph is about how the evening actually ended. On the real 54-event
      session behind this fix, that moves three of the thirty-six places into the
      second half of the evening and brings back both of the late events the old
      choice left out. It is a better sample, not a guarantee about any single
      event, and every event is still listed in full under the summary. Separately, the summariser was
      told to leave out talk that is not about the game and recorded it anyway, as
      if it were story — a conversation about another video game arrived as an
      event, and a player's pun reached the prose. It now labels that talk as table
      admin, the same as the rules calls in #596, so it is kept in the event list,
      folded away, and never written into the summary.

    • [webapp] A summary no longer ends on a "What's next?" with nothing after
      it
      (#595). Every summary prompt asked the model to "end with a short
      'What's next?' hook", and Gemma 4 26B took the quoted phrase as the thing to
      write: both reruns of one session finished on a bare What's next? line and
      stopped, which reads as a summary cut off mid-thought. The prompts now ask for
      what that last paragraph should say — one or two sentences on what the party
      is left facing or has decided to do — and if a model still ends on the
      heading alone, the heading is removed before the summary is saved (and before
      any "part of the session could not be summarised" note is placed, so the note
      never lands in the wrong spot because of a heading that was about to go). A
      hook with something after it, or "what's next" anywhere in the prose, is left
      exactly as written. Each run records whether this happened as hook_dropped,
      so whether the new wording holds for a given model is visible across runs.

    • [webapp] The same event, word for word, no longer appears twice in a
      session's events
      (#588, in part). On one real session "Clio discovers the
      water is much deeper…" was listed twice at 00:04:56, and two more events were
      doubled the same way, because deduplication only merged copies that cited the
      same transcript line for the same person, and these copies had drifted apart
      on one or the other. Two events with the same start time and the same words —
      ignoring capitals, spacing and punctuation at either end — are now one; the
      first is kept, unless only the later copy passed the transcript check. Nothing
      cleverer than that: two differently worded descriptions of one moment, or one
      moment split into two events, are still left alone, because telling those
      apart needs judgement and #588 stays open for it. Each run now records how
      many duplicates deduplication removed as beats_collapsed (migration
      8d9e0f1a2b3c, which also adds hook_dropped).

    • [webapp] The GM running the table is no longer written up as the story
      (#596). Replaying a real session, several of the "events" the summariser
      confirmed were the GM running the game: fixing a character's position on the
      map, moving another to the upper layer, explaining what a thresher is (twice),
      asking everyone for their backstories, three on which skill reading a journal
      needed, and three on adding a skill to a character sheet. Each one passed the
      checks, because the GM really did say it, so they filled up the event list,
      took places among the thirty events the summary is written from, and one
      reached the prose as "the group received an explanation regarding what a
      thresher is". Telling the model to leave such lines out had not worked on
      that model, so now it labels them instead, as table admin, and the label
      does the rest: table admin is never written into the summary and never counts
      against it, and in "Everything that happened" it sits behind one quiet line —
      "9 table notes" — that opens them in place, each marked as such. A line
      the model labels as table admin by mistake is therefore still on the page,
      one click away, not lost. The same goes for the GM answering a question about
      something earlier (#584), which is now labelled rather than dropped. Each run
      also logs how many events look like table admin but were not labelled that
      way, which is how we will know whether a given model is using the label.

    • [webapp] A long session's summary no longer skips the start of the session
      (#589). The cap added in #568 keeps the summary to thirty events and picked
      which thirty by kind, preferring reveals, decisions, fights and travel over
      "social" and "other". On the first real session it ran on, that cut the
      session's own opening: the party's long rest, waking up somewhere they had not
      fallen asleep, and working out that the caverns had shifted were all labelled
      "other" by the extractor, so each of them lost its place to a reveal from
      forty minutes later, and the summary began instead with one character walking
      down a ramp. Fifty of that session's eighty-one confirmed events — 62% of
      it — sat in the losing tier, because six labels borrowed from combat and
      intrigue do not describe a session spent exploring. So the thirty are now
      chosen by when they happened: the session is cut into thirty equal stretches
      of time, every stretch that contains anything contributes an event, and the
      labels only decide which event comes out of a stretch. A summary chosen this
      way can still give a moment less weight than you would have; it can no longer
      leave out the part of the evening that set everything else up. Sessions with
      fewer than thirty events read exactly as they did before, and every confirmed
      event is still listed in full underneath the summary either way.

    • [webapp] The summariser no longer takes the GM answering a question as
      something that just happened
      (#584). On one session the party was thrown
      clean across the map by shifting caverns, and every summariser tried on
      it — a 9B model, a 26B one, a frontier hosted one — put them back in the
      place they had been thrown out of. All three were repeating the same
      extracted event, and that event had passed every check: the GM really had
      said "you're in ", a few turns into the players asking where they had
      been before the caves. The words were spoken and the speaker spoke them,
      which is all the validator can decide from the text; the tense was answering
      a question about the past, which it cannot. So the extractor is now told
      that a GM line answering a player's question — about earlier events, the
      rules, the map or a name — is context rather than something happening now,
      and may only be recorded as an event when the lines around it show the
      fiction changing. Nothing is rejected on a guess about tense: a beat whose
      only evidence is a short GM reply just after a player's question is counted
      in the logs so we can tell whether the rule is working, and still goes into
      your summary, because a heuristic sure enough to delete a real event from a
      session is not one anybody has measured yet. That counting was itself blind to
      start with, and found nothing on the very session it was written for: it was
      reading the shortened transcript the summariser is prompted from, in which a
      run of lines from one speaker is merged into a single line — so a GM answering
      in three short bursts looked like one long line of narration, and "short reply
      just after a question" could never match it. It now reads the lines as they
      were actually spoken, and the counts mean what they say.

    • [webapp] The provider concurrency cap now actually applies to the work that
      happens after a session
      (#573). The cap from #356 exists so that a burst of
      finished sessions costs latency rather than failures, and the post-session
      fan-out — storyline, title suggestions, lore proposals, highlights — was
      running entirely outside it. Every LLM call those tasks made logged "Could not
      reach Redis to take a llm slot … (RuntimeError: Event loop is closed); running
      without a concurrency cap for this call", and then did exactly that. Redis was
      fine; the connection to it was not. A Celery worker runs each task in its own
      asyncio.run(...), an async Redis connection belongs to the event loop it was
      opened on, and the pool holding it was shared for the life of the process — so
      from the second task onwards the worker was asking a closed loop for a slot.
      Because the cap fails open by design, nothing looked broken: a self-hosted
      llama.cpp simply took more concurrent requests than it declares and slowed all
      of them down together, which is the failure #356 was written to prevent.
      Connection pools are now kept per event loop, and a closed loop's pool is
      dropped — the same answer app.database.task_session already gives for the
      database. The one other async pool shared across the process, behind the bot
      event stream, is fixed with it: its two beat tasks would have failed more
      quietly still, since the unread-trim check reads any error as "no stream yet"
      and reports no data loss.

    • [webapp] Session summaries are told the truth about the order events
      happened in
      (#566). Quest Board extracts the events of a session more than
      once — a second pass reliably finds things the first missed — and then hands
      the merged list to the model that writes your summary, with the instruction
      that it is "already in chronological order". It was not: each pass was sorted
      on its own and the passes were then stacked one after the other, so the list
      read as the whole session followed by the bits found on the second look. Every
      summary was affected, because every run takes at least two passes. The list is
      now put in time order at the door the compose step cannot go round, so the
      order the model is told it has is the order it gets — and the summary follows
      the session rather than the order the events happened to be noticed in.

    • [webapp] The quote board reads the whole session again, instead of
      whatever fitted
      (#575). Picking the memorable lines out of a session was the
      one step that still sent the entire transcript in a single prompt, however
      long the session was. On a real one that came to about 31,000 tokens against a
      22,900-token budget, and the log said as much before sending it anyway — so a
      local server that quietly drops the front of an over-long prompt answered from
      the tail of the session, without its instructions, and the quotes came back
      thin with nothing to explain why. The transcript is now split into windows
      that fit, exactly as the summariser's own reading of it already was, and each
      window is asked separately. The number of quotes you asked for is still the
      number you get: they are taken a few from each part of the session rather than
      all from the first, so a four-hour game no longer produces a quote board made
      entirely of its first twenty minutes, and "key moments" are capped for the
      session instead of per call. One window failing no longer loses the rest of
      the board. Nothing changes for a session that already fitted in one prompt.

    • [webapp] A part of the session the summariser could not read is retried,
      and then said out loud
      (#572). Quest Board reads a session for events in
      consecutive windows, and a window whose call to the model failed was skipped:
      the summary simply did not cover that stretch, and the only trace was a line
      in the worker log that ended with the provider's error message — which for a
      local llama.cpp router is the empty string, so it read Beat extraction failed for window 4/5 (pass 1): and stopped there. On the session that prompted
      this, window 4 of 5 was the payoff, it failed on every pass, and the summary
      covered none of it with nothing anywhere to say so. Three changes, all in the
      direction of saying what happened: the log now names the exception's type and
      its repr, and describes "the model returned nothing" in different words from
      "the call failed"; a window that fails is asked once more before it is given
      up on, since these failures come and go per window; and if it still returns
      nothing on every pass, the summary itself ends with a plain sentence naming
      the missing range — "Part of the session (from 00:41 to 00:52) could not be
      summarised because the summariser returned nothing for it; the transcript is
      complete" — placed before the "What's next?" hook. A quiet window that the
      model read and found nothing in is not a failure and says nothing. Which
      windows were lost is also recorded on the summarisation run as windows_failed
      (migration 7c8d9e0f1a2b), so the run history can be asked how often this
      happens on a given endpoint.

    • [webapp] The check that watches for invented people stopped reporting
      "Fortunately" as one
      (#567). After writing a summary Quest Board reads it
      back and looks for anyone the verified events, the speakers and the characters
      cannot account for — a cheap guard against a model adding a person nobody
      played. It decided what a name was by capitalisation, so every sentence that
      opened with an ordinary word was a person: real runs reported "Early",
      "Fortunately", "Undeterred", "Continuing", "Ahead", "Getting", "Whatever",
      "Among" and "Rounding". English capitalises the first word of every sentence
      whether or not it is a name, so that position is now treated as saying
      nothing: an opening word counts only if it is a name Quest Board already
      knows, or if the summary uses the same word capitalised mid-sentence, where
      grammar does not explain it. A genuinely invented name still gets reported the
      moment the prose mentions it anywhere but a sentence's first word — which
      composed prose does, because it introduces a person and then talks about them.
      This has always been a log line and never changed or withheld a summary; what
      changes is that the log line means something again.

    • [webapp] Transcripts no longer contain the things Whisper says about
      silence
      (#569). Speech recognition writes text for near-silent audio, and it
      writes the same things every time: television caption credits ("Sous-titrage
      ST' 501", "Closed Captioning by …", "Red Bee Media"), a bare "Thank you.", a
      lone full stop, one word looped a dozen times, a fragment in a language nobody
      at the table speaks, and — because Quest Board tells the model your campaign's
      proper nouns before it listens (#355) — one of those nouns handed straight back
      to us. One real six-speaker session carried 143 of these among 1,778 lines.
      They cost tokens in the summariser's context, they were cited as evidence for
      events that never happened, and a model that reads "Thank you." seventy-eight
      times writes about how polite everybody was. A filter now drops them before
      the transcript is assembled, matching whole lines only — "Thank you." goes,
      "Thank you, Kira, that was genuinely heroic" stays — and never a name a person
      might have said on its own, so "Wilhelmina?" survives while "Office of
      Prophetic Fulfillment" does not. What it removed is counted per rule on the
      session's summarisation run, so a GM can see that lines were taken out and how
      many; the lines themselves are never logged. Migration 3e4f5a6b7c8d adds one
      nullable column to summarisation_runs.

    • [webapp] Pages served beside the app on the same hostname no longer get
      bounced to the dashboard
      (#535). The installed app's service worker answered
      every navigation on its hostname with the app shell, so a page the reverse
      proxy serves under a path of its own, such as the design-review feedback site
      at /feedback/, loaded as Quest Board and was sent to the dashboard by the
      router, in any browser that had visited the app before. The worker now leaves
      /feedback/ alone, uncached, as it already did for the API and the sign-in
      flow. Browsers that hold the old worker pick up the new one on their next
      visit and are right after one reload.

    • [webapp] Editing a summary and then clicking away no longer loses what you
      wrote
      (#464). Correcting a session's summary or transcript and then heading
      off somewhere else — the nav bar, a campaign link, the browser's Back button —
      discarded every word of the correction without a murmur. Closing the tab or
      reloading had asked first since #410, but a click inside Quest Board never
      leaves the page as far as the browser is concerned, so nothing was there to
      ask. Leaving a half-written edit now stops and asks whether to discard it, and
      staying put leaves the text exactly where it was. Nothing changes if you have
      saved, or if you opened the editor and typed nothing.

    • [webapp] "Saved" now stays on screen under a reviewed stat block (#182).
      Correcting a proposed stat value on a wiki draft autosaves it, and the little
      status line beside the stat block was supposed to say so. It did — for a
      single frame, because the same code that noticed the form was no longer
      unsaved immediately reset the line back to blank. In practice the autosave
      looked like it had done nothing, which is the one thing a silent background
      save must never look like. The line now reads "Saved" until the next edit.

    Internal (no operator-visible change)

    • [repo] The v4.4.0 design record and a self-hosted feedback site (#361,
      #362, #535). docs/design/v4.4.0-concepts/ holds the brief's sources, the
      surface inventory, the wireframe kit and the sixteen concept fragments that
      build the checkpoint A review pages. tools/feedback-site/ is a small,
      separate container (FastAPI, a JSON-file store) that serves the players' and
      GMs' review pages with a form and an admin readout, so beta testers without
      accounts can answer. It shares nothing with Quest Board and is not deployed
      with it.

    • [webapp] Backend support for an admin view of every campaign on the
      instance
      (#437). GET /api/admin/campaigns returns each campaign's
      ownership, member/session counts, Discord routing flags and retention
      overrides regardless of the caller's memberships — metadata only, no campaign
      content. The Admin screen that uses it lands later in the milestone.

    • [webapp] Adopted the React Compiler lint rules from
      eslint-plugin-react-hooks v7
      (#182). The frontend enabled two of the
      plugin's rules by hand and deliberately skipped its recommended preset,
      which in v7 also brings the compiler's own checks — purity, refs,
      immutability, set-state-in-effect and a dozen more. The preset is now on in
      full, and the forty violations it found across twenty-two files are fixed
      rather than suppressed: mostly loading and "stale" flags that an effect
      raised on its way past, rewritten as values derived during render from what
      the data in hand was fetched for. There are no eslint-disable comments for
      any of it. Running the compiler itself over the build remains a separate
      decision that this change does not make.

    Downloads