2026-09-03 The Card Field SillyTavern Sends Twice

What I set out to do

Answer two SillyTavern questions for OpenRouter Interceptor: what “Multiple swipes per generation” does and why OpenRouter is excluded, then whether splitting single-prompt cards into personality, scenario, mes_example and friends buys anything. The second question turned into an audit when three claims I made about the interceptor’s card builder needed verifying.

What I actually did

Multi-swipe first, from the release source rather than the docs (the docs page does not mention it). The setting is the OpenAI n parameter; extra choices become pre-generated swipes, and streaming is supported by routing choices[i].index > 0 into per-swipe buffers. It is gated by an allowlist in openai.js that has only ever held sources confirmed to accept n: OpenAI and Custom in the Feb 2024 commit for issue #1782, then Azure, xAI, AI/ML API and Moonshot one at a time. OpenRouter never joined because its OpenAPI spec has no n on chat completions at all; the only n in the whole spec is on image generation. Tool calling is also switched off for any multi-swipe request.

The card-field question had a cleaner answer than I expected: the split changes where text lands and what can be done to it, not the tokens themselves. Separate Prompt Manager entries, per-chat scenario override, few-shot rendering of mes_example as example_user/example_assistant system messages with budget-aware push-out, post_history_instructions landing after chat history, and system_prompt replacing the preset’s main prompt when “Prefer Char. Prompt” is on. That last pair is where the audit started.

Three claims, checked against build-card.ts, from-prompt.ts and background.ts, then against the live library of 1,310 cards and one real request body pulled from a SignOz trace:

  • post_history_instructions is hard-coded to "" by the builder. True. The 14 cards that carry one are the hand-edited roster cards from August.
  • system_prompt holds the entire cleaned prompt while description holds the same text minus hoisted sections. True, and worse than I said. ST’s prefer_character_prompt defaults on, Adam’s settings have it on, and the Default preset’s main has no forbid_overrides, so the card’s system_prompt replaces the main prompt and description, personality and scenario are still sent as their own entries. 1,139 interceptor cards carry a system_prompt; 732 of them repeat at least one field’s text inside it (338 description, 292 personality, 165 scenario, 79 example dialogue). 312 have description byte-identical to system_prompt, so the whole definition goes out twice.
  • The JanitorAI path overwrites description after withoutDuplicatedSections ran, so duplicates survive. The ordering is real but my mechanism was wrong. JanitorAI’s API description is the public listing blurb, a different text from the definition (857 chars against 12,120 on the first sample). The overwrite replaces the extracted description with the blurb; zero of 676 JanitorAI cards have personality or scenario inside their description. What actually happens is that the definition survives only in system_prompt, and the 224 cards with extracted personality or scenario send those sections twice against it. The trace confirmed it: main prompt = full <Persona> block, second system message = blurb, third = the “radiant bride” personality paragraph that is already inside the first.

What was striking

I described a duplication bug with the right symptom and the wrong shape, and the only reason I caught it was the length column: an 857-character description next to a 12,120-character system prompt cannot be the same text. Counting personality ⊂ description before theorising would have saved a paragraph.

The dedup backstop in from-prompt.ts guards description against personality and scenario, and its comment says why: ST concatenates them. It never looks at system_prompt, which is the field ST actually sends first and in full. The guard protects the pair that is cheapest to compare and misses the pair that carries the most text.

If “Prefer Char. Prompt” were ever turned off, or a preset marked main as forbid_overrides, 649 JanitorAI cards would lose their definitions entirely and keep only a marketing blurb plus whatever sections the extractors caught. The card format is being used as a container for the raw prompt, with the structured fields as a partial index of it, and that only works while one ST toggle stays where it is.

Later: what the spec wants, and the rules the cleaner throws away

Adam asked what SillyTavern actually recommends for system_prompt and post_history_instructions, and whether the latter can be inferred from JanitorAI. The V2 spec is blunt: a frontend’s default behaviour MUST replace the user’s system prompt with the card’s system_prompt, and the jailbreak setting with post_history_instructions; empty means fall back to the user’s own. The fields are replacements for the preset’s instructions, not a home for the definition. ST’s docs say the same thing from the other side: description is “always included in the prompt, so all important facts should be included here.” The interceptor does the inverse, so every card overrides Adam’s preset with a definition.

Mapping the 676 stored JanitorAI prompts: <UserPersona> in 599, <Scenario> in 508, <X's Persona> in 339, <example_dialogs> in 154. Untagged text sits outside the tags in 100 of them, and in 91 it comes after the last closing tag: creator-written rule blocks, median 5,427 characters, 77 distinct texts so per-card rather than Adam’s own custom prompt, 61 reading as rules. Those are the obvious candidates for post_history_instructions. They are also exactly what truncateAfterLastTag in transformation-chains.ts deletes: 90 of the 91 blocks are in neither system_prompt nor description, surviving only in extensions.openrouter.original_system_prompt.

Whether JanitorAI also appends its own jailbreak as a trailing system message after chat history I could not verify: the fixtures carry no message arrays, the captures inbox is empty, no JanitorAI request has crossed litellm in 30 days (zero SignOz hits for <UserPersona>), and the extension’s IndexedDB is Snappy-compressed with no decoder installed. extractSystemPrompt takes only the first system message, so if such a message exists it is discarded before the card is built. A saved capture would settle it in a minute.

The cleaner was written to trim trailing junk after the last tag, and for the untagged cards it trims the creator’s rulebook instead. Same shape as the morning’s finding: a heuristic tuned on the common case quietly discards the uncommon one, and the discarded text is the part with the most behavioural weight.

Evening: the capture answers one question and opens another

Adam saved a diagnostic capture. The expanded request (Celeste, glm-5.3-flash) has four messages: one system message at the top, then user, assistant, user. Nothing after the chat history. JanitorAI sends no post-history block, so post_history_instructions cannot be lifted from a message; anything that goes there has to be carved out of the single system message.

The system message is \n<Celeste's Persona>…</Celeste's Persona><UserPersona>Male</UserPersona> followed by a 10,936-character untagged block of intimate-scene pacing rules. The Celeste card in the library, built thirty seconds earlier from the first request of that chat, has original_system_prompt of 10,638 characters: a newline plus that same block, and no persona at all. The block survived a request in which the character was missing, so it is not the creator’s text. It is whatever JanitorAI appends independently of the character, which points at Adam’s own custom prompt. Only two cards in the library carry it (2026-08-23 and 2026-09-03), so it is not always on. Left for Adam to confirm.

The bigger find is the persona-less first request. 47 JanitorAI cards have original_system_prompt equal to "\n", 46 of them since June. autoDownloadCharacterCard gates on capture.systemPrompt being truthy, a lone newline passes, and the dedupe key is character:<id>, so the fuller second request thirty seconds later is skipped as already downloaded. Those 46 characters have a blurb for a description and nothing else; none ever got a sibling card with a real prompt. JanitorAI fires some request before the character definition is in the message, and the interceptor has been treating it as the character.

The same-creator theory I had been leaning on (both small-caps titles) collapsed the moment a card without a persona still carried the block. Presence in the absence of the thing you are testing against is the cleanest kind of evidence, and it took a capture to get it.

Correction from Adam: the persona-less first request is JanitorAI’s generate-for-user call, which he fires before every real message. The code does account for it, twice, and both guards miss the current shape. isGenerateForUserRequest skips only a prompt that starts with <Scenario> and has no persona tag, which is what that request looked like in April; since June it arrives as a bare "\n" (plus the custom prompt when one is set), which is truthy and not scenario-only. The July consolidation does pick the longest prompt per character, but the spec calls it “display-only grouping … no storage or message-router changes”: it fixes what the sidebar shows, and the card download had already fired on the first request and marked character:<id> as done. Two guards, one for a request shape that no longer exists and one for a surface the bug never touched.

Night: the fix, and the gate that was wrong before it shipped

Adam asked for both fixes, TDD, and repair of the cards already broken. Three pieces landed:

  • hasCharacterDefinition (src/character-card/definition.ts) replaces the scenario-only skip. It rejects a blank prompt, the scenario alone, and free text with no JanitorAI wrapper tag or card notation in it. The validator reports the same test as no-definition.
  • A ledger (src/utils/card-ledger.ts, openrouter_built_cards) records each card’s prompt length and the filename the download landed in. A longer prompt for a known character overwrites that exact file; a hash with no ledger entry is left alone, because the filename cannot be guessed from inside the extension.
  • scripts/rebuild-seed.ts reads the library, finds cards built from persona-less prompts, and writes the ledger entries (hash plus filename) for the options page to import. 69 cards: 47 from the bare newline, 15 from <Scenario> alone (before the April skip existed), 7 from a custom prompt.

The first version of the gate required a persona, scenario, or example-dialogue tag, and the seed script promptly listed 119 cards. Fifty-seven of them were real definitions from 2025, written as untagged prose beside a <UserPersona> block: JanitorAI only started wrapping the personality in <Name's Persona> later. The generate-for-user request drops the user persona along with the character, which makes that block evidence of a real turn, so the rule became “any wrapper tag”. The unit tests passed either way; only running the script against 1,311 real cards showed the rule was wrong. A gate written from the shapes I had seen this week would have marked a year of cards for overwrite.

The remaining ambiguity is a bot with an empty personality chatted by a user with no persona set: <Scenario> alone on every turn, indistinguishable from the old generate-for-user shape. That bot is not built. 1,039 unit tests, typecheck, lint, and build all green; the seed file is at ~/Downloads/rebuild-seed.json waiting for the options-page import.

Later still: reading the extension’s own store, and what it did not hold

Adam did not want an options-page import; he wanted the broken cards rebuilt from here, and pointed at the extension’s storage. That meant reading Firefox’s IndexedDB directly: browser.storage.local records are Snappy-compressed SpiderMonkey structured clones in object_data, with anything over a megabyte spilled to <db>.files/<id> as a framed Snappy stream (sNaPpY identifier chunk, then typed chunks each carrying a 4-byte checksum before the block). Keys are the storage key with every byte shifted up by one behind a type prefix. A Snappy decoder is forty lines of Python; the clone walker is another forty, once the 0xFFF10000 header stopped being read as a double. One infinite loop on a zero copy-offset before the framing was understood.

The PWA profile held seventeen such stores, one per temporary load of the extension over the year, 999 captures between them. Only four of the 69 broken characters were in any of them, all in the current store: Celeste, Super Creek, Evelyn Lao, and a Louis whose entire persona is the word “NTR”. The rest were captured, shown in the sidebar, and cleared, as the memory note about the captures inbox says they routinely are. The store was never an archive.

The repair path is a script, as asked: scripts/lib/rebuild-card.ts runs the recovered prompt through characterCardFromPrompt, keeps the blurb, greetings, avatar and tags that came from the JanitorAI API, and folds the definition in with buildMergedCard, so the empty values land in revision_history. scripts/rebuild-cards.ts drives it from a filename-to-prompt JSON with a backup directory and a dry run. Four cards rebuilt; 65 remain, and for those the only source left is JanitorAI itself or the next real request, which the ledger now handles on its own for cards it has written.

One thing worth keeping from the test: the builder does not hoist a <Name's Persona> body into personality; it hoists labelled sections. So a repaired card looks exactly like a fresh capture of the same bot, definition in system_prompt, personality empty unless the creator wrote Personality:. Consistency with the builder, not with what a card “should” look like, is the right target for a repair.

And then the layout itself

Adam asked which is preferred: the definition in system_prompt, or in the description with the preset owning the instruction. The spec and SillyTavern’s docs both say the latter, and the reasons stack: the preset’s Main Prompt reaches every card again, the Prompt Manager entries become movable, the double-send stops, and the definition no longer depends on one toggle. He said do it, builder first, then the migration.

Builder: characterCardFromPrompt no longer writes system_prompt; the definition is already the description. The JanitorAI block in background.ts became withJanitorAIData and withGreetings in src/character-card/from-janitorai.ts, unit-tested, and the API blurb now goes to creator_notes instead of over the description. A CAPTURE_CREATOR_NOTES constant marks notes nobody has written.

Migration: scripts/lib/relayout-card.ts with two moves, both through buildMergedCard so old values land in revision_history. relayoutCard rebuilds the definition fields from the recorded prompt for a card whose description is the blurb or empty; withoutSystemPrompt just clears the field and files the blurb when the description already holds the definition. scripts/migrate-card-layout.ts (just st-relayout) decides per card; rebuild-cards.ts now shares the same function. Dry run: 608 rebuild, 479 clear, 13 nothing to move, 211 not ours.

The dry run also found a builder crash that the extension has been carrying: a Personality: header with no closing delimiter runs to the end of the prompt, removeSection escaped the whole value into a regular expression, and V8 threw “Regular expression too large” on a 16k card. The remover now finds the value literally with indexOf and matches only the short header pattern in front of it. Two tests: the oversized section and one full of metacharacters.

Worth keeping: the rebuilt description carries JanitorAI’s wrapper tags and, where the creator used <example_dialogs>, the dialogue stays inside it rather than moving to mes_example. That is what the builder does for a fresh capture too, so the migration reproduces the extension’s output rather than improving on it. Hoisting the tagged scenario and example dialogue is a separate change.

The hoisting, read off the card Adam actually plays

Adam asked to check hoisting against his most recent chat, “Hotwife or hot wife?“. The card told a worse story than “not hoisted yet”. Its 10,019-character <Scenario> block sat in the description relabelled <{{char}}>, and every one of the seven uses of the word “scenario” in the prompt had become the character’s name: the model was reading “This Hotwife or hot wife? operates on a strict system of user approval”, which is exactly the line the SignOz trace had shown at the start of the night without my recognising it. Cause: the tag converter’s system-tag list had scenario in lowercase, the check was case-sensitive, and JanitorAI’s persona tag has an apostrophe the tag pattern does not allow, so <Scenario> was the first “character tag” it found. 577 cards carried the relabel, 739 had lost the word.

Second, mes_example held 7,872 characters of the scenario’s escalation ladder: the example extractor’s header pattern matched anywhere, and “For example:” inside prose was enough. SillyTavern parses that field line by line for Name: turns and emits nothing from a block without them, which is why the prompt showed [Example Chat] and nothing after it. The card’s core rules were in the one field SillyTavern trims first, and were not being sent at all.

Third, the tagged blocks were never lifted: 532 cards with a <Scenario> block had an empty scenario field, 142 kept <example_dialogs> inside the description. And creators write **Wife:**, which SillyTavern’s literal startsWith never sees.

Fixes, each test-first: the converter compares tag names case-insensitively and excludes JanitorAI’s structural tags; section headers must start a line; <example_dialogs> is an example keyword; example dialogue is hoisted only when isDialogueShaped (two speaker lines or <START>), and normaliseDialogue strips the markdown from speaker labels and maps every name the character goes by (resolved name plus the persona tag’s) to {{char}}; the persona wrapper is unwrapped after the truncation step, so the custom-prompt tail still falls off. Then --rebuild-all on the relayout script regenerated 977 cards from their recorded prompts. The Hotwife card now has a 2,646-character description, the 10,071-character scenario in its field, and an empty mes_example, which is correct: it has no dialogue.

Two lessons. A trace I had already read contained the corruption in plain sight, and I did not see it until the card’s fields were laid side by side with the original prompt; “the request looks like the card” is not the same check as “the card looks like the source”. And the second-round dry run counted 977 rebuilds against the first round’s 608, because the first migration had faithfully reproduced the builder’s output, defects included. A migration that reproduces the builder is only as good as the builder on the day it runs, which is what --rebuild-all is for.

Spot checks, not sweeps

Adam stopped a breadth verification script mid-flight and asked for deep reads of individual cards instead. Four recently played cards, each laid out field by field against its original prompt:

  • “Hotwife or hot wife?”: scenario field byte-identical to the <Scenario> block after the intended Dragon{{user}} rewrite; behavioural directives live in the scenario because that is where the creator put them; only Harriet’s Personality: line is hoisted, the other four NPCs’ stay inline with their owners.
  • “Your Wife Is A Slut”: correct throughout, blurb (with the creator’s Discord plug) in creator notes.
  • “Syaro Kirimanjaro”: a freeform • personality: bullet sheet the extractor ignored. Harmless; the bullet is now accepted as lead-in.
  • “Ashe & Sierra”: a real defect the sweeps had not shown. A three-character sheet with ### Personality: under each name. Sierra’s capture stopped only at a stop word, so it ran through ### Core Concept, ### Fears, ### Wants, a ---, ## Ashe O'Neill and Ashe’s stats, ending at Ashe’s ### Appearance:. The personality field held 3,058 characters of mostly other things, and Ashe’s appearance sat in the description under no name.

Fix: header forms now capture their heading level and the value is cut at the next heading of the same or higher level, or at a horizontal rule; a header with no heading stops at any heading; deeper sub-headings are kept. Test-first, 1080 green, rebuild-all changed 127 cards. Sierra’s field is her 462-character paragraph and all three name headings are back.

The sweep counted “scenario relabelled” and “example block left” and found nothing wrong with this card, because nothing it measured was wrong. The wrong thing was a field boundary a few lines too late, visible only by reading the field against the source. Breadth checks find the defects you already named; the unnamed ones need someone to read a card.

Three more deep reads. Megan(1) correct. Scarlett and Makoto & Amber were not built by the extension. The elf-wife card (Alisara) was the second runaway of the night: a Key: value sheet with no markdown headings, where Personality: was followed by Likes:, Backstory:, </Alisara>, and the entire <Scenario> block. None of those is a stop word, so the personality field held 11,052 of the prompt’s 11,375 characters, the example dialogue included, and the description was 396 characters of name and age. Header sections now also end at the next key line (one to three words, first capitalised, colon) and at any tag line; prose with a colon stays. 1083 green; the rebuild pass changed 280 cards, which says how common the shape is. Alisara’s personality is now 527 characters and her sheet is back in the description.

Two runaway captures in two spot checks, both invisible to the sweeps. The header form’s original design, “run until a stop word”, assumed the stop-word list was the vocabulary of section names. Real sheets have dozens of keys, and every key the list did not know was a line the capture ran past.

2026-09-04, small hours: the third runaway

Five more deep reads. Ayase (W++) and Sadie (bold-header bullets) correct, Evie not ours, Acacia Wells correct within the singular-field limit (three characters, the first [Personality: hoisted, the other two left with their owners, no duplicate). Dani vs. Goliath was the third runaway: a brace-delimited sheet with lowercase keys, where a bare traits: line was followed by positive:, negative:, speech:, then } and Daniella (Dani): {. None of those ended the capture, so Gareth’s traits and the top of Daniella’s block were in the personality field.

The terminator now also stops at a lowercase single-token key, a parenthesised name key, a line that is only a brace or bracket, and the start of a PList entry; a header followed straight by another key hoists nothing at all, which is the right answer for a sheet whose value is structure. And a bulleted • **Label:** text line is content, not a key, which is what keeps Sadie’s list together. That last point came from noticing that Sadie’s list had survived the previous pass only because the colon sat inside the bold markers and the key pattern happened not to match it. A rule that works by accident is a rule that will stop working. 1086 green; 68 cards changed; Dani’s sheet is whole in the description with the field empty.

Three passes in one night on the same function, each found by reading one card. The header form was designed around a stop-word list, and every sheet dialect (markdown headings, key-value prose, braces and lowercase keys, PList brackets) is a different way of writing “the next section starts here” that the list did not know. The terminator is now a list of those shapes rather than a list of section names, which is the right axis.

A fourth batch of reads. Asami, Claire and Hana were imports, not ours. The Reunion card (narrative example block left in the description by design) and Jennifer Watson (markdown list sheet) were correct. Laura Johnson was the find: her personality lives in <Laura_Johnson_Personality>, a tag no pattern knew, so the hoist fell through to the first bare Personality: in the prompt, which sat inside the <npcs> block, and the card carried Brian’s personality under Laura’s name. Seraphina was the same gap in possessive form, Seraphina's Personality:. Section tags now accept a name prefix and header forms one qualifier word, removal mirrors both, 1089 green, 107 cards changed. Laura has her own 1,034 characters; Seraphina her 900.

Wrong-character attribution is a worse failure than a runaway capture, because nothing about the field looks broken: it is a plausible personality paragraph, just somebody else’s. Only reading it against the prompt shows whose it is.

Fifth batch, chosen by shape rather than recency: the longest remaining personality fields, a YAML-style sheet, a one-line scenario. Aiko (YAML) and Claire Bennett (Setting: Modern U.S. city) were correct. Alexis Sinclair’s 13,545-character personality was the whole ## Personality chapter, because the section opened with **Core Traits:** and the nine bold labels that followed (**Who She Was (One Month Ago):**, **The Corruption Begins (Week Two to Four):**) were four-plus words long, past the key pattern’s limit; a bold-wrapped label is a label whatever its length, so bold is now the signal on its own. Chloe’s sheet was the mirror image of Sadie’s: - Personality Traits: as a bulleted key with - Quirks:, - Likes: as bulleted siblings, where for Sadie the bullets under a plain **Personality:** were the list itself. So whether a bullet is a key now depends on whether the header was one. And a bullet has to be followed by whitespace, or the first * of **Personality:** reads as one, which is exactly what broke the Sadie test until I saw why. 1091 green, 107 cards changed. Alexis is 266 characters of core traits, Chloe 137, and Sadie tightened from 2,161 to 834 because her **Likes:**, **Dislikes:** and **Fetish:** sections are now correctly siblings rather than more personality.

Five terminator changes on one function in one night. Each shape was obvious once one card showed it and invisible before. The principle that finally held is that a section ends at the next label of the same shape as its own header: same heading level, same bold wrapping, same bullet. Stop words were the wrong abstraction from the start; the structure of the sheet is the right one.

Sixth batch, chosen by how far the description had shrunk relative to its persona block. Kelly and the crush-friend card were correct, Tides is one of the 65 prompt-less cards. Two Years Too Late had lost 2,650 of its 2,749 persona characters: the sheet is W++, its only Description(...) category belonged to an NPC (“Wears a lab coat, briefly checks Dragon”), and the builder took that category for the card’s description and discarded everything else. The description is now always the cleaned prompt minus what was hoisted; a W++ category is one field of a sheet, not the sheet. The Trial of Century had its 4.7k scenario in both fields, because the prompt was a <Scenario> block and nothing else that survived cleaning, so the “better the prompt than nothing” fallback put the block straight back. The fallback now runs only when nothing was hoisted. 1093 green, 7 cards changed.

The Trial card also raised something bigger than itself. After </Scenario> it carries 32,077 characters of untagged text: Orenthal’s public reputation, the princess’s sheet, a witness named Kato, “Behavioral Rules for the Bot”. The truncation step drops that as the user’s custom prompt. But it is bot-specific (no other card captured that day has it), and a survey of every tail over 200 characters found 51 keeping {{user}} literally against 3 saying “Dragon”, where all 853 persona blocks say “Dragon” and none keep the macro. Creator fields pass through JanitorAI’s templating; these tails do not. That reads as JanitorAI lorebook entries, appended after the fields without substitution, and if so the interceptor has been throwing creator content away in up to 109 cards. The Celeste pacing block that survived a persona-less request is the counter-evidence for the custom-prompt theory. Left as a question for Adam, who knows what his JanitorAI custom prompt says.

2026-09-04, morning: the tail is the lorebook

Adam’s answer to the custom-prompt question was “you check and look”, so I did, from three directions. The JanitorAI bot page needs a login Chrome does not have and the API returns 403 without one, so the evidence had to come from the data already on this machine.

First, the extension’s own store. The four longest tails it holds are each one shape of creator content: Robyn’s is seventeen paragraphs of superhero world lore (PRIME CITY, the Vanguard, the Ascendancy); the Sato House card carries a room-by-room house definition and then entries numbered 10 and 11 of a guest list with 1 to 9 absent, which is what a keyword-triggered lorebook looks like from inside one chat; the “Content Guidelines for Female Characters” block keeps {{char}} and {{user}} as literal macros. And the 278,722-character tail on the adventuring-party card opens with “AELDRAMOR WORLD FOUNDATION - LOREBOOK ENTRY / ENTRY SETUP INSTRUCTIONS / KEYS (Copy these into the “Keys” field)“. The creator pasted their lorebook authoring notes into the entries.

Second, the library, 109 tails laid out by capture date with every paragraph marked shared or unique. A user’s custom prompt would appear on every card of a period. Nothing does. The shared blocks group by creator (eight HentaVille bots, five camgirl bots with the same 5,643-character guideline block, the two small-caps-titled bots with the pacing rules) or by public lorebook: the same “blowjob: a blowjob, also known as fellatio” and “sockjob” paragraphs turn up in five unrelated bots months apart, each card holding only the entries its chat had triggered. Entries carry {{random: mouth, eyes, face}} and {{poss}}, which are JanitorAI lorebook macros.

Third, the generate-for-user request. It carries the custom prompt and nothing else, and since June it has been a bare newline on every capture. Adam’s custom prompt is empty. The Celeste pacing block that “survived a persona-less request” last night was the lorebook riding along without the character, which is the one case I had not considered: the lorebook is injected independently of the persona, so its presence in a request with no character proved nothing about who wrote it.

So truncateAfterLastTag has been deleting the creator’s lorebook on every card that had one. The fix keeps the chain as it is for the description and adds src/character-card/lorebook.ts, which takes the text after the last closing tag, rewrites the persona name back to {{user}}, and stores it as one always-on entry in character_book (a valibot schema for the book in src/schemas/character-book.ts). One entry rather than one per paragraph, because the entry boundaries are the creator’s own headings and brackets and are not recoverable; SillyTavern imports the book as World Info and can split it there. relayoutCard carries the book through a rebuild, counts a book-only change as a change, and leaves an existing book alone when the new prompt has none, so one edited in SillyTavern survives. The playground parser was checking its input with v.is and so never applied the schema’s defaults; it parses now. 1,101 tests, typecheck, lint, build green; --rebuild-all changed 110 cards, description unchanged on every one, lorebook 0 to between 200 and 278,746 characters.

The lesson from last night’s counter-evidence deserves writing down. “The block is present when the character is absent” felt like the cleanest kind of proof, and it was, for a different claim than the one I attached it to: it proved the block is not inside the persona, not that the user wrote it. The data that actually settled it was the dullest available: which cards share which paragraphs, and whether anything is shared by all of them.

  • OpenRouter Interceptor
  • src/character-card/lorebook.ts, src/schemas/character-book.ts, scripts/lib/relayout-card.ts, docs/sillytavern-integration.md

Midday: the 2025 cards, and five more defects from six deep reads

Adam asked whether a lorebook of 300k is real. It is: the adventuring-party card’s prompt was 303,907 characters sent to Kimi K2.5, 197 paragraphs in the tail and all 197 distinct, eight continents and a bestiary with rank and weakness per creature. The 176k one is fourteen full character sheets for the girls at a party, one heading set repeated fourteen times. The card records what JanitorAI sent; nothing is duplicated by the capture.

Then the spot checks, this time chosen by a criterion no earlier batch had used: the 2025-era cards that now carry a lorebook, in case the older template put something else after the tags. It did not; Taylor’s possum and Jessie’s uncle with the pet alligator are lorebook entries like the rest. But six deep reads turned up five defects, each fixed test-first and each invisible to any count I had run:

  • The lorebook boundary was inside the lorebook. HentaVille’s house rules ship with their own <anti_meta> and <length> blocks, so “after the last closing tag” left the rules in the description and kept one trailing sentence as the book. The boundary is now the last of JanitorAI’s own wrapper tags (src/character-card/prompt-layout.ts), shared by the cleaning chain and the lorebook. 28 cards.
  • The scenario carried a mood reading. Ana’s <Scenario> ended with “SCENARIO DIRECTIVE: Adopt an even, neutral mood… INFERRED SUBJECT(S): Dragon shifts uncomfortably. … NOTE: Sentiment analysis isn’t 100% accurate”, JanitorAI’s per-message read of the user’s last line, quoting it. One card; stripped anyway, because it is the state of one exchange.
  • Cards named after the wrong person. The Carey card was called anti_meta, Shannon’s STRICTLOCK, Isabel Donovan’s Carlos (her rival), and a fresh build did no better: Santa for Carey, “Is” for Shannon. The persona-tag strategy in the name extractor was fine; it just never saw a persona tag, because it was handed the cleaned description, which unwraps the tag. The raw prompt is read first now, and the rebuild renames on that evidence alone, never to a prefix of the stored name (“Alexis Sinclair” stays for a tag saying Alexis; a 38-character roster name is not cut to its own clamp). 38 renamed. {{char}} resolves to the name, so a card called Carlos was telling the model it was Carlos.
  • A qualifier after the keyword. Kreia’s sheet heads her own section Personality - CURRENT (AI must maintain this carefully): and her husband’s plain Personality:, so she had his.
  • Keyword order over sheet order. Isabel’s sheet says [PERSONALITY] then Traits:; Carlos, under supporting characters, gets Personality:. The extractor tried personality before traits and found his. Forms are still ranked by explicitness (a tag beats a header beats a markdown heading; the first attempt at pure earliest-wins broke five cards the other way, Kreia losing her <example_dialogs> to an “Example:” line in her prose), but within a form the earliest section in the sheet wins. Tabitha and Yumi turned out to be carrying an NPC’s personality too.

Four wrong-character attributions in one batch, on top of Laura and Seraphina last night. It is the failure the sweeps cannot see: a plausible paragraph in the right field, about somebody else. The rule that finally covers most of them is that the character’s own section comes first in the sheet and the cast’s after, which is how people write sheets, not how keyword lists are ordered.

  • src/character-card/prompt-layout.ts, src/character-card/from-prompt.ts (personaTagName), src/character-card/sections.ts, scripts/lib/relayout-card.ts

Afternoon: the parser that stole a whole definition

Third round, chosen by shape again: cards whose example field had three or more “speakers”, the notation-tagged cards, and the two whose kept fields covered under 60% of the prompt. Five deep reads, four defects, and the fourth one is the largest single loss so far.

  • The Ali:Chat parser. It fired on any prompt with {{char}}: and {{user}}: anywhere in it (a relationships list saying **{{user}}:** her second chance was enough), then took every {{char}}: line and everything after it until the next turn as example dialogue, while dropping the labels and the user’s turns, which are the one part SillyTavern reads. On Ananya’s sheet that was Sub {{char}}: Rohit Sen and 8,000 characters of the ex-husband’s profile. On Lila’s it was 9,400 of a 9,600-character definition, leaving a 152-character description. It also inferred personality from emote cues (*blush means shy). Gone; an Ali:Chat card is prose plus turns and goes through the section extractors like everything else. 41 cards changed, Lila’s description back to 9,603.
  • The dialogue gate. Age: Mid-40s starts a line and ends in a colon, so two character sheets under an example heading passed as a chat. A turn now needs a macro, one of the character’s names, or spoken or emoted text after the speaker.
  • The creator’s description of the user. JanitorAI substitutes the persona name into the creator’s own tags, so <{{user}}> arrives as <Dragon>, and the chain had a step that deleted it along with <User>, <persona> and the rest. Aoko’s card lost 3,888 characters describing the husband she is leaving, which is the whole premise. Only JanitorAI’s <UserPersona> wrapper goes now.
  • The persona-less cards. 63 cards had only the blurb, and nothing can give them a definition. But a <Scenario>-only request still holds the scenario, and a bare-newline request still carries the bot’s lorebook, which for Tides of Temptation is the entire 21,112-character story. salvageWithoutDefinition hoists what is there; 19 cards salvaged, 44 remain blurb-only.

The parser had unit tests and they passed, because they tested what it did rather than what a card needs. “Collects char turns as example dialogue” was the test’s name and the bug’s description in one line. And the first pass at the dialogue gate counted an apostrophe as speech, which made I'm fine. a spoken line; a test with a plain line and a quoted one caught it before the library did.

  • src/character-card/formats.ts, src/character-card/dialogue.ts, src/transformation-chains.ts, scripts/lib/relayout-card.ts (salvageWithoutDefinition)

2026-09-04, evening: the parser, the tree and the span

Adam asked what was systematic in the day’s defects and then for the structural fixes: a parser, parse-don’t-validate, stricter types, the DRY violations, <START> on example dialogue if the spec asks for it, and a try at the tokeniser. Three commits.

The parser (4c4fba9). JanitorAI’s wrapper grammar was spelt as six separate regexes in six modules plus two scripts, and every transport-level defect of the week had been two of them disagreeing. parseJanitorPrompt reads the message once into persona names, persona body, scenario, user persona, example dialogs, the definition (transport removed, <Scenario> and <example_dialogs> left in place) and the lorebook; the gate, the chain, the lorebook, the card name, the dialogue speakers and the scripts all take the parsed prompt. Two facts from the library shaped it. JanitorAI’s spelling is fixed: 738 <Scenario>, 1,002 <UserPersona>, 226 <example_dialogs>, and every <scenario> or <SCENARIO> is a creator’s, usually inside their persona text, so the match is exact. And one creator wrote <UserPersona> twice inside their persona as a heading; matched as a block, the first ran to JanitorAI’s own closing tag and swallowed 3,800 characters of the character plus the whole scenario, so tags inside a persona block are the creator’s. Seven cards changed, all where the old “last closing tag” guess or the old step order (user persona removed before the lorebook cut, which deleted the only wrapper close) had cut creator text or kept lorebook in the description.

The span and the tree (68eecf8). A hoisted section is now a value plus the spans it occupied, and the description is the prompt minus those spans. The second grammar that found the value again to remove it is gone, and with it the alias class, the regex-too-large crash and the dedupe backstop; a duplicate check over the 1,078 rebuilt cards found three, all text the creator had pasted twice. The sheet is read once into a tree (sheet.ts): headings, Key: lines, **Bold:** labels, bulleted keys, bracketed [Personality: sections, a line that is only a section’s name, with or without a blockquote marker or a space after the colon. The rules that survived a dozen rounds against the library:

  • A block ends at the next label of its own shape. A field (Personality: warm) is closed by any label except its own bulleted sub-keys; a container (Personality: alone) adopts the sub-keys that follow it straight away unless one is a section in its own right or a person; a heading holds every non-heading label. Rules, brace lines and tag lines close everything.
  • A person is a plain key with nothing after the colon and nothing under it, two or more capitalised words or one with a parenthetical, none of them a title word: Daniel Matthews (Kreia's Husband):, Gareth (Goliath): {. Surface:, Emotionally Guarded: slow to trust and • **Fiercely Loyal:** are not.
  • Ownership applies to the personality only, never the scenario. A block is someone else’s when every label it is filed under says so: a cast tag or label (<npcs>, Extra Characters:), a name-shaped heading or tag, a person’s label above it, or the nearest Name: line in a sheet that has several. One Name: line is the character’s own sheet and can confirm but not deny. Persona names are split (Nina, Robbie, & Kimmie) and matched by first name, {{char}} counts because the chain has already substituted it, and a parenthetical is stripped only when it is a relation ('s), not an alias (goes by Jenna).
  • A block that is most of the sheet (60% and 2,000 characters), or a heading whose qualifier is the character’s own name (# PERSONALITY — Taylor Ward), is the sheet: JanitorAI calls the whole definition field “Personality” and creators title their sheet after it.
  • JanitorAI’s own <Scenario> beats a creator’s earlier <setting>; on one card two characters each had one and the first had won the field.

Four hundred cards change. About 150 gain a personality the old extractor never found (Personality: over sub-keys, #Personality: without a space, Personality Keywords:, personalityTraits:, a bare Personality Traits line, a container over Archetype: and Core Traits:). The rest lose duplicated or NPC text from the wrong field, or an old value that had run past its section. Six lose a personality, all correctly: Chloe’s card carried Marcus’s, Delphine’s carried Guillaume’s, four more the same shape, and Diana’s was 70% of her sheet.

<START> (third commit). The V2 spec says nothing about it; SillyTavern’s docs say “Before each example, you need to add the <START> tag”, and parseMesExamples prepends one when it is missing. The card now carries it: 130 library cards gain eight characters. Also escapeRegExp and the 30-character name clamp, each written twice, written once.

What was striking: the drift classifier was the review. Grouping the rebuilt cards by which fields changed and in which direction turned 400 diffs into fifteen classes, and reading three cards from each class found every regression this evening: a Name: {{char}} Park line judged as someone else’s, a persona tag saying “Narrator” making the whole sheet foreign, a lone Other: label taken for a cast container. Every one of those was a rule that was right on the card it came from and wrong on the next, which is the same lesson as the terminator, one level up: the structure of the sheet is the right axis, and the names in the sheet are the right evidence, but each has to be checked against the whole library before it is a rule.

  • src/character-card/janitor-prompt.ts, src/character-card/sheet.ts, src/character-card/sections.ts, src/character-card/dialogue.ts, src/utils/spans.ts, src/utils/regexp.ts

2026-09-04, late evening: the second round of spot checks

Fourteen random rebuilt cards read against their prompts, then five censuses over the library for each thing a read turned up. Kayla, Annalise, Mary Hang, Holly and Dani were right. Seven classes came out; five were systematic and are fixed, one was a policy call (labelled hoisting for multi-character bots), one is left alone (a creator’s bare peer headings the tokeniser does not know, so Zoie’s personality is two thirds of her sheet, all of it personality-adjacent).

The classes and their causes.

  • 98 descriptions carried JanitorAI’s <example_dialogs> tags around quotes with no speaker. The dialogue gate kept the text where it was, which is right, and nothing took the tags. A wrapped Section now carries the spans of its tags alone (wrapper), and wrapperOnly cuts those when the body stays.
  • 7 PList cards had [Setting: Tecumseh, Ohio] as the scenario while the creator’s <Scenario> block sat in the description in its tags: the format parser’s result came before the wrapper was consulted. Wrapped sections now override every notation.
  • 5 cards had the creator’s own <Scenario> nested inside JanitorAI’s, so the first close ended the block and the orphan close tag ended the description. wrapperBlocks pairs by depth now, and the section hoist uses it instead of a second regex spelling of the same grammar. One card had a whole prompt, persona tag and all, pasted inside its persona field, so the persona tag is paired by depth too, and the hoist takes the last wrapper block: JanitorAI’s follows the persona, a creator’s copy sits inside it.
  • 21 personalities carried a sibling section (Audrey’s Sexual Preferences, Yumi’s boss). Two causes. A label of the container’s own shape nested under it when the container had children but no text of its own (**Personality:** over bullets, then **Sexual Preferences:**); it closes the container now unless it is a personality sub-key. And a bold line without a colon (**Habits and Behavior**, **Ryan (The Basketball Captain)**) was not a label unless it was a known section name, so it and everything under it were the tail of the previous block; it is a label now if short and not a sentence.
  • 19 descriptions opened with []: a lone PList pair’s span stopped at the pair. The W++ parser had the same shape, [Personality("...")] leaving [].
  • 183 cards read “{{char}} Embodiment”, “{{char}} Profile”, “{{char}} 1” and “{{char}} 2”. The first cleaning chain rewrote every capitalised Character to the macro; in 318 prompts the word only ever headed a sheet or numbered a cast. The step is gone.

Multi-character bots. 188 cards name several characters in the persona tag and had one character’s personality as the card’s. The V3 spec (SPEC_V3.md) adds group_only_greetings and assets but nothing for a cast inside one card; there is no V3.1; SillyTavern’s answer to several characters is Group Chats, several cards, and its personality_format default is a bare {{personality}}. So the labelling is our convention: every non-foreign personality block, each under its owner’s name, trimmed to the name (Ashe O'Neill: not Joanna | Sex: Female | Age: 29.), when each block has an owner and they differ; a lone block on a multi-character bot is labelled too, so a side character’s section never reads as the bot’s. A cast label no longer disowns a named character, and Side Character: Erika Taniguchi files her block under her. Nested personality labels (### Personality over **Outer Personality:** and **Inner Personality:**) yield the outermost when it has two or more labelled parts; one labelled part under a chapter of other things is still the narrower reading.

The result. 426 cards changed in the rebuild, then 2 more. 298 changed only in the description and a normaliser proved every one of those was the Character word, a wrapper tag or a [] line. 75 personalities changed: 56 multi-character cards now labelled, the rest losing a sibling section or a side character’s paragraph. One personality lost, correctly: it had been three people’s paragraphs. Checks green throughout: 1,202 tests, typecheck, eslint, prettier, build.

What was striking: the same review found the same shape of error at a new level. The drift classifier grouped 426 diffs into forty classes, and every wrong turn this evening (a cast label read as a person’s name, the whole-chapter versus the labelled-part reading, the earliest wrapper versus the last) was a rule right on the card it came from and wrong on the next.

  • Commits 1cdab14 and the follow-up; backups characters-layout-backup-2026-09-04T2042 and T2046.

2026-09-04, night: the third round of spot checks

Eight cards drawn from the whole library this time, not from the round’s changes. Five were right. Three were not, and each was a dialect the tokeniser did not read rather than a rule it got wrong.

  • Name= Nancy, Full Name = Taylor Ward: 43 sheets use an equals sign, and 35 of them had a Personality= line and an empty personality field. = is a label separator now. Kelly’s card, whose whole sheet sat under a Personality: header, went from 1,090 characters of everything to her Personality= line alone.
  • `Appearance:`: six sheets put their labels in backticks. They read like bold now.
  • > personality (after incident), > Personality - Natsumi: a bare section name with a qualifier was prose, so the section was its first bullet alone. The qualifier is allowed, and shared with the hoist’s labelName through withoutQualifier, which taught me that Role-Playing must not become Role: the hyphen has to be spaced.

The multi-character labelling from the evening then met the sheets it had not seen, and four ownership rules fell out of one card each. A sub-key inherits its section’s owner, so Sadie’s Traits: under her > PERSONALITY is hers and not free for Conan to take; but the inheritance stops at a person’s or the cast’s own label, or Paul’s **Personality:** under ### Paul Hale (Husband) walked up to Mireya’s Name: line and became hers. A Name= line reaches only blocks inside the same tags, or the one at the top of an unclosed <Cecilia Blackwood> named Aryn’s block too. A cast tag inside a character’s tag is still the cast’s, and the cast pattern now knows NPC's with the apostrophe. A block with no label owner is owned by its innermost name-shaped tag, <{{char}}> included, which is what finally labelled the spell card’s three characters. And - Personality (continued): joins the block before it.

The W++ parser had its own class: six of eleven W++ cards quote inside a value (She says "hi" a lot), and the category regex stopped at the first quote, so Elena’s personality began in the middle of her Likes and one card had none. A category is read to the ") that ends its line now.

Rebuilt: 69 cards. 47 gain a personality, the rest grow or are relabelled; the description-only residual is zero. Checks green: 1,216 tests.

What was striking: the failure shape changed. The first two rounds found rules that were right on one card and wrong on the next. This round found nothing wrong with a rule; it found notations the grammar did not know, and a review tool (owners.mts, one line per changed personality with its owner labels) that made the right result obvious at a glance. The instrumented copy of sections.ts in the scratchpad was the other tool: when a card would not label and every rule read correct, printing the candidate table showed NPC's in the tags in one look.

  • Commit follows ac0932e; backup characters-layout-backup-2026-09-04T2116.

2026-09-04, late night: the fourth round of spot checks

Eight more cards from the whole library. Three were right, one had no prompt to check against, and four were wrong in four different ways. The pattern from the third round held: three of the four were notations the tokeniser did not read, and the fourth was a cleaning step older than the parser.

  • Mia Muhammed’s sheet marks every section with an emoji (🎭 Personality, 💪 Strengths, 👨‍👩‍👧 Family & Social Circle). None of those lines was a label, so her personality was her Core Traits: field running on until Mother:, heading and all. Twenty cards, 210 such lines. An emoji-fronted title reads as a bold bare line does now; the emoji comes off keys (❤️ Likes:) and bullets (💖 Cuddles) too, and a title inside a run of emoji lines is a bullet, not a heading. That last rule cost two real headings on one card and saved every emoji-bulleted list.
  • Dana Meier’s sheet marks its sections with > and no space: >Personality, >Sexuality, >Speech. Known names behind the marker were labels already; >Sexuality was prose, so Dana’s personality ran into her Intimacy field. 86 cards use > before a title, and in 75 of them nearly every > line is one; in the other ten the marker quotes prose (> He externalizes the problem, between paragraphs of a novel-length sheet). So the decision is per sheet: where most > lines are titles, > is a heading marker.
  • The <system> tags. A cleaning step from the first filter chain deleted <system>…</system> whole. JanitorAI never writes that tag; eleven creators did, around their response guidelines and hard locks, and one card’s entire definition sat inside two of them, leaving a scenario field that read ---. The step is gone.
  • Makoto, Sam and Blake: three characters, each headed by a bold name on its own line (**Makoto Ishikawa**) over bold fields. The person-label rule excluded bold lines with no stated reason, so nothing owned the three personality blocks and the card had Makoto’s alone. Bold names count now; six multi-character cards in that style are labelled.

Two things followed from making titles visible. A bold or emoji title with prose under it and then keys (💄 Personality, a paragraph, Formerly:, Now:, Temperament:) used to end at the first key, by the rule that a container with text of its own adopts nothing; Ruby’s personality was one sentence. Decorated titles keep their keys now, up to the next title. A bare Core Traits does not: Veruca’s has bullets and then Bust: 38EE, and the first attempt at this rule gave her a personality with her measurements in it. And **Mia (Your Girlfriend - MIA / RYAN)** became a label once the word limit set the qualifier aside, which meant it closed the empty **Personality** above it and two cards lost theirs; an empty title straight over a person’s title heads it now.

The tag converter had the last class. It takes the first tag longer than a hundred characters that is not on the system list, and on Eve’s card that was <npcs>: her mother’s paragraph became <{{char}}>, and Ariana’s instructions read “portray Ariana and any relevant {{char}}“. Cast tags and section tags (<setting>) are skipped now, so <Eve> and <Ariana> convert and the prose reads “portray {{char}} and any relevant NPCs”.

Rebuilt: 61 cards. Two gain a personality, fourteen multi-character cards are newly labelled or gain a person, eleven descriptions get their <system> blocks back, and a dozen change only by a tag name or a {{char}} where a name had been unconverted. One card correctly loses its personality: it had been an NPC’s.

What was striking: four attempts at the “keys after a title’s prose” rule, each right on the card in front of me. The first adopted everything and put Veruca’s bust size in her personality. The second looked for a later title to end the section and lost Ruby again, because her sections are separated by --- lines. The third made the decision on the decoration of the title, which is the thing the creator actually wrote to say “this is a heading”. The drift classifier caught each wrong turn within a minute: sixty rows with sizes, and the cards that swing by thousands of characters are the ones to open.

  • Commit 3df28e8 fix(character-card): keep system blocks and mark-only titles; backup characters-layout-backup-2026-09-04T2151.

2026-09-04, past ten: the fifth round of spot checks

Eight cards from the whole library. Four were right, one (Selena) writes her sheet as Age[28], Personality[...], a notation two cards use and I left alone, and three were wrong in ways that turned out to share a cause with a fourth found on the way.

  • Nova and James: two Name: lines, two Personality: blocks, and the card had Nova’s alone, unlabelled. Nova also has Emotional Traits:, which the personality pattern matches, so she owned two candidates, the owners were not all distinct, and the labelled path stood down. One section per person now: the first of each owner’s stands for them. Denise and Markus had the same shape (Traits: and Personality: each).
  • Estella and Silk-E: Silk-E’s block was foreign to her own card. The text side of the name match turns Silk-E into silk e; the persona-tag side did not, and never matched. Both sides are spelt the same way now.
  • Michelle, Viviana and Rafael: three bold name lines, and all three personalities filed under Michelle. Viviana Pérez was not name-shaped, because the name grammar was \w, which does not know é. Names and keys are matched with Unicode letter classes now.
  • Hillary, Rita, Paul and Payton: every person’s block sat inside <{{char}}>. The tag converter takes the first long tag that is not on the system list, and here that was <narrative rules>. Across the library that heuristic had converted <world_context>, <System_Instructions>, <configs>, <Purpose>, <think>, <secret>, <character> (and then the word “character” throughout the prose) and, for three-person bots narrated by a “Narrator”, whichever person came first. Two rules replaced it: a tag made of section words is never the character’s, and when the persona tag’s names are known, only a tag carrying one of them converts, compared without spaces or separators so <BlueMercy> is Blue Mercy and <Lena_Carter> is Lena; a sheet whose tags are <think> and <rules> converts nothing at all.

Rebuilt: 58 cards. Twenty-odd multi-character cards gain their missing people (Nova and James, Fame & Lies with four, the Darkroom card with five), and the rest change by a tag name: the description reads <configs> and <Alina Campbell> becomes <{{char}}>, where before it was the other way round.

What was striking: the tag converter’s mistake was invisible on every card I had read until now because a wrong <{{char}}> looks exactly like a right one. It surfaced only as a side effect, when the ownership rules started trusting the tag. The rule that fixed it was already in hand: the persona tag says who the characters are, and the converter had simply never been told.

  • Commit ad872ec fix(character-card): fix multi-owner and non-Latin name gaps; backup characters-layout-backup-2026-09-04T2210.

2026-09-05, after midnight: the seams, then the census

Adam asked which of the week’s defects were systematic, and why five rounds of eight-card samples kept paying out. The answer was in the journal already: rounds one and two found rules that were right on one card and wrong on the next; rounds three to five found almost no wrong rules, only notations the tokeniser did not read, plus one heuristic older than the parser. Creator formatting is a long tail and a random draw has no stop criterion. A census does. Over the 1,078 rebuilt cards I counted title-shaped lines in heading position that the tokeniser left as prose, grouped by their decoration, and the top rows were the fix list: [Personality] in brackets with no colon (45 cards, 15 heading the personality, 5 of those empty), the [[ Name double-bracket notation (51), === Title === (110, mostly instruction blocks), shouted titles (OUTFIT, HARD RULES, 23), a capitalised word and a run of lowercase ones before a lone colon (Main personality traits:, 89), and a label carrying a macro (Relationship with {{user}}:, 18). Below them, undecorated title-case lines (Behavior, Melody, 127 cards) that nothing marks.

The seams first (837e2b9). Round five’s Unicode fix had covered one of five spellings of “a letter”: \p{L} in the sheet’s keys, [A-Za-z] in its bare labels, titles and the dialogue speaker, a Latin-1 range in the tag converter, [A-Z][a-z]+ in the hoist’s name tags, \w in the section forms. So <Pérez> as a tag, Pérez as a bare title and Pérez as a speaker all still failed the way Viviana had. Likewise “do these two names match” was spelt six ways, and Silk-E had been two of them disagreeing, with the dialogue reader the next pair to disagree. names.ts owns both now: one letter class, one normaliseName, one nameMatches (either contains the other with the separators out, so <BlueMercy> is Blue Mercy). Every sheet label carries a role (cast, name, person, key) read once off its text, and closes() and the ownership rules read it instead of re-testing the text at what had been twenty-two sites. And the tag converter’s fallback is gone: with no persona names it took the first long tag, which on the eight persona-less library cards was <ooc>, <secret> and <Character1> as often as the name, and Mira’s card was called Character1 because of it. The drift from all of that was exactly those eight cards, which is what a behaviour-preserving refactor should show.

Then the notations (second commit). Each marked title now carries its marker (bold, emoji, quote, bracket, ruled, caps, doubled, bare) in place of a boolean, because Poppy’s sheet needed the difference: PERSONALITY over TRAITS over [Good] and [Bad], and [Bad] had closed everything up to PERSONALITY, since the rule knew “title after title with content” but not that [Bad] is [Good]’s sibling and TRAITS’s child. Titles marked alike are siblings now. Ana’s [Behavior Guidelines] had nested three levels down under a bulleted container, so a title closes a plain container that already holds keys. And the lowercase-run container is capped at three words: the first version made What the world sees: a section and Claire’s personality was one sentence. [[ Joanna: makes a lone capitalised word a person, because the brackets say it heads a block.

Rebuilt: 38 cards, backup characters-layout-backup-2026-09-05T0132. Seven gain a personality that was empty (Ashley’s and Helen’s Main personality traits:, Alice’s and Chelsey’s [Personality], Velvet Nights’ three people), nine multi-character cards gain their people (Acacia Wells’ three, Velvet Nights, Rachelle, Wanda and Nathaniel, Luna and Remy, Mara and Dr. Lana), and the rest end where they should: at Relationship with {{user}}:, === General Rules ===, [BACKSTORY], TYPICAL BEHAVIOUR. The Girl at the Crossroads lost two thousand characters, correctly: Angelica’s personality had run through Backstory with {{user}}: and Intimacy with {{user}}:, which are labels now.

The census after: the decorated rows are gone. What is left in heading position and unread is undecorated: bare title-case lines (124 cards), single capitalised words (67), lone-colon phrases of four words or more (63), and unclosed [Macy PList-style blocks (32). Those are the ambiguity classes a sample will keep finding, and the only principled reading of the first is a per-sheet vote like the > rule. What was striking: the consolidation commit changed eight cards and the notation commit thirty-eight, and I could tell which was which before opening a single one, because a refactor that drifts is a refactor with a bug in it.

  • Commits 837e2b9 refactor(character-card): one grammar for names, a role on every label; f6b4f3b fix(character-card): read bracketed, ruled, shouted and double-bracket titles; backup characters-layout-backup-2026-09-05T0132.

2026-09-05, small hours: the two left open

Adam asked for the two items the recap had left open: Tides of Temptation with no personality, and the paragraph whose lead phrase parses as a key and cuts the section it belongs to.

Tides was not a parser gap. The 63 cards the rebuild skips for want of a definition split cleanly once counted: 44 are JanitorAI’s bare-newline request, 14 are <Scenario> alone (the shape nothing can tell from a bot with an empty personality), and 6 are the newline followed by the bot’s lorebook. Tides is one of the six. Its only capture, from February, is that request: a newline and 21,112 characters of story bible with three Name: lines and three Personality sections in it, which is the bot’s lorebook, not a persona that went unread. The card already carries it as one constant, enabled book entry, so the model receives the whole of it; the extension’s own store holds no fuller request; and a personality can only come from a fresh capture of a real turn. Roland Blackwood is the same family one step older: a build from January wrote the lorebook text into the description, with no blurb to fall back to, so the text is in the card once and its layout is merely old. Both left as they are.

The paragraph rule (b494eb9). Macy’s personality is five paragraphs, each Lead phrase: a sentence or two. The leads with a comma in them were prose to the grammar; Devotedly {{user}}-brained: became a key when the macro did, and by the round-four rule that keys after a plain container’s prose close it, her personality was half of itself. The signal that separates her paragraphs from Veruca’s Bust: 38EE is the value: a key whose value is a sentence of eight words or more is a paragraph and continues the prose it follows. Twenty-three personalities grew and every one was read against its source; each now runs to the next real section (Intimacy Notes, Relationships, connections:, [Appearance:). Ava’s cut from the earlier round stays: her > With {{user}} block is a sibling section with its own Likes and Dislikes, not a paragraph.

Rebuilt: 29 cards, backup characters-layout-backup-2026-09-05T0142.

  • Commit b494eb9 fix(character-card): keep a lead-phrase paragraph under the section it continues.

2026-09-05, later: what the backups say the migration cost

Adam asked how many backups there were (21, 9.7 GB, the first of them the 1,087-card library as it stood before the layout migration) and then for a before-and-after comparison, expecting little change and reading any large change as mangling. Paired by filename, with the stored prompt’s hash as the fallback for renamed cards (a first pass pairing by prompt hash alone was wrong twice over: the 44 bare-newline cards share one prompt, and two cards can carry the same prompt): PNG bytes grew 2.4%; the first message and every alternate greeting are byte-identical on all 1,087 cards; the text fields lost 14.6%, which is the system_prompt field going from 10.8 million characters to zero, the duplicate the whole migration was about; description, scenario, examples, creator notes and lorebooks all grew. Then a line-level check: every line of the old card’s text that occurs nowhere in the new card, with the {{char}} and {{user}} rewrites and the character’s names neutralised.

That check found the one real loss, and it is not the parser. The old cards’ system_prompt field was the cleaned prompt the card was built from; the extension’s original_system_prompt is supposed to be the same prompt raw. On 37 cards it is not. Nineteen have an empty stored original, and thirty-seven in all were revised in place by the old merge-revisions script, which copies the changed content fields into the card and leaves the stored original where it was (TRACKED_FIELDS in scripts/lib/revision-merge.ts does not include it). So Elena’s card was first built from a 9.6k capture, then revised to a 21.8k one, and its stored original stayed at 9.6k; Serena’s file was taken over by a different bot of the same name, first message and all, while the stored original stayed the first Serena’s. The rebuild trusted the stored original, so eighteen cards regressed to their first capture, and Serena’s card is a chimera: the first Serena’s definition under the second Serena’s greeting. The nineteen with an empty original kept their old description, since nothing could be rebuilt for them, and lost only the duplicate.

The fuller text survives, cleaned, in the first backup’s system_prompt field. The recovery is to write it back as the stored original for those 37 cards and relay them, on the rule that a card follows the system prompt its greeting was captured with; and the fix is for the merge to carry the stored original along with the content it changes. Neither done yet: which Serena the file should be is Adam’s call.

2026-09-05, past two: the recovery

Adam said go. Two steps. The merge now carries the incoming card’s stored original and capture metadata whenever the incoming card was built from a different prompt (buildMergedCard, test first; commit 1caba8f). And the 37 cards were recovered from the first backup: for each, the pre-migration system_prompt field, which is the cleaned prompt the card’s fields came from, was written back as the stored original, and the card relaid from it. Three adjustments on the way. The old chain had rewritten the name inside the persona tag to <{{char}}'s Persona> on seven of them, which would have named the card after the macro, so the tag gets its name back from the raw original where there is one and from the card where there is not. Five had a definition with no persona wrapper at all (### **[Persona Definition: Sarah]**), which the definition gate refuses as free text, so those are wrapped in the card’s own persona tag; the stored original on those five is therefore a reconstruction, and this entry is the record of it. And one card, Mom’s Dinner, was skipped on the first pass because its stored original was a lone newline and my overlap test read an empty text as fully contained; fixed, recovered on the second.

The check afterwards: old text absent from the new cards fell from 973k to 804k characters, and every recovered card’s residue is now label prefixes (Traits: (...) whose value sits in the personality field without the label), wrapper tags, or fields that had come from an even earlier capture than the one the card now follows. Serena’s residue is the first Serena, whose card this no longer is. Kim’s card gained a 26-character lorebook entry that is an empty <{{char}}> block the old chain had converted from <Kim>, left alone.

What was striking: the parser had been read against 1,300 cards for two days without this showing, because a card rebuilt from the wrong prompt is a perfectly well-formed card. It took the field totals to point at it (a personality field that shrank on a card whose prompt had not changed) and a line-level diff against the oldest backup to see what had gone. The oldest backup is now the only copy of those 37 cards’ fuller prompts in any form, so it stays.

  • Recovery backups: characters-osp-backup-2026-09-05T0214 and T0215 (the cards before their stored original was replaced), characters-layout-backup-2026-09-05T0214 and T0216 (before the rebuilds).

Then the 30 per-step backups went (8.5 GB); characters-layout-backup-2026-09-04T0316, the pre-migration snapshot, stays as the one ground truth outside this code.

Then 0316 was audited for deletion: over its 1,087 cards the avatar bytes, greetings, tags, remaining fields and extension metadata are identical to the live library, the old revision history is a prefix of the new, every old text field is at least 98% covered by the live card’s fields, stored original and previous_values once the old chain’s rewrites are neutralised, and the 38 old names survive as filenames. Nothing in it was not in the library, so it went too. The library now carries its own history and has no backup directory at all.